@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,92 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Smoke tests for the MIT `shogo` agent launcher (argv + env shape).
|
|
5
|
+
* buildAgentSpawn is pure, so no spawn / network / runtime binary is needed.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect } from 'bun:test'
|
|
8
|
+
import { resolve as resolvePath } from 'node:path'
|
|
9
|
+
import { buildAgentSpawn } from '../../commands/agent.ts'
|
|
10
|
+
import type { ResolvedRuntime } from '../runtime-resolver.ts'
|
|
11
|
+
|
|
12
|
+
const runtime: ResolvedRuntime = { path: '/home/u/.shogo/runtime/agent-runtime', source: 'home' }
|
|
13
|
+
const config = { apiKey: 'shogo_sk_test123', cloudUrl: 'https://studio.shogo.ai' }
|
|
14
|
+
|
|
15
|
+
describe('buildAgentSpawn', () => {
|
|
16
|
+
test('interactive launch: argv + billing env point at the proxy', () => {
|
|
17
|
+
const plan = buildAgentSpawn({
|
|
18
|
+
flags: { cwd: '/work/repo' },
|
|
19
|
+
config,
|
|
20
|
+
runtime,
|
|
21
|
+
baseEnv: {},
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
expect(plan.bin).toBe('/home/u/.shogo/runtime/agent-runtime')
|
|
25
|
+
expect(plan.args).toEqual(['interactive'])
|
|
26
|
+
expect(plan.cwd).toBe(resolvePath('/work/repo'))
|
|
27
|
+
|
|
28
|
+
expect(plan.env.SHOGO_INTERACTIVE).toBe('1')
|
|
29
|
+
expect(plan.env.SHOGO_INTERACTIVE_CWD).toBe(resolvePath('/work/repo'))
|
|
30
|
+
expect(plan.env.PROJECT_DIR).toBe(resolvePath('/work/repo'))
|
|
31
|
+
expect(plan.env.WORKSPACE_DIR).toBe(resolvePath('/work/repo'))
|
|
32
|
+
expect(plan.env.SHOGO_API_URL).toBe('https://studio.shogo.ai')
|
|
33
|
+
expect(plan.env.SHOGO_API_KEY).toBe('shogo_sk_test123')
|
|
34
|
+
expect(plan.env.AI_PROXY_URL).toBe('https://studio.shogo.ai/api/ai/v1')
|
|
35
|
+
expect(plan.env.AI_PROXY_TOKEN).toBe('shogo_sk_test123')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('headless -p adds the print argv + SHOGO_PRINT_PROMPT', () => {
|
|
39
|
+
const plan = buildAgentSpawn({
|
|
40
|
+
flags: { print: 'review this', cwd: '/work/repo' },
|
|
41
|
+
config,
|
|
42
|
+
runtime,
|
|
43
|
+
baseEnv: {},
|
|
44
|
+
})
|
|
45
|
+
expect(plan.args).toEqual(['interactive', '-p', 'review this'])
|
|
46
|
+
expect(plan.env.SHOGO_PRINT_PROMPT).toBe('review this')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('--no-tui and --model are threaded through', () => {
|
|
50
|
+
const plan = buildAgentSpawn({
|
|
51
|
+
flags: { noTui: true, model: 'claude-sonnet', cwd: '/work/repo' },
|
|
52
|
+
config,
|
|
53
|
+
runtime,
|
|
54
|
+
baseEnv: {},
|
|
55
|
+
})
|
|
56
|
+
expect(plan.args).toEqual(['interactive', '--no-tui'])
|
|
57
|
+
expect(plan.env.SHOGO_MODEL).toBe('claude-sonnet')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('trailing slash on cloudUrl is stripped before building proxy URL', () => {
|
|
61
|
+
const plan = buildAgentSpawn({
|
|
62
|
+
flags: { cwd: '/work/repo' },
|
|
63
|
+
config: { apiKey: 'shogo_sk_x', cloudUrl: 'https://example.dev/' },
|
|
64
|
+
runtime,
|
|
65
|
+
baseEnv: {},
|
|
66
|
+
})
|
|
67
|
+
expect(plan.env.SHOGO_API_URL).toBe('https://example.dev')
|
|
68
|
+
expect(plan.env.AI_PROXY_URL).toBe('https://example.dev/api/ai/v1')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('base env is preserved (e.g. PATH) and not clobbered', () => {
|
|
72
|
+
const plan = buildAgentSpawn({
|
|
73
|
+
flags: { cwd: '/work/repo' },
|
|
74
|
+
config,
|
|
75
|
+
runtime,
|
|
76
|
+
baseEnv: { PATH: '/usr/bin', HOME: '/home/u' },
|
|
77
|
+
})
|
|
78
|
+
expect(plan.env.PATH).toBe('/usr/bin')
|
|
79
|
+
expect(plan.env.HOME).toBe('/home/u')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('empty -p (no value) still routes to headless argv', () => {
|
|
83
|
+
const plan = buildAgentSpawn({
|
|
84
|
+
flags: { print: '', cwd: '/work/repo' },
|
|
85
|
+
config,
|
|
86
|
+
runtime,
|
|
87
|
+
baseEnv: {},
|
|
88
|
+
})
|
|
89
|
+
expect(plan.args).toEqual(['interactive', '-p', ''])
|
|
90
|
+
expect(plan.env.SHOGO_PRINT_PROMPT).toBe('')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Coverage gaps for cloud-login.ts.
|
|
5
|
+
*
|
|
6
|
+
* Targets:
|
|
7
|
+
* L201-205 sigHandler body (SIGINT/SIGTERM aborts the poll loop)
|
|
8
|
+
* L239-244 poll fetch network error → .catch swallows and retries
|
|
9
|
+
* L287-289 sleep() onAbort handler (signal fires DURING sleep)
|
|
10
|
+
* L323-337 openInBrowser (default openBrowser path)
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from 'bun:test';
|
|
13
|
+
import { runCloudLogin, CloudLoginError } from '../cloud-login.ts';
|
|
14
|
+
|
|
15
|
+
function jsonResponse(body: unknown, status = 200) {
|
|
16
|
+
return new Response(JSON.stringify(body), {
|
|
17
|
+
status,
|
|
18
|
+
headers: { 'content-type': 'application/json' },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const startBody = {
|
|
23
|
+
ok: true,
|
|
24
|
+
state: 'state-abc',
|
|
25
|
+
userCode: 'XXXXXX',
|
|
26
|
+
authUrl: 'https://cloud.example.com/auth/cli-link?state=state-abc',
|
|
27
|
+
expiresInMs: 60_000,
|
|
28
|
+
pollIntervalMs: 1,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('runCloudLogin — sigHandler (L201-205)', () => {
|
|
32
|
+
it('SIGINT during the poll loop unwinds via cancelled CloudLoginError', async () => {
|
|
33
|
+
// Issue SIGINT after start; the sigHandler should abort the local controller.
|
|
34
|
+
let pollCount = 0;
|
|
35
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
36
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
37
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
38
|
+
return jsonResponse(startBody);
|
|
39
|
+
}
|
|
40
|
+
pollCount += 1;
|
|
41
|
+
if (pollCount === 1) {
|
|
42
|
+
// Fire SIGINT in the next microtask while the loop is about to sleep
|
|
43
|
+
setImmediate(() => process.emit('SIGINT' as any));
|
|
44
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
45
|
+
}
|
|
46
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
47
|
+
}) as unknown as typeof fetch;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await runCloudLogin({
|
|
51
|
+
cloudUrl: 'https://cloud.example.com',
|
|
52
|
+
deviceId: 'dev-abc',
|
|
53
|
+
openBrowser: false,
|
|
54
|
+
pollIntervalMs: 100,
|
|
55
|
+
installSignalHandlers: true,
|
|
56
|
+
log: () => {},
|
|
57
|
+
fetchImpl,
|
|
58
|
+
});
|
|
59
|
+
throw new Error('should have thrown');
|
|
60
|
+
} catch (err) {
|
|
61
|
+
expect(err).toBeInstanceOf(CloudLoginError);
|
|
62
|
+
expect((err as CloudLoginError).kind).toBe('cancelled');
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('runCloudLogin — poll network error .catch (L239-244)', () => {
|
|
68
|
+
it('soft network error during poll is logged and the loop retries until approved', async () => {
|
|
69
|
+
let pollCount = 0;
|
|
70
|
+
const logs: string[] = [];
|
|
71
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
72
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
73
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
74
|
+
return jsonResponse(startBody);
|
|
75
|
+
}
|
|
76
|
+
pollCount += 1;
|
|
77
|
+
if (pollCount === 1) {
|
|
78
|
+
throw new Error('ECONNRESET on first poll');
|
|
79
|
+
}
|
|
80
|
+
return jsonResponse({
|
|
81
|
+
ok: true, status: 'approved', key: 'shogo_sk_recovered',
|
|
82
|
+
email: 'u@e.com', workspace: 'w', deviceId: 'd',
|
|
83
|
+
});
|
|
84
|
+
}) as unknown as typeof fetch;
|
|
85
|
+
|
|
86
|
+
const result = await runCloudLogin({
|
|
87
|
+
cloudUrl: 'https://cloud.example.com',
|
|
88
|
+
deviceId: 'd',
|
|
89
|
+
openBrowser: false,
|
|
90
|
+
pollIntervalMs: 1,
|
|
91
|
+
installSignalHandlers: false,
|
|
92
|
+
log: (s) => logs.push(s),
|
|
93
|
+
fetchImpl,
|
|
94
|
+
});
|
|
95
|
+
expect(result.key).toBe('shogo_sk_recovered');
|
|
96
|
+
expect(logs.some((l) => l.includes('poll error'))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('runCloudLogin — sleep onAbort (L287-289)', () => {
|
|
101
|
+
it('abort signal fires DURING the inter-poll sleep → cancelled CloudLoginError', async () => {
|
|
102
|
+
// Use a long pollIntervalMs so sleep is in flight when we abort.
|
|
103
|
+
const ac = new AbortController();
|
|
104
|
+
let pollCount = 0;
|
|
105
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
106
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
107
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
108
|
+
return jsonResponse({ ...startBody, pollIntervalMs: 5000 });
|
|
109
|
+
}
|
|
110
|
+
pollCount += 1;
|
|
111
|
+
if (pollCount === 1) {
|
|
112
|
+
// After this poll, sleep(5000) begins. Schedule abort 20ms later.
|
|
113
|
+
setTimeout(() => ac.abort(), 20);
|
|
114
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
115
|
+
}
|
|
116
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
117
|
+
}) as unknown as typeof fetch;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await runCloudLogin({
|
|
121
|
+
cloudUrl: 'https://cloud.example.com',
|
|
122
|
+
deviceId: 'd',
|
|
123
|
+
openBrowser: false,
|
|
124
|
+
pollIntervalMs: 5000, // long sleep
|
|
125
|
+
installSignalHandlers: false,
|
|
126
|
+
log: () => {},
|
|
127
|
+
fetchImpl,
|
|
128
|
+
abortSignal: ac.signal,
|
|
129
|
+
});
|
|
130
|
+
throw new Error('should have thrown');
|
|
131
|
+
} catch (err) {
|
|
132
|
+
expect(err).toBeInstanceOf(CloudLoginError);
|
|
133
|
+
expect((err as CloudLoginError).kind).toBe('cancelled');
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('runCloudLogin — openInBrowser default path (L323-337)', () => {
|
|
139
|
+
it('with openBrowser undefined: invokes openInBrowser (default path) without breaking the flow', async () => {
|
|
140
|
+
// Default openBrowser triggers spawn('xdg-open' | 'open' | 'cmd'). On a
|
|
141
|
+
// sandbox the spawn may fail or succeed; in BOTH cases the .catch on the
|
|
142
|
+
// call site swallows it. We're after coverage of L323-334 / L335-337.
|
|
143
|
+
let pollCount = 0;
|
|
144
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
145
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
146
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
147
|
+
return jsonResponse(startBody);
|
|
148
|
+
}
|
|
149
|
+
pollCount += 1;
|
|
150
|
+
return jsonResponse({
|
|
151
|
+
ok: true, status: 'approved', key: 'shogo_sk_via_browser',
|
|
152
|
+
email: 'u@e.com', workspace: 'w', deviceId: 'd',
|
|
153
|
+
});
|
|
154
|
+
}) as unknown as typeof fetch;
|
|
155
|
+
|
|
156
|
+
const result = await runCloudLogin({
|
|
157
|
+
cloudUrl: 'https://cloud.example.com',
|
|
158
|
+
deviceId: 'd',
|
|
159
|
+
// openBrowser omitted → defaults to true → openInBrowser runs
|
|
160
|
+
pollIntervalMs: 1,
|
|
161
|
+
installSignalHandlers: false,
|
|
162
|
+
log: () => {},
|
|
163
|
+
fetchImpl,
|
|
164
|
+
});
|
|
165
|
+
expect(result.key).toBe('shogo_sk_via_browser');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('openInBrowser swallows synchronous spawn errors via the outer try/catch (L335-337)', async () => {
|
|
169
|
+
// Force spawn to throw synchronously by temporarily replacing it via
|
|
170
|
+
// process.binding-style override. The cleanest cross-runtime way: monkey-
|
|
171
|
+
// patch the resolved module's spawn. cloud-login imports `spawn` at the
|
|
172
|
+
// top, so we can't change it post-import. Instead, indirectly: pass a
|
|
173
|
+
// platform that resolves to a binary that almost certainly does NOT exist
|
|
174
|
+
// — and rely on the catch branch firing if spawn throws.
|
|
175
|
+
//
|
|
176
|
+
// In practice on the sandbox `spawn('xdg-open', ...)` resolves to ENOENT
|
|
177
|
+
// asynchronously via child.on('error'), not synchronously, so this test
|
|
178
|
+
// mainly documents the contract: a synchronous spawn failure is silently
|
|
179
|
+
// swallowed and the flow still completes.
|
|
180
|
+
let pollCount = 0;
|
|
181
|
+
const fetchImpl = (async (input: RequestInfo | URL) => {
|
|
182
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
183
|
+
if (url.endsWith('/api/cli/login/start')) {
|
|
184
|
+
return jsonResponse(startBody);
|
|
185
|
+
}
|
|
186
|
+
pollCount += 1;
|
|
187
|
+
return jsonResponse({
|
|
188
|
+
ok: true, status: 'approved', key: 'shogo_sk_browser_failed',
|
|
189
|
+
email: 'u@e.com', workspace: 'w', deviceId: 'd',
|
|
190
|
+
});
|
|
191
|
+
}) as unknown as typeof fetch;
|
|
192
|
+
|
|
193
|
+
// Custom openBrowser function that throws — runCloudLogin .catch()es it.
|
|
194
|
+
// This exercises L189-192 (custom openBrowser branch). For the actual
|
|
195
|
+
// spawn-throws branch in openInBrowser we'd need module mocking, which
|
|
196
|
+
// would conflict with the git-cloner.test.ts spawn mock running in the
|
|
197
|
+
// same Bun process. The default-openBrowser test above already exercises
|
|
198
|
+
// openInBrowser's spawn path with whatever the sandbox provides.
|
|
199
|
+
const result = await runCloudLogin({
|
|
200
|
+
cloudUrl: 'https://cloud.example.com',
|
|
201
|
+
deviceId: 'd',
|
|
202
|
+
openBrowser: () => { throw new Error('user opener boom'); },
|
|
203
|
+
pollIntervalMs: 1,
|
|
204
|
+
installSignalHandlers: false,
|
|
205
|
+
log: () => {},
|
|
206
|
+
fetchImpl,
|
|
207
|
+
});
|
|
208
|
+
expect(result.key).toBe('shogo_sk_browser_failed');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
import { describe, it, expect } from 'bun:test';
|
|
4
|
+
import { runCloudLogin, CloudLoginError } from '../cloud-login.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Drive `runCloudLogin` against a scripted fetch implementation so we
|
|
8
|
+
* can exercise the start → poll → approved happy path plus all four
|
|
9
|
+
* documented terminal states (denied / expired / state-mismatch via
|
|
10
|
+
* malformed responses / network error).
|
|
11
|
+
*
|
|
12
|
+
* The CLI client should:
|
|
13
|
+
* - never auto-open a browser when openBrowser:false
|
|
14
|
+
* - poll until status='approved' or terminal state
|
|
15
|
+
* - return the key + email + workspace from the approved poll exactly
|
|
16
|
+
* once and not call any further endpoints
|
|
17
|
+
* - throw CloudLoginError with the right `kind` for each failure mode
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function scripted(handlers: Array<(url: string) => Response | Promise<Response>>) {
|
|
21
|
+
let i = 0;
|
|
22
|
+
const fetchImpl = async (input: RequestInfo | URL, _init?: RequestInit) => {
|
|
23
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
24
|
+
if (i >= handlers.length) {
|
|
25
|
+
throw new Error(`scripted fetch ran out of handlers at request #${i + 1} (${url})`);
|
|
26
|
+
}
|
|
27
|
+
return handlers[i++](url);
|
|
28
|
+
};
|
|
29
|
+
return { fetchImpl: fetchImpl as unknown as typeof fetch, calls: () => i };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function jsonResponse(body: unknown, status = 200) {
|
|
33
|
+
return new Response(JSON.stringify(body), {
|
|
34
|
+
status,
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('runCloudLogin', () => {
|
|
40
|
+
it('returns the minted key on the second poll (start → pending → approved)', async () => {
|
|
41
|
+
const { fetchImpl } = scripted([
|
|
42
|
+
(url) => {
|
|
43
|
+
expect(url).toEndWith('/api/cli/login/start');
|
|
44
|
+
return jsonResponse({
|
|
45
|
+
ok: true,
|
|
46
|
+
state: 'abcdef0123456789',
|
|
47
|
+
userCode: '456789',
|
|
48
|
+
authUrl: 'https://cloud.example.com/auth/cli-link?state=abcdef0123456789',
|
|
49
|
+
expiresInMs: 60_000,
|
|
50
|
+
pollIntervalMs: 1_000,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
(url) => {
|
|
54
|
+
expect(url).toContain('/api/cli/login/poll?state=abcdef0123456789');
|
|
55
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
56
|
+
},
|
|
57
|
+
(url) => {
|
|
58
|
+
expect(url).toContain('/api/cli/login/poll?state=abcdef0123456789');
|
|
59
|
+
return jsonResponse({
|
|
60
|
+
ok: true,
|
|
61
|
+
status: 'approved',
|
|
62
|
+
key: 'shogo_sk_test_key_123',
|
|
63
|
+
email: 'user@example.com',
|
|
64
|
+
workspace: 'My Workspace',
|
|
65
|
+
deviceId: 'dev-abc',
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const result = await runCloudLogin({
|
|
71
|
+
cloudUrl: 'https://cloud.example.com',
|
|
72
|
+
deviceId: 'dev-abc',
|
|
73
|
+
openBrowser: false,
|
|
74
|
+
pollIntervalMs: 1, // tighten so the test runs fast
|
|
75
|
+
log: () => { /* silence */ },
|
|
76
|
+
fetchImpl,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result.key).toBe('shogo_sk_test_key_123');
|
|
80
|
+
expect(result.email).toBe('user@example.com');
|
|
81
|
+
expect(result.workspace).toBe('My Workspace');
|
|
82
|
+
expect(result.deviceId).toBe('dev-abc');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('throws CloudLoginError(kind="denied") when the user clicks Cancel', async () => {
|
|
86
|
+
const { fetchImpl } = scripted([
|
|
87
|
+
() =>
|
|
88
|
+
jsonResponse({
|
|
89
|
+
ok: true,
|
|
90
|
+
state: 's',
|
|
91
|
+
userCode: 'XXXXXX',
|
|
92
|
+
authUrl: 'https://cloud.example.com/auth/cli-link?state=s',
|
|
93
|
+
expiresInMs: 60_000,
|
|
94
|
+
pollIntervalMs: 1,
|
|
95
|
+
}),
|
|
96
|
+
() => jsonResponse({ ok: true, status: 'denied' }),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
runCloudLogin({
|
|
101
|
+
cloudUrl: 'https://cloud.example.com',
|
|
102
|
+
deviceId: 'dev-abc',
|
|
103
|
+
openBrowser: false,
|
|
104
|
+
pollIntervalMs: 1,
|
|
105
|
+
log: () => { /* silence */ },
|
|
106
|
+
fetchImpl,
|
|
107
|
+
}),
|
|
108
|
+
).rejects.toBeInstanceOf(CloudLoginError);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws CloudLoginError(kind="expired") when the cloud says expired', async () => {
|
|
112
|
+
const { fetchImpl } = scripted([
|
|
113
|
+
() =>
|
|
114
|
+
jsonResponse({
|
|
115
|
+
ok: true,
|
|
116
|
+
state: 's',
|
|
117
|
+
userCode: 'XXXXXX',
|
|
118
|
+
authUrl: 'https://cloud.example.com/auth/cli-link?state=s',
|
|
119
|
+
expiresInMs: 60_000,
|
|
120
|
+
pollIntervalMs: 1,
|
|
121
|
+
}),
|
|
122
|
+
() => jsonResponse({ ok: true, status: 'expired' }),
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await runCloudLogin({
|
|
127
|
+
cloudUrl: 'https://cloud.example.com',
|
|
128
|
+
deviceId: 'dev-abc',
|
|
129
|
+
openBrowser: false,
|
|
130
|
+
pollIntervalMs: 1,
|
|
131
|
+
log: () => { /* silence */ },
|
|
132
|
+
fetchImpl,
|
|
133
|
+
});
|
|
134
|
+
throw new Error('should have thrown');
|
|
135
|
+
} catch (err) {
|
|
136
|
+
expect(err).toBeInstanceOf(CloudLoginError);
|
|
137
|
+
expect((err as CloudLoginError).kind).toBe('expired');
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('respects abortSignal and throws cancelled', async () => {
|
|
142
|
+
const ac = new AbortController();
|
|
143
|
+
const { fetchImpl } = scripted([
|
|
144
|
+
() =>
|
|
145
|
+
jsonResponse({
|
|
146
|
+
ok: true,
|
|
147
|
+
state: 's',
|
|
148
|
+
userCode: 'XXXXXX',
|
|
149
|
+
authUrl: 'https://cloud.example.com/auth/cli-link?state=s',
|
|
150
|
+
expiresInMs: 60_000,
|
|
151
|
+
pollIntervalMs: 50,
|
|
152
|
+
}),
|
|
153
|
+
() => {
|
|
154
|
+
// Abort right before the next sleep so the wait raises cancelled.
|
|
155
|
+
ac.abort();
|
|
156
|
+
return jsonResponse({ ok: true, status: 'pending' });
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await runCloudLogin({
|
|
162
|
+
cloudUrl: 'https://cloud.example.com',
|
|
163
|
+
deviceId: 'dev-abc',
|
|
164
|
+
openBrowser: false,
|
|
165
|
+
pollIntervalMs: 50,
|
|
166
|
+
log: () => { /* silence */ },
|
|
167
|
+
fetchImpl,
|
|
168
|
+
abortSignal: ac.signal,
|
|
169
|
+
});
|
|
170
|
+
throw new Error('should have thrown');
|
|
171
|
+
} catch (err) {
|
|
172
|
+
expect(err).toBeInstanceOf(CloudLoginError);
|
|
173
|
+
expect((err as CloudLoginError).kind).toBe('cancelled');
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('throws transport when start endpoint is unreachable', async () => {
|
|
178
|
+
const fetchImpl = (async () => {
|
|
179
|
+
throw new Error('ECONNREFUSED');
|
|
180
|
+
}) as unknown as typeof fetch;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await runCloudLogin({
|
|
184
|
+
cloudUrl: 'https://cloud.example.com',
|
|
185
|
+
deviceId: 'dev-abc',
|
|
186
|
+
openBrowser: false,
|
|
187
|
+
pollIntervalMs: 1,
|
|
188
|
+
log: () => { /* silence */ },
|
|
189
|
+
fetchImpl,
|
|
190
|
+
});
|
|
191
|
+
throw new Error('should have thrown');
|
|
192
|
+
} catch (err) {
|
|
193
|
+
expect(err).toBeInstanceOf(CloudLoginError);
|
|
194
|
+
expect((err as CloudLoginError).kind).toBe('transport');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('throws transport when start returns malformed body', async () => {
|
|
199
|
+
const { fetchImpl } = scripted([
|
|
200
|
+
() => jsonResponse({ ok: false, error: 'bad request' }, 400),
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
await runCloudLogin({
|
|
205
|
+
cloudUrl: 'https://cloud.example.com',
|
|
206
|
+
deviceId: 'dev-abc',
|
|
207
|
+
openBrowser: false,
|
|
208
|
+
pollIntervalMs: 1,
|
|
209
|
+
log: () => { /* silence */ },
|
|
210
|
+
fetchImpl,
|
|
211
|
+
});
|
|
212
|
+
throw new Error('should have thrown');
|
|
213
|
+
} catch (err) {
|
|
214
|
+
expect(err).toBeInstanceOf(CloudLoginError);
|
|
215
|
+
expect((err as CloudLoginError).kind).toBe('transport');
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|