@commercelayer/agent-cli 0.1.8 → 0.1.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 CHANGED
@@ -15,7 +15,7 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
15
15
  | --- | --- |
16
16
  | `init [--name <business name>]` | Create a sandbox store, with no login. The name (the folder name when absent) names the organization; the org slug and store hostname derive from it, numbered when taken. Writes `store.yml` (a commented template) and `credentials`. Reuses the store if they exist. |
17
17
  | `plan` | Validate `store.yml` and the product files, and list what `apply` would create, update or delete: markets, products, and the store's name and policies. |
18
- | `apply [--allow-delete]` | Make the store match the files. Refuses deletes without the flag. |
18
+ | `apply [--allow-delete] [--full]` | Make the store match the files. Refuses deletes without `--allow-delete`. Product changes go through a Commerce Layer import: `apply` waits for it, with progress as JSON lines on stderr, and prints one result. `apply_failed` lists the products Commerce Layer refused; `apply_pending` means the import keeps going after 10 minutes, and `c11r status` follows it. An apply still running is finished before a new one starts. `--full` re-imports every product and price, after edits in the Commerce Layer dashboard. |
19
19
  | `catalog pull` | Read the products from the catalog source in `store.yml` (a Supabase table, a Google Sheet or a Google Merchant Center feed) into `.commercelayer/products/catalog.csv`. Then `plan` and `apply` as usual. |
20
20
  | `storefront init [--force]` | Write `storefront/index.html`: a static page on [drop-in.js](https://commercelayer.github.io/drop-in.js/) listing the products in `store.yml`, grouped by top-level category with a navigation, with a minicart that slides in on add to cart, and the hosted cart and checkout, wired to the sandbox's sales channel. Refuses to overwrite without the flag. |
21
21
  | `storefront publish` | Upload the folder at `storefront.path` (default `storefront`) to the store host. |
@@ -24,7 +24,7 @@ npx -y @commercelayer/agent-cli@latest init --name "The Blue Brand" # runs c11
24
24
  | `orders show <id or number>` | One order. |
25
25
  | `ucp profile [--agent]` | The store's UCP business profile (`/.well-known/ucp`); with `--agent`, this CLI's own platform profile. |
26
26
  | `ucp check` | Walk every UCP capability the store advertises and report what passes. Places and pays one real test order on the way (the output says which). Exits 1 on a failed check. |
27
- | `status` | The sandbox, the sales channel client id, the published storefront, what is applied, the pending changes (exactly what `plan` lists; a store that does not load is pending too, with the error), orders, the UCP profile URL. |
27
+ | `status` | The sandbox, the sales channel client id, the published storefront, what is applied, the last import job (`apply`), the pending changes (exactly what `plan` lists; a store that does not load is pending too, with the error), orders, the UCP profile URL. |
28
28
  | `claim` | Start the device flow that keeps the sandbox: a URL and a code for the human. |
29
29
  | `help [<command>]` | Describe the commands. `--version` prints the version. |
30
30
  ## store.yml
@@ -84,7 +84,7 @@ catalog:
84
84
  - `merchant`: a Google Merchant Center product feed URL, RSS 2.0 (XML, `g:` namespace) or TSV. `id` is the sku, `title` the name, `description`, `image_link` the image, `price` (`14.50 EUR`) the price in its currency, `product_type` the category. An id listed once per currency is one product with several prices. Other attributes, `sale_price` included, are ignored.
85
85
  Error codes: `no_catalog_source`, `invalid_catalog`, `missing_credentials`, `catalog_unreachable`, `catalog_unauthorized`, `catalog_not_found`, `catalog_not_csv`, `catalog_error`, `empty_catalog`, plus `invalid_product` and `missing_price`.
86
86
 
87
- Error codes: `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `storefront_exists`, `no_storefront`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
87
+ Error codes: `no_store`, `invalid_yaml`, `invalid_store`, `invalid_market`, `invalid_product`, `missing_price`, `deletes_need_approval`, `apply_failed`, `apply_pending`, `apply_in_progress`, `storefront_exists`, `no_storefront`, `not_applied`, `not_found`, `missing_argument`, `invalid_argument`, `unknown_command`, `unauthorized`, `expired`, `api_unreachable`, `api_error`, `no_product`, `checkout_not_ready`, `ucp_error`, `ucp_profile_unreachable`, `ucp_profile_malformed`, `ucp_version_unsupported`, `ucp_no_rest_service`, `internal`.
88
88
  ## The store speaks UCP
89
89
  Each store is a UCP business at `https://<org>.sandbox.commercelayer.store` (live stores: `<slug>.commercelayer.store`), implementing release **2026-08-25** over REST:
90
90
  | What | Where |
@@ -1,12 +1,41 @@
1
1
  // The management API at api.commercelayer.ai: sandboxes, resources, storefront
2
2
  // uploads, orders, claims. The CLI's only private API; the store hosts speak UCP.
3
3
  import crypto from 'node:crypto';
4
+ import { canonical } from '../../store.js';
4
5
  import { generateKey } from '../../ucp/signing.js';
5
6
  import { publicOrders, readDb, SANDBOX_DOMAIN, writeDb } from './db.js';
6
7
  import { bearer, error, json, readJson } from './http.js';
7
8
  function publicState(r) {
8
- const { token: _token, ucp: _ucp, ...state } = r;
9
- return { ...state, name: r.name ?? null, policies: r.policies ?? null, orders: publicOrders(r) };
9
+ const { token: _token, ucp: _ucp, pending: _pending, ...state } = r;
10
+ return { ...state, name: r.name ?? null, policies: r.policies ?? null, apply: r.apply ?? null, orders: publicOrders(r) };
11
+ }
12
+ /** Like Commerce Layer refusing a sku in an import. Only the stub does this, so tests can exercise apply_failed. */
13
+ const REFUSED = /^REFUSED-/;
14
+ /** Items an import gets through per read: small, so the CLI polls a few times. */
15
+ const ITEMS_PER_READ = 2;
16
+ /**
17
+ * Move a running apply job forward, as the API does on every read: nothing runs in
18
+ * the background. When it ends, the pending resources are installed, or refused.
19
+ */
20
+ function advance(r) {
21
+ const job = r.apply;
22
+ if (!job || job.status !== 'in_progress' || !r.pending)
23
+ return;
24
+ job.processed = Math.min(job.total, job.processed + ITEMS_PER_READ);
25
+ if (job.processed < job.total)
26
+ return;
27
+ job.step = 'done';
28
+ job.finished_at = new Date().toISOString();
29
+ if (r.pending.refused.length) {
30
+ job.status = 'failed';
31
+ job.errors = r.pending.refused.slice(0, 50).map((sku) => ({ sku, message: 'code is invalid' }));
32
+ }
33
+ else {
34
+ job.status = 'completed';
35
+ r.applied = r.pending.applied;
36
+ r.applied_hash = job.hash;
37
+ }
38
+ delete r.pending;
10
39
  }
11
40
  export function handleManagement(req) {
12
41
  const db = readDb();
@@ -53,16 +82,46 @@ export function handleManagement(req) {
53
82
  if (new Date(record.expires_at) < new Date() && !record.claimed) {
54
83
  return error(410, 'expired', `Sandbox ${org} expired on ${record.expires_at}.`, 'Remove .commercelayer/credentials and run `c11r init` to create a new sandbox; store.yml is kept.');
55
84
  }
56
- if (req.method === 'GET' && rest === '')
85
+ if (req.method === 'GET' && rest === '') {
86
+ advance(record);
87
+ writeDb(db);
57
88
  return json(200, publicState(record));
89
+ }
58
90
  if (req.method === 'PUT' && rest === '/resources') {
59
- const { applied, hash, name, policies } = readJson(req);
60
- record.applied = applied;
61
- record.applied_hash = hash;
91
+ advance(record);
92
+ writeDb(db);
93
+ if (record.apply?.status === 'in_progress')
94
+ return error(409, 'apply_in_progress', 'Another apply is still running.', 'Run `c11r status` to follow it, then apply again.');
95
+ const { applied, hash, name, policies, allow_delete, full } = readJson(req);
96
+ const before = record.applied?.products ?? {};
97
+ const removed = Object.keys(before).filter((sku) => !(sku in applied.products));
98
+ if (removed.length && !allow_delete)
99
+ return error(409, 'deletes_need_approval', `Apply would delete product ${removed.join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
100
+ // The name, policies and markets apply in the request; products go through an
101
+ // import job (SKUs, prices, then cleanups for removals) when any changed.
62
102
  record.name = name;
63
103
  record.policies = policies ?? {};
104
+ const changed = Object.keys(applied.products).filter((sku) => full || !(sku in before) || canonical(before[sku]) !== canonical(applied.products[sku]));
105
+ if (changed.length === 0 && removed.length === 0) {
106
+ record.applied = applied;
107
+ record.applied_hash = hash;
108
+ writeDb(db);
109
+ return json(200, publicState(record));
110
+ }
111
+ record.apply = {
112
+ id: `apply_${crypto.randomBytes(6).toString('hex')}`,
113
+ status: 'in_progress',
114
+ step: 'skus',
115
+ hash,
116
+ processed: 0,
117
+ total: changed.length + removed.length,
118
+ started_at: new Date().toISOString(),
119
+ finished_at: null,
120
+ errors: [],
121
+ };
122
+ record.pending = { applied, refused: changed.filter((sku) => REFUSED.test(sku)) };
64
123
  writeDb(db);
65
- return json(200, publicState(record));
124
+ return json(202, publicState(record));
66
125
  }
67
126
  if (req.method === 'PUT' && rest === '/storefront') {
68
127
  const { path: dir, files } = readJson(req);
package/dist/commands.js CHANGED
@@ -5,7 +5,7 @@ import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { createApi } from './api/client.js';
7
7
  import { ignoreCredentials, paths, readCredentials, requireCredentials, writeCredentials } from './files.js';
8
- import { CliError, fail } from './output.js';
8
+ import { CliError, fail, progress } from './output.js';
9
9
  import { diff, hashStore, loadStore, storefrontPath, template, toApplied } from './store.js';
10
10
  import { storefrontHtml } from './storefront.js';
11
11
  import { pull } from './catalog.js';
@@ -18,7 +18,7 @@ export const VERSION = createRequire(import.meta.url)('../package.json').version
18
18
  export const HELP = {
19
19
  init: 'Create a sandbox store for this repo, with no login: c11r init --name "<business name>" (the folder name when --name is absent). The name becomes the organization\'s name, and the org slug and store hostname derive from it. Writes .commercelayer/store.yml and .commercelayer/credentials. Reuses the store if store.yml already exists.',
20
20
  plan: 'Show what apply would create, update or delete: markets, products and the store\'s name and policies, from .commercelayer/store.yml and the product files under .commercelayer/products/ (*.yml lists, *.csv rows, merged by sku).',
21
- apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given.',
21
+ apply: 'Make the store match .commercelayer/store.yml and .commercelayer/products/. Refuses deletes unless --allow-delete is given. Product changes go through a Commerce Layer import: apply waits for it (progress as JSON lines on stderr) and prints one result; if it stops with apply_pending the import keeps going, so run `c11r status` to follow it. An apply still running is finished first. --full re-imports every product and price, after edits in the Commerce Layer dashboard.',
22
22
  'catalog pull': 'Read the products from the catalog source in store.yml (catalog: { source: supabase | sheets | merchant, url, table }) and write them to .commercelayer/products/catalog.csv; then plan and apply as usual. Columns: sku, name, description, image_url, category and one price column per market currency, in lowercase (eur, usd). Supabase reads C11R_SUPABASE_URL and C11R_SUPABASE_KEY (the anon key) from .commercelayer/credentials or the environment; a Google Sheet needs its CSV link or a link anyone can open; a Merchant Center feed is RSS/XML or TSV.',
23
23
  'storefront init': 'Write storefront/index.html: a static page on drop-in.js listing the products in store.yml, with a minicart that opens on add to cart, and the hosted cart and checkout. Refuses to overwrite unless --force is given.',
24
24
  'storefront publish': 'Upload the storefront folder (store.yml storefront.path, default storefront) to the sandbox preview URL.',
@@ -77,23 +77,59 @@ export async function plan() {
77
77
  const changes = diff(state, desired);
78
78
  return { status: changes.length ? 'changes' : 'up_to_date', changes, deletes: changes.filter((c) => c.action === 'delete').length };
79
79
  }
80
+ /** How often apply reads a running import, and when it stops waiting. Env overrides are for tests. */
81
+ const POLL_MS = Number(process.env.C11R_APPLY_POLL_MS) || 2000;
82
+ const WAIT_MS = Number(process.env.C11R_APPLY_WAIT_MS) || 10 * 60 * 1000;
83
+ const jobSummary = (j) => ({ id: j.id, status: j.status, step: j.step, processed: j.processed, total: j.total });
84
+ /** Read the store until its apply job is no longer in progress, with a progress line per change. */
85
+ async function follow(api, state) {
86
+ const until = Date.now() + WAIT_MS;
87
+ let s = state;
88
+ let last = '';
89
+ while (s.apply?.status === 'in_progress') {
90
+ const line = JSON.stringify(jobSummary(s.apply));
91
+ if (line !== last)
92
+ progress({ apply: jobSummary(s.apply) });
93
+ last = line;
94
+ if (Date.now() >= until) {
95
+ const j = s.apply;
96
+ fail('apply_pending', `The apply is still running: ${j.processed} of ${j.total} items done (step ${j.step}).`, 'The apply keeps going; run `c11r status` to follow it.');
97
+ }
98
+ await new Promise((r) => setTimeout(r, POLL_MS));
99
+ s = await api.getStore();
100
+ }
101
+ return s;
102
+ }
80
103
  export async function apply(flags) {
81
104
  const api = createApi(requireCredentials());
82
105
  const desired = loadStore();
83
- const state = await api.getStore();
106
+ // An apply still running is finished first, so the changes are counted from what it installs.
107
+ let state = await api.getStore();
108
+ if (state.apply?.status === 'in_progress')
109
+ state = await follow(api, state);
84
110
  const changes = diff(state, desired);
85
111
  const deletes = changes.filter((c) => c.action === 'delete');
86
112
  if (deletes.length && !flags.has('--allow-delete')) {
87
113
  fail('deletes_need_approval', `Apply would delete ${deletes.map((d) => `${d.type} ${d.id}`).join(', ')}.`, 'Ask the human whether to delete these. If they agree, run `c11r apply --allow-delete`.');
88
114
  }
89
- const applied = await api.applyStore({ applied: toApplied(desired), hash: hashStore(), name: desired.name, policies: desired.policies });
115
+ const body = { applied: toApplied(desired), hash: hashStore(), name: desired.name, policies: desired.policies, ...(flags.has('--allow-delete') && { allow_delete: true }), ...(flags.has('--full') && { full: true }) };
116
+ const started = await api.applyStore(body);
117
+ const job = started.apply?.status === 'in_progress' ? started.apply : null;
118
+ const done = job ? await follow(api, started) : started;
119
+ if (job && done.apply?.id === job.id && done.apply.status === 'failed') {
120
+ const errors = done.apply.errors;
121
+ const list = errors.map((e) => (e.sku ? `${e.sku}: ${e.message}` : e.message)).join('; ');
122
+ const what = errors.length === 1 ? '1 product was refused' : `${errors.length} products were refused`;
123
+ fail('apply_failed', errors.length ? `${what}: ${list}` : 'The apply failed in Commerce Layer.', 'Fix those products in store.yml or the catalog source and run `c11r apply` again; the rest of the store is unchanged.');
124
+ }
90
125
  return {
91
126
  status: 'applied',
92
127
  changes,
93
- preview_url: applied.preview_url,
128
+ preview_url: done.preview_url,
94
129
  markets: desired.markets.length,
95
130
  products: desired.products.length,
96
131
  payment_gateway: 'sandbox (test payments only)',
132
+ apply: job && done.apply ? jobSummary(done.apply) : undefined,
97
133
  };
98
134
  }
99
135
  export async function catalogPull() {
@@ -208,6 +244,7 @@ export async function status() {
208
244
  sales_channel_client_id: s.sales_channel_client_id,
209
245
  storefront: s.storefront,
210
246
  applied: Boolean(s.applied),
247
+ apply: s.apply ?? undefined,
211
248
  pending_changes: pending.length > 0 || storeError !== undefined,
212
249
  pending: pending.length ? pending : undefined,
213
250
  store_error: storeError,
@@ -215,7 +252,7 @@ export async function status() {
215
252
  products: s.applied ? Object.keys(s.applied.products).length : 0,
216
253
  orders: s.orders.map((o) => ({ id: o.id, label: o.label })),
217
254
  ucp_profile: `${s.preview_url}/.well-known/ucp`,
218
- next: storeError ? 'Fix the store files (see store_error), then run `c11r plan`.' : pending.length ? 'Run `c11r plan` to see the changes, then `c11r apply`.' : undefined,
255
+ next: s.apply?.status === 'in_progress' ? 'An apply is running: run `c11r status` again to follow it, or `c11r apply` to wait for it.' : storeError ? 'Fix the store files (see store_error), then run `c11r plan`.' : pending.length ? 'Run `c11r plan` to see the changes, then `c11r apply`.' : undefined,
219
256
  };
220
257
  }
221
258
  export async function claim() {
package/dist/output.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Everything the CLI says is JSON on stdout: one object per run, pretty-printed.
2
2
  // A failure is { error: { code, message, hint } } with exit code 1. The hint is
3
3
  // the fix: an agent reads it and acts on it before trying anything else.
4
+ // A command that waits (apply) writes progress to stderr, one JSON object per line.
4
5
  export class CliError extends Error {
5
6
  code;
6
7
  hint;
@@ -13,6 +14,9 @@ export class CliError extends Error {
13
14
  export function fail(code, message, hint) {
14
15
  throw new CliError(code, message, hint);
15
16
  }
17
+ export function progress(data) {
18
+ process.stderr.write(JSON.stringify(data) + '\n');
19
+ }
16
20
  export function print(data) {
17
21
  process.stdout.write(JSON.stringify(data, null, 2) + '\n');
18
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commercelayer/agent-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "c11r: the Commerce Layer CLI for coding agents. Builds a sandbox store that people and shopping agents can buy from.",
5
5
  "homepage": "https://commercelayer.ai",
6
6
  "keywords": [