runwork 0.14.0 → 0.15.0

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 (83) 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__/detection.test.js +3 -0
  5. package/dist/agents/detection.js +3 -0
  6. package/dist/agents/intro-skill.js +5 -3
  7. package/dist/agents/registry-data.d.ts +59 -2
  8. package/dist/agents/registry-data.js +176 -0
  9. package/dist/commands/__tests__/clone-args.test.js +8 -1
  10. package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
  11. package/dist/commands/__tests__/clone-source-result.test.js +45 -0
  12. package/dist/commands/clone.d.ts +33 -0
  13. package/dist/commands/clone.js +105 -10
  14. package/dist/commands/deploy.js +128 -11
  15. package/dist/commands/dev.js +72 -32
  16. package/dist/commands/doctor.js +64 -3
  17. package/dist/commands/info.d.ts +3 -0
  18. package/dist/commands/info.js +11 -0
  19. package/dist/commands/sync.js +14 -1
  20. package/dist/commands/validate.d.ts +2 -0
  21. package/dist/commands/validate.js +89 -0
  22. package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
  23. package/dist/deploy/__tests__/deploy-state.test.js +80 -0
  24. package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
  25. package/dist/deploy/__tests__/deploy-status.test.js +41 -0
  26. package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
  27. package/dist/deploy/__tests__/detach-args.test.js +30 -0
  28. package/dist/deploy/deploy-state.d.ts +36 -0
  29. package/dist/deploy/deploy-state.js +63 -0
  30. package/dist/deploy/deploy-status.d.ts +21 -0
  31. package/dist/deploy/deploy-status.js +36 -0
  32. package/dist/deploy/detach.d.ts +32 -0
  33. package/dist/deploy/detach.js +71 -0
  34. package/dist/dev/__tests__/session.test.js +39 -0
  35. package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
  36. package/dist/dev/__tests__/startup-sync.test.js +32 -0
  37. package/dist/dev/session.d.ts +9 -0
  38. package/dist/dev/session.js +4 -1
  39. package/dist/dev/startup-sync.d.ts +23 -0
  40. package/dist/dev/startup-sync.js +14 -0
  41. package/dist/generated/version.d.ts +1 -1
  42. package/dist/generated/version.js +1 -1
  43. package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
  44. package/dist/git/__tests__/classify-sync-error.test.js +57 -0
  45. package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
  46. package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
  47. package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
  48. package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
  49. package/dist/git/__tests__/repo-config.test.d.ts +1 -0
  50. package/dist/git/__tests__/repo-config.test.js +26 -0
  51. package/dist/git/classify-sync-error.d.ts +21 -0
  52. package/dist/git/classify-sync-error.js +111 -0
  53. package/dist/git/credentials.d.ts +43 -0
  54. package/dist/git/credentials.js +67 -0
  55. package/dist/git/remote.d.ts +37 -0
  56. package/dist/git/remote.js +75 -0
  57. package/dist/git/repo-config.d.ts +8 -0
  58. package/dist/git/repo-config.js +29 -0
  59. package/dist/health/__tests__/checks.test.js +2 -2
  60. package/dist/health/__tests__/fix.test.d.ts +1 -0
  61. package/dist/health/__tests__/fix.test.js +72 -0
  62. package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
  63. package/dist/health/__tests__/runner-filter.test.js +61 -0
  64. package/dist/health/checks.d.ts +7 -0
  65. package/dist/health/checks.js +50 -36
  66. package/dist/health/fix.d.ts +22 -0
  67. package/dist/health/fix.js +62 -0
  68. package/dist/health/runner.d.ts +7 -1
  69. package/dist/health/runner.js +40 -27
  70. package/dist/index.js +2 -0
  71. package/dist/tools/types.d.ts +5 -0
  72. package/dist/utils/__tests__/help-json.test.d.ts +1 -0
  73. package/dist/utils/__tests__/help-json.test.js +39 -0
  74. package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
  75. package/dist/utils/help-json.d.ts +14 -0
  76. package/dist/utils/help-json.js +4 -1
  77. package/dist/validate/__tests__/validate.test.d.ts +1 -0
  78. package/dist/validate/__tests__/validate.test.js +42 -0
  79. package/dist/validate/freshness.d.ts +28 -0
  80. package/dist/validate/freshness.js +48 -0
  81. package/dist/validate/preview.d.ts +21 -0
  82. package/dist/validate/preview.js +39 -0
  83. package/package.json +1 -1
@@ -8,6 +8,8 @@ import { ApiClient } from '../api/client.js';
8
8
  import { detectAgents, getAdapterBySlug } from '../agents/detect.js';
9
9
  import { httpFetch } from '../utils/http.js';
10
10
  import { probeGit, parseGitVersion } from '../git/preflight.js';
11
+ import { lookupCredentialHelper, helperBinaryStatus } from '../git/credentials.js';
12
+ import { getDeploySummary } from '../deploy/deploy-state.js';
11
13
  // First-party distribution endpoints. Defaults match the plan, can be
12
14
  // overridden via RUNWORK_DOWNLOAD_BASE_URL for staging or debugging.
13
15
  const BASE_URL = process.env.RUNWORK_DOWNLOAD_BASE_URL || 'https://runwork.ai';
@@ -283,56 +285,40 @@ export async function checkGitCredentialHelper(ctx) {
283
285
  fix: 'runwork login',
284
286
  };
285
287
  }
286
- // `--get-regexp` returns 1 (and prints nothing) when no entry matches,
287
- // so a non-zero exit is a normal "not registered" signal -- not an error.
288
- let helperConfig;
289
- try {
290
- const buf = execFileSync('git', ['config', '--global', '--get-regexp', 'credential\\..*runwork.*\\.helper'], { stdio: ['ignore', 'pipe', 'ignore'] });
291
- helperConfig = buf.toString('utf-8');
292
- }
293
- catch {
288
+ // The scoped lookup (get-regexp + origin match) is shared with the
289
+ // lazy-ensure path in git/credentials.ts so the two can't drift. We only
290
+ // need to map its result onto doctor's richer status messages here.
291
+ const lookup = lookupCredentialHelper(origin);
292
+ if (lookup.status === 'none') {
294
293
  return {
295
294
  name: 'git-credential-helper',
296
295
  status: 'fail',
297
296
  message: `no credential helper registered for ${origin}`,
298
- fix: 'runwork login',
297
+ fix: 'runwork doctor --fix',
299
298
  };
300
299
  }
301
- // Each line is `credential.<scope>.helper <value>`. We want a line whose
302
- // <scope> matches the user's actual baseUrl origin (not e.g. a stale
303
- // staging origin from a previous login).
304
- const lines = helperConfig.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
305
- const escapedOrigin = origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
306
- const scopedRegex = new RegExp(`^credential\\.${escapedOrigin}\\.helper\\s+(.+)$`);
307
- const matchedLine = lines.find(line => scopedRegex.test(line));
308
- if (!matchedLine) {
300
+ if (lookup.status === 'unscoped') {
309
301
  return {
310
302
  name: 'git-credential-helper',
311
303
  status: 'fail',
312
- message: `no credential helper registered for ${origin} (found ${lines.length} other runwork helper entries)`,
313
- fix: 'runwork login',
304
+ message: `no credential helper registered for ${origin} (found ${lookup.otherCount} other runwork helper entries)`,
305
+ fix: 'runwork doctor --fix',
314
306
  };
315
307
  }
316
- const value = scopedRegex.exec(matchedLine)?.[1] ?? '';
317
308
  // Helper values starting with `!` are shell commands. Common shapes:
318
309
  // `!runwork git-credential-helper` (PATH-relative)
319
310
  // `!"/abs/path/to/runwork" git-credential-helper` (post-Pass-2 hardening, POSIX)
320
311
  // `!"C:/.../runwork.exe" git-credential-helper` (post-Pass-2 hardening, Windows)
321
- // We only validate "absolute path" entries by existsSync() because that's
322
- // the unambiguous case. PATH-relative entries we trust git+bash to resolve
323
- // at invocation time (no good way to simulate that without running it).
324
- const absoluteHelperRegex = /^!"?(\/|[A-Za-z]:[\\/])/;
325
- if (absoluteHelperRegex.test(value)) {
326
- const pathMatch = value.match(/^!"([^"]+)"|^!(\S+)/);
327
- const helperPath = pathMatch?.[1] ?? pathMatch?.[2] ?? '';
328
- if (helperPath && !existsSync(helperPath)) {
329
- return {
330
- name: 'git-credential-helper',
331
- status: 'fail',
332
- message: `helper registered but binary not found at ${helperPath}`,
333
- fix: 'runwork login (re-registers with the current binary path)',
334
- };
335
- }
312
+ // helperBinaryStatus() validates only absolute-path entries on disk; it
313
+ // trusts PATH-relative entries to resolve at git-invocation time.
314
+ const binary = helperBinaryStatus(lookup.value);
315
+ if (!binary.ok && binary.path) {
316
+ return {
317
+ name: 'git-credential-helper',
318
+ status: 'fail',
319
+ message: `helper registered but binary not found at ${binary.path}`,
320
+ fix: 'runwork doctor --fix (re-registers with the current binary path)',
321
+ };
336
322
  }
337
323
  return {
338
324
  name: 'git-credential-helper',
@@ -441,9 +427,37 @@ export async function checkGitRemote(ctx) {
441
427
  name: 'git-remote',
442
428
  status: 'fail',
443
429
  message: 'remote "runwork" not configured',
444
- fix: `git remote add runwork ${expectedUrl}`,
430
+ fix: `runwork doctor --fix (or: git remote add runwork ${expectedUrl})`,
431
+ };
432
+ }
433
+ }
434
+ // ── 7a. Deploy freshness ─────────────────────────────────────────────
435
+ /**
436
+ * Compare the current local HEAD against the last deploy recorded from this
437
+ * machine (`.runwork/last-deploy.json`). Surfaces "you have undeployed
438
+ * commits" so a deploy can be verified at a glance (friction log #8). Silent
439
+ * (skip) outside a project or before the first local deploy.
440
+ */
441
+ export async function checkDeployFreshness(ctx) {
442
+ if (!ctx.config) {
443
+ return { name: 'deploy-freshness', status: 'skip', message: 'skipped (no project)' };
444
+ }
445
+ const summary = getDeploySummary(ctx.cwd);
446
+ if (!summary.deployedShortSha) {
447
+ return { name: 'deploy-freshness', status: 'skip', message: 'no deploy recorded from this machine' };
448
+ }
449
+ if (summary.inSync === true) {
450
+ return { name: 'deploy-freshness', status: 'pass', message: `local HEAD matches last deploy (${summary.deployedShortSha})` };
451
+ }
452
+ if (summary.inSync === false) {
453
+ return {
454
+ name: 'deploy-freshness',
455
+ status: 'warn',
456
+ message: `local (${summary.localShortSha}) has commits not in the last deploy (${summary.deployedShortSha})`,
457
+ fix: 'runwork deploy',
445
458
  };
446
459
  }
460
+ return { name: 'deploy-freshness', status: 'skip', message: 'local HEAD unknown' };
447
461
  }
448
462
  // ── 8. Agent setup ───────────────────────────────────────────────────
449
463
  function loadSetupState() {
@@ -0,0 +1,22 @@
1
+ import type { DoctorContext } from './checks.js';
2
+ /** Check names `runwork doctor --fix` knows how to auto-remediate. */
3
+ export declare const FIXABLE_CHECKS: readonly string[];
4
+ export interface FixOutcome {
5
+ /** The check name this outcome corresponds to. */
6
+ name: string;
7
+ /** Whether a remediation was actually applied (false = couldn't, with a reason). */
8
+ applied: boolean;
9
+ /** Human-readable description of what was done, or why it couldn't be. */
10
+ message: string;
11
+ }
12
+ /**
13
+ * Apply auto-remediations for the failing checks doctor knows how to fix.
14
+ * Only acts on `failingNames` that are in FIXABLE_CHECKS; everything else is
15
+ * left for the user (with the `fix` hint each check already carries).
16
+ *
17
+ * - git-credential-helper -> register the runwork git helper for the logged-in
18
+ * origin (the B1 ensure-path), so headless-auth'd sessions can use git.
19
+ * - git-remote -> point the `runwork` remote at the app's canonical URL (the
20
+ * B2 ensure-path), using the correct `/api/git/...` URL from the API client.
21
+ */
22
+ export declare function applyDoctorFixes(ctx: DoctorContext, failingNames: string[]): Promise<FixOutcome[]>;
@@ -0,0 +1,62 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { ensureGitCredentialHelper } from '../git/credentials.js';
4
+ import { ensureRunworkRemote } from '../git/remote.js';
5
+ /** Check names `runwork doctor --fix` knows how to auto-remediate. */
6
+ export const FIXABLE_CHECKS = ['git-credential-helper', 'git-remote'];
7
+ /**
8
+ * Apply auto-remediations for the failing checks doctor knows how to fix.
9
+ * Only acts on `failingNames` that are in FIXABLE_CHECKS; everything else is
10
+ * left for the user (with the `fix` hint each check already carries).
11
+ *
12
+ * - git-credential-helper -> register the runwork git helper for the logged-in
13
+ * origin (the B1 ensure-path), so headless-auth'd sessions can use git.
14
+ * - git-remote -> point the `runwork` remote at the app's canonical URL (the
15
+ * B2 ensure-path), using the correct `/api/git/...` URL from the API client.
16
+ */
17
+ export async function applyDoctorFixes(ctx, failingNames) {
18
+ const failing = new Set(failingNames);
19
+ const outcomes = [];
20
+ if (failing.has('git-credential-helper')) {
21
+ if (ctx.credentials?.baseUrl) {
22
+ await ensureGitCredentialHelper(ctx.credentials.baseUrl);
23
+ outcomes.push({
24
+ name: 'git-credential-helper',
25
+ applied: true,
26
+ message: 'registered the runwork git credential helper',
27
+ });
28
+ }
29
+ else {
30
+ outcomes.push({
31
+ name: 'git-credential-helper',
32
+ applied: false,
33
+ message: 'not logged in -- run `runwork login` first',
34
+ });
35
+ }
36
+ }
37
+ if (failing.has('git-remote')) {
38
+ if (!ctx.config || !ctx.client) {
39
+ outcomes.push({
40
+ name: 'git-remote',
41
+ applied: false,
42
+ message: 'no project config -- run inside an app directory',
43
+ });
44
+ }
45
+ else if (!existsSync(join(ctx.cwd, '.git'))) {
46
+ outcomes.push({
47
+ name: 'git-remote',
48
+ applied: false,
49
+ message: 'not a git repository -- run `git init` first',
50
+ });
51
+ }
52
+ else {
53
+ ensureRunworkRemote(ctx.cwd, ctx.client.getGitRemoteUrl(ctx.config.workspaceId, ctx.config.appId));
54
+ outcomes.push({
55
+ name: 'git-remote',
56
+ applied: true,
57
+ message: 'configured the `runwork` remote at the canonical URL',
58
+ });
59
+ }
60
+ }
61
+ return outcomes;
62
+ }
@@ -1,2 +1,8 @@
1
1
  import type { DoctorReport } from './types.js';
2
- export declare function runAllChecks(): Promise<DoctorReport>;
2
+ /** Every check name doctor knows about, in run order. Used to validate `--check`. */
3
+ export declare const ALL_CHECK_NAMES: readonly string[];
4
+ export interface RunChecksOptions {
5
+ /** When set, run only the named checks (skips unrelated work like network). */
6
+ only?: string[];
7
+ }
8
+ export declare function runAllChecks(options?: RunChecksOptions): Promise<DoctorReport>;
@@ -1,32 +1,45 @@
1
- import { buildContext, checkSystemDeps, checkCliVersion, checkCliArtifactReachable, checkCliInstallLocation, checkAuthAndNetwork, checkGitCredentialHelper, checkProjectConfig, checkAppExists, checkGitRemote, checkAgentSetup, } from './checks.js';
2
- export async function runAllChecks() {
1
+ import { buildContext, checkSystemDeps, checkCliVersion, checkCliArtifactReachable, checkCliInstallLocation, checkAuthAndNetwork, checkGitCredentialHelper, checkProjectConfig, checkAppExists, checkGitRemote, checkDeployFreshness, checkAgentSetup, } from './checks.js';
2
+ const CHECK_RUNNERS = [
3
+ { names: ['system-deps'], run: async () => [await checkSystemDeps()] },
4
+ { names: ['cli-version'], run: async () => [await checkCliVersion()] },
5
+ { names: ['cli-artifact'], run: async () => [await checkCliArtifactReachable()] },
6
+ { names: ['cli-install-location'], run: async () => [await checkCliInstallLocation()] },
7
+ // Auth + Network come from one combined call (network reuses the auth probe).
8
+ {
9
+ names: ['auth', 'network'],
10
+ run: async (ctx) => {
11
+ const { auth, network } = await checkAuthAndNetwork(ctx);
12
+ return [auth, network];
13
+ },
14
+ },
15
+ // Catches "logged in but git auth not wired up" -- on Windows this surfaces
16
+ // as a Git Credential Manager popup mid-clone with no obvious cause.
17
+ { names: ['git-credential-helper'], run: async (ctx) => [await checkGitCredentialHelper(ctx)] },
18
+ { names: ['project-config'], run: async (ctx) => [await checkProjectConfig(ctx)] },
19
+ { names: ['app-exists'], run: async (ctx) => [await checkAppExists(ctx)] },
20
+ { names: ['git-remote'], run: async (ctx) => [await checkGitRemote(ctx)] },
21
+ { names: ['deploy-freshness'], run: async (ctx) => [await checkDeployFreshness(ctx)] },
22
+ { names: ['agent-setup'], run: async () => [await checkAgentSetup()] },
23
+ ];
24
+ /** Every check name doctor knows about, in run order. Used to validate `--check`. */
25
+ export const ALL_CHECK_NAMES = CHECK_RUNNERS.flatMap(r => r.names);
26
+ export async function runAllChecks(options) {
3
27
  const ctx = buildContext();
28
+ const only = options?.only && options.only.length ? new Set(options.only) : null;
4
29
  const checks = [];
5
- // 1. System deps
6
- checks.push(await checkSystemDeps());
7
- // 2. CLI version (runwork.ai/cli/latest.json)
8
- checks.push(await checkCliVersion());
9
- // 2a. CLI artifact reachability for this host platform
10
- checks.push(await checkCliArtifactReachable());
11
- // 2b. Canonical install location
12
- checks.push(await checkCliInstallLocation());
13
- // 3 & 4. Auth + Network (combined call)
14
- const { auth, network } = await checkAuthAndNetwork(ctx);
15
- checks.push(auth);
16
- checks.push(network);
17
- // 4a. Git credential helper -- catches "logged in but git auth not wired
18
- // up" state that surfaces on Windows as a Git Credential Manager
19
- // popup mid-clone with no obvious cause.
20
- checks.push(await checkGitCredentialHelper(ctx));
21
- // 5. Project config
22
- checks.push(await checkProjectConfig(ctx));
23
- // 6. App exists
24
- checks.push(await checkAppExists(ctx));
25
- // 7. Git remote
26
- checks.push(await checkGitRemote(ctx));
27
- // 8. Agent setup
28
- checks.push(await checkAgentSetup());
29
- // Overall status: worst of all checks
30
+ for (const runner of CHECK_RUNNERS) {
31
+ // Skip a runner entirely unless at least one of its names was requested.
32
+ if (only && !runner.names.some(n => only.has(n)))
33
+ continue;
34
+ const results = await runner.run(ctx);
35
+ for (const result of results) {
36
+ // For multi-result runners (auth+network), include only the requested
37
+ // names when scoped -- e.g. `--check auth` shouldn't print network.
38
+ if (only && !only.has(result.name))
39
+ continue;
40
+ checks.push(result);
41
+ }
42
+ }
30
43
  const hasFailure = checks.some(c => c.status === 'fail');
31
44
  const hasWarning = checks.some(c => c.status === 'warn');
32
45
  const status = hasFailure ? 'fail' : hasWarning ? 'warn' : 'pass';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { initCommand } from './commands/init.js';
5
5
  import { cloneCommand } from './commands/clone.js';
6
6
  import { devCommand } from './commands/dev.js';
7
7
  import { deployCommand } from './commands/deploy.js';
8
+ import { validateCommand } from './commands/validate.js';
8
9
  import { logsCommand } from './commands/logs.js';
9
10
  import { upgradeCommand } from './commands/upgrade.js';
10
11
  import { logoutCommand } from './commands/logout.js';
@@ -62,6 +63,7 @@ program.addCommand(initCommand);
62
63
  program.addCommand(cloneCommand);
63
64
  program.addCommand(devCommand);
64
65
  program.addCommand(deployCommand);
66
+ program.addCommand(validateCommand);
65
67
  program.addCommand(logsCommand);
66
68
  program.addCommand(upgradeCommand);
67
69
  program.addCommand(logoutCommand);
@@ -16,6 +16,9 @@ export type PlatformString = string | {
16
16
  * Detection strategy. Supports simple binary/path checks plus richer
17
17
  * Windows-specific methods and a nested `any` combinator for tools that
18
18
  * ship as both GUI app and CLI binary.
19
+ *
20
+ * `always` means "no local probe; treat as available." Used by web-only
21
+ * agents (e.g. ChatGPT Web) that have no local footprint to detect.
19
22
  */
20
23
  export type Detection = {
21
24
  method: 'binary' | 'path';
@@ -26,6 +29,8 @@ export type Detection = {
26
29
  } | {
27
30
  method: 'any';
28
31
  target: Detection[];
32
+ } | {
33
+ method: 'always';
29
34
  };
30
35
  /**
31
36
  * Common shape for anything that can be detected and installed locally.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Command } from 'commander';
3
+ import { buildCommandHelpJson } from '../help-json.js';
4
+ function optByFlags(cmd, flags) {
5
+ const json = buildCommandHelpJson(cmd, '1.0.0');
6
+ const opt = json.options.find(o => o.flags === flags);
7
+ if (!opt)
8
+ throw new Error(`option ${flags} not found`);
9
+ return opt;
10
+ }
11
+ describe('help-json option required semantics', () => {
12
+ const cmd = new Command('demo')
13
+ .option('--workspace <id>', 'workspace id (optional; defaults from credentials)')
14
+ .option('--flag', 'a boolean flag')
15
+ .option('--maybe [value]', 'an optional value')
16
+ .requiredOption('--token <t>', 'a genuinely required option');
17
+ it('marks a value-taking-but-optional option as NOT required (the --workspace bug)', () => {
18
+ const o = optByFlags(cmd, '--workspace <id>');
19
+ expect(o.required).toBe(false); // the option itself is not mandatory
20
+ expect(o.valueRequired).toBe(true); // but its value is required when present
21
+ expect(o.optional).toBe(false);
22
+ });
23
+ it('marks a boolean flag as neither required nor value-required', () => {
24
+ const o = optByFlags(cmd, '--flag');
25
+ expect(o.required).toBe(false);
26
+ expect(o.valueRequired).toBe(false);
27
+ });
28
+ it('marks an optional-value option correctly', () => {
29
+ const o = optByFlags(cmd, '--maybe [value]');
30
+ expect(o.required).toBe(false);
31
+ expect(o.valueRequired).toBe(false);
32
+ expect(o.optional).toBe(true);
33
+ });
34
+ it('marks a genuinely mandatory option as required AND value-required', () => {
35
+ const o = optByFlags(cmd, '--token <t>');
36
+ expect(o.required).toBe(true);
37
+ expect(o.valueRequired).toBe(true);
38
+ });
39
+ });
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
2
2
  import { mkdtempSync, writeFileSync, rmSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { tmpdir } from 'os';
5
- import { buildIgnoreSets, defaultIgnoreSets, isPathIgnored, parseGitignoreContent, } from '../ignore-matcher.js';
5
+ import { buildIgnoreSets, defaultIgnoreSets, isPathIgnored, isRelPathIgnored, parseGitignoreContent, } from '../ignore-matcher.js';
6
6
  const tempDirs = [];
7
7
  function makeTempDir(prefix) {
8
8
  const dir = mkdtempSync(join(tmpdir(), `runwork-ignore-${prefix}-`));
@@ -26,6 +26,23 @@ describe('defaultIgnoreSets()', () => {
26
26
  expect(sets.dirs.has('.runwork')).toBe(true);
27
27
  expect(sets.dirs.has('.bun-cache')).toBe(true);
28
28
  });
29
+ it('excludes every .runwork/ state file from deploy/dev auto-commit staging', () => {
30
+ // commitWorkingTree() filters untracked files through isRelPathIgnored,
31
+ // so any path with a .runwork segment must be excluded -- including the
32
+ // local deploy-state/status files written before the detached child
33
+ // commits (Codex review P1).
34
+ const sets = defaultIgnoreSets();
35
+ for (const p of [
36
+ '.runwork/deploy-status.json',
37
+ '.runwork/last-deploy.json',
38
+ '.runwork/deploy-stdout.log',
39
+ '.runwork/dev-session.json',
40
+ '.runwork/setup.json',
41
+ '.runwork/preview-url',
42
+ ]) {
43
+ expect(isRelPathIgnored(p, sets), `${p} must be ignored`).toBe(true);
44
+ }
45
+ });
29
46
  it('includes .dev.vars and .env file defaults', () => {
30
47
  const sets = defaultIgnoreSets();
31
48
  expect(sets.files.has('.dev.vars')).toBe(true);
@@ -11,7 +11,21 @@ import type { Command } from 'commander';
11
11
  interface JsonOption {
12
12
  flags: string;
13
13
  description: string;
14
+ /**
15
+ * Whether the OPTION itself must be supplied (Commander's `mandatory`, set
16
+ * via `.makeOptionMandatory()`). This is what an agent reasoning about
17
+ * "do I have to pass this?" wants. Most options are false here even when
18
+ * they take a value -- e.g. `--workspace <id>` is optional (defaults from
19
+ * credentials). Previously this field was wrongly mapped to Commander's
20
+ * `required` (value-required), which marked nearly every option as required.
21
+ */
14
22
  required: boolean;
23
+ /**
24
+ * Whether the option's VALUE is required when the flag is present, i.e. it
25
+ * was declared with `<value>` (vs `[value]` or a boolean flag). Independent
26
+ * of whether the option itself must be supplied.
27
+ */
28
+ valueRequired: boolean;
15
29
  optional: boolean;
16
30
  variadic: boolean;
17
31
  defaultValue?: unknown;
@@ -11,7 +11,10 @@ function serializeOption(opt) {
11
11
  const result = {
12
12
  flags: opt.flags,
13
13
  description: opt.description,
14
- required: opt.required,
14
+ // Commander's `mandatory` = the option must be supplied; `required` = the
15
+ // option's value is required when present. Expose both, correctly named.
16
+ required: opt.mandatory,
17
+ valueRequired: opt.required,
15
18
  optional: opt.optional,
16
19
  variadic: opt.variadic,
17
20
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { summarizeFreshness } from '../freshness.js';
3
+ import { detectViteError } from '../preview.js';
4
+ describe('validate/freshness summarizeFreshness()', () => {
5
+ const base = { localSha: 'a', remoteSha: 'a', ahead: 0, behind: 0, uncommitted: false };
6
+ it('is in-sync when local==remote, no ahead/behind, clean tree', () => {
7
+ expect(summarizeFreshness(base).inSync).toBe(true);
8
+ });
9
+ it('is not in-sync with uncommitted changes', () => {
10
+ expect(summarizeFreshness({ ...base, uncommitted: true }).inSync).toBe(false);
11
+ });
12
+ it('is not in-sync when ahead of the remote', () => {
13
+ expect(summarizeFreshness({ ...base, ahead: 2 }).inSync).toBe(false);
14
+ });
15
+ it('is not in-sync when behind the remote', () => {
16
+ expect(summarizeFreshness({ ...base, behind: 1 }).inSync).toBe(false);
17
+ });
18
+ it('is not in-sync when there is no remote ref', () => {
19
+ expect(summarizeFreshness({ ...base, remoteSha: null }).inSync).toBe(false);
20
+ });
21
+ it('is not in-sync when there is no local HEAD', () => {
22
+ expect(summarizeFreshness({ ...base, localSha: null }).inSync).toBe(false);
23
+ });
24
+ it('passes through the raw counts', () => {
25
+ const f = summarizeFreshness({ localSha: 'x', remoteSha: 'y', ahead: 3, behind: 1, uncommitted: true });
26
+ expect(f).toMatchObject({ ahead: 3, behind: 1, uncommitted: true, inSync: false });
27
+ });
28
+ });
29
+ describe('validate/preview detectViteError()', () => {
30
+ it('detects the Vite error overlay element', () => {
31
+ expect(detectViteError('<vite-error-overlay></vite-error-overlay>')).toBe(true);
32
+ });
33
+ it('detects a plugin transform error', () => {
34
+ expect(detectViteError('[plugin:vite:react-babel] Unexpected token')).toBe(true);
35
+ });
36
+ it('detects a failed import resolution', () => {
37
+ expect(detectViteError('Failed to resolve import "./missing" from "src/App.tsx"')).toBe(true);
38
+ });
39
+ it('returns false for a healthy HTML document', () => {
40
+ expect(detectViteError('<!DOCTYPE html><html><body><div id="root"></div></body></html>')).toBe(false);
41
+ });
42
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Git sync-freshness: how the local working tree relates to the deploy remote
3
+ * (`runwork/main`). Answers "do I have changes that aren't synced/deployed?"
4
+ * without scraping the sandbox (friction log #10).
5
+ */
6
+ export interface Freshness {
7
+ localSha: string | null;
8
+ remoteSha: string | null;
9
+ ahead: number;
10
+ behind: number;
11
+ uncommitted: boolean;
12
+ /** true when local == remote and the tree is clean. */
13
+ inSync: boolean;
14
+ }
15
+ export interface FreshnessInput {
16
+ localSha: string | null;
17
+ remoteSha: string | null;
18
+ ahead: number;
19
+ behind: number;
20
+ uncommitted: boolean;
21
+ }
22
+ /** Pure: derive the in-sync verdict from the raw git counts/flags. */
23
+ export declare function summarizeFreshness(input: FreshnessInput): Freshness;
24
+ /**
25
+ * Collect git freshness for `cwd`. Best-effort fetches `runwork/main` first so
26
+ * the comparison reflects the real remote tip; tolerates offline/no-remote.
27
+ */
28
+ export declare function getGitFreshness(cwd: string): Freshness;
@@ -0,0 +1,48 @@
1
+ import { execFileSync } from '../utils/subprocess.js';
2
+ /** Pure: derive the in-sync verdict from the raw git counts/flags. */
3
+ export function summarizeFreshness(input) {
4
+ const inSync = input.localSha !== null &&
5
+ input.remoteSha !== null &&
6
+ input.ahead === 0 &&
7
+ input.behind === 0 &&
8
+ !input.uncommitted;
9
+ return { ...input, inSync };
10
+ }
11
+ function gitOut(cwd, args) {
12
+ try {
13
+ return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /**
20
+ * Collect git freshness for `cwd`. Best-effort fetches `runwork/main` first so
21
+ * the comparison reflects the real remote tip; tolerates offline/no-remote.
22
+ */
23
+ export function getGitFreshness(cwd) {
24
+ // Best-effort: refresh the remote-tracking ref. Failure (offline, no remote)
25
+ // just means we compare against whatever ref we already have.
26
+ try {
27
+ execFileSync('git', ['fetch', 'runwork', 'main'], { cwd, stdio: 'pipe' });
28
+ }
29
+ catch {
30
+ /* offline or no runwork remote -- non-fatal */
31
+ }
32
+ const localSha = gitOut(cwd, ['rev-parse', 'HEAD']);
33
+ const remoteSha = gitOut(cwd, ['rev-parse', 'runwork/main']);
34
+ let ahead = 0;
35
+ let behind = 0;
36
+ if (localSha && remoteSha) {
37
+ // `<behind>\t<ahead>` relative to runwork/main...HEAD
38
+ const counts = gitOut(cwd, ['rev-list', '--left-right', '--count', 'runwork/main...HEAD']);
39
+ if (counts) {
40
+ const [b, a] = counts.split(/\s+/).map(n => parseInt(n, 10));
41
+ behind = Number.isFinite(b) ? b : 0;
42
+ ahead = Number.isFinite(a) ? a : 0;
43
+ }
44
+ }
45
+ const porcelain = gitOut(cwd, ['status', '--porcelain']);
46
+ const uncommitted = porcelain !== null && porcelain.length > 0;
47
+ return summarizeFreshness({ localSha, remoteSha, ahead, behind, uncommitted });
48
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Liveness of the preview deployment: does it respond, and is it serving a
3
+ * working build (vs a Vite error overlay)? This is the cheap "is my change
4
+ * live and compiling" signal the friction log (#10) reached for by hand.
5
+ */
6
+ export interface PreviewLiveness {
7
+ url: string | null;
8
+ responds: boolean;
9
+ status: number | null;
10
+ /** A Vite/build error overlay was detected in the served HTML. */
11
+ errorDetected: boolean;
12
+ error?: string;
13
+ }
14
+ /**
15
+ * Heuristically detect a Vite dev-server error overlay / transform error in
16
+ * served HTML. These are the markers Vite injects when a module fails to
17
+ * compile, so their presence means the preview is serving a broken build.
18
+ */
19
+ export declare function detectViteError(html: string): boolean;
20
+ /** Fetch the preview URL and report whether it's live and error-free. */
21
+ export declare function checkPreview(url: string | null): Promise<PreviewLiveness>;
@@ -0,0 +1,39 @@
1
+ import { httpFetch } from '../utils/http.js';
2
+ /**
3
+ * Heuristically detect a Vite dev-server error overlay / transform error in
4
+ * served HTML. These are the markers Vite injects when a module fails to
5
+ * compile, so their presence means the preview is serving a broken build.
6
+ */
7
+ export function detectViteError(html) {
8
+ const markers = [
9
+ 'vite-error-overlay',
10
+ 'class="vite-error',
11
+ '[vite] Internal Server Error',
12
+ '[plugin:vite:',
13
+ 'Pre-transform error',
14
+ 'Failed to resolve import',
15
+ 'Internal server error:',
16
+ ];
17
+ return markers.some(m => html.includes(m));
18
+ }
19
+ /** Fetch the preview URL and report whether it's live and error-free. */
20
+ export async function checkPreview(url) {
21
+ if (!url) {
22
+ return { url: null, responds: false, status: null, errorDetected: false };
23
+ }
24
+ try {
25
+ const res = await httpFetch(url, { method: 'GET' });
26
+ let errorDetected = false;
27
+ try {
28
+ const body = await res.text();
29
+ errorDetected = detectViteError(body);
30
+ }
31
+ catch {
32
+ /* body read failed -- treat as no overlay detected */
33
+ }
34
+ return { url, responds: res.status > 0 && res.status < 500, status: res.status, errorDetected };
35
+ }
36
+ catch (err) {
37
+ return { url, responds: false, status: null, errorDetected: false, error: err instanceof Error ? err.message : String(err) };
38
+ }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runwork",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "CLI for Runwork: develop, preview, and deploy Runwork apps from your local machine.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Runwork <info@runwork.ai> (https://www.runwork.ai)",