@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.
Files changed (67) hide show
  1. package/README.md +95 -215
  2. package/dist/argv.js +214 -0
  3. package/dist/bin.js +15 -178
  4. package/dist/builtins/auth.js +169 -0
  5. package/dist/builtins/extensions.js +123 -0
  6. package/dist/builtins/index.js +8 -0
  7. package/dist/builtins/loops.js +215 -0
  8. package/dist/builtins/sandbox.js +186 -0
  9. package/dist/config.js +211 -0
  10. package/dist/confirm.js +56 -0
  11. package/dist/engine.js +173 -0
  12. package/dist/errors.js +146 -0
  13. package/dist/help.js +78 -0
  14. package/dist/http.js +117 -0
  15. package/dist/login.js +133 -0
  16. package/dist/resources.js +41 -0
  17. package/dist/schema.js +152 -0
  18. package/dist/schemas.js +72 -0
  19. package/dist/secrets.js +189 -0
  20. package/dist/version.js +4 -8
  21. package/package.json +4 -9
  22. package/dist/api/client.js +0 -190
  23. package/dist/api/server.js +0 -26
  24. package/dist/api/stub/db.js +0 -37
  25. package/dist/api/stub/http.js +0 -18
  26. package/dist/api/stub/index.js +0 -52
  27. package/dist/api/stub/locale.js +0 -52
  28. package/dist/api/stub/management.js +0 -409
  29. package/dist/api/stub/messages.js +0 -126
  30. package/dist/api/stub/orders.js +0 -625
  31. package/dist/api/stub/search.js +0 -78
  32. package/dist/api/stub/shopping.js +0 -673
  33. package/dist/api/stub/theme.js +0 -197
  34. package/dist/api/stub/ucp.js +0 -377
  35. package/dist/api/transport.js +0 -57
  36. package/dist/api/types.js +0 -4
  37. package/dist/assets.js +0 -100
  38. package/dist/catalog.js +0 -366
  39. package/dist/check.js +0 -187
  40. package/dist/commands.js +0 -1241
  41. package/dist/cursor.js +0 -31
  42. package/dist/files.js +0 -98
  43. package/dist/inbox/cursor.js +0 -11
  44. package/dist/inbox/fake.js +0 -95
  45. package/dist/inbox/imap.js +0 -139
  46. package/dist/inbox/mailbox.js +0 -25
  47. package/dist/inbox/message.js +0 -88
  48. package/dist/inspect.js +0 -230
  49. package/dist/languages.js +0 -49
  50. package/dist/links.js +0 -118
  51. package/dist/locales.js +0 -43
  52. package/dist/ops.js +0 -190
  53. package/dist/orderRequest.js +0 -141
  54. package/dist/output.js +0 -39
  55. package/dist/products.js +0 -151
  56. package/dist/promotions.js +0 -291
  57. package/dist/store.js +0 -379
  58. package/dist/storefront.js +0 -285
  59. package/dist/ucp/buy.js +0 -90
  60. package/dist/ucp/check.js +0 -360
  61. package/dist/ucp/client.js +0 -129
  62. package/dist/ucp/images.js +0 -62
  63. package/dist/ucp/platform.js +0 -90
  64. package/dist/ucp/shop.js +0 -357
  65. package/dist/ucp/signing.js +0 -67
  66. package/dist/web.js +0 -316
  67. package/dist/website.js +0 -390
@@ -0,0 +1,189 @@
1
+ // Secrets in answers (contract section 22). credentials create and credentials token answer a
2
+ // secret once, in data.secret: the CLI writes it to a file with mode 600 and prints the file, the
3
+ // format and a hint, never the value. The file is checked before the request is sent, so a create
4
+ // does not make a secret that then has nowhere to go.
5
+ //
6
+ // Default file: ~/.config/c11r/secrets/<org>/<credential name>.env (or <id>-token.env for a token),
7
+ // outside the project so it cannot be committed. --out picks another; --format env|json. A file
8
+ // there already is replaced only with --yes. A file inside a git repository that git does not
9
+ // ignore gets the warning secret_not_ignored.
10
+ import { spawnSync } from 'node:child_process';
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { markOutWritten } from './config.js';
16
+ import { fail } from './errors.js';
17
+ export function secretsDir() {
18
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
19
+ return path.join(base, 'c11r', 'secrets');
20
+ }
21
+ const safe = (s) => s.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'credential';
22
+ /** Where the secret will go, checked now: writable, not a link, not there already unless --yes. */
23
+ export function planSecret(opts) {
24
+ const out = opts.values.out;
25
+ const format = opts.values.format ?? null;
26
+ const base = opts.verb === 'token' ? `${safe(opts.id ?? 'credential')}-token` : safe(String(opts.values.name ?? opts.id ?? 'credential'));
27
+ 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`];
30
+ for (const c of candidates) {
31
+ let st = null;
32
+ try {
33
+ st = fs.lstatSync(c);
34
+ }
35
+ catch {
36
+ // Not there: fine.
37
+ }
38
+ if (st?.isSymbolicLink())
39
+ fail('insecure_file', `${c} is a symbolic link. c11r writes a secret only to a plain file.`, { path: 'out' });
40
+ if (st && !st.isFile())
41
+ fail('invalid_value', `${c} is not a file.`, { path: 'out' });
42
+ if (st && !opts.yes) {
43
+ 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
+ }
45
+ }
46
+ const dir = path.dirname(candidates[0]);
47
+ const existing = nearestExisting(dir);
48
+ try {
49
+ fs.accessSync(existing, fs.constants.W_OK);
50
+ }
51
+ catch {
52
+ fail('invalid_value', `${dir} cannot be written by this user, so the secret would have nowhere to go.`, { path: 'out' });
53
+ }
54
+ return plan;
55
+ }
56
+ function nearestExisting(dir) {
57
+ let d = dir;
58
+ while (!fs.existsSync(d)) {
59
+ const up = path.dirname(d);
60
+ if (up === d)
61
+ break;
62
+ d = up;
63
+ }
64
+ return d;
65
+ }
66
+ /** Writes data.secret to its file and answers the envelope without the value. */
67
+ export function writeSecret(r, plan, ctx) {
68
+ const data = (r.data ?? {});
69
+ const secret = data.secret;
70
+ const credentialId = data.credential?.id ?? data.id ?? ctx.id;
71
+ const format = plan.format ?? (secret.format === 'json' ? 'json' : 'env');
72
+ const file = plan.fixed || plan.format ? plan.file : `${plan.file}.${format}`;
73
+ const env = secret.env && Object.keys(secret.env).length ? { ...secret.env } : { [`CL_${(secret.kind ?? 'secret').toUpperCase()}`]: String(secret.value ?? '') };
74
+ // The endpoint goes with every secret, so the file is enough to call Commerce Layer.
75
+ if (!env.CL_BASE_ENDPOINT && typeof data.base_endpoint === 'string')
76
+ env.CL_BASE_ENDPOINT = data.base_endpoint;
77
+ const written = new Date().toISOString();
78
+ const content = format === 'json'
79
+ ? JSON.stringify({ org: ctx.org, credential_id: credentialId ?? null, kind: secret.kind ?? null, value: secret.value ?? null, expires_at: secret.expires_at ?? null, env, written_at: written }, null, 2) + '\n'
80
+ : [`# ${secret.kind ?? 'secret'} of credential ${credentialId ?? '?'} in ${ctx.org ?? '?'}, written by c11r at ${written}.`, '# Keep it out of git.', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ''].join('\n');
81
+ try {
82
+ atomicWrite(file, content, plan.overwrite);
83
+ }
84
+ catch (e) {
85
+ const id = credentialId ?? '<id>';
86
+ const created = ctx.verb === 'create';
87
+ fail('secret_not_written', `The API answered the ${secret.kind ?? 'secret'} of credential ${id} once, and c11r could not write it to ${file}: ${e.message}. It was not printed, and the API cannot show it again. ${created ? `c11r credentials token ${id} gives a working token now; for a new secret, create another credential and delete ${id}.` : `c11r credentials token ${id} gives another token.`}`, { path: 'out', next_step: { action: 'fix_request', command: `c11r credentials token ${id}` }, details: { credential_id: credentialId ?? null, file } });
88
+ }
89
+ // An --out file is not removed with its credential: remember that one exists, for the warning.
90
+ if (plan.fixed && ctx.org)
91
+ markOutWritten(ctx.org, true);
92
+ // The API says the secret is shown once: c11r kept it in the file, and printed none of it.
93
+ const warnings = r.warnings.filter((w) => w.code !== 'secret_shown_once' && w.code !== 'token_shown_once');
94
+ if (!ignoredByGit(file))
95
+ warnings.push({ code: 'secret_not_ignored', message: `${file} is inside a git repository and git does not ignore it: add it to .gitignore before any commit.`, path: 'out' });
96
+ const { value: _value, env: _env, ...rest } = secret;
97
+ return {
98
+ ...r,
99
+ data: { ...data, secret: { kind: rest.kind ?? null, file, format, hint: rest.hint ?? null, mode: '600', expires_at: rest.expires_at ?? null } },
100
+ warnings,
101
+ };
102
+ }
103
+ /** What a dry run says about the file: where it would go and whether one is there. */
104
+ export function describePlan(plan) {
105
+ const files = plan.fixed || plan.format ? [plan.file] : [`${plan.file}.env`, `${plan.file}.json`];
106
+ return { file: files[0], format: plan.format ?? 'env', would_overwrite: files.some((f) => fs.existsSync(f)), writable: true };
107
+ }
108
+ function atomicWrite(file, content, overwrite) {
109
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
110
+ if (!overwrite && fs.existsSync(file))
111
+ throw new Error('the file appeared meanwhile');
112
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
113
+ fs.writeFileSync(tmp, content, { mode: 0o600, flag: 'wx' });
114
+ try {
115
+ fs.chmodSync(tmp, 0o600);
116
+ fs.renameSync(tmp, file);
117
+ }
118
+ catch (e) {
119
+ fs.rmSync(tmp, { force: true });
120
+ throw e;
121
+ }
122
+ }
123
+ /** True unless the file is inside a git repository that does not ignore it. */
124
+ function ignoredByGit(file) {
125
+ const r = spawnSync('git', ['check-ignore', '-q', path.basename(file)], { cwd: path.dirname(file), stdio: 'ignore' });
126
+ // 0 ignored, 1 not ignored, 128 not a repository (or no git).
127
+ return r.status !== 1;
128
+ }
129
+ /** The folder of an organization's secret files. */
130
+ export const orgSecretsDir = (org) => path.join(secretsDir(), safe(org));
131
+ /**
132
+ * After credentials delete: the files c11r wrote in the organization's secrets folder for that
133
+ * credential (its secret and its tokens), found by the credential id each file records. Files
134
+ * written with --out elsewhere are not tracked.
135
+ */
136
+ export function removeCredentialFiles(org, credentialId) {
137
+ const dir = orgSecretsDir(org);
138
+ let names;
139
+ try {
140
+ names = fs.readdirSync(dir);
141
+ }
142
+ catch {
143
+ return [];
144
+ }
145
+ const removed = [];
146
+ for (const n of names) {
147
+ const file = path.join(dir, n);
148
+ let text;
149
+ try {
150
+ if (!fs.lstatSync(file).isFile())
151
+ continue;
152
+ text = fs.readFileSync(file, 'utf8');
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ let mine = text.startsWith('#') && new RegExp(`^# \\S+ of credential ${credentialId.replace(/[^\w-]/g, '')} in `).test(text);
158
+ if (!mine) {
159
+ try {
160
+ mine = JSON.parse(text).credential_id === credentialId;
161
+ }
162
+ catch {
163
+ // Not JSON, not ours.
164
+ }
165
+ }
166
+ if (mine) {
167
+ fs.rmSync(file);
168
+ removed.push(file);
169
+ }
170
+ }
171
+ if (removed.length) {
172
+ try {
173
+ fs.rmdirSync(dir);
174
+ }
175
+ catch {
176
+ // Not empty: other credentials' files stay.
177
+ }
178
+ }
179
+ return removed;
180
+ }
181
+ /** After sandbox destroy: the organization's whole secrets folder. Null if there was none. */
182
+ export function removeOrgSecrets(org) {
183
+ const dir = orgSecretsDir(org);
184
+ if (!fs.existsSync(dir))
185
+ return null;
186
+ fs.rmSync(dir, { recursive: true, force: true });
187
+ return dir;
188
+ }
189
+ export const OUT_NOT_TRACKED = { code: 'out_files_not_tracked', message: 'Only files in c11r\'s secrets folder (~/.config/c11r/secrets/<org>/) are removed with what they belong to. A file written with --out is not tracked: delete it where it was written.' };
package/dist/version.js CHANGED
@@ -1,9 +1,5 @@
1
- // The CLI's version and how it names itself on the wire.
1
+ // The CLI's version, from package.json, and the User-Agent every request carries.
2
2
  import { createRequire } from 'node:module';
3
- /** The package version, read from package.json so a release bumps it in one place. */
4
- export const VERSION = createRequire(import.meta.url)('../package.json').version;
5
- /** The User-Agent of every request: the API answers cli_outdated when a newer CLI exists. */
6
- export const USER_AGENT = `c11r/${VERSION} (node/${process.versions.node.split('.')[0]})`;
7
- /** The one way to run the CLI that is always current, whatever is installed. */
8
- export const NPX = 'npx -y @commercelayer/agent-cli@latest';
9
- export const LATEST_HINT = `${NPX} always runs the newest version, even where a c11r binary is installed.`;
3
+ const pkg = createRequire(import.meta.url)('../package.json');
4
+ export const VERSION = pkg.version;
5
+ export const USER_AGENT = `c11r/${VERSION} (node ${process.versions.node}; ${process.platform})`;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.8.0",
4
- "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
3
+ "version": "1.0.0-dev.0",
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": [
7
7
  "commercelayer",
@@ -24,20 +24,15 @@
24
24
  "README.md"
25
25
  ],
26
26
  "scripts": {
27
- "build": "rm -rf dist.next && tsc -p tsconfig.json --outDir dist.next && chmod +x dist.next/bin.js dist.next/api/server.js && rm -rf dist.old && { mv dist dist.old 2>/dev/null; mv dist.next dist; } && rm -rf dist.old",
27
+ "build": "rm -rf dist.next && tsc -p tsconfig.json --outDir dist.next && chmod +x dist.next/bin.js && rm -rf dist.old && { mv dist dist.old 2>/dev/null; mv dist.next dist; } && rm -rf dist.old",
28
28
  "dev": "tsx src/bin.ts",
29
- "stub-api": "tsx src/api/server.ts",
30
- "test": "npm run build && vitest run",
31
- "prepublishOnly": "npm run build"
29
+ "test": "npm run build && vitest run"
32
30
  },
33
31
  "dependencies": {
34
- "imapflow": "^2.10.2",
35
- "postal-mime": "^4.2.0",
36
32
  "yaml": "^2.6.0"
37
33
  },
38
34
  "devDependencies": {
39
35
  "@types/node": "^22.0.0",
40
- "happy-dom": "^21.0.4",
41
36
  "tsx": "^4.19.0",
42
37
  "typescript": "^5.6.0",
43
38
  "vitest": "^3.0.0"
@@ -1,190 +0,0 @@
1
- // The client for the management API at api.commercelayer.ai.
2
- import { CliError, fail, notice } from '../output.js';
3
- import { NPX } from '../version.js';
4
- import { apiUrl, DEFAULT_API_URL, send } from './transport.js';
5
- export function createApi(auth) {
6
- const base = apiUrl() === 'stub' ? DEFAULT_API_URL : apiUrl().replace(/\/$/, '');
7
- const org = () => {
8
- if (!auth)
9
- fail('no_store', 'There is no store in this repo.', 'Run `c11r init` in the repo root first.');
10
- return `/stores/${auth.org}`;
11
- };
12
- const request = async (method, path, body) => {
13
- const headers = { accept: 'application/json' };
14
- if (auth)
15
- headers.authorization = `Bearer ${auth.token}`;
16
- const res = await send(method, base + path, headers, body === undefined ? undefined : JSON.stringify(body));
17
- let data;
18
- try {
19
- data = res.body ? JSON.parse(res.body) : null;
20
- }
21
- catch {
22
- fail('api_error', `${base} answered ${res.status} with something that is not JSON.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
23
- }
24
- // The API flags an outdated CLI (from the User-Agent) in warnings, on any answer, errors included.
25
- const warnings = data && typeof data === 'object' && !Array.isArray(data) ? data.warnings : undefined;
26
- if (Array.isArray(warnings)) {
27
- const isOutdated = (w) => !!w && typeof w === 'object' && w.code === 'cli_outdated';
28
- for (const n of warnings.filter(isOutdated))
29
- notice({ ...n, hint: n.hint ?? `Run the commands with ${NPX}. If c11r is installed globally, tell the human it is on ${n.current} and ${n.latest} exists.` });
30
- // Printed once, at the top of the output, not inside the order or store it came with.
31
- const rest = warnings.filter((w) => !isOutdated(w));
32
- if (rest.length)
33
- data.warnings = rest;
34
- else
35
- delete data.warnings;
36
- }
37
- if (res.status < 400)
38
- return data;
39
- const err = data?.error;
40
- if (err)
41
- fail(err.code, err.message, err.hint, err.path);
42
- fail('api_error', `The API answered ${res.status}.`, 'Try again. If it keeps happening, tell the human, with the output of `c11r status`.');
43
- };
44
- // Promotions and links: on an API older than them, the collection route is an unknown route (404 not_found).
45
- const collection = (kind) => `${org()}/${kind}`;
46
- const one = (kind, id) => `${collection(kind)}/${encodeURIComponent(id)}`;
47
- const unsupported = async (kind, call) => {
48
- try {
49
- return await call;
50
- }
51
- catch (e) {
52
- if (e instanceof CliError && (e.code === 'not_found' || (e.code === 'api_error' && /\b404\b/.test(e.message))))
53
- fail(`${kind}_unsupported`, `This API does not manage ${kind} yet.`, 'Try again later; tell the human if it persists.');
54
- throw e;
55
- }
56
- };
57
- const found = async (kind, id, call) => {
58
- try {
59
- return await call;
60
- }
61
- catch (e) {
62
- if (e instanceof CliError && e.code === 'not_found')
63
- fail('not_found', `There is no ${kind.slice(0, -1)} ${id}.`, `Run \`c11r ${kind} list\` for the ids, or \`c11r ${kind} create\` to make it.`);
64
- throw e;
65
- }
66
- };
67
- // Orders and shipments for the merchant: an API older than them answers the UCP array on GET
68
- // /orders?view=merchant and not_found on the routes it lacks. A not_found on one order or
69
- // shipment is checked against the list, so a missing order is still not_found.
70
- const qs = (query) => {
71
- const p = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== ''));
72
- return p.toString() ? `&${p}` : '';
73
- };
74
- const ordersUnsupported = () => fail('orders_unsupported', 'This API does not manage orders for the merchant yet.', 'Try again later; tell the human if it persists. c11r orders test still places test orders.');
75
- const shipmentsUnsupported = () => fail('fulfillment_unsupported', 'This API does not manage shipments yet.', 'Try again later; tell the human if it persists.');
76
- const listMerchantOrders = async (query) => {
77
- const r = (await request('GET', `${org()}/orders?view=merchant${qs(query)}`));
78
- if (Array.isArray(r) || !Array.isArray(r.orders))
79
- ordersUnsupported();
80
- return r;
81
- };
82
- const listShipments = async (query) => {
83
- try {
84
- const p = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== ''));
85
- return (await request('GET', `${org()}/shipments${p.toString() ? `?${p}` : ''}`));
86
- }
87
- catch (e) {
88
- if (e instanceof CliError && e.code === 'not_found')
89
- shipmentsUnsupported();
90
- throw e;
91
- }
92
- };
93
- const orderCall = async (call) => {
94
- try {
95
- return await call();
96
- }
97
- catch (e) {
98
- if (e instanceof CliError && e.code === 'not_found')
99
- await listMerchantOrders({ limit: '1' });
100
- throw e;
101
- }
102
- };
103
- const shipmentCall = async (call) => {
104
- try {
105
- return await call();
106
- }
107
- catch (e) {
108
- if (e instanceof CliError && e.code === 'not_found')
109
- await listShipments({ limit: '1' });
110
- throw e;
111
- }
112
- };
113
- const orderPath = (id) => `${org()}/orders/${encodeURIComponent(id)}`;
114
- const shipmentPath = (id) => `${org()}/shipments/${encodeURIComponent(id)}`;
115
- return {
116
- listMerchantOrders,
117
- getMerchantOrder: (id) => orderCall(async () => {
118
- const o = (await request('GET', `${orderPath(id)}?view=merchant`));
119
- if (o && o.payment_status === undefined)
120
- ordersUnsupported();
121
- return o;
122
- }),
123
- createOrder: async (body) => {
124
- try {
125
- return (await request('POST', `${org()}/orders`, body));
126
- }
127
- catch (e) {
128
- // An API before orders from messages has no POST /orders.
129
- if (e instanceof CliError && e.code === 'not_found')
130
- fail('orders_unsupported', 'This API cannot create orders from messages yet.', 'Try again later; tell the human if it persists.');
131
- throw e;
132
- }
133
- },
134
- orderAction: (id, action, body) => orderCall(() => request('POST', `${orderPath(id)}/${action}`, body ?? {})),
135
- listShipments,
136
- // An API before the snapshot answers not_found: ops_unsupported, and the CLI reads the lists instead.
137
- getOps: async (query) => {
138
- try {
139
- const r = (await request('GET', `${org()}/ops${qs(query).replace(/^&/, '?')}`));
140
- if (!r || typeof r !== 'object' || !r.orders || !r.shipments)
141
- fail('ops_unsupported', 'This API has no operations snapshot yet.', 'The CLI reads the order and shipment lists instead.');
142
- return r;
143
- }
144
- catch (e) {
145
- if (e instanceof CliError && e.code === 'not_found')
146
- fail('ops_unsupported', 'This API has no operations snapshot yet.', 'The CLI reads the order and shipment lists instead.');
147
- throw e;
148
- }
149
- },
150
- insights: async (query) => {
151
- try {
152
- const r = (await request('GET', `${org()}/insights${qs(query).replace(/^&/, '?')}`));
153
- if (!r || typeof r !== 'object' || !('totals' in r))
154
- fail('insights_unsupported', 'This API does not report insights yet.', 'Try again later; tell the human if it persists.');
155
- return r;
156
- }
157
- catch (e) {
158
- if (e instanceof CliError && e.code === 'not_found')
159
- fail('insights_unsupported', 'This API does not report insights yet.', 'Try again later; tell the human if it persists.');
160
- throw e;
161
- }
162
- },
163
- getShipment: (id) => shipmentCall(() => request('GET', shipmentPath(id))),
164
- shipmentAction: (id, action, body) => shipmentCall(() => request('POST', `${shipmentPath(id)}/${action}`, body ?? {})),
165
- listPromotions: () => unsupported('promotions', request('GET', collection('promotions'))),
166
- createPromotion: (spec) => unsupported('promotions', request('POST', collection('promotions'), spec)),
167
- getPromotion: (id) => found('promotions', id, request('GET', one('promotions', id))),
168
- updatePromotion: (id, spec) => found('promotions', id, request('PUT', one('promotions', id), spec)),
169
- setPromotionEnabled: (id, enabled) => found('promotions', id, request('POST', `${one('promotions', id)}/${enabled ? 'enable' : 'disable'}`)),
170
- deletePromotion: async (id) => {
171
- await found('promotions', id, request('DELETE', one('promotions', id)));
172
- },
173
- listLinks: () => unsupported('links', request('GET', collection('links'))),
174
- createLink: (spec) => unsupported('links', request('POST', collection('links'), spec)),
175
- getLink: (id) => found('links', id, request('GET', one('links', id))),
176
- setLinkEnabled: (id, enabled) => found('links', id, request('POST', `${one('links', id)}/${enabled ? 'enable' : 'disable'}`)),
177
- deleteLink: async (id) => {
178
- await found('links', id, request('DELETE', one('links', id)));
179
- },
180
- // C11R_TEST=1 (our own test runs) asks the API to tag the organization as a test.
181
- createSandbox: (name) => request('POST', '/sandboxes', process.env.C11R_TEST === '1' ? { name, test: true } : { name }),
182
- getStore: () => request('GET', org()),
183
- applyStore: (body) => request('PUT', `${org()}/resources`, body),
184
- uploadAssets: (files) => request('PUT', `${org()}/assets`, { files }),
185
- publishStorefront: (path, files) => request('PUT', `${org()}/storefront`, { path, files }),
186
- listOrders: () => request('GET', `${org()}/orders`),
187
- getOrder: (id) => request('GET', `${org()}/orders/${encodeURIComponent(id)}`),
188
- startClaim: () => request('POST', `${org()}/claim`),
189
- };
190
- }
@@ -1,26 +0,0 @@
1
- #!/usr/bin/env node
2
- // Serve the stub platform over HTTP, so the CLI's real HTTP client can be exercised:
3
- // npm run stub-api (port 4747)
4
- // C11R_API_URL=http://localhost:4747 c11r init
5
- // Store hosts are reached as http://localhost:4747/-/<org>.sandbox.commercelayer.store/...
6
- // or with a real Host header.
7
- import http from 'node:http';
8
- import { handle } from './stub/index.js';
9
- const port = Number(process.env.PORT ?? 4747);
10
- http
11
- .createServer((req, res) => {
12
- let raw = '';
13
- req.on('data', (chunk) => (raw += chunk));
14
- req.on('end', () => {
15
- const headers = {};
16
- for (const [k, v] of Object.entries(req.headers))
17
- if (typeof v === 'string')
18
- headers[k.toLowerCase()] = v;
19
- const host = headers.host ?? `localhost:${port}`;
20
- const url = new URL(req.url ?? '/', `http://${host}`);
21
- const { status, headers: resHeaders, body } = handle({ method: req.method ?? 'GET', url, headers, body: raw || undefined });
22
- res.writeHead(status, resHeaders);
23
- res.end(body);
24
- });
25
- })
26
- .listen(port, () => console.log(`stub platform on http://localhost:${port}`));
@@ -1,37 +0,0 @@
1
- // The stub's state: one JSON file, one record per sandbox. The real platform keeps
2
- // the same data behind api.commercelayer.ai and the store hosts.
3
- import fs from 'node:fs';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- const dbFile = process.env.C11R_STUB_DB
7
- ?? path.join(process.env.XDG_STATE_HOME ?? path.join(os.homedir(), '.local', 'state'), 'c11r', 'stub-api.json');
8
- export function readDb() {
9
- return fs.existsSync(dbFile) ? JSON.parse(fs.readFileSync(dbFile, 'utf8')) : {};
10
- }
11
- export function writeDb(db) {
12
- fs.mkdirSync(path.dirname(dbFile), { recursive: true });
13
- fs.writeFileSync(dbFile, JSON.stringify(db, null, 2) + '\n');
14
- }
15
- export const SANDBOX_DOMAIN = 'sandbox.commercelayer.store';
16
- /** The org a store hostname belongs to, or null if it is not a store host. */
17
- export function orgOfHost(hostname) {
18
- const m = hostname.match(/^([a-z0-9-]+)\.sandbox\.commercelayer\.store$/);
19
- return m ? m[1] : null;
20
- }
21
- /** Orders of a store, newest first, without private members. */
22
- export function publicOrders(r) {
23
- return Object.values(r.ucp.orders).map((o) => strip(o)).reverse();
24
- }
25
- /** A deep copy without the members whose name starts with _. */
26
- export function strip(value) {
27
- if (Array.isArray(value))
28
- return value.map(strip);
29
- if (value && typeof value === 'object') {
30
- const out = {};
31
- for (const [k, v] of Object.entries(value))
32
- if (!k.startsWith('_'))
33
- out[k] = strip(v);
34
- return out;
35
- }
36
- return value;
37
- }
@@ -1,18 +0,0 @@
1
- // The HTTP shapes the stub works with: what a Cloudflare Worker would see. Keeping
2
- // the stub HTTP-shaped lets UCP headers (UCP-Agent, Idempotency-Key, signatures)
3
- // be modelled the way the real API will have to handle them.
4
- export function json(status, body, headers = {}) {
5
- return { status, headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) };
6
- }
7
- /** The management API's error shape, the same the CLI prints. */
8
- export function error(status, code, message, hint, path) {
9
- return json(status, { error: { code, message, hint, ...(path ? { path } : {}) } });
10
- }
11
- export function readJson(req) {
12
- if (!req.body)
13
- return undefined;
14
- return JSON.parse(req.body);
15
- }
16
- export function bearer(req) {
17
- return req.headers.authorization?.replace(/^Bearer /i, '');
18
- }
@@ -1,52 +0,0 @@
1
- // A stand-in for the whole platform: api.commercelayer.ai (management) and
2
- // <org>.sandbox.commercelayer.store (the store: UCP, OAuth, the storefront).
3
- // server.ts serves it over HTTP; transport.ts calls handle() in-process when
4
- // C11R_API_URL=stub, so agent tests need no server running.
5
- //
6
- // Routing is by hostname. Over local HTTP, where subdomains do not resolve, the
7
- // path prefix /-/<host>/... stands in for the hostname.
8
- import { orgOfHost, readDb, writeDb } from './db.js';
9
- import { json } from './http.js';
10
- import { handleManagement } from './management.js';
11
- import { handleUcp } from './ucp.js';
12
- export function handle(req) {
13
- let url = req.url;
14
- const prefixed = url.pathname.match(/^\/-\/([^/]+)(\/.*)?$/);
15
- if (prefixed)
16
- url = new URL(`https://${prefixed[1]}${prefixed[2] ?? '/'}${url.search}`);
17
- const org = orgOfHost(url.hostname);
18
- if (org === null)
19
- return outdated(req, handleManagement({ ...req, url }));
20
- const db = readDb();
21
- const store = db[org];
22
- if (!store)
23
- return json(404, { error: { code: 'not_found', message: `There is no store at ${url.hostname}.`, hint: 'The sandbox may have expired.' } });
24
- const res = handleUcp({ ...req, url }, store);
25
- writeDb(db);
26
- return res;
27
- }
28
- /** "0.5.1" → [0, 5, 1], for comparing versions. */
29
- const parts = (v) => v.split('.').map((n) => Number.parseInt(n, 10) || 0);
30
- const older = (a, b) => {
31
- const [x, y] = [parts(a), parts(b)];
32
- for (let i = 0; i < 3; i++)
33
- if ((x[i] ?? 0) !== (y[i] ?? 0))
34
- return (x[i] ?? 0) < (y[i] ?? 0);
35
- return false;
36
- };
37
- /**
38
- * Like the API: a CLI whose User-Agent (c11r/<version>) is older than the latest gets a
39
- * cli_outdated warning on every JSON object it is answered. The stub's latest is
40
- * C11R_STUB_LATEST_CLI; unset, every CLI is current.
41
- */
42
- function outdated(req, res) {
43
- const latest = process.env.C11R_STUB_LATEST_CLI;
44
- const current = req.headers['user-agent']?.match(/^c11r\/(\d+\.\d+\.\d+)/)?.[1];
45
- if (!latest || !current || !older(current, latest) || !res.body)
46
- return res;
47
- const body = JSON.parse(res.body.toString());
48
- if (!body || typeof body !== 'object' || Array.isArray(body))
49
- return res;
50
- const warning = { code: 'cli_outdated', current, latest, hint: `Run the commands with npx -y @commercelayer/agent-cli@latest. If c11r is installed globally, tell the human it is on ${current} and ${latest} exists.` };
51
- return { ...res, body: JSON.stringify({ ...body, warnings: [...(Array.isArray(body.warnings) ? body.warnings : []), warning] }) };
52
- }
@@ -1,52 +0,0 @@
1
- // Locales on the store host, as the store Worker does them: one per country, from the
2
- // `locales` the API derives on apply (the stub keeps them on the record). The texts come
3
- // from applied.products[sku].translations[ll], falling back to the base language.
4
- export const COOKIE = 'c11r_locale';
5
- export const baseLanguage = (r) => r.applied?.language ?? 'en';
6
- /** The languages the store has: the base one, then every language a product is translated into. */
7
- export function languagesOf(r) {
8
- const out = new Set([baseLanguage(r)]);
9
- for (const p of Object.values(r.applied?.products ?? {}))
10
- for (const ll of Object.keys(p.translations ?? {}))
11
- out.add(ll);
12
- return out;
13
- }
14
- /** A product's name, description and category in a language, falling back to the base one. */
15
- export function texts(p, ll) {
16
- const t = p.translations?.[ll] ?? {};
17
- return { name: t.name ?? p.name, description: t.description ?? p.description, category: t.category ?? p.category };
18
- }
19
- /** The store's locales; none before an apply that derived them (the site stays as it was). */
20
- export const localesOf = (r) => r.locales ?? [];
21
- /** The locale at /<path>/, by its country: a known country with the wrong language still matches. */
22
- export const localeOfCountry = (r, country) => localesOf(r).find((l) => l.country === country.toUpperCase());
23
- /** The locale a cookie names, if it is one of the store's. */
24
- export function cookieLocale(r, cookie) {
25
- const m = cookie?.match(new RegExp(`(?:^|;\\s*)${COOKIE}=([a-z]{2}-[a-z]{2})`));
26
- return m ? localesOf(r).find((l) => l.path === m[1]) : undefined;
27
- }
28
- /** The locale of a product URL for a market: the buyer's country when it is in the market, else the market's first. */
29
- export function marketLocale(r, market, country) {
30
- const ls = localesOf(r).filter((l) => l.market === market);
31
- return ls.find((l) => country && l.country === country.toUpperCase()) ?? ls[0];
32
- }
33
- /**
34
- * The language of a UCP catalog answer: context.language (primary subtag), else the first
35
- * Accept-Language the store has, else the language of the buyer's country's locale, else the base.
36
- */
37
- export function ucpLanguage(r, context, acceptLanguage) {
38
- const has = languagesOf(r);
39
- const primary = (tag) => tag.trim().split(/[-_;]/)[0].toLowerCase();
40
- const asked = typeof context?.language === 'string' ? primary(context.language) : '';
41
- if (has.has(asked))
42
- return asked;
43
- for (const part of (acceptLanguage ?? '').split(',')) {
44
- const ll = primary(part);
45
- if (ll && has.has(ll))
46
- return ll;
47
- }
48
- const country = typeof context?.address_country === 'string' ? localeOfCountry(r, context.address_country) : undefined;
49
- if (country && has.has(country.language))
50
- return country.language;
51
- return baseLanguage(r);
52
- }