@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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The `~/.sail` credential store, byte-compatible with the Sail CLI.
3
+ *
4
+ * The secret API key lives in `~/.sail/auth.toml` (0600); non-secret settings
5
+ * (mode, endpoint overrides, and the URL the key was validated against) live
6
+ * in `~/.sail/config.toml`. Environment variables always win over both files.
7
+ * The stored key is tagged with the URL it was validated against
8
+ * (`api_key_api_url`) and is applied only when the active target matches, so
9
+ * a key minted for one environment is never sent to another (fail closed).
10
+ */
11
+
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ import { MODE_API_URLS, MODE_VALUES } from "./constants.js";
17
+ import { parseTomlTable, tomlBasicString } from "./toml.js";
18
+
19
+ /** Settings keys recognized in config.toml, matching the Sail CLI. */
20
+ export const SETTINGS_KEYS = [
21
+ "mode",
22
+ "api_url",
23
+ "sailbox_api_url",
24
+ "imagebuilder_url",
25
+ ];
26
+ export const API_KEY_TARGET_KEY = "api_key_api_url";
27
+
28
+ const AUTH_HEADER =
29
+ "# Managed by `sail auth`. The SAIL_API_KEY env var overrides this.\n";
30
+ const CONFIG_HEADER =
31
+ "# Managed by `sail config` and `sail auth`. Env vars override these values.\n";
32
+
33
+ export function sailHome() {
34
+ const raw = process.env.SAIL_HOME?.trim();
35
+ if (raw) {
36
+ if (raw.startsWith("~/")) return path.join(os.homedir(), raw.slice(2));
37
+ return raw;
38
+ }
39
+ return path.join(os.homedir(), ".sail");
40
+ }
41
+
42
+ export function authPath() {
43
+ return path.join(sailHome(), "auth.toml");
44
+ }
45
+
46
+ export function configPath() {
47
+ return path.join(sailHome(), "config.toml");
48
+ }
49
+
50
+ /** The stored API key, or null when auth.toml is absent or has no key. */
51
+ export function loadAuthKey() {
52
+ const table = readTomlFile(authPath());
53
+ if (table === null) return null;
54
+ const key = typeof table.api_key === "string" ? table.api_key.trim() : "";
55
+ return key === "" ? null : key;
56
+ }
57
+
58
+ /** Recognized settings from config.toml; {} when the file is absent. */
59
+ export function loadSettings() {
60
+ const table = readTomlFile(configPath());
61
+ if (table === null) return {};
62
+ const values = {};
63
+ const unknown = [];
64
+ for (const [key, value] of Object.entries(table)) {
65
+ if (SETTINGS_KEYS.includes(key) || key === API_KEY_TARGET_KEY) {
66
+ values[key] = String(value);
67
+ } else {
68
+ unknown.push(key);
69
+ }
70
+ }
71
+ if (unknown.length > 0) {
72
+ throw new Error(
73
+ `${configPath()} contains unrecognized setting(s): ${unknown.sort().join(", ")}. ` +
74
+ `Allowed settings: ${SETTINGS_KEYS.join(", ")}.`,
75
+ );
76
+ }
77
+ return values;
78
+ }
79
+
80
+ /** Settings, tolerating a missing or malformed file (login write path). */
81
+ export function loadSettingsBestEffort() {
82
+ try {
83
+ return loadSettings();
84
+ } catch {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ function readTomlFile(filePath) {
90
+ let text;
91
+ try {
92
+ text = fs.readFileSync(filePath, "utf8");
93
+ } catch (err) {
94
+ if (err.code === "ENOENT") return null;
95
+ throw new Error(`could not read ${filePath}: ${err.message}`);
96
+ }
97
+ return parseTomlTable(text, filePath);
98
+ }
99
+
100
+ /**
101
+ * The active public API URL and where it came from, mirroring the Rust CLI's
102
+ * resolution: the explicit `--mode` flag wins outright; otherwise each
103
+ * setting resolves env-else-stored (a *present but empty* env var masks the
104
+ * stored value, matching Rust `pick_setting`), and then a non-empty api_url
105
+ * always beats a mode (matching `resolve_target_api_url`). Defaults to prod.
106
+ *
107
+ * A malformed config.toml fails loudly here — same posture as the Rust CLI —
108
+ * so a typo'd file can't silently retarget requests or force a re-login.
109
+ */
110
+ export function resolveTarget({ modeFlag } = {}) {
111
+ if (modeFlag) {
112
+ const url = MODE_API_URLS[modeFlag];
113
+ if (!url) {
114
+ throw new Error(
115
+ `unknown --mode "${modeFlag}" (expected one of: ${MODE_VALUES.join(", ")})`,
116
+ );
117
+ }
118
+ return { apiUrl: url, mode: modeFlag, source: "--mode" };
119
+ }
120
+ const settings = loadSettings();
121
+ const pick = (envName, storedKey) =>
122
+ process.env[envName] !== undefined
123
+ ? { value: process.env[envName].trim(), source: envName }
124
+ : { value: (settings[storedKey] ?? "").trim(), source: "config.toml" };
125
+
126
+ const apiUrl = pick("SAIL_API_URL", "api_url");
127
+ if (apiUrl.value) {
128
+ return {
129
+ apiUrl: apiUrl.value.replace(/\/+$/, ""),
130
+ mode: null,
131
+ source: apiUrl.source,
132
+ };
133
+ }
134
+ const mode = pick("SAIL_MODE", "mode");
135
+ const modeName = mode.value.toLowerCase();
136
+ if (modeName) {
137
+ if (!MODE_API_URLS[modeName]) {
138
+ // Fail closed, matching the Sail CLI: a typo like SAIL_MODE=production
139
+ // must never silently retarget to prod (and reuse a prod key).
140
+ throw new Error(
141
+ `unknown mode "${mode.value}" from ${mode.source} ` +
142
+ `(expected one of: ${MODE_VALUES.join(", ")})`,
143
+ );
144
+ }
145
+ return {
146
+ apiUrl: MODE_API_URLS[modeName],
147
+ mode: modeName,
148
+ source: mode.source,
149
+ };
150
+ }
151
+ return { apiUrl: MODE_API_URLS.prod, mode: "prod", source: "default" };
152
+ }
153
+
154
+ /**
155
+ * Whether the stored key's tagged target equals `effectiveTarget`, so the key
156
+ * is safe to apply. The target is the explicit `api_key_api_url` tag, or, if
157
+ * untagged, the one the stored `api_url`/`mode` imply. A key with no tag and
158
+ * no stored target hint never matches (fail closed).
159
+ */
160
+ export function storedKeyMatchesTarget(settings, effectiveTarget) {
161
+ const stored = storedKeyTarget(settings);
162
+ if (stored === null) return false;
163
+ const effective = String(effectiveTarget ?? "")
164
+ .trim()
165
+ .replace(/\/+$/, "");
166
+ return effective !== "" && stored === effective;
167
+ }
168
+
169
+ function storedKeyTarget(settings) {
170
+ const nonempty = (key) => {
171
+ const v = settings[key]?.trim();
172
+ return v ? v : null;
173
+ };
174
+ const tag = nonempty(API_KEY_TARGET_KEY);
175
+ if (tag) return tag.replace(/\/+$/, "");
176
+ const apiUrl = nonempty("api_url");
177
+ const mode = nonempty("mode");
178
+ if (apiUrl === null && mode === null) return null;
179
+ if (apiUrl) return apiUrl.replace(/\/+$/, "");
180
+ const modeUrl = MODE_API_URLS[mode.toLowerCase()];
181
+ return modeUrl ? modeUrl.replace(/\/+$/, "") : null;
182
+ }
183
+
184
+ /** Write the API key to auth.toml atomically with 0600 permissions. */
185
+ export function saveAuthKey(apiKey) {
186
+ ensurePrivateDir(sailHome());
187
+ const body = `${AUTH_HEADER}api_key = ${tomlBasicString(apiKey)}\n`;
188
+ atomicWrite(authPath(), body);
189
+ return authPath();
190
+ }
191
+
192
+ /**
193
+ * Persist the login target: tag the key with the URL it was validated
194
+ * against, and pin either the named mode or the explicit api_url (never
195
+ * both, so they cannot split the target). Other settings are preserved.
196
+ */
197
+ export function saveLoginSettings({ target, mode }) {
198
+ const settings = loadSettingsBestEffort();
199
+ const cleanTarget = String(target).trim().replace(/\/+$/, "");
200
+ settings[API_KEY_TARGET_KEY] = cleanTarget;
201
+ if (mode && MODE_VALUES.includes(mode)) {
202
+ settings.mode = mode;
203
+ delete settings.api_url;
204
+ } else {
205
+ settings.api_url = cleanTarget;
206
+ delete settings.mode;
207
+ }
208
+ // Clear sibling endpoint overrides left from a previous target so a stale
209
+ // sailbox/imagebuilder host can't combine with the new central target
210
+ // (mirrors sail-cli authcmd.rs validate_and_store).
211
+ delete settings.sailbox_api_url;
212
+ delete settings.imagebuilder_url;
213
+ ensurePrivateDir(sailHome());
214
+ let body = CONFIG_HEADER;
215
+ for (const key of Object.keys(settings).sort()) {
216
+ body += `${key} = ${tomlBasicString(settings[key])}\n`;
217
+ }
218
+ atomicWrite(configPath(), body);
219
+ return configPath();
220
+ }
221
+
222
+ /** Mask a secret for display: `abcd…wxyz`, or `***` when too short. */
223
+ export function maskSecret(secret) {
224
+ const s = String(secret ?? "");
225
+ if (s === "") return "";
226
+ const chars = [...s];
227
+ if (chars.length <= 8) return "***";
228
+ return `${chars.slice(0, 4).join("")}…${chars.slice(-4).join("")}`;
229
+ }
230
+
231
+ function ensurePrivateDir(dir) {
232
+ if (fs.existsSync(dir)) return;
233
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
234
+ }
235
+
236
+ /**
237
+ * Atomic write (temp file + rename). New files get 0600 (everything written
238
+ * here may hold a secret); pass `preserveMode: true` to keep an existing
239
+ * destination's permissions instead of silently tightening them, and add
240
+ * `tighten: true` when the CONTENT holds a secret — owner bits are preserved
241
+ * but group/other access is masked off (0644 → 0600).
242
+ */
243
+ export function atomicWrite(
244
+ filePath,
245
+ contents,
246
+ { preserveMode = false, tighten = false } = {},
247
+ ) {
248
+ let mode = 0o600;
249
+ if (preserveMode) {
250
+ try {
251
+ mode = fs.statSync(filePath).mode & 0o777;
252
+ } catch {
253
+ // Destination doesn't exist yet: keep the private default.
254
+ }
255
+ }
256
+ if (tighten) mode &= 0o700;
257
+ const dir = path.dirname(filePath);
258
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
259
+ try {
260
+ fs.writeFileSync(tmp, contents, { mode });
261
+ } catch (err) {
262
+ // writeFileSync opens (creating tmp) then writes, so a failure mid-write
263
+ // (ENOSPC/EDQUOT) leaves a partial temp file — which can hold the secret
264
+ // `contents` and, for project setup, isn't covered by `.claude/.gitignore`
265
+ // (only the final filename is). Remove it before propagating.
266
+ try {
267
+ fs.unlinkSync(tmp);
268
+ } catch {
269
+ // Never created, or already gone — nothing to clean up.
270
+ }
271
+ throw new Error(`could not write ${filePath}: ${err.message}`);
272
+ }
273
+ try {
274
+ fs.renameSync(tmp, filePath);
275
+ } catch (err) {
276
+ try {
277
+ fs.unlinkSync(tmp);
278
+ } catch {
279
+ // Best effort: the rename error is the one worth surfacing.
280
+ }
281
+ throw new Error(`could not replace ${filePath}: ${err.message}`);
282
+ }
283
+ }