@commercelayer/agent-cli 1.0.0-dev.7 → 1.0.0-dev.9
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 +9 -1
- package/dist/builtins/extensions.js +138 -1
- package/dist/builtins/loops.js +16 -8
- package/dist/engine.js +20 -3
- package/dist/project.js +175 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ This is the 1.0.0 line, in development on the `clean-slate` branch. It replaces
|
|
|
35
35
|
| `c11r auth login [--client-id] [--secret-file] [--keep-file]` | The human's own Commerce Layer login, from their Provisioning API application. See below. |
|
|
36
36
|
| `c11r auth logout`, `auth switch <slug> [--global]` | Forgetting the login; the organization commands act on. |
|
|
37
37
|
|
|
38
|
-
A loop runs a skill a Manage noun's schema names in `loop.skill` (`manage-orders`, `manage-
|
|
38
|
+
A loop runs a skill a Manage or Report noun's schema names in `loop.skill`, or in `loop.skills` for a noun with several (`manage-orders`, `manage-stock`, `report-sales`, `report-pending`, ...); a report loop allows no verb and only reports. create and update refuse any other skill, and any verb that loop does not list, with the allowed values. A loop policy allows verbs: a bare verb is the skill's own noun (`approve`), `<noun>.<verb>` another Manage noun's verb on the same item (`payments.capture`). Verbs that delete or return money are never open to a policy.
|
|
39
39
|
|
|
40
40
|
## Login, organization tokens and secrets
|
|
41
41
|
|
|
@@ -74,6 +74,14 @@ Destructive verbs (deletes, `orders cancel`, `payments void` and `refund`, `sand
|
|
|
74
74
|
- `.c11r.json` holds only `{ "org": "<slug>" }`: no secret, safe to commit. `sandbox create`, `organizations create` and `auth switch` write it, and `organizations connect` when the folder has no link; `reset` follows the new slug; `destroy` removes it when it names the destroyed organization.
|
|
75
75
|
- The project folder is the one with `.c11r.json`, else the nearest with `store.yml` or `loops.yml`, else the current one. `loops.yml` goes there.
|
|
76
76
|
|
|
77
|
+
## The store's setup in files
|
|
78
|
+
|
|
79
|
+
`c11r config export` writes the organization's setup into the project folder: `store.yml` (the Configure nouns, refs by key, no ids, no secrets; its first key, `meta`, names the organization and the time) and `price_lists/<code>.csv`. It records what it wrote in `.c11r/export.json`, and never overwrites a file changed since the last export (or one no export wrote) without `--yes`: the answer lists them. A price list file the organization no longer has, unchanged since, goes. `c11r config plan` sends the files and answers what `config apply` would change; `apply` makes the organization match them, and with `--prune` (confirmed) also deletes what the files lack. While imports run, `apply` answers `status: running` with each noun's state and `retry_after`; `--wait` applies again until done or failed.
|
|
80
|
+
|
|
81
|
+
After a Configure write that went through (create, update, delete or a domain verb), in a folder whose `store.yml` names the same organization, the answer warns `config_drift` and `.c11r/drift` notes the write; `config plan` repeats the warning, and `config export` clears it.
|
|
82
|
+
|
|
83
|
+
`c11r exports get <id> --out <file>` downloads a completed export while its link lasts (5 minutes), unzipped unless the name ends in `.gz`; `--wait` reads the job again until it completes.
|
|
84
|
+
|
|
77
85
|
## Output, errors and exit codes
|
|
78
86
|
|
|
79
87
|
Every answer is one JSON object on stdout, the API's envelope:
|
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
// sales-calls book answers a cancel token once (section 20): stored the same way, by booking id,
|
|
9
9
|
// and sent by sales-calls cancel <booking> without the agent ever holding it.
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import zlib from 'node:zlib';
|
|
11
14
|
import { credentialsFile, hintOf, readLink, storeBooking, storedBooking, storeOrg, writeLink } from '../config.js';
|
|
12
15
|
import { confirm, terminal } from '../confirm.js';
|
|
13
16
|
import { fail } from '../errors.js';
|
|
14
17
|
import { apiUrl } from '../http.js';
|
|
18
|
+
import { clearDrift, driftWarning, readDrift, readStoreFiles, storeFileOrg, writeStoreFiles } from '../project.js';
|
|
15
19
|
import { resourceRequest } from '../resources.js';
|
|
16
20
|
const NAME_MAX = 25;
|
|
17
21
|
export const organizations = {
|
|
@@ -104,4 +108,137 @@ export const salesCalls = {
|
|
|
104
108
|
return undefined;
|
|
105
109
|
},
|
|
106
110
|
};
|
|
107
|
-
|
|
111
|
+
// config (contract section 45.2): the API answers and applies documents; the files are the CLI's.
|
|
112
|
+
// export writes store.yml and price_lists/<code>.csv, never over a file changed since the last
|
|
113
|
+
// export without --yes, and clears the drift note.
|
|
114
|
+
// plan sends the folder's files and says what apply would change, and since when the
|
|
115
|
+
// folder is behind when Configure writes were made here.
|
|
116
|
+
// apply sends them; while imports run (202, running) it shows each noun's state, and with
|
|
117
|
+
// --wait applies again until done or failed. prune is confirmed by the schema.
|
|
118
|
+
const WAIT_MAX_MS = 15 * 60_000;
|
|
119
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
120
|
+
const waitMs = (data) => {
|
|
121
|
+
const s = Number(data?.retry_after);
|
|
122
|
+
const test = process.env.C11R_WAIT_MS;
|
|
123
|
+
if (test !== undefined)
|
|
124
|
+
return Number(test);
|
|
125
|
+
return (Number.isFinite(s) && s > 0 ? Math.min(s, 60) : 5) * 1000;
|
|
126
|
+
};
|
|
127
|
+
export const config = {
|
|
128
|
+
flags: {
|
|
129
|
+
apply: { wait: { type: 'boolean', to: 'local', description: 'While imports run, apply again every retry_after seconds until done or failed (15 minutes at most).' } },
|
|
130
|
+
},
|
|
131
|
+
prepare(verb, parsed) {
|
|
132
|
+
if (verb.name !== 'plan' && verb.name !== 'apply')
|
|
133
|
+
return;
|
|
134
|
+
if (parsed.values.store !== undefined)
|
|
135
|
+
return;
|
|
136
|
+
const files = readStoreFiles();
|
|
137
|
+
parsed.values.store = files.store;
|
|
138
|
+
parsed.values.price_lists = files.price_lists;
|
|
139
|
+
},
|
|
140
|
+
async run(run) {
|
|
141
|
+
const name = run.verb.name;
|
|
142
|
+
if (name === 'export') {
|
|
143
|
+
const r = await run.send(resourceRequest('config', run.verb, undefined, run.parsed.values));
|
|
144
|
+
if (run.globals.dryRun)
|
|
145
|
+
return r;
|
|
146
|
+
const data = r.data ?? {};
|
|
147
|
+
if (!data.store || typeof data.store !== 'object')
|
|
148
|
+
fail('invalid_response', 'config export answered without store.', { next_step: { action: 'none' } });
|
|
149
|
+
const out = writeStoreFiles({ store: data.store, price_lists: data.price_lists ?? {}, meta: { org: data.meta?.org ?? run.org.org ?? undefined, exported_at: data.meta?.exported_at } }, { overwrite: run.globals.yes });
|
|
150
|
+
const counts = Object.fromEntries(Object.entries(data.store).map(([k, v]) => [k, Array.isArray(v) ? v.length : 0]));
|
|
151
|
+
return {
|
|
152
|
+
...r,
|
|
153
|
+
data: { org: data.meta?.org ?? run.org.org, exported_at: data.meta?.exported_at ?? null, folder: out.dir, files: out.written, removed: out.removed, records: counts, price_lists: Object.keys(data.price_lists ?? {}) },
|
|
154
|
+
next: [{ command: 'c11r config plan', why: 'what the files would change in an organization, after you edit them or for another one (--org)' }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
if (name === 'plan' || name === 'apply') {
|
|
158
|
+
const req = resourceRequest('config', run.verb, undefined, run.parsed.values);
|
|
159
|
+
let r = await run.send(req);
|
|
160
|
+
const started = Date.now();
|
|
161
|
+
while (name === 'apply' && run.parsed.values.wait && r.data?.status === 'running' && Date.now() - started < WAIT_MAX_MS) {
|
|
162
|
+
const ms = waitMs(r.data);
|
|
163
|
+
process.stderr.write(JSON.stringify({ running: { nouns: states(r.data), next_in_ms: ms } }) + '\n');
|
|
164
|
+
await sleep(ms);
|
|
165
|
+
r = await run.send(req);
|
|
166
|
+
}
|
|
167
|
+
const warnings = [...r.warnings];
|
|
168
|
+
const here = storeFileOrg();
|
|
169
|
+
const drift = readDrift();
|
|
170
|
+
if (name === 'plan' && drift.length && here === run.org.org)
|
|
171
|
+
warnings.push(driftWarning(drift));
|
|
172
|
+
// The organization now matches the files: the folder is no longer behind.
|
|
173
|
+
if (name === 'apply' && !run.globals.dryRun && r.data?.status === 'done' && here === run.org.org)
|
|
174
|
+
clearDrift();
|
|
175
|
+
const data = r.data ?? {};
|
|
176
|
+
const running = name === 'apply' && data.status === 'running';
|
|
177
|
+
return {
|
|
178
|
+
...r,
|
|
179
|
+
data: running ? { ...data, retry_after: waitMs(data) / 1000, states: states(data) } : data,
|
|
180
|
+
warnings,
|
|
181
|
+
next: running && !run.parsed.values.wait ? [{ command: `${run.line} --wait`, why: 'carry on, and wait until every noun is done or failed' }, ...r.next] : r.next,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
/** Each noun's state while apply runs: done, running (with its import), waiting, failed or unchanged. */
|
|
188
|
+
function states(data) {
|
|
189
|
+
const out = {};
|
|
190
|
+
for (const n of Array.isArray(data?.nouns) ? data.nouns : [])
|
|
191
|
+
out[n.noun] = n.import?.id && n.status === 'running' ? `running (import ${n.import.id})` : String(n.status);
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
// exports (45.3): get --out downloads the file while its link is good (5 minutes), unzipped unless
|
|
195
|
+
// the name ends in .gz; --wait polls a job not completed yet.
|
|
196
|
+
export const exports = {
|
|
197
|
+
flags: {
|
|
198
|
+
get: {
|
|
199
|
+
out: { type: 'string', to: 'local', description: 'Download the export to this file once completed (its link lasts 5 minutes). Unzipped, unless the name ends in .gz.' },
|
|
200
|
+
wait: { type: 'boolean', to: 'local', description: 'Until the export is completed, failed or interrupted, read it again every few seconds (15 minutes at most).' },
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
async run(run) {
|
|
204
|
+
if (run.verb.name !== 'get' || (!run.parsed.values.out && !run.parsed.values.wait))
|
|
205
|
+
return undefined;
|
|
206
|
+
const req = resourceRequest('exports', run.verb, run.parsed.id, run.parsed.values);
|
|
207
|
+
let r = await run.send(req);
|
|
208
|
+
const started = Date.now();
|
|
209
|
+
const busy = (s) => s === 'pending' || s === 'in_progress';
|
|
210
|
+
while (run.parsed.values.wait && busy(r.data?.status) && Date.now() - started < WAIT_MAX_MS) {
|
|
211
|
+
const ms = waitMs(null);
|
|
212
|
+
process.stderr.write(JSON.stringify({ waiting: { export: run.parsed.id, status: r.data?.status, next_in_ms: ms } }) + '\n');
|
|
213
|
+
await sleep(ms);
|
|
214
|
+
r = await run.send(req);
|
|
215
|
+
}
|
|
216
|
+
const out = run.parsed.values.out;
|
|
217
|
+
if (!out)
|
|
218
|
+
return r;
|
|
219
|
+
const data = r.data ?? {};
|
|
220
|
+
if (data.status !== 'completed' || typeof data.url !== 'string') {
|
|
221
|
+
fail('conflict', `Export ${run.parsed.id} is ${data.status ?? 'not completed'}: there is no file to download yet.`, { path: 'out', details: { status: data.status ?? null }, next_step: { action: 'fix_request', command: `${run.line} --wait` } }, 'rejected');
|
|
222
|
+
}
|
|
223
|
+
const file = path.resolve(out);
|
|
224
|
+
let res;
|
|
225
|
+
try {
|
|
226
|
+
res = await fetch(data.url, { signal: AbortSignal.timeout(120_000) });
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
fail('api_unreachable', `The export's file could not be downloaded: ${e.message}.`, { path: 'out', next_step: { action: 'fix_request', command: run.line } });
|
|
230
|
+
}
|
|
231
|
+
// An expired link: get the export again for a new one.
|
|
232
|
+
if (!res.ok)
|
|
233
|
+
fail('expired', `The export's link answered ${res.status}: it lasts 5 minutes. Get the export again for a new one.`, { path: 'out', next_step: { action: 'fix_request', command: run.line } }, 'rejected');
|
|
234
|
+
let bytes = Buffer.from(await res.arrayBuffer());
|
|
235
|
+
const zipped = bytes[0] === 0x1f && bytes[1] === 0x8b;
|
|
236
|
+
if (zipped && !file.endsWith('.gz'))
|
|
237
|
+
bytes = zlib.gunzipSync(bytes);
|
|
238
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
239
|
+
fs.writeFileSync(file, bytes);
|
|
240
|
+
const { url: _url, ...rest } = data;
|
|
241
|
+
return { ...r, data: { ...rest, file, bytes: bytes.length, unzipped: zipped && !file.endsWith('.gz') } };
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
export const extensions = { organizations, 'sales-calls': salesCalls, config, exports };
|
package/dist/builtins/loops.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// schedules the agent repeats it every `every`.
|
|
5
5
|
//
|
|
6
6
|
// A policy allows verbs: a bare verb is the skill's own noun (approve), <noun>.<verb> another
|
|
7
|
-
// Manage noun's verb on the same item (payments.capture). The skill must be one a Manage
|
|
8
|
-
// schema names in loop.skill, and each verb in that loop.verbs; the API checks
|
|
7
|
+
// Manage noun's verb on the same item (payments.capture). The skill must be one a Manage or Report
|
|
8
|
+
// noun's schema names in loop.skill or loop.skills, and each verb in that loop.verbs; the API checks
|
|
9
|
+
// again on every pass.
|
|
9
10
|
import fs from 'node:fs';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
import YAML from 'yaml';
|
|
@@ -17,7 +18,7 @@ import { apiNouns, apiSchema } from '../schemas.js';
|
|
|
17
18
|
const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
18
19
|
const EVERY = /^[1-9]\d*(m|h|d)$/;
|
|
19
20
|
const policyFlags = (required) => [
|
|
20
|
-
{ name: 'skill', type: 'string', required, description: 'The skill the loop runs, as in manage-orders: one a noun\'s schema names in loop.skill.' },
|
|
21
|
+
{ name: 'skill', type: 'string', required, description: 'The skill the loop runs, as in manage-orders or report-sales: one a noun\'s schema names in loop.skill or loop.skills.' },
|
|
21
22
|
{ name: 'every', type: 'string', required, description: 'How often a pass should run, as in 15m, 2h or 1d. The scheduler reads it; the CLI does not wait.' },
|
|
22
23
|
{ name: 'allow', type: 'array', description: 'A verb the policy allows, repeated: approve, payments.capture. Only verbs the noun\'s schema lists in loop.verbs. Without it the loop only reports what is waiting.' },
|
|
23
24
|
{ name: 'max_total', type: 'array', description: 'The largest order total acted on, in major units: 500 for every currency, or EUR=500, repeated per currency. Above it, the item waits for the human.' },
|
|
@@ -192,16 +193,23 @@ async function checkPolicy(def) {
|
|
|
192
193
|
}
|
|
193
194
|
return [];
|
|
194
195
|
}
|
|
195
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* The skills a loop can run, from the schemas of the nouns that have a loop (Manage and Report):
|
|
198
|
+
* each skill to its noun and the verbs its policy may allow. loop.skills, when given, lists every
|
|
199
|
+
* skill of the noun's loop (a report loop per report); else loop.skill is the one.
|
|
200
|
+
*/
|
|
196
201
|
async function loopSkills() {
|
|
197
|
-
const nouns = (await apiNouns()).filter((n) => n.group === 'manage' && n.served !== false);
|
|
202
|
+
const nouns = (await apiNouns()).filter((n) => (n.group === 'manage' || n.group === 'report') && n.served !== false);
|
|
198
203
|
const schemas = await Promise.all(nouns.map((n) => apiSchema(n.noun, []).catch(() => null)));
|
|
199
204
|
const out = new Map();
|
|
200
205
|
for (const s of schemas) {
|
|
201
|
-
if (!s?.loop
|
|
206
|
+
if (!s?.loop)
|
|
202
207
|
continue;
|
|
203
|
-
const
|
|
204
|
-
|
|
208
|
+
const names = Array.isArray(s.loop.skills) && s.loop.skills.length ? s.loop.skills : s.loop.skill ? [s.loop.skill] : [];
|
|
209
|
+
for (const skill of names) {
|
|
210
|
+
const seen = out.get(skill);
|
|
211
|
+
out.set(skill, { noun: seen?.noun ?? s.noun, verbs: [...new Set([...(seen?.verbs ?? []), ...(s.loop.verbs ?? [])])] });
|
|
212
|
+
}
|
|
205
213
|
}
|
|
206
214
|
return out;
|
|
207
215
|
}
|
package/dist/engine.js
CHANGED
|
@@ -12,6 +12,7 @@ import { envelope, request } from './http.js';
|
|
|
12
12
|
import { readInputs } from './inputs.js';
|
|
13
13
|
import { hasLogin, loginToken } from './login.js';
|
|
14
14
|
import { resourceRequest } from './resources.js';
|
|
15
|
+
import { markDrift } from './project.js';
|
|
15
16
|
import { describePlan, OUT_NOT_TRACKED, planSecret, removeRecordFiles, writeSecret } from './secrets.js';
|
|
16
17
|
import { verbOf } from './schema.js';
|
|
17
18
|
import { apiNouns, apiSchema, refreshSchemas, refusedBySchema } from './schemas.js';
|
|
@@ -53,7 +54,10 @@ async function executeOnce(argv, opts) {
|
|
|
53
54
|
const schema = builtins[cmd.noun]?.schema ?? (await apiSchema(cmd.noun, Object.keys(builtins)));
|
|
54
55
|
if (cmd.globals.help && !cmd.verb && !builtins[cmd.noun]?.defaultVerb)
|
|
55
56
|
return envelope(nounHelp(schema));
|
|
56
|
-
const
|
|
57
|
+
const ext = opts.extensions?.[schema.noun];
|
|
58
|
+
const found = verbOf(schema, cmd.verb ?? builtins[cmd.noun]?.defaultVerb);
|
|
59
|
+
const local = ext?.flags?.[found.name];
|
|
60
|
+
const verb = local ? { ...found, flags: { ...found.flags, ...local } } : found;
|
|
57
61
|
if (cmd.globals.help)
|
|
58
62
|
return envelope(verbHelp(schema, verb));
|
|
59
63
|
// A verb the schema lists as refused: no request, no confirmation for something that cannot happen.
|
|
@@ -61,7 +65,6 @@ async function executeOnce(argv, opts) {
|
|
|
61
65
|
const by = verb.refused.by === 'commercelayer' ? 'Commerce Layer' : verb.refused.by;
|
|
62
66
|
fail('not_supported', `c11r ${schema.noun} ${verb.name} is refused by ${by}: ${verb.description || 'it cannot be done through the API.'}`, { details: { refused_by: verb.refused.by }, next_step: { action: 'none' } });
|
|
63
67
|
}
|
|
64
|
-
const ext = opts.extensions?.[schema.noun];
|
|
65
68
|
const positional = ext?.positional?.[verb.name];
|
|
66
69
|
const rest = positional && !verb.id && cmd.rest[0] !== undefined && !cmd.rest[0].startsWith('-') ? [`--${positional}`, ...cmd.rest] : cmd.rest;
|
|
67
70
|
const parsed = parseFields(schema.noun, verb, rest);
|
|
@@ -140,7 +143,21 @@ async function executeOnce(argv, opts) {
|
|
|
140
143
|
};
|
|
141
144
|
const own = (await builtins[schema.noun]?.run?.(run)) ?? (await ext?.run?.(run));
|
|
142
145
|
const r = own ?? (await run.send(resourceRequest(schema.noun, verb, parsed.id, parsed.values)));
|
|
143
|
-
return filesGone(secretOf(r, run, secretPlan), run);
|
|
146
|
+
return drifted(filesGone(secretOf(r, run, secretPlan), run), run);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* A Configure write that went through, in a folder whose store.yml names this organization: the
|
|
150
|
+
* file is now behind it. Noted in .c11r/drift, with the warning config_drift.
|
|
151
|
+
*/
|
|
152
|
+
function drifted(r, run) {
|
|
153
|
+
if (run.globals.dryRun || !run.org.org || run.verb.method === 'GET')
|
|
154
|
+
return r;
|
|
155
|
+
if (run.schema.doc?.group !== 'configure')
|
|
156
|
+
return r;
|
|
157
|
+
const data = (r.data && typeof r.data === 'object' ? r.data : {});
|
|
158
|
+
const id = [run.parsed.id, data.code, data.name, data.id].find((k) => typeof k === 'string' && k !== '');
|
|
159
|
+
const warning = markDrift(run.org.org, { noun: run.schema.noun, verb: run.verb.name, id: id ?? null });
|
|
160
|
+
return warning ? { ...r, warnings: [...r.warnings, warning] } : r;
|
|
144
161
|
}
|
|
145
162
|
/**
|
|
146
163
|
* A delete on a noun that answers secrets: the files c11r wrote for that record in the secrets
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// The project folder's configuration files (contract section 45.2): store.yml and
|
|
2
|
+
// price_lists/<code>.csv, written by config export and read by config plan and apply, and the
|
|
3
|
+
// CLI's own notes about them in .c11r/:
|
|
4
|
+
//
|
|
5
|
+
// .c11r/export.json what the last export wrote: the organization, when, and each file's hash,
|
|
6
|
+
// so the next export never overwrites a file changed since without --yes.
|
|
7
|
+
// .c11r/drift the Configure writes made since, in a folder whose store.yml names the
|
|
8
|
+
// same organization: config plan mentions them, config export clears them.
|
|
9
|
+
//
|
|
10
|
+
// store.yml carries the export's meta ({ org, exported_at }) as its first key; the CLI takes it
|
|
11
|
+
// off before it sends the file, so the API sees { <noun>: [records] } only.
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import YAML from 'yaml';
|
|
16
|
+
import { projectDir } from './config.js';
|
|
17
|
+
import { fail } from './errors.js';
|
|
18
|
+
export const STORE_FILE = 'store.yml';
|
|
19
|
+
export const PRICE_LISTS_DIR = 'price_lists';
|
|
20
|
+
const NOTES_DIR = '.c11r';
|
|
21
|
+
const EXPORT_FILE = 'export.json';
|
|
22
|
+
const DRIFT_FILE = 'drift';
|
|
23
|
+
const hashOf = (text) => `sha256-${crypto.createHash('sha256').update(text).digest('hex')}`;
|
|
24
|
+
const notes = (dir) => path.join(dir, NOTES_DIR);
|
|
25
|
+
/** store.yml and the price list CSVs of the project folder, or store_not_found. */
|
|
26
|
+
export function readStoreFiles(dir = projectDir()) {
|
|
27
|
+
const file = path.join(dir, STORE_FILE);
|
|
28
|
+
let text;
|
|
29
|
+
try {
|
|
30
|
+
text = fs.readFileSync(file, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
fail('not_found', `There is no ${STORE_FILE} in ${dir}. c11r config export writes one from the organization.`, { path: 'store', next_step: { action: 'fix_request', command: 'c11r config export' } });
|
|
34
|
+
}
|
|
35
|
+
let doc;
|
|
36
|
+
try {
|
|
37
|
+
doc = YAML.parse(text) ?? {};
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
fail('invalid_config', `${file} is not YAML: ${e.message}`, { path: 'store' });
|
|
41
|
+
}
|
|
42
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc))
|
|
43
|
+
fail('invalid_config', `${file} holds no nouns: it is { <noun>: [records] }.`, { path: 'store' });
|
|
44
|
+
const { meta, ...store } = doc;
|
|
45
|
+
const org = meta && typeof meta === 'object' && typeof meta.org === 'string' ? meta.org : null;
|
|
46
|
+
const price_lists = {};
|
|
47
|
+
const csvDir = path.join(dir, PRICE_LISTS_DIR);
|
|
48
|
+
let names = [];
|
|
49
|
+
try {
|
|
50
|
+
names = fs.readdirSync(csvDir);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No price lists: fine.
|
|
54
|
+
}
|
|
55
|
+
for (const n of names.sort())
|
|
56
|
+
if (n.endsWith('.csv'))
|
|
57
|
+
price_lists[n.slice(0, -4)] = fs.readFileSync(path.join(csvDir, n), 'utf8');
|
|
58
|
+
return { store, price_lists, org };
|
|
59
|
+
}
|
|
60
|
+
/** The organization the folder's store.yml names, or null without one. */
|
|
61
|
+
export function storeFileOrg(dir = projectDir()) {
|
|
62
|
+
try {
|
|
63
|
+
const doc = YAML.parse(fs.readFileSync(path.join(dir, STORE_FILE), 'utf8'));
|
|
64
|
+
return typeof doc?.meta?.org === 'string' ? doc.meta.org : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Writes an export: store.yml (meta first) and price_lists/<code>.csv. A file there that differs
|
|
72
|
+
* from what the last export wrote (or that no export wrote) is the user's: without overwrite,
|
|
73
|
+
* nothing is written and the answer lists them. Price list files the last export wrote, unchanged
|
|
74
|
+
* since, and no longer in the organization, go.
|
|
75
|
+
*/
|
|
76
|
+
export function writeStoreFiles(data, opts) {
|
|
77
|
+
const dir = opts.dir ?? projectDir();
|
|
78
|
+
const last = readRecord(dir);
|
|
79
|
+
const meta = { org: data.meta?.org ?? null, exported_at: data.meta?.exported_at ?? new Date().toISOString() };
|
|
80
|
+
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
|
+
const files = { [STORE_FILE]: yaml };
|
|
82
|
+
for (const [code, csv] of Object.entries(data.price_lists ?? {}))
|
|
83
|
+
files[path.join(PRICE_LISTS_DIR, `${safeName(code)}.csv`)] = csv;
|
|
84
|
+
const changed = (rel) => {
|
|
85
|
+
const abs = path.join(dir, rel);
|
|
86
|
+
if (!fs.existsSync(abs))
|
|
87
|
+
return false;
|
|
88
|
+
const now = hashOf(fs.readFileSync(abs, 'utf8'));
|
|
89
|
+
return last.files[rel] !== now;
|
|
90
|
+
};
|
|
91
|
+
const stale = Object.keys(last.files).filter((rel) => rel.startsWith(`${PRICE_LISTS_DIR}${path.sep}`) && !(rel in files) && fs.existsSync(path.join(dir, rel)));
|
|
92
|
+
const mine = [...Object.keys(files).filter((rel) => files[rel] !== readOr(path.join(dir, rel))), ...stale].filter(changed);
|
|
93
|
+
if (mine.length && !opts.overwrite) {
|
|
94
|
+
fail('conflict', `${mine.join(', ')} changed since the last export${last.exported_at ? ` (${last.exported_at})` : ''}, or no export wrote ${mine.length === 1 ? 'it' : 'them'}. c11r replaces ${mine.length === 1 ? 'it' : 'them'} only with --yes; c11r config plan shows what the files would change in the organization.`, {
|
|
95
|
+
path: 'store',
|
|
96
|
+
details: { files: mine.map((rel) => path.join(dir, rel)) },
|
|
97
|
+
next_step: { action: 'fix_request', command: 'c11r config plan' },
|
|
98
|
+
}, 'rejected');
|
|
99
|
+
}
|
|
100
|
+
const written = [];
|
|
101
|
+
for (const [rel, text] of Object.entries(files)) {
|
|
102
|
+
const abs = path.join(dir, rel);
|
|
103
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
104
|
+
if (readOr(abs) !== text)
|
|
105
|
+
fs.writeFileSync(abs, text);
|
|
106
|
+
written.push(abs);
|
|
107
|
+
}
|
|
108
|
+
const removed = [];
|
|
109
|
+
for (const rel of stale) {
|
|
110
|
+
fs.rmSync(path.join(dir, rel), { force: true });
|
|
111
|
+
removed.push(path.join(dir, rel));
|
|
112
|
+
}
|
|
113
|
+
writeRecord(dir, { org: meta.org, exported_at: meta.exported_at, files: Object.fromEntries(Object.entries(files).map(([rel, text]) => [rel, hashOf(text)])) });
|
|
114
|
+
clearDrift(dir);
|
|
115
|
+
return { written, removed, dir };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* After a Configure write: in a folder whose store.yml names this organization, notes it in
|
|
119
|
+
* .c11r/drift and answers the warning. Elsewhere, nothing.
|
|
120
|
+
*/
|
|
121
|
+
export function markDrift(org, entry, dir = projectDir()) {
|
|
122
|
+
if (storeFileOrg(dir) !== org)
|
|
123
|
+
return null;
|
|
124
|
+
const list = readDrift(dir);
|
|
125
|
+
list.push({ ...entry, at: new Date().toISOString() });
|
|
126
|
+
fs.mkdirSync(notes(dir), { recursive: true });
|
|
127
|
+
// The first ones say since when; the last 50 say what.
|
|
128
|
+
const kept = list.length > 50 ? [list[0], ...list.slice(-49)] : list;
|
|
129
|
+
fs.writeFileSync(path.join(notes(dir), DRIFT_FILE), JSON.stringify(kept, null, 2) + '\n');
|
|
130
|
+
return driftWarning(kept);
|
|
131
|
+
}
|
|
132
|
+
/** The writes noted since the last export, oldest first. */
|
|
133
|
+
export function readDrift(dir = projectDir()) {
|
|
134
|
+
try {
|
|
135
|
+
const v = JSON.parse(fs.readFileSync(path.join(notes(dir), DRIFT_FILE), 'utf8'));
|
|
136
|
+
return Array.isArray(v) ? v : [];
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export function clearDrift(dir = projectDir()) {
|
|
143
|
+
fs.rmSync(path.join(notes(dir), DRIFT_FILE), { force: true });
|
|
144
|
+
}
|
|
145
|
+
export function driftWarning(list) {
|
|
146
|
+
const first = list[0];
|
|
147
|
+
const what = (e) => `${e.noun} ${e.verb}${e.id ? ` ${e.id}` : ''}`;
|
|
148
|
+
return {
|
|
149
|
+
code: 'config_drift',
|
|
150
|
+
message: `store.yml is now behind the organization, since ${first.at} (${what(first)}${list.length > 1 ? ` and ${list.length - 1} more` : ''}): c11r config export refreshes it.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function readRecord(dir) {
|
|
154
|
+
try {
|
|
155
|
+
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 : {} };
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { org: null, exported_at: null, files: {} };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function writeRecord(dir, r) {
|
|
163
|
+
fs.mkdirSync(notes(dir), { recursive: true });
|
|
164
|
+
fs.writeFileSync(path.join(notes(dir), EXPORT_FILE), JSON.stringify(r, null, 2) + '\n');
|
|
165
|
+
}
|
|
166
|
+
function readOr(file) {
|
|
167
|
+
try {
|
|
168
|
+
return fs.readFileSync(file, 'utf8');
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** A price list code as a file name: letters, digits, dot, dash and underscore. */
|
|
175
|
+
const safeName = (code) => code.replace(/[^A-Za-z0-9._-]+/g, '-');
|
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.9",
|
|
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": [
|