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 +62 -0
- package/README.md +30 -1
- package/dist/index.js +711 -84
- package/dist/lib/validate.js +50 -4
- package/package.json +1 -1
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.
|
|
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
|
-
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
|
107
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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':
|
|
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
|
-
|
|
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.
|
|
735
|
-
console.log(
|
|
835
|
+
else if (json.up_to_date === false) {
|
|
836
|
+
console.log('\n⚠ this instance is running an OLDER artifact than the catalog holds — it 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)}${
|
|
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)}
|
|
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)}
|
|
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
|
-
|
|
1290
|
-
die(`could not read ${what} (HTTP ${status})${errDetail(json)}${
|
|
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
|
|
1375
|
-
octwin
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
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
|
-
|
|
1427
|
-
|
|
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;
|
package/dist/lib/validate.js
CHANGED
|
@@ -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
|
|
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
|
|
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