octwin-cli 0.7.1 → 0.7.3
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 +63 -0
- package/README.md +1 -1
- package/dist/index.js +291 -77
- package/dist/lib/render-check.js +64 -0
- package/package.json +46 -37
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,69 @@ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucket
|
|
|
5
5
|
**Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
|
|
6
6
|
repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
|
|
7
7
|
|
|
8
|
+
## [0.7.3] - 2026-08-26
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **A marketplace listing request is now OPT-IN: `octwin deploy --request-listing`.** A plain
|
|
12
|
+
`octwin deploy` says nothing about the public marketplace — it neither asks for a listing nor
|
|
13
|
+
gives one up. `--withdraw-listing` retracts, including an approved listing.
|
|
14
|
+
**Why:** the request was read from the manifest's `listing.public` on every deploy, and deploying
|
|
15
|
+
is how you *test*. 17 of the 23 published packs carry that key, so an edit-deploy-chat loop filed
|
|
16
|
+
a review request every time round. Worse, the *absence* of the key meant **retract** — so an
|
|
17
|
+
ordinary deploy of a pack whose manifest you had not yet annotated could wipe a listing you
|
|
18
|
+
already had. `--request-listing` still requires `listing.public: true` in the manifest (the
|
|
19
|
+
manifest states that the pack is a product; the flag is you choosing to ask), and the flag
|
|
20
|
+
without the key is a clean error rather than a silent no-op.
|
|
21
|
+
- **The listing verdict distinguishes "you submitted this" from "your approval expired".** They are
|
|
22
|
+
both `pending` and they mean opposite things to an author. A deploy that changes the content of an
|
|
23
|
+
approved pack now says so in those words — an approval covers only the content it was made
|
|
24
|
+
against, so it cannot survive an edit.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **A one-line nudge when your manifest asks and your deploy did not.** A pack carrying
|
|
28
|
+
`listing.public: true` that has never been submitted now prints the flag to use, so an opt-in
|
|
29
|
+
default cannot turn into waiting for a review nobody requested.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **The "newer CLI" and "KB drifted" notices now print when a command FAILS.** All three nudges
|
|
33
|
+
(CLI upgrade · KB drift · unread memos) were awaited at the very end of `main()`, which silently
|
|
34
|
+
meant *only on success* — `die()` is a hard exit, so a failed deploy, an auth error or a bad
|
|
35
|
+
manifest printed none of them. That is backwards: a stale capability reference is a leading cause
|
|
36
|
+
of the failure an author is staring at, so the run that most needed the nudge was the only one
|
|
37
|
+
that never got it. They are now computed before the command and printed however it ends.
|
|
38
|
+
- **The CLI-upgrade notice is no longer hidden from piped output.** It began with
|
|
39
|
+
`if (!process.stdout.isTTY) return`, so every agent-driven and CI run — the readers that cannot
|
|
40
|
+
notice an outdated CLI on their own — was the one audience never told. Its two siblings had
|
|
41
|
+
already dropped that gate for exactly this reason; now all three agree. Still skipped under
|
|
42
|
+
`npx`, where there is nothing to upgrade.
|
|
43
|
+
- **`octwin login` now reports KB drift too.** It is networked and usually the first command of a
|
|
44
|
+
session, i.e. the best possible moment to say the reference moved, and it was excluded.
|
|
45
|
+
- **A memo poll can no longer fail the command it was annotating.** It went through `apiGet` →
|
|
46
|
+
`fetchOrDie`, whose job is to `die` on a network failure — so an unreachable platform turned an
|
|
47
|
+
observer into a command failure. It now uses a plain, timed, fail-silent fetch.
|
|
48
|
+
- **A failing command reports its real exit code again on Windows.** Moving the polls before the
|
|
49
|
+
command made `process.exit()` abort with `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)`
|
|
50
|
+
and return **127** instead of 1 — so CI saw neither success nor the documented failure code. The
|
|
51
|
+
CLI now unwinds through a `CliExit` and lets the event loop drain, which is the only teardown
|
|
52
|
+
that is clean once the polls have opened sockets. `--check`'s exit 2 and the streaming commands
|
|
53
|
+
were re-verified.
|
|
54
|
+
|
|
55
|
+
## [0.7.2] - 2026-08-20
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
- **`octwin feedback` now tells you what is already waiting, on stdout.** It discarded the submit
|
|
59
|
+
response entirely — destructuring `{ status, json }`, checking the status and never reading the
|
|
60
|
+
body — then printed *"a reply arrives as a memo, this CLI will tell you when one is waiting"*. The
|
|
61
|
+
platform started returning the unread counts on that response (2026-08-20), so the answer was
|
|
62
|
+
there to print and was being thrown away.
|
|
63
|
+
**Why it matters more than it sounds:** `notifyIfMemosWaiting` already covers this command and was
|
|
64
|
+
firing correctly — but it writes to **stderr**, and the authors driving this CLI are agents reading
|
|
65
|
+
stdout. The one signal that mattered was addressed to a channel nobody was listening on. On
|
|
66
|
+
2026-08-19 an author submitted two reports two hours after three replies were published and
|
|
67
|
+
re-reported five findings those replies had already closed. Now the count prints in the same block
|
|
68
|
+
as the acknowledgement, naming the command, and says outright when a reply may already answer what
|
|
69
|
+
you just sent. Against an older platform the field is absent and the previous line stands.
|
|
70
|
+
|
|
8
71
|
## [0.7.1] - 2026-08-19
|
|
9
72
|
|
|
10
73
|
### Fixed
|
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ octwin status # "✓ live and current" once it's warm
|
|
|
67
67
|
| `octwin validate` | Check the pack locally (structure + pure-YAML rules). `--remote` runs **the same validation the platform runs to load your pack** — every declaration file (`xrm.yaml`, `scheduling.yaml`, `worklist.yaml`, `roles.yaml`, `automation.yaml`, `integrations.yaml`, `surveys.yaml`, `taps.yaml`, `commands.yaml`, `journeys/`, `messages.<lang>.yaml`), the cross-file checks between them, and the **flow lint** — conventions the schema can't express, like an `assign:` whose value is a quoted literal or a `$t()` key with no namespace. These need template expansion and the platform's own schemas, which only the server has, so a local ✓ does not cover them. A `--remote` ✓ and a successful deploy are now the same answer by construction, not by convention. |
|
|
68
68
|
| `octwin login` | Save a deploy token for a platform URL **and make that URL the default target** (both stored in `~/.octwin/credentials.json`). `--url`, `--token`. |
|
|
69
69
|
| `octwin whoami` | Verify the saved/passed token is valid for a tenant. `--url`, `--tenant`. |
|
|
70
|
-
| `octwin deploy` | Upload + install the pack onto your tenant's project. `--seed` also runs the pack's demo seed.
|
|
70
|
+
| `octwin deploy` | Upload + install the pack onto your tenant's project. `--seed` also runs the pack's demo seed. Says **nothing** about the public marketplace unless asked: `--request-listing` submits the pack for operator review (needs `listing.public: true` in the manifest too), `--withdraw-listing` retracts. Reports the **listing verdict** whenever one exists. |
|
|
71
71
|
| `octwin status` | Report what the platform has live for this pack — installed version, the **content sha** the instance loaded vs. the one the catalog holds (a redeploy of the *same* version changes it), its flows, and whether it is live on the public marketplace. |
|
|
72
72
|
| `octwin pull <packId>` | Write a **deployed** pack's source back to disk — the inverse of `deploy`, and how a pack pushed from one machine is recovered. Defaults to the version installed on the target project; `--version` overrides, `--dir` defaults to `./<packId>`, a non-empty dir needs `--force`. You may pull a pack your tenant **owns**. |
|
|
73
73
|
| `octwin chat "msg"` | Drive a turn through the dev web channel and print **every render with its tap ids**. `--as <handle>` picks the test user; `--tap "<tap-id>"` presses a rendered button/list row; `--json` dumps the raw envelopes. |
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* octwin whoami [--url <url>] [--tenant <slug>]
|
|
15
15
|
* octwin projects [--archived] # the --project slugs this token can name
|
|
16
16
|
* octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
17
|
+
* [--request-listing | --withdraw-listing] # marketplace listing — opt-in, see `deploy` help
|
|
17
18
|
* octwin pull <packId> [--dir <out>] [--version v] [--force] # write a DEPLOYED pack's source back to disk
|
|
18
19
|
* octwin status [<packId>] # did my deploy land? which version is live?
|
|
19
20
|
* octwin records [entity] [id] # inspect the pack's XRM data (records:read token)
|
|
@@ -52,7 +53,7 @@
|
|
|
52
53
|
* operator's GitHub repo import applies. The platform re-validates it
|
|
53
54
|
* (pure-YAML enforcement + manifest/flow Zod) and installs it onto the project.
|
|
54
55
|
*/
|
|
55
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, rmSync } from 'node:fs';
|
|
56
|
+
import { readFileSync, writeFileSync, writeSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, rmSync } from 'node:fs';
|
|
56
57
|
import { join, resolve, dirname, basename } from 'node:path';
|
|
57
58
|
import { homedir } from 'node:os';
|
|
58
59
|
import { fileURLToPath } from 'node:url';
|
|
@@ -155,7 +156,7 @@ function parseFlags(argv) {
|
|
|
155
156
|
}
|
|
156
157
|
function die(msg) {
|
|
157
158
|
console.error(`✗ ${msg}`);
|
|
158
|
-
|
|
159
|
+
exitNow(1);
|
|
159
160
|
}
|
|
160
161
|
// ── network helpers ─────────────────────────────────────────────────────────
|
|
161
162
|
/** `fetch` that dies with the TARGET URL on a network failure — a bare
|
|
@@ -460,8 +461,10 @@ async function latestPublishedVersion() {
|
|
|
460
461
|
const timer = setTimeout(() => ctrl.abort(), 1500);
|
|
461
462
|
const res = await fetch('https://registry.npmjs.org/octwin-cli/latest', { signal: ctrl.signal });
|
|
462
463
|
clearTimeout(timer);
|
|
463
|
-
if (!res.ok)
|
|
464
|
+
if (!res.ok) {
|
|
465
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
464
466
|
return null;
|
|
467
|
+
} // drain — see `fetchKbMeta`
|
|
465
468
|
const latest = (await res.json()).version;
|
|
466
469
|
if (typeof latest !== 'string')
|
|
467
470
|
return null;
|
|
@@ -488,19 +491,30 @@ function isNpx() {
|
|
|
488
491
|
return false;
|
|
489
492
|
}
|
|
490
493
|
}
|
|
491
|
-
/**
|
|
492
|
-
*
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
494
|
+
/** A one-line upgrade notice when a newer octwin-cli is published, or [] when current.
|
|
495
|
+
*
|
|
496
|
+
* Skipped only under npx, where there is genuinely nothing to upgrade. It USED to bail on
|
|
497
|
+
* `!process.stdout.isTTY` as well, which silently made this the one notice a piped reader never
|
|
498
|
+
* saw — the opposite of the rule its two siblings already carry in their docblocks, and measured
|
|
499
|
+
* 2026-08-26: with a newer version cached, `octwin whoami | tail` printed the KB nudge and not
|
|
500
|
+
* this one. An agent driving the CLI cannot notice an outdated CLI on its own, so it is exactly
|
|
501
|
+
* the reader that needs telling. One line on stderr, so piped stdout stays clean either way.
|
|
502
|
+
*
|
|
503
|
+
* Never throws — a version check must never break a command. */
|
|
504
|
+
async function outdatedNotice() {
|
|
505
|
+
if (isNpx())
|
|
506
|
+
return [];
|
|
496
507
|
try {
|
|
497
508
|
const latest = await latestPublishedVersion();
|
|
498
509
|
if (latest && isNewer(latest, VERSION)) {
|
|
499
|
-
|
|
500
|
-
|
|
510
|
+
return [
|
|
511
|
+
`\n⬆ octwin-cli ${latest} is available (you have ${VERSION}).`,
|
|
512
|
+
' Upgrade: npm i -g octwin-cli@latest (or just use npx octwin-cli@latest)',
|
|
513
|
+
];
|
|
501
514
|
}
|
|
502
515
|
}
|
|
503
516
|
catch { /* a version check must never break the CLI */ }
|
|
517
|
+
return [];
|
|
504
518
|
}
|
|
505
519
|
// ── platform-KB drift check (observe the pulled reference, fail-silent) ──
|
|
506
520
|
//
|
|
@@ -550,20 +564,20 @@ function diffKbIndex(prev, next) {
|
|
|
550
564
|
*/
|
|
551
565
|
async function fetchKbMeta(t, timeoutMs = 2_000) {
|
|
552
566
|
const ep = kbEndpoint(t);
|
|
553
|
-
const ctrl = new AbortController();
|
|
554
|
-
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
555
567
|
try {
|
|
556
|
-
const res = await fetch(`${ep.url}?meta=1`, { headers: ep.headers, signal:
|
|
557
|
-
if (!res.ok)
|
|
568
|
+
const res = await fetch(`${ep.url}?meta=1`, { headers: ep.headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
569
|
+
if (!res.ok) {
|
|
570
|
+
// Drain the body even though we do not want it. An unread `Response` body keeps its undici
|
|
571
|
+
// socket checked out of the pool, and this poll runs on the way to a possible `exitNow` —
|
|
572
|
+
// a held socket at exit is a pending libuv handle.
|
|
573
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
558
574
|
return { ok: false, notAuthorized: res.status === 401 || res.status === 403 };
|
|
575
|
+
}
|
|
559
576
|
return { ok: true, meta: await res.json() };
|
|
560
577
|
}
|
|
561
578
|
catch {
|
|
562
579
|
return { ok: false, notAuthorized: false };
|
|
563
580
|
}
|
|
564
|
-
finally {
|
|
565
|
-
clearTimeout(timer);
|
|
566
|
-
}
|
|
567
581
|
}
|
|
568
582
|
/** Nudge (to stderr) when the platform's capability KB has changed since the last
|
|
569
583
|
* `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
|
|
@@ -577,15 +591,15 @@ async function fetchKbMeta(t, timeoutMs = 2_000) {
|
|
|
577
591
|
* authoring agent whose output is piped, and gating on `isTTY` meant the one
|
|
578
592
|
* reader that CANNOT notice a stale reference on its own was the only one never
|
|
579
593
|
* told. It is a single line on stderr, so piped stdout stays clean either way. */
|
|
580
|
-
async function
|
|
594
|
+
async function kbStaleNotice(flags) {
|
|
581
595
|
try {
|
|
582
596
|
const packDir = resolve(flags.dir ?? '.');
|
|
583
597
|
const local = readLocalKb(packDir);
|
|
584
598
|
if (!local?.content_hash)
|
|
585
|
-
return; // never pulled → the skill already says to pull
|
|
599
|
+
return []; // never pulled → the skill already says to pull
|
|
586
600
|
const t = resolveTargetOrNull(flags);
|
|
587
601
|
if (!t)
|
|
588
|
-
return;
|
|
602
|
+
return [];
|
|
589
603
|
const polled = await fetchKbMeta(t);
|
|
590
604
|
if (!polled.ok) {
|
|
591
605
|
// The tenant-scoped meta poll needs `pack:deploy`, but this nudge rides on every
|
|
@@ -594,10 +608,12 @@ async function notifyIfKbStale(flags) {
|
|
|
594
608
|
// makes an author invent a primitive from memory. Say so once; stay silent for every
|
|
595
609
|
// other failure (offline, timeout, a platform without the route).
|
|
596
610
|
if (polled.notAuthorized) {
|
|
597
|
-
|
|
598
|
-
|
|
611
|
+
return [
|
|
612
|
+
'\nⓘ can\'t check whether the platform capability reference drifted — that token lacks `pack:deploy`.',
|
|
613
|
+
' Check it without a token: octwin platform-kb --check (or refresh: octwin platform-kb --token oct_…)',
|
|
614
|
+
];
|
|
599
615
|
}
|
|
600
|
-
return;
|
|
616
|
+
return [];
|
|
601
617
|
}
|
|
602
618
|
const meta = polled.meta;
|
|
603
619
|
if (meta.content_hash && meta.content_hash !== local.content_hash) {
|
|
@@ -614,11 +630,14 @@ async function notifyIfKbStale(flags) {
|
|
|
614
630
|
if (parts.length)
|
|
615
631
|
summary = ` (${parts.join(' · ')})`;
|
|
616
632
|
}
|
|
617
|
-
|
|
618
|
-
|
|
633
|
+
return [
|
|
634
|
+
`\n⬆ the platform capability reference changed since you last pulled it${summary}.`,
|
|
635
|
+
' Refresh it: octwin platform-kb --if-stale',
|
|
636
|
+
];
|
|
619
637
|
}
|
|
620
638
|
}
|
|
621
639
|
catch { /* a KB check must never break the CLI */ }
|
|
640
|
+
return [];
|
|
622
641
|
}
|
|
623
642
|
/**
|
|
624
643
|
* Nudge (to stderr) when the platform has memos this workspace has not read — a reply to
|
|
@@ -642,33 +661,107 @@ async function notifyIfKbStale(flags) {
|
|
|
642
661
|
* platform, so an agent on a fresh machine or in a fresh container still learns about an
|
|
643
662
|
* unread memo. That is the whole reason it is not a local marker file.
|
|
644
663
|
*/
|
|
645
|
-
async function
|
|
664
|
+
async function memosWaitingNotice(flags) {
|
|
646
665
|
try {
|
|
647
666
|
const t = resolveTargetOrNull(flags);
|
|
648
667
|
if (!t)
|
|
649
|
-
return;
|
|
650
|
-
|
|
668
|
+
return [];
|
|
669
|
+
// A PLAIN fetch, not `apiGet` — which goes through `fetchOrDie`, whose whole job is to `die`
|
|
670
|
+
// on a network failure. An observer that can terminate the CLI is not an observer: with an
|
|
671
|
+
// unreachable platform this nudge would have failed the command it was only meant to annotate,
|
|
672
|
+
// and now that the polls run BEFORE the command it would have failed it before it even ran.
|
|
673
|
+
const res = await fetch(`${t.url}/api/self/p/memos?meta=1`, {
|
|
674
|
+
headers: authHeaders(t), signal: AbortSignal.timeout(2_000),
|
|
675
|
+
});
|
|
676
|
+
const status = res.status;
|
|
677
|
+
const json = await res.json().catch(() => null);
|
|
651
678
|
// Silent on ANY failure — an older platform has no such route, and a nudge is never
|
|
652
679
|
// worth a diagnostic of its own. Unlike the KB poll there is no scope to explain: the
|
|
653
680
|
// route is gated on tenant access precisely so every token can answer it.
|
|
654
681
|
if (status !== 200 || !json || typeof json !== 'object')
|
|
655
|
-
return;
|
|
682
|
+
return [];
|
|
656
683
|
const unread = Number(json.unread ?? 0);
|
|
657
684
|
if (!Number.isFinite(unread) || unread <= 0)
|
|
658
|
-
return;
|
|
685
|
+
return [];
|
|
659
686
|
const actionable = Number(json.unread_actionable ?? 0);
|
|
660
687
|
const what = unread === 1 ? '1 memo' : `${unread} memos`;
|
|
661
688
|
const tail = actionable > 0 ? ` (${actionable} needing action)` : '';
|
|
662
|
-
|
|
689
|
+
const lines = [`\n✉ ${what} from the platform${tail} — read them: octwin memos`];
|
|
663
690
|
if (actionable > 0) {
|
|
664
691
|
// Said separately, because the whole point of the severity axis is that an agent
|
|
665
692
|
// mid-build should stop and read rather than finish first.
|
|
666
|
-
|
|
693
|
+
lines.push(' One or more may change what you are building — read before continuing.');
|
|
667
694
|
}
|
|
695
|
+
return lines;
|
|
668
696
|
}
|
|
669
697
|
catch { /* a memo check must never break the CLI */ }
|
|
698
|
+
return [];
|
|
670
699
|
}
|
|
671
|
-
|
|
700
|
+
// ── the notice channel: computed BEFORE the command, printed however it ends ──
|
|
701
|
+
//
|
|
702
|
+
// These three nudges used to be awaited at the very END of `main()`, and that placement quietly
|
|
703
|
+
// meant "only when the command SUCCEEDS": `die()` is `process.exit(1)`, so a failed deploy, an
|
|
704
|
+
// auth error or a bad manifest skipped all three. Measured 2026-08-26 — a failing command printed
|
|
705
|
+
// nothing while the same command succeeding printed the KB nudge. That is backwards. A stale
|
|
706
|
+
// capability reference is a LEADING CAUSE of the failure an author is staring at, so the one run
|
|
707
|
+
// that most needs the nudge was the only one that never got it.
|
|
708
|
+
//
|
|
709
|
+
// So: compute the lines up front (async, needs the network), then print them through a SYNCHRONOUS
|
|
710
|
+
// flush that also runs from `process.on('exit')`. The strings already exist by then, so the exit
|
|
711
|
+
// hook has nothing to await — which is what makes `die()`'s hard exit safe. `die` itself is left
|
|
712
|
+
// alone deliberately: making it throw instead would have to survive 30 broad `catch` blocks in
|
|
713
|
+
// this file, any one of which would turn a hard failure into a silent continue.
|
|
714
|
+
let PENDING_NOTICES = [];
|
|
715
|
+
let NOTICES_FLUSHED = false;
|
|
716
|
+
/** Print the computed notices exactly once. */
|
|
717
|
+
function flushNotices() {
|
|
718
|
+
if (NOTICES_FLUSHED)
|
|
719
|
+
return;
|
|
720
|
+
NOTICES_FLUSHED = true;
|
|
721
|
+
if (PENDING_NOTICES.length === 0)
|
|
722
|
+
return;
|
|
723
|
+
try {
|
|
724
|
+
writeSync(2, `${PENDING_NOTICES.join('\n')}\n`);
|
|
725
|
+
}
|
|
726
|
+
catch { /* a nudge is never worth a crash */ }
|
|
727
|
+
}
|
|
728
|
+
/** Thrown by `exitNow` to unwind to `main` instead of calling `process.exit`. */
|
|
729
|
+
class CliExit extends Error {
|
|
730
|
+
code;
|
|
731
|
+
constructor(code) {
|
|
732
|
+
super('cli-exit');
|
|
733
|
+
this.code = code;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* The ONE way this CLI ends non-zero: flush the notices, then unwind.
|
|
738
|
+
*
|
|
739
|
+
* ## Why this throws instead of calling `process.exit`
|
|
740
|
+
*
|
|
741
|
+
* Because `process.exit()` is not safe here, measured 2026-08-26 on Windows. Once the notice polls
|
|
742
|
+
* moved to BEFORE the command (so a failing run still gets its nudge), a hard exit began aborting
|
|
743
|
+
* with `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\\win\\async.c` — and, far worse
|
|
744
|
+
* than the noise, it replaced the real exit code with **127**, so a failing `octwin deploy` in CI
|
|
745
|
+
* reported neither 0 nor 1. Ruled out one at a time: it is not the notice I/O (a no-fetch failure
|
|
746
|
+
* exits 1 cleanly), not an `exit` handler (removing it changed nothing), not an unread response
|
|
747
|
+
* body, not the `AbortController`, and not concurrency (serialising them changed nothing). It
|
|
748
|
+
* reproduces on **two** pre-command fetches and not on one, which points at undici's pooled
|
|
749
|
+
* sockets — not something this CLI can reach in to close.
|
|
750
|
+
*
|
|
751
|
+
* A NATURAL exit has none of these problems: the same two fetches on the success path exit 0
|
|
752
|
+
* cleanly, because Node tears the pool down itself when the event loop drains. So `main` catches
|
|
753
|
+
* `CliExit`, sets `process.exitCode`, and returns.
|
|
754
|
+
*
|
|
755
|
+
* Safe to throw rather than exit because every `catch` in this file was audited for it: all but a
|
|
756
|
+
* handful guard a single `JSON.parse` / `readFileSync`, and in the three with real bodies (plus
|
|
757
|
+
* `fetchOrDie` and `--fields-json`) the `die()` sits in the **catch handler**, never inside the
|
|
758
|
+
* `try` — so nothing swallows this.
|
|
759
|
+
*/
|
|
760
|
+
function exitNow(code) {
|
|
761
|
+
flushNotices();
|
|
762
|
+
throw new CliExit(code);
|
|
763
|
+
}
|
|
764
|
+
/** Which commands already made a platform call, so the KB-drift poll
|
|
672
765
|
* rides on existing network work (never on offline `validate` / `init`;
|
|
673
766
|
* `platform-kb` refreshes the reference itself, so it needs no nudge). */
|
|
674
767
|
function commandTouchesPlatform(command, flags) {
|
|
@@ -683,6 +776,11 @@ function commandTouchesPlatform(command, flags) {
|
|
|
683
776
|
// `memos` is networked but needs NO memo nudge — it just read them. It still gets the
|
|
684
777
|
// KB drift check, which is a different question.
|
|
685
778
|
case 'memos': return true;
|
|
779
|
+
// `login` verifies the token over the network and is usually the FIRST command of a session —
|
|
780
|
+
// the best possible moment to say the reference drifted. It reads the target from the FLAGS
|
|
781
|
+
// (`--url`/`--token`), which is what the notices resolve too, so it does not depend on the
|
|
782
|
+
// credentials this command is about to save.
|
|
783
|
+
case 'login':
|
|
686
784
|
case 'records':
|
|
687
785
|
case 'work':
|
|
688
786
|
case 'logs':
|
|
@@ -991,7 +1089,7 @@ async function cmdValidate(flags) {
|
|
|
991
1089
|
console.error(`✗ remote validate failed (HTTP ${res.status})`);
|
|
992
1090
|
printAuthHint(res.status, url);
|
|
993
1091
|
console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
|
|
994
|
-
|
|
1092
|
+
exitNow(1);
|
|
995
1093
|
}
|
|
996
1094
|
const warnings = (json?.warnings ?? []);
|
|
997
1095
|
if (warnings.length) {
|
|
@@ -1016,7 +1114,7 @@ async function cmdValidate(flags) {
|
|
|
1016
1114
|
for (const m of msgs)
|
|
1017
1115
|
console.error(` • ${m}`);
|
|
1018
1116
|
}
|
|
1019
|
-
|
|
1117
|
+
exitNow(1);
|
|
1020
1118
|
}
|
|
1021
1119
|
async function cmdLogin(flags) {
|
|
1022
1120
|
const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
|
|
@@ -1157,7 +1255,7 @@ async function cmdPull(flags) {
|
|
|
1157
1255
|
console.error(' → a pack is pullable by the tenant that OWNS it (deployed it), or by an operator.');
|
|
1158
1256
|
}
|
|
1159
1257
|
console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
|
|
1160
|
-
|
|
1258
|
+
exitNow(1);
|
|
1161
1259
|
}
|
|
1162
1260
|
const files = json.files ?? {};
|
|
1163
1261
|
const blobs = json.blobs ?? {};
|
|
@@ -1230,23 +1328,68 @@ async function readDeployProgress(body) {
|
|
|
1230
1328
|
}
|
|
1231
1329
|
return { terminal, stepErrors };
|
|
1232
1330
|
}
|
|
1331
|
+
/** What a `--request-listing` / `--withdraw-listing` deploy says it is doing, for the header line. */
|
|
1332
|
+
const LISTING_LABEL = {
|
|
1333
|
+
submit: 'requesting a public marketplace listing',
|
|
1334
|
+
withdraw: 'withdrawing the public marketplace listing',
|
|
1335
|
+
};
|
|
1336
|
+
/**
|
|
1337
|
+
* Which marketplace act this deploy is, from the two mutually-exclusive flags.
|
|
1338
|
+
*
|
|
1339
|
+
* Returns undefined for the common case — an ordinary deploy asks for nothing. Both flags at once
|
|
1340
|
+
* is a `die` rather than a precedence rule: the two are opposites, and guessing which one the
|
|
1341
|
+
* author meant is exactly the silent-wrong-thing this opt-in default exists to prevent.
|
|
1342
|
+
*/
|
|
1343
|
+
function resolveListingFlags(flags) {
|
|
1344
|
+
const submit = flags['request-listing'] === true;
|
|
1345
|
+
const withdraw = flags['withdraw-listing'] === true;
|
|
1346
|
+
if (submit && withdraw) {
|
|
1347
|
+
die('--request-listing and --withdraw-listing are opposites — pass one, not both');
|
|
1348
|
+
}
|
|
1349
|
+
return submit ? 'submit' : withdraw ? 'withdraw' : undefined;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Nudge the author whose manifest asks to be public but whose deploy did not submit it.
|
|
1353
|
+
*
|
|
1354
|
+
* The listing request is OPT-IN (`--request-listing`), because the CLI is overwhelmingly an
|
|
1355
|
+
* edit-deploy-chat test loop and reading the manifest alone filed a review request on every
|
|
1356
|
+
* single deploy. But a silent default has its own failure: an author who wrote
|
|
1357
|
+
* `listing.public: true` months ago and waits for a review that was never requested. One line,
|
|
1358
|
+
* only when the manifest actually declares it, so a pack that never asked stays silent.
|
|
1359
|
+
*/
|
|
1360
|
+
function printListingNotAsked(packDir) {
|
|
1361
|
+
let declared = false;
|
|
1362
|
+
try {
|
|
1363
|
+
const raw = readFileSync(join(packDir, 'manifest.yaml'), 'utf8');
|
|
1364
|
+
declared = parseYaml(raw)?.listing?.public === true;
|
|
1365
|
+
}
|
|
1366
|
+
catch {
|
|
1367
|
+
return;
|
|
1368
|
+
} // unreadable/unparseable is the deploy's to report
|
|
1369
|
+
if (!declared)
|
|
1370
|
+
return;
|
|
1371
|
+
console.log(' ⓘ your manifest declares listing.public — this deploy did NOT submit it for review.');
|
|
1372
|
+
console.log(' Ask for a public marketplace listing with: octwin deploy --request-listing');
|
|
1373
|
+
}
|
|
1233
1374
|
/**
|
|
1234
1375
|
* The anonymous-marketplace verdict, for `deploy` and `status` alike.
|
|
1235
1376
|
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
* that never asked prints nothing.
|
|
1377
|
+
* The state lived only in the console — so an author working from the CLI got no acknowledgement
|
|
1378
|
+
* that a request had registered, and no sight of a rejection note (which the platform REQUIRES
|
|
1379
|
+
* precisely because it is their only feedback). Silent for `none`/absent, so a pack that never
|
|
1380
|
+
* asked prints nothing.
|
|
1241
1381
|
*
|
|
1242
|
-
* `
|
|
1243
|
-
*
|
|
1382
|
+
* `justAsked` separates the two ways a pack lands in the queue, which read as the same state and
|
|
1383
|
+
* mean opposite things to the author: they submitted it, or a content edit invalidated an approval
|
|
1384
|
+
* they already had (an approval pins the sha it reviewed, so it cannot survive a content change).
|
|
1244
1385
|
*/
|
|
1245
|
-
function printPublicListing(state, note, live) {
|
|
1386
|
+
function printPublicListing(state, note, live, justAsked = false) {
|
|
1246
1387
|
const n = typeof note === 'string' && note ? ` — operator note: ${note}` : '';
|
|
1247
1388
|
switch (state) {
|
|
1248
1389
|
case 'pending':
|
|
1249
|
-
console.log(
|
|
1390
|
+
console.log(justAsked
|
|
1391
|
+
? ' ⓘ marketplace listing: SUBMITTED — now pending operator review.'
|
|
1392
|
+
: ' ⓘ marketplace listing: back to PENDING review — this deploy changed the content, and an approval only covers the content it was made against.');
|
|
1250
1393
|
break;
|
|
1251
1394
|
case 'approved':
|
|
1252
1395
|
console.log(live === false
|
|
@@ -1260,7 +1403,7 @@ function printPublicListing(state, note, live) {
|
|
|
1260
1403
|
break;
|
|
1261
1404
|
}
|
|
1262
1405
|
}
|
|
1263
|
-
function printDeploySuccess(id, version, t, r) {
|
|
1406
|
+
function printDeploySuccess(id, version, t, r, listing) {
|
|
1264
1407
|
console.log(`✓ Deployed ${id}@${version} and installed onto ${targetLabel(t)}`);
|
|
1265
1408
|
if (r?.warning)
|
|
1266
1409
|
console.log(` ⚠ ${r.warning}`);
|
|
@@ -1285,7 +1428,12 @@ function printDeploySuccess(id, version, t, r) {
|
|
|
1285
1428
|
if (dropped > 0) {
|
|
1286
1429
|
console.log(` ⓘ ${dropped} suspended run(s) invalidated — a tap on any card rendered before this deploy will report a stale run. Start those conversations again.`);
|
|
1287
1430
|
}
|
|
1288
|
-
printPublicListing(r?.public_listing, r?.public_review_note);
|
|
1431
|
+
printPublicListing(r?.public_listing, r?.public_review_note, undefined, listing?.intent === 'submit');
|
|
1432
|
+
// Only when the deploy asked for nothing AND the row says nothing: a pack sitting at `none`
|
|
1433
|
+
// with `listing.public` in its manifest is an author waiting for a review nobody requested.
|
|
1434
|
+
if (listing && !listing.intent && (r?.public_listing == null || r.public_listing === 'none')) {
|
|
1435
|
+
printListingNotAsked(listing.packDir);
|
|
1436
|
+
}
|
|
1289
1437
|
console.log(`\nChat with it: octwin chat "hi" --as tester (or the web widget / console test page).`);
|
|
1290
1438
|
}
|
|
1291
1439
|
/**
|
|
@@ -1326,7 +1474,7 @@ async function cmdSeed(flags) {
|
|
|
1326
1474
|
⚠ ${stepErrors.length} step${stepErrors.length === 1 ? '' : 's'} failed — data may be incomplete:`);
|
|
1327
1475
|
for (const e of stepErrors)
|
|
1328
1476
|
console.error(` • ${e}`);
|
|
1329
|
-
|
|
1477
|
+
exitNow(1);
|
|
1330
1478
|
}
|
|
1331
1479
|
return;
|
|
1332
1480
|
}
|
|
@@ -1341,7 +1489,7 @@ async function cmdSeed(flags) {
|
|
|
1341
1489
|
if (!res.ok) {
|
|
1342
1490
|
console.error(`✗ seed failed (HTTP ${res.status})${errDetail(json)}`);
|
|
1343
1491
|
printAuthHint(res.status, url);
|
|
1344
|
-
|
|
1492
|
+
exitNow(1);
|
|
1345
1493
|
}
|
|
1346
1494
|
console.log('✓ seed complete');
|
|
1347
1495
|
printSeedCounts(json?.seeded);
|
|
@@ -1360,13 +1508,17 @@ async function cmdDeploy(flags) {
|
|
|
1360
1508
|
const { id, version, files, blobs } = localValidate(packDir);
|
|
1361
1509
|
const endpoint = `${url}/api/self/p/packs/deploy`;
|
|
1362
1510
|
const seed = flags.seed === true;
|
|
1363
|
-
|
|
1511
|
+
const listing = resolveListingFlags(flags);
|
|
1512
|
+
const extras = [seed ? 'with demo seed' : null, listing ? LISTING_LABEL[listing] : null].filter(Boolean);
|
|
1513
|
+
console.log(`→ Deploying ${id}@${version} (${Object.keys(files).length} files) to ${targetLabel(t)}${extras.length ? ` — ${extras.join(', ')}` : ''} …`);
|
|
1364
1514
|
const res = await fetchOrDie(endpoint, {
|
|
1365
1515
|
method: 'POST',
|
|
1366
1516
|
// Ask for a progress stream; the platform falls back to plain JSON if it
|
|
1367
1517
|
// (or an error before any progress) can't stream — handled below.
|
|
1368
1518
|
headers: { 'content-type': 'application/json', accept: 'text/event-stream', ...authHeaders(t) },
|
|
1369
|
-
|
|
1519
|
+
// `listing` is omitted entirely unless asked for: an ordinary deploy says NOTHING about the
|
|
1520
|
+
// marketplace, which is what keeps a test loop out of the operator review queue.
|
|
1521
|
+
body: JSON.stringify({ files, blobs, seed, ...(listing ? { listing } : {}) }),
|
|
1370
1522
|
}, 'deploy');
|
|
1371
1523
|
// Streaming path — live install + seed progress (image generation can take a
|
|
1372
1524
|
// while, so `--seed` prints per-record / per-image lines as they happen).
|
|
@@ -1374,7 +1526,7 @@ async function cmdDeploy(flags) {
|
|
|
1374
1526
|
const { terminal: final, stepErrors } = await readDeployProgress(res.body);
|
|
1375
1527
|
if (!final || final.stage === 'error')
|
|
1376
1528
|
die(`deploy failed${final?.message ? `: ${final.message}` : ' (stream ended early)'}`);
|
|
1377
|
-
printDeploySuccess(id, version, t, final);
|
|
1529
|
+
printDeploySuccess(id, version, t, final, { intent: listing, packDir });
|
|
1378
1530
|
if (stepErrors.length) {
|
|
1379
1531
|
// The pack IS installed, but a step (e.g. the demo seed) failed — say so
|
|
1380
1532
|
// plainly and exit non-zero so CI / a `deploy && chat` chain doesn't treat
|
|
@@ -1382,7 +1534,7 @@ async function cmdDeploy(flags) {
|
|
|
1382
1534
|
console.error(`\n⚠ Deployed with ${stepErrors.length} warning${stepErrors.length === 1 ? '' : 's'} — data may be incomplete:`);
|
|
1383
1535
|
for (const e of stepErrors)
|
|
1384
1536
|
console.error(` • ${e}`);
|
|
1385
|
-
|
|
1537
|
+
exitNow(1);
|
|
1386
1538
|
}
|
|
1387
1539
|
return;
|
|
1388
1540
|
}
|
|
@@ -1400,9 +1552,9 @@ async function cmdDeploy(flags) {
|
|
|
1400
1552
|
console.error(`✗ deploy failed (HTTP ${res.status})`);
|
|
1401
1553
|
printAuthHint(res.status, url);
|
|
1402
1554
|
console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
|
|
1403
|
-
|
|
1555
|
+
exitNow(1);
|
|
1404
1556
|
}
|
|
1405
|
-
printDeploySuccess(id, version, t, json);
|
|
1557
|
+
printDeploySuccess(id, version, t, json, { intent: listing, packDir });
|
|
1406
1558
|
}
|
|
1407
1559
|
/**
|
|
1408
1560
|
* The QUALIFIED pack id (`<owner>.<name>`) for a manifest's bare name.
|
|
@@ -1472,7 +1624,7 @@ async function cmdStatus(flags) {
|
|
|
1472
1624
|
console.error(`✗ status check failed (HTTP ${res.status})`);
|
|
1473
1625
|
printAuthHint(res.status, url);
|
|
1474
1626
|
console.error(typeof json === 'string' ? json : JSON.stringify(json, null, 2));
|
|
1475
|
-
|
|
1627
|
+
exitNow(1);
|
|
1476
1628
|
}
|
|
1477
1629
|
console.log(`${id} on ${targetLabel(t)} @ ${url}`);
|
|
1478
1630
|
console.log(` installed version : ${json.installed_version}`);
|
|
@@ -1571,16 +1723,16 @@ async function cmdPlatformKb(flags) {
|
|
|
1571
1723
|
console.error(polled.notAuthorized
|
|
1572
1724
|
? '✗ cannot check — the platform refused the token, and this instance serves no anonymous reference.'
|
|
1573
1725
|
: `✗ cannot check — ${url} did not answer.`);
|
|
1574
|
-
|
|
1726
|
+
exitNow(1);
|
|
1575
1727
|
}
|
|
1576
1728
|
const remote = polled.meta.content_hash;
|
|
1577
1729
|
if (!local?.content_hash) {
|
|
1578
1730
|
console.log(`⬆ no reference pulled yet (platform is at ${remote ?? '?'}) — run \`octwin platform-kb pull\`.`);
|
|
1579
|
-
|
|
1731
|
+
exitNow(2);
|
|
1580
1732
|
}
|
|
1581
1733
|
if (remote && remote !== local.content_hash) {
|
|
1582
1734
|
console.log(`⬆ stale: local ${local.content_hash} → platform ${remote}. Run \`octwin platform-kb pull\`.`);
|
|
1583
|
-
|
|
1735
|
+
exitNow(2);
|
|
1584
1736
|
}
|
|
1585
1737
|
console.log(`✓ current (${local.content_hash}).`);
|
|
1586
1738
|
return;
|
|
@@ -1612,7 +1764,7 @@ async function cmdPlatformKb(flags) {
|
|
|
1612
1764
|
}
|
|
1613
1765
|
console.error(`✗ platform-kb pull failed (HTTP ${res.status})`);
|
|
1614
1766
|
console.error(typeof j === 'string' ? j : JSON.stringify(j, null, 2));
|
|
1615
|
-
|
|
1767
|
+
exitNow(1);
|
|
1616
1768
|
}
|
|
1617
1769
|
const bundle = JSON.parse(text);
|
|
1618
1770
|
// Snapshot the prior pull's index BEFORE overwriting it, so we can show the
|
|
@@ -1998,7 +2150,7 @@ async function cmdRecordsWrite(flags) {
|
|
|
1998
2150
|
// useful thing to show, so don't bury it in the generic error line.
|
|
1999
2151
|
console.error(`✗ '${to}' is not a legal move from this record's stage.`);
|
|
2000
2152
|
console.error(` → allowed: ${json.allowed.join(', ') || '(none — terminal stage)'}`);
|
|
2001
|
-
|
|
2153
|
+
exitNow(1);
|
|
2002
2154
|
}
|
|
2003
2155
|
if (status !== 200)
|
|
2004
2156
|
writeFail(`move record ${id} to '${to}'`, status, json, url, true);
|
|
@@ -2109,7 +2261,33 @@ async function cmdFeedback(flags) {
|
|
|
2109
2261
|
console.log(' ⓘ no local capability reference found, so the report carries no KB version.');
|
|
2110
2262
|
console.log(' Pull it before your next session: octwin platform-kb');
|
|
2111
2263
|
}
|
|
2112
|
-
|
|
2264
|
+
// WHAT IS ALREADY WAITING, said here rather than promised for later.
|
|
2265
|
+
//
|
|
2266
|
+
// The submit response carries the same counts `notifyIfMemosWaiting` polls. That nudge
|
|
2267
|
+
// fires on this command too — but it writes to STDERR, and the authors driving this CLI
|
|
2268
|
+
// are agents reading stdout, so the one signal that mattered was addressed to a channel
|
|
2269
|
+
// nobody was listening on. On 2026-08-19 an author sent two reports two hours after
|
|
2270
|
+
// three replies were published and re-reported five already-fixed findings; the nudge
|
|
2271
|
+
// had run correctly both times.
|
|
2272
|
+
//
|
|
2273
|
+
// So it goes on STDOUT, at the moment they are certainly reading, in the same block as
|
|
2274
|
+
// the acknowledgement. `memos` is absent against an older platform — then the old line
|
|
2275
|
+
// stands, which is the honest thing to say when we cannot know.
|
|
2276
|
+
const memos = json?.memos;
|
|
2277
|
+
const unread = Number(memos?.unread ?? 0);
|
|
2278
|
+
const actionable = Number(memos?.unread_actionable ?? 0);
|
|
2279
|
+
if (Number.isFinite(unread) && unread > 0) {
|
|
2280
|
+
const what = unread === 1 ? '1 reply is' : `${unread} replies are`;
|
|
2281
|
+
const tail = Number.isFinite(actionable) && actionable > 0 ? ` (${actionable} needing action)` : '';
|
|
2282
|
+
console.log(` ✉ ${what} already waiting for you${tail} — read them BEFORE your next change: octwin memos`);
|
|
2283
|
+
if (actionable > 0) {
|
|
2284
|
+
// The severity axis exists so an agent mid-build stops instead of finishing first.
|
|
2285
|
+
console.log(' One or more may already answer what you just reported.');
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
else {
|
|
2289
|
+
console.log(' A reply arrives as a memo — this CLI will tell you when one is waiting.');
|
|
2290
|
+
}
|
|
2113
2291
|
}
|
|
2114
2292
|
/**
|
|
2115
2293
|
* `octwin memos [--json] [--all]` — read what the platform has told you.
|
|
@@ -2571,13 +2749,13 @@ async function cmdChat(flags) {
|
|
|
2571
2749
|
// isn't answering — the remaining turns would land out of context.
|
|
2572
2750
|
await cancel();
|
|
2573
2751
|
console.error(` turn ${i + 1} produced no render after ${Math.round(REPLY_TIMEOUT_MS / 1000)}s — stopping the script here.`);
|
|
2574
|
-
|
|
2752
|
+
exitNow(1);
|
|
2575
2753
|
}
|
|
2576
2754
|
}
|
|
2577
2755
|
await cancel();
|
|
2578
2756
|
if (totalRenders === 0) {
|
|
2579
2757
|
console.error(` no reply after ${Math.round(REPLY_TIMEOUT_MS / 1000)}s — the pack may not be warm yet, or the turn produced no render.`);
|
|
2580
|
-
|
|
2758
|
+
exitNow(1);
|
|
2581
2759
|
}
|
|
2582
2760
|
console.log(`\n(same --as '${from}' continues this conversation — timeline: octwin logs --as ${from})`);
|
|
2583
2761
|
}
|
|
@@ -2619,7 +2797,7 @@ async function cmdMedia(flags) {
|
|
|
2619
2797
|
}
|
|
2620
2798
|
console.error(`✗ media generate failed (HTTP ${res.status})${errDetail(j)}`);
|
|
2621
2799
|
printAuthHint(res.status, url);
|
|
2622
|
-
|
|
2800
|
+
exitNow(1);
|
|
2623
2801
|
}
|
|
2624
2802
|
const r = JSON.parse(text);
|
|
2625
2803
|
const absUrl = /^https?:/i.test(r.url) ? r.url : `${url}${r.url}`;
|
|
@@ -3290,7 +3468,7 @@ async function cmdOrdersWrite(flags) {
|
|
|
3290
3468
|
console.error(` → allowed: ${allowed.join(', ')}`);
|
|
3291
3469
|
else
|
|
3292
3470
|
console.error(` → see the allowed set: octwin orders ${ref}`);
|
|
3293
|
-
|
|
3471
|
+
exitNow(1);
|
|
3294
3472
|
}
|
|
3295
3473
|
if (status !== 200)
|
|
3296
3474
|
writeFail(`move order ${ref} to '${to}'`, status, json, url);
|
|
@@ -3325,7 +3503,7 @@ async function cmdOrdersWrite(flags) {
|
|
|
3325
3503
|
console.log(` gateway : ${gw.status ?? (gw.ok === false ? 'failed' : 'ok')}${gw.error || gw.message ? ` — ${gw.error ?? gw.message}` : ''}`);
|
|
3326
3504
|
if (refused) {
|
|
3327
3505
|
console.error('\n✗ the PAYMENT GATEWAY refused the refund — the order was updated but no money moved.');
|
|
3328
|
-
|
|
3506
|
+
exitNow(1);
|
|
3329
3507
|
}
|
|
3330
3508
|
console.log('✓ Refund accepted.');
|
|
3331
3509
|
}
|
|
@@ -3848,6 +4026,7 @@ function help() {
|
|
|
3848
4026
|
octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
|
|
3849
4027
|
octwin projects [--archived] [--json] # the --project slugs this token can name
|
|
3850
4028
|
octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
4029
|
+
[--request-listing | --withdraw-listing] # public marketplace — opt-in, see: octwin help deploy
|
|
3851
4030
|
octwin status [<packId>] [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>]
|
|
3852
4031
|
octwin pull <packId> [--dir <out>] [--version <v>] [--force] # write a DEPLOYED pack's source back to disk (the inverse of deploy)
|
|
3853
4032
|
octwin records [entity] [id] # inspect the pack's XRM data (needs a records:read token)
|
|
@@ -3923,8 +4102,21 @@ octwin projects rm <slug> [--yes]
|
|
|
3923
4102
|
octwin projects rm scratch --yes
|
|
3924
4103
|
Both verbs need the \`projects:write\` scope — a pack:deploy token does NOT confer it.`,
|
|
3925
4104
|
deploy: `octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
|
|
4105
|
+
[--request-listing | --withdraw-listing]
|
|
3926
4106
|
Upload the pack bundle, validate server-side, install onto the project.
|
|
3927
|
-
--seed additionally applies the pack's demo seed (streams progress)
|
|
4107
|
+
--seed additionally applies the pack's demo seed (streams progress).
|
|
4108
|
+
|
|
4109
|
+
A plain deploy says NOTHING about the public marketplace — it is a test loop, so it
|
|
4110
|
+
neither asks for a listing nor gives one up. The marketplace flags are opt-in:
|
|
4111
|
+
|
|
4112
|
+
--request-listing ask an operator to review this pack for the public marketplace
|
|
4113
|
+
(the pre-signup storefront at /packs). Requires 'public: true'
|
|
4114
|
+
under 'listing:' in manifest.yaml — the manifest states that the
|
|
4115
|
+
pack is a product, the flag is you choosing to ask.
|
|
4116
|
+
--withdraw-listing retract the request, including an approved listing.
|
|
4117
|
+
|
|
4118
|
+
An approval covers the CONTENT it was made against, so a later deploy that changes the
|
|
4119
|
+
pack returns it to the review queue on its own — no flag needed, and the CLI says so.`,
|
|
3928
4120
|
seed: `octwin seed [--pack <packId>]
|
|
3929
4121
|
Apply the pack's demo/reference data to the project it is installed on, without
|
|
3930
4122
|
redeploying: xrm \`demo:\` records + scheduling availability, the commerce catalog,
|
|
@@ -4126,6 +4318,23 @@ async function main() {
|
|
|
4126
4318
|
console.log(COMMAND_HELP[command]);
|
|
4127
4319
|
return;
|
|
4128
4320
|
}
|
|
4321
|
+
// Compute the trailing nudges BEFORE the command runs, so they survive a `die()` — see the
|
|
4322
|
+
// notice-channel block above. They are PRINTED after the command, by `flushNotices` here on the
|
|
4323
|
+
// success path and by `exitNow` on every failure path. Fail-silent by construction (each helper
|
|
4324
|
+
// swallows and returns []), and the KB + memo polls still ride only on commands that were
|
|
4325
|
+
// already going to hit the network.
|
|
4326
|
+
// SEQUENTIAL, not `Promise.all`. Two concurrent polls to the same host leave a socket checked
|
|
4327
|
+
// out of undici's pool, and `exitNow`'s `process.exit` then aborts on Windows with
|
|
4328
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c` — replacing the real
|
|
4329
|
+
// exit code with 127, so a failing `octwin deploy` in CI reported the wrong thing. Measured
|
|
4330
|
+
// 2026-08-26: one pre-command poll exits 1 cleanly, two concurrent ones abort. Awaiting them in
|
|
4331
|
+
// turn costs one extra round-trip on an already-networked command and keeps the exit code true.
|
|
4332
|
+
const networked = commandTouchesPlatform(command, flags);
|
|
4333
|
+
if (networked) {
|
|
4334
|
+
PENDING_NOTICES.push(...await kbStaleNotice(flags));
|
|
4335
|
+
PENDING_NOTICES.push(...await memosWaitingNotice(flags));
|
|
4336
|
+
}
|
|
4337
|
+
PENDING_NOTICES.push(...await outdatedNotice());
|
|
4129
4338
|
switch (command) {
|
|
4130
4339
|
case 'init':
|
|
4131
4340
|
cmdInit(flags);
|
|
@@ -4209,13 +4418,18 @@ async function main() {
|
|
|
4209
4418
|
break;
|
|
4210
4419
|
default: die(`unknown command '${command}' — run \`octwin help\``);
|
|
4211
4420
|
}
|
|
4212
|
-
|
|
4213
|
-
// already hit the platform (so it's one extra tiny GET, never a new call on
|
|
4214
|
-
// offline paths); CLI-upgrade always.
|
|
4215
|
-
if (commandTouchesPlatform(command, flags))
|
|
4216
|
-
await notifyIfKbStale(flags);
|
|
4217
|
-
if (commandTouchesPlatform(command, flags))
|
|
4218
|
-
await notifyIfMemosWaiting(flags);
|
|
4219
|
-
await notifyIfOutdated();
|
|
4421
|
+
flushNotices();
|
|
4220
4422
|
}
|
|
4221
|
-
main().catch((err) =>
|
|
4423
|
+
main().catch((err) => {
|
|
4424
|
+
// `CliExit` is our own controlled stop — the message is already printed and the notices already
|
|
4425
|
+
// flushed. Setting `exitCode` rather than calling `process.exit` is the whole point: the process
|
|
4426
|
+
// ends when the event loop drains, which is the only teardown that does not abort on Windows
|
|
4427
|
+
// after the notice polls have opened sockets. See `exitNow`.
|
|
4428
|
+
if (err instanceof CliExit) {
|
|
4429
|
+
process.exitCode = err.code;
|
|
4430
|
+
return;
|
|
4431
|
+
}
|
|
4432
|
+
console.error(`✗ ${err?.message ?? String(err)}`);
|
|
4433
|
+
flushNotices();
|
|
4434
|
+
process.exitCode = 1;
|
|
4435
|
+
});
|
package/dist/lib/render-check.js
CHANGED
|
@@ -58,6 +58,23 @@ export function loadAllowedRenderKeys(packDir) {
|
|
|
58
58
|
}
|
|
59
59
|
return { lookup, keys: out, nested };
|
|
60
60
|
}
|
|
61
|
+
/** The tap verbs, in the order the platform's compiler picks them. */
|
|
62
|
+
const ON_SELECT_VERBS = ['invoke', 'resume', 'reply', 'agent'];
|
|
63
|
+
/**
|
|
64
|
+
* `{ if, then, else }` — mirrors `isIfFragment` in the platform's
|
|
65
|
+
* `interpreter/render-resolver.ts` and `schemas/render.ts`. Three copies is two too
|
|
66
|
+
* many, but this package is deliberately dependency-free from `src/platform`; the
|
|
67
|
+
* predicate is identical and `render-check.parity.test.ts` is what keeps it so.
|
|
68
|
+
*/
|
|
69
|
+
function isIfFragment(v) {
|
|
70
|
+
return !!v && typeof v === 'object' && !Array.isArray(v) &&
|
|
71
|
+
typeof v.if === 'string' &&
|
|
72
|
+
Object.keys(v).every(k => k === 'if' || k === 'then' || k === 'else');
|
|
73
|
+
}
|
|
74
|
+
/** The `on_select` NODE itself — the one place an if-fragment is not evaluated. */
|
|
75
|
+
function isOnSelectPath(at) {
|
|
76
|
+
return at === 'on_select' || at.endsWith('.on_select');
|
|
77
|
+
}
|
|
61
78
|
/**
|
|
62
79
|
* Walk parsed YAML for objects carrying `render_intent` and report keys outside
|
|
63
80
|
* that intent's allowed set. Walks the whole document rather than just `render:`
|
|
@@ -81,7 +98,50 @@ export function findRenderKeyViolations(doc, file, allowedByIntent, nestedByInte
|
|
|
81
98
|
if (!value || typeof value !== 'object')
|
|
82
99
|
return;
|
|
83
100
|
const obj = value;
|
|
101
|
+
// An if-fragment stands in for the value it selects, so check its branches at the SAME
|
|
102
|
+
// path. Mirrors the platform's `checkNested`: without this the wrapper's own keys were
|
|
103
|
+
// reported against the slot it wraps, which rejected the one form that actually works.
|
|
104
|
+
if (isIfFragment(obj) && !isOnSelectPath(at)) {
|
|
105
|
+
for (const b of ['then', 'else']) {
|
|
106
|
+
if (obj[b] !== undefined)
|
|
107
|
+
walkNested(obj[b], intent, at, sets, [...path, b]);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// …and at an `on_select` node it is the reported bug, because runtime does not evaluate
|
|
112
|
+
// it there. One sentence naming the two forms that do work.
|
|
113
|
+
if (isIfFragment(obj) && isOnSelectPath(at)) {
|
|
114
|
+
findings.push({
|
|
115
|
+
file, path: [...path], intent, keys: [], allowed: ON_SELECT_VERBS, nestedAt: at,
|
|
116
|
+
note: `render_intent '${intent}' at '${at}': '{ if, then, else }' is not evaluated inside ` +
|
|
117
|
+
`'on_select' — it fails at render time, not at validate. Wrap the WHOLE entry instead ` +
|
|
118
|
+
`({ if, then: { title, on_select }, else: {…} }), or give each row its own on_select in ` +
|
|
119
|
+
`data and point at it (on_select: '$item.on_select').`,
|
|
120
|
+
});
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
84
123
|
const allowed = sets[at];
|
|
124
|
+
// A missing verb is an absence, not an unknown key, so the key check below is blind to it —
|
|
125
|
+
// and the tap compiler throws on it at render. Only judged where the KB declares the slot,
|
|
126
|
+
// so a KB pulled before on_select was declared simply does not run this.
|
|
127
|
+
if (allowed && isOnSelectPath(at)) {
|
|
128
|
+
const present = ON_SELECT_VERBS.filter(v => obj[v] !== undefined);
|
|
129
|
+
if (present.length === 0) {
|
|
130
|
+
findings.push({
|
|
131
|
+
file, path: [...path], intent, keys: [], allowed: ON_SELECT_VERBS, nestedAt: at,
|
|
132
|
+
note: `render_intent '${intent}' at '${at}': 'on_select' needs exactly one of ` +
|
|
133
|
+
`${ON_SELECT_VERBS.join(' / ')} — it has none, so the tap has no target and ` +
|
|
134
|
+
`fails at render time.`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
else if (present.length > 1) {
|
|
138
|
+
findings.push({
|
|
139
|
+
file, path: [...path], intent, keys: [], allowed: ON_SELECT_VERBS, nestedAt: at,
|
|
140
|
+
note: `render_intent '${intent}' at '${at}': 'on_select' has more than one verb ` +
|
|
141
|
+
`(${present.join(', ')}); only '${present[0]}' would run. Keep one.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
85
145
|
if (allowed) {
|
|
86
146
|
const bad = Object.keys(obj).filter(k => !allowed.includes(k));
|
|
87
147
|
if (bad.length)
|
|
@@ -126,6 +186,10 @@ export function findRenderKeyViolations(doc, file, allowedByIntent, nestedByInte
|
|
|
126
186
|
/** One-line human message per finding. */
|
|
127
187
|
export function describeRenderFinding(f) {
|
|
128
188
|
const at = f.line != null ? `:${f.line}` : (f.path.length ? ` (${f.path.join('.')})` : '');
|
|
189
|
+
// A pre-worded finding (absent verb, if-fragment on a tap payload) carries its own sentence;
|
|
190
|
+
// the key-list template below cannot say either one.
|
|
191
|
+
if (f.note)
|
|
192
|
+
return `${f.file}${at}: ${f.note}`;
|
|
129
193
|
if (f.keys.length === 0) {
|
|
130
194
|
return `${f.file}${at}: unknown render_intent '${f.intent}' — known intents: ${f.allowed.join(', ')}`;
|
|
131
195
|
}
|
package/package.json
CHANGED
|
@@ -1,37 +1,46 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "octwin-cli",
|
|
3
|
-
"version": "0.7.
|
|
4
|
-
"description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"octwin": "dist/index.js"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"dist",
|
|
11
|
-
"templates",
|
|
12
|
-
"README.md",
|
|
13
|
-
"CHANGELOG.md",
|
|
14
|
-
"LICENSE"
|
|
15
|
-
],
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">=22"
|
|
18
|
-
},
|
|
19
|
-
"scripts": {
|
|
20
|
-
"build": "tsc -p tsconfig.json",
|
|
21
|
-
"prepublishOnly": "npm run build"
|
|
22
|
-
},
|
|
23
|
-
"dependencies": {
|
|
24
|
-
"yaml": "^2.6.0"
|
|
25
|
-
},
|
|
26
|
-
"devDependencies": {
|
|
27
|
-
"@types/node": "^22.0.0",
|
|
28
|
-
"typescript": "^5.7.0"
|
|
29
|
-
},
|
|
30
|
-
"publishConfig": {
|
|
31
|
-
"access": "public"
|
|
32
|
-
},
|
|
33
|
-
"keywords": [
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "octwin-cli",
|
|
3
|
+
"version": "0.7.3",
|
|
4
|
+
"description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"octwin": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"templates",
|
|
12
|
+
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"yaml": "^2.6.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"typescript": "^5.7.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"octwin",
|
|
35
|
+
"cequens",
|
|
36
|
+
"cli",
|
|
37
|
+
"whatsapp",
|
|
38
|
+
"chatbot",
|
|
39
|
+
"pack",
|
|
40
|
+
"conversational-ai",
|
|
41
|
+
"yaml"
|
|
42
|
+
],
|
|
43
|
+
"author": "CEQUENS",
|
|
44
|
+
"homepage": "https://www.npmjs.com/package/octwin-cli",
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|