From 90922b4f1ded1abf818ac712d7ae3978979a8acd Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 22:50:44 +0530 Subject: [PATCH 01/18] feat: export platform-neutral adapter analysis --- package.json | 1 + src/adapter-analysis.test.ts | 48 +++++++++++ src/adapter-analysis.ts | 35 ++++++++ src/convention-audit.test.ts | 19 +++++ src/convention-audit.ts | 151 ++++++++++++++++++++--------------- src/validate.test.ts | 9 ++- src/validate.ts | 99 ++++++++++++++--------- 7 files changed, 258 insertions(+), 104 deletions(-) create mode 100644 src/adapter-analysis.test.ts create mode 100644 src/adapter-analysis.ts diff --git a/package.json b/package.json index 1740d45e..a1137fca 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "exports": { ".": "./dist/src/main.js", + "./adapter-analysis": "./dist/src/adapter-analysis.js", "./registry": "./dist/src/registry-api.js", "./errors": "./dist/src/errors.js", "./types": "./dist/src/types.js", diff --git a/src/adapter-analysis.test.ts b/src/adapter-analysis.test.ts new file mode 100644 index 00000000..a7fe0236 --- /dev/null +++ b/src/adapter-analysis.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + auditAdapterConventions, + validateAdapterCommands, + type AdapterAnalysisCommand, +} from './adapter-analysis.js'; + +const command: AdapterAnalysisCommand = { + site: 'demo', + name: 'list', + command: 'demo/list', + description: 'List demos', + access: 'read', + browser: false, + args: [{ name: 'limit' }], + columns: ['item_id'], + pipeline: [{ fetch: { url: 'https://example.com' } }], + runnable: true, + sourceFile: 'plugins/demo/clis/demo/list.js', +}; + +describe('public adapter analysis', () => { + it('validates supplied commands and step names without registry discovery', () => { + expect(validateAdapterCommands([command], { knownPipelineSteps: ['fetch'] })).toEqual({ + ok: true, + results: [{ label: 'demo/list', errors: [], warnings: [] }], + errors: 0, + warnings: 0, + commands: 1, + }); + }); + + it('passes only the logical package-relative path to the source reader', () => { + const readSource = vi.fn(() => 'const row = { item_id: "a", hidden: true };'); + const report = auditAdapterConventions([command], { readSource }); + expect(readSource).toHaveBeenCalledWith('plugins/demo/clis/demo/list.js'); + expect(report.categories.find(category => category.rule === 'silent-column-drop')?.count).toBe(1); + expect(report.categories.flatMap(category => category.violations)[0]?.file) + .toBe('plugins/demo/clis/demo/list.js'); + }); + + it('rejects unknown targets before reading source', () => { + const readSource = vi.fn(); + expect(() => auditAdapterConventions([command], { target: 'missing', readSource })) + .toThrow('No command matches "missing"'); + expect(readSource).not.toHaveBeenCalled(); + }); +}); diff --git a/src/adapter-analysis.ts b/src/adapter-analysis.ts new file mode 100644 index 00000000..4521a286 --- /dev/null +++ b/src/adapter-analysis.ts @@ -0,0 +1,35 @@ +export interface AdapterAnalysisArg { + name: string; + positional?: boolean; + required?: boolean; + help?: string; +} + +export interface AdapterAnalysisCommand { + site: string; + name: string; + command: string; + description?: string; + access?: string; + browser?: boolean; + domain?: string | null; + args?: readonly AdapterAnalysisArg[]; + columns?: readonly string[]; + pipeline?: readonly Record[]; + runnable: boolean; + sourceFile?: string; + modulePath?: string; +} + +export type AdapterAnalysisSourceReader = (logicalPath: string) => string | undefined; + +export { + validateAdapterCommands, + type CommandValidationResult, + type ValidationReport, +} from './validate.js'; +export { + auditAdapterConventions, + type ConventionAuditReport, + type ConventionViolation, +} from './convention-audit.js'; diff --git a/src/convention-audit.test.ts b/src/convention-audit.test.ts index 82388b01..0f2bb9f8 100644 --- a/src/convention-audit.test.ts +++ b/src/convention-audit.test.ts @@ -130,6 +130,25 @@ describe('convention audit', () => { }); }); + it('does not read manifest source paths outside the project root', () => { + const root = makeProject([], {}); + const outsideFile = path.join(path.dirname(root), `${path.basename(root)}-outside.js`); + fs.writeFileSync(outsideFile, 'rows.push({ id: 1, hidden: true });'); + fs.writeFileSync(path.join(root, 'cli-manifest.json'), JSON.stringify([{ + site: 'demo', + name: 'search', + access: 'read', + columns: ['id'], + sourceFile: `clis/../../${path.basename(outsideFile)}`, + }])); + + try { + expect(runConventionAudit({ projectRoot: root }).summary.files_scanned).toBe(0); + } finally { + fs.rmSync(outsideFile, { force: true }); + } + }); + it('renders a compact text report', () => { const root = makeProject([ { site: 'demo', name: 'search', access: 'read', columns: ['id'], sourceFile: 'demo/search.js' }, diff --git a/src/convention-audit.ts b/src/convention-audit.ts index 4ba8ccd5..1754081a 100644 --- a/src/convention-audit.ts +++ b/src/convention-audit.ts @@ -1,5 +1,10 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import type { + AdapterAnalysisCommand, + AdapterAnalysisSourceReader, +} from './adapter-analysis.js'; +import { selectAdapterCommands } from './validate.js'; export type ConventionRuleId = | 'silent-column-drop' @@ -134,42 +139,91 @@ export function runConventionAudit(opts: ConventionAuditOptions): ConventionAudi const manifest = manifestPaths.flatMap(manifestPath => ( JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as ManifestCommand[] )); - const filtered = manifest.filter((entry) => matchesTarget(entry, opts)); + const commands = manifest.flatMap((entry) => { + const command = toAdapterAnalysisCommand(entry); + return command ? [command] : []; + }); + const allowedPaths = new Set(commands.flatMap((command) => { + const logicalPath = command.sourceFile ?? command.modulePath; + return logicalPath ? [logicalPath] : []; + })); + const projectRoot = fs.realpathSync(opts.projectRoot); + const rootPrefix = `${projectRoot}${path.sep}`; + const readSource: AdapterAnalysisSourceReader = (logicalPath) => { + if (!allowedPaths.has(logicalPath)) return undefined; + const packageRelative = logicalPath.startsWith('plugins/') || logicalPath.startsWith('clis/') + ? logicalPath + : path.join('clis', logicalPath); + const sourcePath = path.resolve(projectRoot, packageRelative); + if (sourcePath !== projectRoot && !sourcePath.startsWith(rootPrefix)) return undefined; + try { + const realSourcePath = fs.realpathSync(sourcePath); + if (realSourcePath !== projectRoot && !realSourcePath.startsWith(rootPrefix)) return undefined; + return fs.readFileSync(realSourcePath, 'utf-8'); + } catch { + return undefined; + } + }; + + const target = opts.target?.trim(); + const targetExists = !target || commands.some(command => ( + target.includes('/') ? command.command === target : command.site === target + )); + return auditAdapterConventions(targetExists ? commands : [], { + ...(targetExists && target ? { target } : {}), + ...(opts.site !== undefined ? { site: opts.site } : {}), + readSource, + }); +} + +export function auditAdapterConventions( + commands: readonly AdapterAnalysisCommand[], + options: { + target?: string; + site?: string; + readSource: AdapterAnalysisSourceReader; + }, +): ConventionAuditReport { + const targeted = selectAdapterCommands(commands, options.target, 'convention-audit'); + const site = options.site?.trim(); + const filtered = site ? targeted.filter(command => command.site === site) : targeted; const violations: ConventionViolation[] = []; - const sourceCache = new Map(); + const sourceCache = new Map(); const scannedFiles = new Set(); - for (const entry of filtered) { - const command = normalizeCommand(entry); - if (!command) continue; - - if (entry.access !== 'read' && entry.access !== 'write') { + for (const command of filtered) { + const identity = { site: command.site, name: command.name, command: command.command }; + if (command.access !== 'read' && command.access !== 'write') { violations.push({ rule: 'missing-access-metadata', - ...command, + ...identity, message: `${command.command} must declare access: 'read' | 'write'`, }); } - for (const column of entry.columns ?? []) { + for (const column of command.columns ?? []) { if (/[a-z][A-Z]/.test(column)) { violations.push({ rule: 'camelCase-in-columns', - ...command, + ...identity, message: `${command.command} column "${column}" should use snake_case for agent-stable keys`, details: { column }, }); } } - const sourcePath = resolveSourcePath(opts.projectRoot, entry); - if (!sourcePath) continue; - const source = readSource(sourcePath, sourceCache); - if (source == null) continue; - scannedFiles.add(sourcePath); + const logicalPath = command.sourceFile ?? command.modulePath; + if (!logicalPath) continue; + let source = sourceCache.get(logicalPath); + if (!sourceCache.has(logicalPath)) { + source = options.readSource(logicalPath); + sourceCache.set(logicalPath, source); + } + if (source === undefined) continue; + scannedFiles.add(logicalPath); - violations.push(...auditColumnDrop(command, entry, source, sourcePath, opts.projectRoot)); - violations.push(...auditTypedErrorPatterns(command, source, sourcePath, opts.projectRoot)); + violations.push(...auditColumnDrop(identity, command, source, logicalPath)); + violations.push(...auditTypedErrorPatterns(identity, source, logicalPath)); } violations.push(...auditWriteDeletePair(filtered)); @@ -179,12 +233,11 @@ export function runConventionAudit(opts: ConventionAuditOptions): ConventionAudi return { rule, count: items.length, violations: items }; }); - const commandCount = filtered.filter((entry) => normalizeCommand(entry) != null).length; - const sites = new Set(filtered.map((entry) => entry.site).filter((site): site is string => typeof site === 'string')); + const sites = new Set(filtered.map(command => command.site)); return { ok: violations.length === 0, summary: { - commands: commandCount, + commands: filtered.length, sites: sites.size, files_scanned: scannedFiles.size, violations: violations.length, @@ -219,51 +272,25 @@ export function renderConventionAuditText(report: ConventionAuditReport): string return lines.join('\n'); } -function normalizeCommand(entry: ManifestCommand): Pick | null { +function toAdapterAnalysisCommand(entry: ManifestCommand): AdapterAnalysisCommand | null { if (typeof entry.site !== 'string' || typeof entry.name !== 'string') return null; return { site: entry.site, name: entry.name, command: `${entry.site}/${entry.name}`, + ...(typeof entry.access === 'string' ? { access: entry.access } : {}), + ...(entry.columns ? { columns: entry.columns } : {}), + ...(typeof entry.sourceFile === 'string' ? { sourceFile: entry.sourceFile } : {}), + ...(typeof entry.modulePath === 'string' ? { modulePath: entry.modulePath } : {}), + runnable: true, }; } -function matchesTarget(entry: ManifestCommand, opts: Pick): boolean { - const target = opts.target?.trim(); - const site = opts.site?.trim(); - if (site && entry.site !== site) return false; - if (!target) return true; - if (target.includes('/')) return `${entry.site}/${entry.name}` === target; - return entry.site === target; -} - -function resolveSourcePath(projectRoot: string, entry: ManifestCommand): string | null { - const relative = entry.sourceFile ?? entry.modulePath; - if (!relative) return null; - const sourcePath = relative.startsWith('plugins/') || relative.startsWith('clis/') - ? path.join(projectRoot, relative) - : path.join(projectRoot, 'clis', relative); - return fs.existsSync(sourcePath) ? sourcePath : null; -} - -function readSource(sourcePath: string, cache: Map): string | null { - if (cache.has(sourcePath)) return cache.get(sourcePath) ?? null; - try { - const source = fs.readFileSync(sourcePath, 'utf-8'); - cache.set(sourcePath, source); - return source; - } catch { - cache.set(sourcePath, null); - return null; - } -} - function auditColumnDrop( command: Pick, - entry: ManifestCommand, + entry: Pick, source: string, - sourcePath: string, - projectRoot: string, + logicalPath: string, ): ConventionViolation[] { const columns = new Set(entry.columns ?? []); if (columns.size === 0) return []; @@ -286,7 +313,7 @@ function auditColumnDrop( violations.push({ rule: 'silent-column-drop', ...command, - file: relativeFile(projectRoot, sourcePath), + file: logicalPath, line: lineForIndex(source, object.index), message: `${command.command} row emits key(s) not present in columns: ${missing.join(', ')}`, details: { emitted_keys: keys, columns: [...columns], missing }, @@ -298,11 +325,10 @@ function auditColumnDrop( function auditTypedErrorPatterns( command: Pick, source: string, - sourcePath: string, - projectRoot: string, + logicalPath: string, ): ConventionViolation[] { const violations: ConventionViolation[] = []; - const relative = relativeFile(projectRoot, sourcePath); + const relative = logicalPath; const lines = source.split(/\r?\n/); const catchRanges = findCatchBlockRanges(source); let offset = 0; @@ -352,8 +378,8 @@ function isThrowMessageLine(line: string): boolean { return /\bthrow\s+new\b/.test(line); } -function auditWriteDeletePair(entries: ManifestCommand[]): ConventionViolation[] { - const bySite = new Map(); +function auditWriteDeletePair(entries: readonly AdapterAnalysisCommand[]): ConventionViolation[] { + const bySite = new Map(); for (const entry of entries) { if (!entry.site || !entry.name) continue; const list = bySite.get(entry.site) ?? []; @@ -390,6 +416,7 @@ function extractPotentialRowObjects(source: string): Array<{ text: string; index /\breturn\s+(?:\(\s*)?{/g, /=>\s*\(\s*{/g, /\bmap\s*:\s*{/g, + /\b(?:const|let|var)\s+\w+\s*=\s*{/g, ]; for (const trigger of triggers) { for (const match of source.matchAll(trigger)) { @@ -600,10 +627,6 @@ function lineForIndex(source: string, index: number): number { return source.slice(0, index).split(/\r?\n/).length; } -function relativeFile(projectRoot: string, sourcePath: string): string { - return path.relative(projectRoot, sourcePath).replaceAll(path.sep, '/'); -} - function formatDetails(details: Record | undefined): string { if (!details) return ''; return Object.entries(details) diff --git a/src/validate.test.ts b/src/validate.test.ts index 4b1aece2..0e99f8dd 100644 --- a/src/validate.test.ts +++ b/src/validate.test.ts @@ -186,8 +186,13 @@ describe('validateClisWithTarget unknown target', () => { withValidateFixtures([key], () => { registerValidateFixture(site, 'search'); const report = validateClisWithTarget([], site); - expect(report.ok).toBe(true); - expect(report.commands).toBe(1); + expect(report).toEqual({ + ok: true, + results: [{ label: `${site}/search`, errors: [], warnings: ['Missing description'] }], + errors: 0, + warnings: 1, + commands: 1, + }); }); }); }); diff --git a/src/validate.ts b/src/validate.ts index 955fc8ba..095583e7 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -3,19 +3,10 @@ import { getRegistry, fullName, type CliCommand, type InternalCliCommand } from import { getRegisteredStepNames } from './pipeline/registry.js'; import { CLI_COMMAND } from './brand.js'; import { ArgumentError } from './errors.js'; +import type { AdapterAnalysisCommand } from './adapter-analysis.js'; const SITE_LIST_LIMIT = 20; -/** - * Pipeline step names — derived from the live pipeline registry on each - * validate call so a new step registered in src/pipeline/registry.ts (or by - * a plugin at runtime) is automatically allowlisted here (no parallel - * hand-maintained list, no stale-snapshot drift). - */ -function getKnownStepNames(): Set { - return new Set(getRegisteredStepNames()); -} - export interface CommandValidationResult { /** Display label: "site/name" or source path if available */ label: string; @@ -41,13 +32,31 @@ export function validateClisWithTarget(_dirs: string[], target?: string): Valida const registry = getRegistry(); const commands = collectCanonicalCommands(registry); const normalizedTarget = target?.trim(); - const selected = normalizedTarget - ? commandsMatchingTarget(commands, resolveValidateTarget(registry, normalizedTarget)) - : commands; + const analysisCommands: AdapterAnalysisCommand[] = commands.map(command => ({ + site: command.site, + name: command.name, + command: fullName(command), + ...(command.description ? { description: command.description } : {}), + ...(command.access ? { access: command.access } : {}), + browser: command.browser === true, + ...(command.domain !== undefined ? { domain: command.domain } : {}), + ...(command.args ? { args: command.args } : {}), + ...(command.columns ? { columns: command.columns } : {}), + ...(command.pipeline ? { pipeline: command.pipeline } : {}), + runnable: Boolean(command.func || command.pipeline || (command as InternalCliCommand)._lazy), + })); + + return validateAdapterCommands(analysisCommands, { + ...(normalizedTarget ? { target: resolveValidateTarget(registry, normalizedTarget) } : {}), + knownPipelineSteps: getRegisteredStepNames(), + }); +} - if (normalizedTarget && selected.length === 0) { - throwUnknownValidateTarget(normalizedTarget, commands); - } +export function validateAdapterCommands( + commands: readonly AdapterAnalysisCommand[], + options: { target?: string; knownPipelineSteps: readonly string[] }, +): ValidationReport { + const selected = selectAdapterCommands(commands, options.target); if (commands.length === 0) { const r: CommandValidationResult = { @@ -60,8 +69,9 @@ export function validateClisWithTarget(_dirs: string[], target?: string): Valida const results: CommandValidationResult[] = []; let errors = 0; let warnings = 0; - for (const cmd of selected) { - const r = validateCommand(cmd); + const knownStepNames = new Set(options.knownPipelineSteps); + for (const command of selected) { + const r = validateCommand(command, knownStepNames); results.push(r); errors += r.errors.length; warnings += r.warnings.length; @@ -87,13 +97,26 @@ function resolveValidateTarget(registry: Map, target: string return cmd ? fullName(cmd) : target; } -function commandsMatchingTarget(commands: CliCommand[], target: string): CliCommand[] { - if (target.includes('/')) return commands.filter(cmd => fullName(cmd) === target); - return commands.filter(cmd => cmd.site === target); +export function selectAdapterCommands( + commands: readonly AdapterAnalysisCommand[], + target?: string, + commandName = 'validate', +): AdapterAnalysisCommand[] { + const normalizedTarget = target?.trim(); + if (!normalizedTarget) return [...commands]; + const selected = normalizedTarget.includes('/') + ? commands.filter(command => command.command === normalizedTarget) + : commands.filter(command => command.site === normalizedTarget); + if (selected.length === 0) throwUnknownValidateTarget(normalizedTarget, commands, commandName); + return selected; } -function throwUnknownValidateTarget(target: string, commands: CliCommand[]): never { - const usage = `usage: ${CLI_COMMAND} validate `; +function throwUnknownValidateTarget( + target: string, + commands: readonly AdapterAnalysisCommand[], + commandName: string, +): never { + const usage = `usage: ${CLI_COMMAND} ${commandName} `; const sites = [...new Set(commands.map(cmd => cmd.site))].sort((a, b) => a.localeCompare(b)); if (target.includes('/')) { @@ -102,7 +125,7 @@ function throwUnknownValidateTarget(target: string, commands: CliCommand[]): nev if (names.length > 0) { throw new ArgumentError( `No command matches "${target}". Valid commands for ${site}: ${names.join(', ')}`, - `${usage}\nexample: ${CLI_COMMAND} validate ${site}/${names[0]}`, + `${usage}\nexample: ${CLI_COMMAND} ${commandName} ${site}/${names[0]}`, ); } } @@ -122,27 +145,29 @@ function throwUnknownValidateTarget(target: string, commands: CliCommand[]): nev const more = sites.length > SITE_LIST_LIMIT ? `\nList all: ${CLI_COMMAND} list` : ''; throw new ArgumentError( `No command matches "${target}". ${siteList}`, - `${usage}\nexample: ${CLI_COMMAND} validate ${sites[0]}${more}`, + `${usage}\nexample: ${CLI_COMMAND} ${commandName} ${sites[0]}${more}`, ); } -function validateCommand(cmd: CliCommand): CommandValidationResult { - const label = fullName(cmd); +function validateCommand( + command: AdapterAnalysisCommand, + knownStepNames: ReadonlySet, +): CommandValidationResult { + const label = command.command; const errors: string[] = []; const warnings: string[] = []; - if (!cmd.description) warnings.push('Missing description'); + if (!command.description) warnings.push('Missing description'); // Browser commands should specify a domain for authenticated browser context - if (cmd.browser && !cmd.domain) { + if (command.browser && !command.domain) { warnings.push('Browser command without "domain" — authenticated browser context may not work'); } // Pipeline validation: check step names for typos - if (Array.isArray(cmd.pipeline)) { - const knownStepNames = getKnownStepNames(); - for (let i = 0; i < cmd.pipeline.length; i++) { - const step = cmd.pipeline[i]; + if (Array.isArray(command.pipeline)) { + for (let i = 0; i < command.pipeline.length; i++) { + const step = command.pipeline[i]; if (step && typeof step === 'object') { for (const key of Object.keys(step)) { if (!knownStepNames.has(key)) { @@ -155,17 +180,15 @@ function validateCommand(cmd: CliCommand): CommandValidationResult { } } - // Commands should have either func, pipeline, or be a lazy-loaded module - const internal = cmd as InternalCliCommand; - if (!cmd.func && !cmd.pipeline && !internal._lazy) { + if (!command.runnable) { errors.push('Command has neither "func" nor "pipeline" — it cannot execute'); } // Arg validation - if (cmd.args && cmd.args.length > 0) { + if (command.args && command.args.length > 0) { const argNames = new Set(); let seenNonPositional = false; - for (const arg of cmd.args) { + for (const arg of command.args) { if (argNames.has(arg.name)) { errors.push(`Duplicate arg name "${arg.name}"`); } From 37fa074f4ab65435ba75ffe9325555f4f1529b3e Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 22:56:53 +0530 Subject: [PATCH 02/18] fix: preserve convention audit baseline --- src/convention-audit.test.ts | 21 +++++++++++++++++++++ src/convention-audit.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/convention-audit.test.ts b/src/convention-audit.test.ts index 0f2bb9f8..876f3be7 100644 --- a/src/convention-audit.test.ts +++ b/src/convention-audit.test.ts @@ -192,6 +192,27 @@ describe('convention audit', () => { }); }); + it('does not treat unrelated assigned object literals as emitted rows', () => { + const root = makeProject([ + { site: 'demo', name: 'search', access: 'read', columns: ['id'], sourceFile: 'demo/search.js' }, + ], { + 'demo/search.js': ` + export async function run(item) { + const filters = { id: item.id, query: item.query }; + const payload = { id: item.id, body: item.body }; + const geometry = { id: item.id, x: 0, y: 0 }; + const row = { id: item.id, hidden: item.hidden }; + return row; + } + `, + }); + + const report = runConventionAudit({ projectRoot: root }); + const violations = report.categories.find((item) => item.rule === 'silent-column-drop')!.violations; + + expect(violations.map((violation) => violation.details?.missing)).toEqual([['hidden']]); + }); + it('ignores ok:false diagnostic objects when checking emitted rows', () => { const root = makeProject([ { site: 'demo', name: 'search', access: 'read', columns: ['id', 'url'], sourceFile: 'demo/search.js' }, diff --git a/src/convention-audit.ts b/src/convention-audit.ts index 1754081a..12d496b1 100644 --- a/src/convention-audit.ts +++ b/src/convention-audit.ts @@ -416,7 +416,7 @@ function extractPotentialRowObjects(source: string): Array<{ text: string; index /\breturn\s+(?:\(\s*)?{/g, /=>\s*\(\s*{/g, /\bmap\s*:\s*{/g, - /\b(?:const|let|var)\s+\w+\s*=\s*{/g, + /\b(?:const|let|var)\s+row\s*=\s*{/g, ]; for (const trigger of triggers) { for (const match of source.matchAll(trigger)) { From 6cd8912600dabe1b8344b333efc010a938a51a77 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 23:00:38 +0530 Subject: [PATCH 03/18] feat: negotiate hosted core commands --- package.json | 1 + src/hosted/client.test.ts | 63 ++++++++++++++++++++++++++++++-- src/hosted/client.ts | 45 ++++++++++++++++------- src/hosted/core-commands.test.ts | 38 +++++++++++++++++++ src/hosted/core-commands.ts | 26 +++++++++++++ src/hosted/types.ts | 2 + 6 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 src/hosted/core-commands.test.ts create mode 100644 src/hosted/core-commands.ts diff --git a/package.json b/package.json index a1137fca..d40ba34c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "./pipeline": "./dist/src/pipeline/index.js", "./plugin-runtime": "./dist/src/plugin-runtime.js", "./hosted/availability": "./dist/src/hosted/availability.js", + "./hosted/core-commands": "./dist/src/hosted/core-commands.js", "./hosted/programmatic": "./dist/src/hosted/programmatic.js" }, "files": [ diff --git a/src/hosted/client.test.ts b/src/hosted/client.test.ts index 2160b666..28b53f95 100644 --- a/src/hosted/client.test.ts +++ b/src/hosted/client.test.ts @@ -1,6 +1,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { HostedClient, HostedClientError, resolveWorkspace } from './client.js'; +const manifest = { + userId: 'user_demo', + metadata: { + contractSchemaVersion: 1, + sessionProtocolVersion: 1, + webcmdPackageVersion: '0.7.8', + generatedAt: 'now', + }, + commands: [], +}; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200 }); +} + +function clientOptions(fetchImpl: typeof fetch) { + return { apiBaseUrl: 'https://api.example.com', apiKey: 'key', fetchImpl }; +} + +function clientFor(body: unknown): HostedClient { + return new HostedClient(clientOptions(async () => jsonResponse(body))); +} + const invalidTraceUrlCases = [ { name: 'raw absolute provider URL with token', @@ -71,6 +94,38 @@ const validTraceUrlCases = [ ] as const; describe('HostedClient', () => { + it('advertises live-view and hosted-core capability tokens', async () => { + const fetchImpl = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + expect(new Headers(init?.headers).get('x-webcmd-client-capabilities')) + .toBe('hosted-live-view-v1, hosted-core-commands-v1'); + return jsonResponse({ ok: true, manifest }); + }); + await new HostedClient(clientOptions(fetchImpl)).getManifest(); + }); + + it('accepts an old manifest without coreCommands', async () => { + await expect(clientFor({ ok: true, manifest }).getManifest()).resolves.toEqual(manifest); + }); + + it('accepts the canonical advertised core command IDs', async () => { + const advertised = { + ...manifest, + metadata: { ...manifest.metadata, coreCommands: ['validate', 'profile/create'] }, + }; + await expect(clientFor({ ok: true, manifest: advertised }).getManifest()) + .resolves.toEqual(advertised); + }); + + it.each([ + 'validate', + ['validate', 1], + ['validate', 'validate'], + ['future-command'], + ])('rejects malformed coreCommands metadata: %j', async (coreCommands) => { + const response = { ok: true, manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands } } }; + await expect(clientFor(response).getManifest()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' }); + }); + it('preserves a raw storage endpoint error envelope', async () => { const client = new HostedClient({ apiBaseUrl: 'https://api.example.com', @@ -173,11 +228,11 @@ describe('HostedClient', () => { url: 'https://api.example.com/v1/sessions', method: 'POST', body: '{"name":"Work Project","profile":"work"}', - liveViewCapability: 'hosted-live-view-v1', + liveViewCapability: 'hosted-live-view-v1, hosted-core-commands-v1', }); expect(requests.slice(1).map(({ url, method, liveViewCapability }) => ({ url, method, liveViewCapability }))).toEqual([ - { url: 'https://api.example.com/v1/sessions?profile=default&limit=20', method: 'GET', liveViewCapability: 'hosted-live-view-v1' }, - { url: 'https://api.example.com/v1/sessions/session_wire/close', method: 'POST', liveViewCapability: 'hosted-live-view-v1' }, + { url: 'https://api.example.com/v1/sessions?profile=default&limit=20', method: 'GET', liveViewCapability: 'hosted-live-view-v1, hosted-core-commands-v1' }, + { url: 'https://api.example.com/v1/sessions/session_wire/close', method: 'POST', liveViewCapability: 'hosted-live-view-v1, hosted-core-commands-v1' }, ]); }); @@ -225,7 +280,7 @@ describe('HostedClient', () => { expect(prepareBody).toEqual({ command: 'github/whoami', profile: 'work', session: 'session_work', executionScope: 'profile', }); - expect(prepareCapability).toBe('hosted-live-view-v1'); + expect(prepareCapability).toBe('hosted-live-view-v1, hosted-core-commands-v1'); await expect(client.prepareExecution({ command: 'github/unknown' })).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' }); }); diff --git a/src/hosted/client.ts b/src/hosted/client.ts index 0820d5cd..637afdbe 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -1,5 +1,6 @@ import { attachTraceReceipt, CliError, EXIT_CODES, type ExitCode } from '../errors.js'; import { parseExecutionArtifactDownloadUrl } from './artifact-url.js'; +import { HOSTED_CORE_COMMANDS_CAPABILITY, isHostedCoreCommandId } from './core-commands.js'; import { log } from '../logger.js'; import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; import type { @@ -501,7 +502,10 @@ export class HostedClient { accept: 'application/json', authorization: `Bearer ${this.apiKey}`, 'x-webcmd-session-protocol-version': String(HOSTED_SESSION_PROTOCOL_VERSION), - 'x-webcmd-client-capabilities': 'hosted-live-view-v1', + 'x-webcmd-client-capabilities': [ + 'hosted-live-view-v1', + HOSTED_CORE_COMMANDS_CAPABILITY, + ].join(', '), ...(init.body ? { 'content-type': 'application/json' } : {}), ...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}), ...(init.headers ?? {}), @@ -621,19 +625,32 @@ function isHostedError(value: unknown): value is HostedErrorResponse { } function isHostedManifest(value: unknown): value is HostedManifest { - return hasExactKeys(value, ['userId', 'metadata', 'commands']) - && typeof value.userId === 'string' - && hasExactKeys(value.metadata, ['contractSchemaVersion', 'sessionProtocolVersion', 'webcmdPackageVersion', 'generatedAt']) - && typeof value.metadata.contractSchemaVersion === 'number' - && Number.isInteger(value.metadata.contractSchemaVersion) - && value.metadata.contractSchemaVersion > 0 - && typeof value.metadata.sessionProtocolVersion === 'number' - && Number.isInteger(value.metadata.sessionProtocolVersion) - && value.metadata.sessionProtocolVersion > 0 - && typeof value.metadata.webcmdPackageVersion === 'string' - && typeof value.metadata.generatedAt === 'string' - && Array.isArray(value.commands) - && value.commands.every(isHostedManifestCommand); + if (!hasExactKeys(value, ['userId', 'metadata', 'commands']) + || typeof value.userId !== 'string' + || !hasOnlyKeys(value.metadata, [ + 'contractSchemaVersion', + 'sessionProtocolVersion', + 'webcmdPackageVersion', + 'generatedAt', + 'coreCommands', + ]) + || typeof value.metadata.contractSchemaVersion !== 'number' + || !Number.isInteger(value.metadata.contractSchemaVersion) + || value.metadata.contractSchemaVersion <= 0 + || typeof value.metadata.sessionProtocolVersion !== 'number' + || !Number.isInteger(value.metadata.sessionProtocolVersion) + || value.metadata.sessionProtocolVersion <= 0 + || typeof value.metadata.webcmdPackageVersion !== 'string' + || typeof value.metadata.generatedAt !== 'string' + || !Array.isArray(value.commands) + || !value.commands.every(isHostedManifestCommand)) return false; + + const coreCommands = value.metadata.coreCommands; + if (coreCommands !== undefined) { + if (!Array.isArray(coreCommands) || !coreCommands.every(isHostedCoreCommandId)) return false; + if (new Set(coreCommands).size !== coreCommands.length) return false; + } + return true; } function isHostedExecuteResponse( diff --git a/src/hosted/core-commands.test.ts b/src/hosted/core-commands.test.ts new file mode 100644 index 00000000..c1289b63 --- /dev/null +++ b/src/hosted/core-commands.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + hasHostedCoreCommand, + HOSTED_CORE_COMMAND_IDS, + HOSTED_CORE_COMMANDS_CAPABILITY, + isHostedCoreCommandId, +} from './core-commands.js'; + +describe('hosted core command capability', () => { + it('publishes the v1 capability and canonical command IDs', () => { + expect(HOSTED_CORE_COMMANDS_CAPABILITY).toBe('hosted-core-commands-v1'); + expect(HOSTED_CORE_COMMAND_IDS).toEqual([ + 'validate', + 'verify', + 'convention-audit', + 'doctor', + 'adapter/status', + 'adapter/reset', + 'profile/create', + 'profile/rename', + 'plugin/catalog/list', + ]); + }); + + it.each(HOSTED_CORE_COMMAND_IDS)('recognizes canonical ID %s', (id) => { + expect(isHostedCoreCommandId(id)).toBe(true); + }); + + it.each([undefined, null, 1, 'future-command'])('rejects non-canonical ID %j', (value) => { + expect(isHostedCoreCommandId(value)).toBe(false); + }); + + it('checks optional advertised command membership', () => { + expect(hasHostedCoreCommand(['validate'], 'validate')).toBe(true); + expect(hasHostedCoreCommand(['validate'], 'doctor')).toBe(false); + expect(hasHostedCoreCommand(undefined, 'validate')).toBe(false); + }); +}); diff --git a/src/hosted/core-commands.ts b/src/hosted/core-commands.ts new file mode 100644 index 00000000..2a8e8bf9 --- /dev/null +++ b/src/hosted/core-commands.ts @@ -0,0 +1,26 @@ +export const HOSTED_CORE_COMMANDS_CAPABILITY = 'hosted-core-commands-v1' as const; + +export const HOSTED_CORE_COMMAND_IDS = [ + 'validate', + 'verify', + 'convention-audit', + 'doctor', + 'adapter/status', + 'adapter/reset', + 'profile/create', + 'profile/rename', + 'plugin/catalog/list', +] as const; + +export type HostedCoreCommandId = typeof HOSTED_CORE_COMMAND_IDS[number]; + +export function isHostedCoreCommandId(value: unknown): value is HostedCoreCommandId { + return typeof value === 'string' && (HOSTED_CORE_COMMAND_IDS as readonly string[]).includes(value); +} + +export function hasHostedCoreCommand( + ids: readonly HostedCoreCommandId[] | undefined, + id: HostedCoreCommandId, +): boolean { + return ids?.includes(id) ?? false; +} diff --git a/src/hosted/types.ts b/src/hosted/types.ts index 08fd3052..5cde664c 100644 --- a/src/hosted/types.ts +++ b/src/hosted/types.ts @@ -1,6 +1,7 @@ import type { CommandSurfaceMetadata } from '../command-surface.js'; import type { Arg } from '../registry.js'; import type { FileArgumentDirection } from './contract.js'; +import type { HostedCoreCommandId } from './core-commands.js'; export const HOSTED_SESSION_PROTOCOL_VERSION = 1 as const; @@ -50,6 +51,7 @@ export interface HostedManifest { sessionProtocolVersion: number; webcmdPackageVersion: string; generatedAt: string; + coreCommands?: HostedCoreCommandId[]; }; commands: HostedCommand[]; } From 0adf298c84958af0dca23a78d21b425a1a027413 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 23:04:43 +0530 Subject: [PATCH 04/18] fix: advertise capabilities on artifact downloads --- src/hosted/client.test.ts | 10 +++++++++- src/hosted/client.ts | 13 +++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/hosted/client.test.ts b/src/hosted/client.test.ts index 28b53f95..915874cd 100644 --- a/src/hosted/client.test.ts +++ b/src/hosted/client.test.ts @@ -809,7 +809,13 @@ describe('HostedClient', () => { }); it('prepares, uploads, runs, and downloads execution artifacts with raw byte bodies', async () => { - const requests: Array<{ url: string; method: string; body?: unknown; filename?: string | null }> = []; + const requests: Array<{ + url: string; + method: string; + body?: unknown; + filename?: string | null; + capabilities?: string | null; + }> = []; const bytes = new Uint8Array(Buffer.from('hello cloud')); const client = new HostedClient({ apiBaseUrl: 'https://api.example.com', @@ -821,6 +827,7 @@ describe('HostedClient', () => { method: init?.method ?? 'GET', body: init?.body, filename: new Headers(init?.headers).get('x-webcmd-filename'), + capabilities: new Headers(init?.headers).get('x-webcmd-client-capabilities'), }); if (requestUrl.endsWith('/v1/executions')) { return new Response(JSON.stringify({ @@ -908,6 +915,7 @@ describe('HostedClient', () => { body: new Uint8Array(Buffer.from('png')), }); expect(JSON.parse(String(requests[2]?.body))).toMatchObject({ session: 'session_a' }); + expect(requests[3]?.capabilities).toBe('hosted-live-view-v1, hosted-core-commands-v1'); }); it('preserves execution and trace metadata from hosted failure envelopes', async () => { diff --git a/src/hosted/client.ts b/src/hosted/client.ts index 637afdbe..1c77b2f7 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -372,16 +372,9 @@ export class HostedClient { executionId: string; artifactId: string; }): Promise { - const response = await this.fetchImpl( - `${this.apiBaseUrl}/v1/executions/${encodeURIComponent(input.executionId)}/artifacts/${encodeURIComponent(input.artifactId)}`, - { - ...(this.signal ? { signal: this.signal } : {}), - headers: { - accept: 'application/octet-stream', - authorization: `Bearer ${this.apiKey}`, - ...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}), - }, - }, + const response = await this.authenticatedFetch( + `/v1/executions/${encodeURIComponent(input.executionId)}/artifacts/${encodeURIComponent(input.artifactId)}`, + { headers: { accept: 'application/octet-stream' } }, ); if (!response.ok) { const text = await response.text(); From b2ae4da4c7d885d1f54c4d5f2e2b408ed5776045 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 23:10:13 +0530 Subject: [PATCH 05/18] feat: add strict hosted core transport --- src/hosted/client.test.ts | 224 +++++++++++++++++++++++++++++ src/hosted/client.ts | 275 +++++++++++++++++++++++++++++++++++- src/hosted/core-commands.ts | 13 ++ src/hosted/types.ts | 76 ++++++++++ 4 files changed, 587 insertions(+), 1 deletion(-) diff --git a/src/hosted/client.test.ts b/src/hosted/client.test.ts index 915874cd..0d8c0bae 100644 --- a/src/hosted/client.test.ts +++ b/src/hosted/client.test.ts @@ -12,6 +12,73 @@ const manifest = { commands: [], }; +const publicProfile = { + id: 'profile_1', + name: 'work', + workspace: 'workspace_1', + default: false, + status: 'available', + createdAt: '2026-08-27T00:00:00.000Z', + updatedAt: '2026-08-27T00:00:00.000Z', + lastUsedAt: '2026-08-27T00:00:00.000Z', +} as const; + +const validationReport = { + ok: true, + results: [{ label: 'github/list', errors: [], warnings: [] }], + errors: 0, + warnings: 0, + commands: 1, +}; + +const conventionAuditReport = { + ok: true, + summary: { commands: 1, sites: 1, files_scanned: 1, violations: 0 }, + categories: [], +}; + +const verifyReport = { + ok: true, + validation: validationReport, + smoke: { + requested: true, + executed: true, + ok: true, + summary: '1 passed', + results: [{ command: 'github/list', status: 'passed', message: 'Passed.' }], + }, +}; + +const doctorReport = { + ok: true, + checks: [{ id: 'api', ok: true, required: true, message: 'Authenticated.' }], +}; + +const adapterStatusRows = [{ + command: 'github/list', + kind: 'override', + package: '@user/github', + reconciliationState: 'current', + loadError: null, +}]; + +const adapterResetRemovals = [{ + packageId: 'package_1', + package: '@user/github', + commands: ['github/list', 'github/get'], +}]; + +const marketplaceCatalog = { + ok: true, + sources: [{ + id: 'official', + repository: 'https://github.com/agentrhq/webcmd', + commit: 'abc123', + manifestPath: 'marketplace.json', + status: 'consistent', + }], +}; + function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200 }); } @@ -126,6 +193,163 @@ describe('HostedClient', () => { await expect(clientFor(response).getManifest()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' }); }); + it.each([ + { + name: 'validate adapters', + invoke: (client: HostedClient) => client.validateAdapters('github'), + request: { path: '/v1/adapters/validate?target=github', method: 'GET' }, + response: { ok: true, report: validationReport }, + result: validationReport, + }, + { + name: 'verify adapters', + invoke: (client: HostedClient) => client.verifyAdapters({ target: 'github/list', smoke: true, profile: 'work' }), + request: { + path: '/v1/adapters/verify', + method: 'POST', + body: '{"target":"github/list","smoke":true,"profile":"work"}', + }, + response: { ok: true, report: verifyReport }, + result: verifyReport, + }, + { + name: 'audit adapter conventions', + invoke: (client: HostedClient) => client.auditAdapterConventions({ target: 'github/list', site: 'github' }), + request: { path: '/v1/adapters/convention-audit?target=github%2Flist&site=github', method: 'GET' }, + response: { ok: true, report: conventionAuditReport }, + result: conventionAuditReport, + }, + { + name: 'get doctor', + invoke: (client: HostedClient) => client.getDoctor('work'), + request: { path: '/v1/doctor?profile=work', method: 'GET' }, + response: { ok: true, report: doctorReport }, + result: doctorReport, + }, + { + name: 'list adapters', + invoke: (client: HostedClient) => client.listAdapters(), + request: { path: '/v1/adapters/status', method: 'GET' }, + response: { ok: true, adapters: adapterStatusRows }, + result: adapterStatusRows, + }, + { + name: 'reset one adapter override', + invoke: (client: HostedClient) => client.resetAdapterOverrides({ site: 'github' }), + request: { path: '/v1/adapters/overrides?site=github', method: 'DELETE' }, + response: { ok: true, removed: adapterResetRemovals }, + result: adapterResetRemovals, + }, + { + name: 'reset all adapter overrides', + invoke: (client: HostedClient) => client.resetAdapterOverrides({ all: true }), + request: { path: '/v1/adapters/overrides?all=true', method: 'DELETE' }, + response: { ok: true, removed: adapterResetRemovals }, + result: adapterResetRemovals, + }, + { + name: 'create profile', + invoke: (client: HostedClient) => client.createProfile('work'), + request: { path: '/v1/profiles', method: 'POST', body: '{"name":"work"}' }, + response: { ok: true, profile: publicProfile, created: true }, + responseStatus: 201, + result: { ok: true, profile: publicProfile, created: true }, + }, + { + name: 'rename profile', + invoke: (client: HostedClient) => client.renameProfile('profile_1', 'personal'), + request: { path: '/v1/profiles/profile_1', method: 'PATCH', body: '{"name":"personal"}' }, + response: { ok: true, profile: { ...publicProfile, name: 'personal' }, changed: true }, + result: { ok: true, profile: { ...publicProfile, name: 'personal' }, changed: true }, + }, + { + name: 'list marketplace catalog', + invoke: (client: HostedClient) => client.listMarketplaceCatalog(), + request: { path: '/v1/marketplace/catalog', method: 'GET' }, + response: marketplaceCatalog, + result: marketplaceCatalog, + }, + ])('sends and validates the $name request', async ({ invoke, request, response, responseStatus = 200, result }) => { + const requests: Array<{ path: string; method: string; body?: string }> = []; + const client = new HostedClient(clientOptions(async (url, init) => { + requests.push({ + path: new URL(String(url)).pathname + new URL(String(url)).search, + method: init?.method ?? 'GET', + ...(init?.body !== undefined ? { body: String(init.body) } : {}), + }); + return new Response(JSON.stringify(response), { status: responseStatus }); + })); + + await expect(invoke(client)).resolves.toEqual(result); + expect(requests).toEqual([request]); + }); + + it.each([ + { + name: 'validate response with an extra top-level key', + invoke: (client: HostedClient) => client.validateAdapters(), + response: { ok: true, report: validationReport, extra: true }, + }, + { + name: 'verify response missing a required key', + invoke: (client: HostedClient) => client.verifyAdapters({}), + response: { ok: true, report: { ok: true, validation: validationReport } }, + }, + { + name: 'doctor response with a wrong enum', + invoke: (client: HostedClient) => client.getDoctor(), + response: { ok: true, report: { ok: false, checks: [{ ...doctorReport.checks[0], id: 'daemon' }] } }, + }, + { + name: 'adapter status response with a non-array list', + invoke: (client: HostedClient) => client.listAdapters(), + response: { ok: true, adapters: {} }, + }, + { + name: 'adapter reset response with a nested extra key', + invoke: (client: HostedClient) => client.resetAdapterOverrides({ all: true }), + response: { ok: true, removed: [{ ...adapterResetRemovals[0], extra: true }] }, + }, + { + name: 'profile create response with a nested extra key', + invoke: (client: HostedClient) => client.createProfile('work'), + response: { ok: true, profile: { ...publicProfile, extra: true }, created: true }, + responseStatus: 201, + }, + { + name: 'profile rename response missing changed', + invoke: (client: HostedClient) => client.renameProfile('profile_1', 'personal'), + response: { ok: true, profile: { ...publicProfile, name: 'personal' } }, + }, + { + name: 'catalog response with a wrong status enum', + invoke: (client: HostedClient) => client.listMarketplaceCatalog(), + response: { + ok: true, + sources: [{ ...marketplaceCatalog.sources[0], status: 'healthy' }], + }, + }, + ])('rejects malformed core transport: $name', async ({ invoke, response, responseStatus = 200 }) => { + const client = new HostedClient(clientOptions(async () => new Response(JSON.stringify(response), { status: responseStatus }))); + await expect(invoke(client)).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' }); + }); + + it('requires HTTP 201 for profile creation', async () => { + await expect(clientFor({ ok: true, profile: publicProfile, created: true }).createProfile('work')) + .rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' }); + }); + + it.each([ + {}, + { site: 'github', all: true }, + { site: '' }, + ])('rejects invalid adapter reset input before sending a request: %j', async (input) => { + const fetchImpl = vi.fn(async () => jsonResponse({ ok: true, removed: [] })); + await expect(new HostedClient(clientOptions(fetchImpl)).resetAdapterOverrides(input)) + .rejects.toMatchObject({ code: 'ARGUMENT' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('preserves a raw storage endpoint error envelope', async () => { const client = new HostedClient({ apiBaseUrl: 'https://api.example.com', diff --git a/src/hosted/client.ts b/src/hosted/client.ts index 1c77b2f7..5b8317dd 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -1,4 +1,6 @@ -import { attachTraceReceipt, CliError, EXIT_CODES, type ExitCode } from '../errors.js'; +import type { ConventionAuditReport, ConventionRuleId, ConventionViolation } from '../convention-audit.js'; +import { ArgumentError, attachTraceReceipt, CliError, EXIT_CODES, type ExitCode } from '../errors.js'; +import type { ValidationReport } from '../validate.js'; import { parseExecutionArtifactDownloadUrl } from './artifact-url.js'; import { HOSTED_CORE_COMMANDS_CAPABILITY, isHostedCoreCommandId } from './core-commands.js'; import { log } from '../logger.js'; @@ -30,8 +32,18 @@ import type { HostedMarketplaceSearchResult, HostedSiteMemoryArtifact, HostedAdapterOverrideResponse, + HostedAdapterResetRemoval, HostedAdapterSourceWriteResponse, + HostedAdapterStatusRow, + HostedDoctorCheckId, + HostedDoctorReport, + HostedMarketplaceCatalogResponse, + HostedMarketplaceCatalogSource, + HostedProfileCreateResponse, + HostedProfileRenameResponse, HostedTraceReceipt, + HostedVerifyReport, + HostedVerifySmokeResult, } from './types.js'; export interface HostedClientOptions { @@ -145,6 +157,101 @@ export class HostedClient { return body; } + async validateAdapters(target?: string): Promise { + const params = new URLSearchParams(); + if (target !== undefined) params.set('target', target); + const body = await this.request(`/v1/adapters/validate${params.size ? `?${params}` : ''}`); + if (!hasExactKeys(body, ['ok', 'report']) || body.ok !== true || !isValidationReport(body.report)) { + throw protocolError('Webcmd Cloud returned an invalid adapter validation response.'); + } + return body.report; + } + + async verifyAdapters(input: { target?: string; smoke?: boolean; profile?: string }): Promise { + const body = await this.request('/v1/adapters/verify', { + method: 'POST', + body: JSON.stringify(input), + }); + if (!hasExactKeys(body, ['ok', 'report']) || body.ok !== true || !isHostedVerifyReport(body.report)) { + throw protocolError('Webcmd Cloud returned an invalid adapter verification response.'); + } + return body.report; + } + + async auditAdapterConventions(input: { target?: string; site?: string }): Promise { + const params = new URLSearchParams(); + if (input.target !== undefined) params.set('target', input.target); + if (input.site !== undefined) params.set('site', input.site); + const body = await this.request(`/v1/adapters/convention-audit${params.size ? `?${params}` : ''}`); + if (!hasExactKeys(body, ['ok', 'report']) || body.ok !== true || !isConventionAuditReport(body.report)) { + throw protocolError('Webcmd Cloud returned an invalid adapter convention audit response.'); + } + return body.report; + } + + async getDoctor(profile?: string): Promise { + const params = new URLSearchParams(); + if (profile !== undefined) params.set('profile', profile); + const body = await this.request(`/v1/doctor${params.size ? `?${params}` : ''}`); + if (!hasExactKeys(body, ['ok', 'report']) || body.ok !== true || !isHostedDoctorReport(body.report)) { + throw protocolError('Webcmd Cloud returned an invalid doctor response.'); + } + return body.report; + } + + async listAdapters(): Promise { + const body = await this.request('/v1/adapters/status'); + if (!hasExactKeys(body, ['ok', 'adapters']) || body.ok !== true + || !Array.isArray(body.adapters) || !body.adapters.every(isHostedAdapterStatusRow)) { + throw protocolError('Webcmd Cloud returned an invalid adapter status response.'); + } + return body.adapters; + } + + async resetAdapterOverrides(input: { site?: string; all?: boolean }): Promise { + const site = input.site?.trim(); + if ((!site && input.all !== true) || (site !== undefined && input.all === true)) { + throw new ArgumentError('Specify one adapter site or --all.'); + } + const params = new URLSearchParams(site ? { site } : { all: 'true' }); + const body = await this.request(`/v1/adapters/overrides?${params}`, { method: 'DELETE' }); + if (!hasExactKeys(body, ['ok', 'removed']) || body.ok !== true + || !Array.isArray(body.removed) || !body.removed.every(isHostedAdapterResetRemoval)) { + throw protocolError('Webcmd Cloud returned an invalid adapter reset response.'); + } + return body.removed; + } + + async createProfile(name: string): Promise { + const body = await this.request('/v1/profiles', { + method: 'POST', + body: JSON.stringify({ name }), + }, undefined, 201); + if (!isHostedProfileCreateResponse(body)) { + throw protocolError('Webcmd Cloud returned an invalid profile creation response.'); + } + return body; + } + + async renameProfile(profileId: string, name: string): Promise { + const body = await this.request(`/v1/profiles/${encodeURIComponent(profileId)}`, { + method: 'PATCH', + body: JSON.stringify({ name }), + }); + if (!isHostedProfileRenameResponse(body)) { + throw protocolError('Webcmd Cloud returned an invalid profile rename response.'); + } + return body; + } + + async listMarketplaceCatalog(): Promise { + const body = await this.request('/v1/marketplace/catalog'); + if (!isHostedMarketplaceCatalogResponse(body)) { + throw protocolError('Webcmd Cloud returned an invalid marketplace catalog response.'); + } + return body; + } + async deleteProfile(profileId: string): Promise<{ ok: true; deleted: true }> { const body = await this.request(`/v1/profiles/${encodeURIComponent(profileId)}`, { method: 'DELETE' }); if (!hasExactKeys(body, ['ok', 'deleted']) || body.ok !== true || body.deleted !== true) { @@ -461,6 +568,7 @@ export class HostedClient { path: string, init: RequestInit = {}, executionExpectation?: ExecutionExpectation, + expectedStatus?: number, ): Promise { const response = await this.authenticatedFetch(path, init); const text = await response.text(); @@ -476,6 +584,9 @@ export class HostedClient { throw hostedFailure(body, response.status === 401 ? EXIT_CODES.NOPERM : EXIT_CODES.GENERIC_ERROR); } if (!response.ok) throw protocolError('Webcmd Cloud returned a success envelope with an HTTP error status.'); + if (expectedStatus !== undefined && response.status !== expectedStatus) { + throw protocolError(`Webcmd Cloud returned HTTP ${response.status}; expected ${expectedStatus}.`); + } return body; } @@ -704,6 +815,168 @@ function isHostedProfilesResponse(value: unknown): value is HostedProfilesRespon && value.profiles.every(isHostedPublicProfile); } +function isValidationReport(value: unknown): value is ValidationReport { + return hasExactKeys(value, ['ok', 'results', 'errors', 'warnings', 'commands']) + && typeof value.ok === 'boolean' + && Array.isArray(value.results) + && value.results.every(result => hasExactKeys(result, ['label', 'errors', 'warnings']) + && typeof result.label === 'string' + && Array.isArray(result.errors) + && result.errors.every(error => typeof error === 'string') + && Array.isArray(result.warnings) + && result.warnings.every(warning => typeof warning === 'string')) + && isNonNegativeInteger(value.errors) + && isNonNegativeInteger(value.warnings) + && isNonNegativeInteger(value.commands); +} + +const CONVENTION_RULE_IDS = new Set([ + 'silent-column-drop', + 'camelCase-in-columns', + 'missing-access-metadata', + 'silent-clamp', + 'silent-empty-fallback', + 'silent-sentinel', + 'write-without-delete-pair', +]); + +function isConventionAuditReport(value: unknown): value is ConventionAuditReport { + return hasExactKeys(value, ['ok', 'summary', 'categories']) + && typeof value.ok === 'boolean' + && hasExactKeys(value.summary, ['commands', 'sites', 'files_scanned', 'violations']) + && isNonNegativeInteger(value.summary.commands) + && isNonNegativeInteger(value.summary.sites) + && isNonNegativeInteger(value.summary.files_scanned) + && isNonNegativeInteger(value.summary.violations) + && Array.isArray(value.categories) + && value.categories.every(category => hasExactKeys(category, ['rule', 'count', 'violations']) + && isConventionRuleId(category.rule) + && isNonNegativeInteger(category.count) + && Array.isArray(category.violations) + && category.violations.every(isConventionViolation)); +} + +function isConventionViolation(value: unknown): value is ConventionViolation { + if (!isRecord(value)) return false; + const expected = [ + 'rule', 'site', 'name', 'command', 'message', + ...(value.file === undefined ? [] : ['file']), + ...(value.line === undefined ? [] : ['line']), + ...(value.details === undefined ? [] : ['details']), + ]; + return hasExactKeys(value, expected) + && isConventionRuleId(value.rule) + && typeof value.site === 'string' + && typeof value.name === 'string' + && typeof value.command === 'string' + && typeof value.message === 'string' + && (value.file === undefined || typeof value.file === 'string') + && (value.line === undefined || isNonNegativeInteger(value.line)) + && (value.details === undefined || isRecord(value.details)); +} + +function isConventionRuleId(value: unknown): value is ConventionRuleId { + return typeof value === 'string' && CONVENTION_RULE_IDS.has(value as ConventionRuleId); +} + +function isHostedVerifyReport(value: unknown): value is HostedVerifyReport { + return hasExactKeys(value, ['ok', 'validation', 'smoke']) + && typeof value.ok === 'boolean' + && isValidationReport(value.validation) + && (value.smoke === null || isHostedVerifySmoke(value.smoke)); +} + +function isHostedVerifySmoke(value: unknown): value is Exclude { + return hasExactKeys(value, ['requested', 'executed', 'ok', 'summary', 'results']) + && value.requested === true + && typeof value.executed === 'boolean' + && typeof value.ok === 'boolean' + && typeof value.summary === 'string' + && Array.isArray(value.results) + && value.results.every(isHostedVerifySmokeResult); +} + +function isHostedVerifySmokeResult(value: unknown): value is HostedVerifySmokeResult { + return hasExactKeys(value, ['command', 'status', 'message']) + && typeof value.command === 'string' + && (value.status === 'passed' || value.status === 'failed' || value.status === 'skipped') + && typeof value.message === 'string'; +} + +const HOSTED_DOCTOR_CHECK_IDS = new Set([ + 'api', + 'workspace', + 'compatibility', + 'profile', + 'browser-provider', + 'capacity', +]); + +function isHostedDoctorReport(value: unknown): value is HostedDoctorReport { + return hasExactKeys(value, ['ok', 'checks']) + && typeof value.ok === 'boolean' + && Array.isArray(value.checks) + && value.checks.every(check => hasExactKeys(check, ['id', 'ok', 'required', 'message']) + && typeof check.id === 'string' + && HOSTED_DOCTOR_CHECK_IDS.has(check.id as HostedDoctorCheckId) + && typeof check.ok === 'boolean' + && typeof check.required === 'boolean' + && typeof check.message === 'string'); +} + +function isHostedAdapterStatusRow(value: unknown): value is HostedAdapterStatusRow { + return hasExactKeys(value, ['command', 'kind', 'package', 'reconciliationState', 'loadError']) + && typeof value.command === 'string' + && (value.kind === 'override' || value.kind === 'user') + && typeof value.package === 'string' + && (value.reconciliationState === 'current' + || value.reconciliationState === 'changed' + || value.reconciliationState === 'unknown') + && (value.loadError === null || typeof value.loadError === 'string'); +} + +function isHostedAdapterResetRemoval(value: unknown): value is HostedAdapterResetRemoval { + return hasExactKeys(value, ['packageId', 'package', 'commands']) + && typeof value.packageId === 'string' + && typeof value.package === 'string' + && Array.isArray(value.commands) + && value.commands.every(command => typeof command === 'string'); +} + +function isHostedProfileCreateResponse(value: unknown): value is HostedProfileCreateResponse { + return hasExactKeys(value, ['ok', 'profile', 'created']) + && value.ok === true + && isHostedPublicProfile(value.profile) + && typeof value.created === 'boolean'; +} + +function isHostedProfileRenameResponse(value: unknown): value is HostedProfileRenameResponse { + return hasExactKeys(value, ['ok', 'profile', 'changed']) + && value.ok === true + && isHostedPublicProfile(value.profile) + && typeof value.changed === 'boolean'; +} + +function isHostedMarketplaceCatalogResponse(value: unknown): value is HostedMarketplaceCatalogResponse { + return hasExactKeys(value, ['ok', 'sources']) + && value.ok === true + && Array.isArray(value.sources) + && value.sources.every(isHostedMarketplaceCatalogSource); +} + +function isHostedMarketplaceCatalogSource(value: unknown): value is HostedMarketplaceCatalogSource { + return hasOnlyKeys(value, ['id', 'repository', 'commit', 'manifestPath', 'status']) + && typeof value.id === 'string' + && typeof value.repository === 'string' + && (value.commit === undefined || typeof value.commit === 'string') + && typeof value.manifestPath === 'string' + && (value.status === 'empty' || value.status === 'consistent' || value.status === 'mixed'); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0; +} + function isHostedBrowserSessionResponse(value: unknown): value is HostedBrowserSessionResponse { return hasExactKeys(value, ['ok', 'session']) && value.ok === true diff --git a/src/hosted/core-commands.ts b/src/hosted/core-commands.ts index 2a8e8bf9..6448b76d 100644 --- a/src/hosted/core-commands.ts +++ b/src/hosted/core-commands.ts @@ -24,3 +24,16 @@ export function hasHostedCoreCommand( ): boolean { return ids?.includes(id) ?? false; } + +export type { + HostedAdapterResetRemoval, + HostedAdapterStatusRow, + HostedDoctorCheckId, + HostedDoctorReport, + HostedMarketplaceCatalogResponse, + HostedMarketplaceCatalogSource, + HostedProfileCreateResponse, + HostedProfileRenameResponse, + HostedVerifyReport, + HostedVerifySmokeResult, +} from './types.js'; diff --git a/src/hosted/types.ts b/src/hosted/types.ts index 5cde664c..3cd9e7e7 100644 --- a/src/hosted/types.ts +++ b/src/hosted/types.ts @@ -1,5 +1,6 @@ import type { CommandSurfaceMetadata } from '../command-surface.js'; import type { Arg } from '../registry.js'; +import type { ValidationReport } from '../validate.js'; import type { FileArgumentDirection } from './contract.js'; import type { HostedCoreCommandId } from './core-commands.js'; @@ -72,6 +73,81 @@ export interface HostedProfilesResponse { profiles: HostedPublicProfile[]; } +export interface HostedVerifySmokeResult { + command: string; + status: 'passed' | 'failed' | 'skipped'; + message: string; +} + +export interface HostedVerifyReport { + ok: boolean; + validation: ValidationReport; + smoke: null | { + requested: true; + executed: boolean; + ok: boolean; + summary: string; + results: HostedVerifySmokeResult[]; + }; +} + +export type HostedDoctorCheckId = + | 'api' + | 'workspace' + | 'compatibility' + | 'profile' + | 'browser-provider' + | 'capacity'; + +export interface HostedDoctorReport { + ok: boolean; + checks: Array<{ + id: HostedDoctorCheckId; + ok: boolean; + required: boolean; + message: string; + }>; +} + +export interface HostedAdapterStatusRow { + command: string; + kind: 'override' | 'user'; + package: string; + reconciliationState: 'current' | 'changed' | 'unknown'; + loadError: string | null; +} + +export interface HostedAdapterResetRemoval { + packageId: string; + package: string; + commands: string[]; +} + +export interface HostedProfileCreateResponse { + ok: true; + profile: HostedPublicProfile; + created: boolean; +} + +export interface HostedProfileRenameResponse { + ok: true; + profile: HostedPublicProfile; + changed: boolean; +} + +export interface HostedMarketplaceCatalogSource { + id: string; + repository: string; + commit?: string; + manifestPath: string; + status: 'empty' | 'consistent' | 'mixed'; +} + +export interface HostedMarketplaceCatalogResponse { + ok: true; + sources: HostedMarketplaceCatalogSource[]; +} + export interface HostedBrowserSession { id: string; kind: 'explicit' | 'adapter-default'; From 9c29db720306d9f78fc02eaa27abd3f1f265f4f0 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 23:19:55 +0530 Subject: [PATCH 06/18] feat: dispatch hosted core commands --- src/hosted/core-command-surface.test.ts | 42 ++++ src/hosted/core-command-surface.ts | 88 ++++++++ src/hosted/runner.test.ts | 228 ++++++++++++++++++-- src/hosted/runner.ts | 274 +++++++++++++++++++----- 4 files changed, 556 insertions(+), 76 deletions(-) create mode 100644 src/hosted/core-command-surface.test.ts create mode 100644 src/hosted/core-command-surface.ts diff --git a/src/hosted/core-command-surface.test.ts b/src/hosted/core-command-surface.test.ts new file mode 100644 index 00000000..be4d3666 --- /dev/null +++ b/src/hosted/core-command-surface.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { CommanderStructuralError } from '../command-surface.js'; +import { parseHostedCoreCommand } from './core-command-surface.js'; + +describe('hosted core command surface', () => { + it.each([ + { + argv: ['validate', 'github', '-f', 'json'], + expected: { command: 'validate', target: 'github', format: 'json', formatExplicit: true }, + }, + { + argv: ['verify', 'github/list', '--smoke'], + expected: { command: 'verify', target: 'github/list', smoke: true, format: 'table', formatExplicit: false }, + }, + { + argv: ['convention-audit', '--site', 'github', '--strict'], + expected: { command: 'convention-audit', site: 'github', strict: true, format: 'table', formatExplicit: false }, + }, + { + argv: ['doctor', '--verbose', '-f', 'yaml'], + expected: { command: 'doctor', verbose: true, format: 'yaml', formatExplicit: true }, + }, + ])('parses $argv', ({ argv, expected }) => { + expect(parseHostedCoreCommand(argv, false)).toEqual(expected); + }); + + it.each([ + ['validate', '--unknown'], + ['verify', '--smoke=false'], + ['convention-audit', 'a', 'b'], + ['doctor', '--profile', 'work'], + ])('rejects invalid grammar before dispatch: %j', (...argv) => { + expect(() => parseHostedCoreCommand(argv, false)).toThrow(CommanderStructuralError); + try { + parseHostedCoreCommand(argv, false); + } catch (error) { + expect(error).toMatchObject({ exitCode: 2 }); + expect((error as CommanderStructuralError).output).toContain('error:'); + expect((error as CommanderStructuralError).output).toContain('help:'); + } + }); +}); diff --git a/src/hosted/core-command-surface.ts b/src/hosted/core-command-surface.ts new file mode 100644 index 00000000..c9239dbc --- /dev/null +++ b/src/hosted/core-command-surface.ts @@ -0,0 +1,88 @@ +import { Command, CommanderError } from 'commander'; +import { + addOutputFormatOption, + CommanderStructuralError, + outputFormatIsExplicit, + parseOutputFormat, + requestedOutputFormat, + resolveCommandFromArgv, + structuralErrorFromCommander, +} from '../command-surface.js'; +import { CliError, EXIT_CODES } from '../errors.js'; + +export type ParsedHostedCoreCommand = + | { command: 'validate'; target?: string; format: string; formatExplicit: boolean } + | { command: 'verify'; target?: string; smoke: boolean; format: string; formatExplicit: boolean } + | { command: 'convention-audit'; target?: string; site?: string; strict: boolean; format: string; formatExplicit: boolean } + | { command: 'doctor'; verbose: boolean; format: string; formatExplicit: boolean }; + +/** Normalize a hosted built-in's format, preserving Commander-style usage errors. */ +export function validateHostedFormat(raw: string): string { + try { + return parseOutputFormat(raw); + } catch (error) { + if (error instanceof CliError) { + throw new CommanderStructuralError(`error: ${error.message}\n`, EXIT_CODES.USAGE_ERROR); + } + throw error; + } +} + +export function parseHostedCoreCommand(argv: readonly string[], literal: boolean): ParsedHostedCoreCommand { + let parsed: ParsedHostedCoreCommand | undefined; + let stdout = ''; + let stderr = ''; + const root = new Command('webcmd'); + const output = { + writeOut: (value: string) => { stdout += value; }, + writeErr: (value: string) => { stderr += value; }, + }; + root.exitOverride().configureOutput(output); + + const format = (surface: Command, raw: string): { format: string; formatExplicit: boolean } => ({ + format: validateHostedFormat(String(requestedOutputFormat(surface, raw))), + formatExplicit: outputFormatIsExplicit(surface), + }); + const configure = (surface: Command): Command => surface.exitOverride().configureOutput(output); + + const validate = configure(addOutputFormatOption(root.command('validate').argument('[target]'))); + validate.action((target: string | undefined, options: { format: string }) => { + parsed = { command: 'validate', ...(target !== undefined ? { target } : {}), ...format(validate, options.format) }; + }); + + const verify = configure(addOutputFormatOption(root.command('verify').argument('[target]').option('--smoke', 'Run smoke tests', false))); + verify.action((target: string | undefined, options: { format: string; smoke?: boolean }) => { + parsed = { command: 'verify', ...(target !== undefined ? { target } : {}), smoke: options.smoke === true, ...format(verify, options.format) }; + }); + + const audit = configure(addOutputFormatOption(root.command('convention-audit') + .argument('[target]') + .option('--site ', 'Limit audit to one site') + .option('--strict', 'Exit non-zero when violations are found', false))); + audit.action((target: string | undefined, options: { format: string; site?: string; strict?: boolean }) => { + parsed = { + command: 'convention-audit', + ...(target !== undefined ? { target } : {}), + ...(options.site !== undefined ? { site: options.site } : {}), + strict: options.strict === true, + ...format(audit, options.format), + }; + }); + + const doctor = configure(addOutputFormatOption(root.command('doctor').option('-v, --verbose', 'Show detailed diagnostic output', false))); + doctor.action((options: { format: string; verbose?: boolean }) => { + parsed = { command: 'doctor', verbose: options.verbose === true, ...format(doctor, options.format) }; + }); + + try { + root.parse(literal ? ['--', ...argv] : [...argv], { from: 'user' }); + } catch (error) { + if (!(error instanceof CommanderError)) throw error; + if (error.code === 'commander.helpDisplayed') { + throw new CommanderStructuralError(stdout, EXIT_CODES.SUCCESS); + } + throw structuralErrorFromCommander(error, resolveCommandFromArgv(root, argv), stderr); + } + if (!parsed) throw new CommanderStructuralError(`error: command '${argv[0] ?? ''}' did not run\n`, EXIT_CODES.GENERIC_ERROR); + return parsed; +} diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 32db9f90..4fca1d4e 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -275,6 +275,210 @@ function captureLocalBrowserStructure(argv: string[]): { } describe('runHostedCli', () => { + const hostedValidationReport = { + ok: true, + results: [{ label: 'github/list', errors: [], warnings: [] }], + errors: 0, + warnings: 0, + commands: 1, + }; + const hostedConventionReport = { + ok: true, + summary: { commands: 1, sites: 1, files_scanned: 1, violations: 0 }, + categories: [], + }; + const hostedProfile = { + id: 'profile_1', + name: 'work', + workspace: 'workspace_1', + default: false, + status: 'available', + createdAt: '2026-08-27T00:00:00.000Z', + updatedAt: '2026-08-27T00:00:00.000Z', + lastUsedAt: '2026-08-27T00:00:00.000Z', + }; + + it.each([ + { + name: 'validate', + id: 'validate', + argv: ['validate', 'github', '-f', 'json'], + request: { path: '/v1/adapters/validate?target=github', method: 'GET' }, + response: { ok: true, report: hostedValidationReport }, + structured: hostedValidationReport, + }, + { + name: 'verify', + id: 'verify', + argv: ['verify', 'github/list', '--smoke'], + request: { path: '/v1/adapters/verify', method: 'POST', body: '{"target":"github/list","smoke":true}' }, + response: { ok: true, report: { ok: true, validation: hostedValidationReport, smoke: { requested: true, executed: true, ok: true, summary: '1 passed', results: [{ command: 'github/list', status: 'passed', message: 'Passed.' }] } } }, + contains: 'webcmd validate: PASS', + }, + { + name: 'convention audit', + id: 'convention-audit', + argv: ['convention-audit', '--site', 'github'], + request: { path: '/v1/adapters/convention-audit?site=github', method: 'GET' }, + response: { ok: true, report: hostedConventionReport }, + contains: 'Convention Audit Report', + }, + { + name: 'doctor', + id: 'doctor', + argv: ['doctor'], + request: { path: '/v1/doctor', method: 'GET' }, + response: { ok: true, report: { ok: true, checks: [{ id: 'api', ok: true, required: true, message: 'Authenticated.' }] } }, + contains: 'PASS api Authenticated.', + }, + { + name: 'adapter status', + id: 'adapter/status', + argv: ['adapter', 'status'], + request: { path: '/v1/adapters/status', method: 'GET' }, + response: { ok: true, adapters: [{ command: 'github/list', kind: 'override', package: '@user/github', reconciliationState: 'current', loadError: null }] }, + contains: 'github/list', + }, + { + name: 'adapter reset', + id: 'adapter/reset', + argv: ['adapter', 'reset', 'github'], + request: { path: '/v1/adapters/overrides?site=github', method: 'DELETE' }, + response: { ok: true, removed: [{ packageId: 'package_1', package: '@user/github', commands: ['github/list', 'github/get'] }] }, + contains: '@user/github: github/list, github/get', + }, + { + name: 'profile create', + id: 'profile/create', + argv: ['profile', 'create', 'work'], + request: { path: '/v1/profiles', method: 'POST', body: '{"name":"work"}' }, + response: { ok: true, profile: hostedProfile, created: true }, + responseStatus: 201, + structured: { ok: true, profile: hostedProfile, created: true }, + }, + { + name: 'plugin catalog list', + id: 'plugin/catalog/list', + argv: ['plugin', 'catalog', 'list', '-f', 'json'], + request: { path: '/v1/marketplace/catalog', method: 'GET' }, + response: { ok: true, sources: [{ id: 'official', repository: 'https://github.com/agentrhq/webcmd', commit: 'abc123', manifestPath: 'marketplace.json', status: 'consistent' }] }, + structured: { ok: true, sources: [{ id: 'official', repository: 'https://github.com/agentrhq/webcmd', commit: 'abc123', manifestPath: 'marketplace.json', status: 'consistent' }] }, + }, + ])('gates and dispatches hosted $name', async ({ id, argv, request, response, responseStatus = 200, structured, contains }) => { + const requests: Array<{ path: string; method: string; body?: string }> = []; + const stdout = sink(); + const stderr = sink(); + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl: async (url, init) => { + const path = new URL(String(url)).pathname + new URL(String(url)).search; + requests.push({ path, method: init?.method ?? 'GET', ...(init?.body !== undefined ? { body: String(init.body) } : {}) }); + if (path === '/v1/manifest') { + return new Response(JSON.stringify({ ok: true, manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands: [id] } } })); + } + return new Response(JSON.stringify(response), { status: responseStatus }); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(stderr.text()).toBe(''); + expect(requests).toEqual([{ path: '/v1/manifest', method: 'GET' }, request]); + if (structured) expect(yaml.load(stdout.text())).toEqual(structured); + if (contains) expect(stdout.text()).toContain(contains); + }); + + it('resolves a hosted profile name before rename', async () => { + const requests: Array<{ path: string; method: string; body?: string }> = []; + const stdout = sink(); + const result = await runHostedCli(['profile', 'rename', 'work', 'personal'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: sink().stream, + fetchImpl: async (url, init) => { + const path = new URL(String(url)).pathname + new URL(String(url)).search; + requests.push({ path, method: init?.method ?? 'GET', ...(init?.body !== undefined ? { body: String(init.body) } : {}) }); + if (path === '/v1/manifest') return new Response(JSON.stringify({ ok: true, manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands: ['profile/rename'] } } })); + if (path === '/v1/profiles') return new Response(JSON.stringify({ ok: true, profiles: [hostedProfile] })); + return new Response(JSON.stringify({ ok: true, profile: { ...hostedProfile, name: 'personal' }, changed: true })); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(requests).toEqual([ + { path: '/v1/manifest', method: 'GET' }, + { path: '/v1/profiles', method: 'GET' }, + { path: '/v1/profiles/profile_1', method: 'PATCH', body: '{"name":"personal"}' }, + ]); + expect(yaml.load(stdout.text())).toEqual({ ok: true, profile: { ...hostedProfile, name: 'personal' }, changed: true }); + }); + + it('does not call an unavailable hosted core endpoint', async () => { + const requests: string[] = []; + const stderr = sink(); + const result = await runHostedCli(['validate'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl: async (url) => { + requests.push(new URL(String(url)).pathname); + return new Response(JSON.stringify({ ok: true, manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands: [] } } })); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(requests).toEqual(['/v1/manifest']); + expect(stderr.text()).toContain('Upgrade Webcmd Cloud or use a compatible endpoint.'); + }); + + it('preserves a failed doctor report on stdout and exits CONFIG_ERROR', async () => { + const report = { ok: false, checks: [{ id: 'capacity', ok: false, required: true, message: 'No capacity.' }] }; + const stdout = sink(); + const result = await runHostedCli(['doctor', '-f', 'json'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: sink().stream, + fetchImpl: async (url) => String(url).endsWith('/v1/manifest') + ? new Response(JSON.stringify({ ok: true, manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands: ['doctor'] } } })) + : new Response(JSON.stringify({ ok: true, report })), + }); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(JSON.parse(stdout.text())).toEqual(report); + }); + + it('maps a failing strict convention audit to exit 1', async () => { + const report = { ok: false, summary: { commands: 1, sites: 1, files_scanned: 1, violations: 1 }, categories: [] }; + const result = await runHostedCli(['convention-audit', '--strict', '-f', 'json'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: sink().stream, + stderr: sink().stream, + fetchImpl: async (url) => String(url).endsWith('/v1/manifest') + ? new Response(JSON.stringify({ ok: true, manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands: ['convention-audit'] } } })) + : new Response(JSON.stringify({ ok: true, report })), + }); + + expect(result).toEqual({ handled: true, exitCode: 1 }); + }); + + it.each([ + ['daemon', 'status'], + ['daemon', 'stop'], + ['daemon', 'restart'], + ['plugin', 'catalog', 'add', 'github:owner/repo'], + ['plugin', 'catalog', 'remove', 'official'], + ['plugin', 'install', 'github:owner/repo', '--all'], + ])('keeps excluded hosted mutations local-only: %j', async (...argv) => { + const fetchImpl = vi.fn(); + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: sink().stream, + fetchImpl, + }); + + expect(result.exitCode).toBe(78); + expect(fetchImpl).not.toHaveBeenCalled(); + }); it('registers the hosted site-memory and adapter-source grammar', async () => { const grammar = [ ['site', 'memory', 'show'], ['site', 'memory', 'list'], ['site', 'note', 'add'], @@ -1183,10 +1387,10 @@ describe('runHostedCli', () => { }); }); - it.each(['create', 'get'])('rejects the removed profile %s subcommand', async (command) => { + it('rejects the removed profile get subcommand', async () => { const stderr = sink(); const fetchImpl = vi.fn(); - const result = await runHostedCli(['profile', command, 'Work'], { + const result = await runHostedCli(['profile', 'get', 'Work'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stderr: stderr.stream, fetchImpl, @@ -1225,31 +1429,17 @@ describe('runHostedCli', () => { } }); - it.each(['rename', 'use'])('rejects local-only profile %s in hosted mode without an API call', async (command) => { - const stderr = sink(); - const fetchImpl = vi.fn(); - const result = await runHostedCli(['profile', command, 'value'], { - config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), - stderr: stderr.stream, - fetchImpl, - }); - - expect(result).toEqual({ handled: true, exitCode: 78 }); - expect(stderr.text()).toContain(`webcmd profile ${command} is not available in hosted mode.`); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it('rejects doctor in hosted mode without an API call', async () => { + it('rejects local-only profile use in hosted mode without an API call', async () => { const stderr = sink(); const fetchImpl = vi.fn(); - const result = await runHostedCli(['doctor'], { + const result = await runHostedCli(['profile', 'use', 'value'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stderr: stderr.stream, fetchImpl, }); expect(result).toEqual({ handled: true, exitCode: 78 }); - expect(stderr.text()).toContain('webcmd doctor is local-only. Hosted mode has no local browser bridge.'); + expect(stderr.text()).toContain('webcmd profile use is not available in hosted mode.'); expect(fetchImpl).not.toHaveBeenCalled(); }); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index bf47d057..874ccfec 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -13,7 +13,7 @@ import { configurePluginUpdateSurface, } from '../builtin-command-surface.js'; import { BrowserSessionArgvError, rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv } from '../cli-argv-preprocess.js'; -import { addOutputFormatOption, CommanderStructuralError, MissingRequiredPositionalError, outputFormatIsExplicit, parseOutputFormat, requestedOutputFormat, resolveCommandFromArgv, structuralErrorFromCommander } from '../command-surface.js'; +import { addOutputFormatOption, CommanderStructuralError, MissingRequiredPositionalError, outputFormatIsExplicit, requestedOutputFormat, resolveCommandFromArgv, structuralErrorFromCommander } from '../command-surface.js'; import { filterCommandsByTag, formatRootHelp, getCommandCompletionCandidates } from '../command-presentation.js'; import { getHostedBuiltinCommands, @@ -42,6 +42,8 @@ import type { ExternalCliConfig } from '../external.js'; import { webFetchCommand } from '../fetch/command.js'; import { runHostedArtifactDownload } from './artifact-download.js'; import { HostedClient, HostedClientError, resolveWorkspace } from './client.js'; +import { hasHostedCoreCommand, type HostedCoreCommandId } from './core-commands.js'; +import { parseHostedCoreCommand, validateHostedFormat, type ParsedHostedCoreCommand } from './core-command-surface.js'; import { createVirtualHostedFileIo, realHostedFileIo, type HostedFileIo } from './file-io.js'; import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; import { parseHostedInvocation } from './args.js'; @@ -148,18 +150,6 @@ class CommanderCompatibleError extends Error { } } -/** Normalize a hosted built-in's format, preserving Commander-style usage errors. */ -function validateHostedFormat(raw: string): string { - try { - return parseOutputFormat(raw); - } catch (err) { - if (err instanceof CliError) { - throw new CommanderStructuralError(`error: ${err.message}\n`, EXIT_CODES.USAGE_ERROR); - } - throw err; - } -} - const hostedBrowserCommandsByPath = new Map(browserCommandCatalog.map(command => [command.command, command])); export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = {}): Promise { @@ -185,7 +175,7 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { } const rootSurface = parseHostedRootCommandSurface(argv); const rootName = rootSurface.kind === 'dispatch' ? rootSurface.argv[0] : undefined; - if (rootName === 'validate' || (rootName && opts.installedLocalCommandRoots?.has(rootName))) { + if (rootName && opts.installedLocalCommandRoots?.has(rootName)) { throw new ConfigError(`${CLI_COMMAND} ${rootName} is local-only and is not available in hosted mode.`, LOCAL_ONLY_COMMAND_HELP); } const externals = rootName && isWebcmdOwnedRoot(rootName, opts.installedLocalCommandRoots) @@ -308,6 +298,12 @@ async function dispatchHosted( ): Promise { const rootHelp = getHostedRootHelp(hasLocalClientCommandHandlers); const normalized = parseHostedRootCommandSurface(argv); + let manifestPromise: Promise | undefined; + const getManifest = async (): Promise => { + const manifest = await (manifestPromise ??= client.getManifest()); + validateManifestContractIdentity(manifest); + return manifest; + }; if (normalized.kind === 'help') { const help = formatRootHelp(rootHelp); if (normalized.exitCode !== EXIT_CODES.SUCCESS) { @@ -348,11 +344,10 @@ async function dispatchHosted( LOCAL_ONLY_COMMAND_HELP, ); } - if (args[0] === 'doctor') { - throw new ConfigError( - 'webcmd doctor is local-only. Hosted mode has no local browser bridge.', - LOCAL_ONLY_COMMAND_HELP, - ); + if (isHostedCoreRoot(args[0])) { + const parsed = parseHostedCoreCommand(args, normalized.literal); + await requireHostedCoreCommand(getManifest, parsed.command); + return dispatchHostedCoreCommand(parsed, client, stdout, normalized.profile); } if (args[0] === 'session') { const parsed = parseHostedSessionSurface(args.slice(1), normalized.literal); @@ -360,15 +355,13 @@ async function dispatchHosted( await writeToStream(stdout, parsed.output); return; } - const manifest = await client.getManifest(); - validateManifestContractIdentity(manifest); + await getManifest(); await dispatchHostedSession(parsed, client, stdout, normalized.profile); return; } if (args[0] === 'browser') { const invocation = await parseHostedBrowserInvocation(args, normalized.profile, normalized.session, io); - const manifest = await client.getManifest(); - validateManifestContractIdentity(manifest); + await getManifest(); await dispatchHostedBrowser(invocation, client, stdout, io); return; } @@ -383,8 +376,8 @@ async function dispatchHosted( return; } - if (args[0] === 'adapter' && (args[1] === 'source' || args[1] === 'path' || args[1] === 'override' || args[1] === '--help' || args[1] === '-h')) { - await runHostedAdapterSourceSurface(args.slice(1), normalized.literal, client, stdout, homeDir, io); + if (args[0] === 'adapter' && (args[1] === 'source' || args[1] === 'path' || args[1] === 'override' || args[1] === 'status' || args[1] === 'reset' || args[1] === '--help' || args[1] === '-h')) { + await runHostedAdapterSurface(args.slice(1), normalized.literal, client, stdout, homeDir, io, getManifest); return; } @@ -400,10 +393,10 @@ async function dispatchHosted( } if (args[0] === 'profile') { - if (args[1] === 'rename' || args[1] === 'use' || args[1] === 'create') { + if (args[1] === 'use') { throw new ConfigError( `webcmd profile ${args[1]} is not available in hosted mode.`, - 'Hosted mode supports: webcmd profile list and delete.', + 'Hosted mode supports: webcmd profile list, create, rename, and delete.', ); } const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal); @@ -411,13 +404,22 @@ async function dispatchHosted( await writeToStream(stdout, parsed.output); return; } + if (parsed.command === 'create' || parsed.command === 'rename') { + await requireHostedCoreCommand(getManifest, `profile/${parsed.command}`); + } await dispatchHostedProfile(parsed, client, stdout); return; } if (args[0] === 'plugin') { const subcommand = args[1]; - const allowed = new Set(['search', 'install', 'list', 'uninstall', 'update', 'create', '--help', '-h']); + if (subcommand === 'catalog' && args[2] !== 'list') { + throw new ConfigError( + `webcmd plugin catalog${args[2] ? ` ${args[2]}` : ''} is not available in hosted mode.`, + 'Hosted mode supports: webcmd plugin catalog list.', + ); + } + const allowed = new Set(['search', 'install', 'list', 'uninstall', 'update', 'create', 'catalog', '--help', '-h']); if (!allowed.has(subcommand ?? '')) { throw new ConfigError( `webcmd plugin ${subcommand ?? ''}`.trimEnd() + ' is not available in hosted mode.', @@ -477,6 +479,19 @@ async function dispatchHosted( }); return; } + if (parsed.command === 'catalog-list') { + await requireHostedCoreCommand(getManifest, 'plugin/catalog/list'); + const result = await client.listMarketplaceCatalog(); + await renderOutput(parsed.format === 'table' ? result.sources : result, { + fmt: parsed.format, + fmtExplicit: parsed.formatExplicit, + columns: ['id', 'repository', 'commit', 'manifestPath', 'status'], + title: `${CLI_COMMAND}/plugin-catalog`, + source: `${CLI_COMMAND} plugin catalog list`, + stdout, + }); + return; + } if (parsed.command === 'uninstall') { await client.uninstallMarketplacePlugin(parsed.name); await writeToStream(stdout, `✅ Plugin "${parsed.name}" uninstalled.\n`); @@ -545,7 +560,7 @@ async function dispatchHosted( // The API manifest is tenant-scoped. Only the core client-owned presentation // entry is merged; package and local plugin commands stay out. - const manifest = await getPresentationManifest(client, enableServerWebFetch); + const manifest = withClientOwnedCommands(await getManifest(), enableServerWebFetch); const site = args[0]!; const commandName = args[1]; @@ -713,6 +728,79 @@ async function dispatchHosted( } } +function isHostedCoreRoot(value: string | undefined): value is ParsedHostedCoreCommand['command'] { + return value === 'validate' || value === 'verify' || value === 'convention-audit' || value === 'doctor'; +} + +async function requireHostedCoreCommand( + getManifest: () => Promise, + id: HostedCoreCommandId, +): Promise { + const manifest = await getManifest(); + if (!hasHostedCoreCommand(manifest.metadata.coreCommands, id)) { + throw new ConfigError( + `${CLI_COMMAND} ${id.replaceAll('/', ' ')} is not available from this Webcmd Cloud endpoint.`, + 'Upgrade Webcmd Cloud or use a compatible endpoint.', + ); + } + return manifest; +} + +async function dispatchHostedCoreCommand( + parsed: ParsedHostedCoreCommand, + client: HostedClient, + stdout: NodeJS.WritableStream, + profile?: string, +): Promise { + if (parsed.command === 'validate') { + const report = await client.validateAdapters(parsed.target); + if (parsed.format === 'table') { + const { renderValidationReport } = await import('../validate.js'); + await writeToStream(stdout, `${renderValidationReport(report)}\n`); + } else { + await renderOutput(report, { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, stdout }); + } + return report.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; + } + if (parsed.command === 'verify') { + const report = await client.verifyAdapters({ + ...(parsed.target !== undefined ? { target: parsed.target } : {}), + smoke: parsed.smoke, + ...(profile !== undefined ? { profile } : {}), + }); + if (parsed.format === 'table') { + const { renderVerifyReport } = await import('../verify.js'); + await writeToStream(stdout, `${renderVerifyReport(report)}\n`); + } else { + await renderOutput(report, { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, stdout }); + } + return report.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; + } + if (parsed.command === 'convention-audit') { + const report = await client.auditAdapterConventions({ + ...(parsed.target !== undefined ? { target: parsed.target } : {}), + ...(parsed.site !== undefined ? { site: parsed.site } : {}), + }); + if (parsed.format === 'table') { + const { renderConventionAuditText } = await import('../convention-audit.js'); + await writeToStream(stdout, `${renderConventionAuditText(report)}\n`); + } else { + await renderOutput(report, { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, stdout }); + } + return parsed.strict && !report.ok ? EXIT_CODES.GENERIC_ERROR : EXIT_CODES.SUCCESS; + } + enableVerbose(parsed.verbose); + const report = await client.getDoctor(profile); + if (parsed.format === 'table') { + await writeToStream(stdout, `${report.checks.map(check => `${check.ok ? 'PASS' : 'FAIL'} ${check.id} ${check.message}`).join('\n')}\n`); + } else { + await renderOutput(report, { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, stdout }); + } + return report.checks.some(check => check.required && !check.ok) + ? EXIT_CODES.CONFIG_ERROR + : EXIT_CODES.SUCCESS; +} + function virtualScaffoldConflicts(files: VirtualFileMap, target: string): boolean { for (const existing of files.keys()) { if (existing === target || existing.startsWith(`${target}/`) || target.startsWith(`${existing}/`)) { @@ -760,26 +848,29 @@ function hostedSiteMemoryBackend(client: HostedClient): SiteMemoryBackend { }; } -type HostedAdapterSourceCommand = +type HostedAdapterCommand = | { kind: 'get'; commandKey: string; output?: string } | { kind: 'put'; commandKey: string; path: string } | { kind: 'path'; commandKey: string } - | { kind: 'override'; commandKey: string }; + | { kind: 'override'; commandKey: string } + | { kind: 'status'; format: string; formatExplicit: boolean } + | { kind: 'reset'; site?: string; all: boolean; format: string; formatExplicit: boolean }; function joinAdapterCommandKey(commandKey: string, commandName?: string): string { const key = splitAdapterCommandKey(commandKey, commandName); return key ? `${key.site}/${key.command}` : commandKey; } -async function runHostedAdapterSourceSurface( +async function runHostedAdapterSurface( argv: readonly string[], literal: boolean, client: HostedClient, stdout: NodeJS.WritableStream, homeDir: string, io: HostedDispatchIo, + getManifest: () => Promise, ): Promise { - let parsed: HostedAdapterSourceCommand | undefined; + let parsed: HostedAdapterCommand | undefined; let help = ''; let stderr = ''; const root = new Command('webcmd').exitOverride().configureOutput({ @@ -803,6 +894,26 @@ async function runHostedAdapterSourceSurface( .description('Fork an installed adapter command into a private copy you can modify') .argument('', 'Command to override, as /') .action(commandKey => { parsed = { kind: 'override', commandKey }; }); + const status = addOutputFormatOption(adapter.command('status')); + status.action((options: { format: string }) => { + parsed = { + kind: 'status', + format: validateHostedFormat(String(requestedOutputFormat(status, options.format))), + formatExplicit: outputFormatIsExplicit(status), + }; + }); + const reset = addOutputFormatOption(adapter.command('reset').argument('[site]').option('--all', 'Reset all hosted overrides', false)); + reset.action((site: string | undefined, options: { all?: boolean; format: string }) => { + const all = options.all === true; + if ((!site && !all) || (site !== undefined && all)) throw new ArgumentError('Specify one adapter site or --all.'); + parsed = { + kind: 'reset', + ...(site !== undefined ? { site } : {}), + all, + format: validateHostedFormat(String(requestedOutputFormat(reset, options.format))), + formatExplicit: outputFormatIsExplicit(reset), + }; + }); try { await root.parseAsync(literal ? ['--', 'adapter', ...argv] : ['adapter', ...argv], { from: 'user' }); } catch (error) { @@ -814,6 +925,28 @@ async function runHostedAdapterSourceSurface( throw error; } if (!parsed) throw new CommanderStructuralError("error: command 'adapter' did not run\n", EXIT_CODES.USAGE_ERROR); + if (parsed.kind === 'status') { + await requireHostedCoreCommand(getManifest, 'adapter/status'); + await renderOutput(await client.listAdapters(), { + fmt: parsed.format, + fmtExplicit: parsed.formatExplicit, + columns: ['command', 'kind', 'package', 'reconciliationState', 'loadError'], + title: `${CLI_COMMAND}/adapter-status`, + source: `${CLI_COMMAND} adapter status`, + stdout, + }); + return; + } + if (parsed.kind === 'reset') { + await requireHostedCoreCommand(getManifest, 'adapter/reset'); + const removed = await client.resetAdapterOverrides(parsed.all ? { all: true } : { site: parsed.site }); + if (parsed.format !== 'table') { + await renderOutput({ ok: true, removed }, { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, stdout }); + return; + } + for (const item of removed) await writeToStream(stdout, `${item.package}: ${item.commands.join(', ')}\n`); + return; + } const { site, command } = parseAdapterCommandKey(parsed.commandKey); const destination = hostedAdapterDestination(homeDir, site, command); if (parsed.kind === 'path') return writeToStream(stdout, `${destination}\n`); @@ -1450,17 +1583,12 @@ function parseHostedListSurface(argv: readonly string[], literal: boolean): Pars return { kind: 'run', format: parsedFormat, formatExplicit, ...(parsedTag !== undefined ? { tag: parsedTag } : {}) }; } -type HostedProfileCommand = 'list' | 'delete'; - type ParsedHostedProfileSurface = | { kind: 'help'; output: string } - | { - kind: 'run'; - command: HostedProfileCommand; - format: string; - formatExplicit: boolean; - value?: string; - }; + | { kind: 'run'; command: 'list'; format: string; formatExplicit: boolean } + | { kind: 'run'; command: 'delete'; profile: string; format: string; formatExplicit: boolean } + | { kind: 'run'; command: 'create'; name: string } + | { kind: 'run'; command: 'rename'; profile: string; name: string }; function parseHostedProfileSurface( argv: readonly string[], @@ -1479,18 +1607,21 @@ function parseHostedProfileSurface( profile.exitOverride().configureOutput(output); const configureFormat = (command: Command): Command => addOutputFormatOption(command); - const setParsed = ( - command: HostedProfileCommand, - surface: Command, - value?: string, - ): void => { + const setParsed = (command: 'list' | 'delete', surface: Command, profileId?: string): void => { const options = surface.opts<{ format: string }>(); - parsed = { - kind: 'run', - command, + const outputFormat = { format: validateHostedFormat(String(requestedOutputFormat(surface, options.format))), formatExplicit: outputFormatIsExplicit(surface), - ...(value !== undefined ? { value } : {}), + }; + parsed = command === 'list' ? { + kind: 'run', + command: 'list', + ...outputFormat, + } : { + kind: 'run', + command: 'delete', + profile: profileId!, + ...outputFormat, }; }; @@ -1498,6 +1629,12 @@ function parseHostedProfileSurface( list.exitOverride().configureOutput(output).action(() => setParsed('list', list)); const remove = configureFormat(profile.command('delete').argument('')); remove.exitOverride().configureOutput(output).action((profileId: string) => setParsed('delete', remove, profileId)); + profile.command('create').argument('').exitOverride().configureOutput(output).action((name: string) => { + parsed = { kind: 'run', command: 'create', name }; + }); + profile.command('rename').argument('').argument('').exitOverride().configureOutput(output).action((profileValue: string, name: string) => { + parsed = { kind: 'run', command: 'rename', profile: profileValue, name }; + }); try { root.parse(literal ? ['--', 'profile', ...argv] : ['profile', ...argv], { from: 'user' }); @@ -1517,14 +1654,26 @@ async function dispatchHostedProfile( client: HostedClient, stdout: NodeJS.WritableStream, ): Promise { + if (parsed.command === 'create') { + await renderOutput(await client.createProfile(parsed.name), { fmt: 'yaml', stdout }); + return; + } + if (parsed.command === 'rename') { + const profiles = (await client.listProfiles()).profiles; + const profile = profiles.find(candidate => candidate.id === parsed.profile || candidate.name === parsed.profile); + if (!profile) { + throw new ConfigError( + `Hosted profile "${parsed.profile}" was not found.`, + `Available profiles: ${profiles.map(candidate => candidate.name ?? candidate.id).join(', ') || '(none)'}`, + ); + } + await renderOutput(await client.renameProfile(profile.id, parsed.name), { fmt: 'yaml', stdout }); + return; + } const result = parsed.command === 'list' ? (await client.listProfiles()).profiles - : await client.deleteProfile(parsed.value!); - await renderOutput(result, { - fmt: parsed.format, - fmtExplicit: parsed.formatExplicit, - stdout, - }); + : await client.deleteProfile(parsed.profile); + await renderOutput(result, { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, stdout }); } type ParsedHostedPluginSurface = @@ -1534,6 +1683,7 @@ type ParsedHostedPluginSurface = | { kind: 'run'; command: 'list'; format: string; formatExplicit: boolean } | { kind: 'run'; command: 'uninstall'; name: string } | { kind: 'run'; command: 'update'; name?: string; all: boolean } + | { kind: 'run'; command: 'catalog-list'; format: string; formatExplicit: boolean } | { kind: 'run'; command: 'create'; name: string; dir?: string; description?: string; authorName?: string; authorHandle?: string }; function parseHostedPluginSurface( @@ -1595,6 +1745,16 @@ function parseHostedPluginSurface( ...(options.authorHandle !== undefined ? { authorHandle: options.authorHandle } : {}), }; }); + const catalog = plugin.command('catalog'); + const catalogList = addOutputFormatOption(catalog.command('list')).exitOverride().configureOutput(output); + catalogList.action((options: { format: string }) => { + parsed = { + kind: 'run', + command: 'catalog-list', + format: validateHostedFormat(String(requestedOutputFormat(catalogList, options.format))), + formatExplicit: outputFormatIsExplicit(catalogList), + }; + }); try { root.parse(literal ? ['--', 'plugin', ...argv] : ['plugin', ...argv], { from: 'user' }); From 53b61f261fe6ff41f32ce226cecaf1590542d85d Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 23:29:06 +0530 Subject: [PATCH 07/18] feat: persist hosted profile preference --- src/hosted/config.test.ts | 43 +++++++++ src/hosted/config.ts | 43 +++++++++ src/hosted/credentials.ts | 3 + src/hosted/main-lifecycle.test.ts | 35 +++++++ src/hosted/runner.test.ts | 146 +++++++++++++++++++++++++----- src/hosted/runner.ts | 90 +++++++++++++++--- 6 files changed, 323 insertions(+), 37 deletions(-) diff --git a/src/hosted/config.test.ts b/src/hosted/config.test.ts index 8ad9b69b..e4bbfada 100644 --- a/src/hosted/config.test.ts +++ b/src/hosted/config.test.ts @@ -9,7 +9,9 @@ import { loadWebcmdConfig, makeHostedConfig, makeLocalConfig, + resolveHostedProfileSelection, saveWebcmdConfig, + withHostedPreferredProfile, } from './config.js'; import { resolveHostedApiKey } from './credentials.js'; @@ -69,6 +71,7 @@ describe('hosted config', () => { fetchedAt: '2026-07-08T00:01:00.000Z', manifest: { ok: true }, }, + preferredProfile: 'work', }, }, null, 2)); @@ -85,6 +88,46 @@ describe('hosted config', () => { expect(persisted).not.toContain('wcmd_legacy_secret'); expect(persisted).toContain('apiKeyRef'); expect(persisted).toContain('manifestCache'); + expect(persisted).toContain('preferredProfile'); + }); + + it('loads old hosted config without inventing a preferred profile', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'webcmd-config-profile-old-')); + const env = { WEBCMD_CONFIG_DIR: tempDir } as NodeJS.ProcessEnv; + await writeFile(getConfigPath({ env }), JSON.stringify({ + mode: 'hosted', + updatedAt: '2026-08-27T00:00:00.000Z', + hosted: { apiBaseUrl: 'https://api.example.com', apiKey: 'key' }, + })); + + const loaded = loadWebcmdConfig({ env }); + if (!isHostedConfig(loaded)) throw new Error('Expected hosted config'); + expect(loaded.hosted.preferredProfile).toBeUndefined(); + }); + + it('persists a preferred hosted display name without changing credentials', () => { + const hostedConfig = makeHostedConfig({ + apiBaseUrl: 'https://api.example.com', + apiKey: 'key', + now: new Date('2026-08-27T00:00:00.000Z'), + }); + + const next = withHostedPreferredProfile(hostedConfig, ' work ', new Date('2026-08-27T01:00:00.000Z')); + + expect(next).toMatchObject({ + updatedAt: '2026-08-27T01:00:00.000Z', + hosted: { apiBaseUrl: 'https://api.example.com', apiKey: 'key', preferredProfile: 'work' }, + }); + }); + + it('uses explicit, environment, preferred, then Cloud default precedence', () => { + const hostedConfig = makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }); + const saved = withHostedPreferredProfile(hostedConfig, 'saved'); + + expect(resolveHostedProfileSelection(saved, 'flag', { WEBCMD_PROFILE: 'env' })).toEqual({ name: 'flag', source: 'explicit' }); + expect(resolveHostedProfileSelection(saved, undefined, { WEBCMD_PROFILE: 'env' })).toEqual({ name: 'env', source: 'environment' }); + expect(resolveHostedProfileSelection(saved, undefined, {})).toEqual({ name: 'saved', source: 'preferred' }); + expect(resolveHostedProfileSelection(hostedConfig, undefined, {})).toBeUndefined(); }); it('writes local config and resolves default API URL from env', () => { diff --git a/src/hosted/config.ts b/src/hosted/config.ts index f9c1c823..34a5dfa1 100644 --- a/src/hosted/config.ts +++ b/src/hosted/config.ts @@ -23,9 +23,15 @@ export type WebcmdConfig = apiKeyRef?: string; credentialBackend?: HostedCredentialBackend; manifestCache?: HostedManifestCache; + preferredProfile?: string; }; }; +export interface HostedProfileSelection { + name: string; + source: 'explicit' | 'environment' | 'preferred'; +} + export interface ConfigIo { readFileSync?: typeof fs.readFileSync; writeFileSync?: typeof fs.writeFileSync; @@ -58,6 +64,7 @@ function parseConfig(raw: string): WebcmdConfig { && (typeof parsed.hosted?.apiKey === 'string' || typeof parsed.hosted?.apiKeyRef === 'string') ) { const credentialBackend = readCredentialBackend(parsed.hosted.credentialBackend); + const preferredProfile = normalizeProfileName(parsed.hosted.preferredProfile); return { mode: 'hosted', updatedAt: parsed.updatedAt, @@ -67,6 +74,7 @@ function parseConfig(raw: string): WebcmdConfig { ...(typeof parsed.hosted.apiKeyRef === 'string' ? { apiKeyRef: parsed.hosted.apiKeyRef } : {}), ...(credentialBackend ? { credentialBackend } : {}), ...(parsed.hosted.manifestCache ? { manifestCache: parsed.hosted.manifestCache } : {}), + ...(preferredProfile ? { preferredProfile } : {}), }, }; } @@ -112,8 +120,10 @@ export function makeHostedConfig(input: { apiKeyRef?: string; credentialBackend?: HostedCredentialBackend; manifestCache?: HostedManifestCache; + preferredProfile?: string; now?: Date; }): HostedWebcmdConfig { + const preferredProfile = normalizeProfileName(input.preferredProfile); return { mode: 'hosted', updatedAt: (input.now ?? new Date()).toISOString(), @@ -123,10 +133,38 @@ export function makeHostedConfig(input: { ...(input.apiKeyRef !== undefined ? { apiKeyRef: input.apiKeyRef } : {}), ...(input.credentialBackend !== undefined ? { credentialBackend: input.credentialBackend } : {}), ...(input.manifestCache ? { manifestCache: input.manifestCache } : {}), + ...(preferredProfile ? { preferredProfile } : {}), }, }; } +export function resolveHostedProfileSelection( + config: HostedWebcmdConfig, + explicit: string | undefined, + env: NodeJS.ProcessEnv, +): HostedProfileSelection | undefined { + const explicitName = normalizeProfileName(explicit); + if (explicitName) return { name: explicitName, source: 'explicit' }; + const environmentName = normalizeProfileName(env.WEBCMD_PROFILE); + if (environmentName) return { name: environmentName, source: 'environment' }; + const preferredName = normalizeProfileName(config.hosted.preferredProfile); + return preferredName ? { name: preferredName, source: 'preferred' } : undefined; +} + +export function withHostedPreferredProfile( + config: HostedWebcmdConfig, + name: string, + now: Date = new Date(), +): HostedWebcmdConfig { + const preferredProfile = normalizeProfileName(name); + if (!preferredProfile) throw new Error('Hosted profile name must not be empty.'); + return { + ...config, + updatedAt: now.toISOString(), + hosted: { ...config.hosted, preferredProfile }, + }; +} + export function normalizeApiBaseUrl(raw: string): string { const value = raw.trim().replace(/\/+$/, ''); return value || defaultHostedApiBaseUrl(); @@ -141,6 +179,10 @@ function normalizeConfiguredUrl(raw: string | undefined): string | undefined { return raw.trim().replace(/\/+$/, ''); } +function normalizeProfileName(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + export function isHostedConfig(config: WebcmdConfig): config is Extract { return config.mode === 'hosted'; } @@ -163,6 +205,7 @@ function persistableConfig(config: WebcmdConfig): WebcmdConfig { apiKeyRef: config.hosted.apiKeyRef, ...(config.hosted.credentialBackend ? { credentialBackend: config.hosted.credentialBackend } : {}), ...(config.hosted.manifestCache ? { manifestCache: config.hosted.manifestCache } : {}), + ...(config.hosted.preferredProfile ? { preferredProfile: config.hosted.preferredProfile } : {}), }, }; } diff --git a/src/hosted/credentials.ts b/src/hosted/credentials.ts index 731cb6d3..4cdee638 100644 --- a/src/hosted/credentials.ts +++ b/src/hosted/credentials.ts @@ -45,6 +45,7 @@ interface StoredHostedConfigInput { apiKeyRef: string; credentialBackend: HostedCredentialBackend; manifestCache?: HostedManifestCache; + preferredProfile?: string; now?: Date; } @@ -109,6 +110,7 @@ export async function resolveHostedApiKey( apiKeyRef: stored.apiKeyRef, credentialBackend: stored.credentialBackend, ...(config.hosted.manifestCache ? { manifestCache: config.hosted.manifestCache } : {}), + ...(config.hosted.preferredProfile ? { preferredProfile: config.hosted.preferredProfile } : {}), now: new Date(config.updatedAt), }), io); return { @@ -140,6 +142,7 @@ export function makeStoredHostedConfig(input: StoredHostedConfigInput): HostedWe apiKeyRef: input.apiKeyRef, credentialBackend: input.credentialBackend, ...(input.manifestCache ? { manifestCache: input.manifestCache } : {}), + ...(input.preferredProfile ? { preferredProfile: input.preferredProfile } : {}), }, }; } diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index c30fe290..b8336c5b 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -281,6 +281,25 @@ describe('hosted CLI process lifecycle', () => { await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); }, 20_000); + it('persists profile use through the installed hosted config path', async () => { + const fixture = await createHostedFixture('success'); + + const result = await runCli(['profile', 'use', 'work'], fixture.env); + const saved = JSON.parse(await readFile(path.join(fixture.root, 'config', 'config.json'), 'utf8')) as { + hosted: { apiKeyRef?: string; preferredProfile?: string }; + }; + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('profile: work'); + expect(fixture.requests).toEqual(['GET /v1/profiles']); + expect(saved.hosted).toMatchObject({ + apiKeyRef: 'wcmd_cred_lifecycle', + preferredProfile: 'work', + }); + await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }, 20_000); + it('writes the live view before the prepared browser run completes', async () => { const fixture = await createHostedFixture('browser'); const cli = startCli(['live', 'view', '-f', 'plain'], fixture.env); @@ -394,6 +413,22 @@ async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): }); return; } + if (request.url === '/v1/profiles' && request.method === 'GET') { + sendChunkedJson(response, { + ok: true, + profiles: [{ + id: 'profile_work', + name: 'work', + workspace: null, + default: false, + status: 'available', + createdAt: '2026-08-27T00:00:00.000Z', + updatedAt: '2026-08-27T00:00:00.000Z', + lastUsedAt: '2026-08-27T00:00:00.000Z', + }], + }); + return; + } if (request.url === '/v1/executions' && request.method === 'POST' && outcome === 'browser') { sendChunkedJson(response, { ok: true, diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 4fca1d4e..7a8c1298 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -13,7 +13,7 @@ import { createProgram } from '../cli.js'; import { formatRootHelp } from '../command-presentation.js'; import { HOSTED_ROOT_HELP } from '../completion-shared.js'; import { PKG_VERSION } from '../version.js'; -import { makeHostedConfig, makeLocalConfig } from './config.js'; +import { makeHostedConfig, makeLocalConfig, withHostedPreferredProfile } from './config.js'; import { createCaptureStream } from './capture-stream.js'; import { HostedClient } from './client.js'; import { runHostedCli } from './runner.js'; @@ -895,21 +895,57 @@ describe('runHostedCli', () => { expect(stderr.text()).toContain('Run `webcmd setup` and choose local mode to install this plugin.'); }); - it('renders a JSON error envelope on stderr when -f json is set', async () => { + it.each(['work', 'profile_1'])('uses hosted profile %s and saves its display name without a capability request', async (profile) => { const stdout = sink(); const stderr = sink(); - const result = await runHostedCli(['profile', 'use', 'work', '-f', 'json'], { + const requests: Array<{ path: string; method: string }> = []; + const saveConfig = vi.fn(); + const result = await runHostedCli(['profile', 'use', profile], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: stdout.stream, stderr: stderr.stream, + saveConfig, + fetchImpl: async (url, init) => { + requests.push({ path: new URL(String(url)).pathname, method: init?.method ?? 'GET' }); + return new Response(JSON.stringify({ ok: true, profiles: [hostedProfile] })); + }, }); - expect(result).toEqual({ handled: true, exitCode: 78 }); - expect(stdout.text()).toBe(''); - expect(JSON.parse(stderr.text())).toMatchObject({ - ok: false, - error: { code: 'CONFIG', message: 'webcmd profile use is not available in hosted mode.' }, + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(stderr.text()).toBe(''); + expect(yaml.load(stdout.text())).toEqual({ ok: true, action: 'use', profile: 'work' }); + expect(requests).toEqual([{ path: '/v1/profiles', method: 'GET' }]); + expect(saveConfig).toHaveBeenCalledOnce(); + expect(saveConfig).toHaveBeenCalledWith(expect.objectContaining({ + hosted: expect.objectContaining({ preferredProfile: 'work' }), + })); + }); + + it('rejects an unknown hosted profile with sorted display names and does not save', async () => { + const stdout = sink(); + const stderr = sink(); + const saveConfig = vi.fn(); + const result = await runHostedCli(['profile', 'use', 'missing'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + saveConfig, + fetchImpl: async () => new Response(JSON.stringify({ + ok: true, + profiles: [ + { ...hostedProfile, id: 'profile_z', name: 'zebra' }, + { ...hostedProfile, id: 'profile_a', name: 'alpha' }, + { ...hostedProfile, id: 'profile_none', name: null }, + ], + })), }); + + expect(result).toEqual({ handled: true, exitCode: 66 }); + expect(stdout.text()).toBe(''); + expect(stderr.text()).toContain('PROFILE_NOT_FOUND'); + expect(stderr.text()).toContain('Valid profiles: alpha, zebra'); + expect(stderr.text()).toContain('usage: webcmd profile use '); + expect(saveConfig).not.toHaveBeenCalled(); }); it.each(['catalog'])('rejects unsupported hosted plugin %s without an API call', async (subcommand) => { @@ -1429,20 +1465,6 @@ describe('runHostedCli', () => { } }); - it('rejects local-only profile use in hosted mode without an API call', async () => { - const stderr = sink(); - const fetchImpl = vi.fn(); - const result = await runHostedCli(['profile', 'use', 'value'], { - config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), - stderr: stderr.stream, - fetchImpl, - }); - - expect(result).toEqual({ handled: true, exitCode: 78 }); - expect(stderr.text()).toContain('webcmd profile use is not available in hosted mode.'); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - it.each(['list', 'rename', 'use'])('leaves profile %s to the existing local command surface', async (command) => { const result = await runHostedCli(['profile', command, 'value'], { config: makeLocalConfig(), @@ -1901,6 +1923,86 @@ describe('runHostedCli', () => { expect(requests[1]?.body?.profile).toBe(profile); }); + it('validates and forwards a saved hosted profile without creating it', async () => { + const requests: Array<{ path: string; method: string; body?: Record }> = []; + const result = await runHostedCli(['github', 'whoami', '-f', 'json'], { + config: withHostedPreferredProfile( + makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + 'work', + ), + stdout: sink().stream, + stderr: sink().stream, + fetchImpl: async (url, init) => { + const path = new URL(String(url)).pathname; + requests.push({ + path, + method: init?.method ?? 'GET', + ...(init?.body ? { body: JSON.parse(String(init.body)) as Record } : {}), + }); + if (path === '/v1/manifest') return manifestResponse(); + if (path === '/v1/profiles') return new Response(JSON.stringify({ ok: true, profiles: [hostedProfile] })); + return executionResponse({ result: [] }); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(requests.map(request => request.path)).toEqual(['/v1/manifest', '/v1/profiles', '/v1/execute']); + expect(requests.at(-1)?.body?.profile).toBe('work'); + expect(requests).not.toContainEqual(expect.objectContaining({ path: '/v1/profiles', method: 'POST' })); + }); + + it('fails before execution when the saved hosted profile is stale', async () => { + const requests: string[] = []; + const stderr = sink(); + const result = await runHostedCli(['github', 'whoami'], { + config: withHostedPreferredProfile( + makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + 'work', + ), + stdout: sink().stream, + stderr: stderr.stream, + fetchImpl: async (url) => { + const path = new URL(String(url)).pathname; + requests.push(path); + if (path === '/v1/manifest') return manifestResponse(); + return new Response(JSON.stringify({ ok: true, profiles: [{ ...hostedProfile, name: 'personal' }] })); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 66 }); + expect(requests).toEqual(['/v1/manifest', '/v1/profiles']); + expect(stderr.text()).toContain('PROFILE_NOT_FOUND'); + expect(stderr.text()).toContain('Valid profiles: personal'); + }); + + it.each([ + { name: 'root flag', argv: ['--profile', 'flag', 'github', 'whoami'], env: {}, expected: 'flag' }, + { name: 'environment', argv: ['github', 'whoami'], env: { WEBCMD_PROFILE: 'env' }, expected: 'env' }, + ])('lets $name override the saved hosted profile without validating it', async ({ argv, env, expected }) => { + const requests: Array<{ path: string; body?: Record }> = []; + const result = await runHostedCli(argv, { + config: withHostedPreferredProfile( + makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + 'work', + ), + env, + stdout: sink().stream, + stderr: sink().stream, + fetchImpl: async (url, init) => { + const path = new URL(String(url)).pathname; + requests.push({ + path, + ...(init?.body ? { body: JSON.parse(String(init.body)) as Record } : {}), + }); + return path === '/v1/manifest' ? manifestResponse() : executionResponse({ result: [] }); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(requests.map(request => request.path)).toEqual(['/v1/manifest', '/v1/execute']); + expect(requests.at(-1)?.body?.profile).toBe(expected); + }); + it('does not consume a profile placed after a known leaf command', async () => { const stdout = sink(); const stderr = sink(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 874ccfec..900e586e 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -60,7 +60,16 @@ import { renderHostedSiteHelp, withClientOwnedCommands, } from './manifest.js'; -import { isHostedConfig, loadWebcmdConfig, type WebcmdConfig } from './config.js'; +import { + isHostedConfig, + loadWebcmdConfig, + resolveHostedProfileSelection, + saveWebcmdConfig, + withHostedPreferredProfile, + type HostedProfileSelection, + type HostedWebcmdConfig, + type WebcmdConfig, +} from './config.js'; import { resolveHostedApiKey, type HostedCredentialStore } from './credentials.js'; import { parseHostedRootCommandSurface } from '../root-command-surface.js'; import { registerSiteCommands, type SiteMemoryBackend } from '../site-memory/commands.js'; @@ -112,6 +121,8 @@ export interface HostedRunnerOptions { }; /** Optional local roots that are owned only when installed on this client. */ installedLocalCommandRoots?: ReadonlySet; + /** @internal Persists hosted profile preference in runner tests and embedders. */ + saveConfig?: (config: HostedWebcmdConfig) => void; } interface HostedDispatchIo { @@ -189,6 +200,17 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { randomUUID: opts.randomUUID, migrate: opts.config === undefined, }); + const currentConfig = credential.migrated + ? loadWebcmdConfig({ env: opts.env, homeDir: opts.homeDir }) + : config; + if (!isHostedConfig(currentConfig)) throw new ConfigError('Webcmd hosted configuration could not be reloaded.'); + const profileSelection = resolveHostedProfileSelection( + currentConfig, + rootSurface.kind === 'dispatch' ? rootSurface.profile : undefined, + opts.env ?? process.env, + ); + const saveConfig = opts.saveConfig + ?? ((next: HostedWebcmdConfig) => saveWebcmdConfig(next, { env: opts.env, homeDir: opts.homeDir })); if (opts.signal?.aborted) { throw opts.signal.reason ?? new Error('The operation was aborted.'); } @@ -226,6 +248,9 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { externals, opts.installedLocalCommandRoots, deferredExternalSession, + currentConfig, + profileSelection, + saveConfig, ); return { handled: true, exitCode: exitCode ?? EXIT_CODES.SUCCESS }; } catch (caught) { @@ -295,6 +320,9 @@ async function dispatchHosted( }, installedLocalCommandRoots?: ReadonlySet, deferredExternalSession?: DeferredExternalSession, + config?: HostedWebcmdConfig, + profileSelection?: HostedProfileSelection, + saveConfig?: (config: HostedWebcmdConfig) => void, ): Promise { const rootHelp = getHostedRootHelp(hasLocalClientCommandHandlers); const normalized = parseHostedRootCommandSurface(argv); @@ -304,6 +332,14 @@ async function dispatchHosted( validateManifestContractIdentity(manifest); return manifest; }; + let validatedPreferredProfile: Promise | undefined; + const profileForRequest = (override?: string): Promise => { + if (override !== undefined) return Promise.resolve(override); + if (!profileSelection) return Promise.resolve(undefined); + if (profileSelection.source !== 'preferred') return Promise.resolve(profileSelection.name); + validatedPreferredProfile ??= requireListedHostedProfile(client, profileSelection.name); + return validatedPreferredProfile; + }; if (normalized.kind === 'help') { const help = formatRootHelp(rootHelp); if (normalized.exitCode !== EXIT_CODES.SUCCESS) { @@ -347,7 +383,10 @@ async function dispatchHosted( if (isHostedCoreRoot(args[0])) { const parsed = parseHostedCoreCommand(args, normalized.literal); await requireHostedCoreCommand(getManifest, parsed.command); - return dispatchHostedCoreCommand(parsed, client, stdout, normalized.profile); + const profile = parsed.command === 'verify' || parsed.command === 'doctor' + ? await profileForRequest() + : undefined; + return dispatchHostedCoreCommand(parsed, client, stdout, profile); } if (args[0] === 'session') { const parsed = parseHostedSessionSurface(args.slice(1), normalized.literal); @@ -356,13 +395,13 @@ async function dispatchHosted( return; } await getManifest(); - await dispatchHostedSession(parsed, client, stdout, normalized.profile); + await dispatchHostedSession(parsed, client, stdout, await profileForRequest()); return; } if (args[0] === 'browser') { - const invocation = await parseHostedBrowserInvocation(args, normalized.profile, normalized.session, io); + const invocation = await parseHostedBrowserInvocation(args, profileSelection?.name, normalized.session, io); await getManifest(); - await dispatchHostedBrowser(invocation, client, stdout, io); + await dispatchHostedBrowser({ ...invocation, profile: await profileForRequest() }, client, stdout, io); return; } @@ -393,12 +432,6 @@ async function dispatchHosted( } if (args[0] === 'profile') { - if (args[1] === 'use') { - throw new ConfigError( - `webcmd profile ${args[1]} is not available in hosted mode.`, - 'Hosted mode supports: webcmd profile list, create, rename, and delete.', - ); - } const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal); if (parsed.kind === 'help') { await writeToStream(stdout, parsed.output); @@ -407,7 +440,8 @@ async function dispatchHosted( if (parsed.command === 'create' || parsed.command === 'rename') { await requireHostedCoreCommand(getManifest, `profile/${parsed.command}`); } - await dispatchHostedProfile(parsed, client, stdout); + if (!config || !saveConfig) throw new Error('Internal invariant: hosted profile persistence is unavailable.'); + await dispatchHostedProfile(parsed, client, stdout, config, saveConfig); return; } @@ -686,6 +720,7 @@ async function dispatchHosted( enableVerbose(parsed.verbose); const startTime = now(); + const profile = await profileForRequest(parsed.profile); const response = command.browser || hasPresentFileArgument(command, parsed.args) ? await executeHostedPreparedCommand({ client, @@ -693,7 +728,7 @@ async function dispatchHosted( args: parsed.args, format: parsed.format, trace: parsed.trace, - profile: parsed.profile ?? normalized.profile, + profile, session: normalized.session, stderr, io, @@ -703,7 +738,7 @@ async function dispatchHosted( args: parsed.args, format: parsed.format, trace: parsed.trace, - profile: parsed.profile ?? normalized.profile, + profile, session: normalized.session, }); let format: string = parsed.format; @@ -1588,7 +1623,8 @@ type ParsedHostedProfileSurface = | { kind: 'run'; command: 'list'; format: string; formatExplicit: boolean } | { kind: 'run'; command: 'delete'; profile: string; format: string; formatExplicit: boolean } | { kind: 'run'; command: 'create'; name: string } - | { kind: 'run'; command: 'rename'; profile: string; name: string }; + | { kind: 'run'; command: 'rename'; profile: string; name: string } + | { kind: 'run'; command: 'use'; profile: string }; function parseHostedProfileSurface( argv: readonly string[], @@ -1635,6 +1671,9 @@ function parseHostedProfileSurface( profile.command('rename').argument('').argument('').exitOverride().configureOutput(output).action((profileValue: string, name: string) => { parsed = { kind: 'run', command: 'rename', profile: profileValue, name }; }); + profile.command('use').argument('').exitOverride().configureOutput(output).action((profileValue: string) => { + parsed = { kind: 'run', command: 'use', profile: profileValue }; + }); try { root.parse(literal ? ['--', 'profile', ...argv] : ['profile', ...argv], { from: 'user' }); @@ -1653,6 +1692,8 @@ async function dispatchHostedProfile( parsed: Exclude, client: HostedClient, stdout: NodeJS.WritableStream, + config: HostedWebcmdConfig, + saveConfig: (config: HostedWebcmdConfig) => void, ): Promise { if (parsed.command === 'create') { await renderOutput(await client.createProfile(parsed.name), { fmt: 'yaml', stdout }); @@ -1670,12 +1711,31 @@ async function dispatchHostedProfile( await renderOutput(await client.renameProfile(profile.id, parsed.name), { fmt: 'yaml', stdout }); return; } + if (parsed.command === 'use') { + const profile = await requireListedHostedProfile(client, parsed.profile); + saveConfig(withHostedPreferredProfile(config, profile)); + await renderOutput({ ok: true, action: 'use', profile }, { fmt: 'yaml', stdout }); + return; + } const result = parsed.command === 'list' ? (await client.listProfiles()).profiles : await client.deleteProfile(parsed.profile); await renderOutput(result, { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, stdout }); } +async function requireListedHostedProfile(client: HostedClient, name: string): Promise { + const profiles = (await client.listProfiles()).profiles; + const profile = profiles.find(candidate => candidate.name !== null && (candidate.id === name || candidate.name === name)); + if (profile?.name) return profile.name; + const validNames = profiles.flatMap(candidate => candidate.name ? [candidate.name] : []).sort(); + throw new HostedClientError( + 'PROFILE_NOT_FOUND', + `No hosted profile matches "${name}". Valid profiles: ${validNames.join(', ') || '(none)'}`, + `usage: ${CLI_COMMAND} profile use \nList profiles: ${CLI_COMMAND} profile list`, + EXIT_CODES.EMPTY_RESULT, + ); +} + type ParsedHostedPluginSurface = | { kind: 'help'; output: string } | { kind: 'run'; command: 'search'; query?: string; format: string; formatExplicit: boolean } From 6b2e254b1fcb7e5f95a1e5d4ca00e6ca4d3fe177 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 23:35:19 +0530 Subject: [PATCH 08/18] fix: isolate injected hosted profile config --- src/hosted/runner.test.ts | 28 ++++++++++++++++++++++++++++ src/hosted/runner.ts | 15 +++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 7a8c1298..b20450ac 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -921,6 +921,34 @@ describe('runHostedCli', () => { })); }); + it('does not persist profile use from an injected config without an explicit saver', async () => { + const configDir = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-profile-no-saver-')); + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + ok: true, + profiles: [hostedProfile], + }))); + try { + const result = await runHostedCli(['profile', 'use', 'work'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + env: { WEBCMD_CONFIG_DIR: configDir }, + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(stdout.text()).toBe(''); + expect(stderr.text()).toContain('CONFIG'); + expect(stderr.text()).toContain('cannot persist a hosted profile preference'); + expect(fetchImpl).not.toHaveBeenCalled(); + await expect(access(path.join(configDir, 'config.json'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(configDir, { recursive: true, force: true }); + } + }); + it('rejects an unknown hosted profile with sorted display names and does not save', async () => { const stdout = sink(); const stderr = sink(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 900e586e..70d650a8 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -209,8 +209,9 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { rootSurface.kind === 'dispatch' ? rootSurface.profile : undefined, opts.env ?? process.env, ); - const saveConfig = opts.saveConfig - ?? ((next: HostedWebcmdConfig) => saveWebcmdConfig(next, { env: opts.env, homeDir: opts.homeDir })); + const saveConfig = opts.saveConfig ?? (opts.config === undefined + ? (next: HostedWebcmdConfig) => saveWebcmdConfig(next, { env: opts.env, homeDir: opts.homeDir }) + : undefined); if (opts.signal?.aborted) { throw opts.signal.reason ?? new Error('The operation was aborted.'); } @@ -440,7 +441,7 @@ async function dispatchHosted( if (parsed.command === 'create' || parsed.command === 'rename') { await requireHostedCoreCommand(getManifest, `profile/${parsed.command}`); } - if (!config || !saveConfig) throw new Error('Internal invariant: hosted profile persistence is unavailable.'); + if (!config) throw new Error('Internal invariant: hosted configuration is unavailable.'); await dispatchHostedProfile(parsed, client, stdout, config, saveConfig); return; } @@ -1693,7 +1694,7 @@ async function dispatchHostedProfile( client: HostedClient, stdout: NodeJS.WritableStream, config: HostedWebcmdConfig, - saveConfig: (config: HostedWebcmdConfig) => void, + saveConfig?: (config: HostedWebcmdConfig) => void, ): Promise { if (parsed.command === 'create') { await renderOutput(await client.createProfile(parsed.name), { fmt: 'yaml', stdout }); @@ -1712,6 +1713,12 @@ async function dispatchHostedProfile( return; } if (parsed.command === 'use') { + if (!saveConfig) { + throw new ConfigError( + 'Injected hosted configuration cannot persist a hosted profile preference without saveConfig.', + 'Pass saveConfig when invoking runHostedCli with an injected config.', + ); + } const profile = await requireListedHostedProfile(client, parsed.profile); saveConfig(withHostedPreferredProfile(config, profile)); await renderOutput({ ok: true, action: 'use', profile }, { fmt: 'yaml', stdout }); From 25e7e8fb7bc67944e7c69f784765ecb35c2f1719 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 23:45:10 +0530 Subject: [PATCH 09/18] fix: preserve auth parity in hosted mode --- src/commands/auth.test.ts | 24 +++- src/commands/auth.ts | 34 +++-- src/hooks.test.ts | 7 ++ src/hooks.ts | 4 + src/hosted/auth-command-surface.test.ts | 82 ++++++++++++ src/hosted/auth-command-surface.ts | 93 ++++++++++++++ src/hosted/main-lifecycle.test.ts | 65 ++++++++++ src/hosted/runner.test.ts | 159 ++++++++++++++++++++++++ src/hosted/runner.ts | 30 +++-- src/main.ts | 5 +- 10 files changed, 480 insertions(+), 23 deletions(-) create mode 100644 src/hosted/auth-command-surface.test.ts create mode 100644 src/hosted/auth-command-surface.ts diff --git a/src/commands/auth.test.ts b/src/commands/auth.test.ts index 630f17d8..ae810da2 100644 --- a/src/commands/auth.test.ts +++ b/src/commands/auth.test.ts @@ -11,7 +11,7 @@ vi.mock('../execution.js', () => ({ executeCommand: executeCommandMock, })); -import { collectAuthRefresh, collectAuthStatus, registerAuthCommands } from './auth.js'; +import { collectAuthRefresh, collectAuthStatus, configureAuthCommandSurface, registerAuthCommands } from './auth.js'; import { AuthRequiredError } from '../errors.js'; import { cli, getRegistry, Strategy } from '../registry.js'; @@ -308,6 +308,28 @@ describe('auth command format validation', () => { }); }); +describe('auth command surface', () => { + it('exposes the native grammar without attaching local probe actions', () => { + const program = new Command('webcmd'); + const { status, refresh } = configureAuthCommandSurface(program); + + expect(status.options.map(option => option.flags)).toEqual([ + '--site ', + '--full', + '--concurrency ', + '--timeout ', + '--only ', + '-v, --verbose', + '-f, --format ', + '--json', + ]); + expect(status.options.find(option => option.long === '--only')?.argChoices).toEqual([ + 'all', 'logged-in', 'not-logged-in', 'unknown', 'error', + ]); + expect(refresh.options.map(option => option.long)).not.toContain('--trace'); + }); +}); + // Both probes drive the browser/daemon stack, whose CDP diagnostics are already // gated on isVerbose() — so -v has something observable behind it here (#174). describe('auth verbose flag', () => { diff --git a/src/commands/auth.ts b/src/commands/auth.ts index d22a8b53..aff6a28d 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -71,8 +71,8 @@ interface AuthRefreshState { sites: Record; } -function parsePositiveInt(raw: string | number | undefined, label: string, fallback: number): number { - if (raw === undefined || raw === null || raw === '') return fallback; +export function parseAuthPositiveInt(raw: string | number | undefined, label: string): number | undefined { + if (raw === undefined || raw === null || raw === '') return undefined; const parsed = Number(raw); if (!Number.isInteger(parsed) || parsed <= 0) { throw new InvalidArgumentError(`${label} must be a positive integer. Received: "${String(raw)}"`); @@ -80,6 +80,10 @@ function parsePositiveInt(raw: string | number | undefined, label: string, fallb return parsed; } +function parsePositiveInt(raw: string | number | undefined, label: string, fallback: number): number { + return parseAuthPositiveInt(raw, label) ?? fallback; +} + function parseSiteFilter(raw: string | undefined): Set | null { if (!raw || !raw.trim()) return null; const sites = raw.split(',').map(site => site.trim()).filter(Boolean); @@ -454,7 +458,7 @@ export async function collectAuthRefresh(options: AuthRefreshOptions): Promise', 'Filter rows by status').choices(['all', 'logged-in', 'not-logged-in', 'unknown', 'error']).default('all')) .option('-v, --verbose', 'Debug output', false); addOutputFormatOption(status); + const refresh = auth + .command('refresh') + .description('Touch logged-in site sessions to keep browser auth fresh') + .option('--site ', 'Comma-separated site names to refresh, e.g. github,claude') + .option('--all', 'Ignore the 24h refresh throttle and force every selected site', false) + .option('--concurrency ', 'Maximum sites to refresh at once') + .option('--timeout ', 'Per-site timeout in seconds') + .option('-v, --verbose', 'Debug output', false); + addOutputFormatOption(refresh); + + return { auth, status, refresh }; +} + +export function registerAuthCommands(program: Command): Command { + const { auth, status, refresh } = configureAuthCommandSurface(program); status.action(async (opts) => { // Both auth probes drive the browser/daemon stack, so verbose mode surfaces // the CDP diagnostics those layers already gate on `isVerbose()` (#174). @@ -493,15 +512,6 @@ export function registerAuthCommands(program: Command): Command { }); }); - const refresh = auth - .command('refresh') - .description('Touch logged-in site sessions to keep browser auth fresh') - .option('--site ', 'Comma-separated site names to refresh, e.g. github,claude') - .option('--all', 'Ignore the 24h refresh throttle and force every selected site', false) - .option('--concurrency ', 'Maximum sites to refresh at once') - .option('--timeout ', 'Per-site timeout in seconds') - .option('-v, --verbose', 'Debug output', false); - addOutputFormatOption(refresh); refresh.action(async (opts) => { enableVerbose(opts.verbose === true); const fmt = resolveCommandOutputFormat(refresh, opts.format); diff --git a/src/hooks.test.ts b/src/hooks.test.ts index 4d805f15..1e65820d 100644 --- a/src/hooks.test.ts +++ b/src/hooks.test.ts @@ -14,6 +14,7 @@ import { emitHook, clearAllHooks, shouldEmitStartupHook, + shouldEnsureUserCliCompatShims, shouldRunStartupSideEffects, WEBCMD_ROOT_COMMANDS, type HookContext, @@ -143,6 +144,12 @@ describe('startup hook gating', () => { expect(shouldRunStartupSideEffects(['--get-completions', '--cursor', '1'])).toBe(true); }); + it('keeps the user CLI import shim for structured auth execution only', () => { + expect(shouldEnsureUserCliCompatShims(['auth', 'status', '--site', 'github', '-f', 'json'])).toBe(true); + expect(shouldEnsureUserCliCompatShims(['auth', 'status', '--help', '-f', 'json'])).toBe(false); + expect(shouldEnsureUserCliCompatShims(['list', '-f', 'json'])).toBe(false); + }); + it.each([ ['agent-context', '--json'], ['demo', 'state', '--json'], diff --git a/src/hooks.ts b/src/hooks.ts index 8da048bb..1efdab94 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -109,6 +109,10 @@ export function shouldRunStartupSideEffects(argv: readonly string[]): boolean { return !(hasExplicitOutputFormat(argv) && WEBCMD_ROOT_COMMANDS.has(rootCommand(argv) ?? '')); } +export function shouldEnsureUserCliCompatShims(argv: readonly string[]): boolean { + return shouldRunStartupSideEffects(argv) || (!isHelp(argv) && rootCommand(argv) === 'auth'); +} + export function shouldEmitStartupHook(argv: readonly string[]): boolean { return !isHelp(argv) && !isCompletion(argv); } diff --git a/src/hosted/auth-command-surface.test.ts b/src/hosted/auth-command-surface.test.ts new file mode 100644 index 00000000..63bf8715 --- /dev/null +++ b/src/hosted/auth-command-surface.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { CommanderStructuralError } from '../command-surface.js'; +import { parseHostedAuthCommand } from './auth-command-surface.js'; + +describe('hosted auth command surface', () => { + it('rejects a status choice with the native Commander contract', () => { + expect(() => parseHostedAuthCommand(['auth', 'status', '--only', 'authenticated'], false)).toThrowError( + expect.objectContaining>({ + exitCode: 2, + output: [ + "error: option '--only ' argument 'authenticated' is invalid. Allowed choices are all, logged-in, not-logged-in, unknown, error.", + 'help: usage: webcmd auth status [options]', + '', + ].join('\n'), + }), + ); + }); + + it('rejects generic trace capture with the native refresh flag list', () => { + expect(() => parseHostedAuthCommand(['auth', 'refresh', '--trace', 'on'], false)).toThrowError( + expect.objectContaining>({ + exitCode: 2, + output: [ + "error: unknown option '--trace'", + 'help: valid flags for `webcmd auth refresh`: --site, --all, --concurrency, --timeout, -v, --verbose, -f, --format, --json', + '', + ].join('\n'), + }), + ); + }); + + it('renders status help from the native options in plain and structured formats', () => { + const plain = parseHostedAuthCommand(['auth', 'status', '--help'], false); + const structured = parseHostedAuthCommand(['auth', 'status', '--help', '-f', 'json'], false); + + expect(plain).toMatchObject({ kind: 'help' }); + if (plain.kind !== 'help') throw new Error('Expected help'); + expect(plain.output).toContain('--only '); + expect(plain.output).toContain('"logged-in"'); + expect(plain.output).not.toContain('--trace'); + + expect(structured).toMatchObject({ kind: 'help' }); + if (structured.kind !== 'help') throw new Error('Expected help'); + const data = JSON.parse(structured.output) as { command_options: Array<{ name: string; choices?: string[] }> }; + expect(data.command_options.find(option => option.name === 'only')?.choices).toEqual([ + 'all', 'logged-in', 'not-logged-in', 'unknown', 'error', + ]); + expect(data.command_options.map(option => option.name)).not.toContain('trace'); + }); + + it('preserves the existing hosted execute values for valid auth input', () => { + expect(parseHostedAuthCommand([ + 'auth', 'status', '--site', 'github', '--full', '--concurrency', '2', '--timeout', '15', + '--only', 'logged-in', '-v', '-f', 'json', + ], false)).toEqual({ + kind: 'run', + command: 'auth/status', + args: { site: 'github', full: true, concurrency: 2, timeout: 15, only: 'logged-in' }, + optionSources: { + site: 'cli', full: 'cli', concurrency: 'cli', timeout: 'cli', only: 'cli', + }, + format: 'json', + formatExplicit: true, + trace: 'off', + verbose: true, + help: false, + }); + }); + + it('keeps parsing auth options after a root literal separator', () => { + expect(parseHostedAuthCommand(['auth', 'status', '--only', 'logged-in'], true)).toMatchObject({ + kind: 'run', + command: 'auth/status', + args: { only: 'logged-in' }, + }); + }); + + it('keeps native positive-integer validation before hosted execution', () => { + expect(() => parseHostedAuthCommand(['auth', 'status', '--concurrency', 'many'], false)) + .toThrow('--concurrency must be a positive integer. Received: "many"'); + }); +}); diff --git a/src/hosted/auth-command-surface.ts b/src/hosted/auth-command-surface.ts new file mode 100644 index 00000000..94a3ecbe --- /dev/null +++ b/src/hosted/auth-command-surface.ts @@ -0,0 +1,93 @@ +import { Command, CommanderError } from 'commander'; +import { + CommanderStructuralError, + outputFormatIsExplicit, + requestedOutputFormat, + resolveCommandFromArgv, + structuralErrorFromCommander, + type ParsedCommandSurface, +} from '../command-surface.js'; +import { configureAuthCommandSurface, parseAuthPositiveInt } from '../commands/auth.js'; +import { + commanderCommandHelpData, + commanderNamespaceHelpData, + getRequestedHelpFormat, + renderStructuredHelp, +} from '../help.js'; +import { configureRootCommandSurface } from '../root-command-surface.js'; +import { validateHostedFormat } from './core-command-surface.js'; + +export type ParsedHostedAuthCommand = + | { kind: 'help'; output: string } + | ({ kind: 'run'; command: 'auth/status' | 'auth/refresh' } & ParsedCommandSurface); + +export function parseHostedAuthCommand(argv: readonly string[], _literal: boolean): ParsedHostedAuthCommand { + let parsed: Extract | undefined; + let stdout = ''; + let stderr = ''; + const root = configureRootCommandSurface(new Command('webcmd')); + const { auth, status, refresh } = configureAuthCommandSurface(root); + const output = { + writeOut: (value: string) => { stdout += value; }, + writeErr: (value: string) => { stderr += value; }, + }; + for (const command of [root, auth, status, refresh]) command.exitOverride().configureOutput(output); + + status.action((options: Record) => { + parsed = authRun('auth/status', status, options); + }); + refresh.action((options: Record) => { + parsed = authRun('auth/refresh', refresh, options); + }); + + try { + root.parse([...argv], { from: 'user' }); + } catch (error) { + if (!(error instanceof CommanderError)) throw error; + const command = resolveCommandFromArgv(root, argv); + if (error.code === 'commander.helpDisplayed') { + const format = getRequestedHelpFormat(argv); + if (!format) return { kind: 'help', output: stdout }; + const data = command === auth + ? commanderNamespaceHelpData(auth, { globalCommand: root }) + : commanderCommandHelpData(auth, command, { globalCommand: root }); + return { kind: 'help', output: renderStructuredHelp(data, format) }; + } + throw structuralErrorFromCommander(error, command, stderr); + } + if (!parsed) throw new CommanderStructuralError("error: command 'auth' did not run\n", 1); + for (const name of ['concurrency', 'timeout'] as const) { + const value = parsed.args[name]; + if (value !== undefined) parsed.args[name] = parseAuthPositiveInt(String(value), `--${name}`); + } + return parsed; +} + +function authRun( + command: 'auth/status' | 'auth/refresh', + surface: Command, + options: Record, +): Extract { + const args: Record = {}; + const optionSources: Record = {}; + for (const option of surface.options) { + const name = option.attributeName(); + if (name === 'format' || name === 'json' || name === 'verbose') continue; + const value = options[name]; + if (value !== undefined) args[name] = value; + const source = surface.getOptionValueSource(name); + if (source === 'cli') optionSources[name] = 'cli'; + else if (source === 'default') optionSources[name] = 'default'; + } + return { + kind: 'run', + command, + args, + optionSources, + format: validateHostedFormat(String(requestedOutputFormat(surface, options.format))), + formatExplicit: outputFormatIsExplicit(surface), + trace: 'off', + verbose: options.verbose === true, + help: false, + }; +} diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index b8336c5b..17e19529 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -366,6 +366,23 @@ describe('hosted CLI process lifecycle', () => { expect(result.stderr).toBe(''); expect(JSON.parse(result.stdout)).toEqual({ value: 'ready' }); }, 20_000); + + it('loads a lazy user auth adapter through the package compatibility shim for structured output', async () => { + const fixture = await createLocalLazyAuthFixture(); + + const result = await runCli(['auth', 'status', '--site', 'auth-fixture', '-f', 'json'], fixture.env); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual([{ + site: 'auth-fixture', + status: 'unknown', + logged_in: '', + identity: '', + checked: 'skipped', + error: 'quickCheck not implemented; use --full to run whoami', + }]); + }, 20_000); }); async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): Promise<{ @@ -556,6 +573,54 @@ async function createLocalStartupPluginFixture(): Promise<{ root: string; env: N }; } +async function createLocalLazyAuthFixture(): Promise<{ root: string; env: NodeJS.ProcessEnv }> { + const root = await mkdtemp(path.join(tmpdir(), 'webcmd-local-auth-')); + tempRoots.push(root); + const configDir = path.join(root, 'config'); + const clisDir = path.join(root, '.webcmd', 'clis'); + const siteDir = path.join(clisDir, 'auth-fixture'); + await mkdir(configDir, { recursive: true }); + await mkdir(siteDir, { recursive: true }); + await writeFile(path.join(configDir, 'config.json'), '{"mode":"local"}\n'); + await writeFile(path.join(root, '.webcmd', 'cli-manifest.json'), `${JSON.stringify([{ + site: 'auth-fixture', + name: 'whoami', + description: 'Fixture identity', + access: 'read', + strategy: 'cookie', + browser: true, + args: [], + columns: ['logged_in'], + type: 'js', + modulePath: 'auth-fixture/whoami.js', + }])}\n`); + await writeFile(path.join(siteDir, 'whoami.js'), [ + "import { cli, Strategy } from '@agentrhq/webcmd/registry';", + 'cli({', + " site: 'auth-fixture',", + " name: 'whoami',", + " description: 'Fixture identity',", + " access: 'read',", + ' strategy: Strategy.COOKIE,', + ' browser: true,', + ' args: [],', + " columns: ['logged_in'],", + ' func: async () => ({ logged_in: true }),', + '});', + '', + ].join('\n')); + return { + root, + env: { + ...process.env, + HOME: root, + USERPROFILE: root, + WEBCMD_CONFIG_DIR: configDir, + WEBCMD_NO_UPDATE_CHECK: '1', + }, + }; +} + async function createLocalExternalFixture(name: string): Promise<{ root: string; env: NodeJS.ProcessEnv; diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index b20450ac..cb91d6ed 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -141,6 +141,58 @@ function manifestWithStructuralArguments() { }; } +function manifestWithAuthCommands() { + return { + ...manifest, + commands: [ + ...manifest.commands, + { + site: 'auth', + name: 'status', + command: 'auth/status', + description: 'Show login status for sites with auth adapters', + access: 'read', + strategy: 'PUBLIC', + browser: false, + args: [ + { name: 'site', type: 'string', required: false }, + { name: 'full', type: 'boolean', default: false }, + { name: 'concurrency', type: 'int', required: false }, + { name: 'timeout', type: 'int', required: false }, + { + name: 'only', + type: 'string', + required: false, + default: 'all', + choices: ['all', 'logged-in', 'not-logged-in', 'unknown', 'error'], + }, + ], + columns: ['site', 'status', 'identity', 'checked', 'error'], + }, + { + site: 'auth', + name: 'refresh', + command: 'auth/refresh', + description: 'Touch logged-in site sessions to keep browser auth fresh', + access: 'write', + strategy: 'PUBLIC', + browser: false, + args: [ + { name: 'site', type: 'string', required: false }, + { name: 'all', type: 'boolean', default: false }, + { name: 'concurrency', type: 'int', required: false }, + { name: 'timeout', type: 'int', required: false }, + ], + columns: ['site', 'status', 'last_touched_at', 'next_refresh_at', 'error'], + }, + ], + }; +} + +afterEach(() => { + delete process.env.WEBCMD_VERBOSE; +}); + function manifestWithFileCommand() { return { ...manifest, @@ -298,6 +350,113 @@ describe('runHostedCli', () => { lastUsedAt: '2026-08-27T00:00:00.000Z', }; + it('rejects an invalid hosted auth status choice with native bytes before execution', async () => { + const requests: string[] = []; + const stderr = sink(); + + const result = await runHostedCli(['auth', 'status', '--only', 'authenticated'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl: async (url) => { + const pathname = new URL(String(url)).pathname; + requests.push(pathname); + return new Response(JSON.stringify({ ok: true, manifest: manifestWithAuthCommands() })); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(requests).not.toContain('/v1/execute'); + expect(stderr.text()).toBe([ + "error: option '--only ' argument 'authenticated' is invalid. Allowed choices are all, logged-in, not-logged-in, unknown, error.", + 'help: usage: webcmd auth status [options]', + '', + ].join('\n')); + }); + + it('rejects --trace on hosted auth refresh instead of sending a request', async () => { + const requests: string[] = []; + const stderr = sink(); + + const result = await runHostedCli(['auth', 'refresh', '--trace', 'on'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl: async (url) => { + const pathname = new URL(String(url)).pathname; + requests.push(pathname); + return new Response(JSON.stringify({ ok: true, manifest: manifestWithAuthCommands() })); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(requests).not.toContain('/v1/execute'); + expect(stderr.text()).toBe([ + "error: unknown option '--trace'", + 'help: valid flags for `webcmd auth refresh`: --site, --all, --concurrency, --timeout, -v, --verbose, -f, --format, --json', + '', + ].join('\n')); + }); + + it.each([ + { name: 'plain', argv: ['auth', 'status', '--help'], structured: false }, + { name: 'structured', argv: ['auth', 'status', '--help', '-f', 'json'], structured: true }, + ])('renders $name hosted auth help without generic trace grammar', async ({ argv, structured }) => { + const stdout = sink(); + const fetchImpl = vi.fn(); + + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(stdout.text()).not.toContain('--trace'); + if (structured) { + const data = JSON.parse(stdout.text()) as { command_options: Array<{ name: string; choices?: string[] }> }; + expect(data.command_options.find(option => option.name === 'only')?.choices).toEqual([ + 'all', 'logged-in', 'not-logged-in', 'unknown', 'error', + ]); + } else { + expect(stdout.text()).toContain('--only '); + } + }); + + it('keeps valid hosted auth execute bytes while forcing trace off', async () => { + const requests: Array<{ pathname: string; body?: unknown }> = []; + + const result = await runHostedCli([ + 'auth', 'status', '--site', 'github', '--full', '--concurrency', '2', '--timeout', '15', + '--only', 'logged-in', '-v', '-f', 'json', + ], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: sink().stream, + stderr: sink().stream, + fetchImpl: async (url, init) => { + const pathname = new URL(String(url)).pathname; + requests.push({ + pathname, + ...(init?.body ? { body: JSON.parse(String(init.body)) as unknown } : {}), + }); + return pathname === '/v1/manifest' + ? new Response(JSON.stringify({ ok: true, manifest: manifestWithAuthCommands() })) + : executionResponse({ result: [], command: 'auth/status' }); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(process.env.WEBCMD_VERBOSE).toBe('1'); + expect(requests.at(-1)).toEqual({ + pathname: '/v1/execute', + body: { + command: 'auth/status', + args: { site: 'github', full: true, concurrency: 2, timeout: 15, only: 'logged-in' }, + format: 'json', + trace: 'off', + }, + }); + }); + it.each([ { name: 'validate', diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 70d650a8..07184264 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -47,6 +47,7 @@ import { parseHostedCoreCommand, validateHostedFormat, type ParsedHostedCoreComm import { createVirtualHostedFileIo, realHostedFileIo, type HostedFileIo } from './file-io.js'; import { HOSTED_SESSION_PROTOCOL_VERSION } from './types.js'; import { parseHostedInvocation } from './args.js'; +import { parseHostedAuthCommand, type ParsedHostedAuthCommand } from './auth-command-surface.js'; import { HostedBrowserHelp, parseHostedBrowserStructure, validateRawBrowserSession } from './browser-args.js'; import { materializeHostedOutputs, prepareHostedFiles, rewriteHostedOutputResultPaths } from './files.js'; import { @@ -359,6 +360,15 @@ async function dispatchHosted( return; } const args = normalized.argv; + let hostedAuth: Extract | undefined; + if (args[0] === 'auth' && (!args[1] || args[1] === 'status' || args[1] === 'refresh' || args[1] === '--help' || args[1] === '-h')) { + const parsed = parseHostedAuthCommand(args, normalized.literal); + if (parsed.kind === 'help') { + await writeToStream(stdout, parsed.output); + return; + } + hostedAuth = parsed; + } if (!hasLocalClientCommandHandlers && isLocalClientRootCommand(args[0])) { throw new CommanderCompatibleError(`error: unknown command '${args[0]}'\n`, EXIT_CODES.USAGE_ERROR); } @@ -670,15 +680,19 @@ async function dispatchHosted( } onResolvedCommand?.(trustedCommandResolution(command)); let parsed: ReturnType; - try { - parsed = parseHostedInvocation(command, args.slice(2)); - } catch (error) { - if (error instanceof CommanderStructuralError) { - // A usage error carries its own envelope; only the legacy fallback needs - // the UNKNOWN envelope appended after the human bytes. - throw new CommanderStructuralError(error.output, error.exitCode, !error.envelope, error.envelope); + if (hostedAuth && hostedAuth.command === command.command) { + parsed = hostedAuth; + } else { + try { + parsed = parseHostedInvocation(command, args.slice(2)); + } catch (error) { + if (error instanceof CommanderStructuralError) { + // A usage error carries its own envelope; only the legacy fallback needs + // the UNKNOWN envelope appended after the human bytes. + throw new CommanderStructuralError(error.output, error.exitCode, !error.envelope, error.envelope); + } + throw error; } - throw error; } if (parsed.help) { await writeHostedHelp(stdout, args, hostedCommandHelpData(command), renderHostedCommandHelp(command)); diff --git a/src/main.ts b/src/main.ts index 4c782007..3d9f319c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -168,7 +168,7 @@ if (getCompIdx !== -1) { const { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } = await import('./discovery.js'); const { getCompletions } = await import('./completion.js'); const { createProgram, isExternalRootCommand, runCli } = await import('./cli.js'); -const { emitHook, shouldEmitStartupHook, shouldRunStartupSideEffects } = await import('./hooks.js'); +const { emitHook, shouldEmitStartupHook, shouldEnsureUserCliCompatShims, shouldRunStartupSideEffects } = await import('./hooks.js'); const { installNodeNetwork } = await import('./node-network.js'); const { registerUpdateNoticeOnExit, checkForUpdateBackground } = await import('./update-check.js'); @@ -182,11 +182,12 @@ installNodeNetwork(); // plugins is what makes an override actually take effect. const skipUserDiscovery = argv[0] === 'convention-audit'; const runStartupSideEffects = shouldRunStartupSideEffects(argv); +const ensureCompatShims = shouldEnsureUserCliCompatShims(argv); if (skipUserDiscovery) { await discoverClis(BUILTIN_CLIS); } else { const [, ,] = await Promise.all([ - runStartupSideEffects ? ensureUserCliCompatShims() : Promise.resolve(), + ensureCompatShims ? ensureUserCliCompatShims() : Promise.resolve(), runStartupSideEffects ? ensureUserAdapters() : Promise.resolve(), discoverClis(BUILTIN_CLIS), ]); From 0b2905fc4158256c888745188eab317cabcb9ca3 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Thu, 27 Aug 2026 23:57:18 +0530 Subject: [PATCH 10/18] feat: gate hosted discovery by capability --- src/completion-shared.test.ts | 43 +++++++ src/completion-shared.ts | 116 ++++++++++++++----- src/hosted/main-lifecycle.test.ts | 76 ++++++++++++- src/hosted/manifest.test.ts | 17 ++- src/hosted/root-command-surface.test.ts | 26 ++--- src/hosted/runner.test.ts | 145 ++++++++++++++++++++++++ src/hosted/runner.ts | 53 ++++++--- 7 files changed, 416 insertions(+), 60 deletions(-) diff --git a/src/completion-shared.test.ts b/src/completion-shared.test.ts index 2a4d336e..ca4535b9 100644 --- a/src/completion-shared.test.ts +++ b/src/completion-shared.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; import { + getHostedBuiltinCommands, + getHostedBuiltinSubcommands, + getHostedRootHelp, HOSTED_BUILTIN_COMMANDS, HOSTED_ROOT_HELP, LOCAL_ONLY_COMMAND_HELP, @@ -37,4 +40,44 @@ describe('hosted root help', () => { 'Run `webcmd setup` and choose local mode to use local-only commands.', ); }); + + it('falls back to client-owned roots without a core manifest', () => { + const names = getHostedBuiltinCommands(undefined, true); + + expect(names).toEqual(expect.arrayContaining([ + 'completion', + 'external', + 'setup', + 'skills', + 'update', + 'web', + ])); + expect(names).not.toEqual(expect.arrayContaining([ + 'validate', + 'verify', + 'convention-audit', + 'doctor', + ])); + }); + + it('adds only advertised Cloud root commands', () => { + expect(getHostedBuiltinCommands(['validate', 'doctor'], true)) + .toEqual(expect.arrayContaining(['validate', 'doctor'])); + expect(getHostedBuiltinCommands(['validate', 'doctor'], true)) + .not.toEqual(expect.arrayContaining(['verify', 'convention-audit'])); + }); + + it('gates nested subcommands by canonical IDs', () => { + expect(getHostedBuiltinSubcommands('adapter', ['adapter/status'])) + .toEqual(['override', 'path', 'source', 'status']); + expect(getHostedBuiltinSubcommands('profile', ['profile/create'])) + .toEqual(['create', 'delete', 'list', 'use']); + expect(getHostedBuiltinSubcommands('plugin', ['plugin/catalog/list'])).toContain('catalog'); + }); + + it('keeps only daemon in permanent local-only root help', () => { + expect(getHostedRootHelp(undefined, true).localOnlyCommands).toEqual([ + { name: 'daemon', description: 'Manage the local Webcmd daemon' }, + ]); + }); }); diff --git a/src/completion-shared.ts b/src/completion-shared.ts index 700c7ae2..189a07d3 100644 --- a/src/completion-shared.ts +++ b/src/completion-shared.ts @@ -6,7 +6,8 @@ */ import { CLI_COMMAND } from './brand.js'; -import type { RootHelpPresentation } from './command-presentation.js'; +import type { RootHelpCommand, RootHelpPresentation } from './command-presentation.js'; +import { hasHostedCoreCommand, type HostedCoreCommandId } from './hosted/core-commands.js'; /** * Built-in (non-dynamic) top-level commands. @@ -26,7 +27,35 @@ export const BUILTIN_COMMANDS = [ export const LOCAL_ONLY_COMMAND_HELP = 'Run `webcmd setup` and choose local mode to use local-only commands.'; -export const HOSTED_ROOT_HELP: RootHelpPresentation = { +const HOSTED_CLIENT_ROOT_COMMANDS: readonly RootHelpCommand[] = [ + { name: 'adapter', description: 'Manage hosted adapter sources and overrides' }, + { name: 'artifact', description: 'Download a hosted execution artifact to a local path' }, + { name: 'auth', description: 'Inspect hosted authentication status' }, + { name: 'browser', description: 'Browser control through a hosted browser session' }, + { name: 'completion ', description: 'Output a shell completion script' }, + { name: 'external', description: 'Manage local CLI passthrough commands' }, + { name: 'list', description: 'List all available hosted CLI commands' }, + { name: 'plugin', description: 'Manage Webcmd plugins' }, + { name: 'profile', description: 'Manage hosted browser profiles' }, + { name: 'setup', description: 'Configure local or hosted mode' }, + { name: 'skills', description: 'Manage bundled Webcmd skills on this computer' }, + { name: 'update', description: 'Update the installed Webcmd CLI on this computer' }, + { name: 'web', description: 'Fetch URLs locally without launching a browser. Use after a blocked, 403, or Cloudflare response.' }, +]; + +const HOSTED_CORE_ROOT_COMMANDS: Record = { + validate: { name: 'validate', description: 'Validate hosted CLI definitions' }, + verify: { name: 'verify', description: 'Validate and smoke-test hosted adapters' }, + 'convention-audit': { name: 'convention-audit', description: 'Audit hosted adapter source conventions' }, + doctor: { name: 'doctor', description: 'Diagnose hosted readiness' }, + 'adapter/status': undefined, + 'adapter/reset': undefined, + 'profile/create': undefined, + 'profile/rename': undefined, + 'plugin/catalog/list': undefined, +}; + +const HOSTED_ROOT_HELP_BASE: Omit = { description: 'Make any website your CLI. Zero setup. AI-powered.', usage: [ `${CLI_COMMAND} [args] [options]`, @@ -39,51 +68,78 @@ export const HOSTED_ROOT_HELP: RootHelpPresentation = { { flags: '-V, --version', description: 'Output the version number' }, { flags: '-h, --help', description: 'Display help for command' }, ], - commands: [ - { name: 'artifact', description: 'Download a hosted execution artifact to a local path' }, - { name: 'browser', description: 'Browser control through a hosted browser session' }, - { name: 'completion ', description: 'Output a shell completion script' }, - { name: 'list', description: 'List all available hosted CLI commands' }, - { name: 'profile', description: 'Manage hosted browser profiles' }, - { name: 'setup', description: 'Configure local or hosted mode' }, - { name: 'skills', description: 'Manage bundled Webcmd skills on this computer' }, - { name: 'update', description: 'Update the installed Webcmd CLI on this computer' }, - { name: 'web', description: 'Fetch URLs locally without launching a browser. Use after a blocked, 403, or Cloudflare response.' }, - ], localOnlyCommands: [ - { name: 'adapter', description: 'Manage adapters installed on this computer' }, - { name: 'antigravity', description: 'Run the local Antigravity proxy' }, - { name: 'auth', description: 'Inspect credentials in the local browser runtime' }, - { name: 'convention-audit', description: 'Audit adapter source files on this computer' }, { name: 'daemon', description: 'Manage the local Webcmd daemon' }, - { name: 'doctor', description: 'Diagnose local browser bridge connectivity' }, - { name: 'external', description: 'Manage local CLI passthrough commands' }, - { name: 'plugin', description: 'Manage plugins installed on this computer' }, - { name: 'validate', description: 'Validate local CLI definitions' }, - { name: 'verify', description: 'Validate and smoke-test local adapters' }, ], }; -const LOCAL_CLIENT_ROOT_COMMANDS = new Set(['skills', 'update']); +const LOCAL_CLIENT_ROOT_COMMANDS = new Set(['external', 'skills', 'update']); -export function getHostedRootHelp(hasLocalClientCommandHandlers = true): RootHelpPresentation { - if (hasLocalClientCommandHandlers) return HOSTED_ROOT_HELP; +export function getHostedRootHelp( + coreCommands?: readonly HostedCoreCommandId[], + hasLocalClientCommandHandlers = true, +): RootHelpPresentation { + const commands = [ + ...HOSTED_CLIENT_ROOT_COMMANDS, + ...(coreCommands?.flatMap(id => HOSTED_CORE_ROOT_COMMANDS[id] ?? []) ?? []), + ].sort((left, right) => left.name.localeCompare(right.name)); return { - ...HOSTED_ROOT_HELP, - commands: HOSTED_ROOT_HELP.commands.filter(command => !LOCAL_CLIENT_ROOT_COMMANDS.has(command.name.split(/\s/, 1)[0]!)), + ...HOSTED_ROOT_HELP_BASE, + commands: hasLocalClientCommandHandlers + ? commands + : commands.filter(command => !LOCAL_CLIENT_ROOT_COMMANDS.has(command.name.split(/\s/, 1)[0]!)), }; } -export function getHostedBuiltinCommands(hasLocalClientCommandHandlers = true): string[] { - return getHostedRootHelp(hasLocalClientCommandHandlers).commands +export function getHostedBuiltinCommands( + coreCommands?: readonly HostedCoreCommandId[], + hasLocalClientCommandHandlers = true, +): string[] { + return getHostedRootHelp(coreCommands, hasLocalClientCommandHandlers).commands .map((command) => command.name.split(/\s/, 1)[0]!); } +export function getHostedBuiltinSubcommands( + root: 'adapter' | 'profile' | 'plugin', + coreCommands?: readonly HostedCoreCommandId[], +): string[] { + if (root === 'adapter') { + return [ + 'override', + 'path', + 'source', + ...(hasHostedCoreCommand(coreCommands, 'adapter/status') ? ['status'] : []), + ...(hasHostedCoreCommand(coreCommands, 'adapter/reset') ? ['reset'] : []), + ].sort(); + } + if (root === 'profile') { + return [ + 'delete', + 'list', + 'use', + ...(hasHostedCoreCommand(coreCommands, 'profile/create') ? ['create'] : []), + ...(hasHostedCoreCommand(coreCommands, 'profile/rename') ? ['rename'] : []), + ].sort(); + } + return [ + 'create', + 'install', + 'list', + 'search', + 'uninstall', + 'update', + ...(hasHostedCoreCommand(coreCommands, 'plugin/catalog/list') ? ['catalog'] : []), + ].sort(); +} + export function isLocalClientRootCommand(command: string | undefined): boolean { return command !== undefined && LOCAL_CLIENT_ROOT_COMMANDS.has(command); } -export const HOSTED_BUILTIN_COMMANDS = getHostedBuiltinCommands(); +/** No-core, installed-client snapshot retained for existing callers. */ +export const HOSTED_ROOT_HELP = getHostedRootHelp(undefined, true); + +export const HOSTED_BUILTIN_COMMANDS = getHostedBuiltinCommands(undefined, true); // ── Shell script generators ──────────────────────────────────────────────── diff --git a/src/hosted/main-lifecycle.test.ts b/src/hosted/main-lifecycle.test.ts index 17e19529..608318b6 100644 --- a/src/hosted/main-lifecycle.test.ts +++ b/src/hosted/main-lifecycle.test.ts @@ -77,6 +77,67 @@ describe('hosted CLI process lifecycle', () => { await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); }, 20_000); + it('uses exactly one advertised manifest to render hosted root help', async () => { + const fixture = await createHostedFixture('success', { + coreCommands: ['validate', 'adapter/status'], + }); + + const result = await runCli(['--help'], fixture.env); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toMatch(/validate\s+Validate hosted CLI definitions/); + expect(result.stdout).not.toMatch(/verify\s+Validate/); + expect(fixture.requests).toEqual(['GET /v1/manifest']); + }, 20_000); + + it.each(['unavailable', 'malformed'] as const)( + 'falls back to client-owned root help when the manifest is %s', + async (manifestOutcome) => { + const fixture = await createHostedFixture('success', { manifestOutcome }); + + const result = await runCli(['--help'], fixture.env); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('external'); + expect(result.stdout).not.toMatch(/validate\s+Validate hosted CLI definitions/); + expect(fixture.requests).toEqual(['GET /v1/manifest']); + }, + 20_000, + ); + + it('falls back to client-owned root help when the stored credential is missing', async () => { + const fixture = await createHostedFixture('success'); + await rm(path.join(fixture.root, 'config', 'hosted-credentials.json'), { force: true }); + + const result = await runCli(['--help'], fixture.env); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('external'); + expect(result.stdout).not.toMatch(/validate\s+Validate hosted CLI definitions/); + expect(fixture.requests).toEqual([]); + }, 20_000); + + it('gates root and nested completion by the advertised manifest', async () => { + const fixture = await createHostedFixture('success', { + coreCommands: ['doctor', 'profile/create'], + }); + + const root = await runCli(['--get-completions', '--cursor', '1'], fixture.env); + const profile = await runCli(['--get-completions', '--cursor', '2', 'profile'], fixture.env); + + expect(root.status).toBe(0); + expect(root.stderr).toBe(''); + expect(root.stdout.trim().split('\n')).toEqual(expect.arrayContaining(['doctor'])); + expect(root.stdout.trim().split('\n')).not.toEqual(expect.arrayContaining(['validate'])); + expect(profile.status).toBe(0); + expect(profile.stderr).toBe(''); + expect(profile.stdout.trim().split('\n')).toEqual(['create', 'delete', 'list', 'use']); + expect(fixture.requests).toEqual(['GET /v1/manifest', 'GET /v1/manifest']); + }, 20_000); + it('flushes delayed output and trace bytes, returns success, and never enters local discovery', async () => { const fixture = await createHostedFixture('success'); @@ -385,7 +446,13 @@ describe('hosted CLI process lifecycle', () => { }, 20_000); }); -async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): Promise<{ +async function createHostedFixture( + outcome: 'success' | 'failure' | 'browser', + options: { + coreCommands?: string[]; + manifestOutcome?: 'success' | 'unavailable' | 'malformed'; + } = {}, +): Promise<{ root: string; env: NodeJS.ProcessEnv; discoverySentinel: string; @@ -415,6 +482,10 @@ async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): const server = createServer(async (request, response) => { requests.push(`${request.method ?? 'GET'} ${request.url ?? '/'}`); if (request.url === '/v1/manifest') { + if (options.manifestOutcome === 'unavailable') { + response.writeHead(503).end('unavailable'); + return; + } sendChunkedJson(response, { ok: true, manifest: { @@ -424,6 +495,9 @@ async function createHostedFixture(outcome: 'success' | 'failure' | 'browser'): sessionProtocolVersion: 1, webcmdPackageVersion: PKG_VERSION, generatedAt: '2026-07-14T00:00:00.000Z', + ...(options.manifestOutcome === 'malformed' + ? { coreCommands: ['unknown-core'] } + : options.coreCommands ? { coreCommands: options.coreCommands } : {}), }, commands: [command, authCommand, liveViewCommand], }, diff --git a/src/hosted/manifest.test.ts b/src/hosted/manifest.test.ts index e5eda35e..1f26e827 100644 --- a/src/hosted/manifest.test.ts +++ b/src/hosted/manifest.test.ts @@ -225,7 +225,22 @@ describe('hosted manifest helpers', () => { fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }), }); - expect(stdout.text().trim().split('\n')).toEqual(['artifact', 'browser', 'completion', 'github', 'list', 'profile', 'setup', 'skills', 'update', 'web']); + expect(stdout.text().trim().split('\n')).toEqual([ + 'adapter', + 'artifact', + 'auth', + 'browser', + 'completion', + 'external', + 'github', + 'list', + 'plugin', + 'profile', + 'setup', + 'skills', + 'update', + 'web', + ]); const siteHelp = sink(); await runHostedCli(['web', '--help'], { diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index 24acc657..84e250b1 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -380,18 +380,18 @@ describe('hosted root command surface', () => { describe('hosted root preflight call order', () => { it.each([ - { name: 'no args', argv: [], exitCode: 1, stdout: '', stderr: formatRootHelp(HOSTED_ROOT_HELP) }, - { name: 'profile only', argv: ['--profile', 'work'], exitCode: 1, stdout: '', stderr: formatRootHelp(HOSTED_ROOT_HELP) }, - { name: 'bare separator', argv: ['--'], exitCode: 1, stdout: '', stderr: formatRootHelp(HOSTED_ROOT_HELP) }, - { name: 'help', argv: ['--help'], exitCode: 0, stdout: formatRootHelp(HOSTED_ROOT_HELP), stderr: '' }, - { name: 'help after malformed prefix and site', argv: ['--unknown', 'github', '--help'], exitCode: 0, stdout: formatRootHelp(HOSTED_ROOT_HELP), stderr: '' }, - { name: 'profile consumes help into implicit help', argv: ['--profile', '--help'], exitCode: 1, stdout: '', stderr: formatRootHelp(HOSTED_ROOT_HELP) }, - { name: 'version', argv: ['-Vx'], exitCode: 0, stdout: `${PKG_VERSION}\n`, stderr: '' }, - { name: 'missing profile', argv: ['--profile'], exitCode: 1, stdout: '', stderr: "error: option '--profile ' argument missing\n" }, - { name: 'trailing missing profile beats help', argv: ['--help', 'github', '--profile'], exitCode: 1, stdout: '', stderr: "error: option '--profile ' argument missing\n" }, - { name: 'trailing missing profile beats unknown', argv: ['--unknown', 'github', '--profile'], exitCode: 1, stdout: '', stderr: "error: option '--profile ' argument missing\n" }, - { name: 'unknown option', argv: ['-xV'], exitCode: 2, stdout: '', stderr: "error: unknown option '-xV'\n" }, - ])('$name terminates before Cloud discovery', async ({ name, argv, exitCode, stdout: expectedStdout, stderr: expectedStderr }) => { + { name: 'no args', argv: [], exitCode: 1, stdout: '', stderr: formatRootHelp(HOSTED_ROOT_HELP), requests: 1 }, + { name: 'profile only', argv: ['--profile', 'work'], exitCode: 1, stdout: '', stderr: formatRootHelp(HOSTED_ROOT_HELP), requests: 1 }, + { name: 'bare separator', argv: ['--'], exitCode: 1, stdout: '', stderr: formatRootHelp(HOSTED_ROOT_HELP), requests: 1 }, + { name: 'help', argv: ['--help'], exitCode: 0, stdout: formatRootHelp(HOSTED_ROOT_HELP), stderr: '', requests: 1 }, + { name: 'help after malformed prefix and site', argv: ['--unknown', 'github', '--help'], exitCode: 0, stdout: formatRootHelp(HOSTED_ROOT_HELP), stderr: '', requests: 1 }, + { name: 'profile consumes help into implicit help', argv: ['--profile', '--help'], exitCode: 1, stdout: '', stderr: formatRootHelp(HOSTED_ROOT_HELP), requests: 1 }, + { name: 'version', argv: ['-Vx'], exitCode: 0, stdout: `${PKG_VERSION}\n`, stderr: '', requests: 0 }, + { name: 'missing profile', argv: ['--profile'], exitCode: 1, stdout: '', stderr: "error: option '--profile ' argument missing\n", requests: 0 }, + { name: 'trailing missing profile beats help', argv: ['--help', 'github', '--profile'], exitCode: 1, stdout: '', stderr: "error: option '--profile ' argument missing\n", requests: 0 }, + { name: 'trailing missing profile beats unknown', argv: ['--unknown', 'github', '--profile'], exitCode: 1, stdout: '', stderr: "error: option '--profile ' argument missing\n", requests: 0 }, + { name: 'unknown option', argv: ['-xV'], exitCode: 2, stdout: '', stderr: "error: unknown option '-xV'\n", requests: 0 }, + ])('$name performs only its allowed Cloud preflight', async ({ name, argv, exitCode, stdout: expectedStdout, stderr: expectedStderr, requests }) => { const stdout = sink(); const stderr = sink(); const fetchImpl = vi.fn(); @@ -411,7 +411,7 @@ describe('hosted root preflight call order', () => { } else { expect(stderr.text()).toBe(expectedStderr); } - expect(fetchImpl).not.toHaveBeenCalled(); + expect(fetchImpl).toHaveBeenCalledTimes(requests); }); it.each([ diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index cb91d6ed..247e9a70 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -2081,7 +2081,151 @@ describe('runHostedCli', () => { expect(result).toEqual({ handled: true, exitCode: 0 }); expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP)); expect(stderr.text()).toBe(''); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('fetches one manifest and advertises only its hosted core roots in help', async () => { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + ok: true, + manifest: { + ...manifest, + metadata: { ...manifest.metadata, coreCommands: ['validate', 'doctor'] }, + }, + }), { status: 200 })); + + const result = await runHostedCli(['--help'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(stdout.text()).toMatch(/validate\s+Validate hosted CLI definitions/); + expect(stdout.text()).toMatch(/doctor\s+Diagnose hosted readiness/); + expect(stdout.text()).not.toMatch(/verify\s+Validate/); + expect(stdout.text()).not.toMatch(/convention-audit\s+Audit/); + expect(stderr.text()).toBe(''); + }); + + it.each([ + { + name: 'unavailable manifest', + response: () => new Response('unavailable', { status: 503 }), + }, + { + name: 'malformed manifest', + response: () => new Response(JSON.stringify({ + ok: true, + manifest: { + ...manifest, + metadata: { ...manifest.metadata, coreCommands: ['unknown-core'] }, + }, + }), { status: 200 }), + }, + ])('falls back to client-owned help on $name', async ({ response }) => { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(async () => response()); + + const result = await runHostedCli(['--help'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP)); + expect(stderr.text()).toBe(''); + }); + + it('falls back to client-owned help when hosted credentials are unavailable', async () => { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(); + + const result = await runHostedCli(['--help'], { + config: makeHostedConfig({ + apiBaseUrl: 'https://api.example.com', + apiKeyRef: 'missing', + credentialBackend: 'file-fallback', + }), + credentialStore: { + backend: () => 'file-fallback', + get: async () => null, + put: async () => undefined, + delete: async () => undefined, + }, + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); expect(fetchImpl).not.toHaveBeenCalled(); + expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP)); + expect(stderr.text()).toBe(''); + }); + + it.each([ + { + name: 'root', + argv: ['--get-completions', '--cursor', '1'], + coreCommands: ['validate', 'doctor'], + included: ['validate', 'doctor'], + excluded: ['verify', 'convention-audit'], + }, + { + name: 'adapter', + argv: ['--get-completions', '--cursor', '2', 'adapter'], + coreCommands: ['adapter/status'], + included: ['override', 'path', 'source', 'status'], + excluded: ['reset'], + }, + { + name: 'profile client-owned use', + argv: ['--get-completions', '--cursor', '2', 'profile'], + coreCommands: [], + included: ['delete', 'list', 'use'], + excluded: ['create', 'rename'], + }, + { + name: 'plugin catalog', + argv: ['--get-completions', '--cursor', '2', 'plugin'], + coreCommands: ['plugin/catalog/list'], + included: ['catalog'], + excluded: [], + }, + { + name: 'plugin catalog list', + argv: ['--get-completions', '--cursor', '3', 'plugin', 'catalog'], + coreCommands: ['plugin/catalog/list'], + included: ['list'], + excluded: [], + }, + ])('gates $name completion by manifest capability', async ({ argv, coreCommands, included, excluded }) => { + const stdout = sink(); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + ok: true, + manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands } }, + }), { status: 200 })); + + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl, + }); + + const candidates = stdout.text().trim().split('\n'); + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(candidates).toEqual(expect.arrayContaining(included)); + for (const name of excluded) expect(candidates).not.toContain(name); }); it.each([ @@ -2235,6 +2379,7 @@ describe('runHostedCli', () => { const run = runHostedCli(['--help'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout, + fetchImpl: async () => new Response('unavailable', { status: 503 }), }).then(result => { settled = true; return result; diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 07184264..429df557 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -17,6 +17,7 @@ import { addOutputFormatOption, CommanderStructuralError, MissingRequiredPositio import { filterCommandsByTag, formatRootHelp, getCommandCompletionCandidates } from '../command-presentation.js'; import { getHostedBuiltinCommands, + getHostedBuiltinSubcommands, getHostedRootHelp, HOSTED_ROOT_HELP, isLocalClientRootCommand, @@ -193,14 +194,22 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { const externals = rootName && isWebcmdOwnedRoot(rootName, opts.installedLocalCommandRoots) ? undefined : opts.externals; - const credential = await resolveHostedApiKey(config, { - credentialStore: opts.credentialStore, - env: opts.env, - homeDir: opts.homeDir, - platform: opts.platform, - randomUUID: opts.randomUUID, - migrate: opts.config === undefined, - }); + let credential: Awaited>; + try { + credential = await resolveHostedApiKey(config, { + credentialStore: opts.credentialStore, + env: opts.env, + homeDir: opts.homeDir, + platform: opts.platform, + randomUUID: opts.randomUUID, + migrate: opts.config === undefined, + }); + } catch (error) { + if (rootSurface.kind !== 'help') throw error; + const help = formatRootHelp(getHostedRootHelp(undefined, opts.hasLocalClientCommandHandlers !== false)); + await writeToStream(rootSurface.exitCode === EXIT_CODES.SUCCESS ? stdout : stderr, help); + return { handled: true, exitCode: rootSurface.exitCode }; + } const currentConfig = credential.migrated ? loadWebcmdConfig({ env: opts.env, homeDir: opts.homeDir }) : config; @@ -326,7 +335,6 @@ async function dispatchHosted( profileSelection?: HostedProfileSelection, saveConfig?: (config: HostedWebcmdConfig) => void, ): Promise { - const rootHelp = getHostedRootHelp(hasLocalClientCommandHandlers); const normalized = parseHostedRootCommandSurface(argv); let manifestPromise: Promise | undefined; const getManifest = async (): Promise => { @@ -343,7 +351,13 @@ async function dispatchHosted( return validatedPreferredProfile; }; if (normalized.kind === 'help') { - const help = formatRootHelp(rootHelp); + let coreCommands: readonly HostedCoreCommandId[] | undefined; + try { + coreCommands = (await getManifest()).metadata.coreCommands; + } catch { + // Root help remains usable while offline, logged out, or paired with an incompatible Cloud. + } + const help = formatRootHelp(getHostedRootHelp(coreCommands, hasLocalClientCommandHandlers)); if (normalized.exitCode !== EXIT_CODES.SUCCESS) { throw new CommanderCompatibleError(help, normalized.exitCode); } @@ -355,7 +369,7 @@ async function dispatchHosted( return; } if (normalized.kind === 'completion') { - const manifest = await getPresentationManifest(client, enableServerWebFetch); + const manifest = withClientOwnedCommands(await getManifest(), enableServerWebFetch); await writeToStream(stdout, hostedCompletions(manifest, normalized.argv, hasLocalClientCommandHandlers).join('\n') + '\n'); return; } @@ -635,7 +649,7 @@ async function dispatchHosted( return; } if (unknownRoot.help) { - await writeToStream(stdout, formatRootHelp(rootHelp)); + await writeToStream(stdout, formatRootHelp(getHostedRootHelp(manifest.metadata.coreCommands, hasLocalClientCommandHandlers))); return; } // No help on stdout: an error path that emits a well-formed document to @@ -1960,12 +1974,21 @@ function hostedCompletions(manifest: HostedManifest, argv: string[], hasLocalCli } const commands = hostedCommands(manifest) .filter(command => hasLocalClientCommandHandlers || !isLocalClientRootCommand(command.site)); - return getCommandCompletionCandidates( + const coreCommands = manifest.metadata.coreCommands; + const root = words[0]; + if (cursor === 2 && (root === 'adapter' || root === 'profile' || root === 'plugin')) { + return getHostedBuiltinSubcommands(root, coreCommands); + } + if (cursor === 3 && root === 'plugin' && words[1] === 'catalog') { + return hasHostedCoreCommand(coreCommands, 'plugin/catalog/list') ? ['list'] : []; + } + return [...new Set(getCommandCompletionCandidates( commands, words, Number.isFinite(cursor) ? cursor! : words.length, - getHostedBuiltinCommands(hasLocalClientCommandHandlers).filter(command => command !== 'web'), - ); + getHostedBuiltinCommands(coreCommands, hasLocalClientCommandHandlers) + .filter(command => command !== 'web' && command !== root), + ))]; } function errorExitCode(err: unknown): number { From 8a8f5fb6777e5930d7121b52a4f08a6f46da1675 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 00:04:40 +0530 Subject: [PATCH 11/18] fix: preserve root-help cancellation --- src/hosted/runner.test.ts | 33 +++++++++++++++++++++++++++++++++ src/hosted/runner.ts | 3 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 247e9a70..b16ffcc0 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -3559,6 +3559,39 @@ describe('runHostedCli injected I/O', () => { }); }); + it('does not turn an aborted root-help manifest request into fallback success', async () => { + const controller = new AbortController(); + const stdout = createCaptureStream(64 * 1024); + const stderr = createCaptureStream(64 * 1024); + let fetchStartedResolve!: () => void; + const fetchStarted = new Promise((resolve) => { fetchStartedResolve = resolve; }); + const fetchImpl = vi.fn((_url, init) => new Promise((_resolve, reject) => { + fetchStartedResolve(); + init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + })); + + const run = runHostedCli(['--help'], { + config: hostedConfig, + env: {}, + homeDir: '/nonexistent', + signal: controller.signal, + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + }); + await fetchStarted; + controller.abort(); + + const result = await run; + expect(result).toEqual({ handled: true, exitCode: 130 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(stdout.result().text).toBe(''); + expect(yaml.load(stderr.result().text)).toMatchObject({ + ok: false, + error: { code: 'INTERRUPTED', exitCode: 130 }, + }); + }); + it('handles an already-aborted invocation before issuing a request', async () => { const controller = new AbortController(); controller.abort(); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 429df557..154233a2 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -354,7 +354,8 @@ async function dispatchHosted( let coreCommands: readonly HostedCoreCommandId[] | undefined; try { coreCommands = (await getManifest()).metadata.coreCommands; - } catch { + } catch (error) { + if (signal?.aborted || error instanceof InterruptedError) throw error; // Root help remains usable while offline, logged out, or paired with an incompatible Cloud. } const help = formatRootHelp(getHostedRootHelp(coreCommands, hasLocalClientCommandHandlers)); From 3094daa506a9eeb2695326c0536bc6602c96a88d Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 00:10:28 +0530 Subject: [PATCH 12/18] fix: explain hosted local-only commands --- src/hosted/manifest.test.ts | 29 +++++++++++++++++++++++--- src/hosted/manifest.ts | 27 ++++++++++++++++-------- src/hosted/runner.test.ts | 41 ++++++++++++++++++++++++++++++++++--- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/hosted/manifest.test.ts b/src/hosted/manifest.test.ts index 1f26e827..1fda0416 100644 --- a/src/hosted/manifest.test.ts +++ b/src/hosted/manifest.test.ts @@ -12,6 +12,7 @@ import { Strategy, type CliCommand } from '../registry.js'; import { commandNamesForSite, findHostedCommand, + hostedListPresentation, hostedListRows, renderHostedCommandHelp, renderHostedSiteHelp, @@ -112,8 +113,30 @@ describe('hosted manifest helpers', () => { ]); }); - it('filters LOCAL commands from hosted list rows', () => { - expect(hostedListRows(manifest, true).map((row) => row.command)).toEqual(['github/whoami']); + it('lists excluded commands as LOCAL without making them completable', () => { + expect(hostedListRows(manifest, true)).toEqual(expect.arrayContaining([ + expect.objectContaining({ command: 'github/whoami', availability: 'HOSTED' }), + expect.objectContaining({ command: 'docker/ps', availability: 'LOCAL' }), + ])); + expect(siteNames(manifest)).toEqual(['github']); + expect(commandNamesForSite(manifest, 'docker')).toEqual([]); + }); + + it('includes availability in hosted table presentation', () => { + const presentation = hostedListPresentation(manifest, 'table'); + + expect(presentation.columns).toContain('availability'); + expect(presentation.rows).toEqual(expect.arrayContaining([ + expect.objectContaining({ command: 'github/whoami', availability: 'HOSTED' }), + expect.objectContaining({ command: 'docker/ps', availability: 'LOCAL' }), + ])); + }); + + it('keeps duplicate inventory commands collapsed to one row', () => { + expect(hostedListRows({ + ...manifest, + commands: [manifest.commands[0]!, { ...manifest.commands[0]! }], + }, true)).toHaveLength(1); }); it('finds canonical commands and aliases', () => { @@ -135,7 +158,7 @@ describe('hosted manifest helpers', () => { it('matches local structured list rows for equal metadata', () => { expect(hostedListRows({ ...manifest, commands: [manifest.commands[0]!] }, true)) - .toEqual([serializeCommand(equivalentLocalCommand)]); + .toEqual([{ ...serializeCommand(equivalentLocalCommand), availability: 'HOSTED' }]); }); it('describes universal hosted surfaces and accepted local-only commands at the root', async () => { diff --git a/src/hosted/manifest.ts b/src/hosted/manifest.ts index 384b938c..4e20a544 100644 --- a/src/hosted/manifest.ts +++ b/src/hosted/manifest.ts @@ -53,6 +53,10 @@ export function hostedCommands(manifest: HostedManifest): HostedCommand[] { .sort((a, b) => a.command.localeCompare(b.command)); } +function hostedInventoryCommands(manifest: HostedManifest): HostedCommand[] { + return [...manifest.commands].sort((a, b) => a.command.localeCompare(b.command)); +} + export function findHostedCommand(manifest: HostedManifest, site: string, name: string): HostedCommand | null { return manifest.commands.find((command) => { return command.site === site && (command.name === name || command.aliases?.includes(name)); @@ -64,22 +68,29 @@ export function presentHostedCommand(command: HostedCommand): PresentableCommand } export function hostedListRows(manifest: HostedManifest, structured: boolean): Record[] { - return markClientOwned(commandListRows(hostedCommands(manifest).map(presentHostedCommand), structured), structured); + const commands = hostedInventoryCommands(manifest); + const commandsByName = new Map(commands.map((command) => [command.command, command])); + return commandListRows(commands.map(presentHostedCommand), structured).map((row) => { + const command = commandsByName.get(String(row.command))!; + return { + ...row, + availability: isLocalOnlyHostedCommand(command) ? 'LOCAL' : 'HOSTED', + ...(structured && command.clientOwned ? { clientOwned: true } : {}), + }; + }); } export function hostedListPresentation(manifest: HostedManifest, format: string): CommandListPresentation { - const presentation = commandListPresentation(hostedCommands(manifest).map(presentHostedCommand), format); + const presentation = commandListPresentation(hostedInventoryCommands(manifest).map(presentHostedCommand), format); return { ...presentation, - rows: markClientOwned(presentation.rows, presentation.structured), + rows: hostedListRows(manifest, presentation.structured), + columns: presentation.columns.flatMap((column) => column === 'strategy' + ? [column, 'availability'] + : [column]), }; } -function markClientOwned(rows: Record[], structured: boolean): Record[] { - if (!structured) return rows; - return rows.map(row => row.command === 'web/fetch' ? { ...row, clientOwned: true } : row); -} - export function siteNames(manifest: HostedManifest): string[] { return getCommandCompletionCandidates(hostedCommands(manifest), [], 1, []); } diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index b16ffcc0..2841c3cf 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -2459,7 +2459,7 @@ describe('runHostedCli', () => { expect(end).not.toHaveBeenCalled(); }); - it('renders hosted list without LOCAL commands', async () => { + it('renders hosted list with explicit HOSTED and LOCAL availability', async () => { const stdout = sink(); const result = await runHostedCli(['list', '-f', 'json'], { @@ -2469,8 +2469,43 @@ describe('runHostedCli', () => { }); expect(result).toEqual({ handled: true, exitCode: 0 }); - expect(stdout.text()).toContain('github/whoami'); - expect(stdout.text()).not.toContain('docker/ps'); + expect(JSON.parse(stdout.text())).toEqual(expect.arrayContaining([ + expect.objectContaining({ command: 'github/whoami', availability: 'HOSTED' }), + expect.objectContaining({ command: 'docker/ps', availability: 'LOCAL' }), + ])); + }); + + it('explains a LOCAL manifest command without plugin guidance or execution', async () => { + const stderr = sink(); + const requests: string[] = []; + const localManifest = { + ...manifest, + commands: [{ + site: 'desktop', + name: 'open', + command: 'desktop/open', + description: 'Open a desktop app', + access: 'write', + strategy: 'LOCAL', + browser: false, + args: [], + columns: [], + }], + }; + + const result = await runHostedCli(['desktop', 'open'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl: async (url, init) => { + requests.push(`${init?.method ?? 'GET'} ${new URL(String(url)).pathname}`); + return new Response(JSON.stringify({ ok: true, manifest: localManifest })); + }, + }); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(stderr.text()).toContain('Command desktop/open is local-only and is not available in hosted mode.'); + expect(stderr.text()).not.toContain('plugin search'); + expect(requests).toEqual(['GET /v1/manifest']); }); it('filters hosted structured list rows by an exact case-insensitive tag', async () => { From 894667eb5bf1709506dbda445230683aa0187562 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 00:17:49 +0530 Subject: [PATCH 13/18] fix: show hosted availability in list output --- src/command-presentation.ts | 6 +++++- src/hosted/manifest.ts | 12 ++++++++---- src/hosted/runner.test.ts | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/command-presentation.ts b/src/command-presentation.ts index b0c0b17c..f3d3b388 100644 --- a/src/command-presentation.ts +++ b/src/command-presentation.ts @@ -9,6 +9,7 @@ export interface PresentableCommand { description: string; access: 'read' | 'write'; strategy: string; + availability?: string; browser: boolean; args: readonly Arg[]; columns: readonly string[]; @@ -223,6 +224,7 @@ export function commandListRows( description: command.description, access: command.access, strategy: command.strategy, + ...(command.availability ? { availability: command.availability } : {}), browser: command.browser, args: command.args.map(serializePresentableArg), columns: [...command.columns], @@ -243,6 +245,7 @@ export function commandListRows( description: command.description, access: command.access, strategy: command.strategy, + ...(command.availability ? { availability: command.availability } : {}), browser: command.browser, args: formatArgumentSummary(command.args), }; @@ -276,6 +279,7 @@ export function commandListPresentation( 'description', 'access', 'strategy', + ...(unique.some((command) => command.availability) ? ['availability'] : []), 'browser', 'args', ...(unique.some((command) => command.origin) ? ['origin'] : []), @@ -327,7 +331,7 @@ function formatGroupedCommandList( for (const command of siteCommands) { const aliases = command.aliases.length > 0 ? ` (aliases: ${command.aliases.join(', ')})` : ''; lines.push( - ` ${command.name} [${command.strategy}]${aliases}` + ` ${command.name} [${command.strategy}]${command.availability ? ` [${command.availability}]` : ''}${aliases}` + `${command.description ? ` — ${command.description}` : ''}` + `${command.origin ? ` [${command.origin}]` : ''}`, ); diff --git a/src/hosted/manifest.ts b/src/hosted/manifest.ts index 4e20a544..610cf5b3 100644 --- a/src/hosted/manifest.ts +++ b/src/hosted/manifest.ts @@ -67,6 +67,13 @@ export function presentHostedCommand(command: HostedCommand): PresentableCommand return toPresentableCommand(command); } +function presentHostedListCommand(command: HostedCommand): PresentableCommand { + return { + ...presentHostedCommand(command), + availability: isLocalOnlyHostedCommand(command) ? 'LOCAL' : 'HOSTED', + }; +} + export function hostedListRows(manifest: HostedManifest, structured: boolean): Record[] { const commands = hostedInventoryCommands(manifest); const commandsByName = new Map(commands.map((command) => [command.command, command])); @@ -81,13 +88,10 @@ export function hostedListRows(manifest: HostedManifest, structured: boolean): R } export function hostedListPresentation(manifest: HostedManifest, format: string): CommandListPresentation { - const presentation = commandListPresentation(hostedInventoryCommands(manifest).map(presentHostedCommand), format); + const presentation = commandListPresentation(hostedInventoryCommands(manifest).map(presentHostedListCommand), format); return { ...presentation, rows: hostedListRows(manifest, presentation.structured), - columns: presentation.columns.flatMap((column) => column === 'strategy' - ? [column, 'availability'] - : [column]), }; } diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 2841c3cf..a3e6c650 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -2475,6 +2475,20 @@ describe('runHostedCli', () => { ])); }); + it('renders default hosted list availability for HOSTED and LOCAL commands', async () => { + const stdout = sink(); + + const result = await runHostedCli(['list'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl: async () => manifestResponse(), + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(stdout.text()).toContain('whoami [cookie] [HOSTED]'); + expect(stdout.text()).toContain('ps [local] [LOCAL]'); + }); + it('explains a LOCAL manifest command without plugin guidance or execution', async () => { const stderr = sink(); const requests: string[] = []; From b0802939f3b4e404e05381c89f4fca17bf21811e Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 00:27:42 +0530 Subject: [PATCH 14/18] fix: preserve hosted list compatibility --- src/hosted/external.test.ts | 13 +++++++------ src/hosted/manifest.ts | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/hosted/external.test.ts b/src/hosted/external.test.ts index 2434219a..7a286755 100644 --- a/src/hosted/external.test.ts +++ b/src/hosted/external.test.ts @@ -193,7 +193,7 @@ describe('hosted external CLI execution', () => { expect(h.stderr.text()).toContain('SESSION_SELECTOR_POSITION'); }); - it('keeps a local-only Webcmd root ahead of external fallback', async () => { + it('keeps an unadvertised hosted core command ahead of external fallback', async () => { const run = vi.fn(() => 0); const h = harness(run); @@ -201,20 +201,21 @@ describe('hosted external CLI execution', () => { expect(result).toEqual({ handled: true, exitCode: 78 }); expect(run).not.toHaveBeenCalled(); - expect(h.stderr.text()).toContain('webcmd validate is local-only'); + expect(h.stderr.text()).toContain('webcmd validate is not available from this Webcmd Cloud endpoint'); + expect(h.fetchImpl).toHaveBeenCalledTimes(1); }); - it('keeps bare validate identical and side-effect free with or without a same-name external', async () => { + it('keeps bare validate capability-gated with or without a same-name external', async () => { const variants = [ownershipHarness(false), ownershipHarness(true)]; const results = []; for (const variant of variants) { results.push(await runHostedCli(['validate'], variant.opts)); expect(variant.stdout.text()).toBe(''); - expect(variant.stderr.text()).toContain('webcmd validate is local-only'); + expect(variant.stderr.text()).toContain('webcmd validate is not available from this Webcmd Cloud endpoint'); expect(variant.list).not.toHaveBeenCalled(); expect(variant.run).not.toHaveBeenCalled(); - expect(variant.getCredential).not.toHaveBeenCalled(); - expect(variant.fetchImpl).not.toHaveBeenCalled(); + expect(variant.getCredential).toHaveBeenCalledTimes(1); + expect(variant.fetchImpl).toHaveBeenCalledTimes(1); } expect(results).toEqual([ diff --git a/src/hosted/manifest.ts b/src/hosted/manifest.ts index 610cf5b3..d4af3eb3 100644 --- a/src/hosted/manifest.ts +++ b/src/hosted/manifest.ts @@ -76,7 +76,7 @@ function presentHostedListCommand(command: HostedCommand): PresentableCommand { export function hostedListRows(manifest: HostedManifest, structured: boolean): Record[] { const commands = hostedInventoryCommands(manifest); - const commandsByName = new Map(commands.map((command) => [command.command, command])); + const commandsByName = new Map(commands.map((command) => [`${command.site}/${command.name}`, command])); return commandListRows(commands.map(presentHostedCommand), structured).map((row) => { const command = commandsByName.get(String(row.command))!; return { From 6298268592e98d8d34cb072273dad523847f8a94 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 00:27:42 +0530 Subject: [PATCH 15/18] docs: record hosted core compatibility bridge --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50742635..cbceb41c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased + +### Added + +- Hosted mode can negotiate and run core validation, diagnostics, adapter lifecycle, profile lifecycle, and catalog-list commands advertised by Webcmd Cloud. +- `@agentrhq/webcmd/adapter-analysis` exposes platform-neutral validation and convention-audit rules for trusted hosted command inventories. +- `@agentrhq/webcmd/hosted/core-commands` exposes the `hosted-core-commands-v1` capability contract and canonical command IDs. +- `webcmd profile use` now stores a validated hosted profile preference locally. + +### Changed + +- Hosted help and completion advertise Cloud-owned core commands only when the authenticated manifest advertises them. +- Hosted command lists retain excluded commands as `LOCAL` rows and return a local-only error instead of plugin-install guidance. +- Local auth commands initialize user CLI compatibility shims, and hosted auth uses the same native grammar, flags, choices, and help as local mode. + ## [0.7.8](https://github.com/agentrhq/webcmd/compare/webcmd-v0.7.7...webcmd-v0.7.8) (2026-08-27) ### Highlights From cccebe95fa0515b8ee57eeb0896aa6d9d8fa086b Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 00:37:10 +0530 Subject: [PATCH 16/18] fix: gate hosted namespace help --- src/hosted/runner.test.ts | 71 +++++++++++++++++++++++++++++++++++++-- src/hosted/runner.ts | 55 +++++++++++++++++++++--------- 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index a3e6c650..7877c2a4 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -885,7 +885,7 @@ describe('runHostedCli', () => { expect(result).toEqual({ handled: true, exitCode: 0 }); expect(stdout.text()).toContain('Commands:\n source'); - expect(fetchImpl).not.toHaveBeenCalled(); + expect(fetchImpl).toHaveBeenCalledTimes(1); }); it('presents web fetch help from local metadata without dispatching it to Cloud', async () => { @@ -1316,7 +1316,7 @@ describe('runHostedCli', () => { expect(outputs.files()).toEqual([]); }); - it('shows hosted plugin search and install help without an API call', async () => { + it('shows hosted plugin search and install help after one capability request', async () => { const stdout = sink(); const stderr = sink(); const fetchImpl = vi.fn(); @@ -1331,7 +1331,7 @@ describe('runHostedCli', () => { expect(stderr.text()).toBe(''); expect(stdout.text()).toContain('search'); expect(stdout.text()).toContain('install'); - expect(fetchImpl).not.toHaveBeenCalled(); + expect(fetchImpl).toHaveBeenCalledTimes(1); }); it.each([ @@ -2228,6 +2228,71 @@ describe('runHostedCli', () => { for (const name of excluded) expect(candidates).not.toContain(name); }); + it.each([ + { + namespace: 'adapter', + clientOwned: ['override', 'path', 'source'], + cloudOwned: ['status', 'reset'], + }, + { + namespace: 'profile', + clientOwned: ['delete', 'list', 'use'], + cloudOwned: ['create', 'rename'], + }, + { + namespace: 'plugin', + clientOwned: ['create', 'install', 'list', 'search', 'uninstall', 'update'], + cloudOwned: ['catalog'], + }, + ])('hides $namespace Cloud children from help when coreCommands is absent or empty', async ({ namespace, clientOwned, cloudOwned }) => { + for (const coreCommands of [undefined, []]) { + const stdout = sink(); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + ok: true, + manifest: { + ...manifest, + metadata: { + ...manifest.metadata, + ...(coreCommands === undefined ? {} : { coreCommands }), + }, + }, + }), { status: 200 })); + + const result = await runHostedCli([namespace, '--help'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + for (const name of clientOwned) expect(stdout.text()).toMatch(new RegExp(`^\\s{2}${name}(?:\\s|\\[)`, 'm')); + for (const name of cloudOwned) expect(stdout.text()).not.toMatch(new RegExp(`^\\s{2}${name}(?:\\s|\\[)`, 'm')); + } + }); + + it.each([ + { namespace: 'adapter', coreCommands: ['adapter/status', 'adapter/reset'], cloudOwned: ['status', 'reset'] }, + { namespace: 'profile', coreCommands: ['profile/create', 'profile/rename'], cloudOwned: ['create', 'rename'] }, + { namespace: 'plugin', coreCommands: ['plugin/catalog/list'], cloudOwned: ['catalog'] }, + ])('shows advertised $namespace Cloud children in help', async ({ namespace, coreCommands, cloudOwned }) => { + const stdout = sink(); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + ok: true, + manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands } }, + }), { status: 200 })); + + const result = await runHostedCli([namespace, '--help'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + for (const name of cloudOwned) expect(stdout.text()).toMatch(new RegExp(`^\\s{2}${name}(?:\\s|\\[)`, 'm')); + }); + it.each([ { name: 'before command', argv: ['--profile', 'work', 'github', 'whoami', '-f', 'json'], profile: 'work' }, { name: 'equals form', argv: ['--profile=work', 'github', 'whoami', '-f', 'json'], profile: 'work' }, diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 154233a2..9f025c12 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -342,6 +342,14 @@ async function dispatchHosted( validateManifestContractIdentity(manifest); return manifest; }; + const getHelpCoreCommands = async (): Promise => { + try { + return (await getManifest()).metadata.coreCommands; + } catch (error) { + if (signal?.aborted || error instanceof InterruptedError) throw error; + return undefined; + } + }; let validatedPreferredProfile: Promise | undefined; const profileForRequest = (override?: string): Promise => { if (override !== undefined) return Promise.resolve(override); @@ -351,13 +359,7 @@ async function dispatchHosted( return validatedPreferredProfile; }; if (normalized.kind === 'help') { - let coreCommands: readonly HostedCoreCommandId[] | undefined; - try { - coreCommands = (await getManifest()).metadata.coreCommands; - } catch (error) { - if (signal?.aborted || error instanceof InterruptedError) throw error; - // Root help remains usable while offline, logged out, or paired with an incompatible Cloud. - } + const coreCommands = await getHelpCoreCommands(); const help = formatRootHelp(getHostedRootHelp(coreCommands, hasLocalClientCommandHandlers)); if (normalized.exitCode !== EXIT_CODES.SUCCESS) { throw new CommanderCompatibleError(help, normalized.exitCode); @@ -375,6 +377,7 @@ async function dispatchHosted( return; } const args = normalized.argv; + const requestsNamespaceHelp = !normalized.literal && (args[1] === '--help' || args[1] === '-h'); let hostedAuth: Extract | undefined; if (args[0] === 'auth' && (!args[1] || args[1] === 'status' || args[1] === 'refresh' || args[1] === '--help' || args[1] === '-h')) { const parsed = parseHostedAuthCommand(args, normalized.literal); @@ -442,7 +445,10 @@ async function dispatchHosted( } if (args[0] === 'adapter' && (args[1] === 'source' || args[1] === 'path' || args[1] === 'override' || args[1] === 'status' || args[1] === 'reset' || args[1] === '--help' || args[1] === '-h')) { - await runHostedAdapterSurface(args.slice(1), normalized.literal, client, stdout, homeDir, io, getManifest); + const coreCommands = requestsNamespaceHelp + ? await getHelpCoreCommands() + : undefined; + await runHostedAdapterSurface(args.slice(1), normalized.literal, client, stdout, homeDir, io, getManifest, coreCommands); return; } @@ -458,7 +464,10 @@ async function dispatchHosted( } if (args[0] === 'profile') { - const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal); + const coreCommands = requestsNamespaceHelp + ? await getHelpCoreCommands() + : undefined; + const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal, coreCommands); if (parsed.kind === 'help') { await writeToStream(stdout, parsed.output); return; @@ -486,7 +495,10 @@ async function dispatchHosted( 'Hosted mode supports: webcmd plugin search, install, list, uninstall, update, and create.', ); } - const parsed = parseHostedPluginSurface(args.slice(1), normalized.literal); + const coreCommands = requestsNamespaceHelp + ? await getHelpCoreCommands() + : undefined; + const parsed = parseHostedPluginSurface(args.slice(1), normalized.literal, coreCommands); if (parsed.kind === 'help') { await writeToStream(stdout, parsed.output); return; @@ -934,6 +946,7 @@ async function runHostedAdapterSurface( homeDir: string, io: HostedDispatchIo, getManifest: () => Promise, + coreCommands?: readonly HostedCoreCommandId[], ): Promise { let parsed: HostedAdapterCommand | undefined; let help = ''; @@ -959,7 +972,9 @@ async function runHostedAdapterSurface( .description('Fork an installed adapter command into a private copy you can modify') .argument('', 'Command to override, as /') .action(commandKey => { parsed = { kind: 'override', commandKey }; }); - const status = addOutputFormatOption(adapter.command('status')); + const status = addOutputFormatOption(adapter.command('status', { + hidden: !hasHostedCoreCommand(coreCommands, 'adapter/status'), + })); status.action((options: { format: string }) => { parsed = { kind: 'status', @@ -967,7 +982,9 @@ async function runHostedAdapterSurface( formatExplicit: outputFormatIsExplicit(status), }; }); - const reset = addOutputFormatOption(adapter.command('reset').argument('[site]').option('--all', 'Reset all hosted overrides', false)); + const reset = addOutputFormatOption(adapter.command('reset', { + hidden: !hasHostedCoreCommand(coreCommands, 'adapter/reset'), + }).argument('[site]').option('--all', 'Reset all hosted overrides', false)); reset.action((site: string | undefined, options: { all?: boolean; format: string }) => { const all = options.all === true; if ((!site && !all) || (site !== undefined && all)) throw new ArgumentError('Specify one adapter site or --all.'); @@ -1659,6 +1676,7 @@ type ParsedHostedProfileSurface = function parseHostedProfileSurface( argv: readonly string[], literal: boolean, + coreCommands?: readonly HostedCoreCommandId[], ): ParsedHostedProfileSurface { let stdout = ''; let stderr = ''; @@ -1695,10 +1713,14 @@ function parseHostedProfileSurface( list.exitOverride().configureOutput(output).action(() => setParsed('list', list)); const remove = configureFormat(profile.command('delete').argument('')); remove.exitOverride().configureOutput(output).action((profileId: string) => setParsed('delete', remove, profileId)); - profile.command('create').argument('').exitOverride().configureOutput(output).action((name: string) => { + profile.command('create', { + hidden: !hasHostedCoreCommand(coreCommands, 'profile/create'), + }).argument('').exitOverride().configureOutput(output).action((name: string) => { parsed = { kind: 'run', command: 'create', name }; }); - profile.command('rename').argument('').argument('').exitOverride().configureOutput(output).action((profileValue: string, name: string) => { + profile.command('rename', { + hidden: !hasHostedCoreCommand(coreCommands, 'profile/rename'), + }).argument('').argument('').exitOverride().configureOutput(output).action((profileValue: string, name: string) => { parsed = { kind: 'run', command: 'rename', profile: profileValue, name }; }); profile.command('use').argument('').exitOverride().configureOutput(output).action((profileValue: string) => { @@ -1785,6 +1807,7 @@ type ParsedHostedPluginSurface = function parseHostedPluginSurface( argv: readonly string[], literal: boolean, + coreCommands?: readonly HostedCoreCommandId[], ): ParsedHostedPluginSurface { let stdout = ''; let stderr = ''; @@ -1841,7 +1864,9 @@ function parseHostedPluginSurface( ...(options.authorHandle !== undefined ? { authorHandle: options.authorHandle } : {}), }; }); - const catalog = plugin.command('catalog'); + const catalog = plugin.command('catalog', { + hidden: !hasHostedCoreCommand(coreCommands, 'plugin/catalog/list'), + }); const catalogList = addOutputFormatOption(catalog.command('list')).exitOverride().configureOutput(output); catalogList.action((options: { format: string }) => { parsed = { From 0bc4f425909cd57ba1bb3471c09404254f5ca1ea Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 00:42:06 +0530 Subject: [PATCH 17/18] fix: gate hosted core leaf help --- src/hosted/runner.test.ts | 55 +++++++++++++++++++++++++++++++++++++++ src/hosted/runner.ts | 46 +++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 7877c2a4..95737a47 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -2293,6 +2293,61 @@ describe('runHostedCli', () => { for (const name of cloudOwned) expect(stdout.text()).toMatch(new RegExp(`^\\s{2}${name}(?:\\s|\\[)`, 'm')); }); + it.each([ + { argv: ['validate', '--help'], command: 'validate' }, + { argv: ['adapter', 'status', '--help'], command: 'adapter/status' }, + { argv: ['adapter', 'reset', '--help'], command: 'adapter/reset' }, + { argv: ['profile', 'create', '--help'], command: 'profile/create' }, + { argv: ['profile', 'rename', '--help'], command: 'profile/rename' }, + { argv: ['plugin', 'catalog', 'list', '--help'], command: 'plugin/catalog/list' }, + ])('rejects unavailable hosted core leaf help for $command', async ({ argv, command }) => { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + ok: true, + manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands: [] } }, + }), { status: 200 })); + + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(stdout.text()).toBe(''); + expect(stderr.text()).toContain(`webcmd ${command.replaceAll('/', ' ')} is not available from this Webcmd Cloud endpoint.`); + }); + + it.each([ + { argv: ['validate', '--help'], command: 'validate', usage: 'Usage: webcmd validate' }, + { argv: ['adapter', 'status', '--help'], command: 'adapter/status', usage: 'Usage: webcmd adapter status' }, + { argv: ['adapter', 'reset', '--help'], command: 'adapter/reset', usage: 'Usage: webcmd adapter reset' }, + { argv: ['profile', 'create', '--help'], command: 'profile/create', usage: 'Usage: webcmd profile create' }, + { argv: ['profile', 'rename', '--help'], command: 'profile/rename', usage: 'Usage: webcmd profile rename' }, + { argv: ['plugin', 'catalog', 'list', '--help'], command: 'plugin/catalog/list', usage: 'Usage: webcmd plugin catalog list' }, + ])('shows advertised hosted core leaf help for $command', async ({ argv, command, usage }) => { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ + ok: true, + manifest: { ...manifest, metadata: { ...manifest.metadata, coreCommands: [command] } }, + }), { status: 200 })); + + const result = await runHostedCli(argv, { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(`${stdout.text()}${stderr.text()}`).toContain(usage); + }); + it.each([ { name: 'before command', argv: ['--profile', 'work', 'github', 'whoami', '-f', 'json'], profile: 'work' }, { name: 'equals form', argv: ['--profile=work', 'github', 'whoami', '-f', 'json'], profile: 'work' }, diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 9f025c12..29d23d26 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -377,7 +377,13 @@ async function dispatchHosted( return; } const args = normalized.argv; + const requestsHelp = !normalized.literal && (args.includes('--help') || args.includes('-h')); const requestsNamespaceHelp = !normalized.literal && (args[1] === '--help' || args[1] === '-h'); + const requireHostedCoreHelp = async (id: HostedCoreCommandId): Promise => { + const coreCommands = await getHelpCoreCommands(); + if (!hasHostedCoreCommand(coreCommands, id)) throw hostedCoreCommandUnavailableError(id); + return coreCommands; + }; let hostedAuth: Extract | undefined; if (args[0] === 'auth' && (!args[1] || args[1] === 'status' || args[1] === 'refresh' || args[1] === '--help' || args[1] === '-h')) { const parsed = parseHostedAuthCommand(args, normalized.literal); @@ -410,6 +416,7 @@ async function dispatchHosted( ); } if (isHostedCoreRoot(args[0])) { + if (requestsHelp) await requireHostedCoreHelp(args[0]); const parsed = parseHostedCoreCommand(args, normalized.literal); await requireHostedCoreCommand(getManifest, parsed.command); const profile = parsed.command === 'verify' || parsed.command === 'doctor' @@ -445,9 +452,14 @@ async function dispatchHosted( } if (args[0] === 'adapter' && (args[1] === 'source' || args[1] === 'path' || args[1] === 'override' || args[1] === 'status' || args[1] === 'reset' || args[1] === '--help' || args[1] === '-h')) { - const coreCommands = requestsNamespaceHelp - ? await getHelpCoreCommands() + const coreHelp = requestsHelp && (args[1] === 'status' || args[1] === 'reset') + ? `adapter/${args[1]}` as HostedCoreCommandId : undefined; + const coreCommands = coreHelp + ? await requireHostedCoreHelp(coreHelp) + : requestsNamespaceHelp + ? await getHelpCoreCommands() + : undefined; await runHostedAdapterSurface(args.slice(1), normalized.literal, client, stdout, homeDir, io, getManifest, coreCommands); return; } @@ -464,9 +476,14 @@ async function dispatchHosted( } if (args[0] === 'profile') { - const coreCommands = requestsNamespaceHelp - ? await getHelpCoreCommands() + const coreHelp = requestsHelp && (args[1] === 'create' || args[1] === 'rename') + ? `profile/${args[1]}` as HostedCoreCommandId : undefined; + const coreCommands = coreHelp + ? await requireHostedCoreHelp(coreHelp) + : requestsNamespaceHelp + ? await getHelpCoreCommands() + : undefined; const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal, coreCommands); if (parsed.kind === 'help') { await writeToStream(stdout, parsed.output); @@ -495,9 +512,14 @@ async function dispatchHosted( 'Hosted mode supports: webcmd plugin search, install, list, uninstall, update, and create.', ); } - const coreCommands = requestsNamespaceHelp - ? await getHelpCoreCommands() + const coreHelp = requestsHelp && args[1] === 'catalog' && args[2] === 'list' + ? 'plugin/catalog/list' as HostedCoreCommandId : undefined; + const coreCommands = coreHelp + ? await requireHostedCoreHelp(coreHelp) + : requestsNamespaceHelp + ? await getHelpCoreCommands() + : undefined; const parsed = parseHostedPluginSurface(args.slice(1), normalized.literal, coreCommands); if (parsed.kind === 'help') { await writeToStream(stdout, parsed.output); @@ -809,16 +831,20 @@ function isHostedCoreRoot(value: string | undefined): value is ParsedHostedCoreC return value === 'validate' || value === 'verify' || value === 'convention-audit' || value === 'doctor'; } +function hostedCoreCommandUnavailableError(id: HostedCoreCommandId): ConfigError { + return new ConfigError( + `${CLI_COMMAND} ${id.replaceAll('/', ' ')} is not available from this Webcmd Cloud endpoint.`, + 'Upgrade Webcmd Cloud or use a compatible endpoint.', + ); +} + async function requireHostedCoreCommand( getManifest: () => Promise, id: HostedCoreCommandId, ): Promise { const manifest = await getManifest(); if (!hasHostedCoreCommand(manifest.metadata.coreCommands, id)) { - throw new ConfigError( - `${CLI_COMMAND} ${id.replaceAll('/', ' ')} is not available from this Webcmd Cloud endpoint.`, - 'Upgrade Webcmd Cloud or use a compatible endpoint.', - ); + throw hostedCoreCommandUnavailableError(id); } return manifest; } From 7ffd118fe5b8b0d7d82203b7b03281b77eefb899 Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Fri, 28 Aug 2026 00:47:28 +0530 Subject: [PATCH 18/18] fix: route hosted root help to stdout --- src/hosted/runner.test.ts | 6 +++++- src/hosted/runner.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 95737a47..2d0b1281 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -2323,6 +2323,9 @@ describe('runHostedCli', () => { it.each([ { argv: ['validate', '--help'], command: 'validate', usage: 'Usage: webcmd validate' }, + { argv: ['verify', '--help'], command: 'verify', usage: 'Usage: webcmd verify' }, + { argv: ['convention-audit', '--help'], command: 'convention-audit', usage: 'Usage: webcmd convention-audit' }, + { argv: ['doctor', '--help'], command: 'doctor', usage: 'Usage: webcmd doctor' }, { argv: ['adapter', 'status', '--help'], command: 'adapter/status', usage: 'Usage: webcmd adapter status' }, { argv: ['adapter', 'reset', '--help'], command: 'adapter/reset', usage: 'Usage: webcmd adapter reset' }, { argv: ['profile', 'create', '--help'], command: 'profile/create', usage: 'Usage: webcmd profile create' }, @@ -2345,7 +2348,8 @@ describe('runHostedCli', () => { expect(result).toEqual({ handled: true, exitCode: 0 }); expect(fetchImpl).toHaveBeenCalledTimes(1); - expect(`${stdout.text()}${stderr.text()}`).toContain(usage); + expect(stdout.text()).toContain(usage); + expect(stderr.text()).toBe(''); }); it.each([ diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 29d23d26..692505f1 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -272,6 +272,10 @@ export async function runHostedCli(argv: string[], opts: HostedRunnerOptions = { return { handled: true, exitCode: EXIT_CODES.USAGE_ERROR }; } if (err instanceof CommanderStructuralError) { + if (err.exitCode === EXIT_CODES.SUCCESS) { + await writeToStream(stdout, err.output); + return { handled: true, exitCode: EXIT_CODES.SUCCESS }; + } // Usage errors carry their own envelope; honour -f/--format and --json the // same way the local CLI does instead of falling back to UNKNOWN/exit 1. const usageFormat = requestedMachineFormat(argv);