@commercelayer/agent-cli 1.0.0-dev.14 → 1.0.0-dev.16

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
@@ -34,7 +34,7 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
34
34
  | `c11r loops run <name>` | One pass of a loop. The CLI is not a daemon: a scheduler repeats the pass. |
35
35
  | `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. |
36
36
  | `c11r auth login [--client-id] [--secret-file] [--keep-file]` | The human's own Commerce Layer login, from their Provisioning API application. See below. |
37
- | `c11r auth logout`, `auth switch <slug> [--global]` | Forgetting the login; the organization commands act on. |
37
+ | `c11r auth logout [--live --org <slug>]`, `auth switch <slug> [--global]` | Forgetting the login, or with `--live` revoking the API's live credential for an organization; the organization commands act on. |
38
38
  | `c11r shop profile\|search\|get\|ask <store>` | Reading a store that speaks UCP, as a shopping agent: what it offers, its catalog, a variant, a question. No organization. See below. |
39
39
  | `c11r skills sync` | Local copies of the Commerce Layer skills in `.claude/skills`, from the index at commercelayer.ai, listed in `AGENTS.md`. No organization. See below. |
40
40
  | `c11r shop checkout <store> [<checkout>]`, `shop order <store> <checkout>` | Buying there for the human: their details after their yes on what is shared, then the payment after their yes on the total, with the sandbox test handler only. |
@@ -65,11 +65,25 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
65
65
  | `--dry-run` | On a write, the CLI sends it with `?dry_run=true`: the API checks it all (state, plan, limits, Commerce Layer's current status) and answers what it would do, `would: [...]`, and nothing is written. No confirmation is needed. Reads ignore it. |
66
66
  | `--yes` | Confirms a destructive verb in advance. |
67
67
  | `--org <slug>` | The organization to act on, by its stored token. |
68
- | `--live` | Live data, on a claimed organization: `?mode=live` on every request. Without it, test mode. |
68
+ | `--live` | Live data, on a claimed organization named with `--org` (or `C11R_ORG`), with the human's login: `?mode=live&org=` on every request. Without it, test mode. See below. |
69
69
  | `--help` | Describes the noun or verb it follows, from its schema. |
70
70
 
71
71
  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. Its `next_step.command` is the same command without `--yes`: the agent asks the human first, and only on their yes runs it again with `--yes`. The API's own `confirmation_required` works the same way.
72
72
 
73
+ ## Live mode
74
+
75
+ `--live` acts on a claimed organization's live data. It always sends the human's login, never an organization token, and names the organization with `?org=`: from `--org`, or `C11R_ORG`, never from the folder's link or the default, which are a sandbox's. Without one it is `missing_value` before any call, with the command and `--org ORG_SLUG --live`.
76
+
77
+ - Without a login, nothing is checked on this machine: the request goes without a token, and the API's answer (`login_required`, or `state_required` for an organization not claimed) comes through as it is. So do `live_unavailable` (a plan without live), `forbidden` (the login is not an active admin of it) and `not_supported` for a verb that comes with a later step of go-live.
78
+ - A verb that never runs live (sandbox, plans, sales calls, `auth login`, `auth switch`, `shop`) stops on this machine with `state_required`.
79
+ - `c11r status --live --org <slug>` is the organization's live status, with `live_credential` and the warning `live_credential_held` while the API holds one.
80
+ - `c11r auth logout --live --org <slug>` revokes that credential in Commerce Layer (`DELETE /v1/auth/live`): `{ org, revoked, credential }`. The login stays on this machine. `--dry-run` says what it would revoke.
81
+ - A verb that needs a yes is confirmed by the API in live, with a sentence naming the action, the amount and the mode (`Refunds EUR 1.00 on order N, in LIVE mode, real money.`): the CLI sends it without `confirm=true` and shows that `confirmation_required` as it is, with the API's next command, or asks it with those words in a terminal. `--yes` sends `confirm=true` on every live write, `payments capture` included.
82
+ - A verb that answers a secret (`credentials create`) writes it in live only to the file the human chose with `--out`: without it, `missing_value` with the command and `--out FILE`. Never to c11r's secrets folder, never printed. A live delete leaves the test mode's secret files of the same organization alone.
83
+ - `store.yml`, `.c11r/export.json` and the drift notes are of an organization in a mode: a live write marks a live `store.yml` behind, never a test one, and the other way round.
84
+ - `c11r organizations disconnect <slug>` (confirmed) has the API revoke its credentials and the organization tokens; this machine then forgets the token, its secret files and a `.c11r.json` naming it, as `sandbox destroy` does, and answers `forgotten`.
85
+ - A secret read from a file (`--credentials-file`) is never printed: an answer or an error that holds it, a dry run's included, shows `[secret]` in its place. No live key is stored on this machine.
86
+
73
87
  ## Which organization
74
88
 
75
89
  `--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.
@@ -80,7 +94,7 @@ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sand
80
94
 
81
95
  ## The store's setup in files
82
96
 
83
- `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` (in a row, an empty `compare_at_amount` means equal to `amount`, and one below `amount` is refused). 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, `retry_after` and the run's token, `run`; `--wait` applies again with that token and the same files until done or failed, and the done answer counts everything the run wrote. The `next` an unfinished apply suggests carries `--run <token> --wait`. `plan` lists in `needs` the new records the files can't make alone (a webhook without its `callback_url`); `apply` skips them and warns `needs_input` with the command that creates each.
97
+ `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, the mode (`test`, or `live` with `--live`) and the time) and `price_lists/<code>.csv` (in a row, an empty `compare_at_amount` means equal to `amount`, and one below `amount` is refused). It records what it wrote in `.c11r/export.json`, and never overwrites a file another organization or mode exported, nor 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, `retry_after` and the run's token, `run`; `--wait` applies again with that token and the same files until done or failed, and the done answer counts everything the run wrote. The `next` an unfinished apply suggests carries `--run <token> --wait`. `plan` lists in `needs` the new records the files can't make alone (a webhook without its `callback_url`); `apply` skips them and warns `needs_input` with the command that creates each.
84
98
 
85
99
  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.
86
100
 
@@ -30,6 +30,7 @@ export const auth = {
30
30
  path: '/v1/auth/login',
31
31
  state: 'none',
32
32
  credential: 'none',
33
+ live: false,
33
34
  body: [
34
35
  { name: 'client_id', type: 'string', description: 'The client id of the Provisioning API application. Not a secret. Also read from the secret file.' },
35
36
  { name: 'secret_file', type: 'string', description: `A file with the client secret, alone or as client_secret=..., readable only by its owner (mode 600). By default ${'~/.config/c11r/provisioning.secret'} when there is no terminal.` },
@@ -37,13 +38,21 @@ export const auth = {
37
38
  ],
38
39
  effect: 'Logs in with the human\'s Provisioning API application: mints a token at Commerce Layer, checks it with the API, keeps the client id and secret in credentials.json (mode 600). The API receives only the token.',
39
40
  },
40
- { verb: 'logout', method: 'POST', path: '/v1/auth/logout', state: 'none', credential: 'none', effect: 'Forgets the stored login on this machine. Its last token ends by itself within two hours. Organization tokens stay.' },
41
+ {
42
+ verb: 'logout',
43
+ method: 'POST',
44
+ path: '/v1/auth/logout',
45
+ state: 'none',
46
+ credential: 'none',
47
+ effect: 'Forgets the stored login on this machine. Its last token ends by itself within two hours. Organization tokens stay. With --live --org, instead revokes the API\'s own live credential for that organization in Commerce Layer, with the login: the login stays on this machine.',
48
+ },
41
49
  {
42
50
  verb: 'switch',
43
51
  method: 'POST',
44
52
  path: '/v1/auth/switch/{slug}',
45
53
  state: 'none',
46
54
  credential: 'none',
55
+ live: false,
47
56
  body: [{ name: 'global', type: 'boolean', description: 'Sets the default for this machine, used when no --org, C11R_ORG or .c11r.json names one; without it, links this project folder.' }],
48
57
  effect: 'Picks the organization commands act on: links this project folder to it (.c11r.json), or with --global sets the default for this machine.',
49
58
  },
@@ -54,7 +63,7 @@ export const auth = {
54
63
  case 'login':
55
64
  return login(run);
56
65
  case 'logout':
57
- return logout();
66
+ return run.globals.live ? logoutLive(run) : logout();
58
67
  case 'switch':
59
68
  return switchOrg(run);
60
69
  }
@@ -132,6 +141,12 @@ async function login(run) {
132
141
  next: who.next?.length ? who.next : [{ command: 'c11r organizations list', why: 'The organizations this login reaches.' }],
133
142
  };
134
143
  }
144
+ /** The API's live credential for the organization, revoked in Commerce Layer: DELETE /v1/auth/live?org=. */
145
+ async function logoutLive(run) {
146
+ // The path names live already: the request carries ?org= and not ?mode=live.
147
+ run.ctx.live = false;
148
+ return run.send({ method: 'DELETE', path: '/auth/live' });
149
+ }
135
150
  function logout() {
136
151
  const l = storedLogin();
137
152
  if (!l)
@@ -11,15 +11,18 @@ import crypto from 'node:crypto';
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import zlib from 'node:zlib';
14
- import { credentialsFile, hintOf, projectDir, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
14
+ import { credentialsFile, forgetOrg, forgetOutWritten, hintOf, projectDir, readLink, removeLink, storeBooking, storedBooking, storedOrgs, storeOrg, writeLink } from '../config.js';
15
+ import { OUT_NOT_TRACKED, removeOrgSecrets } from '../secrets.js';
15
16
  import { confirm, terminal } from '../confirm.js';
16
17
  import { fail } from '../errors.js';
17
18
  import { apiUrl } from '../http.js';
18
- import { clearDrift, driftWarning, readDrift, readStoreFiles, storeFileOrg, writeStoreFiles } from '../project.js';
19
+ import { clearDrift, driftWarning, readDrift, readStoreFiles, storeFileIs, writeStoreFiles } from '../project.js';
19
20
  import { resourceRequest } from '../resources.js';
20
21
  const NAME_MAX = 25;
21
22
  export const organizations = {
22
23
  async run(run) {
24
+ if (run.verb.name === 'disconnect')
25
+ return disconnect(run);
23
26
  if (run.verb.name !== 'create' && run.verb.name !== 'connect')
24
27
  return undefined;
25
28
  const name = run.parsed.values.name;
@@ -73,6 +76,23 @@ export const organizations = {
73
76
  return { ...r, data: { ...data, org, token_hint: hint, credentials: credentialsFile(), linked }, warnings };
74
77
  },
75
78
  };
79
+ /**
80
+ * organizations disconnect: the API revokes its own credentials and the organization tokens, so
81
+ * this machine forgets them too, as sandbox destroy does: the token, its secret files, a link
82
+ * naming it.
83
+ */
84
+ async function disconnect(run) {
85
+ const r = await run.send(resourceRequest('organizations', run.verb, run.parsed.id, run.parsed.values));
86
+ if (run.globals.dryRun || r.data?.disconnected !== true)
87
+ return r;
88
+ const org = r.data.org ?? run.parsed.id;
89
+ const out = forgetOutWritten(org);
90
+ const credentials = Boolean(storedOrgs()[org]);
91
+ forgetOrg(org);
92
+ const link = removeLink(org);
93
+ const secrets = removeOrgSecrets(org);
94
+ return { ...r, data: { ...r.data, forgotten: { credentials, link, secrets } }, warnings: [...r.warnings, ...(out ? [OUT_NOT_TRACKED] : [])] };
95
+ }
76
96
  export const salesCalls = {
77
97
  positional: { book: 'slot' },
78
98
  prepare(verb, parsed) {
@@ -146,7 +166,7 @@ export const config = {
146
166
  const data = r.data ?? {};
147
167
  if (!data.store || typeof data.store !== 'object')
148
168
  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 });
169
+ 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, mode: run.globals.live ? 'live' : 'test' });
150
170
  const ambiguous = Array.isArray(data.ambiguous) ? data.ambiguous : [];
151
171
  const counts = Object.fromEntries(Object.entries(data.store).map(([k, v]) => [k, Array.isArray(v) ? v.length : 0]));
152
172
  return {
@@ -183,12 +203,12 @@ export const config = {
183
203
  r = await run.send(resourceRequest('config', run.verb, undefined, values));
184
204
  }
185
205
  const warnings = [...r.warnings];
186
- const here = storeFileOrg();
206
+ const here = storeFileIs(run.org.org, run.globals.live ? 'live' : 'test');
187
207
  const drift = readDrift();
188
- if (name === 'plan' && drift.length && here === run.org.org)
208
+ if (name === 'plan' && drift.length && here)
189
209
  warnings.push(driftWarning(drift));
190
210
  // The organization now matches the files: the folder is no longer behind.
191
- if (name === 'apply' && !run.globals.dryRun && r.data?.status === 'done' && here === run.org.org)
211
+ if (name === 'apply' && !run.globals.dryRun && r.data?.status === 'done' && here)
192
212
  clearDrift();
193
213
  const data = r.data ?? {};
194
214
  const running = name === 'apply' && data.status === 'running';
package/dist/engine.js CHANGED
@@ -81,13 +81,29 @@ async function executeOnce(argv, opts) {
81
81
  const line = ['c11r', ...argv.filter((a) => a !== '--yes' && a !== '-y')].map(quote).join(' ');
82
82
  const org = resolveOrg(cmd.globals.org);
83
83
  const login = hasLogin();
84
+ const live = cmd.globals.live;
85
+ // The command as typed: c11r status, not c11r status get, when the verb was left out.
86
+ const named = `c11r ${schema.noun}${cmd.verb ? ` ${verb.name}` : ''}`;
87
+ if (live && !verb.live) {
88
+ fail('state_required', `${named} does not run in live mode.`, { required_state: 'claimed', path: 'live', next_step: fixWith(line.replace(/\s--live\b/, '')) });
89
+ }
90
+ // Live mode acts on an organization named on the command, never the folder's link or the
91
+ // default, which are a sandbox's: --org, or C11R_ORG.
92
+ const liveOrg = live ? (cmd.globals.org ?? (process.env.C11R_ORG || null)) : null;
93
+ if (live && !liveOrg) {
94
+ fail('missing_value', `${named} --live acts on a claimed organization, named with --org.`, { path: 'org', next_step: fixWith(`${line.replace(/\s--live\b/, '')} --org ORG_SLUG --live`) });
95
+ }
84
96
  // The bearer, from the schema (contract section 23). An organization token for the organization
85
97
  // nouns, the login for the Provisioning ones; credentials take the login when there is one and
86
98
  // the organization token is not a sandbox's.
87
- let bearer = verb.credential === 'login' ? 'login' : verb.credential === 'organization|login' ? (login && !org.token?.startsWith('sbx_') ? 'login' : 'organization') : verb.credential === 'none' ? 'none' : 'organization';
99
+ // In live mode, always the login, never an organization token: the API answers login_required
100
+ // to anything else, and to no token at all.
101
+ let bearer = live
102
+ ? 'login'
103
+ : verb.credential === 'login' ? 'login' : verb.credential === 'organization|login' ? (login && !org.token?.startsWith('sbx_') ? 'login' : 'organization') : verb.credential === 'none' ? 'none' : 'organization';
88
104
  // members and roles with an organization token and no login: the API says what is needed (a
89
105
  // claim for a sandbox, a login for a claimed organization). organizations and auth stop here.
90
- if (bearer === 'login' && !login && org.token && schema.noun !== 'organizations' && schema.noun !== 'auth')
106
+ if (!live && bearer === 'login' && !login && org.token && schema.noun !== 'organizations' && schema.noun !== 'auth')
91
107
  bearer = 'organization';
92
108
  // A verb that needs an organization token: say which is missing before the API does.
93
109
  if (bearer === 'organization' && verb.state !== 'none' && !org.token) {
@@ -100,20 +116,29 @@ async function executeOnce(argv, opts) {
100
116
  // The login names its organization with ?org=, from --org, C11R_ORG, .c11r.json or the default:
101
117
  // never a guess from the only token stored, which may be a sandbox the login does not reach.
102
118
  const loginOrg = org.source && org.source !== 'only_stored' ? org.org : null;
103
- if (cmd.globals.live && !verb.live) {
104
- fail('state_required', `c11r ${schema.noun} ${verb.name} does not run in live mode.`, { required_state: 'claimed', path: 'live', next_step: fixWith(line.replace(/\s--live\b/, '')) });
105
- }
106
119
  let confirmed = false;
107
120
  const confirmFlags = verb.confirmIf.filter((k) => parsed.values[k] !== undefined && parsed.values[k] !== false);
108
- if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun) {
121
+ // In live mode the API asks for the verbs it confirms, with a sentence naming the action, the
122
+ // amount and the mode ("Refunds EUR 1.00 on order N, in LIVE mode, real money."): the CLI sends
123
+ // without confirm=true and shows that 428 as it is, or asks it in a terminal. --yes sends
124
+ // confirm=true on every live write, payments capture included.
125
+ const apiAsks = live && (verb.confirm || confirmFlags.length > 0);
126
+ if (live && cmd.globals.yes && verb.method !== 'GET')
127
+ confirmed = true;
128
+ if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun && !apiAsks) {
109
129
  const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
110
- await confirm(what.charAt(0).toUpperCase() + what.slice(1), line, { yes: cmd.globals.yes, prompter: opts.prompter });
130
+ const sentence = what.charAt(0).toUpperCase() + what.slice(1);
131
+ await confirm(sentence, line, { yes: cmd.globals.yes, prompter: opts.prompter });
111
132
  confirmed = true;
112
133
  }
134
+ // A live secret goes only to a file the human chose: never the default folder, never printed.
135
+ if (live && verb.secret && parsed.values.out === undefined) {
136
+ fail('missing_value', `${named} --live answers a live secret: it is written only to a file the human chooses, with --out.`, { path: 'out', next_step: fixWith(`${line} --out FILE`) });
137
+ }
113
138
  // Where a secret goes, checked before the request makes one.
114
139
  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;
115
- const ctx = { live: cmd.globals.live, token: bearer === 'login' ? await loginToken() : org.token };
116
- const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
140
+ const ctx = { live, token: live ? (login ? await loginToken() : null) : bearer === 'login' ? await loginToken() : org.token };
141
+ const orgQuery = liveOrg ? { org: liveOrg } : bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
117
142
  const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
118
143
  const run = {
119
144
  globals: cmd.globals,
@@ -130,6 +155,15 @@ async function executeOnce(argv, opts) {
130
155
  return await sendOnce(req);
131
156
  }
132
157
  catch (e) {
158
+ // The API's own confirmation, in live: as it is with no terminal; asked with its words in one.
159
+ if (live && !confirmed && e instanceof CliError && e.code === 'confirmation_required') {
160
+ const p = opts.prompter ?? terminal;
161
+ if (!p.interactive)
162
+ throw e;
163
+ await confirm(e.body.message, line, { yes: false, prompter: p, sentence: e.body.message });
164
+ confirmed = true;
165
+ return sendOnce(req);
166
+ }
133
167
  // A login token Commerce Layer no longer takes: mint a new one, once.
134
168
  if (!(e instanceof CliError && e.code === 'login_required') || !login)
135
169
  throw e;
@@ -139,16 +173,51 @@ async function executeOnce(argv, opts) {
139
173
  throw e;
140
174
  ctx.token = await loginToken({ fresh: bearer === 'login' });
141
175
  run.bearer = 'login';
142
- if (loginOrg && schema.noun !== 'organizations')
176
+ if (!live && loginOrg && schema.noun !== 'organizations')
143
177
  Object.assign(orgQuery, { org: loginOrg });
144
178
  return sendOnce(req);
145
179
  }
146
180
  },
147
181
  };
148
- const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
149
- const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
150
- const out = drifted(filesGone(secretOf(r, run, secretPlan), run), run);
151
- return unknown.length ? { ...out, warnings: [...unknown, ...out.warnings] } : out;
182
+ // A secret read from a file (provider or payment keys) is never printed, whatever the answer
183
+ // holds: a dry run or an error that echoes it shows [secret] in its place.
184
+ const secrets = secretValues(verb, parsed.values);
185
+ try {
186
+ const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
187
+ const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
188
+ const out = drifted(filesGone(secretOf(r, run, secretPlan), run), run);
189
+ return scrub(unknown.length ? { ...out, warnings: [...unknown, ...out.warnings] } : out, secrets);
190
+ }
191
+ catch (e) {
192
+ if (e instanceof CliError && secrets.length)
193
+ throw new CliError(scrub(e.body, secrets), e.kind, scrub(e.state, secrets));
194
+ throw e;
195
+ }
196
+ }
197
+ /** The string values of the secret fields given, longest first. */
198
+ function secretValues(verb, values) {
199
+ const out = [];
200
+ const walk = (v) => {
201
+ if (typeof v === 'string' && v.length >= 4)
202
+ out.push(v);
203
+ else if (v && typeof v === 'object')
204
+ for (const x of Object.values(v))
205
+ walk(x);
206
+ };
207
+ for (const spec of Object.values(verb.flags))
208
+ if (spec.input?.kind === 'secret')
209
+ walk(values[spec.input.target]);
210
+ return [...new Set(out)].sort((a, b) => b.length - a.length);
211
+ }
212
+ function scrub(v, secrets) {
213
+ if (!secrets.length || v === undefined)
214
+ return v;
215
+ let text = JSON.stringify(v);
216
+ if (text === undefined)
217
+ return v;
218
+ for (const s of secrets)
219
+ text = text.split(JSON.stringify(s).slice(1, -1)).join('[secret]');
220
+ return JSON.parse(text);
152
221
  }
153
222
  /**
154
223
  * A value outside a flag's documented values (known: webhook topics): not enforced, so it is sent,
@@ -185,7 +254,7 @@ function drifted(r, run) {
185
254
  return r;
186
255
  const data = (r.data && typeof r.data === 'object' ? r.data : {});
187
256
  const id = [run.parsed.id, data.code, data.name, data.id].find((k) => typeof k === 'string' && k !== '');
188
- const warning = markDrift(run.org.org, { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
257
+ const warning = markDrift(run.org.org, run.globals.live ? 'live' : 'test', { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
189
258
  return warning ? { ...r, warnings: [...r.warnings, warning] } : r;
190
259
  }
191
260
  /**
@@ -198,6 +267,10 @@ function filesGone(r, run) {
198
267
  return r;
199
268
  if (!Object.values(run.schema.verbs).some((v) => v.secret))
200
269
  return r;
270
+ // In live mode the secret went to a file the human chose, which is theirs to delete; the
271
+ // secrets folder holds the test mode's files of the same organization, which stay.
272
+ if (run.globals.live)
273
+ return { ...r, warnings: [...r.warnings, OUT_NOT_TRACKED] };
201
274
  const data = (r.data && typeof r.data === 'object' ? r.data : {});
202
275
  const keys = [run.parsed.id, data.id, data.name, data.code].filter((k) => typeof k === 'string' && k !== '');
203
276
  const removed = removeRecordFiles(run.org.org, run.schema.noun, keys);
package/dist/help.js CHANGED
@@ -10,7 +10,7 @@ export const GLOBAL_FLAG_HELP = {
10
10
  '--dry-run': 'On a write, the API checks everything and says what would change, and changes nothing. Reads ignore it.',
11
11
  '--yes': 'Confirms a destructive verb in advance. Without it a destructive verb asks in the terminal, or, with no terminal, stops with confirmation_required.',
12
12
  '--org <slug>': 'The organization to act on, by its stored token. Without it: C11R_ORG, then the folder\'s .c11r.json, then the only stored organization.',
13
- '--live': 'Acts on live data, on a claimed organization. Without it every command works in test mode.',
13
+ '--live': 'Acts on live data, on a claimed organization named with --org, with the human\'s login. Without it every command works in test mode.',
14
14
  '--help': 'Describes the noun or verb it follows, from its schema.',
15
15
  };
16
16
  export const EXIT_CODE_HELP = {
package/dist/project.js CHANGED
@@ -7,7 +7,10 @@
7
7
  // .c11r/drift the Configure writes made since, in a folder whose store.yml names the
8
8
  // same organization: config plan mentions them, config export clears them.
9
9
  //
10
- // store.yml carries the export's meta ({ org, exported_at }) as its first key; the CLI takes it
10
+ // Live is a mode of the same organization, with the same slug: store.yml, the export record and
11
+ // the drift notes say which mode they are of, so test and live notes never mix.
12
+ //
13
+ // store.yml carries the export's meta ({ org, mode, exported_at }) as its first key; the CLI takes it
11
14
  // off before it sends the file, so the API sees { <noun>: [records] } only.
12
15
  import crypto from 'node:crypto';
13
16
  import fs from 'node:fs';
@@ -15,6 +18,7 @@ import path from 'node:path';
15
18
  import YAML from 'yaml';
16
19
  import { projectDir } from './config.js';
17
20
  import { fail } from './errors.js';
21
+ const sameSource = (a, b) => a.org !== null && a.org === b.org && a.mode === b.mode;
18
22
  export const STORE_FILE = 'store.yml';
19
23
  export const PRICE_LISTS_DIR = 'price_lists';
20
24
  const NOTES_DIR = '.c11r';
@@ -57,16 +61,18 @@ export function readStoreFiles(dir = projectDir()) {
57
61
  price_lists[n.slice(0, -4)] = fs.readFileSync(path.join(csvDir, n), 'utf8');
58
62
  return { store, price_lists, org };
59
63
  }
60
- /** The organization the folder's store.yml names, or null without one. */
61
- export function storeFileOrg(dir = projectDir()) {
64
+ /** The organization and mode the folder's store.yml names; org null without one. */
65
+ export function storeFileSource(dir = projectDir()) {
62
66
  try {
63
67
  const doc = YAML.parse(fs.readFileSync(path.join(dir, STORE_FILE), 'utf8'));
64
- return typeof doc?.meta?.org === 'string' ? doc.meta.org : null;
68
+ return { org: typeof doc?.meta?.org === 'string' ? doc.meta.org : null, mode: doc?.meta?.mode === 'live' ? 'live' : 'test' };
65
69
  }
66
70
  catch {
67
- return null;
71
+ return { org: null, mode: 'test' };
68
72
  }
69
73
  }
74
+ /** Whether the folder's store.yml is of this organization in this mode. */
75
+ export const storeFileIs = (org, mode, dir = projectDir()) => sameSource(storeFileSource(dir), { org, mode });
70
76
  /**
71
77
  * Writes an export: store.yml (meta first) and price_lists/<code>.csv. A file there that differs
72
78
  * from what the last export wrote (or that no export wrote) is the user's: without overwrite,
@@ -76,15 +82,19 @@ export function storeFileOrg(dir = projectDir()) {
76
82
  export function writeStoreFiles(data, opts) {
77
83
  const dir = opts.dir ?? projectDir();
78
84
  const last = readRecord(dir);
79
- const meta = { org: data.meta?.org ?? null, exported_at: data.meta?.exported_at ?? new Date().toISOString() };
85
+ const meta = { org: data.meta?.org ?? null, mode: opts.mode, exported_at: data.meta?.exported_at ?? new Date().toISOString() };
80
86
  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
87
  const files = { [STORE_FILE]: yaml };
82
88
  for (const [code, csv] of Object.entries(data.price_lists ?? {}))
83
89
  files[path.join(PRICE_LISTS_DIR, `${safeName(code)}.csv`)] = csv;
90
+ // Files of another organization or mode are not the last export's to replace: --yes first.
91
+ const other = last.org !== null && !sameSource(last, meta);
84
92
  const changed = (rel) => {
85
93
  const abs = path.join(dir, rel);
86
94
  if (!fs.existsSync(abs))
87
95
  return false;
96
+ if (other)
97
+ return true;
88
98
  const now = hashOf(fs.readFileSync(abs, 'utf8'));
89
99
  return last.files[rel] !== now;
90
100
  };
@@ -110,7 +120,7 @@ export function writeStoreFiles(data, opts) {
110
120
  fs.rmSync(path.join(dir, rel), { force: true });
111
121
  removed.push(path.join(dir, rel));
112
122
  }
113
- writeRecord(dir, { org: meta.org, exported_at: meta.exported_at, files: Object.fromEntries(Object.entries(files).map(([rel, text]) => [rel, hashOf(text)])) });
123
+ writeRecord(dir, { org: meta.org, mode: meta.mode, exported_at: meta.exported_at, files: Object.fromEntries(Object.entries(files).map(([rel, text]) => [rel, hashOf(text)])) });
114
124
  clearDrift(dir);
115
125
  return { written, removed, dir };
116
126
  }
@@ -118,8 +128,8 @@ export function writeStoreFiles(data, opts) {
118
128
  * After a Configure write: in a folder whose store.yml names this organization, notes it in
119
129
  * .c11r/drift and answers the warning. Elsewhere, nothing.
120
130
  */
121
- export function markDrift(org, entry, dir = projectDir()) {
122
- if (storeFileOrg(dir) !== org)
131
+ export function markDrift(org, mode, entry, dir = projectDir()) {
132
+ if (!storeFileIs(org, mode, dir))
123
133
  return null;
124
134
  const list = readDrift(dir);
125
135
  list.push({ ...entry, at: new Date().toISOString() });
@@ -153,10 +163,10 @@ export function driftWarning(list) {
153
163
  function readRecord(dir) {
154
164
  try {
155
165
  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 : {} };
166
+ return { org: v.org ?? null, mode: v.mode === 'live' ? 'live' : 'test', exported_at: v.exported_at ?? null, files: v.files && typeof v.files === 'object' ? v.files : {} };
157
167
  }
158
168
  catch {
159
- return { org: null, exported_at: null, files: {} };
169
+ return { org: null, mode: 'test', exported_at: null, files: {} };
160
170
  }
161
171
  }
162
172
  function writeRecord(dir, r) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "1.0.0-dev.14",
3
+ "version": "1.0.0-dev.16",
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": [