@commercelayer/agent-cli 0.8.1 → 1.0.0-dev.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 +98 -215
- package/dist/argv.js +227 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +107 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +238 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +205 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +81 -0
- package/dist/http.js +117 -0
- package/dist/inputs.js +112 -0
- package/dist/login.js +136 -0
- package/dist/resources.js +42 -0
- package/dist/schema.js +182 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +204 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -372
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -237
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -362
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
package/dist/config.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// What the CLI keeps on the machine, and which organization a command acts on.
|
|
2
|
+
//
|
|
3
|
+
// Tokens live only in ~/.config/c11r/credentials.json (XDG_CONFIG_HOME respected), mode 600,
|
|
4
|
+
// keyed by organization slug. A token is printed never: only its hint.
|
|
5
|
+
//
|
|
6
|
+
// A project folder links to its organization with .c11r.json, { "org": "<slug>" }: no secret,
|
|
7
|
+
// safe to commit, found by walking up from the current folder like .git. The project folder is
|
|
8
|
+
// the one with .c11r.json, else the nearest with store.yml or loops.yml, else the current one.
|
|
9
|
+
//
|
|
10
|
+
// The organization: --org, then C11R_ORG, then .c11r.json, then the stored one when exactly one
|
|
11
|
+
// is stored. With several stored and nothing naming one, the command stops and names them.
|
|
12
|
+
// C11R_TOKEN, for CI, is used as is, whatever is stored.
|
|
13
|
+
//
|
|
14
|
+
// Every stored token records the host that issued it: an organization token and a cancel token
|
|
15
|
+
// the API base (api_url), the login the auth endpoint (auth_url). An entry is used, listed and
|
|
16
|
+
// forgotten only while that base is the one in effect, so a token never goes to a host it was not
|
|
17
|
+
// issued by. An entry that records no host is not used, and goes at the next write.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fail, fixWith } from './errors.js';
|
|
22
|
+
import { apiUrl } from './http.js';
|
|
23
|
+
export const LINK_FILE = '.c11r.json';
|
|
24
|
+
export function credentialsFile() {
|
|
25
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
26
|
+
return path.join(base, 'c11r', 'credentials.json');
|
|
27
|
+
}
|
|
28
|
+
function readCredentials() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(fs.readFileSync(credentialsFile(), 'utf8'));
|
|
31
|
+
return { ...raw, orgs: raw.orgs && typeof raw.orgs === 'object' ? raw.orgs : {} };
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
if (e.code === 'ENOENT')
|
|
35
|
+
return { orgs: {} };
|
|
36
|
+
fail('invalid_config', `${credentialsFile()} cannot be read: ${e.message}.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function writeCredentials(c) {
|
|
40
|
+
const file = credentialsFile();
|
|
41
|
+
// An entry that records no issuing host is never used: it is not kept either.
|
|
42
|
+
c.orgs = Object.fromEntries(Object.entries(c.orgs).filter(([, o]) => o?.api_url));
|
|
43
|
+
if (c.login && !c.login.auth_url)
|
|
44
|
+
delete c.login;
|
|
45
|
+
if (c.bookings)
|
|
46
|
+
c.bookings = Object.fromEntries(Object.entries(c.bookings).filter(([, b]) => b?.api_url));
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
+
// Written aside then renamed, so a crash never leaves half a file; 600 from the start.
|
|
49
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
50
|
+
fs.writeFileSync(tmp, JSON.stringify(c, null, 2) + '\n', { mode: 0o600 });
|
|
51
|
+
fs.chmodSync(tmp, 0o600);
|
|
52
|
+
fs.renameSync(tmp, file);
|
|
53
|
+
}
|
|
54
|
+
export const DEFAULT_AUTH_URL = 'https://auth.commercelayer.io/oauth/token';
|
|
55
|
+
export const authUrl = () => process.env.C11R_AUTH_URL || DEFAULT_AUTH_URL;
|
|
56
|
+
const norm = (u) => u.replace(/\/+$/, '');
|
|
57
|
+
/** True when an entry was issued by the API base in effect; one that records none never is. */
|
|
58
|
+
const fromApi = (e) => !!e?.api_url && norm(e.api_url) === norm(apiUrl());
|
|
59
|
+
const fromAuth = (e) => !!e?.auth_url && norm(e.auth_url) === norm(authUrl());
|
|
60
|
+
/** The organization tokens issued by the API in effect. */
|
|
61
|
+
export function storedOrgs() {
|
|
62
|
+
return Object.fromEntries(Object.entries(readCredentials().orgs).filter(([, o]) => fromApi(o)));
|
|
63
|
+
}
|
|
64
|
+
export function storeOrg(slug, org) {
|
|
65
|
+
const c = readCredentials();
|
|
66
|
+
c.orgs[slug] = { ...org, api_url: apiUrl() };
|
|
67
|
+
writeCredentials(c);
|
|
68
|
+
}
|
|
69
|
+
/** After a reset: the same token, a new organization. */
|
|
70
|
+
export function renameOrg(from, to, patch = {}) {
|
|
71
|
+
const c = readCredentials();
|
|
72
|
+
const entry = c.orgs[from];
|
|
73
|
+
if (!fromApi(entry))
|
|
74
|
+
return;
|
|
75
|
+
delete c.orgs[from];
|
|
76
|
+
c.orgs[to] = { ...entry, ...patch };
|
|
77
|
+
writeCredentials(c);
|
|
78
|
+
}
|
|
79
|
+
export function forgetOrg(slug) {
|
|
80
|
+
const c = readCredentials();
|
|
81
|
+
if (!fromApi(c.orgs[slug]))
|
|
82
|
+
return;
|
|
83
|
+
delete c.orgs[slug];
|
|
84
|
+
writeCredentials(c);
|
|
85
|
+
}
|
|
86
|
+
/** The login minted by the auth endpoint in effect. */
|
|
87
|
+
export function storedLogin() {
|
|
88
|
+
const l = readCredentials().login;
|
|
89
|
+
return l && fromAuth(l) ? l : null;
|
|
90
|
+
}
|
|
91
|
+
/** Stores the login for the auth endpoint in effect; null forgets it, only if it is that one's. */
|
|
92
|
+
export function storeLogin(login) {
|
|
93
|
+
const c = readCredentials();
|
|
94
|
+
if (login)
|
|
95
|
+
c.login = { ...login, auth_url: authUrl() };
|
|
96
|
+
else if (fromAuth(c.login))
|
|
97
|
+
delete c.login;
|
|
98
|
+
else
|
|
99
|
+
return;
|
|
100
|
+
writeCredentials(c);
|
|
101
|
+
}
|
|
102
|
+
export function defaultOrg() {
|
|
103
|
+
return readCredentials().default_org ?? null;
|
|
104
|
+
}
|
|
105
|
+
export function setDefaultOrg(org) {
|
|
106
|
+
const c = readCredentials();
|
|
107
|
+
if (org)
|
|
108
|
+
c.default_org = org;
|
|
109
|
+
else
|
|
110
|
+
delete c.default_org;
|
|
111
|
+
writeCredentials(c);
|
|
112
|
+
}
|
|
113
|
+
export function outWritten(org) {
|
|
114
|
+
return Boolean(readCredentials().out_written?.[org]);
|
|
115
|
+
}
|
|
116
|
+
export function markOutWritten(org, written) {
|
|
117
|
+
const c = readCredentials();
|
|
118
|
+
const all = { ...(c.out_written ?? {}) };
|
|
119
|
+
if (written === Boolean(all[org]))
|
|
120
|
+
return;
|
|
121
|
+
if (written)
|
|
122
|
+
all[org] = true;
|
|
123
|
+
else
|
|
124
|
+
delete all[org];
|
|
125
|
+
c.out_written = all;
|
|
126
|
+
writeCredentials(c);
|
|
127
|
+
}
|
|
128
|
+
export function storedBooking(id) {
|
|
129
|
+
const b = readCredentials().bookings?.[id];
|
|
130
|
+
return b && fromApi(b) ? b : null;
|
|
131
|
+
}
|
|
132
|
+
export function storeBooking(id, booking) {
|
|
133
|
+
const c = readCredentials();
|
|
134
|
+
const all = { ...(c.bookings ?? {}) };
|
|
135
|
+
if (booking)
|
|
136
|
+
all[id] = { ...booking, api_url: apiUrl() };
|
|
137
|
+
else if (fromApi(all[id]))
|
|
138
|
+
delete all[id];
|
|
139
|
+
else
|
|
140
|
+
return;
|
|
141
|
+
c.bookings = all;
|
|
142
|
+
writeCredentials(c);
|
|
143
|
+
}
|
|
144
|
+
/** The last four characters, as the API shows them. */
|
|
145
|
+
export const hintOf = (token) => `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
146
|
+
/** The nearest folder, from the current one up, that holds one of the files. */
|
|
147
|
+
function findUp(names, from = process.cwd()) {
|
|
148
|
+
let dir = path.resolve(from);
|
|
149
|
+
for (;;) {
|
|
150
|
+
if (names.some((n) => fs.existsSync(path.join(dir, n))))
|
|
151
|
+
return dir;
|
|
152
|
+
const up = path.dirname(dir);
|
|
153
|
+
if (up === dir)
|
|
154
|
+
return null;
|
|
155
|
+
dir = up;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** The project folder: where .c11r.json, store.yml and loops.yml go. */
|
|
159
|
+
export function projectDir() {
|
|
160
|
+
return findUp([LINK_FILE]) ?? findUp(['store.yml', 'loops.yml']) ?? process.cwd();
|
|
161
|
+
}
|
|
162
|
+
export function readLink() {
|
|
163
|
+
const dir = findUp([LINK_FILE]);
|
|
164
|
+
if (!dir)
|
|
165
|
+
return null;
|
|
166
|
+
const file = path.join(dir, LINK_FILE);
|
|
167
|
+
let raw;
|
|
168
|
+
try {
|
|
169
|
+
raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
fail('invalid_config', `${file} is not valid JSON: ${e.message}.`, { path: LINK_FILE });
|
|
173
|
+
}
|
|
174
|
+
const org = raw?.org;
|
|
175
|
+
if (typeof org !== 'string' || !org)
|
|
176
|
+
fail('invalid_config', `${file} has no org.`, { path: `${LINK_FILE}.org` });
|
|
177
|
+
return { file, org };
|
|
178
|
+
}
|
|
179
|
+
/** Links the project folder to the organization; returns the file and the org it linked before. */
|
|
180
|
+
export function writeLink(org) {
|
|
181
|
+
const existing = readLink();
|
|
182
|
+
const file = existing?.file ?? path.join(process.cwd(), LINK_FILE);
|
|
183
|
+
fs.writeFileSync(file, JSON.stringify({ org }, null, 2) + '\n');
|
|
184
|
+
return { file, previous: existing && existing.org !== org ? existing.org : null };
|
|
185
|
+
}
|
|
186
|
+
/** Removes the link, only when it names this organization. */
|
|
187
|
+
export function removeLink(org) {
|
|
188
|
+
const link = readLink();
|
|
189
|
+
if (!link || link.org !== org)
|
|
190
|
+
return null;
|
|
191
|
+
fs.rmSync(link.file);
|
|
192
|
+
return link.file;
|
|
193
|
+
}
|
|
194
|
+
/** Which organization, and which token. Never throws: callers decide what a problem means. */
|
|
195
|
+
export function resolveOrg(flag) {
|
|
196
|
+
if (process.env.C11R_TOKEN)
|
|
197
|
+
return { org: flag ?? (process.env.C11R_ORG || null), source: 'token_env', token: process.env.C11R_TOKEN, problem: null };
|
|
198
|
+
const orgs = storedOrgs();
|
|
199
|
+
const named = [
|
|
200
|
+
[flag, 'flag'],
|
|
201
|
+
[process.env.C11R_ORG || undefined, 'env'],
|
|
202
|
+
];
|
|
203
|
+
let link = null;
|
|
204
|
+
try {
|
|
205
|
+
link = readLink();
|
|
206
|
+
}
|
|
207
|
+
catch (e) {
|
|
208
|
+
return { org: null, source: null, token: null, problem: { code: 'invalid_config', message: e.message } };
|
|
209
|
+
}
|
|
210
|
+
named.push([link?.org, 'link']);
|
|
211
|
+
named.push([defaultOrg() ?? undefined, 'default']);
|
|
212
|
+
for (const [org, source] of named) {
|
|
213
|
+
if (!org)
|
|
214
|
+
continue;
|
|
215
|
+
const stored = orgs[org];
|
|
216
|
+
if (stored)
|
|
217
|
+
return { org, source, token: stored.token, problem: null };
|
|
218
|
+
const where = source === 'flag' ? '--org' : source === 'env' ? 'C11R_ORG' : source === 'default' ? 'The default organization (c11r auth switch --global)' : link.file;
|
|
219
|
+
return { org, source, token: null, problem: { code: 'no_credentials', message: `${where} names ${org}, and this machine has no token for it.`, orgs: Object.keys(orgs) } };
|
|
220
|
+
}
|
|
221
|
+
const slugs = Object.keys(orgs);
|
|
222
|
+
if (slugs.length === 1)
|
|
223
|
+
return { org: slugs[0], source: 'only_stored', token: orgs[slugs[0]].token, problem: null };
|
|
224
|
+
if (slugs.length > 1)
|
|
225
|
+
return { org: null, source: null, token: null, problem: { code: 'org_required', message: `This machine has tokens for ${slugs.length} organizations (${slugs.join(', ')}), and nothing names one: no --org, no C11R_ORG, no ${LINK_FILE}.`, orgs: slugs } };
|
|
226
|
+
return { org: null, source: null, token: null, problem: null };
|
|
227
|
+
}
|
|
228
|
+
/** The token for a command that needs an organization; a problem becomes its error. */
|
|
229
|
+
export function raiseProblem(r, line) {
|
|
230
|
+
if (!r.problem)
|
|
231
|
+
return;
|
|
232
|
+
const { code, message, orgs } = r.problem;
|
|
233
|
+
if (code === 'org_required')
|
|
234
|
+
fail('org_required', message, { path: 'org', next_step: fixWith(`${line} --org ${orgs[0]}`), details: { orgs } }, 'usage');
|
|
235
|
+
if (code === 'no_credentials')
|
|
236
|
+
fail('no_credentials', message, { path: 'org', next_step: { action: 'none' }, details: { stored: orgs } }, 'auth');
|
|
237
|
+
fail(code, message);
|
|
238
|
+
}
|
package/dist/confirm.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Confirmation for destructive verbs. --yes skips it. With a terminal, it asks on stderr and
|
|
2
|
+
// reads one line. With no terminal (an agent, a pipe, a schedule) it never waits: it fails with
|
|
3
|
+
// confirmation_required, and the agent asks the human, then repeats the command with --yes.
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import { fail } from './errors.js';
|
|
6
|
+
export const terminal = {
|
|
7
|
+
get interactive() {
|
|
8
|
+
return Boolean(process.stdin.isTTY && process.stderr.isTTY) && process.env.C11R_NO_PROMPT !== '1';
|
|
9
|
+
},
|
|
10
|
+
ask(question) {
|
|
11
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
// Resolve before close(): close emits at once, and its handler would answer '' first.
|
|
14
|
+
rl.question(question, (answer) => {
|
|
15
|
+
resolve(answer);
|
|
16
|
+
rl.close();
|
|
17
|
+
});
|
|
18
|
+
// Ctrl-D or a closed stdin is a no.
|
|
19
|
+
rl.on('close', () => resolve(''));
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
askSecret(question) {
|
|
23
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
24
|
+
// Nothing typed is echoed: only the question is written.
|
|
25
|
+
const out = rl;
|
|
26
|
+
let asked = false;
|
|
27
|
+
out._writeToOutput = (s) => {
|
|
28
|
+
if (!asked) {
|
|
29
|
+
process.stderr.write(s);
|
|
30
|
+
asked = true;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
rl.question(question, (answer) => {
|
|
35
|
+
process.stderr.write('\n');
|
|
36
|
+
resolve(answer);
|
|
37
|
+
rl.close();
|
|
38
|
+
});
|
|
39
|
+
rl.on('close', () => resolve(''));
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
export async function confirm(action, command, opts) {
|
|
44
|
+
if (opts.yes)
|
|
45
|
+
return;
|
|
46
|
+
const p = opts.prompter ?? terminal;
|
|
47
|
+
if (!p.interactive) {
|
|
48
|
+
// The agent asks the human; on a yes it repeats the command with --yes.
|
|
49
|
+
fail('confirmation_required', `${action} needs confirmation, and there is no terminal to ask in. Ask the human whether to go ahead.`, {
|
|
50
|
+
next_step: { action: 'fix_request', command: `${command} --yes` },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const answer = (await p.ask(`${action}. Go ahead? [y/N] `)).trim().toLowerCase();
|
|
54
|
+
if (answer !== 'y' && answer !== 'yes')
|
|
55
|
+
fail('confirmation_declined', `${action}: not confirmed, nothing was changed.`);
|
|
56
|
+
}
|
package/dist/engine.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The grammar engine. c11r <noun> <verb> [id] [--field value ...] becomes, in order: the noun's
|
|
2
|
+
// schema (built in, or from the API), the verb, the id and the typed values, the checks the CLI
|
|
3
|
+
// can make before sending (required flags, an organization to act on), confirmation for a
|
|
4
|
+
// destructive verb, then the request. A plain noun needs no code here: its schema says
|
|
5
|
+
// everything. Every answer is the API's envelope, { data, state, warnings, next }.
|
|
6
|
+
import { parseCommand, parseFields, toFlag } from './argv.js';
|
|
7
|
+
import { outWritten, raiseProblem, resolveOrg } from './config.js';
|
|
8
|
+
import { confirm, terminal } from './confirm.js';
|
|
9
|
+
import { CliError, closest, fail, fixWith } from './errors.js';
|
|
10
|
+
import { indexHelp, nounHelp, verbHelp } from './help.js';
|
|
11
|
+
import { envelope, request } from './http.js';
|
|
12
|
+
import { readInputs } from './inputs.js';
|
|
13
|
+
import { hasLogin, loginToken } from './login.js';
|
|
14
|
+
import { resourceRequest } from './resources.js';
|
|
15
|
+
import { describePlan, OUT_NOT_TRACKED, planSecret, removeRecordFiles, writeSecret } from './secrets.js';
|
|
16
|
+
import { verbOf } from './schema.js';
|
|
17
|
+
import { apiNouns, apiSchema } from './schemas.js';
|
|
18
|
+
import { VERSION } from './version.js';
|
|
19
|
+
export async function execute(argv, opts = {}) {
|
|
20
|
+
const cmd = parseCommand(argv);
|
|
21
|
+
const builtins = opts.builtins ?? {};
|
|
22
|
+
if (cmd.globals.version && !cmd.noun)
|
|
23
|
+
return envelope({ version: VERSION });
|
|
24
|
+
if (!cmd.noun || cmd.noun === 'help')
|
|
25
|
+
return help(cmd, opts);
|
|
26
|
+
if (cmd.noun === 'schema')
|
|
27
|
+
return schemaOf(cmd, builtins);
|
|
28
|
+
const schema = builtins[cmd.noun]?.schema ?? (await apiSchema(cmd.noun, Object.keys(builtins)));
|
|
29
|
+
if (cmd.globals.help && !cmd.verb && !builtins[cmd.noun]?.defaultVerb)
|
|
30
|
+
return envelope(nounHelp(schema));
|
|
31
|
+
const verb = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
|
|
32
|
+
if (cmd.globals.help)
|
|
33
|
+
return envelope(verbHelp(schema, verb));
|
|
34
|
+
// A verb the schema lists as refused: no request, no confirmation for something that cannot happen.
|
|
35
|
+
if (verb.refused) {
|
|
36
|
+
const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
|
|
37
|
+
fail('not_supported', `c11r ${schema.noun} ${verb.name} is refused by ${by}: ${verb.description || 'it cannot be done through the API.'}`, { details: { refused_by: verb.refused.by }, next_step: { action: 'none' } });
|
|
38
|
+
}
|
|
39
|
+
const ext = opts.extensions?.[schema.noun];
|
|
40
|
+
const positional = ext?.positional?.[verb.name];
|
|
41
|
+
const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
|
|
42
|
+
const parsed = parseFields(schema.noun, verb, rest);
|
|
43
|
+
ext?.prepare?.(verb, parsed);
|
|
44
|
+
// Files first, so a required document given with --file counts; a secret is typed only once
|
|
45
|
+
// the command is otherwise complete.
|
|
46
|
+
await readInputs(schema.noun, verb, parsed, null);
|
|
47
|
+
check(schema, verb, parsed);
|
|
48
|
+
await readInputs(schema.noun, verb, parsed, opts.prompter ?? terminal);
|
|
49
|
+
const line = ['c11r', ...argv.filter((a) => a !== '--yes' && a !== '-y')].map(quote).join(' ');
|
|
50
|
+
const org = resolveOrg(cmd.globals.org);
|
|
51
|
+
const login = hasLogin();
|
|
52
|
+
// The bearer, from the schema (contract section 23). An organization token for the organization
|
|
53
|
+
// nouns, the login for the Provisioning ones; credentials take the login when there is one and
|
|
54
|
+
// the organization token is not a sandbox's.
|
|
55
|
+
let bearer = verb.credential === 'login' ? 'login' : verb.credential === 'organization|login' ? (login && !org.token?.startsWith('sbx_') ? 'login' : 'organization') : verb.credential === 'none' ? 'none' : 'organization';
|
|
56
|
+
// members and roles with an organization token and no login: the API says what is needed (a
|
|
57
|
+
// claim for a sandbox, a login for a claimed organization). organizations and auth stop here.
|
|
58
|
+
if (bearer === 'login' && !login && org.token && schema.noun !== 'organizations' && schema.noun !== 'auth')
|
|
59
|
+
bearer = 'organization';
|
|
60
|
+
// A verb that needs an organization token: say which is missing before the API does.
|
|
61
|
+
if (bearer === 'organization' && verb.state !== 'none' && !org.token) {
|
|
62
|
+
if (org.problem?.code === 'no_credentials' && login && org.org) {
|
|
63
|
+
fail('no_credentials', `${org.problem.message} The login can make one: c11r organizations connect ${org.org}.`, { path: 'org', next_step: { action: 'fix_request', command: `c11r organizations connect ${org.org}` } }, 'auth');
|
|
64
|
+
}
|
|
65
|
+
raiseProblem(org, line);
|
|
66
|
+
fail('state_required', `c11r ${schema.noun} ${verb.name} needs a sandbox, and this machine has none.`, { required_state: 'sandbox', next_step: { action: 'create_sandbox', command: 'c11r sandbox create' } });
|
|
67
|
+
}
|
|
68
|
+
// The login names its organization with ?org=, from --org, C11R_ORG, .c11r.json or the default:
|
|
69
|
+
// never a guess from the only token stored, which may be a sandbox the login does not reach.
|
|
70
|
+
const loginOrg = org.source && org.source !== 'only_stored' ? org.org : null;
|
|
71
|
+
if (cmd.globals.live && !verb.live) {
|
|
72
|
+
fail('state_required', `c11r ${schema.noun} ${verb.name} does not run in live mode.`, { required_state: 'claimed', path: 'live', next_step: fixWith(line.replace(/\s--live\b/, '')) });
|
|
73
|
+
}
|
|
74
|
+
let confirmed = false;
|
|
75
|
+
const confirmFlags = verb.confirmIf.filter((k) => parsed.values[k] !== undefined && parsed.values[k] !== false);
|
|
76
|
+
if ((verb.destructive || confirmFlags.length) && !cmd.globals.dryRun) {
|
|
77
|
+
const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
|
|
78
|
+
await confirm(what.charAt(0).toUpperCase() + what.slice(1), line, { yes: cmd.globals.yes, prompter: opts.prompter });
|
|
79
|
+
confirmed = true;
|
|
80
|
+
}
|
|
81
|
+
// Where a secret goes, checked before the request makes one.
|
|
82
|
+
const secretPlan = verb.secret ? planSecret({ org: org.org, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
|
|
83
|
+
const ctx = { live: cmd.globals.live, token: bearer === 'login' ? await loginToken() : org.token };
|
|
84
|
+
const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
|
|
85
|
+
const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
|
|
86
|
+
const run = {
|
|
87
|
+
globals: cmd.globals,
|
|
88
|
+
schema,
|
|
89
|
+
verb,
|
|
90
|
+
parsed,
|
|
91
|
+
line,
|
|
92
|
+
org,
|
|
93
|
+
ctx,
|
|
94
|
+
bearer,
|
|
95
|
+
prompter: opts.prompter,
|
|
96
|
+
async send(req) {
|
|
97
|
+
try {
|
|
98
|
+
return await sendOnce(req);
|
|
99
|
+
}
|
|
100
|
+
catch (e) {
|
|
101
|
+
// A login token Commerce Layer no longer takes: mint a new one, once.
|
|
102
|
+
if (!(e instanceof CliError && e.code === 'login_required') || !login)
|
|
103
|
+
throw e;
|
|
104
|
+
if (bearer === 'login' && process.env.C11R_PROVISIONING_TOKEN)
|
|
105
|
+
throw e;
|
|
106
|
+
if (bearer === 'organization' && verb.credential !== 'organization|login')
|
|
107
|
+
throw e;
|
|
108
|
+
ctx.token = await loginToken({ fresh: bearer === 'login' });
|
|
109
|
+
run.bearer = 'login';
|
|
110
|
+
if (loginOrg && schema.noun !== 'organizations')
|
|
111
|
+
Object.assign(orgQuery, { org: loginOrg });
|
|
112
|
+
return sendOnce(req);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
|
|
117
|
+
const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
|
|
118
|
+
return filesGone(secretOf(r, run, secretPlan), run);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* A delete of a record whose verbs answer secrets (credentials, webhooks): the files c11r wrote
|
|
122
|
+
* for it go with it, listed in removed_files. out_files_not_tracked only when it may apply:
|
|
123
|
+
* nothing found here, or a secret of this organization written with --out on this machine.
|
|
124
|
+
*/
|
|
125
|
+
function filesGone(r, run) {
|
|
126
|
+
if (run.verb.name !== 'delete' || run.globals.dryRun || !run.org.org)
|
|
127
|
+
return r;
|
|
128
|
+
if (!Object.values(run.schema.verbs).some((v) => v.secret))
|
|
129
|
+
return r;
|
|
130
|
+
const data = (r.data && typeof r.data === 'object' ? r.data : {});
|
|
131
|
+
const removed = removeRecordFiles(run.org.org, run.schema.noun, [run.parsed.id, data.id, data.name, data.code]);
|
|
132
|
+
const warn = removed.length === 0 || outWritten(run.org.org);
|
|
133
|
+
return { ...r, data: { ...data, removed_files: removed }, warnings: [...r.warnings, ...(warn ? [OUT_NOT_TRACKED] : [])] };
|
|
134
|
+
}
|
|
135
|
+
/** A secret in the answer goes to its file; a dry run says where it would go. */
|
|
136
|
+
function secretOf(r, run, plan) {
|
|
137
|
+
const data = r.data;
|
|
138
|
+
const secret = data && typeof data === 'object' ? data.secret : undefined;
|
|
139
|
+
if (secret && typeof secret === 'object' && 'value' in secret) {
|
|
140
|
+
// A verb the schema did not mark: still never printed.
|
|
141
|
+
const p = plan ?? planSecret({ org: run.org.org, verb: run.verb.name, id: run.parsed.id, values: run.parsed.values, yes: true });
|
|
142
|
+
return writeSecret(r, p, { org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id });
|
|
143
|
+
}
|
|
144
|
+
if (plan && run.globals.dryRun)
|
|
145
|
+
return { ...r, data: { ...(data && typeof data === 'object' ? data : { result: data }), secret_file: describePlan(plan) } };
|
|
146
|
+
return r;
|
|
147
|
+
}
|
|
148
|
+
/** What the command needs before anything is sent: the id, the required flags, known --data keys. */
|
|
149
|
+
function check(schema, verb, parsed) {
|
|
150
|
+
const cmd = `c11r ${schema.noun} ${verb.name}`;
|
|
151
|
+
const alt = verb.idAlternative;
|
|
152
|
+
const altGiven = alt.filter((k) => parsed.values[k] !== undefined);
|
|
153
|
+
if (alt.length && parsed.id !== undefined && altGiven.length) {
|
|
154
|
+
fail('invalid_value', `${cmd} takes the id or ${alt.map(toFlag).join(' with ')}, not both.`, { path: altGiven[0], next_step: fixWith(`${cmd} --help`) });
|
|
155
|
+
}
|
|
156
|
+
if (verb.id && parsed.id === undefined && altGiven.length < Math.max(alt.length, 1)) {
|
|
157
|
+
const accepts = schema.id?.accepts.length ? ` (or its ${schema.id.accepts.filter((a) => a !== 'id').join(' or ')})` : '';
|
|
158
|
+
const instead = alt.length ? `, or ${alt.map(toFlag).join(' with ')}` : '';
|
|
159
|
+
const missing = alt.filter((k) => parsed.values[k] === undefined);
|
|
160
|
+
fail('missing_id', `${cmd} needs the id${accepts} of one of the ${schema.noun}${instead}.`, {
|
|
161
|
+
path: altGiven.length ? missing[0] : 'id',
|
|
162
|
+
...(alt.length ? { details: { id_alternative: alt.map(toFlag) } } : {}),
|
|
163
|
+
next_step: fixWith(`c11r ${schema.noun} list`),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const keys = Object.keys(verb.flags);
|
|
167
|
+
const fromFiles = new Set(Object.values(verb.flags).flatMap((f) => (f.input ? [f.input.target] : [])));
|
|
168
|
+
for (const key of Object.keys(parsed.values)) {
|
|
169
|
+
if (!verb.flags[key] && !fromFiles.has(key))
|
|
170
|
+
fail('unknown_field', `${cmd} has no field ${key}.`, { path: key, did_you_mean: closest(key, keys), details: { flags: keys.map(toFlag) } });
|
|
171
|
+
}
|
|
172
|
+
const missing = Object.entries(verb.flags)
|
|
173
|
+
.filter(([k, f]) => f.required && parsed.values[k] === undefined)
|
|
174
|
+
.map(([k]) => k);
|
|
175
|
+
if (missing.length)
|
|
176
|
+
fail('missing_argument', `${cmd} needs ${missing.map(toFlag).join(', ')}.`, { path: missing[0], details: { missing: missing.map(toFlag) }, next_step: fixWith(`${cmd} --help`) });
|
|
177
|
+
if (verb.name === 'update' && !Object.keys(parsed.values).length)
|
|
178
|
+
fail('no_fields', `${cmd} needs at least one field to change.`, { details: { flags: keys.map(toFlag) }, next_step: fixWith(`${cmd} --help`) });
|
|
179
|
+
}
|
|
180
|
+
async function help(cmd, opts) {
|
|
181
|
+
const builtins = opts.builtins ?? {};
|
|
182
|
+
// c11r help <noun> [verb] is c11r <noun> [verb] --help.
|
|
183
|
+
if (cmd.noun === 'help' && cmd.verb)
|
|
184
|
+
return execute([cmd.verb, ...cmd.rest.filter((a) => !a.startsWith('-')).slice(0, 1), '--help'], opts);
|
|
185
|
+
const own = [
|
|
186
|
+
...Object.values(builtins).map((b) => ({ noun: b.schema.noun, description: b.schema.description, ...(b.schema.doc.group ? { group: b.schema.doc.group } : {}), ...(b.schema.skill ? { skill: b.schema.skill } : {}) })),
|
|
187
|
+
{ noun: 'schema', description: 'c11r schema <noun> returns a noun\'s fields, filters and verbs, and what a loop policy may automate.' },
|
|
188
|
+
{ noun: 'help', description: 'c11r help [noun [verb]] describes the CLI, a noun or a verb.' },
|
|
189
|
+
];
|
|
190
|
+
try {
|
|
191
|
+
const api = (await apiNouns()).filter((n) => !builtins[n.noun]);
|
|
192
|
+
return envelope(indexHelp(own, api));
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
return envelope(indexHelp(own, null), { warnings: [{ code: 'nouns_incomplete', message: `Only the built-in nouns are listed: ${e.message}` }] });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function schemaOf(cmd, builtins) {
|
|
199
|
+
if (!cmd.verb)
|
|
200
|
+
fail('missing_argument', 'c11r schema needs a noun, as in c11r schema markets.', { next_step: fixWith('c11r help') });
|
|
201
|
+
if (cmd.rest.length)
|
|
202
|
+
fail('unexpected_argument', `c11r schema takes one noun, not ${cmd.rest.join(' ')}.`);
|
|
203
|
+
return envelope((builtins[cmd.verb]?.schema ?? (await apiSchema(cmd.verb, Object.keys(builtins)))).doc);
|
|
204
|
+
}
|
|
205
|
+
const quote = (a) => (/^[\w@%+=:,./#-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`);
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Every failure is one JSON object on stdout, { error, state }, in the API contract's shape
|
|
2
|
+
// (section 3), whether the API or the CLI raised it, and a stable exit code. The code is the
|
|
3
|
+
// contract with the agent: it never changes meaning. next_step says what would make the command
|
|
4
|
+
// work, for the agent to propose to the human; the agent does not retry the failed call.
|
|
5
|
+
/**
|
|
6
|
+
* Exit codes. Stable: an agent or a script can branch on them.
|
|
7
|
+
* 0 success, 1 unexpected, 2 usage, 3 confirmation needed, 4 state or plan needed,
|
|
8
|
+
* 5 not found, 6 refused by the API, 7 API unreachable or failing, 8 authentication.
|
|
9
|
+
*/
|
|
10
|
+
export const EXIT = {
|
|
11
|
+
ok: 0,
|
|
12
|
+
unexpected: 1,
|
|
13
|
+
usage: 2,
|
|
14
|
+
confirmation: 3,
|
|
15
|
+
requirement: 4,
|
|
16
|
+
not_found: 5,
|
|
17
|
+
rejected: 6,
|
|
18
|
+
unavailable: 7,
|
|
19
|
+
auth: 8,
|
|
20
|
+
};
|
|
21
|
+
/** The exit kind of every code the CLI raises, and of the contract's codes (section 3). */
|
|
22
|
+
const KIND = {
|
|
23
|
+
// The CLI's own, before anything is sent.
|
|
24
|
+
unknown_noun: 'usage',
|
|
25
|
+
unknown_verb: 'usage',
|
|
26
|
+
unknown_field: 'usage',
|
|
27
|
+
invalid_value: 'usage',
|
|
28
|
+
missing_value: 'usage',
|
|
29
|
+
missing_argument: 'usage',
|
|
30
|
+
missing_id: 'usage',
|
|
31
|
+
unexpected_argument: 'usage',
|
|
32
|
+
no_fields: 'usage',
|
|
33
|
+
confirmation_declined: 'confirmation',
|
|
34
|
+
invalid_response: 'unavailable',
|
|
35
|
+
schema_unavailable: 'unavailable',
|
|
36
|
+
api_unreachable: 'unavailable',
|
|
37
|
+
invalid_config: 'usage',
|
|
38
|
+
secret_required: 'usage',
|
|
39
|
+
insecure_file: 'usage',
|
|
40
|
+
// A secret the API answered once and the disk refused: nothing else to do but say so.
|
|
41
|
+
secret_not_written: 'unexpected',
|
|
42
|
+
// The contract's.
|
|
43
|
+
confirmation_required: 'confirmation',
|
|
44
|
+
state_required: 'requirement',
|
|
45
|
+
plan_required: 'requirement',
|
|
46
|
+
expired: 'requirement',
|
|
47
|
+
limit_reached: 'requirement',
|
|
48
|
+
not_found: 'not_found',
|
|
49
|
+
invalid_argument: 'rejected',
|
|
50
|
+
invalid_transition: 'rejected',
|
|
51
|
+
conflict: 'rejected',
|
|
52
|
+
dependency_missing: 'rejected',
|
|
53
|
+
commercelayer_refused: 'rejected',
|
|
54
|
+
policy_forbidden: 'rejected',
|
|
55
|
+
not_supported: 'rejected',
|
|
56
|
+
rate_limited: 'unavailable',
|
|
57
|
+
commercelayer_busy: 'unavailable',
|
|
58
|
+
commercelayer_unavailable: 'unavailable',
|
|
59
|
+
api_error: 'unavailable',
|
|
60
|
+
unauthorized: 'auth',
|
|
61
|
+
login_required: 'auth',
|
|
62
|
+
no_credentials: 'auth',
|
|
63
|
+
forbidden: 'rejected',
|
|
64
|
+
claim_not_found: 'not_found',
|
|
65
|
+
};
|
|
66
|
+
/** For an API error whose code the CLI does not know: from the HTTP status. */
|
|
67
|
+
export function kindOfStatus(status) {
|
|
68
|
+
if (status === 401)
|
|
69
|
+
return 'auth';
|
|
70
|
+
if (status === 403 || status === 410)
|
|
71
|
+
return 'requirement';
|
|
72
|
+
if (status === 404)
|
|
73
|
+
return 'not_found';
|
|
74
|
+
if (status === 428)
|
|
75
|
+
return 'confirmation';
|
|
76
|
+
if (status === 429 || status >= 500)
|
|
77
|
+
return 'unavailable';
|
|
78
|
+
if (status >= 400)
|
|
79
|
+
return 'rejected';
|
|
80
|
+
return 'unexpected';
|
|
81
|
+
}
|
|
82
|
+
export class CliError extends Error {
|
|
83
|
+
body;
|
|
84
|
+
kind;
|
|
85
|
+
/** The API's state envelope, when the error came from the API. */
|
|
86
|
+
state;
|
|
87
|
+
constructor(body, kind, state = null) {
|
|
88
|
+
super(body.message);
|
|
89
|
+
this.body = body;
|
|
90
|
+
this.kind = kind ?? KIND[body.code] ?? 'unexpected';
|
|
91
|
+
this.state = state;
|
|
92
|
+
}
|
|
93
|
+
get code() {
|
|
94
|
+
return this.body.code;
|
|
95
|
+
}
|
|
96
|
+
get exitCode() {
|
|
97
|
+
return EXIT[this.kind];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function errorBody(code, message, extra = {}) {
|
|
101
|
+
const { next_step, required_state = null, required_plan = null, missing = null, path = null, ...rest } = extra;
|
|
102
|
+
return {
|
|
103
|
+
code,
|
|
104
|
+
message,
|
|
105
|
+
required_state,
|
|
106
|
+
required_plan,
|
|
107
|
+
missing,
|
|
108
|
+
path,
|
|
109
|
+
next_step: next_step ? { action: next_step.action, command: next_step.command ?? null, url: next_step.url ?? null } : null,
|
|
110
|
+
...rest,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function fail(code, message, extra = {}, kind) {
|
|
114
|
+
throw new CliError(errorBody(code, message, extra), kind);
|
|
115
|
+
}
|
|
116
|
+
/** A next step that fixes the command: the command to look at, or to run instead. */
|
|
117
|
+
export const fixWith = (command) => ({ action: 'fix_request', command });
|
|
118
|
+
/** Any thrown value as a CliError: unexpected ones keep their message. */
|
|
119
|
+
export function asCliError(e) {
|
|
120
|
+
if (e instanceof CliError)
|
|
121
|
+
return e;
|
|
122
|
+
return new CliError(errorBody('unexpected', `c11r failed unexpectedly: ${e instanceof Error ? e.message : String(e)}. This is a bug in c11r.`), 'unexpected');
|
|
123
|
+
}
|
|
124
|
+
/** Close matches by edit distance, for did_you_mean. */
|
|
125
|
+
export function closest(word, candidates, max = 3) {
|
|
126
|
+
const scored = [...candidates].map((c) => ({ c, d: distance(word.toLowerCase(), c.toLowerCase()) }));
|
|
127
|
+
const limit = Math.max(2, Math.floor(word.length / 3));
|
|
128
|
+
return scored
|
|
129
|
+
.filter((s) => s.d <= limit || s.c.startsWith(word) || word.startsWith(s.c))
|
|
130
|
+
.sort((a, b) => a.d - b.d || (a.c < b.c ? -1 : 1))
|
|
131
|
+
.slice(0, max)
|
|
132
|
+
.map((s) => s.c);
|
|
133
|
+
}
|
|
134
|
+
function distance(a, b) {
|
|
135
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
136
|
+
for (let i = 1; i <= a.length; i++) {
|
|
137
|
+
let prev = row[0];
|
|
138
|
+
row[0] = i;
|
|
139
|
+
for (let j = 1; j <= b.length; j++) {
|
|
140
|
+
const tmp = row[j];
|
|
141
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
142
|
+
prev = tmp;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return row[b.length];
|
|
146
|
+
}
|