@commercelayer/agent-cli 1.0.0-dev.1 → 1.0.0-dev.10
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 +17 -6
- package/dist/argv.js +33 -6
- package/dist/builtins/extensions.js +152 -1
- package/dist/builtins/loops.js +50 -21
- package/dist/builtins/sandbox.js +5 -7
- package/dist/config.js +24 -7
- package/dist/engine.js +56 -13
- package/dist/help.js +7 -4
- package/dist/http.js +10 -0
- package/dist/project.js +175 -0
- package/dist/resources.js +4 -1
- package/dist/schema.js +8 -2
- package/dist/schemas.js +82 -8
- package/dist/secrets.js +25 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,7 +14,8 @@ 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
|
|
17
|
+
- A ref flag, or one the schema marks `nullable`, given `null` or an empty value sends `null` and takes it off: `c11r customers update ada@example.com --customer-group null` sends `customer_group: null`, `c11r orders update 1001 --coupon-code null` sends `coupon_code: null`. A ref the schema marks `nullable: false`, and any other field, sends the text.
|
|
18
|
+
- 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
19
|
- Amounts are in major units: `--amount 12.50`.
|
|
19
20
|
- 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
21
|
- 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.
|
|
@@ -28,13 +29,13 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
|
|
|
28
29
|
| `c11r schema <noun>` | The noun's schema document: fields, filters, verbs, which verbs are destructive and which a loop may automate. |
|
|
29
30
|
| `c11r sandbox create [--name] [--seed]` | An anonymous sandbox. Its token is stored on this machine and never printed; the folder is linked to it. |
|
|
30
31
|
| `c11r sandbox get`, `reset`, `claim`, `destroy` | The sandbox's details; a fresh organization behind the same token; a claim URL for the human; the end of the sandbox. |
|
|
31
|
-
| `c11r loops create <name> --skill --every --allow [--max-total] [--market]` | A loop in `loops.yml`: a skill, a policy and a cadence. Also `list`, `get`, `update`, `delete`. |
|
|
32
|
+
| `c11r loops create <name> --skill --every [--allow] [--max-total] [--market]` | A loop in `loops.yml`: a skill, a policy and a cadence; with no `--allow` it only reports what is waiting. Also `list`, `get`, `update`, `delete`. |
|
|
32
33
|
| `c11r loops run <name>` | One pass of a loop. The CLI is not a daemon: a scheduler repeats the pass. |
|
|
33
34
|
| `c11r report sales [--from] [--to] [--by] [--interval] [--market] [--top]` | Sales in a window, from the Metrics API. A plain noun: its flags come from the API's schema. |
|
|
34
35
|
| `c11r auth login [--client-id] [--secret-file] [--keep-file]` | The human's own Commerce Layer login, from their Provisioning API application. See below. |
|
|
35
36
|
| `c11r auth logout`, `auth switch <slug> [--global]` | Forgetting the login; the organization commands act on. |
|
|
36
37
|
|
|
37
|
-
A loop policy allows verbs: a bare verb is the skill's own noun (`approve`), `<noun>.<verb>` another Manage noun's verb on the same item (`payments.capture`). Verbs that delete or return money are never open to a policy.
|
|
38
|
+
A loop runs a skill a Manage or Report noun's schema names in `loop.skill`, or in `loop.skills` for a noun with several (`manage-orders`, `manage-stock`, `report-sales`, `report-pending`, ...); a report loop allows no verb and only reports. create and update refuse any other skill, and any verb that loop does not list, with the allowed values. A loop policy allows verbs: a bare verb is the skill's own noun (`approve`), `<noun>.<verb>` another Manage noun's verb on the same item (`payments.capture`). Verbs that delete or return money are never open to a policy.
|
|
38
39
|
|
|
39
40
|
## Login, organization tokens and secrets
|
|
40
41
|
|
|
@@ -50,7 +51,7 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
|
|
|
50
51
|
|
|
51
52
|
`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`.
|
|
52
53
|
|
|
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
|
|
54
|
+
`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; a record with no name, such as a gift card, `gift-card-<id>.env` after the id it is given), 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 on the delete of the record whose secret went to `--out` on this machine, and on `sandbox destroy` of an organization that had one. Any other delete has no such warning. 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.
|
|
54
55
|
|
|
55
56
|
## Global flags
|
|
56
57
|
|
|
@@ -63,7 +64,7 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
|
|
|
63
64
|
| `--live` | Live data, on a claimed organization: `?mode=live` on every request. Without it, test mode. |
|
|
64
65
|
| `--help` | Describes the noun or verb it follows, from its schema. |
|
|
65
66
|
|
|
66
|
-
Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sandbox reset` and `destroy`) ask for confirmation in a terminal, then send `confirm=true`. With no terminal, as when an agent or a schedule runs the CLI, they never wait: without `--yes` they stop at once with `confirmation_required` and exit code 3.
|
|
67
|
+
Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sandbox reset` and `destroy`), and any verb the schema marks `confirm`, ask for confirmation in a terminal, then send `confirm=true`. With no terminal, as when an agent or a schedule runs the CLI, they never wait: without `--yes` they stop at once with `confirmation_required` and exit code 3.
|
|
67
68
|
|
|
68
69
|
## Which organization
|
|
69
70
|
|
|
@@ -73,6 +74,16 @@ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sand
|
|
|
73
74
|
- `.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.
|
|
74
75
|
- 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.
|
|
75
76
|
|
|
77
|
+
## The store's setup in files
|
|
78
|
+
|
|
79
|
+
`c11r config export` writes the organization's setup into the project folder: `store.yml` (the Configure nouns, refs by key, no ids, no secrets; its first key, `meta`, names the organization and the time) and `price_lists/<code>.csv`. It records what it wrote in `.c11r/export.json`, and never overwrites a file changed since the last export (or one no export wrote) without `--yes`: the answer lists them. A price list file the organization no longer has, unchanged since, goes. `c11r config plan` sends the files and answers what `config apply` would change; `apply` makes the organization match them, and with `--prune` (confirmed) also deletes what the files lack. While imports run, `apply` answers `status: running` with each noun's state and `retry_after`; `--wait` applies again until done or failed.
|
|
80
|
+
|
|
81
|
+
Records that share a key (two shipping methods with the same name in one market, say) can't be matched to the files. `export` and `plan` list them in `ambiguous` (an empty list when there are none), each with the command that makes its key differ, and `export` puts the first one at the top of `next`. `apply` refuses with `conflict` and the same list in `error.details.ambiguous`, before it writes anything.
|
|
82
|
+
|
|
83
|
+
After a Configure write that went through (create, update, delete or a domain verb), in a folder whose `store.yml` names the same organization, the answer warns `config_drift` and `.c11r/drift` notes the write; `config plan` repeats the warning, and `config export` clears it.
|
|
84
|
+
|
|
85
|
+
`c11r exports get <id> --out <file>` downloads a completed export while its link lasts (5 minutes), unzipped unless the name ends in `.gz`; `--wait` reads the job again until it completes.
|
|
86
|
+
|
|
76
87
|
## Output, errors and exit codes
|
|
77
88
|
|
|
78
89
|
Every answer is one JSON object on stdout, the API's envelope:
|
|
@@ -124,6 +135,6 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
|
|
|
124
135
|
| `C11R_PROVISIONING_TOKEN` | A ready login token, used as is: for CI. Never a client secret. |
|
|
125
136
|
| `C11R_AUTH_URL` | Commerce Layer's token endpoint, `https://auth.commercelayer.io/oauth/token` by default: for tests. |
|
|
126
137
|
| `C11R_TEST` | `1` marks a sandbox as a test organization, like a name starting `zztest`. |
|
|
127
|
-
| `C11R_SCHEMA_CACHE` | `0` turns off the 10 minute schema cache in `~/.cache/c11r`. |
|
|
138
|
+
| `C11R_SCHEMA_CACHE` | `0` turns off the 10 minute schema cache in `~/.cache/c11r`. The cache records the `C11R-Schema` hash it was read under; an answer with another hash drops it, and a command the API refused on the old schema runs once more on the fresh one (without the header, after `confirmation_required`, `unknown_field` or `unknown_verb`). The second run asks what the fresh schema asks: it never adds `confirm=true` by itself. |
|
|
128
139
|
| `C11R_RETRY_CAP_S` | The longest the CLI waits before one retry, 30 by default. |
|
|
129
140
|
| `C11R_TIMEOUT_MS` | How long a request may take, 30000 by default (sandbox create waits longer). |
|
package/dist/argv.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// verb's flags, which say whether a flag takes a value, what type it is and where it goes.
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import { closest, fail, fixWith } from './errors.js';
|
|
6
|
+
import { clearable } from './schema.js';
|
|
6
7
|
const BOOLEAN_GLOBALS = {
|
|
7
8
|
'--json': 'json',
|
|
8
9
|
'--dry-run': 'dryRun',
|
|
@@ -60,7 +61,11 @@ export function parseFields(noun, verb, rest) {
|
|
|
60
61
|
id = a;
|
|
61
62
|
continue;
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
+
// A verb with no id (stock set): say which flags it does take, as --sku for the sku.
|
|
65
|
+
const sent = Object.entries(verb.flags).filter(([, f]) => f.to !== 'local');
|
|
66
|
+
const required = sent.filter(([, f]) => f.required);
|
|
67
|
+
const takes = (required.length ? required : sent).map(([k]) => toFlag(k));
|
|
68
|
+
fail('unexpected_argument', `${cmd} does not take ${a}${verb.id ? `: it already has the id ${id}` : takes.length ? `: it takes no id, only flags (${takes.join(', ')})` : ': it takes no id, only flags'}. A value goes after its flag.`, { next_step: fixWith(`${cmd} --help`) });
|
|
64
69
|
}
|
|
65
70
|
const [flag, inline] = splitInline(a);
|
|
66
71
|
const key = toKey(flag);
|
|
@@ -104,7 +109,7 @@ export function parseFields(noun, verb, rest) {
|
|
|
104
109
|
for (const k of sub.slice(0, -1))
|
|
105
110
|
target = (target[k] ??= {});
|
|
106
111
|
const part = sub.join('.');
|
|
107
|
-
target[sub[sub.length - 1]] = part in (spec.properties ?? {}) ? coerce(key,
|
|
112
|
+
target[sub[sub.length - 1]] = part in (spec.properties ?? {}) ? coerce(key, spec.properties[part], raw) : raw;
|
|
108
113
|
continue;
|
|
109
114
|
}
|
|
110
115
|
const value = coerce(key, spec, raw);
|
|
@@ -122,6 +127,13 @@ function secretAsValue(cmd, key) {
|
|
|
122
127
|
/** A value as its flag's type says, or invalid_value with the reason. */
|
|
123
128
|
export function coerce(key, spec, raw) {
|
|
124
129
|
const invalid = (why) => fail('invalid_value', `${toFlag(key)} ${JSON.stringify(raw)} ${why}.`, { path: key, ...(spec.values ? { details: { values: spec.values } } : {}) });
|
|
130
|
+
// The schema's fault, not the command's: said so before any value is checked against nothing.
|
|
131
|
+
if ((spec.type === 'enum' || (spec.type === 'array' && spec.items === 'enum')) && !spec.values?.length) {
|
|
132
|
+
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' } });
|
|
133
|
+
}
|
|
134
|
+
// null, or an empty value, clears what the schema lets null clear (--customer-group null, --coupon-code "").
|
|
135
|
+
if (clearable(spec) && (raw === 'null' || raw.trim() === ''))
|
|
136
|
+
return null;
|
|
125
137
|
switch (spec.type) {
|
|
126
138
|
case 'string':
|
|
127
139
|
case 'ref':
|
|
@@ -156,7 +168,7 @@ export function coerce(key, spec, raw) {
|
|
|
156
168
|
const v = raw.trim().startsWith('{') ? json(raw, invalid) : pairs(key, raw, spec.properties ?? {}, invalid);
|
|
157
169
|
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
158
170
|
invalid('is not a JSON object');
|
|
159
|
-
return v;
|
|
171
|
+
return enumParts(key, spec.properties, v);
|
|
160
172
|
}
|
|
161
173
|
case 'array': {
|
|
162
174
|
const item = spec.items ?? 'string';
|
|
@@ -164,11 +176,15 @@ export function coerce(key, spec, raw) {
|
|
|
164
176
|
const list = json(raw, invalid);
|
|
165
177
|
if (!Array.isArray(list))
|
|
166
178
|
invalid('is not a list');
|
|
167
|
-
return list.map((x) => (typeof x === 'string' && item !== 'string' ? coerce(key, { type: item, values: spec.values }, x) : x));
|
|
179
|
+
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));
|
|
168
180
|
}
|
|
169
181
|
// One object per flag (--items sku=A,quantity=2, repeated), or scalars separated by commas.
|
|
170
182
|
if (item === 'object')
|
|
171
183
|
return [coerce(key, { type: 'object', properties: spec.properties }, raw)];
|
|
184
|
+
// An in filter on names or text takes the value whole: a name may have a comma ("UK VAT, manual").
|
|
185
|
+
// Several are the flag repeated, or a JSON array. Enums, numbers and dates still split on commas.
|
|
186
|
+
if (spec.filter?.op === 'in' && (item === 'string' || item === 'ref'))
|
|
187
|
+
return [raw];
|
|
172
188
|
return raw
|
|
173
189
|
.split(',')
|
|
174
190
|
.map((s) => s.trim())
|
|
@@ -177,6 +193,17 @@ export function coerce(key, spec, raw) {
|
|
|
177
193
|
}
|
|
178
194
|
}
|
|
179
195
|
}
|
|
196
|
+
/** In JSON too, an enum part of an object is one of its values. */
|
|
197
|
+
function enumParts(key, props, v) {
|
|
198
|
+
if (!v || typeof v !== 'object')
|
|
199
|
+
return v;
|
|
200
|
+
for (const [name, p] of Object.entries(props ?? {})) {
|
|
201
|
+
const part = v[name];
|
|
202
|
+
if (p.type === 'enum' && typeof part === 'string')
|
|
203
|
+
coerce(`${key}.${name}`, p, part);
|
|
204
|
+
}
|
|
205
|
+
return v;
|
|
206
|
+
}
|
|
180
207
|
function json(raw, invalid) {
|
|
181
208
|
try {
|
|
182
209
|
return JSON.parse(raw);
|
|
@@ -187,7 +214,7 @@ function json(raw, invalid) {
|
|
|
187
214
|
}
|
|
188
215
|
/**
|
|
189
216
|
* sku=A,quantity=2 as { sku: 'A', quantity: 2 }, each part typed by the schema's properties.
|
|
190
|
-
* A part the schema does not type stays text: a
|
|
217
|
+
* A part the schema does not type stays text: a zip code or a phone is never made a number.
|
|
191
218
|
*/
|
|
192
219
|
function pairs(key, raw, props, invalid) {
|
|
193
220
|
const out = {};
|
|
@@ -197,7 +224,7 @@ function pairs(key, raw, props, invalid) {
|
|
|
197
224
|
invalid('is not JSON nor key=value pairs separated by commas');
|
|
198
225
|
const name = part.slice(0, eq).trim();
|
|
199
226
|
const value = part.slice(eq + 1).trim();
|
|
200
|
-
out[name] = props[name] ? coerce(`${key}.${name}`,
|
|
227
|
+
out[name] = props[name] ? coerce(`${key}.${name}`, props[name], value) : value;
|
|
201
228
|
}
|
|
202
229
|
return out;
|
|
203
230
|
}
|
|
@@ -8,10 +8,14 @@
|
|
|
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 fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import zlib from 'node:zlib';
|
|
11
14
|
import { credentialsFile, hintOf, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
|
|
12
15
|
import { confirm, terminal } from '../confirm.js';
|
|
13
16
|
import { fail } from '../errors.js';
|
|
14
17
|
import { apiUrl } from '../http.js';
|
|
18
|
+
import { clearDrift, driftWarning, readDrift, readStoreFiles, storeFileOrg, writeStoreFiles } from '../project.js';
|
|
15
19
|
import { resourceRequest } from '../resources.js';
|
|
16
20
|
const NAME_MAX = 25;
|
|
17
21
|
export const organizations = {
|
|
@@ -104,4 +108,151 @@ export const salesCalls = {
|
|
|
104
108
|
return undefined;
|
|
105
109
|
},
|
|
106
110
|
};
|
|
107
|
-
|
|
111
|
+
// config (contract section 45.2): the API answers and applies documents; the files are the CLI's.
|
|
112
|
+
// export writes store.yml and price_lists/<code>.csv, never over a file changed since the last
|
|
113
|
+
// export without --yes, and clears the drift note.
|
|
114
|
+
// plan sends the folder's files and says what apply would change, and since when the
|
|
115
|
+
// folder is behind when Configure writes were made here.
|
|
116
|
+
// apply sends them; while imports run (202, running) it shows each noun's state, and with
|
|
117
|
+
// --wait applies again until done or failed. prune is confirmed by the schema.
|
|
118
|
+
const WAIT_MAX_MS = 15 * 60_000;
|
|
119
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
120
|
+
const waitMs = (data) => {
|
|
121
|
+
const s = Number(data?.retry_after);
|
|
122
|
+
const test = process.env.C11R_WAIT_MS;
|
|
123
|
+
if (test !== undefined)
|
|
124
|
+
return Number(test);
|
|
125
|
+
return (Number.isFinite(s) && s > 0 ? Math.min(s, 60) : 5) * 1000;
|
|
126
|
+
};
|
|
127
|
+
export const config = {
|
|
128
|
+
flags: {
|
|
129
|
+
apply: { wait: { type: 'boolean', to: 'local', description: 'While imports run, apply again every retry_after seconds until done or failed (15 minutes at most).' } },
|
|
130
|
+
},
|
|
131
|
+
prepare(verb, parsed) {
|
|
132
|
+
if (verb.name !== 'plan' && verb.name !== 'apply')
|
|
133
|
+
return;
|
|
134
|
+
if (parsed.values.store !== undefined)
|
|
135
|
+
return;
|
|
136
|
+
const files = readStoreFiles();
|
|
137
|
+
parsed.values.store = files.store;
|
|
138
|
+
parsed.values.price_lists = files.price_lists;
|
|
139
|
+
},
|
|
140
|
+
async run(run) {
|
|
141
|
+
const name = run.verb.name;
|
|
142
|
+
if (name === 'export') {
|
|
143
|
+
const r = await run.send(resourceRequest('config', run.verb, undefined, run.parsed.values));
|
|
144
|
+
if (run.globals.dryRun)
|
|
145
|
+
return r;
|
|
146
|
+
const data = r.data ?? {};
|
|
147
|
+
if (!data.store || typeof data.store !== 'object')
|
|
148
|
+
fail('invalid_response', 'config export answered without store.', { next_step: { action: 'none' } });
|
|
149
|
+
const out = writeStoreFiles({ store: data.store, price_lists: data.price_lists ?? {}, meta: { org: data.meta?.org ?? run.org.org ?? undefined, exported_at: data.meta?.exported_at } }, { overwrite: run.globals.yes });
|
|
150
|
+
const ambiguous = Array.isArray(data.ambiguous) ? data.ambiguous : [];
|
|
151
|
+
const counts = Object.fromEntries(Object.entries(data.store).map(([k, v]) => [k, Array.isArray(v) ? v.length : 0]));
|
|
152
|
+
return {
|
|
153
|
+
...r,
|
|
154
|
+
data: {
|
|
155
|
+
org: data.meta?.org ?? run.org.org,
|
|
156
|
+
exported_at: data.meta?.exported_at ?? null,
|
|
157
|
+
folder: out.dir,
|
|
158
|
+
files: out.written,
|
|
159
|
+
removed: out.removed,
|
|
160
|
+
records: counts,
|
|
161
|
+
price_lists: Object.keys(data.price_lists ?? {}),
|
|
162
|
+
// Keys several records share (45.9): apply refuses them until they differ.
|
|
163
|
+
ambiguous,
|
|
164
|
+
},
|
|
165
|
+
next: [
|
|
166
|
+
...(ambiguous[0]?.command ? [{ command: String(ambiguous[0].command), why: 'records share a key: plan and apply cannot match them until it differs' }] : []),
|
|
167
|
+
{ command: 'c11r config plan', why: 'what the files would change in an organization, after you edit them or for another one (--org)' },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
if (name === 'plan' || name === 'apply') {
|
|
172
|
+
const req = resourceRequest('config', run.verb, undefined, run.parsed.values);
|
|
173
|
+
let r = await run.send(req);
|
|
174
|
+
const started = Date.now();
|
|
175
|
+
while (name === 'apply' && run.parsed.values.wait && r.data?.status === 'running' && Date.now() - started < WAIT_MAX_MS) {
|
|
176
|
+
const ms = waitMs(r.data);
|
|
177
|
+
process.stderr.write(JSON.stringify({ running: { nouns: states(r.data), next_in_ms: ms } }) + '\n');
|
|
178
|
+
await sleep(ms);
|
|
179
|
+
r = await run.send(req);
|
|
180
|
+
}
|
|
181
|
+
const warnings = [...r.warnings];
|
|
182
|
+
const here = storeFileOrg();
|
|
183
|
+
const drift = readDrift();
|
|
184
|
+
if (name === 'plan' && drift.length && here === run.org.org)
|
|
185
|
+
warnings.push(driftWarning(drift));
|
|
186
|
+
// The organization now matches the files: the folder is no longer behind.
|
|
187
|
+
if (name === 'apply' && !run.globals.dryRun && r.data?.status === 'done' && here === run.org.org)
|
|
188
|
+
clearDrift();
|
|
189
|
+
const data = r.data ?? {};
|
|
190
|
+
const running = name === 'apply' && data.status === 'running';
|
|
191
|
+
return {
|
|
192
|
+
...r,
|
|
193
|
+
data: running ? { ...data, retry_after: waitMs(data) / 1000, states: states(data) } : data,
|
|
194
|
+
warnings,
|
|
195
|
+
next: running && !run.parsed.values.wait ? [{ command: `${run.line} --wait`, why: 'carry on, and wait until every noun is done or failed' }, ...r.next] : r.next,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return undefined;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
/** Each noun's state while apply runs: done, running (with its import), waiting, failed or unchanged. */
|
|
202
|
+
function states(data) {
|
|
203
|
+
const out = {};
|
|
204
|
+
for (const n of Array.isArray(data?.nouns) ? data.nouns : [])
|
|
205
|
+
out[n.noun] = n.import?.id && n.status === 'running' ? `running (import ${n.import.id})` : String(n.status);
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
// exports (45.3): get --out downloads the file while its link is good (5 minutes), unzipped unless
|
|
209
|
+
// the name ends in .gz; --wait polls a job not completed yet.
|
|
210
|
+
export const exports = {
|
|
211
|
+
flags: {
|
|
212
|
+
get: {
|
|
213
|
+
out: { type: 'string', to: 'local', description: 'Download the export to this file once completed (its link lasts 5 minutes). Unzipped, unless the name ends in .gz.' },
|
|
214
|
+
wait: { type: 'boolean', to: 'local', description: 'Until the export is completed, failed or interrupted, read it again every few seconds (15 minutes at most).' },
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
async run(run) {
|
|
218
|
+
if (run.verb.name !== 'get' || (!run.parsed.values.out && !run.parsed.values.wait))
|
|
219
|
+
return undefined;
|
|
220
|
+
const req = resourceRequest('exports', run.verb, run.parsed.id, run.parsed.values);
|
|
221
|
+
let r = await run.send(req);
|
|
222
|
+
const started = Date.now();
|
|
223
|
+
const busy = (s) => s === 'pending' || s === 'in_progress';
|
|
224
|
+
while (run.parsed.values.wait && busy(r.data?.status) && Date.now() - started < WAIT_MAX_MS) {
|
|
225
|
+
const ms = waitMs(null);
|
|
226
|
+
process.stderr.write(JSON.stringify({ waiting: { export: run.parsed.id, status: r.data?.status, next_in_ms: ms } }) + '\n');
|
|
227
|
+
await sleep(ms);
|
|
228
|
+
r = await run.send(req);
|
|
229
|
+
}
|
|
230
|
+
const out = run.parsed.values.out;
|
|
231
|
+
if (!out)
|
|
232
|
+
return r;
|
|
233
|
+
const data = r.data ?? {};
|
|
234
|
+
if (data.status !== 'completed' || typeof data.url !== 'string') {
|
|
235
|
+
fail('conflict', `Export ${run.parsed.id} is ${data.status ?? 'not completed'}: there is no file to download yet.`, { path: 'out', details: { status: data.status ?? null }, next_step: { action: 'fix_request', command: `${run.line} --wait` } }, 'rejected');
|
|
236
|
+
}
|
|
237
|
+
const file = path.resolve(out);
|
|
238
|
+
let res;
|
|
239
|
+
try {
|
|
240
|
+
res = await fetch(data.url, { signal: AbortSignal.timeout(120_000) });
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
fail('api_unreachable', `The export's file could not be downloaded: ${e.message}.`, { path: 'out', next_step: { action: 'fix_request', command: run.line } });
|
|
244
|
+
}
|
|
245
|
+
// An expired link: get the export again for a new one.
|
|
246
|
+
if (!res.ok)
|
|
247
|
+
fail('expired', `The export's link answered ${res.status}: it lasts 5 minutes. Get the export again for a new one.`, { path: 'out', next_step: { action: 'fix_request', command: run.line } }, 'rejected');
|
|
248
|
+
let bytes = Buffer.from(await res.arrayBuffer());
|
|
249
|
+
const zipped = bytes[0] === 0x1f && bytes[1] === 0x8b;
|
|
250
|
+
if (zipped && !file.endsWith('.gz'))
|
|
251
|
+
bytes = zlib.gunzipSync(bytes);
|
|
252
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
253
|
+
fs.writeFileSync(file, bytes);
|
|
254
|
+
const { url: _url, ...rest } = data;
|
|
255
|
+
return { ...r, data: { ...rest, file, bytes: bytes.length, unzipped: zipped && !file.endsWith('.gz') } };
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
export const extensions = { organizations, 'sales-calls': salesCalls, config, exports };
|
package/dist/builtins/loops.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// schedules the agent repeats it every `every`.
|
|
5
5
|
//
|
|
6
6
|
// A policy allows verbs: a bare verb is the skill's own noun (approve), <noun>.<verb> another
|
|
7
|
-
// Manage noun's verb on the same item (payments.capture).
|
|
8
|
-
// loop.verbs; the API checks
|
|
7
|
+
// Manage noun's verb on the same item (payments.capture). The skill must be one a Manage or Report
|
|
8
|
+
// noun's schema names in loop.skill or loop.skills, and each verb in that loop.verbs; the API checks
|
|
9
|
+
// again on every pass.
|
|
9
10
|
import fs from 'node:fs';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import YAML from 'yaml';
|
|
@@ -13,13 +14,13 @@ import { projectDir } from '../config.js';
|
|
|
13
14
|
import { CliError, closest, fail, fixWith } from '../errors.js';
|
|
14
15
|
import { envelope } from '../http.js';
|
|
15
16
|
import { parseSchema } from '../schema.js';
|
|
16
|
-
import { apiSchema } from '../schemas.js';
|
|
17
|
+
import { apiNouns, apiSchema } from '../schemas.js';
|
|
17
18
|
const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
18
19
|
const EVERY = /^[1-9]\d*(m|h|d)$/;
|
|
19
20
|
const policyFlags = (required) => [
|
|
20
|
-
{ name: 'skill', type: 'string', required, description: 'The
|
|
21
|
+
{ name: 'skill', type: 'string', required, description: 'The skill the loop runs, as in manage-orders or report-sales: one a noun\'s schema names in loop.skill or loop.skills.' },
|
|
21
22
|
{ name: 'every', type: 'string', required, description: 'How often a pass should run, as in 15m, 2h or 1d. The scheduler reads it; the CLI does not wait.' },
|
|
22
|
-
{ name: 'allow', type: 'array',
|
|
23
|
+
{ name: 'allow', type: 'array', description: 'A verb the policy allows, repeated: approve, payments.capture. Only verbs the noun\'s schema lists in loop.verbs. Without it the loop only reports what is waiting.' },
|
|
23
24
|
{ name: 'max_total', type: 'array', description: 'The largest order total acted on, in major units: 500 for every currency, or EUR=500, repeated per currency. Above it, the item waits for the human.' },
|
|
24
25
|
{ name: 'market', type: 'array', description: 'A market code the loop acts in, repeated. Items in other markets wait. Without it, every market.' },
|
|
25
26
|
];
|
|
@@ -77,9 +78,11 @@ export function readLoops(file) {
|
|
|
77
78
|
const out = {};
|
|
78
79
|
for (const [name, d] of Object.entries(raw.loops ?? {})) {
|
|
79
80
|
const def = d;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
// allow may be left out, or empty: the loop only reports.
|
|
82
|
+
const allow = def?.policy?.allow ?? [];
|
|
83
|
+
if (!def || typeof def.skill !== 'string' || !Array.isArray(allow))
|
|
84
|
+
fail('invalid_config', `${file}: loop ${name} needs a skill, and its policy.allow is a list of verbs.`, { path: `loops.${name}` });
|
|
85
|
+
out[name] = { ...def, policy: { ...(def.policy ?? {}), allow } };
|
|
83
86
|
}
|
|
84
87
|
return out;
|
|
85
88
|
}
|
|
@@ -118,7 +121,7 @@ async function write(run, name, file) {
|
|
|
118
121
|
skill: v.skill ?? existing.skill,
|
|
119
122
|
every: v.every ?? existing.every,
|
|
120
123
|
policy: {
|
|
121
|
-
allow: v.allow ?? existing
|
|
124
|
+
allow: v.allow ?? existing?.policy.allow ?? [],
|
|
122
125
|
...(v.max_total !== undefined ? { max_total: maxTotal(v.max_total) } : existing?.policy.max_total !== undefined ? { max_total: existing.policy.max_total } : {}),
|
|
123
126
|
...(v.market !== undefined ? { markets: v.market } : existing?.policy.markets ? { markets: existing.policy.markets } : {}),
|
|
124
127
|
},
|
|
@@ -166,24 +169,50 @@ function maxTotal(items) {
|
|
|
166
169
|
return [ccy, amount(n)];
|
|
167
170
|
}));
|
|
168
171
|
}
|
|
169
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* The skill and the policy's verbs against the same source loops run uses: each noun schema's loop,
|
|
174
|
+
* { skill, verbs }. A skill no schema's loop names is refused with the ones that are, and so is a
|
|
175
|
+
* verb its loop does not list.
|
|
176
|
+
*/
|
|
170
177
|
async function checkPolicy(def) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (!
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
const skills = await loopSkills();
|
|
179
|
+
const allowed = skills.get(def.skill);
|
|
180
|
+
if (!allowed) {
|
|
181
|
+
const names = [...skills.keys()].sort();
|
|
182
|
+
fail('invalid_value', `--skill ${def.skill} runs no loop. A loop runs ${names.length ? names.join(', ') : 'no skill yet'}.`, {
|
|
183
|
+
path: 'skill',
|
|
184
|
+
did_you_mean: closest(def.skill, names),
|
|
185
|
+
details: { values: names },
|
|
186
|
+
next_step: fixWith('c11r loops create --help'),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
180
189
|
for (const verb of def.policy.allow) {
|
|
181
|
-
if (!allowed.includes(verb)) {
|
|
182
|
-
fail('policy_forbidden', `A ${def.skill} loop may not allow ${verb}. It may allow ${allowed.join(', ')}
|
|
190
|
+
if (!allowed.verbs.includes(verb)) {
|
|
191
|
+
fail('policy_forbidden', `A ${def.skill} loop may not allow ${verb}. ${allowed.verbs.length ? `It may allow ${allowed.verbs.join(', ')}.` : 'It allows no verb: it only reports what is waiting, so it takes no --allow.'}`, { path: 'allow', did_you_mean: closest(verb, allowed.verbs), details: { loopable: allowed.verbs }, next_step: fixWith(`c11r schema ${allowed.noun}`) });
|
|
183
192
|
}
|
|
184
193
|
}
|
|
185
194
|
return [];
|
|
186
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* The skills a loop can run, from the schemas of the nouns that have a loop (Manage and Report):
|
|
198
|
+
* each skill to its noun and the verbs its policy may allow. loop.skills, when given, lists every
|
|
199
|
+
* skill of the noun's loop (a report loop per report); else loop.skill is the one.
|
|
200
|
+
*/
|
|
201
|
+
async function loopSkills() {
|
|
202
|
+
const nouns = (await apiNouns()).filter((n) => (n.group === 'manage' || n.group === 'report') && n.served !== false);
|
|
203
|
+
const schemas = await Promise.all(nouns.map((n) => apiSchema(n.noun, []).catch(() => null)));
|
|
204
|
+
const out = new Map();
|
|
205
|
+
for (const s of schemas) {
|
|
206
|
+
if (!s?.loop)
|
|
207
|
+
continue;
|
|
208
|
+
const names = Array.isArray(s.loop.skills) && s.loop.skills.length ? s.loop.skills : s.loop.skill ? [s.loop.skill] : [];
|
|
209
|
+
for (const skill of names) {
|
|
210
|
+
const seen = out.get(skill);
|
|
211
|
+
out.set(skill, { noun: seen?.noun ?? s.noun, verbs: [...new Set([...(seen?.verbs ?? []), ...(s.loop.verbs ?? [])])] });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
187
216
|
async function remove(run, name, file) {
|
|
188
217
|
one(file, name);
|
|
189
218
|
if (run.globals.dryRun)
|
package/dist/builtins/sandbox.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// reset moves both to the new organization; destroy forgets them. The token is printed never,
|
|
5
5
|
// not even by create: only its hint.
|
|
6
6
|
import crypto from 'node:crypto';
|
|
7
|
-
import { credentialsFile, defaultOrg,
|
|
7
|
+
import { credentialsFile, defaultOrg, forgetOutWritten, storedLogin, forgetOrg, LINK_FILE, readLink, removeLink, renameOrg, storedOrgs, storeOrg, writeLink, hintOf } from '../config.js';
|
|
8
8
|
import { CliError, fail } from '../errors.js';
|
|
9
9
|
import { apiUrl, envelope, request } from '../http.js';
|
|
10
10
|
import { parseSchema } from '../schema.js';
|
|
@@ -28,9 +28,9 @@ export const sandbox = {
|
|
|
28
28
|
live: false,
|
|
29
29
|
body: [
|
|
30
30
|
{ name: 'name', type: 'string', description: 'The organization name; without it, an invented one. A name starting zztest marks a test organization.' },
|
|
31
|
-
{ name: 'seed', type: 'boolean', description: 'Loads the demo dataset: two markets, six products, shipping and a
|
|
31
|
+
{ name: 'seed', type: 'boolean', description: 'Loads the demo dataset: two markets, six products, shipping and a manual payment setting (Wire transfer), enough to place a test order at once.' },
|
|
32
32
|
],
|
|
33
|
-
effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json. With --seed it answers once the seed is applied, in about
|
|
33
|
+
effect: 'Creates a sandbox, stores its token on this machine (never printed) and links this folder to it with .c11r.json. With --seed it answers once the seed is applied, in about 10 seconds.',
|
|
34
34
|
},
|
|
35
35
|
{ verb: 'get', method: 'GET', path: '/v1/sandbox', live: false, effect: 'Returns the sandbox: organization, expiry, whether it is seeded, the token hint and the claim status.' },
|
|
36
36
|
{
|
|
@@ -124,8 +124,7 @@ async function destroy(run) {
|
|
|
124
124
|
// here. The sandbox is gone either way, so this machine forgets it too.
|
|
125
125
|
if (!(e instanceof CliError && e.code === 'unauthorized') || run.globals.dryRun || !run.org.org)
|
|
126
126
|
throw e;
|
|
127
|
-
const out =
|
|
128
|
-
markOutWritten(run.org.org, false);
|
|
127
|
+
const out = forgetOutWritten(run.org.org);
|
|
129
128
|
forgetOrg(run.org.org);
|
|
130
129
|
const link = removeLink(run.org.org);
|
|
131
130
|
const secrets = removeOrgSecrets(run.org.org);
|
|
@@ -136,8 +135,7 @@ async function destroy(run) {
|
|
|
136
135
|
const org = r.data?.org ?? run.org.org;
|
|
137
136
|
if (!org)
|
|
138
137
|
return r;
|
|
139
|
-
const out =
|
|
140
|
-
markOutWritten(org, false);
|
|
138
|
+
const out = forgetOutWritten(org);
|
|
141
139
|
forgetOrg(org);
|
|
142
140
|
const link = removeLink(org);
|
|
143
141
|
const secrets = removeOrgSecrets(org);
|
package/dist/config.js
CHANGED
|
@@ -110,20 +110,37 @@ export function setDefaultOrg(org) {
|
|
|
110
110
|
delete c.default_org;
|
|
111
111
|
writeCredentials(c);
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
/** A record's secret went to an --out file: remembered so its delete can say that file stays. */
|
|
114
|
+
export function markOutWritten(org, record) {
|
|
115
|
+
const keys = record.keys.filter(Boolean);
|
|
116
|
+
if (!keys.length)
|
|
117
|
+
return;
|
|
118
|
+
const c = readCredentials();
|
|
119
|
+
const all = { ...(c.out_written ?? {}) };
|
|
120
|
+
const list = (all[org] ?? []).filter((r) => !(r.noun === record.noun && r.keys.some((k) => keys.includes(k))));
|
|
121
|
+
all[org] = [...list, { noun: record.noun, keys }];
|
|
122
|
+
c.out_written = all;
|
|
123
|
+
writeCredentials(c);
|
|
115
124
|
}
|
|
116
|
-
|
|
125
|
+
/**
|
|
126
|
+
* After a delete: whether that record had a secret written with --out, which is then forgotten.
|
|
127
|
+
* With no record named, the organization goes (sandbox destroy).
|
|
128
|
+
*/
|
|
129
|
+
export function forgetOutWritten(org, record) {
|
|
117
130
|
const c = readCredentials();
|
|
131
|
+
const list = c.out_written?.[org] ?? [];
|
|
132
|
+
const hit = (r) => !record || (r.noun === record.noun && r.keys.some((k) => record.keys.includes(k)));
|
|
133
|
+
if (!list.some(hit))
|
|
134
|
+
return false;
|
|
135
|
+
const rest = list.filter((r) => !hit(r));
|
|
118
136
|
const all = { ...(c.out_written ?? {}) };
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
if (written)
|
|
122
|
-
all[org] = true;
|
|
137
|
+
if (rest.length)
|
|
138
|
+
all[org] = rest;
|
|
123
139
|
else
|
|
124
140
|
delete all[org];
|
|
125
141
|
c.out_written = all;
|
|
126
142
|
writeCredentials(c);
|
|
143
|
+
return true;
|
|
127
144
|
}
|
|
128
145
|
export function storedBooking(id) {
|
|
129
146
|
const b = readCredentials().bookings?.[id];
|
package/dist/engine.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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 {
|
|
7
|
+
import { forgetOutWritten, raiseProblem, resolveOrg } from './config.js';
|
|
8
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';
|
|
@@ -12,11 +12,37 @@ import { envelope, request } from './http.js';
|
|
|
12
12
|
import { readInputs } from './inputs.js';
|
|
13
13
|
import { hasLogin, loginToken } from './login.js';
|
|
14
14
|
import { resourceRequest } from './resources.js';
|
|
15
|
+
import { markDrift } from './project.js';
|
|
15
16
|
import { describePlan, OUT_NOT_TRACKED, planSecret, removeRecordFiles, writeSecret } from './secrets.js';
|
|
16
17
|
import { verbOf } from './schema.js';
|
|
17
|
-
import { apiNouns, apiSchema } from './schemas.js';
|
|
18
|
+
import { apiNouns, apiSchema, refreshSchemas, refusedBySchema } from './schemas.js';
|
|
18
19
|
import { VERSION } from './version.js';
|
|
20
|
+
/**
|
|
21
|
+
* One command. When it was read against a cached schema the API has since changed (a deploy), and
|
|
22
|
+
* the refusal is one that explains (refusedBySchema), it runs once more on fresh schemas. The API
|
|
23
|
+
* refused, so nothing happened; the second run asks again what the fresh schema asks, and never
|
|
24
|
+
* adds confirm=true on its own.
|
|
25
|
+
*/
|
|
19
26
|
export async function execute(argv, opts = {}) {
|
|
27
|
+
try {
|
|
28
|
+
return await executeOnce(argv, opts);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
if (!(e instanceof CliError) || !refusedBySchema(e.code) || !refusedBeforeEffect(e))
|
|
32
|
+
throw e;
|
|
33
|
+
refreshSchemas();
|
|
34
|
+
process.stderr.write(JSON.stringify({ retrying: { code: e.code, why: 'the schema changed on the API: read again' } }) + '\n');
|
|
35
|
+
return executeOnce(argv, opts);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** A refusal the API answered (4xx), or a flag or verb the cached schema did not know: nothing was done. */
|
|
39
|
+
function refusedBeforeEffect(e) {
|
|
40
|
+
const status = e.body.details?.status;
|
|
41
|
+
if (typeof status === 'number')
|
|
42
|
+
return status >= 400 && status < 500;
|
|
43
|
+
return e.code === 'unknown_field' || e.code === 'unknown_verb';
|
|
44
|
+
}
|
|
45
|
+
async function executeOnce(argv, opts) {
|
|
20
46
|
const cmd = parseCommand(argv);
|
|
21
47
|
const builtins = opts.builtins ?? {};
|
|
22
48
|
if (cmd.globals.version && !cmd.noun)
|
|
@@ -28,7 +54,10 @@ export async function execute(argv, opts = {}) {
|
|
|
28
54
|
const schema = builtins[cmd.noun]?.schema ?? (await apiSchema(cmd.noun, Object.keys(builtins)));
|
|
29
55
|
if (cmd.globals.help && !cmd.verb && !builtins[cmd.noun]?.defaultVerb)
|
|
30
56
|
return envelope(nounHelp(schema));
|
|
31
|
-
const
|
|
57
|
+
const ext = opts.extensions?.[schema.noun];
|
|
58
|
+
const found = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
|
|
59
|
+
const local = ext?.flags?.[found.name];
|
|
60
|
+
const verb = local ? { ...found, flags: { ...found.flags, ...local } } : found;
|
|
32
61
|
if (cmd.globals.help)
|
|
33
62
|
return envelope(verbHelp(schema, verb));
|
|
34
63
|
// A verb the schema lists as refused: no request, no confirmation for something that cannot happen.
|
|
@@ -36,7 +65,6 @@ export async function execute(argv, opts = {}) {
|
|
|
36
65
|
const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
|
|
37
66
|
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
67
|
}
|
|
39
|
-
const ext = opts.extensions?.[schema.noun];
|
|
40
68
|
const positional = ext?.positional?.[verb.name];
|
|
41
69
|
const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
|
|
42
70
|
const parsed = parseFields(schema.noun, verb, rest);
|
|
@@ -73,13 +101,13 @@ export async function execute(argv, opts = {}) {
|
|
|
73
101
|
}
|
|
74
102
|
let confirmed = false;
|
|
75
103
|
const confirmFlags = verb.confirmIf.filter((k) => parsed.values[k] !== undefined && parsed.values[k] !== false);
|
|
76
|
-
if ((verb.destructive || confirmFlags.length) && !cmd.globals.dryRun) {
|
|
104
|
+
if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun) {
|
|
77
105
|
const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
|
|
78
106
|
await confirm(what.charAt(0).toUpperCase() + what.slice(1), line, { yes: cmd.globals.yes, prompter: opts.prompter });
|
|
79
107
|
confirmed = true;
|
|
80
108
|
}
|
|
81
109
|
// 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;
|
|
110
|
+
const secretPlan = verb.secret ? planSecret({ org: org.org, noun: schema.noun, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
|
|
83
111
|
const ctx = { live: cmd.globals.live, token: bearer === 'login' ? await loginToken() : org.token };
|
|
84
112
|
const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
|
|
85
113
|
const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
|
|
@@ -115,12 +143,26 @@ export async function execute(argv, opts = {}) {
|
|
|
115
143
|
};
|
|
116
144
|
const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
|
|
117
145
|
const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
|
|
118
|
-
return filesGone(secretOf(r, run, secretPlan), run);
|
|
146
|
+
return drifted(filesGone(secretOf(r, run, secretPlan), run), run);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A Configure write that went through, in a folder whose store.yml names this organization: the
|
|
150
|
+
* file is now behind it. Noted in .c11r/drift, with the warning config_drift.
|
|
151
|
+
*/
|
|
152
|
+
function drifted(r, run) {
|
|
153
|
+
if (run.globals.dryRun || !run.org.org || run.verb.method === 'GET')
|
|
154
|
+
return r;
|
|
155
|
+
if (run.schema.doc?.group !== 'configure')
|
|
156
|
+
return r;
|
|
157
|
+
const data = (r.data && typeof r.data === 'object' ? r.data : {});
|
|
158
|
+
const id = [run.parsed.id, data.code, data.name, data.id].find((k) => typeof k === 'string' && k !== '');
|
|
159
|
+
const warning = markDrift(run.org.org, { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
|
|
160
|
+
return warning ? { ...r, warnings: [...r.warnings, warning] } : r;
|
|
119
161
|
}
|
|
120
162
|
/**
|
|
121
|
-
* A delete
|
|
122
|
-
*
|
|
123
|
-
*
|
|
163
|
+
* A delete on a noun that answers secrets: the files c11r wrote for that record in the secrets
|
|
164
|
+
* folder go with it, listed in removed_files. out_files_not_tracked only when that record's secret
|
|
165
|
+
* was written with --out on this machine: that file stays where it was written.
|
|
124
166
|
*/
|
|
125
167
|
function filesGone(r, run) {
|
|
126
168
|
if (run.verb.name !== 'delete' || run.globals.dryRun || !run.org.org)
|
|
@@ -128,8 +170,9 @@ function filesGone(r, run) {
|
|
|
128
170
|
if (!Object.values(run.schema.verbs).some((v) => v.secret))
|
|
129
171
|
return r;
|
|
130
172
|
const data = (r.data && typeof r.data === 'object' ? r.data : {});
|
|
131
|
-
const
|
|
132
|
-
const
|
|
173
|
+
const keys = [run.parsed.id, data.id, data.name, data.code].filter((k) => typeof k === 'string' && k !== '');
|
|
174
|
+
const removed = removeRecordFiles(run.org.org, run.schema.noun, keys);
|
|
175
|
+
const warn = forgetOutWritten(run.org.org, { noun: run.schema.noun, keys });
|
|
133
176
|
return { ...r, data: { ...data, removed_files: removed }, warnings: [...r.warnings, ...(warn ? [OUT_NOT_TRACKED] : [])] };
|
|
134
177
|
}
|
|
135
178
|
/** A secret in the answer goes to its file; a dry run says where it would go. */
|
|
@@ -138,7 +181,7 @@ function secretOf(r, run, plan) {
|
|
|
138
181
|
const secret = data && typeof data === 'object' ? data.secret : undefined;
|
|
139
182
|
if (secret && typeof secret === 'object' && 'value' in secret) {
|
|
140
183
|
// 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 });
|
|
184
|
+
const p = plan ?? planSecret({ org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id, values: run.parsed.values, yes: true });
|
|
142
185
|
return writeSecret(r, p, { org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id });
|
|
143
186
|
}
|
|
144
187
|
if (plan && run.globals.dryRun)
|
package/dist/help.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// reads it and offers to do the work.
|
|
4
4
|
import { toFlag } from './argv.js';
|
|
5
5
|
import { EXIT } from './errors.js';
|
|
6
|
+
import { clearable } from './schema.js';
|
|
6
7
|
export const USAGE = 'c11r <noun> <verb> [id] [--field value ...]';
|
|
7
8
|
export const GLOBAL_FLAG_HELP = {
|
|
8
9
|
'--json': 'Output as JSON. The default, and the only format: one object on stdout, { data, state, warnings, next } or { error, state }.',
|
|
@@ -21,7 +22,7 @@ export const EXIT_CODE_HELP = {
|
|
|
21
22
|
[EXIT.not_found]: 'no such resource',
|
|
22
23
|
[EXIT.rejected]: 'the API or Commerce Layer refused the request as it is',
|
|
23
24
|
[EXIT.unavailable]: 'the API did not answer, is busy, or answered with an error of its own',
|
|
24
|
-
[EXIT.auth]: 'a wrong or revoked token,
|
|
25
|
+
[EXIT.auth]: 'a wrong or revoked token, no token stored for the organization named, or no login (login_required)',
|
|
25
26
|
};
|
|
26
27
|
export function indexHelp(builtins, api) {
|
|
27
28
|
return {
|
|
@@ -29,7 +30,7 @@ export function indexHelp(builtins, api) {
|
|
|
29
30
|
description: 'c11r is the Commerce Layer CLI for coding agents. Every answer, errors included, is one JSON object on stdout.',
|
|
30
31
|
nouns: [...builtins, ...(api ?? [])].sort((a, b) => (a.noun < b.noun ? -1 : 1)),
|
|
31
32
|
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
|
|
33
|
+
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 ref flag, or a flag the schema marks nullable, given null or an empty value sends null and takes it off (--customer-group null, --coupon-code null). 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
34
|
global_flags: GLOBAL_FLAG_HELP,
|
|
34
35
|
exit_codes: EXIT_CODE_HELP,
|
|
35
36
|
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.',
|
|
@@ -53,7 +54,8 @@ export function verbHelp(s, v) {
|
|
|
53
54
|
state: v.state,
|
|
54
55
|
...(v.plan ? { plan: v.plan } : {}),
|
|
55
56
|
...(v.refused ? { served: false, refused_by: v.refused.by } : {}),
|
|
56
|
-
...(v.destructive ? { destructive: true
|
|
57
|
+
...(v.destructive ? { destructive: true } : {}),
|
|
58
|
+
...(v.confirm ? { confirmation: 'Asks for confirmation unless --yes; with no terminal and no --yes it stops with confirmation_required.' } : {}),
|
|
57
59
|
...(v.loopable ? { loopable: 'A loop policy may allow this verb.' } : {}),
|
|
58
60
|
...(v.live ? {} : { live: 'Not in live mode.' }),
|
|
59
61
|
flags: Object.entries(v.flags).map(([key, f]) => flagHelp(key, f)),
|
|
@@ -66,6 +68,7 @@ function flagHelp(key, f) {
|
|
|
66
68
|
...(f.required || (f.input?.kind === 'secret' && f.input.required) ? { required: true } : {}),
|
|
67
69
|
...(f.input ? { reads: f.input.kind === 'secret' ? `${f.input.target}, a secret: never a flag value, never printed` : `${f.input.target}, a JSON document` } : {}),
|
|
68
70
|
...(f.values ? { values: f.values } : {}),
|
|
71
|
+
...(clearable(f) ? { clear: 'null, or an empty value, takes it off' } : {}),
|
|
69
72
|
...(f.filter ? { filter: f.filter.op } : {}),
|
|
70
73
|
...(f.description ? { description: f.description } : {}),
|
|
71
74
|
};
|
|
@@ -74,7 +77,7 @@ export function usageOf(noun, v) {
|
|
|
74
77
|
const needed = (f) => f.required || (f.input?.kind === 'secret' && f.input.required);
|
|
75
78
|
const required = Object.entries(v.flags)
|
|
76
79
|
.filter(([, f]) => needed(f))
|
|
77
|
-
.map(([k, f]) => `${toFlag(k)} <${f.input ? 'file' : f.type === 'enum' ? f.values
|
|
80
|
+
.map(([k, f]) => `${toFlag(k)} <${f.input ? 'file' : f.type === 'enum' && f.values?.length ? f.values.join('|') : f.type}>`);
|
|
78
81
|
const optional = Object.entries(v.flags).some(([k, f]) => !needed(f) && !v.idAlternative.includes(k));
|
|
79
82
|
const id = v.id ? (v.idAlternative.length ? `<id | ${v.idAlternative.map((k) => `${toFlag(k)} <${v.flags[k].type}>`).join(' ')}>` : '<id>') : '';
|
|
80
83
|
return [`c11r ${noun} ${v.name}`, id, ...required, optional ? '[--field value ...]' : ''].filter(Boolean).join(' ');
|
package/dist/http.js
CHANGED
|
@@ -33,6 +33,13 @@ export function urlOf(req, ctx) {
|
|
|
33
33
|
const qs = q.toString();
|
|
34
34
|
return `${apiUrl()}${req.path}${qs ? `?${qs}` : ''}`;
|
|
35
35
|
}
|
|
36
|
+
/** The header with the hash of the schemas the API serves: it changes with a deploy that changes them. */
|
|
37
|
+
export const SCHEMA_HEADER = 'c11r-schema';
|
|
38
|
+
let schemaWatcher = null;
|
|
39
|
+
/** Called with the header's value on every answer that has it (the schema cache listens). */
|
|
40
|
+
export function watchSchemaHeader(fn) {
|
|
41
|
+
schemaWatcher = fn;
|
|
42
|
+
}
|
|
36
43
|
/** Sends the request: the envelope, or the API's error as a CliError. */
|
|
37
44
|
export async function request(req, ctx) {
|
|
38
45
|
for (let attempt = 1;; attempt++) {
|
|
@@ -76,6 +83,9 @@ async function once(req, ctx) {
|
|
|
76
83
|
next_step: req.method === 'GET' ? { action: 'retry_later' } : { action: 'none' },
|
|
77
84
|
});
|
|
78
85
|
}
|
|
86
|
+
const version = res.headers.get(SCHEMA_HEADER);
|
|
87
|
+
if (version && schemaWatcher)
|
|
88
|
+
schemaWatcher(version);
|
|
79
89
|
const text = await res.text();
|
|
80
90
|
let data;
|
|
81
91
|
try {
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The project folder's configuration files (contract section 45.2): store.yml and
|
|
2
|
+
// price_lists/<code>.csv, written by config export and read by config plan and apply, and the
|
|
3
|
+
// CLI's own notes about them in .c11r/:
|
|
4
|
+
//
|
|
5
|
+
// .c11r/export.json what the last export wrote: the organization, when, and each file's hash,
|
|
6
|
+
// so the next export never overwrites a file changed since without --yes.
|
|
7
|
+
// .c11r/drift the Configure writes made since, in a folder whose store.yml names the
|
|
8
|
+
// same organization: config plan mentions them, config export clears them.
|
|
9
|
+
//
|
|
10
|
+
// store.yml carries the export's meta ({ org, exported_at }) as its first key; the CLI takes it
|
|
11
|
+
// off before it sends the file, so the API sees { <noun>: [records] } only.
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import YAML from 'yaml';
|
|
16
|
+
import { projectDir } from './config.js';
|
|
17
|
+
import { fail } from './errors.js';
|
|
18
|
+
export const STORE_FILE = 'store.yml';
|
|
19
|
+
export const PRICE_LISTS_DIR = 'price_lists';
|
|
20
|
+
const NOTES_DIR = '.c11r';
|
|
21
|
+
const EXPORT_FILE = 'export.json';
|
|
22
|
+
const DRIFT_FILE = 'drift';
|
|
23
|
+
const hashOf = (text) => `sha256-${crypto.createHash('sha256').update(text).digest('hex')}`;
|
|
24
|
+
const notes = (dir) => path.join(dir, NOTES_DIR);
|
|
25
|
+
/** store.yml and the price list CSVs of the project folder, or store_not_found. */
|
|
26
|
+
export function readStoreFiles(dir = projectDir()) {
|
|
27
|
+
const file = path.join(dir, STORE_FILE);
|
|
28
|
+
let text;
|
|
29
|
+
try {
|
|
30
|
+
text = fs.readFileSync(file, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
fail('not_found', `There is no ${STORE_FILE} in ${dir}. c11r config export writes one from the organization.`, { path: 'store', next_step: { action: 'fix_request', command: 'c11r config export' } });
|
|
34
|
+
}
|
|
35
|
+
let doc;
|
|
36
|
+
try {
|
|
37
|
+
doc = YAML.parse(text) ?? {};
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
fail('invalid_config', `${file} is not YAML: ${e.message}`, { path: 'store' });
|
|
41
|
+
}
|
|
42
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
43
|
+
fail('invalid_config', `${file} holds no nouns: it is { <noun>: [records] }.`, { path: 'store' });
|
|
44
|
+
const { meta, ...store } = doc;
|
|
45
|
+
const org = meta && typeof meta === 'object' && typeof meta.org === 'string' ? meta.org : null;
|
|
46
|
+
const price_lists = {};
|
|
47
|
+
const csvDir = path.join(dir, PRICE_LISTS_DIR);
|
|
48
|
+
let names = [];
|
|
49
|
+
try {
|
|
50
|
+
names = fs.readdirSync(csvDir);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No price lists: fine.
|
|
54
|
+
}
|
|
55
|
+
for (const n of names.sort())
|
|
56
|
+
if (n.endsWith('.csv'))
|
|
57
|
+
price_lists[n.slice(0, -4)] = fs.readFileSync(path.join(csvDir, n), 'utf8');
|
|
58
|
+
return { store, price_lists, org };
|
|
59
|
+
}
|
|
60
|
+
/** The organization the folder's store.yml names, or null without one. */
|
|
61
|
+
export function storeFileOrg(dir = projectDir()) {
|
|
62
|
+
try {
|
|
63
|
+
const doc = YAML.parse(fs.readFileSync(path.join(dir, STORE_FILE), 'utf8'));
|
|
64
|
+
return typeof doc?.meta?.org === 'string' ? doc.meta.org : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Writes an export: store.yml (meta first) and price_lists/<code>.csv. A file there that differs
|
|
72
|
+
* from what the last export wrote (or that no export wrote) is the user's: without overwrite,
|
|
73
|
+
* nothing is written and the answer lists them. Price list files the last export wrote, unchanged
|
|
74
|
+
* since, and no longer in the organization, go.
|
|
75
|
+
*/
|
|
76
|
+
export function writeStoreFiles(data, opts) {
|
|
77
|
+
const dir = opts.dir ?? projectDir();
|
|
78
|
+
const last = readRecord(dir);
|
|
79
|
+
const meta = { org: data.meta?.org ?? null, exported_at: data.meta?.exported_at ?? new Date().toISOString() };
|
|
80
|
+
const yaml = `# The organization's setup, written by c11r config export. Edit it, then c11r config plan and c11r config apply.\n${YAML.stringify({ meta, ...data.store }, { lineWidth: 0 })}`;
|
|
81
|
+
const files = { [STORE_FILE]: yaml };
|
|
82
|
+
for (const [code, csv] of Object.entries(data.price_lists ?? {}))
|
|
83
|
+
files[path.join(PRICE_LISTS_DIR, `${safeName(code)}.csv`)] = csv;
|
|
84
|
+
const changed = (rel) => {
|
|
85
|
+
const abs = path.join(dir, rel);
|
|
86
|
+
if (!fs.existsSync(abs))
|
|
87
|
+
return false;
|
|
88
|
+
const now = hashOf(fs.readFileSync(abs, 'utf8'));
|
|
89
|
+
return last.files[rel] !== now;
|
|
90
|
+
};
|
|
91
|
+
const stale = Object.keys(last.files).filter((rel) => rel.startsWith(`${PRICE_LISTS_DIR}${path.sep}`) && !(rel in files) && fs.existsSync(path.join(dir, rel)));
|
|
92
|
+
const mine = [...Object.keys(files).filter((rel) => files[rel] !== readOr(path.join(dir, rel))), ...stale].filter(changed);
|
|
93
|
+
if (mine.length && !opts.overwrite) {
|
|
94
|
+
fail('conflict', `${mine.join(', ')} changed since the last export${last.exported_at ? ` (${last.exported_at})` : ''}, or no export wrote ${mine.length === 1 ? 'it' : 'them'}. c11r replaces ${mine.length === 1 ? 'it' : 'them'} only with --yes; c11r config plan shows what the files would change in the organization.`, {
|
|
95
|
+
path: 'store',
|
|
96
|
+
details: { files: mine.map((rel) => path.join(dir, rel)) },
|
|
97
|
+
next_step: { action: 'fix_request', command: 'c11r config plan' },
|
|
98
|
+
}, 'rejected');
|
|
99
|
+
}
|
|
100
|
+
const written = [];
|
|
101
|
+
for (const [rel, text] of Object.entries(files)) {
|
|
102
|
+
const abs = path.join(dir, rel);
|
|
103
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
104
|
+
if (readOr(abs) !== text)
|
|
105
|
+
fs.writeFileSync(abs, text);
|
|
106
|
+
written.push(abs);
|
|
107
|
+
}
|
|
108
|
+
const removed = [];
|
|
109
|
+
for (const rel of stale) {
|
|
110
|
+
fs.rmSync(path.join(dir, rel), { force: true });
|
|
111
|
+
removed.push(path.join(dir, rel));
|
|
112
|
+
}
|
|
113
|
+
writeRecord(dir, { org: meta.org, exported_at: meta.exported_at, files: Object.fromEntries(Object.entries(files).map(([rel, text]) => [rel, hashOf(text)])) });
|
|
114
|
+
clearDrift(dir);
|
|
115
|
+
return { written, removed, dir };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* After a Configure write: in a folder whose store.yml names this organization, notes it in
|
|
119
|
+
* .c11r/drift and answers the warning. Elsewhere, nothing.
|
|
120
|
+
*/
|
|
121
|
+
export function markDrift(org, entry, dir = projectDir()) {
|
|
122
|
+
if (storeFileOrg(dir) !== org)
|
|
123
|
+
return null;
|
|
124
|
+
const list = readDrift(dir);
|
|
125
|
+
list.push({ ...entry, at: new Date().toISOString() });
|
|
126
|
+
fs.mkdirSync(notes(dir), { recursive: true });
|
|
127
|
+
// The first ones say since when; the last 50 say what.
|
|
128
|
+
const kept = list.length > 50 ? [list[0], ...list.slice(-49)] : list;
|
|
129
|
+
fs.writeFileSync(path.join(notes(dir), DRIFT_FILE), JSON.stringify(kept, null, 2) + '\n');
|
|
130
|
+
return driftWarning(kept);
|
|
131
|
+
}
|
|
132
|
+
/** The writes noted since the last export, oldest first. */
|
|
133
|
+
export function readDrift(dir = projectDir()) {
|
|
134
|
+
try {
|
|
135
|
+
const v = JSON.parse(fs.readFileSync(path.join(notes(dir), DRIFT_FILE), 'utf8'));
|
|
136
|
+
return Array.isArray(v) ? v : [];
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function clearDrift(dir = projectDir()) {
|
|
143
|
+
fs.rmSync(path.join(notes(dir), DRIFT_FILE), { force: true });
|
|
144
|
+
}
|
|
145
|
+
export function driftWarning(list) {
|
|
146
|
+
const first = list[0];
|
|
147
|
+
const what = (e) => `${e.noun} ${e.verb}${e.id ? ` ${e.id}` : ''}`;
|
|
148
|
+
return {
|
|
149
|
+
code: 'config_drift',
|
|
150
|
+
message: `store.yml is now behind the organization, since ${first.at} (${what(first)}${list.length > 1 ? ` and ${list.length - 1} more` : ''}): c11r config export refreshes it.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function readRecord(dir) {
|
|
154
|
+
try {
|
|
155
|
+
const v = JSON.parse(fs.readFileSync(path.join(notes(dir), EXPORT_FILE), 'utf8'));
|
|
156
|
+
return { org: v.org ?? null, exported_at: v.exported_at ?? null, files: v.files && typeof v.files === 'object' ? v.files : {} };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { org: null, exported_at: null, files: {} };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function writeRecord(dir, r) {
|
|
163
|
+
fs.mkdirSync(notes(dir), { recursive: true });
|
|
164
|
+
fs.writeFileSync(path.join(notes(dir), EXPORT_FILE), JSON.stringify(r, null, 2) + '\n');
|
|
165
|
+
}
|
|
166
|
+
function readOr(file) {
|
|
167
|
+
try {
|
|
168
|
+
return fs.readFileSync(file, 'utf8');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** A price list code as a file name: letters, digits, dot, dash and underscore. */
|
|
175
|
+
const safeName = (code) => code.replace(/[^A-Za-z0-9._-]+/g, '-');
|
package/dist/resources.js
CHANGED
|
@@ -21,7 +21,7 @@ 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
|
}
|
|
@@ -39,4 +39,7 @@ export function resourceRequest(noun, verb, id, values) {
|
|
|
39
39
|
req.body = body;
|
|
40
40
|
return req;
|
|
41
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));
|
|
42
45
|
const text = (v) => (Array.isArray(v) ? v.join(',') : typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v));
|
package/dist/schema.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The schema of a noun, as the contract's schema document has it (section 5.1), and what the
|
|
2
2
|
// engine makes of it: for every verb, the flags it takes and where each one goes (body, query or
|
|
3
|
-
// filter). A plain noun (markets, orders,
|
|
3
|
+
// filter). A plain noun (markets, orders, payments, about 60 of them) has no code in the CLI: its
|
|
4
4
|
// schema is read from GET /v1/schema/<noun>. Built-in nouns (sandbox, loops, report) write theirs
|
|
5
5
|
// in the same document format, so one reader serves both.
|
|
6
6
|
import { closest, fail, fixWith } from './errors.js';
|
|
@@ -53,6 +53,7 @@ export function parseSchema(raw, expected) {
|
|
|
53
53
|
method,
|
|
54
54
|
id: v.path ? /\{[^}]+\}/.test(v.path) : !['list', 'create'].includes(name),
|
|
55
55
|
destructive,
|
|
56
|
+
confirm: destructive || v.confirm === true,
|
|
56
57
|
// A policy never deletes nor returns money, whatever else the document says.
|
|
57
58
|
loopable: Boolean(v.loopable) && !v.deletes && !v.returns_money && name !== 'delete',
|
|
58
59
|
live: v.live !== false,
|
|
@@ -145,8 +146,9 @@ function flag(f, to, required) {
|
|
|
145
146
|
to,
|
|
146
147
|
...(f.values ? { values: f.values } : {}),
|
|
147
148
|
...(f.items ? { items: f.items } : {}),
|
|
148
|
-
...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, p.type])) } : {}),
|
|
149
|
+
...(f.properties?.length ? { properties: Object.fromEntries(f.properties.map((p) => [p.name, { type: p.type, ...(p.values ? { values: p.values } : {}) }])) } : {}),
|
|
149
150
|
...(f.noun ? { noun: f.noun } : {}),
|
|
151
|
+
...(f.nullable !== undefined ? { nullable: f.nullable } : {}),
|
|
150
152
|
...(required ? { required: true } : {}),
|
|
151
153
|
...(f.description ? { description: f.description } : {}),
|
|
152
154
|
};
|
|
@@ -180,3 +182,7 @@ export function verbOf(schema, verb) {
|
|
|
180
182
|
}
|
|
181
183
|
return v;
|
|
182
184
|
}
|
|
185
|
+
/** A body flag that null clears: a field marked nullable, or a ref unless marked nullable: false. */
|
|
186
|
+
export function clearable(f) {
|
|
187
|
+
return f.to === 'body' && (f.nullable === true || (f.type === 'ref' && f.nullable !== false));
|
|
188
|
+
}
|
package/dist/schemas.js
CHANGED
|
@@ -2,28 +2,91 @@
|
|
|
2
2
|
// one (contract section 5), cached on disk for a few minutes so a command costs one request, not
|
|
3
3
|
// two. Schemas are the same at every state, so no token is sent. C11R_SCHEMA_CACHE=0 turns the
|
|
4
4
|
// cache off.
|
|
5
|
+
//
|
|
6
|
+
// A deploy can change a schema inside those minutes. Every answer carries C11R-Schema, the hash of
|
|
7
|
+
// the schemas served: an entry records the one it was loaded with, and an answer with another
|
|
8
|
+
// drops the cache. The engine then runs the command once more on fresh schemas when the API
|
|
9
|
+
// refused it (refusedBySchema). Without the header, a refusal that a stale schema explains
|
|
10
|
+
// (confirmation_required, unknown_field, unknown_verb) does the same.
|
|
5
11
|
import crypto from 'node:crypto';
|
|
6
12
|
import fs from 'node:fs';
|
|
7
13
|
import os from 'node:os';
|
|
8
14
|
import path from 'node:path';
|
|
9
15
|
import { CliError, closest, fail, fixWith } from './errors.js';
|
|
10
|
-
import { apiUrl, request } from './http.js';
|
|
16
|
+
import { apiUrl, request, watchSchemaHeader } from './http.js';
|
|
11
17
|
import { parseSchema } from './schema.js';
|
|
12
18
|
import { VERSION } from './version.js';
|
|
13
19
|
const TTL_MS = 10 * 60_000;
|
|
14
|
-
function
|
|
20
|
+
function cacheDir() {
|
|
15
21
|
if (process.env.C11R_SCHEMA_CACHE === '0')
|
|
16
22
|
return null;
|
|
17
23
|
const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
18
24
|
const host = crypto.createHash('sha256').update(apiUrl()).digest('hex').slice(0, 12);
|
|
19
|
-
return path.join(base, 'c11r', `schema-${VERSION}-${host}
|
|
25
|
+
return path.join(base, 'c11r', `schema-${VERSION}-${host}`);
|
|
26
|
+
}
|
|
27
|
+
/** This process: the header last seen, the versions of the cache entries used, and whether to skip the cache. */
|
|
28
|
+
const seen = { header: null, used: new Set(), fromCache: false, stale: false, fresh: false, written: null };
|
|
29
|
+
/** The hash the cache on disk was written under, in its own file: any answer can check it, whatever the command read. */
|
|
30
|
+
const HASH_FILE = '_hash';
|
|
31
|
+
watchSchemaHeader((version) => {
|
|
32
|
+
seen.header = version;
|
|
33
|
+
// Schemas this command read from the cache, under another hash: the refusal may be theirs.
|
|
34
|
+
if (seen.fromCache && [...seen.used].some((v) => v !== version))
|
|
35
|
+
seen.stale = true;
|
|
36
|
+
// The cache on disk, under another hash (or none recorded): gone, whatever this command read.
|
|
37
|
+
const dir = cacheDir();
|
|
38
|
+
if (!dir || seen.written === version)
|
|
39
|
+
return;
|
|
40
|
+
let names;
|
|
41
|
+
try {
|
|
42
|
+
names = fs.readdirSync(dir);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const hash = names.includes(HASH_FILE) ? fs.readFileSync(path.join(dir, HASH_FILE), 'utf8').trim() : null;
|
|
48
|
+
if (hash !== version && names.some((n) => n.endsWith('.json')))
|
|
49
|
+
dropSchemaCache();
|
|
50
|
+
});
|
|
51
|
+
/** Every cached schema of this API, gone: the next command loads them again. */
|
|
52
|
+
export function dropSchemaCache() {
|
|
53
|
+
const dir = cacheDir();
|
|
54
|
+
if (dir)
|
|
55
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Whether a refusal may come from a schema older than the API's: one was served from the cache,
|
|
59
|
+
* and the API's header says the schemas changed, or, with no header, the refusal is one a stale
|
|
60
|
+
* schema explains. The caller then refreshes (refreshSchemas) and runs the command once more.
|
|
61
|
+
*/
|
|
62
|
+
export function refusedBySchema(code) {
|
|
63
|
+
if (!seen.fromCache || seen.fresh)
|
|
64
|
+
return false;
|
|
65
|
+
if (seen.stale)
|
|
66
|
+
return true;
|
|
67
|
+
return seen.header === null && STALE_CODES.has(code);
|
|
68
|
+
}
|
|
69
|
+
const STALE_CODES = new Set(['confirmation_required', 'unknown_field', 'unknown_verb']);
|
|
70
|
+
/** From now on, in this process, schemas come from the API, and the cache is rewritten. */
|
|
71
|
+
export function refreshSchemas() {
|
|
72
|
+
dropSchemaCache();
|
|
73
|
+
seen.fresh = true;
|
|
74
|
+
seen.fromCache = false;
|
|
75
|
+
seen.used.clear();
|
|
20
76
|
}
|
|
21
77
|
async function cached(key, load) {
|
|
22
|
-
const
|
|
23
|
-
|
|
78
|
+
const dir = cacheDir();
|
|
79
|
+
const file = dir ? path.join(dir, `${key}.json`) : null;
|
|
80
|
+
if (file && !seen.fresh) {
|
|
24
81
|
try {
|
|
25
|
-
if (Date.now() - fs.statSync(file).mtimeMs < TTL_MS)
|
|
26
|
-
|
|
82
|
+
if (Date.now() - fs.statSync(file).mtimeMs < TTL_MS) {
|
|
83
|
+
const entry = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
84
|
+
if (entry && typeof entry === 'object' && 'value' in entry) {
|
|
85
|
+
seen.fromCache = true;
|
|
86
|
+
seen.used.add(entry.version ?? null);
|
|
87
|
+
return entry.value;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
27
90
|
}
|
|
28
91
|
catch {
|
|
29
92
|
// Not cached, or unreadable: load it.
|
|
@@ -33,7 +96,18 @@ async function cached(key, load) {
|
|
|
33
96
|
if (file) {
|
|
34
97
|
try {
|
|
35
98
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
-
|
|
99
|
+
// Written under another hash than the files there: those go first, so the cache is one hash.
|
|
100
|
+
if (seen.header && seen.written !== seen.header) {
|
|
101
|
+
const hashFile = path.join(path.dirname(file), HASH_FILE);
|
|
102
|
+
const hash = fs.existsSync(hashFile) ? fs.readFileSync(hashFile, 'utf8').trim() : null;
|
|
103
|
+
if (hash !== seen.header) {
|
|
104
|
+
dropSchemaCache();
|
|
105
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
106
|
+
fs.writeFileSync(hashFile, seen.header);
|
|
107
|
+
}
|
|
108
|
+
seen.written = seen.header;
|
|
109
|
+
}
|
|
110
|
+
fs.writeFileSync(file, JSON.stringify({ version: seen.header, value }));
|
|
37
111
|
}
|
|
38
112
|
catch {
|
|
39
113
|
// A read-only home is fine: no cache.
|
package/dist/secrets.js
CHANGED
|
@@ -14,6 +14,8 @@ import os from 'node:os';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { markOutWritten } from './config.js';
|
|
16
16
|
import { fail } from './errors.js';
|
|
17
|
+
/** Where the record's id goes in a file named after it, once the answer gives the id. */
|
|
18
|
+
const ID_SLOT = '<id>';
|
|
17
19
|
export function secretsDir() {
|
|
18
20
|
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
19
21
|
return path.join(base, 'c11r', 'secrets');
|
|
@@ -23,10 +25,14 @@ const safe = (s) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|
|
|
|
23
25
|
export function planSecret(opts) {
|
|
24
26
|
const out = opts.values.out;
|
|
25
27
|
const format = opts.values.format ?? null;
|
|
26
|
-
|
|
28
|
+
// By the name given, else the id given; a record with neither (a gift card) by the id it is
|
|
29
|
+
// given, so each has a file of its own: gift-card-<id>.env.
|
|
30
|
+
const named = opts.values.name ?? opts.id;
|
|
31
|
+
const byRecord = !out && opts.verb !== 'token' && (named === undefined || named === null || named === '');
|
|
32
|
+
const base = opts.verb === 'token' ? `${safe(opts.id ?? 'credential')}-token` : byRecord ? `${safe(opts.noun.replace(/s$/, ''))}-${ID_SLOT}` : safe(String(named));
|
|
27
33
|
const file = out ? path.resolve(out) : path.join(secretsDir(), safe(opts.org ?? 'default'), base);
|
|
28
|
-
const plan = { file: out || !format ? file : `${file}.${format}`, format, fixed: Boolean(out), overwrite: opts.yes };
|
|
29
|
-
const candidates = plan.fixed || format ? [plan.file] : [`${file}.env`, `${file}.json`];
|
|
34
|
+
const plan = { file: out || !format ? file : `${file}.${format}`, format, fixed: Boolean(out), overwrite: opts.yes, byRecord };
|
|
35
|
+
const candidates = byRecord ? [] : plan.fixed || format ? [plan.file] : [`${file}.env`, `${file}.json`];
|
|
30
36
|
for (const c of candidates) {
|
|
31
37
|
let st = null;
|
|
32
38
|
try {
|
|
@@ -43,7 +49,7 @@ export function planSecret(opts) {
|
|
|
43
49
|
fail('conflict', `${c} exists. c11r replaces it only with --yes, or writes the secret elsewhere with --out.`, { path: 'out', next_step: { action: 'fix_request', command: null }, details: { file: c } }, 'rejected');
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
|
-
const dir = path.dirname(
|
|
52
|
+
const dir = path.dirname(plan.file);
|
|
47
53
|
const existing = nearestExisting(dir);
|
|
48
54
|
try {
|
|
49
55
|
fs.accessSync(existing, fs.constants.W_OK);
|
|
@@ -74,7 +80,7 @@ export function writeSecret(r, plan, ctx) {
|
|
|
74
80
|
const credentialId = str(rec.id) ?? ctx.id;
|
|
75
81
|
const record = { noun: ctx.noun, id: credentialId ?? null, name: str(rec.name), code: str(rec.code) };
|
|
76
82
|
const format = plan.format ?? (secret.format === 'json' ? 'json' : 'env');
|
|
77
|
-
const file = plan.fixed || plan.format ? plan.file : `${plan.file}.${format}
|
|
83
|
+
const file = byRecordFile(plan.fixed || plan.format ? plan.file : `${plan.file}.${format}`, plan, credentialId);
|
|
78
84
|
const env = secret.env && Object.keys(secret.env).length ? { ...secret.env } : { [`CL_${(secret.kind ?? 'secret').toUpperCase()}`]: String(secret.value ?? '') };
|
|
79
85
|
// The endpoint goes with every secret, so the file is enough to call Commerce Layer.
|
|
80
86
|
if (!env.CL_BASE_ENDPOINT && typeof data.base_endpoint === 'string')
|
|
@@ -97,9 +103,9 @@ export function writeSecret(r, plan, ctx) {
|
|
|
97
103
|
const created = ctx.verb === 'create';
|
|
98
104
|
fail('secret_not_written', `The API answered the ${secret.kind ?? 'secret'} of credential ${id} once, and c11r could not write it to ${file}: ${e.message}. It was not printed, and the API cannot show it again. ${created ? `c11r credentials token ${id} gives a working token now; for a new secret, create another credential and delete ${id}.` : `c11r credentials token ${id} gives another token.`}`, { path: 'out', next_step: { action: 'fix_request', command: `c11r credentials token ${id}` }, details: { credential_id: credentialId ?? null, file } });
|
|
99
105
|
}
|
|
100
|
-
// An --out file is not removed with its
|
|
106
|
+
// An --out file is not removed with its record: remember which record has one, for the warning.
|
|
101
107
|
if (plan.fixed && ctx.org)
|
|
102
|
-
markOutWritten(ctx.org,
|
|
108
|
+
markOutWritten(ctx.org, { noun: ctx.noun, keys: [record.id, record.name, record.code, ctx.id].filter((k) => typeof k === 'string' && k !== '') });
|
|
103
109
|
// The API says the secret is shown once: c11r kept it in the file, and printed none of it.
|
|
104
110
|
const warnings = r.warnings.filter((w) => w.code !== 'secret_shown_once' && w.code !== 'token_shown_once');
|
|
105
111
|
if (!ignoredByGit(file))
|
|
@@ -111,6 +117,18 @@ export function writeSecret(r, plan, ctx) {
|
|
|
111
117
|
warnings,
|
|
112
118
|
};
|
|
113
119
|
}
|
|
120
|
+
/** A file named after its record: the id in its slot, and a file there already left alone. */
|
|
121
|
+
function byRecordFile(file, plan, id) {
|
|
122
|
+
if (!plan.byRecord)
|
|
123
|
+
return file;
|
|
124
|
+
// The id as the API gives it, case kept: ids are case-sensitive (lqgwUkXlBw).
|
|
125
|
+
const slot = (id ?? crypto.randomBytes(4).toString('hex')).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '') || crypto.randomBytes(4).toString('hex');
|
|
126
|
+
const named = file.replace(ID_SLOT, slot);
|
|
127
|
+
if (plan.overwrite || !fs.existsSync(named))
|
|
128
|
+
return named;
|
|
129
|
+
const ext = path.extname(named);
|
|
130
|
+
return `${named.slice(0, -ext.length || undefined)}-${crypto.randomBytes(3).toString('hex')}${ext}`;
|
|
131
|
+
}
|
|
114
132
|
/** What a dry run says about the file: where it would go and whether one is there. */
|
|
115
133
|
export function describePlan(plan) {
|
|
116
134
|
const files = plan.fixed || plan.format ? [plan.file] : [`${plan.file}.env`, `${plan.file}.json`];
|
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.10",
|
|
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": [
|