octwin-cli 0.1.13 → 0.1.15
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 +58 -0
- package/README.md +29 -0
- package/dist/index.js +779 -104
- package/package.json +1 -1
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) {
|
|
@@ -335,8 +394,8 @@ async function notifyIfKbStale(flags) {
|
|
|
335
394
|
return;
|
|
336
395
|
const ctrl = new AbortController();
|
|
337
396
|
const timer = setTimeout(() => ctrl.abort(), 2_000);
|
|
338
|
-
const res = await fetch(`${t.url}/api/
|
|
339
|
-
headers:
|
|
397
|
+
const res = await fetch(`${t.url}/api/self/t/octwin-platform-kb?meta=1`, {
|
|
398
|
+
headers: authHeaders(t), signal: ctrl.signal,
|
|
340
399
|
});
|
|
341
400
|
clearTimeout(timer);
|
|
342
401
|
if (!res.ok)
|
|
@@ -376,7 +435,12 @@ function commandTouchesPlatform(command, flags) {
|
|
|
376
435
|
case 'records':
|
|
377
436
|
case 'cases':
|
|
378
437
|
case 'logs':
|
|
379
|
-
case 'whoami':
|
|
438
|
+
case 'whoami':
|
|
439
|
+
case 'agents':
|
|
440
|
+
case 'orders':
|
|
441
|
+
case 'analytics':
|
|
442
|
+
case 'catalog':
|
|
443
|
+
case 'scheduling': return true;
|
|
380
444
|
default: return false;
|
|
381
445
|
}
|
|
382
446
|
}
|
|
@@ -402,10 +466,11 @@ function cmdInit(flags) {
|
|
|
402
466
|
displayName: flags['display-name'] ?? undefined,
|
|
403
467
|
});
|
|
404
468
|
// Deploy config + repo hygiene + a README.
|
|
469
|
+
// The token carries its own tenant (and optional project pin), so pack.json
|
|
470
|
+
// needs only the platform URL. `tenant`/`project` may be added as optional
|
|
471
|
+
// overrides (they also seed `octwin chat`, which is tenant/project-pathed).
|
|
405
472
|
writeFileSync(join(dir, 'pack.json'), JSON.stringify({
|
|
406
473
|
platform_url: 'http://localhost:3000',
|
|
407
|
-
tenant: 'your-tenant-slug',
|
|
408
|
-
project: 'main',
|
|
409
474
|
}, null, 2) + '\n', 'utf8');
|
|
410
475
|
writeFileSync(join(dir, '.gitignore'), 'node_modules/\n.pack-bundles/\n.octwin/\n', 'utf8');
|
|
411
476
|
if (!existsSync(join(dir, 'README.md'))) {
|
|
@@ -417,7 +482,7 @@ function cmdInit(flags) {
|
|
|
417
482
|
console.log(' git init && git add -A && git commit -m "init pack"');
|
|
418
483
|
console.log(' # edit manifest.yaml / flows / prompts, then:');
|
|
419
484
|
console.log(' octwin validate');
|
|
420
|
-
console.log(' # set platform_url
|
|
485
|
+
console.log(' # set platform_url in pack.json (tenant comes from your token), then:');
|
|
421
486
|
console.log(' octwin login --url <platformUrl> --token <deploy-token>');
|
|
422
487
|
console.log(' octwin deploy');
|
|
423
488
|
}
|
|
@@ -443,11 +508,12 @@ async function cmdValidate(flags) {
|
|
|
443
508
|
}
|
|
444
509
|
// Remote: the SAME validation the deploy route runs — manifest `.strict()` +
|
|
445
510
|
// every flow (schema/expression/structure) — returning ALL errors at once.
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
511
|
+
const t = resolveTarget(flags, packDir);
|
|
512
|
+
const { url } = t;
|
|
513
|
+
console.log(`→ Validating against ${targetLabel(t)} @ ${url} …`);
|
|
514
|
+
const res = await fetchOrDie(`${url}/api/self/p/packs/validate`, {
|
|
449
515
|
method: 'POST',
|
|
450
|
-
headers: { 'content-type': 'application/json',
|
|
516
|
+
headers: { 'content-type': 'application/json', ...authHeaders(t) },
|
|
451
517
|
body: JSON.stringify({ files }),
|
|
452
518
|
}, 'remote validate');
|
|
453
519
|
const text = await res.text();
|
|
@@ -467,6 +533,12 @@ async function cmdValidate(flags) {
|
|
|
467
533
|
console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
|
|
468
534
|
process.exit(1);
|
|
469
535
|
}
|
|
536
|
+
const warnings = (json?.warnings ?? []);
|
|
537
|
+
if (warnings.length) {
|
|
538
|
+
console.log(` ⚠ ${warnings.length} warning${warnings.length === 1 ? '' : 's'} (won't block deploy):`);
|
|
539
|
+
for (const w of warnings)
|
|
540
|
+
console.log(` ⚠ [${w.where}] ${w.message}`);
|
|
541
|
+
}
|
|
470
542
|
if (json?.ok) {
|
|
471
543
|
console.log(`✓ ${id}@${version} passes the platform's FULL validation — deploy won't reject on schema.`);
|
|
472
544
|
return;
|
|
@@ -486,55 +558,83 @@ async function cmdValidate(flags) {
|
|
|
486
558
|
}
|
|
487
559
|
process.exit(1);
|
|
488
560
|
}
|
|
489
|
-
function cmdLogin(flags) {
|
|
490
|
-
const
|
|
561
|
+
async function cmdLogin(flags) {
|
|
562
|
+
const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
|
|
563
|
+
const url = rawUrl.replace(/\/$/, '');
|
|
491
564
|
const token = flags.token ?? process.env.PACK_TOKEN ?? die('missing --token');
|
|
492
565
|
const creds = readCreds();
|
|
493
|
-
creds[url
|
|
566
|
+
creds[url] = token;
|
|
494
567
|
writeCreds(creds);
|
|
495
568
|
console.log(`✓ Saved token for ${url}`);
|
|
569
|
+
// Best-effort: echo what the token reaches (workspace + project pin + scopes)
|
|
570
|
+
// so a fresh token self-identifies without a second `octwin whoami`. A network
|
|
571
|
+
// failure never fails the save — the token is stored regardless.
|
|
572
|
+
try {
|
|
573
|
+
const res = await fetch(`${url}/api/self/t/whoami`, { headers: { authorization: `Bearer ${token}` } });
|
|
574
|
+
if (res.ok) {
|
|
575
|
+
const j = await res.json();
|
|
576
|
+
const scopes = Array.isArray(j.scopes) && j.scopes.length ? ` · scopes: ${j.scopes.join(', ')}` : '';
|
|
577
|
+
console.log(` → workspace '${j.tenant_slug}'${j.project_slug ? `, pinned to project '${j.project_slug}'` : ''}${scopes}`);
|
|
578
|
+
}
|
|
579
|
+
else if (res.status === 401 || res.status === 403) {
|
|
580
|
+
console.log(` ⚠ token saved, but the platform rejected it (HTTP ${res.status}) — check it's a current oct_… token`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
catch { /* platform unreachable — the token is saved regardless */ }
|
|
496
584
|
}
|
|
497
|
-
/**
|
|
585
|
+
/** Bearer auth + the optional self-surface overrides, as request headers. */
|
|
586
|
+
function authHeaders(t) {
|
|
587
|
+
const h = { authorization: `Bearer ${t.token}` };
|
|
588
|
+
if (t.tenant)
|
|
589
|
+
h['x-octwin-tenant'] = t.tenant;
|
|
590
|
+
if (t.project)
|
|
591
|
+
h['x-octwin-project'] = t.project;
|
|
592
|
+
return h;
|
|
593
|
+
}
|
|
594
|
+
/** Resolve platform url + token (+ optional tenant/project overrides):
|
|
595
|
+
* flags > pack.json > env > saved login. Tenant is derived from the token
|
|
596
|
+
* server-side, so only url + token are required. */
|
|
498
597
|
function resolveTarget(flags, packDir) {
|
|
499
598
|
const cfg = readPackConfig(packDir);
|
|
500
599
|
const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
|
|
501
|
-
const tenant = flags.tenant
|
|
502
|
-
const project = flags.project
|
|
600
|
+
const tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || undefined;
|
|
601
|
+
const project = flags.project || process.env.PACK_PROJECT || cfg.project || undefined;
|
|
503
602
|
const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
|
|
504
603
|
if (!url)
|
|
505
604
|
die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
|
|
506
|
-
if (!tenant)
|
|
507
|
-
die('no tenant — set it in pack.json, --tenant, or PACK_TENANT');
|
|
508
605
|
if (!token)
|
|
509
|
-
die('no token — generate
|
|
510
|
-
return { url, tenant, project
|
|
606
|
+
die('no token — generate an API token in the console (Settings → API tokens), then `octwin login --url <url> --token oct_…` or pass --token');
|
|
607
|
+
return { url, token, tenant, project };
|
|
511
608
|
}
|
|
512
|
-
/** Non-fatal `resolveTarget`: returns null (never dies) when
|
|
513
|
-
*
|
|
609
|
+
/** Non-fatal `resolveTarget`: returns null (never dies) when url or token is
|
|
610
|
+
* missing. Used by the fail-silent KB-staleness observer, which must never
|
|
514
611
|
* interrupt a command over a config gap. */
|
|
515
612
|
function resolveTargetOrNull(flags, packDir) {
|
|
516
613
|
const cfg = readPackConfig(packDir);
|
|
517
614
|
const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
|
|
518
|
-
const tenant = flags.tenant
|
|
519
|
-
const project = flags.project
|
|
615
|
+
const tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || undefined;
|
|
616
|
+
const project = flags.project || process.env.PACK_PROJECT || cfg.project || undefined;
|
|
520
617
|
const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
|
|
521
|
-
if (!url || !
|
|
618
|
+
if (!url || !token)
|
|
522
619
|
return null;
|
|
523
|
-
return { url, tenant, project
|
|
620
|
+
return { url, token, tenant, project };
|
|
524
621
|
}
|
|
525
622
|
async function cmdWhoami(flags) {
|
|
526
623
|
const packDir = resolve(flags.dir ?? '.');
|
|
527
|
-
const
|
|
528
|
-
console.log(`→ Checking the
|
|
529
|
-
const res = await fetchOrDie(`${url}/api/
|
|
624
|
+
const t = resolveTarget(flags, packDir);
|
|
625
|
+
console.log(`→ Checking the token against ${t.url} …`);
|
|
626
|
+
const res = await fetchOrDie(`${t.url}/api/self/t/whoami`, { headers: authHeaders(t) }, 'token check');
|
|
530
627
|
if (res.ok) {
|
|
531
|
-
|
|
628
|
+
const j = await res.json();
|
|
629
|
+
console.log(`✓ Token valid — workspace '${j.tenant_slug}'${j.project_slug ? `, pinned to project '${j.project_slug}'` : ''} (${j.kind === 'api_token' ? 'API token' : 'session'})`);
|
|
630
|
+
if (Array.isArray(j.scopes))
|
|
631
|
+
console.log(` scopes: ${j.scopes.length ? j.scopes.join(', ') : '(none)'}`);
|
|
532
632
|
return;
|
|
533
633
|
}
|
|
534
634
|
const why = res.status === 401 ? 'invalid / expired / revoked token'
|
|
535
|
-
: res.status === 403 ? 'token not authorized for this
|
|
635
|
+
: res.status === 403 ? 'token not authorized for this workspace'
|
|
536
636
|
: await res.text();
|
|
537
|
-
die(`token check failed
|
|
637
|
+
die(`token check failed (HTTP ${res.status}) — ${why}`);
|
|
538
638
|
}
|
|
539
639
|
/**
|
|
540
640
|
* Read the deploy SSE stream, printing each progress frame's message live, and
|
|
@@ -547,6 +647,11 @@ async function readDeployProgress(body) {
|
|
|
547
647
|
const decoder = new TextDecoder();
|
|
548
648
|
let buf = '';
|
|
549
649
|
let terminal = null;
|
|
650
|
+
// Non-terminal frames with `status:'error'` are step failures the install
|
|
651
|
+
// SOFTENS to non-fatal (e.g. a demo-seed row) — the reconcile keeps going and
|
|
652
|
+
// still emits a `done`. We collect them so the deploy is NOT reported as a
|
|
653
|
+
// clean ✓ when a step actually failed (the false-✓ trap).
|
|
654
|
+
const stepErrors = [];
|
|
550
655
|
for (;;) {
|
|
551
656
|
const { done, value } = await reader.read();
|
|
552
657
|
if (done)
|
|
@@ -570,14 +675,18 @@ async function readDeployProgress(body) {
|
|
|
570
675
|
terminal = ev;
|
|
571
676
|
continue;
|
|
572
677
|
}
|
|
573
|
-
if (ev.message)
|
|
574
|
-
|
|
678
|
+
if (ev.message) {
|
|
679
|
+
const isErr = ev.status === 'error';
|
|
680
|
+
if (isErr)
|
|
681
|
+
stepErrors.push(ev.message);
|
|
682
|
+
console.log(` ${isErr ? '⚠' : '·'} ${ev.message}`);
|
|
683
|
+
}
|
|
575
684
|
}
|
|
576
685
|
}
|
|
577
|
-
return terminal;
|
|
686
|
+
return { terminal, stepErrors };
|
|
578
687
|
}
|
|
579
|
-
function printDeploySuccess(id, version,
|
|
580
|
-
console.log(`✓ Deployed ${id}@${version} and installed onto ${
|
|
688
|
+
function printDeploySuccess(id, version, t, r) {
|
|
689
|
+
console.log(`✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
|
|
581
690
|
if (r?.warning)
|
|
582
691
|
console.log(` ⚠ ${r.warning}`);
|
|
583
692
|
const s = r?.summary;
|
|
@@ -594,29 +703,39 @@ function printDeploySuccess(id, version, tenant, project, r) {
|
|
|
594
703
|
if (parts.length)
|
|
595
704
|
console.log(` Seeded: ${parts.join(', ')}`);
|
|
596
705
|
}
|
|
597
|
-
console.log(`\nChat with it
|
|
706
|
+
console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
|
|
598
707
|
}
|
|
599
708
|
async function cmdDeploy(flags) {
|
|
600
709
|
const packDir = resolve(flags.dir ?? '.');
|
|
601
|
-
const
|
|
710
|
+
const t = resolveTarget(flags, packDir);
|
|
711
|
+
const { url } = t;
|
|
602
712
|
const { id, version, files } = localValidate(packDir);
|
|
603
|
-
const endpoint = `${url}/api/
|
|
713
|
+
const endpoint = `${url}/api/self/p/packs/deploy`;
|
|
604
714
|
const seed = flags.seed === true;
|
|
605
|
-
console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${
|
|
715
|
+
console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${seed ? ' — with demo seed' : ''} …`);
|
|
606
716
|
const res = await fetchOrDie(endpoint, {
|
|
607
717
|
method: 'POST',
|
|
608
718
|
// Ask for a progress stream; the platform falls back to plain JSON if it
|
|
609
719
|
// (or an error before any progress) can't stream — handled below.
|
|
610
|
-
headers: { 'content-type': 'application/json', accept: 'text/event-stream',
|
|
720
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
|
|
611
721
|
body: JSON.stringify({ files, seed }),
|
|
612
722
|
}, 'deploy');
|
|
613
723
|
// Streaming path — live install + seed progress (image generation can take a
|
|
614
724
|
// while, so `--seed` prints per-record / per-image lines as they happen).
|
|
615
725
|
if (res.ok && (res.headers.get('content-type') ?? '').includes('text/event-stream') && res.body) {
|
|
616
|
-
const final = await readDeployProgress(res.body);
|
|
726
|
+
const { terminal: final, stepErrors } = await readDeployProgress(res.body);
|
|
617
727
|
if (!final || final.stage === 'error')
|
|
618
728
|
die(`deploy failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
|
|
619
|
-
printDeploySuccess(id, version,
|
|
729
|
+
printDeploySuccess(id, version, t, final);
|
|
730
|
+
if (stepErrors.length) {
|
|
731
|
+
// The pack IS installed, but a step (e.g. the demo seed) failed — say so
|
|
732
|
+
// plainly and exit non-zero so CI / a `deploy && chat` chain doesn't treat
|
|
733
|
+
// an incomplete install as a clean success.
|
|
734
|
+
console.error(`\n⚠ Deployed with ${stepErrors.length} warning${stepErrors.length === 1 ? '' : 's'} — data may be incomplete:`);
|
|
735
|
+
for (const e of stepErrors)
|
|
736
|
+
console.error(` • ${e}`);
|
|
737
|
+
process.exit(1);
|
|
738
|
+
}
|
|
620
739
|
return;
|
|
621
740
|
}
|
|
622
741
|
// Non-streaming path — plain JSON (older platform, or an error thrown before
|
|
@@ -635,11 +754,12 @@ async function cmdDeploy(flags) {
|
|
|
635
754
|
console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
|
|
636
755
|
process.exit(1);
|
|
637
756
|
}
|
|
638
|
-
printDeploySuccess(id, version,
|
|
757
|
+
printDeploySuccess(id, version, t, json);
|
|
639
758
|
}
|
|
640
759
|
async function cmdStatus(flags) {
|
|
641
760
|
const packDir = resolve(flags.dir ?? '.');
|
|
642
|
-
const
|
|
761
|
+
const t = resolveTarget(flags, packDir);
|
|
762
|
+
const { url } = t;
|
|
643
763
|
const manifestPath = join(packDir, 'manifest.yaml');
|
|
644
764
|
if (!existsSync(manifestPath))
|
|
645
765
|
die('no manifest.yaml in the pack directory (run from your pack dir or pass --dir)');
|
|
@@ -648,9 +768,9 @@ async function cmdStatus(flags) {
|
|
|
648
768
|
die('manifest.yaml must declare a string `id`');
|
|
649
769
|
const id = doc.id;
|
|
650
770
|
const localVersion = typeof doc?.version === 'string' ? doc.version : '?';
|
|
651
|
-
console.log(`→ Checking ${id}@${localVersion} on ${
|
|
652
|
-
const res = await fetchOrDie(`${url}/api/
|
|
653
|
-
headers:
|
|
771
|
+
console.log(`→ Checking ${id}@${localVersion} on ${targetLabel(t)} @ ${url} …`);
|
|
772
|
+
const res = await fetchOrDie(`${url}/api/self/p/packs/${id}/runtime`, {
|
|
773
|
+
headers: authHeaders(t),
|
|
654
774
|
}, 'status check');
|
|
655
775
|
const text = await res.text();
|
|
656
776
|
let json;
|
|
@@ -662,13 +782,13 @@ async function cmdStatus(flags) {
|
|
|
662
782
|
}
|
|
663
783
|
if (!res.ok) {
|
|
664
784
|
if (res.status === 404)
|
|
665
|
-
die(`'${id}' is not installed on ${
|
|
785
|
+
die(`'${id}' is not installed on ${targetLabel(t)} yet — run \`octwin deploy\` first`);
|
|
666
786
|
console.error(`✗ status check failed (HTTP ${res.status})`);
|
|
667
787
|
printAuthHint(res.status, url);
|
|
668
788
|
console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
|
|
669
789
|
process.exit(1);
|
|
670
790
|
}
|
|
671
|
-
console.log(`${id} on ${
|
|
791
|
+
console.log(`${id} on ${targetLabel(t)} @ ${url}`);
|
|
672
792
|
console.log(` installed version : ${json.installed_version}`);
|
|
673
793
|
console.log(` live on instance : registered=${json.registered} source=${json.source} loaded=${json.loaded_version ?? '(none)'}`);
|
|
674
794
|
console.log(` flows : ${(json.flows ?? []).join(', ') || '(none)'}`);
|
|
@@ -687,10 +807,11 @@ async function cmdStatus(flags) {
|
|
|
687
807
|
}
|
|
688
808
|
async function cmdPlatformKb(flags) {
|
|
689
809
|
const packDir = resolve(flags.dir ?? '.');
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
810
|
+
const t = resolveTarget(flags, packDir);
|
|
811
|
+
const { url } = t;
|
|
812
|
+
console.log(`→ Pulling the platform capability reference from ${url} …`);
|
|
813
|
+
const res = await fetchOrDie(`${url}/api/self/t/octwin-platform-kb`, {
|
|
814
|
+
headers: authHeaders(t),
|
|
694
815
|
}, 'platform-kb pull');
|
|
695
816
|
const text = await res.text();
|
|
696
817
|
if (!res.ok) {
|
|
@@ -755,8 +876,8 @@ async function cmdPlatformKb(flags) {
|
|
|
755
876
|
/** GET an admin endpoint with the deploy token; returns `{ status, json }`.
|
|
756
877
|
* Dies (with the URL) on a network failure; auth failures return so the
|
|
757
878
|
* caller can add command-specific context on top of `authFailureHint`. */
|
|
758
|
-
async function apiGet(endpoint,
|
|
759
|
-
const res = await fetchOrDie(endpoint, { headers:
|
|
879
|
+
async function apiGet(endpoint, t) {
|
|
880
|
+
const res = await fetchOrDie(endpoint, { headers: authHeaders(t) }, 'request');
|
|
760
881
|
const text = await res.text();
|
|
761
882
|
let json;
|
|
762
883
|
try {
|
|
@@ -767,16 +888,22 @@ async function apiGet(endpoint, token) {
|
|
|
767
888
|
}
|
|
768
889
|
return { status: res.status, json };
|
|
769
890
|
}
|
|
891
|
+
/** A progress-line label for the target workspace. The token names the tenant, so
|
|
892
|
+
* we surface at most the project (when pinned or overridden by `--project`). */
|
|
893
|
+
function targetLabel(t) {
|
|
894
|
+
return t.project ? `project '${t.project}'` : 'your workspace';
|
|
895
|
+
}
|
|
770
896
|
/** `octwin records [entity] [id]` — inspect the pack's XRM data (needs a `records:read` token). */
|
|
771
897
|
async function cmdRecords(flags) {
|
|
772
898
|
const packDir = resolve(flags.dir ?? '.');
|
|
773
|
-
const
|
|
774
|
-
const
|
|
899
|
+
const t = resolveTarget(flags, packDir);
|
|
900
|
+
const { url } = t;
|
|
901
|
+
const base = `${url}/api/self/p`;
|
|
775
902
|
const entity = flags._[0];
|
|
776
903
|
const recordId = flags._[1];
|
|
777
|
-
console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${
|
|
904
|
+
console.log(`→ Reading ${recordId ? `${entity} record ${recordId}` : entity ? `${entity} records` : 'the entity catalog'} from ${targetLabel(t)} …`);
|
|
778
905
|
if (!entity) {
|
|
779
|
-
const { status, json } = await apiGet(`${base}/xrm/entities`,
|
|
906
|
+
const { status, json } = await apiGet(`${base}/xrm/entities`, t);
|
|
780
907
|
if (status !== 200)
|
|
781
908
|
die(`could not read entities (HTTP ${status})`);
|
|
782
909
|
if (json?.has_xrm === false) {
|
|
@@ -788,7 +915,7 @@ async function cmdRecords(flags) {
|
|
|
788
915
|
console.log('No entities visible — mint a token with the `records:read` scope to inspect data.');
|
|
789
916
|
return;
|
|
790
917
|
}
|
|
791
|
-
console.log(`Entities in ${
|
|
918
|
+
console.log(`Entities in ${targetLabel(t)}:`);
|
|
792
919
|
for (const e of ents)
|
|
793
920
|
console.log(` ${e.entity} (${e.open_count ?? 0} records)`);
|
|
794
921
|
console.log('\nList records: octwin records <entity>');
|
|
@@ -796,16 +923,14 @@ async function cmdRecords(flags) {
|
|
|
796
923
|
}
|
|
797
924
|
if (!recordId) {
|
|
798
925
|
const limit = flags.limit ?? '50';
|
|
799
|
-
const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`,
|
|
800
|
-
if (status === 403)
|
|
801
|
-
die('forbidden — the paged record list needs the `records` plan feature on this tenant');
|
|
926
|
+
const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&limit=${limit}`, t);
|
|
802
927
|
if (status !== 200) {
|
|
803
928
|
// Always show the server's reason (it names the unknown entity). Cases are
|
|
804
929
|
// casework (worklist), not pack-declared XRM — point at the right command.
|
|
805
930
|
if (entity === 'case' || entity === 'cases') {
|
|
806
931
|
console.error(` '${entity}' is casework (worklist), not a pack-declared XRM entity — inspect tickets with: octwin cases`);
|
|
807
932
|
}
|
|
808
|
-
die(`could not read records (HTTP ${status})${errDetail(json)}${
|
|
933
|
+
die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
809
934
|
}
|
|
810
935
|
const rows = (json?.records ?? []);
|
|
811
936
|
console.log(`${entity}: ${json?.total ?? rows.length} record(s)`);
|
|
@@ -815,32 +940,29 @@ async function cmdRecords(flags) {
|
|
|
815
940
|
console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'}${r.stage ? ` [${r.stage}]` : ''} ${r.id}`);
|
|
816
941
|
return;
|
|
817
942
|
}
|
|
818
|
-
const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`,
|
|
819
|
-
if (status === 403)
|
|
820
|
-
die('forbidden — mint a token with the `records:read` scope');
|
|
943
|
+
const { status, json } = await apiGet(`${base}/xrm/records/${encodeURIComponent(recordId)}`, t);
|
|
821
944
|
if (status === 404)
|
|
822
945
|
die(`record '${recordId}' not found`);
|
|
823
|
-
if (status === 401)
|
|
824
|
-
die(`could not read record — ${authFailureHint(401, url)}`);
|
|
825
946
|
if (status !== 200)
|
|
826
|
-
die(`could not read record (HTTP ${status})`);
|
|
947
|
+
die(`could not read record (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
827
948
|
console.log(JSON.stringify(json?.record ?? json, null, 2));
|
|
828
949
|
}
|
|
829
950
|
/** `octwin logs [conversationId] [--as <handle>] [--json]` — list conversations
|
|
830
951
|
* or show one's event timeline (full text + the renders each turn produced). */
|
|
831
952
|
async function cmdLogs(flags) {
|
|
832
953
|
const packDir = resolve(flags.dir ?? '.');
|
|
833
|
-
const
|
|
834
|
-
const
|
|
954
|
+
const t = resolveTarget(flags, packDir);
|
|
955
|
+
const { url } = t;
|
|
956
|
+
const base = `${url}/api/self/p`;
|
|
835
957
|
const convId = flags._[0];
|
|
836
958
|
const asJson = flags.json === true;
|
|
837
959
|
const asHandle = typeof flags.as === 'string' ? flags.as : undefined;
|
|
838
960
|
if (!asJson)
|
|
839
|
-
console.log(`→ Reading ${convId ? `conversation ${convId}` : 'recent conversations'} from ${
|
|
961
|
+
console.log(`→ Reading ${convId ? `conversation ${convId}` : 'recent conversations'} from ${targetLabel(t)} …`);
|
|
840
962
|
if (!convId) {
|
|
841
|
-
const { status, json } = await apiGet(`${base}/conversations?limit=50`,
|
|
963
|
+
const { status, json } = await apiGet(`${base}/conversations?limit=50`, t);
|
|
842
964
|
if (status !== 200)
|
|
843
|
-
die(`could not read conversations (HTTP ${status})${errDetail(json)}
|
|
965
|
+
die(`could not read conversations (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
844
966
|
let convs = (json?.conversations ?? []);
|
|
845
967
|
if (asHandle)
|
|
846
968
|
convs = convs.filter((c) => c.contact?.channel_contact_handle === asHandle);
|
|
@@ -852,7 +974,7 @@ async function cmdLogs(flags) {
|
|
|
852
974
|
console.log(JSON.stringify(convs, null, 2));
|
|
853
975
|
return;
|
|
854
976
|
}
|
|
855
|
-
console.log(`Recent conversations in ${
|
|
977
|
+
console.log(`Recent conversations in ${targetLabel(t)}${asHandle ? ` (handle: ${asHandle})` : ''}:`);
|
|
856
978
|
for (const c of convs) {
|
|
857
979
|
const handle = c.contact?.channel_contact_handle ?? '?';
|
|
858
980
|
const name = c.contact?.display_name && c.contact.display_name !== handle ? ` (${c.contact.display_name})` : '';
|
|
@@ -862,11 +984,11 @@ async function cmdLogs(flags) {
|
|
|
862
984
|
console.log('\nView a timeline: octwin logs <conversationId> (add --json for full payloads)');
|
|
863
985
|
return;
|
|
864
986
|
}
|
|
865
|
-
const { status, json } = await apiGet(`${base}/conversations/${encodeURIComponent(convId)}`,
|
|
987
|
+
const { status, json } = await apiGet(`${base}/conversations/${encodeURIComponent(convId)}`, t);
|
|
866
988
|
if (status === 404)
|
|
867
989
|
die(`conversation '${convId}' not found`);
|
|
868
990
|
if (status !== 200)
|
|
869
|
-
die(`could not read conversation (HTTP ${status})${errDetail(json)}
|
|
991
|
+
die(`could not read conversation (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
870
992
|
const events = (json?.events ?? []);
|
|
871
993
|
if (asJson) {
|
|
872
994
|
console.log(JSON.stringify(events, null, 2));
|
|
@@ -1017,12 +1139,33 @@ async function cmdChat(flags) {
|
|
|
1017
1139
|
const packDir = resolve(flags.dir ?? '.');
|
|
1018
1140
|
const cfg = readPackConfig(packDir);
|
|
1019
1141
|
const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? cfg.platform_url ?? '').replace(/\/$/, '');
|
|
1020
|
-
const tenant = flags.tenant ?? process.env.PACK_TENANT ?? cfg.tenant ?? '';
|
|
1021
|
-
const project = flags.project ?? process.env.PACK_PROJECT ?? cfg.project ?? 'main';
|
|
1022
1142
|
if (!url)
|
|
1023
1143
|
die('no platform url — set it in pack.json, --url, or PACK_PLATFORM_URL');
|
|
1144
|
+
let tenant = flags.tenant || process.env.PACK_TENANT || cfg.tenant || '';
|
|
1145
|
+
let project = flags.project || process.env.PACK_PROJECT || cfg.project || '';
|
|
1146
|
+
// The dev web channel is tenant/project-pathed (it simulates an end-user on a
|
|
1147
|
+
// specific project). When they aren't configured, derive them from the token —
|
|
1148
|
+
// its tenant + optional project pin — via the slug-free `/api/self/t/whoami`.
|
|
1149
|
+
if (!tenant || !project) {
|
|
1150
|
+
const token = flags.token ?? process.env.PACK_TOKEN ?? readCreds()[url] ?? '';
|
|
1151
|
+
if (token) {
|
|
1152
|
+
try {
|
|
1153
|
+
const who = await fetch(`${url}/api/self/t/whoami`, {
|
|
1154
|
+
headers: { authorization: `Bearer ${token}`, ...(tenant ? { 'x-octwin-tenant': tenant } : {}) },
|
|
1155
|
+
});
|
|
1156
|
+
if (who.ok) {
|
|
1157
|
+
const j = await who.json();
|
|
1158
|
+
tenant = tenant || (j.tenant_slug ?? '');
|
|
1159
|
+
project = project || (j.project_slug ?? '');
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
catch { /* fall through to the checks below */ }
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1024
1165
|
if (!tenant)
|
|
1025
|
-
die('no tenant — set
|
|
1166
|
+
die('no tenant — set --tenant / PACK_TENANT / pack.json, or pass a --token to derive it');
|
|
1167
|
+
if (!project)
|
|
1168
|
+
project = 'main';
|
|
1026
1169
|
const from = flags.as ?? 'cli-tester';
|
|
1027
1170
|
const asJson = flags.json === true;
|
|
1028
1171
|
const tapId = typeof flags.tap === 'string' ? flags.tap : undefined;
|
|
@@ -1136,7 +1279,8 @@ async function cmdMedia(flags) {
|
|
|
1136
1279
|
if (sub !== 'generate')
|
|
1137
1280
|
die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
|
|
1138
1281
|
const packDir = resolve(flags.dir ?? '.');
|
|
1139
|
-
const
|
|
1282
|
+
const t = resolveTarget(flags, packDir);
|
|
1283
|
+
const { url } = t;
|
|
1140
1284
|
const prompt = flags._[1];
|
|
1141
1285
|
if (!prompt)
|
|
1142
1286
|
die('usage: octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json]');
|
|
@@ -1144,10 +1288,10 @@ async function cmdMedia(flags) {
|
|
|
1144
1288
|
const size = typeof flags.size === 'string' ? flags.size : undefined;
|
|
1145
1289
|
const out = typeof flags.out === 'string' ? flags.out : undefined;
|
|
1146
1290
|
if (!asJson)
|
|
1147
|
-
console.log(`→ Generating an image on
|
|
1148
|
-
const res = await fetchOrDie(`${url}/api/
|
|
1291
|
+
console.log(`→ Generating an image on ${targetLabel(t)} @ ${url} …`);
|
|
1292
|
+
const res = await fetchOrDie(`${url}/api/self/p/media/generate`, {
|
|
1149
1293
|
method: 'POST',
|
|
1150
|
-
headers: { 'content-type': 'application/json',
|
|
1294
|
+
headers: { 'content-type': 'application/json', ...authHeaders(t) },
|
|
1151
1295
|
body: JSON.stringify({ prompt, ...(size ? { size } : {}) }),
|
|
1152
1296
|
}, 'media generate');
|
|
1153
1297
|
const text = await res.text();
|
|
@@ -1191,19 +1335,22 @@ async function cmdMedia(flags) {
|
|
|
1191
1335
|
* the aggregate inbox, one case + its timeline, or the queue list. */
|
|
1192
1336
|
async function cmdCases(flags) {
|
|
1193
1337
|
const packDir = resolve(flags.dir ?? '.');
|
|
1194
|
-
const
|
|
1195
|
-
const
|
|
1338
|
+
const t = resolveTarget(flags, packDir);
|
|
1339
|
+
const { url } = t;
|
|
1340
|
+
const base = `${url}/api/self/p`;
|
|
1196
1341
|
const caseId = flags._[0];
|
|
1197
1342
|
const asJson = flags.json === true;
|
|
1198
1343
|
if (!asJson)
|
|
1199
|
-
console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${
|
|
1344
|
+
console.log(`→ Reading ${flags.queues === true ? 'case queues' : caseId ? `case ${caseId}` : 'the case inbox'} from ${targetLabel(t)} …`);
|
|
1200
1345
|
const caseFail = (what, status, json) => {
|
|
1346
|
+
// A 403 here can also be an RBAC gap the scope hint can't see — a role whose
|
|
1347
|
+
// grants don't reach the queue passes the scope gate and still gets nothing.
|
|
1201
1348
|
if (status === 403)
|
|
1202
|
-
|
|
1203
|
-
die(`could not read ${what} (HTTP ${status})${errDetail(json)}${
|
|
1349
|
+
console.error(' → (a role whose grants reach the queue is also required)');
|
|
1350
|
+
die(`could not read ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1204
1351
|
};
|
|
1205
1352
|
if (flags.queues === true) {
|
|
1206
|
-
const { status, json } = await apiGet(`${base}/case-queues`,
|
|
1353
|
+
const { status, json } = await apiGet(`${base}/case-queues`, t);
|
|
1207
1354
|
if (status !== 200)
|
|
1208
1355
|
caseFail('case queues', status, json);
|
|
1209
1356
|
if (asJson) {
|
|
@@ -1211,7 +1358,7 @@ async function cmdCases(flags) {
|
|
|
1211
1358
|
return;
|
|
1212
1359
|
}
|
|
1213
1360
|
const queues = (json?.queues ?? []);
|
|
1214
|
-
console.log(`Case queues in ${
|
|
1361
|
+
console.log(`Case queues in ${targetLabel(t)}:`);
|
|
1215
1362
|
for (const q of queues)
|
|
1216
1363
|
console.log(` ${q.key}${q.name ? ` (${q.name})` : ''} ${q.open_count} open`);
|
|
1217
1364
|
if (json?.unrouted_open_count)
|
|
@@ -1220,7 +1367,7 @@ async function cmdCases(flags) {
|
|
|
1220
1367
|
}
|
|
1221
1368
|
if (!caseId) {
|
|
1222
1369
|
const limit = flags.limit ?? '50';
|
|
1223
|
-
const { status, json } = await apiGet(`${base}/cases?limit=${limit}`,
|
|
1370
|
+
const { status, json } = await apiGet(`${base}/cases?limit=${limit}`, t);
|
|
1224
1371
|
if (status !== 200)
|
|
1225
1372
|
caseFail('cases', status, json);
|
|
1226
1373
|
if (asJson) {
|
|
@@ -1228,7 +1375,7 @@ async function cmdCases(flags) {
|
|
|
1228
1375
|
return;
|
|
1229
1376
|
}
|
|
1230
1377
|
const rows = (json?.cases ?? []);
|
|
1231
|
-
console.log(`Cases in ${
|
|
1378
|
+
console.log(`Cases in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
|
|
1232
1379
|
if (rows.length === 0)
|
|
1233
1380
|
console.log(' (none)');
|
|
1234
1381
|
for (const c of rows) {
|
|
@@ -1238,7 +1385,7 @@ async function cmdCases(flags) {
|
|
|
1238
1385
|
console.log('\nOne case + timeline: octwin cases <caseId> queues: octwin cases --queues');
|
|
1239
1386
|
return;
|
|
1240
1387
|
}
|
|
1241
|
-
const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`,
|
|
1388
|
+
const { status, json } = await apiGet(`${base}/cases/${encodeURIComponent(caseId)}`, t);
|
|
1242
1389
|
if (status === 404)
|
|
1243
1390
|
die(`case '${caseId}' not found`);
|
|
1244
1391
|
if (status !== 200)
|
|
@@ -1269,6 +1416,483 @@ async function cmdCases(flags) {
|
|
|
1269
1416
|
console.log(` Decisions: ${dispositions.map((d) => `${d.action}${d.next_status ? `→${d.next_status}` : ''}`).join(', ')}`);
|
|
1270
1417
|
}
|
|
1271
1418
|
}
|
|
1419
|
+
// ── money formatting (orders / catalog) ─────────────────────────────────────
|
|
1420
|
+
/** Format a MAJOR-unit decimal amount as currency — the catalog's `price` shape.
|
|
1421
|
+
* Mirrors the console's `fmtAmount` (console/src/lib/money.ts) so an amount reads
|
|
1422
|
+
* the same in the terminal as on the page. */
|
|
1423
|
+
function fmtAmount(value, currency) {
|
|
1424
|
+
if (value == null)
|
|
1425
|
+
return '—';
|
|
1426
|
+
if (!currency)
|
|
1427
|
+
return value.toFixed(2);
|
|
1428
|
+
try {
|
|
1429
|
+
return new Intl.NumberFormat('en', { style: 'currency', currency }).format(value);
|
|
1430
|
+
}
|
|
1431
|
+
catch {
|
|
1432
|
+
return `${value.toFixed(2)} ${currency}`;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
/** Format MINOR units (the offset-100 `*_minor` ints every order field is projected
|
|
1436
|
+
* to on the wire — see `toOrderView`) as currency. `fmtAmount(minor / 100, …)`. */
|
|
1437
|
+
function fmtMinor(minor, currency) {
|
|
1438
|
+
if (minor == null)
|
|
1439
|
+
return '—';
|
|
1440
|
+
return fmtAmount(minor / 100, currency);
|
|
1441
|
+
}
|
|
1442
|
+
/** Human wording for a governed `source` — the layer that won the cascade
|
|
1443
|
+
* (per-project override > platform default > pack manifest default). The four
|
|
1444
|
+
* values mirror the platform's `GovernedSource` union exactly. */
|
|
1445
|
+
const GOVERNED_SOURCE_LABEL = {
|
|
1446
|
+
project: 'this project overrides it',
|
|
1447
|
+
platform: 'a PLATFORM default overrides the pack',
|
|
1448
|
+
pack: 'the pack manifest value is in force',
|
|
1449
|
+
unset: 'no value at any layer',
|
|
1450
|
+
};
|
|
1451
|
+
function showValue(v) {
|
|
1452
|
+
if (v === undefined || v === null)
|
|
1453
|
+
return '(unset)';
|
|
1454
|
+
return typeof v === 'string' ? v : JSON.stringify(v);
|
|
1455
|
+
}
|
|
1456
|
+
/** Print one governed setting as `key: <effective> [why]`, and — when the pack's
|
|
1457
|
+
* own declared value is NOT what runs — an explicit second line naming what the
|
|
1458
|
+
* pack asked for. That override is invisible from the pack source, which is the
|
|
1459
|
+
* whole reason this command exists. */
|
|
1460
|
+
function printGoverned(label, g) {
|
|
1461
|
+
if (!g)
|
|
1462
|
+
return;
|
|
1463
|
+
const source = typeof g.source === 'string' ? g.source : '';
|
|
1464
|
+
console.log(` ${label.padEnd(22)} ${showValue(g.effective)} [${GOVERNED_SOURCE_LABEL[source] ?? source ?? '?'}]`);
|
|
1465
|
+
// Warn only when a higher layer displaced the pack's declared value with a
|
|
1466
|
+
// DIFFERENT one. A platform default that happens to equal the pack's value has
|
|
1467
|
+
// changed nothing an author needs to know about.
|
|
1468
|
+
const displaced = (source === 'project' || source === 'platform')
|
|
1469
|
+
&& g.pack_default !== undefined
|
|
1470
|
+
&& JSON.stringify(g.pack_default) !== JSON.stringify(g.effective);
|
|
1471
|
+
if (displaced)
|
|
1472
|
+
console.log(` ⚠ your pack declares ${showValue(g.pack_default)} — it is NOT in force`);
|
|
1473
|
+
if (g.opted_out)
|
|
1474
|
+
console.log(' (this workspace is opted OUT of the platform default for this key)');
|
|
1475
|
+
}
|
|
1476
|
+
/** `octwin agents [agentRef] [--prompt] [--json]` — the agent roster with the
|
|
1477
|
+
* EFFECTIVE model/memory settings and which layer won, plus (`--prompt`) the exact
|
|
1478
|
+
* system prompt the LLM sees for this project. Needs an `agents:read` token. */
|
|
1479
|
+
async function cmdAgents(flags) {
|
|
1480
|
+
const packDir = resolve(flags.dir ?? '.');
|
|
1481
|
+
const t = resolveTarget(flags, packDir);
|
|
1482
|
+
const { url } = t;
|
|
1483
|
+
const base = `${url}/api/self/p/agents`;
|
|
1484
|
+
const ref = flags._[0];
|
|
1485
|
+
const asJson = flags.json === true;
|
|
1486
|
+
const wantPrompt = flags.prompt === true;
|
|
1487
|
+
if (wantPrompt && !ref)
|
|
1488
|
+
die('usage: octwin agents <packId::agentId> --prompt (name the agent — `octwin agents` lists them)');
|
|
1489
|
+
if (!asJson)
|
|
1490
|
+
console.log(`→ Reading ${ref ? `agent ${ref}` : 'the agent roster'} from ${targetLabel(t)} …`);
|
|
1491
|
+
// --prompt — the resolved system prompt (pack instructions + platform protocol +
|
|
1492
|
+
// the per-project overlay). No LLM call; pure resolution server-side.
|
|
1493
|
+
if (wantPrompt) {
|
|
1494
|
+
const { status, json } = await apiGet(`${base}/${encodeURIComponent(ref)}/preview-prompt`, t);
|
|
1495
|
+
if (status === 404)
|
|
1496
|
+
die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
|
|
1497
|
+
// 503 = the pack is installed but not warm on this instance yet (same trap
|
|
1498
|
+
// `octwin status` explains): the agent loads on the next inbound.
|
|
1499
|
+
if (status === 503)
|
|
1500
|
+
die(`'${ref}' is not registered with Mastra on the instance you hit yet — it loads on the next inbound (chat once, then retry)`);
|
|
1501
|
+
if (status !== 200)
|
|
1502
|
+
die(`could not resolve the prompt (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1503
|
+
if (asJson) {
|
|
1504
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
console.log(`Resolved system prompt for ${json?.agent?.pack_id}::${json?.agent?.pack_agent_id} (${json?.bytes ?? '?'} bytes`
|
|
1508
|
+
+ `${json?.has_overlay ? ', includes this project\'s overlay' : ', no project overlay'}):\n`);
|
|
1509
|
+
console.log(json?.resolved_prompt ?? '(empty)');
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
if (!ref) {
|
|
1513
|
+
const { status, json } = await apiGet(base, t);
|
|
1514
|
+
if (status !== 200)
|
|
1515
|
+
die(`could not read agents (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1516
|
+
if (asJson) {
|
|
1517
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const agents = (json?.agents ?? []);
|
|
1521
|
+
if (agents.length === 0) {
|
|
1522
|
+
console.log('No agents — is a pack installed on this project? (`octwin status`)');
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
console.log(`Agents in ${targetLabel(t)}:`);
|
|
1526
|
+
for (const a of agents) {
|
|
1527
|
+
console.log(` ${a.pack_id}::${a.pack_agent_id} "${a.display_name}"${a.enabled === false ? ' [DISABLED]' : ''}`);
|
|
1528
|
+
printGoverned('model', a.governed?.model);
|
|
1529
|
+
if (a.last_error_at)
|
|
1530
|
+
console.log(` last error: ${a.last_error_at} — ${a.last_error_reason ?? '(no reason)'}`);
|
|
1531
|
+
}
|
|
1532
|
+
console.log('\nOne agent + its full settings: octwin agents <packId::agentId>');
|
|
1533
|
+
console.log('The prompt the LLM sees: octwin agents <packId::agentId> --prompt');
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
const { status, json } = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
|
|
1537
|
+
if (status === 404)
|
|
1538
|
+
die(`agent '${ref}' not found — run \`octwin agents\` for the roster`);
|
|
1539
|
+
if (status !== 200)
|
|
1540
|
+
die(`could not read agent (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1541
|
+
if (asJson) {
|
|
1542
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
console.log(`${json.pack_id}::${json.pack_agent_id} "${json.display_name}"${json.enabled === false ? ' [DISABLED]' : ''}`);
|
|
1546
|
+
console.log(' Governed settings (project override > platform default > pack manifest):');
|
|
1547
|
+
printGoverned('model', json.governed?.model);
|
|
1548
|
+
printGoverned('memory.last_messages', json.governed?.last_messages);
|
|
1549
|
+
printGoverned('working_memory', json.governed?.working_memory_enabled);
|
|
1550
|
+
console.log(` tools: ${(json.available_tools ?? []).join(', ') || '(none)'}`);
|
|
1551
|
+
console.log(` instructions overlay: ${json.instructions_overlay ? `${String(json.instructions_overlay).length} chars (project-specific)` : '(none)'}`);
|
|
1552
|
+
if (json.last_invoked_at)
|
|
1553
|
+
console.log(` last invoked: ${json.last_invoked_at}`);
|
|
1554
|
+
if (json.last_error_at)
|
|
1555
|
+
console.log(` last error: ${json.last_error_at} — ${json.last_error_reason ?? '(no reason)'}`);
|
|
1556
|
+
console.log(`\nThe prompt the LLM actually sees: octwin agents ${ref} --prompt`);
|
|
1557
|
+
}
|
|
1558
|
+
// ── orders: the commerce lifecycle a conversation produces ───────────────────
|
|
1559
|
+
/** Why a `pending` / `none` payment is usually CORRECT, not a bug. The forward
|
|
1560
|
+
* payment lifecycle is webhook-owned (`payment_status` is deliberately not
|
|
1561
|
+
* patchable — only `refund` is an operator move), and the default driver is the
|
|
1562
|
+
* gateway-less `ManualPaymentAdapter`, on which `payment_request` takes its `empty`
|
|
1563
|
+
* port and mutates nothing. Settling a payment headlessly has no path today — see
|
|
1564
|
+
* docs/BACKLOG.md → "Headless payment settle" (a `--settle` flag lands here once
|
|
1565
|
+
* the platform grows a manual-driver-only reconcile endpoint). */
|
|
1566
|
+
function printPaymentNote(paymentStatus) {
|
|
1567
|
+
if (paymentStatus !== 'pending' && paymentStatus !== 'none')
|
|
1568
|
+
return;
|
|
1569
|
+
console.log(` → payment_status '${paymentStatus}' is expected without a live gateway: the forward payment`);
|
|
1570
|
+
console.log(' lifecycle is WEBHOOK-owned (not patchable), and the default `manual` driver has no hosted');
|
|
1571
|
+
console.log(' checkout — so `payment_request` takes its `empty` port and the flow should offer');
|
|
1572
|
+
console.log(' pay-on-delivery. Refund is the one operator-driven move.');
|
|
1573
|
+
}
|
|
1574
|
+
/** `octwin orders [referenceId] [--status s] [--payment p] [--limit n] [--json]` —
|
|
1575
|
+
* the orders a conversation created: money breakdown, payment state, allowed
|
|
1576
|
+
* transitions. Needs an `orders:read` token + the `orders` plan feature. */
|
|
1577
|
+
async function cmdOrders(flags) {
|
|
1578
|
+
const packDir = resolve(flags.dir ?? '.');
|
|
1579
|
+
const t = resolveTarget(flags, packDir);
|
|
1580
|
+
const { url } = t;
|
|
1581
|
+
const base = `${url}/api/self/p/orders`;
|
|
1582
|
+
const referenceId = flags._[0];
|
|
1583
|
+
const asJson = flags.json === true;
|
|
1584
|
+
if (!asJson)
|
|
1585
|
+
console.log(`→ Reading ${referenceId ? `order ${referenceId}` : 'orders'} from ${targetLabel(t)} …`);
|
|
1586
|
+
if (!referenceId) {
|
|
1587
|
+
const q = new URLSearchParams({ limit: flags.limit ?? '50' });
|
|
1588
|
+
if (typeof flags.status === 'string')
|
|
1589
|
+
q.set('status', flags.status);
|
|
1590
|
+
if (typeof flags.payment === 'string')
|
|
1591
|
+
q.set('payment', flags.payment);
|
|
1592
|
+
const { status, json } = await apiGet(`${base}?${q.toString()}`, t);
|
|
1593
|
+
if (status !== 200)
|
|
1594
|
+
die(`could not read orders (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1595
|
+
if (asJson) {
|
|
1596
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
const rows = (json?.orders ?? []);
|
|
1600
|
+
console.log(`Orders in ${targetLabel(t)}: ${json?.total ?? rows.length} total`);
|
|
1601
|
+
if (rows.length === 0)
|
|
1602
|
+
console.log(' (none — drive a cart to `cart_submit` with `octwin chat`, or seed demo data)');
|
|
1603
|
+
for (const o of rows) {
|
|
1604
|
+
const who = o.contact?.channel_contact_handle ?? o.contact?.display_name ?? '—';
|
|
1605
|
+
console.log(` #${o.record_number} ${o.status}/${o.payment_status} ${fmtMinor(o.total_minor, o.currency)} ${who} ${o.reference_id}`);
|
|
1606
|
+
}
|
|
1607
|
+
console.log('\nOne order + its money breakdown: octwin orders <reference_id>');
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
const { status, json } = await apiGet(`${base}/${encodeURIComponent(referenceId)}`, t);
|
|
1611
|
+
if (status === 404)
|
|
1612
|
+
die(`order '${referenceId}' not found (pass the opaque reference_id, not the #number)`);
|
|
1613
|
+
if (status !== 200)
|
|
1614
|
+
die(`could not read order (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1615
|
+
if (asJson) {
|
|
1616
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const o = json?.order ?? {};
|
|
1620
|
+
const cur = o.currency;
|
|
1621
|
+
console.log(`Order #${o.record_number} ${o.status} payment: ${o.payment_status}`);
|
|
1622
|
+
console.log(` reference: ${o.reference_id}${o.payment_ref ? ` payment_ref: ${o.payment_ref}` : ''}`);
|
|
1623
|
+
for (const it of (o.items ?? [])) {
|
|
1624
|
+
console.log(` ${it.quantity} × ${it.name ?? it.retailer_id} @ ${fmtMinor(it.amount_minor, it.currency || cur)}`);
|
|
1625
|
+
}
|
|
1626
|
+
console.log(` subtotal ${fmtMinor(o.subtotal_minor, cur)}`
|
|
1627
|
+
+ ` tax ${fmtMinor(o.tax_minor, cur)}`
|
|
1628
|
+
+ ` shipping ${fmtMinor(o.shipping_minor, cur)}`
|
|
1629
|
+
+ ` discount ${fmtMinor(o.discount_minor, cur)}`);
|
|
1630
|
+
console.log(` TOTAL ${fmtMinor(o.total_minor, cur)}`);
|
|
1631
|
+
if (o.note)
|
|
1632
|
+
console.log(` note: ${o.note}`);
|
|
1633
|
+
const transitions = (json?.transitions ?? []);
|
|
1634
|
+
console.log(` allowed transitions: ${transitions.join(', ') || '(none — terminal)'}`);
|
|
1635
|
+
printPaymentNote(String(o.payment_status ?? ''));
|
|
1636
|
+
}
|
|
1637
|
+
// ── analytics: the generic per-entity funnel (any pipelined entity) ──────────
|
|
1638
|
+
const ANALYTICS_MODES = ['funnel', 'overview', 'milestones', 'trends', 'cost'];
|
|
1639
|
+
/** Both "unknown/non-pipelined entity" and "your role lacks the grant" answer
|
|
1640
|
+
* 200 + `has_data:false` (a deliberate empty state, never a 403) — so a bare
|
|
1641
|
+
* "no data" would hide the real cause. Name both. */
|
|
1642
|
+
function printNoAnalyticsData(entity) {
|
|
1643
|
+
console.log(`No analytics for '${entity}'. Either:`);
|
|
1644
|
+
console.log(` • '${entity}' isn't declared with a \`pipeline:\` (a funnel needs stages), or`);
|
|
1645
|
+
console.log(` • your token's role has no \`view\` grant on \`record.${entity}\`.`);
|
|
1646
|
+
console.log('Run `octwin analytics` for the entities that DO carry a pipeline.');
|
|
1647
|
+
}
|
|
1648
|
+
/** `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost]
|
|
1649
|
+
* [--stage <id>] [--json]` — stage conversion over ANY pipelined XRM entity
|
|
1650
|
+
* (orders, carts, cases, bookings, or a pack's own). Needs `records:read`. */
|
|
1651
|
+
async function cmdAnalytics(flags) {
|
|
1652
|
+
const packDir = resolve(flags.dir ?? '.');
|
|
1653
|
+
const t = resolveTarget(flags, packDir);
|
|
1654
|
+
const { url } = t;
|
|
1655
|
+
const base = `${url}/api/self/p/xrm/analytics`;
|
|
1656
|
+
const entity = flags._[0];
|
|
1657
|
+
const asJson = flags.json === true;
|
|
1658
|
+
const stage = typeof flags.stage === 'string' ? flags.stage : undefined;
|
|
1659
|
+
const mode = ANALYTICS_MODES.find(m => flags[m] === true) ?? 'funnel';
|
|
1660
|
+
if (stage && !entity)
|
|
1661
|
+
die('usage: octwin analytics <entity> --stage <stageId> (a stage belongs to an entity)');
|
|
1662
|
+
if (!entity) {
|
|
1663
|
+
if (!asJson)
|
|
1664
|
+
console.log(`→ Reading the analytics entity list from ${targetLabel(t)} …`);
|
|
1665
|
+
const { status, json } = await apiGet(`${base}/entities`, t);
|
|
1666
|
+
if (status !== 200)
|
|
1667
|
+
die(`could not read analytics entities (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1668
|
+
if (asJson) {
|
|
1669
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const ents = (json?.entities ?? []);
|
|
1673
|
+
if (ents.length === 0) {
|
|
1674
|
+
console.log('No pipelined entities visible — a funnel needs an entity declared with a `pipeline:`,');
|
|
1675
|
+
console.log('and your role needs a `view` grant on it. (Journeys have their own analytics surface.)');
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
console.log(`Entities with a funnel in ${targetLabel(t)}:`);
|
|
1679
|
+
for (const e of ents) {
|
|
1680
|
+
console.log(` ${e.entity} (${e.stage_count} stage(s), ${e.milestone_count} milestone(s))`);
|
|
1681
|
+
}
|
|
1682
|
+
console.log('\nStage conversion: octwin analytics <entity> (add --overview / --milestones / --trends / --cost)');
|
|
1683
|
+
console.log('Who is at a stage: octwin analytics <entity> --stage <stageId>');
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
// Stage drill-down — records CURRENTLY at a stage. A live snapshot, deliberately
|
|
1687
|
+
// NOT range-filtered like the funnel's cumulative reached-≥ counts.
|
|
1688
|
+
if (stage) {
|
|
1689
|
+
if (!asJson)
|
|
1690
|
+
console.log(`→ Reading ${entity} records at stage '${stage}' from ${targetLabel(t)} …`);
|
|
1691
|
+
const limit = flags.limit ?? '50';
|
|
1692
|
+
const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/stages/${encodeURIComponent(stage)}/records?limit=${limit}`, t);
|
|
1693
|
+
if (status === 404)
|
|
1694
|
+
die(`unknown stage '${stage}' for '${entity}'${errDetail(json)}`);
|
|
1695
|
+
if (status !== 200)
|
|
1696
|
+
die(`could not read stage records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1697
|
+
if (asJson) {
|
|
1698
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
if (json?.has_data === false) {
|
|
1702
|
+
printNoAnalyticsData(entity);
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const rows = (json?.records ?? []);
|
|
1706
|
+
console.log(`${entity} at '${stage}' (live snapshot): ${json?.total ?? rows.length} record(s)`);
|
|
1707
|
+
for (const r of rows) {
|
|
1708
|
+
const who = r.channel_contact_handle ?? r.display_name ?? '—';
|
|
1709
|
+
console.log(` #${r.record_number ?? '?'} ${r.title ?? '(untitled)'} ${who}${r.completed ? ' [completed]' : ''} ${r.record_id}`);
|
|
1710
|
+
}
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
if (!asJson)
|
|
1714
|
+
console.log(`→ Reading ${entity} ${mode} from ${targetLabel(t)} …`);
|
|
1715
|
+
const { status, json } = await apiGet(`${base}/${encodeURIComponent(entity)}/${mode}`, t);
|
|
1716
|
+
if (status !== 200)
|
|
1717
|
+
die(`could not read ${mode} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1718
|
+
if (asJson) {
|
|
1719
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (json?.has_data === false) {
|
|
1723
|
+
printNoAnalyticsData(entity);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const range = json?.range ? ` (${json.range.from} → ${json.range.to})` : '';
|
|
1727
|
+
console.log(`${entity} — ${mode}${range}:`);
|
|
1728
|
+
switch (mode) {
|
|
1729
|
+
case 'funnel':
|
|
1730
|
+
for (const s of (json?.funnel ?? [])) {
|
|
1731
|
+
const conv = s.conversion_from_prev_pct == null ? '' : ` ${s.conversion_from_prev_pct}% of prev`;
|
|
1732
|
+
const lost = s.drop_off_from_prev ? ` (−${s.drop_off_from_prev})` : '';
|
|
1733
|
+
console.log(` ${String(s.rank).padStart(2)}. ${String(s.stage).padEnd(24)} ${String(s.reached).padStart(6)}${conv}${lost}`);
|
|
1734
|
+
}
|
|
1735
|
+
break;
|
|
1736
|
+
case 'overview': {
|
|
1737
|
+
const s = json?.summary ?? {};
|
|
1738
|
+
console.log(` entered ${s.entered} → converted ${s.converted}${s.conversion_pct == null ? '' : ` (${s.conversion_pct}%)`}`);
|
|
1739
|
+
if (s.biggest_dropoff)
|
|
1740
|
+
console.log(` biggest drop-off: ${s.biggest_dropoff.from} → ${s.biggest_dropoff.to} (lost ${s.biggest_dropoff.lost})`);
|
|
1741
|
+
if (s.top_milestone)
|
|
1742
|
+
console.log(` top milestone: ${s.top_milestone.milestone} (${s.top_milestone.completions})`);
|
|
1743
|
+
break;
|
|
1744
|
+
}
|
|
1745
|
+
case 'milestones':
|
|
1746
|
+
for (const m of (json?.milestones ?? [])) {
|
|
1747
|
+
console.log(` ${String(m.milestone).padEnd(28)} ${String(m.completions).padStart(6)} completion(s), ${m.unique_contacts} contact(s)`);
|
|
1748
|
+
}
|
|
1749
|
+
break;
|
|
1750
|
+
case 'trends':
|
|
1751
|
+
for (const b of (json?.buckets ?? [])) {
|
|
1752
|
+
console.log(` ${b.bucket} active ${b.active_contacts} milestones ${b.milestone_completions}`);
|
|
1753
|
+
}
|
|
1754
|
+
for (const c of (json?.cohorts ?? [])) {
|
|
1755
|
+
console.log(` cohort ${c.bucket} entered ${c.entered} converted ${c.converted}`);
|
|
1756
|
+
}
|
|
1757
|
+
break;
|
|
1758
|
+
case 'cost':
|
|
1759
|
+
if (json?.cost_partial)
|
|
1760
|
+
console.log(' ⚠ partial — some token-usage rows carry no cost');
|
|
1761
|
+
for (const r of (json?.by_milestone ?? [])) {
|
|
1762
|
+
console.log(` ${String(r.id).padEnd(28)} ${r.conversations} conv, ${r.total_tokens} tokens, $${r.cost_usd}`);
|
|
1763
|
+
}
|
|
1764
|
+
for (const d of (json?.drivers ?? [])) {
|
|
1765
|
+
console.log(` driver flow=${d.source_flow_id ?? '—'} agent=${d.source_agent_id ?? '—'} conversions ${d.conversions}`);
|
|
1766
|
+
}
|
|
1767
|
+
break;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
// ── catalog: the commerce products + their WhatsApp binding ──────────────────
|
|
1771
|
+
/** `octwin catalog [--readiness] [--json]` — the `product` records a commerce pack
|
|
1772
|
+
* sells, their stock, and the WhatsApp catalog binding. Needs `catalog:read` + the
|
|
1773
|
+
* `catalog` plan feature. */
|
|
1774
|
+
async function cmdCatalog(flags) {
|
|
1775
|
+
const packDir = resolve(flags.dir ?? '.');
|
|
1776
|
+
const t = resolveTarget(flags, packDir);
|
|
1777
|
+
const { url } = t;
|
|
1778
|
+
const base = `${url}/api/self/p/catalog`;
|
|
1779
|
+
const asJson = flags.json === true;
|
|
1780
|
+
// --readiness makes LIVE Meta Graph calls (and 409s with no bound access token),
|
|
1781
|
+
// so it's opt-in rather than part of the default read.
|
|
1782
|
+
if (flags.readiness === true) {
|
|
1783
|
+
if (!asJson)
|
|
1784
|
+
console.log(`→ Checking WhatsApp commerce readiness for ${targetLabel(t)} (live Meta Graph calls) …`);
|
|
1785
|
+
const { status, json } = await apiGet(`${base}/readiness`, t);
|
|
1786
|
+
if (status === 409)
|
|
1787
|
+
die(`no Meta access token on this project's WhatsApp channel — readiness needs one${errDetail(json)}`);
|
|
1788
|
+
if (status !== 200)
|
|
1789
|
+
die(`could not read readiness (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1790
|
+
if (asJson) {
|
|
1791
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
const r = json?.readiness ?? {};
|
|
1795
|
+
console.log(`WhatsApp commerce readiness: ${r.summary ?? '?'}`);
|
|
1796
|
+
for (const c of (r.checks ?? [])) {
|
|
1797
|
+
const mark = c.status === 'ok' ? '✓' : c.status === 'warn' ? '⚠' : c.status === 'skip' ? '·' : '✗';
|
|
1798
|
+
console.log(` ${mark} ${c.label}${c.detail ? ` — ${c.detail}` : ''}`);
|
|
1799
|
+
if (c.solution)
|
|
1800
|
+
console.log(` → ${c.solution}`);
|
|
1801
|
+
}
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
if (!asJson)
|
|
1805
|
+
console.log(`→ Reading the product catalog from ${targetLabel(t)} …`);
|
|
1806
|
+
const { status, json } = await apiGet(base, t);
|
|
1807
|
+
if (status !== 200)
|
|
1808
|
+
die(`could not read the catalog (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1809
|
+
if (asJson) {
|
|
1810
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
const products = (json?.products ?? []);
|
|
1814
|
+
console.log(`Products in ${targetLabel(t)}: ${products.length}`);
|
|
1815
|
+
if (products.length === 0)
|
|
1816
|
+
console.log(' (none — a commerce pack seeds `product` records, or add them in the console Catalog)');
|
|
1817
|
+
for (const p of products) {
|
|
1818
|
+
// `available: null` = the SKU isn't inventory-tracked (always sellable).
|
|
1819
|
+
const stock = p.available == null ? 'untracked' : `${p.available}`;
|
|
1820
|
+
console.log(` ${String(p.retailer_id).padEnd(20)} ${String(p.name ?? '').padEnd(28)} ${fmtAmount(p.price, p.currency)}`
|
|
1821
|
+
+ ` avail=${p.availability} stock=${stock} sync=${p.sync_status ?? '—'}`);
|
|
1822
|
+
}
|
|
1823
|
+
// A binding row can exist with no catalog_id yet (a WABA is configured but no Meta
|
|
1824
|
+
// catalog picked) — that is "not bound" for selling purposes, so say so.
|
|
1825
|
+
const b = json?.binding;
|
|
1826
|
+
console.log(b?.catalog_id
|
|
1827
|
+
? `\nWhatsApp catalog binding: catalog ${b.catalog_id} (waba ${b.waba_id ?? '—'}), sync=${b.sync_status ?? '—'}, last=${b.last_sync_at ?? 'never'}`
|
|
1828
|
+
: `\nWhatsApp catalog binding: no Meta catalog bound${b?.waba_id ? ` (waba ${b.waba_id} is configured — pick a catalog)` : ''}`
|
|
1829
|
+
+ ' — the catalog works web-only (`--readiness` explains what Meta needs).');
|
|
1830
|
+
}
|
|
1831
|
+
// ── scheduling: the availability engine + a slot preview ─────────────────────
|
|
1832
|
+
/** `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]`
|
|
1833
|
+
* — the scheduling engine's state, or the computed slots for one bookable resource
|
|
1834
|
+
* (the verification the `--seed` availability fan-out was missing). `scheduling:read`. */
|
|
1835
|
+
async function cmdScheduling(flags) {
|
|
1836
|
+
const packDir = resolve(flags.dir ?? '.');
|
|
1837
|
+
const t = resolveTarget(flags, packDir);
|
|
1838
|
+
const { url } = t;
|
|
1839
|
+
const base = `${url}/api/self/p/scheduling`;
|
|
1840
|
+
const asJson = flags.json === true;
|
|
1841
|
+
const resourceId = typeof flags.slots === 'string' ? flags.slots : undefined;
|
|
1842
|
+
if (flags.slots === true)
|
|
1843
|
+
die('usage: octwin scheduling --slots <resourceRecordId> (the record id of a bookable resource)');
|
|
1844
|
+
if (resourceId) {
|
|
1845
|
+
const q = new URLSearchParams({ include_booked: '1' }); // full occupancy, as the operator preview does
|
|
1846
|
+
if (typeof flags.from === 'string')
|
|
1847
|
+
q.set('from', flags.from);
|
|
1848
|
+
if (typeof flags.days === 'string')
|
|
1849
|
+
q.set('days', flags.days); // server-clamped to 1–31
|
|
1850
|
+
if (!asJson)
|
|
1851
|
+
console.log(`→ Computing slots for resource ${resourceId} in ${targetLabel(t)} …`);
|
|
1852
|
+
const { status, json } = await apiGet(`${base}/resources/${encodeURIComponent(resourceId)}/slots?${q.toString()}`, t);
|
|
1853
|
+
if (status === 404)
|
|
1854
|
+
die(`resource '${resourceId}' not found (pass an XRM record id — \`octwin records <entity>\` lists them)`);
|
|
1855
|
+
if (status === 400)
|
|
1856
|
+
die(`that record isn't a bookable resource${errDetail(json)}`);
|
|
1857
|
+
if (status !== 200)
|
|
1858
|
+
die(`could not compute slots (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1859
|
+
if (asJson) {
|
|
1860
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
if (json?.has_scheduling === false) {
|
|
1864
|
+
console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const slots = (json?.slots ?? []);
|
|
1868
|
+
console.log(`Slots for ${resourceId} (timezone ${json?.timezone ?? '?'}): ${slots.length}`);
|
|
1869
|
+
if (slots.length === 0)
|
|
1870
|
+
console.log(' (none — no availability rules cover this window; `octwin deploy --seed` seeds the demo rules)');
|
|
1871
|
+
for (const s of slots) {
|
|
1872
|
+
console.log(` ${s.slot_start} → ${s.slot_end} ${s.remaining}/${s.capacity} free`);
|
|
1873
|
+
}
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
if (!asJson)
|
|
1877
|
+
console.log(`→ Reading the scheduling engine state from ${targetLabel(t)} …`);
|
|
1878
|
+
const { status, json } = await apiGet(base, t);
|
|
1879
|
+
if (status !== 200)
|
|
1880
|
+
die(`could not read scheduling (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1881
|
+
if (asJson) {
|
|
1882
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
if (json?.has_scheduling === false) {
|
|
1886
|
+
console.log('This pack declares no `scheduling.yaml` — nothing to schedule.');
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
console.log(`Scheduling in ${targetLabel(t)} — bookings land as '${json?.booking_entity}'`);
|
|
1890
|
+
for (const rt of (json?.resource_types ?? [])) {
|
|
1891
|
+
console.log(` ${rt.entity} ${rt.resources} resource(s), ${rt.window_days}-day booking window`);
|
|
1892
|
+
}
|
|
1893
|
+
console.log(` upcoming slots: ${json?.upcoming_slots ?? 0} booked seats: ${json?.booked_seats ?? 0}`);
|
|
1894
|
+
console.log('\nSlots for one resource: octwin scheduling --slots <resourceRecordId> (ids: octwin records <entity>)');
|
|
1895
|
+
}
|
|
1272
1896
|
function help() {
|
|
1273
1897
|
console.log(`octwin ${VERSION} — Octwin external-pack developer CLI (by CEQUENS)
|
|
1274
1898
|
|
|
@@ -1284,6 +1908,11 @@ function help() {
|
|
|
1284
1908
|
octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
|
|
1285
1909
|
octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
|
|
1286
1910
|
octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
|
|
1911
|
+
octwin agents [packId::agentId] [--prompt] [--json] # effective model/memory + WHICH layer won; --prompt = the resolved system prompt
|
|
1912
|
+
octwin orders [reference_id] [--status s] [--payment p] [--json] # the orders a conversation produced + money + payment state
|
|
1913
|
+
octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
|
|
1914
|
+
octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
|
|
1915
|
+
octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
|
|
1287
1916
|
octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
1288
1917
|
octwin test [--dir .] # = validate --remote (the full platform check)
|
|
1289
1918
|
|
|
@@ -1304,7 +1933,8 @@ const COMMAND_HELP = {
|
|
|
1304
1933
|
Offline structural check; --remote additionally runs the platform's FULL
|
|
1305
1934
|
manifest + flow-DSL validation (all errors at once) — same check as deploy.`,
|
|
1306
1935
|
login: `octwin login --url <platformUrl> --token oct_…
|
|
1307
|
-
Save a deploy token (console → Settings → API tokens) for that platform url
|
|
1936
|
+
Save a deploy token (console → Settings → API tokens) for that platform url,
|
|
1937
|
+
and echo the workspace + project pin + scopes the token reaches.`,
|
|
1308
1938
|
whoami: `octwin whoami [--url <url>] [--tenant <slug>]
|
|
1309
1939
|
Verify the resolved token authenticates against the tenant.`,
|
|
1310
1940
|
deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
@@ -1335,6 +1965,35 @@ const COMMAND_HELP = {
|
|
|
1335
1965
|
public asset, and print its MEDIA- handle + serve URL. --out downloads the
|
|
1336
1966
|
bytes (WhatsApp renders only .png/.jpg); --json emits { media_id, url, mime,
|
|
1337
1967
|
width, height, bytes }. Pair with 'octwin chat --media' to drive media flows.`,
|
|
1968
|
+
agents: `octwin agents [packId::agentId] [--prompt] [--json]
|
|
1969
|
+
No args = the roster with each agent's EFFECTIVE model and which layer set it.
|
|
1970
|
+
With an agent = every governed setting (model / memory.last_messages /
|
|
1971
|
+
working_memory) plus the layer that won — an operator PLATFORM default can
|
|
1972
|
+
override what your manifest declares, and this is where you see that.
|
|
1973
|
+
--prompt = the exact system prompt the LLM sees for this project (pack
|
|
1974
|
+
instructions + platform protocol + any project overlay). Needs agents:read.
|
|
1975
|
+
The agent ref is the compound \`<packId>::<agentId>\` key or the override-row UUID.`,
|
|
1976
|
+
orders: `octwin orders [reference_id] [--status s] [--payment p] [--limit 50] [--json]
|
|
1977
|
+
No args = the order list (#number, status/payment, total, contact). With a
|
|
1978
|
+
reference_id = line items, the subtotal/tax/shipping/discount/total breakdown,
|
|
1979
|
+
payment_ref, and the allowed status transitions. Needs orders:read + the
|
|
1980
|
+
\`orders\` plan feature. Note: the forward payment lifecycle is webhook-owned,
|
|
1981
|
+
so \`pending\` on a gateway-less workspace is expected, not a bug.`,
|
|
1982
|
+
analytics: `octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] [--json]
|
|
1983
|
+
No args = the entities that carry a \`pipeline:\` (a funnel needs stages).
|
|
1984
|
+
With an entity = stage-by-stage conversion (default --funnel) over the last 30
|
|
1985
|
+
days. --stage <id> lists the records CURRENTLY at a stage (a live snapshot, not
|
|
1986
|
+
range-filtered). Needs records:read + a \`view\` grant on \`record.<entity>\`.`,
|
|
1987
|
+
catalog: `octwin catalog [--readiness] [--json]
|
|
1988
|
+
The commerce \`product\` records + price, availability, stock (null = not
|
|
1989
|
+
inventory-tracked) and the WhatsApp catalog binding. --readiness runs the Meta
|
|
1990
|
+
Graph checklist (LIVE Graph calls; needs a bound access token). Needs
|
|
1991
|
+
catalog:read + the \`catalog\` plan feature.`,
|
|
1992
|
+
scheduling: `octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] [--json]
|
|
1993
|
+
No args = the engine state (bookable resource types, upcoming slots, booked
|
|
1994
|
+
seats). --slots <recordId> computes the slots for one bookable resource
|
|
1995
|
+
(occupancy included; --days is clamped to 1-31 server-side) — the way to verify
|
|
1996
|
+
the availability rules a \`deploy --seed\` created. Needs scheduling:read.`,
|
|
1338
1997
|
'platform-kb': `octwin platform-kb [pull] [--dir .] [--url <url>] [--tenant <slug>] [--token <t>]
|
|
1339
1998
|
Pull the platform capability reference (markdown + JSON catalogs) into
|
|
1340
1999
|
.octwin/platform-kb/ for the octwin-pack authoring skill.`,
|
|
@@ -1344,6 +2003,7 @@ const COMMAND_HELP = {
|
|
|
1344
2003
|
async function main() {
|
|
1345
2004
|
const [command, ...rest] = process.argv.slice(2);
|
|
1346
2005
|
const flags = parseFlags(rest);
|
|
2006
|
+
CURRENT_COMMAND = command; // so an auth failure can name the scope THIS command needs
|
|
1347
2007
|
// Per-subcommand --help/-h — intercepted BEFORE the command runs, so help can
|
|
1348
2008
|
// never hit the network or die on auth (author-feedback A8).
|
|
1349
2009
|
if (command && command in COMMAND_HELP && (flags.help === true || flags._.includes('-h'))) {
|
|
@@ -1358,7 +2018,7 @@ async function main() {
|
|
|
1358
2018
|
await cmdValidate(flags);
|
|
1359
2019
|
break;
|
|
1360
2020
|
case 'login':
|
|
1361
|
-
cmdLogin(flags);
|
|
2021
|
+
await cmdLogin(flags);
|
|
1362
2022
|
break;
|
|
1363
2023
|
case 'whoami':
|
|
1364
2024
|
await cmdWhoami(flags);
|
|
@@ -1384,6 +2044,21 @@ async function main() {
|
|
|
1384
2044
|
case 'media':
|
|
1385
2045
|
await cmdMedia(flags);
|
|
1386
2046
|
break;
|
|
2047
|
+
case 'agents':
|
|
2048
|
+
await cmdAgents(flags);
|
|
2049
|
+
break;
|
|
2050
|
+
case 'orders':
|
|
2051
|
+
await cmdOrders(flags);
|
|
2052
|
+
break;
|
|
2053
|
+
case 'analytics':
|
|
2054
|
+
await cmdAnalytics(flags);
|
|
2055
|
+
break;
|
|
2056
|
+
case 'catalog':
|
|
2057
|
+
await cmdCatalog(flags);
|
|
2058
|
+
break;
|
|
2059
|
+
case 'scheduling':
|
|
2060
|
+
await cmdScheduling(flags);
|
|
2061
|
+
break;
|
|
1387
2062
|
case 'platform-kb':
|
|
1388
2063
|
await cmdPlatformKb(flags);
|
|
1389
2064
|
break;
|