Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/cli-debug-command-log.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .changeset/cli-init-json.md
Original file line number Diff line number Diff line change
@@ -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
178 changes: 91 additions & 87 deletions packages/cli/README.md

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions packages/cli/authoring/config/config.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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<string, XleComponent> } }',
Expand All @@ -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: [
{
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/authoring/config/parse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PostCodemodHook['buildCommand']>} */ (
Expand All @@ -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<DebugEventHandler>} */ (
z.custom(value => typeof value === 'function', {message: 'Expected a function'})
);

const configSchema = z
.object({
integrations: z.array(z.string()).optional(),
Expand All @@ -44,6 +52,7 @@ const configSchema = z
.object({postCodemod: z.array(postCodemodHookSchema).optional()})
.strict()
.optional(),
debug: debugSchema.optional(),
experimental: z
.object({
xle: z
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/authoring/config/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. */
Expand All @@ -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.
Expand Down
143 changes: 143 additions & 0 deletions packages/cli/authoring/debug/parse.mjs
Original file line number Diff line number Diff line change
@@ -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<false>` 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<z.infer<typeof eventSchema>, 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);
}
Loading