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
5 changes: 5 additions & 0 deletions .changeset/optional-dev-log-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': minor
---

Allow Dev Instance logs to omit the source field when all messages have the same producer.
5 changes: 5 additions & 0 deletions .changeset/theme-dev-instance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/theme': minor
---

Record `theme dev` sessions, preview URLs, and JSONL logs so agents can reuse a running session.
16 changes: 16 additions & 0 deletions docs/cli/dev-instance-agent-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,19 @@ With `--json`, dev stream records use identical JSONL bytes in stdout and the se
log. The log contains process output, preview URLs, and session status messages.
CLI setup messages and other diagnostics use stderr. Fatal CLI errors use the
standard JSON error output on stdout and are not added to the session log.

## Theme dev

`shopify theme dev` owns one session per store and theme ID in a directory.
Sessions for different listings of the same store and theme cannot run together.
Different stores or theme IDs can run together.

The instance details include `store`, `themeId`, `themeName`, `listing` when set,
`host`, `port`, and `urls` with `local`, `preview`, `themeEditor`, and `giftCard`
links. URLs are added during setup. The instance status becomes `running` after
the server is listening and initial synchronization has completed.

The log includes theme dev messages, file synchronization results, request lines,
errors, and setup progress. Theme records omit the redundant `source` field.
Authentication before the session and fatal CLI errors reported after it are
outside this log.
9 changes: 6 additions & 3 deletions packages/cli-kit/src/private/node/dev-instance-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ export const devInstanceLogOutputSchema = defineJsonOutputSchema({
schema: zod
.object({
timestamp: jsonOutputTimestampSchema,
source: zod.string().describe('What produced the line, for example a process prefix like web or extensions.'),
source: zod
.string()
.optional()
.describe('What produced the line, for example a process prefix like web or extensions, when needed.'),
level: zod.enum(['debug', 'info', 'warning', 'error']).optional(),
message: zod.string(),
})
Expand Down Expand Up @@ -63,7 +66,7 @@ export function createDevInstanceOutput({logFile, logOutput, isActive, onError}:
log: writeLogEntry,
tee: (
target: Writable,
{source, stringWritesAreMessages = false}: {source: string; stringWritesAreMessages?: boolean},
{source, stringWritesAreMessages = false}: {source?: string; stringWritesAreMessages?: boolean},
) => {
const decoder = new StringDecoder('utf8')
let pending = ''
Expand All @@ -82,7 +85,7 @@ export function createDevInstanceOutput({logFile, logOutput, isActive, onError}:
const output = new DevInstanceOutput({
decodeStrings: !stringWritesAreMessages,
write(chunk: Buffer | string, encoding, callback) {
const nextSource = getConcurrentOutputContext()?.outputPrefix ?? source
const nextSource = source === undefined ? undefined : (getConcurrentOutputContext()?.outputPrefix ?? source)
const completeMessage = stringWritesAreMessages && typeof chunk === 'string'
const previous = nextSource !== currentSource || completeMessage ? flush() : Promise.resolve()
currentSource = nextSource
Expand Down
20 changes: 20 additions & 0 deletions packages/cli-kit/src/public/node/dev-instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,26 @@ describe('withDevInstance', () => {
})

describe('acquireDevInstance', () => {
test('omits sources from direct messages and streams when no source is supplied', async () => {
await inTemporaryDirectory(async (directory) => {
const mirror = collectingStream()
const target = collectingStream()
const identity = {command: 'theme dev', directory}
await withDevInstance({...identity, logOutput: mirror.stream}, async (instance) => {
await instance.log({message: 'Setup'})
const output = instance.tee(target.stream, {stringWritesAreMessages: true})
useConcurrentOutputContext({outputPrefix: 'theme'}, () => output.write('Ready\n'))
})
const {logFile} = devInstancePaths(identity)
await expect(readLog(logFile)).resolves.toEqual([
{timestamp: expect.any(String), message: 'Setup'},
{timestamp: expect.any(String), message: 'Ready'},
])
expect(target.chunks.join('')).toBe('Ready\n')
expect(mirror.chunks.join('')).toBe(await readFile(logFile))
})
})

test('does not include CLI fatal errors in the session log schema', () => {
expect(() =>
devInstanceLogOutputSchema.validate({
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-kit/src/public/node/dev-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ export interface DevInstance {
tee: (
target: Writable,
options: {
source: string
/** Omit the source field when the stream does not need to distinguish producers. */
source?: string
/** Treat each string write as a complete message. Byte writes remain buffered into lines. */
stringWritesAreMessages?: boolean
},
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2009,7 +2009,7 @@ DESCRIPTION
},
"source": {
"type": "string",
"description": "What produced the line, for example a process prefix like web or extensions."
"description": "What produced the line, for example a process prefix like web or extensions, when needed."
},
"level": {
"type": "string",
Expand All @@ -2026,7 +2026,6 @@ DESCRIPTION
},
"required": [
"timestamp",
"source",
"message"
],
"additionalProperties": false,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1191,7 +1191,7 @@
"args": {
},
"customPluginName": "@shopify/app",
"description": "Builds and previews your app on a dev store, and watches for changes. \"Read more about testing apps locally\" (https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally).\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `DevInstanceLogEntry` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"timestamp\": {\n \"$ref\": \"#/definitions/DevInstance/properties/startedAt\"\n },\n \"source\": {\n \"type\": \"string\",\n \"description\": \"What produced the line, for example a process prefix like web or extensions.\"\n },\n \"level\": {\n \"type\": \"string\",\n \"enum\": [\n \"debug\",\n \"info\",\n \"warning\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"source\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"definitions\": {\n \"DevInstance\": {\n \"type\": \"object\",\n \"properties\": {\n \"schemaVersion\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"sessionId\": {\n \"type\": \"string\"\n },\n \"command\": {\n \"type\": \"string\",\n \"const\": \"app dev\"\n },\n \"key\": {\n \"type\": \"string\"\n },\n \"pid\": {\n \"type\": \"integer\",\n \"exclusiveMinimum\": 0\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"starting\",\n \"running\"\n ]\n },\n \"startedAt\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"updatedAt\": {\n \"$ref\": \"#/definitions/DevInstance/properties/startedAt\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"logFile\": {\n \"type\": \"string\"\n },\n \"details\": {\n \"type\": \"object\",\n \"properties\": {\n \"configPath\": {\n \"type\": \"string\"\n },\n \"appName\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\"\n },\n \"storeFqdn\": {\n \"type\": \"string\"\n },\n \"previewUrl\": {\n \"type\": \"string\"\n },\n \"graphiqlUrl\": {\n \"type\": \"string\"\n },\n \"appUrl\": {\n \"type\": \"string\"\n },\n \"localUrl\": {\n \"type\": \"string\"\n },\n \"preview\": {\n \"$ref\": \"#/definitions/DevPreview\",\n \"description\": \"The CLI-reported preview state in the instance file; this is not a health check.\"\n }\n },\n \"required\": [\n \"configPath\",\n \"appName\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"schemaVersion\",\n \"sessionId\",\n \"command\",\n \"pid\",\n \"status\",\n \"startedAt\",\n \"updatedAt\",\n \"directory\",\n \"logFile\",\n \"details\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The instance JSON file under .shopify/dev/. This object is not emitted to stdout.\"\n },\n \"DevPreview\": {\n \"type\": \"object\",\n \"properties\": {\n \"state\": {\n \"type\": \"string\",\n \"enum\": [\n \"starting\",\n \"updating\",\n \"ready\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"errors\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"state\",\n \"message\",\n \"errors\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The CLI-reported preview state in the instance file; this is not a health check.\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"description": "Builds and previews your app on a dev store, and watches for changes. \"Read more about testing apps locally\" (https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally).\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `DevInstanceLogEntry` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"timestamp\": {\n \"$ref\": \"#/definitions/DevInstance/properties/startedAt\"\n },\n \"source\": {\n \"type\": \"string\",\n \"description\": \"What produced the line, for example a process prefix like web or extensions, when needed.\"\n },\n \"level\": {\n \"type\": \"string\",\n \"enum\": [\n \"debug\",\n \"info\",\n \"warning\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"timestamp\",\n \"message\"\n ],\n \"additionalProperties\": false,\n \"title\": \"DevInstanceLogEntry\",\n \"definitions\": {\n \"DevInstance\": {\n \"type\": \"object\",\n \"properties\": {\n \"schemaVersion\": {\n \"type\": \"number\",\n \"const\": 1\n },\n \"sessionId\": {\n \"type\": \"string\"\n },\n \"command\": {\n \"type\": \"string\",\n \"const\": \"app dev\"\n },\n \"key\": {\n \"type\": \"string\"\n },\n \"pid\": {\n \"type\": \"integer\",\n \"exclusiveMinimum\": 0\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"starting\",\n \"running\"\n ]\n },\n \"startedAt\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"pattern\": \"^\\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z$\",\n \"description\": \"A UTC ISO 8601 instant with whole seconds and the Z timezone marker.\"\n },\n \"updatedAt\": {\n \"$ref\": \"#/definitions/DevInstance/properties/startedAt\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"logFile\": {\n \"type\": \"string\"\n },\n \"details\": {\n \"type\": \"object\",\n \"properties\": {\n \"configPath\": {\n \"type\": \"string\"\n },\n \"appName\": {\n \"type\": \"string\"\n },\n \"clientId\": {\n \"type\": \"string\"\n },\n \"storeFqdn\": {\n \"type\": \"string\"\n },\n \"previewUrl\": {\n \"type\": \"string\"\n },\n \"graphiqlUrl\": {\n \"type\": \"string\"\n },\n \"appUrl\": {\n \"type\": \"string\"\n },\n \"localUrl\": {\n \"type\": \"string\"\n },\n \"preview\": {\n \"$ref\": \"#/definitions/DevPreview\",\n \"description\": \"The CLI-reported preview state in the instance file; this is not a health check.\"\n }\n },\n \"required\": [\n \"configPath\",\n \"appName\",\n \"clientId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"schemaVersion\",\n \"sessionId\",\n \"command\",\n \"pid\",\n \"status\",\n \"startedAt\",\n \"updatedAt\",\n \"directory\",\n \"logFile\",\n \"details\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The instance JSON file under .shopify/dev/. This object is not emitted to stdout.\"\n },\n \"DevPreview\": {\n \"type\": \"object\",\n \"properties\": {\n \"state\": {\n \"type\": \"string\",\n \"enum\": [\n \"starting\",\n \"updating\",\n \"ready\",\n \"error\"\n ]\n },\n \"message\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"errors\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"state\",\n \"message\",\n \"errors\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The CLI-reported preview state in the instance file; this is not a health check.\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"descriptionWithMarkdown": "Builds and previews your app on a dev store, and watches for changes. [Read more about testing apps locally](https://shopify.dev/docs/apps/build/cli-for-apps/test-apps-locally).",
"flags": {
"auth-alias": {
Expand Down
59 changes: 59 additions & 0 deletions packages/theme/src/cli/services/dev.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {test, expect} from 'vitest'
import {captureOutputWithExitCode} from '@shopify/cli-kit/node/system'
import {fileExists, inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs'
import {devInstancePaths} from '@shopify/cli-kit/node/dev-instance'
import {fileURLToPath} from 'node:url'

const bootstrapUrl = new URL('../../../../cli/src/bootstrap.ts', import.meta.url).href
const serviceUrl = new URL('./dev.ts', import.meta.url).href
const loaderUrl = new URL('../../../test/fixtures/dev-instance-loader.js', import.meta.url).href
const tsconfigPath = fileURLToPath(new URL('../../../../app/test/fixtures/tsconfig.json', import.meta.url))

test.skipIf(process.platform === 'win32').each(['SIGINT', 'SIGTERM'])(
'%s with bootstrap flushes theme output and exits on the first signal',
{timeout: 60000},
async (signal) => {
await inTemporaryDirectory(async (directory) => {
const script = `
await import(${JSON.stringify(bootstrapUrl)})
const {dev} = await import(${JSON.stringify(serviceUrl)})
await dev({
directory: ${JSON.stringify(directory)}, store: 'fixture.myshopify.com',
adminSession: {storeFqdn: 'fixture.myshopify.com', token: 'fixture'}, commandConfig: {},
theme: {id: 123, name: 'Fixture', role: 'development'},
open: false, force: false, noDelete: false, ignore: [], only: [],
'standard-events-inspector': false, 'theme-editor-sync': false,
'live-reload': 'hot-reload', 'error-overlay': 'default',
})
`
const result = await captureOutputWithExitCode(
process.execPath,
['--loader', 'ts-node/esm', '--loader', loaderUrl, '--input-type=module', '--eval', script],
{
env: {
...process.env,
NODE_NO_WARNINGS: '1',
SHOPIFY_UNIT_TEST: '0',
SHOPIFY_CLI_ENV: 'development',
SHOPIFY_CLI_NO_ANALYTICS: '1',
CI: '1',
TS_NODE_PROJECT: tsconfigPath,
THEME_DEV_TEST_SIGNAL: signal,
},
},
)
expect(result.exitCode, result.stderr).toBe(0)
const paths = devInstancePaths({command: 'theme dev', directory, key: 'fixture.myshopify.com:123'})
const messages = (await readFile(paths.logFile))
.trim()
.split('\n')
.map((line) => JSON.parse(line).message)
expect(messages.filter((message: string) => message.startsWith('line '))).toEqual(
Array.from({length: 1000}, (_, index) => `line ${index}`),
)
expect(messages.at(-1)).toBe('Stopped')
await expect(fileExists(paths.infoFile)).resolves.toBe(false)
await expect(fileExists(paths.lockFile)).resolves.toBe(false)
})
},
)
Loading
Loading