@tailglow/cli 0.5.1 → 0.6.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/README.md +46 -8
- package/package.json +1 -1
- package/src/commands.generated.js +531 -46
- package/src/config-command.js +14 -10
- package/src/config.js +210 -16
- package/src/docs-command.js +139 -0
- package/src/docs.generated.js +73 -0
- package/src/help.js +59 -14
- package/src/index.js +96 -28
- package/src/login-command.js +957 -0
- package/src/output.js +28 -0
- package/src/request.js +125 -12
- package/src/types.d.ts +41 -1
package/src/config-command.js
CHANGED
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
CONFIG_KEYS,
|
|
4
4
|
isSecretSetting,
|
|
5
5
|
loadConfig,
|
|
6
|
-
|
|
6
|
+
readStoredLogin,
|
|
7
7
|
redactSetting,
|
|
8
|
-
|
|
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
|
|
153
|
-
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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 {
|
|
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 {
|
|
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?:
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
171
|
-
*
|
|
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
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
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
|
-
|
|
178
|
-
"
|
|
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
|
-
|
|
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
|
+
}
|