@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 +140 -23
- package/dist/guide.js +5 -5
- package/dist/main.js +147 -125
- package/dist/playbooks.js +11 -11
- package/dist/runner/run.js +1 -1
- package/dist/skills.js +3 -8
- package/package.json +1 -1
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
|
-
|
|
34
|
-
|
|
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
|
|
45
|
-
if (!
|
|
53
|
+
const saved = parseSavedLogins(text);
|
|
54
|
+
if (!saved)
|
|
46
55
|
throw new UnreadableCredentialsError(path);
|
|
47
|
-
return
|
|
56
|
+
return saved;
|
|
48
57
|
}
|
|
49
|
-
function
|
|
58
|
+
function parseSavedLogins(text) {
|
|
59
|
+
let value;
|
|
50
60
|
try {
|
|
51
|
-
|
|
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
|
-
|
|
61
|
-
const
|
|
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
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
295
|
-
${cli} logout
|
|
296
|
-
${cli} whoami show the current workspace and
|
|
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
|