@commercelayer/agent-cli 1.0.0-dev.4 → 1.0.0-dev.5
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 +3 -3
- package/dist/argv.js +5 -1
- package/dist/builtins/loops.js +8 -8
- package/dist/engine.js +3 -3
- package/dist/help.js +3 -2
- package/dist/schema.js +1 -0
- package/dist/secrets.js +23 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
|
|
|
28
28
|
| `c11r schema <noun>` | The noun's schema document: fields, filters, verbs, which verbs are destructive and which a loop may automate. |
|
|
29
29
|
| `c11r sandbox create [--name] [--seed]` | An anonymous sandbox. Its token is stored on this machine and never printed; the folder is linked to it. |
|
|
30
30
|
| `c11r sandbox get`, `reset`, `claim`, `destroy` | The sandbox's details; a fresh organization behind the same token; a claim URL for the human; the end of the sandbox. |
|
|
31
|
-
| `c11r loops create <name> --skill --every --allow [--max-total] [--market]` | A loop in `loops.yml`: a skill, a policy and a cadence. Also `list`, `get`, `update`, `delete`. |
|
|
31
|
+
| `c11r loops create <name> --skill --every [--allow] [--max-total] [--market]` | A loop in `loops.yml`: a skill, a policy and a cadence; with no `--allow` it only reports what is waiting. Also `list`, `get`, `update`, `delete`. |
|
|
32
32
|
| `c11r loops run <name>` | One pass of a loop. The CLI is not a daemon: a scheduler repeats the pass. |
|
|
33
33
|
| `c11r report sales [--from] [--to] [--by] [--interval] [--market] [--top]` | Sales in a window, from the Metrics API. A plain noun: its flags come from the API's schema. |
|
|
34
34
|
| `c11r auth login [--client-id] [--secret-file] [--keep-file]` | The human's own Commerce Layer login, from their Provisioning API application. See below. |
|
|
@@ -50,7 +50,7 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
|
|
|
50
50
|
|
|
51
51
|
`organizations create` and `organizations connect <slug>` answer an organization token once: it is stored like a sandbox token. `connect --revoke-others` also ends every other token of that organization, and asks for confirmation. With a login and no organization token for the organization named, an organization noun stops with `no_credentials` and the next step `c11r organizations connect <slug>`. `members` and `roles` with an organization token and no login are sent with that token, and the API says what is needed: a claim for a sandbox, a login for a claimed organization. `organizations` with no login stops here with `login_required`.
|
|
52
52
|
|
|
53
|
-
`credentials create` and `credentials token` answer a secret once. c11r writes it to a file with mode 600 and prints the file, the format and a hint, never the value: by default `~/.config/c11r/secrets/<org>/<name>.env` (`<id>-token.env` for a token), or `--out <path>`, as `KEY=value` lines or with `--format json` one JSON object. The file is checked before the request is sent; one there already is replaced only with `--yes`. A file inside a git repository that git does not ignore gets the warning `secret_not_ignored`. With `--dry-run` nothing is written and `secret_file` says where it would go. Each file records the record it belongs to. A `delete` of a record whose noun answers secrets (`credentials`, `webhooks`) removes the files c11r wrote for it in `~/.config/c11r/secrets/<org>/` (a credential's secret and tokens, a webhook's shared secret), found by the id, name or code given, and lists them in `removed_files`; `sandbox destroy` removes the whole folder of that organization and names it in `forgotten.secrets`. A file written with `--out` is not tracked and stays: the warning `out_files_not_tracked` says so 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.
|
|
53
|
+
`credentials create` and `credentials token` answer a secret once. c11r writes it to a file with mode 600 and prints the file, the format and a hint, never the value: by default `~/.config/c11r/secrets/<org>/<name>.env` (`<id>-token.env` for a token; a record with no name, such as a gift card, `gift-card-<id>.env` after the id it is given), or `--out <path>`, as `KEY=value` lines or with `--format json` one JSON object. The file is checked before the request is sent; one there already is replaced only with `--yes`. A file inside a git repository that git does not ignore gets the warning `secret_not_ignored`. With `--dry-run` nothing is written and `secret_file` says where it would go. Each file records the record it belongs to. A `delete` of a record whose noun answers secrets (`credentials`, `webhooks`) removes the files c11r wrote for it in `~/.config/c11r/secrets/<org>/` (a credential's secret and tokens, a webhook's shared secret), found by the id, name or code given, and lists them in `removed_files`; `sandbox destroy` removes the whole folder of that organization and names it in `forgotten.secrets`. A file written with `--out` is not tracked and stays: the warning `out_files_not_tracked` says so on the delete of the record whose secret went to `--out` on this machine, and on `sandbox destroy` of an organization that had one. Any other delete has no such warning. A token file carries `CL_BASE_ENDPOINT` beside the token. `sales-calls book` answers a cancel token once: it is kept in `credentials.json` and `sales-calls cancel <booking>` sends it.
|
|
54
54
|
|
|
55
55
|
## Global flags
|
|
56
56
|
|
|
@@ -63,7 +63,7 @@ Never from a flag or the environment. c11r exchanges the pair for a two hour tok
|
|
|
63
63
|
| `--live` | Live data, on a claimed organization: `?mode=live` on every request. Without it, test mode. |
|
|
64
64
|
| `--help` | Describes the noun or verb it follows, from its schema. |
|
|
65
65
|
|
|
66
|
-
Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sandbox reset` and `destroy`) ask for confirmation in a terminal, then send `confirm=true`. With no terminal, as when an agent or a schedule runs the CLI, they never wait: without `--yes` they stop at once with `confirmation_required` and exit code 3.
|
|
66
|
+
Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sandbox reset` and `destroy`), and any verb the schema marks `confirm`, ask for confirmation in a terminal, then send `confirm=true`. With no terminal, as when an agent or a schedule runs the CLI, they never wait: without `--yes` they stop at once with `confirmation_required` and exit code 3.
|
|
67
67
|
|
|
68
68
|
## Which organization
|
|
69
69
|
|
package/dist/argv.js
CHANGED
|
@@ -60,7 +60,11 @@ export function parseFields(noun, verb, rest) {
|
|
|
60
60
|
id = a;
|
|
61
61
|
continue;
|
|
62
62
|
}
|
|
63
|
-
|
|
63
|
+
// A verb with no id (stock set): say which flags it does take, as --sku for the sku.
|
|
64
|
+
const sent = Object.entries(verb.flags).filter(([, f]) => f.to !== 'local');
|
|
65
|
+
const required = sent.filter(([, f]) => f.required);
|
|
66
|
+
const takes = (required.length ? required : sent).map(([k]) => toFlag(k));
|
|
67
|
+
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
68
|
}
|
|
65
69
|
const [flag, inline] = splitInline(a);
|
|
66
70
|
const key = toKey(flag);
|
package/dist/builtins/loops.js
CHANGED
|
@@ -20,7 +20,7 @@ const EVERY = /^[1-9]\d*(m|h|d)$/;
|
|
|
20
20
|
const policyFlags = (required) => [
|
|
21
21
|
{ name: 'skill', type: 'string', required, description: 'The skill or playbook the loop runs, as in manage-orders or playbook-mail-to-order: one a noun\'s schema names in loop.skill.' },
|
|
22
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.' },
|
|
23
|
-
{ name: 'allow', type: 'array',
|
|
23
|
+
{ name: 'allow', type: 'array', description: 'A verb the policy allows, repeated: approve, payments.capture. Only verbs the noun\'s schema lists in loop.verbs. Without it the loop only reports what is waiting.' },
|
|
24
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.' },
|
|
25
25
|
{ name: 'market', type: 'array', description: 'A market code the loop acts in, repeated. Items in other markets wait. Without it, every market.' },
|
|
26
26
|
];
|
|
@@ -78,9 +78,11 @@ export function readLoops(file) {
|
|
|
78
78
|
const out = {};
|
|
79
79
|
for (const [name, d] of Object.entries(raw.loops ?? {})) {
|
|
80
80
|
const def = d;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 } };
|
|
84
86
|
}
|
|
85
87
|
return out;
|
|
86
88
|
}
|
|
@@ -119,7 +121,7 @@ async function write(run, name, file) {
|
|
|
119
121
|
skill: v.skill ?? existing.skill,
|
|
120
122
|
every: v.every ?? existing.every,
|
|
121
123
|
policy: {
|
|
122
|
-
allow: v.allow ?? existing
|
|
124
|
+
allow: v.allow ?? existing?.policy.allow ?? [],
|
|
123
125
|
...(v.max_total !== undefined ? { max_total: maxTotal(v.max_total) } : existing?.policy.max_total !== undefined ? { max_total: existing.policy.max_total } : {}),
|
|
124
126
|
...(v.market !== undefined ? { markets: v.market } : existing?.policy.markets ? { markets: existing.policy.markets } : {}),
|
|
125
127
|
},
|
|
@@ -173,8 +175,6 @@ function maxTotal(items) {
|
|
|
173
175
|
* verb its loop does not list.
|
|
174
176
|
*/
|
|
175
177
|
async function checkPolicy(def) {
|
|
176
|
-
if (!def.policy.allow.length)
|
|
177
|
-
fail('missing_argument', 'A loop needs at least one --allow.', { path: 'allow' });
|
|
178
178
|
const skills = await loopSkills();
|
|
179
179
|
const allowed = skills.get(def.skill);
|
|
180
180
|
if (!allowed) {
|
|
@@ -188,7 +188,7 @@ async function checkPolicy(def) {
|
|
|
188
188
|
}
|
|
189
189
|
for (const verb of def.policy.allow) {
|
|
190
190
|
if (!allowed.verbs.includes(verb)) {
|
|
191
|
-
fail('policy_forbidden', `A ${def.skill} loop may not allow ${verb}. It may allow ${allowed.verbs.join(', ')}
|
|
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}`) });
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
return [];
|
package/dist/engine.js
CHANGED
|
@@ -73,13 +73,13 @@ export async function execute(argv, opts = {}) {
|
|
|
73
73
|
}
|
|
74
74
|
let confirmed = false;
|
|
75
75
|
const confirmFlags = verb.confirmIf.filter((k) => parsed.values[k] !== undefined && parsed.values[k] !== false);
|
|
76
|
-
if ((verb.destructive || confirmFlags.length) && !cmd.globals.dryRun) {
|
|
76
|
+
if ((verb.destructive || verb.confirm || confirmFlags.length) && !cmd.globals.dryRun) {
|
|
77
77
|
const what = `${verb.name} ${schema.noun}${parsed.id ? ` ${parsed.id}` : ''}${confirmFlags.length ? ` with ${confirmFlags.map(toFlag).join(' ')}` : ''}`;
|
|
78
78
|
await confirm(what.charAt(0).toUpperCase() + what.slice(1), line, { yes: cmd.globals.yes, prompter: opts.prompter });
|
|
79
79
|
confirmed = true;
|
|
80
80
|
}
|
|
81
81
|
// Where a secret goes, checked before the request makes one.
|
|
82
|
-
const secretPlan = verb.secret ? planSecret({ org: org.org, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
|
|
82
|
+
const secretPlan = verb.secret ? planSecret({ org: org.org, noun: schema.noun, verb: verb.name, id: parsed.id, values: parsed.values, yes: cmd.globals.yes }) : null;
|
|
83
83
|
const ctx = { live: cmd.globals.live, token: bearer === 'login' ? await loginToken() : org.token };
|
|
84
84
|
const orgQuery = bearer === 'login' && loginOrg && schema.noun !== 'organizations' && schema.noun !== 'auth' ? { org: loginOrg } : {};
|
|
85
85
|
const sendOnce = (req) => request({ ...req, query: { ...(req.query ?? {}), ...orgQuery }, dryRun: req.dryRun ?? cmd.globals.dryRun, confirm: confirmed || req.confirm }, ctx);
|
|
@@ -139,7 +139,7 @@ function secretOf(r, run, plan) {
|
|
|
139
139
|
const secret = data && typeof data === 'object' ? data.secret : undefined;
|
|
140
140
|
if (secret && typeof secret === 'object' && 'value' in secret) {
|
|
141
141
|
// A verb the schema did not mark: still never printed.
|
|
142
|
-
const p = plan ?? planSecret({ org: run.org.org, verb: run.verb.name, id: run.parsed.id, values: run.parsed.values, yes: true });
|
|
142
|
+
const p = plan ?? planSecret({ org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id, values: run.parsed.values, yes: true });
|
|
143
143
|
return writeSecret(r, p, { org: run.org.org, noun: run.schema.noun, verb: run.verb.name, id: run.parsed.id });
|
|
144
144
|
}
|
|
145
145
|
if (plan && run.globals.dryRun)
|
package/dist/help.js
CHANGED
|
@@ -21,7 +21,7 @@ export const EXIT_CODE_HELP = {
|
|
|
21
21
|
[EXIT.not_found]: 'no such resource',
|
|
22
22
|
[EXIT.rejected]: 'the API or Commerce Layer refused the request as it is',
|
|
23
23
|
[EXIT.unavailable]: 'the API did not answer, is busy, or answered with an error of its own',
|
|
24
|
-
[EXIT.auth]: 'a wrong or revoked token,
|
|
24
|
+
[EXIT.auth]: 'a wrong or revoked token, no token stored for the organization named, or no login (login_required)',
|
|
25
25
|
};
|
|
26
26
|
export function indexHelp(builtins, api) {
|
|
27
27
|
return {
|
|
@@ -53,7 +53,8 @@ export function verbHelp(s, v) {
|
|
|
53
53
|
state: v.state,
|
|
54
54
|
...(v.plan ? { plan: v.plan } : {}),
|
|
55
55
|
...(v.refused ? { served: false, refused_by: v.refused.by } : {}),
|
|
56
|
-
...(v.destructive ? { destructive: true
|
|
56
|
+
...(v.destructive ? { destructive: true } : {}),
|
|
57
|
+
...(v.confirm ? { confirmation: 'Asks for confirmation unless --yes; with no terminal and no --yes it stops with confirmation_required.' } : {}),
|
|
57
58
|
...(v.loopable ? { loopable: 'A loop policy may allow this verb.' } : {}),
|
|
58
59
|
...(v.live ? {} : { live: 'Not in live mode.' }),
|
|
59
60
|
flags: Object.entries(v.flags).map(([key, f]) => flagHelp(key, f)),
|
package/dist/schema.js
CHANGED
|
@@ -53,6 +53,7 @@ export function parseSchema(raw, expected) {
|
|
|
53
53
|
method,
|
|
54
54
|
id: v.path ? /\{[^}]+\}/.test(v.path) : !['list', 'create'].includes(name),
|
|
55
55
|
destructive,
|
|
56
|
+
confirm: destructive || v.confirm === true,
|
|
56
57
|
// A policy never deletes nor returns money, whatever else the document says.
|
|
57
58
|
loopable: Boolean(v.loopable) && !v.deletes && !v.returns_money && name !== 'delete',
|
|
58
59
|
live: v.live !== false,
|
package/dist/secrets.js
CHANGED
|
@@ -14,6 +14,8 @@ import os from 'node:os';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { markOutWritten } from './config.js';
|
|
16
16
|
import { fail } from './errors.js';
|
|
17
|
+
/** Where the record's id goes in a file named after it, once the answer gives the id. */
|
|
18
|
+
const ID_SLOT = '<id>';
|
|
17
19
|
export function secretsDir() {
|
|
18
20
|
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
19
21
|
return path.join(base, 'c11r', 'secrets');
|
|
@@ -23,10 +25,14 @@ const safe = (s) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|
|
|
|
23
25
|
export function planSecret(opts) {
|
|
24
26
|
const out = opts.values.out;
|
|
25
27
|
const format = opts.values.format ?? null;
|
|
26
|
-
|
|
28
|
+
// By the name given, else the id given; a record with neither (a gift card) by the id it is
|
|
29
|
+
// given, so each has a file of its own: gift-card-<id>.env.
|
|
30
|
+
const named = opts.values.name ?? opts.id;
|
|
31
|
+
const byRecord = !out && opts.verb !== 'token' && (named === undefined || named === null || named === '');
|
|
32
|
+
const base = opts.verb === 'token' ? `${safe(opts.id ?? 'credential')}-token` : byRecord ? `${safe(opts.noun.replace(/s$/, ''))}-${ID_SLOT}` : safe(String(named));
|
|
27
33
|
const file = out ? path.resolve(out) : path.join(secretsDir(), safe(opts.org ?? 'default'), base);
|
|
28
|
-
const plan = { file: out || !format ? file : `${file}.${format}`, format, fixed: Boolean(out), overwrite: opts.yes };
|
|
29
|
-
const candidates = plan.fixed || format ? [plan.file] : [`${file}.env`, `${file}.json`];
|
|
34
|
+
const plan = { file: out || !format ? file : `${file}.${format}`, format, fixed: Boolean(out), overwrite: opts.yes, byRecord };
|
|
35
|
+
const candidates = byRecord ? [] : plan.fixed || format ? [plan.file] : [`${file}.env`, `${file}.json`];
|
|
30
36
|
for (const c of candidates) {
|
|
31
37
|
let st = null;
|
|
32
38
|
try {
|
|
@@ -43,7 +49,7 @@ export function planSecret(opts) {
|
|
|
43
49
|
fail('conflict', `${c} exists. c11r replaces it only with --yes, or writes the secret elsewhere with --out.`, { path: 'out', next_step: { action: 'fix_request', command: null }, details: { file: c } }, 'rejected');
|
|
44
50
|
}
|
|
45
51
|
}
|
|
46
|
-
const dir = path.dirname(
|
|
52
|
+
const dir = path.dirname(plan.file);
|
|
47
53
|
const existing = nearestExisting(dir);
|
|
48
54
|
try {
|
|
49
55
|
fs.accessSync(existing, fs.constants.W_OK);
|
|
@@ -74,7 +80,7 @@ export function writeSecret(r, plan, ctx) {
|
|
|
74
80
|
const credentialId = str(rec.id) ?? ctx.id;
|
|
75
81
|
const record = { noun: ctx.noun, id: credentialId ?? null, name: str(rec.name), code: str(rec.code) };
|
|
76
82
|
const format = plan.format ?? (secret.format === 'json' ? 'json' : 'env');
|
|
77
|
-
const file = plan.fixed || plan.format ? plan.file : `${plan.file}.${format}
|
|
83
|
+
const file = byRecordFile(plan.fixed || plan.format ? plan.file : `${plan.file}.${format}`, plan, credentialId);
|
|
78
84
|
const env = secret.env && Object.keys(secret.env).length ? { ...secret.env } : { [`CL_${(secret.kind ?? 'secret').toUpperCase()}`]: String(secret.value ?? '') };
|
|
79
85
|
// The endpoint goes with every secret, so the file is enough to call Commerce Layer.
|
|
80
86
|
if (!env.CL_BASE_ENDPOINT && typeof data.base_endpoint === 'string')
|
|
@@ -111,6 +117,18 @@ export function writeSecret(r, plan, ctx) {
|
|
|
111
117
|
warnings,
|
|
112
118
|
};
|
|
113
119
|
}
|
|
120
|
+
/** A file named after its record: the id in its slot, and a file there already left alone. */
|
|
121
|
+
function byRecordFile(file, plan, id) {
|
|
122
|
+
if (!plan.byRecord)
|
|
123
|
+
return file;
|
|
124
|
+
// The id as the API gives it, case kept: ids are case-sensitive (lqgwUkXlBw).
|
|
125
|
+
const slot = (id ?? crypto.randomBytes(4).toString('hex')).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '') || crypto.randomBytes(4).toString('hex');
|
|
126
|
+
const named = file.replace(ID_SLOT, slot);
|
|
127
|
+
if (plan.overwrite || !fs.existsSync(named))
|
|
128
|
+
return named;
|
|
129
|
+
const ext = path.extname(named);
|
|
130
|
+
return `${named.slice(0, -ext.length || undefined)}-${crypto.randomBytes(3).toString('hex')}${ext}`;
|
|
131
|
+
}
|
|
114
132
|
/** What a dry run says about the file: where it would go and whether one is there. */
|
|
115
133
|
export function describePlan(plan) {
|
|
116
134
|
const files = plan.fixed || plan.format ? [plan.file] : [`${plan.file}.env`, `${plan.file}.json`];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commercelayer/agent-cli",
|
|
3
|
-
"version": "1.0.0-dev.
|
|
3
|
+
"version": "1.0.0-dev.5",
|
|
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": [
|