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.
- package/dist/agents/__tests__/intro-skill.test.js +6 -2
- package/dist/agents/codex.js +9 -3
- package/dist/api/__tests__/client.test.js +10 -2
- package/dist/api/client.js +5 -4
- package/dist/auth/__tests__/login-flow.test.js +57 -81
- package/dist/auth/__tests__/store.test.js +35 -6
- package/dist/commands/__tests__/info-merge.test.d.ts +1 -0
- package/dist/commands/__tests__/info-merge.test.js +55 -0
- package/dist/commands/__tests__/upgrade.test.js +25 -42
- package/dist/commands/clone.d.ts +2 -2
- package/dist/commands/clone.js +39 -7
- package/dist/commands/dev.js +9 -1
- package/dist/commands/endpoints.js +2 -1
- package/dist/commands/files.js +3 -2
- package/dist/commands/info.d.ts +141 -0
- package/dist/commands/info.js +29 -7
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +34 -5
- package/dist/commands/upgrade.js +4 -3
- package/dist/commands/welcome.js +2 -2
- package/dist/devtools/registry-data.d.ts +7 -0
- package/dist/devtools/registry-data.js +1 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/credentials.test.js +4 -4
- package/dist/git/__tests__/identity.test.d.ts +1 -0
- package/dist/git/__tests__/identity.test.js +146 -0
- package/dist/git/__tests__/preflight.test.d.ts +1 -0
- package/dist/git/__tests__/preflight.test.js +36 -0
- package/dist/git/auto-commit.js +8 -2
- package/dist/git/credentials.js +1 -1
- package/dist/git/identity.d.ts +44 -0
- package/dist/git/identity.js +133 -0
- package/dist/git/preflight.d.ts +28 -0
- package/dist/git/preflight.js +50 -0
- package/dist/git/sync.js +3 -1
- package/dist/health/__tests__/cli-distribution-checks.test.js +25 -28
- package/dist/health/checks.js +3 -2
- package/dist/index.js +32 -1
- package/dist/utils/__tests__/format-error.test.d.ts +1 -0
- package/dist/utils/__tests__/format-error.test.js +43 -0
- package/dist/utils/__tests__/http.test.d.ts +1 -0
- package/dist/utils/__tests__/http.test.js +381 -0
- package/dist/utils/agent-guidance.js +10 -3
- package/dist/utils/format-error.d.ts +10 -0
- package/dist/utils/format-error.js +38 -0
- package/dist/utils/http.d.ts +46 -0
- package/dist/utils/http.js +421 -0
- 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
|
+
});
|
package/dist/git/auto-commit.js
CHANGED
|
@@ -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);
|
package/dist/git/credentials.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
* so the suite has no outbound network
|
|
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
|
-
|
|
80
|
+
currentRedirectBase = server.url;
|
|
83
81
|
});
|
|
84
82
|
afterEach(async () => {
|
|
85
|
-
|
|
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
|
-
|
|
120
|
+
currentRedirectBase = server.url;
|
|
124
121
|
});
|
|
125
122
|
afterEach(async () => {
|
|
126
|
-
|
|
123
|
+
currentRedirectBase = null;
|
|
127
124
|
await server.stop();
|
|
128
125
|
});
|
|
129
126
|
function platformKey() {
|
package/dist/health/checks.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 {};
|