@shogo-ai/worker 1.0.10

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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +432 -0
  3. package/bin/shogo.mjs +38 -0
  4. package/dist/cli.mjs +64 -0
  5. package/package.json +62 -0
  6. package/src/cli.ts +199 -0
  7. package/src/commands/agent.ts +123 -0
  8. package/src/commands/config.ts +29 -0
  9. package/src/commands/doctor.ts +128 -0
  10. package/src/commands/login.ts +126 -0
  11. package/src/commands/logs.ts +24 -0
  12. package/src/commands/project-checkout.ts +179 -0
  13. package/src/commands/project-pull.ts +133 -0
  14. package/src/commands/project-push.ts +89 -0
  15. package/src/commands/runtime.ts +104 -0
  16. package/src/commands/start.ts +290 -0
  17. package/src/commands/status.ts +22 -0
  18. package/src/commands/stop.ts +13 -0
  19. package/src/lib/__tests__/agent-launch.test.ts +92 -0
  20. package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
  21. package/src/lib/__tests__/cloud-login.test.ts +218 -0
  22. package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
  23. package/src/lib/__tests__/config.test.ts +141 -0
  24. package/src/lib/__tests__/db-doctor.test.ts +204 -0
  25. package/src/lib/__tests__/git-cloner.test.ts +382 -0
  26. package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
  27. package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
  28. package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
  29. package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
  30. package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
  31. package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
  32. package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
  33. package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
  34. package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
  35. package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
  36. package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
  37. package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
  38. package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
  39. package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
  40. package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
  41. package/src/lib/api-discovery.ts +36 -0
  42. package/src/lib/cloud-login.ts +320 -0
  43. package/src/lib/cloud-sync-watcher.ts +322 -0
  44. package/src/lib/config.ts +68 -0
  45. package/src/lib/db-doctor.ts +355 -0
  46. package/src/lib/device-id.ts +27 -0
  47. package/src/lib/git-cloner.ts +354 -0
  48. package/src/lib/paths.ts +53 -0
  49. package/src/lib/preflight.ts +158 -0
  50. package/src/lib/process-manager.ts +123 -0
  51. package/src/lib/resource-limits.ts +340 -0
  52. package/src/lib/runtime-install.ts +371 -0
  53. package/src/lib/runtime-manager.ts +1873 -0
  54. package/src/lib/runtime-resolver.ts +136 -0
  55. package/src/lib/transport.ts +202 -0
  56. package/src/lib/tunnel.ts +710 -0
  57. package/src/lib/user-agent.ts +28 -0
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Pin the wire shape of the structured 502 response WorkerTunnel sends
5
+ * when a resolver declines to forward a tunneled request. Studio reads
6
+ * `code`, `message`, and `path` from the body so a future debugger can
7
+ * tell what was attempted without log archaeology.
8
+ */
9
+ import { describe, expect, it } from 'bun:test';
10
+ import { WorkerTunnel, type ResolveRejection, type RuntimeResolver } from '../tunnel.ts';
11
+
12
+ class FakeWebSocket {
13
+ readyState = 1; // WebSocket.OPEN
14
+ sent: string[] = [];
15
+ send(msg: string): void {
16
+ this.sent.push(msg);
17
+ }
18
+ close(): void {
19
+ this.readyState = 3; // WebSocket.CLOSED
20
+ }
21
+ }
22
+
23
+ function makeResolver(overrides: Partial<RuntimeResolver> = {}): RuntimeResolver {
24
+ return {
25
+ resolveLocalUrl: async () => null,
26
+ deriveRuntimeToken: () => null,
27
+ getActiveProjects: () => [],
28
+ status: () => null,
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ const silentLogger = { log: () => {}, warn: () => {}, error: () => {} };
34
+
35
+ describe('WorkerTunnel structured 502', () => {
36
+ it('echoes resolver-provided code+message and the original path', async () => {
37
+ const rejection: ResolveRejection = {
38
+ code: 'CLI_WORKER_HAS_NO_DATA_API',
39
+ message: 'cli-worker only serves /agent/* paths; tried: /api/projects',
40
+ };
41
+ const resolver = makeResolver({
42
+ describeRejection: () => rejection,
43
+ });
44
+ const tunnel = new WorkerTunnel({
45
+ apiKey: 'shogo_sk_x',
46
+ cloudUrl: 'https://api.test',
47
+ resolver,
48
+ logger: silentLogger,
49
+ });
50
+ const fake = new FakeWebSocket();
51
+ tunnel._testing().installFakeWs(fake as unknown as WebSocket);
52
+
53
+ await tunnel._testing().handleRequest({
54
+ type: 'request',
55
+ requestId: 'r-1',
56
+ method: 'GET',
57
+ path: '/api/projects?workspaceId=ws-1',
58
+ stream: false,
59
+ });
60
+
61
+ expect(fake.sent.length).toBe(1);
62
+ const frame = JSON.parse(fake.sent[0]!);
63
+ expect(frame.type).toBe('response');
64
+ expect(frame.requestId).toBe('r-1');
65
+ expect(frame.status).toBe(502);
66
+ expect(frame.headers?.['content-type']).toBe('application/json');
67
+
68
+ const body = JSON.parse(frame.body);
69
+ expect(body.code).toBe('CLI_WORKER_HAS_NO_DATA_API');
70
+ expect(body.message).toBe(rejection.message);
71
+ // Tunnel always echoes back the ORIGINAL path (with query) so a
72
+ // browser request's URL survives the round-trip into the 502 body.
73
+ expect(body.path).toBe('/api/projects?workspaceId=ws-1');
74
+ });
75
+
76
+ it('falls back to a generic NO_LOCAL_RUNTIME body when resolver omits describeRejection', async () => {
77
+ const resolver = makeResolver();
78
+ const tunnel = new WorkerTunnel({
79
+ apiKey: 'shogo_sk_x',
80
+ cloudUrl: 'https://api.test',
81
+ resolver,
82
+ logger: silentLogger,
83
+ });
84
+ const fake = new FakeWebSocket();
85
+ tunnel._testing().installFakeWs(fake as unknown as WebSocket);
86
+
87
+ await tunnel._testing().handleRequest({
88
+ type: 'request',
89
+ requestId: 'r-2',
90
+ method: 'GET',
91
+ path: '/whatever',
92
+ stream: false,
93
+ });
94
+
95
+ expect(fake.sent.length).toBe(1);
96
+ const body = JSON.parse(JSON.parse(fake.sent[0]!).body);
97
+ expect(body.code).toBe('NO_LOCAL_RUNTIME');
98
+ expect(body.path).toBe('/whatever');
99
+ expect(body.message).toContain('/whatever');
100
+ });
101
+ });
@@ -0,0 +1,463 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Greenfield sweep for three never-loaded shogo-worker files:
5
+ * lib/runtime-install.ts, commands/project-checkout.ts, commands/project-push.ts
6
+ * Shared mocks: node:fs / node:child_process / node:crypto / node:os /
7
+ * node:stream / node:stream/promises / ../config / ../paths / ../git-cloner /
8
+ * @shogo-ai/sdk/cloud-file-transport, plus a routable global fetch.
9
+ */
10
+ import { describe, test, expect, beforeEach, afterEach, mock, afterAll } from 'bun:test'
11
+ import { EventEmitter } from 'node:events'
12
+
13
+ // ── routable global fetch ─────────────────────────────────────────────────────
14
+ type FetchHandler = (url: string, init?: any) => any
15
+ let fetchHandler: FetchHandler = () => { throw new Error('fetch not configured') }
16
+ const origFetch = globalThis.fetch
17
+ beforeEach(() => {
18
+ fetchHandler = () => { throw new Error('fetch not configured') }
19
+ ;(globalThis as any).fetch = (url: any, init?: any) => Promise.resolve(fetchHandler(String(url), init))
20
+ })
21
+ afterEach(() => { (globalThis as any).fetch = origFetch })
22
+
23
+ // ── controllable node:fs ──────────────────────────────────────────────────────
24
+ let existsPredicate: (p: string) => boolean = () => false
25
+ let readFileImpl: (p: string) => any = () => ''
26
+ const fsWrites: Array<{ path: string; data: string }> = []
27
+ const fsRenames: Array<{ from: string; to: string }> = []
28
+ const fsRemoves: string[] = []
29
+ const fsMkdirs: string[] = []
30
+ mock.module('node:fs', () => ({
31
+ existsSync: (p: string) => existsPredicate(String(p)),
32
+ readFileSync: (p: string) => readFileImpl(String(p)),
33
+ writeFileSync: (p: string, data: any) => { fsWrites.push({ path: String(p), data: String(data) }) },
34
+ mkdirSync: (p: string) => { fsMkdirs.push(String(p)) },
35
+ renameSync: (a: string, b: string) => { fsRenames.push({ from: String(a), to: String(b) }) },
36
+ rmSync: (p: string) => { fsRemoves.push(String(p)) },
37
+ chmodSync: () => {},
38
+ createWriteStream: () => ({ on: () => {}, end: () => {}, write: () => true }),
39
+ }))
40
+
41
+ // ── node:crypto (deterministic digest) ────────────────────────────────────────
42
+ let computedDigest = 'a'.repeat(64)
43
+ mock.module('node:crypto', () => ({
44
+ createHash: () => ({ update: () => ({ digest: () => computedDigest }) }),
45
+ }))
46
+
47
+ // ── node:os / node:stream / node:stream/promises ──────────────────────────────
48
+ mock.module('node:os', () => ({ tmpdir: () => '/tmp' }))
49
+ mock.module('node:stream', () => ({ Readable: { fromWeb: () => ({}) } }))
50
+ let pipelineThrows = false
51
+ mock.module('node:stream/promises', () => ({
52
+ pipeline: async () => { if (pipelineThrows) throw new Error('pipeline boom') },
53
+ }))
54
+
55
+ // ── fake child_process.spawn (tar) ────────────────────────────────────────────
56
+ let tarExitCode = 0
57
+ let tarEmitsError = false
58
+ class FakeTar extends EventEmitter {
59
+ stderr = new EventEmitter()
60
+ constructor() {
61
+ super()
62
+ queueMicrotask(() => {
63
+ if (tarEmitsError) { this.emit('error', new Error('spawn failed')); return }
64
+ if (tarExitCode !== 0) this.stderr.emit('data', Buffer.from('tar failure'))
65
+ this.emit('exit', tarExitCode)
66
+ })
67
+ }
68
+ }
69
+ mock.module('node:child_process', () => ({
70
+ spawn: () => new FakeTar(),
71
+ }))
72
+
73
+ // ── ../config ─────────────────────────────────────────────────────────────────
74
+ mock.module('../config.ts', () => ({
75
+ resolveConfig: (o: any = {}) => ({
76
+ apiKey: o.apiKey ?? 'key-123',
77
+ cloudUrl: o.cloudUrl ?? 'https://cloud.shogo.dev',
78
+ projectsDir: '/projects',
79
+ }),
80
+ }))
81
+
82
+ // ── ../paths (serves both runtime-install ./paths and commands ../lib/paths) ────
83
+ mock.module('../paths.ts', () => ({
84
+ RUNTIME_BIN: '/runtime/agent-runtime',
85
+ RUNTIME_DIR: '/runtime',
86
+ RUNTIME_VERSION_FILE: '/runtime/version.json',
87
+ ensureRuntimeDir: () => {},
88
+ projectDirFor: (id: string) => `/projects/${id}`,
89
+ }))
90
+
91
+ // ── ../git-cloner ─────────────────────────────────────────────────────────────
92
+ let isGitRepoVal = true
93
+ let runGitImpl: (args: string[]) => any = async () => ({ stdout: '', stderr: '' })
94
+ const gitCalls: { fetchReset: any[]; unshallow: any[] } = { fetchReset: [], unshallow: [] }
95
+ let fetchResetImpl: (o: any) => any = async () => ({ commitSha: 'abcdef1234567890' })
96
+ let unshallowImpl: (o: any) => any = async () => {}
97
+ mock.module('../git-cloner.ts', () => ({
98
+ isGitRepo: () => isGitRepoVal,
99
+ runGit: (args: string[], opts: any) => runGitImpl(args),
100
+ gitFetchAndReset: (o: any) => { gitCalls.fetchReset.push(o); return fetchResetImpl(o) },
101
+ gitFetchUnshallow: (o: any) => { gitCalls.unshallow.push(o); return unshallowImpl(o) },
102
+ }))
103
+
104
+ // ── @shogo-ai/sdk/cloud-file-transport ────────────────────────────────────────
105
+ let uploadAllImpl: (o: any) => any = async () => ({ uploaded: 3, deleted: 0, errors: [] })
106
+ let lastTransportOpts: any = null
107
+ mock.module('@shogo-ai/sdk/cloud-file-transport', () => ({
108
+ CloudFileTransport: class {
109
+ opts: any
110
+ constructor(o: any) { this.opts = o; lastTransportOpts = o }
111
+ uploadAll(args: any) { return uploadAllImpl({ ...args, ctor: this.opts }) }
112
+ },
113
+ }))
114
+
115
+ import {
116
+ detectTarget,
117
+ readInstalledVersion,
118
+ resolveLatestVersion,
119
+ installRuntime,
120
+ getRuntimePaths,
121
+ } from '../runtime-install'
122
+ import { runProjectCheckout } from '../../commands/project-checkout'
123
+ import { runProjectPush } from '../../commands/project-push'
124
+
125
+ const silentLogger = { log: () => {}, warn: () => {}, error: () => {} }
126
+
127
+ beforeEach(() => {
128
+ existsPredicate = () => false
129
+ readFileImpl = () => ''
130
+ fsWrites.length = 0; fsRenames.length = 0; fsRemoves.length = 0; fsMkdirs.length = 0
131
+ computedDigest = 'a'.repeat(64)
132
+ pipelineThrows = false
133
+ tarExitCode = 0; tarEmitsError = false
134
+ isGitRepoVal = true
135
+ runGitImpl = async () => ({ stdout: '', stderr: '' })
136
+ fetchResetImpl = async () => ({ commitSha: 'abcdef1234567890' })
137
+ unshallowImpl = async () => {}
138
+ gitCalls.fetchReset.length = 0; gitCalls.unshallow.length = 0
139
+ uploadAllImpl = async () => ({ uploaded: 3, deleted: 0, errors: [] })
140
+ lastTransportOpts = null
141
+ })
142
+
143
+ // ════════════════════════════════════════════════════════════════════════════
144
+ // runtime-install.ts
145
+ // ════════════════════════════════════════════════════════════════════════════
146
+ describe('runtime-install: detectTarget', () => {
147
+ const origPlatform = process.platform
148
+ const origArch = process.arch
149
+ function setEnv(platform: string, arch: string) {
150
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true })
151
+ Object.defineProperty(process, 'arch', { value: arch, configurable: true })
152
+ }
153
+ afterEach(() => {
154
+ Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true })
155
+ Object.defineProperty(process, 'arch', { value: origArch, configurable: true })
156
+ })
157
+
158
+ test('darwin/arm64', () => { setEnv('darwin', 'arm64'); expect(detectTarget()).toBe('darwin-arm64') })
159
+ test('linux/x64', () => { setEnv('linux', 'x64'); expect(detectTarget()).toBe('linux-x64') })
160
+ test('win32/x64', () => { setEnv('win32', 'x64'); expect(detectTarget()).toBe('windows-x64') })
161
+ test('unsupported platform throws', () => { setEnv('freebsd', 'x64'); expect(() => detectTarget()).toThrow(/Unsupported platform/) })
162
+ test('unsupported arch throws', () => { setEnv('linux', 'mips'); expect(() => detectTarget()).toThrow(/Unsupported arch/) })
163
+ })
164
+
165
+ describe('runtime-install: readInstalledVersion', () => {
166
+ test('returns null when version file absent', () => {
167
+ existsPredicate = () => false
168
+ expect(readInstalledVersion()).toBeNull()
169
+ })
170
+ test('parses installed version json', () => {
171
+ existsPredicate = () => true
172
+ readFileImpl = () => JSON.stringify({ version: '1.2.3', target: 'linux-x64' })
173
+ expect(readInstalledVersion()?.version).toBe('1.2.3')
174
+ })
175
+ test('returns null on malformed json', () => {
176
+ existsPredicate = () => true
177
+ readFileImpl = () => '{not-json'
178
+ expect(readInstalledVersion()).toBeNull()
179
+ })
180
+ })
181
+
182
+ describe('runtime-install: resolveLatestVersion', () => {
183
+ const ghBase = 'https://github.com/shogo-ai/shogo/releases/download'
184
+
185
+ test('non-github baseUrl throws', async () => {
186
+ await expect(resolveLatestVersion('stable', 'https://cdn.example.com/rt')).rejects.toThrow(/non-GitHub baseUrl/)
187
+ })
188
+ test('stable reads releases/latest tag_name', async () => {
189
+ fetchHandler = () => ({ ok: true, json: async () => ({ tag_name: 'v2.5.1' }) })
190
+ expect(await resolveLatestVersion('stable', ghBase)).toBe('2.5.1')
191
+ })
192
+ test('stable throws on API error', async () => {
193
+ fetchHandler = () => ({ ok: false, status: 503 })
194
+ await expect(resolveLatestVersion('stable', ghBase)).rejects.toThrow(/GitHub API 503/)
195
+ })
196
+ test('stable throws when tag_name missing', async () => {
197
+ fetchHandler = () => ({ ok: true, json: async () => ({}) })
198
+ await expect(resolveLatestVersion('stable', ghBase)).rejects.toThrow(/did not return tag_name/)
199
+ })
200
+ test('beta picks newest matching prerelease', async () => {
201
+ fetchHandler = () => ({ ok: true, json: async () => ([
202
+ { tag_name: 'v3.0.0', prerelease: false },
203
+ { tag_name: 'v3.1.0-beta.2', prerelease: true },
204
+ ]) })
205
+ expect(await resolveLatestVersion('beta', ghBase)).toBe('3.1.0-beta.2')
206
+ })
207
+ test('nightly matches -nightly tags', async () => {
208
+ fetchHandler = () => ({ ok: true, json: async () => ([
209
+ { tag_name: 'v4.0.0-nightly.7', prerelease: true },
210
+ ]) })
211
+ expect(await resolveLatestVersion('nightly', ghBase)).toBe('4.0.0-nightly.7')
212
+ })
213
+ test('prerelease list API error throws', async () => {
214
+ fetchHandler = () => ({ ok: false, status: 500 })
215
+ await expect(resolveLatestVersion('beta', ghBase)).rejects.toThrow(/GitHub API 500/)
216
+ })
217
+ test('no matching prerelease throws', async () => {
218
+ fetchHandler = () => ({ ok: true, json: async () => ([{ tag_name: 'v1.0.0', prerelease: false }]) })
219
+ await expect(resolveLatestVersion('beta', ghBase)).rejects.toThrow(/No beta runtime release/)
220
+ })
221
+ test('malformed tag throws via tagToVersion', async () => {
222
+ fetchHandler = () => ({ ok: true, json: async () => ({ tag_name: 'release-99' }) })
223
+ await expect(resolveLatestVersion('stable', ghBase)).rejects.toThrow(/Unexpected app tag/)
224
+ })
225
+ })
226
+
227
+ describe('runtime-install: installRuntime', () => {
228
+ const ghBase = 'https://github.com/shogo-ai/shogo/releases/download'
229
+
230
+ function wireDownload(sha = 'a'.repeat(64)) {
231
+ fetchHandler = (url: string) => {
232
+ if (url.endsWith('.sha256')) return { ok: true, text: async () => `${sha} shogo-agent-runtime.tar.gz` }
233
+ return { ok: true, body: {} }
234
+ }
235
+ }
236
+
237
+ test('short-circuits when same version already installed', async () => {
238
+ existsPredicate = () => true
239
+ readFileImpl = () => JSON.stringify({ version: '1.0.0', target: 'linux-x64', source: 'src-url', sha256: 'dd' })
240
+ const res = await installRuntime({ version: '1.0.0', target: 'linux-x64', logger: silentLogger })
241
+ expect(res.version).toBe('1.0.0')
242
+ expect(res.source).toBe('src-url')
243
+ expect(gitCalls.fetchReset.length).toBe(0)
244
+ })
245
+
246
+ test('full install happy path verifies sha + writes version record', async () => {
247
+ // version file absent → no short circuit; staging bin present after extract
248
+ existsPredicate = (p) => p.endsWith('agent-runtime') || p.endsWith('.next') === false && p.includes('extract')
249
+ existsPredicate = (p) => p.includes('extract') && p.endsWith('agent-runtime')
250
+ computedDigest = 'b'.repeat(64)
251
+ wireDownload('b'.repeat(64))
252
+ const res = await installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger })
253
+ expect(res.version).toBe('2.0.0')
254
+ expect(res.sha256).toBe('b'.repeat(64))
255
+ expect(fsWrites.some((w) => w.path.endsWith('version.json'))).toBe(true)
256
+ })
257
+
258
+ test('sha mismatch throws', async () => {
259
+ existsPredicate = (p) => p.includes('extract') && p.endsWith('agent-runtime')
260
+ computedDigest = 'c'.repeat(64)
261
+ wireDownload('d'.repeat(64))
262
+ await expect(installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger }))
263
+ .rejects.toThrow(/SHA-256 mismatch/)
264
+ })
265
+
266
+ test('missing agent-runtime in tarball throws', async () => {
267
+ existsPredicate = () => false
268
+ computedDigest = 'e'.repeat(64)
269
+ wireDownload('e'.repeat(64))
270
+ await expect(installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger }))
271
+ .rejects.toThrow(/did not contain .\/agent-runtime/)
272
+ })
273
+
274
+ test('download HTTP error throws', async () => {
275
+ fetchHandler = () => ({ ok: false, status: 404 })
276
+ await expect(installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger }))
277
+ .rejects.toThrow(/Download failed: HTTP 404/)
278
+ })
279
+
280
+ test('tar exit non-zero throws', async () => {
281
+ existsPredicate = (p) => p.includes('extract') && p.endsWith('agent-runtime')
282
+ tarExitCode = 1
283
+ computedDigest = 'f'.repeat(64)
284
+ wireDownload('f'.repeat(64))
285
+ await expect(installRuntime({ version: '2.0.0', target: 'linux-x64', logger: silentLogger }))
286
+ .rejects.toThrow(/tar exited 1/)
287
+ })
288
+
289
+ test('resolves latest version when none provided', async () => {
290
+ existsPredicate = (p) => p.includes('extract') && p.endsWith('agent-runtime')
291
+ computedDigest = '0'.repeat(64)
292
+ fetchHandler = (url: string) => {
293
+ if (url.includes('releases/latest')) return { ok: true, json: async () => ({ tag_name: 'v9.9.9' }) }
294
+ if (url.endsWith('.sha256')) return { ok: true, text: async () => `${'0'.repeat(64)} x` }
295
+ return { ok: true, body: {} }
296
+ }
297
+ const res = await installRuntime({ target: 'linux-x64', baseUrl: ghBase, logger: silentLogger })
298
+ expect(res.version).toBe('9.9.9')
299
+ })
300
+ })
301
+
302
+ describe('runtime-install: getRuntimePaths', () => {
303
+ test('returns the three runtime paths', () => {
304
+ const p = getRuntimePaths()
305
+ expect(p.runtimeBin).toBe('/runtime/agent-runtime')
306
+ expect(p.versionFile).toBe('/runtime/version.json')
307
+ })
308
+ })
309
+
310
+ // ════════════════════════════════════════════════════════════════════════════
311
+ // commands/project-checkout.ts
312
+ // ════════════════════════════════════════════════════════════════════════════
313
+ describe('project-checkout', () => {
314
+ test('throws without projectId', async () => {
315
+ await expect(runProjectCheckout('', {})).rejects.toThrow(/projectId is required/)
316
+ })
317
+ test('throws when local dir missing', async () => {
318
+ existsPredicate = () => false
319
+ await expect(runProjectCheckout('p1', {})).rejects.toThrow(/does not exist/)
320
+ })
321
+ test('throws when not a git repo', async () => {
322
+ existsPredicate = () => true
323
+ isGitRepoVal = false
324
+ await expect(runProjectCheckout('p1', {})).rejects.toThrow(/not a git repo/)
325
+ })
326
+ test('no --at fast-forwards to remote HEAD', async () => {
327
+ existsPredicate = () => true
328
+ await runProjectCheckout('p1', {})
329
+ expect(gitCalls.fetchReset.length).toBe(1)
330
+ expect(gitCalls.fetchReset[0].branch).toBeUndefined()
331
+ })
332
+ test('--unshallow runs unshallow before reset', async () => {
333
+ existsPredicate = () => true
334
+ await runProjectCheckout('p1', { unshallow: true })
335
+ expect(gitCalls.unshallow.length).toBe(1)
336
+ })
337
+ test('--at as resolvable SHA fetches that commit', async () => {
338
+ existsPredicate = () => true
339
+ runGitImpl = async (args) => {
340
+ if (args[0] === 'rev-parse') return { stdout: 'deadbeefdeadbeef\n', stderr: '' }
341
+ return { stdout: '', stderr: '' }
342
+ }
343
+ await runProjectCheckout('p1', { at: 'deadbeef' })
344
+ expect(gitCalls.fetchReset[0].branch).toBe('deadbeefdeadbeef')
345
+ })
346
+ test('--at falls back to checkpoint-name lookup', async () => {
347
+ existsPredicate = () => true
348
+ runGitImpl = async (args) => {
349
+ if (args[0] === 'rev-parse') throw new Error('not a sha')
350
+ return { stdout: '', stderr: '' }
351
+ }
352
+ fetchHandler = () => ({ ok: true, json: async () => ({
353
+ checkpoints: [{ id: 'c1', commitSha: 'cafebabecafebabe', name: 'My Save', commitMessage: 'x', createdAt: 'now' }],
354
+ hasMore: false,
355
+ }) })
356
+ await runProjectCheckout('p1', { at: 'My Save' })
357
+ expect(gitCalls.fetchReset[0].branch).toBe('cafebabecafebabe')
358
+ })
359
+ test('--at fetch failure triggers unshallow retry', async () => {
360
+ existsPredicate = () => true
361
+ runGitImpl = async (args) => (args[0] === 'rev-parse' ? { stdout: 'aa11bb22\n', stderr: '' } : { stdout: '', stderr: '' })
362
+ let calls = 0
363
+ fetchResetImpl = async () => { calls++; if (calls === 1) throw new Error('outside shallow window'); return { commitSha: 'aa11bb22' } }
364
+ await runProjectCheckout('p1', { at: 'aa11bb22' })
365
+ expect(gitCalls.unshallow.length).toBe(1)
366
+ })
367
+ test('--at fetch failure with --unshallow set throws', async () => {
368
+ existsPredicate = () => true
369
+ runGitImpl = async (args) => (args[0] === 'rev-parse' ? { stdout: 'aa11bb22\n', stderr: '' } : { stdout: '', stderr: '' })
370
+ fetchResetImpl = async () => { throw new Error('nope') }
371
+ await expect(runProjectCheckout('p1', { at: 'aa11bb22', unshallow: true })).rejects.toThrow(/Cannot reach commit/)
372
+ })
373
+ })
374
+
375
+ describe('project-checkout: resolveCheckpointByName branches', () => {
376
+ test('checkpoint list HTTP error throws', async () => {
377
+ existsPredicate = () => true
378
+ runGitImpl = async () => { throw new Error('not a sha') }
379
+ fetchHandler = () => ({ ok: false, status: 403 })
380
+ await expect(runProjectCheckout('p1', { at: 'foo' })).rejects.toThrow(/Failed to list checkpoints: HTTP 403/)
381
+ })
382
+ test('no matching checkpoint throws', async () => {
383
+ existsPredicate = () => true
384
+ runGitImpl = async () => { throw new Error('not a sha') }
385
+ fetchHandler = () => ({ ok: true, json: async () => ({ checkpoints: [], hasMore: false }) })
386
+ await expect(runProjectCheckout('p1', { at: 'foo' })).rejects.toThrow(/No checkpoint matches/)
387
+ })
388
+ test('matches by commitSha prefix', async () => {
389
+ existsPredicate = () => true
390
+ runGitImpl = async (args) => { if (args[0] === 'rev-parse') throw new Error('not a sha'); return { stdout: '', stderr: '' } }
391
+ fetchHandler = () => ({ ok: true, json: async () => ({
392
+ checkpoints: [{ id: 'c', commitSha: 'feed00001111', name: null, commitMessage: null, createdAt: 'n' }],
393
+ hasMore: false,
394
+ }) })
395
+ await runProjectCheckout('p1', { at: 'feed0000' })
396
+ expect(gitCalls.fetchReset[0].branch).toBe('feed00001111')
397
+ })
398
+ test('matches by commitMessage substring', async () => {
399
+ existsPredicate = () => true
400
+ runGitImpl = async (args) => { if (args[0] === 'rev-parse') throw new Error('not a sha'); return { stdout: '', stderr: '' } }
401
+ fetchHandler = () => ({ ok: true, json: async () => ({
402
+ checkpoints: [{ id: 'c', commitSha: '99887766aabb', name: null, commitMessage: 'Fix the LOGIN bug', createdAt: 'n' }],
403
+ hasMore: false,
404
+ }) })
405
+ await runProjectCheckout('p1', { at: 'login' })
406
+ expect(gitCalls.fetchReset[0].branch).toBe('99887766aabb')
407
+ })
408
+ })
409
+
410
+ // ════════════════════════════════════════════════════════════════════════════
411
+ // commands/project-push.ts
412
+ // ════════════════════════════════════════════════════════════════════════════
413
+ describe('project-push', () => {
414
+ test('throws without projectId', async () => {
415
+ await expect(runProjectPush('', {})).rejects.toThrow(/projectId is required/)
416
+ })
417
+ test('throws when source dir missing', async () => {
418
+ existsPredicate = () => false
419
+ await expect(runProjectPush('p1', {})).rejects.toThrow(/Source directory does not exist/)
420
+ })
421
+ test('happy path uploads and prints success summary', async () => {
422
+ existsPredicate = () => true
423
+ uploadAllImpl = async () => ({ uploaded: 5, deleted: 0, errors: [] })
424
+ await runProjectPush('p1', {})
425
+ expect(lastTransportOpts.projectId).toBe('p1')
426
+ })
427
+ test('deleteRemote flag forwarded to uploadAll', async () => {
428
+ existsPredicate = () => true
429
+ let seen: any = null
430
+ uploadAllImpl = async (o) => { seen = o; return { uploaded: 1, deleted: 2, errors: [] } }
431
+ await runProjectPush('p1', { deleteRemote: true })
432
+ expect(seen.deleteRemote).toBe(true)
433
+ })
434
+ test('include csv parsed into array', async () => {
435
+ existsPredicate = () => true
436
+ await runProjectPush('p1', { include: 'src, dist ,, README.md' })
437
+ expect(lastTransportOpts.include).toEqual(['src', 'dist', 'README.md'])
438
+ })
439
+ test('error summary prints first 5 + overflow note', async () => {
440
+ existsPredicate = () => true
441
+ const errors = Array.from({ length: 7 }, (_, i) => ({ path: `f${i}`, message: 'boom' }))
442
+ uploadAllImpl = async () => ({ uploaded: 0, deleted: 0, errors })
443
+ await runProjectPush('p1', {})
444
+ expect(lastTransportOpts.projectId).toBe('p1')
445
+ })
446
+ test('onProgress callback handles upload/delete/other + byte formats', async () => {
447
+ existsPredicate = () => true
448
+ uploadAllImpl = async (o) => {
449
+ const cb = o.ctor.onProgress
450
+ cb({ kind: 'upload', index: 0, total: 3, path: 'a', bytes: 512 }) // <1KB
451
+ cb({ kind: 'delete', index: 1, total: 3, path: 'b', bytes: 2048 }) // KB
452
+ cb({ kind: 'scan', index: 2, total: 0, path: 'c', bytes: 5 * 1024 * 1024 }) // MB, total 0
453
+ cb({ kind: 'upload', index: 2, total: 3, path: 'd', bytes: null }) // no bytes
454
+ return { uploaded: 1, deleted: 1, errors: [] }
455
+ }
456
+ await runProjectPush('p1', {})
457
+ expect(lastTransportOpts.projectId).toBe('p1')
458
+ })
459
+ })
460
+
461
+ afterAll(() => {
462
+ mock.restore()
463
+ })