@wrangled/cli 0.3.44 → 0.3.45

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/dist/api.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { mkdir, readFile, rm } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { brand, envPrefix } from "./brand.js";
5
- import { writeFileAtomic } from "./files.js";
5
+ import { RUN_ROOT_ENV, writeFileAtomic } from "./files.js";
6
6
  export const exitCodes = { ok: 0, error: 1, usage: 2, auth: 3, interrupted: 130 };
7
7
  export class CliError extends Error {
8
8
  exitCode;
@@ -30,8 +30,17 @@ export const IMPORT_NAME_PATTERN = /^[a-z][a-z0-9_]{0,40}$/;
30
30
  export function credentialsPath(configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")) {
31
31
  return join(configHome, brand.slug, "credentials.json");
32
32
  }
33
- export async function readCredentials() {
34
- const path = credentialsPath();
33
+ const CLIENT_NAME = /^[a-z0-9][a-z0-9-]*$/;
34
+ /** The file that names a folder's client; it holds only the name, never a key. */
35
+ export const CLIENT_FILE = `.${brand.slug}-client`;
36
+ export function parseClientName(name) {
37
+ if (!CLIENT_NAME.test(name)) {
38
+ throw new CliError(`'${name}' is not a client name: use lowercase letters, digits and dashes, starting with a letter or digit.`, exitCodes.usage);
39
+ }
40
+ return name;
41
+ }
42
+ export async function readSavedLogins(configHome) {
43
+ const path = credentialsPath(configHome);
35
44
  let text;
36
45
  try {
37
46
  text = await readFile(path, "utf8");
@@ -41,38 +50,146 @@ export async function readCredentials() {
41
50
  return null;
42
51
  throw error;
43
52
  }
44
- const credentials = parseCredentials(text);
45
- if (!credentials)
53
+ const saved = parseSavedLogins(text);
54
+ if (!saved)
46
55
  throw new UnreadableCredentialsError(path);
47
- return credentials;
56
+ return saved;
48
57
  }
49
- function parseCredentials(text) {
58
+ function parseSavedLogins(text) {
59
+ let value;
50
60
  try {
51
- const value = JSON.parse(text);
52
- if (typeof value?.apiKey !== "string" || typeof value.apiUrl !== "string")
53
- return null;
54
- return { apiKey: value.apiKey, apiUrl: value.apiUrl };
61
+ value = JSON.parse(text);
55
62
  }
56
63
  catch {
57
64
  return null;
58
65
  }
66
+ if (typeof value !== "object" || value === null)
67
+ return null;
68
+ const { apiKey, apiUrl, clients } = value;
69
+ const top = parseLogin({ apiKey, apiUrl });
70
+ if (top === null && (apiKey !== undefined || apiUrl !== undefined))
71
+ return null;
72
+ // No prototype, so a client named like an Object method (`constructor`) finds nothing rather than a function.
73
+ const named = Object.create(null);
74
+ if (clients !== undefined) {
75
+ if (typeof clients !== "object" || clients === null || Array.isArray(clients))
76
+ return null;
77
+ for (const [name, entry] of Object.entries(clients)) {
78
+ const login = parseLogin(entry);
79
+ if (!CLIENT_NAME.test(name) || login === null)
80
+ return null;
81
+ named[name] = login;
82
+ }
83
+ }
84
+ if (top === null && Object.keys(named).length === 0)
85
+ return null;
86
+ return { default: top, clients: named };
59
87
  }
60
- export async function writeCredentials(credentials, configHome) {
61
- const path = credentialsPath(configHome);
88
+ function parseLogin(value) {
89
+ const { apiKey, apiUrl } = (value ?? {});
90
+ return typeof apiKey === "string" && typeof apiUrl === "string" ? { apiKey, apiUrl } : null;
91
+ }
92
+ /** Saves `credentials` as the default login, or as the named client's, keeping every other saved login. */
93
+ export async function writeCredentials(credentials, options = {}) {
94
+ const path = credentialsPath(options.configHome);
62
95
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
63
- await writeFileAtomic(path, `${JSON.stringify(credentials, null, 2)}\n`, 0o600);
96
+ const saved = (await readSavedLogins(options.configHome).catch(ignoreUnreadable)) ?? { default: null, clients: {} };
97
+ const next = options.client === undefined ? { ...saved, default: credentials } : { ...saved, clients: { ...saved.clients, [options.client]: credentials } };
98
+ await writeSavedLogins(path, next);
64
99
  return path;
65
100
  }
66
- export async function removeCredentials() {
67
- await rm(credentialsPath(), { force: true });
101
+ /** Removes the default login, or the named client's, keeping the rest; the file goes once nothing is left in it. */
102
+ export async function removeCredentials(client) {
103
+ const path = credentialsPath();
104
+ const saved = await readSavedLogins().catch(ignoreUnreadable);
105
+ const clients = { ...saved?.clients };
106
+ if (client !== undefined)
107
+ delete clients[client];
108
+ const next = { default: client === undefined ? null : (saved?.default ?? null), clients };
109
+ if (next.default === null && Object.keys(clients).length === 0)
110
+ return rm(path, { force: true });
111
+ await writeSavedLogins(path, next);
112
+ }
113
+ async function writeSavedLogins(path, saved) {
114
+ const file = { ...saved.default, ...(Object.keys(saved.clients).length > 0 && { clients: saved.clients }) };
115
+ await writeFileAtomic(path, `${JSON.stringify(file, null, 2)}\n`, 0o600);
116
+ }
117
+ /** An unreadable file is replaced by the next login, as nothing in it can be used. */
118
+ function ignoreUnreadable(error) {
119
+ if (error instanceof UnreadableCredentialsError)
120
+ return null;
121
+ throw error;
122
+ }
123
+ /** The API key env var beats `--client`, which beats the nearest client file above `cwd`, which beats the default login. */
124
+ export async function chooseClient(clientFlag, cwd = process.cwd()) {
125
+ const flagged = clientFlag === undefined ? undefined : parseClientName(clientFlag);
126
+ if (process.env[`${envPrefix}_API_KEY`])
127
+ return { source: "env" };
128
+ if (flagged !== undefined)
129
+ return { source: "flag", client: flagged };
130
+ // A scheduled hand's run holds only its own default key, and must not be steered by files outside its folder.
131
+ if (process.env[RUN_ROOT_ENV])
132
+ return { source: "default" };
133
+ const found = await findClientFile(cwd);
134
+ return found ? { source: "folder", ...found } : { source: "default" };
135
+ }
136
+ async function findClientFile(from) {
137
+ for (let dir = resolve(from);; dir = dirname(dir)) {
138
+ const path = join(dir, CLIENT_FILE);
139
+ const text = await readFile(path, "utf8").catch((error) => {
140
+ if (error.code === "ENOENT" || error.code === "ENOTDIR")
141
+ return null;
142
+ throw new CliError(`Could not read the client file at ${path} (${error.code ?? error.message}), so the client to use is unknown.`, exitCodes.usage);
143
+ });
144
+ if (text !== null)
145
+ return { client: parseClientFile(text, path), path };
146
+ if (dirname(dir) === dir)
147
+ return null;
148
+ }
68
149
  }
69
- export async function resolveConnection(apiUrlFlag) {
70
- const stored = await readCredentials();
71
- const apiKey = process.env[`${envPrefix}_API_KEY`] || stored?.apiKey;
72
- if (!apiKey) {
73
- throw new CliError(`Not logged in. Run \`${brand.slug} login\` or set ${envPrefix}_API_KEY.`, exitCodes.auth);
150
+ function parseClientFile(text, path) {
151
+ const malformed = new CliError(`The client file at ${path} is malformed. It should hold {"client": "<name>"}, a lowercase name with letters, digits and dashes.`, exitCodes.usage);
152
+ let value;
153
+ try {
154
+ value = JSON.parse(text);
74
155
  }
75
- return { apiUrl: resolveApiUrl(apiUrlFlag, stored), apiKey };
156
+ catch {
157
+ throw malformed;
158
+ }
159
+ const client = value?.client;
160
+ if (typeof client !== "string" || !CLIENT_NAME.test(client))
161
+ throw malformed;
162
+ return client;
163
+ }
164
+ export async function resolveLogin(options) {
165
+ const choice = await chooseClient(options.client);
166
+ const saved = await readSavedLogins();
167
+ if (choice.source === "env") {
168
+ return { choice, connection: { apiUrl: resolveApiUrl(options.apiUrl, saved?.default ?? null), apiKey: process.env[`${envPrefix}_API_KEY`] } };
169
+ }
170
+ if (choice.source === "default") {
171
+ if (!saved?.default)
172
+ throw new CliError(`Not logged in. Run \`${brand.slug} login\` or set ${envPrefix}_API_KEY.`, exitCodes.auth);
173
+ return { choice, connection: { apiUrl: resolveApiUrl(options.apiUrl, saved.default), apiKey: saved.default.apiKey } };
174
+ }
175
+ const login = saved?.clients[choice.client];
176
+ const relogin = `\`${brand.slug} login --client ${choice.client}\``;
177
+ if (!login) {
178
+ const why = choice.source === "folder" ? `, named by ${choice.path}` : "";
179
+ throw new CliError(`No saved key for client ${choice.client}${why}. Run ${relogin}.`, exitCodes.auth);
180
+ }
181
+ const apiUrl = resolveApiUrl(options.apiUrl, login);
182
+ return { choice, connection: { apiUrl, apiKey: login.apiKey, rejected: `The API key for client ${choice.client} was rejected by ${apiUrl}. Run ${relogin} with a valid key.` } };
183
+ }
184
+ export async function resolveConnection(options) {
185
+ return (await resolveLogin(options)).connection;
186
+ }
187
+ /** The API URL the chosen login points at, for connections that carry another key; a missing or unreadable login is no error. */
188
+ export async function chosenApiUrl(options) {
189
+ const choice = await chooseClient(options.client);
190
+ const saved = await readSavedLogins().catch(ignoreUnreadable);
191
+ const login = choice.source === "flag" || choice.source === "folder" ? saved?.clients[choice.client] : saved?.default;
192
+ return resolveApiUrl(options.apiUrl, login ?? null);
76
193
  }
77
194
  export function resolveApiUrl(apiUrlFlag, stored) {
78
195
  return (apiUrlFlag || process.env[`${envPrefix}_API_URL`] || stored?.apiUrl || brand.apiUrl).replace(/\/+$/, "");
package/dist/guide.js CHANGED
@@ -23,8 +23,8 @@ export const guide = `# ${brand.name} CLI
23
23
 
24
24
  ## Setup
25
25
 
26
- 1. Check the login: \`${cli} whoami\`. It prints the workspace name, slug, and id, and whether the key has library access.
27
- 2. If it exits with code 3, you are not logged in. Ask the user to run \`${cli} login\` in their own terminal. It prompts for the key without showing it. Never ask for the key in chat and never put it on a command line, where it lands in shell history and logs. In CI, the key comes from the \`${envPrefix}_API_KEY\` environment variable.
26
+ 1. Check the login: \`${cli} whoami\`. It prints the workspace name, slug, and id, whether the key has library access, and which client's key is in use and why.
27
+ 2. If it exits with code 3, you are not logged in. Ask the user to run the \`${cli} login\` command the error names (\`${cli} login --client <name>\` for a named client) in their own terminal. It prompts for the key without showing it. Never ask for the key in chat and never put it on a command line, where it lands in shell history and logs. In CI, the key comes from the \`${envPrefix}_API_KEY\` environment variable.
28
28
 
29
29
  ## Start with the data guide
30
30
 
@@ -291,9 +291,9 @@ A scheduled hand's run acts as its skill's agent, the way the Facebook ads agent
291
291
  ## Commands
292
292
 
293
293
  \`\`\`
294
- ${cli} login validate a key (prompted, piped, or ${envPrefix}_API_KEY) and save it
295
- ${cli} logout delete saved credentials
296
- ${cli} whoami show the current workspace and library access
294
+ ${cli} login [--client <name>] validate a key (prompted, piped, or ${envPrefix}_API_KEY) and save it, as the default or under a client's name
295
+ ${cli} logout [--client <name>] delete the default saved login, or one client's
296
+ ${cli} whoami show the current workspace, library access, and which client's key is in use and why
297
297
  ${cli} knowledge search "<question>" [--limit <n>] [--kind <kind>] [--hook-kind <v> ...] short, cited snippets from the know-how libraries (keys with library access)
298
298
  ${cli} knowledge tag <entry-id> --hook-kind <v> --confidence <c> tag a library entry with the marketing grammar (or --file tags.json)
299
299
  ${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first