Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
587b14d
Expose app dev session files for agents
isaacroldan Oct 19, 2026
0bde236
Document session identity in the dev status file
isaacroldan Oct 19, 2026
3197d4a
Keep app dev log messages and status transitions aligned with termina…
isaacroldan Oct 20, 2026
003947d
Own app dev sessions in the service
isaacroldan Oct 20, 2026
c1eb1bb
Document the 15-second dev stale interval
isaacroldan Oct 20, 2026
4c57c84
Flush app dev output before signal shutdown
isaacroldan Oct 20, 2026
f1869f2
Capture Javy diagnostics in dev session logs
isaacroldan Oct 20, 2026
2632d64
Document dev log discovery with identity hashes
isaacroldan Oct 20, 2026
7792233
Run dev shutdown subprocess tests from workspace sources
isaacroldan Oct 20, 2026
d445199
Exit non-interactive dev after the postrun hook
isaacroldan Oct 21, 2026
1a87349
Separate complete messages in non-interactive dev output
isaacroldan Oct 21, 2026
a3cebb6
Use scoped dev instance ownership in the app service
isaacroldan Oct 21, 2026
ade2bbc
Separate source startup and shutdown test deadlines
isaacroldan Oct 21, 2026
d632b3f
Remove stale stream options after restacking
isaacroldan Oct 21, 2026
78e2360
Document shared dev sessions and test shutdown on Windows
isaacroldan Oct 22, 2026
349478a
Preserve the root process during Windows dev shutdown
isaacroldan Oct 22, 2026
0405cff
Trace Windows subprocess shutdown in CI
isaacroldan Oct 22, 2026
479c9f2
Trace native Windows signal redelivery
isaacroldan Oct 22, 2026
567898b
Let dev shutdown handlers drain output before Windows exit
isaacroldan Oct 22, 2026
dce1186
Restore the standard manual test workflow
isaacroldan Oct 22, 2026
83d5e5c
Allow Windows child cleanup its postrun startup budget
isaacroldan Oct 22, 2026
b7d103f
Merge branch 'main' into make-dev-agentic
isaacroldan Oct 22, 2026
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/app-dev-instance-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Record `app dev` sessions, preview status, and JSONL logs so agents can reuse a running session.
6 changes: 6 additions & 0 deletions .changeset/windows-dev-shutdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': patch
'@shopify/cli': patch
---

Preserve graceful shutdown and child-process cleanup on Windows.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The list below contains valuable resources for people interested in contributing
* [Designing for CLI](./cli/designing-for-cli.md)
* [Conventions](./cli/conventions.md)
* [JSON output contracts](./cli/json-output.md)
* [Dev Instance files](./cli/dev-instance-agent-workflow.md)
* [Performance](./cli/performance.md)
* [Debugging](./cli/debugging.md)
* [ESLint rules](./cli/eslint-rules.md)
Expand Down
64 changes: 64 additions & 0 deletions docs/cli/dev-instance-agent-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Dev Instance files

Long-running dev commands write an instance JSON file and a JSONL log under
`<project>/.shopify/dev/`. Agents can read these files when dev was started in
another terminal. There is no IPC endpoint or extra command.

The instance JSON includes the command, project directory, PID, `sessionId`,
`status`, `logFile`, and command-specific `details`. `status` is `starting` or
`running`. Use `logFile` to find the log; filenames include an identity hash.
A saved instance file alone does not prove that the process is alive. Check its
PID and lock heartbeat before reusing the session.

## Read new output

Each complete JSONL line contains `timestamp` and `message`, with `source` and
`level` where available. Terminal colors, spinners, and screen redraws are excluded.
Keep a byte offset to read new lines. Wait for an incomplete final line to finish.
If `sessionId` in the instance JSON changes, reset the offset: each new run clears
the log. Logs have no size limit or rotation.

## Agent workflow

1. Find the instance JSON for the intended command and configuration. Check its
identity details, PID, and lock heartbeat.
2. Reuse an existing session. If none is available, start the dev command through
the agent's background terminal facility. Supply the required configuration and
store flags and use `--no-input` when setup and authentication are available.
3. If a second start reports `dev_instance_already_running`, read the reported
instance and log. Do not stop a session started by the developer.
4. Make the change, read new output, inspect the command-specific status, and test
the result. Use a bounded wait and report errors or timeouts.

Reported status is a snapshot of the CLI's state, not a health check or proof that
an edit has reached the store. Web-server and browser health require their own checks.

## Shutdown and recovery

Normal shutdown removes the instance JSON and lock, and retains the log until the
next run. Ctrl+C cancels dev work and waits for queued log writes. On Unix,
SIGINT and SIGTERM use the same shutdown path. Sending these signals through
`process.kill()` on Windows forces termination and does not drain queued writes.

A live PID blocks takeover, including after the fixed 15-second stale interval.
Stop an unresponsive owner before starting another instance. Crashes after
startup recover automatically on the next start. If the CLI is killed during the
short lock-acquisition step, a `.lock.acquire` directory can remain. The next
start reports its path. Confirm that no other startup is running before removing
that directory and trying again.

A crash can leave stale metadata or an incomplete log. Keep these local files out
of commits and external reports; output and local URLs can contain secrets.

## App dev

`shopify app dev` owns one session per app configuration. The instance details
include the app configuration, app identity, store, URLs, and `preview`:

- `state`: `starting`, `updating`, `ready`, or `error`.
- `message`: the current CLI status message, or `null` before one is available.
- `errors`: the current reported errors. Errors remain during retries and clear
when the CLI reports success.

Process messages keep their terminal source, including extension handles.
Changes to the status message also produce a log record with source `app dev`.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,10 @@
"protobufjs",
"unrs-resolver",
"yarn"
]
],
"patchedDependencies": {
"when-exit@2.1.5": "patches/when-exit@2.1.5.patch"
}
},
"resolutions": {
"@types/react": "18.3.12",
Expand Down
45 changes: 44 additions & 1 deletion packages/app/src/cli/commands/app/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,18 @@ import {
testProject,
} from '../../models/app/app.test-data.js'
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
import {
acquireDevInstance,
DEV_INSTANCE_ALREADY_RUNNING_CODE,
readDevInstance,
} from '@shopify/cli-kit/node/dev-instance'
import {addPublicMetadata} from '@shopify/cli-kit/node/metadata'
import {beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('../../services/dev.js')
vi.mock('../../services/dev.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../services/dev.js')>()),
dev: vi.fn(),
}))
vi.mock('../../services/dev-clean.js')
vi.mock('../../services/app-context.js')
vi.mock('../../services/store-context.js')
Expand Down Expand Up @@ -160,4 +168,39 @@ describe('app dev command', () => {
expect(devClean).not.toHaveBeenCalled()
})
})
test('records the running instance while dev runs and releases it afterwards', async () => {
await inTemporaryDirectory(async (tmp) => {
const {store} = mockAppAndStore(tmp)
vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'})
const identity = {command: 'app dev', directory: tmp, key: 'shopify.app.toml'}
let instanceWhileRunning: Awaited<ReturnType<typeof readDevInstance>>
vi.mocked(dev).mockImplementation(async () => {
instanceWhileRunning = await readDevInstance(identity)
})

await Dev.run(['--path', tmp, '--store', store.shopDomain], import.meta.url)

expect(instanceWhileRunning).toMatchObject({command: 'app dev', key: 'shopify.app.toml', pid: process.pid})
expect(dev).toHaveBeenCalledWith(expect.objectContaining({devInstance: expect.any(Object)}))
await expect(readDevInstance(identity)).resolves.toBeUndefined()
})
})

test('refuses to start when app dev is already running for the same configuration', async () => {
await inTemporaryDirectory(async (tmp) => {
const {store} = mockAppAndStore(tmp)
vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'})
const running = await acquireDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'})

// The command's error handler renders the AbortError and exits.
await expect(Dev.run(['--path', tmp, '--store', store.shopDomain], import.meta.url)).rejects.toThrow()
await expect(
acquireDevInstance({command: 'app dev', directory: tmp, key: 'shopify.app.toml'}),
).rejects.toMatchObject({code: DEV_INSTANCE_ALREADY_RUNNING_CODE, details: {instance: running.info}})

expect(storeContext).not.toHaveBeenCalled()
expect(dev).not.toHaveBeenCalled()
await running.release()
})
})
})
62 changes: 33 additions & 29 deletions packages/app/src/cli/commands/app/dev.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {appFlags} from '../../flags.js'
import {dev, DevOptions} from '../../services/dev.js'
import {dev, DevOptions, withDevSession} from '../../services/dev.js'
import {checkFolderIsValidApp} from '../../models/app/loader.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js'
import {linkedAppContext} from '../../services/app-context.js'
Expand Down Expand Up @@ -142,35 +142,39 @@ export default class Dev extends AppLinkedCommand {
forceRelink: flags.reset,
userProvidedConfigName: flags.config,
})
const store = await storeContext({
appContextResult,
storeFqdn: flags.store,
forceReselectStore: flags.reset,
storeCreationMode: 'selection-option',
})

const devOptions: DevOptions = {
...appContextResult,
store,
directory: flags.path,
update: !flags['no-update'],
unsafeValidation: flags['unsafe-validation'],
skipDependenciesInstallation: flags['skip-dependencies-installation'],
commandConfig: this.config,
subscriptionProductUrl: flags['subscription-product-url'],
checkoutCartUrl: flags['checkout-cart-url'],
theme: flags.theme,
themeExtensionPort: flags['theme-app-extension-port'],
storePassword: flags['store-password'],
notify: flags.notify,
graphiqlPort: flags['graphiql-port'],
graphiqlKey: flags['graphiql-key'],
// Must stay undefined when the flag is absent: generateCertificate() only prompts when this is nullish.
installMkcert: flags['install-mkcert'],
tunnel: tunnelMode,
}
return withDevSession(appContextResult, async (devInstance) => {
Comment thread
isaacroldan marked this conversation as resolved.
const store = await storeContext({
appContextResult,
storeFqdn: flags.store,
forceReselectStore: flags.reset,
storeCreationMode: 'selection-option',
})

await dev(devOptions)
return {app: appContextResult.app}
const devOptions: DevOptions = {
...appContextResult,
store,
directory: flags.path,
update: !flags['no-update'],
unsafeValidation: flags['unsafe-validation'],
skipDependenciesInstallation: flags['skip-dependencies-installation'],
commandConfig: this.config,
subscriptionProductUrl: flags['subscription-product-url'],
checkoutCartUrl: flags['checkout-cart-url'],
theme: flags.theme,
themeExtensionPort: flags['theme-app-extension-port'],
storePassword: flags['store-password'],
notify: flags.notify,
graphiqlPort: flags['graphiql-port'],
graphiqlKey: flags['graphiql-key'],
// Must stay undefined when the flag is absent: generateCertificate() only prompts when this is nullish.
installMkcert: flags['install-mkcert'],
tunnel: tunnelMode,
devInstance,
}

await dev(devOptions)
return {app: appContextResult.app}
})
}
}
130 changes: 130 additions & 0 deletions packages/app/src/cli/services/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {dev, blockIfMigrationIncomplete} from './dev.js'
import {setupDevProcesses} from './dev/processes/setup-dev-processes.js'
import {renderDev} from './dev/ui.js'
import {fetchAppRemoteConfiguration} from './app/select-app.js'
import {DevSessionStatusManager} from './dev/processes/dev-session/dev-session-status-manager.js'
import {DevSessionLogger} from './dev/processes/dev-session/dev-session-logger.js'
import {
testAppLinked,
testDeveloperPlatformClient,
Expand All @@ -15,7 +17,12 @@ import {describe, expect, test, vi} from 'vitest'
import {hashString} from '@shopify/cli-kit/node/crypto'
import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics'
import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp'
import {acquireDevInstance} from '@shopify/cli-kit/node/dev-instance'
import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs'
import {useConcurrentOutputContext} from '@shopify/cli-kit/node/ui/components'
import {Writable} from 'stream'

vi.mock('./local-storage.js')
vi.mock('./dev/fetch.js')
vi.mock('./dev/processes/setup-dev-processes.js')
vi.mock('./dev/ui.js')
Expand Down Expand Up @@ -86,6 +93,129 @@ describe('dev', () => {
})
})

describe('dev with a dev instance', () => {
test.each([false, true])('captures output and session URLs, stopping on lock loss: %s', async (loseLock) => {
await inTemporaryDirectory(async (tmp) => {
const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'})
const app = testAppLinked({directory: tmp})
const manager = new DevSessionStatusManager({
isReady: false,
statusMessage: {type: 'loading', message: 'Preparing dev preview'},
})
const devInstance = await acquireDevInstance({command: 'app dev', directory: tmp})
const ownership = new AbortController()
const previewUrl = 'https://dev-store.myshopify.com/admin/apps/api-key'
const graphiqlUrl = 'http://localhost:3456/graphiql'
let infoWhileRunning: unknown

vi.mocked(fetchAppRemoteConfiguration).mockResolvedValue({
name: 'Remote app',
application_url: '',
embedded: true,
})
vi.mocked(getAvailableTCPPort).mockResolvedValue(3456)
vi.mocked(checkPortAvailability).mockResolvedValue(true)
vi.mocked(setupDevProcesses).mockResolvedValue({
processes: [
{
type: 'web',
prefix: 'web',
function: async ({stdout, stderr}: {stdout: Writable; stderr: Writable}) => {
stdout.write('Server listening on 3000\n')
stderr.write('Deprecation warning\n')
useConcurrentOutputContext({outputPrefix: 'discount'}, () => {
stdout.write('Building function discount...')
stdout.write('Building GraphQL types...\n')
})
await new DevSessionLogger(stdout).info('Update accepted')
},
options: {},
} as any,
],
previewUrl,
graphiqlUrl,
devSessionStatusManager: manager,
})
const terminal = new Writable({write: (_chunk, _encoding, callback) => callback()})
vi.mocked(renderDev).mockImplementation(async ({processes, abortController}) => {
manager.updateStatus({previewURL: previewUrl})
manager.setMessage('READY')
manager.setMessage('CHANGE_DETECTED')
manager.updateStatus({isReady: true})
manager.setMessage('BUILD_ERROR', ['discount: Invalid input'])
manager.updateStatus({graphiqlURL: graphiqlUrl})
await vi.waitFor(async () => {
infoWhileRunning = JSON.parse(await readFile(devInstance.paths.infoFile))
expect(infoWhileRunning).toHaveProperty('details.preview.errors', ['discount: Invalid input'])
})
await Promise.all(processes.map((process) => process.action(terminal, terminal, new AbortController().signal)))
if (loseLock) {
ownership.abort(new Error('Lock lost'))
expect(abortController.signal.aborted).toBe(true)
}
})

const running = dev({
app,
project: testProject({usesWorkspaces: true}),
remoteApp: testOrganizationApp({apiKey: 'api-key'}),
organization: testOrganization(),
specifications: [],
developerPlatformClient: testDeveloperPlatformClient(),
store,
directory: app.directory,
update: false,
commandConfig: {} as any,
skipDependenciesInstallation: true,
tunnel: {mode: 'custom', url: 'https://localhost:3456'},
devInstance: {...devInstance, abortSignal: ownership.signal},
})
if (loseLock) {
await expect(running).rejects.toThrow('Lock lost')
} else {
await running
}
await devInstance.release()

expect(infoWhileRunning).toMatchObject({
status: 'running',
details: {
storeFqdn: 'dev-store.myshopify.com',
previewUrl,
preview: {state: 'error', errors: ['discount: Invalid input']},
},
})
const entries = (await readFile(devInstance.paths.logFile))
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line))
entries.forEach((entry) => expect(entry).not.toHaveProperty('stream'))
expect(entries).toEqual(
expect.arrayContaining([
expect.objectContaining({source: 'app dev', message: `Preview URL: ${previewUrl}`}),
expect.objectContaining({source: 'app dev', message: `GraphiQL URL (Admin API): ${graphiqlUrl}`}),
expect.objectContaining({source: 'web', message: 'Server listening on 3000'}),
expect.objectContaining({source: 'web', message: 'Deprecation warning'}),
expect.objectContaining({source: 'discount', message: 'Building function discount...'}),
expect.objectContaining({source: 'discount', message: 'Building GraphQL types...'}),
expect.objectContaining({source: 'app-preview', message: 'Update accepted'}),
]),
)
expect(
entries
.filter(({source}) => source === 'app dev')
.filter(({message}) => !message.startsWith('Preview URL:') && !message.startsWith('GraphiQL URL'))
.map(({level, message}) => ({level, message})),
).toEqual([
{level: 'info', message: 'Preparing dev preview'},
{level: 'info', message: 'Ready, watching for changes in your app'},
{level: 'info', message: 'Change detected, updating dev preview'},
{level: 'error', message: 'Build error. Please review your code and try again'},
])
})
})
})

describe('blockIfMigrationIncomplete', () => {
const baseConfig = () => ({
localApp: testAppLinked({}),
Expand Down
Loading
Loading