@sigrun/sigrun 0.0.0-stage → 0.1.1
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/CHANGELOG.md +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The CLI entrypoints (Task 5): `sigrun` / `sigrun serve` / `sigrun attach`
|
|
4
|
+
* — the package's `bin` (`"sigrun": "dist/cli.js"`, plain node — build
|
|
5
|
+
* Task 3; the tsx shebang served the from-source era).
|
|
6
|
+
*
|
|
7
|
+
* - `sigrun` (run): boot harness+server IN-PROCESS (the `src/serve.ts`
|
|
8
|
+
* composition) and launch the TUI against the loopback URL of the bound
|
|
9
|
+
* port. The spec's lifecycle rule: the agent dies with the TUI — when the
|
|
10
|
+
* TUI exits, the server+harness shut down. SIGTERM/SIGINT also stop the
|
|
11
|
+
* in-process server — and, in run mode, exit the process once shutdown
|
|
12
|
+
* completes (fix wave A8: the TUI must never run against a dead server).
|
|
13
|
+
* - `sigrun serve`: the deploy composition minus the TUI — literally the
|
|
14
|
+
* `src/index.ts` entry (same graceful shutdown, same fail-loud boot).
|
|
15
|
+
* - `sigrun attach`: no harness boot; launch the TUI against the resolved
|
|
16
|
+
* server URL, carrying the resolved user into the client config.
|
|
17
|
+
*
|
|
18
|
+
* Build-and-webui Task 2: the TUI is a SPAWNED package, not an in-process
|
|
19
|
+
* import. `run`/`attach` spawn `node <resolved @sigrun/tui bin>` as a
|
|
20
|
+
* child process, passing the resolved server URL, user, and tui.json UI
|
|
21
|
+
* fields as `--flags` (the bin, `tui/dist/launch.js`, parses them itself).
|
|
22
|
+
* The parent watches the child's exit: exit → shutdown (agent-dies-with-
|
|
23
|
+
* tui), nonzero exit → exit 1. A package-resolution failure surfaces as
|
|
24
|
+
* an actionable `install @sigrun/tui` message, never a module-not-found
|
|
25
|
+
* crash — the core install is server-only unless the companion tui
|
|
26
|
+
* package is present. The old lazy `import("../tui/src/launch.js")` is
|
|
27
|
+
* gone: an in-process import would drag the tui workspace's runtime
|
|
28
|
+
* (pi-tui) into the core's resolution graph.
|
|
29
|
+
*
|
|
30
|
+
* Resolution chains (both `attach`; `run` resolves `user` the same way):
|
|
31
|
+
* - server: `--server` > tui.json `server` > `http://localhost:$SIGRUN_PORT`
|
|
32
|
+
* (default 3000)
|
|
33
|
+
* - user: `--user` > `SIGRUN_USER` > tui.json `user`
|
|
34
|
+
*
|
|
35
|
+
* The TUI entry (Task 6) is the `@sigrun/tui` package's `sigrun-tui` bin
|
|
36
|
+
* (`tui/dist/launch.js` — build-and-webui Task 2) — the CLI's default
|
|
37
|
+
* `launchTui` spawns it; everything else is real and tested through the
|
|
38
|
+
* seams (`test/cli.test.ts` injects factories — process-launch stays out
|
|
39
|
+
* of the unit tests, and `npm start` → src/index.ts is unchanged for the
|
|
40
|
+
* cluster's monolith).
|
|
41
|
+
*/
|
|
42
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
43
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
44
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
45
|
+
import { createRequire } from "node:module";
|
|
46
|
+
import { dirname, join } from "node:path";
|
|
47
|
+
import * as readline from "node:readline/promises";
|
|
48
|
+
import { Writable } from "node:stream";
|
|
49
|
+
import { resolveStoreRoots, writeFileAtomic } from "./harness/stores.js";
|
|
50
|
+
import { openIdentityStores } from "./harness/identity/index.js";
|
|
51
|
+
import { pathToFileURL } from "node:url";
|
|
52
|
+
import { serve } from "./serve.js";
|
|
53
|
+
import { ACCENT_NAMES, PALETTE_NAMES, isAccentName, isPaletteName, } from "@sigrun/shared/themes";
|
|
54
|
+
export const USAGE_TEXT = `usage: sigrun [--user <name>]
|
|
55
|
+
sigrun serve
|
|
56
|
+
sigrun attach [--server <url>] [--user <name>]
|
|
57
|
+
sigrun token create <name> --user <user>
|
|
58
|
+
sigrun token list
|
|
59
|
+
sigrun token revoke <id>
|
|
60
|
+
sigrun user add <user> [--admin] [--name <n>] [--email <e>]
|
|
61
|
+
sigrun user list
|
|
62
|
+
sigrun provider list [--url <base>] [--token <token>]
|
|
63
|
+
sigrun provider key <id> [--url <base>] [--token <token>]
|
|
64
|
+
sigrun provider clear <id> [--url <base>] [--token <token>]
|
|
65
|
+
|
|
66
|
+
provider commands are API clients: they talk to a running sigrun server
|
|
67
|
+
(--url <base>, default http://localhost:$SIGRUN_PORT or 3000) and never
|
|
68
|
+
start one; provider key reads the api key from stdin, never argv`;
|
|
69
|
+
/** The marker prefix on usage-error messages. */
|
|
70
|
+
const USAGE_ERROR_PREFIX = "usage:";
|
|
71
|
+
/**
|
|
72
|
+
* The pattern matching a usage error's message (tests assert parseArgs
|
|
73
|
+
* throws it; the dispatch prints the message + usage and exits 1).
|
|
74
|
+
*/
|
|
75
|
+
export const USAGE_ERROR_PATTERN = /^usage:/;
|
|
76
|
+
function usageError(message) {
|
|
77
|
+
return new Error(`${USAGE_ERROR_PREFIX} ${message}\n${USAGE_TEXT}`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Parse `token <sub> …` (Task 7): `create <name> --user <u>`, `list`,
|
|
81
|
+
* `revoke <id>`. The positional/flag grammar is strict — anything else
|
|
82
|
+
* is a usage error.
|
|
83
|
+
*/
|
|
84
|
+
function parseTokenArgs(argv) {
|
|
85
|
+
const sub = argv[0];
|
|
86
|
+
if (sub === "list") {
|
|
87
|
+
if (argv.length !== 1) {
|
|
88
|
+
throw usageError("token list takes no arguments");
|
|
89
|
+
}
|
|
90
|
+
return { command: "token", sub: "list" };
|
|
91
|
+
}
|
|
92
|
+
if (sub === "revoke") {
|
|
93
|
+
if (argv.length !== 2) {
|
|
94
|
+
throw usageError("token revoke takes exactly one <id>");
|
|
95
|
+
}
|
|
96
|
+
return { command: "token", sub: "revoke", id: argv[1] };
|
|
97
|
+
}
|
|
98
|
+
if (sub === "create") {
|
|
99
|
+
let tokenName;
|
|
100
|
+
let user;
|
|
101
|
+
let index = 1;
|
|
102
|
+
while (index < argv.length) {
|
|
103
|
+
const arg = argv[index];
|
|
104
|
+
if (arg === "--user") {
|
|
105
|
+
const value = argv[index + 1];
|
|
106
|
+
if (value === undefined || value.startsWith("--")) {
|
|
107
|
+
throw usageError("--user requires a value");
|
|
108
|
+
}
|
|
109
|
+
user = value;
|
|
110
|
+
index += 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (tokenName === undefined && !arg.startsWith("--")) {
|
|
114
|
+
tokenName = arg;
|
|
115
|
+
index += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
throw usageError(`unexpected argument ${JSON.stringify(arg)}`);
|
|
119
|
+
}
|
|
120
|
+
if (tokenName === undefined) {
|
|
121
|
+
throw usageError("token create requires a <name>");
|
|
122
|
+
}
|
|
123
|
+
if (user === undefined) {
|
|
124
|
+
throw usageError("token create requires --user");
|
|
125
|
+
}
|
|
126
|
+
return { command: "token", sub: "create", tokenName, user };
|
|
127
|
+
}
|
|
128
|
+
throw usageError(`unknown token subcommand ${JSON.stringify(sub ?? "")} (create, list, revoke)`);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Parse `user <sub> …` (Task 7): `add <user> [--admin] [--name <n>]
|
|
132
|
+
* [--email <e>]`, `list`. One positional (the username), flags anywhere.
|
|
133
|
+
*/
|
|
134
|
+
function parseUserArgs(argv) {
|
|
135
|
+
const sub = argv[0];
|
|
136
|
+
if (sub === "list") {
|
|
137
|
+
if (argv.length !== 1) {
|
|
138
|
+
throw usageError("user list takes no arguments");
|
|
139
|
+
}
|
|
140
|
+
return { command: "user", sub: "list" };
|
|
141
|
+
}
|
|
142
|
+
if (sub === "add") {
|
|
143
|
+
let user;
|
|
144
|
+
let admin = false;
|
|
145
|
+
let name;
|
|
146
|
+
let email;
|
|
147
|
+
let index = 1;
|
|
148
|
+
while (index < argv.length) {
|
|
149
|
+
const arg = argv[index];
|
|
150
|
+
if (arg === "--admin") {
|
|
151
|
+
admin = true;
|
|
152
|
+
index += 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (arg === "--name" || arg === "--email") {
|
|
156
|
+
const value = argv[index + 1];
|
|
157
|
+
if (value === undefined || value.startsWith("--")) {
|
|
158
|
+
throw usageError(`${arg} requires a value`);
|
|
159
|
+
}
|
|
160
|
+
if (arg === "--name")
|
|
161
|
+
name = value;
|
|
162
|
+
else
|
|
163
|
+
email = value;
|
|
164
|
+
index += 2;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (user === undefined && !arg.startsWith("--")) {
|
|
168
|
+
user = arg;
|
|
169
|
+
index += 1;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
throw usageError(`unexpected argument ${JSON.stringify(arg)}`);
|
|
173
|
+
}
|
|
174
|
+
if (user === undefined) {
|
|
175
|
+
throw usageError("user add requires a <user>");
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
command: "user",
|
|
179
|
+
sub: "add",
|
|
180
|
+
user,
|
|
181
|
+
...(admin ? { admin } : {}),
|
|
182
|
+
...(name === undefined ? {} : { name }),
|
|
183
|
+
...(email === undefined ? {} : { email }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
throw usageError(`unknown user subcommand ${JSON.stringify(sub ?? "")} (add, list)`);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Parse `provider <sub> …` (release 0.1 Task 2b): `list`, `key <id>`,
|
|
190
|
+
* `clear <id>`, each accepting `--url <base>` (a remote server; the
|
|
191
|
+
* default is `http://localhost:$SIGRUN_PORT` or 3000 — the attach
|
|
192
|
+
* shape) and `--token <t>` (the bearer for a token-method server).
|
|
193
|
+
* These commands are API clients: they NEVER start a server (the usage
|
|
194
|
+
* note says so).
|
|
195
|
+
*/
|
|
196
|
+
function parseProviderArgs(argv) {
|
|
197
|
+
const sub = argv[0];
|
|
198
|
+
if (sub !== "list" && sub !== "key" && sub !== "clear") {
|
|
199
|
+
throw usageError(`unknown provider subcommand ${JSON.stringify(sub ?? "")} (list, key, clear)`);
|
|
200
|
+
}
|
|
201
|
+
let id;
|
|
202
|
+
let url;
|
|
203
|
+
let token;
|
|
204
|
+
let index = 1;
|
|
205
|
+
while (index < argv.length) {
|
|
206
|
+
const arg = argv[index];
|
|
207
|
+
if (arg === "--url" || arg === "--token") {
|
|
208
|
+
const value = argv[index + 1];
|
|
209
|
+
if (value === undefined || value.startsWith("--")) {
|
|
210
|
+
throw usageError(`${arg} requires a value`);
|
|
211
|
+
}
|
|
212
|
+
if (arg === "--url")
|
|
213
|
+
url = value;
|
|
214
|
+
else
|
|
215
|
+
token = value;
|
|
216
|
+
index += 2;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (id === undefined && !arg.startsWith("--")) {
|
|
220
|
+
id = arg;
|
|
221
|
+
index += 1;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// NEVER echo the offending token (secret-hygiene, review P2): a
|
|
225
|
+
// `provider key acme THEKEY` typo puts the key on stderr — stderr
|
|
226
|
+
// lands in CI logs, issue reports and scrollback where shell history
|
|
227
|
+
// does not. Name the problem + the accepted shape, reproduce nothing.
|
|
228
|
+
throw usageError("unexpected argument — `provider <sub> [<id>] [--url <base>] [--token <token>]` takes one id at most, and reads the api key from stdin");
|
|
229
|
+
}
|
|
230
|
+
if (sub === "list" && id !== undefined) {
|
|
231
|
+
throw usageError("provider list takes no arguments");
|
|
232
|
+
}
|
|
233
|
+
if (sub !== "list" && id === undefined) {
|
|
234
|
+
throw usageError(`provider ${sub} requires an <id>`);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
command: "provider",
|
|
238
|
+
sub,
|
|
239
|
+
...(id === undefined ? {} : { id }),
|
|
240
|
+
...(url === undefined ? {} : { url }),
|
|
241
|
+
...(token === undefined ? {} : { token }),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Parse argv (post-node slices — no `node`/`cli.ts` entries). A pure
|
|
246
|
+
* function: bare → `run`; `serve`/`attach` as subcommands; `token`/
|
|
247
|
+
* `user`/`provider` dispatch to their own sub-parsers (Task 7 + release
|
|
248
|
+
* 0.1 Task 2b — they must be the FIRST argument); `--user` is valid on
|
|
249
|
+
* every command, `--server` only on `attach`. Unknown subcommands/flags
|
|
250
|
+
* and missing flag values throw usage errors.
|
|
251
|
+
*/
|
|
252
|
+
export function parseArgs(argv) {
|
|
253
|
+
if (argv[0] === "token")
|
|
254
|
+
return parseTokenArgs(argv.slice(1));
|
|
255
|
+
if (argv[0] === "user")
|
|
256
|
+
return parseUserArgs(argv.slice(1));
|
|
257
|
+
if (argv[0] === "provider")
|
|
258
|
+
return parseProviderArgs(argv.slice(1));
|
|
259
|
+
let command;
|
|
260
|
+
let server;
|
|
261
|
+
let user;
|
|
262
|
+
let index = 0;
|
|
263
|
+
while (index < argv.length) {
|
|
264
|
+
const arg = argv[index];
|
|
265
|
+
if (arg === "run" || arg === "serve" || arg === "attach") {
|
|
266
|
+
if (command !== undefined) {
|
|
267
|
+
throw usageError(`unexpected extra command ${JSON.stringify(arg)}`);
|
|
268
|
+
}
|
|
269
|
+
command = arg;
|
|
270
|
+
index += 1;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (arg === "--server" || arg === "--user") {
|
|
274
|
+
const value = argv[index + 1];
|
|
275
|
+
if (value === undefined || value.startsWith("--")) {
|
|
276
|
+
throw usageError(`${arg} requires a value`);
|
|
277
|
+
}
|
|
278
|
+
if (arg === "--server")
|
|
279
|
+
server = value;
|
|
280
|
+
else
|
|
281
|
+
user = value;
|
|
282
|
+
index += 2;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
throw usageError(`unknown argument ${JSON.stringify(arg)}`);
|
|
286
|
+
}
|
|
287
|
+
if (server !== undefined && command !== "attach") {
|
|
288
|
+
throw usageError("--server is only valid for attach");
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
command: command ?? "run",
|
|
292
|
+
...(server === undefined ? {} : { server }),
|
|
293
|
+
...(user === undefined ? {} : { user }),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The config dir: the ONE config-root chain (`resolveStoreRoots`,
|
|
298
|
+
* spec Decision 3) — `SIGRUN_CONFIG_DIR` → `$XDG_CONFIG_HOME`/sigrun
|
|
299
|
+
* → `~/.config/sigrun`, identical to `defaultSettingsDir()` in
|
|
300
|
+
* src/harness/settings.ts (both delegate to the same resolver, so
|
|
301
|
+
* settings/auth/packages/git can never split from the store roots);
|
|
302
|
+
* `SIGRUN_CONFIG_DIR` itself stays env-only.
|
|
303
|
+
*/
|
|
304
|
+
export function defaultConfigDir(env) {
|
|
305
|
+
return resolveStoreRoots(env).configDir;
|
|
306
|
+
}
|
|
307
|
+
/** Read `tui.json` (see {@link TuiFile}); `env` defaults to process.env. */
|
|
308
|
+
export async function loadTuiFile(env) {
|
|
309
|
+
const path = join(defaultConfigDir(env), "tui.json");
|
|
310
|
+
let raw;
|
|
311
|
+
try {
|
|
312
|
+
raw = await readFile(path, "utf8");
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// Absent file is the normal case (ENOENT); any read failure is
|
|
316
|
+
// treated as absent — a malformed FILE is the fail-loud case below.
|
|
317
|
+
return {};
|
|
318
|
+
}
|
|
319
|
+
let parsed;
|
|
320
|
+
try {
|
|
321
|
+
parsed = JSON.parse(raw);
|
|
322
|
+
}
|
|
323
|
+
catch (cause) {
|
|
324
|
+
throw new Error(`tui.json: ${path} is not valid JSON (${cause instanceof Error ? cause.message : String(cause)})`);
|
|
325
|
+
}
|
|
326
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
327
|
+
throw new Error(`tui.json: ${path} must contain a JSON object`);
|
|
328
|
+
}
|
|
329
|
+
const record = parsed;
|
|
330
|
+
const user = record.user;
|
|
331
|
+
const server = record.server;
|
|
332
|
+
const token = record.token;
|
|
333
|
+
if (user !== undefined && typeof user !== "string") {
|
|
334
|
+
throw new Error(`tui.json: user must be a string`);
|
|
335
|
+
}
|
|
336
|
+
if (server !== undefined && typeof server !== "string") {
|
|
337
|
+
throw new Error(`tui.json: server must be a string`);
|
|
338
|
+
}
|
|
339
|
+
if (token !== undefined && typeof token !== "string") {
|
|
340
|
+
throw new Error(`tui.json: token must be a string`);
|
|
341
|
+
}
|
|
342
|
+
// UI refresh (C1): validate each client field, failing loud naming the
|
|
343
|
+
// key — the shared themes module owns the palette/accent name sets.
|
|
344
|
+
const palette = record.palette;
|
|
345
|
+
if (palette !== undefined && !isPaletteName(palette)) {
|
|
346
|
+
throw new Error(`tui.json: palette must be one of ${PALETTE_NAMES.join(", ")}`);
|
|
347
|
+
}
|
|
348
|
+
const accent = record.accent;
|
|
349
|
+
if (accent !== undefined && !isAccentName(accent)) {
|
|
350
|
+
throw new Error(`tui.json: accent must be one of ${ACCENT_NAMES.join(", ")}`);
|
|
351
|
+
}
|
|
352
|
+
const mode = record.mode;
|
|
353
|
+
if (mode !== undefined && mode !== "dark" && mode !== "light") {
|
|
354
|
+
throw new Error(`tui.json: mode must be one of dark, light`);
|
|
355
|
+
}
|
|
356
|
+
const keymap = record.keymap;
|
|
357
|
+
if (keymap !== undefined &&
|
|
358
|
+
keymap !== "standard" &&
|
|
359
|
+
keymap !== "vim" &&
|
|
360
|
+
keymap !== "emacs") {
|
|
361
|
+
throw new Error(`tui.json: keymap must be one of standard, vim, emacs`);
|
|
362
|
+
}
|
|
363
|
+
// Any string: the editor command's existence/validation is C4's job.
|
|
364
|
+
const editor = record.editor;
|
|
365
|
+
if (editor !== undefined && typeof editor !== "string") {
|
|
366
|
+
throw new Error(`tui.json: editor must be a string`);
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
...(user === undefined ? {} : { user }),
|
|
370
|
+
...(server === undefined ? {} : { server }),
|
|
371
|
+
...(token === undefined ? {} : { token }),
|
|
372
|
+
...(palette === undefined ? {} : { palette }),
|
|
373
|
+
...(accent === undefined ? {} : { accent }),
|
|
374
|
+
...(mode === undefined ? {} : { mode }),
|
|
375
|
+
...(keymap === undefined ? {} : { keymap }),
|
|
376
|
+
...(editor === undefined ? {} : { editor }),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Merge `update` into `tui.json` and write it atomically at mode 0600
|
|
381
|
+
* (Task 7: the onboarding claim stores its `token` here — credential
|
|
382
|
+
* material never lands world-readable, and a pre-existing wider mode is
|
|
383
|
+
* tightened by the rewrite). The file's other fields (theme, keymap,
|
|
384
|
+
* editor) survive verbatim.
|
|
385
|
+
*/
|
|
386
|
+
export async function writeTuiFile(update, env) {
|
|
387
|
+
const dir = defaultConfigDir(env);
|
|
388
|
+
await mkdir(dir, { recursive: true });
|
|
389
|
+
const merged = { ...(await loadTuiFile(env)), ...update };
|
|
390
|
+
await writeFileAtomic(join(dir, "tui.json"), `${JSON.stringify(merged, null, "\t")}\n`, 0o600);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Resolve the `@sigrun/tui` bin: the package manifest through its
|
|
394
|
+
* `./package.json` exports entry (a createRequire rooted at THIS module —
|
|
395
|
+
* never a relative path into the workspace), joined with the `sigrun-tui`
|
|
396
|
+
* bin the manifest declares. Any resolution failure (package absent,
|
|
397
|
+
* manifest shape, bin missing on disk) throws — callers translate it into
|
|
398
|
+
* the `install @sigrun/tui` error.
|
|
399
|
+
*/
|
|
400
|
+
export function resolveTuiBin() {
|
|
401
|
+
const require = createRequire(import.meta.url);
|
|
402
|
+
const manifestPath = require.resolve("@sigrun/tui/package.json");
|
|
403
|
+
// The manifest path is read DIRECTLY (never wrapped in a file:// URL —
|
|
404
|
+
// interpolating a filesystem path into a URL mis-parses on `#`, `?`,
|
|
405
|
+
// and `%`, and WHATWG quirks are exactly how the T2 reviewer's
|
|
406
|
+
// mis-parse class happens; a plain path is what readFileSync takes).
|
|
407
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
408
|
+
const binField = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.["sigrun-tui"];
|
|
409
|
+
if (binField === undefined) {
|
|
410
|
+
throw new Error(`@sigrun/tui/package.json declares no sigrun-tui bin`);
|
|
411
|
+
}
|
|
412
|
+
return join(dirname(manifestPath), binField);
|
|
413
|
+
}
|
|
414
|
+
/** The tui.json UI fields as `--flag value` argv pairs (Task 2: the tui
|
|
415
|
+
* is spawned; the fields the old in-process call threaded now ride the
|
|
416
|
+
* child's argv, parsed by the bin's own `parseTuiLaunchArgs`). */
|
|
417
|
+
function tuiArgv(options) {
|
|
418
|
+
return [
|
|
419
|
+
"--server",
|
|
420
|
+
options.baseUrl,
|
|
421
|
+
...(options.user === undefined ? [] : ["--user", options.user]),
|
|
422
|
+
...(options.palette === undefined ? [] : ["--palette", options.palette]),
|
|
423
|
+
...(options.accent === undefined ? [] : ["--accent", options.accent]),
|
|
424
|
+
...(options.mode === undefined ? [] : ["--mode", options.mode]),
|
|
425
|
+
...(options.keymap === undefined ? [] : ["--keymap", options.keymap]),
|
|
426
|
+
...(options.editor === undefined ? [] : ["--editor", options.editor]),
|
|
427
|
+
];
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* The default `launchTui`: spawn `node <resolved sigrun-tui bin>` as a
|
|
431
|
+
* child process (stdio inherited — the TUI owns the terminal) and resolve
|
|
432
|
+
* when the child exits. A bin-resolution failure is wrapped as the
|
|
433
|
+
* actionable `install @sigrun/tui` message; a nonzero child exit rejects
|
|
434
|
+
* (the CLI prints it and exits 1).
|
|
435
|
+
*/
|
|
436
|
+
export async function launchTuiProcess(options, seams) {
|
|
437
|
+
const doSpawn = seams?.spawn ??
|
|
438
|
+
((command, args, spawnOptions) => nodeSpawn(command, [...args], {
|
|
439
|
+
stdio: "inherit",
|
|
440
|
+
...(spawnOptions?.env === undefined
|
|
441
|
+
? {}
|
|
442
|
+
: { env: spawnOptions.env }),
|
|
443
|
+
}));
|
|
444
|
+
const resolveBin = seams?.resolveTuiBin ?? resolveTuiBin;
|
|
445
|
+
let bin;
|
|
446
|
+
try {
|
|
447
|
+
bin = resolveBin();
|
|
448
|
+
}
|
|
449
|
+
catch (cause) {
|
|
450
|
+
throw new Error(`sigrun-tui is not installed; install @sigrun/tui to use the terminal client (${cause instanceof Error ? cause.message : String(cause)})`);
|
|
451
|
+
}
|
|
452
|
+
const child = doSpawn(process.execPath, [bin, ...tuiArgv(options)],
|
|
453
|
+
// The token's ONLY route into the child: the environment (an
|
|
454
|
+
// explicit env REPLACES process.env in node's spawn — spread the
|
|
455
|
+
// parent's through so PATH and friends survive).
|
|
456
|
+
options.token === undefined
|
|
457
|
+
? {}
|
|
458
|
+
: { env: { ...process.env, SIGRUN_TOKEN: options.token } });
|
|
459
|
+
const code = await new Promise((resolve, reject) => {
|
|
460
|
+
child.on("exit", (exitCode) => resolve(exitCode));
|
|
461
|
+
child.on("error", (error) => reject(error));
|
|
462
|
+
});
|
|
463
|
+
if (code !== 0) {
|
|
464
|
+
throw new Error(`sigrun-tui exited with ${code ?? "no exit code (terminated)"}`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* The production prompt (Task 7): one readline question per call on the
|
|
469
|
+
* real stdin/stdout. `secret: true` answers with the echo muted — the
|
|
470
|
+
* question itself still prints, the typed text does not.
|
|
471
|
+
*/
|
|
472
|
+
export async function ttyPrompt(question, opts) {
|
|
473
|
+
if (opts?.secret === true) {
|
|
474
|
+
process.stdout.write(question);
|
|
475
|
+
// A black-hole output stream: readline echoes what it writes to its
|
|
476
|
+
// output — pointing that at a sink mutes the typing without raw-mode
|
|
477
|
+
// games (the standard readline muted-input idiom).
|
|
478
|
+
const muted = new Writable({
|
|
479
|
+
write: (_chunk, _encoding, callback) => {
|
|
480
|
+
callback();
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
const rl = readline.createInterface({ input: process.stdin, output: muted });
|
|
484
|
+
try {
|
|
485
|
+
return await rl.question("");
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
rl.close();
|
|
489
|
+
process.stdout.write("\n");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const rl = readline.createInterface({
|
|
493
|
+
input: process.stdin,
|
|
494
|
+
output: process.stdout,
|
|
495
|
+
});
|
|
496
|
+
try {
|
|
497
|
+
return await rl.question(question);
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
rl.close();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** Resolve the attach/run user: `--user` > `SIGRUN_USER` > tui.json. */
|
|
504
|
+
function resolveUser(parsed, env, file) {
|
|
505
|
+
return parsed.user ?? env.SIGRUN_USER ?? file.user;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* The tui.json UI refresh fields (C1) as a spreadable launch fragment —
|
|
509
|
+
* the same five keys for both dispatch paths (attach + run).
|
|
510
|
+
*/
|
|
511
|
+
function tuiThemeFields(file) {
|
|
512
|
+
return {
|
|
513
|
+
...(file.palette === undefined ? {} : { palette: file.palette }),
|
|
514
|
+
...(file.accent === undefined ? {} : { accent: file.accent }),
|
|
515
|
+
...(file.mode === undefined ? {} : { mode: file.mode }),
|
|
516
|
+
...(file.keymap === undefined ? {} : { keymap: file.keymap }),
|
|
517
|
+
...(file.editor === undefined ? {} : { editor: file.editor }),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/** Parse a port env value: undefined when absent/empty, else a positive
|
|
521
|
+
* integer — anything else fails loud naming the variable (D1: extracted
|
|
522
|
+
* from resolveServer's lazy IIFE). */
|
|
523
|
+
export function parsePort(raw) {
|
|
524
|
+
if (raw === undefined || raw === "")
|
|
525
|
+
return undefined;
|
|
526
|
+
const port = Number(raw);
|
|
527
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
528
|
+
throw new Error(`SIGRUN_PORT must be a positive integer, got ${raw}`);
|
|
529
|
+
}
|
|
530
|
+
return port;
|
|
531
|
+
}
|
|
532
|
+
/** Resolve the attach server: `--server` > tui.json > localhost default. */
|
|
533
|
+
function resolveServer(parsed, env, file) {
|
|
534
|
+
if (parsed.server !== undefined)
|
|
535
|
+
return parsed.server;
|
|
536
|
+
if (file.server !== undefined)
|
|
537
|
+
return file.server;
|
|
538
|
+
return `http://localhost:${parsePort(env.SIGRUN_PORT) ?? 3000}`;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Read the api key for `provider key` (release 0.1 Task 2b): a muted
|
|
542
|
+
* prompt when stdin is a TTY (the typed key never echoes), otherwise
|
|
543
|
+
* ALL of piped stdin, trimmed (`printf 'k' | sigrun provider key …`).
|
|
544
|
+
* The value returns to the caller only — it is never printed, logged,
|
|
545
|
+
* or carried on argv.
|
|
546
|
+
*/
|
|
547
|
+
export async function readCliSecret() {
|
|
548
|
+
if (process.stdin.isTTY === true) {
|
|
549
|
+
return (await ttyPrompt("api key: ", { secret: true })).trim();
|
|
550
|
+
}
|
|
551
|
+
process.stdin.setEncoding("utf8");
|
|
552
|
+
let text = "";
|
|
553
|
+
for await (const chunk of process.stdin) {
|
|
554
|
+
text += chunk;
|
|
555
|
+
}
|
|
556
|
+
return text.trim();
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Run the `provider` subcommands (release 0.1 Task 2b, spec §7.6): API
|
|
560
|
+
* clients over the RUNNING server's credential surface — `list` prints
|
|
561
|
+
* the readiness report, `key` PUTs a secret read from stdin, `clear`
|
|
562
|
+
* DELETEs one. The command NEVER starts a server: `--url` targets a
|
|
563
|
+
* remote base, and the default is the localhost shape `attach` resolves
|
|
564
|
+
* (`SIGRUN_PORT` or 3000); nothing listening fails loud naming the URL.
|
|
565
|
+
* Output is id, readiness, model count — one tab-separated row per
|
|
566
|
+
* provider (the identity rows' style). The key rides the PUT body ONLY:
|
|
567
|
+
* it never appears in any output, error, or log line.
|
|
568
|
+
*/
|
|
569
|
+
async function runProviderCommand(parsed, env, io, fetchImpl, readSecret) {
|
|
570
|
+
const base = parsed.url ?? `http://localhost:${parsePort(env.SIGRUN_PORT) ?? 3000}`;
|
|
571
|
+
/** One route call: bearer when --token was given, fail-loud on non-2xx
|
|
572
|
+
* (the status + the server's error code — never any body value). */
|
|
573
|
+
const call = async (method, path, body) => {
|
|
574
|
+
let response;
|
|
575
|
+
try {
|
|
576
|
+
response = await fetchImpl(`${base}${path}`, {
|
|
577
|
+
method,
|
|
578
|
+
headers: {
|
|
579
|
+
accept: "application/json",
|
|
580
|
+
...(parsed.token === undefined
|
|
581
|
+
? {}
|
|
582
|
+
: { authorization: `Bearer ${parsed.token}` }),
|
|
583
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
584
|
+
},
|
|
585
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (cause) {
|
|
589
|
+
throw new Error(`cannot reach a sigrun server at ${base} (${cause instanceof Error ? cause.message : String(cause)}) — provider commands are API clients and never start a server; start one (sigrun serve) or pass --url`);
|
|
590
|
+
}
|
|
591
|
+
const payload = (await response.json().catch(() => undefined));
|
|
592
|
+
if (!response.ok) {
|
|
593
|
+
throw new Error(`server rejected ${method} ${path}: status ${response.status}${typeof payload?.error === "string" ? ` (${payload.error})` : ""}`);
|
|
594
|
+
}
|
|
595
|
+
return (payload ?? {});
|
|
596
|
+
};
|
|
597
|
+
/** One readiness row: id, status, model count — tab-separated. */
|
|
598
|
+
const rowOf = (entry) => [
|
|
599
|
+
String(entry.provider ?? "?"),
|
|
600
|
+
String(entry.status ?? "?"),
|
|
601
|
+
String(entry.models ?? "?"),
|
|
602
|
+
].join("\t");
|
|
603
|
+
switch (parsed.sub) {
|
|
604
|
+
case "list": {
|
|
605
|
+
const report = await call("GET", "/v1/providers");
|
|
606
|
+
const providers = report.providers;
|
|
607
|
+
if (!Array.isArray(providers)) {
|
|
608
|
+
throw new Error("server returned a malformed providers report");
|
|
609
|
+
}
|
|
610
|
+
for (const entry of providers) {
|
|
611
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
612
|
+
throw new Error("server returned a malformed providers report");
|
|
613
|
+
}
|
|
614
|
+
io.out?.(rowOf(entry));
|
|
615
|
+
}
|
|
616
|
+
return 0;
|
|
617
|
+
}
|
|
618
|
+
case "key": {
|
|
619
|
+
const secret = (await readSecret()).trim();
|
|
620
|
+
if (secret === "") {
|
|
621
|
+
throw new Error("provider key: no api key was provided — pipe it on stdin or type it at the prompt");
|
|
622
|
+
}
|
|
623
|
+
const entry = await call("PUT", `/v1/providers/${encodeURIComponent(parsed.id)}/credential`, { apiKey: secret });
|
|
624
|
+
io.out?.(rowOf(entry));
|
|
625
|
+
return 0;
|
|
626
|
+
}
|
|
627
|
+
case "clear": {
|
|
628
|
+
const entry = await call("DELETE", `/v1/providers/${encodeURIComponent(parsed.id)}/credential`);
|
|
629
|
+
io.out?.(rowOf(entry));
|
|
630
|
+
return 0;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
// parseArgs guarantees one of the cases above.
|
|
634
|
+
throw new Error(`unhandled provider subcommand`);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Run the `token`/`user` subcommands (Task 7, spec §5.4): the identity
|
|
638
|
+
* stores DIRECTLY over the resolved roots — no server needed, honored
|
|
639
|
+
* live by a running one (the stores re-read on mtime change).
|
|
640
|
+
*
|
|
641
|
+
* - `token create` checks the user exists first (a token IS its user);
|
|
642
|
+
* - `token list`/`user list` print one row per record, no secrets (the
|
|
643
|
+
* secret exists only at create time);
|
|
644
|
+
* - `token revoke` fails loud on an unknown id.
|
|
645
|
+
*/
|
|
646
|
+
async function runIdentityCommand(parsed, env, io) {
|
|
647
|
+
const stores = openIdentityStores(resolveStoreRoots(env));
|
|
648
|
+
if (parsed.command === "token") {
|
|
649
|
+
switch (parsed.sub) {
|
|
650
|
+
case "create": {
|
|
651
|
+
const user = parsed.user;
|
|
652
|
+
if (stores.users.get(user) === undefined) {
|
|
653
|
+
throw new Error(`unknown user "${user}" — add it first: sigrun user add ${user}`);
|
|
654
|
+
}
|
|
655
|
+
const { token } = await stores.tokens.create(parsed.tokenName, user);
|
|
656
|
+
// The one-time secret: printed ONCE, never stored.
|
|
657
|
+
io.out?.(token);
|
|
658
|
+
return 0;
|
|
659
|
+
}
|
|
660
|
+
case "list": {
|
|
661
|
+
for (const record of stores.tokens.list()) {
|
|
662
|
+
io.out?.([
|
|
663
|
+
record.name,
|
|
664
|
+
record.user,
|
|
665
|
+
record.id,
|
|
666
|
+
new Date(record.createdAt).toISOString(),
|
|
667
|
+
].join("\t"));
|
|
668
|
+
}
|
|
669
|
+
return 0;
|
|
670
|
+
}
|
|
671
|
+
case "revoke": {
|
|
672
|
+
const revoked = await stores.tokens.revoke(parsed.id);
|
|
673
|
+
if (!revoked) {
|
|
674
|
+
throw new Error(`no token with id ${parsed.id}`);
|
|
675
|
+
}
|
|
676
|
+
return 0;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
switch (parsed.sub) {
|
|
681
|
+
case "add": {
|
|
682
|
+
await stores.users.add({
|
|
683
|
+
user: parsed.user,
|
|
684
|
+
admin: parsed.admin ?? false,
|
|
685
|
+
...(parsed.name === undefined ? {} : { name: parsed.name }),
|
|
686
|
+
...(parsed.email === undefined ? {} : { email: parsed.email }),
|
|
687
|
+
source: "cli",
|
|
688
|
+
});
|
|
689
|
+
return 0;
|
|
690
|
+
}
|
|
691
|
+
case "list": {
|
|
692
|
+
for (const record of stores.users.list()) {
|
|
693
|
+
io.out?.([
|
|
694
|
+
record.admin ? "admin" : "-",
|
|
695
|
+
record.user,
|
|
696
|
+
...(record.name === undefined ? [] : [record.name]),
|
|
697
|
+
...(record.email === undefined ? [] : [record.email]),
|
|
698
|
+
].join("\t"));
|
|
699
|
+
}
|
|
700
|
+
return 0;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
// parseArgs guarantees one of the cases above.
|
|
704
|
+
throw new Error(`unhandled ${parsed.command} subcommand`);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* The pre-launch onboarding flow (Task 7, spec §5.8): `attach`/`run`
|
|
708
|
+
* ask the server its state; an UNCLAIMED server gets the interactive
|
|
709
|
+
* claim — setup code (when the GET said one is required), username,
|
|
710
|
+
* name, email — then `POST /v1/onboarding/claim {…, issueToken: true}`.
|
|
711
|
+
*
|
|
712
|
+
* Returns `undefined` whenever the flow does not apply (claimed server,
|
|
713
|
+
* unreachable server, a non-setup GET failure): the TUI launches on the
|
|
714
|
+
* old path and surfaces whatever the server says. A failed CLAIM is
|
|
715
|
+
* loud (the server's error body, exit 1) — the operator re-runs.
|
|
716
|
+
*/
|
|
717
|
+
async function onboardIfUnclaimed(server, prompt, fetchImpl) {
|
|
718
|
+
let state;
|
|
719
|
+
try {
|
|
720
|
+
const response = await fetchImpl(`${server}/v1/onboarding`, {
|
|
721
|
+
method: "GET",
|
|
722
|
+
headers: { accept: "application/json" },
|
|
723
|
+
});
|
|
724
|
+
if (response.ok) {
|
|
725
|
+
state = (await response.json());
|
|
726
|
+
}
|
|
727
|
+
else if (response.status === 403) {
|
|
728
|
+
// "a setup_required arrives later": a public-path GET that still
|
|
729
|
+
// answers setup_required means unclaimed with a code required.
|
|
730
|
+
const body = (await response.json().catch(() => undefined));
|
|
731
|
+
if (body?.error !== "setup_required")
|
|
732
|
+
return undefined;
|
|
733
|
+
state = { state: "unclaimed", methods: [], codeRequired: true };
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// Unreachable server: not this flow's business — the TUI reports it.
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
if (state.state !== "unclaimed")
|
|
744
|
+
return undefined;
|
|
745
|
+
let code;
|
|
746
|
+
if (state.codeRequired) {
|
|
747
|
+
code = (await prompt("setup code: ")).trim();
|
|
748
|
+
if (code === "") {
|
|
749
|
+
throw new Error("onboarding: a setup code is required for this server");
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const user = (await prompt("username: ")).trim();
|
|
753
|
+
if (user === "") {
|
|
754
|
+
throw new Error("onboarding: a username is required");
|
|
755
|
+
}
|
|
756
|
+
const name = (await prompt("name (optional): ")).trim();
|
|
757
|
+
const email = (await prompt("email (optional): ")).trim();
|
|
758
|
+
const response = await fetchImpl(`${server}/v1/onboarding/claim`, {
|
|
759
|
+
method: "POST",
|
|
760
|
+
headers: { "content-type": "application/json" },
|
|
761
|
+
body: JSON.stringify({
|
|
762
|
+
...(code === undefined ? {} : { code }),
|
|
763
|
+
user,
|
|
764
|
+
...(name === "" ? {} : { name }),
|
|
765
|
+
...(email === "" ? {} : { email }),
|
|
766
|
+
issueToken: true,
|
|
767
|
+
}),
|
|
768
|
+
});
|
|
769
|
+
if (!response.ok) {
|
|
770
|
+
const body = (await response.json().catch(() => undefined));
|
|
771
|
+
throw new Error(`onboarding claim failed: ${typeof body?.error === "string" ? body.error : `status ${response.status}`}`);
|
|
772
|
+
}
|
|
773
|
+
const claimed = (await response.json());
|
|
774
|
+
return {
|
|
775
|
+
user: claimed.user.user,
|
|
776
|
+
...(claimed.token === undefined ? {} : { token: claimed.token }),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* The dispatch. Returns the intended process exit code (0 clean, 1 on a
|
|
781
|
+
* usage error or a boot/launch failure) — the module scope below maps it
|
|
782
|
+
* onto `process.exit`. Env and deps are injectable: unit tests pass a
|
|
783
|
+
* hermetic env and stub factories.
|
|
784
|
+
*/
|
|
785
|
+
export async function cliMain(argv, env = process.env, deps, io = {
|
|
786
|
+
error: (text) => console.error(text),
|
|
787
|
+
out: (text) => console.log(text),
|
|
788
|
+
}) {
|
|
789
|
+
const realDeps = {
|
|
790
|
+
...(deps ?? {}),
|
|
791
|
+
serve: deps?.serve ??
|
|
792
|
+
(async (serveEnv) => {
|
|
793
|
+
const booted = await serve(serveEnv);
|
|
794
|
+
const stop = () => {
|
|
795
|
+
void booted.shutdownOnce().then(() => process.exit(0), (error) => {
|
|
796
|
+
console.error("sigrun shutdown error:", error);
|
|
797
|
+
process.exit(1);
|
|
798
|
+
});
|
|
799
|
+
};
|
|
800
|
+
process.on("SIGTERM", stop);
|
|
801
|
+
process.on("SIGINT", stop);
|
|
802
|
+
// Stay pending until a signal runs `stop` (which exits the
|
|
803
|
+
// process): cliMain's caller maps this promise's RESOLUTION onto
|
|
804
|
+
// `process.exit(0)` — a serve that returned after attaching the
|
|
805
|
+
// handlers would exit the process the instant the server started
|
|
806
|
+
// listening (the built-bin test caught exactly that: boot, print
|
|
807
|
+
// the listening line, exit 0, serve nothing).
|
|
808
|
+
await new Promise(() => { });
|
|
809
|
+
}),
|
|
810
|
+
boot: deps?.boot ?? ((bootEnv, bootOptions) => serve(bootEnv, bootOptions)),
|
|
811
|
+
};
|
|
812
|
+
// The resolved TUI launch: an injected `launchTui` wins (cli.test.ts's
|
|
813
|
+
// in-process stubs); otherwise the SPAWN default, composed with the
|
|
814
|
+
// injected child seams (cli-spawn.test.ts fakes the child while running
|
|
815
|
+
// this real wiring).
|
|
816
|
+
const launchTui = realDeps.launchTui ??
|
|
817
|
+
((options) => launchTuiProcess(options, {
|
|
818
|
+
...(realDeps.spawn === undefined ? {} : { spawn: realDeps.spawn }),
|
|
819
|
+
...(realDeps.resolveTuiBin === undefined
|
|
820
|
+
? {}
|
|
821
|
+
: { resolveTuiBin: realDeps.resolveTuiBin }),
|
|
822
|
+
}));
|
|
823
|
+
const prompt = realDeps.prompt ??
|
|
824
|
+
((question, opts) => ttyPrompt(question, opts));
|
|
825
|
+
const fetchImpl = realDeps.fetch ??
|
|
826
|
+
((url, init) => globalThis.fetch(url, init));
|
|
827
|
+
const readSecret = realDeps.readSecret ?? readCliSecret;
|
|
828
|
+
let parsed;
|
|
829
|
+
try {
|
|
830
|
+
parsed = parseArgs(argv);
|
|
831
|
+
}
|
|
832
|
+
catch (error) {
|
|
833
|
+
io.error(error instanceof Error ? error.message : String(error));
|
|
834
|
+
return 1;
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
switch (parsed.command) {
|
|
838
|
+
case "serve": {
|
|
839
|
+
await realDeps.serve(env);
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
case "token":
|
|
843
|
+
case "user": {
|
|
844
|
+
// The identity commands (Task 7): stores directly, server or not.
|
|
845
|
+
return await runIdentityCommand(parsed, env, io);
|
|
846
|
+
}
|
|
847
|
+
case "provider": {
|
|
848
|
+
// The provider commands (release 0.1 Task 2b): API clients over
|
|
849
|
+
// the running server — never a boot, never a local store.
|
|
850
|
+
return await runProviderCommand(parsed, env, io, fetchImpl, readSecret);
|
|
851
|
+
}
|
|
852
|
+
case "attach": {
|
|
853
|
+
const file = await loadTuiFile(env);
|
|
854
|
+
const server = resolveServer(parsed, env, file);
|
|
855
|
+
const onboarded = await onboardIfUnclaimed(server, prompt, fetchImpl);
|
|
856
|
+
if (onboarded !== undefined) {
|
|
857
|
+
// The claim's carry-home: identity + where it was claimed + the
|
|
858
|
+
// one-time token (0600, merged over any existing fields).
|
|
859
|
+
await writeTuiFile({
|
|
860
|
+
user: onboarded.user,
|
|
861
|
+
server,
|
|
862
|
+
...(onboarded.token === undefined ? {} : { token: onboarded.token }),
|
|
863
|
+
}, env);
|
|
864
|
+
}
|
|
865
|
+
// Token precedence (spec §5.4): env SIGRUN_TOKEN > tui.json > claim.
|
|
866
|
+
const token = env.SIGRUN_TOKEN ?? file.token ?? onboarded?.token;
|
|
867
|
+
const user = resolveUser(parsed, env, file) ?? onboarded?.user;
|
|
868
|
+
await launchTui({
|
|
869
|
+
baseUrl: server,
|
|
870
|
+
...(user === undefined ? {} : { user }),
|
|
871
|
+
...(token === undefined || token === "" ? {} : { token }),
|
|
872
|
+
...tuiThemeFields(file),
|
|
873
|
+
});
|
|
874
|
+
return 0;
|
|
875
|
+
}
|
|
876
|
+
case "run": {
|
|
877
|
+
// The flags are parsed; the model spec fails loud inside boot
|
|
878
|
+
// (loadConfig names SIGRUN_DEFAULT_MODEL).
|
|
879
|
+
const file = await loadTuiFile(env);
|
|
880
|
+
// Workstation mode binds loopback only: the TUI dials
|
|
881
|
+
// 127.0.0.1, and the naive-identity API has no business
|
|
882
|
+
// answering the LAN.
|
|
883
|
+
const booted = await realDeps.boot(env, { host: "127.0.0.1" });
|
|
884
|
+
const exit = realDeps.exit ?? ((exitCode) => process.exit(exitCode));
|
|
885
|
+
const stop = () => {
|
|
886
|
+
// Fix wave A8: once shutdown completes, run mode EXITS — the
|
|
887
|
+
// TUI must never run against a dead server (a signal is the
|
|
888
|
+
// operator's stop request; the serve entry keeps its own
|
|
889
|
+
// semantics in src/index.ts, unchanged).
|
|
890
|
+
void booted.shutdownOnce().then(() => exit(0), (error) => {
|
|
891
|
+
console.error("sigrun shutdown error:", error);
|
|
892
|
+
exit(1);
|
|
893
|
+
});
|
|
894
|
+
};
|
|
895
|
+
process.on("SIGTERM", stop);
|
|
896
|
+
process.on("SIGINT", stop);
|
|
897
|
+
try {
|
|
898
|
+
const server = `http://localhost:${booted.port}`;
|
|
899
|
+
// Loopback onboarding (Task 7): no code (loopback is the trust);
|
|
900
|
+
// tui.json gains ONLY the user — workstation run stays
|
|
901
|
+
// unauthenticated, so there is no token to keep.
|
|
902
|
+
const onboarded = await onboardIfUnclaimed(server, prompt, fetchImpl);
|
|
903
|
+
if (onboarded !== undefined) {
|
|
904
|
+
await writeTuiFile({ user: onboarded.user }, env);
|
|
905
|
+
}
|
|
906
|
+
const user = resolveUser(parsed, env, file) ?? onboarded?.user;
|
|
907
|
+
await launchTui({
|
|
908
|
+
baseUrl: server,
|
|
909
|
+
...(user === undefined ? {} : { user }),
|
|
910
|
+
...tuiThemeFields(file),
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
finally {
|
|
914
|
+
// The lifecycle rule: the agent dies with the TUI.
|
|
915
|
+
await booted.shutdownOnce();
|
|
916
|
+
}
|
|
917
|
+
return 0;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
923
|
+
// The launchTui placeholder message already carries the prefix.
|
|
924
|
+
io.error(message.startsWith("sigrun:") ? message : `sigrun: ${message}`);
|
|
925
|
+
return 1;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Script detection (fix wave A7 + build Task 3): the exact entry-module
|
|
929
|
+
// comparison — the old endsWith("cli.ts") fired for ANY cli.ts basename.
|
|
930
|
+
// The argv side is REALPATH'd first (the T2 fix, mirrored for the core
|
|
931
|
+
// bin): npm bin entries are symlinks (…/.bin/sigrun → dist/cli.js), node
|
|
932
|
+
// realpaths the main module (import.meta.url is the real path) while
|
|
933
|
+
// process.argv[1] keeps the invoked symlink — a raw comparison mismatches
|
|
934
|
+
// and the bin exits 0 SILENTLY.
|
|
935
|
+
function invokedEntryPath() {
|
|
936
|
+
if (process.argv[1] === undefined)
|
|
937
|
+
return undefined;
|
|
938
|
+
try {
|
|
939
|
+
return realpathSync(process.argv[1]);
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
return undefined;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const invokedEntry = invokedEntryPath();
|
|
946
|
+
const invokedAsScript = invokedEntry !== undefined &&
|
|
947
|
+
import.meta.url === pathToFileURL(invokedEntry).href;
|
|
948
|
+
if (invokedAsScript) {
|
|
949
|
+
void cliMain(process.argv.slice(2)).then((code) => {
|
|
950
|
+
process.exit(code);
|
|
951
|
+
});
|
|
952
|
+
}
|