settld 0.2.0 → 0.2.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/SETTLD_VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.2.1
package/bin/settld.js CHANGED
@@ -9,6 +9,7 @@ function usage() {
9
9
  console.error("usage:");
10
10
  console.error(" settld --version");
11
11
  console.error(" settld onboard [--help]");
12
+ console.error(" settld login [--help]");
12
13
  console.error(" settld setup [--help]");
13
14
  console.error(" settld setup legacy [--help]");
14
15
  console.error(" settld setup circle [--help]");
@@ -138,6 +139,10 @@ function main() {
138
139
  return runNodeScript("scripts/setup/onboard.mjs", argv.slice(1));
139
140
  }
140
141
 
142
+ if (cmd === "login") {
143
+ return runNodeScript("scripts/setup/login.mjs", argv.slice(1));
144
+ }
145
+
141
146
  if (cmd === "doctor") {
142
147
  return runNodeScript("scripts/doctor/mcp-host.mjs", argv.slice(1));
143
148
  }
@@ -17,7 +17,9 @@ Required inputs:
17
17
 
18
18
  - `SETTLD_BASE_URL` (local or hosted API URL)
19
19
  - `SETTLD_TENANT_ID`
20
- - `SETTLD_API_KEY` (`keyId.secret`)
20
+ - one of:
21
+ - `SETTLD_API_KEY` (`keyId.secret`), or
22
+ - `SETTLD_BOOTSTRAP_API_KEY` (onboarding bootstrap key that mints `SETTLD_API_KEY` during setup)
21
23
  - Node.js 20+
22
24
 
23
25
  Recommended non-interactive pattern:
@@ -35,6 +37,20 @@ settld setup --non-interactive \
35
37
  --out-env ./.tmp/settld-openclaw.env
36
38
  ```
37
39
 
40
+ If you want setup to generate the tenant API key for you:
41
+
42
+ ```bash
43
+ settld setup --non-interactive \
44
+ --host openclaw \
45
+ --base-url https://api.settld.work \
46
+ --tenant-id tenant_default \
47
+ --bootstrap-api-key 'ml_admin_xxx' \
48
+ --wallet-mode managed \
49
+ --wallet-bootstrap remote \
50
+ --profile-id engineering-spend \
51
+ --smoke
52
+ ```
53
+
38
54
  If you want validation only (no config writes):
39
55
 
40
56
  ```bash
@@ -53,6 +53,19 @@ npx -y settld@latest setup \
53
53
  --smoke
54
54
  ```
55
55
 
56
+ If you do not have a tenant `sk_*` yet, let setup mint one:
57
+
58
+ ```bash
59
+ npx -y settld@latest setup \
60
+ --non-interactive \
61
+ --host openclaw \
62
+ --base-url https://api.settld.work \
63
+ --tenant-id tenant_default \
64
+ --bootstrap-api-key 'ml_admin_xxx' \
65
+ --wallet-mode managed \
66
+ --wallet-bootstrap remote
67
+ ```
68
+
56
69
  ## 3) Verify OpenClaw + Settld are wired
57
70
 
58
71
  Run:
@@ -0,0 +1,42 @@
1
+ # Vercel Monorepo Deploy (Docs + Website)
2
+
3
+ Use **two separate Vercel projects** pointing at the same GitHub repo:
4
+
5
+ 1. `settld-docs` (MkDocs)
6
+ 2. `settld-site` (Dashboard website)
7
+
8
+ ## Project 1: `settld-docs` (MkDocs)
9
+
10
+ - Root Directory: repo root (`.`)
11
+ - Production Branch: `main`
12
+ - Build/Output config comes from `/vercel.json`:
13
+ - `installCommand`: `bash scripts/vercel/install-mkdocs.sh`
14
+ - `buildCommand`: `bash scripts/vercel/build-mkdocs.sh`
15
+ - `ignoreCommand`: `bash scripts/vercel/ignore-mkdocs.sh`
16
+ - `outputDirectory`: `mkdocs/site`
17
+
18
+ Deploy will run when docs-relevant files change (including `mkdocs/docs/**`).
19
+
20
+ ## Project 2: `settld-site` (Website)
21
+
22
+ - Root Directory: `dashboard`
23
+ - Production Branch: `main`
24
+ - Build/Output config comes from `/dashboard/vercel.json`:
25
+ - `installCommand`: `npm install`
26
+ - `buildCommand`: `npm run build`
27
+ - `ignoreCommand`: `bash ../scripts/vercel/ignore-dashboard.sh`
28
+ - `outputDirectory`: `dist`
29
+
30
+ Deploy will run when website-relevant files change (`dashboard/**` + deploy scripts/workflows).
31
+
32
+ ## Push Flow
33
+
34
+ 1. Commit and push changes to `main`.
35
+ 2. Verify both Vercel projects are connected to this repo and track `main`.
36
+ 3. Check the commit SHA in each Vercel deployment detail page matches the pushed commit.
37
+
38
+ ## Quick Troubleshooting
39
+
40
+ - Docs didn’t deploy: confirm changes touched `mkdocs/docs/**` or another path matched by `scripts/vercel/ignore-mkdocs.sh`.
41
+ - Website didn’t deploy: confirm changes touched `dashboard/**` or another path matched by `scripts/vercel/ignore-dashboard.sh`.
42
+ - Wrong commit deployed: confirm Vercel project production branch is `main`, not a feature branch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "settld",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Settld kernel CLI and local control-plane tooling",
5
5
  "private": false,
6
6
  "type": "module",
@@ -0,0 +1,299 @@
1
+ #!/usr/bin/env node
2
+
3
+ import process from "node:process";
4
+ import path from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { cookieHeaderFromSetCookie, defaultSessionPath, writeSavedSession } from "./session-store.mjs";
9
+
10
+ const FORMAT_OPTIONS = new Set(["text", "json"]);
11
+
12
+ function usage() {
13
+ const text = [
14
+ "usage:",
15
+ " settld login [flags]",
16
+ " node scripts/setup/login.mjs [flags]",
17
+ "",
18
+ "flags:",
19
+ " --base-url <url> Settld onboarding base URL (default: https://api.settld.work)",
20
+ " --tenant-id <id> Existing tenant ID (omit to create via public signup)",
21
+ " --email <email> Login email",
22
+ " --company <name> Company name (required when --tenant-id omitted)",
23
+ " --otp <code> OTP code (otherwise prompted)",
24
+ " --non-interactive Disable prompts; require explicit flags",
25
+ " --session-file <path> Session output path (default: ~/.settld/session.json)",
26
+ " --format <text|json> Output format (default: text)",
27
+ " --help Show this help"
28
+ ].join("\n");
29
+ process.stderr.write(`${text}\n`);
30
+ }
31
+
32
+ function readArgValue(argv, index, rawArg) {
33
+ const arg = String(rawArg ?? "");
34
+ const eq = arg.indexOf("=");
35
+ if (eq >= 0) return { value: arg.slice(eq + 1), nextIndex: index };
36
+ return { value: String(argv[index + 1] ?? ""), nextIndex: index + 1 };
37
+ }
38
+
39
+ function parseArgs(argv) {
40
+ const out = {
41
+ baseUrl: "https://api.settld.work",
42
+ tenantId: "",
43
+ email: "",
44
+ company: "",
45
+ otp: "",
46
+ nonInteractive: false,
47
+ sessionFile: defaultSessionPath(),
48
+ format: "text",
49
+ help: false
50
+ };
51
+
52
+ for (let i = 0; i < argv.length; i += 1) {
53
+ const arg = String(argv[i] ?? "");
54
+ if (!arg) continue;
55
+
56
+ if (arg === "--help" || arg === "-h") {
57
+ out.help = true;
58
+ continue;
59
+ }
60
+ if (arg === "--non-interactive" || arg === "--yes") {
61
+ out.nonInteractive = true;
62
+ continue;
63
+ }
64
+ if (arg === "--base-url" || arg.startsWith("--base-url=")) {
65
+ const parsed = readArgValue(argv, i, arg);
66
+ out.baseUrl = parsed.value;
67
+ i = parsed.nextIndex;
68
+ continue;
69
+ }
70
+ if (arg === "--tenant-id" || arg.startsWith("--tenant-id=")) {
71
+ const parsed = readArgValue(argv, i, arg);
72
+ out.tenantId = parsed.value;
73
+ i = parsed.nextIndex;
74
+ continue;
75
+ }
76
+ if (arg === "--email" || arg.startsWith("--email=")) {
77
+ const parsed = readArgValue(argv, i, arg);
78
+ out.email = parsed.value;
79
+ i = parsed.nextIndex;
80
+ continue;
81
+ }
82
+ if (arg === "--company" || arg.startsWith("--company=")) {
83
+ const parsed = readArgValue(argv, i, arg);
84
+ out.company = parsed.value;
85
+ i = parsed.nextIndex;
86
+ continue;
87
+ }
88
+ if (arg === "--otp" || arg.startsWith("--otp=")) {
89
+ const parsed = readArgValue(argv, i, arg);
90
+ out.otp = parsed.value;
91
+ i = parsed.nextIndex;
92
+ continue;
93
+ }
94
+ if (arg === "--session-file" || arg.startsWith("--session-file=")) {
95
+ const parsed = readArgValue(argv, i, arg);
96
+ out.sessionFile = parsed.value;
97
+ i = parsed.nextIndex;
98
+ continue;
99
+ }
100
+ if (arg === "--format" || arg.startsWith("--format=")) {
101
+ const parsed = readArgValue(argv, i, arg);
102
+ out.format = String(parsed.value ?? "").trim().toLowerCase();
103
+ i = parsed.nextIndex;
104
+ continue;
105
+ }
106
+ throw new Error(`unknown argument: ${arg}`);
107
+ }
108
+
109
+ if (!FORMAT_OPTIONS.has(out.format)) throw new Error("--format must be text|json");
110
+ out.baseUrl = String(out.baseUrl ?? "").trim().replace(/\/+$/, "");
111
+ out.tenantId = String(out.tenantId ?? "").trim();
112
+ out.email = String(out.email ?? "").trim().toLowerCase();
113
+ out.company = String(out.company ?? "").trim();
114
+ out.otp = String(out.otp ?? "").trim();
115
+ out.sessionFile = path.resolve(process.cwd(), String(out.sessionFile ?? "").trim() || defaultSessionPath());
116
+ return out;
117
+ }
118
+
119
+ function mustHttpUrl(value, label) {
120
+ const raw = String(value ?? "").trim();
121
+ let parsed;
122
+ try {
123
+ parsed = new URL(raw);
124
+ } catch {
125
+ throw new Error(`${label} must be a valid URL`);
126
+ }
127
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
128
+ throw new Error(`${label} must use http/https`);
129
+ }
130
+ return parsed.toString().replace(/\/+$/, "");
131
+ }
132
+
133
+ async function requestJson(url, { method, body, headers = {}, fetchImpl = fetch } = {}) {
134
+ const res = await fetchImpl(url, {
135
+ method,
136
+ headers: {
137
+ "content-type": "application/json",
138
+ ...headers
139
+ },
140
+ body: body === undefined ? undefined : JSON.stringify(body)
141
+ });
142
+ const text = await res.text();
143
+ let json = null;
144
+ try {
145
+ json = text ? JSON.parse(text) : null;
146
+ } catch {
147
+ json = null;
148
+ }
149
+ return { res, text, json };
150
+ }
151
+
152
+ async function promptLine(rl, label, { defaultValue = "", required = true } = {}) {
153
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
154
+ const value = String(await rl.question(`${label}${suffix}: `) ?? "").trim() || String(defaultValue ?? "").trim();
155
+ if (value || !required) return value;
156
+ throw new Error(`${label} is required`);
157
+ }
158
+
159
+ function printBanner(stdout = process.stdout) {
160
+ stdout.write("Settld login\n");
161
+ stdout.write("============\n");
162
+ stdout.write("Sign in with OTP and save local session for one-command setup.\n\n");
163
+ }
164
+
165
+ export async function runLogin({
166
+ argv = process.argv.slice(2),
167
+ stdin = process.stdin,
168
+ stdout = process.stdout,
169
+ fetchImpl = fetch,
170
+ writeSavedSessionImpl = writeSavedSession
171
+ } = {}) {
172
+ const args = parseArgs(argv);
173
+ if (args.help) {
174
+ usage();
175
+ return { ok: true, code: 0 };
176
+ }
177
+
178
+ const interactive = !args.nonInteractive;
179
+ const state = {
180
+ baseUrl: args.baseUrl,
181
+ tenantId: args.tenantId,
182
+ email: args.email,
183
+ company: args.company,
184
+ otp: args.otp,
185
+ sessionFile: args.sessionFile,
186
+ format: args.format
187
+ };
188
+
189
+ if (interactive) printBanner(stdout);
190
+ const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null;
191
+ try {
192
+ if (interactive) {
193
+ state.baseUrl = await promptLine(rl, "Settld base URL", { defaultValue: state.baseUrl || "https://api.settld.work" });
194
+ state.tenantId = await promptLine(rl, "Tenant ID (optional for new signup)", { defaultValue: state.tenantId, required: false });
195
+ state.email = (await promptLine(rl, "Email", { defaultValue: state.email })).toLowerCase();
196
+ if (!state.tenantId) {
197
+ state.company = await promptLine(rl, "Company name", { defaultValue: state.company });
198
+ }
199
+ }
200
+
201
+ const baseUrl = mustHttpUrl(state.baseUrl, "base URL");
202
+ if (!state.email) throw new Error("email is required");
203
+ if (!state.tenantId && !state.company) throw new Error("company is required when tenant ID is omitted");
204
+
205
+ let tenantId = state.tenantId;
206
+ if (!tenantId) {
207
+ const signup = await requestJson(`${baseUrl}/v1/public/signup`, {
208
+ method: "POST",
209
+ body: { email: state.email, company: state.company },
210
+ fetchImpl
211
+ });
212
+ if (!signup.res.ok) {
213
+ const code = typeof signup.json?.code === "string" ? signup.json.code : "";
214
+ const message = typeof signup.json?.message === "string" ? signup.json.message : signup.text;
215
+ if (code === "SIGNUP_DISABLED") {
216
+ throw new Error("Public signup is disabled for this environment. Use an existing tenant ID or bootstrap key flow.");
217
+ }
218
+ throw new Error(`public signup failed (${signup.res.status}): ${message || "unknown error"}`);
219
+ }
220
+ tenantId = String(signup.json?.tenantId ?? "").trim();
221
+ if (!tenantId) throw new Error("public signup response missing tenantId");
222
+ if (interactive) stdout.write(`Created tenant: ${tenantId}\n`);
223
+ } else {
224
+ const otpRequest = await requestJson(`${baseUrl}/v1/tenants/${encodeURIComponent(tenantId)}/buyer/login/otp`, {
225
+ method: "POST",
226
+ body: { email: state.email },
227
+ fetchImpl
228
+ });
229
+ if (!otpRequest.res.ok) {
230
+ const message = typeof otpRequest.json?.message === "string" ? otpRequest.json.message : otpRequest.text;
231
+ throw new Error(`otp request failed (${otpRequest.res.status}): ${message || "unknown error"}`);
232
+ }
233
+ }
234
+
235
+ if (!state.otp && interactive) {
236
+ state.otp = await promptLine(rl, "OTP code", { required: true });
237
+ }
238
+ if (!state.otp) throw new Error("otp code is required (pass --otp in non-interactive mode)");
239
+
240
+ const login = await requestJson(`${baseUrl}/v1/tenants/${encodeURIComponent(tenantId)}/buyer/login`, {
241
+ method: "POST",
242
+ body: { email: state.email, code: state.otp },
243
+ fetchImpl
244
+ });
245
+ if (!login.res.ok) {
246
+ const message = typeof login.json?.message === "string" ? login.json.message : login.text;
247
+ throw new Error(`login failed (${login.res.status}): ${message || "unknown error"}`);
248
+ }
249
+ const setCookie = login.res.headers.get("set-cookie") ?? "";
250
+ const cookie = cookieHeaderFromSetCookie(setCookie);
251
+ if (!cookie) throw new Error("login response missing session cookie");
252
+
253
+ const session = await writeSavedSessionImpl({
254
+ sessionPath: state.sessionFile,
255
+ session: {
256
+ baseUrl,
257
+ tenantId,
258
+ email: state.email,
259
+ cookie,
260
+ expiresAt: typeof login.json?.expiresAt === "string" ? login.json.expiresAt : null
261
+ }
262
+ });
263
+
264
+ const payload = {
265
+ ok: true,
266
+ schemaVersion: "SettldLoginResult.v1",
267
+ baseUrl: session.baseUrl,
268
+ tenantId: session.tenantId,
269
+ email: session.email,
270
+ sessionFile: state.sessionFile,
271
+ expiresAt: session.expiresAt ?? null
272
+ };
273
+
274
+ if (state.format === "json") {
275
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
276
+ } else {
277
+ stdout.write(`Login saved.\n`);
278
+ stdout.write(`Tenant: ${session.tenantId}\n`);
279
+ stdout.write(`Session file: ${state.sessionFile}\n`);
280
+ stdout.write(`Next: run \`settld setup\`.\n`);
281
+ }
282
+ return payload;
283
+ } finally {
284
+ if (rl) rl.close();
285
+ }
286
+ }
287
+
288
+ async function main(argv = process.argv.slice(2)) {
289
+ try {
290
+ await runLogin({ argv });
291
+ } catch (err) {
292
+ process.stderr.write(`${err?.message ?? String(err)}\n`);
293
+ process.exit(1);
294
+ }
295
+ }
296
+
297
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
298
+ main();
299
+ }
@@ -10,8 +10,9 @@ import { spawnSync } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
11
 
12
12
  import { bootstrapWalletProvider } from "../../src/core/wallet-provider-bootstrap.js";
13
- import { loadHostConfigHelper, runWizard } from "./wizard.mjs";
13
+ import { extractBootstrapMcpEnv, loadHostConfigHelper, runWizard } from "./wizard.mjs";
14
14
  import { SUPPORTED_HOSTS } from "./host-config.mjs";
15
+ import { defaultSessionPath, readSavedSession } from "./session-store.mjs";
15
16
 
16
17
  const WALLET_MODES = new Set(["managed", "byo", "none"]);
17
18
  const WALLET_PROVIDERS = new Set(["circle"]);
@@ -37,6 +38,12 @@ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
37
38
  const REPO_ROOT = path.resolve(SCRIPT_DIR, "..", "..");
38
39
  const SETTLD_BIN = path.join(REPO_ROOT, "bin", "settld.js");
39
40
  const PROFILE_FINGERPRINT_REGEX = /^[0-9a-f]{64}$/;
41
+ const ANSI_RESET = "\u001b[0m";
42
+ const ANSI_BOLD = "\u001b[1m";
43
+ const ANSI_DIM = "\u001b[2m";
44
+ const ANSI_CYAN = "\u001b[36m";
45
+ const ANSI_GREEN = "\u001b[32m";
46
+ const ANSI_MAGENTA = "\u001b[35m";
40
47
 
41
48
  function usage() {
42
49
  const text = [
@@ -51,6 +58,11 @@ function usage() {
51
58
  " --base-url <url> Settld API base URL (or SETTLD_BASE_URL)",
52
59
  " --tenant-id <id> Settld tenant ID (or SETTLD_TENANT_ID)",
53
60
  " --settld-api-key <key> Settld tenant API key (or SETTLD_API_KEY)",
61
+ " --bootstrap-api-key <key> Onboarding bootstrap API key used to mint tenant API key",
62
+ " --magic-link-api-key <key> Alias for --bootstrap-api-key",
63
+ " --session-file <path> Saved login session path (default: ~/.settld/session.json)",
64
+ " --bootstrap-key-id <id> Optional API key ID hint for runtime bootstrap",
65
+ " --bootstrap-scopes <csv> Optional scopes for generated tenant API key",
54
66
  " --wallet-mode <managed|byo|none> Wallet setup mode (default: managed)",
55
67
  " --wallet-provider <name> Wallet provider (circle; default: circle)",
56
68
  " --wallet-bootstrap <auto|local|remote> Managed wallet setup path (default: auto)",
@@ -101,6 +113,10 @@ function parseArgs(argv) {
101
113
  baseUrl: null,
102
114
  tenantId: null,
103
115
  settldApiKey: null,
116
+ bootstrapApiKey: null,
117
+ sessionFile: defaultSessionPath(),
118
+ bootstrapKeyId: null,
119
+ bootstrapScopes: null,
104
120
  walletMode: "managed",
105
121
  walletProvider: "circle",
106
122
  walletBootstrap: "auto",
@@ -186,6 +202,35 @@ function parseArgs(argv) {
186
202
  i = parsed.nextIndex;
187
203
  continue;
188
204
  }
205
+ if (
206
+ arg === "--bootstrap-api-key" ||
207
+ arg === "--magic-link-api-key" ||
208
+ arg.startsWith("--bootstrap-api-key=") ||
209
+ arg.startsWith("--magic-link-api-key=")
210
+ ) {
211
+ const parsed = readArgValue(argv, i, arg);
212
+ out.bootstrapApiKey = parsed.value;
213
+ i = parsed.nextIndex;
214
+ continue;
215
+ }
216
+ if (arg === "--session-file" || arg.startsWith("--session-file=")) {
217
+ const parsed = readArgValue(argv, i, arg);
218
+ out.sessionFile = parsed.value;
219
+ i = parsed.nextIndex;
220
+ continue;
221
+ }
222
+ if (arg === "--bootstrap-key-id" || arg.startsWith("--bootstrap-key-id=")) {
223
+ const parsed = readArgValue(argv, i, arg);
224
+ out.bootstrapKeyId = parsed.value;
225
+ i = parsed.nextIndex;
226
+ continue;
227
+ }
228
+ if (arg === "--bootstrap-scopes" || arg.startsWith("--bootstrap-scopes=")) {
229
+ const parsed = readArgValue(argv, i, arg);
230
+ out.bootstrapScopes = parsed.value;
231
+ i = parsed.nextIndex;
232
+ continue;
233
+ }
189
234
  if (arg === "--wallet-mode" || arg.startsWith("--wallet-mode=")) {
190
235
  const parsed = readArgValue(argv, i, arg);
191
236
  out.walletMode = String(parsed.value ?? "").trim().toLowerCase();
@@ -272,6 +317,7 @@ function parseArgs(argv) {
272
317
  if (out.preflightOnly && out.preflight === false) {
273
318
  throw new Error("--preflight-only cannot be combined with --no-preflight");
274
319
  }
320
+ out.sessionFile = path.resolve(process.cwd(), String(out.sessionFile ?? "").trim() || defaultSessionPath());
275
321
  if (out.outEnv) out.outEnv = path.resolve(process.cwd(), out.outEnv);
276
322
  if (out.reportPath) out.reportPath = path.resolve(process.cwd(), out.reportPath);
277
323
  return out;
@@ -290,6 +336,19 @@ function normalizeHttpUrl(value) {
290
336
  return parsed.toString().replace(/\/+$/, "");
291
337
  }
292
338
 
339
+ function supportsColor(output = process.stdout, env = process.env) {
340
+ if (!output?.isTTY) return false;
341
+ if (String(env.NO_COLOR ?? "").trim()) return false;
342
+ if (String(env.FORCE_COLOR ?? "").trim() === "0") return false;
343
+ return true;
344
+ }
345
+
346
+ function tint(enabled, code, value) {
347
+ const text = String(value ?? "");
348
+ if (!enabled) return text;
349
+ return `${code}${text}${ANSI_RESET}`;
350
+ }
351
+
293
352
  function commandExists(command, { platform = process.platform } = {}) {
294
353
  const lookupCmd = platform === "win32" ? "where" : "which";
295
354
  const probe = spawnSync(lookupCmd, [command], { stdio: "ignore" });
@@ -582,7 +641,7 @@ async function promptSelect(
582
641
  stdout,
583
642
  label,
584
643
  options,
585
- { defaultValue = null, hint = null } = {}
644
+ { defaultValue = null, hint = null, color = false } = {}
586
645
  ) {
587
646
  if (!Array.isArray(options) || options.length === 0) {
588
647
  throw new Error(`${label} requires at least one option`);
@@ -614,14 +673,14 @@ async function promptSelect(
614
673
 
615
674
  const render = () => {
616
675
  const lines = [];
617
- lines.push(`${label} (arrow keys + Enter)`);
676
+ lines.push(tint(color, ANSI_CYAN, `${label} (arrow keys + Enter)`));
618
677
  for (let i = 0; i < normalizedOptions.length; i += 1) {
619
678
  const option = normalizedOptions[i];
620
- const prefix = i === index ? ">" : " ";
679
+ const prefix = i === index ? tint(color, ANSI_GREEN, "") : tint(color, ANSI_DIM, "○");
621
680
  const detail = option.hint ? ` - ${option.hint}` : "";
622
- lines.push(` ${prefix} ${option.label}${detail}`);
681
+ lines.push(` ${prefix} ${i === index ? tint(color, ANSI_BOLD, option.label) : option.label}${tint(color, ANSI_DIM, detail)}`);
623
682
  }
624
- if (hint) lines.push(` ${hint}`);
683
+ if (hint) lines.push(` ${tint(color, ANSI_DIM, hint)}`);
625
684
  if (renderedLines > 0) {
626
685
  stdout.write(`\u001b[${renderedLines}A`);
627
686
  }
@@ -648,7 +707,7 @@ async function promptSelect(
648
707
  const resolveWithSelection = () => {
649
708
  const selected = normalizedOptions[index];
650
709
  cleanup();
651
- stdout.write(`\u001b[2K\r${label}: ${selected.label}\n`);
710
+ stdout.write(`\u001b[2K\r${tint(color, ANSI_CYAN, label)}: ${tint(color, ANSI_GREEN, selected.label)}\n`);
652
711
  resolve(selected.value);
653
712
  };
654
713
 
@@ -679,7 +738,14 @@ async function promptSelect(
679
738
  });
680
739
  }
681
740
 
682
- async function promptBooleanChoice(rl, stdin, stdout, label, defaultValue, { trueLabel = "Yes", falseLabel = "No", hint = null } = {}) {
741
+ async function promptBooleanChoice(
742
+ rl,
743
+ stdin,
744
+ stdout,
745
+ label,
746
+ defaultValue,
747
+ { trueLabel = "Yes", falseLabel = "No", hint = null, color = false } = {}
748
+ ) {
683
749
  const selected = await promptSelect(
684
750
  rl,
685
751
  stdin,
@@ -689,7 +755,7 @@ async function promptBooleanChoice(rl, stdin, stdout, label, defaultValue, { tru
689
755
  { value: "yes", label: trueLabel },
690
756
  { value: "no", label: falseLabel }
691
757
  ],
692
- { defaultValue: defaultValue ? "yes" : "no", hint }
758
+ { defaultValue: defaultValue ? "yes" : "no", hint, color }
693
759
  );
694
760
  return selected === "yes";
695
761
  }
@@ -824,6 +890,81 @@ function resolveByoWalletEnv({ walletProvider, walletEnvRows, runtimeEnv }) {
824
890
  return env;
825
891
  }
826
892
 
893
+ function parseScopes(raw) {
894
+ if (!raw || !String(raw).trim()) return [];
895
+ const seen = new Set();
896
+ const out = [];
897
+ for (const part of String(raw).split(",")) {
898
+ const scope = String(part ?? "").trim();
899
+ if (!scope || seen.has(scope)) continue;
900
+ seen.add(scope);
901
+ out.push(scope);
902
+ }
903
+ return out;
904
+ }
905
+
906
+ async function requestRuntimeBootstrapMcpEnv({
907
+ baseUrl,
908
+ tenantId,
909
+ bootstrapApiKey,
910
+ sessionCookie,
911
+ bootstrapKeyId = null,
912
+ bootstrapScopes = [],
913
+ idempotencyKey = null,
914
+ fetchImpl = fetch
915
+ } = {}) {
916
+ const normalizedBaseUrl = normalizeHttpUrl(baseUrl);
917
+ if (!normalizedBaseUrl) throw new Error(`invalid runtime bootstrap base URL: ${baseUrl}`);
918
+ const apiKey = String(bootstrapApiKey ?? "").trim();
919
+ const cookie = String(sessionCookie ?? "").trim();
920
+ if (!apiKey && !cookie) {
921
+ throw new Error("runtime bootstrap requires bootstrap API key or saved login session");
922
+ }
923
+
924
+ const headers = {
925
+ "content-type": "application/json"
926
+ };
927
+ if (apiKey) headers["x-api-key"] = apiKey;
928
+ if (cookie) headers.cookie = cookie;
929
+ if (idempotencyKey) headers["x-idempotency-key"] = String(idempotencyKey);
930
+
931
+ const body = {
932
+ apiKey: {
933
+ create: true,
934
+ description: "settld setup runtime bootstrap"
935
+ }
936
+ };
937
+ if (bootstrapKeyId) body.apiKey.keyId = String(bootstrapKeyId);
938
+ if (Array.isArray(bootstrapScopes) && bootstrapScopes.length > 0) {
939
+ body.apiKey.scopes = bootstrapScopes;
940
+ }
941
+
942
+ const url = new URL(
943
+ `/v1/tenants/${encodeURIComponent(String(tenantId ?? ""))}/onboarding/runtime-bootstrap`,
944
+ normalizedBaseUrl
945
+ );
946
+ const res = await fetchImpl(url.toString(), {
947
+ method: "POST",
948
+ headers,
949
+ body: JSON.stringify(body)
950
+ });
951
+ const text = await res.text();
952
+ let json = null;
953
+ try {
954
+ json = text ? JSON.parse(text) : null;
955
+ } catch {
956
+ json = null;
957
+ }
958
+ if (!res.ok) {
959
+ const message =
960
+ json && typeof json === "object"
961
+ ? json?.message ?? json?.error ?? `HTTP ${res.status}`
962
+ : text || `HTTP ${res.status}`;
963
+ throw new Error(`runtime bootstrap request failed (${res.status}): ${String(message)}`);
964
+ }
965
+ return extractBootstrapMcpEnv(json);
966
+ }
967
+
827
968
  async function requestRemoteWalletBootstrap({
828
969
  baseUrl,
829
970
  tenantId,
@@ -889,6 +1030,8 @@ async function resolveRuntimeConfig({
889
1030
  stdout = process.stdout,
890
1031
  detectInstalledHostsImpl = detectInstalledHosts
891
1032
  }) {
1033
+ const sessionFile = String(args.sessionFile ?? runtimeEnv.SETTLD_SESSION_FILE ?? defaultSessionPath()).trim();
1034
+ const savedSession = await readSavedSession({ sessionPath: sessionFile });
892
1035
  const installedHosts = detectInstalledHostsImpl();
893
1036
  const defaultHost = selectDefaultHost({
894
1037
  explicitHost: args.host ? String(args.host).toLowerCase() : "",
@@ -900,6 +1043,13 @@ async function resolveRuntimeConfig({
900
1043
  baseUrl: String(args.baseUrl ?? runtimeEnv.SETTLD_BASE_URL ?? "").trim(),
901
1044
  tenantId: String(args.tenantId ?? runtimeEnv.SETTLD_TENANT_ID ?? "").trim(),
902
1045
  settldApiKey: String(args.settldApiKey ?? runtimeEnv.SETTLD_API_KEY ?? "").trim(),
1046
+ bootstrapApiKey: String(
1047
+ args.bootstrapApiKey ?? runtimeEnv.SETTLD_BOOTSTRAP_API_KEY ?? runtimeEnv.MAGIC_LINK_API_KEY ?? ""
1048
+ ).trim(),
1049
+ sessionFile,
1050
+ sessionCookie: String(runtimeEnv.SETTLD_SESSION_COOKIE ?? "").trim(),
1051
+ bootstrapKeyId: String(args.bootstrapKeyId ?? runtimeEnv.SETTLD_BOOTSTRAP_KEY_ID ?? "").trim(),
1052
+ bootstrapScopes: String(args.bootstrapScopes ?? runtimeEnv.SETTLD_BOOTSTRAP_SCOPES ?? "").trim(),
903
1053
  walletProvider: args.walletProvider,
904
1054
  walletBootstrap: args.walletBootstrap,
905
1055
  circleApiKey: String(args.circleApiKey ?? runtimeEnv.CIRCLE_API_KEY ?? "").trim(),
@@ -914,12 +1064,19 @@ async function resolveRuntimeConfig({
914
1064
  dryRun: Boolean(args.dryRun),
915
1065
  installedHosts
916
1066
  };
1067
+ if (savedSession) {
1068
+ if (!out.baseUrl) out.baseUrl = String(savedSession.baseUrl ?? "").trim();
1069
+ if (!out.tenantId) out.tenantId = String(savedSession.tenantId ?? "").trim();
1070
+ if (!out.sessionCookie) out.sessionCookie = String(savedSession.cookie ?? "").trim();
1071
+ }
917
1072
 
918
1073
  if (args.nonInteractive) {
919
1074
  if (!SUPPORTED_HOSTS.includes(out.host)) throw new Error(`--host must be one of: ${SUPPORTED_HOSTS.join(", ")}`);
920
1075
  if (!out.baseUrl) throw new Error("--base-url is required");
921
1076
  if (!out.tenantId) throw new Error("--tenant-id is required");
922
- if (!out.settldApiKey) throw new Error("--settld-api-key is required");
1077
+ if (!out.settldApiKey && !out.bootstrapApiKey && !out.sessionCookie) {
1078
+ throw new Error("--settld-api-key, --bootstrap-api-key, or saved login session is required");
1079
+ }
923
1080
  if (out.walletMode === "managed" && out.walletBootstrap === "local" && !out.circleApiKey) {
924
1081
  throw new Error("--circle-api-key is required for --wallet-mode managed --wallet-bootstrap local");
925
1082
  }
@@ -929,15 +1086,22 @@ async function resolveRuntimeConfig({
929
1086
  if (!stdin.isTTY || !stdout.isTTY) {
930
1087
  throw new Error("interactive mode requires a TTY. Re-run with --non-interactive and explicit flags.");
931
1088
  }
1089
+ const color = supportsColor(stdout, runtimeEnv);
932
1090
  const mutableOutput = createMutableOutput(stdout);
933
1091
  const rl = createInterface({ input: stdin, output: mutableOutput });
934
1092
  try {
935
- stdout.write("Settld guided setup\n");
936
- stdout.write("===================\n");
1093
+ const title = tint(color, ANSI_BOLD, "Settld guided setup");
1094
+ const subtitle = tint(color, ANSI_DIM, "Deterministic onboarding for trusted agent spend");
1095
+ stdout.write(`${title}\n`);
1096
+ stdout.write(`${tint(color, ANSI_MAGENTA, "===================")}\n`);
1097
+ stdout.write(`${subtitle}\n`);
937
1098
  if (installedHosts.length > 0) {
938
- stdout.write(`Detected hosts: ${installedHosts.join(", ")}\n`);
1099
+ stdout.write(`${tint(color, ANSI_CYAN, "Detected hosts")}: ${installedHosts.join(", ")}\n`);
939
1100
  } else {
940
- stdout.write("Detected hosts: none (will still write config files)\n");
1101
+ stdout.write(`${tint(color, ANSI_CYAN, "Detected hosts")}: none (will still write config files)\n`);
1102
+ }
1103
+ if (savedSession?.tenantId) {
1104
+ stdout.write(`${tint(color, ANSI_GREEN, "Saved login session")}: tenant ${savedSession.tenantId}\n`);
941
1105
  }
942
1106
  stdout.write("\n");
943
1107
 
@@ -952,7 +1116,7 @@ async function resolveRuntimeConfig({
952
1116
  stdout,
953
1117
  "Select host",
954
1118
  hostOptions,
955
- { defaultValue: hostPromptDefault, hint: "Up/Down arrows change selection" }
1119
+ { defaultValue: hostPromptDefault, hint: "Up/Down arrows change selection", color }
956
1120
  );
957
1121
 
958
1122
  if (!out.walletMode) out.walletMode = "managed";
@@ -966,7 +1130,7 @@ async function resolveRuntimeConfig({
966
1130
  { value: "byo", label: "byo", hint: "Use your existing wallet IDs and secrets" },
967
1131
  { value: "none", label: "none", hint: "No payment rail wiring during setup" }
968
1132
  ],
969
- { defaultValue: out.walletMode }
1133
+ { defaultValue: out.walletMode, color }
970
1134
  );
971
1135
 
972
1136
  if (!out.baseUrl) {
@@ -975,7 +1139,48 @@ async function resolveRuntimeConfig({
975
1139
  if (!out.tenantId) {
976
1140
  out.tenantId = await promptLine(rl, "Tenant ID", { defaultValue: "tenant_default" });
977
1141
  }
978
- if (!out.settldApiKey) out.settldApiKey = await promptSecretLine(rl, mutableOutput, stdout, "Settld API key");
1142
+ if (!out.settldApiKey) {
1143
+ const canUseSavedSession =
1144
+ Boolean(out.sessionCookie) &&
1145
+ (!savedSession ||
1146
+ (normalizeHttpUrl(out.baseUrl) === normalizeHttpUrl(savedSession?.baseUrl) &&
1147
+ String(out.tenantId ?? "").trim() === String(savedSession?.tenantId ?? "").trim()));
1148
+ const keyOptions = [];
1149
+ if (canUseSavedSession) {
1150
+ keyOptions.push({
1151
+ value: "session",
1152
+ label: "Use saved login session",
1153
+ hint: `Reuse ${out.sessionFile} to mint runtime key`
1154
+ });
1155
+ }
1156
+ keyOptions.push(
1157
+ { value: "bootstrap", label: "Generate during setup", hint: "Use onboarding bootstrap API key" },
1158
+ { value: "manual", label: "Paste existing key", hint: "Use an existing tenant API key" }
1159
+ );
1160
+ const keyMode = await promptSelect(
1161
+ rl,
1162
+ stdin,
1163
+ stdout,
1164
+ "How should setup get your Settld API key?",
1165
+ keyOptions,
1166
+ { defaultValue: canUseSavedSession ? "session" : "bootstrap", color }
1167
+ );
1168
+ if (keyMode === "bootstrap") {
1169
+ if (!out.bootstrapApiKey) {
1170
+ out.bootstrapApiKey = await promptSecretLine(rl, mutableOutput, stdout, "Onboarding bootstrap API key");
1171
+ }
1172
+ if (!out.bootstrapKeyId) {
1173
+ out.bootstrapKeyId = await promptLine(rl, "Generated key ID (optional)", { required: false });
1174
+ }
1175
+ if (!out.bootstrapScopes) {
1176
+ out.bootstrapScopes = await promptLine(rl, "Generated key scopes CSV (optional)", { required: false });
1177
+ }
1178
+ } else if (keyMode === "manual") {
1179
+ out.settldApiKey = await promptSecretLine(rl, mutableOutput, stdout, "Settld API key");
1180
+ } else {
1181
+ out.bootstrapApiKey = "";
1182
+ }
1183
+ }
979
1184
 
980
1185
  if (out.walletMode === "managed") {
981
1186
  out.walletBootstrap = await promptSelect(
@@ -988,7 +1193,7 @@ async function resolveRuntimeConfig({
988
1193
  { value: "local", label: "local", hint: "Always use local Circle API key flow" },
989
1194
  { value: "remote", label: "remote", hint: "Always use tenant onboarding endpoint" }
990
1195
  ],
991
- { defaultValue: out.walletBootstrap || "auto" }
1196
+ { defaultValue: out.walletBootstrap || "auto", color }
992
1197
  );
993
1198
  if (out.walletBootstrap === "local" && !out.circleApiKey) {
994
1199
  out.circleApiKey = await promptSecretLine(rl, mutableOutput, stdout, "Circle API key");
@@ -1024,7 +1229,8 @@ async function resolveRuntimeConfig({
1024
1229
  out.preflight,
1025
1230
  {
1026
1231
  trueLabel: "Yes - validate API/auth/paths",
1027
- falseLabel: "No - skip preflight"
1232
+ falseLabel: "No - skip preflight",
1233
+ color
1028
1234
  }
1029
1235
  );
1030
1236
  out.smoke = await promptBooleanChoice(
@@ -1035,7 +1241,8 @@ async function resolveRuntimeConfig({
1035
1241
  out.smoke,
1036
1242
  {
1037
1243
  trueLabel: "Yes - run settld.about probe",
1038
- falseLabel: "No - skip smoke"
1244
+ falseLabel: "No - skip smoke",
1245
+ color
1039
1246
  }
1040
1247
  );
1041
1248
 
@@ -1047,7 +1254,8 @@ async function resolveRuntimeConfig({
1047
1254
  !out.skipProfileApply,
1048
1255
  {
1049
1256
  trueLabel: "Yes - apply profile now",
1050
- falseLabel: "No - skip profile apply"
1257
+ falseLabel: "No - skip profile apply",
1258
+ color
1051
1259
  }
1052
1260
  );
1053
1261
  out.skipProfileApply = !applyProfile;
@@ -1065,7 +1273,8 @@ async function resolveRuntimeConfig({
1065
1273
  out.dryRun,
1066
1274
  {
1067
1275
  trueLabel: "Yes - preview only",
1068
- falseLabel: "No - write config"
1276
+ falseLabel: "No - write config",
1277
+ color
1069
1278
  }
1070
1279
  );
1071
1280
  }
@@ -1084,6 +1293,7 @@ export async function runOnboard({
1084
1293
  runWizardImpl = runWizard,
1085
1294
  loadHostConfigHelperImpl = loadHostConfigHelper,
1086
1295
  bootstrapWalletProviderImpl = bootstrapWalletProvider,
1296
+ requestRuntimeBootstrapMcpEnvImpl = requestRuntimeBootstrapMcpEnv,
1087
1297
  requestRemoteWalletBootstrapImpl = requestRemoteWalletBootstrap,
1088
1298
  runPreflightChecksImpl = runPreflightChecks,
1089
1299
  detectInstalledHostsImpl = detectInstalledHosts
@@ -1110,7 +1320,28 @@ export async function runOnboard({
1110
1320
  const normalizedBaseUrl = normalizeHttpUrl(mustString(config.baseUrl, "SETTLD_BASE_URL / --base-url"));
1111
1321
  if (!normalizedBaseUrl) throw new Error(`invalid Settld base URL: ${config.baseUrl}`);
1112
1322
  const tenantId = mustString(config.tenantId, "SETTLD_TENANT_ID / --tenant-id");
1113
- const settldApiKey = mustString(config.settldApiKey, "SETTLD_API_KEY / --settld-api-key");
1323
+ let settldApiKey = String(config.settldApiKey ?? "").trim();
1324
+ let runtimeBootstrapEnv = null;
1325
+ if (!settldApiKey) {
1326
+ if (showSteps) stdout.write("Generating tenant runtime API key via onboarding bootstrap/session...\n");
1327
+ runtimeBootstrapEnv = await requestRuntimeBootstrapMcpEnvImpl({
1328
+ baseUrl: normalizedBaseUrl,
1329
+ tenantId,
1330
+ bootstrapApiKey: config.bootstrapApiKey,
1331
+ sessionCookie: config.sessionCookie,
1332
+ bootstrapKeyId: config.bootstrapKeyId || null,
1333
+ bootstrapScopes: parseScopes(config.bootstrapScopes),
1334
+ fetchImpl
1335
+ });
1336
+ settldApiKey = mustString(runtimeBootstrapEnv?.SETTLD_API_KEY ?? "", "runtime bootstrap SETTLD_API_KEY");
1337
+ }
1338
+ const runtimeBootstrapOptionalEnv = {};
1339
+ if (runtimeBootstrapEnv?.SETTLD_PAID_TOOLS_BASE_URL) {
1340
+ runtimeBootstrapOptionalEnv.SETTLD_PAID_TOOLS_BASE_URL = String(runtimeBootstrapEnv.SETTLD_PAID_TOOLS_BASE_URL);
1341
+ }
1342
+ if (runtimeBootstrapEnv?.SETTLD_PAID_TOOLS_AGENT_PASSPORT) {
1343
+ runtimeBootstrapOptionalEnv.SETTLD_PAID_TOOLS_AGENT_PASSPORT = String(runtimeBootstrapEnv.SETTLD_PAID_TOOLS_AGENT_PASSPORT);
1344
+ }
1114
1345
 
1115
1346
  if (showSteps) printStep(stdout, step, totalSteps, "Resolve wallet configuration");
1116
1347
  let walletBootstrapMode = "none";
@@ -1198,13 +1429,19 @@ export async function runOnboard({
1198
1429
  preflight,
1199
1430
  hostInstallDetected: Array.isArray(config.installedHosts) && config.installedHosts.includes(config.host),
1200
1431
  installedHosts: config.installedHosts,
1201
- env: walletEnv,
1432
+ env: {
1433
+ SETTLD_BASE_URL: normalizedBaseUrl,
1434
+ SETTLD_TENANT_ID: tenantId,
1435
+ SETTLD_API_KEY: settldApiKey,
1436
+ ...runtimeBootstrapOptionalEnv,
1437
+ ...walletEnv
1438
+ },
1202
1439
  outEnv: args.outEnv ?? null,
1203
1440
  reportPath: args.reportPath ?? null
1204
1441
  };
1205
1442
  if (args.outEnv) {
1206
1443
  await fs.mkdir(path.dirname(args.outEnv), { recursive: true });
1207
- await fs.writeFile(args.outEnv, toEnvFileText(walletEnv), "utf8");
1444
+ await fs.writeFile(args.outEnv, toEnvFileText(payload.env), "utf8");
1208
1445
  }
1209
1446
  await writeJsonReport(args.reportPath, payload);
1210
1447
  if (args.format === "json") {
@@ -1251,11 +1488,15 @@ export async function runOnboard({
1251
1488
  argv: wizardArgv,
1252
1489
  fetchImpl,
1253
1490
  stdout,
1254
- extraEnv: walletEnv
1491
+ extraEnv: {
1492
+ ...runtimeBootstrapOptionalEnv,
1493
+ ...walletEnv
1494
+ }
1255
1495
  });
1256
1496
  step += 1;
1257
1497
 
1258
1498
  const mergedEnv = {
1499
+ ...runtimeBootstrapOptionalEnv,
1259
1500
  ...(walletEnv ?? {}),
1260
1501
  ...(wizardResult?.env && typeof wizardResult.env === "object" ? wizardResult.env : {})
1261
1502
  };
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ const SESSION_SCHEMA_VERSION = "SettldCliSession.v1";
8
+
9
+ function normalizeCookieHeader(value) {
10
+ const raw = String(value ?? "").trim();
11
+ if (!raw) return null;
12
+ const firstSegment = raw.split(";")[0]?.trim() ?? "";
13
+ if (!firstSegment) return null;
14
+ const eq = firstSegment.indexOf("=");
15
+ if (eq <= 0) return null;
16
+ const name = firstSegment.slice(0, eq).trim();
17
+ const token = firstSegment.slice(eq + 1).trim();
18
+ if (!name || !token) return null;
19
+ return `${name}=${token}`;
20
+ }
21
+
22
+ export function defaultSessionPath({ homeDir = os.homedir() } = {}) {
23
+ return path.join(homeDir, ".settld", "session.json");
24
+ }
25
+
26
+ export function normalizeSession(input) {
27
+ const row = input && typeof input === "object" && !Array.isArray(input) ? input : {};
28
+ const baseUrl = typeof row.baseUrl === "string" ? row.baseUrl.trim().replace(/\/+$/, "") : "";
29
+ const tenantId = typeof row.tenantId === "string" ? row.tenantId.trim() : "";
30
+ const email = typeof row.email === "string" ? row.email.trim().toLowerCase() : "";
31
+ const cookie = normalizeCookieHeader(row.cookie);
32
+ if (!baseUrl || !tenantId || !cookie) return null;
33
+ const out = {
34
+ schemaVersion: SESSION_SCHEMA_VERSION,
35
+ savedAt: typeof row.savedAt === "string" && row.savedAt.trim() ? row.savedAt.trim() : new Date().toISOString(),
36
+ baseUrl,
37
+ tenantId,
38
+ cookie,
39
+ email: email || null
40
+ };
41
+ if (typeof row.expiresAt === "string" && row.expiresAt.trim()) out.expiresAt = row.expiresAt.trim();
42
+ return out;
43
+ }
44
+
45
+ export async function readSavedSession({ sessionPath = defaultSessionPath() } = {}) {
46
+ try {
47
+ const raw = await fs.readFile(sessionPath, "utf8");
48
+ const parsed = JSON.parse(raw);
49
+ return normalizeSession(parsed);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export async function writeSavedSession({ session, sessionPath = defaultSessionPath() } = {}) {
56
+ const normalized = normalizeSession(session);
57
+ if (!normalized) throw new Error("invalid session payload");
58
+ await fs.mkdir(path.dirname(sessionPath), { recursive: true });
59
+ await fs.writeFile(sessionPath, `${JSON.stringify(normalized, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
60
+ return normalized;
61
+ }
62
+
63
+ export function cookieHeaderFromSetCookie(value) {
64
+ return normalizeCookieHeader(value);
65
+ }
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Vercel "ignoreCommand" contract:
5
+ # - exit 0 => skip deployment
6
+ # - exit 1 => continue with deployment
7
+
8
+ if ! git rev-parse --verify HEAD^ >/dev/null 2>&1; then
9
+ # No parent commit context available; build to stay safe.
10
+ exit 1
11
+ fi
12
+
13
+ if git diff --quiet HEAD^ HEAD -- \
14
+ dashboard/ \
15
+ scripts/vercel/ignore-dashboard.sh \
16
+ .github/workflows/release.yml \
17
+ .github/workflows/tests.yml; then
18
+ # No website changes; skip dashboard deployment.
19
+ exit 0
20
+ fi
21
+
22
+ # Relevant website/deploy files changed; run deployment.
23
+ exit 1
@@ -11,6 +11,8 @@ if ! git rev-parse --verify HEAD^ >/dev/null 2>&1; then
11
11
  fi
12
12
 
13
13
  if git diff --quiet HEAD^ HEAD -- \
14
+ mkdocs/docs/ \
15
+ mkdocs/ \
14
16
  docs/ \
15
17
  mkdocs.yml \
16
18
  scripts/vercel/ \