@commercelayer/agent-cli 1.0.0-dev.14 → 1.0.0-dev.15
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 -3
- package/dist/builtins/auth.js +17 -2
- package/dist/builtins/extensions.js +26 -6
- package/dist/engine.js +72 -14
- package/dist/help.js +1 -1
- package/dist/project.js +21 -11
- package/package.json +1 -1
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
|
|
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 destructive verb's confirmation names the mode and the organization: `Delete markets eu, in LIVE mode on S`. When the API supplies its own sentence (money, in a later step), that one is shown.
|
|
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
|
|
package/dist/builtins/auth.js
CHANGED
|
@@ -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
|
-
{
|
|
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,
|
|
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 =
|
|
206
|
+
const here = storeFileIs(run.org.org, run.globals.live ? 'live' : 'test');
|
|
187
207
|
const drift = readDrift();
|
|
188
|
-
if (name === 'plan' && drift.length && here
|
|
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
|
|
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
|
-
|
|
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,23 @@ 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
121
|
if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun) {
|
|
109
122
|
const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
|
|
110
|
-
|
|
123
|
+
// In live mode the sentence says so, and on which organization.
|
|
124
|
+
const sentence = `${what.charAt(0).toUpperCase() + what.slice(1)}${live ? `, in LIVE mode on ${liveOrg}` : ''}`;
|
|
125
|
+
await confirm(sentence, line, { yes: cmd.globals.yes, prompter: opts.prompter });
|
|
111
126
|
confirmed = true;
|
|
112
127
|
}
|
|
128
|
+
// A live secret goes only to a file the human chose: never the default folder, never printed.
|
|
129
|
+
if (live && verb.secret && parsed.values.out === undefined) {
|
|
130
|
+
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`) });
|
|
131
|
+
}
|
|
113
132
|
// Where a secret goes, checked before the request makes one.
|
|
114
133
|
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:
|
|
116
|
-
const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
|
|
134
|
+
const ctx = { live, token: live ? (login ? await loginToken() : null) : bearer === 'login' ? await loginToken() : org.token };
|
|
135
|
+
const orgQuery = liveOrg ? { org: liveOrg } : bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
|
|
117
136
|
const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
|
|
118
137
|
const run = {
|
|
119
138
|
globals: cmd.globals,
|
|
@@ -139,16 +158,51 @@ async function executeOnce(argv, opts) {
|
|
|
139
158
|
throw e;
|
|
140
159
|
ctx.token = await loginToken({ fresh: bearer === 'login' });
|
|
141
160
|
run.bearer = 'login';
|
|
142
|
-
if (loginOrg && schema.noun !== 'organizations')
|
|
161
|
+
if (!live && loginOrg && schema.noun !== 'organizations')
|
|
143
162
|
Object.assign(orgQuery, { org: loginOrg });
|
|
144
163
|
return sendOnce(req);
|
|
145
164
|
}
|
|
146
165
|
},
|
|
147
166
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
167
|
+
// A secret read from a file (provider or payment keys) is never printed, whatever the answer
|
|
168
|
+
// holds: a dry run or an error that echoes it shows [secret] in its place.
|
|
169
|
+
const secrets = secretValues(verb, parsed.values);
|
|
170
|
+
try {
|
|
171
|
+
const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
|
|
172
|
+
const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
|
|
173
|
+
const out = drifted(filesGone(secretOf(r, run, secretPlan), run), run);
|
|
174
|
+
return scrub(unknown.length ? { ...out, warnings: [...unknown, ...out.warnings] } : out, secrets);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
if (e instanceof CliError && secrets.length)
|
|
178
|
+
throw new CliError(scrub(e.body, secrets), e.kind, scrub(e.state, secrets));
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** The string values of the secret fields given, longest first. */
|
|
183
|
+
function secretValues(verb, values) {
|
|
184
|
+
const out = [];
|
|
185
|
+
const walk = (v) => {
|
|
186
|
+
if (typeof v === 'string' && v.length >= 4)
|
|
187
|
+
out.push(v);
|
|
188
|
+
else if (v && typeof v === 'object')
|
|
189
|
+
for (const x of Object.values(v))
|
|
190
|
+
walk(x);
|
|
191
|
+
};
|
|
192
|
+
for (const spec of Object.values(verb.flags))
|
|
193
|
+
if (spec.input?.kind === 'secret')
|
|
194
|
+
walk(values[spec.input.target]);
|
|
195
|
+
return [...new Set(out)].sort((a, b) => b.length - a.length);
|
|
196
|
+
}
|
|
197
|
+
function scrub(v, secrets) {
|
|
198
|
+
if (!secrets.length || v === undefined)
|
|
199
|
+
return v;
|
|
200
|
+
let text = JSON.stringify(v);
|
|
201
|
+
if (text === undefined)
|
|
202
|
+
return v;
|
|
203
|
+
for (const s of secrets)
|
|
204
|
+
text = text.split(JSON.stringify(s).slice(1, -1)).join('[secret]');
|
|
205
|
+
return JSON.parse(text);
|
|
152
206
|
}
|
|
153
207
|
/**
|
|
154
208
|
* A value outside a flag's documented values (known: webhook topics): not enforced, so it is sent,
|
|
@@ -185,7 +239,7 @@ function drifted(r, run) {
|
|
|
185
239
|
return r;
|
|
186
240
|
const data = (r.data && typeof r.data === 'object' ? r.data : {});
|
|
187
241
|
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 });
|
|
242
|
+
const warning = markDrift(run.org.org, run.globals.live ? 'live' : 'test', { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
|
|
189
243
|
return warning ? { ...r, warnings: [...r.warnings, warning] } : r;
|
|
190
244
|
}
|
|
191
245
|
/**
|
|
@@ -198,6 +252,10 @@ function filesGone(r, run) {
|
|
|
198
252
|
return r;
|
|
199
253
|
if (!Object.values(run.schema.verbs).some((v) => v.secret))
|
|
200
254
|
return r;
|
|
255
|
+
// In live mode the secret went to a file the human chose, which is theirs to delete; the
|
|
256
|
+
// secrets folder holds the test mode's files of the same organization, which stay.
|
|
257
|
+
if (run.globals.live)
|
|
258
|
+
return { ...r, warnings: [...r.warnings, OUT_NOT_TRACKED] };
|
|
201
259
|
const data = (r.data && typeof r.data === 'object' ? r.data : {});
|
|
202
260
|
const keys = [run.parsed.id, data.id, data.name, data.code].filter((k) => typeof k === 'string' && k !== '');
|
|
203
261
|
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
|
-
//
|
|
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
|
|
61
|
-
export function
|
|
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 (
|
|
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.
|
|
3
|
+
"version": "1.0.0-dev.15",
|
|
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": [
|