@tailglow/cli 0.5.1 → 0.6.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.
@@ -3,9 +3,9 @@ import {
3
3
  CONFIG_KEYS,
4
4
  isSecretSetting,
5
5
  loadConfig,
6
- loadConfigForWrite,
6
+ readStoredLogin,
7
7
  redactSetting,
8
- saveConfig
8
+ updateConfig
9
9
  } from "./config.js";
10
10
  import { RESERVED_FLAGS } from "./flags.js";
11
11
  import { shouldEmitJson } from "./output.js";
@@ -107,7 +107,7 @@ export async function runConfigCommand(words, flags) {
107
107
  const stored = await loadConfig();
108
108
  const entries = Object.keys(CONFIG_KEYS)
109
109
  .filter((name) => typeof stored[name] === "string")
110
- .map((name) => [name, redactSetting(name, stored[name])]);
110
+ .map((name) => [name, redactSetting(name, String(stored[name]))]);
111
111
 
112
112
  if (json) return { output: JSON.stringify(Object.fromEntries(entries), null, 2), code: 0 };
113
113
  if (entries.length === 0) {
@@ -149,19 +149,23 @@ export async function runConfigCommand(words, flags) {
149
149
 
150
150
  if (action === "set") {
151
151
  if (!value) return fail(`Missing a value for ${key}.`, flags);
152
- const stored = await loadConfigForWrite();
153
- await saveConfig({ ...stored, [key]: value });
152
+ const next = await updateConfig((stored) => ({ ...stored, [key]: value }));
153
+
154
+ // A `tglow login` credential outranks a stored key, so setting one would otherwise look like it
155
+ // took effect when every command keeps using the login.
156
+ const outranked = key === "api_key" && readStoredLogin(next) !== null;
157
+ const note = outranked
158
+ ? "\nYou are signed in with `tglow login`, which is used first. Run `tglow logout` to use this key."
159
+ : "";
154
160
  return succeed(
155
- `Set ${key} to ${redactSetting(key, value)}.`,
156
- { ok: true, key, value: redactSetting(key, value) },
161
+ `Set ${key} to ${redactSetting(key, value)}.${note}`,
162
+ { ok: true, key, value: redactSetting(key, value), outranked_by_login: outranked },
157
163
  flags
158
164
  );
159
165
  }
160
166
 
161
167
  if (action === "unset") {
162
- const stored = await loadConfigForWrite();
163
- const { [key]: _removed, ...rest } = stored;
164
- await saveConfig(rest);
168
+ await updateConfig(({ [key]: _removed, ...rest }) => rest);
165
169
  return succeed(`Removed ${key}.`, { ok: true, key, removed: true }, flags);
166
170
  }
167
171
 
package/src/config.js CHANGED
@@ -1,9 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
 
5
7
  export const CLI_CONFIGS = {
6
8
  DEFAULT_API_URL: "https://api.tailglow.io",
9
+ DOCS_URL: "https://docs.tailglow.io",
7
10
  CONFIG_PATH: join(homedir(), ".tailglow", "config.json"),
8
11
  // The API caps a page at 200, so the default is 50 requests. Deliberately a guardrail rather than
9
12
  // a ceiling: `--max` moves it, and hitting it always prints the cursor to continue from.
@@ -11,7 +14,25 @@ export const CLI_CONFIGS = {
11
14
  PAGE_SIZE: 200,
12
15
  // Nobody else may read the file: it holds an API key. 0700 on the directory for the same reason.
13
16
  FILE_MODE: 0o600,
14
- DIRECTORY_MODE: 0o700
17
+ DIRECTORY_MODE: 0o700,
18
+ LOCK_RETRY_MS: 50,
19
+ // The lock covers one small read and write, so waiting this long means its holder stopped.
20
+ LOCK_TIMEOUT_MS: 10_000
21
+ };
22
+
23
+ export const LOGIN_CONFIGS = {
24
+ /** `tglow login --wait` stops starting polls after this long and exits with `EXIT_PENDING`. */
25
+ WAIT_BUDGET_MS: 60_000,
26
+ /** Fallback lifetime for pending sign-ins saved before the API expiry was stored. */
27
+ INTERACTIVE_BUDGET_MS: 10 * 60 * 1_000,
28
+ REQUEST_TIMEOUT_MS: 30_000,
29
+ DEFAULT_INTERVAL_MS: 5_000,
30
+ /** Added to the interval each time the server answers `slow_down`. */
31
+ SLOW_DOWN_STEP_MS: 5_000,
32
+ /** A waiting sign-in the server never answered for is forgotten after this long. */
33
+ PENDING_FORGET_MS: 60 * 60 * 1_000,
34
+ /** EX_TEMPFAIL: still waiting, so a script can tell "run it again" apart from a failure. */
35
+ EXIT_PENDING: 75
15
36
  };
16
37
 
17
38
  /**
@@ -35,6 +56,17 @@ export function isSecretSetting(key) {
35
56
  return SECRET_KEYS.has(key);
36
57
  }
37
58
 
59
+ /** @type {Promise<string> | null} */
60
+ let version_read = null;
61
+
62
+ /** The installed CLI's version, read once. */
63
+ export function readCliVersion() {
64
+ version_read ??= fs
65
+ .readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")
66
+ .then((manifest) => String(JSON.parse(manifest).version));
67
+ return version_read;
68
+ }
69
+
38
70
  /**
39
71
  * Reads the stored config, if there is one.
40
72
  *
@@ -43,7 +75,7 @@ export function isSecretSetting(key) {
43
75
  * another way. That includes a file holding a bare `null`, which parses fine and is not an object.
44
76
  *
45
77
  * @param {string} [path]
46
- * @returns {Promise<Record<string, string>>}
78
+ * @returns {Promise<import("./types.d.ts").CliConfig>}
47
79
  */
48
80
  export async function loadConfig(path = CLI_CONFIGS.CONFIG_PATH) {
49
81
  try {
@@ -63,7 +95,7 @@ export async function loadConfig(path = CLI_CONFIGS.CONFIG_PATH) {
63
95
  * whatever the file actually held, which for a file holding an API key is silent credential loss.
64
96
  *
65
97
  * @param {string} [path]
66
- * @returns {Promise<Record<string, string>>}
98
+ * @returns {Promise<import("./types.d.ts").CliConfig>}
67
99
  */
68
100
  export async function loadConfigForWrite(path = CLI_CONFIGS.CONFIG_PATH) {
69
101
  /** @type {string} */
@@ -93,7 +125,7 @@ export async function loadConfigForWrite(path = CLI_CONFIGS.CONFIG_PATH) {
93
125
  );
94
126
  }
95
127
 
96
- return /** @type {Record<string, string>} */ (parsed);
128
+ return /** @type {import("./types.d.ts").CliConfig} */ (parsed);
97
129
  }
98
130
 
99
131
  /**
@@ -104,7 +136,7 @@ export async function loadConfigForWrite(path = CLI_CONFIGS.CONFIG_PATH) {
104
136
  * and chmodding afterwards leaves a window where the key is on disk and readable; a temp file
105
137
  * created at 0600 and renamed into place never has one.
106
138
  *
107
- * @param {Record<string, string>} values
139
+ * @param {import("./types.d.ts").CliConfig} values
108
140
  * @param {string} [path]
109
141
  */
110
142
  export async function saveConfig(values, path = CLI_CONFIGS.CONFIG_PATH) {
@@ -112,7 +144,7 @@ export async function saveConfig(values, path = CLI_CONFIGS.CONFIG_PATH) {
112
144
  await fs.mkdir(directory, { recursive: true, mode: CLI_CONFIGS.DIRECTORY_MODE });
113
145
  await fs.chmod(directory, CLI_CONFIGS.DIRECTORY_MODE);
114
146
 
115
- const staged = `${path}.${process.pid}.tmp`;
147
+ const staged = `${path}.${process.pid}.${Date.now()}.tmp`;
116
148
  try {
117
149
  await fs.writeFile(staged, `${JSON.stringify(values, null, 2)}\n`, {
118
150
  mode: CLI_CONFIGS.FILE_MODE,
@@ -126,6 +158,79 @@ export async function saveConfig(values, path = CLI_CONFIGS.CONFIG_PATH) {
126
158
  }
127
159
  }
128
160
 
161
+ /**
162
+ * Removes the lock if it still holds `token`. Only its holder writes a token and nothing else
163
+ * removes a lock, so a matching token means the lock is still this process's.
164
+ *
165
+ * @param {string} lock_path
166
+ * @param {string} token
167
+ */
168
+ async function releaseLock(lock_path, token) {
169
+ if ((await fs.readFile(lock_path, "utf-8").catch(() => null)) === token) {
170
+ await fs.rm(lock_path, { force: true });
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Runs `callback` holding the config lock: a file created with O_EXCL next to the config, so a
176
+ * `tglow login --wait` finishing while `tglow config set` runs cannot lose either write. A lock is
177
+ * never taken over: one left by a process that stopped mid-write makes the next change wait, then
178
+ * name the file to delete.
179
+ *
180
+ * @template T
181
+ * @param {() => Promise<T>} callback
182
+ * @param {string} [path]
183
+ * @param {{ timeout_ms?: number }} [options]
184
+ * @returns {Promise<T>}
185
+ */
186
+ export async function withConfigLock(
187
+ callback,
188
+ path = CLI_CONFIGS.CONFIG_PATH,
189
+ { timeout_ms = CLI_CONFIGS.LOCK_TIMEOUT_MS } = {}
190
+ ) {
191
+ const lock_path = `${path}.lock`;
192
+ const token = `${process.pid}:${randomUUID()}`;
193
+ await fs.mkdir(dirname(path), { recursive: true, mode: CLI_CONFIGS.DIRECTORY_MODE });
194
+
195
+ const started_at = Date.now();
196
+ while (true) {
197
+ try {
198
+ await fs.writeFile(lock_path, token, { flag: "wx", mode: CLI_CONFIGS.FILE_MODE });
199
+ break;
200
+ } catch (err) {
201
+ if (/** @type {NodeJS.ErrnoException} */ (err).code !== "EEXIST") throw err;
202
+ if (Date.now() - started_at > timeout_ms) {
203
+ throw new Error(
204
+ `Another tglow process is changing ${path}, or one stopped while changing it. ` +
205
+ `If no tglow is running, delete ${lock_path} and try again.`
206
+ );
207
+ }
208
+ await new Promise((resolve) => setTimeout(resolve, CLI_CONFIGS.LOCK_RETRY_MS));
209
+ }
210
+ }
211
+
212
+ try {
213
+ return await callback();
214
+ } finally {
215
+ await releaseLock(lock_path, token);
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Read-modify-write of the config under the lock. `mutate` receives what the file holds NOW, not
221
+ * what the caller read earlier, and returns the complete next contents.
222
+ *
223
+ * @param {(current: import("./types.d.ts").CliConfig) => import("./types.d.ts").CliConfig} mutate
224
+ * @param {string} [path]
225
+ */
226
+ export async function updateConfig(mutate, path = CLI_CONFIGS.CONFIG_PATH) {
227
+ return withConfigLock(async () => {
228
+ const next = mutate(await loadConfigForWrite(path));
229
+ await saveConfig(next, path);
230
+ return next;
231
+ }, path);
232
+ }
233
+
129
234
  /**
130
235
  * @param {string} key
131
236
  * @param {string} value
@@ -135,6 +240,46 @@ export function redactSetting(key, value) {
135
240
  return value.length <= 8 ? "********" : `${value.slice(0, 4)}...${value.slice(-4)}`;
136
241
  }
137
242
 
243
+ /** @param {unknown} value */
244
+ function isNonEmptyString(value) {
245
+ return typeof value === "string" && value.length > 0;
246
+ }
247
+
248
+ /**
249
+ * The credential `tglow login` stored, when the file holds a complete one.
250
+ *
251
+ * @param {import("./types.d.ts").CliConfig} config
252
+ * @returns {import("./types.d.ts").StoredLogin | null}
253
+ */
254
+ export function readStoredLogin(config) {
255
+ const login = config.login;
256
+ if (!login || typeof login !== "object" || Array.isArray(login)) return null;
257
+ const fields = /** @type {Record<string, unknown>} */ (login);
258
+ if (fields.type !== "session" && fields.type !== "api_key") return null;
259
+ for (const name of ["secret", "id", "api_url", "team_id", "user_id", "email"]) {
260
+ if (!isNonEmptyString(fields[name])) return null;
261
+ }
262
+ return /** @type {import("./types.d.ts").StoredLogin} */ (login);
263
+ }
264
+
265
+ /**
266
+ * The sign-in `tglow login` started and is waiting to collect, when the file holds one.
267
+ *
268
+ * @param {import("./types.d.ts").CliConfig} config
269
+ * @returns {import("./types.d.ts").PendingLogin | null}
270
+ */
271
+ export function readPendingLogin(config) {
272
+ const pending = config.pending_login;
273
+ if (!pending || typeof pending !== "object" || Array.isArray(pending)) return null;
274
+ const fields = /** @type {Record<string, unknown>} */ (pending);
275
+ if (fields.method !== "account" && fields.method !== "api_key") return null;
276
+ for (const name of ["id", "poll_secret", "user_code", "verification_url", "api_url"]) {
277
+ if (!isNonEmptyString(fields[name])) return null;
278
+ }
279
+ if (typeof fields.started_at !== "number") return null;
280
+ return /** @type {import("./types.d.ts").PendingLogin} */ (pending);
281
+ }
282
+
138
283
  /**
139
284
  * Resolves one setting.
140
285
  *
@@ -146,7 +291,7 @@ export function redactSetting(key, value) {
146
291
  * @param {{
147
292
  * flags?: Record<string, unknown>;
148
293
  * env?: NodeJS.ProcessEnv;
149
- * config?: Record<string, string>;
294
+ * config?: import("./types.d.ts").CliConfig;
150
295
  * }} [sources]
151
296
  * @returns {string | null}
152
297
  */
@@ -161,22 +306,71 @@ export function resolveSetting(name, { flags = {}, env = process.env, config = {
161
306
  return typeof stored === "string" && stored.length > 0 ? stored : null;
162
307
  }
163
308
 
164
- /** @param {{ flags?: Record<string, unknown>; config?: Record<string, string> }} [options] */
309
+ /**
310
+ * @param {{
311
+ * flags?: Record<string, unknown>;
312
+ * env?: NodeJS.ProcessEnv;
313
+ * config?: import("./types.d.ts").CliConfig;
314
+ * }} [options]
315
+ */
165
316
  export function resolveApiUrl(options = {}) {
166
317
  return resolveSetting("api_url", options) ?? CLI_CONFIGS.DEFAULT_API_URL;
167
318
  }
168
319
 
169
320
  /**
170
- * @param {{ flags?: Record<string, unknown>; config?: Record<string, string> }} [options]
171
- * @returns {string}
321
+ * Whether two API URLs name the same API, compared as origins so a trailing slash or a path does
322
+ * not make them differ.
323
+ *
324
+ * @param {string} first
325
+ * @param {string} second
326
+ */
327
+ export function isSameApiUrl(first, second) {
328
+ try {
329
+ return new URL(first).origin === new URL(second).origin;
330
+ } catch {
331
+ return first === second;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * The credential a command authenticates with.
337
+ *
338
+ * `--api-key` beats `TAILGLOW_API_KEY`, which beats the `tglow login` credential, which beats a
339
+ * stored `api_key`. A login credential is only ever sent to the API that issued it: pointing
340
+ * `--api-url` elsewhere needs an explicit key rather than handing the login to another host.
341
+ *
342
+ * @param {{
343
+ * flags?: Record<string, unknown>;
344
+ * env?: NodeJS.ProcessEnv;
345
+ * config?: import("./types.d.ts").CliConfig;
346
+ * }} [options]
347
+ * @returns {import("./types.d.ts").ResolvedCredential}
172
348
  */
173
- export function resolveApiKey(options = {}) {
174
- const key = resolveSetting("api_key", options);
175
- if (!key) {
349
+ export function resolveCredential({ flags = {}, env = process.env, config = {} } = {}) {
350
+ const flag = flags["api-key"];
351
+ if (isNonEmptyString(flag)) return { secret: String(flag), source: "flag", login: null };
352
+
353
+ const from_env = env.TAILGLOW_API_KEY;
354
+ if (isNonEmptyString(from_env)) return { secret: String(from_env), source: "env", login: null };
355
+
356
+ const login = readStoredLogin(config);
357
+ const api_url = resolveApiUrl({ flags, env, config });
358
+ if (login && isSameApiUrl(login.api_url, api_url)) {
359
+ return { secret: login.secret, source: "login", login };
360
+ }
361
+
362
+ const stored = config.api_key;
363
+ if (isNonEmptyString(stored)) return { secret: String(stored), source: "config", login: null };
364
+
365
+ if (login) {
176
366
  throw new Error(
177
- "No API key. Set TAILGLOW_API_KEY, or run `tglow config set api_key <key>`.\n" +
178
- "Create a key in the dashboard under Team settings."
367
+ `You are signed in to ${login.api_url}, not ${api_url}, and a sign-in is never sent to ` +
368
+ "another API. Pass --api-key for this one, or run `tglow login --api-url " +
369
+ `${api_url}\`.`
179
370
  );
180
371
  }
181
- return key;
372
+ throw new Error(
373
+ "Not signed in. Run `tglow login`, or set TAILGLOW_API_KEY to an API key.\n" +
374
+ "Create a key in the dashboard under Team settings."
375
+ );
182
376
  }
@@ -0,0 +1,139 @@
1
+ import { CLI_CONFIGS } from "./config.js";
2
+ import { DOCS_INDEX, DOCS_PAGES } from "./docs.generated.js";
3
+ import { RESERVED_FLAGS } from "./flags.js";
4
+
5
+ /**
6
+ * @param {string} message
7
+ * @param {boolean} json
8
+ */
9
+ function fail(message, json) {
10
+ return { output: json ? JSON.stringify({ error: message }) : message, code: 1 };
11
+ }
12
+
13
+ /**
14
+ * Reduces whatever was copied out of the index or the docs site to a page key, so
15
+ * `/guides/sdk.md#installation`, `https://docs.tailglow.io/guides/sdk` and `guides/sdk` all read
16
+ * the same page.
17
+ *
18
+ * @param {string} reference
19
+ */
20
+ export function toDocsPath(reference) {
21
+ return reference
22
+ .replace(/^https?:\/\/[^/]+/, "")
23
+ .replace(/[#?].*$/, "")
24
+ .replace(/^\/+|\/+$/g, "")
25
+ .replace(/\.md$/, "");
26
+ }
27
+
28
+ /** @param {string} path */
29
+ function findSimilarPaths(path) {
30
+ const name = (path.split("/").pop() ?? path).toLowerCase();
31
+ if (!name) return [];
32
+
33
+ return Object.keys(DOCS_PAGES)
34
+ .filter((candidate) => {
35
+ const candidate_name = (candidate.split("/").pop() ?? candidate).toLowerCase();
36
+ return candidate_name.includes(name) || name.includes(candidate_name);
37
+ })
38
+ .slice(0, 5);
39
+ }
40
+
41
+ function renderDocsHelp() {
42
+ return [
43
+ "tglow docs: every guide and API reference page, as markdown",
44
+ "",
45
+ "Usage:",
46
+ " tglow docs The index: every page, with the link to read it by",
47
+ " tglow docs <path> One page: tglow docs guides/sdk, or any link from the index",
48
+ "",
49
+ "Pages print as markdown even when piped. --json returns { path, url, content } for a page,",
50
+ "or { index, pages, online } for the index.",
51
+ "The pages ship with this version of the CLI, so they describe the commands it has.",
52
+ `The latest copies are online at ${CLI_CONFIGS.DOCS_URL}/llms.txt.`
53
+ ].join("\n");
54
+ }
55
+
56
+ /**
57
+ * `tglow docs [path]` prints the documentation the CLI was built with, so an agent working in a
58
+ * terminal never has to leave it to learn the product.
59
+ *
60
+ * Markdown unless `--json` is passed, even into a pipe: every other command switches to JSON there,
61
+ * but the reader of a docs page is almost always an agent, and a page wrapped in a JSON string
62
+ * arrives with every newline escaped.
63
+ *
64
+ * @param {string[]} words
65
+ * @param {Record<string, unknown>} flags
66
+ * @returns {{ output: string; code: number }}
67
+ */
68
+ export function runDocsCommand(words, flags) {
69
+ const [reference, ...rest] = words.filter((word) => word !== undefined);
70
+ const json = Boolean(flags.json);
71
+
72
+ const unknown = Object.keys(flags).filter((flag) => !RESERVED_FLAGS.has(flag));
73
+ if (unknown.length > 0) {
74
+ return fail(
75
+ `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown.map((flag) => `--${flag}`).join(", ")}.`,
76
+ json
77
+ );
78
+ }
79
+ if (rest.length > 0) {
80
+ return fail(`Unexpected argument${rest.length === 1 ? "" : "s"}: ${rest.join(", ")}.`, json);
81
+ }
82
+
83
+ if (flags.help) {
84
+ return {
85
+ output: json
86
+ ? JSON.stringify(
87
+ {
88
+ usage: "tglow docs [path]",
89
+ description: "Print the documentation index, or one page of it, as markdown.",
90
+ pages: Object.keys(DOCS_PAGES).length,
91
+ online: `${CLI_CONFIGS.DOCS_URL}/llms.txt`
92
+ },
93
+ null,
94
+ 2
95
+ )
96
+ : renderDocsHelp(),
97
+ code: 0
98
+ };
99
+ }
100
+
101
+ if (!reference) {
102
+ if (json) {
103
+ return {
104
+ output: JSON.stringify(
105
+ {
106
+ index: DOCS_INDEX,
107
+ pages: Object.keys(DOCS_PAGES),
108
+ online: `${CLI_CONFIGS.DOCS_URL}/llms.txt`
109
+ },
110
+ null,
111
+ 2
112
+ ),
113
+ code: 0
114
+ };
115
+ }
116
+
117
+ return {
118
+ output: `${DOCS_INDEX.trimEnd()}\n\nRead a page with \`tglow docs <link>\`, for example \`tglow docs /guides/agents.md\`.`,
119
+ code: 0
120
+ };
121
+ }
122
+
123
+ const path = toDocsPath(reference);
124
+ const content = Object.hasOwn(DOCS_PAGES, path) ? DOCS_PAGES[path] : null;
125
+
126
+ if (content === null) {
127
+ const similar = findSimilarPaths(path);
128
+ const hint =
129
+ similar.length > 0
130
+ ? ` Did you mean ${similar.map((candidate) => `\`${candidate}\``).join(", ")}?`
131
+ : "";
132
+ return fail(`No docs page "${reference}".${hint} Run \`tglow docs\` for the index.`, json);
133
+ }
134
+
135
+ const url = `${CLI_CONFIGS.DOCS_URL}/${path}.md`;
136
+ if (json) return { output: JSON.stringify({ path, url, content }, null, 2), code: 0 };
137
+
138
+ return { output: `${content.trimEnd()}\n\nLatest version online: ${url}`, code: 0 };
139
+ }