octwin-cli 0.8.8 → 0.8.10
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 +37 -0
- package/dist/index.js +70 -28
- package/dist/lib/client-headers.js +80 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ 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.8.10] - 2026-09-07
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **`octwin memos` failed with `400 project required` for a tenant-wide token — the exact
|
|
12
|
+
command the nudge tells you to run.** Every networked command can end with
|
|
13
|
+
*"✉ N memos from the platform — read them: `octwin memos`"*, and running precisely that with
|
|
14
|
+
an unpinned token answered a 400. Measured on staging: the nudge fired during a
|
|
15
|
+
`deploy --project …` (which supplied a project) and then named a command that could not run.
|
|
16
|
+
|
|
17
|
+
A memo has no project dimension to scope by — neither `pack_memos` nor `pack_memo_reads`
|
|
18
|
+
carries a `project_id`, and the read ledger is keyed by tenant — so the three call sites now
|
|
19
|
+
use `/api/self/t/memos`. Passing `--project` still works and is no longer needed.
|
|
20
|
+
|
|
21
|
+
## [0.8.9] - 2026-09-07
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- **Every authenticated request now says which CLI and which capability reference you are on.**
|
|
25
|
+
Two headers off `authHeaders` — `x-octwin-cli` and `x-octwin-kb` (the `content_hash` of your
|
|
26
|
+
pulled reference) — so `deploy`, `validate --remote`, `status`, `chat`, `logs` and `memos` all
|
|
27
|
+
carry them, from one place. The platform stores them against your deploy token and shows them in
|
|
28
|
+
the console (Settings → API tokens, and Platform → Packs → Feedback → Author tooling for us).
|
|
29
|
+
|
|
30
|
+
**Why the platform needs this from you.** Almost every field report so far turned out to be
|
|
31
|
+
either already fixed in a newer CLI or written against a stale reference — three very different
|
|
32
|
+
answers that look identical without these two facts. They were captured on `octwin feedback`
|
|
33
|
+
only, so an author who never filed a report was invisible. Nothing here affects auth: a request
|
|
34
|
+
that sends neither header authenticates exactly as before, and a value that could not travel as
|
|
35
|
+
a header (a corrupt `package.json`, an interrupted pull) is dropped rather than sent.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **The upgrade notice was skipped under `npx` — silencing the population most likely to be
|
|
39
|
+
stale.** The premise was that "`@latest` already resolves the newest", which holds for
|
|
40
|
+
`npx octwin-cli@latest` and is false for the bare `npx octwin-cli` the docs put in front of
|
|
41
|
+
external developers: npm serves that from its **cache**, so it can be arbitrarily old. Under
|
|
42
|
+
`@latest` the check is self-silencing anyway (you are already newest), so keeping it costs
|
|
43
|
+
nothing — only the advice line differs, because there is no global install to reinstall.
|
|
44
|
+
|
|
8
45
|
## [0.8.8] - 2026-09-06
|
|
9
46
|
|
|
10
47
|
### Added
|
package/dist/index.js
CHANGED
|
@@ -69,6 +69,7 @@ import { loadAllowedRenderKeys, findRenderKeyViolations, describeRenderFinding }
|
|
|
69
69
|
import { loadPrimitiveArgSpecs, findArgViolations, describeArgFinding } from './lib/args-check.js';
|
|
70
70
|
import { yamlLineOf } from './lib/yaml-pos.js';
|
|
71
71
|
import { localhostFamilyHint } from './lib/net-hint.js';
|
|
72
|
+
import { authHeaders as buildAuthHeaders } from './lib/client-headers.js';
|
|
72
73
|
import { loadBuiltinNames, findBuiltinViolations, describeBuiltinFinding } from './lib/builtin-check.js';
|
|
73
74
|
import { loadTemplateSpecs, findTemplateViolations, describeTemplateFinding } from './lib/template-check.js';
|
|
74
75
|
import { loadSystemEntities, findEntityViolations, describeEntityFinding } from './lib/entity-check.js';
|
|
@@ -542,9 +543,8 @@ async function latestPublishedVersion() {
|
|
|
542
543
|
}
|
|
543
544
|
}
|
|
544
545
|
/** True when running via `npx` — the CLI's own file lives in npx's cache dir, or npm
|
|
545
|
-
* ran it as `npm exec`.
|
|
546
|
-
*
|
|
547
|
-
* misleading — stay silent. The notice is for a GLOBAL install (`npm i -g`). */
|
|
546
|
+
* ran it as `npm exec`. Only the ADVICE changes on this: there is no global install
|
|
547
|
+
* to upgrade, so the fix is to pin `@latest` rather than to reinstall. */
|
|
548
548
|
function isNpx() {
|
|
549
549
|
try {
|
|
550
550
|
return fileURLToPath(import.meta.url).includes('_npx') || process.env.npm_command === 'exec';
|
|
@@ -555,23 +555,31 @@ function isNpx() {
|
|
|
555
555
|
}
|
|
556
556
|
/** A one-line upgrade notice when a newer octwin-cli is published, or [] when current.
|
|
557
557
|
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
558
|
+
* Gated on NOTHING. It USED to bail on `!process.stdout.isTTY`, which silently made this the one
|
|
559
|
+
* notice a piped reader never saw — the opposite of the rule its two siblings already carry in
|
|
560
|
+
* their docblocks, and measured 2026-08-26: with a newer version cached, `octwin whoami | tail`
|
|
561
|
+
* printed the KB nudge and not this one. An agent driving the CLI cannot notice an outdated CLI on
|
|
562
|
+
* its own, so it is exactly the reader that needs telling. One line on stderr, so piped stdout
|
|
563
|
+
* stays clean either way.
|
|
564
|
+
*
|
|
565
|
+
* IT ALSO USED TO BAIL UNDER npx, on the premise that "`@latest` already resolves the newest".
|
|
566
|
+
* That premise holds for `npx octwin-cli@latest` and is false for the bare `npx octwin-cli` that
|
|
567
|
+
* `BACKLOG-decided.md` and the authoring skill both put in front of external developers — npm
|
|
568
|
+
* serves that from its cache, so it can be arbitrarily old and was the ONE population this
|
|
569
|
+
* notice never reached. Under `@latest` the check is self-silencing anyway (`VERSION` is already
|
|
570
|
+
* the newest, so `isNewer` is false), which is why keeping it costs nothing and only the advice
|
|
571
|
+
* line has to differ: there is no global install to reinstall.
|
|
564
572
|
*
|
|
565
573
|
* Never throws — a version check must never break a command. */
|
|
566
574
|
async function outdatedNotice() {
|
|
567
|
-
if (isNpx())
|
|
568
|
-
return [];
|
|
569
575
|
try {
|
|
570
576
|
const latest = await latestPublishedVersion();
|
|
571
577
|
if (latest && isNewer(latest, VERSION)) {
|
|
572
578
|
return [
|
|
573
579
|
`\n⬆ octwin-cli ${latest} is available (you have ${VERSION}).`,
|
|
574
|
-
|
|
580
|
+
isNpx()
|
|
581
|
+
? ' You are running via npx, which serves a CACHED copy — pin it: npx octwin-cli@latest'
|
|
582
|
+
: ' Upgrade: npm i -g octwin-cli@latest (or just use npx octwin-cli@latest)',
|
|
575
583
|
];
|
|
576
584
|
}
|
|
577
585
|
}
|
|
@@ -585,6 +593,18 @@ async function outdatedNotice() {
|
|
|
585
593
|
// catalogs only) describes how to enumerate a catalog so we can EXPLODE it into
|
|
586
594
|
// one file per entry; the PLATFORM supplies it rather than this CLI hardcoding
|
|
587
595
|
// per-catalog knowledge, so a platform that adds a catalog needs no CLI release.
|
|
596
|
+
/**
|
|
597
|
+
* The pulled reference for THIS run, read once in `main` before any command.
|
|
598
|
+
*
|
|
599
|
+
* Two readers need it and both used to reach for the disk themselves: the drift
|
|
600
|
+
* nudge, and now `authHeaders` (which runs on every request — see the client
|
|
601
|
+
* report). Reading it once also keeps the corrupt-`index.json` warning below to
|
|
602
|
+
* one printing.
|
|
603
|
+
*
|
|
604
|
+
* `null` = nothing pulled (or unreadable); a non-null row with a null
|
|
605
|
+
* `content_hash` = pulled, but the identity file did not carry one.
|
|
606
|
+
*/
|
|
607
|
+
let LOCAL_KB = null;
|
|
588
608
|
/** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
|
|
589
609
|
* (content hash + per-entry index), or null if nothing has been pulled yet. */
|
|
590
610
|
function readLocalKb(packDir) {
|
|
@@ -658,8 +678,9 @@ async function fetchKbMeta(t, timeoutMs = 2_000) {
|
|
|
658
678
|
* `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
|
|
659
679
|
* of the CLI: run only after commands that already hit the platform, so this adds
|
|
660
680
|
* a single tiny `?meta=1` GET on top of work that was networked anyway. Never
|
|
661
|
-
* throws — observing must never break a command. No-op
|
|
662
|
-
*
|
|
681
|
+
* throws — observing must never break a command. No-op if the target can't be
|
|
682
|
+
* resolved, or until the author has pulled once — nothing pulled is the PLATFORM's
|
|
683
|
+
* line to say, on deploy + validate, so that it reaches an old CLI too.
|
|
663
684
|
*
|
|
664
685
|
* NOT TTY-gated, deliberately. It was, on the reasoning that a nudge is for a
|
|
665
686
|
* human watching a terminal — but the primary reader of this CLI is now an
|
|
@@ -668,10 +689,24 @@ async function fetchKbMeta(t, timeoutMs = 2_000) {
|
|
|
668
689
|
* told. It is a single line on stderr, so piped stdout stays clean either way. */
|
|
669
690
|
async function kbStaleNotice(flags) {
|
|
670
691
|
try {
|
|
671
|
-
const
|
|
672
|
-
|
|
692
|
+
const local = LOCAL_KB;
|
|
693
|
+
/**
|
|
694
|
+
* NOTHING PULLED IS NOT THIS OBSERVER'S JOB — it is the PLATFORM's.
|
|
695
|
+
*
|
|
696
|
+
* This branch is a no-op, which reads like the bug it used to be, so: the "you have
|
|
697
|
+
* pulled no reference" line now comes from the platform, on `deploy` and
|
|
698
|
+
* `validate --remote` (`routes/packs/_cli-advisory.ts`), and that is strictly better
|
|
699
|
+
* placed. A client-side nudge can only reach a CLI new enough to contain it, and the
|
|
700
|
+
* authors who have pulled nothing are exactly the ones most likely to be on an old
|
|
701
|
+
* one. The server answer reaches every CLI.
|
|
702
|
+
*
|
|
703
|
+
* Saying it here TOO was measured on 2026-09-07 and rejected: a never-pulled
|
|
704
|
+
* `validate` printed the same fact three times — the SKIPPED-checks line (which is
|
|
705
|
+
* the most specific of the three, naming how many checks could not run), the
|
|
706
|
+
* platform's advisory, and this. Three is how the credible ones stop being read.
|
|
707
|
+
*/
|
|
673
708
|
if (!local?.content_hash)
|
|
674
|
-
return [];
|
|
709
|
+
return [];
|
|
675
710
|
// URL only, deliberately: the KB poll is anonymous, so requiring a token here would
|
|
676
711
|
// silence the nudge for exactly the authors who most need it. This used to call
|
|
677
712
|
// `resolveTargetOrNull` (url AND token) against the tenant-scoped route, which needs
|
|
@@ -741,7 +776,7 @@ async function memosWaitingNotice(flags) {
|
|
|
741
776
|
// on a network failure. An observer that can terminate the CLI is not an observer: with an
|
|
742
777
|
// unreachable platform this nudge would have failed the command it was only meant to annotate,
|
|
743
778
|
// and now that the polls run BEFORE the command it would have failed it before it even ran.
|
|
744
|
-
const res = await fetch(`${t.url}/api/self/
|
|
779
|
+
const res = await fetch(`${t.url}/api/self/t/memos?meta=1`, {
|
|
745
780
|
headers: authHeaders(t), signal: AbortSignal.timeout(2_000),
|
|
746
781
|
});
|
|
747
782
|
const status = res.status;
|
|
@@ -1418,14 +1453,16 @@ async function cmdLoginBrowser(url, flags) {
|
|
|
1418
1453
|
}
|
|
1419
1454
|
exitNow(EXIT_STILL_PENDING);
|
|
1420
1455
|
}
|
|
1421
|
-
/**
|
|
1456
|
+
/**
|
|
1457
|
+
* Bearer auth + the optional self-surface overrides + this run's client report.
|
|
1458
|
+
*
|
|
1459
|
+
* A thin wrapper so the ~90 call sites stay unchanged while the logic (and its
|
|
1460
|
+
* two omit-don't-placeholder rules) lives in a testable module. The KB hash comes
|
|
1461
|
+
* from `LOCAL_KB`, read ONCE per run in `main` — reading it per request would hit
|
|
1462
|
+
* the disk on every call and could print the corrupt-index warning many times.
|
|
1463
|
+
*/
|
|
1422
1464
|
function authHeaders(t) {
|
|
1423
|
-
|
|
1424
|
-
if (t.tenant)
|
|
1425
|
-
h['x-octwin-tenant'] = t.tenant;
|
|
1426
|
-
if (t.project)
|
|
1427
|
-
h['x-octwin-project'] = t.project;
|
|
1428
|
-
return h;
|
|
1465
|
+
return buildAuthHeaders(t, { cliVersion: VERSION, kbContentHash: LOCAL_KB?.content_hash ?? null });
|
|
1429
1466
|
}
|
|
1430
1467
|
/** Resolve platform url + token (+ optional tenant/project overrides):
|
|
1431
1468
|
* flags > env > saved login. Tenant is derived from the token server-side, so
|
|
@@ -2581,7 +2618,9 @@ async function cmdFeedback(flags) {
|
|
|
2581
2618
|
const doc = parseYaml(readFileSync(manifestPath, 'utf8'));
|
|
2582
2619
|
const packId = typeof doc?.id === 'string' ? doc.id : die('manifest.yaml must declare a string `id`');
|
|
2583
2620
|
const packVersion = typeof doc?.version === 'string' ? doc.version : undefined;
|
|
2584
|
-
|
|
2621
|
+
// `LOCAL_KB` is this run's read of the SAME `--dir`, taken in `main` — so this is the
|
|
2622
|
+
// hash the client report already sent in the headers, not a second opinion about it.
|
|
2623
|
+
const kbHash = LOCAL_KB?.content_hash ?? undefined;
|
|
2585
2624
|
console.log(`→ Submitting ${Math.round(Buffer.byteLength(report, 'utf8') / 1024)}KB of feedback on ${packId} to ${targetLabel(t)} …`);
|
|
2586
2625
|
const { status, json } = await apiSend('POST', `${url}/api/self/p/packs/feedback`, {
|
|
2587
2626
|
pack_id: packId,
|
|
@@ -2642,7 +2681,7 @@ async function cmdFeedback(flags) {
|
|
|
2642
2681
|
async function cmdMemos(flags) {
|
|
2643
2682
|
const t = resolveTarget(flags);
|
|
2644
2683
|
const all = flags.all === true;
|
|
2645
|
-
const { status, json } = await apiGet(`${t.url}/api/self/
|
|
2684
|
+
const { status, json } = await apiGet(`${t.url}/api/self/t/memos${all ? '?all=1' : ''}`, t);
|
|
2646
2685
|
if (status !== 200) {
|
|
2647
2686
|
die(`could not read memos (HTTP ${status})${errDetail(json)}${authFailureDetail(status, t.url)}`);
|
|
2648
2687
|
}
|
|
@@ -2672,7 +2711,7 @@ async function cmdMemos(flags) {
|
|
|
2672
2711
|
if (!all) {
|
|
2673
2712
|
const ids = rows.filter(m => !m.read_at).map(m => m.id);
|
|
2674
2713
|
if (ids.length > 0) {
|
|
2675
|
-
const res = await apiSend('POST', `${t.url}/api/self/
|
|
2714
|
+
const res = await apiSend('POST', `${t.url}/api/self/t/memos/read`, { ids }, t);
|
|
2676
2715
|
// A failed ack is not a failed command — the author HAS read them. Say so rather
|
|
2677
2716
|
// than dying after printing everything, and the nudge simply fires again.
|
|
2678
2717
|
if (res.status !== 200)
|
|
@@ -5550,6 +5589,9 @@ async function main() {
|
|
|
5550
5589
|
// exit code with 127, so a failing `octwin deploy` in CI reported the wrong thing. Measured
|
|
5551
5590
|
// 2026-08-26: one pre-command poll exits 1 cleanly, two concurrent ones abort. Awaiting them in
|
|
5552
5591
|
// turn costs one extra round-trip on an already-networked command and keeps the exit code true.
|
|
5592
|
+
// Read the pulled reference ONCE, here, before anything can ask for it: the drift nudge below
|
|
5593
|
+
// needs its index, and `authHeaders` needs its hash on every request of every command.
|
|
5594
|
+
LOCAL_KB = readLocalKb(resolve(flags.dir ?? '.'));
|
|
5553
5595
|
const networked = commandTouchesPlatform(command, flags);
|
|
5554
5596
|
if (networked) {
|
|
5555
5597
|
PENDING_NOTICES.push(...await kbStaleNotice(flags));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this CLI tells the platform about ITSELF, on every authenticated request.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* A pack built against an old CLI or a stale capability reference is wrong in ways
|
|
7
|
+
* that look like platform bugs. That is measured, not feared: one author hardcoded
|
|
8
|
+
* English across all 16 flows of a production pack to work around a bug fixed two
|
|
9
|
+
* days earlier, and two authors independently filed a CLI bug as platform replica
|
|
10
|
+
* lag. The platform's own schema says so — `007_pack_feedback.sql` captures
|
|
11
|
+
* `cli_version` + `kb_content_hash` because "almost every report from the field so
|
|
12
|
+
* far turned out to be either (a) already fixed in a newer CLI, or (b) written
|
|
13
|
+
* against a stale capability reference".
|
|
14
|
+
*
|
|
15
|
+
* But it captured them on ONE route: `octwin feedback`. `deploy` and `validate`
|
|
16
|
+
* sent neither, so an author who never filed a report was invisible — and the two
|
|
17
|
+
* client-side nudges (the npm upgrade check, the KB drift poll) are both blind in
|
|
18
|
+
* exactly the states that matter most: nothing pulled at all, and `npx`.
|
|
19
|
+
*
|
|
20
|
+
* So the report rides the AUTH HEADERS instead. `authHeaders` is the one place
|
|
21
|
+
* every networked command builds its headers, which is why two lines here cover
|
|
22
|
+
* deploy, validate, status, chat, logs and memos at once.
|
|
23
|
+
*
|
|
24
|
+
* ## Two rules that are not obvious
|
|
25
|
+
*
|
|
26
|
+
* **Omit, never send a placeholder.** A missing `x-octwin-kb` means "this author
|
|
27
|
+
* pulled no reference". A literal `"none"` would be a magic string every reader has
|
|
28
|
+
* to learn, and it would land in the platform's column as a value that looks like
|
|
29
|
+
* data.
|
|
30
|
+
*
|
|
31
|
+
* **A malformed value is dropped, not sent.** undici THROWS on a header value
|
|
32
|
+
* containing a newline, and these values come off disk (`package.json`, a pulled
|
|
33
|
+
* `index.json`) — so an unvalidated pass-through turns a corrupt file into a failed
|
|
34
|
+
* command. A diagnostic that can break the command it annotates is not a
|
|
35
|
+
* diagnostic. The platform validates again on its side; that is a bound on what it
|
|
36
|
+
* stores, not a substitute for this.
|
|
37
|
+
*/
|
|
38
|
+
/** This CLI's version. Read by the platform on every authenticated request. */
|
|
39
|
+
export const HEADER_CLI_VERSION = 'x-octwin-cli';
|
|
40
|
+
/** The `content_hash` of the capability reference the author actually pulled. */
|
|
41
|
+
export const HEADER_KB_HASH = 'x-octwin-kb';
|
|
42
|
+
/**
|
|
43
|
+
* A semver-ish version safe to put in a header. Deliberately wider than `x.y.z`
|
|
44
|
+
* (a prerelease tag is a real version) and hard-bounded, because the value comes
|
|
45
|
+
* from a `package.json` this module does not control.
|
|
46
|
+
*/
|
|
47
|
+
const SAFE_VERSION = /^[0-9A-Za-z.+-]{1,32}$/;
|
|
48
|
+
/**
|
|
49
|
+
* The published hash shape is 12 hex — `contentHash()` truncates a sha256 — but
|
|
50
|
+
* the range is loose on purpose: the platform owns that length and may change it,
|
|
51
|
+
* and a CLI that rejected a longer hash would silence its own report on the very
|
|
52
|
+
* upgrade it should be reporting.
|
|
53
|
+
*/
|
|
54
|
+
const SAFE_KB_HASH = /^[0-9a-f]{8,64}$/;
|
|
55
|
+
/** The client report as headers — each field omitted when absent or malformed. */
|
|
56
|
+
export function clientReportHeaders(report) {
|
|
57
|
+
const h = {};
|
|
58
|
+
if (SAFE_VERSION.test(report.cliVersion))
|
|
59
|
+
h[HEADER_CLI_VERSION] = report.cliVersion;
|
|
60
|
+
const kb = report.kbContentHash;
|
|
61
|
+
if (kb && SAFE_KB_HASH.test(kb))
|
|
62
|
+
h[HEADER_KB_HASH] = kb;
|
|
63
|
+
return h;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Bearer auth + the optional self-surface overrides + the client report.
|
|
67
|
+
*
|
|
68
|
+
* The token carries its own tenant (server-side), so the unified `/api/self/**`
|
|
69
|
+
* surface needs neither tenant nor project in the URL. `tenant`/`project` are kept
|
|
70
|
+
* only as OPTIONAL overrides (project overrides an unpinned token; a mismatched
|
|
71
|
+
* override of a pinned token is rejected server-side).
|
|
72
|
+
*/
|
|
73
|
+
export function authHeaders(t, report) {
|
|
74
|
+
const h = { authorization: `Bearer ${t.token}` };
|
|
75
|
+
if (t.tenant)
|
|
76
|
+
h['x-octwin-tenant'] = t.tenant;
|
|
77
|
+
if (t.project)
|
|
78
|
+
h['x-octwin-project'] = t.project;
|
|
79
|
+
return { ...h, ...clientReportHeaders(report) };
|
|
80
|
+
}
|
package/package.json
CHANGED