@commercelayer/agent-cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,350 @@
1
+ // Everything a store host serves: the UCP profile and REST service, the OAuth
2
+ // surface for identity linking, the permalink, the policy and continue pages,
3
+ // and the published storefront. This is the spec of the Worker behind
4
+ // *.sandbox.commercelayer.store.
5
+ import crypto from 'node:crypto';
6
+ import { agentProfile, AGENT_PROFILE_URL, capabilityEntry, EXTENSIONS, SANDBOX_HANDLER, SANDBOX_HANDLER_ID, SANDBOX_HANDLER_SCHEMA, SANDBOX_HANDLER_SPEC, SHOPPING_CAPABILITIES, UCP, UCP_VERSION } from '../../ucp/platform.js';
7
+ import { contentDigest, signHeaders } from '../../ucp/signing.js';
8
+ import { strip } from './db.js';
9
+ import { json } from './http.js';
10
+ import * as shop from './shopping.js';
11
+ const SCOPES = ['dev.ucp.shopping.order:read', 'dev.ucp.shopping.order:manage'];
12
+ const OUR_CAPABILITIES = [...SHOPPING_CAPABILITIES, 'dev.ucp.common.ask', ...Object.keys(EXTENSIONS), 'dev.ucp.common.identity_linking', 'dev.ucp.shopping.permalink'];
13
+ // ---------- profile
14
+ export function profile(r, origin) {
15
+ const service = (schema) => [{ version: UCP_VERSION, spec: `${UCP}/specification/overview`, transport: 'rest', schema: `${UCP}/services/${schema}/rest.openapi.json`, endpoint: `${origin}/ucp/v1` }];
16
+ const capabilities = {};
17
+ for (const name of OUR_CAPABILITIES) {
18
+ const extra = {};
19
+ if (name === 'dev.ucp.common.identity_linking')
20
+ extra.config = { scopes: Object.fromEntries(SCOPES.map((s) => [s, { description: { plain: s.endsWith(':read') ? 'View your orders and their status.' : 'Cancel or return your orders.' } }])) };
21
+ if (name === 'dev.ucp.shopping.permalink')
22
+ extra.config = { endpoint: `${origin}/buy` };
23
+ if (name === 'dev.ucp.shopping.fulfillment')
24
+ extra.config = { method_combinations: [['shipping']] };
25
+ capabilities[name] = [capabilityEntry(name, extra)];
26
+ }
27
+ return {
28
+ ucp: {
29
+ version: UCP_VERSION,
30
+ services: { 'dev.ucp.shopping': service('shopping'), 'dev.ucp.common': service('common') },
31
+ capabilities,
32
+ payment_handlers: {
33
+ [SANDBOX_HANDLER]: [{ id: SANDBOX_HANDLER_ID, version: UCP_VERSION, spec: SANDBOX_HANDLER_SPEC, schema: SANDBOX_HANDLER_SCHEMA, available_instruments: [{ type: 'card' }], config: { environment: 'sandbox' } }],
34
+ },
35
+ },
36
+ keys: [r.ucp.public_jwk],
37
+ };
38
+ }
39
+ // ---------- negotiation
40
+ /** What the platform behind a UCP-Agent URL supports. The stub knows this CLI's profile; any other HTTPS platform is taken at face value. */
41
+ function negotiate(platformUrl) {
42
+ let theirs = null;
43
+ if (platformUrl === AGENT_PROFILE_URL)
44
+ theirs = agentProfile();
45
+ const ours = new Set(OUR_CAPABILITIES);
46
+ if (!theirs)
47
+ return { shared: ours, webhookUrl: null };
48
+ const shared = new Set();
49
+ const caps = theirs.ucp.capabilities ?? {};
50
+ for (const name of ours)
51
+ if ((caps[name] ?? []).some((e) => e.version === UCP_VERSION))
52
+ shared.add(name);
53
+ let changed = true;
54
+ while (changed) {
55
+ changed = false;
56
+ for (const [ext, parents] of Object.entries(EXTENSIONS)) {
57
+ if (shared.has(ext) && !parents.some((p) => shared.has(p))) {
58
+ shared.delete(ext);
59
+ changed = true;
60
+ }
61
+ }
62
+ }
63
+ const webhookUrl = caps['dev.ucp.shopping.order']?.[0]?.config?.webhook_url ?? null;
64
+ return { shared, webhookUrl };
65
+ }
66
+ // ---------- the REST service
67
+ const protocolError = (status, code, content, headers = {}) => json(status, { code, content }, headers);
68
+ function withUcpHeaders(r, res) {
69
+ const digest = contentDigest(res.body);
70
+ const values = { '@status': String(res.status), 'content-digest': digest, 'content-type': res.headers['content-type'] ?? 'application/json' };
71
+ const sig = signHeaders('sig1', values, { components: ['@status', 'content-digest', 'content-type'], keyid: r.ucp.kid, created: Math.floor(Date.now() / 1000) }, r.ucp.private_jwk);
72
+ return { ...res, headers: { ...res.headers, 'content-digest': digest, ...sig } };
73
+ }
74
+ function handleService(req, r, origin, path) {
75
+ // 1. Who is calling: UCP-Agent names the platform profile; Request-Id is required.
76
+ const agent = req.headers['ucp-agent']?.match(/profile="(https:\/\/[^"]+)"/);
77
+ if (!agent)
78
+ return protocolError(400, 'invalid_profile_url', 'UCP-Agent must be `profile="https://..."`, an HTTPS URL of the platform profile.');
79
+ if (!req.headers['request-id'])
80
+ return protocolError(400, 'missing_request_id', 'Request-Id is required on every request.');
81
+ const { shared, webhookUrl } = negotiate(agent[1]);
82
+ const ctx = { record: r, origin, platform: agent[1], shared, webhookUrl, bearer: bearerOf(req) };
83
+ // 2. Body integrity and idempotency.
84
+ const body = req.body ?? '';
85
+ if (req.headers['content-digest'] && req.headers['content-digest'] !== contentDigest(body))
86
+ return protocolError(400, 'digest_mismatch', 'Content-Digest does not match the body.');
87
+ let parsed = {};
88
+ if (body) {
89
+ try {
90
+ parsed = JSON.parse(body);
91
+ }
92
+ catch {
93
+ return protocolError(400, 'invalid_json', 'The body is not valid JSON.');
94
+ }
95
+ }
96
+ const mutating = req.method !== 'GET' && !path.startsWith('/catalog/') && path !== '/ask';
97
+ const key = req.headers['idempotency-key'];
98
+ const digest = contentDigest(body);
99
+ if (mutating) {
100
+ if (!key)
101
+ return protocolError(400, 'missing_idempotency_key', 'Idempotency-Key is required on operations that change state.');
102
+ const seen = r.ucp.idempotency[`${req.method} ${path} ${key}`];
103
+ if (seen) {
104
+ if (seen.digest !== digest)
105
+ return protocolError(409, 'idempotency_key_reused', 'Idempotency-Key was already used with a different body.');
106
+ return { status: seen.status, headers: { ...seen.headers, 'idempotent-replayed': 'true' }, body: seen.body };
107
+ }
108
+ }
109
+ // 3. The operation.
110
+ const res = dispatch(ctx, req.method, path, parsed);
111
+ if (mutating && key)
112
+ r.ucp.idempotency[`${req.method} ${path} ${key}`] = { digest, status: res.status, headers: res.headers, body: res.body };
113
+ return res;
114
+ }
115
+ function dispatch(ctx, method, path, body) {
116
+ const r = ctx.record;
117
+ const out = (status, data) => withUcpHeaders(r, json(status, strip(data)));
118
+ const needs = (cap) => (ctx.shared.has(cap) ? null : out(200, shop.errorResponse(ctx, 'checkout', 'capabilities_incompatible', `${cap} is not shared with this platform.`)));
119
+ let m;
120
+ if (method === 'POST' && path === '/catalog/search')
121
+ return needs('dev.ucp.shopping.catalog.search') ?? out(200, shop.searchCatalog(ctx, body));
122
+ if (method === 'POST' && path === '/catalog/lookup')
123
+ return needs('dev.ucp.shopping.catalog.lookup') ?? out(200, shop.lookupCatalog(ctx, body));
124
+ if (method === 'POST' && path === '/catalog/product')
125
+ return needs('dev.ucp.shopping.catalog.lookup') ?? out(200, shop.getProduct(ctx, body));
126
+ if (method === 'POST' && path === '/carts') {
127
+ const blocked = needs('dev.ucp.shopping.cart');
128
+ if (blocked)
129
+ return blocked;
130
+ const created = shop.createCart(ctx, body);
131
+ return out(created.status, created.body);
132
+ }
133
+ if ((m = path.match(/^\/carts\/([^/]+)$/))) {
134
+ if (method === 'GET')
135
+ return needs('dev.ucp.shopping.cart') ?? out(200, shop.getCart(ctx, m[1]));
136
+ if (method === 'PUT')
137
+ return needs('dev.ucp.shopping.cart') ?? out(200, shop.updateCart(ctx, m[1], body));
138
+ }
139
+ if (method === 'POST' && (m = path.match(/^\/carts\/([^/]+)\/cancel$/)))
140
+ return needs('dev.ucp.shopping.cart') ?? out(200, shop.cancelCart(ctx, m[1]));
141
+ if (method === 'POST' && path === '/checkout-sessions') {
142
+ const blocked = needs('dev.ucp.shopping.checkout');
143
+ if (blocked)
144
+ return blocked;
145
+ const created = shop.createCheckout(ctx, body);
146
+ return out(created.status, created.body);
147
+ }
148
+ if ((m = path.match(/^\/checkout-sessions\/([^/]+)$/))) {
149
+ if (method === 'GET')
150
+ return needs('dev.ucp.shopping.checkout') ?? out(200, shop.getCheckout(ctx, m[1]));
151
+ if (method === 'PUT')
152
+ return needs('dev.ucp.shopping.checkout') ?? out(200, shop.updateCheckout(ctx, m[1], body));
153
+ }
154
+ if (method === 'POST' && (m = path.match(/^\/checkout-sessions\/([^/]+)\/complete$/)))
155
+ return needs('dev.ucp.shopping.checkout') ?? out(200, shop.completeCheckout(ctx, m[1], body));
156
+ if (method === 'POST' && (m = path.match(/^\/checkout-sessions\/([^/]+)\/cancel$/)))
157
+ return needs('dev.ucp.shopping.checkout') ?? out(200, shop.cancelCheckout(ctx, m[1]));
158
+ if (method === 'GET' && (m = path.match(/^\/orders\/([^/]+)$/))) {
159
+ const blocked = needs('dev.ucp.shopping.order');
160
+ if (blocked)
161
+ return blocked;
162
+ const order = shop.getOrder(ctx, m[1]);
163
+ if (!order)
164
+ return out(200, shop.errorResponse(ctx, 'order', 'not_found', `No order ${m[1]}.`));
165
+ const auth = authorize(ctx, order);
166
+ if (auth)
167
+ return auth;
168
+ return out(200, order);
169
+ }
170
+ if (method === 'POST' && path === '/ask')
171
+ return needs('dev.ucp.common.ask') ?? out(200, shop.ask(ctx, body));
172
+ return protocolError(404, 'not_found', `No operation ${method} ${path} in this service.`);
173
+ }
174
+ /** Orders are private: the platform that placed one, or a buyer token with the order scope, may read it. */
175
+ function authorize(ctx, order) {
176
+ const r = ctx.record;
177
+ const issuer = ctx.origin;
178
+ const bearer = ctx.bearer;
179
+ if (bearer) {
180
+ const t = r.ucp.tokens[bearer];
181
+ if (!t || new Date(t.expires_at) < new Date()) {
182
+ return json(401, { ucp: shop.envelope(ctx, 'order', 'error'), messages: [{ type: 'error', code: 'identity_required', content: 'The token is invalid or expired.', severity: 'requires_buyer_review' }] }, { 'www-authenticate': `Bearer realm="${issuer}", error="invalid_token"` });
183
+ }
184
+ if (!t.scope.split(' ').some((s) => SCOPES.includes(s))) {
185
+ return json(403, { ucp: shop.envelope(ctx, 'order', 'error'), messages: [{ type: 'error', code: 'insufficient_scope', content: `Reading orders needs ${SCOPES[0]}.`, severity: 'requires_buyer_review' }] }, { 'www-authenticate': `Bearer realm="${issuer}", error="insufficient_scope", scope="${SCOPES[0]}"` });
186
+ }
187
+ return null;
188
+ }
189
+ if (order._platform === ctx.platform)
190
+ return null;
191
+ return json(401, { ucp: shop.envelope(ctx, 'order', 'error'), messages: [{ type: 'error', code: 'identity_required', content: 'Sign in to see this order.', severity: 'requires_buyer_review' }] }, { 'www-authenticate': `Bearer realm="${issuer}"` });
192
+ }
193
+ // ---------- OAuth 2.0 for identity linking (RFC 8414 metadata, PKCE, client credentials)
194
+ function oauthMetadata(origin) {
195
+ return {
196
+ issuer: origin,
197
+ authorization_endpoint: `${origin}/oauth/authorize`,
198
+ token_endpoint: `${origin}/oauth/token`,
199
+ revocation_endpoint: `${origin}/oauth/revoke`,
200
+ jwks_uri: `${origin}/oauth/jwks`,
201
+ scopes_supported: SCOPES,
202
+ response_types_supported: ['code'],
203
+ grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
204
+ code_challenge_methods_supported: ['S256'],
205
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_basic'],
206
+ authorization_response_iss_parameter_supported: true,
207
+ };
208
+ }
209
+ function form(body) {
210
+ return Object.fromEntries(new URLSearchParams(body ?? ''));
211
+ }
212
+ function handleOauth(req, r, origin, path) {
213
+ const u = r.ucp;
214
+ const oauthError = (error, description, status = 400) => json(status, { error, error_description: description });
215
+ if (path === '/.well-known/oauth-authorization-server' && req.method === 'GET')
216
+ return json(200, oauthMetadata(origin), { 'cache-control': 'public, max-age=300' });
217
+ if (path === '/oauth/jwks' && req.method === 'GET')
218
+ return json(200, { keys: [u.public_jwk] });
219
+ if (path === '/oauth/authorize' && req.method === 'GET') {
220
+ const q = Object.fromEntries(req.url.searchParams);
221
+ if (q.response_type !== 'code' || !q.client_id || !q.redirect_uri || !q.code_challenge || q.code_challenge_method !== 'S256') {
222
+ return oauthError('invalid_request', 'response_type=code, client_id, redirect_uri, code_challenge and code_challenge_method=S256 are required.');
223
+ }
224
+ // A sandbox has no users: the buyer is signed in and consents at once.
225
+ const code = crypto.randomBytes(16).toString('hex');
226
+ u.codes[code] = { client_id: q.client_id, redirect_uri: q.redirect_uri, scope: q.scope ?? SCOPES[0], code_challenge: q.code_challenge, expires_at: new Date(Date.now() + 600_000).toISOString() };
227
+ const location = new URL(q.redirect_uri);
228
+ location.searchParams.set('code', code);
229
+ if (q.state)
230
+ location.searchParams.set('state', q.state);
231
+ location.searchParams.set('iss', origin);
232
+ return { status: 302, headers: { location: location.toString(), 'cache-control': 'no-store' }, body: '' };
233
+ }
234
+ if (path === '/oauth/token' && req.method === 'POST') {
235
+ const f = form(req.body);
236
+ let scope = f.scope ?? SCOPES[0];
237
+ let client = f.client_id ?? 'anonymous';
238
+ if (f.grant_type === 'authorization_code') {
239
+ const c = u.codes[f.code ?? ''];
240
+ if (!c || new Date(c.expires_at) < new Date())
241
+ return oauthError('invalid_grant', 'Unknown or expired code.');
242
+ if (c.redirect_uri !== f.redirect_uri || c.client_id !== f.client_id)
243
+ return oauthError('invalid_grant', 'redirect_uri or client_id do not match the authorization request.');
244
+ const challenge = crypto.createHash('sha256').update(f.code_verifier ?? '').digest('base64url');
245
+ if (challenge !== c.code_challenge)
246
+ return oauthError('invalid_grant', 'PKCE verification failed.');
247
+ delete u.codes[f.code];
248
+ scope = c.scope;
249
+ client = c.client_id;
250
+ }
251
+ else if (f.grant_type !== 'client_credentials')
252
+ return oauthError('unsupported_grant_type', 'Use authorization_code or client_credentials.');
253
+ const token = `at_${crypto.randomBytes(16).toString('hex')}`;
254
+ u.tokens[token] = { client_id: client, scope, expires_at: new Date(Date.now() + 3600_000).toISOString() };
255
+ return json(200, { access_token: token, token_type: 'Bearer', expires_in: 3600, scope }, { 'cache-control': 'no-store' });
256
+ }
257
+ if (path === '/oauth/revoke' && req.method === 'POST') {
258
+ delete u.tokens[form(req.body).token ?? ''];
259
+ return { status: 200, headers: {}, body: '' };
260
+ }
261
+ return null;
262
+ }
263
+ // ---------- permalink, pages, storefront
264
+ const html = (status, title, body, headers = {}) => ({
265
+ status,
266
+ headers: { 'content-type': 'text/html; charset=utf-8', ...headers },
267
+ body: `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head><body><main><h1>${title}</h1>${body}</main></body></html>\n`,
268
+ });
269
+ function handlePermalink(req, r, origin, path) {
270
+ const safe = { 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' };
271
+ const items = path.replace(/^\/buy\/?/, '');
272
+ const lines = [];
273
+ if (items) {
274
+ if (!/^(?:[A-Za-z0-9._-]+|~[A-Za-z0-9_-]+):[1-9][0-9]*(?:,(?:[A-Za-z0-9._-]+|~[A-Za-z0-9_-]+):[1-9][0-9]*)*$/.test(items)) {
275
+ return { status: 303, headers: { location: `${origin}/`, ...safe }, body: '' };
276
+ }
277
+ for (const part of items.split(',')) {
278
+ const [token, qty] = part.split(':');
279
+ const itemId = token.startsWith('~') ? Buffer.from(token.slice(1), 'base64url').toString('utf8') : token;
280
+ lines.push({ item: { id: itemId }, quantity: Number(qty) });
281
+ }
282
+ }
283
+ const body = { line_items: lines };
284
+ for (const [k, v] of req.url.searchParams) {
285
+ if (k === 'continue_to')
286
+ continue;
287
+ const pointer = k.replace(/^\/+/, '').split('/');
288
+ if (pointer[0] === 'context' || pointer[0] === 'buyer')
289
+ (body[pointer[0]] ??= {})[pointer[1]] = v;
290
+ if (pointer[0] === 'discounts' && pointer[1] === 'codes')
291
+ ((body.discounts ??= {}).codes ??= []).push(v);
292
+ }
293
+ const ctx = { record: r, origin, platform: 'permalink', shared: new Set(OUR_CAPABILITIES) };
294
+ const created = lines.length ? shop.createCart(ctx, body) : null;
295
+ const cartId = created?.body?.id;
296
+ let target = `${origin}/`;
297
+ const cont = req.url.searchParams.get('continue_to');
298
+ if (cont && /^\/(?!\/)[^\s\\]*$/.test(cont))
299
+ target = new URL(cont, origin).toString();
300
+ else if (cartId)
301
+ target = `${origin}/carts/${cartId}`;
302
+ return { status: 303, headers: { location: target, ...safe }, body: '' };
303
+ }
304
+ const MIME = { html: 'text/html; charset=utf-8', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', json: 'application/json', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', ico: 'image/x-icon', txt: 'text/plain' };
305
+ function serveStorefront(r, path) {
306
+ const files = r.ucp.files;
307
+ const candidates = [path.replace(/^\//, ''), `${path.replace(/^\/|\/$/g, '')}/index.html`.replace(/^\//, ''), ...(path === '/' ? ['index.html'] : [])];
308
+ for (const c of candidates) {
309
+ if (files[c] !== undefined) {
310
+ const ext = c.split('.').pop() ?? '';
311
+ return { status: 200, headers: { 'content-type': MIME[ext] ?? 'application/octet-stream' }, body: Buffer.from(files[c], 'base64').toString(ext in MIME && !MIME[ext].startsWith('image') ? 'utf8' : 'binary') };
312
+ }
313
+ }
314
+ if (Object.keys(files).length === 0)
315
+ return html(200, r.org, '<p>Nothing published yet. Run <code>c11r storefront publish</code>.</p>');
316
+ return html(404, 'Not found', `<p>No ${path} in this storefront.</p>`);
317
+ }
318
+ // ---------- the host
319
+ export function handleUcp(req, r) {
320
+ const origin = `https://${req.url.hostname}`;
321
+ const path = req.url.pathname.replace(/\/+$/, '') || '/';
322
+ if (path === '/.well-known/ucp' && req.method === 'GET')
323
+ return json(200, profile(r, origin), { 'cache-control': 'public, max-age=300' });
324
+ const oauth = handleOauth(req, r, origin, path);
325
+ if (oauth)
326
+ return oauth;
327
+ if (path.startsWith('/ucp/v1/') || path === '/ucp/v1') {
328
+ const res = handleService({ ...req, headers: { ...req.headers } }, r, origin, path.slice('/ucp/v1'.length) || '/');
329
+ return res;
330
+ }
331
+ if (path === '/buy' || path.startsWith('/buy/'))
332
+ return handlePermalink(req, r, origin, path);
333
+ let m;
334
+ if ((m = path.match(/^\/policies\/(privacy|terms|returns)$/))) {
335
+ const titles = { privacy: 'Privacy policy', terms: 'Terms of service', returns: 'Returns' };
336
+ return html(200, titles[m[1]], `<p>Default sandbox ${titles[m[1]].toLowerCase()} for ${r.org}. Set <code>policies</code> in store.yml to use your own.</p>`);
337
+ }
338
+ if ((m = path.match(/^\/(carts|checkout-sessions|orders)\/([^/]+)$/))) {
339
+ const kind = m[1] === 'carts' ? 'cart' : m[1] === 'orders' ? 'order' : 'checkout';
340
+ const found = kind === 'cart' ? r.ucp.carts[m[2]] : kind === 'order' ? r.ucp.orders[m[2]] : r.ucp.checkouts[m[2]];
341
+ if (!found)
342
+ return html(404, 'Not found', `<p>No ${kind} ${m[2]}.</p>`);
343
+ return html(200, `${kind === 'order' ? `Order ${found.label}` : `Your ${kind}`}`, `<p>${kind === 'order' ? 'Thank you. ' : ''}${found.line_items.length} line item(s), ${found.currency} ${(found.totals.at(-1).amount / 100).toFixed(2)}.</p>`);
344
+ }
345
+ return serveStorefront(r, path);
346
+ }
347
+ /** Lets the service read the bearer token for order authorization. */
348
+ export function bearerOf(req) {
349
+ return req.headers.authorization?.replace(/^Bearer /i, '');
350
+ }
@@ -0,0 +1,112 @@
1
+ // A stand-in for api.commercelayer.ai: the same routes, answered from a JSON file.
2
+ // server.ts serves it over HTTP; client.ts can also call handle() in-process when
3
+ // COMMERCELAYER_AI_API_URL=stub, so agent tests need no server running.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ const dbFile = process.env.COMMERCELAYER_AI_STUB_DB
9
+ ?? path.join(process.env.XDG_STATE_HOME ?? path.join(os.homedir(), '.local', 'state'), 'commercelayer-ai', 'stub-api.json');
10
+ function readDb() {
11
+ return fs.existsSync(dbFile) ? JSON.parse(fs.readFileSync(dbFile, 'utf8')) : {};
12
+ }
13
+ function writeDb(db) {
14
+ fs.mkdirSync(path.dirname(dbFile), { recursive: true });
15
+ fs.writeFileSync(dbFile, JSON.stringify(db, null, 2) + '\n');
16
+ }
17
+ function error(status, code, message, hint) {
18
+ return { status, body: { error: { code, message, hint } } };
19
+ }
20
+ function publicState(r) {
21
+ const { token: _token, ...state } = r;
22
+ return state;
23
+ }
24
+ export function handle(req) {
25
+ const db = readDb();
26
+ if (req.method === 'POST' && req.path === '/sandboxes') {
27
+ const name = String(req.body?.name ?? 'store');
28
+ const slug = `${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'store'}-${crypto.randomBytes(2).toString('hex')}`;
29
+ const expires = new Date(Date.now() + 7 * 24 * 3600 * 1000);
30
+ expires.setUTCHours(0, 0, 0, 0);
31
+ const record = {
32
+ org: slug,
33
+ token: `sbx_${crypto.randomBytes(16).toString('hex')}`,
34
+ sandbox: true,
35
+ claimed: false,
36
+ claim_url: `https://commercelayer.ai/claim/${crypto.randomBytes(6).toString('hex')}`,
37
+ expires_at: expires.toISOString(),
38
+ preview_url: `https://${slug}.sandbox.commercelayer.store`,
39
+ sales_channel_client_id: `sc_${crypto.randomBytes(12).toString('hex')}`,
40
+ storefront: null,
41
+ applied: null,
42
+ applied_hash: null,
43
+ orders: [],
44
+ };
45
+ db[slug] = record;
46
+ writeDb(db);
47
+ const { org, token, claim_url, expires_at, preview_url, sales_channel_client_id } = record;
48
+ return { status: 201, body: { org, token, claim_url, expires_at, preview_url, sales_channel_client_id } };
49
+ }
50
+ const m = req.path.match(/^\/stores\/([^/]+)(\/.*)?$/);
51
+ if (!m)
52
+ return error(404, 'not_found', `No route ${req.method} ${req.path}.`, 'Check the command; run `commercelayer-ai help`.');
53
+ const [, org, rest = ''] = m;
54
+ const record = db[org];
55
+ if (!record || record.token !== req.token) {
56
+ return error(401, 'unauthorized', `No sandbox ${org} for this token.`, 'The sandbox may have expired or the credentials file may be stale. Remove .commercelayer/credentials and run `commercelayer-ai init` again.');
57
+ }
58
+ if (new Date(record.expires_at) < new Date() && !record.claimed) {
59
+ return error(410, 'expired', `Sandbox ${org} expired on ${record.expires_at}.`, 'Remove .commercelayer/credentials and run `commercelayer-ai init` to create a new sandbox; store.yml is kept.');
60
+ }
61
+ if (req.method === 'GET' && rest === '')
62
+ return { status: 200, body: publicState(record) };
63
+ if (req.method === 'PUT' && rest === '/resources') {
64
+ const { applied, hash } = req.body;
65
+ record.applied = applied;
66
+ record.applied_hash = hash;
67
+ writeDb(db);
68
+ return { status: 200, body: publicState(record) };
69
+ }
70
+ if (req.method === 'PUT' && rest === '/storefront') {
71
+ const { path: dir, files } = req.body;
72
+ if (!files?.some((f) => f.path === 'index.html')) {
73
+ return error(422, 'no_storefront', 'The upload has no index.html.', 'Run `commercelayer-ai storefront init` to create one, or set storefront.path in store.yml to the folder that has index.html.');
74
+ }
75
+ // The real API serves these files at preview_url; the stub only remembers them.
76
+ record.storefront = { path: dir, files: files.length, published_at: new Date().toISOString() };
77
+ writeDb(db);
78
+ return { status: 200, body: record.storefront };
79
+ }
80
+ if (req.method === 'GET' && rest === '/orders')
81
+ return { status: 200, body: [...record.orders].reverse() };
82
+ const order = rest.match(/^\/orders\/(\d+)$/);
83
+ if (req.method === 'GET' && order) {
84
+ const found = record.orders.find((o) => String(o.number) === order[1]);
85
+ if (!found)
86
+ return error(404, 'not_found', `There is no order ${order[1]}.`, 'Run `commercelayer-ai orders list` to see the order numbers.');
87
+ return { status: 200, body: found };
88
+ }
89
+ if (req.method === 'POST' && rest === '/orders/test') {
90
+ if (!record.applied)
91
+ return error(409, 'not_applied', 'The store has nothing applied yet.', 'Run `commercelayer-ai apply` first.');
92
+ const market = Object.values(record.applied.markets)[0];
93
+ const product = Object.values(record.applied.products)[0];
94
+ const order = {
95
+ number: 1001 + record.orders.length,
96
+ status: 'paid',
97
+ market: market.code,
98
+ currency: market.currency,
99
+ total: product.prices[market.currency],
100
+ items: [{ sku: product.sku, quantity: 1 }],
101
+ payment_gateway: 'sandbox',
102
+ payment: 'captured',
103
+ };
104
+ record.orders.push(order);
105
+ writeDb(db);
106
+ return { status: 201, body: order };
107
+ }
108
+ if (req.method === 'POST' && rest === '/claim') {
109
+ return { status: 200, body: { verification_url: 'https://commercelayer.ai/device', user_code: 'BXQT-4KMF' } };
110
+ }
111
+ return error(404, 'not_found', `No route ${req.method} ${req.path}.`, 'Check the command; run `commercelayer-ai help`.');
112
+ }
@@ -0,0 +1,37 @@
1
+ // One way to send HTTP requests, for both the management client and the UCP buyer:
2
+ // over the network, or in-process to the stub when C11R_API_URL=stub.
3
+ // When the API URL points at a local server, requests to other hosts (the store
4
+ // hosts) are rewritten to that server's /-/<host>/ prefix, so one local process
5
+ // stands in for the whole platform.
6
+ import { fail } from '../output.js';
7
+ import { handle } from './stub/index.js';
8
+ export const DEFAULT_API_URL = 'https://api.commercelayer.ai';
9
+ export const apiUrl = () => process.env.C11R_API_URL ?? DEFAULT_API_URL;
10
+ export async function send(method, url, headers = {}, body) {
11
+ const base = apiUrl();
12
+ const lower = {};
13
+ for (const [k, v] of Object.entries(headers))
14
+ lower[k.toLowerCase()] = v;
15
+ if (body !== undefined && !lower['content-type'])
16
+ lower['content-type'] = 'application/json';
17
+ if (base === 'stub')
18
+ return handle({ method, url: new URL(url), headers: lower, body });
19
+ let target = url;
20
+ const baseUrl = new URL(base);
21
+ const targetUrl = new URL(url);
22
+ if (base !== DEFAULT_API_URL && targetUrl.host !== baseUrl.host && !url.startsWith(base)) {
23
+ target = `${base.replace(/\/$/, '')}/-/${targetUrl.host}${targetUrl.pathname}${targetUrl.search}`;
24
+ }
25
+ let res;
26
+ try {
27
+ // Never follow redirects: UCP forbids it for profiles, and the 302/303 of
28
+ // /oauth/authorize and the permalink are the answer, not a hop.
29
+ res = await fetch(target, { method, headers: lower, body, redirect: 'manual' });
30
+ }
31
+ catch (e) {
32
+ fail('api_unreachable', `Could not reach ${new URL(target).origin}: ${e.message}`, 'Check the network. If you are developing the CLI, run `npm run stub-api` and set C11R_API_URL=http://localhost:4747, or set it to "stub".');
33
+ }
34
+ const resHeaders = {};
35
+ res.headers.forEach((v, k) => (resHeaders[k] = v));
36
+ return { status: res.status, headers: resHeaders, body: await res.text() };
37
+ }
@@ -0,0 +1,4 @@
1
+ // The contract between the CLI and api.commercelayer.ai (the management API).
2
+ // The store hosts speak UCP, which has its own spec; src/ucp/ is the client side.
3
+ // stub/ implements both locally until the real platform exists.
4
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // c11r: the Commerce Layer CLI for coding agents (package @commercelayer/agent-cli).
3
+ // Arguments are a command, optional subcommand, --flags and --option values.
4
+ // Output is always JSON; exit code 1 on an error or a failed check.
5
+ import * as cmd from './commands.js';
6
+ import { CliError, print } from './output.js';
7
+ const VALUE_OPTIONS = new Set(['--sku', '--quantity', '--country', '--code']);
8
+ const argv = process.argv.slice(2);
9
+ const flags = new Set();
10
+ const values = {};
11
+ const args = [];
12
+ for (let i = 0; i < argv.length; i++) {
13
+ const a = argv[i];
14
+ if (!a.startsWith('--')) {
15
+ args.push(a);
16
+ continue;
17
+ }
18
+ const eq = a.indexOf('=');
19
+ if (eq > 0)
20
+ values[a.slice(2, eq)] = a.slice(eq + 1);
21
+ else if (VALUE_OPTIONS.has(a) && i + 1 < argv.length && !argv[i + 1].startsWith('--'))
22
+ values[a.slice(2)] = argv[++i];
23
+ else
24
+ flags.add(a);
25
+ }
26
+ const [name, sub] = args;
27
+ async function run() {
28
+ if (flags.has('--version') || name === 'version')
29
+ return { version: cmd.VERSION };
30
+ switch (name) {
31
+ case undefined:
32
+ case 'help':
33
+ return cmd.help(args.slice(1).join(' ') || undefined);
34
+ case 'init':
35
+ return cmd.init();
36
+ case 'plan':
37
+ return cmd.plan();
38
+ case 'apply':
39
+ return cmd.apply(flags);
40
+ case 'status':
41
+ return cmd.status();
42
+ case 'claim':
43
+ return cmd.claim();
44
+ case 'storefront':
45
+ if (sub === 'init')
46
+ return cmd.storefrontInit(flags);
47
+ if (sub === 'publish')
48
+ return cmd.storefrontPublish();
49
+ throw new CliError('unknown_command', `There is no command "storefront ${sub ?? ''}".`, 'Use `c11r storefront init` or `c11r storefront publish`.');
50
+ case 'orders':
51
+ if (sub === 'test')
52
+ return cmd.ordersTest(flags, values);
53
+ if (sub === 'list')
54
+ return cmd.ordersList();
55
+ if (sub === 'show')
56
+ return cmd.ordersShow(args[2]);
57
+ throw new CliError('unknown_command', `There is no command "orders ${sub ?? ''}".`, 'Use `c11r orders test`, `orders list` or `orders show <id>`.');
58
+ case 'ucp':
59
+ if (sub === 'profile')
60
+ return cmd.ucpProfile(flags);
61
+ if (sub === 'check')
62
+ return cmd.ucpCheck();
63
+ throw new CliError('unknown_command', `There is no command "ucp ${sub ?? ''}".`, 'Use `c11r ucp profile` or `c11r ucp check`.');
64
+ default:
65
+ throw new CliError('unknown_command', `There is no command "${name}".`, `Run \`c11r help\`. Commands: ${Object.keys(cmd.HELP).join(', ')}.`);
66
+ }
67
+ }
68
+ run()
69
+ .then((result) => {
70
+ print(result);
71
+ if (result?.status === 'fail')
72
+ process.exit(1);
73
+ })
74
+ .catch((e) => {
75
+ if (e instanceof CliError) {
76
+ print({ error: { code: e.code, message: e.message, hint: e.hint } });
77
+ }
78
+ else {
79
+ const message = e instanceof Error ? e.message : String(e);
80
+ print({ error: { code: 'internal', message, hint: 'This is a bug in the CLI. Report it at https://commercelayer.ai/support with this output.' } });
81
+ }
82
+ process.exit(1);
83
+ });