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
@@ -145,9 +145,13 @@ describe('generateInstructionHint', () => {
145
145
  expect(hint).toContain('runwork');
146
146
  expect(hint).toContain('skill');
147
147
  });
148
- it('includes routing directive', () => {
148
+ it('includes the default-to-Runwork routing directive', () => {
149
+ // The earlier hint format used a "Routing:" header; the current format
150
+ // expresses the same idea as a "Default to Runwork" rule plus an explicit
151
+ // routing table. Either of those two anchors is sufficient guidance.
149
152
  const hint = generateInstructionHint(baseCtx);
150
- expect(hint).toContain('Routing:');
153
+ expect(hint).toContain('Default to Runwork');
154
+ expect(hint).toContain('| User asks for | Use Runwork | Don\'t |');
151
155
  });
152
156
  it('includes inventory line', () => {
153
157
  const hint = generateInstructionHint({ ...baseCtx, appCount: 2, skillCount: 3 });
@@ -336,12 +336,18 @@ export class CodexAdapter {
336
336
  const instructions = payload.instructions;
337
337
  if (typeof instructions !== 'string')
338
338
  continue;
339
- // Parse "- name: description (file: path/SKILL.md)" entries
340
- // from the "### Available skills" section
341
- const skillRegex = /^- ([^:]+):\s+.+\(file:\s+.+\/SKILL\.md\)/gm;
339
+ // Parse "- name: description (file: path/SKILL.md)" entries from the
340
+ // "### Available skills" section. Skip Codex's built-in system skills
341
+ // (those installed under `.system/`, e.g. skill-creator,
342
+ // skill-installer) since they are bundled with the agent and would
343
+ // skew adoption metrics meant to track *user-installed* skills.
344
+ const skillRegex = /^- ([^:]+):\s+.+\(file:\s+(.+\/SKILL\.md)\)/gm;
342
345
  let match;
343
346
  while ((match = skillRegex.exec(instructions)) !== null) {
344
347
  const skillName = match[1].trim();
348
+ const skillPath = match[2];
349
+ if (skillPath.includes('/.system/'))
350
+ continue;
345
351
  const existing = skillCounts.get(skillName);
346
352
  if (existing) {
347
353
  existing.count++;
@@ -1,11 +1,19 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { ApiClient } from '../client.js';
2
+ // HTTP goes through our `httpFetch` wrapper (single seam for diagnostics
3
+ // and the optional `curl` transport). Tests mock that wrapper directly so
4
+ // they exercise the same code path the production CLI uses.
3
5
  const mockFetch = vi.fn();
4
- globalThis.fetch = mockFetch;
6
+ vi.mock('../../utils/http.js', () => ({
7
+ httpFetch: (...args) => mockFetch(...args),
8
+ }));
9
+ const { ApiClient } = await import('../client.js');
5
10
  function createJsonResponse(data, status = 200) {
6
11
  return {
7
12
  ok: status >= 200 && status < 300,
8
13
  status,
14
+ statusText: '',
15
+ url: '',
16
+ headers: new Headers(),
9
17
  json: () => Promise.resolve(data),
10
18
  text: () => Promise.resolve(JSON.stringify(data)),
11
19
  arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
@@ -1,3 +1,4 @@
1
+ import { httpFetch } from '../utils/http.js';
1
2
  const DEFAULT_BASE_URL = 'https://runwork.ai';
2
3
  export class ApiClient {
3
4
  baseUrl;
@@ -10,12 +11,12 @@ export class ApiClient {
10
11
  const url = `${this.baseUrl}${path}`;
11
12
  const headers = {
12
13
  'Content-Type': 'application/json',
13
- ...Object.fromEntries(Object.entries(options.headers || {})),
14
+ ...Object.fromEntries(Object.entries(options.headers || {}).filter(([, v]) => v !== undefined)),
14
15
  };
15
16
  if (this.apiKey) {
16
17
  headers['Authorization'] = `Bearer ${this.apiKey}`;
17
18
  }
18
- const response = await fetch(url, { ...options, headers });
19
+ const response = await httpFetch(url, { ...options, headers });
19
20
  if (!response.ok) {
20
21
  const body = await response.text();
21
22
  throw new Error(`API error ${response.status}: ${body}`);
@@ -121,7 +122,7 @@ export class ApiClient {
121
122
  if (this.apiKey) {
122
123
  headers['Authorization'] = `Bearer ${this.apiKey}`;
123
124
  }
124
- const response = await fetch(url, { headers });
125
+ const response = await httpFetch(url, { headers });
125
126
  if (!response.ok) {
126
127
  throw new Error(`Failed to download skeleton: ${response.status}`);
127
128
  }
@@ -368,7 +369,7 @@ export class ApiClient {
368
369
  if (opts?.body) {
369
370
  headers['Content-Type'] = 'application/json';
370
371
  }
371
- const response = await fetch(url, {
372
+ const response = await httpFetch(url, {
372
373
  method,
373
374
  headers,
374
375
  body: opts?.body ? JSON.stringify(opts.body) : undefined,
@@ -6,7 +6,26 @@ vi.mock('../store.js', () => ({
6
6
  vi.mock('../../git/credentials.js', () => ({
7
7
  configureGitCredentials: vi.fn().mockResolvedValue(undefined),
8
8
  }));
9
+ // HTTP goes through our `httpFetch` wrapper (single seam for diagnostics
10
+ // and the optional `curl` transport). Tests mock that wrapper directly so
11
+ // they exercise the same code path the production CLI uses.
12
+ const mockFetch = vi.fn();
13
+ vi.mock('../../utils/http.js', () => ({
14
+ httpFetch: (...args) => mockFetch(...args),
15
+ }));
9
16
  import { saveCredentials } from '../store.js';
17
+ function jsonResponse(data, status = 200) {
18
+ return {
19
+ ok: status >= 200 && status < 300,
20
+ status,
21
+ statusText: '',
22
+ url: '',
23
+ headers: new Headers(),
24
+ json: () => Promise.resolve(data),
25
+ text: () => Promise.resolve(JSON.stringify(data)),
26
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
27
+ };
28
+ }
10
29
  beforeEach(() => {
11
30
  vi.clearAllMocks();
12
31
  vi.spyOn(console, 'log').mockImplementation(() => { });
@@ -14,100 +33,58 @@ beforeEach(() => {
14
33
  });
15
34
  describe('performLoginNoOpen', () => {
16
35
  it('with printOnly prints URL and returns null without polling', async () => {
17
- // Mock ApiClient at the instance level via fetch
18
- const originalFetch = globalThis.fetch;
19
- const mockFetch = vi.fn().mockResolvedValue({
20
- ok: true,
21
- status: 200,
22
- json: () => Promise.resolve({
23
- data: { sessionId: 'sess-123', loginUrl: 'https://runwork.ai/cli-auth?session=sess-123' },
24
- }),
25
- });
26
- globalThis.fetch = mockFetch;
27
- try {
28
- const { performLoginNoOpen } = await import('../login-flow.js');
29
- const result = await performLoginNoOpen('https://runwork.ai', true);
30
- expect(console.log).toHaveBeenCalledWith('https://runwork.ai/cli-auth?session=sess-123');
31
- expect(result).toBeNull();
32
- expect(saveCredentials).not.toHaveBeenCalled();
33
- }
34
- finally {
35
- globalThis.fetch = originalFetch;
36
- }
36
+ mockFetch.mockResolvedValue(jsonResponse({
37
+ data: { sessionId: 'sess-123', loginUrl: 'https://runwork.ai/cli-auth?session=sess-123' },
38
+ }));
39
+ const { performLoginNoOpen } = await import('../login-flow.js');
40
+ const result = await performLoginNoOpen('https://runwork.ai', true);
41
+ expect(console.log).toHaveBeenCalledWith('https://runwork.ai/cli-auth?session=sess-123');
42
+ expect(result).toBeNull();
43
+ expect(saveCredentials).not.toHaveBeenCalled();
37
44
  });
38
45
  });
39
46
  describe('performLoginWithApiKey', () => {
40
47
  it('validates key by listing workspaces and saves credentials', async () => {
41
- const originalFetch = globalThis.fetch;
42
- const mockFetch = vi.fn().mockResolvedValue({
43
- ok: true,
44
- status: 200,
45
- json: () => Promise.resolve({
46
- data: [{ id: 'ws-1', name: 'My Workspace' }],
47
- }),
48
- });
49
- globalThis.fetch = mockFetch;
50
- try {
51
- const { performLoginWithApiKey } = await import('../login-flow.js');
52
- const result = await performLoginWithApiKey('rw_test123');
53
- expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({
54
- apiKey: 'rw_test123',
55
- defaultWorkspaceId: 'ws-1',
56
- defaultWorkspaceName: 'My Workspace',
57
- }));
58
- expect(result.apiKey).toBe('rw_test123');
59
- }
60
- finally {
61
- globalThis.fetch = originalFetch;
62
- }
48
+ mockFetch.mockResolvedValue(jsonResponse({
49
+ data: [{ id: 'ws-1', name: 'My Workspace' }],
50
+ }));
51
+ const { performLoginWithApiKey } = await import('../login-flow.js');
52
+ const result = await performLoginWithApiKey('rw_test123');
53
+ expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({
54
+ apiKey: 'rw_test123',
55
+ defaultWorkspaceId: 'ws-1',
56
+ defaultWorkspaceName: 'My Workspace',
57
+ }));
58
+ expect(result.apiKey).toBe('rw_test123');
63
59
  });
64
60
  it('saves credentials with custom base URL', async () => {
65
- const originalFetch = globalThis.fetch;
66
- const mockFetch = vi.fn().mockResolvedValue({
67
- ok: true,
68
- status: 200,
69
- json: () => Promise.resolve({ data: [{ id: 'ws-1', name: 'WS' }] }),
70
- });
71
- globalThis.fetch = mockFetch;
72
- try {
73
- const { performLoginWithApiKey } = await import('../login-flow.js');
74
- const result = await performLoginWithApiKey('rw_key', 'https://custom.runwork.dev');
75
- expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('https://custom.runwork.dev'), expect.anything());
76
- expect(result.baseUrl).toBe('https://custom.runwork.dev');
77
- }
78
- finally {
79
- globalThis.fetch = originalFetch;
80
- }
61
+ mockFetch.mockResolvedValue(jsonResponse({ data: [{ id: 'ws-1', name: 'WS' }] }));
62
+ const { performLoginWithApiKey } = await import('../login-flow.js');
63
+ const result = await performLoginWithApiKey('rw_key', 'https://custom.runwork.dev');
64
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('https://custom.runwork.dev'), expect.anything());
65
+ expect(result.baseUrl).toBe('https://custom.runwork.dev');
81
66
  });
82
67
  it('handles no workspaces (sets no default)', async () => {
83
- const originalFetch = globalThis.fetch;
84
- const mockFetch = vi.fn().mockResolvedValue({
85
- ok: true,
86
- status: 200,
87
- json: () => Promise.resolve({ data: [] }),
88
- });
89
- globalThis.fetch = mockFetch;
90
- try {
91
- const { performLoginWithApiKey } = await import('../login-flow.js');
92
- const result = await performLoginWithApiKey('rw_key');
93
- expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({
94
- apiKey: 'rw_key',
95
- defaultWorkspaceId: undefined,
96
- defaultWorkspaceName: undefined,
97
- }));
98
- expect(result.apiKey).toBe('rw_key');
99
- }
100
- finally {
101
- globalThis.fetch = originalFetch;
102
- }
68
+ mockFetch.mockResolvedValue(jsonResponse({ data: [] }));
69
+ const { performLoginWithApiKey } = await import('../login-flow.js');
70
+ const result = await performLoginWithApiKey('rw_key');
71
+ expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({
72
+ apiKey: 'rw_key',
73
+ defaultWorkspaceId: undefined,
74
+ defaultWorkspaceName: undefined,
75
+ }));
76
+ expect(result.apiKey).toBe('rw_key');
103
77
  });
104
78
  it('exits on invalid API key', async () => {
105
- const originalFetch = globalThis.fetch;
106
- globalThis.fetch = vi.fn().mockResolvedValue({
79
+ mockFetch.mockResolvedValue({
107
80
  ok: false,
108
81
  status: 401,
82
+ statusText: 'Unauthorized',
83
+ url: '',
84
+ headers: new Headers(),
109
85
  json: () => Promise.resolve({ error: 'Unauthorized' }),
110
86
  text: () => Promise.resolve('Unauthorized'),
87
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
111
88
  });
112
89
  const mockExit = vi.spyOn(process, 'exit').mockImplementation(((code) => {
113
90
  throw new Error(`process.exit(${code})`);
@@ -118,7 +95,6 @@ describe('performLoginWithApiKey', () => {
118
95
  expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Invalid API key'));
119
96
  }
120
97
  finally {
121
- globalThis.fetch = originalFetch;
122
98
  mockExit.mockRestore();
123
99
  }
124
100
  });
@@ -87,17 +87,46 @@ describe('auth/store', () => {
87
87
  const result = requireAuth();
88
88
  expect(result).toEqual(validCredentials);
89
89
  });
90
- it('exits process with code 1 when not logged in', () => {
90
+ it('exits process with code 1 with TTY guidance when invoked from a terminal', () => {
91
91
  vi.mocked(mockFs.existsSync).mockReturnValue(false);
92
92
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
93
93
  throw new Error('process.exit called');
94
94
  }));
95
95
  const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
96
- expect(() => requireAuth()).toThrow('process.exit called');
97
- expect(exitSpy).toHaveBeenCalledWith(1);
98
- expect(errorSpy).toHaveBeenCalledWith('Not logged in. Run `runwork login` first.');
99
- exitSpy.mockRestore();
100
- errorSpy.mockRestore();
96
+ const previousTTY = process.stdin.isTTY;
97
+ Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
98
+ try {
99
+ expect(() => requireAuth()).toThrow('process.exit called');
100
+ expect(exitSpy).toHaveBeenCalledWith(1);
101
+ expect(errorSpy).toHaveBeenCalledWith('Not logged in. Run `runwork login` first.');
102
+ }
103
+ finally {
104
+ Object.defineProperty(process.stdin, 'isTTY', { value: previousTTY, configurable: true });
105
+ exitSpy.mockRestore();
106
+ errorSpy.mockRestore();
107
+ }
108
+ });
109
+ it('exits process with code 1 with headless guidance when invoked without a TTY', () => {
110
+ vi.mocked(mockFs.existsSync).mockReturnValue(false);
111
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
112
+ throw new Error('process.exit called');
113
+ }));
114
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
115
+ const previousTTY = process.stdin.isTTY;
116
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
117
+ try {
118
+ expect(() => requireAuth()).toThrow('process.exit called');
119
+ expect(exitSpy).toHaveBeenCalledWith(1);
120
+ const message = errorSpy.mock.calls[0]?.[0];
121
+ expect(message).toContain('Not logged in.');
122
+ expect(message).toContain('RUNWORK_API_KEY');
123
+ expect(message).toContain('runwork login --api-key');
124
+ }
125
+ finally {
126
+ Object.defineProperty(process.stdin, 'isTTY', { value: previousTTY, configurable: true });
127
+ exitSpy.mockRestore();
128
+ errorSpy.mockRestore();
129
+ }
101
130
  });
102
131
  });
103
132
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { __testing } from '../info.js';
3
+ const { mergeSources, padName } = __testing;
4
+ describe('mergeSources — bad data resilience', () => {
5
+ it('drops blueprint entries with missing name', () => {
6
+ const map = mergeSources([
7
+ { name: 'good', extra: {} },
8
+ { name: undefined, extra: {} },
9
+ { name: '', extra: {} },
10
+ ], [], (item) => item.name ?? '', 'app-1');
11
+ expect(Array.from(map.keys())).toEqual(['good']);
12
+ });
13
+ it('drops server items whose name resolver returns undefined or empty', () => {
14
+ const map = mergeSources([], [
15
+ { name: 'good', appId: 'app-1', appName: 'My App', deploymentMode: 'preview' },
16
+ { name: undefined, appId: 'app-1', appName: 'My App', deploymentMode: 'preview' },
17
+ { name: '', appId: 'app-1', appName: 'My App', deploymentMode: 'preview' },
18
+ ], (item) => item.name ?? '', 'app-1');
19
+ expect(Array.from(map.keys())).toEqual(['good']);
20
+ });
21
+ it('skips server items belonging to other apps', () => {
22
+ const map = mergeSources([], [
23
+ { name: 'mine', appId: 'app-1', appName: 'A', deploymentMode: 'preview' },
24
+ { name: 'theirs', appId: 'app-2', appName: 'B', deploymentMode: 'preview' },
25
+ ], (item) => item.name ?? '', 'app-1');
26
+ expect(Array.from(map.keys())).toEqual(['mine']);
27
+ });
28
+ it('merges blueprint and server entries by name', () => {
29
+ const map = mergeSources([{ name: 'agent-x', extra: { type: 'task' } }], [
30
+ { name: 'agent-x', appId: 'app-1', appName: 'A', deploymentMode: 'production' },
31
+ { name: 'agent-y', appId: 'app-1', appName: 'A', deploymentMode: 'preview' },
32
+ ], (item) => item.name ?? '', 'app-1');
33
+ const x = map.get('agent-x');
34
+ const y = map.get('agent-y');
35
+ expect(x?.sources).toEqual(['blueprint', 'production']);
36
+ expect(x?.blueprintExtra).toEqual({ type: 'task' });
37
+ expect(y?.sources).toEqual(['preview']);
38
+ });
39
+ });
40
+ describe('padName — defensive printer', () => {
41
+ it('pads valid names to the requested width', () => {
42
+ expect(padName('foo', 8)).toBe('foo ');
43
+ });
44
+ it('returns a placeholder for undefined / null / empty inputs', () => {
45
+ expect(padName(undefined, 12)).toContain('(unnamed)');
46
+ expect(padName(null, 12)).toContain('(unnamed)');
47
+ expect(padName('', 12)).toContain('(unnamed)');
48
+ // Placeholder is also padded to the requested width.
49
+ expect(padName(undefined, 12).length).toBe(12);
50
+ });
51
+ it('does not throw on non-string inputs', () => {
52
+ expect(() => padName(42, 8)).not.toThrow();
53
+ expect(() => padName({}, 8)).not.toThrow();
54
+ });
55
+ });
@@ -2,12 +2,27 @@
2
2
  * Tests for `runwork upgrade`.
3
3
  *
4
4
  * The module under test has top-level side effects (it builds a Commander
5
- * command and captures fetch/process references), so we exercise the exported
5
+ * command and captures `httpFetch` references), so we exercise the exported
6
6
  * __internal helpers against a local HTTP server that serves a fake
7
- * /cli/latest.json.
7
+ * /cli/latest.json. We intercept `httpFetch` and rewrite the LATEST_JSON_URL
8
+ * to point at the test server so the actual node:https-backed wrapper is
9
+ * exercised end to end.
8
10
  */
9
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
11
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
10
12
  import { createServer } from 'node:http';
13
+ let currentServerUrl = 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 (currentServerUrl && url.endsWith('/cli/latest.json')) {
20
+ return actual.httpFetch(`${currentServerUrl}/cli/latest.json`, init);
21
+ }
22
+ return actual.httpFetch(url, init);
23
+ },
24
+ };
25
+ });
11
26
  import { __internal } from '../upgrade.js';
12
27
  function startJsonServer(body, status = 200) {
13
28
  return new Promise((resolvePromise) => {
@@ -41,17 +56,15 @@ function startJsonServer(body, status = 200) {
41
56
  }
42
57
  describe('__internal.fetchLatestVersion', () => {
43
58
  let originalBase;
44
- let originalFetch;
45
59
  let serverStop = null;
46
60
  beforeEach(() => {
47
61
  originalBase = process.env.RUNWORK_DOWNLOAD_BASE_URL;
48
- originalFetch = globalThis.fetch;
49
62
  });
50
63
  afterEach(async () => {
51
64
  if (serverStop)
52
65
  await serverStop();
53
66
  serverStop = null;
54
- globalThis.fetch = originalFetch;
67
+ currentServerUrl = null;
55
68
  if (originalBase === undefined)
56
69
  delete process.env.RUNWORK_DOWNLOAD_BASE_URL;
57
70
  else
@@ -65,64 +78,34 @@ describe('__internal.fetchLatestVersion', () => {
65
78
  artifacts: {},
66
79
  });
67
80
  serverStop = server.stop;
68
- // Override fetch URL by intercepting via globalThis.fetch
69
- const realFetch = globalThis.fetch;
70
- globalThis.fetch = ((input, init) => {
71
- const url = typeof input === 'string' ? input : input.toString();
72
- if (url === __internal.LATEST_JSON_URL) {
73
- return realFetch(`${server.url}/cli/latest.json`, init);
74
- }
75
- return realFetch(input, init);
76
- });
81
+ currentServerUrl = server.url;
77
82
  const version = await __internal.fetchLatestVersion();
78
83
  expect(version).toBe('1.2.3');
79
84
  });
80
85
  it('strips a leading v from the version', async () => {
81
86
  const server = await startJsonServer({ version: 'v9.9.9' });
82
87
  serverStop = server.stop;
83
- const realFetch = globalThis.fetch;
84
- globalThis.fetch = ((input, init) => {
85
- const url = typeof input === 'string' ? input : input.toString();
86
- if (url === __internal.LATEST_JSON_URL) {
87
- return realFetch(`${server.url}/cli/latest.json`, init);
88
- }
89
- return realFetch(input, init);
90
- });
88
+ currentServerUrl = server.url;
91
89
  const version = await __internal.fetchLatestVersion();
92
90
  expect(version).toBe('9.9.9');
93
91
  });
94
92
  it('returns null on HTTP error', async () => {
95
93
  const server = await startJsonServer(null, 500);
96
94
  serverStop = server.stop;
97
- const realFetch = globalThis.fetch;
98
- globalThis.fetch = ((input, init) => {
99
- const url = typeof input === 'string' ? input : input.toString();
100
- if (url === __internal.LATEST_JSON_URL) {
101
- return realFetch(`${server.url}/cli/latest.json`, init);
102
- }
103
- return realFetch(input, init);
104
- });
95
+ currentServerUrl = server.url;
105
96
  const version = await __internal.fetchLatestVersion();
106
97
  expect(version).toBeNull();
107
98
  });
108
99
  it('returns null when fetch throws', async () => {
109
- globalThis.fetch = (() => {
110
- throw new Error('network down');
111
- });
100
+ // Point at a port that is virtually guaranteed to refuse connections.
101
+ currentServerUrl = 'http://127.0.0.1:1';
112
102
  const version = await __internal.fetchLatestVersion();
113
103
  expect(version).toBeNull();
114
104
  });
115
105
  it('returns null when the manifest has no version field', async () => {
116
106
  const server = await startJsonServer({ name: 'runwork' });
117
107
  serverStop = server.stop;
118
- const realFetch = globalThis.fetch;
119
- globalThis.fetch = ((input, init) => {
120
- const url = typeof input === 'string' ? input : input.toString();
121
- if (url === __internal.LATEST_JSON_URL) {
122
- return realFetch(`${server.url}/cli/latest.json`, init);
123
- }
124
- return realFetch(input, init);
125
- });
108
+ currentServerUrl = server.url;
126
109
  const version = await __internal.fetchLatestVersion();
127
110
  expect(version).toBeNull();
128
111
  });
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { ApiClient } from '../api/client.js';
3
- import type { AppInfo } from '../types.js';
3
+ import type { Credentials, AppInfo } from '../types.js';
4
4
  export interface CloneResult {
5
5
  appId: string;
6
6
  appName: string;
@@ -9,5 +9,5 @@ export interface CloneResult {
9
9
  workspaceId: string;
10
10
  workspaceName: string;
11
11
  }
12
- export declare function execClone(client: ApiClient, app: AppInfo, directory?: string): Promise<CloneResult>;
12
+ export declare function execClone(client: ApiClient, app: AppInfo, directory?: string, creds?: Credentials | null): Promise<CloneResult>;
13
13
  export declare const cloneCommand: Command;
@@ -12,7 +12,10 @@ import { removeNestedGitDirs } from '../utils/fs.js';
12
12
  import { runAgentWizard } from '../ui/banner.js';
13
13
  import { shouldOutputJson, jsonOut } from '../utils/output.js';
14
14
  import { buildCloneGuide, buildErrorResponse } from '../utils/agent-guidance.js';
15
- export async function execClone(client, app, directory) {
15
+ import { ensureGitIdentity } from '../git/identity.js';
16
+ import { requireGit } from '../git/preflight.js';
17
+ import { formatError } from '../utils/format-error.js';
18
+ export async function execClone(client, app, directory, creds) {
16
19
  const slug = app.slug || app.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
17
20
  const dir = directory || slug;
18
21
  const remoteUrl = client.getGitRemoteUrl(app.workspaceId, app.id);
@@ -24,8 +27,13 @@ export async function execClone(client, app, directory) {
24
27
  extractZip(zipData, dir);
25
28
  removeNestedGitDirs(dir);
26
29
  }
27
- catch {
28
- console.warn('Failed to download template. Cloning git-tracked files only.');
30
+ catch (err) {
31
+ // Surface the real error so we never silently end up with an empty
32
+ // clone directory. The clone is best-effort here -- git fetch may
33
+ // still recover the project below -- but the user should know what
34
+ // went wrong on the way in.
35
+ console.warn(`Failed to download template: ${formatError(err)}`);
36
+ console.warn('Continuing with the git overlay only.');
29
37
  }
30
38
  // Generate template manifest BEFORE git overlay (captures pristine template checksums)
31
39
  const manifest = await generateManifest(dir);
@@ -33,8 +41,27 @@ export async function execClone(client, app, directory) {
33
41
  // Step 2: Git clone into the directory (overlays changed files on top of template)
34
42
  // Init git, add remote, fetch, and checkout to overlay on existing template files
35
43
  if (!existsSync(join(dir, '.git'))) {
36
- execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
44
+ try {
45
+ execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
46
+ }
47
+ catch (err) {
48
+ // `stdio: 'pipe'` discards git's stderr, so we have to print the
49
+ // error ourselves -- otherwise this fails silently and the user
50
+ // is left with an empty directory and no clue what happened.
51
+ console.error(`git init failed in ${dir}: ${formatError(err)}`);
52
+ throw err;
53
+ }
54
+ // Force the initial branch to `main` regardless of the user's
55
+ // `init.defaultBranch` config (older Git installs default to
56
+ // `master`). symbolic-ref works before any commits exist.
57
+ try {
58
+ execFileSync('git', ['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir, stdio: 'pipe' });
59
+ }
60
+ catch { /* best-effort: push uses HEAD:main so this is just cleanliness */ }
37
61
  }
62
+ // Seed a local git identity so subsequent commits do not fail on machines
63
+ // (commonly fresh Windows installs) without `git config --global user.email`.
64
+ ensureGitIdentity(dir, creds);
38
65
  try {
39
66
  execFileSync('git', ['remote', 'set-url', 'runwork', remoteUrl], { cwd: dir, stdio: 'pipe' });
40
67
  }
@@ -48,8 +75,12 @@ export async function execClone(client, app, directory) {
48
75
  // Checkout git-tracked files (overwrites template files where they differ)
49
76
  execFileSync('git', ['checkout', '.'], { cwd: dir, stdio: 'pipe' });
50
77
  }
51
- catch {
52
- console.warn('No remote commits yet. Starting with template only.');
78
+ catch (err) {
79
+ // Distinguish "remote is empty" (expected for brand-new apps) from
80
+ // real failures. We print both the generic next-step hint and the
81
+ // underlying error so authentication / network failures aren't lost.
82
+ console.warn('Could not pull from runwork remote. Starting with template only.');
83
+ console.warn(` ${formatError(err)}`);
53
84
  }
54
85
  // Write .runwork.json config
55
86
  const config = {
@@ -84,6 +115,7 @@ export const cloneCommand = new Command('clone')
84
115
  .argument('[directory]', 'Target directory')
85
116
  .option('--app <name-or-id>', 'App name or ID (skips interactive selection)')
86
117
  .action(async (appId, directory, options) => {
118
+ requireGit('clone');
87
119
  const creds = requireAuth();
88
120
  const client = new ApiClient(creds);
89
121
  const useJson = shouldOutputJson(undefined);
@@ -108,7 +140,7 @@ export const cloneCommand = new Command('clone')
108
140
  if (!useJson) {
109
141
  console.log(`Cloning "${app.name}"...`);
110
142
  }
111
- const cloneResult = await execClone(client, app, directory);
143
+ const cloneResult = await execClone(client, app, directory, creds);
112
144
  if (useJson) {
113
145
  const response = {
114
146
  success: true,
@@ -6,6 +6,8 @@ import { requireAuth } from '../auth/store.js';
6
6
  import { ApiClient } from '../api/client.js';
7
7
  import { watchAndAutoCommit, stopAutoCommit } from '../git/auto-commit.js';
8
8
  import { syncWithRemote } from '../git/sync.js';
9
+ import { ensureGitIdentity } from '../git/identity.js';
10
+ import { requireGit } from '../git/preflight.js';
9
11
  import { startLogTailer } from '../logs/tailer.js';
10
12
  import { populateTypes } from '../types-manager.js';
11
13
  import { loadManifest, generateManifest, saveManifest, detectUserEdits } from '../template/manifest.js';
@@ -85,7 +87,8 @@ function commitAndPushRestoredFiles(cwd, files) {
85
87
  return false;
86
88
  }
87
89
  try {
88
- execFileSync('git', ['push', 'runwork', 'main'], { cwd, stdio: 'pipe' });
90
+ // `HEAD:main` so the push works regardless of local branch name.
91
+ execFileSync('git', ['push', 'runwork', 'HEAD:main'], { cwd, stdio: 'pipe' });
89
92
  return true;
90
93
  }
91
94
  catch {
@@ -96,11 +99,16 @@ function commitAndPushRestoredFiles(cwd, files) {
96
99
  }
97
100
  export async function execDev(options) {
98
101
  const useJson = options?.json ?? false;
102
+ requireGit('dev');
99
103
  const config = readConfig();
100
104
  const creds = requireAuth();
101
105
  const client = new ApiClient(creds);
102
106
  const cwd = process.cwd();
103
107
  const ts = () => new Date().toISOString();
108
+ // Seed a local git identity so the very first commit cannot fail on a
109
+ // fresh machine where `git config --global user.email/.name` has never
110
+ // been set (extremely common on Windows after a clean install).
111
+ ensureGitIdentity(cwd, creds);
104
112
  // Detect user edits made outside of `runwork dev`
105
113
  const oldManifest = await loadManifest(cwd);
106
114
  if (oldManifest) {