octwin-cli 0.3.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -1
- package/README.md +214 -210
- package/dist/index.js +731 -255
- package/dist/lib/args-check.js +11 -10
- package/dist/lib/builtin-check.js +130 -0
- package/dist/lib/declaration-check.js +192 -0
- package/dist/lib/entity-check.js +150 -0
- package/dist/lib/kb-index.js +214 -0
- package/dist/lib/kb-path.js +17 -0
- package/dist/lib/kb-symbols.js +271 -0
- package/dist/lib/render-check.js +13 -12
- package/dist/lib/template-check.js +107 -0
- package/dist/lib/validate.js +33 -4
- package/dist/lib/yaml-pos.js +29 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* octwin pull <packId> [--dir <out>] [--version v] [--force] # write a DEPLOYED pack's source back to disk
|
|
18
18
|
* octwin status [--dir .] # did my deploy land? which version is live?
|
|
19
19
|
* octwin records [entity] [id] # inspect the pack's XRM data (records:read token)
|
|
20
|
-
* octwin
|
|
20
|
+
* octwin work [recordId] [--queues] # inspect the work inbox (worked records) — list / one item + timeline
|
|
21
21
|
* octwin logs [conversationId] [--as h] [--json] # list conversations / show one's timeline
|
|
22
22
|
* octwin chat "msg" [--as h] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn via the web channel (+ send media)
|
|
23
23
|
* octwin chat --script <file> [--as h] # drive a WHOLE conversation, one turn per line (the reliable way to test a flow)
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion, any pipelined entity
|
|
28
28
|
* octwin catalog [--readiness] # commerce products + stock + the WhatsApp catalog binding (catalog:read)
|
|
29
29
|
* octwin scheduling [--slots <resourceRecordId>] # engine state / computed slots (scheduling:read)
|
|
30
|
-
* octwin platform-kb [pull] [--dir .] #
|
|
30
|
+
* octwin platform-kb [pull] [--if-stale|--check] [--dir .] # the platform capability reference (no token needed)
|
|
31
31
|
* octwin test [--dir .] # = validate --remote (the full platform check)
|
|
32
32
|
*
|
|
33
33
|
* Multi-turn testing: the platform keeps ONE open conversation per `--as` handle,
|
|
@@ -58,12 +58,19 @@ import { homedir } from 'node:os';
|
|
|
58
58
|
import { fileURLToPath } from 'node:url';
|
|
59
59
|
import { parse as parseYaml } from 'yaml';
|
|
60
60
|
import { applyRenames } from './lib/rename.js';
|
|
61
|
-
import { validatePackBundle } from './lib/validate.js';
|
|
61
|
+
import { validatePackBundle, describePackNameProblem, asPackName } from './lib/validate.js';
|
|
62
62
|
import { loadAllowedRenderKeys, findRenderKeyViolations, describeRenderFinding } from './lib/render-check.js';
|
|
63
63
|
import { loadPrimitiveArgSpecs, findArgViolations, describeArgFinding } from './lib/args-check.js';
|
|
64
|
+
import { yamlLineOf } from './lib/yaml-pos.js';
|
|
65
|
+
import { loadBuiltinNames, findBuiltinViolations, describeBuiltinFinding } from './lib/builtin-check.js';
|
|
66
|
+
import { loadTemplateSpecs, findTemplateViolations, describeTemplateFinding } from './lib/template-check.js';
|
|
67
|
+
import { loadSystemEntities, findEntityViolations, describeEntityFinding } from './lib/entity-check.js';
|
|
68
|
+
import { loadDeclarationSpecs, findDeclarationViolations, describeDeclarationFinding } from './lib/declaration-check.js';
|
|
64
69
|
import { describeKbLookup, findPlatformKbDir } from './lib/kb-path.js';
|
|
65
70
|
import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
|
|
66
71
|
import { readPage, morePageHint } from './lib/page.js';
|
|
72
|
+
import { kbOneLiner, buildKbIndexMarkdown, buildKbOutlineMarkdown, } from './lib/kb-index.js';
|
|
73
|
+
import { buildSymbols, linkExplainers, renderSymbolsMarkdown } from './lib/kb-symbols.js';
|
|
67
74
|
// The in-package starter template ships alongside `dist/` and `src/` (both one
|
|
68
75
|
// level under the package root), so `../templates/starter` resolves for the
|
|
69
76
|
// built CLI and `tsx` dev alike.
|
|
@@ -170,7 +177,7 @@ function authFailureHint(status, url) {
|
|
|
170
177
|
}
|
|
171
178
|
/**
|
|
172
179
|
* Write VERBS need a different scope than the read they share a command with —
|
|
173
|
-
* `octwin
|
|
180
|
+
* `octwin work` is `work:read`, `octwin work note` is `work:write` — so the
|
|
174
181
|
* requirement is resolved by `<command> <verb>` first, then by command.
|
|
175
182
|
*
|
|
176
183
|
* Keyed on the verb rather than duplicating whole commands, because the hint's
|
|
@@ -183,13 +190,17 @@ const VERB_REQUIREMENTS = {
|
|
|
183
190
|
'records note': { scope: 'records:write', feature: 'records' },
|
|
184
191
|
'records tasks': { scope: 'records:read', feature: 'tasks' },
|
|
185
192
|
'records task': { scope: 'records:write', feature: 'tasks' },
|
|
186
|
-
'
|
|
187
|
-
'
|
|
188
|
-
|
|
189
|
-
//
|
|
193
|
+
'work assign': { scope: 'work:write', feature: 'work' },
|
|
194
|
+
'work note': { scope: 'work:write', feature: 'work' },
|
|
195
|
+
// The stage move rides the XRM records verb (`POST …/xrm/records/:id/stage`) —
|
|
196
|
+
// the work surface deliberately has no second transition spelling (P-02).
|
|
197
|
+
'work stage': { scope: 'records:write', feature: 'records' },
|
|
198
|
+
// `decide --dry-run` hits the PREVIEW route, which is `work:read`. Naming the
|
|
190
199
|
// write scope is still the right hint: the committing form is the default.
|
|
191
|
-
'
|
|
192
|
-
|
|
200
|
+
'work decide': { scope: 'work:write', feature: 'work' },
|
|
201
|
+
// The stage move rides the XRM records verb (P-02), so the scope is records:write —
|
|
202
|
+
// the read that resolves the reference still needs orders:read.
|
|
203
|
+
'orders transition': { scope: 'records:write', feature: 'records' },
|
|
193
204
|
'orders refund': { scope: 'orders:write', feature: 'orders' },
|
|
194
205
|
'catalog availability': { scope: 'catalog:write', feature: 'catalog' },
|
|
195
206
|
'catalog stock': { scope: 'catalog:write', feature: 'catalog' },
|
|
@@ -197,9 +208,15 @@ const VERB_REQUIREMENTS = {
|
|
|
197
208
|
'scheduling rule': { scope: 'scheduling:write' },
|
|
198
209
|
'scheduling exception': { scope: 'scheduling:write' },
|
|
199
210
|
'agents set': { scope: 'agents:write' },
|
|
211
|
+
// Creating and destroying a project are the same scope as editing one. Worth
|
|
212
|
+
// spelling out because the natural token for the deploy loop is `pack:deploy`,
|
|
213
|
+
// which does NOT confer this — that 403 is otherwise baffling.
|
|
214
|
+
'projects create': { scope: 'projects:write' },
|
|
215
|
+
'projects rm': { scope: 'projects:write' },
|
|
200
216
|
};
|
|
201
217
|
const COMMAND_REQUIREMENTS = {
|
|
202
218
|
deploy: { scope: 'pack:deploy' },
|
|
219
|
+
seed: { scope: 'pack:deploy' },
|
|
203
220
|
validate: { scope: 'pack:deploy' },
|
|
204
221
|
status: { scope: 'pack:deploy' },
|
|
205
222
|
test: { scope: 'pack:deploy' },
|
|
@@ -207,7 +224,12 @@ const COMMAND_REQUIREMENTS = {
|
|
|
207
224
|
// same scope. Omitting it here meant a 403 on the one command that recovers a pack's
|
|
208
225
|
// only source copy printed the generic hint WITHOUT naming the scope to grant.
|
|
209
226
|
pull: { scope: 'pack:deploy' },
|
|
210
|
-
|
|
227
|
+
// `platform-kb` deliberately has NO requirement: the reference is platform stdlib,
|
|
228
|
+
// and the platform serves it anonymously at `/api/public/octwin-platform-kb` so a
|
|
229
|
+
// cold start needs no login. A token is still USED when present (the tenant-scoped
|
|
230
|
+
// route also works against platforms predating the public one) — but declaring a
|
|
231
|
+
// requirement here would print "needs pack:deploy" on a failure whose real cause is
|
|
232
|
+
// an unreachable instance.
|
|
211
233
|
feedback: { scope: 'pack:deploy' },
|
|
212
234
|
media: { scope: 'media:generate' },
|
|
213
235
|
// The plan feature gates RECORD reads, not the entity list (`/xrm/entities` carries only
|
|
@@ -215,7 +237,7 @@ const COMMAND_REQUIREMENTS = {
|
|
|
215
237
|
// plan for a 403 the plan did not cause.
|
|
216
238
|
records: { scope: 'records:read', feature: 'records', featureAppliesTo: 'reading records (listing entities needs only the scope)' },
|
|
217
239
|
analytics: { scope: 'records:read', feature: 'records' },
|
|
218
|
-
|
|
240
|
+
work: { scope: 'work:read', feature: 'work' },
|
|
219
241
|
logs: { scope: 'conversations:read' },
|
|
220
242
|
orders: { scope: 'orders:read', feature: 'orders' },
|
|
221
243
|
catalog: { scope: 'catalog:read', feature: 'catalog' },
|
|
@@ -228,7 +250,7 @@ const COMMAND_REQUIREMENTS = {
|
|
|
228
250
|
};
|
|
229
251
|
/** The command currently running — set once in `main()` so any failure printer can
|
|
230
252
|
* name the scope that command needs without threading it through every call.
|
|
231
|
-
* Carries the write VERB too (`
|
|
253
|
+
* Carries the write VERB too (`work note`), since that is what decides the scope. */
|
|
232
254
|
let CURRENT_COMMAND;
|
|
233
255
|
/** `→ needs the \`orders:read\` scope …` — the requirement line for the running
|
|
234
256
|
* command, or '' when the command has no declared requirement. */
|
|
@@ -478,6 +500,13 @@ async function notifyIfOutdated() {
|
|
|
478
500
|
}
|
|
479
501
|
catch { /* a version check must never break the CLI */ }
|
|
480
502
|
}
|
|
503
|
+
// ── platform-KB drift check (observe the pulled reference, fail-silent) ──
|
|
504
|
+
//
|
|
505
|
+
// `KbIndexEntry` / `KbEntriesDescriptor` mirror the platform's own types and live
|
|
506
|
+
// in `lib/kb-index.js` beside the renderers that consume them. `entries` (list
|
|
507
|
+
// catalogs only) describes how to enumerate a catalog so we can EXPLODE it into
|
|
508
|
+
// one file per entry; the PLATFORM supplies it rather than this CLI hardcoding
|
|
509
|
+
// per-catalog knowledge, so a platform that adds a catalog needs no CLI release.
|
|
481
510
|
/** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
|
|
482
511
|
* (content hash + per-entry index), or null if nothing has been pulled yet. */
|
|
483
512
|
function readLocalKb(packDir) {
|
|
@@ -509,15 +538,44 @@ function diffKbIndex(prev, next) {
|
|
|
509
538
|
const removed = prev.filter(e => !nextKeys.has(e.key)).map(e => e.key);
|
|
510
539
|
return { added, removed, changed };
|
|
511
540
|
}
|
|
541
|
+
/**
|
|
542
|
+
* Fetch the platform's KB identity (`?meta=1`) — the cheap poll behind both the
|
|
543
|
+
* drift nudge and `--if-stale`. Returns null on anything that is not a clean
|
|
544
|
+
* answer; the caller decides whether that is worth a word.
|
|
545
|
+
*
|
|
546
|
+
* `notAuthorized` is reported separately because it is the one failure with a
|
|
547
|
+
* fix the author can act on.
|
|
548
|
+
*/
|
|
549
|
+
async function fetchKbMeta(t, timeoutMs = 2_000) {
|
|
550
|
+
const ep = kbEndpoint(t);
|
|
551
|
+
const ctrl = new AbortController();
|
|
552
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
553
|
+
try {
|
|
554
|
+
const res = await fetch(`${ep.url}?meta=1`, { headers: ep.headers, signal: ctrl.signal });
|
|
555
|
+
if (!res.ok)
|
|
556
|
+
return { ok: false, notAuthorized: res.status === 401 || res.status === 403 };
|
|
557
|
+
return { ok: true, meta: await res.json() };
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return { ok: false, notAuthorized: false };
|
|
561
|
+
}
|
|
562
|
+
finally {
|
|
563
|
+
clearTimeout(timer);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
512
566
|
/** Nudge (to stderr) when the platform's capability KB has changed since the last
|
|
513
567
|
* `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
|
|
514
568
|
* of the CLI: run only after commands that already hit the platform, so this adds
|
|
515
569
|
* a single tiny `?meta=1` GET on top of work that was networked anyway. Never
|
|
516
570
|
* throws — observing must never break a command. No-op until the author has pulled
|
|
517
|
-
* once (nothing to compare) or if the target
|
|
571
|
+
* once (nothing to compare) or if the target can't be resolved.
|
|
572
|
+
*
|
|
573
|
+
* NOT TTY-gated, deliberately. It was, on the reasoning that a nudge is for a
|
|
574
|
+
* human watching a terminal — but the primary reader of this CLI is now an
|
|
575
|
+
* authoring agent whose output is piped, and gating on `isTTY` meant the one
|
|
576
|
+
* reader that CANNOT notice a stale reference on its own was the only one never
|
|
577
|
+
* told. It is a single line on stderr, so piped stdout stays clean either way. */
|
|
518
578
|
async function notifyIfKbStale(flags) {
|
|
519
|
-
if (!process.stdout.isTTY)
|
|
520
|
-
return;
|
|
521
579
|
try {
|
|
522
580
|
const packDir = resolve(flags.dir ?? '.');
|
|
523
581
|
const local = readLocalKb(packDir);
|
|
@@ -526,25 +584,20 @@ async function notifyIfKbStale(flags) {
|
|
|
526
584
|
const t = resolveTargetOrNull(flags);
|
|
527
585
|
if (!t)
|
|
528
586
|
return;
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
// an author invent a primitive from memory. Say so once; stay silent for every other
|
|
540
|
-
// failure (offline, timeout, a platform without the route).
|
|
541
|
-
if (res.status === 401 || res.status === 403) {
|
|
542
|
-
console.error('\nⓘ can\'t check whether the platform capability reference drifted — that needs a `pack:deploy` token.');
|
|
543
|
-
console.error(' Refresh it directly with a deploy token: octwin platform-kb --token oct_…');
|
|
587
|
+
const polled = await fetchKbMeta(t);
|
|
588
|
+
if (!polled.ok) {
|
|
589
|
+
// The tenant-scoped meta poll needs `pack:deploy`, but this nudge rides on every
|
|
590
|
+
// networked command — so an author inspecting data with a narrow (`records:read`-only)
|
|
591
|
+
// token got NO drift signal at all, silently, and a stale reference is exactly what
|
|
592
|
+
// makes an author invent a primitive from memory. Say so once; stay silent for every
|
|
593
|
+
// other failure (offline, timeout, a platform without the route).
|
|
594
|
+
if (polled.notAuthorized) {
|
|
595
|
+
console.error('\nⓘ can\'t check whether the platform capability reference drifted — that token lacks `pack:deploy`.');
|
|
596
|
+
console.error(' Check it without a token: octwin platform-kb --check (or refresh: octwin platform-kb --token oct_…)');
|
|
544
597
|
}
|
|
545
598
|
return;
|
|
546
599
|
}
|
|
547
|
-
const meta =
|
|
600
|
+
const meta = polled.meta;
|
|
548
601
|
if (meta.content_hash && meta.content_hash !== local.content_hash) {
|
|
549
602
|
// Per-entry summary (now that the index carries per-entry hashes) — the
|
|
550
603
|
// exact list of what changed is one `octwin platform-kb` away.
|
|
@@ -560,7 +613,7 @@ async function notifyIfKbStale(flags) {
|
|
|
560
613
|
summary = ` (${parts.join(' · ')})`;
|
|
561
614
|
}
|
|
562
615
|
console.error(`\n⬆ the platform capability reference changed since you last pulled it${summary}.`);
|
|
563
|
-
console.error(' Refresh it: octwin platform-kb');
|
|
616
|
+
console.error(' Refresh it: octwin platform-kb --if-stale');
|
|
564
617
|
}
|
|
565
618
|
}
|
|
566
619
|
catch { /* a KB check must never break the CLI */ }
|
|
@@ -578,7 +631,7 @@ function commandTouchesPlatform(command, flags) {
|
|
|
578
631
|
case 'media':
|
|
579
632
|
case 'pull':
|
|
580
633
|
case 'records':
|
|
581
|
-
case '
|
|
634
|
+
case 'work':
|
|
582
635
|
case 'logs':
|
|
583
636
|
case 'whoami':
|
|
584
637
|
case 'feedback':
|
|
@@ -587,7 +640,8 @@ function commandTouchesPlatform(command, flags) {
|
|
|
587
640
|
case 'analytics':
|
|
588
641
|
case 'catalog':
|
|
589
642
|
case 'scheduling':
|
|
590
|
-
case 'projects':
|
|
643
|
+
case 'projects':
|
|
644
|
+
case 'seed': return true;
|
|
591
645
|
default: return false;
|
|
592
646
|
}
|
|
593
647
|
}
|
|
@@ -595,9 +649,14 @@ function commandTouchesPlatform(command, flags) {
|
|
|
595
649
|
function cmdInit(flags) {
|
|
596
650
|
const target = flags._[0] ?? die('usage: octwin init <dir> [--id my-pack]');
|
|
597
651
|
const dir = resolve(target);
|
|
652
|
+
// A BARE name, and it stays bare: the platform prefixes the workspace slug at publish,
|
|
653
|
+
// which is why `init` can stay offline (it has no idea which workspace this will deploy
|
|
654
|
+
// to, and no longer needs one) and why the same scaffold deploys into two workspaces as
|
|
655
|
+
// two packs with nothing to edit in between.
|
|
598
656
|
const id = flags.id ?? target.replace(/[/\\]/g, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
|
599
|
-
|
|
600
|
-
|
|
657
|
+
const idProblem = describePackNameProblem(id);
|
|
658
|
+
if (idProblem)
|
|
659
|
+
die(`${idProblem}\n → pass --id <pack-name>`);
|
|
601
660
|
if (existsSync(dir) && readdirSync(dir).length > 0)
|
|
602
661
|
die(`target '${dir}' is not empty`);
|
|
603
662
|
if (!existsSync(TEMPLATE_DIR))
|
|
@@ -629,7 +688,14 @@ function cmdInit(flags) {
|
|
|
629
688
|
}
|
|
630
689
|
function localValidate(packDir) {
|
|
631
690
|
const { files, blobs } = collectBundleFiles(packDir);
|
|
632
|
-
const { id, version } = readManifestIdVersion(files);
|
|
691
|
+
const { id: rawId, version } = readManifestIdVersion(files);
|
|
692
|
+
// The manifest declares a BARE name; the platform prefixes your workspace at publish.
|
|
693
|
+
// Minted here so an unusable one is reported with the rest of the bundle's errors.
|
|
694
|
+
const id = asPackName(rawId);
|
|
695
|
+
if (!id) {
|
|
696
|
+
console.error(` ✗ ${describePackNameProblem(rawId)}`);
|
|
697
|
+
die('bundle validation failed (1 error)');
|
|
698
|
+
}
|
|
633
699
|
const r = validatePackBundle(id, files, blobs);
|
|
634
700
|
if (!r.ok) {
|
|
635
701
|
for (const e of r.errors)
|
|
@@ -653,17 +719,36 @@ async function cmdValidate(flags) {
|
|
|
653
719
|
return [];
|
|
654
720
|
}
|
|
655
721
|
});
|
|
656
|
-
// Checks that need the pulled KB.
|
|
657
|
-
// gitignored cache wiped by every pull,
|
|
658
|
-
//
|
|
659
|
-
// clone before the author could act. But a skip is now ANNOUNCED, and remembered:
|
|
722
|
+
// Checks that need the pulled KB. All of them DEGRADE when it is absent — the KB
|
|
723
|
+
// is a gitignored cache wiped by every pull, so failing hard would break a fresh
|
|
724
|
+
// clone before the author could act. But a skip is ANNOUNCED, and remembered:
|
|
660
725
|
// the ✓ used to print above these blocks unconditionally while the per-check ✓s
|
|
661
726
|
// lived inside the `if`s, so a KB-less run read as "one check, passed". An entire
|
|
662
727
|
// backlog batch reached production that way. The defect is the silence, not the skip.
|
|
728
|
+
//
|
|
729
|
+
// Reasons are COLLECTED rather than printed inline. When the KB is missing, every
|
|
730
|
+
// check skips for the identical reason, and six copies of one sentence is how a
|
|
731
|
+
// reader learns to scroll past the ⚠ block — which is the same failure as not
|
|
732
|
+
// printing it. One line, naming all six.
|
|
663
733
|
const skipped = [];
|
|
734
|
+
const skipReasons = new Map();
|
|
735
|
+
const noteSkip = (label, lookup) => {
|
|
736
|
+
skipped.push(label);
|
|
737
|
+
// Group by the lookup's IDENTITY, then let `describeKbLookup` phrase the one
|
|
738
|
+
// line at print time — so the wording stays in the module that owns it and
|
|
739
|
+
// cannot drift into a doubled "SKIPPED — SKIPPED —".
|
|
740
|
+
const key = lookup.state === 'ok' ? 'ok'
|
|
741
|
+
: `${lookup.state}|${'dir' in lookup ? lookup.dir : ''}|${'reason' in lookup ? lookup.reason : ''}`;
|
|
742
|
+
const bucket = skipReasons.get(key) ?? { lookup, labels: [] };
|
|
743
|
+
bucket.labels.push(label);
|
|
744
|
+
skipReasons.set(key, bucket);
|
|
745
|
+
};
|
|
746
|
+
/** Stamp each finding with its source line — the walkers carry the node
|
|
747
|
+
* path; `files` holds the raw text the locator needs (E-08). */
|
|
748
|
+
const withLines = (fs) => fs.map(f => ({ ...f, line: files[f.file] ? yamlLineOf(files[f.file], f.path) : null }));
|
|
664
749
|
const render = loadAllowedRenderKeys(packDir);
|
|
665
750
|
if (render.keys) {
|
|
666
|
-
const findings = yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys));
|
|
751
|
+
const findings = withLines(yamlDocs().flatMap(([p, doc]) => findRenderKeyViolations(doc, p, render.keys)));
|
|
667
752
|
if (findings.length) {
|
|
668
753
|
console.error(`✗ ${findings.length} render-intent field error${findings.length === 1 ? '' : 's'}:`);
|
|
669
754
|
for (const f of findings)
|
|
@@ -673,14 +758,13 @@ async function cmdValidate(flags) {
|
|
|
673
758
|
console.log('✓ render intents use only fields the platform renders');
|
|
674
759
|
}
|
|
675
760
|
else {
|
|
676
|
-
|
|
677
|
-
skipped.push('render-intent fields');
|
|
761
|
+
noteSkip('render-intent fields', render.lookup);
|
|
678
762
|
}
|
|
679
763
|
// Primitive `args:` keys, same source and same contract. Cannot see inside a
|
|
680
764
|
// `use:` template body (expansion is the platform's job); `--remote` covers that.
|
|
681
765
|
const args = loadPrimitiveArgSpecs(packDir);
|
|
682
766
|
if (args.specs) {
|
|
683
|
-
const findings = yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs));
|
|
767
|
+
const findings = withLines(yamlDocs().flatMap(([p, doc]) => findArgViolations(doc, p, args.specs)));
|
|
684
768
|
if (findings.length) {
|
|
685
769
|
console.error(`✗ ${findings.length} primitive-argument error${findings.length === 1 ? '' : 's'}:`);
|
|
686
770
|
for (const f of findings)
|
|
@@ -690,8 +774,104 @@ async function cmdValidate(flags) {
|
|
|
690
774
|
console.log('✓ primitive arguments match their declared inputs');
|
|
691
775
|
}
|
|
692
776
|
else {
|
|
693
|
-
|
|
694
|
-
|
|
777
|
+
noteSkip('primitive arguments', args.lookup);
|
|
778
|
+
}
|
|
779
|
+
// Expression builtins — the function set is CLOSED and generated from the
|
|
780
|
+
// runtime, so an invented `$fn(` is checkable here and nowhere else offline.
|
|
781
|
+
const builtins = loadBuiltinNames(packDir);
|
|
782
|
+
if (builtins.names) {
|
|
783
|
+
const findings = withLines(yamlDocs().flatMap(([p, doc]) => findBuiltinViolations(doc, p, builtins.names)));
|
|
784
|
+
if (findings.length) {
|
|
785
|
+
console.error(`✗ ${findings.length} unknown expression function${findings.length === 1 ? '' : 's'}:`);
|
|
786
|
+
for (const f of findings)
|
|
787
|
+
console.error(` ✗ ${describeBuiltinFinding(f)}`);
|
|
788
|
+
die('fix these before deploying — the evaluator cannot resolve them, and it fails mid-conversation');
|
|
789
|
+
}
|
|
790
|
+
console.log('✓ every $function() in an expression exists');
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
noteSkip('expression functions', builtins.lookup);
|
|
794
|
+
}
|
|
795
|
+
// `use:` templates. A pack's OWN templates shadow the platform's, so they are
|
|
796
|
+
// named here and skipped — this check has no schema for them.
|
|
797
|
+
const templates = loadTemplateSpecs(packDir);
|
|
798
|
+
if (templates.specs) {
|
|
799
|
+
// A pack template is `templates/<name>.template.yaml` — the `.template`
|
|
800
|
+
// segment is part of the convention the expander scans for, NOT part of the
|
|
801
|
+
// name a `use:` writes. Capturing it would leave every pack that shadows a
|
|
802
|
+
// platform template (kaiian shadows `field_prompt_render`) reported as using
|
|
803
|
+
// one that does not exist.
|
|
804
|
+
const packTemplates = new Set(Object.keys(files)
|
|
805
|
+
.map(p => /^templates\/(.+)\.template\.ya?ml$/i.exec(p.replace(/\\/g, '/'))?.[1])
|
|
806
|
+
.filter((n) => !!n));
|
|
807
|
+
const findings = yamlDocs().flatMap(([p, doc]) => findTemplateViolations(doc, p, templates.specs, packTemplates));
|
|
808
|
+
if (findings.length) {
|
|
809
|
+
console.error(`✗ ${findings.length} template error${findings.length === 1 ? '' : 's'}:`);
|
|
810
|
+
for (const f of findings)
|
|
811
|
+
console.error(` ✗ ${describeTemplateFinding(f)}`);
|
|
812
|
+
die('fix these before deploying — a template param that does not exist arrives as undefined, and renders as a blank');
|
|
813
|
+
}
|
|
814
|
+
console.log('✓ `use:` templates and their params exist');
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
noteSkip('`use:` templates', templates.lookup);
|
|
818
|
+
}
|
|
819
|
+
// Reserved XRM entity keys — a boot error, which means the pack deploys clean
|
|
820
|
+
// and then fails to load on the first inbound message.
|
|
821
|
+
const system = loadSystemEntities(packDir);
|
|
822
|
+
if (system.entities) {
|
|
823
|
+
const findings = yamlDocs()
|
|
824
|
+
.filter(([p]) => /(^|[/\\])xrm\.ya?ml$/i.test(p))
|
|
825
|
+
.flatMap(([p, doc]) => findEntityViolations(doc, p, system.entities, system.rules));
|
|
826
|
+
if (findings.length) {
|
|
827
|
+
console.error(`✗ ${findings.length} reserved-entity error${findings.length === 1 ? '' : 's'}:`);
|
|
828
|
+
for (const f of findings)
|
|
829
|
+
console.error(` ✗ ${describeEntityFinding(f)}`);
|
|
830
|
+
die('fix these before deploying — these fail at BOOT, after a deploy that reported success');
|
|
831
|
+
}
|
|
832
|
+
// Name the narrower promise when the catalog-level rules are absent (a KB
|
|
833
|
+
// pulled before they were published) — a ✓ that reads wider than what ran is
|
|
834
|
+
// the failure this file's skip contract exists to prevent.
|
|
835
|
+
console.log(system.rules
|
|
836
|
+
? '✓ no entity collides with a reserved platform key, and no extension overrides a platform-owned one'
|
|
837
|
+
: '✓ no entity collides with a reserved platform key (re-pull for the `contact`/extension-override rules)');
|
|
838
|
+
}
|
|
839
|
+
else {
|
|
840
|
+
noteSkip('reserved entity keys', system.lookup);
|
|
841
|
+
}
|
|
842
|
+
// The declaration files themselves, against the published JSON Schemas.
|
|
843
|
+
// Deliberately narrow (see declaration-check.ts) — it walks away from anything
|
|
844
|
+
// it cannot read rather than guessing.
|
|
845
|
+
const decls = loadDeclarationSpecs(packDir);
|
|
846
|
+
if (decls.specs) {
|
|
847
|
+
const findings = yamlDocs().flatMap(([p, doc]) => {
|
|
848
|
+
const base = p.replace(/\\/g, '/').split('/').pop() ?? p;
|
|
849
|
+
const spec = decls.specs.get(base);
|
|
850
|
+
// Only a file at the PACK ROOT is a declaration — `flows/tools/xrm.yaml`
|
|
851
|
+
// would be a flow that happens to share a name.
|
|
852
|
+
if (!spec || p.replace(/\\/g, '/').includes('/'))
|
|
853
|
+
return [];
|
|
854
|
+
return findDeclarationViolations(doc, spec).map(f => ({ ...f, file: p }));
|
|
855
|
+
});
|
|
856
|
+
if (findings.length) {
|
|
857
|
+
console.error(`✗ ${findings.length} declaration error${findings.length === 1 ? '' : 's'}:`);
|
|
858
|
+
for (const f of findings)
|
|
859
|
+
console.error(` ✗ ${describeDeclarationFinding(f)}`);
|
|
860
|
+
die('fix these before deploying — a declaration file is parsed strictly, and an unknown key is rejected');
|
|
861
|
+
}
|
|
862
|
+
// Deliberately narrow wording. Most of `xrm.yaml`'s field shapes are a Zod
|
|
863
|
+
// union, which renders as `anyOf` and which this check walks away from by
|
|
864
|
+
// design — so "matches its schema" would be a promise it does not keep, and
|
|
865
|
+
// an over-claimed ✓ is how an author stops reading `--remote` output.
|
|
866
|
+
console.log('✓ declaration files carry no unknown or missing keys (unions are left to --remote)');
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
noteSkip('declaration schemas', decls.lookup);
|
|
870
|
+
}
|
|
871
|
+
// One ⚠ per distinct reason, naming every check it cost.
|
|
872
|
+
for (const { lookup, labels } of skipReasons.values()) {
|
|
873
|
+
const what = labels.length === 1 ? labels[0] : `${labels.length} checks (${labels.join(', ')})`;
|
|
874
|
+
console.log(`⚠ ${describeKbLookup(lookup, what)}`);
|
|
695
875
|
}
|
|
696
876
|
// `--require-kb` is for CI, where a skip nobody reads is worse than a red build.
|
|
697
877
|
if (skipped.length && flags['require-kb'] === true) {
|
|
@@ -701,7 +881,7 @@ async function cmdValidate(flags) {
|
|
|
701
881
|
// The LAST line carries the skip. A reader who sees a ✓ and stops there is the
|
|
702
882
|
// failure mode; a caveat printed ABOVE the ✓ does not fix it.
|
|
703
883
|
if (skipped.length) {
|
|
704
|
-
console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.
|
|
884
|
+
console.log(`\n⚠ ${id}@${version} passed the checks that RAN — ${skipped.length} skipped: ${skipped.join(', ')}.`);
|
|
705
885
|
console.log(' Run `octwin platform-kb pull` (once, at your repo root — it covers every pack under it),');
|
|
706
886
|
console.log(' or `octwin validate --remote` to have the platform run everything server-side.');
|
|
707
887
|
}
|
|
@@ -730,9 +910,25 @@ async function cmdValidate(flags) {
|
|
|
730
910
|
json = text;
|
|
731
911
|
}
|
|
732
912
|
if (!res.ok) {
|
|
733
|
-
// 404
|
|
734
|
-
|
|
735
|
-
|
|
913
|
+
// A 404 here is AMBIGUOUS and must not be collapsed. The route resolves the
|
|
914
|
+
// tenant and the project BEFORE it validates anything, so a 404 is usually an
|
|
915
|
+
// unknown `--tenant`/`--project` — and a token's project PIN answers 404 by
|
|
916
|
+
// design (an out-of-pin project is deliberately indistinguishable from one that
|
|
917
|
+
// does not exist). Reporting all of those as "older platform" sends the author
|
|
918
|
+
// hunting for a version mismatch that does not exist.
|
|
919
|
+
//
|
|
920
|
+
// The two are told apart by the BODY, not the status: the platform has no
|
|
921
|
+
// custom not-found handler, so a missing route is Fastify's default
|
|
922
|
+
// `{ statusCode, error: 'Not Found', message: 'Route … not found' }`, whereas
|
|
923
|
+
// `resolveTenantOr404`/`resolveProjectOr404` send a bare `{ error: "<what> not
|
|
924
|
+
// found" }`. The server's own message already names the slug it tried, so the
|
|
925
|
+
// hint carries the fix rather than repeating the target.
|
|
926
|
+
if (res.status === 404) {
|
|
927
|
+
const routeMissing = typeof json !== 'object' || json === null || json.error === 'Not Found';
|
|
928
|
+
if (routeMissing)
|
|
929
|
+
die('this platform has no /packs/validate endpoint yet (older version) — deploy runs the full check');
|
|
930
|
+
die(`remote validate${errDetail(json)} — check --tenant/--project (or PACK_TENANT/PACK_PROJECT); \`octwin projects\` lists what this token can reach`);
|
|
931
|
+
}
|
|
736
932
|
console.error(`✗ remote validate failed (HTTP ${res.status})`);
|
|
737
933
|
printAuthHint(res.status, url);
|
|
738
934
|
console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
|
|
@@ -815,6 +1011,24 @@ function resolveTargetOrNull(flags) {
|
|
|
815
1011
|
const t = readTarget(flags);
|
|
816
1012
|
return t.url && t.token ? t : null;
|
|
817
1013
|
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Where to read the platform capability reference from, and how.
|
|
1016
|
+
*
|
|
1017
|
+
* The KB is tenant-independent platform stdlib, and the platform now serves it
|
|
1018
|
+
* anonymously at `/api/public/octwin-platform-kb` precisely so a COLD START does
|
|
1019
|
+
* not need a login: `.octwin/` is gitignored, so a fresh clone — or a fresh
|
|
1020
|
+
* Claude Code session — has no reference at all, and an authoring agent's first
|
|
1021
|
+
* useful question came after a credential round-trip it had no way to satisfy.
|
|
1022
|
+
*
|
|
1023
|
+
* A token still WINS when one is available: the tenant-scoped route is the one
|
|
1024
|
+
* that has always existed, it works against platforms that predate the public
|
|
1025
|
+
* rung, and using it keeps the author's own instance the source of truth.
|
|
1026
|
+
*/
|
|
1027
|
+
function kbEndpoint(t) {
|
|
1028
|
+
return t.token
|
|
1029
|
+
? { url: `${t.url}/api/self/t/octwin-platform-kb`, headers: authHeaders(t), anonymous: false }
|
|
1030
|
+
: { url: `${t.url}/api/public/octwin-platform-kb`, headers: {}, anonymous: true };
|
|
1031
|
+
}
|
|
818
1032
|
/** The raw resolution both wrappers share — may return empty url/token. */
|
|
819
1033
|
function readTarget(flags) {
|
|
820
1034
|
const url = (flags.url ?? process.env.PACK_PLATFORM_URL ?? savedDefaultUrl()).replace(/\/$/, '');
|
|
@@ -1015,6 +1229,71 @@ function printDeploySuccess(id, version, t, r) {
|
|
|
1015
1229
|
printPublicListing(r?.public_listing, r?.public_review_note);
|
|
1016
1230
|
console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
|
|
1017
1231
|
}
|
|
1232
|
+
/**
|
|
1233
|
+
* `octwin seed [--pack <id>]` — apply the pack's demo/reference data to the project it
|
|
1234
|
+
* is installed on, without redeploying.
|
|
1235
|
+
*
|
|
1236
|
+
* Exists because seeding used to be reachable only as `deploy --seed`: the platform's
|
|
1237
|
+
* seed endpoint was keyed on an install id, guarded `requirePlatformAdmin`, and carried
|
|
1238
|
+
* no tenant/project segments — so the `/api/self/**` rewrite could not reach it and a
|
|
1239
|
+
* `pack:deploy` token never could. Re-seeding meant a full redeploy, or asking an
|
|
1240
|
+
* operator.
|
|
1241
|
+
*
|
|
1242
|
+
* Reuses `readDeployProgress` verbatim: the platform emits ONE seed-progress vocabulary
|
|
1243
|
+
* now (`stage:'seed'` with a `kind`), so a second reader would only be a second thing to
|
|
1244
|
+
* keep in step.
|
|
1245
|
+
*/
|
|
1246
|
+
async function cmdSeed(flags) {
|
|
1247
|
+
const t = resolveTarget(flags);
|
|
1248
|
+
const { url } = t;
|
|
1249
|
+
const packId = typeof flags.pack === 'string' ? flags.pack : undefined;
|
|
1250
|
+
console.log(`→ Seeding ${packId ?? 'the installed pack'} on ${targetLabel(t)} …`);
|
|
1251
|
+
const res = await fetchOrDie(`${url}/api/self/p/packs/seed`, {
|
|
1252
|
+
method: 'POST',
|
|
1253
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
|
|
1254
|
+
body: JSON.stringify(packId ? { pack_id: packId } : {}),
|
|
1255
|
+
}, 'seed');
|
|
1256
|
+
if (res.ok && (res.headers.get('content-type') ?? '').includes('text/event-stream') && res.body) {
|
|
1257
|
+
const { terminal: final, stepErrors } = await readDeployProgress(res.body);
|
|
1258
|
+
if (!final || final.stage === 'error')
|
|
1259
|
+
die(`seed failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
|
|
1260
|
+
console.log(`
|
|
1261
|
+
✓ ${final.message ?? 'seed complete'}`);
|
|
1262
|
+
printSeedCounts(final.result?.seeded);
|
|
1263
|
+
if (stepErrors.length) {
|
|
1264
|
+
// A kind failed but the rest ran — the reconcile softens each step. Say which,
|
|
1265
|
+
// and exit non-zero so a scripted `seed && chat` doesn't read as clean.
|
|
1266
|
+
console.error(`
|
|
1267
|
+
⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
|
|
1268
|
+
for (const e of stepErrors)
|
|
1269
|
+
console.error(` • ${e}`);
|
|
1270
|
+
process.exit(1);
|
|
1271
|
+
}
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const text = await res.text();
|
|
1275
|
+
let json;
|
|
1276
|
+
try {
|
|
1277
|
+
json = JSON.parse(text);
|
|
1278
|
+
}
|
|
1279
|
+
catch {
|
|
1280
|
+
json = text;
|
|
1281
|
+
}
|
|
1282
|
+
if (!res.ok) {
|
|
1283
|
+
console.error(`✗ seed failed (HTTP ${res.status})${errDetail(json)}`);
|
|
1284
|
+
printAuthHint(res.status, url);
|
|
1285
|
+
process.exit(1);
|
|
1286
|
+
}
|
|
1287
|
+
console.log('✓ seed complete');
|
|
1288
|
+
printSeedCounts(json?.seeded);
|
|
1289
|
+
}
|
|
1290
|
+
/** Per-kind counts, one line each. Prints nothing when the pack declared nothing. */
|
|
1291
|
+
function printSeedCounts(seeded) {
|
|
1292
|
+
for (const [kind, counts] of Object.entries(seeded ?? {})) {
|
|
1293
|
+
const detail = Object.entries(counts).filter(([, v]) => v > 0).map(([k, v]) => `${v} ${k}`).join(' · ');
|
|
1294
|
+
console.log(` ${kind.padEnd(11)} ${detail || '—'}`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1018
1297
|
async function cmdDeploy(flags) {
|
|
1019
1298
|
const packDir = resolve(flags.dir ?? '.');
|
|
1020
1299
|
const t = resolveTarget(flags);
|
|
@@ -1143,35 +1422,10 @@ async function cmdStatus(flags) {
|
|
|
1143
1422
|
function kbEntryFileName(name) {
|
|
1144
1423
|
return name.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
1145
1424
|
}
|
|
1146
|
-
/**
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
|
|
1150
|
-
* text routinely inlines an envelope shape (`… { rows, total, …, refs? } …`) whose
|
|
1151
|
-
* `?` would otherwise cut the summary off mid-brace. */
|
|
1152
|
-
function kbOneLiner(text, max = 160) {
|
|
1153
|
-
if (typeof text !== 'string' || !text.trim())
|
|
1154
|
-
return '';
|
|
1155
|
-
const flat = text.replace(/\s+/g, ' ').trim();
|
|
1156
|
-
let depth = 0;
|
|
1157
|
-
let end = -1;
|
|
1158
|
-
for (let i = 0; i < flat.length; i++) {
|
|
1159
|
-
const ch = flat[i];
|
|
1160
|
-
if (ch === '{' || ch === '(' || ch === '[')
|
|
1161
|
-
depth++;
|
|
1162
|
-
else if (ch === '}' || ch === ')' || ch === ']')
|
|
1163
|
-
depth = Math.max(0, depth - 1);
|
|
1164
|
-
else if (depth === 0 && (ch === '.' || ch === '!' || ch === '?')) {
|
|
1165
|
-
const next = flat[i + 1];
|
|
1166
|
-
if (next === undefined || next === ' ') {
|
|
1167
|
-
end = i + 1;
|
|
1168
|
-
break;
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
const line = end >= 40 ? flat.slice(0, end) : flat;
|
|
1173
|
-
return line.length > max ? line.slice(0, max - 1).trimEnd() + '…' : line;
|
|
1174
|
-
}
|
|
1425
|
+
/** Envelope keys that are bookkeeping, not catalog-level content — never worth a
|
|
1426
|
+
* `_catalog.json` of their own. Anything else in the envelope is a real rule that
|
|
1427
|
+
* would otherwise be dropped by the explode. */
|
|
1428
|
+
const CATALOG_ENVELOPE_NOISE = new Set(['version', 'description', 'source']);
|
|
1175
1429
|
/** Enumerate a catalog's entries per the platform-supplied descriptor. Handles both
|
|
1176
1430
|
* collection shapes in use: an ARRAY of named objects (`primitives`, keyed by
|
|
1177
1431
|
* `name`) and an OBJECT MAP keyed by entry name (`declarations`, `system-entities`).
|
|
@@ -1200,73 +1454,56 @@ function enumerateKbEntries(catalog, d) {
|
|
|
1200
1454
|
}
|
|
1201
1455
|
return [];
|
|
1202
1456
|
}
|
|
1203
|
-
/**
|
|
1204
|
-
* Build `INDEX.md` — the map an authoring agent reads FIRST.
|
|
1205
|
-
*
|
|
1206
|
-
* The KB is ~800 KB across three dozen files; reading it whole costs more context
|
|
1207
|
-
* than the pack being authored. This index is one ~7k-token read that names every
|
|
1208
|
-
* doc and every catalog entry with a one-line summary and its exact path, so the
|
|
1209
|
-
* agent can jump straight to the ~600-token file it actually needs.
|
|
1210
|
-
*/
|
|
1211
|
-
function buildKbIndexMarkdown(bundle, exploded) {
|
|
1212
|
-
const index = bundle.index ?? [];
|
|
1213
|
-
const docs = index.filter(e => e.kind === 'doc');
|
|
1214
|
-
const catalogs = index.filter(e => e.kind === 'catalog');
|
|
1215
|
-
const L = [];
|
|
1216
|
-
L.push('# Octwin platform capability reference — INDEX');
|
|
1217
|
-
L.push('');
|
|
1218
|
-
L.push(`Reference version ${bundle.version ?? '?'} · content_hash \`${bundle.content_hash ?? '?'}\` · pulled ${bundle.generated_at ?? '?'}`);
|
|
1219
|
-
L.push('');
|
|
1220
|
-
L.push('**This is the map. Read it, then open only the specific file you need — never a whole catalog.**');
|
|
1221
|
-
L.push('Everything the platform supports is here; if a step, function, field, or render intent is NOT in');
|
|
1222
|
-
L.push('this index, it does not exist for a pure-YAML pack. Do not fill a gap from memory.');
|
|
1223
|
-
L.push('');
|
|
1224
|
-
L.push('## Start here');
|
|
1225
|
-
L.push('');
|
|
1226
|
-
L.push('1. `craft-capabilities.md` — how this reference fits together.');
|
|
1227
|
-
L.push('2. `craft-ux.md` — what a *good* pack looks like (home hub, rich cards, confirm-before-commit).');
|
|
1228
|
-
L.push('3. `craft-flows.md` — the flow DSL in practice.');
|
|
1229
|
-
L.push('4. Then the tables below, on demand.');
|
|
1230
|
-
L.push('');
|
|
1231
|
-
L.push('## Guides & reference docs');
|
|
1232
|
-
L.push('');
|
|
1233
|
-
L.push('| Doc | Read it for | File |');
|
|
1234
|
-
L.push('|---|---|---|');
|
|
1235
|
-
for (const d of docs)
|
|
1236
|
-
L.push(`| ${d.title ?? d.key} | ${kbOneLiner(d.summary)} | \`${d.key}.md\` |`);
|
|
1237
|
-
L.push('');
|
|
1238
|
-
L.push('## Catalogs — exact machine-readable schemas');
|
|
1239
|
-
L.push('');
|
|
1240
|
-
for (const c of catalogs) {
|
|
1241
|
-
const entries = exploded.get(c.key);
|
|
1242
|
-
L.push(`### ${c.title ?? c.key}`);
|
|
1243
|
-
L.push('');
|
|
1244
|
-
L.push(kbOneLiner(c.summary, 400));
|
|
1245
|
-
L.push('');
|
|
1246
|
-
if (!entries || entries.length === 0) {
|
|
1247
|
-
L.push(`Single document: \`${c.key}.json\``);
|
|
1248
|
-
L.push('');
|
|
1249
|
-
continue;
|
|
1250
|
-
}
|
|
1251
|
-
L.push(`${entries.length} entries in \`${c.key}/\` — one file each.`);
|
|
1252
|
-
L.push('');
|
|
1253
|
-
L.push('| Entry | What it does | File |');
|
|
1254
|
-
L.push('|---|---|---|');
|
|
1255
|
-
for (const e of entries) {
|
|
1256
|
-
L.push(`| \`${e.name}\` | ${e.summary.replace(/\|/g, '\\|')} | \`${c.key}/${kbEntryFileName(e.name)}.json\` |`);
|
|
1257
|
-
}
|
|
1258
|
-
L.push('');
|
|
1259
|
-
}
|
|
1260
|
-
return L.join('\n') + '\n';
|
|
1261
|
-
}
|
|
1262
1457
|
async function cmdPlatformKb(flags) {
|
|
1263
1458
|
const packDir = resolve(flags.dir ?? '.');
|
|
1264
|
-
const t =
|
|
1459
|
+
const t = readTarget(flags);
|
|
1460
|
+
if (!t.url)
|
|
1461
|
+
die('no platform url — pass --url <url>, set PACK_PLATFORM_URL, or run `octwin login`');
|
|
1265
1462
|
const { url } = t;
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1463
|
+
const ep = kbEndpoint(t);
|
|
1464
|
+
// ── --check: report staleness as an EXIT CODE, write nothing ──────────
|
|
1465
|
+
//
|
|
1466
|
+
// For a loop that wants to branch on "is my reference current?" without
|
|
1467
|
+
// parsing prose. 0 = current · 2 = stale (or never pulled) · 1 = could not
|
|
1468
|
+
// tell. Three codes, not two: an agent that treats "unreachable" as "stale"
|
|
1469
|
+
// re-pulls forever against an instance that is down.
|
|
1470
|
+
if (flags.check === true) {
|
|
1471
|
+
const local = readLocalKb(packDir);
|
|
1472
|
+
const polled = await fetchKbMeta(t, 10_000);
|
|
1473
|
+
if (!polled.ok) {
|
|
1474
|
+
console.error(polled.notAuthorized
|
|
1475
|
+
? '✗ cannot check — the platform refused the token, and this instance serves no anonymous reference.'
|
|
1476
|
+
: `✗ cannot check — ${url} did not answer.`);
|
|
1477
|
+
process.exit(1);
|
|
1478
|
+
}
|
|
1479
|
+
const remote = polled.meta.content_hash;
|
|
1480
|
+
if (!local?.content_hash) {
|
|
1481
|
+
console.log(`⬆ no reference pulled yet (platform is at ${remote ?? '?'}) — run \`octwin platform-kb pull\`.`);
|
|
1482
|
+
process.exit(2);
|
|
1483
|
+
}
|
|
1484
|
+
if (remote && remote !== local.content_hash) {
|
|
1485
|
+
console.log(`⬆ stale: local ${local.content_hash} → platform ${remote}. Run \`octwin platform-kb pull\`.`);
|
|
1486
|
+
process.exit(2);
|
|
1487
|
+
}
|
|
1488
|
+
console.log(`✓ current (${local.content_hash}).`);
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
// ── --if-stale: make "pull at the start of every session" free ────────
|
|
1492
|
+
//
|
|
1493
|
+
// One small `?meta=1` GET instead of ~1 MB, and it is what lets the skill say
|
|
1494
|
+
// "pull every session" without that costing a megabyte per session. An
|
|
1495
|
+
// unreachable platform is NOT treated as current: it falls through to the real
|
|
1496
|
+
// pull, which fails loudly with the actual error.
|
|
1497
|
+
if (flags['if-stale'] === true) {
|
|
1498
|
+
const local = readLocalKb(packDir);
|
|
1499
|
+
const polled = await fetchKbMeta(t, 10_000);
|
|
1500
|
+
if (polled.ok && local?.content_hash && polled.meta.content_hash === local.content_hash) {
|
|
1501
|
+
console.log(`✓ capability reference already current (${local.content_hash}) — nothing to pull.`);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
console.log(`→ Pulling the platform capability reference from ${url}${ep.anonymous ? ' (anonymous — no token needed for the reference)' : ''} …`);
|
|
1506
|
+
const res = await fetchOrDie(ep.url, { headers: ep.headers }, 'platform-kb pull');
|
|
1270
1507
|
const text = await res.text();
|
|
1271
1508
|
if (!res.ok) {
|
|
1272
1509
|
let j;
|
|
@@ -1317,6 +1554,9 @@ async function cmdPlatformKb(flags) {
|
|
|
1317
1554
|
// (or an unexpected payload shape) fall back to the flat file.
|
|
1318
1555
|
const byKey = new Map((bundle.index ?? []).map(e => [e.key, e]));
|
|
1319
1556
|
const exploded = new Map();
|
|
1557
|
+
/** Catalogs written as ONE file — the flow schema is the only one today, and the
|
|
1558
|
+
* symbol router still mines it for node op-keys. */
|
|
1559
|
+
const flatCatalogs = {};
|
|
1320
1560
|
let catalogCount = 0;
|
|
1321
1561
|
let entryCount = 0;
|
|
1322
1562
|
for (const [key, val] of Object.entries(bundle.sources ?? {})) {
|
|
@@ -1327,6 +1567,7 @@ async function cmdPlatformKb(flags) {
|
|
|
1327
1567
|
const entries = descriptor ? enumerateKbEntries(val, descriptor) : [];
|
|
1328
1568
|
if (entries.length === 0) {
|
|
1329
1569
|
writeFileSync(join(outDir, `${key}.json`), JSON.stringify(val, null, 2) + '\n', 'utf8');
|
|
1570
|
+
flatCatalogs[key] = val;
|
|
1330
1571
|
continue;
|
|
1331
1572
|
}
|
|
1332
1573
|
const dir = join(outDir, key);
|
|
@@ -1334,17 +1575,57 @@ async function cmdPlatformKb(flags) {
|
|
|
1334
1575
|
for (const entry of entries) {
|
|
1335
1576
|
writeFileSync(join(dir, `${kbEntryFileName(entry.name)}.json`), JSON.stringify(entry.value, null, 2) + '\n', 'utf8');
|
|
1336
1577
|
}
|
|
1578
|
+
// The ENVELOPE, when it carries anything beyond the collection.
|
|
1579
|
+
//
|
|
1580
|
+
// Exploding a catalog drops everything that is not an entry — which was fine
|
|
1581
|
+
// while the envelope held only `version`/`description`/`source`, and silently
|
|
1582
|
+
// wrong the moment a catalog published a rule that belongs to the whole set
|
|
1583
|
+
// rather than to one member. `system-entities` does exactly that: the reserved
|
|
1584
|
+
// entity keys, the reserved prefix, and the keys an `extends: system` entity
|
|
1585
|
+
// may not override are properties of the CATALOG. Written as `_catalog.json`
|
|
1586
|
+
// so the offline checks can read them.
|
|
1587
|
+
//
|
|
1588
|
+
// The `_` prefix is load-bearing: every loader that reads one of these
|
|
1589
|
+
// directories iterates `*.json`, so a sibling that is not an entry must be
|
|
1590
|
+
// skippable by name. See the `isEntryFile` guard the checks share.
|
|
1591
|
+
const envelope = Object.fromEntries(Object.entries(val)
|
|
1592
|
+
.filter(([k]) => k !== descriptor.at && !CATALOG_ENVELOPE_NOISE.has(k)));
|
|
1593
|
+
if (Object.keys(envelope).length > 0) {
|
|
1594
|
+
writeFileSync(join(dir, '_catalog.json'), JSON.stringify(envelope, null, 2) + '\n', 'utf8');
|
|
1595
|
+
}
|
|
1337
1596
|
exploded.set(key, entries);
|
|
1338
1597
|
entryCount += entries.length;
|
|
1339
1598
|
}
|
|
1340
|
-
//
|
|
1341
|
-
|
|
1599
|
+
// ── the three maps ────────────────────────────────────────────────────
|
|
1600
|
+
//
|
|
1601
|
+
// Three, not one, because an author arrives with three different things in
|
|
1602
|
+
// hand: a QUESTION (INDEX), a NAME (SYMBOLS), or a doc too heavy to read whole
|
|
1603
|
+
// (OUTLINE). One file answering all three is the file nobody can afford to
|
|
1604
|
+
// read — which is what INDEX.md had become at ~30 KB, most of it entry rows
|
|
1605
|
+
// that belong in a grep target.
|
|
1606
|
+
writeFileSync(join(outDir, 'INDEX.md'), buildKbIndexMarkdown(bundle, new Map([...exploded].map(([k, v]) => [k, v.length]))), 'utf8');
|
|
1607
|
+
// SYMBOLS.md — every addressable name → the file that defines it. Derived from
|
|
1608
|
+
// the catalogs just written, so it needs nothing extra over the wire.
|
|
1609
|
+
const symbols = buildSymbols(exploded, (catalogKey, entryName) => `${catalogKey}/${kbEntryFileName(entryName)}.json`, flatCatalogs);
|
|
1610
|
+
linkExplainers(symbols, new Map((bundle.index ?? [])
|
|
1611
|
+
.filter(e => e.kind === 'doc' && e.sections?.length)
|
|
1612
|
+
.map(e => [e.key, e.sections])));
|
|
1613
|
+
writeFileSync(join(outDir, 'SYMBOLS.md'), renderSymbolsMarkdown(symbols, bundle.content_hash), 'utf8');
|
|
1614
|
+
// OUTLINE.md — only when the platform published section data. An empty outline
|
|
1615
|
+
// would read as "these docs have no sections", which is worse than its absence.
|
|
1616
|
+
const outline = buildKbOutlineMarkdown(bundle);
|
|
1617
|
+
if (outline)
|
|
1618
|
+
writeFileSync(join(outDir, 'OUTLINE.md'), outline, 'utf8');
|
|
1342
1619
|
// Persist `content_hash` too — the staleness observer (`notifyIfKbStale`) reads
|
|
1343
1620
|
// it back and compares against the platform's current hash to nudge a re-pull.
|
|
1344
1621
|
writeFileSync(join(outDir, 'index.json'), JSON.stringify({ version: bundle.version, content_hash: bundle.content_hash, generated_at: bundle.generated_at, index: bundle.index }, null, 2) + '\n', 'utf8');
|
|
1345
1622
|
console.log(`✓ Pulled the Octwin platform KB → ${outDir}`);
|
|
1346
1623
|
console.log(` ${mdCount} markdown docs + ${catalogCount} catalogs (${entryCount} entries, one file each) — reference version ${bundle.version ?? '?'}`);
|
|
1347
|
-
console.log(
|
|
1624
|
+
console.log(` Three maps: INDEX.md (the corpus) · SYMBOLS.md (${symbols.length} names → their file, grep it)`
|
|
1625
|
+
+ `${outline ? ' · OUTLINE.md (every heading, with line numbers)' : ''}`);
|
|
1626
|
+
if (!outline) {
|
|
1627
|
+
console.log(' (no OUTLINE.md — this platform publishes no doc section data; upgrade it for line-addressable docs.)');
|
|
1628
|
+
}
|
|
1348
1629
|
console.log(' Every pack UNDER this directory finds it — `octwin validate` walks up to locate it,');
|
|
1349
1630
|
console.log(' so one pull at a repo root covers a whole monorepo of packs.');
|
|
1350
1631
|
// Changelog since the last pull — per-entry hashes tell us WHICH docs/catalogs
|
|
@@ -1366,7 +1647,7 @@ async function cmdPlatformKb(flags) {
|
|
|
1366
1647
|
}
|
|
1367
1648
|
console.log(' The octwin-pack authoring skill reads these as the source of truth for what the platform supports.');
|
|
1368
1649
|
}
|
|
1369
|
-
// ── records /
|
|
1650
|
+
// ── records / work / logs / chat — headless inspect + test with the deploy token ────
|
|
1370
1651
|
/** GET an admin endpoint with the deploy token; returns `{ status, json }`.
|
|
1371
1652
|
* Dies (with the URL) on a network failure; auth failures return so the
|
|
1372
1653
|
* caller can add command-specific context on top of `authFailureHint`. */
|
|
@@ -1528,9 +1809,9 @@ async function cmdRecords(flags) {
|
|
|
1528
1809
|
const { status, json } = await apiGet(`${base}/xrm/records?entity=${encodeURIComponent(entity)}&${pagingQs(flags)}`, t);
|
|
1529
1810
|
if (status !== 200) {
|
|
1530
1811
|
// Always show the server's reason (it names the unknown entity). Cases are
|
|
1531
|
-
//
|
|
1812
|
+
// worked records (worklist), not pack-declared XRM — point at the right command.
|
|
1532
1813
|
if (entity === 'case' || entity === 'cases') {
|
|
1533
|
-
console.error(` '${entity}' is
|
|
1814
|
+
console.error(` '${entity}' is a worked record (worklist), not a pack-declared XRM entity — inspect it with: octwin work`);
|
|
1534
1815
|
}
|
|
1535
1816
|
die(`could not read records (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
1536
1817
|
}
|
|
@@ -2206,70 +2487,86 @@ async function cmdMedia(flags) {
|
|
|
2206
2487
|
console.log(` saved → ${out}`);
|
|
2207
2488
|
console.log(` Send it into a chat: octwin chat "here you go" --media ${out ?? r.media_id} --as <handle>`);
|
|
2208
2489
|
}
|
|
2209
|
-
/** Reserved leading words on `octwin
|
|
2210
|
-
const
|
|
2490
|
+
/** Reserved leading words on `octwin work` — see `RECORD_VERBS` for the rule. */
|
|
2491
|
+
const WORK_VERBS = new Set(['assign', 'note', 'stage', 'decide']);
|
|
2492
|
+
/** A localized label bag (or legacy bare string) → one printable string.
|
|
2493
|
+
* The work routes ship labels as full bags so each consumer picks; the CLI
|
|
2494
|
+
* prefers English and falls back to whatever the bag has. */
|
|
2495
|
+
function pickLabel(v) {
|
|
2496
|
+
if (typeof v === 'string')
|
|
2497
|
+
return v;
|
|
2498
|
+
if (v && typeof v === 'object') {
|
|
2499
|
+
const bag = v;
|
|
2500
|
+
return bag.en ?? Object.values(bag)[0] ?? null;
|
|
2501
|
+
}
|
|
2502
|
+
return null;
|
|
2503
|
+
}
|
|
2211
2504
|
/**
|
|
2212
|
-
* The write half of `octwin
|
|
2505
|
+
* The write half of `octwin work` — assign / note / stage / decide.
|
|
2213
2506
|
*
|
|
2214
|
-
* `
|
|
2215
|
-
*
|
|
2216
|
-
*
|
|
2507
|
+
* `stage` is deliberately the XRM records verb (`POST …/xrm/records/:id/stage`):
|
|
2508
|
+
* the work surface carries no second transition spelling — a stage move has one
|
|
2509
|
+
* verb platform-wide (consolidation P-02).
|
|
2510
|
+
*
|
|
2511
|
+
* `decide --dry-run` routes to the PREVIEW endpoint, which sits behind `work:read`
|
|
2512
|
+
* rather than `work:write`: it renders the customer-facing copy and the resulting
|
|
2513
|
+
* stage without committing. That makes "show me what this disposition would do"
|
|
2217
2514
|
* safe to run with a read-only token, which is exactly when an author wants it.
|
|
2218
2515
|
*/
|
|
2219
|
-
async function
|
|
2516
|
+
async function cmdWorkWrite(flags) {
|
|
2220
2517
|
const t = resolveTarget(flags);
|
|
2221
2518
|
const { url } = t;
|
|
2222
2519
|
const base = `${url}/api/self/p`;
|
|
2223
2520
|
const verb = flags._[0];
|
|
2224
|
-
const id = flags._[1] ?? die(`usage: octwin
|
|
2225
|
-
const readBack = () => console.log(`\nRead it back: octwin
|
|
2521
|
+
const id = flags._[1] ?? die(`usage: octwin work ${verb} <recordId> …`);
|
|
2522
|
+
const readBack = () => console.log(`\nRead it back: octwin work ${id}`);
|
|
2226
2523
|
if (verb === 'assign') {
|
|
2227
2524
|
// `--to none` unassigns (the route takes null); anything else must carry the
|
|
2228
2525
|
// principal kind, because a bare uuid cannot say user-or-team.
|
|
2229
2526
|
const to = typeof flags.to === 'string' ? flags.to
|
|
2230
|
-
: die('usage: octwin
|
|
2527
|
+
: die('usage: octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none');
|
|
2231
2528
|
const assignee = to === 'none' ? null : to;
|
|
2232
2529
|
if (assignee !== null && !/^(user|team):/.test(assignee)) {
|
|
2233
2530
|
die(`--to must be 'user:<uuid>', 'team:<uuid>' or 'none' (got '${to}')`);
|
|
2234
2531
|
}
|
|
2235
|
-
console.log(`→ ${assignee === null ? 'Unassigning' : `Assigning to ${assignee}`}
|
|
2236
|
-
const { status, json } = await apiSend('PATCH', `${base}/
|
|
2532
|
+
console.log(`→ ${assignee === null ? 'Unassigning' : `Assigning to ${assignee}`} work item ${id} …`);
|
|
2533
|
+
const { status, json } = await apiSend('PATCH', `${base}/work/${encodeURIComponent(id)}/assign`, { assignee }, t);
|
|
2237
2534
|
if (status !== 200)
|
|
2238
|
-
writeFail(`assign
|
|
2535
|
+
writeFail(`assign work item ${id}`, status, json, url, true);
|
|
2239
2536
|
console.log(assignee === null ? '✓ Unassigned.' : `✓ Assigned to ${json?.assignee ?? assignee}.`);
|
|
2240
2537
|
return;
|
|
2241
2538
|
}
|
|
2242
2539
|
if (verb === 'note') {
|
|
2243
|
-
const note = flags._[2] ?? die('usage: octwin
|
|
2244
|
-
console.log(`→ Adding a note to
|
|
2245
|
-
const { status, json } = await apiSend('POST', `${base}/
|
|
2540
|
+
const note = flags._[2] ?? die('usage: octwin work note <recordId> "the note text"');
|
|
2541
|
+
console.log(`→ Adding a note to work item ${id} …`);
|
|
2542
|
+
const { status, json } = await apiSend('POST', `${base}/work/${encodeURIComponent(id)}/note`, { note }, t);
|
|
2246
2543
|
if (status !== 200)
|
|
2247
|
-
writeFail(`note
|
|
2248
|
-
console.log('✓ Note added to the
|
|
2544
|
+
writeFail(`note work item ${id}`, status, json, url, true);
|
|
2545
|
+
console.log('✓ Note added to the record timeline.');
|
|
2249
2546
|
readBack();
|
|
2250
2547
|
return;
|
|
2251
2548
|
}
|
|
2252
|
-
if (verb === '
|
|
2549
|
+
if (verb === 'stage') {
|
|
2253
2550
|
const to = typeof flags.to === 'string' ? flags.to
|
|
2254
|
-
: die('usage: octwin
|
|
2255
|
-
const body = {
|
|
2551
|
+
: die('usage: octwin work stage <recordId> --to <stage> [--note "..."]');
|
|
2552
|
+
const body = { to_stage: to };
|
|
2256
2553
|
if (typeof flags.note === 'string')
|
|
2257
2554
|
body.note = flags.note;
|
|
2258
|
-
console.log(`→ Moving
|
|
2259
|
-
const { status, json } = await apiSend('POST', `${base}/
|
|
2555
|
+
console.log(`→ Moving record ${id} to '${to}' …`);
|
|
2556
|
+
const { status, json } = await apiSend('POST', `${base}/xrm/records/${encodeURIComponent(id)}/stage`, body, t);
|
|
2260
2557
|
if (status !== 200) {
|
|
2261
|
-
// The
|
|
2558
|
+
// The work detail read carries the legal targets; point at it rather than
|
|
2262
2559
|
// leaving the author to guess the vocabulary.
|
|
2263
2560
|
if (status === 400)
|
|
2264
|
-
console.error(` → legal targets for this
|
|
2265
|
-
writeFail(`move
|
|
2561
|
+
console.error(` → legal targets for this record: octwin work ${id} (see its workflow)`);
|
|
2562
|
+
writeFail(`move record ${id} to '${to}'`, status, json, url, true);
|
|
2266
2563
|
}
|
|
2267
|
-
console.log(`✓
|
|
2564
|
+
console.log(`✓ Record is now '${json?.stage ?? to}'.`);
|
|
2268
2565
|
return;
|
|
2269
2566
|
}
|
|
2270
|
-
// decide
|
|
2567
|
+
// decide — apply one of the entity's declared operator actions
|
|
2271
2568
|
const action = typeof flags.action === 'string' ? flags.action
|
|
2272
|
-
: die('usage: octwin
|
|
2569
|
+
: die('usage: octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]');
|
|
2273
2570
|
const params = {};
|
|
2274
2571
|
for (const pair of flagList(flags, 'param')) {
|
|
2275
2572
|
const eq = pair.indexOf('=');
|
|
@@ -2281,39 +2578,41 @@ async function cmdCasesWrite(flags) {
|
|
|
2281
2578
|
const body = { action, ...(Object.keys(params).length ? { params } : {}) };
|
|
2282
2579
|
if (!dryRun && typeof flags.note === 'string')
|
|
2283
2580
|
body.internal_note = flags.note;
|
|
2284
|
-
console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on
|
|
2285
|
-
const endpoint = `${base}/
|
|
2581
|
+
console.log(`→ ${dryRun ? 'Previewing' : 'Applying'} '${action}' on work item ${id} …`);
|
|
2582
|
+
const endpoint = `${base}/work/${encodeURIComponent(id)}/action${dryRun ? '/preview' : ''}`;
|
|
2286
2583
|
const { status, json } = await apiSend('POST', endpoint, body, t);
|
|
2287
2584
|
if (status === 404)
|
|
2288
|
-
die(`
|
|
2585
|
+
die(`work item '${id}' not found`);
|
|
2289
2586
|
if (status !== 200) {
|
|
2290
|
-
console.error(` → the
|
|
2291
|
-
writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on
|
|
2587
|
+
console.error(` → the item's applicable actions are listed by: octwin work ${id}`);
|
|
2588
|
+
writeFail(`${dryRun ? 'preview' : 'apply'} '${action}' on work item ${id}`, status, json, url, true);
|
|
2292
2589
|
}
|
|
2293
2590
|
if (dryRun) {
|
|
2294
2591
|
console.log('Preview (nothing was committed):');
|
|
2295
2592
|
console.log(JSON.stringify(json, null, 2));
|
|
2296
2593
|
return;
|
|
2297
2594
|
}
|
|
2298
|
-
console.log(`✓ Applied '${action}' —
|
|
2299
|
-
// `notified`
|
|
2300
|
-
//
|
|
2301
|
-
|
|
2595
|
+
console.log(`✓ Applied '${action}'${json?.to_stage ? ` — record is now '${json.to_stage}'` : ''}.`);
|
|
2596
|
+
// `relayed`/`notified` are the customer-facing half; silence here usually means
|
|
2597
|
+
// the action had no message template, which is easy to mistake for a failure.
|
|
2598
|
+
const reached = json?.relayed || json?.notified;
|
|
2599
|
+
console.log(reached ? ' ✓ the customer was notified.' : ' ⓘ no customer notification was sent by this action.');
|
|
2302
2600
|
readBack();
|
|
2303
2601
|
}
|
|
2304
|
-
/** `octwin
|
|
2305
|
-
* the aggregate inbox, one
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2602
|
+
/** `octwin work [recordId] [--queues]` — inspect the work inbox (every entity the
|
|
2603
|
+
* pack declares worked): the aggregate inbox, one item + its timeline, or the
|
|
2604
|
+
* queue list. */
|
|
2605
|
+
async function cmdWork(flags) {
|
|
2606
|
+
if (typeof flags._[0] === 'string' && WORK_VERBS.has(flags._[0]))
|
|
2607
|
+
return cmdWorkWrite(flags);
|
|
2309
2608
|
const t = resolveTarget(flags);
|
|
2310
2609
|
const { url } = t;
|
|
2311
2610
|
const base = `${url}/api/self/p`;
|
|
2312
|
-
const
|
|
2611
|
+
const recordId = flags._[0];
|
|
2313
2612
|
const asJson = flags.json === true;
|
|
2314
2613
|
if (!asJson)
|
|
2315
|
-
console.log(`→ Reading ${flags.queues === true ? '
|
|
2316
|
-
const
|
|
2614
|
+
console.log(`→ Reading ${flags.queues === true ? 'work queues' : recordId ? `work item ${recordId}` : 'the work inbox'} from ${targetLabel(t)} …`);
|
|
2615
|
+
const workFail = (what, status, json) => {
|
|
2317
2616
|
// A 403 here can also be an RBAC gap the scope hint can't see — a role whose
|
|
2318
2617
|
// grants don't reach the queue passes the scope gate and still gets nothing.
|
|
2319
2618
|
if (status === 403)
|
|
@@ -2321,72 +2620,74 @@ async function cmdCases(flags) {
|
|
|
2321
2620
|
die(`could not read ${what} (HTTP ${status})${errDetail(json)}${authFailureDetail(status, url)}`);
|
|
2322
2621
|
};
|
|
2323
2622
|
if (flags.queues === true) {
|
|
2324
|
-
const { status, json } = await apiGet(`${base}/
|
|
2623
|
+
const { status, json } = await apiGet(`${base}/work/queues`, t);
|
|
2325
2624
|
if (status !== 200)
|
|
2326
|
-
|
|
2625
|
+
workFail('work queues', status, json);
|
|
2327
2626
|
if (asJson) {
|
|
2328
2627
|
console.log(JSON.stringify(json, null, 2));
|
|
2329
2628
|
return;
|
|
2330
2629
|
}
|
|
2331
2630
|
const queues = (json?.queues ?? []);
|
|
2332
|
-
console.log(`
|
|
2333
|
-
for (const q of queues)
|
|
2334
|
-
|
|
2631
|
+
console.log(`Work queues in ${targetLabel(t)}:`);
|
|
2632
|
+
for (const q of queues) {
|
|
2633
|
+
const name = pickLabel(q.name);
|
|
2634
|
+
console.log(` ${q.key}${name ? ` (${name})` : ''} ${q.open_count} open`);
|
|
2635
|
+
}
|
|
2335
2636
|
if (json?.unrouted_open_count)
|
|
2336
2637
|
console.log(` (unrouted: ${json.unrouted_open_count} open)`);
|
|
2337
2638
|
return;
|
|
2338
2639
|
}
|
|
2339
|
-
if (!
|
|
2340
|
-
const { status, json } = await apiGet(`${base}/
|
|
2640
|
+
if (!recordId) {
|
|
2641
|
+
const { status, json } = await apiGet(`${base}/work?${pagingQs(flags)}`, t);
|
|
2341
2642
|
if (status !== 200)
|
|
2342
|
-
|
|
2643
|
+
workFail('the work inbox', status, json);
|
|
2343
2644
|
if (asJson) {
|
|
2344
2645
|
console.log(JSON.stringify(json, null, 2));
|
|
2345
2646
|
return;
|
|
2346
2647
|
}
|
|
2347
2648
|
const page = readPage(json);
|
|
2348
|
-
console.log(`
|
|
2649
|
+
console.log(`Work items in ${targetLabel(t)}: ${page.total ?? page.rows.length} total`);
|
|
2349
2650
|
if (page.rows.length === 0)
|
|
2350
2651
|
console.log(' (none)');
|
|
2351
|
-
for (const
|
|
2352
|
-
const sla =
|
|
2353
|
-
console.log(` #${
|
|
2652
|
+
for (const w of page.rows) {
|
|
2653
|
+
const sla = w.sla_due_at ? ` sla:${w.sla_due_at}` : '';
|
|
2654
|
+
console.log(` #${w.record_number ?? '?'} ${w.entity}${w.type ? `/${w.type}` : ''} [${w.stage ?? '?'}] ${w.priority}${w.queue_key ? ` q:${w.queue_key}` : ''}${sla} ${w.record_id}`);
|
|
2354
2655
|
}
|
|
2355
|
-
const more = morePageHint(page, 'octwin
|
|
2656
|
+
const more = morePageHint(page, 'octwin work');
|
|
2356
2657
|
if (more)
|
|
2357
2658
|
console.log(more);
|
|
2358
|
-
console.log('\nOne
|
|
2659
|
+
console.log('\nOne item + timeline: octwin work <recordId> queues: octwin work --queues');
|
|
2359
2660
|
return;
|
|
2360
2661
|
}
|
|
2361
|
-
const { status, json } = await apiGet(`${base}/
|
|
2662
|
+
const { status, json } = await apiGet(`${base}/work/${encodeURIComponent(recordId)}`, t);
|
|
2362
2663
|
if (status === 404)
|
|
2363
|
-
die(`
|
|
2664
|
+
die(`work item '${recordId}' not found`);
|
|
2364
2665
|
if (status !== 200)
|
|
2365
|
-
|
|
2666
|
+
workFail('work item', status, json);
|
|
2366
2667
|
if (asJson) {
|
|
2367
2668
|
console.log(JSON.stringify(json, null, 2));
|
|
2368
2669
|
return;
|
|
2369
2670
|
}
|
|
2370
|
-
const
|
|
2371
|
-
console.log(
|
|
2372
|
-
console.log(` id: ${
|
|
2671
|
+
const w = json?.item ?? {};
|
|
2672
|
+
console.log(`${pickLabel(json?.entity_label) ?? w.entity} #${w.record_number ?? '?'}${w.type ? ` ${w.type}` : ''} [${w.stage ?? '?'}] ${w.priority}`);
|
|
2673
|
+
console.log(` id: ${w.record_id} queue: ${w.queue_key ?? '(unrouted)'} assignee: ${w.assignee_principal ?? '(none)'}`);
|
|
2373
2674
|
if (json?.contact)
|
|
2374
2675
|
console.log(` contact: ${json.contact.display_name ?? json.contact.channel_contact_handle ?? json.contact.id}`);
|
|
2375
|
-
if (
|
|
2376
|
-
console.log(` conversation: ${
|
|
2377
|
-
if (
|
|
2378
|
-
console.log(` sla due: ${
|
|
2379
|
-
if (
|
|
2380
|
-
console.log(` fields: ${JSON.stringify(
|
|
2381
|
-
const events = (json?.
|
|
2676
|
+
if (w.conversation_id)
|
|
2677
|
+
console.log(` conversation: ${w.conversation_id} (octwin logs ${w.conversation_id})`);
|
|
2678
|
+
if (w.sla_due_at)
|
|
2679
|
+
console.log(` sla due: ${w.sla_due_at}`);
|
|
2680
|
+
if (w.fields && Object.keys(w.fields).length > 0)
|
|
2681
|
+
console.log(` fields: ${JSON.stringify(w.fields)}`);
|
|
2682
|
+
const events = (json?.timeline ?? []);
|
|
2382
2683
|
console.log(` Timeline (${events.length}):`);
|
|
2383
2684
|
for (const e of events) {
|
|
2384
2685
|
const payload = e.payload && Object.keys(e.payload).length > 0 ? ` ${JSON.stringify(e.payload)}` : '';
|
|
2385
2686
|
console.log(` ${e.ts ?? ''} ${e.kind}${e.actor ? ` (${e.actor})` : ''}${payload}`);
|
|
2386
2687
|
}
|
|
2387
|
-
const
|
|
2388
|
-
if (
|
|
2389
|
-
console.log(`
|
|
2688
|
+
const actions = (json?.actions ?? []);
|
|
2689
|
+
if (actions.length > 0) {
|
|
2690
|
+
console.log(` Actions: ${actions.map((a) => `${a.action}${a.to_stage ? `→${a.to_stage}` : ''}`).join(', ')}`);
|
|
2390
2691
|
}
|
|
2391
2692
|
}
|
|
2392
2693
|
// ── money formatting (orders / catalog) ─────────────────────────────────────
|
|
@@ -2523,6 +2824,10 @@ async function cmdAgentsWrite(flags) {
|
|
|
2523
2824
|
* (`/api/self/t/`), unlike `agents` — the list is a property of the workspace.
|
|
2524
2825
|
*/
|
|
2525
2826
|
async function cmdProjects(flags) {
|
|
2827
|
+
if (flags._[0] === 'create')
|
|
2828
|
+
return cmdProjectsCreate(flags);
|
|
2829
|
+
if (flags._[0] === 'rm')
|
|
2830
|
+
return cmdProjectsRm(flags);
|
|
2526
2831
|
const t = resolveTarget(flags);
|
|
2527
2832
|
const { url } = t;
|
|
2528
2833
|
const asJson = flags.json === true;
|
|
@@ -2558,6 +2863,128 @@ async function cmdProjects(flags) {
|
|
|
2558
2863
|
console.log('\nUse one as: octwin deploy --project <slug>');
|
|
2559
2864
|
if (!archived)
|
|
2560
2865
|
console.log('Archived too: octwin projects --archived');
|
|
2866
|
+
console.log('New one: octwin projects create "<name>"');
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* `octwin projects create "<name>" [--slug <slug>] [--pack <packId>]`
|
|
2870
|
+
*
|
|
2871
|
+
* The missing half of the deploy loop. `octwin deploy` has always needed a project
|
|
2872
|
+
* that already exists, and the CLI could only LIST them — so standing up a throwaway
|
|
2873
|
+
* end-to-end deployment meant opening the console or asking an operator. With this,
|
|
2874
|
+
* a full disposable environment is two commands:
|
|
2875
|
+
*
|
|
2876
|
+
* octwin projects create "Scratch" # → slug `scratch`
|
|
2877
|
+
* octwin deploy --project scratch --seed # publish + install + demo data
|
|
2878
|
+
* octwin chat "hi" --project scratch # talk to it
|
|
2879
|
+
* octwin projects rm scratch --yes # throw it away
|
|
2880
|
+
*
|
|
2881
|
+
* A demo is deliberately NOT a special kind of thing — it is an ordinary project in
|
|
2882
|
+
* the developer's own workspace, so it inherits their plan, entitlements, RBAC and
|
|
2883
|
+
* teardown with no bespoke lifecycle to keep honest.
|
|
2884
|
+
*
|
|
2885
|
+
* `packs: []` is the default because the very next step is normally `octwin deploy`,
|
|
2886
|
+
* which publishes the working tree AND installs it. `--pack` is for an ALREADY
|
|
2887
|
+
* published pack (it resolves through `pack_registry` and fails fast if absent).
|
|
2888
|
+
*/
|
|
2889
|
+
async function cmdProjectsCreate(flags) {
|
|
2890
|
+
// Argument check BEFORE `resolveTarget`, so a missing name reports the usage line
|
|
2891
|
+
// rather than "no platform url" — an argument mistake must not be masked by a
|
|
2892
|
+
// config one the author may not even have.
|
|
2893
|
+
const name = flags._[1];
|
|
2894
|
+
if (!name)
|
|
2895
|
+
die('usage: octwin projects create "<name>" [--slug <slug>] [--pack <packId>]');
|
|
2896
|
+
const t = resolveTarget(flags);
|
|
2897
|
+
const { url } = t;
|
|
2898
|
+
// The Project URL is DERIVED from the name and uniquified server-side unless the
|
|
2899
|
+
// caller pins one — same contract the console's create form uses, so the two
|
|
2900
|
+
// cannot disagree about what slug a given name produces.
|
|
2901
|
+
const body = { name, packs: flags.pack ? [flags.pack] : [] };
|
|
2902
|
+
if (typeof flags.slug === 'string')
|
|
2903
|
+
body.slug = flags.slug;
|
|
2904
|
+
console.log(`→ Creating project "${name}" in ${targetLabel(t)} …`);
|
|
2905
|
+
const { status, json } = await apiSend('POST', `${url}/api/self/t/projects`, body, t);
|
|
2906
|
+
// 402 is the plan cap, and it is the ONE failure here with a non-obvious fix, so it
|
|
2907
|
+
// gets the server's own sentence rather than a generic write failure.
|
|
2908
|
+
if (status === 402)
|
|
2909
|
+
die(`${json?.error ?? 'project limit reached'} — free the slot with \`octwin projects rm <slug> --yes\`, or upgrade the plan.`);
|
|
2910
|
+
if (status !== 200 && status !== 201)
|
|
2911
|
+
writeFail(`create project "${name}"`, status, json, url);
|
|
2912
|
+
if (flags.json === true) {
|
|
2913
|
+
console.log(JSON.stringify(json, null, 2));
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
const slug = json?.slug ?? flags.slug ?? '(unknown)';
|
|
2917
|
+
const installed = (json?.installed_packs ?? []);
|
|
2918
|
+
console.log(`✓ Project created — ${slug}`);
|
|
2919
|
+
for (const p of installed)
|
|
2920
|
+
console.log(` installed ${p.pack_id}@${p.version}`);
|
|
2921
|
+
console.log('\nNext:');
|
|
2922
|
+
console.log(` octwin deploy --project ${slug} --seed`);
|
|
2923
|
+
console.log(` octwin chat "hi" --project ${slug}`);
|
|
2924
|
+
}
|
|
2925
|
+
/**
|
|
2926
|
+
* `octwin projects rm <slug> --yes`
|
|
2927
|
+
*
|
|
2928
|
+
* HARD delete — the row and everything the FK graph cascades from it (conversations,
|
|
2929
|
+
* contacts, records, installs, webhooks). Not the archive verb; there is no undo.
|
|
2930
|
+
*
|
|
2931
|
+
* `--yes` is required rather than prompted because the CLI is non-interactive by
|
|
2932
|
+
* design (it runs under `npx`, in scripts and in CI, where a prompt reads EOF and a
|
|
2933
|
+
* "safe" default would be a lie). Without it this prints the same impact preview the
|
|
2934
|
+
* console's confirm dialog shows — derived from `pg_constraint`, not a hand-written
|
|
2935
|
+
* list — and stops. That makes the dry run the DEFAULT, which is the right way round
|
|
2936
|
+
* for an irreversible verb.
|
|
2937
|
+
*/
|
|
2938
|
+
async function cmdProjectsRm(flags) {
|
|
2939
|
+
const slug = flags._[1];
|
|
2940
|
+
if (!slug)
|
|
2941
|
+
die('usage: octwin projects rm <slug> --yes (omit --yes to preview what it destroys)');
|
|
2942
|
+
const t = resolveTarget(flags);
|
|
2943
|
+
const { url } = t;
|
|
2944
|
+
const preview = await apiGet(`${url}/api/self/t/projects/${encodeURIComponent(slug)}/preview-hard-delete`, t);
|
|
2945
|
+
if (preview.status === 404)
|
|
2946
|
+
die(`no project '${slug}' in ${targetLabel(t)} — \`octwin projects\` lists them`);
|
|
2947
|
+
if (preview.status !== 200)
|
|
2948
|
+
die(`could not preview the delete (HTTP ${preview.status})${errDetail(preview.json)}${authFailureDetail(preview.status, url)}`);
|
|
2949
|
+
if (flags.json === true && flags.yes !== true) {
|
|
2950
|
+
console.log(JSON.stringify(preview.json, null, 2));
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
// Shapes come from `HardDeletePreview` (routes/_hard-delete-preview.ts) — the same
|
|
2954
|
+
// payload the console's confirm dialog renders, so the two can't disagree about
|
|
2955
|
+
// what a delete costs.
|
|
2956
|
+
const tables = (preview.json?.tables ?? []);
|
|
2957
|
+
const hits = tables.filter(r => r.count > 0);
|
|
2958
|
+
const totals = preview.json?.totals ?? {};
|
|
2959
|
+
console.log(`Deleting project ${slug} from ${targetLabel(t)} destroys:`);
|
|
2960
|
+
if (hits.length === 0)
|
|
2961
|
+
console.log(' (nothing — the project has no rows yet)');
|
|
2962
|
+
for (const r of hits) {
|
|
2963
|
+
const mark = r.disposition === 'cascade' ? '' : ` [${r.disposition}]`;
|
|
2964
|
+
console.log(` ${r.count}${r.capped ? '+' : ''}\t${r.schema}.${r.table}${mark}`);
|
|
2965
|
+
}
|
|
2966
|
+
if (hits.length > 0) {
|
|
2967
|
+
console.log(` — ${totals.rows_deleted}${totals.rows_deleted_capped ? '+' : ''} rows across ${totals.tables_affected} tables`);
|
|
2968
|
+
}
|
|
2969
|
+
// Side effects no FK walk can see (storage blobs, agent memory, Meta registrations).
|
|
2970
|
+
// Anything not `deleted` is what SURVIVES the delete — the part worth reading.
|
|
2971
|
+
const residue = (preview.json?.residue ?? []);
|
|
2972
|
+
const surviving = residue.filter(r => r.disposition !== 'deleted');
|
|
2973
|
+
if (surviving.length > 0) {
|
|
2974
|
+
console.log('\nNot removed by the cascade:');
|
|
2975
|
+
for (const r of surviving)
|
|
2976
|
+
console.log(` [${r.disposition}] ${r.label}${r.count != null ? ` (${r.count})` : ''} — ${r.detail}`);
|
|
2977
|
+
}
|
|
2978
|
+
if (totals.blocked > 0)
|
|
2979
|
+
console.log(`\n! ${totals.blocked} table(s) would BLOCK this delete.`);
|
|
2980
|
+
if (flags.yes !== true) {
|
|
2981
|
+
console.log('\nNothing was deleted. Re-run with --yes to go through with it.');
|
|
2982
|
+
return;
|
|
2983
|
+
}
|
|
2984
|
+
const { status, json } = await apiSend('DELETE', `${url}/api/self/t/projects/${encodeURIComponent(slug)}/hard`, undefined, t);
|
|
2985
|
+
if (status !== 200 && status !== 204)
|
|
2986
|
+
writeFail(`delete project '${slug}'`, status, json, url);
|
|
2987
|
+
console.log(`\n✓ Deleted ${slug}.`);
|
|
2561
2988
|
}
|
|
2562
2989
|
async function cmdAgents(flags) {
|
|
2563
2990
|
if (flags._[0] === 'set')
|
|
@@ -2677,21 +3104,32 @@ async function cmdOrdersWrite(flags) {
|
|
|
2677
3104
|
if (verb === 'transition') {
|
|
2678
3105
|
const to = typeof flags.to === 'string' ? flags.to
|
|
2679
3106
|
: die('usage: octwin orders transition <reference_id> --to <status>');
|
|
3107
|
+
// A stage move has ONE verb platform-wide — `POST …/xrm/records/:id/stage`
|
|
3108
|
+
// (`POST …/orders/:ref/transition` was retired, consolidation P-02). The order
|
|
3109
|
+
// detail read resolves the opaque reference to the record id and carries the
|
|
3110
|
+
// legal targets for the failure hint.
|
|
2680
3111
|
console.log(`→ Moving order ${ref} to '${to}' …`);
|
|
2681
|
-
const
|
|
2682
|
-
if (status === 404)
|
|
3112
|
+
const detail = await apiGet(`${base}/${encodeURIComponent(ref)}`, t);
|
|
3113
|
+
if (detail.status === 404)
|
|
2683
3114
|
die(`order '${ref}' not found (pass the opaque reference_id, not the #number)`);
|
|
2684
|
-
if (status
|
|
3115
|
+
if (detail.status !== 200)
|
|
3116
|
+
writeFail(`read order ${ref}`, detail.status, detail.json, url);
|
|
3117
|
+
const recordId = detail.json?.order?.id;
|
|
3118
|
+
if (!recordId)
|
|
3119
|
+
die(`order '${ref}' carries no record id — cannot move its stage`);
|
|
3120
|
+
const { status, json } = await apiSend('POST', `${url}/api/self/p/xrm/records/${encodeURIComponent(recordId)}/stage`, { to_stage: to }, t);
|
|
3121
|
+
if (status === 400 || status === 409) {
|
|
2685
3122
|
console.error(`✗ '${to}' is not a legal move for this order.`);
|
|
2686
|
-
|
|
2687
|
-
|
|
3123
|
+
const allowed = (detail.json?.transitions ?? []);
|
|
3124
|
+
if (allowed.length > 0)
|
|
3125
|
+
console.error(` → allowed: ${allowed.join(', ')}`);
|
|
2688
3126
|
else
|
|
2689
3127
|
console.error(` → see the allowed set: octwin orders ${ref}`);
|
|
2690
3128
|
process.exit(1);
|
|
2691
3129
|
}
|
|
2692
3130
|
if (status !== 200)
|
|
2693
3131
|
writeFail(`move order ${ref} to '${to}'`, status, json, url);
|
|
2694
|
-
console.log(`✓ Order is now '${json?.
|
|
3132
|
+
console.log(`✓ Order is now '${json?.stage ?? to}'.`);
|
|
2695
3133
|
return;
|
|
2696
3134
|
}
|
|
2697
3135
|
// refund — irreversible, and money. `--force` rather than a prompt: the CLI is
|
|
@@ -3236,7 +3674,7 @@ function help() {
|
|
|
3236
3674
|
octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
|
|
3237
3675
|
octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
|
|
3238
3676
|
octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
|
|
3239
|
-
octwin
|
|
3677
|
+
octwin work [recordId] [--queues] [--json] # inspect the work inbox (worked records) + timelines
|
|
3240
3678
|
octwin logs [conversationId] [--as <handle>] [--json] # list conversations / show one's event timeline
|
|
3241
3679
|
octwin chat "message" [--as <handle>] [--tap <tap-id>] [--media <file|id>] [--json] # drive a turn (+ send media) + print every render
|
|
3242
3680
|
octwin media generate "<prompt>" [--out <file.png>] [--size 1024x1024] [--json] # AI-generate an image → MEDIA- handle (needs media:generate scope)
|
|
@@ -3245,15 +3683,15 @@ function help() {
|
|
|
3245
3683
|
octwin analytics [entity] [--funnel|--overview|--milestones|--trends|--cost] [--stage <id>] # stage conversion for any pipelined entity
|
|
3246
3684
|
octwin catalog [--readiness] [--json] # commerce products + stock + the WhatsApp catalog binding
|
|
3247
3685
|
octwin scheduling [--slots <resourceRecordId>] [--from YYYY-MM-DD] [--days n] # engine state / computed slots
|
|
3248
|
-
octwin platform-kb [pull] [--dir .] [--url <url>]
|
|
3686
|
+
octwin platform-kb [pull] [--if-stale|--check] [--dir .] [--url <url>] # no token needed
|
|
3249
3687
|
octwin test [--dir .] # = validate --remote (the full platform check)
|
|
3250
3688
|
octwin feedback [--dir .] # submit this pack's FEEDBACK.md to the platform team
|
|
3251
3689
|
|
|
3252
3690
|
Writes — exercise the state your pack creates (each needs the matching :write scope):
|
|
3253
3691
|
octwin records create <entity> --set field=value … # also: patch <id> --entity <e>, stage <id> --to <s>, note <id> "…"
|
|
3254
3692
|
octwin records tasks | task complete <taskId> [--outcome done|cancelled]
|
|
3255
|
-
octwin
|
|
3256
|
-
octwin
|
|
3693
|
+
octwin work assign <id> --to user:<uuid>|none | note <id> "…" | stage <id> --to <stage>
|
|
3694
|
+
octwin work decide <id> --action <a> [--param k=v] [--dry-run] # --dry-run previews, commits nothing
|
|
3257
3695
|
octwin orders transition <ref> --to <status> | refund <ref> --force
|
|
3258
3696
|
octwin catalog availability <sku> --to "in stock" | stock <sku> [--set-on-hand n]
|
|
3259
3697
|
octwin scheduling rules --resource <id> | rule add|rm | exception add|rm
|
|
@@ -3287,15 +3725,37 @@ const COMMAND_HELP = {
|
|
|
3287
3725
|
projects: `octwin projects [--archived] [--json]
|
|
3288
3726
|
List the workspace's projects — the slugs every --project flag takes, with the
|
|
3289
3727
|
plan's project cap. --archived includes archived ones. A pack:deploy token
|
|
3290
|
-
reaches this (it names a project in every other command)
|
|
3728
|
+
reaches this (it names a project in every other command).
|
|
3729
|
+
|
|
3730
|
+
octwin projects create "<name>" [--slug <slug>] [--pack <packId>]
|
|
3731
|
+
Create a project. The URL slug is derived from the name unless --slug pins one.
|
|
3732
|
+
--pack installs an ALREADY-published pack; the usual next step is instead
|
|
3733
|
+
\`octwin deploy --project <slug>\`, which publishes this working tree and installs it.
|
|
3734
|
+
|
|
3735
|
+
octwin projects rm <slug> [--yes]
|
|
3736
|
+
HARD delete — the project and everything cascading from it (conversations,
|
|
3737
|
+
contacts, records, installs). No undo, and not the same as archiving.
|
|
3738
|
+
WITHOUT --yes it only previews what would be destroyed, so the dry run is the
|
|
3739
|
+
default. Together these make a disposable end-to-end environment:
|
|
3740
|
+
octwin projects create "Scratch" && octwin deploy --project scratch --seed
|
|
3741
|
+
octwin chat "hi" --project scratch
|
|
3742
|
+
octwin projects rm scratch --yes
|
|
3743
|
+
Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
|
|
3291
3744
|
deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
3292
3745
|
Upload the pack bundle, validate server-side, install onto the project.
|
|
3293
3746
|
--seed additionally applies the pack's demo seed (streams progress).`,
|
|
3747
|
+
seed: `octwin seed [--pack <packId>]
|
|
3748
|
+
Apply the pack's demo/reference data to the project it is installed on, without
|
|
3749
|
+
redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
|
|
3750
|
+
and the demo operator topology. Reports what each kind produced.
|
|
3751
|
+
Idempotent and safe to re-run — records upsert, and existing media is REUSED rather
|
|
3752
|
+
than regenerated, so a second pass costs nothing. --pack is only needed when a
|
|
3753
|
+
project somehow runs more than one.`,
|
|
3294
3754
|
status: `octwin status [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>]
|
|
3295
3755
|
Show installed vs live version + the flow list for this pack.`,
|
|
3296
3756
|
records: `octwin records [entity] [id] [--limit 50] [--offset n]
|
|
3297
|
-
Inspect the pack's XRM data. No args = list entities.
|
|
3298
|
-
|
|
3757
|
+
Inspect the pack's XRM data. No args = list entities. Worked records (cases,
|
|
3758
|
+
tickets, anything routed to a queue) read best through \`octwin work\`.
|
|
3299
3759
|
|
|
3300
3760
|
WRITES (need \`records:write\`; every one is re-checked by RBAC on the record):
|
|
3301
3761
|
octwin records create <entity> --set field=value [--set …] [--stage s] [--contact <id>]
|
|
@@ -3310,19 +3770,21 @@ const COMMAND_HELP = {
|
|
|
3310
3770
|
\`patch\` needs --entity even though it has an id: the route resolves the field
|
|
3311
3771
|
validator from it. A leading \`create/patch/stage/note/tasks/task\` is read as a
|
|
3312
3772
|
VERB — to list an entity actually named one of those, use \`--entity <name>\`.`,
|
|
3313
|
-
|
|
3314
|
-
Inspect
|
|
3315
|
-
|
|
3773
|
+
work: `octwin work [recordId] [--queues] [--limit 50] [--offset n] [--json]
|
|
3774
|
+
Inspect the work inbox — every entity the pack declares worked (cases, orders
|
|
3775
|
+
needing review, applications, …): the inbox, one item + its timeline
|
|
3776
|
+
(+ applicable actions), or --queues for queue keys + open counts.
|
|
3316
3777
|
|
|
3317
|
-
WRITES (need \`
|
|
3318
|
-
octwin
|
|
3319
|
-
octwin
|
|
3320
|
-
octwin
|
|
3321
|
-
octwin
|
|
3778
|
+
WRITES (need \`work:write\`; \`stage\` needs \`records:write\`):
|
|
3779
|
+
octwin work assign <recordId> --to user:<uuid>|team:<uuid>|none
|
|
3780
|
+
octwin work note <recordId> "the note text"
|
|
3781
|
+
octwin work stage <recordId> --to <stage> [--note "..."]
|
|
3782
|
+
octwin work decide <recordId> --action <action> [--param k=v] [--note "..."] [--dry-run]
|
|
3322
3783
|
|
|
3323
|
-
\`decide\` applies one of the
|
|
3784
|
+
\`decide\` applies one of the entity's declared operator actions — \`octwin work <id>\`
|
|
3324
3785
|
lists them with their params. --dry-run previews the customer-facing copy and the
|
|
3325
|
-
resulting
|
|
3786
|
+
resulting stage WITHOUT committing (that route needs only \`work:read\`).
|
|
3787
|
+
\`stage\` is the XRM records verb (one transition spelling platform-wide).`,
|
|
3326
3788
|
logs: `octwin logs [conversationId] [--as <handle>] [--json]
|
|
3327
3789
|
No id = recent conversations (handle, status, last activity; --as filters).
|
|
3328
3790
|
With id = the full event timeline including what each turn rendered.
|
|
@@ -3430,9 +3892,20 @@ const COMMAND_HELP = {
|
|
|
3430
3892
|
|
|
3431
3893
|
--dow is 0-6, 0 = Sunday. \`rules\` is how you find an id to remove, and
|
|
3432
3894
|
\`--slots\` is how you check what a rule actually produces.`,
|
|
3433
|
-
'platform-kb': `octwin platform-kb [pull] [--
|
|
3895
|
+
'platform-kb': `octwin platform-kb [pull] [--if-stale] [--check] [--dir .] [--url <url>] [--token <t>]
|
|
3434
3896
|
Pull the platform capability reference (markdown + JSON catalogs) into
|
|
3435
|
-
.octwin/platform-kb/ for the octwin-pack authoring skill
|
|
3897
|
+
.octwin/platform-kb/ for the octwin-pack authoring skill, plus three maps:
|
|
3898
|
+
INDEX.md (the corpus) · SYMBOLS.md (every name -> its file; grep this) ·
|
|
3899
|
+
OUTLINE.md (every heading with its line number).
|
|
3900
|
+
|
|
3901
|
+
NO TOKEN NEEDED — the reference is platform stdlib and is served anonymously.
|
|
3902
|
+
A token is used when you have one (it also works against older platforms).
|
|
3903
|
+
|
|
3904
|
+
--if-stale poll the platform's content_hash first and skip the download when
|
|
3905
|
+
nothing changed. Cheap enough to run at the start of every session.
|
|
3906
|
+
--check report only, write nothing. Exit 0 = current, 2 = stale or never
|
|
3907
|
+
pulled, 1 = could not tell (offline / refused). For scripts and
|
|
3908
|
+
agent loops that want to branch without parsing prose.`,
|
|
3436
3909
|
test: `octwin test [--dir .]
|
|
3437
3910
|
Alias for \`octwin validate --remote\` — the full platform check.`,
|
|
3438
3911
|
feedback: `octwin feedback [--dir .]
|
|
@@ -3449,7 +3922,7 @@ async function main() {
|
|
|
3449
3922
|
const [command, ...rest] = process.argv.slice(2);
|
|
3450
3923
|
const flags = parseFlags(rest);
|
|
3451
3924
|
// So an auth failure can name the scope THIS invocation needs. A leading write
|
|
3452
|
-
// verb changes the answer (`
|
|
3925
|
+
// verb changes the answer (`work` reads, `work note` writes), so it rides along
|
|
3453
3926
|
// when the first positional is one — `VERB_REQUIREMENTS` is keyed that way.
|
|
3454
3927
|
const leadingVerb = flags._[0];
|
|
3455
3928
|
CURRENT_COMMAND = (typeof leadingVerb === 'string' && command && `${command} ${leadingVerb}` in VERB_REQUIREMENTS)
|
|
@@ -3486,8 +3959,8 @@ async function main() {
|
|
|
3486
3959
|
case 'records':
|
|
3487
3960
|
await cmdRecords(flags);
|
|
3488
3961
|
break;
|
|
3489
|
-
case '
|
|
3490
|
-
await
|
|
3962
|
+
case 'work':
|
|
3963
|
+
await cmdWork(flags);
|
|
3491
3964
|
break;
|
|
3492
3965
|
case 'logs':
|
|
3493
3966
|
await cmdLogs(flags);
|
|
@@ -3498,6 +3971,9 @@ async function main() {
|
|
|
3498
3971
|
case 'media':
|
|
3499
3972
|
await cmdMedia(flags);
|
|
3500
3973
|
break;
|
|
3974
|
+
case 'seed':
|
|
3975
|
+
await cmdSeed(flags);
|
|
3976
|
+
break;
|
|
3501
3977
|
case 'projects':
|
|
3502
3978
|
await cmdProjects(flags);
|
|
3503
3979
|
break;
|