@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.
- package/LICENSE +21 -0
- package/README.md +432 -0
- package/bin/shogo.mjs +38 -0
- package/dist/cli.mjs +64 -0
- package/package.json +62 -0
- package/src/cli.ts +199 -0
- package/src/commands/agent.ts +123 -0
- package/src/commands/config.ts +29 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/login.ts +126 -0
- package/src/commands/logs.ts +24 -0
- package/src/commands/project-checkout.ts +179 -0
- package/src/commands/project-pull.ts +133 -0
- package/src/commands/project-push.ts +89 -0
- package/src/commands/runtime.ts +104 -0
- package/src/commands/start.ts +290 -0
- package/src/commands/status.ts +22 -0
- package/src/commands/stop.ts +13 -0
- package/src/lib/__tests__/agent-launch.test.ts +92 -0
- package/src/lib/__tests__/cloud-login-coverage.test.ts +210 -0
- package/src/lib/__tests__/cloud-login.test.ts +218 -0
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +214 -0
- package/src/lib/__tests__/config.test.ts +141 -0
- package/src/lib/__tests__/db-doctor.test.ts +204 -0
- package/src/lib/__tests__/git-cloner.test.ts +382 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
- package/src/lib/__tests__/runtime-manager-cloud-sync-gate.test.ts +119 -0
- package/src/lib/__tests__/runtime-manager-coverage-gaps.test.ts +768 -0
- package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
- package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
- package/src/lib/__tests__/runtime-manager-idle-eviction.test.ts +196 -0
- package/src/lib/__tests__/runtime-manager-restart-cap.test.ts +310 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +189 -0
- package/src/lib/__tests__/runtime-manager-wait-for-health.test.ts +269 -0
- package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
- package/src/lib/__tests__/tunnel-coverage.test.ts +1094 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/__tests__/worker-commands-greenfield.test.ts +463 -0
- package/src/lib/__tests__/worker-libs-greenfield.test.ts +355 -0
- package/src/lib/__tests__/worker-transport-greenfield.test.ts +176 -0
- package/src/lib/api-discovery.ts +36 -0
- package/src/lib/cloud-login.ts +320 -0
- package/src/lib/cloud-sync-watcher.ts +322 -0
- package/src/lib/config.ts +68 -0
- package/src/lib/db-doctor.ts +355 -0
- package/src/lib/device-id.ts +27 -0
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +53 -0
- package/src/lib/preflight.ts +158 -0
- package/src/lib/process-manager.ts +123 -0
- package/src/lib/resource-limits.ts +340 -0
- package/src/lib/runtime-install.ts +371 -0
- package/src/lib/runtime-manager.ts +1873 -0
- package/src/lib/runtime-resolver.ts +136 -0
- package/src/lib/transport.ts +202 -0
- package/src/lib/tunnel.ts +710 -0
- 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
|
+
})
|