@commercelayer/agent-cli 0.8.0 → 1.0.0-dev.0
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 +95 -215
- package/dist/argv.js +214 -0
- package/dist/bin.js +15 -178
- package/dist/builtins/auth.js +169 -0
- package/dist/builtins/extensions.js +123 -0
- package/dist/builtins/index.js +8 -0
- package/dist/builtins/loops.js +215 -0
- package/dist/builtins/sandbox.js +186 -0
- package/dist/config.js +211 -0
- package/dist/confirm.js +56 -0
- package/dist/engine.js +173 -0
- package/dist/errors.js +146 -0
- package/dist/help.js +78 -0
- package/dist/http.js +117 -0
- package/dist/login.js +133 -0
- package/dist/resources.js +41 -0
- package/dist/schema.js +152 -0
- package/dist/schemas.js +72 -0
- package/dist/secrets.js +189 -0
- package/dist/version.js +4 -8
- package/package.json +4 -9
- package/dist/api/client.js +0 -190
- package/dist/api/server.js +0 -26
- package/dist/api/stub/db.js +0 -37
- package/dist/api/stub/http.js +0 -18
- package/dist/api/stub/index.js +0 -52
- package/dist/api/stub/locale.js +0 -52
- package/dist/api/stub/management.js +0 -409
- package/dist/api/stub/messages.js +0 -126
- package/dist/api/stub/orders.js +0 -625
- package/dist/api/stub/search.js +0 -78
- package/dist/api/stub/shopping.js +0 -673
- package/dist/api/stub/theme.js +0 -197
- package/dist/api/stub/ucp.js +0 -377
- package/dist/api/transport.js +0 -57
- package/dist/api/types.js +0 -4
- package/dist/assets.js +0 -100
- package/dist/catalog.js +0 -366
- package/dist/check.js +0 -187
- package/dist/commands.js +0 -1241
- package/dist/cursor.js +0 -31
- package/dist/files.js +0 -98
- package/dist/inbox/cursor.js +0 -11
- package/dist/inbox/fake.js +0 -95
- package/dist/inbox/imap.js +0 -139
- package/dist/inbox/mailbox.js +0 -25
- package/dist/inbox/message.js +0 -88
- package/dist/inspect.js +0 -230
- package/dist/languages.js +0 -49
- package/dist/links.js +0 -118
- package/dist/locales.js +0 -43
- package/dist/ops.js +0 -190
- package/dist/orderRequest.js +0 -141
- package/dist/output.js +0 -39
- package/dist/products.js +0 -151
- package/dist/promotions.js +0 -291
- package/dist/store.js +0 -379
- package/dist/storefront.js +0 -285
- package/dist/ucp/buy.js +0 -90
- package/dist/ucp/check.js +0 -360
- package/dist/ucp/client.js +0 -129
- package/dist/ucp/images.js +0 -62
- package/dist/ucp/platform.js +0 -90
- package/dist/ucp/shop.js +0 -357
- package/dist/ucp/signing.js +0 -67
- package/dist/web.js +0 -316
- package/dist/website.js +0 -390
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Code around plain nouns, whose schema is the API's: what happens on this machine.
|
|
2
|
+
//
|
|
3
|
+
// organizations create and connect answer an organization token once (org_..., contract section
|
|
4
|
+
// 21): it is stored like a sandbox token, in credentials.json with mode 600, and never printed.
|
|
5
|
+
// create sends an Idempotency-Key, as sandbox create does, and links the folder; connect links it
|
|
6
|
+
// only when no link names another organization.
|
|
7
|
+
//
|
|
8
|
+
// sales-calls book answers a cancel token once (section 20): stored the same way, by booking id,
|
|
9
|
+
// and sent by sales-calls cancel <booking> without the agent ever holding it.
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { credentialsFile, hintOf, outWritten, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
|
|
12
|
+
import { confirm, terminal } from '../confirm.js';
|
|
13
|
+
import { fail } from '../errors.js';
|
|
14
|
+
import { apiUrl } from '../http.js';
|
|
15
|
+
import { resourceRequest } from '../resources.js';
|
|
16
|
+
import { OUT_NOT_TRACKED, removeCredentialFiles } from '../secrets.js';
|
|
17
|
+
const NAME_MAX = 25;
|
|
18
|
+
export const organizations = {
|
|
19
|
+
async run(run) {
|
|
20
|
+
if (run.verb.name !== 'create' && run.verb.name !== 'connect')
|
|
21
|
+
return undefined;
|
|
22
|
+
const name = run.parsed.values.name;
|
|
23
|
+
if (run.verb.name === 'create' && name !== undefined && (name.trim() === '' || name.length > NAME_MAX)) {
|
|
24
|
+
fail('invalid_value', `--name ${JSON.stringify(name)} is ${name.trim() === '' ? 'empty' : `${name.length} characters`}: an organization name has 1 to ${NAME_MAX}.`, { path: 'name', next_step: { action: 'fix_request', command: null } });
|
|
25
|
+
}
|
|
26
|
+
const req = resourceRequest('organizations', run.verb, run.parsed.id, run.parsed.values);
|
|
27
|
+
// Ending every other token of the organization is confirmed: by the schema's confirm_if, or
|
|
28
|
+
// here for a schema without it.
|
|
29
|
+
if (run.verb.name === 'connect' && run.parsed.values.revoke_others && !run.globals.dryRun) {
|
|
30
|
+
if (!run.verb.confirmIf.includes('revoke_others'))
|
|
31
|
+
await confirm(`Connect ${run.parsed.id} and end every other organization token of it`, run.line, { yes: run.globals.yes, prompter: run.prompter ?? terminal });
|
|
32
|
+
req.confirm = true;
|
|
33
|
+
req.body = { ...req.body, confirm: true };
|
|
34
|
+
}
|
|
35
|
+
if (run.verb.name === 'create') {
|
|
36
|
+
req.idempotencyKey = `c11r-${crypto.randomBytes(16).toString('base64url')}`;
|
|
37
|
+
req.timeoutMs = 120_000;
|
|
38
|
+
}
|
|
39
|
+
const r = await run.send(req);
|
|
40
|
+
if (run.globals.dryRun)
|
|
41
|
+
return r;
|
|
42
|
+
const { token, ...data } = r.data ?? {};
|
|
43
|
+
const org = (data.org ?? data.slug ?? run.parsed.id);
|
|
44
|
+
if (typeof token !== 'string' || typeof org !== 'string')
|
|
45
|
+
fail('invalid_response', `organizations ${run.verb.name} answered without the organization and its token.`, { next_step: { action: 'none' } });
|
|
46
|
+
const hint = typeof data.token_hint === 'string' ? data.token_hint : hintOf(token);
|
|
47
|
+
storeOrg(org, { token, token_hint: hint, expires_at: data.expires_at ?? null, created_at: new Date().toISOString(), api_url: apiUrl() });
|
|
48
|
+
const warnings = r.warnings.filter((w) => w.code !== 'token_shown_once');
|
|
49
|
+
const existing = (() => {
|
|
50
|
+
try {
|
|
51
|
+
return readLink();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
let linked = null;
|
|
58
|
+
if (run.verb.name === 'create' || !existing || existing.org === org) {
|
|
59
|
+
const link = writeLink(org);
|
|
60
|
+
linked = link.file;
|
|
61
|
+
if (link.previous)
|
|
62
|
+
warnings.push({ code: 'relinked', message: `${link.file} named ${link.previous}; it now names ${org}. The token of ${link.previous} stays stored.` });
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
warnings.push({ code: 'not_linked', message: `${existing.file} names ${existing.org}, so this folder stays linked to it. --org ${org}, or c11r auth switch ${org}, acts on ${org}.` });
|
|
66
|
+
}
|
|
67
|
+
if (typeof data.revoked === 'number') {
|
|
68
|
+
warnings.push({ code: 'tokens_revoked', message: data.revoked ? `${data.revoked} other organization token${data.revoked === 1 ? '' : 's'} of ${org} no longer work${data.revoked === 1 ? 's' : ''}, on whatever machine held them.` : `No other organization token of ${org} was in use.` });
|
|
69
|
+
}
|
|
70
|
+
return { ...r, data: { ...data, org, token_hint: hint, credentials: credentialsFile(), linked }, warnings };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
export const salesCalls = {
|
|
74
|
+
positional: { book: 'slot' },
|
|
75
|
+
prepare(verb, parsed) {
|
|
76
|
+
// The cancel token stays on this machine: cancel takes it from there.
|
|
77
|
+
if (verb.name !== 'cancel' || parsed.id === undefined || parsed.values.cancel_token !== undefined)
|
|
78
|
+
return;
|
|
79
|
+
const stored = storedBooking(parsed.id);
|
|
80
|
+
if (stored)
|
|
81
|
+
parsed.values.cancel_token = stored.cancel_token;
|
|
82
|
+
else if (verb.flags.cancel_token) {
|
|
83
|
+
fail('no_credentials', `This machine has no cancel token for booking ${parsed.id}: it was booked elsewhere, or cancelled already.`, { path: 'id', next_step: { action: 'none' } }, 'auth');
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
async run(run) {
|
|
87
|
+
if (run.verb.name === 'book') {
|
|
88
|
+
const r = await run.send(resourceRequest('sales-calls', run.verb, run.parsed.id, run.parsed.values));
|
|
89
|
+
if (run.globals.dryRun)
|
|
90
|
+
return r;
|
|
91
|
+
const { cancel_token: cancelToken, ...data } = r.data ?? {};
|
|
92
|
+
const id = data.booking?.id;
|
|
93
|
+
if (typeof cancelToken === 'string' && id !== undefined) {
|
|
94
|
+
storeBooking(String(id), { cancel_token: cancelToken, start: data.booking?.start ?? null, created_at: new Date().toISOString() });
|
|
95
|
+
return { ...r, data: { ...data, cancel_token: 'stored', credentials: credentialsFile() }, warnings: r.warnings.filter((w) => w.code !== 'token_shown_once') };
|
|
96
|
+
}
|
|
97
|
+
return { ...r, data };
|
|
98
|
+
}
|
|
99
|
+
if (run.verb.name === 'cancel') {
|
|
100
|
+
const r = await run.send(resourceRequest('sales-calls', run.verb, run.parsed.id, run.parsed.values));
|
|
101
|
+
if (!run.globals.dryRun && run.parsed.id !== undefined)
|
|
102
|
+
storeBooking(run.parsed.id, null);
|
|
103
|
+
return r;
|
|
104
|
+
}
|
|
105
|
+
return undefined;
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
export const credentials = {
|
|
109
|
+
async run(run) {
|
|
110
|
+
if (run.verb.name !== 'delete')
|
|
111
|
+
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)
|
|
114
|
+
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] : [])] };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
export const extensions = { organizations, 'sales-calls': salesCalls, credentials };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The nouns with code of their own. Each brings a schema document in the API's format, so help,
|
|
2
|
+
// validation and confirmation are the engine's. report is a plain noun: the API's schema drives
|
|
3
|
+
// it. skills (c11r skills sync) comes with deliverable 4.
|
|
4
|
+
import { auth } from './auth.js';
|
|
5
|
+
import { loops } from './loops.js';
|
|
6
|
+
import { sandbox, status } from './sandbox.js';
|
|
7
|
+
export const builtins = { sandbox, status, loops, auth };
|
|
8
|
+
export { extensions } from './extensions.js';
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// c11r loops: a skill, a policy and a cadence (contract section 9). Definitions live in loops.yml
|
|
2
|
+
// in the project folder, next to store.yml, so they travel with the configuration; the API keeps
|
|
3
|
+
// each loop's cursor. The CLI is not a daemon: loops run <name> does one pass, and whatever
|
|
4
|
+
// schedules the agent repeats it every `every`.
|
|
5
|
+
//
|
|
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.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import YAML from 'yaml';
|
|
12
|
+
import { projectDir } from '../config.js';
|
|
13
|
+
import { CliError, closest, fail, fixWith } from '../errors.js';
|
|
14
|
+
import { envelope } from '../http.js';
|
|
15
|
+
import { parseSchema } from '../schema.js';
|
|
16
|
+
import { apiSchema } from '../schemas.js';
|
|
17
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
18
|
+
const EVERY = /^[1-9]\d*(m|h|d)$/;
|
|
19
|
+
const policyFlags = (required) => [
|
|
20
|
+
{ name: 'skill', type: 'string', required, description: 'The Manage skill or playbook the loop runs, as in manage-orders.' },
|
|
21
|
+
{ 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: '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
|
+
{ name: 'market', type: 'array', description: 'A market code the loop acts in, repeated. Items in other markets wait. Without it, every market.' },
|
|
25
|
+
];
|
|
26
|
+
export const loops = {
|
|
27
|
+
schema: parseSchema({
|
|
28
|
+
noun: 'loops',
|
|
29
|
+
group: 'manage',
|
|
30
|
+
skill: 'manage-loops',
|
|
31
|
+
kind: 'task',
|
|
32
|
+
description: 'Loops: a skill on a schedule, under a policy written in advance. Definitions are in loops.yml in the project folder; each pass is one c11r loops run.',
|
|
33
|
+
id: { accepts: ['name'], example: 'orders-15m' },
|
|
34
|
+
verbs: [
|
|
35
|
+
{ verb: 'list', method: 'GET', state: 'none', effect: 'Lists the loops of loops.yml.' },
|
|
36
|
+
{ verb: 'get', method: 'GET', path: '/v1/loops/{id}', state: 'none', effect: 'Returns a loop from loops.yml, with its last pass when the organization has run it.' },
|
|
37
|
+
{ verb: 'create', method: 'POST', path: '/v1/loops/{id}', state: 'none', body: policyFlags(true), effect: 'Adds a loop to loops.yml, after checking its policy against the skill noun\'s schema. Nothing runs until a pass.' },
|
|
38
|
+
{ verb: 'update', method: 'PATCH', path: '/v1/loops/{id}', state: 'none', body: policyFlags(false), effect: 'Changes a loop in loops.yml. A flag given replaces what is there: --allow replaces the whole list.' },
|
|
39
|
+
{ verb: 'delete', method: 'DELETE', path: '/v1/loops/{id}', state: 'none', destructive: true, deletes: true, effect: 'Removes a loop from loops.yml, and its cursor from the organization.' },
|
|
40
|
+
{ verb: 'run', method: 'POST', path: '/v1/loops/{id}/run', effect: 'One pass: what is waiting since the last pass, and the actions the policy allows. Answers done, waiting (new: true for what appeared since the last pass) and failed. With --dry-run nothing changes and the last pass time stays.' },
|
|
41
|
+
],
|
|
42
|
+
}),
|
|
43
|
+
async run(run) {
|
|
44
|
+
const name = run.parsed.id;
|
|
45
|
+
if (name !== undefined && !NAME.test(name))
|
|
46
|
+
fail('invalid_value', `${name} is not a loop name: lowercase letters, digits and dashes, as in orders-15m.`, { path: 'id' });
|
|
47
|
+
const file = loopsFile();
|
|
48
|
+
switch (run.verb.name) {
|
|
49
|
+
case 'list': {
|
|
50
|
+
const all = readLoops(file);
|
|
51
|
+
return envelope({ items: Object.entries(all).map(([n, d]) => ({ name: n, ...d })), total: Object.keys(all).length, file });
|
|
52
|
+
}
|
|
53
|
+
case 'get':
|
|
54
|
+
return get(run, name, file);
|
|
55
|
+
case 'create':
|
|
56
|
+
case 'update':
|
|
57
|
+
return write(run, name, file);
|
|
58
|
+
case 'delete':
|
|
59
|
+
return remove(run, name, file);
|
|
60
|
+
case 'run':
|
|
61
|
+
return pass(run, name, file);
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
export const loopsFile = () => path.join(projectDir(), 'loops.yml');
|
|
67
|
+
function readDoc(file) {
|
|
68
|
+
if (!fs.existsSync(file))
|
|
69
|
+
return new YAML.Document({ loops: {} });
|
|
70
|
+
const doc = YAML.parseDocument(fs.readFileSync(file, 'utf8'));
|
|
71
|
+
if (doc.errors.length)
|
|
72
|
+
fail('invalid_config', `${file} is not valid YAML: ${doc.errors[0].message}`, { path: 'loops.yml' });
|
|
73
|
+
return doc;
|
|
74
|
+
}
|
|
75
|
+
export function readLoops(file) {
|
|
76
|
+
const raw = (readDoc(file).toJS() ?? {});
|
|
77
|
+
const out = {};
|
|
78
|
+
for (const [name, d] of Object.entries(raw.loops ?? {})) {
|
|
79
|
+
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;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function one(file, name) {
|
|
87
|
+
const def = readLoops(file)[name];
|
|
88
|
+
if (!def)
|
|
89
|
+
fail('not_found', `${file} has no loop ${name}.`, { path: 'id', did_you_mean: closest(name, Object.keys(readLoops(file))), next_step: fixWith('c11r loops list') });
|
|
90
|
+
return def;
|
|
91
|
+
}
|
|
92
|
+
async function get(run, name, file) {
|
|
93
|
+
const def = one(file, name);
|
|
94
|
+
const warnings = [];
|
|
95
|
+
let last = null;
|
|
96
|
+
let state = null;
|
|
97
|
+
if (run.org.token) {
|
|
98
|
+
try {
|
|
99
|
+
const r = await run.send({ method: 'GET', path: `/loops/${name}` });
|
|
100
|
+
last = r.data;
|
|
101
|
+
state = r.state;
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
if (!(e instanceof CliError && e.kind === 'not_found'))
|
|
105
|
+
warnings.push({ code: 'last_pass_unknown', message: `The last pass could not be read: ${e.message}` });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return envelope({ name, ...def, last_pass: last, file }, { state, warnings });
|
|
109
|
+
}
|
|
110
|
+
async function write(run, name, file) {
|
|
111
|
+
const v = run.parsed.values;
|
|
112
|
+
const existing = readLoops(file)[name];
|
|
113
|
+
if (run.verb.name === 'create' && existing)
|
|
114
|
+
fail('conflict', `${file} already has a loop ${name}.`, { path: 'id', next_step: fixWith(`c11r loops update ${name}`) });
|
|
115
|
+
if (run.verb.name === 'update' && !existing)
|
|
116
|
+
one(file, name);
|
|
117
|
+
const def = {
|
|
118
|
+
skill: v.skill ?? existing.skill,
|
|
119
|
+
every: v.every ?? existing.every,
|
|
120
|
+
policy: {
|
|
121
|
+
allow: v.allow ?? existing.policy.allow,
|
|
122
|
+
...(v.max_total !== undefined ? { max_total: maxTotal(v.max_total) } : existing?.policy.max_total !== undefined ? { max_total: existing.policy.max_total } : {}),
|
|
123
|
+
...(v.market !== undefined ? { markets: v.market } : existing?.policy.markets ? { markets: existing.policy.markets } : {}),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
if (!EVERY.test(def.every))
|
|
127
|
+
fail('invalid_value', `--every ${def.every} is not a cadence: a number and m, h or d, as in 15m.`, { path: 'every' });
|
|
128
|
+
const warnings = await checkPolicy(def);
|
|
129
|
+
if (!run.globals.dryRun) {
|
|
130
|
+
const doc = readDoc(file);
|
|
131
|
+
if (!doc.has('loops'))
|
|
132
|
+
doc.set('loops', {});
|
|
133
|
+
doc.setIn(['loops', name], doc.createNode(def));
|
|
134
|
+
if (!fs.existsSync(file))
|
|
135
|
+
doc.commentBefore = ' Loops: a skill, a policy and a cadence. c11r loops run <name> does one pass;\n whatever schedules the agent repeats it every `every`.';
|
|
136
|
+
fs.writeFileSync(file, doc.toString({ lineWidth: 0 }));
|
|
137
|
+
}
|
|
138
|
+
return envelope({ name, ...def, file, ...(run.globals.dryRun ? { dry_run: true, would: [{ file, loop: name, verb: run.verb.name }] } : {}) }, {
|
|
139
|
+
warnings,
|
|
140
|
+
next: [
|
|
141
|
+
{ command: `c11r loops run ${name} --dry-run`, why: 'what one pass would do now, with nothing changed' },
|
|
142
|
+
{ command: `c11r loops run ${name}`, why: `one pass; a scheduler repeats it every ${def.every}` },
|
|
143
|
+
],
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** 500, or EUR=500 per currency. */
|
|
147
|
+
function maxTotal(items) {
|
|
148
|
+
const plain = items.filter((i) => !i.includes('='));
|
|
149
|
+
if (plain.length && plain.length !== items.length)
|
|
150
|
+
fail('invalid_value', '--max-total takes one amount, or one CURRENCY=amount per currency, not both.', { path: 'max_total' });
|
|
151
|
+
const amount = (s) => {
|
|
152
|
+
const n = Number(s);
|
|
153
|
+
if (s.trim() === '' || !Number.isFinite(n) || n < 0)
|
|
154
|
+
fail('invalid_value', `--max-total ${s} is not an amount in major units, as in 500.`, { path: 'max_total' });
|
|
155
|
+
return n;
|
|
156
|
+
};
|
|
157
|
+
if (plain.length) {
|
|
158
|
+
if (plain.length > 1)
|
|
159
|
+
fail('invalid_value', '--max-total takes one amount for every currency.', { path: 'max_total' });
|
|
160
|
+
return amount(plain[0]);
|
|
161
|
+
}
|
|
162
|
+
return Object.fromEntries(items.map((i) => {
|
|
163
|
+
const [ccy, n] = i.split('=');
|
|
164
|
+
if (!/^[A-Z]{3}$/.test(ccy))
|
|
165
|
+
fail('invalid_value', `--max-total ${i}: ${ccy} is not a currency code, as in EUR.`, { path: 'max_total' });
|
|
166
|
+
return [ccy, amount(n)];
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
/** The policy's verbs against the skill noun's schema; a playbook is checked by the API at the first pass. */
|
|
170
|
+
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' });
|
|
180
|
+
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}`) });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
async function remove(run, name, file) {
|
|
188
|
+
one(file, name);
|
|
189
|
+
if (run.globals.dryRun)
|
|
190
|
+
return envelope({ name, file, dry_run: true, would: [{ file, loop: name, verb: 'delete' }] });
|
|
191
|
+
const warnings = [];
|
|
192
|
+
let cursor = 'none';
|
|
193
|
+
if (run.org.token) {
|
|
194
|
+
try {
|
|
195
|
+
await run.send({ method: 'DELETE', path: `/loops/${name}` });
|
|
196
|
+
cursor = 'forgotten';
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
if (!(e instanceof CliError && e.kind === 'not_found'))
|
|
200
|
+
throw e;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
warnings.push({ code: 'cursor_kept', message: 'No organization to forget the cursor in: only loops.yml changed.' });
|
|
205
|
+
}
|
|
206
|
+
const doc = readDoc(file);
|
|
207
|
+
doc.deleteIn(['loops', name]);
|
|
208
|
+
fs.writeFileSync(file, doc.toString({ lineWidth: 0 }));
|
|
209
|
+
return envelope({ name, file, deleted: true, cursor }, { warnings });
|
|
210
|
+
}
|
|
211
|
+
async function pass(run, name, file) {
|
|
212
|
+
const def = one(file, name);
|
|
213
|
+
// dry_run goes in the body, as the pass's own: the pass reports what it would do.
|
|
214
|
+
return run.send({ method: 'POST', path: `/loops/${name}/run`, body: { skill: def.skill, policy: def.policy, dry_run: run.globals.dryRun }, dryRun: false });
|
|
215
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// c11r sandbox create [--name] [--seed], get, reset, claim, destroy (contract section 4), and
|
|
2
|
+
// c11r status (section 5). They are built in for what happens on this machine: create stores the
|
|
3
|
+
// sandbox token (mode 600, ~/.config/c11r/credentials.json) and links the folder (.c11r.json);
|
|
4
|
+
// reset moves both to the new organization; destroy forgets them. The token is printed never,
|
|
5
|
+
// not even by create: only its hint.
|
|
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';
|
|
8
|
+
import { CliError, fail } from '../errors.js';
|
|
9
|
+
import { apiUrl, envelope, request } from '../http.js';
|
|
10
|
+
import { parseSchema } from '../schema.js';
|
|
11
|
+
import { OUT_NOT_TRACKED, removeOrgSecrets } from '../secrets.js';
|
|
12
|
+
import { VERSION } from '../version.js';
|
|
13
|
+
/** The longest sandbox name the API takes (contract: 422 invalid_argument past it). */
|
|
14
|
+
const NAME_MAX = 25;
|
|
15
|
+
export const sandbox = {
|
|
16
|
+
schema: parseSchema({
|
|
17
|
+
noun: 'sandbox',
|
|
18
|
+
group: 'setup',
|
|
19
|
+
skill: 'setup-sandbox',
|
|
20
|
+
kind: 'task',
|
|
21
|
+
description: 'The anonymous sandbox: a Commerce Layer organization in test mode, claimed later by a human. create stores its token on this machine and links the folder to it.',
|
|
22
|
+
verbs: [
|
|
23
|
+
{
|
|
24
|
+
verb: 'create',
|
|
25
|
+
method: 'POST',
|
|
26
|
+
path: '/v1/sandboxes',
|
|
27
|
+
state: 'none',
|
|
28
|
+
live: false,
|
|
29
|
+
body: [
|
|
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.' },
|
|
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.',
|
|
34
|
+
},
|
|
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
|
+
{
|
|
37
|
+
verb: 'reset',
|
|
38
|
+
method: 'POST',
|
|
39
|
+
path: '/v1/sandbox/reset',
|
|
40
|
+
live: false,
|
|
41
|
+
destructive: true,
|
|
42
|
+
body: [{ name: 'seed', type: 'boolean', description: 'Seeds the fresh organization, or not; without it, as before.' }],
|
|
43
|
+
effect: 'Replaces the sandbox with a fresh organization behind the same token, seeded as before, and retires the old one. The slug changes; this machine and .c11r.json follow it.',
|
|
44
|
+
},
|
|
45
|
+
{ verb: 'claim', method: 'POST', path: '/v1/sandbox/claim', live: false, effect: 'Returns a one-time claim URL, valid 30 minutes, for the human to open: they sign in with Commerce Layer and pick a plan. The agent never opens it for them.' },
|
|
46
|
+
{
|
|
47
|
+
verb: 'destroy',
|
|
48
|
+
method: 'POST',
|
|
49
|
+
path: '/v1/sandbox/destroy',
|
|
50
|
+
live: false,
|
|
51
|
+
destructive: true,
|
|
52
|
+
deletes: true,
|
|
53
|
+
effect: 'Revokes the token, deletes what Commerce Layer lets the API delete, and marks the organization for deletion by support. Answers what was wiped and what is kept. This machine forgets the token and the folder link.',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}),
|
|
57
|
+
async run(run) {
|
|
58
|
+
const values = run.parsed.values;
|
|
59
|
+
switch (run.verb.name) {
|
|
60
|
+
case 'create':
|
|
61
|
+
return create(run, values);
|
|
62
|
+
case 'get':
|
|
63
|
+
return run.send({ method: 'GET', path: '/sandbox' });
|
|
64
|
+
case 'claim':
|
|
65
|
+
return run.send({ method: 'POST', path: '/sandbox/claim', body: {} });
|
|
66
|
+
case 'reset':
|
|
67
|
+
return reset(run, values);
|
|
68
|
+
case 'destroy':
|
|
69
|
+
return destroy(run);
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
async function create(run, values) {
|
|
75
|
+
const name = values.name;
|
|
76
|
+
if (name !== undefined && (name.trim() === '' || name.length > NAME_MAX)) {
|
|
77
|
+
fail('invalid_value', `--name ${JSON.stringify(name)} is ${name.trim() === '' ? 'empty' : `${name.length} characters`}: a sandbox name has 1 to ${NAME_MAX}.`, { path: 'name', next_step: { action: 'fix_request', command: null } });
|
|
78
|
+
}
|
|
79
|
+
const seed = Boolean(values.seed);
|
|
80
|
+
const test = process.env.C11R_TEST === '1' || Boolean(name?.startsWith('zztest'));
|
|
81
|
+
// The token goes only when this folder, --org or C11R_ORG names a sandbox: the API then
|
|
82
|
+
// finishes a failed seed there, or answers 409 conflict naming it, rather than make a second
|
|
83
|
+
// one for the same folder. A token picked only because it is the one stored stays home.
|
|
84
|
+
const named = run.org.source === 'link' || run.org.source === 'flag' || run.org.source === 'env' || run.org.source === 'token_env';
|
|
85
|
+
// A fresh key per create: after a 5xx or a lost connection the same create is repeated with it,
|
|
86
|
+
// and the API answers the sandbox (and token) it made, rather than leave one with a lost token.
|
|
87
|
+
const idempotencyKey = `c11r-${crypto.randomBytes(16).toString('base64url')}`;
|
|
88
|
+
const r = await request({ method: 'POST', path: '/sandboxes', body: { ...(name ? { name } : {}), seed, test }, dryRun: run.globals.dryRun, timeoutMs: seed ? 120_000 : 60_000, idempotencyKey }, { live: false, token: named ? run.org.token : null });
|
|
89
|
+
if (run.globals.dryRun)
|
|
90
|
+
return r;
|
|
91
|
+
const { token, ...data } = r.data ?? {};
|
|
92
|
+
if (typeof token !== 'string' || typeof data.org !== 'string')
|
|
93
|
+
fail('invalid_response', 'The API created a sandbox without returning its org and token.', { next_step: { action: 'none' } });
|
|
94
|
+
const hint = typeof data.token_hint === 'string' ? data.token_hint : hintOf(token);
|
|
95
|
+
storeOrg(data.org, { token, token_hint: hint, expires_at: data.expires_at ?? null, created_at: new Date().toISOString(), api_url: apiUrl() });
|
|
96
|
+
const link = writeLink(data.org);
|
|
97
|
+
// The API says to store the token: the CLI has, and printed none of it.
|
|
98
|
+
const warnings = r.warnings.filter((w) => w.code !== 'token_shown_once');
|
|
99
|
+
if (link.previous)
|
|
100
|
+
warnings.push({ code: 'relinked', message: `${link.file} named ${link.previous}; it now names ${data.org}. The token of ${link.previous} stays stored.` });
|
|
101
|
+
return { ...r, data: { ...data, token_hint: hint, credentials: credentialsFile(), linked: link.file }, warnings };
|
|
102
|
+
}
|
|
103
|
+
async function reset(run, values) {
|
|
104
|
+
const r = await run.send({ method: 'POST', path: '/sandbox/reset', body: values.seed === undefined ? {} : { seed: values.seed } });
|
|
105
|
+
if (run.globals.dryRun)
|
|
106
|
+
return r;
|
|
107
|
+
const { org, previous_org: previous } = r.data ?? {};
|
|
108
|
+
const before = previous ?? run.org.org;
|
|
109
|
+
if (typeof org === 'string' && before && org !== before) {
|
|
110
|
+
renameOrg(before, org, r.data.expires_at ? { expires_at: r.data.expires_at } : {});
|
|
111
|
+
const link = readLink();
|
|
112
|
+
if (link?.org === before)
|
|
113
|
+
writeLink(org);
|
|
114
|
+
}
|
|
115
|
+
return r;
|
|
116
|
+
}
|
|
117
|
+
async function destroy(run) {
|
|
118
|
+
let r;
|
|
119
|
+
try {
|
|
120
|
+
r = await run.send({ method: 'POST', path: '/sandbox/destroy', body: {} });
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
// After a destroy the token answers 401: a destroy whose answer was lost, repeated, lands
|
|
124
|
+
// here. The sandbox is gone either way, so this machine forgets it too.
|
|
125
|
+
if (!(e instanceof CliError && e.code === 'unauthorized') || run.globals.dryRun || !run.org.org)
|
|
126
|
+
throw e;
|
|
127
|
+
const out = outWritten(run.org.org);
|
|
128
|
+
markOutWritten(run.org.org, false);
|
|
129
|
+
forgetOrg(run.org.org);
|
|
130
|
+
const link = removeLink(run.org.org);
|
|
131
|
+
const secrets = removeOrgSecrets(run.org.org);
|
|
132
|
+
return envelope({ org: run.org.org, already_destroyed: true, forgotten: { credentials: true, link, secrets } }, { state: e.state, warnings: [{ code: 'already_destroyed', message: `The API no longer accepts the token of ${run.org.org}: it was destroyed already. This machine forgot it too.` }, ...(out ? [OUT_NOT_TRACKED] : [])] });
|
|
133
|
+
}
|
|
134
|
+
if (run.globals.dryRun)
|
|
135
|
+
return r;
|
|
136
|
+
const org = r.data?.org ?? run.org.org;
|
|
137
|
+
if (!org)
|
|
138
|
+
return r;
|
|
139
|
+
const out = outWritten(org);
|
|
140
|
+
markOutWritten(org, false);
|
|
141
|
+
forgetOrg(org);
|
|
142
|
+
const link = removeLink(org);
|
|
143
|
+
const secrets = removeOrgSecrets(org);
|
|
144
|
+
return { ...r, data: { ...r.data, forgotten: { credentials: true, link, secrets } }, warnings: [...r.warnings, ...(out ? [OUT_NOT_TRACKED] : [])] };
|
|
145
|
+
}
|
|
146
|
+
export const status = {
|
|
147
|
+
schema: parseSchema({
|
|
148
|
+
noun: 'status',
|
|
149
|
+
kind: 'task',
|
|
150
|
+
description: 'c11r status: where this machine and folder stand. The organization picked and from where, its lifecycle state, plan, mode, limits, usage and what is waiting.',
|
|
151
|
+
verbs: [{ verb: 'get', method: 'GET', path: '/v1/status', state: 'none', effect: 'Returns the status. c11r status is the same as c11r status get.' }],
|
|
152
|
+
}),
|
|
153
|
+
defaultVerb: 'get',
|
|
154
|
+
async run(run) {
|
|
155
|
+
// Status works at every state: an unresolved organization is shown, not raised.
|
|
156
|
+
const r = await run.send({ method: 'GET', path: '/status' });
|
|
157
|
+
const link = (() => {
|
|
158
|
+
try {
|
|
159
|
+
return readLink();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
const stored = Object.entries(storedOrgs()).map(([org, o]) => ({ org, token_hint: o.token_hint, expires_at: o.expires_at }));
|
|
166
|
+
const cli = {
|
|
167
|
+
version: VERSION,
|
|
168
|
+
api_url: apiUrl(),
|
|
169
|
+
org: run.org.org ? { slug: run.org.org, from: run.org.source } : null,
|
|
170
|
+
...(run.org.problem ? { org_problem: { code: run.org.problem.code, message: run.org.problem.message } } : {}),
|
|
171
|
+
link: link ? { file: link.file, org: link.org } : { file: null, name: LINK_FILE },
|
|
172
|
+
credentials: credentialsFile(),
|
|
173
|
+
stored,
|
|
174
|
+
login: (() => {
|
|
175
|
+
const l = storedLogin();
|
|
176
|
+
// Local only: what auth login stored, never the secret, no call to the API.
|
|
177
|
+
return l ? { client_id: l.client_id, email: l.user?.email ?? null, stored_at: l.created_at } : null;
|
|
178
|
+
})(),
|
|
179
|
+
default_org: defaultOrg(),
|
|
180
|
+
};
|
|
181
|
+
// The API's cli (package, latest) and this machine's, in one object.
|
|
182
|
+
const api = r.data && typeof r.data === 'object' && !Array.isArray(r.data) ? r.data : { status: r.data };
|
|
183
|
+
const data = { ...api, cli: { ...(api.cli && typeof api.cli === 'object' ? api.cli : {}), ...cli } };
|
|
184
|
+
return { ...r, data };
|
|
185
|
+
},
|
|
186
|
+
};
|