Repository navigation
Add typed JSON output to app dev - #8957
isaacroldan wants to merge 44 commits into
Conversation
|
/snapit |
|
🫰✨ Thanks @isaacroldan! Your snapshot has been published to npm. Built from Test the snapshot by installing your package globally: pnpm i -g --@shopify:registry=https://registry.npmjs.org @shopify/cli@0.0.0-snapshot-20261020091722Caution After installing, validate the version by running |
|
/snapit |
|
🫰✨ Thanks @isaacroldan! Your snapshot has been published to npm. Built from Test the snapshot by installing your package globally: pnpm i -g --@shopify:registry=https://registry.npmjs.org @shopify/cli@0.0.0-snapshot-20261020115852Caution After installing, validate the version by running |
# Conflicts: # packages/app/src/cli/services/dev/ui.tsx
# Conflicts: # packages/app/src/cli/services/dev/ui.test.tsx # packages/app/src/cli/services/dev/ui.tsx
# Conflicts: # packages/app/src/cli/commands/app/dev.ts # packages/app/src/cli/services/dev.ts # packages/app/src/cli/services/dev/ui.integration.test.ts
Differences in type declarationsWe detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:
New type declarationspackages/cli-kit/dist/private/node/dev-instance-output.d.tsimport { zod } from '../../public/node/schema.js';
import { Writable, type WritableOptions } from 'stream';
export declare const devInstanceLogOutputSchema: import("../../public/node/json-output-schema.js").JsonOutputSchema<zod.ZodObject<{
timestamp: zod.ZodString;
source: zod.ZodString;
level: zod.ZodOptional<zod.ZodEnum<["debug", "info", "warning", "error"]>>;
message: zod.ZodString;
}, "strict", zod.ZodTypeAny, {
source: string;
message: string;
timestamp: string;
level?: "info" | "error" | "debug" | "warning" | undefined;
}, {
source: string;
message: string;
timestamp: string;
level?: "info" | "error" | "debug" | "warning" | undefined;
}>>;
/** One line of a dev instance's JSON Lines log. */
export type DevInstanceLogEntry = zod.infer<typeof devInstanceLogOutputSchema.schema>;
interface DevInstanceOutputOptions {
logFile: string;
logOutput?: Writable;
isActive: () => boolean;
onError: (error: unknown) => void;
}
export declare function createDevInstanceOutput({ logFile, logOutput, isActive, onError }: DevInstanceOutputOptions): {
opened: Promise<any[]>;
log: (entry: Omit<DevInstanceLogEntry, "timestamp"> & {
timestamp?: string;
}) => Promise<void>;
tee: (target: Writable, { source, stringWritesAreMessages }: {
source: string;
stringWritesAreMessages?: boolean;
}) => DevInstanceOutput;
flush: () => Promise<PromiseSettledResult<void>[]>;
close: () => Promise<void>;
};
declare class DevInstanceOutput extends Writable {
private readonly contexts;
constructor(options: WritableOptions & {
write: NonNullable<WritableOptions['write']>;
});
write(chunk: string | Uint8Array, encoding?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void): boolean;
end(chunk?: string | Uint8Array | null | (() => void), encoding?: BufferEncoding | (() => void), callback?: () => void): this;
}
export {};
packages/cli-kit/dist/private/node/output-context.d.tsexport interface ConcurrentOutputContext {
outputPrefix?: string;
/** Controls ANSI stripping for terminal output. JSON diagnostics are always unstyled. */
stripAnsi?: boolean;
}
export declare function useConcurrentOutputContext<T>(context: ConcurrentOutputContext, callback: () => T): T;
/** Returns the source and styling context for the current output write. */
export declare function getConcurrentOutputContext(): ConcurrentOutputContext | undefined;
packages/cli-kit/dist/public/node/dev-instance.d.tsimport { type DevInstanceLogEntry } from '../../private/node/dev-instance-output.js';
import { Writable } from 'stream';
/**
* A stable error code for JSON consumers when a long-running command refuses to start because another instance of
* it is already running for the same project.
*/
export declare const DEV_INSTANCE_ALREADY_RUNNING_CODE = "dev_instance_already_running";
/**
* Describes a running instance of a long-running command such as `app dev` or `theme dev`.
* It's written to `.shopify/dev/<slug>.json` so agents and other tools can discover the instance.
*/
export interface DevInstanceInfo {
schemaVersion: 1;
/** Identifies this run, including when the OS reuses a PID. */
sessionId: string;
/** The command that owns the instance, for example `app dev`. */
command: string;
/** Distinguishes instances of the same command in the same directory, for example the app configuration file. */
key?: string;
pid: number;
status: 'starting' | 'running';
startedAt: string;
updatedAt: string;
/** The project directory the instance runs for. */
directory: string;
/** The JSON Lines file the instance appends its output to. */
logFile: string;
/** Command-specific, JSON-serializable details such as URLs and ports. */
details: {
[key: string]: unknown;
};
}
export { devInstanceLogOutputSchema, type DevInstanceLogEntry } from '../../private/node/dev-instance-output.js';
export interface DevInstancePaths {
/** The folder that holds every dev instance of the project: `.shopify/dev`. */
folder: string;
/** The instance description. */
infoFile: string;
/** The lock directory, owned by `proper-lockfile`. */
lockFile: string;
/** The JSON Lines log. */
logFile: string;
}
export interface DevInstanceIdentity {
/** The command that owns the instance, for example `app dev`. */
command: string;
/** The project directory. */
directory: string;
/** Distinguishes several instances of the same command in the same directory. */
key?: string;
}
export interface AcquireDevInstanceOptions extends DevInstanceIdentity {
/** Initial command-specific details. */
details?: {
[key: string]: unknown;
};
/** Receives the exact JSONL bytes written to the session log. The caller owns this stream. */
logOutput?: Writable;
}
export interface DevInstanceUpdate {
status?: DevInstanceInfo['status'];
/** Merged into the existing details. */
details?: {
[key: string]: unknown;
};
}
export interface DevInstance {
readonly info: DevInstanceInfo;
readonly paths: DevInstancePaths;
/** Aborts when this session loses its lock or output fails. Consumers must stop their owned work. */
readonly abortSignal: AbortSignal;
/** Updates and persists the instance description. */
update: (changes: DevInstanceUpdate) => Promise<void>;
/** Appends one entry to the JSON Lines log. */
log: (entry: Omit<DevInstanceLogEntry, 'timestamp'> & {
timestamp?: string;
}) => Promise<void>;
/**
* Wraps a stream so every line written to it is also appended to the log, without ANSI styling.
* The returned stream forwards the original bytes to `target` unchanged.
*/
tee: (target: Writable, options: {
source: string;
/** Treat each string write as a complete message. Byte writes remain buffered into lines. */
stringWritesAreMessages?: boolean;
}) => Writable;
/** Closes the log, removes the instance description and releases the lock. Safe to call more than once. */
release: () => Promise<void>;
}
/**
* Returns the files used by a dev instance. Every instance of a project shares `.shopify/dev`,
* with one `<slug>.json`, `<slug>.lock` and `<slug>.jsonl` per command and key.
*
* @param identity - The command, directory and optional key of the instance.
* @returns The paths of the instance files.
*/
export declare function devInstancePaths(identity: DevInstanceIdentity): DevInstancePaths;
/**
* Marks this process as the running instance of a long-running command for a project.
*
* Mutual exclusion uses `proper-lockfile`: an atomic `mkdir` lock whose mtime this process refreshes as a heartbeat.
* An exclusive startup directory serializes abandoned-lock recovery. An interrupted startup requires explicit
* cleanup of that directory. A live owner blocks takeover, including when its heartbeat is stale.
*
* @param options - The instance identity, initial details.
* @returns The acquired instance.
* @throws AbortError with code {@link DEV_INSTANCE_ALREADY_RUNNING_CODE} when another live instance holds the lock.
*/
export declare function acquireDevInstance(options: AcquireDevInstanceOptions): Promise<DevInstance>;
/**
* Acquires a dev instance and releases it after the callback completes or throws.
*
* @param options - The command, project and output options for the instance.
* @param execute - The work to run while the instance is owned.
* @returns The callback's result after instance cleanup completes.
*/
export declare function withDevInstance<T>(options: AcquireDevInstanceOptions, execute: (instance: DevInstance) => T | Promise<T>): Promise<T>;
/**
* Returns the running instance of a command for a project, if there is one.
*
* @param identity - The command, directory and optional key of the instance.
* @returns The instance description, or undefined when no live instance holds the lock.
*/
export declare function readDevInstance(identity: DevInstanceIdentity): Promise<DevInstanceInfo | undefined>;
/**
* Lists every running dev instance of a project, across commands and keys.
*
* @param directory - The project directory.
* @returns The descriptions of the live instances.
*/
export declare function listDevInstances(directory: string): Promise<DevInstanceInfo[]>;
/**
* Checks whether a process exists. A process owned by another user counts as alive.
*
* @param pid - The process ID.
* @returns True when the process exists.
*/
export declare function isProcessAlive(pid: number): boolean;
Existing type declarationspackages/cli-kit/dist/private/node/ui/components/ConcurrentOutput.d.ts@@ -1,3 +1,4 @@
+import { ConcurrentOutputContext, getConcurrentOutputContext, useConcurrentOutputContext } from '../../output-context.js';
import { type OutputProcess } from '../../../../public/node/output.js';
import { AbortSignal } from '../../../../public/node/abort.js';
import { FunctionComponent } from 'react';
@@ -13,12 +14,6 @@ export interface ConcurrentOutputProps {
keepRunningAfterProcessesResolve?: boolean;
useAlternativeColorPalette?: boolean;
}
-interface ConcurrentOutputContext {
- outputPrefix?: string;
- /** Controls ANSI stripping for terminal output. JSON diagnostics are always unstyled. */
- stripAnsi?: boolean;
-}
-declare function useConcurrentOutputContext<T>(context: ConcurrentOutputContext, callback: () => T): T;
/** Runs finite processes concurrently and routes their output through the shared diagnostic context. */
export declare function runConcurrentProcessesForJson({ processes, abortSignal, }: Pick<ConcurrentOutputProps, 'processes' | 'abortSignal'>): Promise<void>;
/**
@@ -55,4 +50,4 @@ export declare function runConcurrentProcessesForJson({ processes, abortSignal,
* ```
*/
declare const ConcurrentOutput: FunctionComponent<ConcurrentOutputProps>;
-export { ConcurrentOutput, ConcurrentOutputContext, useConcurrentOutputContext };
\ No newline at end of file
+export { ConcurrentOutput, ConcurrentOutputContext, getConcurrentOutputContext, useConcurrentOutputContext };
\ No newline at end of file
|
# Conflicts: # docs/cli/app-dev-agent-workflow.md # packages/app/src/cli/commands/app/dev.test.ts # packages/app/src/cli/commands/app/dev.ts # packages/app/src/cli/services/dev.ts # packages/app/src/cli/services/dev/ui.integration.test.ts # packages/app/src/cli/services/dev/ui.test.tsx # packages/app/src/cli/services/dev/ui.tsx
WHY are these changes introduced?
Agents need to read
app devoutput while the command keeps running. This adds JSON output above the session-file implementation in #8926.WHAT is this pull request doing?
Adds
--json(-j/SHOPIFY_FLAG_JSON) and schema discovery. The shared logger from #8952 validates and serializes each dev record once, then writes identical JSONL bytes to the file and stdout. Records containtimestamp,source,message, and optionallevel, with notype,stream, or repeated session ID. All changes todev-instance.tsand its tests remain in #8952.The log contains dev process output, preview URLs, and session status messages, including preview errors that users can fix while dev keeps running. CLI setup messages and other diagnostics use stderr. Fatal CLI errors use the standard
{error: {...}}document on stdout and are not added to the file. There is no final success result.--no-inputremains independent of JSON output.Session acquisition and cleanup are provided by #8926. This PR adds stdout mirroring and JSON UI routing, and extends the process-cleanup test to JSON mode. This PR does not change
base-command.ts,error-handler.ts, ordescriptionWithMarkdown.Example dev records, identical in stdout and the log:
{"timestamp":"2026-10-20T09:00:00Z","source":"app dev","level":"info","message":"Preview URL: https://example.myshopify.com/admin/apps/example"} {"timestamp":"2026-10-20T09:00:01Z","source":"web-backend","message":"Server listening on 3000"}How to manually test your changes?
shopify app dev --json --no-input --path <app-path> --store <dev-store.myshopify.com> > /tmp/dev-output.jsonl. Read the log path from the instance metadata under<app-path>/.shopify/dev/and edit the app. Press Ctrl+C once, then compare the files withcmp /tmp/dev-output.jsonl <session-log-path>.--jsonto check terminal output. Runshopify app dev --json-schemato inspect the schemas.app dev --jsonfor the same configuration. Check fordev_instance_already_running. Confirm that this error is absent from the running session's log.Validation
Includes Expose app dev session files for agents #8926’s CI fixes after Add reusable dev session files to CLI Kit #8952 merged: callers match the merged tee API, and startup time is separate from the shutdown deadline. The updated stack passes 113 focused tests and the full workspace build locally. The slow-start probe passes while the stuck-shutdown control still fails.
The updated stack passes 158 focused tests after the Add reusable dev session files to CLI Kit #8952 helper/output refactor and Expose app dev session files for agents #8926 service integration. The command passes its parsed JSON option to the service function. CLI Kit/App type checks, focused lint, build, and bundle pass. Node 22.12.0 single-SIGINT cases pass in both text and JSON modes.
The text line-break fix comes from Expose app dev session files for agents #8926. All 21 focused UI/service/shutdown tests pass on this branch. Compiled subprocess probes confirm separate warning lines in text mode with 20 processes and 24,106 retained log records, and unchanged JSON stdout/file bytes for 24,104 records. Single-signal shutdown passes in both probes.
110 focused tests passed across app dev, real-bootstrap shutdown, function builds, and dev-instance behavior. Expose app dev session files for agents #8926 independently passed 58 focused tests plus App/CLI type checks and lint. The regression cases verify that setup diagnostics and fatal CLI errors stay out of the file, dev records match stdout, and failures and cancellation release ownership. The signal and compiler-diagnostic fixes live in Expose app dev session files for agents #8926; this PR uses that updated base.
The common shutdown fix lives in Expose app dev session files for agents #8926. Subprocess tests send one SIGINT or SIGTERM in each of text and JSON modes while background work remains active. They check postrun completion, all 1,001 queued records, ownership cleanup, and exact stdout/file bytes in JSON mode. The SIGINT cases also pass on Node 22.12.0.
The compiled 20-process probe now leaves a background timer active. A single SIGTERM exits successfully after the real postrun hook, retaining all 24,104 records.
JSON process tees use a separate discard stream per process. A 20-process regression reproduced 40 error listeners on the previous shared target; the fix stays below the default listener limit and removes the listeners on release.
A compiled-command subprocess probe ran 20 processes and produced 24,104 records with a delayed stdout reader. No
MaxListenersExceededWarningoccurred. All stdout bytes matched the file after SIGTERM, per-source output stayed ordered, and cleanup and restart passed. Remote setup was replaced in this local probe.The retained stream test sends 1,000 lines, split UTF-8 bytes, ANSI text, messages without newlines, and output after a watcher finishes setup. It checks record sources, final-line flushing, stdout/file byte equality, and sibling-process cleanup after failure.
A local subprocess probe loads the real bundled CLI bootstrap and uses the compiled command lifecycle and dev service with remote setup replaced. It sent 5,000 ordered process lines, 100 session status messages, one preview URL, split UTF-8, and late output, while delaying the stdout reader. After SIGTERM, all 5,104 stdout records matched the session log byte for byte. The process exited with code zero; lock cleanup and restart recovery passed.
A separate subprocess failure retained the dev process record in the file and printed the standard fatal error document only to stdout. Setup and runtime CLI diagnostics went to stderr. The instance was released.
The built CLI kept standard JSON error documents and nonzero exits for invalid flags. Its stream schema excludes fatal-error fields.
descriptionWithMarkdownmatches Expose app dev session files for agents #8926 exactly.App and CLI Kit type checks, the build and CLI bundle, focused lint/format checks, command snapshot, and documentation generators passed. No live-store, physical-TTY, or full end-to-end run was performed.
Checklist
minor) and added a changeset