@sailresearch/code 0.1.0

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/src/setup.js ADDED
@@ -0,0 +1,482 @@
1
+ /**
2
+ * `sail-code setup` — persist the Sail env block into Claude Code's
3
+ * settings.json so a bare `claude` (launched from anywhere) routes to Sail.
4
+ * VS Code extension sessions read this env block too, but the extension's
5
+ * own pre-launch login check does not — account-less users additionally
6
+ * need `claudeCode.environmentVariables` in VS Code's settings (the setup
7
+ * output prints the snippet).
8
+ *
9
+ * The write is reversible: the original file is backed up to
10
+ * `settings.json.sail-backup` before the first change, and `--revert`
11
+ * restores it (or, without a backup, removes exactly the keys we own).
12
+ */
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+
19
+ import {
20
+ CONFLICTING_PROVIDER_VARS,
21
+ HEADER_INJECTION_VARS,
22
+ MODEL_ALIAS_COMPANION_VARS,
23
+ OWNED_ENV_KEYS,
24
+ buildClaudeEnv,
25
+ } from "./constants.js";
26
+ import { atomicWrite } from "./credentials.js";
27
+ import { resolveCredentials } from "./run.js";
28
+
29
+ export const BACKUP_SUFFIX = ".sail-backup";
30
+
31
+ /**
32
+ * Backup sentinel recorded when the settings file did NOT exist before the
33
+ * first `setup`. A re-run then finds a backup already present (so it won't
34
+ * capture the Sail-injected file as if it were the user's original), and
35
+ * `--revert` sees the sentinel and DELETES the file we created rather than
36
+ * "restoring" a token-bearing file. Deliberately not valid JSON so it can
37
+ * never be mistaken for a real settings backup.
38
+ */
39
+ export const ABSENT_SENTINEL = "SAIL_ORIGINAL_ABSENT\n";
40
+
41
+ /**
42
+ * Merge the Sail env block into a settings object and neutralize any inherited
43
+ * keys that would outrank or mutate our routing once Claude Code reads the
44
+ * settings-file `env` at startup: provider selectors (`CLAUDE_CODE_USE_*`,
45
+ * which precede `ANTHROPIC_AUTH_TOKEN` in the auth precedence), header
46
+ * injectors (`ANTHROPIC_CUSTOM_HEADERS`), and alias companion vars
47
+ * (`ANTHROPIC_DEFAULT_<ALIAS>_MODEL_{NAME,DESCRIPTION,SUPPORTED_CAPABILITIES}`,
48
+ * which would mis-describe the GLM model the alias pins point at). Mirrors the
49
+ * child-env scrub on the `run` path so bare `claude`/VS Code can't silently
50
+ * bypass Sail or mis-advertise GLM's capabilities after `setup`.
51
+ *
52
+ * Deleting a key only clears it when the file we write is the one that holds
53
+ * it — but a conflict can also come from a place our write can't reach by
54
+ * omission, so for those we set a falsy override (like the temp `--settings`
55
+ * layer) instead of deleting:
56
+ * - a sibling `settings.json` that the higher-precedence project
57
+ * `settings.local.json` MERGES over (project scope; we can't see it from
58
+ * here, so we override unconditionally); and
59
+ * - the current shell/profile: a settings-file `env` value overrides the
60
+ * inherited process env, so a shell-exported `CLAUDE_CODE_USE_*` /
61
+ * `ANTHROPIC_CUSTOM_HEADERS` survives a delete-from-file (there's nothing
62
+ * in the file to delete) and would still outrank Sail. We override the
63
+ * keys actually present in `shellEnv` — for either scope — so a clean
64
+ * settings file isn't littered with empty markers.
65
+ * Companion vars stay deleted even then: empty string isn't a documented
66
+ * "unset" for `_SUPPORTED_CAPABILITIES` (it could read as "no capabilities"),
67
+ * so a companion var in a repo-shared `settings.json` is a residual gap.
68
+ *
69
+ * Mutates `settings.env`; returns the keys that were actually removed (so the
70
+ * caller can surface them). `--revert` restores them from the byte backup, or
71
+ * clears the falsy overrides we wrote. Exported so tests exercise the real
72
+ * merge, not a copy.
73
+ */
74
+ export function mergeSailEnv(
75
+ settings,
76
+ env,
77
+ { scope = "user", shellEnv = process.env } = {},
78
+ ) {
79
+ const merged = { ...(settings.env ?? {}), ...env };
80
+ const removed = [];
81
+ // Companion vars: delete inherited ones (empty string is unsafe for these),
82
+ // but keep the values OUR env block sets — buildClaudeEnv declares
83
+ // `_SUPPORTED_CAPABILITIES` for aliases pinned to the default GLM model so
84
+ // effort/thinking aren't dropped for the custom model ID.
85
+ for (const key of MODEL_ALIAS_COMPANION_VARS) {
86
+ if (key in env) continue; // ours — already overlaid by the spread above
87
+ if (key in merged) removed.push(key);
88
+ delete merged[key];
89
+ }
90
+ for (const key of [...CONFLICTING_PROVIDER_VARS, ...HEADER_INJECTION_VARS]) {
91
+ if (scope === "project" || key in shellEnv) {
92
+ // Falsy override beats a conflict we can't clear by omission: a sibling
93
+ // settings.json this local file merges over (project scope), or a shell-
94
+ // exported var (either scope) that a settings-file env value outranks.
95
+ // revert clears these empty markers.
96
+ merged[key] = "";
97
+ } else if (key in merged) {
98
+ // Our env block never sets these keys, so presence == inherited in the
99
+ // file; user scope is lowest-precedence, so deleting fully clears it.
100
+ removed.push(key);
101
+ delete merged[key];
102
+ }
103
+ }
104
+ settings.env = merged;
105
+ return removed;
106
+ }
107
+
108
+ export function settingsPath(scope) {
109
+ if (scope === "project") {
110
+ // The project scope writes the *local* settings file, not the shared
111
+ // `settings.json`: it carries the API key, and `.claude/settings.local
112
+ // .json` is Claude Code's documented personal/not-committed file (higher
113
+ // precedence than `settings.json`, still below our temp `--settings`). We
114
+ // gitignore it on write since Claude Code only auto-ignores files it
115
+ // creates itself.
116
+ return path.join(process.cwd(), ".claude", "settings.local.json");
117
+ }
118
+ // Honor CLAUDE_CONFIG_DIR: when it's set, Claude Code reads user config from
119
+ // that directory instead of ~/.claude (credentials live at
120
+ // `$CLAUDE_CONFIG_DIR/.credentials.json`, settings at
121
+ // `$CLAUDE_CONFIG_DIR/settings.json`). Writing ~/.claude/settings.json there
122
+ // would report success while the launched session reads the relocated file
123
+ // and ignores the Sail env. Only the user scope relocates; project settings
124
+ // stay in the repo's `.claude/`.
125
+ const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
126
+ if (configDir) {
127
+ return path.join(configDir, "settings.json");
128
+ }
129
+ return path.join(os.homedir(), ".claude", "settings.json");
130
+ }
131
+
132
+ /**
133
+ * Ensure `.claude/settings.local.json` (and its `.sail-backup`) are gitignored,
134
+ * so a project-scoped setup can't leak the API key into a commit. Claude Code
135
+ * auto-ignores this file only when *it* creates it; we create it, so we write a
136
+ * scoped `.claude/.gitignore`. Best-effort: a failure here never blocks setup.
137
+ * Exported for tests.
138
+ */
139
+ export function ensureLocalSettingsIgnored(filePath) {
140
+ try {
141
+ const claudeDir = path.dirname(filePath);
142
+ fs.mkdirSync(claudeDir, { recursive: true });
143
+ const ignoreFile = path.join(claudeDir, ".gitignore");
144
+ const wanted = ["settings.local.json", "settings.local.json.sail-backup"];
145
+ let lines = [];
146
+ if (fs.existsSync(ignoreFile)) {
147
+ lines = fs.readFileSync(ignoreFile, "utf8").split(/\r?\n/);
148
+ }
149
+ const have = new Set(lines.map((l) => l.trim()));
150
+ const missing = wanted.filter((w) => !have.has(w));
151
+ if (missing.length === 0) return;
152
+ const prefix = lines.length && lines[lines.length - 1] !== "" ? "\n" : "";
153
+ fs.appendFileSync(ignoreFile, prefix + missing.join("\n") + "\n");
154
+ } catch {
155
+ // A missing/unwritable .claude dir surfaces on the settings write instead;
156
+ // never fail setup just because we couldn't touch .gitignore.
157
+ }
158
+ }
159
+
160
+ /** The nearest ancestor of `dir` that exists on disk (walks up to the root). */
161
+ function firstExistingDir(dir) {
162
+ let cur = path.resolve(dir);
163
+ while (!fs.existsSync(cur)) {
164
+ const parent = path.dirname(cur);
165
+ if (parent === cur) break; // reached the filesystem root
166
+ cur = parent;
167
+ }
168
+ return cur;
169
+ }
170
+
171
+ /**
172
+ * Whether `filePath` is tracked in git. `git ls-files` reads the INDEX, so a
173
+ * file tracked but absent from the working tree (deleted locally, sparse
174
+ * checkout) still counts — but we must run git from a directory that EXISTS,
175
+ * or spawn fails with ENOENT and we'd wrongly read "not tracked". Run from the
176
+ * nearest existing ancestor of the file. Only `true` when git confirms it — a
177
+ * non-repo, a missing git binary, or an untracked file all read as `false`
178
+ * (we refuse only when we're certain, never on a can't-tell). Exported for
179
+ * tests.
180
+ */
181
+ export function isGitTracked(filePath) {
182
+ // Resolve to an absolute path: a relative filePath (e.g. a relative
183
+ // CLAUDE_CONFIG_DIR like `.claude`) would be interpreted relative to git's
184
+ // cwd (the file's own dir), so the pathspec would miss the real file and
185
+ // wrongly read "not tracked". An absolute pathspec resolves correctly.
186
+ const abs = path.resolve(filePath);
187
+ try {
188
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", abs], {
189
+ cwd: firstExistingDir(path.dirname(abs)),
190
+ stdio: "ignore",
191
+ timeout: 5000,
192
+ });
193
+ return true; // exit 0 → tracked
194
+ } catch {
195
+ return false; // non-zero: untracked, not a repo, or git unavailable
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Whether git considers `filePath` ignored. `git check-ignore` evaluates the
201
+ * effective ignore rules (including parent `.gitignore`s and negations), not
202
+ * just our scoped file, and works on a path that doesn't exist yet.
203
+ * Returns "ignored" (exit 0), "not-ignored" (exit 1 — a real repo where it
204
+ * isn't ignored), or "unknown" (exit 128 / no repo / git missing — can't tell,
205
+ * so no `git add` risk we can confirm). Exported for tests.
206
+ */
207
+ export function gitIgnoreState(filePath) {
208
+ // Absolute pathspec (see isGitTracked) so a relative filePath can't be
209
+ // misresolved against git's cwd.
210
+ const abs = path.resolve(filePath);
211
+ try {
212
+ execFileSync("git", ["check-ignore", "-q", "--", abs], {
213
+ cwd: firstExistingDir(path.dirname(abs)),
214
+ stdio: "ignore",
215
+ timeout: 5000,
216
+ });
217
+ return "ignored"; // exit 0
218
+ } catch (err) {
219
+ return err && err.status === 1 ? "not-ignored" : "unknown";
220
+ }
221
+ }
222
+
223
+ /** Read settings.json as an object; missing file → {}; malformed → error. */
224
+ function readSettings(filePath) {
225
+ let text;
226
+ try {
227
+ text = fs.readFileSync(filePath, "utf8");
228
+ } catch (err) {
229
+ if (err.code === "ENOENT") return { settings: {}, existed: false };
230
+ throw new Error(`could not read ${filePath}: ${err.message}`);
231
+ }
232
+ try {
233
+ // Strip a UTF-8 BOM (some Windows editors add one; JSON.parse rejects it).
234
+ const settings = JSON.parse(text.replace(/^\uFEFF/, ""));
235
+ if (
236
+ settings === null ||
237
+ typeof settings !== "object" ||
238
+ Array.isArray(settings)
239
+ ) {
240
+ throw new Error("top level is not an object");
241
+ }
242
+ return { settings, existed: true, originalText: text };
243
+ } catch (err) {
244
+ throw new Error(
245
+ `${filePath} is not valid JSON (${err.message}) — fix it before running setup`,
246
+ );
247
+ }
248
+ }
249
+
250
+ function writeSettings(filePath, settings, { containsSecret = false } = {}) {
251
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
252
+ // Preserve an existing file's permissions in general, but once the content
253
+ // includes the API key, mask group/other bits (0644 → 0600) so the token
254
+ // isn't readable by other local users. Fresh files get 0600. A --revert
255
+ // restores the original file (and its original mode) from the backup.
256
+ atomicWrite(filePath, JSON.stringify(settings, null, 2) + "\n", {
257
+ preserveMode: true,
258
+ tighten: containsSecret,
259
+ });
260
+ }
261
+
262
+ /**
263
+ * Refuse to write a secret into a git-tracked settings file (or its backup):
264
+ * `.gitignore` can't untrack an already-tracked file, so the token would show
265
+ * in `git diff`/`status` and could be committed. Applies to BOTH scopes — user
266
+ * settings can live in a tracked `~/.claude` dotfiles checkout or a
267
+ * `CLAUDE_CONFIG_DIR` inside a repo, not just project settings. Throws before
268
+ * any key is minted or written (resolveCredentials can trigger a browser login).
269
+ */
270
+ function assertSettingsNotGitTracked(filePath) {
271
+ for (const p of [filePath, filePath + BACKUP_SUFFIX]) {
272
+ if (isGitTracked(p)) {
273
+ throw new Error(
274
+ `${p} is tracked in git — setup won't write a secret into a tracked ` +
275
+ "file (a .gitignore entry won't untrack it). Untrack it with `git rm " +
276
+ "--cached`, or set SAIL_API_KEY in your environment instead (nothing " +
277
+ "is persisted then).",
278
+ );
279
+ }
280
+ }
281
+ }
282
+
283
+ /** Apply the Sail env block to settings.json (backing up the original). */
284
+ export async function setup({
285
+ scope = "user",
286
+ modeFlag,
287
+ model,
288
+ backgroundModel,
289
+ }) {
290
+ const filePath = settingsPath(scope);
291
+ const backupPath = filePath + BACKUP_SUFFIX;
292
+ // Never write a secret into a git-tracked file — for EITHER scope. Project
293
+ // settings live in the repo, but user settings can too (a tracked ~/.claude
294
+ // dotfiles checkout, or CLAUDE_CONFIG_DIR pointing inside a repo). Refuse
295
+ // before a key is minted/written rather than leak it.
296
+ assertSettingsNotGitTracked(filePath);
297
+
298
+ if (scope === "project") {
299
+ // Ensure both the file and its backup are git-ignored BEFORE we resolve/
300
+ // mint a key or write the token. A read-only or locked `.claude/.gitignore`
301
+ // makes the append fail silently, which would leave the secret stageable by
302
+ // `git add .` despite the "added ignore rules" message. Append, then verify
303
+ // with `git check-ignore`; refuse if a real repo confirms either still
304
+ // isn't ignored (no key minted, no token written yet).
305
+ ensureLocalSettingsIgnored(filePath);
306
+ for (const p of [filePath, backupPath]) {
307
+ if (gitIgnoreState(p) === "not-ignored") {
308
+ throw new Error(
309
+ `couldn't ensure ${p} is git-ignored (is .claude/.gitignore ` +
310
+ "writable?) — refusing to write a secret where `git add` could " +
311
+ "stage it. Fix that, or use `--user` instead.",
312
+ );
313
+ }
314
+ }
315
+ console.error(
316
+ `Note: this writes your Sail API key into ${filePath} (Claude Code's ` +
317
+ "personal, not-shared project settings), now git-ignored via " +
318
+ "`.claude/.gitignore`. Double-check it stays out of commits, or use " +
319
+ "`--user` instead.",
320
+ );
321
+ } else if (
322
+ gitIgnoreState(filePath) === "not-ignored" ||
323
+ gitIgnoreState(backupPath) === "not-ignored"
324
+ ) {
325
+ // User settings resolved INSIDE a git repo (dotfiles / CLAUDE_CONFIG_DIR)
326
+ // and aren't ignored: not tracked yet, but a `git add .` there could stage
327
+ // the token. Fail closed like the tracked-file and project-scope guards —
328
+ // a warning scrolls past and the key still lands where git can stage it.
329
+ // We don't edit the user's own dotfiles-repo .gitignore for them; the
330
+ // error says how to fix it. Throws before any key is minted/written.
331
+ throw new Error(
332
+ `${filePath} is inside a git repo and isn't git-ignored — a ` +
333
+ "`git add .` there could stage your Sail API key. Add it (and its " +
334
+ ".sail-backup) to that repo's .gitignore, or set SAIL_API_KEY in " +
335
+ "your environment instead (nothing is persisted then).",
336
+ );
337
+ }
338
+
339
+ const { apiKey, apiUrl } = await resolveCredentials({ modeFlag });
340
+ const env = buildClaudeEnv({ apiUrl, apiKey, model, backgroundModel });
341
+
342
+ const { settings, existed } = readSettings(filePath);
343
+ // Create the settings dir before writing the backup/sentinel. On a fresh
344
+ // machine (no ~/.claude, or a brand-new CLAUDE_CONFIG_DIR) the sentinel write
345
+ // below would otherwise ENOENT — after the key was already minted — since
346
+ // writeSettings only creates the parent later. mkdir is a no-op when it
347
+ // exists (and project scope already made it via ensureLocalSettingsIgnored).
348
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
349
+ // The FIRST setup records the true pre-Sail state so --revert restores it;
350
+ // a re-run must never overwrite that record with an already-Sail-injected
351
+ // file. When the file existed, back up its bytes (carrying its mode). When
352
+ // it did NOT, write the absent-sentinel so a re-run (file now present)
353
+ // doesn't capture the Sail file as the "original", and --revert deletes
354
+ // what we created instead of leaving the API key behind. Both writes are
355
+ // create-exclusive (COPYFILE_EXCL / "wx"): a plain existsSync check would
356
+ // race two concurrent setups, letting the second clobber the first's backup
357
+ // — which by then may already hold the Sail-injected file — and lose the
358
+ // real original. An atomic create fails with EEXIST instead; that just means
359
+ // a prior/concurrent setup already recorded the state, so it's expected.
360
+ try {
361
+ if (existed) {
362
+ fs.copyFileSync(filePath, backupPath, fs.constants.COPYFILE_EXCL);
363
+ } else {
364
+ fs.writeFileSync(backupPath, ABSENT_SENTINEL, {
365
+ mode: 0o600,
366
+ flag: "wx",
367
+ });
368
+ }
369
+ } catch (err) {
370
+ if (err.code !== "EEXIST") throw err;
371
+ }
372
+
373
+ const removedKeys = mergeSailEnv(settings, env, { scope });
374
+ writeSettings(filePath, settings, { containsSecret: true });
375
+
376
+ if (removedKeys.length > 0) {
377
+ console.error(
378
+ `Removed conflicting env ${removedKeys.join(", ")} from ${filePath} so ` +
379
+ "it can't outrank or mutate Sail routing (--revert restores them).",
380
+ );
381
+ }
382
+ if (scope === "project") {
383
+ console.error(
384
+ "Set empty overrides for provider/header env in the local file so they " +
385
+ "beat any conflicting value in a repo-shared `.claude/settings.json` " +
386
+ "(--revert clears them).",
387
+ );
388
+ }
389
+ const shellConflicts = [
390
+ ...CONFLICTING_PROVIDER_VARS,
391
+ ...HEADER_INJECTION_VARS,
392
+ ].filter((key) => key in process.env);
393
+ if (shellConflicts.length > 0) {
394
+ console.error(
395
+ `Your shell exports ${shellConflicts.join(", ")} — wrote empty overrides ` +
396
+ `in ${filePath} so bare \`claude\` still routes to Sail (a settings-file ` +
397
+ "env value outranks the shell). --revert clears them; you can also unset " +
398
+ "them in your shell profile.",
399
+ );
400
+ }
401
+
402
+ const revertCmd =
403
+ "sail-code setup --revert" + (scope === "project" ? " --project" : "");
404
+ // User settings are the LOWEST-precedence settings source, so a repo that
405
+ // already carries `.claude/settings.json` (or settings.local.json) with its
406
+ // own ANTHROPIC_BASE_URL / provider selector overrides this write inside
407
+ // that repo. Don't claim it applies everywhere — point those repos at
408
+ // `--project`. Project scope writes the near-top local file, so it does win.
409
+ const reach =
410
+ scope === "project"
411
+ ? "This applies to `claude` launched from THIS directory — Claude Code " +
412
+ "reads project settings from the launch directory (verified " +
413
+ "empirically; not the git root), so run setup where you run `claude`. " +
414
+ "Project settings outrank user settings."
415
+ : "This applies to `claude` launched from anywhere, EXCEPT a repo whose " +
416
+ "own `.claude/settings.json` sets a conflicting ANTHROPIC_BASE_URL / " +
417
+ "provider var — those outrank user settings; run `setup --project` " +
418
+ "there.";
419
+ console.error(
420
+ `Wrote Sail routing to ${filePath}` +
421
+ (existed ? ` (original backed up to ${backupPath})` : "") +
422
+ " — permissions set to owner-only since the file now holds your API key." +
423
+ "\n" +
424
+ reach +
425
+ " Undo anytime with: " +
426
+ revertCmd +
427
+ "\n\nVS Code note: the extension reads this file for its sessions, but " +
428
+ "its own pre-launch login check does not — if you have no saved " +
429
+ "Anthropic login, also add these to VS Code's user settings " +
430
+ "(Preferences: Open User Settings (JSON)), then reload the window:\n" +
431
+ ` "claudeCode.disableLoginPrompt": true,\n` +
432
+ ` "claudeCode.environmentVariables": [\n` +
433
+ ` { "name": "ANTHROPIC_BASE_URL", "value": "${apiUrl}" },\n` +
434
+ ` { "name": "ANTHROPIC_AUTH_TOKEN", "value": "<your SAIL_API_KEY>" }\n` +
435
+ ` ]`,
436
+ );
437
+ return 0;
438
+ }
439
+
440
+ /** Restore the pre-setup settings.json, or strip exactly our keys. */
441
+ export function revert({ scope = "user" }) {
442
+ const filePath = settingsPath(scope);
443
+ const backupPath = filePath + BACKUP_SUFFIX;
444
+
445
+ if (fs.existsSync(backupPath)) {
446
+ if (fs.readFileSync(backupPath, "utf8") === ABSENT_SENTINEL) {
447
+ // setup created this file (nothing existed before). Remove what we made
448
+ // — deleting the API key — rather than "restoring" the Sail-injected
449
+ // file. Clear the sentinel too so the slate is truly clean.
450
+ fs.rmSync(filePath, { force: true });
451
+ fs.rmSync(backupPath, { force: true });
452
+ console.error(`Removed ${filePath} (it did not exist before setup).`);
453
+ return 0;
454
+ }
455
+ fs.renameSync(backupPath, filePath);
456
+ console.error(`Restored ${filePath} from ${backupPath}.`);
457
+ return 0;
458
+ }
459
+
460
+ const { settings, existed } = readSettings(filePath);
461
+ if (!existed) {
462
+ console.error(`Nothing to revert: ${filePath} does not exist.`);
463
+ return 0;
464
+ }
465
+ if (settings.env && typeof settings.env === "object") {
466
+ for (const key of OWNED_ENV_KEYS) delete settings.env[key];
467
+ // Clear the falsy overrides a project-scope setup wrote, but only when
468
+ // still empty — a real value re-added after setup is the user's, not ours.
469
+ for (const key of [
470
+ ...CONFLICTING_PROVIDER_VARS,
471
+ ...HEADER_INJECTION_VARS,
472
+ ]) {
473
+ if (settings.env[key] === "") delete settings.env[key];
474
+ }
475
+ if (Object.keys(settings.env).length === 0) delete settings.env;
476
+ }
477
+ writeSettings(filePath, settings);
478
+ console.error(
479
+ `Removed Sail routing keys from ${filePath} (no backup was present).`,
480
+ );
481
+ return 0;
482
+ }
package/src/toml.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Minimal TOML subset shared with the Sail CLI's `~/.sail` files: flat
3
+ * `key = "value"` tables with comments. This is not a general TOML parser —
4
+ * it reads exactly the shape `sail auth` / `sail config` write (plus bare
5
+ * unquoted values from hand edits) and fails loudly on anything else, the
6
+ * same posture as the Rust CLI.
7
+ */
8
+
9
+ /** Parse a flat TOML table into a plain object of string values. */
10
+ export function parseTomlTable(text, sourcePath) {
11
+ const values = {};
12
+ const lines = text.split(/\r?\n/);
13
+ for (let i = 0; i < lines.length; i++) {
14
+ const line = lines[i].trim();
15
+ if (line === "" || line.startsWith("#")) continue;
16
+ const eq = line.indexOf("=");
17
+ if (eq === -1) {
18
+ throw new Error(
19
+ `could not parse ${sourcePath} (line ${i + 1}: expected "key = value"). ` +
20
+ `Edit the file to fix it, or delete it and log in again.`,
21
+ );
22
+ }
23
+ const key = line.slice(0, eq).trim();
24
+ if (!/^[A-Za-z0-9_-]+$/.test(key)) {
25
+ throw new Error(
26
+ `could not parse ${sourcePath} (line ${i + 1}: invalid key "${key}"). ` +
27
+ `Edit the file to fix it, or delete it and log in again.`,
28
+ );
29
+ }
30
+ const rawValue = line.slice(eq + 1).trim();
31
+ values[key] = parseTomlValue(rawValue, sourcePath, i + 1);
32
+ }
33
+ return values;
34
+ }
35
+
36
+ function parseTomlValue(raw, sourcePath, lineNo) {
37
+ if (raw.startsWith('"')) {
38
+ return parseBasicString(raw, sourcePath, lineNo);
39
+ }
40
+ if (raw.startsWith("'")) {
41
+ // TOML literal string: verbatim content, no escapes.
42
+ const end = raw.indexOf("'", 1);
43
+ if (end === -1) {
44
+ throw new Error(
45
+ `could not parse ${sourcePath} (line ${lineNo}: unterminated string).`,
46
+ );
47
+ }
48
+ const rest = raw.slice(end + 1).trim();
49
+ if (rest !== "" && !rest.startsWith("#")) {
50
+ throw new Error(
51
+ `could not parse ${sourcePath} (line ${lineNo}: trailing content after string).`,
52
+ );
53
+ }
54
+ return raw.slice(1, end);
55
+ }
56
+ // Bare value (hand-edited file): take up to a trailing comment.
57
+ const hash = raw.indexOf("#");
58
+ const bare = (hash === -1 ? raw : raw.slice(0, hash)).trim();
59
+ if (bare === "") {
60
+ throw new Error(
61
+ `could not parse ${sourcePath} (line ${lineNo}: empty value). ` +
62
+ `Edit the file to fix it, or delete it and log in again.`,
63
+ );
64
+ }
65
+ return bare;
66
+ }
67
+
68
+ function parseBasicString(raw, sourcePath, lineNo) {
69
+ let out = "";
70
+ let i = 1;
71
+ while (i < raw.length) {
72
+ const ch = raw[i];
73
+ if (ch === '"') {
74
+ const rest = raw.slice(i + 1).trim();
75
+ if (rest !== "" && !rest.startsWith("#")) {
76
+ throw new Error(
77
+ `could not parse ${sourcePath} (line ${lineNo}: trailing content after string).`,
78
+ );
79
+ }
80
+ return out;
81
+ }
82
+ if (ch === "\\") {
83
+ const esc = raw[i + 1];
84
+ const simple = {
85
+ '"': '"',
86
+ "\\": "\\",
87
+ b: "\b",
88
+ t: "\t",
89
+ n: "\n",
90
+ f: "\f",
91
+ r: "\r",
92
+ };
93
+ if (esc in simple) {
94
+ out += simple[esc];
95
+ i += 2;
96
+ continue;
97
+ }
98
+ if (esc === "u" || esc === "U") {
99
+ const width = esc === "u" ? 4 : 8;
100
+ const hex = raw.slice(i + 2, i + 2 + width);
101
+ if (!new RegExp(`^[0-9A-Fa-f]{${width}}$`).test(hex)) {
102
+ throw new Error(
103
+ `could not parse ${sourcePath} (line ${lineNo}: bad \\${esc} escape).`,
104
+ );
105
+ }
106
+ out += String.fromCodePoint(parseInt(hex, 16));
107
+ i += 2 + width;
108
+ continue;
109
+ }
110
+ throw new Error(
111
+ `could not parse ${sourcePath} (line ${lineNo}: unsupported escape \\${esc ?? ""}).`,
112
+ );
113
+ }
114
+ out += ch;
115
+ i += 1;
116
+ }
117
+ throw new Error(
118
+ `could not parse ${sourcePath} (line ${lineNo}: unterminated string).`,
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Render a value as a TOML basic string with control characters escaped,
124
+ * byte-identical to the Rust CLI's `toml_basic_string`, so files written by
125
+ * either tool round-trip through the other.
126
+ */
127
+ export function tomlBasicString(value) {
128
+ let out = '"';
129
+ for (const ch of String(value)) {
130
+ const code = ch.codePointAt(0);
131
+ if (ch === '"') out += '\\"';
132
+ else if (ch === "\\") out += "\\\\";
133
+ else if (ch === "\b") out += "\\b";
134
+ else if (ch === "\t") out += "\\t";
135
+ else if (ch === "\n") out += "\\n";
136
+ else if (ch === "\f") out += "\\f";
137
+ else if (ch === "\r") out += "\\r";
138
+ else if (code < 0x20 || code === 0x7f) {
139
+ out += `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
140
+ } else out += ch;
141
+ }
142
+ return out + '"';
143
+ }