runwork 0.14.0 → 0.15.1

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.
Files changed (90) hide show
  1. package/dist/__tests__/install-scripts.test.js +33 -0
  2. package/dist/agents/__tests__/chatgpt-registry.test.d.ts +1 -0
  3. package/dist/agents/__tests__/chatgpt-registry.test.js +61 -0
  4. package/dist/agents/__tests__/claude-code-managed-block.test.js +52 -44
  5. package/dist/agents/__tests__/detection.test.js +3 -0
  6. package/dist/agents/claude-code.d.ts +11 -5
  7. package/dist/agents/claude-code.js +50 -51
  8. package/dist/agents/cursor.d.ts +2 -15
  9. package/dist/agents/cursor.js +3 -33
  10. package/dist/agents/defaults-merge.d.ts +15 -0
  11. package/dist/agents/defaults-merge.js +19 -0
  12. package/dist/agents/detection.js +3 -0
  13. package/dist/agents/intro-skill.js +5 -3
  14. package/dist/agents/registry-data.d.ts +59 -2
  15. package/dist/agents/registry-data.js +176 -0
  16. package/dist/commands/__tests__/clone-args.test.js +8 -1
  17. package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
  18. package/dist/commands/__tests__/clone-source-result.test.js +45 -0
  19. package/dist/commands/clone.d.ts +33 -0
  20. package/dist/commands/clone.js +105 -10
  21. package/dist/commands/deploy.js +128 -11
  22. package/dist/commands/dev.js +72 -32
  23. package/dist/commands/doctor.js +64 -3
  24. package/dist/commands/info.d.ts +3 -0
  25. package/dist/commands/info.js +11 -0
  26. package/dist/commands/sync.js +14 -1
  27. package/dist/commands/validate.d.ts +2 -0
  28. package/dist/commands/validate.js +89 -0
  29. package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
  30. package/dist/deploy/__tests__/deploy-state.test.js +80 -0
  31. package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
  32. package/dist/deploy/__tests__/deploy-status.test.js +41 -0
  33. package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
  34. package/dist/deploy/__tests__/detach-args.test.js +30 -0
  35. package/dist/deploy/deploy-state.d.ts +36 -0
  36. package/dist/deploy/deploy-state.js +63 -0
  37. package/dist/deploy/deploy-status.d.ts +21 -0
  38. package/dist/deploy/deploy-status.js +36 -0
  39. package/dist/deploy/detach.d.ts +32 -0
  40. package/dist/deploy/detach.js +71 -0
  41. package/dist/dev/__tests__/session.test.js +39 -0
  42. package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
  43. package/dist/dev/__tests__/startup-sync.test.js +32 -0
  44. package/dist/dev/session.d.ts +9 -0
  45. package/dist/dev/session.js +4 -1
  46. package/dist/dev/startup-sync.d.ts +23 -0
  47. package/dist/dev/startup-sync.js +14 -0
  48. package/dist/generated/version.d.ts +1 -1
  49. package/dist/generated/version.js +1 -1
  50. package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
  51. package/dist/git/__tests__/classify-sync-error.test.js +57 -0
  52. package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
  53. package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
  54. package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
  55. package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
  56. package/dist/git/__tests__/repo-config.test.d.ts +1 -0
  57. package/dist/git/__tests__/repo-config.test.js +26 -0
  58. package/dist/git/classify-sync-error.d.ts +21 -0
  59. package/dist/git/classify-sync-error.js +111 -0
  60. package/dist/git/credentials.d.ts +43 -0
  61. package/dist/git/credentials.js +67 -0
  62. package/dist/git/remote.d.ts +37 -0
  63. package/dist/git/remote.js +75 -0
  64. package/dist/git/repo-config.d.ts +8 -0
  65. package/dist/git/repo-config.js +29 -0
  66. package/dist/health/__tests__/checks.test.js +2 -2
  67. package/dist/health/__tests__/fix.test.d.ts +1 -0
  68. package/dist/health/__tests__/fix.test.js +72 -0
  69. package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
  70. package/dist/health/__tests__/runner-filter.test.js +61 -0
  71. package/dist/health/checks.d.ts +7 -0
  72. package/dist/health/checks.js +50 -36
  73. package/dist/health/fix.d.ts +22 -0
  74. package/dist/health/fix.js +62 -0
  75. package/dist/health/runner.d.ts +7 -1
  76. package/dist/health/runner.js +40 -27
  77. package/dist/index.js +2 -0
  78. package/dist/tools/types.d.ts +5 -0
  79. package/dist/utils/__tests__/help-json.test.d.ts +1 -0
  80. package/dist/utils/__tests__/help-json.test.js +39 -0
  81. package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
  82. package/dist/utils/help-json.d.ts +14 -0
  83. package/dist/utils/help-json.js +4 -1
  84. package/dist/validate/__tests__/validate.test.d.ts +1 -0
  85. package/dist/validate/__tests__/validate.test.js +42 -0
  86. package/dist/validate/freshness.d.ts +28 -0
  87. package/dist/validate/freshness.js +48 -0
  88. package/dist/validate/preview.d.ts +21 -0
  89. package/dist/validate/preview.js +39 -0
  90. package/package.json +1 -1
@@ -1,11 +1,18 @@
1
- import { Command } from 'commander';
1
+ import { Command, Option } from 'commander';
2
2
  import { readFileSync, existsSync } from 'fs';
3
3
  import { requireAuth } from '../auth/store.js';
4
4
  import { ApiClient } from '../api/client.js';
5
- import { shouldOutputJson, jsonOut } from '../utils/output.js';
5
+ import { shouldOutputJson, jsonOut, jsonLine } from '../utils/output.js';
6
+ import { dim, green, yellow } from '../ui/colors.js';
7
+ import { getHeadSha, writeDeployState, getDeploySummary } from '../deploy/deploy-state.js';
8
+ import { writeDeployStatus, readDeployStatus, deployLogPath } from '../deploy/deploy-status.js';
9
+ import { isInternalDeployChild, buildDeployChildArgs, spawnDetachedDeploy, DEPLOY_DETACHED_CHILD_FLAG } from '../deploy/detach.js';
6
10
  import { buildDeployGuide, buildErrorResponse } from '../utils/agent-guidance.js';
7
11
  import { requireGit } from '../git/preflight.js';
8
12
  import { ensureGitIdentity } from '../git/identity.js';
13
+ import { ensureGitCredentialHelper } from '../git/credentials.js';
14
+ import { ensureRunworkRemote } from '../git/remote.js';
15
+ import { diagnoseSyncError } from '../git/classify-sync-error.js';
9
16
  import { commitWorkingTree } from '../git/auto-commit.js';
10
17
  import { syncWithRemote, hasCommits } from '../git/sync.js';
11
18
  import { snapshotCriticalFiles, restoreMissingCriticalFiles, commitAndPushRestoredFiles } from '../git/critical-files.js';
@@ -14,8 +21,14 @@ import { promptConfirm } from '../utils/prompt.js';
14
21
  export const deployCommand = new Command('deploy')
15
22
  .description('Deploy the current app to production')
16
23
  .option('-y, --yes', 'Skip the confirmation prompt when the working tree differs from the preview')
24
+ .option('--detach', 'Run the deploy in the background and return immediately. Logs go to .runwork/deploy-{stdout,stderr}.log; poll `runwork deploy --status`.')
25
+ .option('--status', 'Show the status (in-progress/succeeded/failed) of the last deploy started from this machine, plus the deployed-vs-local commit.')
26
+ // Hidden marker the `--detach` parent passes to its background child. Not
27
+ // for end users -- registered so commander accepts it on the child.
28
+ .addOption(new Option(DEPLOY_DETACHED_CHILD_FLAG).hideHelp())
17
29
  .action(async (opts, command) => {
18
30
  const useJson = shouldOutputJson(command.optsWithGlobals().json);
31
+ const cwd = process.cwd();
19
32
  if (!existsSync('.runwork.json')) {
20
33
  if (useJson) {
21
34
  jsonOut(buildErrorResponse('deploy', 'No .runwork.json found', 'This directory is not a Runwork app.', ['Run runwork init to create an app first', 'Or cd into an existing app directory']));
@@ -24,11 +37,50 @@ export const deployCommand = new Command('deploy')
24
37
  console.error('No .runwork.json found. Run `runwork init` first.');
25
38
  process.exit(1);
26
39
  }
40
+ // --status: a local read; no auth/git required.
41
+ if (opts.status) {
42
+ printDeployStatus(cwd, useJson);
43
+ return;
44
+ }
27
45
  requireGit('deploy');
28
46
  const config = JSON.parse(readFileSync('.runwork.json', 'utf-8'));
47
+ const isChild = isInternalDeployChild(process.argv);
48
+ // --detach parent: spawn a background child running the real deploy,
49
+ // record an in-progress status, print a poll hint, and exit immediately so
50
+ // the launching shell doesn't time out (friction log #7).
51
+ if (opts.detach && !isChild) {
52
+ const startedAt = new Date().toISOString();
53
+ const handle = spawnDetachedDeploy(buildDeployChildArgs(process.argv), cwd);
54
+ writeDeployStatus(cwd, { state: 'in-progress', startedAt, pid: handle.pid });
55
+ if (useJson) {
56
+ jsonLine({ event: 'deploy_started', detached: true, pid: handle.pid, logPath: deployLogPath(cwd), startedAt, hint: 'Poll `runwork deploy --status` for completion.' });
57
+ }
58
+ else {
59
+ console.log(`Deploy started in the background (pid ${handle.pid}).`);
60
+ console.log(`Logs: ${deployLogPath(cwd)}`);
61
+ console.log('Check: runwork deploy --status');
62
+ }
63
+ return;
64
+ }
65
+ // The detached child finalizes the status file on exit. If it crashes or
66
+ // hits any `process.exit(1)` failure path before the success block marks
67
+ // it done, record `failed` so `--status` doesn't report a stuck deploy.
68
+ // Register this BEFORE requireAuth(): a child whose credentials expired
69
+ // between spawn and run would otherwise exit in requireAuth() with the
70
+ // status stuck at `in-progress`. (requireGit/config above ran identically
71
+ // in the parent before it spawned this child, so they can't fail here.)
72
+ let deployFinalized = false;
73
+ const childStartedAt = readDeployStatus(cwd)?.startedAt ?? new Date().toISOString();
74
+ if (isChild) {
75
+ jsonLine({ event: 'deploy_started', startedAt: childStartedAt });
76
+ process.on('exit', (code) => {
77
+ if (!deployFinalized && code !== 0) {
78
+ writeDeployStatus(cwd, { state: 'failed', startedAt: childStartedAt, finishedAt: new Date().toISOString(), error: `deploy process exited with code ${code}` });
79
+ }
80
+ });
81
+ }
29
82
  const creds = requireAuth();
30
83
  const client = new ApiClient(creds);
31
- const cwd = process.cwd();
32
84
  // Deploy guard: the preview reflects the last *pushed* commit. If the
33
85
  // working tree is dirty or ahead of `runwork/main` and no auto-syncing
34
86
  // dev session is keeping the preview in lockstep, `runwork deploy` will
@@ -42,8 +94,9 @@ export const deployCommand = new Command('deploy')
42
94
  // Agents must never be blocked: the warning rides along in the
43
95
  // final success response below so we proceed without prompting.
44
96
  }
45
- else if (opts.yes || !process.stdin.isTTY) {
46
- // Non-interactive (no TTY) or explicit --yes: warn and proceed.
97
+ else if (opts.yes || isChild || !process.stdin.isTTY) {
98
+ // Non-interactive (no TTY), the detached child, or explicit --yes:
99
+ // warn and proceed without prompting.
47
100
  console.warn(deployGuardWarning);
48
101
  }
49
102
  else {
@@ -61,6 +114,16 @@ export const deployCommand = new Command('deploy')
61
114
  // `ensureGitIdentity` seeds a local identity so the commit cannot fail
62
115
  // on a fresh machine where git user.email/.name were never configured.
63
116
  ensureGitIdentity(cwd, creds);
117
+ // Ensure git can authenticate to the runwork remote before sync/push.
118
+ // Headless auth (get_cli_setup / hand-written credentials) registers API
119
+ // creds but not the git helper. See
120
+ // git/credentials.ts:ensureGitCredentialHelper.
121
+ if (creds?.baseUrl) {
122
+ await ensureGitCredentialHelper(creds.baseUrl);
123
+ }
124
+ // Ensure the `runwork` remote exists and points at the canonical URL, so a
125
+ // manually `git clone`d repo (remote named `origin`) still syncs/pushes.
126
+ ensureRunworkRemote(cwd, client.getGitRemoteUrl(config.workspaceId, config.appId));
64
127
  if (!useJson)
65
128
  console.log('Syncing...');
66
129
  try {
@@ -94,15 +157,14 @@ export const deployCommand = new Command('deploy')
94
157
  }
95
158
  }
96
159
  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.';
160
+ const diag = diagnoseSyncError(syncResult.error);
100
161
  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']));
162
+ jsonOut(buildErrorResponse('deploy', 'Sync failed before deployment', diag.diagnosis, [...diag.suggestions, 'Then retry runwork deploy']));
102
163
  process.exit(1);
103
164
  }
104
- console.error(`Sync failed: ${reason}`);
105
- console.error('Resolve conflicts (git status), or run `runwork dev`, then retry `runwork deploy`.');
165
+ console.error(`Sync failed: ${diag.message}. ${diag.diagnosis}`);
166
+ for (const s of diag.suggestions)
167
+ console.error(` - ${s}`);
106
168
  process.exit(1);
107
169
  }
108
170
  if (!syncResult.pushed) {
@@ -143,6 +205,21 @@ export const deployCommand = new Command('deploy')
143
205
  console.error('Run `runwork info` to check state, or `runwork logs --production` to inspect errors.');
144
206
  process.exit(1);
145
207
  }
208
+ // Record what we just deployed (local HEAD == the pushed/deployed commit)
209
+ // so `runwork info` / `deploy --status` can cheaply compare it to HEAD
210
+ // later without scraping the production bundle.
211
+ const deployedSha = getHeadSha(cwd);
212
+ const finishedAt = new Date().toISOString();
213
+ if (deployedSha) {
214
+ writeDeployState(cwd, { sha: deployedSha, deployedAt: finishedAt, url: deploymentUrl });
215
+ }
216
+ // Mark the deploy finished so the child's exit hook doesn't overwrite this
217
+ // with a `failed` status, and so `runwork deploy --status` reports success.
218
+ deployFinalized = true;
219
+ writeDeployStatus(cwd, { state: 'succeeded', startedAt: childStartedAt, finishedAt, url: deploymentUrl, sha: deployedSha ?? undefined });
220
+ if (isChild) {
221
+ jsonLine({ event: 'deploy_succeeded', url: deploymentUrl, deployedSha, finishedAt });
222
+ }
146
223
  if (useJson) {
147
224
  const response = {
148
225
  success: true,
@@ -151,6 +228,7 @@ export const deployCommand = new Command('deploy')
151
228
  deployed,
152
229
  url: deploymentUrl,
153
230
  appName: config.appName,
231
+ deployedSha,
154
232
  },
155
233
  guide: buildDeployGuide(),
156
234
  ...(deployGuardWarning ? { warning: deployGuardWarning } : {}),
@@ -159,4 +237,43 @@ export const deployCommand = new Command('deploy')
159
237
  return;
160
238
  }
161
239
  console.log(`Deployed: ${deploymentUrl}`);
240
+ if (deployedSha)
241
+ console.log(dim(`Commit: ${deployedSha.slice(0, 7)}`));
162
242
  });
243
+ /**
244
+ * Print the status of the last deploy started from this machine
245
+ * (`runwork deploy --status`), reconciled with the deployed-vs-local commit
246
+ * comparison from deploy-state.
247
+ */
248
+ function printDeployStatus(cwd, useJson) {
249
+ const status = readDeployStatus(cwd);
250
+ const summary = getDeploySummary(cwd);
251
+ if (useJson) {
252
+ jsonOut({ success: true, command: 'deploy', result: { status, deploy: summary } });
253
+ return;
254
+ }
255
+ if (!status) {
256
+ console.log('No deploy has been started from this machine yet.');
257
+ }
258
+ else {
259
+ const label = status.state === 'succeeded' ? green('succeeded')
260
+ : status.state === 'failed' ? yellow('failed')
261
+ : 'in-progress';
262
+ console.log(`Last deploy: ${label}`);
263
+ console.log(dim(` started: ${status.startedAt}`));
264
+ if (status.finishedAt)
265
+ console.log(dim(` finished: ${status.finishedAt}`));
266
+ if (status.url)
267
+ console.log(dim(` url: ${status.url}`));
268
+ if (status.error)
269
+ console.log(yellow(` error: ${status.error}`));
270
+ if (status.state === 'in-progress')
271
+ console.log(dim(` logs: ${deployLogPath(cwd)}`));
272
+ }
273
+ if (summary.deployedShortSha) {
274
+ const sync = summary.inSync === true ? green('in sync')
275
+ : summary.inSync === false ? yellow('local has undeployed commits')
276
+ : dim('unknown');
277
+ console.log(`Commit: deployed ${summary.deployedShortSha}${summary.localShortSha ? `, local ${summary.localShortSha}` : ''} ${sync}`);
278
+ }
279
+ }
@@ -8,6 +8,10 @@ import { watchAndAutoCommit, stopAutoCommit } from '../git/auto-commit.js';
8
8
  import { syncWithRemote } from '../git/sync.js';
9
9
  import { snapshotCriticalFiles, restoreMissingCriticalFiles, commitAndPushRestoredFiles } from '../git/critical-files.js';
10
10
  import { ensureGitIdentity } from '../git/identity.js';
11
+ import { ensureGitCredentialHelper } from '../git/credentials.js';
12
+ import { ensureRunworkRemote } from '../git/remote.js';
13
+ import { diagnoseSyncError } from '../git/classify-sync-error.js';
14
+ import { buildStartupSyncSummary } from '../dev/startup-sync.js';
11
15
  import { requireGit } from '../git/preflight.js';
12
16
  import { startLogTailer } from '../logs/tailer.js';
13
17
  import { startPreviewUrlPoller } from '../dev/preview-url-poller.js';
@@ -110,6 +114,17 @@ export async function execDev(options) {
110
114
  // fresh machine where `git config --global user.email/.name` has never
111
115
  // been set (extremely common on Windows after a clean install).
112
116
  ensureGitIdentity(cwd, creds);
117
+ // Ensure git can authenticate to the runwork remote before the sync loop
118
+ // fetches/pushes. Headless auth (get_cli_setup / hand-written credentials)
119
+ // registers API creds but not the git helper. See
120
+ // git/credentials.ts:ensureGitCredentialHelper.
121
+ if (creds?.baseUrl) {
122
+ await ensureGitCredentialHelper(creds.baseUrl);
123
+ }
124
+ // Ensure the `runwork` remote exists and points at the canonical URL. A
125
+ // manual `git clone <url>` names the remote `origin`, which otherwise makes
126
+ // the sync loop fail with "'runwork' does not appear to be a git repository".
127
+ ensureRunworkRemote(cwd, client.getGitRemoteUrl(config.workspaceId, config.appId));
113
128
  // Detect user edits made outside of `runwork dev`. When this fires
114
129
  // for an AI agent, it almost always means the agent edited code
115
130
  // before starting dev -- the wrong order. We surface this loudly so
@@ -189,6 +204,10 @@ export async function execDev(options) {
189
204
  await populateSkill(cwd, client, config.appId);
190
205
  if (useJson)
191
206
  jsonLine({ event: 'startup', phase: 'skill_fetched', timestamp: ts() });
207
+ // Track the startup sync outcome so `session_started` can tell agents
208
+ // whether the preview reflects their latest edits or is serving stale code
209
+ // (friction log #2: a happy session_started masked a failed initial sync).
210
+ let startupSync = buildStartupSyncSummary({ noSync });
192
211
  // Sync. In no-sync mode we never mutate the working tree: instead of
193
212
  // syncWithRemote (rebase/merge/commit/push) we do a best-effort,
194
213
  // read-only `git fetch` so remote-tracking refs stay current and
@@ -212,6 +231,7 @@ export async function execDev(options) {
212
231
  console.log(dim('Syncing...'));
213
232
  const criticalSnapshot = snapshotCriticalFiles(cwd);
214
233
  const syncResult = syncWithRemote(cwd);
234
+ startupSync = buildStartupSyncSummary({ noSync: false, status: syncResult.status });
215
235
  const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
216
236
  if (restoredCriticalFiles.length > 0) {
217
237
  const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
@@ -233,37 +253,24 @@ export async function execDev(options) {
233
253
  if (useJson) {
234
254
  jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
235
255
  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
- }
256
+ const diag = diagnoseSyncError(syncResult.error);
253
257
  jsonLine({
254
258
  event: 'error',
255
259
  phase: 'sync',
256
260
  timestamp: ts(),
257
261
  error: {
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.',
260
- suggestions: [
261
- 'Run runwork dev again to start a fresh session',
262
- 'Check your network connection',
263
- 'Run runwork doctor to diagnose environment issues',
264
- ],
262
+ reason: diag.reason,
263
+ message: diag.message,
264
+ diagnosis: diag.diagnosis,
265
+ suggestions: diag.suggestions,
265
266
  },
266
267
  });
268
+ // A stash-pop conflict leaves the working tree in a state the user
269
+ // must resolve, so we stop. Other sync failures are non-fatal -- the
270
+ // preview keeps serving the last good state -- so we continue.
271
+ if (syncResult.error === 'stash-conflict') {
272
+ process.exit(1);
273
+ }
267
274
  }
268
275
  }
269
276
  else {
@@ -277,16 +284,20 @@ export async function execDev(options) {
277
284
  case 'merged':
278
285
  console.log(yellow(' Merged with Runwork (histories diverged).'));
279
286
  break;
280
- case 'sync-failed':
287
+ case 'sync-failed': {
288
+ const diag = diagnoseSyncError(syncResult.error);
281
289
  if (syncResult.error === 'stash-conflict') {
282
- console.error('Merge conflicts detected after stash pop. Resolve conflicts and restart.');
290
+ console.error(`${diag.message}. ${diag.diagnosis}`);
291
+ for (const s of diag.suggestions)
292
+ console.error(dim(` - ${s}`));
283
293
  process.exit(1);
284
294
  }
285
- console.warn(yellow(`Sync failed. Continuing...`));
286
- if (syncResult.error) {
287
- console.warn(dim(` ${syncResult.error}`));
288
- }
295
+ console.warn(yellow(`Sync failed: ${diag.message}. Continuing...`));
296
+ console.warn(dim(` ${diag.diagnosis}`));
297
+ for (const s of diag.suggestions)
298
+ console.warn(dim(` - ${s}`));
289
299
  break;
300
+ }
290
301
  }
291
302
  if (!syncResult.pushed && syncResult.status !== 'skipped' && syncResult.status !== 'sync-failed') {
292
303
  console.warn(yellow('Push failed. Continuing with current state...'));
@@ -311,13 +322,33 @@ export async function execDev(options) {
311
322
  cliVersion: VERSION,
312
323
  mode,
313
324
  noSync,
325
+ // Persist the startup sync outcome so the detached parent can relay
326
+ // stale-code warnings -- not just the child's own log (friction log #2).
327
+ startupSync,
314
328
  }));
315
- // Emit session_started (JSON) or show banner (human)
329
+ // Emit session_started (JSON) or show banner (human). We always emit
330
+ // session_started -- it's the rendezvous signal `runwork dev --detach`
331
+ // polls for -- but carry the startup sync outcome so agents don't mistake a
332
+ // stale preview for one reflecting their latest edits. `staleCode: true`
333
+ // means the initial sync failed and the preview is serving the last good
334
+ // state, not the current working tree.
316
335
  if (useJson) {
317
- jsonLine({ event: 'session_started', previewUrl: currentPreviewUrl, appName: config.appName, timestamp: ts(), guide: buildDevSessionGuide() });
336
+ jsonLine({
337
+ event: 'session_started',
338
+ previewUrl: currentPreviewUrl,
339
+ appName: config.appName,
340
+ syncStatus: startupSync.syncStatus,
341
+ syncFailed: startupSync.syncFailed,
342
+ staleCode: startupSync.staleCode,
343
+ timestamp: ts(),
344
+ guide: buildDevSessionGuide(),
345
+ });
318
346
  }
319
347
  else {
320
348
  console.log(getDevBanner({ appName: config.appName, previewUrl: currentPreviewUrl, workspaceName: config.workspaceName }));
349
+ if (startupSync.syncFailed) {
350
+ console.log(yellow(' Warning: initial sync failed. The preview shows the last deployed code, NOT your latest changes.'));
351
+ }
321
352
  console.log(getKeyboardHints());
322
353
  console.log('');
323
354
  console.log(dim(' ─────────────────────────────────────────────'));
@@ -568,6 +599,9 @@ async function runDevDetachParent(opts) {
568
599
  switch (outcome.result) {
569
600
  case 'started': {
570
601
  const f = outcome.file;
602
+ // Relay the child's startup sync outcome so a detached caller learns the
603
+ // preview may be serving stale code (the child only logs it otherwise).
604
+ const syncFailed = f.startupSync?.syncFailed ?? false;
571
605
  if (opts.json) {
572
606
  jsonLine({
573
607
  event: 'session_started',
@@ -575,6 +609,9 @@ async function runDevDetachParent(opts) {
575
609
  pid: f.pid,
576
610
  sessionId: f.sessionId,
577
611
  mode: f.mode,
612
+ syncStatus: f.startupSync?.syncStatus,
613
+ syncFailed,
614
+ staleCode: f.startupSync?.staleCode ?? false,
578
615
  timestamp: ts(),
579
616
  guide: buildDevSessionGuide(),
580
617
  });
@@ -583,6 +620,9 @@ async function runDevDetachParent(opts) {
583
620
  console.log('');
584
621
  console.log(`Dev session running in background (PID ${f.pid}).`);
585
622
  console.log(` Preview: ${green(f.previewUrl)}`);
623
+ if (syncFailed) {
624
+ console.log(yellow(' Warning: initial sync failed. The preview shows the last deployed code, NOT your latest changes.'));
625
+ }
586
626
  console.log(dim(` Stop with: runwork dev stop`));
587
627
  console.log(dim(` Logs: tail -f .runwork/dev-stdout.log`));
588
628
  console.log('');
@@ -1,7 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import { shouldOutputJson, jsonOut } from '../utils/output.js';
3
- import { bold, dim, green, yellow, cyan } from '../ui/colors.js';
4
- import { runAllChecks } from '../health/runner.js';
3
+ import { bold, dim, green, yellow, cyan, red } from '../ui/colors.js';
4
+ import { runAllChecks, ALL_CHECK_NAMES } from '../health/runner.js';
5
+ import { buildContext } from '../health/checks.js';
6
+ import { applyDoctorFixes } from '../health/fix.js';
5
7
  import { detectCurrentAgent } from '../agents/runtime-detection.js';
6
8
  /**
7
9
  * Env vars worth surfacing in --verbose for AI agents debugging their own
@@ -98,6 +100,7 @@ const CHECK_LABELS = {
98
100
  'project-config': 'Project config',
99
101
  'app-exists': 'App exists',
100
102
  'git-remote': 'Git remote',
103
+ 'deploy-freshness': 'Deploy freshness',
101
104
  'agent-setup': 'Agent setup',
102
105
  };
103
106
  function printHumanReport(report) {
@@ -147,12 +150,70 @@ function printHumanReport(report) {
147
150
  }
148
151
  console.log('');
149
152
  }
153
+ function printFixOutcomes(outcomes) {
154
+ console.log('');
155
+ console.log(bold('Applying fixes'));
156
+ if (outcomes.length === 0) {
157
+ console.log(` ${dim('Nothing to fix (no auto-remediable failures).')}`);
158
+ return;
159
+ }
160
+ for (const o of outcomes) {
161
+ const marker = o.applied ? green('[fixed]') : yellow('[skip]');
162
+ console.log(` ${marker} ${o.name}: ${dim(o.message)}`);
163
+ }
164
+ }
165
+ /** Parse and validate `--check a,b,c` against the known check names. */
166
+ function parseCheckNames(raw) {
167
+ const requested = raw.split(',').map(s => s.trim()).filter(Boolean);
168
+ const known = new Set(ALL_CHECK_NAMES);
169
+ const only = requested.filter(n => known.has(n));
170
+ const unknown = requested.filter(n => !known.has(n));
171
+ return { only, unknown };
172
+ }
150
173
  export const doctorCommand = new Command('doctor')
151
174
  .description('Check system health: auth, network, project config, agent setup')
152
175
  .option('-v, --verbose', 'Include host-agent detection results, runtime info, and allowlisted env vars (useful for AI agents debugging their own environment)')
176
+ .option('--check <names>', `Run only the named checks (comma-separated). Available: ${ALL_CHECK_NAMES.join(', ')}`)
177
+ .option('--fix', 'Auto-remediate fixable failures (git credential helper, runwork remote), then re-check')
153
178
  .action(async (opts, command) => {
154
179
  const asJson = shouldOutputJson(command.optsWithGlobals().json);
155
- const report = await runAllChecks();
180
+ // Resolve `--check` scope (if any) up front so both fix and report honor it.
181
+ let only;
182
+ if (opts.check) {
183
+ const parsed = parseCheckNames(opts.check);
184
+ if (parsed.unknown.length > 0) {
185
+ const msg = `Unknown check(s): ${parsed.unknown.join(', ')}. Available: ${ALL_CHECK_NAMES.join(', ')}`;
186
+ if (asJson) {
187
+ jsonOut({ success: false, command: 'doctor', error: msg });
188
+ }
189
+ else {
190
+ console.error(red(msg));
191
+ }
192
+ process.exit(1);
193
+ }
194
+ only = parsed.only;
195
+ }
196
+ // --- Fix flow: report, remediate fixable failures, re-check ---
197
+ if (opts.fix) {
198
+ const before = await runAllChecks({ only });
199
+ const failingNames = before.checks.filter(c => c.status === 'fail').map(c => c.name);
200
+ const ctx = buildContext();
201
+ const outcomes = await applyDoctorFixes(ctx, failingNames);
202
+ const after = await runAllChecks({ only });
203
+ if (asJson) {
204
+ jsonOut({ success: after.status !== 'fail', command: 'doctor', fixes: outcomes, report: after });
205
+ }
206
+ else {
207
+ printFixOutcomes(outcomes);
208
+ printHumanReport(after);
209
+ }
210
+ if (after.checks.some(c => c.status === 'fail')) {
211
+ process.exit(1);
212
+ }
213
+ return;
214
+ }
215
+ // --- Normal (optionally scoped) report ---
216
+ const report = await runAllChecks({ only });
156
217
  const verboseInfo = opts.verbose ? collectVerboseInfo() : null;
157
218
  if (asJson) {
158
219
  jsonOut(verboseInfo ? { ...report, verbose: verboseInfo } : report);
@@ -1,5 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { type SessionMode, type SessionStaleReason } from '../dev/session.js';
3
+ import { type DeploySummary } from '../deploy/deploy-state.js';
3
4
  import type { WorkspaceAllData } from '../types.js';
4
5
  interface BlueprintEntity {
5
6
  entityName: string;
@@ -124,6 +125,8 @@ interface InfoOutput {
124
125
  url: string | null;
125
126
  deployed: boolean;
126
127
  };
128
+ /** Last deploy from this machine vs the current local HEAD (friction log #8). */
129
+ deploy: DeploySummary;
127
130
  integrations: Array<{
128
131
  id: string;
129
132
  status: string;
@@ -10,6 +10,7 @@ import { requireAuth } from '../auth/store.js';
10
10
  import { ApiClient } from '../api/client.js';
11
11
  import { resolveWorkspace } from '../workspace/resolve.js';
12
12
  import { getSessionState } from '../dev/session.js';
13
+ import { getDeploySummary } from '../deploy/deploy-state.js';
13
14
  function readLocalDevSession(appDir, appId) {
14
15
  if (!appId)
15
16
  return { state: 'none' };
@@ -245,6 +246,15 @@ function printHumanOutput(data) {
245
246
  else {
246
247
  console.log(` ${dim(pad('Production:'))}${dim('(not deployed)')}`);
247
248
  }
249
+ if (data.deploy.deployedShortSha) {
250
+ const localPart = data.deploy.localShortSha ? `, local ${data.deploy.localShortSha}` : '';
251
+ const sync = data.deploy.inSync === true
252
+ ? green('in sync')
253
+ : data.deploy.inSync === false
254
+ ? yellow('local has undeployed commits')
255
+ : dim('unknown');
256
+ console.log(` ${dim(pad('Last deploy:'))}${data.deploy.deployedShortSha}${dim(localPart)} ${sync}`);
257
+ }
248
258
  // Local dev session view: what's running on THIS machine, from the
249
259
  // session file. This may disagree with the server's "preview"
250
260
  // status above; that's diagnostic, not a bug.
@@ -428,6 +438,7 @@ export const infoCommand = new Command('info')
428
438
  preview,
429
439
  localDevSession,
430
440
  production: appInfo?.production ?? { url: null, deployed: false },
441
+ deploy: getDeploySummary(process.cwd()),
431
442
  integrations,
432
443
  registries,
433
444
  cli: {
@@ -16,7 +16,7 @@ import { buildShareConversationSkill, buildSaveConversationSkill, BUILT_IN_SKILL
16
16
  import { computeSyncPlan } from '../sync/change-detect.js';
17
17
  import { printSyncSummary, resolveConflict, resolveConflictNonInteractive } from '../sync/conflict-ui.js';
18
18
  import { executeSyncPlan } from '../sync/executor.js';
19
- import { getRegistryAgent, resolveToAbsolute } from '../agents/registry.js';
19
+ import { getRegistryAgent, resolveToAbsolute, isConnectOnlyAgent } from '../agents/registry.js';
20
20
  function loadSetupState(filePath) {
21
21
  if (!existsSync(filePath))
22
22
  return null;
@@ -340,6 +340,13 @@ export async function syncFromState(state, statePath, credentials, opts) {
340
340
  hookInstallCalls: 0,
341
341
  };
342
342
  for (const adapter of adapters) {
343
+ // Connect-only agents (e.g. ChatGPT) have no local files to write; the
344
+ // connector lives in the user's account. Skip all local-write phases.
345
+ if (isConnectOnlyAgent(getRegistryAgent(adapter.slug))) {
346
+ console.log(` [${adapter.name}] Connect-only agent: no local files to sync`);
347
+ summary.adaptersProcessed++;
348
+ continue;
349
+ }
343
350
  let adapterFailedAnyScope = false;
344
351
  for (const scope of scopes) {
345
352
  try {
@@ -433,6 +440,8 @@ export async function syncFromState(state, statePath, credentials, opts) {
433
440
  }
434
441
  // Write team instructions and/or per-agent extra instructions.
435
442
  for (const adapter of adapters) {
443
+ if (isConnectOnlyAgent(getRegistryAgent(adapter.slug)))
444
+ continue;
436
445
  if (!adapter.writeTeamInstructions)
437
446
  continue;
438
447
  const agentExtra = agentConfigs?.[adapter.slug]?.extraInstructions;
@@ -455,6 +464,8 @@ export async function syncFromState(state, statePath, credentials, opts) {
455
464
  // User scope is handled below in the unified defaults+network+minimum block.
456
465
  if (agentConfigs) {
457
466
  for (const adapter of adapters) {
467
+ if (isConnectOnlyAgent(getRegistryAgent(adapter.slug)))
468
+ continue;
458
469
  const agentConfig = agentConfigs[adapter.slug];
459
470
  if (!agentConfig || !adapter.writeAgentConfig)
460
471
  continue;
@@ -507,6 +518,8 @@ export async function syncFromState(state, statePath, credentials, opts) {
507
518
  if (!state.agentDefaults)
508
519
  state.agentDefaults = {};
509
520
  for (const adapter of adapters) {
521
+ if (isConnectOnlyAgent(getRegistryAgent(adapter.slug)))
522
+ continue;
510
523
  if (!adapter.writeAgentConfig)
511
524
  continue;
512
525
  const slug = adapter.slug;
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare const validateCommand: Command;