runwork 0.9.4 → 0.10.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 (49) hide show
  1. package/dist/agents/__tests__/intro-skill.test.js +6 -2
  2. package/dist/agents/codex.js +9 -3
  3. package/dist/api/__tests__/client.test.js +10 -2
  4. package/dist/api/client.js +5 -4
  5. package/dist/auth/__tests__/login-flow.test.js +57 -81
  6. package/dist/auth/__tests__/store.test.js +35 -6
  7. package/dist/commands/__tests__/info-merge.test.d.ts +1 -0
  8. package/dist/commands/__tests__/info-merge.test.js +55 -0
  9. package/dist/commands/__tests__/upgrade.test.js +25 -42
  10. package/dist/commands/clone.d.ts +2 -2
  11. package/dist/commands/clone.js +39 -7
  12. package/dist/commands/dev.js +9 -1
  13. package/dist/commands/endpoints.js +2 -1
  14. package/dist/commands/files.js +3 -2
  15. package/dist/commands/info.d.ts +141 -0
  16. package/dist/commands/info.js +29 -7
  17. package/dist/commands/init.d.ts +2 -2
  18. package/dist/commands/init.js +34 -5
  19. package/dist/commands/upgrade.js +4 -3
  20. package/dist/commands/welcome.js +2 -2
  21. package/dist/devtools/registry-data.d.ts +7 -0
  22. package/dist/devtools/registry-data.js +1 -0
  23. package/dist/generated/version.d.ts +1 -1
  24. package/dist/generated/version.js +1 -1
  25. package/dist/git/__tests__/credentials.test.js +4 -4
  26. package/dist/git/__tests__/identity.test.d.ts +1 -0
  27. package/dist/git/__tests__/identity.test.js +146 -0
  28. package/dist/git/__tests__/preflight.test.d.ts +1 -0
  29. package/dist/git/__tests__/preflight.test.js +36 -0
  30. package/dist/git/auto-commit.js +8 -2
  31. package/dist/git/credentials.js +1 -1
  32. package/dist/git/identity.d.ts +44 -0
  33. package/dist/git/identity.js +133 -0
  34. package/dist/git/preflight.d.ts +28 -0
  35. package/dist/git/preflight.js +50 -0
  36. package/dist/git/sync.js +3 -1
  37. package/dist/health/__tests__/cli-distribution-checks.test.js +25 -28
  38. package/dist/health/checks.js +3 -2
  39. package/dist/index.js +32 -1
  40. package/dist/utils/__tests__/format-error.test.d.ts +1 -0
  41. package/dist/utils/__tests__/format-error.test.js +43 -0
  42. package/dist/utils/__tests__/http.test.d.ts +1 -0
  43. package/dist/utils/__tests__/http.test.js +381 -0
  44. package/dist/utils/agent-guidance.js +10 -3
  45. package/dist/utils/format-error.d.ts +10 -0
  46. package/dist/utils/format-error.js +38 -0
  47. package/dist/utils/http.d.ts +46 -0
  48. package/dist/utils/http.js +421 -0
  49. package/package.json +3 -2
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { probeGit, buildMissingGitMessage } from '../preflight.js';
3
+ describe('probeGit', () => {
4
+ it('reports installed=true when git is on PATH (CI invariant)', () => {
5
+ // The CLI test suite already shells out to git in many other tests, so
6
+ // we treat git as a hard prerequisite for this package's own tests.
7
+ const probe = probeGit();
8
+ expect(probe.installed).toBe(true);
9
+ expect(probe.version).toMatch(/^git version /);
10
+ });
11
+ });
12
+ describe('buildMissingGitMessage', () => {
13
+ it('mentions the failing command name', () => {
14
+ const msg = buildMissingGitMessage('clone');
15
+ expect(msg).toContain('runwork clone');
16
+ });
17
+ it('lists install hints for all three OSes', () => {
18
+ const msg = buildMissingGitMessage('dev');
19
+ expect(msg).toMatch(/macOS/);
20
+ expect(msg).toMatch(/Windows/);
21
+ expect(msg).toMatch(/Linux/);
22
+ });
23
+ it('ends with a verification command', () => {
24
+ const msg = buildMissingGitMessage('init');
25
+ expect(msg).toContain('git --version');
26
+ });
27
+ it('only includes the Windows-shell-PATH hint when running on Windows', () => {
28
+ const msg = buildMissingGitMessage('clone');
29
+ if (process.platform === 'win32') {
30
+ expect(msg).toMatch(/cached when it opened/);
31
+ }
32
+ else {
33
+ expect(msg).not.toMatch(/cached when it opened/);
34
+ }
35
+ });
36
+ });
@@ -188,12 +188,18 @@ function commitAndPush() {
188
188
  return;
189
189
  }
190
190
  }
191
+ // Push the current HEAD to the remote's `main` branch regardless of
192
+ // what the local branch is named. This way `git init` defaults of
193
+ // `master` (older Git) or any other custom branch name still sync
194
+ // cleanly to runwork's `main`. Without `HEAD:main`, `git push runwork
195
+ // main` fails with "src refspec main does not match any" when the
196
+ // local branch isn't called `main`.
191
197
  try {
192
198
  execFileSync('git', ['rev-parse', '--abbrev-ref', '@{u}'], { stdio: 'pipe' });
193
- execFileSync('git', ['push', 'runwork', 'main'], { stdio: 'pipe' });
199
+ execFileSync('git', ['push', 'runwork', 'HEAD:main'], { stdio: 'pipe' });
194
200
  }
195
201
  catch {
196
- execFileSync('git', ['push', '-u', 'runwork', 'main'], { stdio: 'pipe' });
202
+ execFileSync('git', ['push', '-u', 'runwork', 'HEAD:main'], { stdio: 'pipe' });
197
203
  }
198
204
  console.log(dim(` Pushed ${stagedFiles.length} file(s) to git.`));
199
205
  activeCallbacks?.onGitPush?.(stagedFiles.length);
@@ -32,7 +32,7 @@ export async function removeGitCredentials(baseUrl) {
32
32
  execFileSync('git', [
33
33
  'config', '--global', '--unset',
34
34
  `credential.${origin}.helper`,
35
- ]);
35
+ ], { stdio: 'pipe' });
36
36
  }
37
37
  catch {
38
38
  // Ignore errors if the config key doesn't exist
@@ -0,0 +1,44 @@
1
+ import type { Credentials } from '../types.js';
2
+ /**
3
+ * Derive a human-friendly display name from an email address. Used when the
4
+ * credentials store only has the email (which is the common case). e.g.
5
+ * "oytun@motaword.com" -> "Oytun"; "anna.maria@example.com" -> "Anna Maria".
6
+ */
7
+ declare function deriveNameFromEmail(email: string): string;
8
+ declare function pickIdentity(creds?: Credentials | null): {
9
+ email: string;
10
+ name: string;
11
+ };
12
+ /**
13
+ * Ensure the repository at `cwd` has a usable git identity (user.email and
14
+ * user.name) and that, whenever possible, commits are attributed to the
15
+ * authenticated Runwork user.
16
+ *
17
+ * Precedence:
18
+ * 1. If we have usable Runwork credentials, always write local-scoped
19
+ * `user.email` / `user.name` from those credentials. Commits to a
20
+ * Runwork remote should be attributable to the logged-in Runwork user,
21
+ * not to whatever generic identity the developer uses for unrelated
22
+ * personal repos.
23
+ * 2. If credentials are missing or are an unusable placeholder
24
+ * (e.g. API-key login), fall back to any existing global / system
25
+ * configuration so the user's pre-existing identity still works.
26
+ * 3. If neither credentials nor any existing config are available, write a
27
+ * synthetic local identity so the inevitable `git commit` does not fail
28
+ * on a fresh Windows / minimal-container install with the classic
29
+ * `unable to auto-detect email address` error.
30
+ *
31
+ * Local scope (`git config --local`) is mandatory: we never touch the user's
32
+ * global config so we cannot disturb identities they use elsewhere.
33
+ *
34
+ * Safe to call multiple times; it short-circuits when the desired identity
35
+ * is already in place.
36
+ */
37
+ export declare function ensureGitIdentity(cwd: string, creds?: Credentials | null): void;
38
+ export declare const __testing: {
39
+ deriveNameFromEmail: typeof deriveNameFromEmail;
40
+ pickIdentity: typeof pickIdentity;
41
+ FALLBACK_EMAIL: string;
42
+ FALLBACK_NAME: string;
43
+ };
44
+ export {};
@@ -0,0 +1,133 @@
1
+ import { execFileSync } from 'child_process';
2
+ /**
3
+ * Default identity used when we cannot derive anything better from the
4
+ * authenticated credentials. Local-scoped, never written to global config,
5
+ * so it does not leak into other repositories on the user's machine.
6
+ */
7
+ const FALLBACK_EMAIL = 'runwork-cli@runwork.local';
8
+ const FALLBACK_NAME = 'Runwork User';
9
+ /**
10
+ * Derive a human-friendly display name from an email address. Used when the
11
+ * credentials store only has the email (which is the common case). e.g.
12
+ * "oytun@motaword.com" -> "Oytun"; "anna.maria@example.com" -> "Anna Maria".
13
+ */
14
+ function deriveNameFromEmail(email) {
15
+ const local = email.split('@')[0] ?? '';
16
+ if (!local)
17
+ return FALLBACK_NAME;
18
+ const cleaned = local.replace(/[._+-]+/g, ' ').trim();
19
+ if (!cleaned)
20
+ return FALLBACK_NAME;
21
+ return cleaned
22
+ .split(/\s+/)
23
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
24
+ .join(' ');
25
+ }
26
+ function isUsableEmail(value) {
27
+ if (!value)
28
+ return false;
29
+ // Reject the "api-key-auth" placeholder used by API-key login flows.
30
+ if (!value.includes('@'))
31
+ return false;
32
+ return true;
33
+ }
34
+ function pickIdentity(creds) {
35
+ const email = isUsableEmail(creds?.email) ? creds.email : FALLBACK_EMAIL;
36
+ const name = email === FALLBACK_EMAIL ? FALLBACK_NAME : deriveNameFromEmail(email);
37
+ return { email, name };
38
+ }
39
+ function readGitConfig(cwd, key) {
40
+ try {
41
+ const value = execFileSync('git', ['config', '--get', key], { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
42
+ return value.toString('utf-8').trim();
43
+ }
44
+ catch {
45
+ return '';
46
+ }
47
+ }
48
+ function readLocalGitConfig(cwd, key) {
49
+ try {
50
+ const value = execFileSync('git', ['config', '--local', '--get', key], { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
51
+ return value.toString('utf-8').trim();
52
+ }
53
+ catch {
54
+ return '';
55
+ }
56
+ }
57
+ /**
58
+ * Ensure the repository at `cwd` has a usable git identity (user.email and
59
+ * user.name) and that, whenever possible, commits are attributed to the
60
+ * authenticated Runwork user.
61
+ *
62
+ * Precedence:
63
+ * 1. If we have usable Runwork credentials, always write local-scoped
64
+ * `user.email` / `user.name` from those credentials. Commits to a
65
+ * Runwork remote should be attributable to the logged-in Runwork user,
66
+ * not to whatever generic identity the developer uses for unrelated
67
+ * personal repos.
68
+ * 2. If credentials are missing or are an unusable placeholder
69
+ * (e.g. API-key login), fall back to any existing global / system
70
+ * configuration so the user's pre-existing identity still works.
71
+ * 3. If neither credentials nor any existing config are available, write a
72
+ * synthetic local identity so the inevitable `git commit` does not fail
73
+ * on a fresh Windows / minimal-container install with the classic
74
+ * `unable to auto-detect email address` error.
75
+ *
76
+ * Local scope (`git config --local`) is mandatory: we never touch the user's
77
+ * global config so we cannot disturb identities they use elsewhere.
78
+ *
79
+ * Safe to call multiple times; it short-circuits when the desired identity
80
+ * is already in place.
81
+ */
82
+ export function ensureGitIdentity(cwd, creds) {
83
+ // Only act on actual git repos; refuse silently if `cwd` has no .git.
84
+ try {
85
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
86
+ }
87
+ catch {
88
+ return;
89
+ }
90
+ const credsUsable = isUsableEmail(creds?.email);
91
+ // Path 1: real Runwork credentials -- always overwrite local config so the
92
+ // commit is attributed to the Runwork user, regardless of global state.
93
+ if (credsUsable) {
94
+ const { email, name } = pickIdentity(creds);
95
+ const existingLocalEmail = readLocalGitConfig(cwd, 'user.email');
96
+ const existingLocalName = readLocalGitConfig(cwd, 'user.name');
97
+ if (existingLocalEmail !== email) {
98
+ try {
99
+ execFileSync('git', ['config', '--local', 'user.email', email], { cwd, stdio: 'pipe' });
100
+ }
101
+ catch { /* read-only fs */ }
102
+ }
103
+ if (existingLocalName !== name) {
104
+ try {
105
+ execFileSync('git', ['config', '--local', 'user.name', name], { cwd, stdio: 'pipe' });
106
+ }
107
+ catch { /* read-only fs */ }
108
+ }
109
+ return;
110
+ }
111
+ // Path 2: no usable creds. Respect any pre-existing identity (global/local).
112
+ const existingEmail = readGitConfig(cwd, 'user.email');
113
+ const existingName = readGitConfig(cwd, 'user.name');
114
+ if (existingEmail && existingName)
115
+ return;
116
+ // Path 3: no creds AND no existing identity -- seed a synthetic fallback
117
+ // so commits do not fail on a fresh machine.
118
+ const { email, name } = pickIdentity(creds);
119
+ if (!existingEmail) {
120
+ try {
121
+ execFileSync('git', ['config', '--local', 'user.email', email], { cwd, stdio: 'pipe' });
122
+ }
123
+ catch { /* read-only fs */ }
124
+ }
125
+ if (!existingName) {
126
+ try {
127
+ execFileSync('git', ['config', '--local', 'user.name', name], { cwd, stdio: 'pipe' });
128
+ }
129
+ catch { /* read-only fs */ }
130
+ }
131
+ }
132
+ // Exported for unit tests so we do not have to re-derive expectations there.
133
+ export const __testing = { deriveNameFromEmail, pickIdentity, FALLBACK_EMAIL, FALLBACK_NAME };
@@ -0,0 +1,28 @@
1
+ export interface GitProbe {
2
+ installed: boolean;
3
+ /** Trimmed `git --version` output when detected; undefined otherwise. */
4
+ version?: string;
5
+ /** Underlying error for diagnostics (most often ENOENT on a missing git binary). */
6
+ error?: NodeJS.ErrnoException;
7
+ }
8
+ /**
9
+ * Probe whether `git` is callable from this process. We use `execFileSync`
10
+ * (not `which`/`where.exe`) so the check follows the exact PATH lookup any
11
+ * subsequent git invocation will use -- that way we never report "found"
12
+ * when the real call would fail with ENOENT, and vice versa.
13
+ */
14
+ export declare function probeGit(): GitProbe;
15
+ /**
16
+ * Build a beginner-friendly message explaining how to recover from a missing
17
+ * git binary. Includes a Windows-specific hint because the most common
18
+ * scenario there is "winget install Git.Git just succeeded but this shell's
19
+ * PATH was cached at launch" -- restarting the shell fixes it without a
20
+ * second install attempt.
21
+ */
22
+ export declare function buildMissingGitMessage(commandName: string): string;
23
+ /**
24
+ * Convenience wrapper for command entry points: probe git, and if it's
25
+ * missing, print the beginner-friendly message and exit with code 1.
26
+ * Returns void on success so callers can early-return on failure.
27
+ */
28
+ export declare function requireGit(commandName: string): void;
@@ -0,0 +1,50 @@
1
+ import { execFileSync } from 'child_process';
2
+ /**
3
+ * Probe whether `git` is callable from this process. We use `execFileSync`
4
+ * (not `which`/`where.exe`) so the check follows the exact PATH lookup any
5
+ * subsequent git invocation will use -- that way we never report "found"
6
+ * when the real call would fail with ENOENT, and vice versa.
7
+ */
8
+ export function probeGit() {
9
+ try {
10
+ const out = execFileSync('git', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
11
+ return { installed: true, version: out.toString('utf-8').trim() };
12
+ }
13
+ catch (err) {
14
+ return { installed: false, error: err };
15
+ }
16
+ }
17
+ /**
18
+ * Build a beginner-friendly message explaining how to recover from a missing
19
+ * git binary. Includes a Windows-specific hint because the most common
20
+ * scenario there is "winget install Git.Git just succeeded but this shell's
21
+ * PATH was cached at launch" -- restarting the shell fixes it without a
22
+ * second install attempt.
23
+ */
24
+ export function buildMissingGitMessage(commandName) {
25
+ const lines = [
26
+ `Git is required to run \`runwork ${commandName}\`, but it was not found on PATH.`,
27
+ '',
28
+ 'Install Git, then re-run this command:',
29
+ ' - macOS: brew install git (or: xcode-select --install)',
30
+ ' - Windows: winget install --id Git.Git -e',
31
+ ' - Linux: sudo apt-get install -y git (or your distro\'s equivalent)',
32
+ ];
33
+ if (process.platform === 'win32') {
34
+ lines.push('', 'If you just installed Git, this shell\'s PATH was cached when it opened', 'and does not yet include Git. Close this PowerShell / Command Prompt', 'window, open a fresh one, and re-run the command.');
35
+ }
36
+ lines.push('', 'Verify the install with: git --version');
37
+ return lines.join('\n');
38
+ }
39
+ /**
40
+ * Convenience wrapper for command entry points: probe git, and if it's
41
+ * missing, print the beginner-friendly message and exit with code 1.
42
+ * Returns void on success so callers can early-return on failure.
43
+ */
44
+ export function requireGit(commandName) {
45
+ const probe = probeGit();
46
+ if (probe.installed)
47
+ return;
48
+ console.error(buildMissingGitMessage(commandName));
49
+ process.exit(1);
50
+ }
package/dist/git/sync.js CHANGED
@@ -147,7 +147,9 @@ export function syncWithRemote(cwd) {
147
147
  }
148
148
  let pushed = false;
149
149
  try {
150
- execFileSync('git', ['push', 'runwork', 'main'], { cwd, stdio: 'pipe' });
150
+ // `HEAD:main` so the push works regardless of local branch name
151
+ // (older Git defaults to `master`, some users keep custom defaults).
152
+ execFileSync('git', ['push', 'runwork', 'HEAD:main'], { cwd, stdio: 'pipe' });
151
153
  pushed = true;
152
154
  }
153
155
  catch {
@@ -1,12 +1,29 @@
1
1
  /**
2
2
  * Tests for the first-party distribution checks added to runwork doctor.
3
3
  *
4
- * These checks rely on fetch() to hit https://runwork.ai/cli/latest.json.
5
- * We stub globalThis.fetch to redirect those calls to a local HTTP server
6
- * so the suite has no outbound network dependency.
4
+ * These checks rely on `httpFetch` (our node:https-backed fetch wrapper) to
5
+ * hit https://runwork.ai/cli/latest.json. We mock the wrapper to redirect
6
+ * those calls to a local HTTP server so the suite has no outbound network
7
+ * dependency. We still exercise the real wrapper end to end -- only the
8
+ * URL is rewritten -- so this remains an integration test of the network
9
+ * code path.
7
10
  */
8
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
11
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
12
  import { createServer } from 'node:http';
13
+ let currentRedirectBase = null;
14
+ vi.mock('../../utils/http.js', async () => {
15
+ const actual = await vi.importActual('../../utils/http.js');
16
+ return {
17
+ ...actual,
18
+ httpFetch: (url, init) => {
19
+ if (currentRedirectBase && url.startsWith('https://runwork.ai')) {
20
+ const redirected = url.replace('https://runwork.ai', currentRedirectBase);
21
+ return actual.httpFetch(redirected, init);
22
+ }
23
+ return actual.httpFetch(url, init);
24
+ },
25
+ };
26
+ });
10
27
  import { checkCliVersion, checkCliArtifactReachable, checkCliInstallLocation, } from '../checks.js';
11
28
  function startServer() {
12
29
  return new Promise((resolvePromise) => {
@@ -56,33 +73,14 @@ function startServer() {
56
73
  });
57
74
  });
58
75
  }
59
- /**
60
- * Redirect fetch calls to https://runwork.ai/... at the real test server.
61
- * Returns a restore function.
62
- */
63
- function redirectFetch(baseUrl) {
64
- const realFetch = globalThis.fetch;
65
- globalThis.fetch = ((input, init) => {
66
- const href = typeof input === 'string' ? input : input.toString();
67
- if (href.startsWith('https://runwork.ai')) {
68
- const redirected = href.replace('https://runwork.ai', baseUrl);
69
- return realFetch(redirected, init);
70
- }
71
- return realFetch(input, init);
72
- });
73
- return () => {
74
- globalThis.fetch = realFetch;
75
- };
76
- }
77
76
  describe('checkCliVersion (runwork.ai backed)', () => {
78
77
  let server;
79
- let restoreFetch;
80
78
  beforeEach(async () => {
81
79
  server = await startServer();
82
- restoreFetch = redirectFetch(server.url);
80
+ currentRedirectBase = server.url;
83
81
  });
84
82
  afterEach(async () => {
85
- restoreFetch();
83
+ currentRedirectBase = null;
86
84
  await server.stop();
87
85
  });
88
86
  it('skips with an explicit unreachable message when the manifest 404s', async () => {
@@ -117,13 +115,12 @@ describe('checkCliVersion (runwork.ai backed)', () => {
117
115
  });
118
116
  describe('checkCliArtifactReachable', () => {
119
117
  let server;
120
- let restoreFetch;
121
118
  beforeEach(async () => {
122
119
  server = await startServer();
123
- restoreFetch = redirectFetch(server.url);
120
+ currentRedirectBase = server.url;
124
121
  });
125
122
  afterEach(async () => {
126
- restoreFetch();
123
+ currentRedirectBase = null;
127
124
  await server.stop();
128
125
  });
129
126
  function platformKey() {
@@ -6,6 +6,7 @@ import { VERSION } from '../generated/version.js';
6
6
  import { getCredentials } from '../auth/store.js';
7
7
  import { ApiClient } from '../api/client.js';
8
8
  import { detectAgents, getAdapterBySlug } from '../agents/detect.js';
9
+ import { httpFetch } from '../utils/http.js';
9
10
  // First-party distribution endpoints. Defaults match the plan, can be
10
11
  // overridden via RUNWORK_DOWNLOAD_BASE_URL for staging or debugging.
11
12
  const BASE_URL = process.env.RUNWORK_DOWNLOAD_BASE_URL || 'https://runwork.ai';
@@ -23,7 +24,7 @@ function detectPlatform() {
23
24
  }
24
25
  async function fetchReleaseManifest() {
25
26
  try {
26
- const response = await fetch(LATEST_JSON_URL, { cache: 'no-store' });
27
+ const response = await httpFetch(LATEST_JSON_URL, { cache: 'no-store' });
27
28
  if (!response.ok)
28
29
  return null;
29
30
  return await response.json();
@@ -123,7 +124,7 @@ export async function checkCliArtifactReachable() {
123
124
  }
124
125
  const artifactUrl = `${BASE_URL}${entry.path}`;
125
126
  try {
126
- const response = await fetch(artifactUrl, { method: 'HEAD' });
127
+ const response = await httpFetch(artifactUrl, { method: 'HEAD' });
127
128
  if (!response.ok) {
128
129
  return {
129
130
  name: 'cli-artifact',
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Command } from 'commander';
2
+ import { formatError } from './utils/format-error.js';
2
3
  import { loginCommand } from './commands/login.js';
3
4
  import { initCommand } from './commands/init.js';
4
5
  import { cloneCommand } from './commands/clone.js';
@@ -30,6 +31,20 @@ import { handleGitCredentialRequest } from './git/credentials.js';
30
31
  import { VERSION } from './generated/version.js';
31
32
  import { shouldOutputJson, jsonOut } from './utils/output.js';
32
33
  import { buildHelpJson, buildCommandHelpJson } from './utils/help-json.js';
34
+ // Surface escaped errors and rejections instead of letting the process exit
35
+ // silently. Without these handlers, an unhandled rejection inside a Commander
36
+ // async action vanishes on the bun-compiled Windows binary -- the user sees
37
+ // only the last `console.log` we managed to flush before the runtime tore
38
+ // the program down. Both handlers are deliberately strict (exit 1) so the
39
+ // failure is visible in CI / wrappers like the desktop app.
40
+ process.on('unhandledRejection', (reason) => {
41
+ console.error(`Unhandled rejection: ${formatError(reason)}`);
42
+ process.exit(1);
43
+ });
44
+ process.on('uncaughtException', (err) => {
45
+ console.error(`Uncaught exception: ${formatError(err)}`);
46
+ process.exit(1);
47
+ });
33
48
  const program = new Command();
34
49
  program
35
50
  .name('runwork')
@@ -115,4 +130,20 @@ if (!hasCommand && !isHelpOrVersion && args.length === 0) {
115
130
  process.exit(0);
116
131
  }
117
132
  }
118
- program.parse();
133
+ // IMPORTANT: use parseAsync, not parse. Many of our actions are async and
134
+ // the bun-compiled Windows binary will exit before pending promises resolve
135
+ // when we don't await the returned promise -- that manifests as a silent
136
+ // abort partway through `runwork clone`, `dev`, etc. with no error output.
137
+ //
138
+ // Call with NO arguments so Commander reads `process.argv` itself with its
139
+ // `'auto'` detection. Passing `process.argv` explicitly defaults to the
140
+ // `'node'` parse mode and strips the wrong elements when this is run as a
141
+ // bun-compiled standalone binary (where there is no separate "script" argv
142
+ // slot), making every command silently fail to match.
143
+ try {
144
+ await program.parseAsync();
145
+ }
146
+ catch (err) {
147
+ console.error(formatError(err));
148
+ process.exit(1);
149
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatError } from '../format-error.js';
3
+ describe('formatError', () => {
4
+ it('handles plain Error objects', () => {
5
+ expect(formatError(new Error('boom'))).toBe('boom');
6
+ });
7
+ it('returns "unknown error" for null/undefined', () => {
8
+ expect(formatError(null)).toBe('unknown error');
9
+ expect(formatError(undefined)).toBe('unknown error');
10
+ });
11
+ it('extracts stderr buffer from execFileSync-style errors', () => {
12
+ const err = Object.assign(new Error('Command failed: git fetch'), {
13
+ stderr: Buffer.from('fatal: could not read Username\n'),
14
+ });
15
+ expect(formatError(err)).toContain('fatal: could not read Username');
16
+ });
17
+ it('extracts stderr string when not a buffer', () => {
18
+ const err = Object.assign(new Error('Command failed'), {
19
+ stderr: ' fatal: bad ref ',
20
+ });
21
+ expect(formatError(err)).toContain('fatal: bad ref');
22
+ });
23
+ it('falls back to stdout when stderr is empty', () => {
24
+ const err = Object.assign(new Error('Command failed'), {
25
+ stderr: '',
26
+ stdout: Buffer.from('CONFLICT (content): merge conflict in foo'),
27
+ });
28
+ expect(formatError(err)).toContain('CONFLICT');
29
+ });
30
+ it('prefixes with errno code when available', () => {
31
+ const err = Object.assign(new Error('spawn git ENOENT'), {
32
+ code: 'ENOENT',
33
+ stderr: 'spawn git ENOENT',
34
+ });
35
+ const out = formatError(err);
36
+ expect(out).toMatch(/^ENOENT:/);
37
+ expect(out).toContain('spawn git ENOENT');
38
+ });
39
+ it('coerces non-Error values to strings', () => {
40
+ expect(formatError('a plain string')).toBe('a plain string');
41
+ expect(formatError(42)).toBe('42');
42
+ });
43
+ });
@@ -0,0 +1 @@
1
+ export {};