@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.
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/shogo.mjs +38 -0
- package/package.json +56 -0
- package/src/cli.ts +127 -0
- package/src/commands/config.ts +29 -0
- package/src/commands/login.ts +126 -0
- package/src/commands/logs.ts +24 -0
- package/src/commands/runtime.ts +104 -0
- package/src/commands/start.ts +252 -0
- package/src/commands/status.ts +22 -0
- package/src/commands/stop.ts +13 -0
- package/src/lib/__tests__/cloud-login.test.ts +218 -0
- package/src/lib/__tests__/config.test.ts +136 -0
- package/src/lib/__tests__/runtime-resolver.test.ts +112 -0
- package/src/lib/api-discovery.ts +36 -0
- package/src/lib/cloud-login.ts +321 -0
- package/src/lib/config.ts +63 -0
- package/src/lib/device-id.ts +27 -0
- package/src/lib/paths.ts +35 -0
- package/src/lib/preflight.ts +158 -0
- package/src/lib/process-manager.ts +123 -0
- package/src/lib/runtime-install.ts +371 -0
- package/src/lib/runtime-manager.ts +645 -0
- package/src/lib/runtime-resolver.ts +136 -0
- package/src/lib/transport.ts +202 -0
- package/src/lib/tunnel.ts +664 -0
|
@@ -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
|
+
});
|