@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,136 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
4
+ import { mkdtempSync, rmSync, writeFileSync, statSync, existsSync, readFileSync, mkdirSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+
8
+ /**
9
+ * The config/credentials store reads HOME at module-load time. To test
10
+ * different home dirs we mock `paths.ts` per-test so config.ts picks up
11
+ * a tmpdir-rooted CONFIG_FILE / HOME_DIR pair without us having to
12
+ * mutate process.env.HOME (which is racy across the rest of the suite).
13
+ */
14
+
15
+ let tmp: string;
16
+
17
+ function setupTmpHome(): string {
18
+ tmp = mkdtempSync(join(tmpdir(), 'shogo-config-'));
19
+ const homeDir = join(tmp, '.shogo');
20
+ const configFile = join(homeDir, 'config.json');
21
+ mock.module('../paths.ts', () => ({
22
+ HOME_DIR: homeDir,
23
+ CONFIG_FILE: configFile,
24
+ CREDENTIALS_FILE: join(homeDir, 'credentials.json'),
25
+ DEVICE_ID_FILE: join(homeDir, 'device-id'),
26
+ PID_FILE: join(homeDir, 'worker.pid'),
27
+ LOGS_DIR: join(homeDir, 'logs'),
28
+ WORKER_LOG: join(homeDir, 'logs', 'worker.log'),
29
+ WORKER_ERR: join(homeDir, 'logs', 'worker.err.log'),
30
+ RUNTIME_DIR: join(homeDir, 'runtime'),
31
+ RUNTIME_BIN: join(homeDir, 'runtime', 'agent-runtime'),
32
+ RUNTIME_VERSION_FILE: join(homeDir, 'runtime', 'version.json'),
33
+ ensureHome: () => {
34
+ mkdirSync(homeDir, { recursive: true, mode: 0o700 });
35
+ mkdirSync(join(homeDir, 'logs'), { recursive: true, mode: 0o700 });
36
+ },
37
+ ensureRuntimeDir: () => {
38
+ mkdirSync(join(homeDir, 'runtime'), { recursive: true, mode: 0o700 });
39
+ },
40
+ }));
41
+ return configFile;
42
+ }
43
+
44
+ describe('config: load + save round-trip', () => {
45
+ let configFile: string;
46
+ beforeEach(() => {
47
+ configFile = setupTmpHome();
48
+ });
49
+ afterEach(() => {
50
+ mock.restore();
51
+ rmSync(tmp, { recursive: true, force: true });
52
+ });
53
+
54
+ it('loadConfig returns {} when no file exists', async () => {
55
+ const { loadConfig } = await import('../config.ts');
56
+ expect(loadConfig()).toEqual({});
57
+ });
58
+
59
+ it('saveConfig writes JSON with mode 0600', async () => {
60
+ const { saveConfig, loadConfig } = await import('../config.ts');
61
+ saveConfig({ apiKey: 'shogo_sk_test', cloudUrl: 'https://example.com' });
62
+
63
+ expect(existsSync(configFile)).toBe(true);
64
+ const parsed = JSON.parse(readFileSync(configFile, 'utf-8'));
65
+ expect(parsed.apiKey).toBe('shogo_sk_test');
66
+ expect(parsed.cloudUrl).toBe('https://example.com');
67
+ if (process.platform !== 'win32') {
68
+ const st = statSync(configFile);
69
+ expect(st.mode & 0o077).toBe(0);
70
+ }
71
+ expect(loadConfig().apiKey).toBe('shogo_sk_test');
72
+ });
73
+
74
+ it('loadConfig throws on corrupt JSON', async () => {
75
+ mkdirSync(join(tmp, '.shogo'), { recursive: true });
76
+ writeFileSync(configFile, '{not json');
77
+ const { loadConfig } = await import('../config.ts');
78
+ expect(() => loadConfig()).toThrow(/Corrupt config/);
79
+ });
80
+
81
+ it('mergeConfig prefers override over base, drops undefined', async () => {
82
+ const { mergeConfig } = await import('../config.ts');
83
+ expect(
84
+ mergeConfig({ apiKey: 'a', cloudUrl: 'old' }, { cloudUrl: 'new', name: undefined }),
85
+ ).toEqual({ apiKey: 'a', cloudUrl: 'new' });
86
+ });
87
+ });
88
+
89
+ describe('config: resolveConfig precedence', () => {
90
+ let prevEnvKey: string | undefined;
91
+
92
+ beforeEach(() => {
93
+ setupTmpHome();
94
+ prevEnvKey = process.env.SHOGO_API_KEY;
95
+ delete process.env.SHOGO_API_KEY;
96
+ });
97
+ afterEach(() => {
98
+ mock.restore();
99
+ if (prevEnvKey === undefined) delete process.env.SHOGO_API_KEY;
100
+ else process.env.SHOGO_API_KEY = prevEnvKey;
101
+ rmSync(tmp, { recursive: true, force: true });
102
+ });
103
+
104
+ it('throws when no API key resolves anywhere', async () => {
105
+ const { resolveConfig } = await import('../config.ts');
106
+ expect(() => resolveConfig()).toThrow(/No API key/);
107
+ });
108
+
109
+ it('reads API key from explicit override (highest precedence)', async () => {
110
+ const { resolveConfig, saveConfig } = await import('../config.ts');
111
+ saveConfig({ apiKey: 'shogo_sk_file' });
112
+ process.env.SHOGO_API_KEY = 'shogo_sk_env';
113
+ expect(resolveConfig({ apiKey: 'shogo_sk_override' }).apiKey).toBe('shogo_sk_override');
114
+ });
115
+
116
+ it('falls back to env var when no override is given', async () => {
117
+ const { resolveConfig, saveConfig } = await import('../config.ts');
118
+ saveConfig({ apiKey: 'shogo_sk_file' });
119
+ process.env.SHOGO_API_KEY = 'shogo_sk_env';
120
+ expect(resolveConfig().apiKey).toBe('shogo_sk_env');
121
+ });
122
+
123
+ it('falls back to file when no override or env is set', async () => {
124
+ const { resolveConfig, saveConfig } = await import('../config.ts');
125
+ saveConfig({ apiKey: 'shogo_sk_file' });
126
+ expect(resolveConfig().apiKey).toBe('shogo_sk_file');
127
+ });
128
+
129
+ it('fills defaults for cloudUrl + port', async () => {
130
+ const { resolveConfig, saveConfig } = await import('../config.ts');
131
+ saveConfig({ apiKey: 'shogo_sk_file' });
132
+ const cfg = resolveConfig();
133
+ expect(cfg.cloudUrl).toBe('https://studio.shogo.ai');
134
+ expect(cfg.port).toBe(8002);
135
+ });
136
+ });
@@ -0,0 +1,112 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
4
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { resolveRuntime, formatMissingRuntimeError } from '../runtime-resolver.ts';
8
+
9
+ /**
10
+ * Resolver priority chain:
11
+ * 1. --runtime-bin flag
12
+ * 2. SHOGO_AGENT_RUNTIME_BIN env var
13
+ * 3. ~/.shogo/runtime/agent-runtime (the "home" path; we can't easily
14
+ * override that without mocking paths.ts, so we just assert it's
15
+ * consulted via the missing-binary message).
16
+ * 4. PATH search for the system bin name
17
+ *
18
+ * These tests construct a tmpdir with a fake executable and exercise the
19
+ * first two + the PATH branch directly.
20
+ */
21
+
22
+ describe('runtime-resolver: resolveRuntime', () => {
23
+ let tmp: string;
24
+ let realBin: string;
25
+ let dummyDir: string;
26
+ let dummyBin: string;
27
+
28
+ beforeEach(() => {
29
+ tmp = mkdtempSync(join(tmpdir(), 'shogo-resolver-'));
30
+ realBin = join(tmp, 'fake-runtime');
31
+ writeFileSync(realBin, '#!/bin/sh\necho hi\n');
32
+ chmodSync(realBin, 0o755);
33
+
34
+ dummyDir = join(tmp, 'pathdir');
35
+ mkdirSync(dummyDir);
36
+ dummyBin = join(dummyDir, 'shogo-agent-runtime');
37
+ writeFileSync(dummyBin, '#!/bin/sh\necho hi\n');
38
+ chmodSync(dummyBin, 0o755);
39
+ });
40
+
41
+ afterEach(() => {
42
+ rmSync(tmp, { recursive: true, force: true });
43
+ });
44
+
45
+ it('--runtime-bin flag wins when set and the file is executable', () => {
46
+ const result = resolveRuntime({ flag: realBin, env: { PATH: '' } });
47
+ expect(result?.path).toBe(realBin);
48
+ expect(result?.source).toBe('flag');
49
+ });
50
+
51
+ it('falls through when the flag points at a non-existent path', () => {
52
+ const result = resolveRuntime({
53
+ flag: join(tmp, 'does-not-exist'),
54
+ env: { SHOGO_AGENT_RUNTIME_BIN: realBin, PATH: '' },
55
+ });
56
+ expect(result?.path).toBe(realBin);
57
+ expect(result?.source).toBe('env');
58
+ });
59
+
60
+ it('falls through when the flag points at a non-executable file', () => {
61
+ const nonExec = join(tmp, 'non-exec');
62
+ writeFileSync(nonExec, 'plain text');
63
+ chmodSync(nonExec, 0o644);
64
+
65
+ const result = resolveRuntime({
66
+ flag: nonExec,
67
+ env: { SHOGO_AGENT_RUNTIME_BIN: realBin, PATH: '' },
68
+ });
69
+ expect(result?.source).toBe('env');
70
+ });
71
+
72
+ it('SHOGO_AGENT_RUNTIME_BIN wins when no flag is set', () => {
73
+ const result = resolveRuntime({ env: { SHOGO_AGENT_RUNTIME_BIN: realBin, PATH: '' } });
74
+ expect(result?.path).toBe(realBin);
75
+ expect(result?.source).toBe('env');
76
+ });
77
+
78
+ it('falls back to PATH when flag/env/home are all unavailable', () => {
79
+ const result = resolveRuntime({
80
+ env: { PATH: dummyDir },
81
+ systemBinName: 'shogo-agent-runtime',
82
+ });
83
+ expect(result?.path).toBe(dummyBin);
84
+ expect(result?.source).toBe('path');
85
+ });
86
+
87
+ it('returns null when nothing resolves', () => {
88
+ const result = resolveRuntime({
89
+ env: { PATH: tmp /* tmp has no bin matching the system name */ },
90
+ systemBinName: 'definitely-not-on-path-xyz',
91
+ });
92
+ expect(result).toBeNull();
93
+ });
94
+ });
95
+
96
+ describe('runtime-resolver: formatMissingRuntimeError', () => {
97
+ it('mentions the flag if one was passed', () => {
98
+ const msg = formatMissingRuntimeError({ flag: '/tmp/foo' });
99
+ expect(msg).toContain('--runtime-bin /tmp/foo');
100
+ });
101
+
102
+ it('mentions SHOGO_AGENT_RUNTIME_BIN when set', () => {
103
+ const msg = formatMissingRuntimeError({ env: { SHOGO_AGENT_RUNTIME_BIN: '/opt/foo' } });
104
+ expect(msg).toContain('SHOGO_AGENT_RUNTIME_BIN');
105
+ expect(msg).toContain('/opt/foo');
106
+ });
107
+
108
+ it('always includes the install hint', () => {
109
+ const msg = formatMissingRuntimeError();
110
+ expect(msg).toContain('shogo runtime install');
111
+ });
112
+ });
@@ -0,0 +1,36 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Finds the apps/api entry to spawn as the tunnel host.
5
+ *
6
+ * In the monorepo (dev), we resolve the workspace sibling.
7
+ * When published, the worker ships a bundled apps/api copy at ./dist/api/entry.js.
8
+ */
9
+ import { existsSync } from 'node:fs';
10
+ import { dirname, join, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+
15
+ interface Resolved {
16
+ entry: string;
17
+ runner: 'bun' | 'node';
18
+ mode: 'monorepo' | 'bundled';
19
+ }
20
+
21
+ export function findApiEntry(): Resolved {
22
+ const packageRoot = resolve(__dirname, '..', '..');
23
+ const monorepoRoot = resolve(packageRoot, '..', '..');
24
+ const monorepoEntry = join(monorepoRoot, 'apps', 'api', 'src', 'entry.ts');
25
+ const bundledEntry = join(packageRoot, 'dist', 'api', 'entry.js');
26
+
27
+ if (existsSync(bundledEntry)) {
28
+ return { entry: bundledEntry, runner: 'node', mode: 'bundled' };
29
+ }
30
+ if (existsSync(monorepoEntry)) {
31
+ return { entry: monorepoEntry, runner: 'bun', mode: 'monorepo' };
32
+ }
33
+ throw new Error(
34
+ `Cannot locate apps/api entry. Looked in:\n ${bundledEntry}\n ${monorepoEntry}`,
35
+ );
36
+ }
@@ -0,0 +1,321 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * CLI cloud-login — pure poll-based device flow.
5
+ *
6
+ * The CLI asks the cloud to mint a one-time pending-state, opens the
7
+ * bridge page in the user's browser, then polls the cloud until the
8
+ * bridge page approves the request — at which point the cloud returns
9
+ * the minted device-tagged API key exactly once and discards the
10
+ * state. The Shogo desktop app uses the exact same handshake (driven
11
+ * from `apps/desktop/src/main.ts → runCloudSignIn`); the only thing
12
+ * that differs is the `client=cli` URL hint we send so the bridge
13
+ * page renders the right copy.
14
+ *
15
+ * Sequence:
16
+ * 1. CLI → POST /api/cli/login/start (with device metadata)
17
+ * cloud stores pending state, replies { state, userCode, authUrl,
18
+ * expiresInMs, pollIntervalMs }.
19
+ * 2. CLI prints the URL + userCode and opens it in the browser.
20
+ * 3. User signs in at <cloudUrl>/auth/cli-link?state=... and clicks
21
+ * "Approve". The bridge page POSTs /api/cli/login/approve which
22
+ * mints the key on the cloud and pins it to `state`.
23
+ * 4. CLI polls GET /api/cli/login/poll?state=... at pollIntervalMs:
24
+ * pending → keep polling
25
+ * approved → returns { key, email, workspace, deviceId } once,
26
+ * then the state is deleted.
27
+ * denied → user clicked "Cancel" on the bridge page.
28
+ * expired → 5-minute TTL elapsed.
29
+ *
30
+ * Notes:
31
+ * - The userCode (last 6 hex of state, uppercased) is shown in both
32
+ * the CLI and the browser so the user can confirm the device they
33
+ * are approving matches the terminal they typed `shogo login` in.
34
+ * - No localhost listener, no protocol handler, no deep links —
35
+ * works behind firewalls / over SSH / from a remote tmux session.
36
+ */
37
+ import { spawn } from 'node:child_process';
38
+ import { hostname, platform as osPlatform, arch } from 'node:os';
39
+ import pc from 'picocolors';
40
+
41
+ export interface CloudLoginResult {
42
+ /** The minted shogo_sk_ key. */
43
+ key: string;
44
+ /** User email reported by cloud. */
45
+ email: string | null;
46
+ /** Workspace name the key was minted for. */
47
+ workspace: string | null;
48
+ /** Stable device id we sent up; cloud echoes it back. */
49
+ deviceId: string;
50
+ }
51
+
52
+ export interface CloudLoginOptions {
53
+ cloudUrl: string;
54
+ /** "cli" | "desktop" — hint sent to the cloud and echoed on the
55
+ * bridge page so the UI can label the approval flow correctly.
56
+ * Default: "cli". */
57
+ client?: 'cli' | 'desktop';
58
+ /** Override the device label shown in the dashboard. Defaults to hostname. */
59
+ deviceName?: string;
60
+ /** Override the platform string shown in the dashboard
61
+ * (e.g. "darwin-arm64"). Defaults to `${os.platform()}-${os.arch()}`. */
62
+ devicePlatform?: string;
63
+ /** Stable id for this machine. Caller should persist this so re-logins
64
+ * dedupe to the same device row. */
65
+ deviceId: string;
66
+ /** App version label sent to cloud. */
67
+ appVersion?: string;
68
+ /** Optional pre-select for the bridge picker. */
69
+ workspaceId?: string;
70
+ /** Cap on total wait — defaults to whatever the cloud says (usually 5min). */
71
+ timeoutMs?: number;
72
+ /** Browser-open behaviour. Three modes:
73
+ * - undefined / true: spawn the platform default opener (open /
74
+ * xdg-open / cmd start). Suitable for the CLI.
75
+ * - false: never open the browser; caller surfaces the URL.
76
+ * - function: custom opener (e.g. Electron's `shell.openExternal`).
77
+ * Errors thrown from the function are swallowed so flaky openers
78
+ * don't kill the flow — the URL is still printed via `log`. */
79
+ openBrowser?: boolean | ((url: string) => void | Promise<void>);
80
+ /** Custom logger — defaults to console.log. */
81
+ log?: (line: string) => void;
82
+ /** Override poll interval in ms. Defaults to whatever the cloud returns. */
83
+ pollIntervalMs?: number;
84
+ /** Install process-level SIGINT/SIGTERM handlers that abort the poll
85
+ * loop. Default: true (right for the CLI). Set false when embedding
86
+ * inside a long-lived host process (e.g. Electron main) where the
87
+ * caller already manages signal lifecycle and an unrelated Ctrl+C
88
+ * shouldn't unwind the host. */
89
+ installSignalHandlers?: boolean;
90
+ /** Test seam: replace the real fetch. */
91
+ fetchImpl?: typeof fetch;
92
+ /** Test seam: an AbortSignal to stop polling early. */
93
+ abortSignal?: AbortSignal;
94
+ }
95
+
96
+ export class CloudLoginError extends Error {
97
+ constructor(
98
+ message: string,
99
+ public readonly kind: 'timeout' | 'denied' | 'cancelled' | 'expired' | 'transport',
100
+ ) {
101
+ super(message);
102
+ this.name = 'CloudLoginError';
103
+ }
104
+ }
105
+
106
+ interface StartResponse {
107
+ ok: boolean;
108
+ error?: string;
109
+ state: string;
110
+ userCode: string;
111
+ authUrl: string;
112
+ expiresInMs: number;
113
+ pollIntervalMs: number;
114
+ }
115
+
116
+ interface PollResponse {
117
+ ok: boolean;
118
+ status: 'pending' | 'approved' | 'denied' | 'expired';
119
+ key?: string;
120
+ email?: string | null;
121
+ workspace?: string | null;
122
+ deviceId?: string;
123
+ error?: string;
124
+ }
125
+
126
+ export async function runCloudLogin(opts: CloudLoginOptions): Promise<CloudLoginResult> {
127
+ const cloudUrl = opts.cloudUrl.replace(/\/$/, '');
128
+ const client = opts.client ?? 'cli';
129
+ const deviceName = opts.deviceName ?? hostname();
130
+ const devicePlatform = opts.devicePlatform ?? `${osPlatform()}-${arch()}`;
131
+ const appVersion = opts.appVersion ?? readWorkerVersion();
132
+ const log = opts.log ?? ((line: string) => console.log(line));
133
+ const fetchImpl = opts.fetchImpl ?? fetch;
134
+
135
+ // 1. Start
136
+ const startRes = await fetchImpl(`${cloudUrl}/api/cli/login/start`, {
137
+ method: 'POST',
138
+ headers: { 'content-type': 'application/json' },
139
+ body: JSON.stringify({
140
+ deviceId: opts.deviceId,
141
+ deviceName,
142
+ devicePlatform,
143
+ deviceAppVersion: appVersion,
144
+ workspaceId: opts.workspaceId,
145
+ client,
146
+ }),
147
+ signal: AbortSignal.timeout(10_000),
148
+ }).catch((err) => {
149
+ throw new CloudLoginError(
150
+ `Cannot reach Shogo Cloud at ${cloudUrl}: ${err?.message ?? err}`,
151
+ 'transport',
152
+ );
153
+ });
154
+ if (!startRes.ok) {
155
+ const text = await startRes.text().catch(() => '');
156
+ throw new CloudLoginError(
157
+ `Cloud rejected /api/cli/login/start (HTTP ${startRes.status}): ${text || 'no body'}`,
158
+ 'transport',
159
+ );
160
+ }
161
+ const start = (await startRes.json().catch(() => ({} as any))) as StartResponse;
162
+ if (!start?.ok || !start.state || !start.authUrl) {
163
+ throw new CloudLoginError(
164
+ `Cloud returned a malformed start response: ${start?.error ?? JSON.stringify(start)}`,
165
+ 'transport',
166
+ );
167
+ }
168
+
169
+ const pollIntervalMs = clampPollInterval(opts.pollIntervalMs ?? start.pollIntervalMs);
170
+ const timeoutMs = opts.timeoutMs ?? start.expiresInMs;
171
+ const deadline = Date.now() + timeoutMs;
172
+
173
+ // 2. Print + open browser
174
+ log('');
175
+ log(pc.bold('Sign in to Shogo Cloud'));
176
+ log(pc.dim(' cloud: ') + cloudUrl);
177
+ log(pc.dim(' device: ') + `${deviceName} (${devicePlatform})`);
178
+ log(pc.dim(' user code: ') + pc.cyan(start.userCode));
179
+ log('');
180
+ log(' Open this URL in your browser to approve:');
181
+ log(' ' + pc.cyan(start.authUrl));
182
+ log('');
183
+
184
+ if (opts.openBrowser !== false) {
185
+ if (typeof opts.openBrowser === 'function') {
186
+ // Caller-supplied opener (e.g. Electron `shell.openExternal`). We
187
+ // intentionally swallow errors here so a flaky opener doesn't
188
+ // kill the flow — the URL is already in `log` for copy/paste.
189
+ try {
190
+ await opts.openBrowser(start.authUrl);
191
+ } catch { /* user can copy/paste */ }
192
+ } else {
193
+ openInBrowser(start.authUrl).catch(() => { /* user can copy/paste */ });
194
+ }
195
+ }
196
+
197
+ log(pc.dim('Waiting for approval...'));
198
+
199
+ // 3. Poll loop
200
+ const installSignals = opts.installSignalHandlers ?? true;
201
+ const sigHandler = () => {
202
+ // We can't actually throw out of a signal handler — Node won't
203
+ // propagate it up the stack. Setting the abort signal is the right
204
+ // way to unwind the poll loop.
205
+ abortLocal.abort();
206
+ };
207
+ // Internal abort controller composes the caller's signal with our
208
+ // own signal-handler-driven cancellation, so the poll loop only
209
+ // needs to watch one signal.
210
+ const abortLocal = new AbortController();
211
+ if (opts.abortSignal) {
212
+ if (opts.abortSignal.aborted) abortLocal.abort();
213
+ else opts.abortSignal.addEventListener('abort', () => abortLocal.abort(), { once: true });
214
+ }
215
+ if (installSignals) {
216
+ process.once('SIGINT', sigHandler);
217
+ process.once('SIGTERM', sigHandler);
218
+ }
219
+
220
+ try {
221
+ while (true) {
222
+ if (abortLocal.signal.aborted) {
223
+ throw new CloudLoginError('Aborted by caller.', 'cancelled');
224
+ }
225
+ if (Date.now() >= deadline) {
226
+ throw new CloudLoginError(
227
+ `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for approval.`,
228
+ 'timeout',
229
+ );
230
+ }
231
+
232
+ const pollRes = await fetchImpl(
233
+ `${cloudUrl}/api/cli/login/poll?state=${encodeURIComponent(start.state)}`,
234
+ {
235
+ method: 'GET',
236
+ headers: { accept: 'application/json' },
237
+ signal: AbortSignal.timeout(10_000),
238
+ },
239
+ ).catch((err) => {
240
+ // Soft network errors during polling shouldn't kill the flow —
241
+ // user might have a flaky connection. Log and back off.
242
+ log(pc.dim(` (poll error: ${err?.message ?? err} — retrying)`));
243
+ return null;
244
+ });
245
+
246
+ if (pollRes && pollRes.ok) {
247
+ const data = (await pollRes.json().catch(() => ({} as any))) as PollResponse;
248
+ if (data?.status === 'approved' && data.key) {
249
+ return {
250
+ key: data.key,
251
+ email: data.email ?? null,
252
+ workspace: data.workspace ?? null,
253
+ deviceId: data.deviceId ?? opts.deviceId,
254
+ };
255
+ }
256
+ if (data?.status === 'denied') {
257
+ throw new CloudLoginError('Sign-in was denied in the browser.', 'denied');
258
+ }
259
+ if (data?.status === 'expired') {
260
+ throw new CloudLoginError('Sign-in request expired before it was approved.', 'expired');
261
+ }
262
+ // status === 'pending' (or unknown) → keep polling
263
+ }
264
+
265
+ await sleep(pollIntervalMs, abortLocal.signal);
266
+ }
267
+ } finally {
268
+ if (installSignals) {
269
+ process.removeListener('SIGINT', sigHandler);
270
+ process.removeListener('SIGTERM', sigHandler);
271
+ }
272
+ }
273
+ }
274
+
275
+ function clampPollInterval(ms: number): number {
276
+ if (!Number.isFinite(ms) || ms <= 0) return 2000;
277
+ return Math.min(Math.max(ms, 1000), 10_000);
278
+ }
279
+
280
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
281
+ return new Promise((resolve, reject) => {
282
+ if (signal?.aborted) return reject(new CloudLoginError('Aborted by caller.', 'cancelled'));
283
+ const t = setTimeout(() => {
284
+ signal?.removeEventListener('abort', onAbort);
285
+ resolve();
286
+ }, ms);
287
+ const onAbort = () => {
288
+ clearTimeout(t);
289
+ reject(new CloudLoginError('Aborted by caller.', 'cancelled'));
290
+ };
291
+ signal?.addEventListener('abort', onAbort, { once: true });
292
+ });
293
+ }
294
+
295
+ function readWorkerVersion(): string {
296
+ try {
297
+ const url = new URL('../../package.json', import.meta.url);
298
+ const pkg = JSON.parse(require('node:fs').readFileSync(url, 'utf-8'));
299
+ return pkg?.version ? `shogo-cli/${pkg.version}` : 'shogo-cli/unknown';
300
+ } catch {
301
+ return 'shogo-cli/unknown';
302
+ }
303
+ }
304
+
305
+ function openInBrowser(url: string): Promise<void> {
306
+ return new Promise((resolve) => {
307
+ const cmd =
308
+ process.platform === 'darwin' ? 'open'
309
+ : process.platform === 'win32' ? 'cmd'
310
+ : 'xdg-open';
311
+ const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url];
312
+ try {
313
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
314
+ child.on('error', () => resolve());
315
+ child.unref();
316
+ resolve();
317
+ } catch {
318
+ resolve();
319
+ }
320
+ });
321
+ }
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Reads/writes ~/.shogo/config.json. The worker reads this at start,
5
+ * and the `config` / `login` commands write to it.
6
+ */
7
+ import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
8
+ import { hostname } from 'node:os';
9
+ import { CONFIG_FILE, ensureHome } from './paths.ts';
10
+
11
+ export interface WorkerConfig {
12
+ apiKey?: string;
13
+ cloudUrl?: string;
14
+ name?: string;
15
+ workerDir?: string;
16
+ port?: number;
17
+ }
18
+
19
+ const DEFAULTS: Required<Pick<WorkerConfig, 'cloudUrl' | 'port'>> = {
20
+ cloudUrl: 'https://studio.shogo.ai',
21
+ port: 8002,
22
+ };
23
+
24
+ export function loadConfig(): WorkerConfig {
25
+ if (!existsSync(CONFIG_FILE)) return {};
26
+ try {
27
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
28
+ } catch (err) {
29
+ throw new Error(`Corrupt config at ${CONFIG_FILE}: ${(err as Error).message}`);
30
+ }
31
+ }
32
+
33
+ export function saveConfig(cfg: WorkerConfig): void {
34
+ ensureHome();
35
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 });
36
+ chmodSync(CONFIG_FILE, 0o600);
37
+ }
38
+
39
+ export function mergeConfig(base: WorkerConfig, override: WorkerConfig): WorkerConfig {
40
+ return { ...base, ...Object.fromEntries(Object.entries(override).filter(([, v]) => v !== undefined)) };
41
+ }
42
+
43
+ export function resolveConfig(override: WorkerConfig = {}): Required<WorkerConfig> & { apiKey: string } {
44
+ const fileCfg = loadConfig();
45
+ const env: WorkerConfig = {
46
+ apiKey: process.env.SHOGO_API_KEY,
47
+ cloudUrl: process.env.SHOGO_CLOUD_URL,
48
+ name: process.env.SHOGO_INSTANCE_NAME,
49
+ workerDir: process.env.SHOGO_WORKER_DIR,
50
+ port: process.env.PORT ? parseInt(process.env.PORT, 10) : undefined,
51
+ };
52
+ const merged = mergeConfig(mergeConfig(fileCfg, env), override);
53
+ if (!merged.apiKey) {
54
+ throw new Error('No API key. Run `shogo login` or pass --api-key / set SHOGO_API_KEY.');
55
+ }
56
+ return {
57
+ apiKey: merged.apiKey,
58
+ cloudUrl: merged.cloudUrl ?? DEFAULTS.cloudUrl,
59
+ name: merged.name ?? hostname(),
60
+ workerDir: merged.workerDir ?? process.cwd(),
61
+ port: merged.port ?? DEFAULTS.port,
62
+ };
63
+ }