@commercelayer/agent-cli 1.0.0-dev.0 → 1.0.0-dev.2
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 +6 -3
- package/dist/argv.js +37 -5
- package/dist/builtins/auth.js +2 -2
- package/dist/builtins/extensions.js +2 -18
- package/dist/config.js +37 -10
- package/dist/engine.js +40 -8
- package/dist/help.js +9 -6
- package/dist/inputs.js +112 -0
- package/dist/login.js +12 -9
- package/dist/resources.js +6 -2
- package/dist/schema.js +33 -3
- package/dist/secrets.js +36 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,8 +14,11 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
|
|
|
14
14
|
- A plain noun has no code in the CLI. Its fields, filters and verbs come from the API's schema, and `c11r schema <noun>` returns that schema.
|
|
15
15
|
- Flags are the schema's fields, in kebab or snake case: `--price-list` and `--price_list` are the same.
|
|
16
16
|
- On `list`, `--<field> <value>` filters by equality and `--<field>.<op> <value>` by another operator: `--placed-at.gte 2026-11-01`, `--status.in placed,approved`. `--waiting` lists what needs a decision now.
|
|
17
|
-
- A dotted flag sets part of an object: `--customer.email ada@example.com`. An array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes `key=value` pairs, one object per flag: `--items sku=COFFEE,quantity=2`. `--data` takes the fields as one JSON object, or `@` and a file name.
|
|
17
|
+
- A dotted flag sets part of an object: `--customer.email ada@example.com`. An `in` filter takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it (`--tax-calculator.in "UK VAT, manual"`), and a list that has one is sent as a JSON array. Any other array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes `key=value` pairs, one object per flag: `--items sku=COFFEE,quantity=2`. `--data` takes the fields as one JSON object, or `@` and a file name.
|
|
18
18
|
- Amounts are in major units: `--amount 12.50`.
|
|
19
|
+
- A secret field the schema marks `secret_input` (provider credentials) has no flag of its own: `--credentials-file <file>` reads it, as JSON or `KEY=value` lines, from a plain file only its owner can read (mode 600). Without the file, a terminal asks for it without echo; with neither, the command stops with `secret_required`. It is refused as a flag value or in `--data`, and never printed.
|
|
20
|
+
- A document field the schema marks `file_input` (price rules, rule filters) also reads from a JSON file: `--file <file>`, or `--<field>-file` when a verb has more than one.
|
|
21
|
+
- A verb whose schema lists `id_alternative` takes the id or those flags in its place: `c11r prices get pr_1`, or `c11r prices get --sku BEANS-1KG --price-list eur`. Both at once, or half the flags, stop before sending.
|
|
19
22
|
|
|
20
23
|
## Built-in nouns
|
|
21
24
|
|
|
@@ -47,7 +50,7 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
|
|
|
47
50
|
|
|
48
51
|
`organizations create` and `organizations connect <slug>` answer an organization token once: it is stored like a sandbox token. `connect --revoke-others` also ends every other token of that organization, and asks for confirmation. With a login and no organization token for the organization named, an organization noun stops with `no_credentials` and the next step `c11r organizations connect <slug>`. `members` and `roles` with an organization token and no login are sent with that token, and the API says what is needed: a claim for a sandbox, a login for a claimed organization. `organizations` with no login stops here with `login_required`.
|
|
49
52
|
|
|
50
|
-
`credentials create` and `credentials token` answer a secret once. c11r writes it to a file with mode 600 and prints the file, the format and a hint, never the value: by default `~/.config/c11r/secrets/<org>/<name>.env` (`<id>-token.env` for a token), or `--out <path>`, as `KEY=value` lines or with `--format json` one JSON object. The file is checked before the request is sent; one there already is replaced only with `--yes`. A file inside a git repository that git does not ignore gets the warning `secret_not_ignored`. With `--dry-run` nothing is written and `secret_file` says where it would go. `credentials
|
|
53
|
+
`credentials create` and `credentials token` answer a secret once. c11r writes it to a file with mode 600 and prints the file, the format and a hint, never the value: by default `~/.config/c11r/secrets/<org>/<name>.env` (`<id>-token.env` for a token), or `--out <path>`, as `KEY=value` lines or with `--format json` one JSON object. The file is checked before the request is sent; one there already is replaced only with `--yes`. A file inside a git repository that git does not ignore gets the warning `secret_not_ignored`. With `--dry-run` nothing is written and `secret_file` says where it would go. Each file records the record it belongs to. A `delete` of a record whose noun answers secrets (`credentials`, `webhooks`) removes the files c11r wrote for it in `~/.config/c11r/secrets/<org>/` (a credential's secret and tokens, a webhook's shared secret), found by the id, name or code given, and lists them in `removed_files`; `sandbox destroy` removes the whole folder of that organization and names it in `forgotten.secrets`. A file written with `--out` is not tracked and stays: the warning `out_files_not_tracked` says so, when a secret of that organization was written with `--out` on this machine, or when a delete found nothing to remove. A token file carries `CL_BASE_ENDPOINT` beside the token. `sales-calls book` answers a cancel token once: it is kept in `credentials.json` and `sales-calls cancel <booking>` sends it.
|
|
51
54
|
|
|
52
55
|
## Global flags
|
|
53
56
|
|
|
@@ -66,7 +69,7 @@ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sand
|
|
|
66
69
|
|
|
67
70
|
`--org`, then `C11R_ORG`, then `.c11r.json` in the project folder (found by walking up, like `.git`), then the default set by `auth switch --global`, then the stored organization when exactly one is stored (never for a login noun's `?org=`). With several stored and nothing naming one, the command stops with `org_required` and lists them.
|
|
68
71
|
|
|
69
|
-
- Tokens are only in `~/.config/c11r/credentials.json` (or under `XDG_CONFIG_HOME`), mode 600. The CLI never prints one, only its hint.
|
|
72
|
+
- Tokens are only in `~/.config/c11r/credentials.json` (or under `XDG_CONFIG_HOME`), mode 600. The CLI never prints one, only its hint. Each token records the host that issued it: organization and cancel tokens the API base, the login the auth endpoint. One is used, listed and forgotten only while that host is the one in effect (`C11R_API_URL`, `C11R_AUTH_URL`), so a token never goes to another host. An entry that records no host is not used, and goes at the next write.
|
|
70
73
|
- `.c11r.json` holds only `{ "org": "<slug>" }`: no secret, safe to commit. `sandbox create`, `organizations create` and `auth switch` write it, and `organizations connect` when the folder has no link; `reset` follows the new slug; `destroy` removes it when it names the destroyed organization.
|
|
71
74
|
- The project folder is the one with `.c11r.json`, else the nearest with `store.yml` or `loops.yml`, else the current one. `loops.yml` goes there.
|
|
72
75
|
|
package/dist/argv.js
CHANGED
|
@@ -65,12 +65,18 @@ export function parseFields(noun, verb, rest) {
|
|
|
65
65
|
const [flag, inline] = splitInline(a);
|
|
66
66
|
const key = toKey(flag);
|
|
67
67
|
if (key === 'data') {
|
|
68
|
-
|
|
68
|
+
const data = readData(inline ?? rest[++i]);
|
|
69
|
+
for (const k of Object.keys(data))
|
|
70
|
+
if (verb.flags[`${k}_file`]?.input?.kind === 'secret')
|
|
71
|
+
secretAsValue(cmd, k);
|
|
72
|
+
Object.assign(values, data);
|
|
69
73
|
continue;
|
|
70
74
|
}
|
|
71
75
|
// A key of its own (a filter operator: created_at.gte), else part of an object (checkout.url).
|
|
72
76
|
const [top, ...sub] = verb.flags[key] ? [key] : key.split('.');
|
|
73
77
|
const spec = verb.flags[top];
|
|
78
|
+
if (!spec && verb.flags[`${top}_file`]?.input?.kind === 'secret')
|
|
79
|
+
secretAsValue(cmd, top);
|
|
74
80
|
if (!spec) {
|
|
75
81
|
fail('unknown_field', `${cmd} has no flag ${toFlag(key)}.`, {
|
|
76
82
|
path: key,
|
|
@@ -98,7 +104,7 @@ export function parseFields(noun, verb, rest) {
|
|
|
98
104
|
for (const k of sub.slice(0, -1))
|
|
99
105
|
target = (target[k] ??= {});
|
|
100
106
|
const part = sub.join('.');
|
|
101
|
-
target[sub[sub.length - 1]] = part in (spec.properties ?? {}) ? coerce(key,
|
|
107
|
+
target[sub[sub.length - 1]] = part in (spec.properties ?? {}) ? coerce(key, spec.properties[part], raw) : raw;
|
|
102
108
|
continue;
|
|
103
109
|
}
|
|
104
110
|
const value = coerce(key, spec, raw);
|
|
@@ -106,9 +112,20 @@ export function parseFields(noun, verb, rest) {
|
|
|
106
112
|
}
|
|
107
113
|
return { id, values };
|
|
108
114
|
}
|
|
115
|
+
/** A secret given as a value: refused before it goes anywhere, and the message does not repeat it. */
|
|
116
|
+
function secretAsValue(cmd, key) {
|
|
117
|
+
fail('invalid_value', `${cmd} takes ${key} only from a file, ${toFlag(`${key}_file`)} <file> (mode 600), or typed in a terminal: never as a flag value or in --data, where it would stay in the shell history. It was not sent.`, {
|
|
118
|
+
path: key,
|
|
119
|
+
next_step: { action: 'human_step', command: null, url: null },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
109
122
|
/** A value as its flag's type says, or invalid_value with the reason. */
|
|
110
123
|
export function coerce(key, spec, raw) {
|
|
111
124
|
const invalid = (why) => fail('invalid_value', `${toFlag(key)} ${JSON.stringify(raw)} ${why}.`, { path: key, ...(spec.values ? { details: { values: spec.values } } : {}) });
|
|
125
|
+
// The schema's fault, not the command's: said so before any value is checked against nothing.
|
|
126
|
+
if ((spec.type === 'enum' || (spec.type === 'array' && spec.items === 'enum')) && !spec.values?.length) {
|
|
127
|
+
fail('invalid_response', `The API's schema gives ${toFlag(key)} as an enum with no values, so c11r cannot check ${JSON.stringify(raw)}. It was not sent.`, { path: key, next_step: { action: 'none' } });
|
|
128
|
+
}
|
|
112
129
|
switch (spec.type) {
|
|
113
130
|
case 'string':
|
|
114
131
|
case 'ref':
|
|
@@ -143,7 +160,7 @@ export function coerce(key, spec, raw) {
|
|
|
143
160
|
const v = raw.trim().startsWith('{') ? json(raw, invalid) : pairs(key, raw, spec.properties ?? {}, invalid);
|
|
144
161
|
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
145
162
|
invalid('is not a JSON object');
|
|
146
|
-
return v;
|
|
163
|
+
return enumParts(key, spec.properties, v);
|
|
147
164
|
}
|
|
148
165
|
case 'array': {
|
|
149
166
|
const item = spec.items ?? 'string';
|
|
@@ -151,11 +168,15 @@ export function coerce(key, spec, raw) {
|
|
|
151
168
|
const list = json(raw, invalid);
|
|
152
169
|
if (!Array.isArray(list))
|
|
153
170
|
invalid('is not a list');
|
|
154
|
-
return list.map((x) => (typeof x === 'string' && item !== 'string' ? coerce(key, { type: item, values: spec.values }, x) : x));
|
|
171
|
+
return list.map((x) => (typeof x === 'string' && item !== 'string' ? coerce(key, { type: item, values: spec.values }, x) : item === 'object' ? enumParts(key, spec.properties, x) : x));
|
|
155
172
|
}
|
|
156
173
|
// One object per flag (--items sku=A,quantity=2, repeated), or scalars separated by commas.
|
|
157
174
|
if (item === 'object')
|
|
158
175
|
return [coerce(key, { type: 'object', properties: spec.properties }, raw)];
|
|
176
|
+
// An in filter on names or text takes the value whole: a name may have a comma ("UK VAT, manual").
|
|
177
|
+
// Several are the flag repeated, or a JSON array. Enums, numbers and dates still split on commas.
|
|
178
|
+
if (spec.filter?.op === 'in' && (item === 'string' || item === 'ref'))
|
|
179
|
+
return [raw];
|
|
159
180
|
return raw
|
|
160
181
|
.split(',')
|
|
161
182
|
.map((s) => s.trim())
|
|
@@ -164,6 +185,17 @@ export function coerce(key, spec, raw) {
|
|
|
164
185
|
}
|
|
165
186
|
}
|
|
166
187
|
}
|
|
188
|
+
/** In JSON too, an enum part of an object is one of its values. */
|
|
189
|
+
function enumParts(key, props, v) {
|
|
190
|
+
if (!v || typeof v !== 'object')
|
|
191
|
+
return v;
|
|
192
|
+
for (const [name, p] of Object.entries(props ?? {})) {
|
|
193
|
+
const part = v[name];
|
|
194
|
+
if (p.type === 'enum' && typeof part === 'string')
|
|
195
|
+
coerce(`${key}.${name}`, p, part);
|
|
196
|
+
}
|
|
197
|
+
return v;
|
|
198
|
+
}
|
|
167
199
|
function json(raw, invalid) {
|
|
168
200
|
try {
|
|
169
201
|
return JSON.parse(raw);
|
|
@@ -184,7 +216,7 @@ function pairs(key, raw, props, invalid) {
|
|
|
184
216
|
invalid('is not JSON nor key=value pairs separated by commas');
|
|
185
217
|
const name = part.slice(0, eq).trim();
|
|
186
218
|
const value = part.slice(eq + 1).trim();
|
|
187
|
-
out[name] = props[name] ? coerce(`${key}.${name}`,
|
|
219
|
+
out[name] = props[name] ? coerce(`${key}.${name}`, props[name], value) : value;
|
|
188
220
|
}
|
|
189
221
|
return out;
|
|
190
222
|
}
|
package/dist/builtins/auth.js
CHANGED
|
@@ -96,7 +96,7 @@ async function login(run) {
|
|
|
96
96
|
const minted = await mint(clientId, secret);
|
|
97
97
|
const who = await request({ method: 'GET', path: '/auth', query: { limit: '100' } }, { live: false, token: minted.token });
|
|
98
98
|
const orgs = who.data?.organizations;
|
|
99
|
-
const items = Array.isArray(orgs
|
|
99
|
+
const items = Array.isArray(orgs?.items) ? orgs.items : [];
|
|
100
100
|
const previous = storedLogin();
|
|
101
101
|
if (!run.globals.dryRun) {
|
|
102
102
|
storeLogin({
|
|
@@ -146,7 +146,7 @@ async function switchOrg(run) {
|
|
|
146
146
|
if (!tokens[slug] && !reached.includes(slug) && storedLogin()) {
|
|
147
147
|
// The list may be older than the organization: read it again.
|
|
148
148
|
const who = await request({ method: 'GET', path: '/auth', query: { limit: '100' } }, { live: false, token: await loginToken() });
|
|
149
|
-
const items = who.data?.organizations?.items ??
|
|
149
|
+
const items = who.data?.organizations?.items ?? [];
|
|
150
150
|
reached = items.map((o) => o.slug);
|
|
151
151
|
const l = storedLogin();
|
|
152
152
|
if (l)
|
|
@@ -8,12 +8,11 @@
|
|
|
8
8
|
// sales-calls book answers a cancel token once (section 20): stored the same way, by booking id,
|
|
9
9
|
// and sent by sales-calls cancel <booking> without the agent ever holding it.
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
|
-
import { credentialsFile, hintOf,
|
|
11
|
+
import { credentialsFile, hintOf, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
|
|
12
12
|
import { confirm, terminal } from '../confirm.js';
|
|
13
13
|
import { fail } from '../errors.js';
|
|
14
14
|
import { apiUrl } from '../http.js';
|
|
15
15
|
import { resourceRequest } from '../resources.js';
|
|
16
|
-
import { OUT_NOT_TRACKED, removeCredentialFiles } from '../secrets.js';
|
|
17
16
|
const NAME_MAX = 25;
|
|
18
17
|
export const organizations = {
|
|
19
18
|
async run(run) {
|
|
@@ -105,19 +104,4 @@ export const salesCalls = {
|
|
|
105
104
|
return undefined;
|
|
106
105
|
},
|
|
107
106
|
};
|
|
108
|
-
export const
|
|
109
|
-
async run(run) {
|
|
110
|
-
if (run.verb.name !== 'delete')
|
|
111
|
-
return undefined;
|
|
112
|
-
const r = await run.send(resourceRequest('credentials', run.verb, run.parsed.id, run.parsed.values));
|
|
113
|
-
if (run.globals.dryRun || !run.org.org)
|
|
114
|
-
return r;
|
|
115
|
-
// The secret and token files c11r wrote for it go with it.
|
|
116
|
-
const id = String(r.data?.id ?? run.parsed.id);
|
|
117
|
-
const removed = removeCredentialFiles(run.org.org, id);
|
|
118
|
-
// The warning only when it may apply: nothing found here, or a file written with --out for this organization.
|
|
119
|
-
const warn = removed.length === 0 || outWritten(run.org.org);
|
|
120
|
-
return { ...r, data: { ...(r.data ?? {}), removed_files: removed }, warnings: [...r.warnings, ...(warn ? [OUT_NOT_TRACKED] : [])] };
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
export const extensions = { organizations, 'sales-calls': salesCalls, credentials };
|
|
107
|
+
export const extensions = { organizations, 'sales-calls': salesCalls };
|
package/dist/config.js
CHANGED
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
// The organization: --org, then C11R_ORG, then .c11r.json, then the stored one when exactly one
|
|
11
11
|
// is stored. With several stored and nothing naming one, the command stops and names them.
|
|
12
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.
|
|
13
18
|
import fs from 'node:fs';
|
|
14
19
|
import os from 'node:os';
|
|
15
20
|
import path from 'node:path';
|
|
16
21
|
import { fail, fixWith } from './errors.js';
|
|
22
|
+
import { apiUrl } from './http.js';
|
|
17
23
|
export const LINK_FILE = '.c11r.json';
|
|
18
24
|
export function credentialsFile() {
|
|
19
25
|
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
@@ -32,6 +38,12 @@ function readCredentials() {
|
|
|
32
38
|
}
|
|
33
39
|
function writeCredentials(c) {
|
|
34
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));
|
|
35
47
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
36
48
|
// Written aside then renamed, so a crash never leaves half a file; 600 from the start.
|
|
37
49
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
@@ -39,19 +51,26 @@ function writeCredentials(c) {
|
|
|
39
51
|
fs.chmodSync(tmp, 0o600);
|
|
40
52
|
fs.renameSync(tmp, file);
|
|
41
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. */
|
|
42
61
|
export function storedOrgs() {
|
|
43
|
-
return readCredentials().orgs;
|
|
62
|
+
return Object.fromEntries(Object.entries(readCredentials().orgs).filter(([, o]) => fromApi(o)));
|
|
44
63
|
}
|
|
45
64
|
export function storeOrg(slug, org) {
|
|
46
65
|
const c = readCredentials();
|
|
47
|
-
c.orgs[slug] = org;
|
|
66
|
+
c.orgs[slug] = { ...org, api_url: apiUrl() };
|
|
48
67
|
writeCredentials(c);
|
|
49
68
|
}
|
|
50
69
|
/** After a reset: the same token, a new organization. */
|
|
51
70
|
export function renameOrg(from, to, patch = {}) {
|
|
52
71
|
const c = readCredentials();
|
|
53
72
|
const entry = c.orgs[from];
|
|
54
|
-
if (!entry)
|
|
73
|
+
if (!fromApi(entry))
|
|
55
74
|
return;
|
|
56
75
|
delete c.orgs[from];
|
|
57
76
|
c.orgs[to] = { ...entry, ...patch };
|
|
@@ -59,20 +78,25 @@ export function renameOrg(from, to, patch = {}) {
|
|
|
59
78
|
}
|
|
60
79
|
export function forgetOrg(slug) {
|
|
61
80
|
const c = readCredentials();
|
|
62
|
-
if (!c.orgs[slug])
|
|
81
|
+
if (!fromApi(c.orgs[slug]))
|
|
63
82
|
return;
|
|
64
83
|
delete c.orgs[slug];
|
|
65
84
|
writeCredentials(c);
|
|
66
85
|
}
|
|
86
|
+
/** The login minted by the auth endpoint in effect. */
|
|
67
87
|
export function storedLogin() {
|
|
68
|
-
|
|
88
|
+
const l = readCredentials().login;
|
|
89
|
+
return l && fromAuth(l) ? l : null;
|
|
69
90
|
}
|
|
91
|
+
/** Stores the login for the auth endpoint in effect; null forgets it, only if it is that one's. */
|
|
70
92
|
export function storeLogin(login) {
|
|
71
93
|
const c = readCredentials();
|
|
72
94
|
if (login)
|
|
73
|
-
c.login = login;
|
|
74
|
-
else
|
|
95
|
+
c.login = { ...login, auth_url: authUrl() };
|
|
96
|
+
else if (fromAuth(c.login))
|
|
75
97
|
delete c.login;
|
|
98
|
+
else
|
|
99
|
+
return;
|
|
76
100
|
writeCredentials(c);
|
|
77
101
|
}
|
|
78
102
|
export function defaultOrg() {
|
|
@@ -102,15 +126,18 @@ export function markOutWritten(org, written) {
|
|
|
102
126
|
writeCredentials(c);
|
|
103
127
|
}
|
|
104
128
|
export function storedBooking(id) {
|
|
105
|
-
|
|
129
|
+
const b = readCredentials().bookings?.[id];
|
|
130
|
+
return b && fromApi(b) ? b : null;
|
|
106
131
|
}
|
|
107
132
|
export function storeBooking(id, booking) {
|
|
108
133
|
const c = readCredentials();
|
|
109
134
|
const all = { ...(c.bookings ?? {}) };
|
|
110
135
|
if (booking)
|
|
111
|
-
all[id] = booking;
|
|
112
|
-
else
|
|
136
|
+
all[id] = { ...booking, api_url: apiUrl() };
|
|
137
|
+
else if (fromApi(all[id]))
|
|
113
138
|
delete all[id];
|
|
139
|
+
else
|
|
140
|
+
return;
|
|
114
141
|
c.bookings = all;
|
|
115
142
|
writeCredentials(c);
|
|
116
143
|
}
|
package/dist/engine.js
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
// destructive verb, then the request. A plain noun needs no code here: its schema says
|
|
5
5
|
// everything. Every answer is the API's envelope, { data, state, warnings, next }.
|
|
6
6
|
import { parseCommand, parseFields, toFlag } from './argv.js';
|
|
7
|
-
import { raiseProblem, resolveOrg } from './config.js';
|
|
8
|
-
import { confirm } from './confirm.js';
|
|
7
|
+
import { outWritten, raiseProblem, resolveOrg } from './config.js';
|
|
8
|
+
import { confirm, terminal } from './confirm.js';
|
|
9
9
|
import { CliError, closest, fail, fixWith } from './errors.js';
|
|
10
10
|
import { indexHelp, nounHelp, verbHelp } from './help.js';
|
|
11
11
|
import { envelope, request } from './http.js';
|
|
12
|
+
import { readInputs } from './inputs.js';
|
|
12
13
|
import { hasLogin, loginToken } from './login.js';
|
|
13
14
|
import { resourceRequest } from './resources.js';
|
|
14
|
-
import { describePlan, planSecret, writeSecret } from './secrets.js';
|
|
15
|
+
import { describePlan, OUT_NOT_TRACKED, planSecret, removeRecordFiles, writeSecret } from './secrets.js';
|
|
15
16
|
import { verbOf } from './schema.js';
|
|
16
17
|
import { apiNouns, apiSchema } from './schemas.js';
|
|
17
18
|
import { VERSION } from './version.js';
|
|
@@ -40,7 +41,11 @@ export async function execute(argv, opts = {}) {
|
|
|
40
41
|
const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
|
|
41
42
|
const parsed = parseFields(schema.noun, verb, rest);
|
|
42
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);
|
|
43
47
|
check(schema, verb, parsed);
|
|
48
|
+
await readInputs(schema.noun, verb, parsed, opts.prompter ?? terminal);
|
|
44
49
|
const line = ['c11r', ...argv.filter((a) => a !== '--yes' && a !== '-y')].map(quote).join(' ');
|
|
45
50
|
const org = resolveOrg(cmd.globals.org);
|
|
46
51
|
const login = hasLogin();
|
|
@@ -110,7 +115,22 @@ export async function execute(argv, opts = {}) {
|
|
|
110
115
|
};
|
|
111
116
|
const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
|
|
112
117
|
const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
|
|
113
|
-
return secretOf(r, run, secretPlan);
|
|
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] : [])] };
|
|
114
134
|
}
|
|
115
135
|
/** A secret in the answer goes to its file; a dry run says where it would go. */
|
|
116
136
|
function secretOf(r, run, plan) {
|
|
@@ -119,7 +139,7 @@ function secretOf(r, run, plan) {
|
|
|
119
139
|
if (secret && typeof secret === 'object' && 'value' in secret) {
|
|
120
140
|
// A verb the schema did not mark: still never printed.
|
|
121
141
|
const p = plan ?? planSecret({ org: run.org.org, verb: run.verb.name, id: run.parsed.id, values: run.parsed.values, yes: true });
|
|
122
|
-
return writeSecret(r, p, { org: run.org.org, verb: run.verb.name, id: run.parsed.id });
|
|
142
|
+
return writeSecret(r, p, { org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id });
|
|
123
143
|
}
|
|
124
144
|
if (plan && run.globals.dryRun)
|
|
125
145
|
return { ...r, data: { ...(data && typeof data === 'object' ? data : { result: data }), secret_file: describePlan(plan) } };
|
|
@@ -128,13 +148,25 @@ function secretOf(r, run, plan) {
|
|
|
128
148
|
/** What the command needs before anything is sent: the id, the required flags, known --data keys. */
|
|
129
149
|
function check(schema, verb, parsed) {
|
|
130
150
|
const cmd = `c11r ${schema.noun} ${verb.name}`;
|
|
131
|
-
|
|
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)) {
|
|
132
157
|
const accepts = schema.id?.accepts.length ? ` (or its ${schema.id.accepts.filter((a) => a !== 'id').join(' or ')})` : '';
|
|
133
|
-
|
|
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
|
+
});
|
|
134
165
|
}
|
|
135
166
|
const keys = Object.keys(verb.flags);
|
|
167
|
+
const fromFiles = new Set(Object.values(verb.flags).flatMap((f) => (f.input ? [f.input.target] : [])));
|
|
136
168
|
for (const key of Object.keys(parsed.values)) {
|
|
137
|
-
if (!verb.flags[key])
|
|
169
|
+
if (!verb.flags[key] && !fromFiles.has(key))
|
|
138
170
|
fail('unknown_field', `${cmd} has no field ${key}.`, { path: key, did_you_mean: closest(key, keys), details: { flags: keys.map(toFlag) } });
|
|
139
171
|
}
|
|
140
172
|
const missing = Object.entries(verb.flags)
|
package/dist/help.js
CHANGED
|
@@ -29,7 +29,7 @@ export function indexHelp(builtins, api) {
|
|
|
29
29
|
description: 'c11r is the Commerce Layer CLI for coding agents. Every answer, errors included, is one JSON object on stdout.',
|
|
30
30
|
nouns: [...builtins, ...(api ?? [])].sort((a, b) => (a.noun < b.noun ? -1 : 1)),
|
|
31
31
|
standard_verbs: 'list, get, create, update, delete. Nouns with a state machine add their own verbs.',
|
|
32
|
-
flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01
|
|
32
|
+
flags: 'Fields in kebab or snake case (--price-list, --price_list). On list, --<field> <value> filters by equality and --<field>.<op> <value> by another operator (--created-at.gte 2026-11-01). in takes several values as the flag repeated or a JSON array; on enums, numbers and dates also separated by commas, while a name or text is one value even with a comma in it, and a list that has one is sent as a JSON array. A dotted flag sets part of an object (--customer.email). Any other array takes a JSON array, values separated by commas, or the flag repeated; an array of objects takes key=value pairs (--items sku=COFFEE,quantity=2). --data takes the fields as one JSON object, or @ and a file name.',
|
|
33
33
|
global_flags: GLOBAL_FLAG_HELP,
|
|
34
34
|
exit_codes: EXIT_CODE_HELP,
|
|
35
35
|
more: 'c11r <noun> --help describes a noun and its verbs; c11r <noun> <verb> --help a verb and its flags; c11r schema <noun> returns the full schema.',
|
|
@@ -63,16 +63,19 @@ function flagHelp(key, f) {
|
|
|
63
63
|
return {
|
|
64
64
|
flag: toFlag(key),
|
|
65
65
|
type: f.type === 'array' ? `array of ${f.items ?? 'string'}` : f.type === 'ref' ? `${f.noun ?? 'resource'} id` : f.type === 'money' ? 'amount in major units' : f.type,
|
|
66
|
-
...(f.required ? { required: true } : {}),
|
|
66
|
+
...(f.required || (f.input?.kind === 'secret' && f.input.required) ? { required: true } : {}),
|
|
67
|
+
...(f.input ? { reads: f.input.kind === 'secret' ? `${f.input.target}, a secret: never a flag value, never printed` : `${f.input.target}, a JSON document` } : {}),
|
|
67
68
|
...(f.values ? { values: f.values } : {}),
|
|
68
69
|
...(f.filter ? { filter: f.filter.op } : {}),
|
|
69
70
|
...(f.description ? { description: f.description } : {}),
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
export function usageOf(noun, v) {
|
|
74
|
+
const needed = (f) => f.required || (f.input?.kind === 'secret' && f.input.required);
|
|
73
75
|
const required = Object.entries(v.flags)
|
|
74
|
-
.filter(([, f]) => f
|
|
75
|
-
.map(([k, f]) => `${toFlag(k)} <${f.type === 'enum' ? f.values
|
|
76
|
-
const optional = Object.
|
|
77
|
-
|
|
76
|
+
.filter(([, f]) => needed(f))
|
|
77
|
+
.map(([k, f]) => `${toFlag(k)} <${f.input ? 'file' : f.type === 'enum' && f.values?.length ? f.values.join('|') : f.type}>`);
|
|
78
|
+
const optional = Object.entries(v.flags).some(([k, f]) => !needed(f) && !v.idAlternative.includes(k));
|
|
79
|
+
const id = v.id ? (v.idAlternative.length ? `<id | ${v.idAlternative.map((k) => `${toFlag(k)} <${v.flags[k].type}>`).join(' ')}>` : '<id>') : '';
|
|
80
|
+
return [`c11r ${noun} ${v.name}`, id, ...required, optional ? '[--field value ...]' : ''].filter(Boolean).join(' ');
|
|
78
81
|
}
|
package/dist/inputs.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Values read from files before anything is sent (contract section 28.6 and 39).
|
|
2
|
+
//
|
|
3
|
+
// A secret field (secret_input: provider credentials) is taken only from --<name>-file, as JSON or
|
|
4
|
+
// KEY=value lines, from a plain file only its owner can read; or, in a terminal, typed without
|
|
5
|
+
// echo. Never from a flag value or --data, never printed: an error names the file, not the value.
|
|
6
|
+
// A document field (file_input: price rules, rule filters) is also read from a JSON file, --file.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { toFlag } from './argv.js';
|
|
9
|
+
import { fail } from './errors.js';
|
|
10
|
+
import { readPrivateFile } from './login.js';
|
|
11
|
+
/** The flag a secret field is read from, if the key is one. */
|
|
12
|
+
export function secretFileFlag(verb, key) {
|
|
13
|
+
const k = `${key}_file`;
|
|
14
|
+
return verb.flags[k]?.input?.kind === 'secret' ? k : null;
|
|
15
|
+
}
|
|
16
|
+
/** Reads the files given; with a prompter, also asks for a required secret not given (null: files only). */
|
|
17
|
+
export async function readInputs(noun, verb, parsed, prompter) {
|
|
18
|
+
const cmd = `c11r ${noun} ${verb.name}`;
|
|
19
|
+
for (const [key, spec] of Object.entries(verb.flags)) {
|
|
20
|
+
const input = spec.input;
|
|
21
|
+
if (!input)
|
|
22
|
+
continue;
|
|
23
|
+
const file = parsed.values[key];
|
|
24
|
+
// The second pass only asks for a secret: the files were read in the first.
|
|
25
|
+
if (prompter !== null && (input.kind === 'document' || parsed.values[input.target] !== undefined))
|
|
26
|
+
continue;
|
|
27
|
+
if (input.kind === 'document') {
|
|
28
|
+
if (file === undefined)
|
|
29
|
+
continue;
|
|
30
|
+
if (parsed.values[input.target] !== undefined)
|
|
31
|
+
fail('invalid_value', `${cmd} takes ${input.target} from ${toFlag(input.target)} or from ${toFlag(key)}, not both.`, { path: key });
|
|
32
|
+
parsed.values[input.target] = documentOf(file, key, spec);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (file !== undefined) {
|
|
36
|
+
parsed.values[input.target] = secretOf(readPrivateFile(file, key), file, key, input.target_type);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!input.required || prompter === null)
|
|
40
|
+
continue;
|
|
41
|
+
if (prompter.interactive && prompter.askSecret) {
|
|
42
|
+
const typed = (await prompter.askSecret(`${input.target} as JSON, or KEY=value pairs separated by spaces (not shown): `)).trim();
|
|
43
|
+
if (!typed)
|
|
44
|
+
fail('secret_required', `Nothing was typed: ${cmd} needs ${input.target}.`, { path: key });
|
|
45
|
+
parsed.values[input.target] = secretOf(typed.startsWith('{') ? typed : typed.split(/\s+/).join('\n'), null, key, input.target_type);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
fail('secret_required', `${cmd} needs ${input.target}, and there is no terminal to type it in. Ask the human to save it in a file only they can read (mode 600), as JSON or KEY=value lines, then run the command with ${toFlag(key)} <file>. Do not open or print that file.`, {
|
|
49
|
+
path: key,
|
|
50
|
+
next_step: { action: 'human_step', command: null, url: null },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function documentOf(file, key, spec) {
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = fs.readFileSync(file, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
fail('invalid_value', `${toFlag(key)} ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`, { path: key });
|
|
61
|
+
}
|
|
62
|
+
let v;
|
|
63
|
+
try {
|
|
64
|
+
v = JSON.parse(text);
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
fail('invalid_value', `${file} is not valid JSON: ${e.message}.`, { path: key });
|
|
68
|
+
}
|
|
69
|
+
const want = spec.input.target_type;
|
|
70
|
+
if (want === 'object' && (!v || typeof v !== 'object' || Array.isArray(v)))
|
|
71
|
+
fail('invalid_value', `${file} is not a JSON object.`, { path: key });
|
|
72
|
+
if (want === 'array' && !Array.isArray(v))
|
|
73
|
+
fail('invalid_value', `${file} is not a JSON list.`, { path: key });
|
|
74
|
+
return v;
|
|
75
|
+
}
|
|
76
|
+
/** JSON, or KEY=value lines (keys lowercased: API_KEY is api_key). The value never goes in a message. */
|
|
77
|
+
function secretOf(text, file, key, type) {
|
|
78
|
+
const where = file ?? 'What was typed';
|
|
79
|
+
const trimmed = text.trim();
|
|
80
|
+
if (trimmed.startsWith('{')) {
|
|
81
|
+
let v;
|
|
82
|
+
try {
|
|
83
|
+
v = JSON.parse(trimmed);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
fail('invalid_value', `${where} is not valid JSON.`, { path: key });
|
|
87
|
+
}
|
|
88
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
89
|
+
fail('invalid_value', `${where} is not a JSON object.`, { path: key });
|
|
90
|
+
return v;
|
|
91
|
+
}
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const line of trimmed.split(/\r?\n/).map((l) => l.trim())) {
|
|
94
|
+
if (!line || line.startsWith('#'))
|
|
95
|
+
continue;
|
|
96
|
+
const eq = line.indexOf('=');
|
|
97
|
+
if (eq < 1)
|
|
98
|
+
fail('invalid_value', `${where} has a line that is not KEY=value.`, { path: key });
|
|
99
|
+
const value = line.slice(eq + 1).trim().replace(/^(['"])(.*)\1$/, '$2');
|
|
100
|
+
if (value)
|
|
101
|
+
out[line.slice(0, eq).trim().toLowerCase()] = value;
|
|
102
|
+
}
|
|
103
|
+
if (!Object.keys(out).length)
|
|
104
|
+
fail('secret_required', `${where} has no values.`, { path: key });
|
|
105
|
+
if (type !== 'object') {
|
|
106
|
+
const values = Object.values(out);
|
|
107
|
+
if (values.length !== 1)
|
|
108
|
+
fail('invalid_value', `${where} has to hold one value.`, { path: key });
|
|
109
|
+
return values[0];
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
package/dist/login.js
CHANGED
|
@@ -9,13 +9,12 @@
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
|
-
import { storedLogin, storeLogin } from './config.js';
|
|
12
|
+
import { authUrl, storedLogin, storeLogin } from './config.js';
|
|
13
13
|
import { CliError, errorBody, fail } from './errors.js';
|
|
14
14
|
import { USER_AGENT } from './version.js';
|
|
15
15
|
export const PROVISIONING_URL = 'https://dashboard.commercelayer.io/user/provisioning_api';
|
|
16
|
-
const AUTH_URL = 'https://auth.commercelayer.io/oauth/token';
|
|
17
16
|
const MARGIN_MS = 60_000;
|
|
18
|
-
export
|
|
17
|
+
export { authUrl };
|
|
19
18
|
/** Where the human saves the client id and secret when there is no terminal to type them in. */
|
|
20
19
|
export function secretFileDefault() {
|
|
21
20
|
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
@@ -78,25 +77,29 @@ export async function loginToken(opts = {}) {
|
|
|
78
77
|
return minted.token;
|
|
79
78
|
}
|
|
80
79
|
export const hasLogin = () => Boolean(process.env.C11R_PROVISIONING_TOKEN) || storedLogin() !== null;
|
|
81
|
-
/**
|
|
82
|
-
export function
|
|
80
|
+
/** A secret file's text: a plain file only its owner can read, else insecure_file. */
|
|
81
|
+
export function readPrivateFile(file, flagPath) {
|
|
83
82
|
let st;
|
|
84
83
|
try {
|
|
85
84
|
st = fs.lstatSync(file);
|
|
86
85
|
}
|
|
87
86
|
catch {
|
|
88
|
-
fail('invalid_value', `${file} does not exist.`, { path:
|
|
87
|
+
fail('invalid_value', `${file} does not exist.`, { path: flagPath });
|
|
89
88
|
}
|
|
90
89
|
if (st.isSymbolicLink() || !st.isFile())
|
|
91
|
-
fail('insecure_file', `${file} is not a plain file. c11r reads a secret only from a plain file.`, { path:
|
|
90
|
+
fail('insecure_file', `${file} is not a plain file. c11r reads a secret only from a plain file.`, { path: flagPath });
|
|
92
91
|
if (st.mode & 0o077) {
|
|
93
92
|
fail('insecure_file', `${file} can be read by other users (mode ${(st.mode & 0o777).toString(8)}). c11r reads a secret only from a file that only its owner can read, mode 600.`, {
|
|
94
|
-
path:
|
|
93
|
+
path: flagPath,
|
|
95
94
|
next_step: { action: 'human_step', command: null, url: null },
|
|
96
95
|
details: { file, mode: (st.mode & 0o777).toString(8) },
|
|
97
96
|
});
|
|
98
97
|
}
|
|
99
|
-
|
|
98
|
+
return fs.readFileSync(file, 'utf8');
|
|
99
|
+
}
|
|
100
|
+
/** The client id and secret, from a file the human saved: key=value lines, JSON, or the secret alone. */
|
|
101
|
+
export function readSecretFile(file) {
|
|
102
|
+
const text = readPrivateFile(file, 'secret_file');
|
|
100
103
|
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
|
|
101
104
|
try {
|
|
102
105
|
const json = JSON.parse(text);
|
package/dist/resources.js
CHANGED
|
@@ -21,13 +21,14 @@ export function resourceRequest(noun, verb, id, values) {
|
|
|
21
21
|
else if (flag.to === 'local')
|
|
22
22
|
continue;
|
|
23
23
|
else if (flag.to === 'filter')
|
|
24
|
-
query[`filter[${flag.filter.field}][${flag.filter.op}]`] = text(value);
|
|
24
|
+
query[`filter[${flag.filter.field}][${flag.filter.op}]`] = flag.filter.op === 'in' ? inList(value) : text(value);
|
|
25
25
|
else
|
|
26
26
|
query[key] = text(value);
|
|
27
27
|
}
|
|
28
28
|
// The schema's own route when it gives one (/plans/current, /members/invite), else the grammar.
|
|
29
29
|
const path = verb.path
|
|
30
|
-
?
|
|
30
|
+
? // No id (one given by its id_alternative flags): the segment goes, /prices/{id}/tiers is /prices/tiers.
|
|
31
|
+
verb.path.replace(/\/\{[^}]+\}/, id === undefined ? '' : `/${encodeURIComponent(id)}`)
|
|
31
32
|
: ['list', 'get', 'create', 'update', 'delete'].includes(verb.name)
|
|
32
33
|
? verb.name === 'list' || verb.name === 'create'
|
|
33
34
|
? base
|
|
@@ -38,4 +39,7 @@ export function resourceRequest(noun, verb, id, values) {
|
|
|
38
39
|
req.body = body;
|
|
39
40
|
return req;
|
|
40
41
|
}
|
|
42
|
+
// An in list is joined by commas, or sent as a JSON array of strings when a value has a comma of its
|
|
43
|
+
// own ("UK VAT, manual"), so the API keeps each value whole (contract 28.4).
|
|
44
|
+
const inList = (v) => (Array.isArray(v) && v.some((x) => typeof x === 'string' && x.includes(',')) ? JSON.stringify(v.map(String)) : text(v));
|
|
41
45
|
const text = (v) => (Array.isArray(v) ? v.join(',') : typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v));
|
package/dist/schema.js
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
import { closest, fail, fixWith } from './errors.js';
|
|
7
7
|
const FIELD_TYPES = ['string', 'integer', 'number', 'money', 'boolean', 'date', 'datetime', 'enum', 'object', 'array', 'ref'];
|
|
8
8
|
export const FILTER_OPS = ['eq', 'in', 'gt', 'gte', 'lt', 'lte', 'start', 'cont', 'null'];
|
|
9
|
-
|
|
10
|
-
const CREDENTIALS = { none: 'none', organization: 'organization', login: 'login', 'organization|login': 'organization|login', sandbox: 'organization', 'sandbox|login': 'organization|login' };
|
|
9
|
+
const CREDENTIALS = { none: 'none', organization: 'organization', login: 'login', 'organization|login': 'organization|login' };
|
|
11
10
|
const STANDARD = ['list', 'get', 'create', 'update', 'delete'];
|
|
12
11
|
const METHOD = { list: 'GET', get: 'GET', create: 'POST', update: 'PATCH', delete: 'DELETE' };
|
|
13
12
|
export function parseSchema(raw, expected) {
|
|
@@ -39,6 +38,7 @@ export function parseSchema(raw, expected) {
|
|
|
39
38
|
flags[f.name] = flag(f, method === 'GET' ? 'query' : 'body', Boolean(f.required || (name === 'create' && f.create === 'required')));
|
|
40
39
|
for (const f of own(v.query))
|
|
41
40
|
flags[f.name] = flag(f, 'query', Boolean(f.required));
|
|
41
|
+
fileInputs(flags, fields, own(v.body));
|
|
42
42
|
if (v.secret) {
|
|
43
43
|
flags.out = { type: 'string', to: 'local', description: 'The file the secret is written to, mode 600. By default one under ~/.config/c11r/secrets/<org>/.' };
|
|
44
44
|
flags.format = { type: 'enum', to: 'local', values: ['env', 'json'], description: 'env: KEY=value lines, for a .env file; json: one JSON object. By default as the API answers it.' };
|
|
@@ -60,6 +60,7 @@ export function parseSchema(raw, expected) {
|
|
|
60
60
|
plan: v.plan ?? null,
|
|
61
61
|
credential: v.credential ? CREDENTIALS[v.credential] : state === 'none' ? 'none' : 'organization',
|
|
62
62
|
secret: Boolean(v.secret),
|
|
63
|
+
idAlternative: Array.isArray(v.id_alternative) ? v.id_alternative.filter((x) => typeof x === 'string' && flags[x]) : [],
|
|
63
64
|
confirmIf: Array.isArray(v.confirm_if) ? v.confirm_if.filter((x) => typeof x === 'string') : [],
|
|
64
65
|
refused: v.served === false ? { by: v.refused_by ?? 'the API' } : null,
|
|
65
66
|
path: v.path ? v.path.replace(/^\/v\d+(?=\/)/, '') : null,
|
|
@@ -77,6 +78,35 @@ export function parseSchema(raw, expected) {
|
|
|
77
78
|
doc,
|
|
78
79
|
};
|
|
79
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* A secret field gets --<name>-file in place of its own flag: the value never sits in a command
|
|
83
|
+
* line or a shell history. A document field keeps its flag (inline JSON) and gets --file, or
|
|
84
|
+
* --<name>-file when the verb has more than one.
|
|
85
|
+
*/
|
|
86
|
+
function fileInputs(flags, fields, body) {
|
|
87
|
+
const doc = (k) => fields.find((f) => f.name === k) ?? body.find((f) => f.name === k);
|
|
88
|
+
const keys = Object.keys(flags).filter((k) => flags[k].to === 'body' || flags[k].to === 'query');
|
|
89
|
+
for (const k of keys.filter((k) => doc(k)?.secret_input)) {
|
|
90
|
+
const f = flags[k];
|
|
91
|
+
delete flags[k];
|
|
92
|
+
flags[`${k}_file`] = {
|
|
93
|
+
type: 'string',
|
|
94
|
+
to: 'local',
|
|
95
|
+
input: { kind: 'secret', target: k, required: Boolean(f.required), target_type: f.type },
|
|
96
|
+
description: `${f.description ? `${f.description.replace(/\.?$/, '.')} ` : ''}A file with ${k}, as JSON or KEY=value lines, readable only by its owner (mode 600). Never given as a flag value; without the file, c11r asks in a terminal. Never printed.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const docs = keys.filter((k) => flags[k] && doc(k)?.file_input);
|
|
100
|
+
for (const k of docs) {
|
|
101
|
+
flags[docs.length === 1 ? 'file' : `${k}_file`] = {
|
|
102
|
+
type: 'string',
|
|
103
|
+
to: 'local',
|
|
104
|
+
input: { kind: 'document', target: k, required: Boolean(flags[k].required), target_type: flags[k].type },
|
|
105
|
+
description: `A JSON file with ${k}, in place of ${toFlag(k)}.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const toFlag = (key) => `--${key.replace(/_/g, '-')}`;
|
|
80
110
|
function listFlags(flags, fields, doc) {
|
|
81
111
|
for (const f of fields) {
|
|
82
112
|
for (const op of f.filter ?? []) {
|
|
@@ -115,7 +145,7 @@ function flag(f, to, required) {
|
|
|
115
145
|
to,
|
|
116
146
|
...(f.values ? { values: f.values } : {}),
|
|
117
147
|
...(f.items ? { items: f.items } : {}),
|
|
118
|
-
...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, p.type])) } : {}),
|
|
148
|
+
...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, { type: p.type, ...(p.values ? { values: p.values } : {}) }])) } : {}),
|
|
119
149
|
...(f.noun ? { noun: f.noun } : {}),
|
|
120
150
|
...(required ? { required: true } : {}),
|
|
121
151
|
...(f.description ? { description: f.description } : {}),
|
package/dist/secrets.js
CHANGED
|
@@ -63,11 +63,16 @@ function nearestExisting(dir) {
|
|
|
63
63
|
}
|
|
64
64
|
return d;
|
|
65
65
|
}
|
|
66
|
+
const RECORD_LINE = '# c11r-record: ';
|
|
66
67
|
/** Writes data.secret to its file and answers the envelope without the value. */
|
|
67
68
|
export function writeSecret(r, plan, ctx) {
|
|
68
69
|
const data = (r.data ?? {});
|
|
69
70
|
const secret = data.secret;
|
|
70
|
-
|
|
71
|
+
// credentials answer { credential: {...}, secret }, other nouns the record itself and secret.
|
|
72
|
+
const rec = (data.credential && typeof data.credential === 'object' ? data.credential : data);
|
|
73
|
+
const str = (v) => (typeof v === 'string' || typeof v === 'number' ? String(v) : null);
|
|
74
|
+
const credentialId = str(rec.id) ?? ctx.id;
|
|
75
|
+
const record = { noun: ctx.noun, id: credentialId ?? null, name: str(rec.name), code: str(rec.code) };
|
|
71
76
|
const format = plan.format ?? (secret.format === 'json' ? 'json' : 'env');
|
|
72
77
|
const file = plan.fixed || plan.format ? plan.file : `${plan.file}.${format}`;
|
|
73
78
|
const env = secret.env && Object.keys(secret.env).length ? { ...secret.env } : { [`CL_${(secret.kind ?? 'secret').toUpperCase()}`]: String(secret.value ?? '') };
|
|
@@ -76,8 +81,14 @@ export function writeSecret(r, plan, ctx) {
|
|
|
76
81
|
env.CL_BASE_ENDPOINT = data.base_endpoint;
|
|
77
82
|
const written = new Date().toISOString();
|
|
78
83
|
const content = format === 'json'
|
|
79
|
-
? JSON.stringify({ org: ctx.org,
|
|
80
|
-
: [
|
|
84
|
+
? JSON.stringify({ org: ctx.org, record, kind: secret.kind ?? null, value: secret.value ?? null, expires_at: secret.expires_at ?? null, env, written_at: written }, null, 2) + '\n'
|
|
85
|
+
: [
|
|
86
|
+
`# ${secret.kind ?? 'secret'} of ${ctx.noun} ${credentialId ?? '?'} in ${ctx.org ?? '?'}, written by c11r at ${written}.`,
|
|
87
|
+
`${RECORD_LINE}${JSON.stringify({ org: ctx.org, ...record })}`,
|
|
88
|
+
'# Keep it out of git.',
|
|
89
|
+
...Object.entries(env).map(([k, v]) => `${k}=${v}`),
|
|
90
|
+
'',
|
|
91
|
+
].join('\n');
|
|
81
92
|
try {
|
|
82
93
|
atomicWrite(file, content, plan.overwrite);
|
|
83
94
|
}
|
|
@@ -128,13 +139,25 @@ function ignoredByGit(file) {
|
|
|
128
139
|
}
|
|
129
140
|
/** The folder of an organization's secret files. */
|
|
130
141
|
export const orgSecretsDir = (org) => path.join(secretsDir(), safe(org));
|
|
142
|
+
/** The record a file in the secrets folder belongs to, or null when c11r did not write it. */
|
|
143
|
+
function recordOf(text) {
|
|
144
|
+
const line = text.split('\n').find((l) => l.startsWith(RECORD_LINE));
|
|
145
|
+
try {
|
|
146
|
+
const v = line ? JSON.parse(line.slice(RECORD_LINE.length)) : JSON.parse(text).record;
|
|
147
|
+
return v && typeof v === 'object' && typeof v.noun === 'string' ? v : null;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
131
153
|
/**
|
|
132
|
-
* After
|
|
133
|
-
* credential
|
|
134
|
-
* written with --out elsewhere are not tracked.
|
|
154
|
+
* After a delete: the files c11r wrote in the organization's secrets folder for that record (a
|
|
155
|
+
* credential's secret and tokens, a webhook's shared secret), found by the noun and the id, name
|
|
156
|
+
* or code each file records. Files written with --out elsewhere are not tracked.
|
|
135
157
|
*/
|
|
136
|
-
export function
|
|
158
|
+
export function removeRecordFiles(org, noun, keys) {
|
|
137
159
|
const dir = orgSecretsDir(org);
|
|
160
|
+
const wanted = new Set(keys.filter((k) => typeof k === 'string' && k !== ''));
|
|
138
161
|
let names;
|
|
139
162
|
try {
|
|
140
163
|
names = fs.readdirSync(dir);
|
|
@@ -154,26 +177,18 @@ export function removeCredentialFiles(org, credentialId) {
|
|
|
154
177
|
catch {
|
|
155
178
|
continue;
|
|
156
179
|
}
|
|
157
|
-
|
|
158
|
-
if (!
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
catch {
|
|
163
|
-
// Not JSON, not ours.
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
if (mine) {
|
|
167
|
-
fs.rmSync(file);
|
|
168
|
-
removed.push(file);
|
|
169
|
-
}
|
|
180
|
+
const rec = recordOf(text);
|
|
181
|
+
if (!rec || rec.noun !== noun || ![rec.id, rec.name, rec.code].some((k) => k !== null && wanted.has(k)))
|
|
182
|
+
continue;
|
|
183
|
+
fs.rmSync(file);
|
|
184
|
+
removed.push(file);
|
|
170
185
|
}
|
|
171
186
|
if (removed.length) {
|
|
172
187
|
try {
|
|
173
188
|
fs.rmdirSync(dir);
|
|
174
189
|
}
|
|
175
190
|
catch {
|
|
176
|
-
// Not empty: other
|
|
191
|
+
// Not empty: other records' files stay.
|
|
177
192
|
}
|
|
178
193
|
}
|
|
179
194
|
return removed;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "1.0.0-dev.
|
|
3
|
+
"version": "1.0.0-dev.2",
|
|
4
4
|
"description": "c11r: the Commerce Layer CLI for coding agents. One grammar, c11r <noun> <verb>, from a sandbox to a running organization.",
|
|
5
5
|
"homepage": "https://commercelayer.ai",
|
|
6
6
|
"keywords": [
|