blob: a6572f2934f59a80f8f9d2d9468598d2bfbbc8a9 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import { parseMakaCliArgs, runMakaCli } from '../cli-core.js';
describe('Maka CLI args', () => {
test('declares a bin-only package surface', async () => {
const manifest = JSON.parse(
await readFile(new URL('../../package.json', import.meta.url), 'utf8'),
) as Record<string, unknown>;
assert.deepEqual(manifest.bin, {
maka: './dist/cli.js',
});
assert.deepEqual(manifest.exports, {});
assert.equal(Object.hasOwn(manifest, 'main'), false);
assert.equal(Object.hasOwn(manifest, 'types'), false);
await assert.rejects(access(new URL('../index.js', import.meta.url)), { code: 'ENOENT' });
});
test('publishes the supported release command surface', () => {
const help = parseMakaCliArgs(['--help'], '0.1.0');
assert.equal(help.kind, 'help');
if (help.kind !== 'help') return;
assert.match(help.text, /^ {2}maka {2,}Start the TUI$/m);
assert.doesNotMatch(help.text, /maka-agent/);
assert.match(help.text, /^ {2}maka run /m);
assert.match(help.text, /^ {2}maka activate /m);
assert.match(help.text, /^ {2}maka eval /m);
assert.match(help.text, /^ {2}maka update /m);
assert.match(
help.text,
/^ {2}maka --acp {2,}Serve ACP v1 over stdio \(sessions, prompts, streaming, cancellation\)$/m,
);
// Runtime Host owns its own help; the root lists it once and points there.
assert.match(help.text, /^ {2}maka runtime-host \.\.\. {2,}Serve and manage a Runtime Host$/m);
assert.doesNotMatch(help.text, /^ {2}maka runtime-host (?:serve|service|access) /m);
assert.ok(help.text.split('\n').length < 30, 'the root help stays a single screen');
assert.doesNotMatch(help.text, /cli:dev/);
});
test('requires an explicit installed update target and interruption choice', () => {
assert.deepEqual(parseMakaCliArgs(['update', '--target', 'next'], '0.1.0'), {
kind: 'runtime-host-installed-update',
selector: { kind: 'channel', channel: 'next' },
allowInterruptActiveTasks: false,
});
assert.deepEqual(
parseMakaCliArgs(['update', '--target', '1.2.3', '--allow-interrupt-active-tasks'], '0.1.0'),
{
kind: 'runtime-host-installed-update',
selector: { kind: 'exact', version: '1.2.3' },
allowInterruptActiveTasks: true,
},
);
assert.deepEqual(parseMakaCliArgs(['update'], '0.1.0'), {
kind: 'error',
message: 'update requires --target <latest|next|version>',
exitCode: 2,
});
});
test('selects a Runtime Host and Project for TUI startup', () => {
assert.deepEqual(parseMakaCliArgs(['--host', 'office', '--project', 'project-1'], '0.1.0'), {
kind: 'tui',
hostProfileId: 'office',
projectId: 'project-1',
});
});
test('parses the ACP stdio command before TUI flags', () => {
assert.deepEqual(parseMakaCliArgs(['--acp'], '0.1.0'), { kind: 'acp' });
assert.deepEqual(parseMakaCliArgs(['--acp', 'extra'], '0.1.0'), {
kind: 'error',
message: 'maka --acp does not accept arguments',
exitCode: 2,
showHelp: false,
});
assert.deepEqual(parseMakaCliArgs(['--host', 'office', '--project', 'project-1'], '0.1.0'), {
kind: 'tui',
hostProfileId: 'office',
projectId: 'project-1',
});
});
test('compiled ACP launcher rejects trailing arguments without help output', async () => {
const result = await runCompiledCli('dev-cli.js', ['--acp', 'extra'], process.env);
assert.equal(result.signal, null);
assert.equal(result.code, 2);
assert.equal(result.stdout, '');
assert.equal(result.stderr, 'maka --acp does not accept arguments\n');
});
test('uses the active launcher in development help and rejects an empty profile name', async () => {
const help = parseMakaCliArgs(['--help'], '0.1.0', 'npm run cli:dev --');
assert.equal(help.kind, 'help');
if (help.kind === 'help') {
assert.match(help.text, /^Usage: npm run cli:dev --$/m);
assert.match(help.text, /^ {2}npm run cli:dev -- runtime-host \.\.\. /m);
assert.doesNotMatch(help.text, /^ {2}maka runtime-host /m);
assert.doesNotMatch(help.text, /maka-agent/);
}
// The launcher name has to survive every layer, not just the root.
const hostHelp = parseMakaCliArgs(['runtime-host', '--help'], '0.1.0', 'npm run cli:dev --');
assert.equal(hostHelp.kind, 'help');
if (hostHelp.kind === 'help') {
assert.match(hostHelp.text, /^Usage: npm run cli:dev -- runtime-host <command>/m);
}
const accessHelp = parseMakaCliArgs(
['runtime-host', 'access', '--help'],
'0.1.0',
'npm run cli:dev --',
);
assert.equal(accessHelp.kind, 'help');
if (accessHelp.kind === 'help') {
assert.match(
accessHelp.text,
/^ {2}npm run cli:dev -- runtime-host access issue --principal <id> --preset /m,
);
}
await assert.rejects(
runMakaCli(['--version'], {
dataProfileName: '',
cliCommand: 'invalid',
capabilityProviderIdentityScope: 'client-data-root',
}),
/profile name must be a non-empty path segment/,
);
});
test('does not add a discoverable global locale flag', () => {
assert.deepEqual(parseMakaCliArgs(['--locale', 'zh-CN'], '0.1.0'), {
kind: 'error',
message: 'Unexpected argument: --locale',
exitCode: 2,
});
});
test('establishes the fatal exit before reporting can throw', async () => {
const cliUrl = new URL('../cli-core.js', import.meta.url).href;
const childSource = `
import { handleMakaCliProcessExit } from ${JSON.stringify(cliUrl)};
try {
handleMakaCliProcessExit(1, new Error('fatal'), () => { throw new Error('writer failed'); });
} catch {}
`;
const child = spawn(process.execPath, ['--input-type=module', '-e', childSource], {
stdio: 'ignore',
});
const [code, signal] = (await once(child, 'exit')) as [number | null, NodeJS.Signals | null];
assert.equal(signal, null);
assert.equal(code, 1);
});
test('compiled release and repository entries isolate profile writes', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-cli-launch-profile-'));
t.after(() => rm(root, { recursive: true, force: true }));
const home = join(root, 'home');
const applicationData = join(root, 'application-data');
const releaseRoot = platformProfileRoot(home, applicationData, 'Maka');
const developmentRoot = platformProfileRoot(home, applicationData, 'Maka Dev');
const env = {
...process.env,
HOME: home,
USERPROFILE: home,
APPDATA: applicationData,
XDG_CONFIG_HOME: applicationData,
MAKA_TEST_RUNTIME_HOST_CREDENTIAL: 'opaque-token',
};
const profileArgs = [
'runtime-host',
'profile',
'set',
'--id',
'office',
'--name',
'Office',
'--tls-url',
'wss://runtime.example.com/runtime-host',
'--expected-root',
'a'.repeat(64),
'--credential-env',
'MAKA_TEST_RUNTIME_HOST_CREDENTIAL',
];
await mkdir(releaseRoot, { recursive: true });
await writeFile(join(releaseRoot, 'sentinel.txt'), 'release-data\n', 'utf8');
const development = await runCompiledCli('dev-cli.js', profileArgs, env);
assert.equal(development.signal, null);
assert.equal(development.code, 0, development.stderr);
await assertProfileFiles(developmentRoot);
assert.deepEqual(await readdir(releaseRoot), ['sentinel.txt']);
assert.equal(await readFile(join(releaseRoot, 'sentinel.txt'), 'utf8'), 'release-data\n');
await assert.rejects(access(join(developmentRoot, 'sentinel.txt')), { code: 'ENOENT' });
const developmentProfile = await readFile(
join(developmentRoot, 'runtime-host-profiles.json'),
'utf8',
);
const release = await runCompiledCli('cli.js', profileArgs, env);
assert.equal(release.signal, null);
assert.equal(release.code, 0, release.stderr);
await assertProfileFiles(releaseRoot);
assert.equal(
await readFile(join(developmentRoot, 'runtime-host-profiles.json'), 'utf8'),
developmentProfile,
);
assert.equal(await readFile(join(releaseRoot, 'sentinel.txt'), 'utf8'), 'release-data\n');
});
test('compiled launchers isolate capability-provider identities', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-cli-provider-identity-'));
t.after(() => rm(root, { recursive: true, force: true }));
const home = join(root, 'home');
const applicationData = join(root, 'application-data');
const releaseRoot = platformProfileRoot(home, applicationData, 'Maka');
const developmentRoot = platformProfileRoot(home, applicationData, 'Maka Dev');
const configPath = join(root, 'mcp.json');
const url = 'https://invalid.example/runtime-host';
const identityFile = providerIdentityFileName(url, configPath);
const developmentIdentityPath = join(
developmentRoot,
'runtime-host-capability-providers',
identityFile,
);
const releaseIdentityPath = join(
home,
'.maka',
'runtime-host-capability-providers',
identityFile,
);
const releaseProfileIdentityPath = join(
releaseRoot,
'runtime-host-capability-providers',
identityFile,
);
const explicitUrl = 'https://explicit.invalid/runtime-host';
const explicitDefaultIdentityPath = join(
developmentRoot,
'runtime-host-capability-providers',
providerIdentityFileName(explicitUrl, configPath),
);
const explicitIdentityPath = join(root, 'explicit-provider-identity.json');
const env = {
...process.env,
HOME: home,
USERPROFILE: home,
APPDATA: applicationData,
XDG_CONFIG_HOME: applicationData,
MAKA_TEST_RUNTIME_HOST_CREDENTIAL: 'opaque-token',
};
await writeFile(configPath, '{"mcpServers": {}}\n', 'utf8');
const providerArgs = [
'runtime-host',
'capability-provider',
'serve',
'--url',
url,
'--mcp-config',
configPath,
'--expected-root',
'b'.repeat(64),
'--credential-env',
'MAKA_TEST_RUNTIME_HOST_CREDENTIAL',
];
const development = await runCompiledCli('dev-cli.js', providerArgs, env);
assert.equal(development.signal, null);
assert.equal(development.code, 1);
const developmentIdentity = await readClientInstanceId(developmentIdentityPath);
await assert.rejects(access(releaseIdentityPath), { code: 'ENOENT' });
const release = await runCompiledCli('cli.js', providerArgs, env);
assert.equal(release.signal, null);
assert.equal(release.code, 1);
const releaseIdentity = await readClientInstanceId(releaseIdentityPath);
assert.notEqual(releaseIdentity, developmentIdentity);
await assert.rejects(access(releaseProfileIdentityPath), { code: 'ENOENT' });
const developmentIdentityBeforeOverride = await readFile(developmentIdentityPath, 'utf8');
const explicitProviderArgs = providerArgs.map((arg) => (arg === url ? explicitUrl : arg));
const explicit = await runCompiledCli(
'dev-cli.js',
[...explicitProviderArgs, '--client-identity', explicitIdentityPath],
env,
);
assert.equal(explicit.signal, null);
assert.equal(explicit.code, 1);
const explicitIdentity = await readClientInstanceId(explicitIdentityPath);
assert.notEqual(explicitIdentity, developmentIdentity);
assert.notEqual(explicitIdentity, releaseIdentity);
await assert.rejects(access(explicitDefaultIdentityPath), { code: 'ENOENT' });
assert.equal(
await readFile(developmentIdentityPath, 'utf8'),
developmentIdentityBeforeOverride,
);
});
});
async function runCompiledCli(
entrypoint: string,
args: readonly string[],
env: NodeJS.ProcessEnv,
): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
const child = spawn(
process.execPath,
[fileURLToPath(new URL(`../${entrypoint}`, import.meta.url)), ...args],
{ env, stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000, killSignal: 'SIGKILL' },
);
let stdout = '';
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk: string) => {
stdout += chunk;
});
let stderr = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});
const [code, signal] = (await once(child, 'close')) as [number | null, NodeJS.Signals | null];
return { code, signal, stdout, stderr };
}
function platformProfileRoot(home: string, applicationData: string, profileName: string): string {
if (process.platform === 'darwin') {
return join(home, 'Library', 'Application Support', profileName);
}
return join(applicationData, profileName);
}
async function assertProfileFiles(root: string): Promise<void> {
await access(join(root, 'runtime-host-profiles.json'));
await access(join(root, 'runtime-host-client', 'credentials.json'));
}
function providerIdentityFileName(url: string, configPath: string): string {
const identity = createHash('sha256')
.update(`runtime-host-capability-provider\0${url}\0${configPath}`)
.digest('hex')
.slice(0, 24);
return `${identity}.json`;
}
async function readClientInstanceId(path: string): Promise<string> {
const document = JSON.parse(await readFile(path, 'utf8')) as {
schemaVersion?: unknown;
clientInstanceId?: unknown;
};
assert.equal(document.schemaVersion, 1);
if (typeof document.clientInstanceId !== 'string') {
assert.fail('Expected a persisted Client instance id');
}
return document.clientInstanceId;
}
describe('layered help coverage', () => {
const screens = (launcher = 'maka') => {
const texts: string[] = [];
const grab = (argv: string[]) => {
const command = parseMakaCliArgs(argv, '0.1.0', launcher);
if (command.kind === 'help') texts.push(command.text);
};
grab(['--help']);
grab(['update', '--help']);
grab(['session-export', '--help']);
grab(['session-import', '--help']);
grab(['runtime-host', '--help']);
for (const topic of [
'activate',
'serve',
'setup',
'service',
'access',
'project',
'plugin',
'profile',
'capability-provider',
]) {
grab(['runtime-host', topic, '--help']);
}
return texts.join('\n');
};
test('every command the parser accepts has a help screen', () => {
const all = screens();
for (const command of [
'runtime-host activate',
'runtime-host serve',
'runtime-host setup',
'runtime-host service',
'runtime-host access',
'runtime-host project',
'runtime-host plugin',
'runtime-host profile',
'runtime-host capability-provider',
'session-export',
'session-import',
'update',
]) {
assert.ok(all.includes(`maka ${command}`), `${command} is unreachable from any help screen`);
}
});
test('the credential environment variable stays documented', () => {
// Its only other home was the root screen, which the layering shrank.
assert.match(screens(), /MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL/);
});
test('every summary in the root screen shares one column', () => {
const help = parseMakaCliArgs(['--help'], '0.1.0');
assert.equal(help.kind, 'help');
if (help.kind !== 'help') return;
const summaryColumns = help.text
.split('\n')
.filter((line) => /^ {2}maka\b/.test(line))
.map((line) => line.search(/(?<= {2})\S(?!.*\s{2}\S)/u))
.filter((column) => column > 0);
assert.ok(summaryColumns.length > 5, 'the root screen should list several commands');
assert.equal(
new Set(summaryColumns).size,
1,
`summaries start at ${[...new Set(summaryColumns)].join(', ')}`,
);
});
});