octwin-cli 0.1.14 → 0.1.16

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/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucket
5
5
  **Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
6
6
  repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
7
7
 
8
+ ## [0.1.16] - 2026-07-26
9
+
10
+ ### Added
11
+ - **A pack can ship committed images.** `jpg`/`jpeg`/`png`/`webp`/`gif`/`pdf` files in your pack
12
+ directory now travel as a separate `blobs` half of the bundle (base64 on the wire, `bytea` in
13
+ storage) instead of being rejected as "not an allowed pack file type". Declare each one under the
14
+ manifest's `static_assets: [{ file, key }]` and reference it from `config:` with the
15
+ `$pack_asset:<key>` sentinel — the platform uploads it to the media system at install and the
16
+ sentinel resolves to the served URL. `octwin validate` reports the image count alongside the file
17
+ count.
18
+ - **The size ceilings fail locally, before upload** — 2 MB per file, 32 MB of binary per pack,
19
+ mirroring the server. `svg` stays rejected: it is script-capable and these assets are served to
20
+ browsers.
21
+
22
+ ### Fixed
23
+ - **Committed images were silently corrupted.** `collectBundleFiles` read *every* file with
24
+ `readFileSync(full, 'utf8')`, so a JPEG went through a lossy UTF-8 decode and arrived mangled
25
+ server-side — a deploy that "succeeded" and rendered a broken image. Binary files now split off
26
+ into `blobs` and keep their bytes.
27
+ - **`octwin status` reported `loaded=(none)` for every pack, always.** It printed a `loaded_version`
28
+ field that stopped existing when reload moved to content-sha keying, which also meant the
29
+ version-drift warning underneath it could never fire. It now prints the **content sha** the
30
+ instance has loaded and the one the catalog holds (with the artifact's `origin`), and warns off the
31
+ platform's own `up_to_date` flag. The sha is the more useful fact anyway: re-publishing the *same*
32
+ version changes it, which is exactly the author's inner loop. A pack that is live and current but
33
+ **withdrawn** from the catalog now says so, rather than reporting a clean bill of health.
34
+
35
+ ## [0.1.15] - 2026-07-25
36
+
37
+ ### Added
38
+ - **`octwin agents [packId::agentId] [--prompt]` — see what your agent actually runs as.** The platform
39
+ gained *governed* agent settings: an operator platform default can override the `default_model`
40
+ (and history window / working-memory toggle) your manifest declares, and that override is invisible
41
+ from the pack source. The command prints each governed setting's **effective** value, **which layer
42
+ won** (project override → platform default → pack manifest), and warns explicitly when your pack's
43
+ declared value is *not* in force. `--prompt` prints the exact system prompt the LLM sees for this
44
+ project (pack instructions + platform protocol + any project overlay).
45
+ - **`octwin orders [reference_id]` — the commerce a conversation produced.** The list (number,
46
+ status/payment, total, contact) or one order with its line items, the
47
+ subtotal/tax/shipping/discount/total breakdown, `payment_ref`, and the allowed transitions. On a
48
+ `pending`/`none` payment it explains *why that's expected*: the forward payment lifecycle is
49
+ **webhook-owned** (not patchable), and the default gateway-less `manual` driver makes
50
+ `payment_request` take its `empty` port — so the flow should confirm pay-on-delivery, not error.
51
+ - **`octwin analytics [entity]` — stage conversion for any pipelined entity.** The funnel engine that
52
+ powered journeys is now generic, so any entity declared with a `pipeline:` charts conversion +
53
+ drop-off (`--overview` / `--milestones` / `--trends` / `--cost`, and `--stage <id>` for the records
54
+ currently at a stage). An empty result names **both** causes the platform folds into one response —
55
+ no `pipeline:`, or no `view` grant on `record.<entity>`.
56
+ - **`octwin catalog [--readiness]`** — commerce products with price, availability and stock
57
+ (`untracked` when the SKU isn't inventory-tracked), plus the WhatsApp catalog binding.
58
+ `--readiness` runs the Meta Graph checklist with each failing item's suggested fix.
59
+ - **`octwin scheduling [--slots <resourceRecordId>]`** — the engine state (bookable resource types,
60
+ upcoming slots, booked seats) or the computed slots for one resource. This is how you verify the
61
+ availability rules a `deploy --seed` created, which 0.1.14 could create but not read back.
62
+
63
+ ### Changed
64
+ - **Auth failures now name the scope the command needs.** Every 401/403 appends the required scope
65
+ (and the plan feature, where the route is entitlement-gated) from one client-side mirror of the
66
+ platform's scope registry — replacing the per-command hardcoded strings, several of which named no
67
+ scope at all. It also spells out that `pack:deploy` / `media:generate` are direct-grant only, so a
68
+ `tenant:admin` preset token does **not** confer them (the most common "but my token is admin" dead end).
69
+
8
70
  ## [0.1.14] - 2026-07-23
9
71
 
10
72
  ### Fixed
package/README.md CHANGED
@@ -65,11 +65,16 @@ octwin status # "✓ live and current" once it's warm
65
65
  | `octwin login` | Save a deploy token for a platform URL (stored in `~/.octwin/credentials.json`). `--url`, `--token`. |
66
66
  | `octwin whoami` | Verify the saved/passed token is valid for a tenant. `--url`, `--tenant`. |
67
67
  | `octwin deploy` | Upload + install the pack onto your tenant's project. `--seed` also runs the pack's demo seed. |
68
- | `octwin status` | Report what the platform has live for this pack — installed vs. loaded version, and its flows. |
68
+ | `octwin status` | Report what the platform has live for this pack — installed version, the **content sha** the instance loaded vs. the one the catalog holds (a redeploy of the *same* version changes it), and its flows. |
69
69
  | `octwin chat "msg"` | Drive a turn through the dev web channel and print **every render with its tap ids**. `--as <handle>` picks the test user; `--tap "<tap-id>"` presses a rendered button/list row; `--json` dumps the raw envelopes. |
70
70
  | `octwin logs` | List recent conversations (handle, status, last activity; `--as` filters), or show one conversation's full event timeline — including what each turn rendered. `--json` for raw payloads. |
71
71
  | `octwin records` | Inspect the pack's XRM data (needs a `records:read` token). No args = list entities. |
72
72
  | `octwin cases` | Inspect casework (support tickets): the inbox, one case + its timeline and decisions, or `--queues` for queue keys + open counts. |
73
+ | `octwin agents` | The agent roster with each agent's **effective** model / history window and **which layer set it** (project override → platform default → pack manifest) — an operator platform default can override what your manifest declares. `--prompt` prints the exact system prompt the LLM sees. Needs `agents:read`. |
74
+ | `octwin orders` | The orders a conversation produced. No args = the list; with a `reference_id` = line items, the subtotal/tax/shipping/discount/total breakdown, `payment_ref`, and the allowed transitions. Needs `orders:read` + the `orders` plan feature. |
75
+ | `octwin analytics` | Stage-by-stage conversion for **any** entity declared with a `pipeline:` (`--overview` / `--milestones` / `--trends` / `--cost`; `--stage <id>` lists the records currently at a stage). Needs `records:read`. |
76
+ | `octwin catalog` | Commerce products with price / availability / stock, plus the WhatsApp catalog binding. `--readiness` runs the Meta Graph checklist. Needs `catalog:read` + the `catalog` plan feature. |
77
+ | `octwin scheduling` | The scheduling engine's state, or `--slots <resourceRecordId>` for the slots one bookable resource actually computes — how you verify the availability rules `deploy --seed` created. Needs `scheduling:read`. |
73
78
  | `octwin platform-kb pull` | Pull the platform's capability reference (built-ins, primitives, render intents, flow-DSL — as markdown + JSON) into `.octwin/platform-kb/`, for the **`octwin-pack`** Claude Code authoring plugin to consult. |
74
79
  | `octwin test` | Alias for `octwin validate --remote` — the platform's full manifest + flow-DSL check. |
75
80
  | `octwin help` | Show usage. Every subcommand also answers `--help`. |
@@ -90,6 +95,30 @@ octwin logs --as tester1 # find the conversation, then:
90
95
  octwin logs <conversationId> # the full timeline (taps, renders, tool events)
91
96
  ```
92
97
 
98
+ ### Reading back the state your pack created
99
+
100
+ `chat`/`logs` show what the bot *said*; these show what it *did*. A 401/403 on any of them names the
101
+ token scope (and plan feature) that command needs, so you can mint a wider token instead of guessing.
102
+
103
+ ```bash
104
+ octwin agents # effective model per agent + which layer set it
105
+ octwin agents my-pack::assistant --prompt # the exact system prompt the LLM sees
106
+ octwin orders # then: octwin orders <reference_id>
107
+ octwin analytics # then: octwin analytics <entity> [--stage <id>]
108
+ octwin catalog # products + stock + the WhatsApp binding
109
+ octwin scheduling --slots <resourceRecordId> # the slots your availability rules compute
110
+ ```
111
+
112
+ Three things worth knowing when you read the output:
113
+
114
+ - **A `pending` payment is usually correct.** The forward payment lifecycle is **webhook-owned**, and a
115
+ workspace with no gateway runs the credential-free `manual` driver — so `payment_request` takes its
116
+ `empty` port and your flow should confirm pay-on-delivery. `octwin orders <ref>` says this inline.
117
+ - **Your declared model may not be the one running.** An operator platform default overrides the pack
118
+ manifest; `octwin agents` is where that becomes visible.
119
+ - **An empty funnel has two causes** — the entity has no `pipeline:`, or your token's role has no `view`
120
+ grant on `record.<entity>`. The command prints both rather than a bare "no data".
121
+
93
122
  ## Configuration
94
123
 
95
124
  The deploy target has four settings. Three live in a committed **`pack.json`** at the root of your
package/dist/index.js CHANGED
@@ -19,6 +19,11 @@
19
19
  * octwin logs [conversationId] [--as h] [--json] # list conversations / show one's timeline
20
20
  * octwin chat "msg" [--as h] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn via the web channel (+ send media)
21
21
  * octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (media:generate scope)
22
+ * octwin agents [packId::agentId] [--prompt] # effective model/memory + which layer won; --prompt = the resolved system prompt
23
+ * octwin orders [reference_id] # the orders a conversation produced — money breakdown + payment state (orders:read)
24
+ * octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion, any pipelined entity
25
+ * octwin catalog [--readiness] # commerce products + stock + the WhatsApp catalog binding (catalog:read)
26
+ * octwin scheduling [--slots <resourceRecordId>] # engine state / computed slots (scheduling:read)
22
27
  * octwin platform-kb [pull] [--dir .] # pull the platform capability reference for the authoring skill
23
28
  * octwin test [--dir .] # = validate --remote (the full platform check)
24
29
  *
@@ -99,12 +104,66 @@ function authFailureHint(status, url) {
99
104
  ? `the token was rejected — invalid / expired / revoked. If it JUST worked, this can be a one-off platform hiccup: retry once before re-logging in (octwin login --url ${url} --token oct_…)`
100
105
  : `the token is valid but not authorized here (missing scope, plan feature, or role)`;
101
106
  }
102
- /** Print the auth-scope hint below an HTTP-failure line when it's a 401/403 —
103
- * so every command explains a token problem, not just the inspect family
104
- * (author-feedback A7). No-op for other statuses. */
107
+ /**
108
+ * The scope (and plan feature, where the route is entitlement-gated) each command's
109
+ * endpoints require — a client-side mirror of the platform's scope registry
110
+ * (`src/platform/core/identity/scopes.ts` `SCOPE_REGISTRY`) narrowed to what the CLI
111
+ * calls. A 403 that names the missing scope is the difference between a two-minute
112
+ * fix (mint a wider token) and a support thread.
113
+ *
114
+ * `pack:deploy` and `media:generate` have access `special`: they match by DIRECT
115
+ * grant only, so even a `tenant:admin` preset token does NOT confer them
116
+ * (`scopeSatisfies` — the preset branches only reach `:read`/`:write`). That is the
117
+ * single most common "but my token is admin" confusion, hence the explicit note below.
118
+ */
119
+ const COMMAND_REQUIREMENTS = {
120
+ deploy: { scope: 'pack:deploy' },
121
+ validate: { scope: 'pack:deploy' },
122
+ status: { scope: 'pack:deploy' },
123
+ test: { scope: 'pack:deploy' },
124
+ 'platform-kb': { scope: 'pack:deploy' },
125
+ media: { scope: 'media:generate' },
126
+ records: { scope: 'records:read', feature: 'records' },
127
+ analytics: { scope: 'records:read', feature: 'records' },
128
+ cases: { scope: 'cases:read', feature: 'cases' },
129
+ logs: { scope: 'conversations:read' },
130
+ orders: { scope: 'orders:read', feature: 'orders' },
131
+ catalog: { scope: 'catalog:read', feature: 'catalog' },
132
+ scheduling: { scope: 'scheduling:read' },
133
+ agents: { scope: 'agents:read' },
134
+ };
135
+ /** The command currently running — set once in `main()` so any failure printer can
136
+ * name the scope that command needs without threading it through every call. */
137
+ let CURRENT_COMMAND;
138
+ /** `→ needs the \`orders:read\` scope …` — the requirement line for the running
139
+ * command, or '' when the command has no declared requirement. */
140
+ function scopeRequirementHint() {
141
+ const req = CURRENT_COMMAND ? COMMAND_REQUIREMENTS[CURRENT_COMMAND] : undefined;
142
+ if (!req)
143
+ return '';
144
+ const special = req.scope === 'pack:deploy' || req.scope === 'media:generate';
145
+ return `needs the \`${req.scope}\` scope`
146
+ + (special ? ' (granted DIRECTLY only — a `tenant:admin` token does not confer it)' : '')
147
+ + (req.feature ? `, and the \`${req.feature}\` plan feature on this workspace` : '');
148
+ }
149
+ /** Print the auth hints below an HTTP-failure line when it's a 401/403 — so every
150
+ * command explains a token problem, not just the inspect family (author-feedback A7):
151
+ * WHY it failed, then WHAT the command needs. No-op for other statuses. */
105
152
  function printAuthHint(status, url) {
106
- if (status === 401 || status === 403)
107
- console.error(` → ${authFailureHint(status, url)}`);
153
+ if (status !== 401 && status !== 403)
154
+ return;
155
+ console.error(` → ${authFailureHint(status, url)}`);
156
+ const req = scopeRequirementHint();
157
+ if (req)
158
+ console.error(` → ${req}`);
159
+ }
160
+ /** The same two hints folded into ONE line, for the `die(...)` call sites that
161
+ * report an auth failure inline instead of via `printAuthHint`. */
162
+ function authFailureDetail(status, url) {
163
+ if (status !== 401 && status !== 403)
164
+ return '';
165
+ const req = scopeRequirementHint();
166
+ return ` — ${authFailureHint(status, url)}${req ? `; ${req}` : ''}`;
108
167
  }
109
168
  /** Pretty-print a JSON error body (or raw text) for an HTTP failure line. */
110
169
  function errDetail(json) {
@@ -159,9 +218,27 @@ async function resolveMediaPart(url, arg) {
159
218
  }
160
219
  // ── bundle collection ───────────────────────────────────────────────────────
161
220
  const SKIP_DIRS = new Set(['.git', 'node_modules', '.pack-bundles', 'dist', '.mastra']);
162
- /** Collect every text file under `packDir` into a `{ relPath: content }` map. */
221
+ /**
222
+ * Binary extensions the platform accepts as artifact BLOBS (mirrors
223
+ * `ALLOWED_BINARY_EXT` server-side). Deliberately no `svg` — script-capable.
224
+ */
225
+ const BINARY_EXT = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf']);
226
+ /** Per-blob / total ceilings, mirroring the server so oversize fails LOCALLY. */
227
+ const MAX_BLOB_BYTES = 2 * 1024 * 1024;
228
+ const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
229
+ /**
230
+ * Collect a pack directory into its two halves: `files` (`{ relPath: utf8 }`) and
231
+ * `blobs` (`{ relPath: base64 }`).
232
+ *
233
+ * Every file used to be read with `readFileSync(full, 'utf8')`, which silently
234
+ * MANGLED any committed image — the bytes went through a lossy UTF-8 decode and
235
+ * arrived corrupt. Binary files now split off into `blobs`, transported as base64
236
+ * (transport only; they land in `bytea` server-side).
237
+ */
163
238
  function collectBundleFiles(packDir) {
164
239
  const files = {};
240
+ const blobs = {};
241
+ let totalBlobBytes = 0;
165
242
  const walk = (dir, prefix) => {
166
243
  for (const name of readdirSync(dir)) {
167
244
  const full = join(dir, name);
@@ -176,11 +253,24 @@ function collectBundleFiles(packDir) {
176
253
  continue; // deploy config, not part of the pack
177
254
  if (name.startsWith('.'))
178
255
  continue; // .gitignore etc. — not pack content
256
+ const ext = name.slice(name.lastIndexOf('.') + 1).toLowerCase();
257
+ if (BINARY_EXT.has(ext)) {
258
+ const buf = readFileSync(full);
259
+ if (buf.byteLength > MAX_BLOB_BYTES) {
260
+ die(`'${rel}' is ${(buf.byteLength / 1024 / 1024).toFixed(1)} MB — the per-file limit is ${MAX_BLOB_BYTES / 1024 / 1024} MB`);
261
+ }
262
+ totalBlobBytes += buf.byteLength;
263
+ blobs[rel] = buf.toString('base64');
264
+ continue;
265
+ }
179
266
  files[rel] = readFileSync(full, 'utf8');
180
267
  }
181
268
  };
182
269
  walk(packDir, '');
183
- return files;
270
+ if (totalBlobBytes > MAX_ARTIFACT_BYTES) {
271
+ die(`binary payload is ${(totalBlobBytes / 1024 / 1024).toFixed(1)} MB — the per-pack limit is ${MAX_ARTIFACT_BYTES / 1024 / 1024} MB`);
272
+ }
273
+ return { files, blobs };
184
274
  }
185
275
  function readManifestIdVersion(files) {
186
276
  const raw = files['manifest.yaml'];
@@ -376,7 +466,12 @@ function commandTouchesPlatform(command, flags) {
376
466
  case 'records':
377
467
  case 'cases':
378
468
  case 'logs':
379
- case 'whoami': return true;
469
+ case 'whoami':
470
+ case 'agents':
471
+ case 'orders':
472
+ case 'analytics':
473
+ case 'catalog':
474
+ case 'scheduling': return true;
380
475
  default: return false;
381
476
  }
382
477
  }
@@ -423,20 +518,20 @@ function cmdInit(flags) {
423
518
  console.log(' octwin deploy');
424
519
  }
425
520
  function localValidate(packDir) {
426
- const files = collectBundleFiles(packDir);
521
+ const { files, blobs } = collectBundleFiles(packDir);
427
522
  const { id, version } = readManifestIdVersion(files);
428
- const r = validatePackBundle(id, files);
523
+ const r = validatePackBundle(id, files, blobs);
429
524
  if (!r.ok) {
430
525
  for (const e of r.errors)
431
526
  console.error(` ✗ ${e}`);
432
527
  die(`bundle validation failed (${r.errors.length} error${r.errors.length === 1 ? '' : 's'})`);
433
528
  }
434
- return { id, version, files };
529
+ return { id, version, files, blobs };
435
530
  }
436
531
  async function cmdValidate(flags) {
437
532
  const packDir = resolve(flags.dir ?? '.');
438
- const { id, version, files } = localValidate(packDir); // offline structural gate first (fast, no server/token)
439
- console.log(`✓ ${id}@${version} passes the offline structural check (${Object.keys(files).length} files)`);
533
+ const { id, version, files, blobs } = localValidate(packDir); // offline structural gate first (fast, no server/token)
534
+ console.log(`✓ ${id}@${version} passes the offline structural check (${Object.keys(files).length} files, ${Object.keys(blobs).length} image(s))`);
440
535
  if (flags.remote !== true) {
441
536
  console.log(' Run `octwin validate --remote` to run the platform\'s FULL manifest + flow-DSL validation');
442
537
  console.log(' (all errors at once) before you deploy.');
@@ -450,7 +545,7 @@ async function cmdValidate(flags) {
450
545
  const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
451
546
  method: 'POST',
452
547
  headers: { 'content-type': 'application/json', ...authHeaders(t) },
453
- body: JSON.stringify({ files }),
548
+ body: JSON.stringify({ files, blobs }),
454
549
  }, 'remote validate');
455
550
  const text = await res.text();
456
551
  let json;
@@ -645,7 +740,7 @@ async function cmdDeploy(flags) {
645
740
  const packDir = resolve(flags.dir ?? '.');
646
741
  const t = resolveTarget(flags, packDir);
647
742
  const { url } = t;
648
- const { id, version, files } = localValidate(packDir);
743
+ const { id, version, files, blobs } = localValidate(packDir);
649
744
  const endpoint = `${url}/api/self/p/packs/deploy`;
650
745
  const seed = flags.seed === true;
651
746
  console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${seed ? ' — with demo seed' : ''} …`);
@@ -654,7 +749,7 @@ async function cmdDeploy(flags) {
654
749
  // Ask for a progress stream; the platform falls back to plain JSON if it
655
750
  // (or an error before any progress) can't stream — handled below.
656
751
  headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
657
- body: JSON.stringify({ files, seed }),
752
+ body: JSON.stringify({ files, blobs, seed }),
658
753
  }, 'deploy');
659
754
  // Streaming path — live install + seed progress (image generation can take a
660
755
  // while, so `--seed` prints per-record / per-image lines as they happen).
@@ -726,13 +821,22 @@ async function cmdStatus(flags) {
726
821
  }
727
822
  console.log(`${id} on ${targetLabel(t)} @ ${url}`);
728
823
  console.log(` installed version : ${json.installed_version}`);
729
- console.log(` live on instance : registered=${json.registered} source=${json.source} loaded=${json.loaded_version ?? '(none)'}`);
824
+ // Reads the CONTENT SHA, not a version string. It printed `loaded=${json.loaded_version}` a
825
+ // field that stopped existing when reload moved to sha keying, so this line always said
826
+ // `(none)` and the drift warning below could never fire. The sha is also the more useful fact:
827
+ // re-publishing the SAME version changes it, which is exactly the author's inner loop.
828
+ const shortSha = (s) => (typeof s === 'string' && s ? s.slice(0, 12) + '…' : '(none)');
829
+ console.log(` live on instance : registered=${json.registered} loaded=${shortSha(json.loaded_content_sha)}`);
830
+ console.log(` catalog artifact : ${shortSha(json.catalog_content_sha)}${json.origin ? ` origin=${json.origin}` : ''}`);
730
831
  console.log(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
731
832
  if (!json.registered) {
732
833
  console.log('\n… not warm on the instance you hit yet — it loads on the next inbound (chat once, then re-check).');
733
834
  }
734
- else if (json.loaded_version && json.installed_version && json.loaded_version !== json.installed_version) {
735
- console.log(`\n⚠ instance has ${json.loaded_version} but the project is bound to ${json.installed_version}a redeploy lands on the next turn.`);
835
+ else if (json.up_to_date === false) {
836
+ console.log('\n⚠ this instance is running an OLDER artifact than the catalog holdsit picks up the current one on the next inbound turn (chat once, then re-check).');
837
+ }
838
+ else if (json.catalog_status === 'withdrawn') {
839
+ console.log('\n⚠ live and current, but the pack is WITHDRAWN from the catalog — existing installs keep running; new installs are refused.');
736
840
  }
737
841
  else {
738
842
  console.log('\n✓ live and current.');
@@ -860,15 +964,13 @@ async function cmdRecords(flags) {
860
964
  if (!recordId) {
861
965
  const limit = flags.limit ?? '50';
862
966
  const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, t);
863
- if (status === 403)
864
- die('forbidden — the paged record list needs the `records` plan feature on this tenant');
865
967
  if (status !== 200) {
866
968
  // Always show the server's reason (it names the unknown entity). Cases are
867
969
  // casework (worklist), not pack-declared XRM — point at the right command.
868
970
  if (entity === 'case' || entity === 'cases') {
869
971
  console.error(` '${entity}' is casework (worklist), not a pack-declared XRM entity — inspect tickets with: octwin cases`);
870
972
  }
871
- die(`could not read records (HTTP ${status})${errDetail(json)}${status === 401 ? ` — ${authFailureHint(status, url)}` : ''}`);
973
+ die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
872
974
  }
873
975
  const rows = (json?.records ?? []);
874
976
  console.log(`${entity}: ${json?.total ?? rows.length} record(s)`);
@@ -879,14 +981,10 @@ async function cmdRecords(flags) {
879
981
  return;
880
982
  }
881
983
  const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`, t);
882
- if (status === 403)
883
- die('forbidden — mint a token with the `records:read` scope');
884
984
  if (status === 404)
885
985
  die(`record '${recordId}' not found`);
886
- if (status === 401)
887
- die(`could not read record — ${authFailureHint(401, url)}`);
888
986
  if (status !== 200)
889
- die(`could not read record (HTTP ${status})`);
987
+ die(`could not read record (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
890
988
  console.log(JSON.stringify(json?.record ?? json, null, 2));
891
989
  }
892
990
  /** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
@@ -904,7 +1002,7 @@ async function cmdLogs(flags) {
904
1002
  if (!convId) {
905
1003
  const { status, json } = await apiGet(`${base}/conversations?limit=50`, t);
906
1004
  if (status !== 200)
907
- die(`could not read conversations (HTTP ${status})${errDetail(json)}${authFailureHint(status, url)}`);
1005
+ die(`could not read conversations (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
908
1006
  let convs = (json?.conversations ?? []);
909
1007
  if (asHandle)
910
1008
  convs = convs.filter((c) => c.contact?.channel_contact_handle === asHandle);
@@ -930,7 +1028,7 @@ async function cmdLogs(flags) {
930
1028
  if (status === 404)
931
1029
  die(`conversation '${convId}' not found`);
932
1030
  if (status !== 200)
933
- die(`could not read conversation (HTTP ${status})${errDetail(json)}${authFailureHint(status, url)}`);
1031
+ die(`could not read conversation (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
934
1032
  const events = (json?.events ?? []);
935
1033
  if (asJson) {
936
1034
  console.log(JSON.stringify(events, null, 2));
@@ -1285,9 +1383,11 @@ async function cmdCases(flags) {
1285
1383
  if (!asJson)
1286
1384
  console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${targetLabel(t)} …`);
1287
1385
  const caseFail = (what, status, json) => {
1386
+ // A 403 here can also be an RBAC gap the scope hint can't see — a role whose
1387
+ // grants don't reach the queue passes the scope gate and still gets nothing.
1288
1388
  if (status === 403)
1289
- die(`forbidden — casework needs the 'cases' plan feature on this tenant, and a role whose grants reach the queue`);
1290
- die(`could not read ${what} (HTTP ${status})${errDetail(json)}${status === 401 ? ` — ${authFailureHint(status, url)}` : ''}`);
1389
+ console.error(' (a role whose grants reach the queue is also required)');
1390
+ die(`could not read ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1291
1391
  };
1292
1392
  if (flags.queues === true) {
1293
1393
  const { status, json } = await apiGet(`${base}/case-queues`, t);
@@ -1356,82 +1456,594 @@ async function cmdCases(flags) {
1356
1456
  console.log(` Decisions: ${dispositions.map((d) => `${d.action}${d.next_status ? `→${d.next_status}` : ''}`).join(', ')}`);
1357
1457
  }
1358
1458
  }
1459
+ // ── money formatting (orders / catalog) ─────────────────────────────────────
1460
+ /** Format a MAJOR-unit decimal amount as currency — the catalog's `price` shape.
1461
+ * Mirrors the console's `fmtAmount` (console/src/lib/money.ts) so an amount reads
1462
+ * the same in the terminal as on the page. */
1463
+ function fmtAmount(value, currency) {
1464
+ if (value == null)
1465
+ return '—';
1466
+ if (!currency)
1467
+ return value.toFixed(2);
1468
+ try {
1469
+ return new Intl.NumberFormat('en', { style: 'currency', currency }).format(value);
1470
+ }
1471
+ catch {
1472
+ return `${value.toFixed(2)} ${currency}`;
1473
+ }
1474
+ }
1475
+ /** Format MINOR units (the offset-100 `*_minor` ints every order field is projected
1476
+ * to on the wire — see `toOrderView`) as currency. `fmtAmount(minor / 100, …)`. */
1477
+ function fmtMinor(minor, currency) {
1478
+ if (minor == null)
1479
+ return '—';
1480
+ return fmtAmount(minor / 100, currency);
1481
+ }
1482
+ /** Human wording for a governed `source` — the layer that won the cascade
1483
+ * (per-project override > platform default > pack manifest default). The four
1484
+ * values mirror the platform's `GovernedSource` union exactly. */
1485
+ const GOVERNED_SOURCE_LABEL = {
1486
+ project: 'this project overrides it',
1487
+ platform: 'a PLATFORM default overrides the pack',
1488
+ pack: 'the pack manifest value is in force',
1489
+ unset: 'no value at any layer',
1490
+ };
1491
+ function showValue(v) {
1492
+ if (v === undefined || v === null)
1493
+ return '(unset)';
1494
+ return typeof v === 'string' ? v : JSON.stringify(v);
1495
+ }
1496
+ /** Print one governed setting as `key: <effective> [why]`, and — when the pack's
1497
+ * own declared value is NOT what runs — an explicit second line naming what the
1498
+ * pack asked for. That override is invisible from the pack source, which is the
1499
+ * whole reason this command exists. */
1500
+ function printGoverned(label, g) {
1501
+ if (!g)
1502
+ return;
1503
+ const source = typeof g.source === 'string' ? g.source : '';
1504
+ console.log(` ${label.padEnd(22)} ${showValue(g.effective)} [${GOVERNED_SOURCE_LABEL[source] ?? source ?? '?'}]`);
1505
+ // Warn only when a higher layer displaced the pack's declared value with a
1506
+ // DIFFERENT one. A platform default that happens to equal the pack's value has
1507
+ // changed nothing an author needs to know about.
1508
+ const displaced = (source === 'project' || source === 'platform')
1509
+ && g.pack_default !== undefined
1510
+ && JSON.stringify(g.pack_default) !== JSON.stringify(g.effective);
1511
+ if (displaced)
1512
+ console.log(` ⚠ your pack declares ${showValue(g.pack_default)} — it is NOT in force`);
1513
+ if (g.opted_out)
1514
+ console.log(' (this workspace is opted OUT of the platform default for this key)');
1515
+ }
1516
+ /** `octwin agents [agentRef] [--prompt] [--json]` — the agent roster with the
1517
+ * EFFECTIVE model/memory settings and which layer won, plus (`--prompt`) the exact
1518
+ * system prompt the LLM sees for this project. Needs an `agents:read` token. */
1519
+ async function cmdAgents(flags) {
1520
+ const packDir = resolve(flags.dir ?? '.');
1521
+ const t = resolveTarget(flags, packDir);
1522
+ const { url } = t;
1523
+ const base = `${url}/api/self/p/agents`;
1524
+ const ref = flags._[0];
1525
+ const asJson = flags.json === true;
1526
+ const wantPrompt = flags.prompt === true;
1527
+ if (wantPrompt && !ref)
1528
+ die('usage: octwin agents <packId::agentId> --prompt (name the agent — `octwin agents` lists them)');
1529
+ if (!asJson)
1530
+ console.log(`→ Reading ${ref ? `agent ${ref}` : 'the agent roster'} from ${targetLabel(t)} …`);
1531
+ // --prompt — the resolved system prompt (pack instructions + platform protocol +
1532
+ // the per-project overlay). No LLM call; pure resolution server-side.
1533
+ if (wantPrompt) {
1534
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(ref)}/preview-prompt`, t);
1535
+ if (status === 404)
1536
+ die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
1537
+ // 503 = the pack is installed but not warm on this instance yet (same trap
1538
+ // `octwin status` explains): the agent loads on the next inbound.
1539
+ if (status === 503)
1540
+ die(`'${ref}' is not registered with Mastra on the instance you hit yet — it loads on the next inbound (chat once, then retry)`);
1541
+ if (status !== 200)
1542
+ die(`could not resolve the prompt (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1543
+ if (asJson) {
1544
+ console.log(JSON.stringify(json, null, 2));
1545
+ return;
1546
+ }
1547
+ console.log(`Resolved system prompt for ${json?.agent?.pack_id}::${json?.agent?.pack_agent_id} (${json?.bytes ?? '?'} bytes`
1548
+ + `${json?.has_overlay ? ', includes this project\'s overlay' : ', no project overlay'}):\n`);
1549
+ console.log(json?.resolved_prompt ?? '(empty)');
1550
+ return;
1551
+ }
1552
+ if (!ref) {
1553
+ const { status, json } = await apiGet(base, t);
1554
+ if (status !== 200)
1555
+ die(`could not read agents (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1556
+ if (asJson) {
1557
+ console.log(JSON.stringify(json, null, 2));
1558
+ return;
1559
+ }
1560
+ const agents = (json?.agents ?? []);
1561
+ if (agents.length === 0) {
1562
+ console.log('No agents — is a pack installed on this project? (`octwin status`)');
1563
+ return;
1564
+ }
1565
+ console.log(`Agents in ${targetLabel(t)}:`);
1566
+ for (const a of agents) {
1567
+ console.log(` ${a.pack_id}::${a.pack_agent_id} "${a.display_name}"${a.enabled === false ? ' [DISABLED]' : ''}`);
1568
+ printGoverned('model', a.governed?.model);
1569
+ if (a.last_error_at)
1570
+ console.log(` last error: ${a.last_error_at} — ${a.last_error_reason ?? '(no reason)'}`);
1571
+ }
1572
+ console.log('\nOne agent + its full settings: octwin agents <packId::agentId>');
1573
+ console.log('The prompt the LLM sees: octwin agents <packId::agentId> --prompt');
1574
+ return;
1575
+ }
1576
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
1577
+ if (status === 404)
1578
+ die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
1579
+ if (status !== 200)
1580
+ die(`could not read agent (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1581
+ if (asJson) {
1582
+ console.log(JSON.stringify(json, null, 2));
1583
+ return;
1584
+ }
1585
+ console.log(`${json.pack_id}::${json.pack_agent_id} "${json.display_name}"${json.enabled === false ? ' [DISABLED]' : ''}`);
1586
+ console.log(' Governed settings (project override > platform default > pack manifest):');
1587
+ printGoverned('model', json.governed?.model);
1588
+ printGoverned('memory.last_messages', json.governed?.last_messages);
1589
+ printGoverned('working_memory', json.governed?.working_memory_enabled);
1590
+ console.log(` tools: ${(json.available_tools ?? []).join(', ') || '(none)'}`);
1591
+ console.log(` instructions overlay: ${json.instructions_overlay ? `${String(json.instructions_overlay).length} chars (project-specific)` : '(none)'}`);
1592
+ if (json.last_invoked_at)
1593
+ console.log(` last invoked: ${json.last_invoked_at}`);
1594
+ if (json.last_error_at)
1595
+ console.log(` last error: ${json.last_error_at} — ${json.last_error_reason ?? '(no reason)'}`);
1596
+ console.log(`\nThe prompt the LLM actually sees: octwin agents ${ref} --prompt`);
1597
+ }
1598
+ // ── orders: the commerce lifecycle a conversation produces ───────────────────
1599
+ /** Why a `pending` / `none` payment is usually CORRECT, not a bug. The forward
1600
+ * payment lifecycle is webhook-owned (`payment_status` is deliberately not
1601
+ * patchable — only `refund` is an operator move), and the default driver is the
1602
+ * gateway-less `ManualPaymentAdapter`, on which `payment_request` takes its `empty`
1603
+ * port and mutates nothing. Settling a payment headlessly has no path today — see
1604
+ * docs/BACKLOG.md → "Headless payment settle" (a `--settle` flag lands here once
1605
+ * the platform grows a manual-driver-only reconcile endpoint). */
1606
+ function printPaymentNote(paymentStatus) {
1607
+ if (paymentStatus !== 'pending' && paymentStatus !== 'none')
1608
+ return;
1609
+ console.log(` → payment_status '${paymentStatus}' is expected without a live gateway: the forward payment`);
1610
+ console.log(' lifecycle is WEBHOOK-owned (not patchable), and the default `manual` driver has no hosted');
1611
+ console.log(' checkout — so `payment_request` takes its `empty` port and the flow should offer');
1612
+ console.log(' pay-on-delivery. Refund is the one operator-driven move.');
1613
+ }
1614
+ /** `octwin orders [referenceId] [--status s] [--payment p] [--limit n] [--json]` —
1615
+ * the orders a conversation created: money breakdown, payment state, allowed
1616
+ * transitions. Needs an `orders:read` token + the `orders` plan feature. */
1617
+ async function cmdOrders(flags) {
1618
+ const packDir = resolve(flags.dir ?? '.');
1619
+ const t = resolveTarget(flags, packDir);
1620
+ const { url } = t;
1621
+ const base = `${url}/api/self/p/orders`;
1622
+ const referenceId = flags._[0];
1623
+ const asJson = flags.json === true;
1624
+ if (!asJson)
1625
+ console.log(`→ Reading ${referenceId ? `order ${referenceId}` : 'orders'} from ${targetLabel(t)} …`);
1626
+ if (!referenceId) {
1627
+ const q = new URLSearchParams({ limit: flags.limit ?? '50' });
1628
+ if (typeof flags.status === 'string')
1629
+ q.set('status', flags.status);
1630
+ if (typeof flags.payment === 'string')
1631
+ q.set('payment', flags.payment);
1632
+ const { status, json } = await apiGet(`${base}?${q.toString()}`, t);
1633
+ if (status !== 200)
1634
+ die(`could not read orders (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1635
+ if (asJson) {
1636
+ console.log(JSON.stringify(json, null, 2));
1637
+ return;
1638
+ }
1639
+ const rows = (json?.orders ?? []);
1640
+ console.log(`Orders in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
1641
+ if (rows.length === 0)
1642
+ console.log(' (none — drive a cart to `cart_submit` with `octwin chat`, or seed demo data)');
1643
+ for (const o of rows) {
1644
+ const who = o.contact?.channel_contact_handle ?? o.contact?.display_name ?? '—';
1645
+ console.log(` #${o.record_number} ${o.status}/${o.payment_status} ${fmtMinor(o.total_minor, o.currency)} ${who} ${o.reference_id}`);
1646
+ }
1647
+ console.log('\nOne order + its money breakdown: octwin orders <reference_id>');
1648
+ return;
1649
+ }
1650
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(referenceId)}`, t);
1651
+ if (status === 404)
1652
+ die(`order '${referenceId}' not found (pass the opaque reference_id, not the #number)`);
1653
+ if (status !== 200)
1654
+ die(`could not read order (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1655
+ if (asJson) {
1656
+ console.log(JSON.stringify(json, null, 2));
1657
+ return;
1658
+ }
1659
+ const o = json?.order ?? {};
1660
+ const cur = o.currency;
1661
+ console.log(`Order #${o.record_number} ${o.status} payment: ${o.payment_status}`);
1662
+ console.log(` reference: ${o.reference_id}${o.payment_ref ? ` payment_ref: ${o.payment_ref}` : ''}`);
1663
+ for (const it of (o.items ?? [])) {
1664
+ console.log(` ${it.quantity} × ${it.name ?? it.retailer_id} @ ${fmtMinor(it.amount_minor, it.currency || cur)}`);
1665
+ }
1666
+ console.log(` subtotal ${fmtMinor(o.subtotal_minor, cur)}`
1667
+ + ` tax ${fmtMinor(o.tax_minor, cur)}`
1668
+ + ` shipping ${fmtMinor(o.shipping_minor, cur)}`
1669
+ + ` discount ${fmtMinor(o.discount_minor, cur)}`);
1670
+ console.log(` TOTAL ${fmtMinor(o.total_minor, cur)}`);
1671
+ if (o.note)
1672
+ console.log(` note: ${o.note}`);
1673
+ const transitions = (json?.transitions ?? []);
1674
+ console.log(` allowed transitions: ${transitions.join(', ') || '(none — terminal)'}`);
1675
+ printPaymentNote(String(o.payment_status ?? ''));
1676
+ }
1677
+ // ── analytics: the generic per-entity funnel (any pipelined entity) ──────────
1678
+ const ANALYTICS_MODES = ['funnel', 'overview', 'milestones', 'trends', 'cost'];
1679
+ /** Both "unknown/non-pipelined entity" and "your role lacks the grant" answer
1680
+ * 200 + `has_data:false` (a deliberate empty state, never a 403) — so a bare
1681
+ * "no data" would hide the real cause. Name both. */
1682
+ function printNoAnalyticsData(entity) {
1683
+ console.log(`No analytics for '${entity}'. Either:`);
1684
+ console.log(` • '${entity}' isn't declared with a \`pipeline:\` (a funnel needs stages), or`);
1685
+ console.log(` • your token's role has no \`view\` grant on \`record.${entity}\`.`);
1686
+ console.log('Run `octwin analytics` for the entities that DO carry a pipeline.');
1687
+ }
1688
+ /** `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost]
1689
+ * [--stage <id>] [--json]` — stage conversion over ANY pipelined XRM entity
1690
+ * (orders, carts, cases, bookings, or a pack's own). Needs `records:read`. */
1691
+ async function cmdAnalytics(flags) {
1692
+ const packDir = resolve(flags.dir ?? '.');
1693
+ const t = resolveTarget(flags, packDir);
1694
+ const { url } = t;
1695
+ const base = `${url}/api/self/p/xrm/analytics`;
1696
+ const entity = flags._[0];
1697
+ const asJson = flags.json === true;
1698
+ const stage = typeof flags.stage === 'string' ? flags.stage : undefined;
1699
+ const mode = ANALYTICS_MODES.find(m => flags[m] === true) ?? 'funnel';
1700
+ if (stage && !entity)
1701
+ die('usage: octwin analytics <entity> --stage <stageId> (a stage belongs to an entity)');
1702
+ if (!entity) {
1703
+ if (!asJson)
1704
+ console.log(`→ Reading the analytics entity list from ${targetLabel(t)} …`);
1705
+ const { status, json } = await apiGet(`${base}/entities`, t);
1706
+ if (status !== 200)
1707
+ die(`could not read analytics entities (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1708
+ if (asJson) {
1709
+ console.log(JSON.stringify(json, null, 2));
1710
+ return;
1711
+ }
1712
+ const ents = (json?.entities ?? []);
1713
+ if (ents.length === 0) {
1714
+ console.log('No pipelined entities visible — a funnel needs an entity declared with a `pipeline:`,');
1715
+ console.log('and your role needs a `view` grant on it. (Journeys have their own analytics surface.)');
1716
+ return;
1717
+ }
1718
+ console.log(`Entities with a funnel in ${targetLabel(t)}:`);
1719
+ for (const e of ents) {
1720
+ console.log(` ${e.entity} (${e.stage_count} stage(s), ${e.milestone_count} milestone(s))`);
1721
+ }
1722
+ console.log('\nStage conversion: octwin analytics <entity> (add --overview / --milestones / --trends / --cost)');
1723
+ console.log('Who is at a stage: octwin analytics <entity> --stage <stageId>');
1724
+ return;
1725
+ }
1726
+ // Stage drill-down — records CURRENTLY at a stage. A live snapshot, deliberately
1727
+ // NOT range-filtered like the funnel's cumulative reached-≥ counts.
1728
+ if (stage) {
1729
+ if (!asJson)
1730
+ console.log(`→ Reading ${entity} records at stage '${stage}' from ${targetLabel(t)} …`);
1731
+ const limit = flags.limit ?? '50';
1732
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/stages/${encodeURIComponent(stage)}/records?limit=${limit}`, t);
1733
+ if (status === 404)
1734
+ die(`unknown stage '${stage}' for '${entity}'${errDetail(json)}`);
1735
+ if (status !== 200)
1736
+ die(`could not read stage records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1737
+ if (asJson) {
1738
+ console.log(JSON.stringify(json, null, 2));
1739
+ return;
1740
+ }
1741
+ if (json?.has_data === false) {
1742
+ printNoAnalyticsData(entity);
1743
+ return;
1744
+ }
1745
+ const rows = (json?.records ?? []);
1746
+ console.log(`${entity} at '${stage}' (live snapshot): ${json?.total ?? rows.length} record(s)`);
1747
+ for (const r of rows) {
1748
+ const who = r.channel_contact_handle ?? r.display_name ?? '—';
1749
+ console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'} ${who}${r.completed ? ' [completed]' : ''} ${r.record_id}`);
1750
+ }
1751
+ return;
1752
+ }
1753
+ if (!asJson)
1754
+ console.log(`→ Reading ${entity} ${mode} from ${targetLabel(t)} …`);
1755
+ const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/${mode}`, t);
1756
+ if (status !== 200)
1757
+ die(`could not read ${mode} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1758
+ if (asJson) {
1759
+ console.log(JSON.stringify(json, null, 2));
1760
+ return;
1761
+ }
1762
+ if (json?.has_data === false) {
1763
+ printNoAnalyticsData(entity);
1764
+ return;
1765
+ }
1766
+ const range = json?.range ? ` (${json.range.from} → ${json.range.to})` : '';
1767
+ console.log(`${entity} — ${mode}${range}:`);
1768
+ switch (mode) {
1769
+ case 'funnel':
1770
+ for (const s of (json?.funnel ?? [])) {
1771
+ const conv = s.conversion_from_prev_pct == null ? '' : ` ${s.conversion_from_prev_pct}% of prev`;
1772
+ const lost = s.drop_off_from_prev ? ` (−${s.drop_off_from_prev})` : '';
1773
+ console.log(` ${String(s.rank).padStart(2)}. ${String(s.stage).padEnd(24)} ${String(s.reached).padStart(6)}${conv}${lost}`);
1774
+ }
1775
+ break;
1776
+ case 'overview': {
1777
+ const s = json?.summary ?? {};
1778
+ console.log(` entered ${s.entered} → converted ${s.converted}${s.conversion_pct == null ? '' : ` (${s.conversion_pct}%)`}`);
1779
+ if (s.biggest_dropoff)
1780
+ console.log(` biggest drop-off: ${s.biggest_dropoff.from} → ${s.biggest_dropoff.to} (lost ${s.biggest_dropoff.lost})`);
1781
+ if (s.top_milestone)
1782
+ console.log(` top milestone: ${s.top_milestone.milestone} (${s.top_milestone.completions})`);
1783
+ break;
1784
+ }
1785
+ case 'milestones':
1786
+ for (const m of (json?.milestones ?? [])) {
1787
+ console.log(` ${String(m.milestone).padEnd(28)} ${String(m.completions).padStart(6)} completion(s), ${m.unique_contacts} contact(s)`);
1788
+ }
1789
+ break;
1790
+ case 'trends':
1791
+ for (const b of (json?.buckets ?? [])) {
1792
+ console.log(` ${b.bucket} active ${b.active_contacts} milestones ${b.milestone_completions}`);
1793
+ }
1794
+ for (const c of (json?.cohorts ?? [])) {
1795
+ console.log(` cohort ${c.bucket} entered ${c.entered} converted ${c.converted}`);
1796
+ }
1797
+ break;
1798
+ case 'cost':
1799
+ if (json?.cost_partial)
1800
+ console.log(' ⚠ partial — some token-usage rows carry no cost');
1801
+ for (const r of (json?.by_milestone ?? [])) {
1802
+ console.log(` ${String(r.id).padEnd(28)} ${r.conversations} conv, ${r.total_tokens} tokens, $${r.cost_usd}`);
1803
+ }
1804
+ for (const d of (json?.drivers ?? [])) {
1805
+ console.log(` driver flow=${d.source_flow_id ?? '—'} agent=${d.source_agent_id ?? '—'} conversions ${d.conversions}`);
1806
+ }
1807
+ break;
1808
+ }
1809
+ }
1810
+ // ── catalog: the commerce products + their WhatsApp binding ──────────────────
1811
+ /** `octwin catalog [--readiness] [--json]` — the `product` records a commerce pack
1812
+ * sells, their stock, and the WhatsApp catalog binding. Needs `catalog:read` + the
1813
+ * `catalog` plan feature. */
1814
+ async function cmdCatalog(flags) {
1815
+ const packDir = resolve(flags.dir ?? '.');
1816
+ const t = resolveTarget(flags, packDir);
1817
+ const { url } = t;
1818
+ const base = `${url}/api/self/p/catalog`;
1819
+ const asJson = flags.json === true;
1820
+ // --readiness makes LIVE Meta Graph calls (and 409s with no bound access token),
1821
+ // so it's opt-in rather than part of the default read.
1822
+ if (flags.readiness === true) {
1823
+ if (!asJson)
1824
+ console.log(`→ Checking WhatsApp commerce readiness for ${targetLabel(t)} (live Meta Graph calls) …`);
1825
+ const { status, json } = await apiGet(`${base}/readiness`, t);
1826
+ if (status === 409)
1827
+ die(`no Meta access token on this project's WhatsApp channel — readiness needs one${errDetail(json)}`);
1828
+ if (status !== 200)
1829
+ die(`could not read readiness (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1830
+ if (asJson) {
1831
+ console.log(JSON.stringify(json, null, 2));
1832
+ return;
1833
+ }
1834
+ const r = json?.readiness ?? {};
1835
+ console.log(`WhatsApp commerce readiness: ${r.summary ?? '?'}`);
1836
+ for (const c of (r.checks ?? [])) {
1837
+ const mark = c.status === 'ok' ? '✓' : c.status === 'warn' ? '⚠' : c.status === 'skip' ? '·' : '✗';
1838
+ console.log(` ${mark} ${c.label}${c.detail ? ` — ${c.detail}` : ''}`);
1839
+ if (c.solution)
1840
+ console.log(` → ${c.solution}`);
1841
+ }
1842
+ return;
1843
+ }
1844
+ if (!asJson)
1845
+ console.log(`→ Reading the product catalog from ${targetLabel(t)} …`);
1846
+ const { status, json } = await apiGet(base, t);
1847
+ if (status !== 200)
1848
+ die(`could not read the catalog (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1849
+ if (asJson) {
1850
+ console.log(JSON.stringify(json, null, 2));
1851
+ return;
1852
+ }
1853
+ const products = (json?.products ?? []);
1854
+ console.log(`Products in ${targetLabel(t)}: ${products.length}`);
1855
+ if (products.length === 0)
1856
+ console.log(' (none — a commerce pack seeds `product` records, or add them in the console Catalog)');
1857
+ for (const p of products) {
1858
+ // `available: null` = the SKU isn't inventory-tracked (always sellable).
1859
+ const stock = p.available == null ? 'untracked' : `${p.available}`;
1860
+ console.log(` ${String(p.retailer_id).padEnd(20)} ${String(p.name ?? '').padEnd(28)} ${fmtAmount(p.price, p.currency)}`
1861
+ + ` avail=${p.availability} stock=${stock} sync=${p.sync_status ?? '—'}`);
1862
+ }
1863
+ // A binding row can exist with no catalog_id yet (a WABA is configured but no Meta
1864
+ // catalog picked) — that is "not bound" for selling purposes, so say so.
1865
+ const b = json?.binding;
1866
+ console.log(b?.catalog_id
1867
+ ? `\nWhatsApp catalog binding: catalog ${b.catalog_id} (waba ${b.waba_id ?? '—'}), sync=${b.sync_status ?? '—'}, last=${b.last_sync_at ?? 'never'}`
1868
+ : `\nWhatsApp catalog binding: no Meta catalog bound${b?.waba_id ? ` (waba ${b.waba_id} is configured — pick a catalog)` : ''}`
1869
+ + ' — the catalog works web-only (`--readiness` explains what Meta needs).');
1870
+ }
1871
+ // ── scheduling: the availability engine + a slot preview ─────────────────────
1872
+ /** `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]`
1873
+ * — the scheduling engine's state, or the computed slots for one bookable resource
1874
+ * (the verification the `--seed` availability fan-out was missing). `scheduling:read`. */
1875
+ async function cmdScheduling(flags) {
1876
+ const packDir = resolve(flags.dir ?? '.');
1877
+ const t = resolveTarget(flags, packDir);
1878
+ const { url } = t;
1879
+ const base = `${url}/api/self/p/scheduling`;
1880
+ const asJson = flags.json === true;
1881
+ const resourceId = typeof flags.slots === 'string' ? flags.slots : undefined;
1882
+ if (flags.slots === true)
1883
+ die('usage: octwin scheduling --slots <resourceRecordId> (the record id of a bookable resource)');
1884
+ if (resourceId) {
1885
+ const q = new URLSearchParams({ include_booked: '1' }); // full occupancy, as the operator preview does
1886
+ if (typeof flags.from === 'string')
1887
+ q.set('from', flags.from);
1888
+ if (typeof flags.days === 'string')
1889
+ q.set('days', flags.days); // server-clamped to 1–31
1890
+ if (!asJson)
1891
+ console.log(`→ Computing slots for resource ${resourceId} in ${targetLabel(t)} …`);
1892
+ const { status, json } = await apiGet(`${base}/resources/${encodeURIComponent(resourceId)}/slots?${q.toString()}`, t);
1893
+ if (status === 404)
1894
+ die(`resource '${resourceId}' not found (pass an XRM record id — \`octwin records <entity>\` lists them)`);
1895
+ if (status === 400)
1896
+ die(`that record isn't a bookable resource${errDetail(json)}`);
1897
+ if (status !== 200)
1898
+ die(`could not compute slots (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1899
+ if (asJson) {
1900
+ console.log(JSON.stringify(json, null, 2));
1901
+ return;
1902
+ }
1903
+ if (json?.has_scheduling === false) {
1904
+ console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
1905
+ return;
1906
+ }
1907
+ const slots = (json?.slots ?? []);
1908
+ console.log(`Slots for ${resourceId} (timezone ${json?.timezone ?? '?'}): ${slots.length}`);
1909
+ if (slots.length === 0)
1910
+ console.log(' (none — no availability rules cover this window; `octwin deploy --seed` seeds the demo rules)');
1911
+ for (const s of slots) {
1912
+ console.log(` ${s.slot_start} → ${s.slot_end} ${s.remaining}/${s.capacity} free`);
1913
+ }
1914
+ return;
1915
+ }
1916
+ if (!asJson)
1917
+ console.log(`→ Reading the scheduling engine state from ${targetLabel(t)} …`);
1918
+ const { status, json } = await apiGet(base, t);
1919
+ if (status !== 200)
1920
+ die(`could not read scheduling (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
1921
+ if (asJson) {
1922
+ console.log(JSON.stringify(json, null, 2));
1923
+ return;
1924
+ }
1925
+ if (json?.has_scheduling === false) {
1926
+ console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
1927
+ return;
1928
+ }
1929
+ console.log(`Scheduling in ${targetLabel(t)} — bookings land as '${json?.booking_entity}'`);
1930
+ for (const rt of (json?.resource_types ?? [])) {
1931
+ console.log(` ${rt.entity} ${rt.resources} resource(s), ${rt.window_days}-day booking window`);
1932
+ }
1933
+ console.log(` upcoming slots: ${json?.upcoming_slots ?? 0} booked seats: ${json?.booked_seats ?? 0}`);
1934
+ console.log('\nSlots for one resource: octwin scheduling --slots <resourceRecordId> (ids: octwin records <entity>)');
1935
+ }
1359
1936
  function help() {
1360
- console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
1361
-
1362
- octwin --version # print the CLI version (+ any upgrade notice)
1363
- octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
1364
- octwin validate [--dir .] [--remote] # --remote runs the platform's FULL schema check (all errors at once)
1365
- octwin login --url <platformUrl> --token oct_… # a deploy token from the console
1366
- octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
1367
- octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
1368
- octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
1369
- octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
1370
- octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
1371
- octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
1372
- octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
1373
- octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
1374
- octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1375
- octwin test [--dir .] # = validate --remote (the full platform check)
1376
-
1377
- Multi-turn: the platform keeps ONE open conversation per --as handle consecutive
1378
- \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
1379
- button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
1380
- Get a deploy token: console your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
1381
- octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
1382
- Config (deploy): flags > pack.json > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login.
1937
+ console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
1938
+
1939
+ octwin --version # print the CLI version (+ any upgrade notice)
1940
+ octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
1941
+ octwin validate [--dir .] [--remote] # --remote runs the platform's FULL schema check (all errors at once)
1942
+ octwin login --url <platformUrl> --token oct_… # a deploy token from the console
1943
+ octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
1944
+ octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
1945
+ octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
1946
+ octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
1947
+ octwin cases [caseId] [--queues] [--json] # inspect casework (support tickets) + timelines
1948
+ octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
1949
+ octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
1950
+ octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
1951
+ octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
1952
+ octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
1953
+ octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
1954
+ octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
1955
+ octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
1956
+ octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1957
+ octwin test [--dir .] # = validate --remote (the full platform check)
1958
+
1959
+ Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
1960
+ \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
1961
+ button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
1962
+ Get a deploy token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
1963
+ octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
1964
+ Config (deploy): flags > pack.json > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login.
1383
1965
  Per-command usage: octwin <command> --help`);
1384
1966
  }
1385
1967
  /** Per-subcommand usage — printed for `octwin <cmd> --help|-h` BEFORE any
1386
1968
  * network/auth work (a --help that 401s is worse than no help at all). */
1387
1969
  const COMMAND_HELP = {
1388
- init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
1970
+ init: `octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
1389
1971
  Scaffold a pure-YAML starter pack into <dir>.`,
1390
- validate: `octwin validate [--dir .] [--remote]
1391
- Offline structural check; --remote additionally runs the platform's FULL
1972
+ validate: `octwin validate [--dir .] [--remote]
1973
+ Offline structural check; --remote additionally runs the platform's FULL
1392
1974
  manifest + flow-DSL validation (all errors at once) — same check as deploy.`,
1393
- login: `octwin login --url <platformUrl> --token oct_…
1394
- Save a deploy token (console → Settings → API tokens) for that platform url,
1975
+ login: `octwin login --url <platformUrl> --token oct_…
1976
+ Save a deploy token (console → Settings → API tokens) for that platform url,
1395
1977
  and echo the workspace + project pin + scopes the token reaches.`,
1396
- whoami: `octwin whoami [--url <url>] [--tenant <slug>]
1978
+ whoami: `octwin whoami [--url <url>] [--tenant <slug>]
1397
1979
  Verify the resolved token authenticates against the tenant.`,
1398
- deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
1399
- Upload the pack bundle, validate server-side, install onto the project.
1980
+ deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
1981
+ Upload the pack bundle, validate server-side, install onto the project.
1400
1982
  --seed additionally applies the pack's demo seed (streams progress).`,
1401
- status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
1983
+ status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
1402
1984
  Show installed vs live version + the flow list for this pack.`,
1403
- records: `octwin records [entity] [id] [--limit 50]
1404
- Inspect the pack's XRM data. No args = list entities. Cases/tickets are
1985
+ records: `octwin records [entity] [id] [--limit 50]
1986
+ Inspect the pack's XRM data. No args = list entities. Cases/tickets are
1405
1987
  casework, not XRM — use \`octwin cases\` for those.`,
1406
- cases: `octwin cases [caseId] [--queues] [--limit 50] [--json]
1407
- Inspect casework (support tickets): the inbox, one case + its timeline
1988
+ cases: `octwin cases [caseId] [--queues] [--limit 50] [--json]
1989
+ Inspect casework (support tickets): the inbox, one case + its timeline
1408
1990
  (+ applicable decisions), or --queues for queue keys + open counts.`,
1409
- logs: `octwin logs [conversationId] [--as <handle>] [--json]
1410
- No id = recent conversations (handle, status, last activity; --as filters).
1411
- With id = the full event timeline including what each turn rendered.
1991
+ logs: `octwin logs [conversationId] [--as <handle>] [--json]
1992
+ No id = recent conversations (handle, status, last activity; --as filters).
1993
+ With id = the full event timeline including what each turn rendered.
1412
1994
  --json = raw events (verbatim payloads).`,
1413
- chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
1414
- Drive one turn through the dev web channel and print every render with its
1415
- tap ids. Same --as handle = same conversation (multi-turn works).
1416
- --tap presses a rendered button/list row instead of sending text.
1417
- --media uploads a local file (or a media id from 'media generate --json') as
1418
- an image/document/audio inbound — any "message" rides as its caption; feeds a
1419
- running media-collect flow (e.g. activate-app).
1995
+ chat: `octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json]
1996
+ Drive one turn through the dev web channel and print every render with its
1997
+ tap ids. Same --as handle = same conversation (multi-turn works).
1998
+ --tap presses a rendered button/list row instead of sending text.
1999
+ --media uploads a local file (or a media id from 'media generate --json') as
2000
+ an image/document/audio inbound — any "message" rides as its caption; feeds a
2001
+ running media-collect flow (e.g. activate-app).
1420
2002
  --json dumps the raw SSE envelopes for the turn.`,
1421
- media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
1422
- AI-generate an image (needs a media:generate-scoped token), store it as a
1423
- public asset, and print its MEDIA- handle + serve URL. --out downloads the
1424
- bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
2003
+ media: `octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]
2004
+ AI-generate an image (needs a media:generate-scoped token), store it as a
2005
+ public asset, and print its MEDIA- handle + serve URL. --out downloads the
2006
+ bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
1425
2007
  width, height, bytes }. Pair with 'octwin chat --media' to drive media flows.`,
1426
- 'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
1427
- Pull the platform capability reference (markdown + JSON catalogs) into
2008
+ agents: `octwin agents [packId::agentId] [--prompt] [--json]
2009
+ No args = the roster with each agent's EFFECTIVE model and which layer set it.
2010
+ With an agent = every governed setting (model / memory.last_messages /
2011
+ working_memory) plus the layer that won — an operator PLATFORM default can
2012
+ override what your manifest declares, and this is where you see that.
2013
+ --prompt = the exact system prompt the LLM sees for this project (pack
2014
+ instructions + platform protocol + any project overlay). Needs agents:read.
2015
+ The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.`,
2016
+ orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
2017
+ No args = the order list (#number, status/payment, total, contact). With a
2018
+ reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
2019
+ payment_ref, and the allowed status transitions. Needs orders:read + the
2020
+ \`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
2021
+ so \`pending\` on a gateway-less workspace is expected, not a bug.`,
2022
+ analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
2023
+ No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
2024
+ With an entity = stage-by-stage conversion (default --funnel) over the last 30
2025
+ days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
2026
+ range-filtered). Needs records:read + a \`view\` grant on \`record.<entity>\`.`,
2027
+ catalog: `octwin catalog [--readiness] [--json]
2028
+ The commerce \`product\` records + price, availability, stock (null = not
2029
+ inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
2030
+ Graph checklist (LIVE Graph calls; needs a bound access token). Needs
2031
+ catalog:read + the \`catalog\` plan feature.`,
2032
+ scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
2033
+ No args = the engine state (bookable resource types, upcoming slots, booked
2034
+ seats). --slots <recordId> computes the slots for one bookable resource
2035
+ (occupancy included; --days is clamped to 1-31 server-side) — the way to verify
2036
+ the availability rules a \`deploy --seed\` created. Needs scheduling:read.`,
2037
+ 'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
2038
+ Pull the platform capability reference (markdown + JSON catalogs) into
1428
2039
  .octwin/platform-kb/ for the octwin-pack authoring skill.`,
1429
- test: `octwin test [--dir .]
2040
+ test: `octwin test [--dir .]
1430
2041
  Alias for \`octwin validate --remote\` — the full platform check.`,
1431
2042
  };
1432
2043
  async function main() {
1433
2044
  const [command, ...rest] = process.argv.slice(2);
1434
2045
  const flags = parseFlags(rest);
2046
+ CURRENT_COMMAND = command; // so an auth failure can name the scope THIS command needs
1435
2047
  // Per-subcommand --help/-h — intercepted BEFORE the command runs, so help can
1436
2048
  // never hit the network or die on auth (author-feedback A8).
1437
2049
  if (command && command in COMMAND_HELP && (flags.help === true || flags._.includes('-h'))) {
@@ -1472,6 +2084,21 @@ async function main() {
1472
2084
  case 'media':
1473
2085
  await cmdMedia(flags);
1474
2086
  break;
2087
+ case 'agents':
2088
+ await cmdAgents(flags);
2089
+ break;
2090
+ case 'orders':
2091
+ await cmdOrders(flags);
2092
+ break;
2093
+ case 'analytics':
2094
+ await cmdAnalytics(flags);
2095
+ break;
2096
+ case 'catalog':
2097
+ await cmdCatalog(flags);
2098
+ break;
2099
+ case 'scheduling':
2100
+ await cmdScheduling(flags);
2101
+ break;
1475
2102
  case 'platform-kb':
1476
2103
  await cmdPlatformKb(flags);
1477
2104
  break;
@@ -8,9 +8,20 @@
8
8
  * uploading. The **server re-validates authoritatively** on deploy — this local
9
9
  * copy is a fast pre-check, not the source of truth — and it omits the server's
10
10
  * in-repo shadow check (there is no `src/packs` in a developer's own repo).
11
+ *
12
+ * The duplication is deliberate: `octwin-cli` is published to npm and must not
13
+ * import the platform. `validate-parity.test.ts` asserts the two copies agree on
14
+ * every rule, so a divergence fails the build rather than surfacing as "it passed
15
+ * locally but the deploy rejected it".
11
16
  */
12
- /** Declarative file extensions a pure-YAML pack may contain. */
17
+ /** Declarative TEXT extensions a pure-YAML pack may contain. */
13
18
  const ALLOWED_EXT = new Set(['yaml', 'yml', 'md', 'sql', 'json']);
19
+ /** Binary extensions that travel as artifact BLOBS (base64 on the wire, bytea in
20
+ * storage). No `svg` — it is script-capable and these are served to browsers. */
21
+ const ALLOWED_BINARY_EXT = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf']);
22
+ /** Ceilings, mirrored from the server so oversize fails LOCALLY before upload. */
23
+ const MAX_BLOB_BYTES = 2 * 1024 * 1024;
24
+ const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
14
25
  /** Extensions/paths that mean executable code or a non-YAML capability — rejected. */
15
26
  const CODE_EXT = new Set(['ts', 'js', 'mjs', 'cjs', 'jsx', 'tsx', 'node', 'wasm', 'sh', 'bash', 'exe', 'py', 'rb']);
16
27
  /** Normalize to forward slashes + strip a leading `./`. */
@@ -28,13 +39,15 @@ function ext(p) {
28
39
  * no path traversal or absolute paths, declarative extensions only. Returns all
29
40
  * violations at once.
30
41
  */
31
- export function validatePackBundle(packId, files) {
42
+ export function validatePackBundle(packId, files,
43
+ /** `{ relPath: base64 }` — the binary half, as the CLI collects it. */
44
+ blobs = {}) {
32
45
  const errors = [];
33
46
  if (!/^[a-z][a-z0-9-]*$/.test(packId)) {
34
47
  errors.push(`pack id '${packId}' must be lowercase ASCII with hyphens (e.g. 'my-pack')`);
35
48
  }
36
49
  const paths = Object.keys(files);
37
- if (paths.length === 0)
50
+ if (paths.length === 0 && Object.keys(blobs).length === 0)
38
51
  errors.push('bundle is empty');
39
52
  if (!paths.some(p => norm(p) === 'manifest.yaml')) {
40
53
  errors.push('bundle is missing manifest.yaml at its root');
@@ -62,10 +75,43 @@ export function validatePackBundle(packId, files) {
62
75
  errors.push(`'${p}': executable code is not allowed (pure-YAML packs only)`);
63
76
  continue;
64
77
  }
78
+ if (ALLOWED_BINARY_EXT.has(e)) {
79
+ errors.push(`'${p}': binary files travel as blobs, not in the text file map`);
80
+ continue;
81
+ }
65
82
  if (!ALLOWED_EXT.has(e)) {
66
- errors.push(`'${p}': not an allowed pack file type (.yaml/.yml/.md/.sql only)`);
83
+ errors.push(`'${p}': not an allowed pack file type (.yaml/.yml/.md/.sql/.json, or an image: ${[...ALLOWED_BINARY_EXT].join('/')})`);
84
+ continue;
85
+ }
86
+ }
87
+ // Blobs — same rules the server applies to the decoded bytes. Sizes are derived
88
+ // from the base64 length (3 bytes per 4 chars, minus padding) so this needs no
89
+ // Buffer and stays platform-free.
90
+ let totalBlobBytes = 0;
91
+ for (const raw of Object.keys(blobs)) {
92
+ const p = norm(raw);
93
+ if (p.startsWith('/') || /^[a-zA-Z]:/.test(p) || p.split('/').includes('..')) {
94
+ errors.push(`unsafe blob path '${raw}' (absolute or traversal)`);
95
+ continue;
96
+ }
97
+ const e = ext(p);
98
+ if (!ALLOWED_BINARY_EXT.has(e)) {
99
+ errors.push(`'${p}': not an allowed image type (${[...ALLOWED_BINARY_EXT].join('/')}; SVG is rejected — it is script-capable)`);
67
100
  continue;
68
101
  }
102
+ const b64 = blobs[raw];
103
+ const size = Math.floor(b64.replace(/=+$/, '').length * 3 / 4);
104
+ totalBlobBytes += size;
105
+ if (size > MAX_BLOB_BYTES) {
106
+ errors.push(`'${p}': ${(size / 1024 / 1024).toFixed(1)} MB exceeds the ${MAX_BLOB_BYTES / 1024 / 1024} MB per-file limit`);
107
+ }
108
+ if (files[raw] !== undefined) {
109
+ errors.push(`'${p}': present as BOTH a text file and a blob — pick one`);
110
+ }
111
+ }
112
+ if (totalBlobBytes > MAX_ARTIFACT_BYTES) {
113
+ errors.push(`binary payload ${(totalBlobBytes / 1024 / 1024).toFixed(1)} MB exceeds the ` +
114
+ `${MAX_ARTIFACT_BYTES / 1024 / 1024} MB per-pack limit`);
69
115
  }
70
116
  return { ok: errors.length === 0, errors };
71
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
5
  "type": "module",
6
6
  "bin": {