runwork 0.13.3 → 0.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,12 @@ import { DurableObject } from 'cloudflare:workers';
9
9
  import type { NativeWorkflowInstance, NativeWorkflowConfig } from './core-workflow-types';
10
10
  import type { StepOptions, WaitEventOptions } from './core-workflows';
11
11
  import { type Env } from './core-utils';
12
+ export declare class WorkflowPausedError extends Error {
13
+ reason: 'sleep' | 'waitEvent';
14
+ resumeAt?: number | undefined;
15
+ constructor(reason: 'sleep' | 'waitEvent', resumeAt?: number | undefined);
16
+ }
17
+ export declare function isWorkflowControlSignal(error: unknown): boolean;
12
18
  /**
13
19
  * WorkflowInstance Durable Object
14
20
  *
@@ -12,3 +12,4 @@ export { DEFAULT_WORKFLOW_CONFIG } from './core-workflow-types';
12
12
  export { WORKFLOW_INFRA_MODE } from './core-workflow-config';
13
13
  export { WorkflowInstanceDO } from './core-workflow-instance';
14
14
  export { WorkflowCoordinator } from './core-workflow-coordinator';
15
+ export { WorkflowPausedError, isWorkflowControlSignal } from './core-workflow-instance';
@@ -1,13 +1,20 @@
1
1
  import { Command } from 'commander';
2
- import { execFileSync } from '../utils/subprocess.js';
3
2
  import { readFileSync, existsSync } from 'fs';
4
3
  import { requireAuth } from '../auth/store.js';
5
4
  import { ApiClient } from '../api/client.js';
6
5
  import { shouldOutputJson, jsonOut } from '../utils/output.js';
7
6
  import { buildDeployGuide, buildErrorResponse } from '../utils/agent-guidance.js';
7
+ import { requireGit } from '../git/preflight.js';
8
+ import { ensureGitIdentity } from '../git/identity.js';
9
+ import { commitWorkingTree } from '../git/auto-commit.js';
10
+ import { syncWithRemote, hasCommits } from '../git/sync.js';
11
+ import { snapshotCriticalFiles, restoreMissingCriticalFiles, commitAndPushRestoredFiles } from '../git/critical-files.js';
12
+ import { evaluateDeployGuard } from '../git/deploy-guard.js';
13
+ import { promptConfirm } from '../utils/prompt.js';
8
14
  export const deployCommand = new Command('deploy')
9
15
  .description('Deploy the current app to production')
10
- .action(async (_opts, command) => {
16
+ .option('-y, --yes', 'Skip the confirmation prompt when the working tree differs from the preview')
17
+ .action(async (opts, command) => {
11
18
  const useJson = shouldOutputJson(command.optsWithGlobals().json);
12
19
  if (!existsSync('.runwork.json')) {
13
20
  if (useJson) {
@@ -17,36 +24,93 @@ export const deployCommand = new Command('deploy')
17
24
  console.error('No .runwork.json found. Run `runwork init` first.');
18
25
  process.exit(1);
19
26
  }
27
+ requireGit('deploy');
20
28
  const config = JSON.parse(readFileSync('.runwork.json', 'utf-8'));
21
29
  const creds = requireAuth();
22
30
  const client = new ApiClient(creds);
23
- // Sync: pull + push (skip if no commits yet)
24
- let repoHasCommits = false;
31
+ const cwd = process.cwd();
32
+ // Deploy guard: the preview reflects the last *pushed* commit. If the
33
+ // working tree is dirty or ahead of `runwork/main` and no auto-syncing
34
+ // dev session is keeping the preview in lockstep, `runwork deploy` will
35
+ // commit and ship code the user never saw in the preview. Warn first.
36
+ // Runs BEFORE the commit/sync/deploy steps below, which are unchanged.
37
+ const guard = evaluateDeployGuard(cwd, config.appId);
38
+ let deployGuardWarning;
39
+ if (guard.warn) {
40
+ deployGuardWarning = 'You have uncommitted or unpushed changes. `runwork deploy` will commit and deploy them, but they are not reflected in the preview.';
41
+ if (useJson) {
42
+ // Agents must never be blocked: the warning rides along in the
43
+ // final success response below so we proceed without prompting.
44
+ }
45
+ else if (opts.yes || !process.stdin.isTTY) {
46
+ // Non-interactive (no TTY) or explicit --yes: warn and proceed.
47
+ console.warn(deployGuardWarning);
48
+ }
49
+ else {
50
+ console.warn(deployGuardWarning);
51
+ const proceed = await promptConfirm('Deploy these changes anyway?');
52
+ if (!proceed) {
53
+ console.log('Deploy cancelled.');
54
+ process.exit(0);
55
+ }
56
+ }
57
+ }
58
+ // Deploy must work even when no `runwork dev` session is (or ever was)
59
+ // running. Reuse the exact sync path dev uses: commit any pending
60
+ // working-tree changes, then fetch -> rebase (fallback merge) -> push.
61
+ // `ensureGitIdentity` seeds a local identity so the commit cannot fail
62
+ // on a fresh machine where git user.email/.name were never configured.
63
+ ensureGitIdentity(cwd, creds);
64
+ if (!useJson)
65
+ console.log('Syncing...');
25
66
  try {
26
- execFileSync('git', ['rev-parse', 'HEAD'], { stdio: 'pipe' });
27
- repoHasCommits = true;
67
+ commitWorkingTree(cwd, `deploy: ${new Date().toISOString().replace('T', ' ').slice(0, 19)}`);
28
68
  }
29
69
  catch {
30
- // No commits yet
70
+ // No commit created (clean tree) or git not initialized yet. The
71
+ // sync/push step below surfaces the actionable failure.
72
+ }
73
+ if (!hasCommits(cwd)) {
74
+ if (useJson) {
75
+ jsonOut(buildErrorResponse('deploy', 'Nothing to deploy', 'No commits exist and the working tree has no changes to commit, so there is nothing to sync or deploy.', ['Make changes to your app first', 'Run runwork dev to develop and verify changes', 'Then run runwork deploy']));
76
+ process.exit(1);
77
+ }
78
+ console.error('Nothing to deploy. Make changes first, then run `runwork deploy`.');
79
+ process.exit(1);
31
80
  }
32
- if (repoHasCommits) {
33
- if (!useJson)
34
- console.log('Syncing...');
35
- try {
36
- execFileSync('git', ['pull', '--rebase', 'runwork', 'main'], { stdio: useJson ? 'pipe' : 'inherit' });
81
+ // Protect critical files across the sync exactly as `runwork dev` does:
82
+ // syncWithRemote may merge with `-X theirs` (remote wins) to reconcile
83
+ // diverged histories, which can delete `.runwork.json`/`blueprint.json`/
84
+ // `.gitignore` if the remote tip lacks them. Snapshot before, restore after.
85
+ const criticalSnapshot = snapshotCriticalFiles(cwd);
86
+ const syncResult = syncWithRemote(cwd);
87
+ const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
88
+ if (restoredCriticalFiles.length > 0) {
89
+ const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
90
+ if (!useJson) {
91
+ console.warn(`Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`);
92
+ if (!pushed)
93
+ console.warn('Restoration committed locally but not pushed; will retry on next sync.');
37
94
  }
38
- catch {
39
- if (!useJson)
40
- console.warn('Pull failed. Continuing...');
95
+ }
96
+ if (syncResult.status === 'sync-failed') {
97
+ const reason = syncResult.error && syncResult.error !== 'stash-conflict'
98
+ ? syncResult.error
99
+ : 'Local history could not be reconciled with the remote.';
100
+ if (useJson) {
101
+ jsonOut(buildErrorResponse('deploy', 'Sync failed before deployment', reason, ['Run git fetch runwork && git rebase runwork/main and resolve any conflicts', 'Or run runwork dev to sync interactively', 'Then retry runwork deploy']));
102
+ process.exit(1);
41
103
  }
42
- execFileSync('git', ['push', 'runwork', 'main'], { stdio: useJson ? 'pipe' : 'inherit' });
104
+ console.error(`Sync failed: ${reason}`);
105
+ console.error('Resolve conflicts (git status), or run `runwork dev`, then retry `runwork deploy`.');
106
+ process.exit(1);
43
107
  }
44
- else {
108
+ if (!syncResult.pushed) {
45
109
  if (useJson) {
46
- jsonOut(buildErrorResponse('deploy', 'Sync failed before deployment', 'Files could not be synced to the remote.', ['Run runwork dev first to ensure files sync correctly', 'Check your network connection', 'Retry runwork deploy']));
110
+ jsonOut(buildErrorResponse('deploy', 'Failed to push before deployment', 'Local commits were synced but could not be pushed to the remote.', ['Check your network connection', 'Run runwork dev to retry syncing', 'Then retry runwork deploy']));
47
111
  process.exit(1);
48
112
  }
49
- console.error('No commits to deploy. Commit your changes first.');
113
+ console.error('Push failed. Check your connection and retry `runwork deploy`.');
50
114
  process.exit(1);
51
115
  }
52
116
  // Trigger deploy
@@ -65,19 +129,34 @@ export const deployCommand = new Command('deploy')
65
129
  console.error(`Deploy failed: ${message}`);
66
130
  process.exit(1);
67
131
  }
132
+ // The server returns a deployment URL only when a deploy actually
133
+ // happened. An empty/missing URL means the deploy was a no-op or did
134
+ // not produce a live deployment, so we must not report success.
135
+ const deploymentUrl = result.deploymentUrl?.trim() ?? '';
136
+ const deployed = deploymentUrl.length > 0;
137
+ if (!deployed) {
138
+ if (useJson) {
139
+ jsonOut(buildErrorResponse('deploy', 'Deployment did not produce a URL', 'The deploy API returned no deployment URL. The deploy may have been a no-op (no changes) or failed server-side.', ['Confirm your changes were committed and pushed (the sync step above succeeded)', 'Run runwork info to check the deployed state', 'Run runwork logs --production to inspect server-side errors', 'Retry runwork deploy']));
140
+ process.exit(1);
141
+ }
142
+ console.error('Deploy did not return a URL. The deploy may have been a no-op or failed server-side.');
143
+ console.error('Run `runwork info` to check state, or `runwork logs --production` to inspect errors.');
144
+ process.exit(1);
145
+ }
68
146
  if (useJson) {
69
147
  const response = {
70
148
  success: true,
71
149
  command: 'deploy',
72
150
  result: {
73
- deployed: true,
74
- url: result.deploymentUrl,
151
+ deployed,
152
+ url: deploymentUrl,
75
153
  appName: config.appName,
76
154
  },
77
155
  guide: buildDeployGuide(),
156
+ ...(deployGuardWarning ? { warning: deployGuardWarning } : {}),
78
157
  };
79
158
  jsonOut(response);
80
159
  return;
81
160
  }
82
- console.log(`Deployed: ${result.deploymentUrl}`);
161
+ console.log(`Deployed: ${deploymentUrl}`);
83
162
  });
@@ -6,5 +6,6 @@ export declare function execDev(options?: {
6
6
  json?: boolean;
7
7
  mode?: SessionMode;
8
8
  restart?: boolean;
9
+ noSync?: boolean;
9
10
  }): Promise<void>;
10
11
  export declare const devCommand: Command;
@@ -6,6 +6,7 @@ import { requireAuth } from '../auth/store.js';
6
6
  import { ApiClient } from '../api/client.js';
7
7
  import { watchAndAutoCommit, stopAutoCommit } from '../git/auto-commit.js';
8
8
  import { syncWithRemote } from '../git/sync.js';
9
+ import { snapshotCriticalFiles, restoreMissingCriticalFiles, commitAndPushRestoredFiles } from '../git/critical-files.js';
9
10
  import { ensureGitIdentity } from '../git/identity.js';
10
11
  import { requireGit } from '../git/preflight.js';
11
12
  import { startLogTailer } from '../logs/tailer.js';
@@ -43,69 +44,14 @@ function readConfig() {
43
44
  }
44
45
  return JSON.parse(readFileSync('.runwork.json', 'utf-8'));
45
46
  }
46
- // Files the CLI cannot function without and which the user did not author.
47
- // `.runwork.json` is the local app identity; `blueprint.json` is the app's
48
- // canonical feature definition; `.gitignore` keeps caches out of git. If a
49
- // sync from the remote silently removes any of these (which has happened
50
- // when a server-side agent commits with a stale index — see
51
- // worker/agents/git/git.ts), restoring from a pre-sync snapshot keeps the
52
- // project usable and unblocks `runwork deploy`.
53
- const CRITICAL_FILES = ['.runwork.json', 'blueprint.json', '.gitignore'];
54
- function snapshotCriticalFiles(cwd) {
55
- const snapshots = [];
56
- for (const rel of CRITICAL_FILES) {
57
- const abs = join(cwd, rel);
58
- if (!existsSync(abs))
59
- continue;
60
- try {
61
- snapshots.push({ path: rel, contents: readFileSync(abs, 'utf-8') });
62
- }
63
- catch {
64
- // Best-effort: skip unreadable files.
65
- }
66
- }
67
- return snapshots;
68
- }
69
- function restoreMissingCriticalFiles(cwd, snapshots) {
70
- const restored = [];
71
- for (const snap of snapshots) {
72
- const abs = join(cwd, snap.path);
73
- if (existsSync(abs))
74
- continue;
75
- try {
76
- writeFileSync(abs, snap.contents, 'utf-8');
77
- restored.push(snap.path);
78
- }
79
- catch {
80
- // Best-effort: skip files we cannot write back.
81
- }
82
- }
83
- return restored;
84
- }
85
- function commitAndPushRestoredFiles(cwd, files) {
86
- if (files.length === 0)
87
- return false;
88
- try {
89
- execFileSync('git', ['add', '--', ...files], { cwd, stdio: 'pipe' });
90
- execFileSync('git', ['commit', '-m', 'chore: restore critical files removed by sync'], { cwd, stdio: 'pipe' });
91
- }
92
- catch {
93
- return false;
94
- }
95
- try {
96
- // `HEAD:main` so the push works regardless of local branch name.
97
- execFileSync('git', ['push', 'runwork', 'HEAD:main'], { cwd, stdio: 'pipe' });
98
- return true;
99
- }
100
- catch {
101
- // Push failures are non-fatal: the local copy is restored, and the
102
- // restoration commit will be pushed with the next auto-sync cycle.
103
- return false;
104
- }
105
- }
106
47
  export async function execDev(options) {
107
48
  const useJson = options?.json ?? false;
108
49
  const mode = options?.mode ?? 'foreground';
50
+ // No-sync mode: hold the preview open but never auto-touch the user's
51
+ // git or working tree. No file watcher, no auto-commit, and no
52
+ // working-tree-mutating startup (template auto-update / syncWithRemote).
53
+ // The user drives git manually; `git push` updates the preview.
54
+ const noSync = options?.noSync ?? false;
109
55
  requireGit('dev');
110
56
  const config = readConfig();
111
57
  const creds = requireAuth();
@@ -199,30 +145,34 @@ export async function execDev(options) {
199
145
  }
200
146
  }
201
147
  }
202
- // Download fresh template to ensure latest version
203
- if (useJson) {
204
- jsonLine({ event: 'startup', phase: 'template_update', timestamp: ts() });
205
- }
206
- else {
207
- console.log(dim('Updating template...'));
208
- }
209
- try {
210
- const zipData = await client.downloadSkeleton();
211
- extractZip(zipData, cwd);
212
- removeNestedGitDirs(cwd);
213
- const newManifest = await generateManifest(cwd);
214
- await saveManifest(cwd, newManifest);
148
+ // Download fresh template to ensure latest version. Skipped entirely in
149
+ // no-sync mode: this block mutates the working tree (`git checkout -- .`),
150
+ // which would fight the user's manual git workflow.
151
+ if (!noSync) {
152
+ if (useJson) {
153
+ jsonLine({ event: 'startup', phase: 'template_update', timestamp: ts() });
154
+ }
155
+ else {
156
+ console.log(dim('Updating template...'));
157
+ }
215
158
  try {
216
- execFileSync('git', ['checkout', '--', '.'], { cwd, stdio: 'pipe' });
159
+ const zipData = await client.downloadSkeleton();
160
+ extractZip(zipData, cwd);
161
+ removeNestedGitDirs(cwd);
162
+ const newManifest = await generateManifest(cwd);
163
+ await saveManifest(cwd, newManifest);
164
+ try {
165
+ execFileSync('git', ['checkout', '--', '.'], { cwd, stdio: 'pipe' });
166
+ }
167
+ catch {
168
+ // May fail if no commits yet
169
+ }
217
170
  }
218
171
  catch {
219
- // May fail if no commits yet
172
+ if (!useJson)
173
+ console.warn(yellow('Template update failed. Continuing with current files.'));
220
174
  }
221
175
  }
222
- catch {
223
- if (!useJson)
224
- console.warn(yellow('Template update failed. Continuing with current files.'));
225
- }
226
176
  // Populate type definitions
227
177
  await populateTypes(cwd);
228
178
  if (useJson)
@@ -239,89 +189,108 @@ export async function execDev(options) {
239
189
  await populateSkill(cwd, client, config.appId);
240
190
  if (useJson)
241
191
  jsonLine({ event: 'startup', phase: 'skill_fetched', timestamp: ts() });
242
- // Sync
243
- if (!useJson)
244
- console.log(dim('Syncing...'));
245
- const criticalSnapshot = snapshotCriticalFiles(cwd);
246
- const syncResult = syncWithRemote(cwd);
247
- const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
248
- if (restoredCriticalFiles.length > 0) {
249
- const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
192
+ // Sync. In no-sync mode we never mutate the working tree: instead of
193
+ // syncWithRemote (rebase/merge/commit/push) we do a best-effort,
194
+ // read-only `git fetch` so remote-tracking refs stay current and
195
+ // `runwork info` can still report ahead/behind accurately.
196
+ if (noSync) {
197
+ try {
198
+ execFileSync('git', ['fetch', 'runwork'], { cwd, stdio: 'pipe' });
199
+ }
200
+ catch {
201
+ // Offline, or the `runwork` remote does not exist yet. Non-fatal.
202
+ }
250
203
  if (useJson) {
251
- jsonLine({
252
- event: 'sync_restored_critical_files',
253
- files: restoredCriticalFiles,
254
- pushed,
255
- timestamp: ts(),
256
- });
204
+ jsonLine({ event: 'startup', phase: 'fetch', timestamp: ts() });
257
205
  }
258
206
  else {
259
- console.warn(yellow(` Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`));
260
- if (!pushed) {
261
- console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
262
- }
207
+ console.log(dim('Fetched remote refs (no-sync mode).'));
263
208
  }
264
209
  }
265
- if (useJson) {
266
- jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
267
- if (syncResult.status === 'sync-failed') {
268
- if (syncResult.error === 'stash-conflict') {
210
+ else {
211
+ if (!useJson)
212
+ console.log(dim('Syncing...'));
213
+ const criticalSnapshot = snapshotCriticalFiles(cwd);
214
+ const syncResult = syncWithRemote(cwd);
215
+ const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
216
+ if (restoredCriticalFiles.length > 0) {
217
+ const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
218
+ if (useJson) {
219
+ jsonLine({
220
+ event: 'sync_restored_critical_files',
221
+ files: restoredCriticalFiles,
222
+ pushed,
223
+ timestamp: ts(),
224
+ });
225
+ }
226
+ else {
227
+ console.warn(yellow(` Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`));
228
+ if (!pushed) {
229
+ console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
230
+ }
231
+ }
232
+ }
233
+ if (useJson) {
234
+ jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
235
+ if (syncResult.status === 'sync-failed') {
236
+ if (syncResult.error === 'stash-conflict') {
237
+ jsonLine({
238
+ event: 'error',
239
+ phase: 'sync',
240
+ timestamp: ts(),
241
+ error: {
242
+ message: 'Merge conflicts detected after stash pop',
243
+ diagnosis: 'Local changes could not be automatically merged with the remote state. The stash pop resulted in conflicts that require manual resolution.',
244
+ suggestions: [
245
+ 'Resolve the merge conflicts in the affected files',
246
+ 'Run git add on resolved files, then git commit',
247
+ 'Run runwork dev again after resolving conflicts',
248
+ ],
249
+ },
250
+ });
251
+ process.exit(1);
252
+ }
269
253
  jsonLine({
270
254
  event: 'error',
271
255
  phase: 'sync',
272
256
  timestamp: ts(),
273
257
  error: {
274
- message: 'Merge conflicts detected after stash pop',
275
- diagnosis: 'Local changes could not be automatically merged with the remote state. The stash pop resulted in conflicts that require manual resolution.',
258
+ message: syncResult.error ?? 'File sync failed',
259
+ diagnosis: 'Could not sync files to the sandbox. This usually means the sandbox session expired or the network connection was interrupted.',
276
260
  suggestions: [
277
- 'Resolve the merge conflicts in the affected files',
278
- 'Run git add on resolved files, then git commit',
279
- 'Run runwork dev again after resolving conflicts',
261
+ 'Run runwork dev again to start a fresh session',
262
+ 'Check your network connection',
263
+ 'Run runwork doctor to diagnose environment issues',
280
264
  ],
281
265
  },
282
266
  });
283
- process.exit(1);
284
267
  }
285
- jsonLine({
286
- event: 'error',
287
- phase: 'sync',
288
- timestamp: ts(),
289
- error: {
290
- message: syncResult.error ?? 'File sync failed',
291
- diagnosis: 'Could not sync files to the sandbox. This usually means the sandbox session expired or the network connection was interrupted.',
292
- suggestions: [
293
- 'Run runwork dev again to start a fresh session',
294
- 'Check your network connection',
295
- 'Run runwork doctor to diagnose environment issues',
296
- ],
297
- },
298
- });
299
268
  }
300
- }
301
- else {
302
- switch (syncResult.status) {
303
- case 'skipped':
304
- console.log(dim(' No commits yet. Skipping sync.'));
305
- break;
306
- case 'synced':
307
- console.log(green(' Synced with Runwork.'));
308
- break;
309
- case 'merged':
310
- console.log(yellow(' Merged with Runwork (histories diverged).'));
311
- break;
312
- case 'sync-failed':
313
- if (syncResult.error === 'stash-conflict') {
314
- console.error('Merge conflicts detected after stash pop. Resolve conflicts and restart.');
315
- process.exit(1);
316
- }
317
- console.warn(yellow(`Sync failed. Continuing...`));
318
- if (syncResult.error) {
319
- console.warn(dim(` ${syncResult.error}`));
320
- }
321
- break;
322
- }
323
- if (!syncResult.pushed && syncResult.status !== 'skipped' && syncResult.status !== 'sync-failed') {
324
- console.warn(yellow('Push failed. Continuing with current state...'));
269
+ else {
270
+ switch (syncResult.status) {
271
+ case 'skipped':
272
+ console.log(dim(' No commits yet. Skipping sync.'));
273
+ break;
274
+ case 'synced':
275
+ console.log(green(' Synced with Runwork.'));
276
+ break;
277
+ case 'merged':
278
+ console.log(yellow(' Merged with Runwork (histories diverged).'));
279
+ break;
280
+ case 'sync-failed':
281
+ if (syncResult.error === 'stash-conflict') {
282
+ console.error('Merge conflicts detected after stash pop. Resolve conflicts and restart.');
283
+ process.exit(1);
284
+ }
285
+ console.warn(yellow(`Sync failed. Continuing...`));
286
+ if (syncResult.error) {
287
+ console.warn(dim(` ${syncResult.error}`));
288
+ }
289
+ break;
290
+ }
291
+ if (!syncResult.pushed && syncResult.status !== 'skipped' && syncResult.status !== 'sync-failed') {
292
+ console.warn(yellow('Push failed. Continuing with current state...'));
293
+ }
325
294
  }
326
295
  }
327
296
  // The session POST returns a snapshot of the preview URL at boot time.
@@ -341,6 +310,7 @@ export async function execDev(options) {
341
310
  previewUrl: currentPreviewUrl,
342
311
  cliVersion: VERSION,
343
312
  mode,
313
+ noSync,
344
314
  }));
345
315
  // Emit session_started (JSON) or show banner (human)
346
316
  if (useJson) {
@@ -457,15 +427,32 @@ export async function execDev(options) {
457
427
  }
458
428
  });
459
429
  }
460
- // Start file watcher
461
- await watchAndAutoCommit(process.cwd(), client, config.appId, {
462
- onFastSync: useJson
463
- ? (count) => { jsonLine({ event: 'files_synced', count, target: 'preview', timestamp: ts() }); }
464
- : (count) => { syncedCount += count; updateStatus(); },
465
- onGitPush: useJson
466
- ? (count) => { jsonLine({ event: 'files_pushed', count, target: 'git', timestamp: ts() }); }
467
- : (count) => { syncedCount += count; updateStatus(); },
468
- });
430
+ // Start file watcher. In no-sync mode we never watch the working tree:
431
+ // no fast-sync to the sandbox, no git auto-commit. The user drives git
432
+ // manually and `git push` is what updates the preview. We print a loud
433
+ // notice so it is unmistakable that edits are not flowing automatically.
434
+ if (noSync) {
435
+ if (useJson) {
436
+ jsonLine({ event: 'startup', phase: 'no_sync_mode', timestamp: ts() });
437
+ }
438
+ else {
439
+ console.log('');
440
+ console.log(yellow(' Auto-sync is OFF (--no-sync).'));
441
+ console.log(dim(' The preview reflects pushed commits. Run `git push` to update it;'));
442
+ console.log(dim(' uncommitted or unpushed changes are not shown.'));
443
+ console.log('');
444
+ }
445
+ }
446
+ else {
447
+ await watchAndAutoCommit(process.cwd(), client, config.appId, {
448
+ onFastSync: useJson
449
+ ? (count) => { jsonLine({ event: 'files_synced', count, target: 'preview', timestamp: ts() }); }
450
+ : (count) => { syncedCount += count; updateStatus(); },
451
+ onGitPush: useJson
452
+ ? (count) => { jsonLine({ event: 'files_pushed', count, target: 'git', timestamp: ts() }); }
453
+ : (count) => { syncedCount += count; updateStatus(); },
454
+ });
455
+ }
469
456
  // Start log tailing (unless --no-logs)
470
457
  if (options?.logs !== false) {
471
458
  logTailer = startLogTailer({
@@ -487,12 +474,14 @@ export const devCommand = new Command('dev')
487
474
  .option('--logs-only-file', 'Write logs to file only, not terminal')
488
475
  .option('--detach', 'Start the dev session in the background and exit. Logs go to .runwork/dev-{stdout,stderr}.log')
489
476
  .option('--restart', 'Stop any existing dev session first, then start a fresh one')
477
+ .option('--no-sync', 'Hold the preview open without auto-syncing files or git; you push manually to update the preview')
490
478
  // Hidden internal flag passed by `runwork dev --detach` when it spawns
491
479
  // its detached child. Not for end users -- registered here only so
492
480
  // commander does not throw "unknown option" when the child receives it.
493
481
  .addOption(new Option(INTERNAL_DETACHED_CHILD_FLAG).hideHelp())
494
482
  .action(async (options, command) => {
495
483
  const globalJson = shouldOutputJson(command.optsWithGlobals().json);
484
+ const noSync = options.sync === false;
496
485
  // The internal child marker is parsed manually because we want it
497
486
  // hidden from --help and from commander's option list. Its presence
498
487
  // means: "this process is the detached child of a `--detach` parent;
@@ -510,6 +499,7 @@ export const devCommand = new Command('dev')
510
499
  json: globalJson,
511
500
  mode: isChild ? 'detached' : 'foreground',
512
501
  restart: options.restart,
502
+ noSync,
513
503
  });
514
504
  });
515
505
  /**
@@ -21,13 +21,20 @@ export const logsCommand = new Command('logs')
21
21
  .option('--production', 'Show production runtime logs')
22
22
  .option('--events', 'Show app activity/events')
23
23
  .option('--all', 'Show both runtime logs and events')
24
- .option('--follow', 'Poll for new logs every 3s')
24
+ .option('--follow', 'Poll for new logs every 3s (TTY only; ignored when piped or with --json)')
25
+ .option('--once', 'Fetch once and exit, even if --follow is set (forced in non-TTY/agent contexts)')
25
26
  .option('--limit <n>', 'Number of log entries to show', '25')
26
27
  .option('--level <level>', 'Filter by log level (production only)')
27
28
  .option('--search <text>', 'Search log content (production only)')
28
29
  .option('--type <type>', 'Filter by event type (events only)')
29
30
  .action(async (opts, command) => {
30
31
  const useJson = shouldOutputJson(command.optsWithGlobals().json);
32
+ // Follow mode blocks forever (until SIGINT/SIGTERM), which deadlocks
33
+ // non-interactive callers: piped shells, CI, and AI agents never send
34
+ // those signals and hang waiting for the command to exit. Only follow
35
+ // when explicitly requested AND stdout is an interactive TTY AND we are
36
+ // not emitting JSON. `--once` forces single-shot even on a TTY.
37
+ const shouldFollow = Boolean(opts.follow) && !opts.once && !useJson && Boolean(process.stdout.isTTY);
31
38
  const config = readConfig();
32
39
  const creds = requireAuth();
33
40
  const client = new ApiClient(creds);
@@ -79,7 +86,7 @@ export const logsCommand = new Command('logs')
79
86
  // Preview logs (default)
80
87
  try {
81
88
  const { stdout, stderr } = await client.getPreviewLogs(appId);
82
- if (opts.follow && !isFirstPoll) {
89
+ if (shouldFollow && !isFirstPoll) {
83
90
  // Incremental mode: only print new content since last poll
84
91
  // Reset detection: if buffer was truncated (sandbox restart), reset cursor
85
92
  if (stdout && stdout.length < lastStdoutLength) {
@@ -198,7 +205,7 @@ export const logsCommand = new Command('logs')
198
205
  isFirstPoll = false;
199
206
  };
200
207
  await fetchAndPrint();
201
- if (opts.follow) {
208
+ if (shouldFollow) {
202
209
  let stopped = false;
203
210
  let timer;
204
211
  const scheduleNext = () => {