@commercelayer/agent-cli 1.0.0-dev.0 → 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 CHANGED
@@ -14,8 +14,12 @@ 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 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`.
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.
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.
22
+ - 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
23
 
20
24
  ## Built-in nouns
21
25
 
@@ -25,13 +29,13 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
25
29
  | `c11r schema <noun>` | The noun's schema document: fields, filters, verbs, which verbs are destructive and which a loop may automate. |
26
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. |
27
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. |
28
- | `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`. |
29
33
  | `c11r loops run <name>` | One pass of a loop. The CLI is not a daemon: a scheduler repeats the pass. |
30
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. |
31
35
  | `c11r auth login [--client-id] [--secret-file] [--keep-file]` | The human's own Commerce Layer login, from their Provisioning API application. See below. |
32
36
  | `c11r auth logout`, `auth switch <slug> [--global]` | Forgetting the login; the organization commands act on. |
33
37
 
34
- 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.
35
39
 
36
40
  ## Login, organization tokens and secrets
37
41
 
@@ -47,7 +51,7 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
47
51
 
48
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`.
49
53
 
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 delete` removes the files c11r wrote for that credential in `~/.config/c11r/secrets/<org>/` (its secret and its tokens) 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.
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.
51
55
 
52
56
  ## Global flags
53
57
 
@@ -60,16 +64,26 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
60
64
  | `--live` | Live data, on a claimed organization: `?mode=live` on every request. Without it, test mode. |
61
65
  | `--help` | Describes the noun or verb it follows, from its schema. |
62
66
 
63
- 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.
64
68
 
65
69
  ## Which organization
66
70
 
67
71
  `--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
72
 
69
- - Tokens are only in `~/.config/c11r/credentials.json` (or under `XDG_CONFIG_HOME`), mode 600. The CLI never prints one, only its hint.
73
+ - 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
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.
71
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.
72
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
+
73
87
  ## Output, errors and exit codes
74
88
 
75
89
  Every answer is one JSON object on stdout, the API's envelope:
@@ -121,6 +135,6 @@ The CLI's own answers (help, loops.yml) have the same shape, with `state` null.
121
135
  | `C11R_PROVISIONING_TOKEN` | A ready login token, used as is: for CI. Never a client secret. |
122
136
  | `C11R_AUTH_URL` | Commerce Layer's token endpoint, `https://auth.commercelayer.io/oauth/token` by default: for tests. |
123
137
  | `C11R_TEST` | `1` marks a sandbox as a test organization, like a name starting `zztest`. |
124
- | `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. |
125
139
  | `C11R_RETRY_CAP_S` | The longest the CLI waits before one retry, 30 by default. |
126
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,17 +61,27 @@ export function parseFields(noun, verb, rest) {
60
61
  id = a;
61
62
  continue;
62
63
  }
63
- fail('unexpected_argument', `${cmd} does not take ${a}${verb.id ? `: it already has the id ${id}` : ''}. A value goes after its flag.`, { next_step: fixWith(`${cmd} --help`) });
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);
67
72
  if (key === 'data') {
68
- Object.assign(values, readData(inline ?? rest[++i]));
73
+ const data = readData(inline ?? rest[++i]);
74
+ for (const k of Object.keys(data))
75
+ if (verb.flags[`${k}_file`]?.input?.kind === 'secret')
76
+ secretAsValue(cmd, k);
77
+ Object.assign(values, data);
69
78
  continue;
70
79
  }
71
80
  // A key of its own (a filter operator: created_at.gte), else part of an object (checkout.url).
72
81
  const [top, ...sub] = verb.flags[key] ? [key] : key.split('.');
73
82
  const spec = verb.flags[top];
83
+ if (!spec && verb.flags[`${top}_file`]?.input?.kind === 'secret')
84
+ secretAsValue(cmd, top);
74
85
  if (!spec) {
75
86
  fail('unknown_field', `${cmd} has no flag ${toFlag(key)}.`, {
76
87
  path: key,
@@ -98,7 +109,7 @@ export function parseFields(noun, verb, rest) {
98
109
  for (const k of sub.slice(0, -1))
99
110
  target = (target[k] ??= {});
100
111
  const part = sub.join('.');
101
- target[sub[sub.length - 1]] = part in (spec.properties ?? {}) ? coerce(key, { type: spec.properties[part] }, raw) : raw;
112
+ target[sub[sub.length - 1]] = part in (spec.properties ?? {}) ? coerce(key, spec.properties[part], raw) : raw;
102
113
  continue;
103
114
  }
104
115
  const value = coerce(key, spec, raw);
@@ -106,9 +117,23 @@ export function parseFields(noun, verb, rest) {
106
117
  }
107
118
  return { id, values };
108
119
  }
120
+ /** A secret given as a value: refused before it goes anywhere, and the message does not repeat it. */
121
+ function secretAsValue(cmd, key) {
122
+ 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.`, {
123
+ path: key,
124
+ next_step: { action: 'human_step', command: null, url: null },
125
+ });
126
+ }
109
127
  /** A value as its flag's type says, or invalid_value with the reason. */
110
128
  export function coerce(key, spec, raw) {
111
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;
112
137
  switch (spec.type) {
113
138
  case 'string':
114
139
  case 'ref':
@@ -143,7 +168,7 @@ export function coerce(key, spec, raw) {
143
168
  const v = raw.trim().startsWith('{') ? json(raw, invalid) : pairs(key, raw, spec.properties ?? {}, invalid);
144
169
  if (!v || typeof v !== 'object' || Array.isArray(v))
145
170
  invalid('is not a JSON object');
146
- return v;
171
+ return enumParts(key, spec.properties, v);
147
172
  }
148
173
  case 'array': {
149
174
  const item = spec.items ?? 'string';
@@ -151,11 +176,15 @@ export function coerce(key, spec, raw) {
151
176
  const list = json(raw, invalid);
152
177
  if (!Array.isArray(list))
153
178
  invalid('is not a list');
154
- 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));
155
180
  }
156
181
  // One object per flag (--items sku=A,quantity=2, repeated), or scalars separated by commas.
157
182
  if (item === 'object')
158
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];
159
188
  return raw
160
189
  .split(',')
161
190
  .map((s) => s.trim())
@@ -164,6 +193,17 @@ export function coerce(key, spec, raw) {
164
193
  }
165
194
  }
166
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
+ }
167
207
  function json(raw, invalid) {
168
208
  try {
169
209
  return JSON.parse(raw);
@@ -174,7 +214,7 @@ function json(raw, invalid) {
174
214
  }
175
215
  /**
176
216
  * sku=A,quantity=2 as { sku: 'A', quantity: 2 }, each part typed by the schema's properties.
177
- * A part the schema does not type stays text: a postal code or a phone is never made a number.
217
+ * A part the schema does not type stays text: a zip code or a phone is never made a number.
178
218
  */
179
219
  function pairs(key, raw, props, invalid) {
180
220
  const out = {};
@@ -184,7 +224,7 @@ function pairs(key, raw, props, invalid) {
184
224
  invalid('is not JSON nor key=value pairs separated by commas');
185
225
  const name = part.slice(0, eq).trim();
186
226
  const value = part.slice(eq + 1).trim();
187
- out[name] = props[name] ? coerce(`${key}.${name}`, { type: props[name] }, value) : value;
227
+ out[name] = props[name] ? coerce(`${key}.${name}`, props[name], value) : value;
188
228
  }
189
229
  return out;
190
230
  }
@@ -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) ? orgs : Array.isArray(orgs?.items) ? orgs.items : [];
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 ?? who.data?.organizations ?? [];
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,15 @@
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, outWritten, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import zlib from 'node:zlib';
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
- import { OUT_NOT_TRACKED, removeCredentialFiles } from '../secrets.js';
17
20
  const NAME_MAX = 25;
18
21
  export const organizations = {
19
22
  async run(run) {
@@ -105,19 +108,151 @@ export const salesCalls = {
105
108
  return undefined;
106
109
  },
107
110
  };
108
- export const credentials = {
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
+ },
109
217
  async run(run) {
110
- if (run.verb.name !== 'delete')
218
+ if (run.verb.name !== 'get' || (!run.parsed.values.out && !run.parsed.values.wait))
111
219
  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)
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)
114
232
  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] : [])] };
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') } };
121
256
  },
122
257
  };
123
- export const extensions = { organizations, 'sales-calls': salesCalls, credentials };
258
+ export const extensions = { organizations, 'sales-calls': salesCalls, config, exports };
@@ -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). Each must be in the noun's schema
8
- // loop.verbs; the API checks again on every pass.
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 Manage skill or playbook the loop runs, as in manage-orders.' },
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', required, description: 'A verb the policy allows, repeated: approve, payments.capture. Only verbs the noun\'s schema lists in loop.verbs.' },
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
- if (!def || typeof def.skill !== 'string' || !Array.isArray(def.policy?.allow))
81
- fail('invalid_config', `${file}: loop ${name} needs a skill and a policy with allow.`, { path: `loops.${name}` });
82
- out[name] = def;
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.policy.allow,
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
- /** The policy's verbs against the skill noun's schema; a playbook is checked by the API at the first pass. */
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
- if (!def.policy.allow.length)
172
- fail('missing_argument', 'A loop needs at least one --allow.', { path: 'allow' });
173
- if (!def.skill.startsWith('manage-'))
174
- return [{ code: 'policy_unchecked', message: `${def.skill} is not a Manage skill: the API checks its policy at the first pass.` }];
175
- const noun = def.skill.slice('manage-'.length);
176
- const schema = await apiSchema(noun, []);
177
- const allowed = schema.loop?.verbs ?? [];
178
- if (!allowed.length)
179
- fail('policy_forbidden', `No loop acts on ${noun}: its schema has no loop.`, { path: 'skill' });
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(', ')}.`, { path: 'allow', did_you_mean: closest(verb, allowed), details: { loopable: allowed }, next_step: fixWith(`c11r schema ${noun}`) });
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)
@@ -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, markOutWritten, outWritten, storedLogin, forgetOrg, LINK_FILE, readLink, removeLink, renameOrg, storedOrgs, storeOrg, writeLink, hintOf } from '../config.js';
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 test payment method, enough to place a test order at once.' },
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 20 seconds.',
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 = outWritten(run.org.org);
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 = outWritten(org);
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);