octwin-cli 0.8.8 → 0.8.9

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 CHANGED
@@ -5,6 +5,30 @@ 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.9] - 2026-09-07
9
+
10
+ ### Added
11
+ - **Every authenticated request now says which CLI and which capability reference you are on.**
12
+ Two headers off `authHeaders` — `x-octwin-cli` and `x-octwin-kb` (the `content_hash` of your
13
+ pulled reference) — so `deploy`, `validate --remote`, `status`, `chat`, `logs` and `memos` all
14
+ carry them, from one place. The platform stores them against your deploy token and shows them in
15
+ the console (Settings → API tokens, and Platform → Packs → Feedback → Author tooling for us).
16
+
17
+ **Why the platform needs this from you.** Almost every field report so far turned out to be
18
+ either already fixed in a newer CLI or written against a stale reference — three very different
19
+ answers that look identical without these two facts. They were captured on `octwin feedback`
20
+ only, so an author who never filed a report was invisible. Nothing here affects auth: a request
21
+ that sends neither header authenticates exactly as before, and a value that could not travel as
22
+ a header (a corrupt `package.json`, an interrupted pull) is dropped rather than sent.
23
+
24
+ ### Fixed
25
+ - **The upgrade notice was skipped under `npx` — silencing the population most likely to be
26
+ stale.** The premise was that "`@latest` already resolves the newest", which holds for
27
+ `npx octwin-cli@latest` and is false for the bare `npx octwin-cli` the docs put in front of
28
+ external developers: npm serves that from its **cache**, so it can be arbitrarily old. Under
29
+ `@latest` the check is self-silencing anyway (you are already newest), so keeping it costs
30
+ nothing — only the advice line differs, because there is no global install to reinstall.
31
+
8
32
  ## [0.8.8] - 2026-09-06
9
33
 
10
34
  ### 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`. Under npx there is no persistent install to upgrade
546
- * (`@latest` already resolves the newest), so an upgrade notice would be
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
- * Skipped only under npx, where there is genuinely nothing to upgrade. It USED to bail on
559
- * `!process.stdout.isTTY` as well, which silently made this the one notice a piped reader never
560
- * saw the opposite of the rule its two siblings already carry in their docblocks, and measured
561
- * 2026-08-26: with a newer version cached, `octwin whoami | tail` printed the KB nudge and not
562
- * this one. An agent driving the CLI cannot notice an outdated CLI on its own, so it is exactly
563
- * the reader that needs telling. One line on stderr, so piped stdout stays clean either way.
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
- ' Upgrade: npm i -g octwin-cli@latest (or just use npx octwin-cli@latest)',
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 until the author has pulled
662
- * once (nothing to compare) or if the target can't be resolved.
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 packDir = resolve(flags.dir ?? '.');
672
- const local = readLocalKb(packDir);
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 []; // never pulled → the skill already says to pull
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
@@ -1418,14 +1453,16 @@ async function cmdLoginBrowser(url, flags) {
1418
1453
  }
1419
1454
  exitNow(EXIT_STILL_PENDING);
1420
1455
  }
1421
- /** Bearer auth + the optional self-surface overrides, as request headers. */
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
- const h = { authorization: `Bearer ${t.token}` };
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
- const kbHash = readLocalKb(packDir)?.content_hash ?? undefined;
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,
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "Octwin external-pack developer CLI (by CEQUENS) — scaffold, validate, deploy, and check pure-YAML packs on your tenant.",
5
5
  "type": "module",
6
6
  "bin": {