@shogo-ai/worker 1.7.4

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.
@@ -0,0 +1,104 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * `shogo runtime` subcommands — manage the locally-installed
5
+ * agent-runtime binary that the worker spawns per-project.
6
+ *
7
+ * shogo runtime install [--channel <stable|beta|nightly>] [--version <x>] [--force] [--base-url <url>]
8
+ * shogo runtime version
9
+ * shogo runtime where
10
+ * shogo runtime update [--channel <...>]
11
+ *
12
+ * The runtime is AGPL-3.0-or-later (see packages/agent-runtime). The
13
+ * worker (MIT) installs it as a separate on-disk binary and spawns it
14
+ * as a child process — no library link.
15
+ */
16
+ import pc from 'picocolors';
17
+ import {
18
+ type Channel,
19
+ detectTarget,
20
+ getRuntimePaths,
21
+ installRuntime,
22
+ readInstalledVersion,
23
+ } from '../lib/runtime-install.ts';
24
+ import { resolveRuntime, formatMissingRuntimeError } from '../lib/runtime-resolver.ts';
25
+
26
+ interface InstallFlags {
27
+ channel?: Channel;
28
+ version?: string;
29
+ baseUrl?: string;
30
+ force?: boolean;
31
+ }
32
+
33
+ export async function runRuntimeInstall(flags: InstallFlags = {}): Promise<void> {
34
+ const result = await installRuntime({
35
+ channel: flags.channel,
36
+ version: flags.version,
37
+ baseUrl: flags.baseUrl,
38
+ force: flags.force,
39
+ });
40
+ console.log();
41
+ console.log(pc.green('✓'), `agent-runtime ${pc.bold(result.version)} installed (${result.target})`);
42
+ console.log(` ${pc.dim('path: ')} ${result.binPath}`);
43
+ console.log(` ${pc.dim('source: ')} ${result.source}`);
44
+ console.log(` ${pc.dim('sha256: ')} ${result.sha256}`);
45
+ console.log(` ${pc.dim('channel: ')} ${result.channel}`);
46
+ }
47
+
48
+ export function runRuntimeVersion(): void {
49
+ const installed = readInstalledVersion();
50
+ if (!installed) {
51
+ console.log(pc.yellow('No agent-runtime installed.'));
52
+ console.log(`Run ${pc.cyan('shogo runtime install')} to download the latest.`);
53
+ process.exitCode = 1;
54
+ return;
55
+ }
56
+ console.log(`${pc.bold('agent-runtime')} ${installed.version}`);
57
+ console.log(` ${pc.dim('target: ')} ${installed.target}`);
58
+ console.log(` ${pc.dim('channel: ')} ${installed.channel}`);
59
+ console.log(` ${pc.dim('installed at:')} ${installed.installedAt}`);
60
+ console.log(` ${pc.dim('source: ')} ${installed.source}`);
61
+ }
62
+
63
+ export function runRuntimeWhere(): void {
64
+ const resolved = resolveRuntime();
65
+ const paths = getRuntimePaths();
66
+ if (!resolved) {
67
+ console.log(pc.yellow('agent-runtime binary not found on this machine.'));
68
+ console.log();
69
+ console.log(pc.dim('Default install path:'), paths.runtimeBin);
70
+ console.log();
71
+ console.log(formatMissingRuntimeError());
72
+ process.exitCode = 1;
73
+ return;
74
+ }
75
+ console.log(resolved.path);
76
+ if (process.env.SHOGO_DEBUG || process.env.VERBOSE) {
77
+ console.log(pc.dim(` (resolved via: ${resolved.source})`));
78
+ }
79
+ }
80
+
81
+ interface UpdateFlags {
82
+ channel?: Channel;
83
+ baseUrl?: string;
84
+ }
85
+
86
+ export async function runRuntimeUpdate(flags: UpdateFlags = {}): Promise<void> {
87
+ const installed = readInstalledVersion();
88
+ const targetChannel = flags.channel ?? installed?.channel ?? 'stable';
89
+ if (installed) {
90
+ console.log(
91
+ `${pc.dim('current:')} ${installed.version} (${installed.target}, ${installed.channel})`,
92
+ );
93
+ } else {
94
+ console.log(pc.dim('No existing install — installing fresh...'));
95
+ }
96
+ // installRuntime resolves the latest in-channel version on its own;
97
+ // pass force:true so we always reinstall when the user explicitly
98
+ // ran `update` (matches `npm update` / `brew upgrade` muscle memory).
99
+ await runRuntimeInstall({ channel: targetChannel, baseUrl: flags.baseUrl, force: true });
100
+ }
101
+
102
+ export function getDetectedTarget(): string {
103
+ return detectTarget();
104
+ }
@@ -0,0 +1,252 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * `shogo worker start` — pair this machine with Shogo Cloud.
5
+ *
6
+ * Two execution modes:
7
+ *
8
+ * --foreground Run the tunnel + runtime-manager in this process,
9
+ * log to stdout, exit on SIGINT/SIGTERM. This is the
10
+ * shape used inside the spawned detached child below
11
+ * (so we never duplicate the wire-up code) and the
12
+ * shape `shogo runtime install && shogo worker start --foreground`
13
+ * uses for CI / `systemd --user` setups.
14
+ *
15
+ * (default) Detach: re-spawn `shogo worker start --foreground`
16
+ * as a background process via `spawnWorker`, write
17
+ * the pid file, return immediately. The PID file is
18
+ * what `shogo worker stop / status / logs` poll.
19
+ *
20
+ * Foreground path responsibilities (in order):
21
+ * 1. Resolve config (api key, cloud url, name, worker dir).
22
+ * 2. Apply HTTPS_PROXY into the process env so outbound fetch picks
23
+ * it up via Node/Bun's automatic dispatcher.
24
+ * 3. Locate the AGPL agent-runtime binary on disk; abort with a
25
+ * friendly install hint if missing.
26
+ * 4. Optional --debug preflight (proxy reachability + cloud ping).
27
+ * 5. Construct WorkerRuntimeManager with cloud-routed default spawn
28
+ * config (every per-project runtime gets cloudUrl + apiKey).
29
+ * 6. Construct WorkerTunnel with the runtime manager as its resolver.
30
+ * 7. Install signal handlers (SIGINT / SIGTERM / SIGHUP) that stop
31
+ * the tunnel, stop all per-project runtimes, then exit.
32
+ * 8. Wait forever; the cloud drives traffic in via the tunnel WS.
33
+ */
34
+ import pc from 'picocolors';
35
+ import { existsSync } from 'node:fs';
36
+ import { resolveConfig } from '../lib/config.ts';
37
+ import { spawnWorker } from '../lib/process-manager.ts';
38
+ import { makeChecks, runPreflight } from '../lib/preflight.ts';
39
+ import { resolveProxy, applyProxyToEnv } from '../lib/transport.ts';
40
+ import { resolveRuntime, formatMissingRuntimeError } from '../lib/runtime-resolver.ts';
41
+ import { WorkerRuntimeManager, type ProjectSpawnConfig } from '../lib/runtime-manager.ts';
42
+ import { WorkerTunnel } from '../lib/tunnel.ts';
43
+
44
+ export interface StartFlags {
45
+ name?: string;
46
+ workerDir?: string;
47
+ apiKey?: string;
48
+ cloudUrl?: string;
49
+ port?: string;
50
+ proxy?: string;
51
+ project?: string;
52
+ runtimeBin?: string;
53
+ debug?: boolean;
54
+ foreground?: boolean;
55
+ }
56
+
57
+ export async function runStart(flags: StartFlags): Promise<void> {
58
+ const cfg = resolveConfig({
59
+ name: flags.name,
60
+ workerDir: flags.workerDir,
61
+ apiKey: flags.apiKey,
62
+ cloudUrl: flags.cloudUrl,
63
+ port: flags.port ? parseInt(flags.port, 10) : undefined,
64
+ });
65
+
66
+ const proxy = resolveProxy(flags.proxy);
67
+
68
+ if (!flags.foreground) {
69
+ // Detached default — re-launch this same CLI with --foreground in
70
+ // a child process so the user gets their shell back. The actual
71
+ // tunnel + runtime-manager work happens in `runStartForeground()`
72
+ // below in the spawned process.
73
+ return runDetached({ cfg, proxy, flags });
74
+ }
75
+
76
+ // Foreground path: surface a friendly missing-binary error BEFORE
77
+ // we open the tunnel — the binary is always required and it's a
78
+ // much better failure mode to tell the user up front than to fail
79
+ // on the first inbound /agent/* request.
80
+ const resolved = resolveRuntime({ flag: flags.runtimeBin });
81
+ if (!resolved) {
82
+ console.error(pc.red(formatMissingRuntimeError({ flag: flags.runtimeBin })));
83
+ process.exit(1);
84
+ }
85
+
86
+ applyProxyToEnv(process.env, proxy);
87
+
88
+ if (flags.debug) {
89
+ const ok = await runPreflight(
90
+ makeChecks({
91
+ cloudUrl: cfg.cloudUrl,
92
+ apiKey: cfg.apiKey,
93
+ workerDir: cfg.workerDir,
94
+ proxy,
95
+ }),
96
+ );
97
+ if (!ok) process.exit(1);
98
+ }
99
+
100
+ console.log(pc.bold('\nShogo Worker — Starting'));
101
+ console.log(pc.dim(' name ') + cfg.name);
102
+ console.log(pc.dim(' worker-dir ') + cfg.workerDir);
103
+ console.log(pc.dim(' cloud ') + cfg.cloudUrl);
104
+ console.log(pc.dim(' runtime ') + `${resolved.path} ${pc.dim(`(via ${resolved.source})`)}`);
105
+ if (flags.project) console.log(pc.dim(' project ') + flags.project);
106
+ if (proxy) {
107
+ console.log(pc.dim(' proxy ') + `${proxy.url} ${pc.dim(`(from ${proxy.source})`)}`);
108
+ }
109
+ console.log('');
110
+
111
+ const defaultSpawnConfig: ProjectSpawnConfig = {
112
+ cloudUrl: cfg.cloudUrl,
113
+ apiKey: cfg.apiKey,
114
+ // No projectDir on the worker — the agent-runtime fetches workspace
115
+ // state from the cloud using its API key. CWD defaults to a tmp
116
+ // dir per `WorkerRuntimeManager.resolveCwd()`.
117
+ };
118
+
119
+ const runtimeManager = new WorkerRuntimeManager({
120
+ runtimeBin: flags.runtimeBin,
121
+ defaultSpawnConfig,
122
+ });
123
+
124
+ // Eagerly resolve so the cached `resolved` is reused — also exits early
125
+ // if a race deleted the binary between the check above and now.
126
+ if (!runtimeManager.resolveBinary()) {
127
+ console.error(pc.red(formatMissingRuntimeError({ flag: flags.runtimeBin })));
128
+ process.exit(1);
129
+ }
130
+
131
+ const tunnel = new WorkerTunnel({
132
+ apiKey: cfg.apiKey,
133
+ cloudUrl: cfg.cloudUrl,
134
+ name: cfg.name,
135
+ kind: 'cli-worker',
136
+ resolver: runtimeManager,
137
+ onAuthRevoked: (reason) => {
138
+ console.error(pc.red(`✗ Cloud auth revoked: ${reason}`));
139
+ console.error(pc.dim(` Run \`shogo login\` to re-authenticate; this worker will keep polling at the auth-failure backoff until then.`));
140
+ },
141
+ });
142
+
143
+ let shuttingDown = false;
144
+ const shutdown = async (signal: NodeJS.Signals) => {
145
+ if (shuttingDown) return;
146
+ shuttingDown = true;
147
+ console.log(pc.dim(`\nReceived ${signal} — shutting down...`));
148
+ try { tunnel.stop(); } catch { /* already stopped */ }
149
+ try { await runtimeManager.stopAll(); } catch (err: any) {
150
+ console.warn(pc.yellow(`stopAll: ${err?.message ?? err}`));
151
+ }
152
+ process.exit(0);
153
+ };
154
+ process.once('SIGINT', () => void shutdown('SIGINT'));
155
+ process.once('SIGTERM', () => void shutdown('SIGTERM'));
156
+ process.once('SIGHUP', () => void shutdown('SIGHUP'));
157
+
158
+ tunnel.start();
159
+ console.log(pc.green('✓ Worker running. Ctrl-C to stop.'));
160
+
161
+ // Pin the foreground process. The shutdown handler above will
162
+ // process.exit() when the user terminates.
163
+ await new Promise<void>(() => { /* never resolves */ });
164
+ }
165
+
166
+ interface DetachedOpts {
167
+ cfg: { apiKey: string; cloudUrl: string; name: string; workerDir: string; port: number };
168
+ proxy: ReturnType<typeof resolveProxy>;
169
+ flags: StartFlags;
170
+ }
171
+
172
+ /**
173
+ * Detach implementation: figure out the right argv to re-invoke this
174
+ * CLI with `worker start --foreground`, spawn it via `spawnWorker`
175
+ * (which writes the PID file + redirects stdio to ~/.shogo/logs/),
176
+ * then exit. The child becomes the long-running worker.
177
+ */
178
+ function runDetached({ cfg, proxy, flags }: DetachedOpts): void {
179
+ const { entry, runner } = resolveSelfEntry();
180
+
181
+ const env: NodeJS.ProcessEnv = applyProxyToEnv(
182
+ {
183
+ ...process.env,
184
+ SHOGO_API_KEY: cfg.apiKey,
185
+ SHOGO_CLOUD_URL: cfg.cloudUrl,
186
+ SHOGO_INSTANCE_NAME: cfg.name,
187
+ SHOGO_WORKER_DIR: cfg.workerDir,
188
+ SHOGO_LOCAL_MODE: 'true',
189
+ PORT: String(cfg.port),
190
+ },
191
+ proxy,
192
+ );
193
+
194
+ // We pass `--foreground` to the spawned child so it takes the
195
+ // foreground branch above. Anything else the user passed is also
196
+ // forwarded so e.g. `--project <id>` survives detachment.
197
+ const argv = buildChildArgv(flags);
198
+
199
+ const { pid } = spawnWorker({
200
+ entry,
201
+ runner,
202
+ env: { ...env, SHOGO_DETACHED_ARGS: argv.join(' ') },
203
+ cwd: cfg.workerDir,
204
+ detach: true,
205
+ inheritStdio: false,
206
+ });
207
+
208
+ console.log(pc.bold('\nShogo Worker — Started'));
209
+ console.log(pc.dim(' pid: ') + pid);
210
+ console.log(pc.dim(' name: ') + cfg.name);
211
+ console.log(pc.dim(' logs: ') + '~/.shogo/logs/worker.log');
212
+ console.log(pc.dim(' stop: ') + 'shogo worker stop');
213
+ }
214
+
215
+ function buildChildArgv(flags: StartFlags): string[] {
216
+ const out: string[] = ['worker', 'start', '--foreground'];
217
+ if (flags.name) out.push('--name', flags.name);
218
+ if (flags.workerDir) out.push('--worker-dir', flags.workerDir);
219
+ if (flags.apiKey) out.push('--api-key', flags.apiKey);
220
+ if (flags.cloudUrl) out.push('--cloud-url', flags.cloudUrl);
221
+ if (flags.port) out.push('--port', flags.port);
222
+ if (flags.proxy) out.push('--proxy', flags.proxy);
223
+ if (flags.project) out.push('--project', flags.project);
224
+ if (flags.runtimeBin) out.push('--runtime-bin', flags.runtimeBin);
225
+ if (flags.debug) out.push('--debug');
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * Find the entry point + runner the detached child should use.
231
+ *
232
+ * Priority:
233
+ * 1. The currently-executing argv[1] if it's an existing file (eg.
234
+ * a globally installed `shogo` bin or `bun src/cli.ts` in the
235
+ * monorepo).
236
+ * 2. The compiled binary at /usr/local/bin/shogo on PATH (best-effort).
237
+ * 3. The bin shim shipped with this package.
238
+ */
239
+ function resolveSelfEntry(): { entry: string; runner: 'bun' | 'node' } {
240
+ // process.execPath is the bun/node binary; argv[1] is the script.
241
+ const execPath = process.execPath;
242
+ const isBun = /\bbun(?:-[^/\\]*)?$/.test(execPath) || typeof (globalThis as any).Bun !== 'undefined';
243
+ const argvScript = process.argv[1];
244
+ if (argvScript && existsSync(argvScript)) {
245
+ return { entry: argvScript, runner: isBun ? 'bun' : 'node' };
246
+ }
247
+ // Fallback: the compiled bin shim shipped with the package.
248
+ // Resolved relative to this file via import.meta.url so it works
249
+ // both in monorepo and when published.
250
+ const shim = new URL('../../bin/shogo.mjs', import.meta.url).pathname;
251
+ return { entry: shim, runner: isBun ? 'bun' : 'node' };
252
+ }
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ import pc from 'picocolors';
4
+ import { readPid, isRunning } from '../lib/process-manager.ts';
5
+ import { loadConfig } from '../lib/config.ts';
6
+
7
+ export async function runStatus(): Promise<void> {
8
+ const pid = readPid();
9
+ if (!pid) {
10
+ console.log(pc.yellow('● stopped') + pc.dim(' (no pid file)'));
11
+ return;
12
+ }
13
+ if (!isRunning(pid)) {
14
+ console.log(pc.red('● dead') + pc.dim(` (stale pid ${pid})`));
15
+ return;
16
+ }
17
+ const cfg = loadConfig();
18
+ console.log(pc.green('● running') + pc.dim(` (pid ${pid})`));
19
+ if (cfg.name) console.log(pc.dim(` name: `) + cfg.name);
20
+ if (cfg.cloudUrl) console.log(pc.dim(` cloud: `) + cfg.cloudUrl);
21
+ if (cfg.workerDir) console.log(pc.dim(` dir: `) + cfg.workerDir);
22
+ }
@@ -0,0 +1,13 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ import pc from 'picocolors';
4
+ import { stopWorker } from '../lib/process-manager.ts';
5
+
6
+ export async function runStop(): Promise<void> {
7
+ const { killedPid } = stopWorker('SIGTERM');
8
+ if (killedPid === null) {
9
+ console.log(pc.dim('No worker running.'));
10
+ return;
11
+ }
12
+ console.log(pc.green(`✓ Worker stopped (pid=${killedPid}).`));
13
+ }
@@ -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
+ });