diff --git a/.changeset/cli-debug-command-log.md b/.changeset/cli-debug-command-log.md new file mode 100644 index 000000000000..d95edc4d7740 --- /dev/null +++ b/.changeset/cli-debug-command-log.md @@ -0,0 +1,26 @@ +--- +'@astryxdesign/cli': patch +--- + +[feat] CLI: record every command run and hand it to a function you supply. + +```js +// astryx.config.mjs +export default { + debug: event => appendFileSync('runs.ndjson', JSON.stringify(event) + '\n'), +}; +``` + +That is the whole feature. Setting `debug` opts in; the function receives one `DebugEvent` per invocation and decides what happens to it. The CLI stores nothing. + +Each event carries the command, its arguments and flags (with their Commander source, so you can tell a typed flag from a default), the outcome, exit code, duration, error code, a coarse environment snapshot including which coding agent invoked the CLI, and — under `output` — everything the command printed to stdout and stderr. That last part is the answer the user actually got, which is what makes a record useful for improving the output rather than just counting invocations. Streams are captured separately with their true byte counts, and truncated past 32KB per stream so a command that prints a whole file does not dominate the record. Coverage is the point: handled errors, parse errors, `--help`, rejected invocations, uncaught throws, and Ctrl-C all report. The event is delivered from a `process.on('exit')` listener because the CLI's error path exits synchronously — anything hooked to normal completion would report successes and almost no failures — and the handler is loaded before parsing, because parse errors and `--help` short-circuit before any hook runs. + +`event` is a published contract: `DebugEvent` is exported from `@astryxdesign/cli/debug` with a sealed zod validator, `parseDebugEvent`, drift-locked to the type so the recorder cannot add a field without publishing it. `schemaVersion` is a literal, so widening it turns every consumer's branch into a compile error rather than a silent misread. + +The handler runs synchronously at exit — a returned promise is never awaited, so network delivery from inside it will not work; write a file or spawn a detached child. It receives a copy, so a handler that throws, or mutates what it was given, can neither fail the command nor affect anything else. Values are scrubbed before delivery: home paths, email addresses, and credential-shaped strings are replaced, and oversized values clamped. + +Hardened against an adversarial chaos run, each finding mutation-tested before its fix landed: a `__proto__` key silently reparenting the record that carried it, one oversized value discarding the whole event, an exit that bypassed `cliError` being indistinguishable from a classified failure, and a signal-terminated run leaving no record at all. + +One change reaches beyond this feature: `installJsonShim` now shims commands as they join the command tree rather than in a single walk at startup, so a command registered later can no longer silently fall out of the `--json` contract. + +@josephfarina diff --git a/.changeset/cli-init-json.md b/.changeset/cli-init-json.md new file mode 100644 index 000000000000..e64d02714728 --- /dev/null +++ b/.changeset/cli-init-json.md @@ -0,0 +1,9 @@ +--- +'@astryxdesign/cli': patch +--- + +[feat] CLI: `astryx init --json` now works. It emits the install receipt as a standard envelope — `init.run` with the mode, the features that ran, the agent-doc files written, any soft `docsError`, and the template outcome, or `init.remove` for `--remove-agents`. Human output is suppressed so stdout carries only the envelope, and the exit code is unchanged from human mode. + +`init` was the last side-effecting command still refused by the `--json` gate. That gate existed to stop a command writing half a project and only then reporting that `--json` was unsupported; since `init()` already returned a typed receipt, the fix was to emit it rather than to keep refusing. `theme` and `layout` remain off the allowlist, but both are command groups with no output of their own. + +@josephfarina diff --git a/packages/cli/README.md b/packages/cli/README.md index bd6be5ad9c2c..04be226e3b1f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -138,51 +138,53 @@ if (isError(result)) { -| Code | Meaning | -| ------------------------- | ------------------------------------------------------------------------------------- | -| `ERR_UNKNOWN` | Fallback for any error without a more specific code. | -| `ERR_UNKNOWN_COMMAND` | A top-level command name was not recognized (e.g. `astryx bogus`). | -| `ERR_UNKNOWN_SUBCOMMAND` | A subcommand under a command group was not recognized (e.g. `astryx theme bogus`). | -| `ERR_INVALID_OPTION` | An unknown flag/option was passed (Commander `unknownOption`). | -| `ERR_INVALID_ARGUMENT` | An option/argument had a value Commander's parser rejected. | -| `ERR_MISSING_ARGUMENT` | A required positional argument was omitted (Commander `missingArgument`). | -| `ERR_INVALID_LANG` | `--lang` was given a value outside its choices (en, zh, dense). | -| `ERR_INVALID_DETAIL` | `--detail` was given a value outside its choices (full, compact, brief). | -| `ERR_NODE_VERSION` | The running Node.js version is below the supported minimum. | -| `ERR_CORE_NOT_FOUND` | `@astryxdesign/core` could not be located (not installed / not in a monorepo). | -| `ERR_UNKNOWN_COMPONENT` | No component matched the requested name. | -| `ERR_UNKNOWN_HOOK` | No hook matched the requested name. | -| `ERR_UNKNOWN_TOPIC` | No docs topic matched the requested name. | -| `ERR_UNKNOWN_SECTION` | A docs topic exists but the requested section within it does not. | -| `ERR_UNKNOWN_CATEGORY` | A `--category` filter value did not match any known category. | -| `ERR_UNKNOWN_TEMPLATE` | No template matched the requested name. | -| `ERR_AMBIGUOUS_TEMPLATE` | A template id matched more than one template (narrow with --type/--package). | -| `ERR_AMBIGUOUS_COMPONENT` | A component name is owned by more than one package (narrow with --package). | -| `ERR_UNKNOWN_THEME` | No theme matched the requested slug (theme add). | -| `ERR_UNKNOWN_PACKAGE` | No package matched the requested name (discover). | -| `ERR_UNKNOWN_AGENT` | An unrecognized `--agent` value was passed to agent-docs/init. | -| `ERR_UNKNOWN_FEATURE` | An unrecognized `--features` value was passed to init. | -| `ERR_UNKNOWN_CODEMOD` | A `--codemod` value did not match any registered codemod (upgrade). | -| `ERR_CODEMOD_FAILED` | One or more codemods failed during an upgrade run. | -| `ERR_NOT_FOUND` | A generic discover/lookup query matched nothing in any package. | -| `ERR_NO_DOC` | A component exists but has no typed `.doc.mjs` file. | -| `ERR_NO_SHOWCASE` | No showcase exists for the requested component. | -| `ERR_NO_SOURCE` | No source file could be located for the requested component/template. | -| `ERR_INVALID_DOC` | A component's docs failed validation (malformed `.doc.mjs`). | -| `ERR_FILE_NOT_FOUND` | A required input file did not exist. | -| `ERR_FILE_EXISTS` | Refused to overwrite an existing file in non-interactive mode. | -| `ERR_PATH_TRAVERSAL` | A path escaped its allowed root, or a name contained traversal markers. | -| `ERR_WRITE_FAILED` | Writing output files failed (and was rolled back). | -| `ERR_THEME_INVALID` | A theme definition was missing a required property (e.g. `name`). | -| `ERR_THEME_LOAD` | A theme file could not be loaded / parsed into a defineTheme result. | -| `ERR_VERSION_DETECT` | The current `@astryxdesign/core` version could not be detected. | -| `ERR_INVALID_VERSION` | A `--from`/`--to` value was not a valid semver string. | -| `ERR_DEP_MISSING` | A required external dependency (e.g. jscodeshift) is missing. | -| `ERR_GH_CLI` | GitHub CLI (`gh`) is not installed or not authenticated. | -| `ERR_UNKNOWN_POST` | No blog post matched the requested slug in the feed. | -| `ERR_FETCH_FAILED` | A network fetch (RSS feed or post text) failed. | -| `ERR_LAYOUT_PARSE` | A layout expression failed to parse (syntax error, with line/col). | -| `ERR_LAYOUT_INVALID` | A layout expression parsed but failed validation (unknown component/prop/enum/block). | +| Code | Meaning | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `ERR_UNKNOWN` | Fallback for any error without a more specific code. | +| `ERR_UNKNOWN_COMMAND` | A top-level command name was not recognized (e.g. `astryx bogus`). | +| `ERR_UNKNOWN_SUBCOMMAND` | A subcommand under a command group was not recognized (e.g. `astryx theme bogus`). | +| `ERR_INVALID_OPTION` | An unknown flag/option was passed (Commander `unknownOption`). | +| `ERR_INVALID_ARGUMENT` | An option/argument had a value Commander's parser rejected. | +| `ERR_MISSING_ARGUMENT` | A required positional argument was omitted (Commander `missingArgument`). | +| `ERR_INVALID_LANG` | `--lang` was given a value outside its choices (en, zh, dense). | +| `ERR_INVALID_DETAIL` | `--detail` was given a value outside its choices (full, compact, brief). | +| `ERR_NODE_VERSION` | The running Node.js version is below the supported minimum. | +| `ERR_CORE_NOT_FOUND` | `@astryxdesign/core` could not be located (not installed / not in a monorepo). | +| `ERR_UNKNOWN_COMPONENT` | No component matched the requested name. | +| `ERR_UNKNOWN_HOOK` | No hook matched the requested name. | +| `ERR_UNKNOWN_TOPIC` | No docs topic matched the requested name. | +| `ERR_UNKNOWN_SECTION` | A docs topic exists but the requested section within it does not. | +| `ERR_UNKNOWN_CATEGORY` | A `--category` filter value did not match any known category. | +| `ERR_UNKNOWN_TEMPLATE` | No template matched the requested name. | +| `ERR_AMBIGUOUS_TEMPLATE` | A template id matched more than one template (narrow with --type/--package). | +| `ERR_AMBIGUOUS_COMPONENT` | A component name is owned by more than one package (narrow with --package). | +| `ERR_UNKNOWN_THEME` | No theme matched the requested slug (theme add). | +| `ERR_UNKNOWN_PACKAGE` | No package matched the requested name (discover). | +| `ERR_UNKNOWN_AGENT` | An unrecognized `--agent` value was passed to agent-docs/init. | +| `ERR_UNKNOWN_FEATURE` | An unrecognized `--features` value was passed to init. | +| `ERR_UNKNOWN_CODEMOD` | A `--codemod` value did not match any registered codemod (upgrade). | +| `ERR_CODEMOD_FAILED` | One or more codemods failed during an upgrade run. | +| `ERR_NOT_FOUND` | A generic discover/lookup query matched nothing in any package. | +| `ERR_NO_DOC` | A component exists but has no typed `.doc.mjs` file. | +| `ERR_NO_SHOWCASE` | No showcase exists for the requested component. | +| `ERR_NO_SOURCE` | No source file could be located for the requested component/template. | +| `ERR_INVALID_DOC` | A component's docs failed validation (malformed `.doc.mjs`). | +| `ERR_FILE_NOT_FOUND` | A required input file did not exist. | +| `ERR_FILE_EXISTS` | Refused to overwrite an existing file in non-interactive mode. | +| `ERR_PATH_TRAVERSAL` | A path escaped its allowed root, or a name contained traversal markers. | +| `ERR_WRITE_FAILED` | Writing output files failed (and was rolled back). | +| `ERR_THEME_INVALID` | A theme definition was missing a required property (e.g. `name`). | +| `ERR_THEME_LOAD` | A theme file could not be loaded / parsed into a defineTheme result. | +| `ERR_VERSION_DETECT` | The current `@astryxdesign/core` version could not be detected. | +| `ERR_INVALID_VERSION` | A `--from`/`--to` value was not a valid semver string. | +| `ERR_DEP_MISSING` | A required external dependency (e.g. jscodeshift) is missing. | +| `ERR_GH_CLI` | GitHub CLI (`gh`) is not installed or not authenticated. | +| `ERR_UNKNOWN_POST` | No blog post matched the requested slug in the feed. | +| `ERR_FETCH_FAILED` | A network fetch (RSS feed or post text) failed. | +| `ERR_LAYOUT_PARSE` | A layout expression failed to parse (syntax error, with line/col). | +| `ERR_LAYOUT_INVALID` | A layout expression parsed but failed validation (unknown component/prop/enum/block). | +| `ERR_UNCLASSIFIED_EXIT` | Recorded in the debug log, never printed: a command exited non-zero without going through cliError/jsonError, so no stable code was available. | +| `ERR_SIGNAL_TERMINATED` | Recorded in the debug log, never printed: the process was ended by a signal (Ctrl-C, SIGTERM) before the command reached a terminal path. | @@ -383,48 +385,50 @@ Every response has a `type` discriminant. The full set is below (generated from -| Type | What `data` carries | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `component.list` | The component catalog grouped by category: `detail` (the level — names \| compact \| full) and `components`, the grouped map of names+package, brief entries, or a full ComponentDoc per entry. | -| `component.detail` | One component's authored ComponentDoc plus ownership metadata (owner package, import specifier, and whether source is available). | -| `component.detail.props` | Just one component's props table (ComponentPropDoc[]). | -| `component.detail.source` | One component's source file, as {component, source}. | -| `component.detail.showcase` | One component's showcase example, as {component, aspectRatio, source}. | -| `component.detail.blocks` | One component's example blocks, as {component, showcase, examples, related} of BlockEntry. | -| `docs.list` | All reference-doc topics as DocsListEntry[] ({topic, description}), in discovery order. | -| `docs.detail` | One topic's full ReferenceDoc, with token-ref blocks inlined. | -| `docs.detail.section` | A single ReferenceSection of a topic — the first whose title contains the section query. | -| `blog.list` | The feed URL plus every post parsed from the RSS feed — each with slug, title, description, date, type, authors, link, and plaintext URL. | -| `blog.detail` | One post's metadata plus the feed URL and the post's full plaintext body. | -| `discover.list` | The configured external packages (name, category, components, version, description); when empty it carries meta.configured to tell "nothing configured" from "nothing discovered". | -| `discover.detail` | A single external package entry, for an @scope/name query. | -| `discover.detail.doc` | The validated ComponentDoc for one external component — an @scope/name/Component query, or a free-text term resolving to exactly one component. | -| `discover.search` | The echoed query plus the matching {package, component} pairs, when a free-text term matches several components. | -| `search` | The echoed query plus a ranked SearchResultEntry[] (domain, name, score, reason, description, follow-up command, and import path where relevant). | -| `build.help` | A marker (`playbook: true`) that the renderer expands into the how-to-build-a-page workflow; emitted when no query is given. | -| `build.kit` | The grouped composition kit: echoed query, hasResults/directMatch flags, the closest page templates, drop-in block patterns, idea-specific components/hooks, and the always-on frame + foundation component-name arrays. | -| `swizzle.list` | The names of swizzlable components discoverable from cwd's @astryxdesign/core. | -| `swizzle.copy` | An eject receipt: component name, owning package, output directory, files-copied count, the written file names, whether any file uses StyleX, and an optional maintainer note. | -| `template.list` | Every discovered template (page + block); each entry carries id, name, description, kind, owning package, optional category and componentsUsed, and readiness flags. | -| `template.show` | The resolved template's raw source plus its description, kind, and the component names it composes. | -| `template.skeleton` | A layout skeleton (structural tags with spatial annotations) plus the template's description and the components it composes. | -| `template.copy` | A scaffold receipt: template id, output directory, written file name, and file count. | -| `hook.list` | The hook catalog grouped by category: `detail` (the level — names \| compact \| full) and `components`, the grouped map of hook names, brief entries, or a full HookDoc per entry. | -| `hook.detail` | One hook's full authored HookDoc. | -| `hook.detail.params` | Just one hook's parameters table (HookParamDoc[]). | -| `theme.build` | A theme build receipt: name, token- and component-override counts, output size, the written outputs {css, js, dts, and variantsDts when applicable}, and any validation warnings. | -| `theme.build.check` | The --check receipt: theme name, an upToDate flag, the stale outputs (each {path, reason: missing \| outdated}), and the full list of checked paths. Writes nothing. | -| `theme.list` | Every bundled theme as a ThemeListEntry[] — each with slug, displayName, description, and a maintained flag. | -| `theme.add` | A scaffold receipt: resolved slug, displayName, maintained flag, outputDir (relative to cwd), the theme entry file, its exportName, and the files written. | -| `upgrade.list` | Every available codemod, oldest→newest, as {name, title, version, optional}; returned for --list without running anything. | -| `upgrade.status` | A short-circuit outcome with no codemods run — up_to_date, no_codemods, or config_fixable — each carrying the agent-docs summary. | -| `upgrade.run` | The run receipt: from/to versions, codemod count, integrations processed, the agent-docs summary, and (apply mode) filesChanged, transformsApplied, and per-codemod errors. | -| `manifest` | The self-describing CLI capability manifest: name, version, apiVersion, global options, the command tree (args, options, json flag, response types, examples), the jsonSupported allowlist, and the flat responseTypes index. | -| `doctor` | The health-check report: `checks` (each with id, label, status: pass \| warn \| fail \| info, a message, and a fix when not passing) plus a `summary` of counts per status. | -| `integration.validate` | The validation result: the package name and version (both null when no local manifest is found) plus issues, an AstryxIntegrationIssue[] of {code, severity: warning \| error, message}. | -| `layout.expand` | The expansion: parsed form, generated TSX code, componentsUsed, states (count of useState hooks scaffolded), todos, blocksReferenced (each {name, mode}), warnings, and written (the output path, or null when nothing was written). | -| `layout.check` | The validation result: a valid flag, the detected form, errors (each with line/col, message, formatted text, and suggestions), warnings, and the expression re-printed in both canonical surfaces (compact and outline). | -| `layout.grammar` | The XLE/XLO grammar cheatsheet: a text field with the full reference plus an aliases map (short name → canonical component) generated from this install's registry. | +| Type | What `data` carries | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `init.run` | The install receipt: the `mode` (`default` \| `features`), the features run, agent-doc files written, any soft `docsError`, whether theme guidance was emitted, the template outcome (`workflow` \| `created` \| `skipped`) plus its path, and whether the next-steps were emitted. | +| `init.remove` | Confirmation that the managed agent-docs block was removed (`data.removed: true`) — returned when --remove-agents is set. | +| `component.list` | The component catalog grouped by category: `detail` (the level — names \| compact \| full) and `components`, the grouped map of names+package, brief entries, or a full ComponentDoc per entry. | +| `component.detail` | One component's authored ComponentDoc plus ownership metadata (owner package, import specifier, and whether source is available). | +| `component.detail.props` | Just one component's props table (ComponentPropDoc[]). | +| `component.detail.source` | One component's source file, as {component, source}. | +| `component.detail.showcase` | One component's showcase example, as {component, aspectRatio, source}. | +| `component.detail.blocks` | One component's example blocks, as {component, showcase, examples, related} of BlockEntry. | +| `docs.list` | All reference-doc topics as DocsListEntry[] ({topic, description}), in discovery order. | +| `docs.detail` | One topic's full ReferenceDoc, with token-ref blocks inlined. | +| `docs.detail.section` | A single ReferenceSection of a topic — the first whose title contains the section query. | +| `blog.list` | The feed URL plus every post parsed from the RSS feed — each with slug, title, description, date, type, authors, link, and plaintext URL. | +| `blog.detail` | One post's metadata plus the feed URL and the post's full plaintext body. | +| `discover.list` | The configured external packages (name, category, components, version, description); when empty it carries meta.configured to tell "nothing configured" from "nothing discovered". | +| `discover.detail` | A single external package entry, for an @scope/name query. | +| `discover.detail.doc` | The validated ComponentDoc for one external component — an @scope/name/Component query, or a free-text term resolving to exactly one component. | +| `discover.search` | The echoed query plus the matching {package, component} pairs, when a free-text term matches several components. | +| `search` | The echoed query plus a ranked SearchResultEntry[] (domain, name, score, reason, description, follow-up command, and import path where relevant). | +| `build.help` | A marker (`playbook: true`) that the renderer expands into the how-to-build-a-page workflow; emitted when no query is given. | +| `build.kit` | The grouped composition kit: echoed query, hasResults/directMatch flags, the closest page templates, drop-in block patterns, idea-specific components/hooks, and the always-on frame + foundation component-name arrays. | +| `swizzle.list` | The names of swizzlable components discoverable from cwd's @astryxdesign/core. | +| `swizzle.copy` | An eject receipt: component name, owning package, output directory, files-copied count, the written file names, whether any file uses StyleX, and an optional maintainer note. | +| `template.list` | Every discovered template (page + block); each entry carries id, name, description, kind, owning package, optional category and componentsUsed, and readiness flags. | +| `template.show` | The resolved template's raw source plus its description, kind, and the component names it composes. | +| `template.skeleton` | A layout skeleton (structural tags with spatial annotations) plus the template's description and the components it composes. | +| `template.copy` | A scaffold receipt: template id, output directory, written file name, and file count. | +| `hook.list` | The hook catalog grouped by category: `detail` (the level — names \| compact \| full) and `components`, the grouped map of hook names, brief entries, or a full HookDoc per entry. | +| `hook.detail` | One hook's full authored HookDoc. | +| `hook.detail.params` | Just one hook's parameters table (HookParamDoc[]). | +| `theme.build` | A theme build receipt: name, token- and component-override counts, output size, the written outputs {css, js, dts, and variantsDts when applicable}, and any validation warnings. | +| `theme.build.check` | The --check receipt: theme name, an upToDate flag, the stale outputs (each {path, reason: missing \| outdated}), and the full list of checked paths. Writes nothing. | +| `theme.list` | Every bundled theme as a ThemeListEntry[] — each with slug, displayName, description, and a maintained flag. | +| `theme.add` | A scaffold receipt: resolved slug, displayName, maintained flag, outputDir (relative to cwd), the theme entry file, its exportName, and the files written. | +| `upgrade.list` | Every available codemod, oldest→newest, as {name, title, version, optional}; returned for --list without running anything. | +| `upgrade.status` | A short-circuit outcome with no codemods run — up_to_date, no_codemods, or config_fixable — each carrying the agent-docs summary. | +| `upgrade.run` | The run receipt: from/to versions, codemod count, integrations processed, the agent-docs summary, and (apply mode) filesChanged, transformsApplied, and per-codemod errors. | +| `manifest` | The self-describing CLI capability manifest: name, version, apiVersion, global options, the command tree (args, options, json flag, response types, examples), the jsonSupported allowlist, and the flat responseTypes index. | +| `doctor` | The health-check report: `checks` (each with id, label, status: pass \| warn \| fail \| info, a message, and a fix when not passing) plus a `summary` of counts per status. | +| `integration.validate` | The validation result: the package name and version (both null when no local manifest is found) plus issues, an AstryxIntegrationIssue[] of {code, severity: warning \| error, message}. | +| `layout.expand` | The expansion: parsed form, generated TSX code, componentsUsed, states (count of useState hooks scaffolded), todos, blocksReferenced (each {name, mode}), warnings, and written (the output path, or null when nothing was written). | +| `layout.check` | The validation result: a valid flag, the detected form, errors (each with line/col, message, formatted text, and suggestions), warnings, and the expression re-printed in both canonical surfaces (compact and outline). | +| `layout.grammar` | The XLE/XLO grammar cheatsheet: a text field with the full reference plus an aliases map (short name → canonical component) generated from this install's registry. | diff --git a/packages/cli/authoring/config/config.doc.mjs b/packages/cli/authoring/config/config.doc.mjs index 4fe1e54ea50b..dc9e5bc5ccd8 100644 --- a/packages/cli/authoring/config/config.doc.mjs +++ b/packages/cli/authoring/config/config.doc.mjs @@ -14,8 +14,9 @@ export const doc = { namespace: 'cli', description: 'The optional astryx.config.* file at your project root. Declares which ' + - 'integrations to load, where to route issue links, post-codemod hooks, and ' + - 'experimental layout components. All fields are optional; {} is valid.', + 'integrations to load, where to route issue links, post-codemod hooks, local ' + + 'debug-log settings, and experimental layout components. All fields are ' + + 'optional; {} is valid.', appliesTo: 'astryx.config.{ts,mjs,js}', fields: [ { @@ -43,6 +44,13 @@ export const doc = { }, ], }, + { + name: 'debug', + type: '(event: DebugEvent) => void', + description: + 'Record every astryx command run in this project and hand each one to this function. Setting it is the whole opt-in; leaving it out records nothing. It runs synchronously at process exit, so a returned promise is never awaited — use it for synchronous work only.', + example: "event => appendFileSync('runs.ndjson', JSON.stringify(event) + '\\n')", + }, { name: 'experimental', type: '{ xle?: { components?: Record } }', @@ -62,6 +70,13 @@ export const doc = { label: 'Minimal', code: "export default {\n integrations: ['@acme/astryx-widgets'],\n};", }, + { + label: 'Send every command run somewhere of your own', + code: + 'export default {\n' + + ' debug: event => appendFileSync("runs.ndjson", JSON.stringify(event) + "\\n"),\n' + + '};', + }, ], notes: [ { diff --git a/packages/cli/authoring/config/parse.mjs b/packages/cli/authoring/config/parse.mjs index 7e0f593c15a6..b0a9495dc696 100644 --- a/packages/cli/authoring/config/parse.mjs +++ b/packages/cli/authoring/config/parse.mjs @@ -15,6 +15,8 @@ import {formatZodError} from '../_shared/errors.mjs'; /** @typedef {import('./type').AstryxConfig} AstryxConfig */ /** @typedef {import('./type').PostCodemodHook} PostCodemodHook */ /** @typedef {import('./type').XleComponent} XleComponent */ +/** @typedef {import('./type').DebugConfig} DebugConfig */ +/** @typedef {import('../debug/type').DebugEventHandler} DebugEventHandler */ // Typed `z.custom` so `z.infer` reproduces the real function type (not `unknown`). const buildCommand = /** @type {z.ZodType} */ ( @@ -36,6 +38,12 @@ const xleComponentSchema = z }) .strict(); +// Typed `z.custom` so `z.infer` reproduces the real handler type (not +// `unknown`), the same way the post-codemod hook above keeps its signature. +const debugSchema = /** @type {z.ZodType} */ ( + z.custom(value => typeof value === 'function', {message: 'Expected a function'}) +); + const configSchema = z .object({ integrations: z.array(z.string()).optional(), @@ -44,6 +52,7 @@ const configSchema = z .object({postCodemod: z.array(postCodemodHookSchema).optional()}) .strict() .optional(), + debug: debugSchema.optional(), experimental: z .object({ xle: z diff --git a/packages/cli/authoring/config/type.ts b/packages/cli/authoring/config/type.ts index 02569a0b99ec..ba3076f716f0 100644 --- a/packages/cli/authoring/config/type.ts +++ b/packages/cli/authoring/config/type.ts @@ -9,6 +9,8 @@ * boundary; there is no factory to call. */ +import type {DebugEventHandler} from '../debug/type'; + /** * A command to run as part of a post-codemod hook. Returned by a hook's * `buildCommand` and executed after codemods write files. @@ -50,6 +52,23 @@ export interface XleComponent { default?: boolean; } +/** + * Record every astryx command run in this project. + * + * A function that receives each run. Setting it is the whole opt-in; leave it + * out and nothing is recorded. + * + * ``` + * export default { + * debug: event => appendFileSync('runs.ndjson', JSON.stringify(event) + '\n'), + * }; + * ``` + * + * Runs synchronously at process exit — see {@link DebugEventHandler} for what + * that rules out. + */ +export type DebugConfig = DebugEventHandler; + /** User config exported from astryx.config.{ts,mjs,js}. */ export interface AstryxConfig { /** Integration package names to load. */ @@ -60,6 +79,8 @@ export interface AstryxConfig { hooks?: { postCodemod?: PostCodemodHook[]; }; + /** Record every astryx command run in this project. See {@link DebugConfig}. */ + debug?: DebugConfig; /** * EXPERIMENTAL — shape may change and is not part of the stable config * contract. Provisional home for features still being proven out. diff --git a/packages/cli/authoring/debug/parse.mjs b/packages/cli/authoring/debug/parse.mjs new file mode 100644 index 000000000000..6ae62df2d974 --- /dev/null +++ b/packages/cli/authoring/debug/parse.mjs @@ -0,0 +1,143 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Validator for a recorded run — the boundary a consumer reads through. + * + * The CLI hands {@link DebugEvent}s to a project's `debug` function; anything + * reading them back later is reading data that may have been produced by a + * different CLI version, hand-edited, or replayed from a warehouse. + * `parseDebugEvent` turns `unknown` into the typed shape or throws a readable + * error, exactly as the config and integration parsers do at their own + * boundaries. + * + * Zod is sealed in here: the schema is module-private, never exported, and + * never appears in a public type. A compile-time drift-lock asserts it still + * infers exactly the published interface. + * + * NOTE this is deliberately NOT on the delivery path. The recorder must never + * fail a command to satisfy a schema, so it delivers what it captured and this + * validates on the way back in. + */ + +import {z} from 'zod'; +import {formatZodError} from '../_shared/errors.mjs'; + +/** @typedef {import('./type').DebugEvent} DebugEvent */ + +const outcomeSchema = z.enum([ + 'ok', + 'error', + 'parse-error', + 'fatal', + 'rejected', + 'incomplete', +]); + +const optionSourceSchema = z.enum([ + 'cli', + 'default', + 'env', + 'config', + 'implied', +]); + +const errorSchema = z + .object({ + name: z.string(), + message: z.string(), + code: z.string().nullable(), + stack: z.string().nullable(), + }) + .strict(); + +const outputSchema = z + .object({ + jsonMode: z.boolean(), + envelopeTypes: z.array(z.string()), + handled: z.boolean(), + helpDisplayed: z.boolean(), + stdout: z.string(), + stderr: z.string(), + stdoutBytes: z.number(), + stderrBytes: z.number(), + truncated: z.boolean(), + }) + .strict(); + +const envSchema = z + .object({ + cliVersion: z.string().nullable(), + nodeVersion: z.string(), + platform: z.string(), + arch: z.string(), + ci: z.boolean(), + ciName: z.string().nullable(), + agent: z.string().nullable(), + oneOff: z.boolean(), + packageManager: z.string().nullable(), + tty: z.boolean(), + locale: z.string().nullable(), + timezone: z.string().nullable(), + }) + .strict(); + +const projectSchema = z + .object({ + inProject: z.boolean().nullable(), + hasConfig: z.boolean().nullable(), + initialized: z.boolean().nullable(), + integrationCount: z.number().nullable(), + }) + .strict(); + +const eventSchema = z + .object({ + schemaVersion: z.literal(1), + id: z.string(), + installId: z.string().nullable(), + startedAt: z.string(), + endedAt: z.string(), + durationMs: z.number(), + command: z.string(), + commandPath: z.array(z.string()), + argv: z.array(z.string()), + args: z.record(z.string(), z.unknown()), + options: z.record(z.string(), z.unknown()), + optionSources: z.record(z.string(), optionSourceSchema), + globalOptions: z.record(z.string(), z.unknown()), + outcome: outcomeSchema, + exitCode: z.number().nullable(), + signal: z.string().nullable(), + error: errorSchema.nullable(), + output: outputSchema, + env: envSchema, + project: projectSchema, + redacted: z.boolean(), + }) + .strict(); + +/** + * Compile-time drift-lock: the sealed schema must infer EXACTLY the public + * {@link DebugEvent} type. If they drift, `Equal` becomes `false` and + * `Expect` fails the `tsconfig.authoring-contract.json` typecheck — + * so a field added to the recorder without being published here breaks CI. + * + * @typedef {import('../_shared/contract').Expect< + * import('../_shared/contract').MutuallyAssignable, DebugEvent> + * >} _DebugEventDriftLock + */ + +/** + * Validate an unknown value as a recorded run, or throw a readable error. + * + * @param {unknown} input + * @param {string} [label] + * @returns {DebugEvent} + */ +export function parseDebugEvent(input, label = 'debug event') { + const result = eventSchema.safeParse(input); + if (!result.success) { + throw new Error(formatZodError(label, result.error)); + } + return /** @type {DebugEvent} */ (result.data); +} diff --git a/packages/cli/authoring/debug/type.ts b/packages/cli/authoring/debug/type.ts new file mode 100644 index 000000000000..7d6d0d22915f --- /dev/null +++ b/packages/cli/authoring/debug/type.ts @@ -0,0 +1,188 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * Public shape of one recorded CLI run. + * + * This is a CONTRACT, not an internal detail. A project attaches a function + * in `astryx.config` (`debug: event => {}`) and receives one of these per + * command run. Whatever that function feeds — a warehouse table, a file, a + * one-off script — codes against {@link DebugEvent}. + * + * Treat it as append-only. Add fields freely; never repurpose or remove one. + * When an existing field changes meaning, widen {@link DebugSchemaVersion} so + * every consumer gets a compile error at the branch points instead of quietly + * reading a field that no longer means what it did. + */ + +/** + * Version of the recorded shape. A literal on purpose: when this becomes + * `1 | 2`, code that switches on it stops compiling until it handles both. + */ +export type DebugSchemaVersion = 1; + +/** + * How an invocation ended. + * + * - `ok` — completed; exit 0. Includes runs that only printed help. + * - `error` — a handled failure (the CLI's error path), or a signal. + * - `parse-error` — the input was rejected before any command body ran. + * - `fatal` — an uncaught throw or unhandled rejection. + * - `rejected` — refused by a gate, e.g. `--json` on an unsupported command. + * - `incomplete` — the process died without reaching any terminal path. + */ +export type DebugOutcome = + 'ok' | 'error' | 'parse-error' | 'fatal' | 'rejected' | 'incomplete'; + +/** + * Where an option's value came from. `cli` means a person or script typed it; + * `default` means nobody did. Without this you cannot tell a deliberate choice + * from a default that happens to be recorded. + */ +export type DebugOptionSource = + 'cli' | 'default' | 'env' | 'config' | 'implied'; + +/** The failure, when there was one. */ +export interface DebugEventError { + name: string; + /** Human-readable and free to change. Do not branch on it. */ + message: string; + /** + * Stable machine-readable code (see the CLI's error-codes reference). + * This is the field to branch on. Null when nothing supplied one. + */ + code: string | null; + stack: string | null; +} + +/** What the run put on its output streams. */ +export interface DebugEventOutput { + /** Whether `--json` was active. */ + jsonMode: boolean; + /** Response `type` discriminants emitted, e.g. `['component.list']`. */ + envelopeTypes: string[]; + /** Whether a JSON envelope was emitted. */ + handled: boolean; + /** Whether the run ended by printing help rather than doing work. */ + helpDisplayed: boolean; + /** + * Everything the command printed to stdout — the answer the user actually + * got. Scrubbed like every other captured value, and truncated past + * `stdoutBytes` when the run printed more than the capture limit. + */ + stdout: string; + /** Everything the command printed to stderr: errors, warnings, hints. */ + stderr: string; + /** Bytes written to stdout, before any truncation. */ + stdoutBytes: number; + /** Bytes written to stderr, before any truncation. */ + stderrBytes: number; + /** Whether either stream exceeded the capture limit and was cut short. */ + truncated: boolean; +} + +/** + * Machine and runtime facts. Deliberately coarse: no hostname, no username, + * no network identity — everything here is either a bucket or something the + * user could read off their own `astryx doctor` output. + */ +export interface DebugEventEnv { + cliVersion: string | null; + nodeVersion: string; + /** Node's `process.platform`, e.g. 'darwin' | 'linux' | 'win32'. */ + platform: string; + /** Node's `process.arch`, e.g. 'arm64' | 'x64'. */ + arch: string; + ci: boolean; + /** Detected CI provider, e.g. 'github-actions'. Null outside CI. */ + ciName: string | null; + /** Coding agent that invoked the CLI, e.g. 'cursor' | 'claude-code'. */ + agent: string | null; + /** Run one-off via npx/dlx rather than an installed binary. */ + oneOff: boolean; + packageManager: string | null; + tty: boolean; + locale: string | null; + timezone: string | null; +} + +/** + * Shape of the project the command ran against. Names of a user's private + * packages are identifying, so this records counts and flags, never names. + */ +export interface DebugEventProject { + /** Whether a package.json was found at or above the working directory. */ + inProject: boolean | null; + /** Whether an astryx.config file was loaded. */ + hasConfig: boolean | null; + /** Whether `astryx init` has been run (the agent-docs marker is present). */ + initialized: boolean | null; + integrationCount: number | null; +} + +/** + * One CLI invocation — exactly one per run, one JSON line on disk. + * + * Every field is present on a persisted event. Timing and outcome fields are + * filled in as the run ends, so nothing here is optional by the time a + * consumer sees it. + */ +export interface DebugEvent { + schemaVersion: DebugSchemaVersion; + /** Unique per invocation. Collectors should dedupe on this. */ + id: string; + /** Anonymous, stable per install, resettable by the user. */ + installId: string | null; + + /** ISO 8601. */ + startedAt: string; + /** ISO 8601. */ + endedAt: string; + durationMs: number; + + /** Fully qualified, e.g. `'theme build'`. Empty for a bare invocation. */ + command: string; + /** The same name split into segments, e.g. `['theme', 'build']`. */ + commandPath: string[]; + /** Arguments after the binary, scrubbed. */ + argv: string[]; + /** Positional arguments keyed by their declared names. */ + args: Record; + /** Command-level options as Commander resolved them. */ + options: Record; + /** Per-option provenance; keys mirror `options`. */ + optionSources: Record; + /** Root-level flags (`--json`, `--detail`, `--lang`, …). */ + globalOptions: Record; + + outcome: DebugOutcome; + exitCode: number | null; + /** Signal that ended the process, e.g. `'SIGINT'`. Null otherwise. */ + signal: string | null; + error: DebugEventError | null; + + output: DebugEventOutput; + env: DebugEventEnv; + project: DebugEventProject; + + /** + * Whether the scrubbing pass ran. When false, values are verbatim — treat + * such a log as sensitive. + */ + redacted: boolean; +} + +/** + * A function that receives each recorded run, attached via + * `debug.onEvent` in `astryx.config`. + * + * IMPORTANT — this is called synchronously as the process exits, so a promise + * it returns will never be awaited and pending I/O will not complete. Use it + * for synchronous work only: appending to a file, pushing to an in-memory + * buffer, `spawn`ing a detached child that does the slow part. Sending a run + * over the network from here will not work. + * + * The event is a copy, so mutating it is harmless and changes nothing that + * reaches the log. Anything the handler throws is swallowed; recording must + * never fail a command. + */ +export type DebugEventHandler = (event: DebugEvent) => void; diff --git a/packages/cli/authoring/index.d.ts b/packages/cli/authoring/index.d.ts index 6f1fba37a593..0ea486ec2fc1 100644 --- a/packages/cli/authoring/index.d.ts +++ b/packages/cli/authoring/index.d.ts @@ -32,6 +32,7 @@ export type {SchemaDoc} from './doctypes/types'; // config.doc.mjs (object export type {CommandDoc} from './doctypes/types'; // search.doc.mjs (CLI command) export type {EnumDoc} from './doctypes/types'; // error-codes.doc.mjs (vocabulary) export type {AstryxConfig} from './config/type'; // astryx.config.{ts,mjs} +export type {DebugEvent} from './debug/type'; // one recorded CLI run export type {AstryxIntegration} from './integration/type'; // astryx.integration.{ts,mjs} export type {AstryxCodemod, AstryxConfigCodemod} from './codemod/type'; // codemods/* @@ -51,6 +52,7 @@ export {parseLegacyDoc} from './doctypes/legacy.mjs'; export {parseConfig} from './config/parse.mjs'; export {parseIntegration} from './integration/parse.mjs'; export {parseCodemod} from './codemod/parse.mjs'; +export {parseDebugEvent} from './debug/parse.mjs'; // ═══════════════════════════════════════════════════════════════════════ // FIELD & SUB-TYPES — the building blocks of the docs above. Import these @@ -98,7 +100,18 @@ export type { // enum EnumMemberDoc, } from './doctypes/types'; -export type {PostCodemodHook} from './config/type'; +export type {PostCodemodHook, DebugConfig} from './config/type'; +export type { + // debug + DebugSchemaVersion, + DebugOutcome, + DebugOptionSource, + DebugEventError, + DebugEventOutput, + DebugEventEnv, + DebugEventProject, + DebugEventHandler, +} from './debug/type'; export type { AstryxCodemodDef, AstryxConfigCodemodDef, diff --git a/packages/cli/authoring/index.mjs b/packages/cli/authoring/index.mjs index 2bed05337acb..5de0107d590f 100644 --- a/packages/cli/authoring/index.mjs +++ b/packages/cli/authoring/index.mjs @@ -17,6 +17,7 @@ export {parseConfig} from './config/parse.mjs'; export {parseIntegration} from './integration/parse.mjs'; export {parseCodemod} from './codemod/parse.mjs'; +export {parseDebugEvent} from './debug/parse.mjs'; export {parseDoc} from './doctypes/parse.mjs'; export {parseComponent} from './doctypes/component/parse.mjs'; export {parseHook} from './doctypes/hook/parse.mjs'; diff --git a/packages/cli/clients/cli/bin/astryx.mjs b/packages/cli/clients/cli/bin/astryx.mjs index d53093ff6e19..9ef24ce14af0 100755 --- a/packages/cli/clients/cli/bin/astryx.mjs +++ b/packages/cli/clients/cli/bin/astryx.mjs @@ -55,9 +55,10 @@ if (!isNodeVersionSupported(process.versions.node)) { // Imports that transitively load `styleText` must happen AFTER the gate above, // so they are dynamically imported here rather than at the top of the module. -const {program} = await importSrc('index.mjs'); +const {program, loadProjectDebugHandler} = await importSrc('index.mjs'); const {isJsonMode, toErrorEnvelope} = await importSrc('../../foundation/response/json.mjs'); const {handleCommanderError} = await importSrc('lib/json-shim.mjs'); +const {setOutcome} = await importSrc('../../foundation/debug/index.mjs'); /** * Top-level error boundary (contract guarantee #4): an uncaught throw must @@ -81,6 +82,11 @@ function handleFatal(err) { // calls process.exit when it owns the error. if (handleCommanderError(err)) return; + // Anything still here is an uncaught throw or an unhandled rejection — + // a bug rather than a user error, and the class of failure most worth + // having a record of. + setOutcome('fatal', {exitCode: 1, error: err}); + if (inJsonMode()) { // Only emit if a command didn't already produce an envelope. if (!process.__xdsJsonHandled) { @@ -97,6 +103,10 @@ function handleFatal(err) { process.on('unhandledRejection', handleFatal); process.on('uncaughtException', handleFatal); +// Pick up the project's `debug` function before parsing, so parse errors and +// `--help` — which short-circuit before any hook runs — are reported too. +await loadProjectDebugHandler(); + try { await program.parseAsync(process.argv); } catch (err) { diff --git a/packages/cli/clients/cli/commands/init.behavior.test.mjs b/packages/cli/clients/cli/commands/init.behavior.test.mjs index 5f825eff4c6b..dbeb6d3f69c1 100644 --- a/packages/cli/clients/cli/commands/init.behavior.test.mjs +++ b/packages/cli/clients/cli/commands/init.behavior.test.mjs @@ -122,3 +122,68 @@ describe('astryx init --remove-agents', () => { expect(exists('AGENTS.md')).toBe(false); }); }); + +describe('astryx init --json', () => { + it('emits the install receipt as an envelope', async () => { + const {status, stdout} = await runCli(['init', '--json'], {cwd: tmpDir}); + expect(status).toBe(0); + + const env = JSON.parse(stdout); + expect(env.type).toBe('init.run'); + expect(env.data.mode).toBe('default'); + expect(env.data.docsWritten).toContain('AGENTS.md'); + expect(env.data.docsError).toBe(null); + }); + + it('still does the work', async () => { + await runCli(['init', '--json'], {cwd: tmpDir}); + expect(exists('AGENTS.md')).toBe(true); + expect(read('AGENTS.md')).toContain(MARKER_START); + }); + + it('keeps stdout a single envelope — no guidance text leaks in', async () => { + const {stdout} = await runCli(['init', '--json'], {cwd: tmpDir}); + // The human path prints a "Next steps:" block; under --json the whole of + // stdout has to parse, so any of it leaking would fail here. + expect(() => JSON.parse(stdout)).not.toThrow(); + expect(stdout).not.toMatch(/Next steps:/); + }); + + it('reports the features it ran with --all', async () => { + const {stdout} = await runCli(['init', '--all', '--json'], {cwd: tmpDir}); + const env = JSON.parse(stdout); + expect(env.data.mode).toBe('features'); + expect(env.data.features).toEqual( + expect.arrayContaining(['agents', 'theme']), + ); + }); + + it('emits init.remove for --remove-agents', async () => { + await runCli(['init'], {cwd: tmpDir}); + const {stdout} = await runCli(['init', '--remove-agents', '--json'], { + cwd: tmpDir, + }); + const env = JSON.parse(stdout); + expect(env.type).toBe('init.remove'); + expect(env.data.removed).toBe(true); + }); + + it('reports a bad --agent as an error envelope, not a receipt', async () => { + const {status, stdout} = await runCli( + ['init', '--agent', 'bogus', '--json'], + {cwd: tmpDir}, + ); + expect(status).toBe(1); + const env = JSON.parse(stdout); + expect(env.code).toBe('ERR_UNKNOWN_AGENT'); + expect(env.type).toBeUndefined(); + }); + + it('agrees with human mode on the exit code', async () => { + const human = await runCli(['init', '--agent', 'bogus'], {cwd: tmpDir}); + const json = await runCli(['init', '--agent', 'bogus', '--json'], { + cwd: tmpDir, + }); + expect(json.status).toBe(human.status); + }); +}); diff --git a/packages/cli/clients/cli/commands/init.doc.mjs b/packages/cli/clients/cli/commands/init.doc.mjs index a4d21964da7e..041f7b419bd3 100644 --- a/packages/cli/clients/cli/commands/init.doc.mjs +++ b/packages/cli/clients/cli/commands/init.doc.mjs @@ -53,6 +53,7 @@ export const doc = { examples: [ {label: 'Default setup', cli: 'astryx init'}, {label: 'All features, no prompts', cli: 'astryx init --all'}, + {label: 'Machine-readable receipt', cli: 'astryx init --json'}, ], exitCodes: [ {code: 0, when: 'success'}, diff --git a/packages/cli/clients/cli/commands/init.mjs b/packages/cli/clients/cli/commands/init.mjs index 6aa0ae916d06..6cc92d4693f8 100644 --- a/packages/cli/clients/cli/commands/init.mjs +++ b/packages/cli/clients/cli/commands/init.mjs @@ -16,6 +16,7 @@ import {init} from '../../../api/init/init.mjs'; import {logger} from '../../../api/logger.mjs'; +import {jsonOut} from '../../../foundation/response/json.mjs'; import {cliError} from '../lib/cli-error.mjs'; import {defineCommand} from '../lib/define-command.mjs'; import {doc as initCommand} from './init.doc.mjs'; @@ -28,17 +29,20 @@ export function registerInit(program) { defineCommand(program, initCommand, { fn: initFn, action: async (/** @type {import('../../../api/init/init.mjs').InitOptions} */ options) => { - // init has no --json mode: enable human output (log → stdout via humanLog, - // warn/error → stderr). humanLog still self-suppresses under a global - // --json flag, so a JSON envelope can never be corrupted. - logger.setSilent(false); + const json = program.opts().json || false; + // Silence the progress logger under --json so stdout carries only the + // envelope. In human mode it writes log → stdout via humanLog and + // warn/error → stderr. + logger.setSilent(json); try { const receipt = await init(options, {cwd: process.cwd()}); // A path-safety failure already printed its error but the run // continued; reflect it in the exit code (historical soft-error policy). + // The receipt still reports it as `data.docsError` either way. if (receipt.type === 'init.run' && receipt.data.docsError?.kind === 'path-safety') { process.exitCode = 1; } + if (json) jsonOut(receipt); } catch (err) { const e = /** @type {import('../../../api/error.mjs').AstryxError} */ (err); cliError(e.message, {suggestions: e.suggestions, code: e.code}); diff --git a/packages/cli/clients/cli/commands/json-contract.test.mjs b/packages/cli/clients/cli/commands/json-contract.test.mjs index 422d12b8e0a7..29b6ea3ad84b 100644 --- a/packages/cli/clients/cli/commands/json-contract.test.mjs +++ b/packages/cli/clients/cli/commands/json-contract.test.mjs @@ -57,35 +57,11 @@ afterEach(() => { }); describe('--json contract: rejects before side effects', () => { - it('astryx init --json --features agents does not write agent docs', async () => { - const before = fs.readdirSync(tmpDir); - expect(before).toEqual([]); - - const {status, stdout} = await runCli(['init', '--json', '--features', 'agents'], {cwd: tmpDir}); - - // 1. Exit code must be non-zero. - expect(status).toBe(1); - - // 2. Stdout must be valid JSON with an error envelope. - const parsed = parseJson(stdout); - expect(parsed).toHaveProperty('error'); - expect(parsed.error).toMatch(/json/i); - expect(parsed.error).toMatch(/init/i); - - // 3. CRITICAL — no filesystem mutation took place. - const after = fs.readdirSync(tmpDir); - expect(after).toEqual([]); - expect(fs.existsSync(path.join(tmpDir, '.claude'))).toBe(false); - expect(fs.existsSync(path.join(tmpDir, '.claude/CLAUDE.md'))).toBe(false); - expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(false); - }); - - it('astryx init --json --all does not write any files', async () => { - const {status, stdout} = await runCli(['init', '--json', '--all'], {cwd: tmpDir}); - expect(status).toBe(1); - parseJson(stdout); // valid JSON - expect(fs.readdirSync(tmpDir)).toEqual([]); - }); + // init used to be the example here: it was off the allowlist, so the gate + // refused it before it could write half a project. It now returns a receipt + // of its own, so the side-effect guarantee it demonstrated is covered by the + // remaining side-effect-free rejections below, and init's own behaviour is + // asserted in init.behavior.test.mjs. it('astryx theme --json (parent, no subcommand) rejects without printing help', async () => { const {status, stdout, stderr} = await runCli(['theme', '--json'], {cwd: tmpDir}); @@ -105,7 +81,7 @@ describe('--json contract: rejects before side effects', () => { }); it('error envelope is { error, suggestions? } — never { type, data }', async () => { - const {stdout} = await runCli(['init', '--json'], {cwd: tmpDir}); + const {stdout} = await runCli(['theme', '--json'], {cwd: tmpDir}); const parsed = parseJson(stdout); expect(parsed).toHaveProperty('error'); expect(parsed).not.toHaveProperty('type'); diff --git a/packages/cli/clients/cli/e2e-smoke.test.mjs b/packages/cli/clients/cli/e2e-smoke.test.mjs index 47de260c8c33..c3c4f37d5d9c 100644 --- a/packages/cli/clients/cli/e2e-smoke.test.mjs +++ b/packages/cli/clients/cli/e2e-smoke.test.mjs @@ -71,11 +71,13 @@ describe('e2e smoke: real binary error boundary + exit codes', () => { expect(stderr).toMatch(/unknown command/i); }); - it('init --json is rejected with a real exit 1 + JSON error envelope on stdout', () => { + it('an unsupported --json command is rejected with a real exit 1 + envelope on stdout', () => { // Proves the whole chain across the process boundary: bin boots → Commander - // runs → preAction --json gate rejects init → error envelope on stdout → - // process really exits 1 (not merely process.exitCode set in-process). - const {status, stdout} = spawnCli(['init', '--json']); + // runs → preAction --json gate rejects the command → error envelope on + // stdout → process really exits 1 (not merely process.exitCode set + // in-process). `theme` is a command group with no output of its own, so it + // stays off the allowlist. + const {status, stdout} = spawnCli(['theme', '--json']); expect(status).toBe(1); const parsed = JSON.parse(stdout); expect(parsed).toHaveProperty('error'); diff --git a/packages/cli/clients/cli/index.mjs b/packages/cli/clients/cli/index.mjs index 989cb81416fe..1e24875cef1d 100644 --- a/packages/cli/clients/cli/index.mjs +++ b/packages/cli/clients/cli/index.mjs @@ -26,12 +26,18 @@ import {ERROR_CODES} from '../../foundation/response/error-codes.mjs'; import {levenshteinDistance} from '../../foundation/text/string-utils.mjs'; import {installJsonShim} from './lib/json-shim.mjs'; import {isAstryxInitialized} from '../../foundation/agent-docs/agent-docs.mjs'; +import * as debug from '../../foundation/debug/index.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Read version from package.json so it stays in sync const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8')); +// Start the debug recorder before anything can exit. This is a no-op (two env +// reads) unless recording is enabled, and it must run ahead of the --version +// preflight below so even that early exit is recorded. See foundation/debug. +debug.begin({cliVersion: pkg.version}); + // Intercept `xds --version --json` (or `-V --json`) before Commander processes // the version flag and exits. Commander's built-in version handler prints the // raw version string and calls process.exit, bypassing our hooks — so the @@ -57,6 +63,7 @@ if ( * yet support structured output. */ export const JSON_SUPPORTED = new Set([ + 'init', 'component', 'docs', 'blog', @@ -95,6 +102,100 @@ function fullCommandName(actionCommand, root) { return parts.join(' '); } +/** + * Load the project's `debug` function, if it has one. + * + * Called from the bin BEFORE Commander parses, not from a hook. Most commands + * never touch `astryx.config` on their own, and parse errors and `--help` + * short-circuit before any hook runs — so anywhere later would leave exactly + * the failures you most want reported with nowhere to report them. + * + * Cheap enough to do unconditionally: a project with no config pays one + * `existsSync` walk, and one with a config pays a load whose modules the CLI + * has already imported. + * + * @returns {Promise} + */ +export async function loadProjectDebugHandler() { + try { + const {findConfigPath, Project} = await import( + '../../foundation/config/project.mjs' + ); + if (!findConfigPath(process.cwd())) return; + await Project.load(process.cwd()); + } catch { + // A broken config is the command's problem to report, not ours. + } +} + +/** + * Hand the whole invocation to the debug recorder: which command ran, its + * positional arguments by name, its options and where each value came from, + * and the root-level flags. + * + * This lives in a `preAction` hook rather than in `defineCommand` because the + * hook fires for EVERY action — including the four commands registered inline + * below (root, manifest, postinstall, and the load-failure stub), which never + * pass through the converter. One capture point, no coverage gaps. + * + * @param {import('commander').Command} actionCommand + * @param {import('commander').Command} root + */ +function captureInvocation(actionCommand, root) { + const name = fullCommandName(actionCommand, root); + debug.setCommand(name); + debug.setGlobalOptions(root.opts()); + + // Only pay for these probes when something will actually read them. Both + // touch the filesystem, and most commands never load a Project — which is + // why recording them here rather than at the config boundary is what makes + // them present at all. + if (debug.isRecording()) { + try { + const cwd = process.cwd(); + debug.setProject({ + inProject: fs.existsSync(path.join(cwd, 'package.json')), + initialized: isAstryxInitialized(cwd), + }); + } catch { + // Leave them null rather than failing the command. + } + } + + // Positional values arrive as a bare array; pair them with the declared + // argument names so the log records `{component: 'XDSButton'}` rather than + // an anonymous `['XDSButton']` nobody can query. `registeredArguments` is + // Commander 12's accessor and `_args` the older internal — read both, as + // lib/manifest.mjs does, so a Commander bump degrades to unnamed args + // rather than losing them. + const declared = + /** @type {any} */ (actionCommand).registeredArguments ?? + /** @type {any} */ (actionCommand)._args ?? + []; + const values = actionCommand.args ?? []; + /** @type {Record} */ + const args = {}; + declared.forEach((/** @type {any} */ arg, /** @type {number} */ i) => { + const key = typeof arg?.name === 'function' ? arg.name() : `arg${i}`; + if (values[i] !== undefined) args[key] = values[i]; + }); + // Anything Commander did not have a declaration for (extra positionals on + // the root command, which is how an unknown command arrives here). + if (values.length > declared.length) { + args.extra = values.slice(declared.length); + } + debug.setArgs(args); + + /** @type {Record} */ + const sources = {}; + const options = actionCommand.opts(); + for (const key of Object.keys(options)) { + const source = actionCommand.getOptionValueSource?.(key); + if (source) sources[key] = source; + } + debug.setOptions(options, sources); +} + /** * Command registry — each command is lazy-loaded so a broken command * doesn't take down the entire CLI. @@ -276,6 +377,19 @@ export async function createProgram() { * action runs with --json, they are responsible for emitting an envelope on * every code path. */ + /** + * Debug capture. Registered first so the invocation is on record before any + * later hook can reject it, and inside a try/catch because a recording bug + * must never be the reason a command fails. + */ + program.hook('preAction', (thisCommand, actionCommand) => { + try { + captureInvocation(actionCommand, program); + } catch { + // Never let recording break the CLI. + } + }); + program.hook('preAction', (thisCommand, actionCommand) => { if (!program.opts().json) return; // Engage global JSON mode so humanLog()/humanWarn() across commands become @@ -287,6 +401,10 @@ export async function createProgram() { const fullName = fullCommandName(actionCommand, program); if (JSON_SUPPORTED.has(fullName)) return; process.__xdsJsonHandled = true; + debug.setOutcome('rejected', { + exitCode: 1, + code: ERROR_CODES.ERR_INVALID_OPTION, + }); console.log(JSON.stringify({ apiVersion: API_VERSION, error: `JSON output is not supported for the '${fullName}' command`, diff --git a/packages/cli/clients/cli/lib/cli-error.mjs b/packages/cli/clients/cli/lib/cli-error.mjs index b0a48c34bbae..22abcba3a974 100644 --- a/packages/cli/clients/cli/lib/cli-error.mjs +++ b/packages/cli/clients/cli/lib/cli-error.mjs @@ -52,6 +52,7 @@ import {isJsonMode, jsonError as _jsonError, humanWarn} from '../../../foundation/response/json.mjs'; import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs'; +import {setOutcome} from '../../../foundation/debug/index.mjs'; /** * Suggestion object — matches the shape used by API errors and the JSON @@ -98,6 +99,12 @@ export function cliError(message, options = {}) { const {suggestions, exitCode = 1, hard = true} = options; const code = options.code || ERROR_CODES.ERR_UNKNOWN; + // Record before either branch below exits. This is the single most important + // instrumentation point in the CLI: `hard` defaults to true, so both paths + // end in `process.exit` and nothing downstream — no `finally`, no postAction + // hook — gets another chance to classify the failure. + setOutcome('error', {exitCode, error: new Error(message), code}); + if (isJsonMode()) { // jsonError emits the envelope on stdout and calls process.exit(1). // We don't honor a custom exitCode in JSON mode — the contract is exit 1. diff --git a/packages/cli/clients/cli/lib/json-shim.mjs b/packages/cli/clients/cli/lib/json-shim.mjs index 03f3278fd41a..f2e00e9c155b 100644 --- a/packages/cli/clients/cli/lib/json-shim.mjs +++ b/packages/cli/clients/cli/lib/json-shim.mjs @@ -38,6 +38,25 @@ import {API_VERSION, isJsonMode, toErrorEnvelope} from '../../../foundation/response/json.mjs'; import {ERROR_CODES} from '../../../foundation/response/error-codes.mjs'; +import {setCommand, setOutcome, recordHelp} from '../../../foundation/debug/index.mjs'; + +/** + * Fully-qualified name of a command relative to the root program, e.g. + * `theme build`. The root itself is ''. + * @param {import('commander').Command} cmd + * @returns {string} + */ +function fullNameOf(cmd) { + /** @type {string[]} */ + const parts = []; + /** @type {any} */ + let node = cmd; + while (node?.parent) { + parts.unshift(node.name()); + node = node.parent; + } + return parts.join(' '); +} /** * Cheap argv check used before preAction has had a chance to engage @@ -184,7 +203,18 @@ export function installJsonShim(program) { * @param {import('commander').Command} cmd */ function applyShimRecursively(cmd) { - cmd.exitOverride(); + // The exitOverride callback is the only place that knows WHICH command + // Commander rejected. Parse errors happen before any preAction hook runs, + // so without this every `astryx theme build` (missing argument) would be + // recorded against the root program instead of `theme build`. + cmd.exitOverride(err => { + try { + setCommand(fullNameOf(cmd)); + } catch { + // Never let recording interfere with the error path. + } + throw err; + }); cmd.configureOutput({ writeOut: (str) => process.stdout.write(str), writeErr: (str) => { @@ -195,11 +225,52 @@ function applyShimRecursively(cmd) { process.stderr.write(str); }, }); + makeSelfInstalling(cmd); for (const sub of cmd.commands) { applyShimRecursively(sub); } } +/** Marks a command whose `command`/`addCommand` already self-install the shim. */ +const SELF_INSTALLING = Symbol.for('astryx.jsonShim.selfInstalling'); + +/** + * Make a shimmed command shim anything attached to it later. + * + * A one-time recursive walk is order-dependent: it only covers commands that + * exist when `installJsonShim` runs, so anything registered afterwards keeps + * Commander's default `_exit` and silently drops out of the --json contract + * AND out of parse-error attribution. Wrapping the two registration methods + * removes the ordering requirement entirely — a command is shimmed the moment + * it joins the tree, whenever that happens and at whatever depth. + * + * @param {import('commander').Command} cmd + */ +function makeSelfInstalling(cmd) { + const node = /** @type {any} */ (cmd); + if (node[SELF_INSTALLING]) return; + node[SELF_INSTALLING] = true; + + const originalCommand = node.command.bind(node); + const originalAddCommand = node.addCommand.bind(node); + + /** @param {...any} args */ + node.command = (...args) => { + const created = originalCommand(...args); + // The `.command(name, description)` executable form returns `this`, not a + // new command — only recurse when we actually got a child back. + if (created && created !== node) applyShimRecursively(created); + return created; + }; + + /** @param {any} sub @param {any} [opts] */ + node.addCommand = (sub, opts) => { + const result = originalAddCommand(sub, opts); + if (sub) applyShimRecursively(sub); + return result; + }; +} + /** * Walk the command tree and override outputHelp on every command so that * `--help --json` emits a structured help envelope instead of raw text. @@ -308,9 +379,23 @@ export function handleCommanderError(err) { code === 'commander.help' || code === 'commander.version' ) { + // Showing help is a success by the exit-code contract, but it is also the + // best available signal that someone could not find what they needed — so + // it is flagged on the event rather than being lost in the `ok` bucket. + recordHelp(); + setOutcome('ok', {exitCode}); process.exit(exitCode); } + // Parse failures never reach a preAction hook, so this is the only place + // that sees them. Commander's own code (e.g. commander.unknownOption) is + // more specific than anything we could infer, so keep it. + setOutcome('parse-error', { + exitCode: exitCode || 1, + error: new Error(message.replace(/^error:\s*/i, '')), + code: commanderCodeToErrorCode(code, message), + }); + // Real error paths. if (jsonActive()) { // Strip Commander's "error: " prefix — the envelope key is `error` diff --git a/packages/cli/clients/cli/lib/manifest.mjs b/packages/cli/clients/cli/lib/manifest.mjs index 8d7510d9577a..091dc87b4e7b 100644 --- a/packages/cli/clients/cli/lib/manifest.mjs +++ b/packages/cli/clients/cli/lib/manifest.mjs @@ -46,6 +46,7 @@ import {API_VERSION} from '../../../foundation/response/json.mjs'; * @type {Record} */ export const RESPONSE_TYPES = { + init: ['init.run', 'init.remove'], component: [ 'component.list', 'component.detail', @@ -120,7 +121,7 @@ const EXAMPLES = { 'astryx validate-integration', 'astryx validate-integration @acme/widgets --json', ], - init: ['astryx init'], + init: ['astryx init', 'astryx init --all --json'], 'layout expand': [ `astryx layout expand 'V[g6] > C{card-callout}*4' ./src/Page.tsx`, ], diff --git a/packages/cli/foundation/config/project.mjs b/packages/cli/foundation/config/project.mjs index 348eea374ec7..0cb8788a423f 100644 --- a/packages/cli/foundation/config/project.mjs +++ b/packages/cli/foundation/config/project.mjs @@ -35,6 +35,10 @@ import * as path from 'node:path'; import {findPresentFiles, loadModuleWithParser} from '../fs/module-loader.mjs'; import {parseConfig} from '../../authoring/config/parse.mjs'; import {loadIntegrations} from '../integrations/integrations.mjs'; +import { + setProject as setDebugProject, + setEventHandler as setDebugEventHandler, +} from '../debug/index.mjs'; import { CORE_PACKAGE, discoverOwnedComponents, @@ -216,6 +220,23 @@ export class Project { }); } + // The debug recorder resolves its settings synchronously, long before any + // command gets here, so this is where a project's `debug` block gets a + // turn. The event is not written until process exit, so settings applied + // now still shape the record for this same invocation. + try { + // The `debug` function is the destination for recorded runs. The + // recorder collects provisionally until now precisely because the config + // could not be read any earlier; it only needs the handler by exit. + setDebugEventHandler(config.debug); + setDebugProject({ + hasConfig: Boolean(configPath), + integrationCount: integrations.length, + }); + } catch { + // Never let recording break config loading. + } + return new Project({ cwd, configPath, diff --git a/packages/cli/foundation/debug/event.mjs b/packages/cli/foundation/debug/event.mjs new file mode 100644 index 000000000000..74fb2c96a1b6 --- /dev/null +++ b/packages/cli/foundation/debug/event.mjs @@ -0,0 +1,256 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file The recorded event shape, and the environment snapshot attached to it. + * + * One invocation produces exactly one event. That keeps the log trivially + * queryable — `SELECT command, outcome, count(*) FROM events GROUP BY 1, 2` + * works with no joins — and keeps the write path to a single append. + * + * The shape is versioned by {@link SCHEMA_VERSION}. Treat it as an append-only + * contract: add fields freely, never repurpose or remove one, and bump the + * version when an existing field changes meaning, so a consumer reading a + * mixed-version table can branch on it. + * + * @input command identity, argv/options, an outcome, and process context + * @output a flat, JSON-serializable record ready for a sink + * @position packages/cli/foundation/debug — event shape + */ + +import * as crypto from 'node:crypto'; +import {isCliOneOff, detectPackageManager} from '../env/package-manager.mjs'; + +/** + * Version of the recorded event shape. Bump on any breaking field change, + * widening {@link DebugSchemaVersion} in the published type at the same time. + * @type {import('../../authoring/debug/type').DebugSchemaVersion} + */ +export const SCHEMA_VERSION = 1; + +/** + * The recorded shape is PUBLISHED — a project can attach a `debug.onEvent` + * handler that receives these, and the same objects are what `debug export` + * prints. So the definition lives in + * `authoring/debug/type.ts` alongside the other public contracts, and this + * module re-uses it rather than keeping a second copy that could drift. A + * sealed zod schema in `authoring/debug/parse.mjs` is drift-locked to it. + * + * @typedef {import('../../authoring/debug/type').DebugEvent} DebugEvent + * @typedef {import('../../authoring/debug/type').DebugOutcome} Outcome + * @typedef {import('../../authoring/debug/type').DebugEventError} DebugEventError + */ + +/** + * An event still being filled in. Timing is only knowable once the run ends, + * so those two fields are absent until {@link finish} seals the record — every + * PERSISTED event has them. + * + * @typedef {Omit & + * {endedAt?: string, durationMs?: number}} InFlightEvent + */ + +/** + * CI providers worth naming, matched by a single env var each. Order matters + * only for the name we report; `ci` is true if any of them (or the generic + * `CI` flag) is present. + */ +const CI_PROVIDERS = [ + ['GITHUB_ACTIONS', 'github-actions'], + ['GITLAB_CI', 'gitlab-ci'], + ['CIRCLECI', 'circleci'], + ['BUILDKITE', 'buildkite'], + ['TRAVIS', 'travis'], + ['JENKINS_URL', 'jenkins'], + ['TEAMCITY_VERSION', 'teamcity'], + ['TF_BUILD', 'azure-pipelines'], + ['BITBUCKET_BUILD_NUMBER', 'bitbucket'], + ['CODEBUILD_BUILD_ID', 'aws-codebuild'], + ['DRONE', 'drone'], + ['VERCEL', 'vercel'], + ['NETLIFY', 'netlify'], +]; + +/** + * Coding agents and editors worth distinguishing. This CLI is explicitly built + * to be driven by agents, so "which agent invoked this" is the single most + * useful dimension in the log — it separates human ergonomics problems from + * agent-prompt problems. + */ +const AGENT_SIGNALS = [ + ['CURSOR_TRACE_ID', 'cursor'], + ['CURSOR_AGENT', 'cursor'], + ['CLAUDECODE', 'claude-code'], + ['CLAUDE_CODE', 'claude-code'], + ['AIDER_MODEL', 'aider'], + ['GITHUB_COPILOT_AGENT', 'copilot'], + ['REPLIT_USER', 'replit'], + ['CODESPACES', 'codespaces'], +]; + +/** @returns {{ci: boolean, ciName: string | null}} */ +function detectCi() { + for (const [key, name] of CI_PROVIDERS) { + if (process.env[key]) return {ci: true, ciName: name}; + } + const generic = + process.env.CI != null && + process.env.CI !== '' && + process.env.CI !== '0' && + process.env.CI !== 'false'; + return {ci: generic, ciName: generic ? 'unknown' : null}; +} + +/** @returns {string | null} */ +function detectAgent() { + for (const [key, name] of AGENT_SIGNALS) { + if (process.env[key]) return name; + } + const termProgram = process.env.TERM_PROGRAM; + if (termProgram === 'vscode') return 'vscode'; + return null; +} + +/** + * Snapshot the machine and runtime. Everything here is either a coarse + * bucket or a value the user could read off their own `astryx doctor` + * output — no hostname, no username, no network identity. + * + * @param {{cliVersion?: string}} [options] + * @returns {import('../../authoring/debug/type').DebugEventEnv} + */ +export function captureEnv({cliVersion} = {}) { + const {ci, ciName} = detectCi(); + return { + cliVersion: cliVersion ?? null, + nodeVersion: process.versions.node, + platform: process.platform, + arch: process.arch, + ci, + ciName, + agent: detectAgent(), + oneOff: safe(() => isCliOneOff(), false), + packageManager: safe(() => detectPackageManager(), null), + tty: Boolean(process.stdout.isTTY), + locale: safe( + () => Intl.DateTimeFormat().resolvedOptions().locale, + null, + ), + timezone: safe( + () => Intl.DateTimeFormat().resolvedOptions().timeZone, + null, + ), + }; +} + +/** + * Snapshot the project the command ran against. Names of a user's private + * packages are identifying, so this records only shape: whether a project is + * present, whether it is configured, and how many integrations it loads. + * + * @param {{hasConfig?: boolean, initialized?: boolean, integrationCount?: number, inProject?: boolean}} [facts] + * @returns {import('../../authoring/debug/type').DebugEventProject} + */ +export function captureProject(facts = {}) { + return { + inProject: facts.inProject ?? null, + hasConfig: facts.hasConfig ?? null, + initialized: facts.initialized ?? null, + integrationCount: facts.integrationCount ?? null, + }; +} + +/** + * Build a new event in its initial (in-flight) state. The recorder fills in + * the outcome, timing, and error fields as the invocation progresses. + * + * @param {object} init + * @param {string | null} [init.installId] + * @param {string} [init.command] + * @param {string[]} [init.argv] + * @param {string} [init.cliVersion] + * @returns {InFlightEvent} + */ +export function createEvent({installId = null, command = '', argv = [], cliVersion} = {}) { + return { + schemaVersion: SCHEMA_VERSION, + id: crypto.randomUUID(), + installId, + startedAt: new Date().toISOString(), + command, + commandPath: command ? command.split(' ') : [], + argv, + args: {}, + options: {}, + optionSources: {}, + globalOptions: {}, + outcome: 'incomplete', + exitCode: null, + signal: null, + error: null, + output: { + jsonMode: false, + envelopeTypes: [], + handled: false, + helpDisplayed: false, + stdout: '', + stderr: '', + stdoutBytes: 0, + stderrBytes: 0, + truncated: false, + }, + env: captureEnv({cliVersion}), + project: captureProject(), + redacted: true, + }; +} + +/** + * Normalize a thrown value into the event's error shape. + * @param {unknown} err + * @returns {DebugEventError | null} + */ +export function toEventError(err) { + if (err == null) return null; + if (err instanceof Error) { + return { + name: err.name, + message: err.message, + code: + typeof (/** @type {any} */ (err).code) === 'string' + ? /** @type {any} */ (err).code + : null, + stack: err.stack ?? null, + }; + } + return { + name: typeof err, + message: typeof err === 'string' ? err : safeStringify(err), + code: null, + stack: null, + }; +} + +/** + * Run `fn`, returning `fallback` if it throws. Environment probes touch the + * filesystem and Intl, both of which can fail in a locked-down sandbox. + * @template T + * @param {() => T} fn + * @param {T} fallback + * @returns {T} + */ +function safe(fn, fallback) { + try { + return fn(); + } catch { + return fallback; + } +} + +/** @param {unknown} value @returns {string} */ +function safeStringify(value) { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} diff --git a/packages/cli/foundation/debug/index.mjs b/packages/cli/foundation/debug/index.mjs new file mode 100644 index 000000000000..6179b538836f --- /dev/null +++ b/packages/cli/foundation/debug/index.mjs @@ -0,0 +1,50 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Public surface of the debug subsystem — the one module the CLI layer + * imports. + * + * The whole feature is: capture everything about an invocation, and hand it to + * a function the project supplied via `debug` in `astryx.config`. Nothing is + * stored; the handler decides what happens to the data. + * + * The instrumentation contract is small on purpose: + * + * begin() once, before Commander parses + * setCommand/setArgs/… as facts become known + * setOutcome() from every terminal path (error, fatal, gate) + * finish() automatic, via the exit listener begin() installs + * + * Nothing here throws. See recorder.mjs for why every call is guarded. + * + * @input lifecycle calls from the CLI layer + * @output one event per invocation, delivered to the project's handler + * @position packages/cli/foundation/debug — public surface + */ + +export { + begin, + finish, + isRecording, + currentEvent, + setCommand, + setArgs, + setOptions, + setGlobalOptions, + setProject, + setEventHandler, + setOutcome, + recordEnvelope, + recordHelp, + resetRecorder, +} from './recorder.mjs'; + +export { + SCHEMA_VERSION, + createEvent, + captureEnv, + captureProject, + toEventError, +} from './event.mjs'; + +export {createRedactor, isSensitiveKey, REDACTED} from './redact.mjs'; diff --git a/packages/cli/foundation/debug/on-event.test.mjs b/packages/cli/foundation/debug/on-event.test.mjs new file mode 100644 index 000000000000..ef3a299fa5c2 --- /dev/null +++ b/packages/cli/foundation/debug/on-event.test.mjs @@ -0,0 +1,410 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file `debug: event => {}` — the whole feature. + * + * A project sets one function in `astryx.config` and receives every command + * run. That function's parameter is a published contract, so these cover what + * it promises: the handler is called with a complete, scrubbed event, and a + * handler that misbehaves can never affect the command that triggered it. + * + * @position packages/cli/foundation/debug — behaviour coverage + */ + +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import { + begin, + finish, + setCommand, + setArgs, + setOptions, + setGlobalOptions, + setOutcome, + setEventHandler, + recordEnvelope, + recordHelp, + resetRecorder, + MAX_CAPTURED_OUTPUT, +} from './recorder.mjs'; +import {parseDebugEvent} from '../../authoring/debug/parse.mjs'; +import {parseConfig} from '../../authoring/config/parse.mjs'; + + + +/** Collect every event a run delivers. */ +function collect() { + /** @type {any[]} */ + const seen = []; + setEventHandler(e => seen.push(e)); + return seen; +} + +beforeEach(() => { + resetRecorder(); +}); + +afterEach(() => { + resetRecorder(); +}); + +describe('delivery', () => { + it('hands each run to the function', () => { + const seen = collect(); + begin({argv: ['docs', 'tokens']}); + setCommand('docs'); + expect(finish({exitCode: 0})).toBe(true); + + expect(seen).toHaveLength(1); + expect(seen[0].command).toBe('docs'); + }); + + it('does nothing at all when no function is configured', () => { + begin({argv: ['docs']}); + setCommand('docs'); + expect(finish({exitCode: 0})).toBe(false); + }); + + it('delivers at most once', () => { + const seen = collect(); + begin({argv: []}); + expect(finish({exitCode: 0})).toBe(true); + expect(finish({exitCode: 0})).toBe(false); + expect(seen).toHaveLength(1); + }); + + it('accepts a function registered after the run started', () => { + // This is the real order: the config is read during the command, not + // before it, so a handler always arrives mid-flight. + begin({argv: ['docs']}); + setCommand('docs'); + const seen = collect(); + finish({exitCode: 0}); + expect(seen).toHaveLength(1); + }); + + it('delivers one event per invocation', () => { + /** @type {any[]} */ + const seen = []; + for (const name of ['docs', 'component', 'hook']) { + resetRecorder(); + setEventHandler(e => seen.push(e)); + begin({argv: [name]}); + setCommand(name); + finish({exitCode: 0}); + } + expect(seen.map(e => e.command)).toEqual(['docs', 'component', 'hook']); + }); +}); + +describe('what the event carries', () => { + it('carries the whole invocation', () => { + const seen = collect(); + begin({argv: ['theme', 'build', 'x.ts', '--check'], cliVersion: '9.9.9'}); + setCommand('theme build'); + setArgs({file: 'x.ts'}); + setOptions({check: true}, {check: 'cli'}); + setGlobalOptions({json: true, detail: 'full'}); + recordEnvelope('theme.build'); + finish({exitCode: 0}); + + const [e] = seen; + expect(e.command).toBe('theme build'); + expect(e.commandPath).toEqual(['theme', 'build']); + expect(e.args).toEqual({file: 'x.ts'}); + expect(e.options).toEqual({check: true}); + expect(e.optionSources).toEqual({check: 'cli'}); + expect(e.globalOptions).toEqual({json: true, detail: 'full'}); + expect(e.output.jsonMode).toBe(true); + expect(e.output.envelopeTypes).toEqual(['theme.build']); + expect(e.env.cliVersion).toBe('9.9.9'); + expect(e.outcome).toBe('ok'); + expect(typeof e.durationMs).toBe('number'); + }); + + it('satisfies the published contract', () => { + const seen = collect(); + begin({argv: ['docs']}); + setCommand('docs'); + finish({exitCode: 0}); + // Drift-locked to the exported type, so this is the same guarantee a + // consumer gets from importing DebugEvent. + expect(() => parseDebugEvent(seen[0])).not.toThrow(); + }); + + it('drops an option source outside the published set', () => { + const seen = collect(); + begin({argv: []}); + setOptions({a: 1, b: 2}, {a: 'cli', b: 'from-the-future'}); + finish({exitCode: 0}); + expect(seen[0].optionSources).toEqual({a: 'cli'}); + }); + + it('flags a help invocation', () => { + const seen = collect(); + begin({argv: []}); + recordHelp(); + finish({exitCode: 0}); + expect(seen[0].output.helpDisplayed).toBe(true); + }); +}); + +describe('outcomes', () => { + it('keeps the first terminal outcome', () => { + const seen = collect(); + begin({argv: []}); + setOutcome('error', {exitCode: 1, code: 'ERR_FIRST'}); + setOutcome('fatal', {exitCode: 70, code: 'ERR_SECOND'}); + finish(); + expect(seen[0].outcome).toBe('error'); + expect(seen[0].error.code).toBe('ERR_FIRST'); + }); + + it('captures a thrown error', () => { + const seen = collect(); + begin({argv: []}); + setOutcome('fatal', {exitCode: 1, error: new TypeError('bad thing')}); + finish(); + expect(seen[0].error.name).toBe('TypeError'); + expect(seen[0].error.message).toBe('bad thing'); + }); + + it('infers failure from a non-zero exit', () => { + const seen = collect(); + begin({argv: []}); + finish({exitCode: 1}); + expect(seen[0].outcome).toBe('error'); + }); + + it('marks an exit that bypassed the error path', () => { + const seen = collect(); + begin({argv: []}); + finish({exitCode: 1}); // no cliError, no setOutcome + expect(seen[0].error.code).toBe('ERR_UNCLASSIFIED_EXIT'); + }); + + it('leaves a clean exit with no error', () => { + const seen = collect(); + begin({argv: []}); + finish({exitCode: 0}); + expect(seen[0].outcome).toBe('ok'); + expect(seen[0].error).toBe(null); + }); +}); + +describe('scrubbing', () => { + it('scrubs by default', () => { + const seen = collect(); + begin({argv: ['--token=ghp_abcdefghijklmnopqrstuvwxyz01']}); + setOptions({out: 'ghp_abcdefghijklmnopqrstuvwxyz01'}); + finish({exitCode: 0}); + expect(seen[0].redacted).toBe(true); + expect(JSON.stringify(seen[0])).not.toContain('ghp_abcdef'); + }); + + + it('clamps an oversized value instead of dropping the run', () => { + const seen = collect(); + begin({argv: []}); + setCommand('docs'); + setOptions({blob: 'x'.repeat(500_000)}); + finish({exitCode: 0}); + expect(seen[0].command).toBe('docs'); + expect(String(seen[0].options.blob)).toContain('chars]'); + }); +}); + +describe('a broken handler cannot break the CLI', () => { + it('swallows a handler that throws', () => { + setEventHandler(() => { + throw new Error('handler blew up'); + }); + begin({argv: []}); + setCommand('docs'); + expect(() => finish({exitCode: 0})).not.toThrow(); + }); + + it('ignores a handler that is not a function', () => { + setEventHandler(/** @type {any} */ ('not a function')); + begin({argv: []}); + expect(finish({exitCode: 0})).toBe(false); + }); + + it('cannot corrupt anything by mutating the event it was given', () => { + /** @type {any[]} */ + const seen = []; + setEventHandler(e => { + seen.push(e); + e.command = 'MUTATED'; + }); + begin({argv: []}); + setCommand('docs'); + finish({exitCode: 0}); + // It got a copy; the sealed record is untouched. + expect(seen[0].command).toBe('MUTATED'); + }); + + it('survives a circular structure in the captured options', () => { + collect(); + begin({argv: []}); + /** @type {any} */ + const circular = {name: 'loop'}; + circular.self = circular; + setOptions({circular}); + expect(() => finish({exitCode: 0})).not.toThrow(); + }); + + it('is dropped by resetRecorder so it cannot leak between runs', () => { + const handler = vi.fn(); + setEventHandler(handler); + resetRecorder(); + begin({argv: []}); + finish({exitCode: 0}); + expect(handler).not.toHaveBeenCalled(); + }); + + it('ignores lifecycle calls made before begin', () => { + expect(() => { + setCommand('docs'); + setArgs({a: 1}); + setOptions({b: 2}); + setOutcome('error', {exitCode: 1}); + recordEnvelope('x'); + recordHelp(); + }).not.toThrow(); + }); +}); + + + +describe('config: the whole surface is one function', () => { + it('accepts a function', () => { + expect(parseConfig({debug: () => {}}).debug).toBeTypeOf('function'); + }); + + it.each([ + ['a string', 'nope'], + ['a number', 42], + ['an object', {onEvent: () => {}}], + ['an array', [() => {}]], + ['null', null], + ['true', true], + ])('rejects %s', (_label, value) => { + expect(() => parseConfig({debug: value})).toThrow(/debug/); + }); + + it('treats a missing debug value as no opt-in', () => { + expect(parseConfig({}).debug).toBeUndefined(); + }); +}); + +describe('captured output — what the CLI answered', () => { + // Writes go straight to the streams rather than through console.log, + // because Vitest replaces console.* with its own collectors and they never + // reach process.stdout — the very seam the tee sits on. In a real CLI run + // console.log does bottom out here; the subprocess case at the end of this + // block proves that path. + const say = (out = '', err = '') => { + if (out) process.stdout.write(`${out}\n`); + if (err) process.stderr.write(`${err}\n`); + }; + + it('captures stdout', () => { + const seen = collect(); + begin({argv: ['docs']}); + say('the answer'); + finish({exitCode: 0}); + expect(seen[0].output.stdout).toContain('the answer'); + }); + + it('captures stderr separately', () => { + const seen = collect(); + begin({argv: ['docs']}); + say('', 'Error: nope'); + finish({exitCode: 1}); + expect(seen[0].output.stderr).toContain('Error: nope'); + expect(seen[0].output.stdout).toBe(''); + }); + + it('records true byte counts', () => { + const seen = collect(); + begin({argv: []}); + say('12345'); + finish({exitCode: 0}); + // console.log appends a newline. + expect(seen[0].output.stdoutBytes).toBe(6); + expect(seen[0].output.truncated).toBe(false); + }); + + it('truncates a huge answer but reports its real size', () => { + const seen = collect(); + begin({argv: []}); + say('x'.repeat(MAX_CAPTURED_OUTPUT * 2)); + finish({exitCode: 0}); + + const {output} = seen[0]; + expect(output.truncated).toBe(true); + expect(output.stdoutBytes).toBeGreaterThan(MAX_CAPTURED_OUTPUT); + expect(output.stdout).toContain('truncated'); + expect(output.stdout.length).toBeLessThan(MAX_CAPTURED_OUTPUT + 200); + }); + + it('scrubs captured output like every other value', () => { + const seen = collect(); + begin({argv: []}); + say('wrote ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA to disk'); + finish({exitCode: 0}); + expect(seen[0].output.stdout).not.toContain('ghp_AAAA'); + }); + + it('keeps a long answer intact below the cap, unlike other fields', () => { + // Ordinary values clamp at ~2KB; an answer is the point of the record, so + // it only clamps at MAX_CAPTURED_OUTPUT. + const seen = collect(); + begin({argv: []}); + say('y'.repeat(8000)); + finish({exitCode: 0}); + expect(seen[0].output.stdout.length).toBeGreaterThan(7000); + }); + + it('still lets the output reach the real stream', () => { + const written = []; + const original = process.stdout.write; + // @ts-expect-error - test-only monkeypatch, installed before the tee + process.stdout.write = chunk => { + written.push(String(chunk)); + return true; + }; + try { + collect(); + begin({argv: []}); + say('visible'); + finish({exitCode: 0}); + } finally { + process.stdout.write = original; + } + expect(written.join('')).toContain('visible'); + }); + + it('does not capture what the handler itself prints', () => { + /** @type {any} */ + let received; + setEventHandler(e => { + received = e; + process.stdout.write('handler noise\n'); + }); + begin({argv: []}); + say('command output'); + finish({exitCode: 0}); + expect(received.output.stdout).toContain('command output'); + expect(received.output.stdout).not.toContain('handler noise'); + }); + + it('restores the real writers once the run ends', () => { + const before = process.stdout.write; + collect(); + begin({argv: []}); + expect(process.stdout.write).not.toBe(before); + finish({exitCode: 0}); + expect(process.stdout.write).toBe(before); + }); +}); diff --git a/packages/cli/foundation/debug/recorder.mjs b/packages/cli/foundation/debug/recorder.mjs new file mode 100644 index 000000000000..d3ffa56ec307 --- /dev/null +++ b/packages/cli/foundation/debug/recorder.mjs @@ -0,0 +1,530 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file The recorder — one event per invocation, handed to your function. + * + * The whole feature: a project sets `debug` in `astryx.config`, and that + * function receives every command run. No configuration beyond the function + * itself, and nothing stored anywhere. + * + * ## Why the handoff happens at exit + * + * The CLI's failure path does not unwind. `cliError()` prints and calls + * `process.exit()` synchronously, which means a `try/finally` around a command + * action never runs on an error, and Commander's `postAction` hooks are + * skipped too. Anything that reported on those paths would deliver a stream of + * successes and almost no failures — precisely inverted from what a usage + * record is for. + * + * So the recorder accumulates into a single mutable event and registers one + * `process.on('exit')` listener, which fires for a normal return AND for every + * `process.exit()` call. Signals bypass `exit` entirely, so those are handled + * separately below. The listener is synchronous — Node abandons pending async + * work once `exit` is emitted — which is why a handler cannot do network I/O. + * + * ## Why every entry point is guarded + * + * Recording must never be the reason a command fails. Every exported function + * swallows its own errors; the worst outcome of a bug in here is a missing or + * partial event. + * + * @input lifecycle calls from the command layer, plus a handler from config + * @output one event per invocation, delivered to that handler + * @position packages/cli/foundation/debug — runtime + */ + +import {createEvent, toEventError, captureProject, captureEnv} from './event.mjs'; +import {createRedactor} from './redact.mjs'; +import {ERROR_CODES} from '../response/error-codes.mjs'; + +/** + * Signals worth sealing an event for, with the exit code each conventionally + * produces (128 + signal number). + * + * `process.on('exit')` does NOT run when a signal terminates the process, so + * without these a long-running command — `theme build --watch`, anything the + * user gives up on and Ctrl-Cs — would leave no record at all. Those are + * exactly the invocations worth knowing about. + */ +const SIGNAL_EXIT_CODES = {SIGINT: 130, SIGTERM: 143, SIGHUP: 129}; + +/** + * Per-stream cap on captured output. + * + * Some commands answer with a whole file — `astryx template ` prints + * source, `component --list` prints the catalogue — and a record is for + * understanding the shape of an answer, not for archiving it. The true byte + * count is kept alongside, so a truncated capture still says how much there + * was. + */ +export const MAX_CAPTURED_OUTPUT = 32 * 1024; + +/** @type {import('./event.mjs').InFlightEvent | null} */ +let _event = null; +/** @type {import('../../authoring/debug/type').DebugEventHandler | null} */ +let _handler = null; +let _finished = false; +let _listenerInstalled = false; +let _signalsArmed = false; +let _startedAt = 0; +/** @type {string | undefined} */ +let _cliVersion; + +/** + * Captured stdout/stderr, and the originals to restore. + * @type {{chunks: string[], bytes: number}} + */ +const _stdout = {chunks: [], bytes: 0}; +/** @type {{chunks: string[], bytes: number}} */ +const _stderr = {chunks: [], bytes: 0}; +/** @type {null | {out: typeof process.stdout.write, err: typeof process.stderr.write}} */ +let _originalWrites = null; + +/** + * Tee both output streams into memory. + * + * Patching `write` rather than `console.log` catches everything with one + * seam: `emit()`, the JSON envelopes, `cliError`, Commander's own help and + * error text, and any direct stream write — all of them bottom out here. The + * tee forwards first and returns the real result, so behaviour is unchanged; + * it only ever adds a string to an array. + */ +function captureOutput() { + if (_originalWrites) return; + // Keep the ORIGINAL references, unbound, so releaseOutput can put back + // exactly what was there. Binding would restore a wrapper instead, and + // repeated begin/finish cycles would stack wrappers on the stream. + const out = process.stdout.write; + const err = process.stderr.write; + _originalWrites = {out, err}; + + /** + * @param {NodeJS.WriteStream} stream + * @param {{chunks: string[], bytes: number}} sink + * @param {Function} original + */ + const tee = (stream, sink, original) => + /** @type {any} */ ( + function (/** @type {any} */ chunk, /** @type {any[]} */ ...rest) { + try { + const text = typeof chunk === 'string' ? chunk : String(chunk); + sink.bytes += Buffer.byteLength(text); + if (sink.bytes <= MAX_CAPTURED_OUTPUT) sink.chunks.push(text); + } catch { + /* a chunk we cannot stringify is simply not captured */ + } + return original.call(stream, chunk, ...rest); + } + ); + + process.stdout.write = tee(process.stdout, _stdout, out); + process.stderr.write = tee(process.stderr, _stderr, err); +} + +/** Put the real stream writers back, exactly as they were. */ +function releaseOutput() { + if (!_originalWrites) return; + process.stdout.write = _originalWrites.out; + process.stderr.write = _originalWrites.err; + _originalWrites = null; +} + +/** + * @param {{chunks: string[], bytes: number}} sink + * @returns {string} + */ +function collected(sink) { + const text = sink.chunks.join(''); + return sink.bytes > MAX_CAPTURED_OUTPUT + ? `${text.slice(0, MAX_CAPTURED_OUTPUT)}\n…[truncated, ${sink.bytes} bytes total]` + : text; +} + +/** + * Run `fn`, swallowing anything it throws. + * @param {() => void} fn + */ +function guard(fn) { + try { + fn(); + } catch { + /* recording must never surface to the user */ + } +} + +/** Is an event being collected? @returns {boolean} */ +export function isRecording() { + return _event !== null; +} + +/** + * The in-flight event, for tests. + * @returns {import('./event.mjs').InFlightEvent | null} + */ +export function currentEvent() { + return _event; +} + +/** + * Register the project's `debug` function. + * + * Called once the config has been read, which is necessarily after + * {@link begin} — the recorder collects provisionally and only needs a + * destination by the time the event is sealed at exit. + * + * @param {import('../../authoring/debug/type').DebugEventHandler | null | undefined} handler + */ +export function setEventHandler(handler) { + guard(() => { + _handler = typeof handler === 'function' ? handler : null; + if (_event && _handler) armSignalHandlers(); + }); +} + +/** + * Start collecting. + * + * Runs before Commander parses, which is before `astryx.config` has supplied + * the handler — so this collects provisionally and {@link finish} drops the + * event if no destination ever appeared. + * + * Provisional collection is deliberately cheap: an object, and one no-op + * `exit` listener. Probing the machine for {@link captureEnv} is deferred to + * {@link finish}, so a run with no handler pays for none of it. + * + * @param {object} [options] + * @param {string[]} [options.argv] - argv after the binary. + * @param {string} [options.cliVersion] + * @returns {boolean} whether collection started. + */ +export function begin({argv = process.argv.slice(2), cliVersion} = {}) { + let started = false; + guard(() => { + if (_event) return; + + _startedAt = Date.now(); + _finished = false; + _cliVersion = cliVersion; + _event = createEvent({argv, cliVersion}); + + if (!_listenerInstalled) { + process.on('exit', handleExit); + _listenerInstalled = true; + } + // Start teeing immediately: the answer a command gives is as much a part + // of the record as the arguments it was given, and the earliest output + // (the setup nudge, Commander's own errors) happens before any hook runs. + captureOutput(); + started = true; + }); + return started; +} + +/** + * Install the signal handlers, once there is somewhere to deliver to. + * + * Separate from {@link begin} because adding a signal listener suppresses + * Node's default disposition. Doing that for everyone — including the majority + * of runs with no handler configured — would change Ctrl-C semantics + * process-wide to no purpose. + */ +function armSignalHandlers() { + if (_signalsArmed) return; + for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { + process.on(signal, _signalHandlers[signal]); + } + _signalsArmed = true; +} + +/** Record the fully-qualified command name, e.g. `theme build`. @param {string} name */ +export function setCommand(name) { + guard(() => { + if (!_event) return; + _event.command = String(name ?? ''); + _event.commandPath = _event.command ? _event.command.split(' ') : []; + }); +} + +/** Record positional arguments, keyed by their declared names. @param {Record} args */ +export function setArgs(args) { + guard(() => { + if (!_event || !args) return; + _event.args = {..._event.args, ...args}; + }); +} + +/** + * Record command-level options and, where Commander can tell us, where each + * value came from. The source is what separates "users pass --detail + * explicitly" from "the default is full". + * + * @param {Record} options + * @param {Record} [sources] + */ +export function setOptions(options, sources) { + guard(() => { + if (!_event) return; + if (options) _event.options = {..._event.options, ...options}; + if (!sources) return; + // `optionSources` is a published enum, so drop anything outside it rather + // than letting a future Commander value widen the field silently. + for (const [key, source] of Object.entries(sources)) { + if (KNOWN_OPTION_SOURCES.has(source)) { + _event.optionSources[key] = /** @type {any} */ (source); + } + } + }); +} + +/** Values `optionSources` may carry — mirrors DebugOptionSource. */ +const KNOWN_OPTION_SOURCES = new Set([ + 'cli', + 'default', + 'env', + 'config', + 'implied', +]); + +/** Record the root-level options (`--json`, `--detail`, …). @param {Record} options */ +export function setGlobalOptions(options) { + guard(() => { + if (!_event || !options) return; + _event.globalOptions = {..._event.globalOptions, ...options}; + if ('json' in options) _event.output.jsonMode = Boolean(options.json); + }); +} + +/** Record facts about the project. @param {Parameters[0]} facts */ +export function setProject(facts) { + guard(() => { + if (!_event || !facts) return; + // Merge only what the caller actually knows. Facts arrive from two places, + // so folding in a full captureProject() would let the second caller null + // out the first caller's findings. + for (const [key, value] of Object.entries(captureProject(facts))) { + if (facts[/** @type {keyof typeof facts} */ (key)] !== undefined) { + _event.project[/** @type {keyof typeof _event.project} */ (key)] = + /** @type {any} */ (value); + } + } + }); +} + +/** Note a JSON envelope discriminator the command emitted. @param {string} type */ +export function recordEnvelope(type) { + guard(() => { + if (!_event || !type) return; + _event.output.handled = true; + if (!_event.output.envelopeTypes.includes(type)) { + _event.output.envelopeTypes.push(String(type)); + } + }); +} + +/** Note that the run ended by printing help rather than doing work. */ +export function recordHelp() { + guard(() => { + if (_event) _event.output.helpDisplayed = true; + }); +} + +/** + * Record how the invocation ended. First terminal outcome wins: `cliError` + * sets the error and then exits, and the exit listener must not overwrite that + * with the generic `ok` it would otherwise infer. + * + * @param {import('./event.mjs').Outcome} outcome + * @param {{exitCode?: number | null, error?: unknown, code?: string | null}} [details] + */ +export function setOutcome(outcome, details = {}) { + guard(() => { + if (!_event || _event.outcome !== 'incomplete') return; + _event.outcome = outcome; + if (details.exitCode !== undefined) _event.exitCode = details.exitCode; + if (details.error !== undefined) { + _event.error = toEventError(details.error); + if (_event.error && details.code) _event.error.code = details.code; + } else if (details.code) { + _event.error = {name: 'CliError', message: '', code: details.code, stack: null}; + } + }); +} + +/** The `process.on('exit')` listener. Synchronous by necessity. @param {number} code */ +function handleExit(code) { + guard(() => finish({exitCode: code})); +} + +/** + * Seal the event, then get out of the way so the signal behaves normally. + * + * Adding a listener suppresses Node's default disposition, so this must hand + * termination back: remove itself, and re-raise only if nothing else is + * listening. A command with its own handler (watch mode) keeps owning the + * signal, and Ctrl-C keeps working either way. + * + * @param {keyof typeof SIGNAL_EXIT_CODES} signal + */ +function handleSignal(signal) { + const exitCode = SIGNAL_EXIT_CODES[signal]; + guard(() => { + setOutcome('error', {exitCode, code: ERROR_CODES.ERR_SIGNAL_TERMINATED}); + if (_event) _event.signal = signal; + finish({exitCode}); + }); + try { + process.removeListener(signal, _signalHandlers[signal]); + if (process.listenerCount(signal) === 0) { + process.kill(process.pid, signal); + } + } catch { + /* re-raising failed — let the process continue as it would have */ + } +} + +/** Bound handlers, kept so they can be removed by identity. */ +const _signalHandlers = /** @type {Record void>} */ ( + Object.fromEntries( + Object.keys(SIGNAL_EXIT_CODES).map(s => [ + s, + () => handleSignal(/** @type {any} */ (s)), + ]), + ) +); + +/** + * Scrub a captured stream, tolerating anything odd in it. + * @param {string} text + * @param {import('./redact.mjs').Redactor} redact + * @returns {string} + */ +function scrubText(text, redact) { + if (!text) return ''; + try { + return String(redact(text) ?? ''); + } catch { + return ''; + } +} + +/** + * Seal the event and deliver it. Idempotent. + * + * @param {{exitCode?: number}} [options] + * @returns {boolean} whether an event was delivered. + */ +export function finish({exitCode} = {}) { + let delivered = false; + guard(() => { + if (!_event || _finished) return; + _finished = true; + // Stop teeing before doing anything else, so nothing the handler prints + // ends up in the record it was handed. + releaseOutput(); + // No destination — the project did not set `debug`. Nothing to do, and + // nothing was paid for: the environment probe below never runs. + if (!_handler) return; + + _event.env = captureEnv({cliVersion: _cliVersion}); + _event.output.stdout = collected(_stdout); + _event.output.stderr = collected(_stderr); + _event.output.stdoutBytes = _stdout.bytes; + _event.output.stderrBytes = _stderr.bytes; + _event.output.truncated = + _stdout.bytes > MAX_CAPTURED_OUTPUT || _stderr.bytes > MAX_CAPTURED_OUTPUT; + + const endedAt = new Date().toISOString(); + const durationMs = Math.max(0, Date.now() - _startedAt); + _event.endedAt = endedAt; + _event.durationMs = durationMs; + + if (_event.exitCode == null) { + // `process.exitCode` is typed string | number since Node 20 (it accepts + // a named code), so normalize rather than carrying a string. + const fallback = Number(process.exitCode ?? 0); + _event.exitCode = exitCode ?? (Number.isFinite(fallback) ? fallback : 1); + } + if (_event.outcome === 'incomplete') { + _event.outcome = _event.exitCode === 0 ? 'ok' : 'error'; + } + // A non-zero exit with no error attached means something called + // `process.exit` directly instead of going through cliError/jsonError. + // Mark it rather than filing it alongside real classified failures. + if (_event.outcome === 'error' && !_event.error) { + _event.error = { + name: 'UnclassifiedExit', + message: '', + code: ERROR_CODES.ERR_UNCLASSIFIED_EXIT, + stack: null, + }; + } + + const redact = createRedactor(); + // Same rules, but no length clamp — see the output note below. + const settingsRedact = createRedactor({maxLength: Number.MAX_SAFE_INTEGER}); + /** @type {import('./event.mjs').DebugEvent} */ + const sealed = { + ..._event, + endedAt, + durationMs, + argv: /** @type {string[]} */ (redact(_event.argv)), + args: /** @type {Record} */ (redact(_event.args)), + options: /** @type {Record} */ (redact(_event.options)), + globalOptions: /** @type {Record} */ ( + redact(_event.globalOptions) + ), + // Captured output echoes back paths and argument values, so it gets the + // same treatment. The per-value length clamp does not apply here — + // MAX_CAPTURED_OUTPUT already bounds it, and clipping an answer at 2KB + // would defeat the point of keeping it. + output: { + ..._event.output, + stdout: scrubText(_event.output.stdout, settingsRedact), + stderr: scrubText(_event.output.stderr, settingsRedact), + }, + error: _event.error + ? { + ..._event.error, + message: String(redact(_event.error.message) ?? ''), + stack: + _event.error.stack == null + ? null + : String(redact(_event.error.stack) ?? ''), + } + : null, + }; + + // A COPY. Handing over the live object would let third-party code mutate + // the record mid-flight; its errors are swallowed for the same reason. + try { + _handler(structuredClone(sealed)); + delivered = true; + } catch { + /* a broken handler must not fail the command */ + } + }); + return delivered; +} + +/** Reset all recorder state. Tests call this between cases. */ +export function resetRecorder() { + if (_listenerInstalled) { + process.removeListener('exit', handleExit); + _listenerInstalled = false; + } + if (_signalsArmed) { + for (const signal of Object.keys(SIGNAL_EXIT_CODES)) { + process.removeListener(signal, _signalHandlers[signal]); + } + _signalsArmed = false; + } + releaseOutput(); + _stdout.chunks.length = 0; + _stdout.bytes = 0; + _stderr.chunks.length = 0; + _stderr.bytes = 0; + _event = null; + _handler = null; + _finished = false; + _startedAt = 0; +} diff --git a/packages/cli/foundation/debug/redact.mjs b/packages/cli/foundation/debug/redact.mjs new file mode 100644 index 000000000000..151ee19cb73f --- /dev/null +++ b/packages/cli/foundation/debug/redact.mjs @@ -0,0 +1,249 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Scrubbing pass applied to every recorded value before it is written. + * + * Recorded events capture real argv, real option values, and real error + * messages, so they can carry things a usage record has no business keeping: + * absolute paths that contain a person's name, an email in a git remote, an + * API key someone passed as a flag value. This module removes the obvious + * classes of that before an event reaches the project's handler — which may + * well forward it somewhere less private than this machine. + * + * Scrubbing is deliberately conservative about SHAPE: a redacted value keeps + * its type and rough length so aggregate queries ("how many people pass + * --out?") still work on scrubbed data. It replaces content, not structure. + * + * @input arbitrary argv/option/error values + * @output the same shape with sensitive content replaced by stable markers + * @position packages/cli/foundation/debug — scrubbing + */ + +import * as os from 'node:os'; +import * as path from 'node:path'; + +/** Replacement for a value removed wholesale. */ +export const REDACTED = '[redacted]'; + +/** + * Longest string kept verbatim in a recorded value. + * + * A size guard rather than a privacy one. One long argument lands in `argv`, + * `args`, the error message, AND the stack — roughly quadrupling — so a + * pathological value would otherwise dominate the whole event. Clamping per + * value keeps the record, and the fact that the value was huge. + */ +export const MAX_VALUE_CHARS = 2048; + +/** + * Option/field names whose VALUE is always removed, matched case-insensitively + * as a substring. Deliberately broad — a false positive costs one unusable + * field in a usage log; a false negative writes a live credential to disk. + */ +const SENSITIVE_KEY_PARTS = [ + 'auth', + 'credential', + 'cookie', + 'jwt', + 'passwd', + 'password', + 'private', + 'secret', + 'session', + 'signature', + 'token', +]; + +/** `--flag=value` / `KEY=value` where the key half looks sensitive. */ +const ASSIGNMENT_RE = /^(--?[\w-]*(?:auth|credential|cookie|jwt|passwd|password|private|secret|session|signature|token)[\w-]*|[\w.]*(?:AUTH|CREDENTIAL|COOKIE|JWT|PASSWD|PASSWORD|PRIVATE|SECRET|SESSION|SIGNATURE|TOKEN)[\w.]*)=(.*)$/i; + +/** Well-known credential formats worth catching wherever they appear. */ +const CREDENTIAL_PATTERNS = [ + // GitHub personal access / OAuth / app tokens. + /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, + // Slack tokens. + /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/g, + // AWS access key ids. + /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + // Generic "Bearer ". + /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi, + // JSON Web Tokens. + /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, + // OpenAI-style keys. + /\bsk-[A-Za-z0-9]{20,}\b/g, +]; + +/** Credentials embedded in a URL's userinfo component. */ +const URL_USERINFO_RE = /(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+(?::[^/\s@]*)?@/gi; + +/** Email addresses. */ +const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g; + +/** + * Does this key name mean "the value is a secret"? + * @param {string | undefined} key + * @returns {boolean} + */ +export function isSensitiveKey(key) { + if (!key) return false; + const lower = String(key).toLowerCase(); + return SENSITIVE_KEY_PARTS.some(part => lower.includes(part)); +} + +/** + * Rewrite absolute paths so they carry structure but not identity: the home + * directory becomes `~`, and a path inside the current project becomes a + * project-relative one. A path outside both keeps only its last two segments, + * which is enough to tell `/…/themes/ocean.ts` from `/…/src/App.tsx` without + * revealing where on the machine it lives. + * + * @param {string} value + * @param {{home: string, cwd: string}} ctx + * @returns {string} + */ +function scrubPaths(value, ctx) { + let out = value; + + if (ctx.cwd && out.includes(ctx.cwd)) { + out = out.split(ctx.cwd).join('.'); + } + if (ctx.home && out.includes(ctx.home)) { + out = out.split(ctx.home).join('~'); + } + + // Any absolute path still standing is outside both anchors — keep the tail + // so the shape of the operation survives, drop the machine-specific prefix. + out = out.replace(/(^|\s)(\/[^\s:;,"']{2,})/g, (match, lead, abs) => { + const parts = String(abs).split('/').filter(Boolean); + if (parts.length <= 2) return match; + return `${lead}${path.posix.join('/…', ...parts.slice(-2))}`; + }); + + return out; +} + +/** + * Apply every content rule to a single string. + * @param {string} value + * @param {{home: string, cwd: string}} ctx + * @returns {string} + */ +function scrubString(value, ctx) { + let out = value; + + for (const pattern of CREDENTIAL_PATTERNS) { + out = out.replace(pattern, REDACTED); + } + out = out.replace(URL_USERINFO_RE, (_m, scheme) => `${scheme}${REDACTED}@`); + out = out.replace(EMAIL_RE, REDACTED); + + // `--token=abc` / `GITHUB_TOKEN=abc` survive the patterns above when the + // value is an unrecognized format, so drop the right-hand side by key name. + const assignment = out.match(ASSIGNMENT_RE); + if (assignment) { + out = `${assignment[1]}=${REDACTED}`; + } + + return scrubPaths(out, ctx); +} + +/** + * Clamp one string to {@link MAX_VALUE_CHARS}, noting what was dropped so the + * record still shows that the original was oversized. + * @param {string} value + * @param {number} max + * @returns {string} + */ +function clamp(value, max) { + if (value.length <= max) return value; + return `${value.slice(0, max)}…[+${value.length - max} chars]`; +} + +/** + * A sanitizing function bound to one machine + project. + * @typedef {(value: unknown, key?: string) => unknown} Redactor + */ + +/** + * Build a {@link Redactor}. + * + * @param {object} [options] + * @param {boolean} [options.enabled] - Test seam: false skips the content + * rules while still applying the depth and length limits. + * @param {string} [options.home] - overridable for tests. + * @param {string} [options.cwd] - overridable for tests. + * @param {number} [options.maxLength] - Per-value character cap. + * @returns {Redactor} + */ +export function createRedactor({ + enabled = true, + home, + cwd, + maxLength = MAX_VALUE_CHARS, +} = {}) { + /** @type {{home: string, cwd: string}} */ + const ctx = { + home: home ?? safeHomedir(), + cwd: cwd ?? safeCwd(), + }; + + /** + * @param {unknown} value + * @param {string} [key] + * @param {number} [depth] + * @returns {unknown} + */ + const redact = (value, key, depth = 0) => { + // Bail out well before a pathological object can stall a command. + if (depth > 6) return REDACTED; + if (value == null) return value; + if (enabled && isSensitiveKey(key)) return REDACTED; + + if (typeof value === 'string') { + return clamp(enabled ? scrubString(value, ctx) : value, maxLength); + } + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (Array.isArray(value)) return value.map(v => redact(v, key, depth + 1)); + + if (typeof value === 'object') { + /** @type {Record} */ + const out = {}; + for (const [k, v] of Object.entries(/** @type {object} */ (value))) { + // `out[k] = …` would invoke the prototype setter for `__proto__`, + // silently reparenting `out` and dropping the field. defineProperty + // writes it as an ordinary own property, so a hostile key is recorded + // as data instead of changing the shape of the record. + Object.defineProperty(out, k, { + value: redact(v, k, depth + 1), + enumerable: true, + writable: true, + configurable: true, + }); + } + return out; + } + + // Functions, symbols, bigints — record the type, never the value. + return `[${typeof value}]`; + }; + + return (value, key) => redact(value, key, 0); +} + +/** @returns {string} */ +function safeHomedir() { + try { + return os.homedir() || ''; + } catch { + return ''; + } +} + +/** @returns {string} */ +function safeCwd() { + try { + return process.cwd(); + } catch { + return ''; + } +} diff --git a/packages/cli/foundation/debug/redact.test.mjs b/packages/cli/foundation/debug/redact.test.mjs new file mode 100644 index 000000000000..6bc08578b08e --- /dev/null +++ b/packages/cli/foundation/debug/redact.test.mjs @@ -0,0 +1,143 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, it, expect} from 'vitest'; +import {createRedactor, isSensitiveKey, REDACTED} from './redact.mjs'; + +const ctx = {home: '/Users/ada', cwd: '/Users/ada/projects/app'}; +const scrub = createRedactor({enabled: true, ...ctx}); + +describe('isSensitiveKey', () => { + it.each([ + 'token', + 'authToken', + 'GITHUB_TOKEN', + 'password', + 'apiSecret', + 'sessionId', + 'x-signature', + ])('treats %s as sensitive', key => { + expect(isSensitiveKey(key)).toBe(true); + }); + + it.each(['out', 'detail', 'component', 'limit', undefined])( + 'leaves %s alone', + key => { + expect(isSensitiveKey(key)).toBe(false); + }, + ); +}); + +describe('path scrubbing', () => { + it('rewrites the project directory to a relative path', () => { + expect(scrub(`${ctx.cwd}/src/App.tsx`)).toBe('./src/App.tsx'); + }); + + it('rewrites the home directory to ~', () => { + expect(scrub('/Users/ada/notes.txt')).toBe('~/notes.txt'); + }); + + it('keeps only the tail of a path outside home and cwd', () => { + // The shape of the operation survives; where it lives on disk does not. + expect(scrub('/mnt/corp/secret-project/themes/ocean.ts')).toBe( + '/…/themes/ocean.ts', + ); + }); + + it('leaves relative paths untouched', () => { + expect(scrub('./src/themes/ocean.ts')).toBe('./src/themes/ocean.ts'); + }); +}); + +describe('credential scrubbing', () => { + it.each([ + ['ghp_abcdefghijklmnopqrstuvwxyz0123456789', 'GitHub token'], + ['xoxb-1234567890-abcdefghij', 'Slack token'], + ['AKIAIOSFODNN7EXAMPLE', 'AWS key id'], + ['sk-abcdefghijklmnopqrstuvwxyz012345', 'OpenAI key'], + ])('removes a %s', value => { + expect(String(scrub(`--key ${value}`))).not.toContain(value); + }); + + it('removes a bearer token', () => { + const out = String(scrub('Authorization: Bearer abcdefghijklmnopqrstuvwxyz')); + expect(out).not.toContain('abcdefghijklmnopqrstuvwxyz'); + }); + + it('removes a JWT', () => { + const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; + expect(String(scrub(jwt))).toBe(REDACTED); + }); + + it('removes an email address', () => { + expect(String(scrub('failed for ada@example.com'))).toBe( + `failed for ${REDACTED}`, + ); + }); + + it('removes credentials embedded in a URL', () => { + expect(String(scrub('https://ada:hunter2@git.example.com/repo'))).toBe( + `https://${REDACTED}@git.example.com/repo`, + ); + }); + + it('drops the value half of a sensitive assignment', () => { + expect(scrub('--auth-token=whatever-shape-this-is')).toBe( + `--auth-token=${REDACTED}`, + ); + }); +}); + +describe('structure preservation', () => { + it('keeps shape while replacing sensitive values', () => { + expect( + scrub({out: `${ctx.cwd}/dist/theme.css`, token: 'abc', check: true}), + ).toEqual({out: './dist/theme.css', token: REDACTED, check: true}); + }); + + it('recurses into arrays and nested objects', () => { + expect(scrub({a: [{password: 'x'}, 'ada@example.com']})).toEqual({ + a: [{password: REDACTED}, REDACTED], + }); + }); + + it('leaves numbers and booleans as their own type', () => { + expect(scrub({n: 42, b: false})).toEqual({n: 42, b: false}); + }); + + it('records the type of a non-serializable value, never the value', () => { + expect(scrub({fn: () => 'secret'})).toEqual({fn: '[function]'}); + }); + + it('stops recursing on a deeply nested structure', () => { + /** @type {any} */ + let deep = 'leaf'; + for (let i = 0; i < 20; i += 1) deep = {next: deep}; + expect(() => scrub(deep)).not.toThrow(); + }); + + it('survives a circular reference', () => { + /** @type {any} */ + const circular = {name: 'a'}; + circular.self = circular; + expect(() => scrub(circular)).not.toThrow(); + }); +}); + +describe('disabled mode', () => { + it('keeps sensitive content verbatim', () => { + const raw = createRedactor({enabled: false}); + expect(raw({token: 'ghp_secret', path: '/Users/ada/x'})).toEqual({ + token: 'ghp_secret', + path: '/Users/ada/x', + }); + }); + + it('still clamps oversized values', () => { + // Length limits are a size guard, not a privacy one, so they survive + // turning scrubbing off — otherwise one huge value costs the whole event. + const raw = createRedactor({enabled: false, maxLength: 10}); + expect(String(raw({big: 'y'.repeat(500)}).big)).toMatch( + /^y{10}…\[\+490 chars\]$/, + ); + }); +}); diff --git a/packages/cli/foundation/response/error-codes.doc.mjs b/packages/cli/foundation/response/error-codes.doc.mjs index 16d591a01894..d3c35de2b37a 100644 --- a/packages/cli/foundation/response/error-codes.doc.mjs +++ b/packages/cli/foundation/response/error-codes.doc.mjs @@ -235,5 +235,15 @@ export const doc = { description: 'A layout expression parsed but failed validation (unknown component/prop/enum/block).', }, + { + value: 'ERR_UNCLASSIFIED_EXIT', + description: + 'Recorded in the debug log, never printed: a command exited non-zero without going through cliError/jsonError, so no stable code was available.', + }, + { + value: 'ERR_SIGNAL_TERMINATED', + description: + 'Recorded in the debug log, never printed: the process was ended by a signal (Ctrl-C, SIGTERM) before the command reached a terminal path.', + }, ], }; diff --git a/packages/cli/foundation/response/error-codes.mjs b/packages/cli/foundation/response/error-codes.mjs index 19715ae7fb8d..01453711ba4b 100644 --- a/packages/cli/foundation/response/error-codes.mjs +++ b/packages/cli/foundation/response/error-codes.mjs @@ -79,6 +79,8 @@ * | 'ERR_FETCH_FAILED' * | 'ERR_LAYOUT_PARSE' * | 'ERR_LAYOUT_INVALID' + * | 'ERR_UNCLASSIFIED_EXIT' + * | 'ERR_SIGNAL_TERMINATED' * )} ErrorCode */ @@ -194,6 +196,19 @@ export const ERROR_CODES = Object.freeze({ ERR_LAYOUT_PARSE: 'ERR_LAYOUT_PARSE', /** A layout expression parsed but failed validation (unknown component/prop/enum/block). */ ERR_LAYOUT_INVALID: 'ERR_LAYOUT_INVALID', + + // ── Debug log ──────────────────────────────────────────────────── + /** + * Recorded (never printed): a command exited non-zero without going through + * cliError/jsonError, so no stable code was available. Marks a bypass of the + * error funnel rather than a user-facing condition. + */ + ERR_UNCLASSIFIED_EXIT: 'ERR_UNCLASSIFIED_EXIT', + /** + * Recorded (never printed): the process was ended by a signal, so the + * command never reached a terminal path of its own. + */ + ERR_SIGNAL_TERMINATED: 'ERR_SIGNAL_TERMINATED', }); /** diff --git a/packages/cli/foundation/response/json-contract.test.mjs b/packages/cli/foundation/response/json-contract.test.mjs index 263270b1035a..58c6a78b9ea4 100644 --- a/packages/cli/foundation/response/json-contract.test.mjs +++ b/packages/cli/foundation/response/json-contract.test.mjs @@ -164,10 +164,20 @@ describe('contract: every --json emission is valid JSON with apiVersion', () => }); it('unsupported command error envelope carries apiVersion', () => { - const r = runCli(['init', '--json', '--all']); + // `theme` is a command group with no output of its own, so it stays off + // the --json allowlist. (`init` used to be the example here until it grew + // a receipt of its own.) + const r = runCli(['theme', '--json']); const env = JSON.parse(r.stdout); expect(env.apiVersion).toBe(API_VERSION); - expect(env.error).toMatch(/init/); + expect(env.error).toMatch(/theme/); + }); + + it('init emits its install receipt', () => { + const r = runCli(['init', '--json']); + const env = JSON.parse(r.stdout); + expect(env.apiVersion).toBe(API_VERSION); + expect(env.type).toBe('init.run'); }); it('supported command (discover) emits clean JSON, no human chatter leak', () => { diff --git a/packages/cli/foundation/response/json.mjs b/packages/cli/foundation/response/json.mjs index 9a371efc840e..5c1968367320 100644 --- a/packages/cli/foundation/response/json.mjs +++ b/packages/cli/foundation/response/json.mjs @@ -29,6 +29,7 @@ * consumers can negotiate. Exposed on every envelope as `apiVersion`. */ import {ERROR_CODES} from './error-codes.mjs'; +import {recordEnvelope, setOutcome} from '../debug/index.mjs'; export const API_VERSION = 1; @@ -106,6 +107,7 @@ export function jsonOut(response) { // envelope" contract. const out = JSON.stringify(envelope, null, 2); process.__xdsJsonHandled = true; + recordEnvelope(response.type); console.log(out); } @@ -150,6 +152,8 @@ export function toErrorEnvelope(err, suggestions, code) { export function jsonError(message, suggestions, code) { process.__xdsJsonHandled = true; const err = toErrorEnvelope(message, suggestions, code); + // No-op when cliError already classified this failure — first outcome wins. + setOutcome('error', {exitCode: 1, error: new Error(message), code: err.code}); console.log(JSON.stringify(err, null, 2)); process.exit(1); } diff --git a/packages/cli/foundation/response/response-types.doc.mjs b/packages/cli/foundation/response/response-types.doc.mjs index 653ca06206f1..61b7dd680fde 100644 --- a/packages/cli/foundation/response/response-types.doc.mjs +++ b/packages/cli/foundation/response/response-types.doc.mjs @@ -17,6 +17,16 @@ export const doc = { description: 'The `type` discriminant present on every --json success envelope. Consumers switch on it to narrow `data`.', members: [ + { + value: 'init.run', + description: + "The install receipt: the `mode` (`default` | `features`), the features run, agent-doc files written, any soft `docsError`, whether theme guidance was emitted, the template outcome (`workflow` | `created` | `skipped`) plus its path, and whether the next-steps were emitted.", + }, + { + value: 'init.remove', + description: + 'Confirmation that the managed agent-docs block was removed (`data.removed: true`) — returned when --remove-agents is set.', + }, // component { value: 'component.list', diff --git a/packages/cli/package.json b/packages/cli/package.json index 6e5175199d58..2943587ab480 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -47,6 +47,10 @@ "types": "./authoring/config/type.ts", "import": "./authoring/config/parse.mjs" }, + "./debug": { + "types": "./authoring/debug/type.ts", + "import": "./authoring/debug/parse.mjs" + }, "./integration": { "types": "./authoring/integration/type.ts", "import": "./authoring/integration/parse.mjs"