Compare commits

..

3 Commits

Author SHA1 Message Date
Jarvis
060112c869 feat(macp): add registerMacpCommand for mosaic macp CLI surface
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
Adds mosaic macp tasks list|submit|gate|events tail subcommands to
@mosaicstack/macp, wires registerMacpCommand into the root mosaic CLI,
and ships a smoke test asserting command structure without touching disk
or starting an event emitter. Ref CU-05-08.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 01:14:51 -05:00
3abd63ea5c Merge pull request 'feat(mosaic): mosaic auth CLI surface' (#413) from feat/mosaic-auth-cli into main
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 06:11:33 +00:00
641e4604d5 feat(forge): mosaic forge CLI surface (#412)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
2026-04-05 06:08:50 +00:00
7 changed files with 259 additions and 30 deletions

View File

@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { Command } from 'commander';
import { registerMacpCommand } from './cli.js';
describe('registerMacpCommand', () => {
function buildProgram(): Command {
const program = new Command();
program.exitOverride(); // prevent process.exit in tests
registerMacpCommand(program);
return program;
}
it('registers a "macp" command on the parent', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp');
expect(macpCmd).toBeDefined();
});
it('registers "macp tasks" subcommand group', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks');
expect(tasksCmd).toBeDefined();
});
it('registers "macp tasks list" subcommand with --status and --type flags', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const tasksCmd = macpCmd.commands.find((c) => c.name() === 'tasks')!;
const listCmd = tasksCmd.commands.find((c) => c.name() === 'list');
expect(listCmd).toBeDefined();
const optionNames = listCmd!.options.map((o) => o.long);
expect(optionNames).toContain('--status');
expect(optionNames).toContain('--type');
});
it('registers "macp submit" subcommand', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const submitCmd = macpCmd.commands.find((c) => c.name() === 'submit');
expect(submitCmd).toBeDefined();
});
it('registers "macp gate" subcommand with --fail-on flag', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const gateCmd = macpCmd.commands.find((c) => c.name() === 'gate');
expect(gateCmd).toBeDefined();
const optionNames = gateCmd!.options.map((o) => o.long);
expect(optionNames).toContain('--fail-on');
});
it('registers "macp events" subcommand group', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events');
expect(eventsCmd).toBeDefined();
});
it('registers "macp events tail" subcommand', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const eventsCmd = macpCmd.commands.find((c) => c.name() === 'events')!;
const tailCmd = eventsCmd.commands.find((c) => c.name() === 'tail');
expect(tailCmd).toBeDefined();
});
it('has all required top-level subcommands', () => {
const program = buildProgram();
const macpCmd = program.commands.find((c) => c.name() === 'macp')!;
const topLevel = macpCmd.commands.map((c) => c.name());
expect(topLevel).toContain('tasks');
expect(topLevel).toContain('submit');
expect(topLevel).toContain('gate');
expect(topLevel).toContain('events');
});
});

92
packages/macp/src/cli.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { Command } from 'commander';
/**
* Register macp subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
* caller's Command instance directly.
*/
export function registerMacpCommand(parent: Command): void {
const macp = parent.command('macp').description('MACP task and gate management');
// ─── tasks ───────────────────────────────────────────────────────────────
const tasks = macp.command('tasks').description('Manage MACP tasks');
tasks
.command('list')
.description('List MACP tasks')
.option(
'--status <status>',
'Filter by task status (pending|running|gated|completed|failed|escalated)',
)
.option(
'--type <type>',
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
)
.action((opts: { status?: string; type?: string }) => {
// not yet wired — task persistence layer is not present in @mosaicstack/macp
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
if (opts.status) {
console.log(` status filter: ${opts.status}`);
}
if (opts.type) {
console.log(` type filter: ${opts.type}`);
}
process.exitCode = 0;
});
// ─── submit ──────────────────────────────────────────────────────────────
macp
.command('submit <path>')
.description('Submit a task from a JSON/YAML spec file')
.action((specPath: string) => {
// not yet wired — task submission requires a running MACP server
console.log('[macp] submit: not yet wired — use macp package programmatically');
console.log(` spec path: ${specPath}`);
console.log(' task id: (unavailable — no MACP server connected)');
console.log(' status: (unavailable — no MACP server connected)');
process.exitCode = 0;
});
// ─── gate ────────────────────────────────────────────────────────────────
macp
.command('gate <spec>')
.description('Run a gate from a spec string or file path (wraps runGate/runGates)')
.option('--fail-on <mode>', 'Gate fail-on mode: ai|fail|both|none', 'fail')
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
// not yet wired — gate execution requires a task context and event sink
console.log('[macp] gate: not yet wired — use macp package programmatically');
console.log(` spec: ${spec}`);
console.log(` fail-on: ${opts.failOn}`);
console.log(` cwd: ${opts.cwd}`);
console.log(` log: ${opts.log}`);
console.log(` timeout: ${opts.timeout}s`);
process.exitCode = 0;
});
// ─── events ──────────────────────────────────────────────────────────────
const events = macp.command('events').description('Stream MACP events');
events
.command('tail')
.description('Tail MACP events from the event log (wraps event emitter)')
.option('--file <path>', 'Path to the MACP events NDJSON file')
.option('--follow', 'Follow the file for new events (like tail -f)')
.action((opts: { file?: string; follow?: boolean }) => {
// not yet wired — event streaming requires a live event source
console.log('[macp] events tail: not yet wired — use macp package programmatically');
if (opts.file) {
console.log(` file: ${opts.file}`);
}
if (opts.follow) {
console.log(' mode: follow');
}
process.exitCode = 0;
});
}

View File

@@ -74,7 +74,8 @@ export function saveSession(gatewayUrl: string, auth: AuthResult): void {
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
};
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
// 0o600: owner read/write only — the session cookie is a credential
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { encoding: 'utf-8', mode: 0o600 });
}
/**

View File

@@ -126,10 +126,18 @@ export function registerGatewayCommand(program: Command): void {
.description('Sign in to the gateway (defaults to URL from meta.json)')
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
.option('-e, --email <email>', 'Email address')
.option('-p, --password <password>', 'Password')
.option(
'-p, --password <password>',
'[UNSAFE] Avoid — exposes credentials in shell history and process listings',
)
.action(async (cmdOpts: { gateway?: string; email?: string; password?: string }) => {
const { runLogin } = await import('./gateway/login.js');
const url = getGatewayUrl(cmdOpts.gateway);
if (cmdOpts.password) {
console.warn(
'Warning: --password flag exposes credentials in shell history and process listings.',
);
}
try {
await runLogin({ gatewayUrl: url, email: cmdOpts.email, password: cmdOpts.password });
} catch (err) {

View File

@@ -2,6 +2,62 @@ import { createInterface } from 'node:readline';
import { signIn, saveSession } from '../../auth.js';
import { readMeta } from './daemon.js';
/**
* Prompt for a single line of input (with echo).
*/
export function promptLine(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
/**
* Prompt for a secret value without echoing the typed characters to the terminal.
* Uses TTY raw mode when available so that passwords do not appear in terminal
* recordings, scrollback, or shared screen sessions.
*/
export function promptSecret(question: string): Promise<string> {
return new Promise((resolve) => {
process.stdout.write(question);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding('utf-8');
let secret = '';
const onData = (char: string): void => {
if (char === '\n' || char === '\r' || char === '\u0004') {
process.stdout.write('\n');
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
process.stdin.removeListener('data', onData);
resolve(secret);
} else if (char === '\u0003') {
// ^C
process.stdout.write('\n');
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.pause();
process.stdin.removeListener('data', onData);
process.exit(130);
} else if (char === '\u007f' || char === '\b') {
secret = secret.slice(0, -1);
} else {
secret += char;
}
};
process.stdin.on('data', onData);
});
}
/**
* Shared login helper used by both `mosaic login` and `mosaic gateway login`.
* Prompts for email/password if not supplied, signs in, and persists the session.
@@ -11,17 +67,9 @@ export async function runLogin(opts: {
email?: string;
password?: string;
}): Promise<void> {
let email = opts.email;
let password = opts.password;
if (!email || !password) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
if (!email) email = await ask('Email: ');
if (!password) password = await ask('Password: ');
rl.close();
}
const email = opts.email ?? (await promptLine('Email: '));
// Do not trim password — it may intentionally contain leading/trailing whitespace
const password = opts.password ?? (await promptSecret('Password: '));
const auth = await signIn(opts.gatewayUrl, email, password);
saveSession(opts.gatewayUrl, auth);

View File

@@ -16,14 +16,9 @@ vi.mock('./daemon.js', () => ({
vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
}));
// Mock readline so tests don't block on stdin
vi.mock('node:readline', () => ({
createInterface: vi.fn().mockReturnValue({
question: vi.fn((_q: string, cb: (a: string) => void) => cb('test-input')),
close: vi.fn(),
}),
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
promptLine: vi.fn().mockResolvedValue('test@example.com'),
promptSecret: vi.fn().mockResolvedValue('test-password'),
}));
const mockFetch = vi.fn();

View File

@@ -1,7 +1,6 @@
import { createInterface } from 'node:readline';
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { readMeta, writeMeta } from './daemon.js';
import { getGatewayUrl } from './login.js';
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
interface MintedToken {
id: string;
@@ -58,6 +57,9 @@ export async function mintAdminToken(
/**
* Persist the new token into meta.json and print the confirmation banner.
*
* Emits a warning when the target gateway differs from the locally installed one,
* so operators are aware that meta.json may not reflect the intended gateway.
*/
export function persistToken(gatewayUrl: string, minted: MintedToken): void {
const meta = readMeta() ?? {
@@ -68,6 +70,15 @@ export function persistToken(gatewayUrl: string, minted: MintedToken): void {
port: parseInt(new URL(gatewayUrl).port || '14242', 10),
};
// Warn when the target gateway does not match the locally installed one
const targetHost = new URL(gatewayUrl).hostname;
if (targetHost !== meta.host) {
console.warn(
`Warning: token was minted against ${gatewayUrl} but is being saved to the local` +
` meta.json (host: ${meta.host}). Copy the token manually if targeting a remote gateway.`,
);
}
writeMeta({ ...meta, adminToken: minted.plaintext });
const preview = `${minted.plaintext.slice(0, 8)}...`;
@@ -108,13 +119,10 @@ export async function ensureSession(gatewayUrl: string): Promise<string> {
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
}
// Prompt for credentials
const rl = createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
const email = (await ask('Email: ')).trim();
const password = (await ask('Password: ')).trim();
rl.close();
// Prompt for credentials — password must not be echoed to the terminal
const email = await promptLine('Email: ');
// Do not trim password — it may contain intentional leading/trailing whitespace
const password = await promptSecret('Password: ');
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));