octwin-cli 0.8.7 → 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,67 @@ 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
+
32
+ ## [0.8.8] - 2026-09-06
33
+
34
+ ### Added
35
+ - **`octwin login` can sign you in through a browser — no token is pasted anywhere.** Run it with
36
+ no `--token` and it prints an approval link:
37
+
38
+ ```
39
+ Open this link to approve: https://app.octwin.ai/cli-login?code=WDJB-MJHT
40
+ Code shown on that page: WDJB-MJHT
41
+ ```
42
+
43
+ A workspace admin opens it, checks which machine is asking, and clicks Approve; the CLI collects a
44
+ 30-day token by itself and saves it exactly where `--token` would have. `octwin login --token oct_…`
45
+ is unchanged, and CI should still skip login entirely and set `PACK_PLATFORM_URL` + `PACK_TOKEN`.
46
+
47
+ **The wait is bounded and resumable, because this CLI is mostly driven by an agent.** An agent's
48
+ shell call is killed after about two minutes, and a wait that outlives its caller is worse than a
49
+ short one — the process dies mid-handshake and the code the human is still typing belongs to
50
+ nothing. So `login` waits 90 seconds, then exits **75** (`EX_TEMPFAIL`) with the handshake kept in
51
+ `~/.octwin/pending-login.json`; running it again resumes the **same** code, which the platform
52
+ keeps alive for ten minutes. `0` still means signed in and `1` still means failed, so an agent
53
+ branches on the exit code instead of parsing prose. `--wait <seconds>` overrides the 90 (`--wait 0`
54
+ prints the code and returns at once), and `--json` prints the same facts as an object.
55
+
56
+ **Approve only a link you opened because you ran the command.** The link carries the code
57
+ (RFC 8628 `verification_uri_complete`), so the click *is* the consent: approving a link somebody
58
+ sent you would connect their machine to your workspace. The console says so on the page. The code
59
+ is printed here too, and shown there, so you can always tell one request from another.
60
+
61
+ The pending handshake gets its own file rather than a new key in `credentials.json`: every value
62
+ in that file is a token, and its one reserved key (`default_url`) is collision-free only because a
63
+ platform url always contains `://`. A second value shape there would end that argument.
64
+
65
+ - **A 401 now points at `octwin login`, not at the console.** With a 30-day browser token, renewing is
66
+ one command; the old hint sent people to mint a replacement by hand. Mint a token yourself if you
67
+ want a different lifetime — the console offers 30 / 90 days / a year / never.
68
+
8
69
  ## [0.8.7] - 2026-09-06
9
70
 
10
71
  ### Added
package/README.md CHANGED
@@ -46,9 +46,10 @@ git init && git add -A && git commit -m "init pack"
46
46
  # 2. Author it — edit manifest.yaml, flows/tools/main.flow.yaml (+ its locale),
47
47
  # and prompts/identity.md. Everything is pure YAML.
48
48
 
49
- # 3. Point it at your platform — one command, no config file
50
- # (Octwin console your workspace API tokens Generate)
51
- octwin login --url https://your-octwin.example.com --token oct_…
49
+ # 3. Point it at your platform — one command, no config file.
50
+ # It prints a link; approve it in the console and it saves a 30-day token
51
+ # by itself. (Already have a token? add --token oct_…)
52
+ octwin login --url https://your-octwin.example.com
52
53
 
53
54
  # 4. Validate → deploy → confirm it's live
54
55
  octwin validate
@@ -65,7 +66,7 @@ octwin status # "✓ live and current" once it's warm
65
66
  | --- | --- |
66
67
  | `octwin init <dir>` | Scaffold a new pure-YAML pack into `<dir>` (writes a starter `manifest.yaml`, flow, prompt, `.gitignore` and `README.md` — pack content only). Options: `--id`, `--description`, `--display-name`. |
67
68
  | `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
- | `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
+ | `octwin login` | Sign in to a platform URL **and make it the default target** (both stored in `~/.octwin/credentials.json`). With no `--token` it prints a link to approve in the console, then collects a 30-day token itself — nothing is pasted. The wait is bounded and resumable: run it again to keep waiting on the same request. `--url`, `--token`, `--wait <seconds>`, `--json`. |
69
70
  | `octwin whoami` | Verify the saved/passed token is valid for a tenant. `--url`, `--tenant`. |
70
71
  | `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
72
  | `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. |
@@ -164,10 +165,12 @@ Three things worth knowing when you read the output:
164
165
  holds pack content and nothing else, so the same repo deploys from any machine:
165
166
 
166
167
  ```bash
167
- octwin login --url https://your-octwin.example.com --token oct_…
168
+ octwin login --url https://your-octwin.example.com # approve in a browser
169
+ octwin login --url https://your-octwin.example.com --token oct_… # or bring your own token
168
170
  ```
169
171
 
170
- That stores the token *and* makes the URL your default target, in `~/.octwin/credentials.json`:
172
+ Either way it stores the token *and* makes the URL your default target, in
173
+ `~/.octwin/credentials.json`:
171
174
 
172
175
  ```jsonc
173
176
  {
@@ -176,6 +179,9 @@ That stores the token *and* makes the URL your default target, in `~/.octwin/cre
176
179
  }
177
180
  ```
178
181
 
182
+ A browser sign-in in progress is held separately, in `~/.octwin/pending-login.json`, and is deleted
183
+ as soon as the token arrives — the credentials file holds tokens only.
184
+
179
185
  Tenant and project need no setting at all — the **token carries its own tenant**, plus an optional
180
186
  project pin. `--tenant` / `--project` exist only as overrides (a multi-workspace human, or an
181
187
  unpinned token that must name a project).
@@ -195,9 +201,24 @@ variables (add `PACK_PROJECT` only if the token isn't pinned).
195
201
  ## Authentication
196
202
 
197
203
  You authenticate with a tenant-scoped **deploy token** (prefixed `oct_…`) — not a password and not
198
- an operator token. Generate it in the Octwin console (**your workspace API tokens → Generate**).
199
- It is **least-privilege** (scope `pack:deploy`): it can deploy packs to your tenant but cannot
200
- manage members, billing, or other tenants, and it is revocable at any time.
204
+ an operator token. It is **least-privilege** (scope `pack:deploy`): it can deploy packs to your
205
+ tenant but cannot manage members, billing, or other tenants, and it is revocable at any time. A
206
+ token can never mint another token, which is why approving a browser sign-in needs a *human*
207
+ workspace admin.
208
+
209
+ Two ways to get one:
210
+
211
+ - **`octwin login` with no `--token`** prints an approval link. A workspace admin opens it, sees
212
+ which machine is asking, and clicks Approve; the CLI collects a 30-day token by polling. Nothing is
213
+ copied by hand, so no live credential passes through a chat window or an email. The wait is
214
+ bounded (90s) and **resumable** — run the command again to keep waiting on the same request, which
215
+ lives ten minutes. Exit code `75` means *still waiting*; `0` means signed in.
216
+ **Approve only a link you opened because you ran the command.** The link carries the code, so
217
+ clicking Approve on a link someone sent you would connect *their* machine to your workspace. The
218
+ code is printed too, and the page shows it, so you can always tell one request from another.
219
+ - **Generate one yourself** in the Octwin console (**your workspace → API tokens → Generate**) and
220
+ pass it with `--token`. This is the way to pick narrower scopes or a different expiry, and the
221
+ way to get a token for CI.
201
222
 
202
223
  Add the optional **`media:generate`** scope to let a `--seed` deploy AI-generate seed images
203
224
  (for a demo record field like `photo: "generate:<prompt>"`); without it, such fields are seeded as
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * octwin --version | -v # print the CLI version (+ any upgrade notice)
11
11
  * octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
12
12
  * octwin validate [--dir .] [--remote] [--require-kb] # --remote → the platform's FULL schema check + lint, all errors at once
13
- * octwin login --url <platformUrl> --token oct_…
13
+ * octwin login --url <platformUrl> [--token oct_…] # no token → approve in a browser
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]
@@ -60,7 +60,7 @@
60
60
  */
61
61
  import { readFileSync, writeFileSync, writeSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, rmSync } from 'node:fs';
62
62
  import { join, resolve, dirname, basename } from 'node:path';
63
- import { homedir } from 'node:os';
63
+ import { homedir, hostname, platform } from 'node:os';
64
64
  import { fileURLToPath } from 'node:url';
65
65
  import { parse as parseYaml } from 'yaml';
66
66
  import { applyRenames } from './lib/rename.js';
@@ -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';
@@ -79,6 +80,7 @@ import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
79
80
  import { readPage, morePageHint } from './lib/page.js';
80
81
  import { kbOneLiner, buildKbIndexMarkdown, buildKbOutlineMarkdown, } from './lib/kb-index.js';
81
82
  import { buildSymbols, linkExplainers, renderSymbolsMarkdown } from './lib/kb-symbols.js';
83
+ import { readPending, writePending, clearPending, isPendingExpired, secondsLeft, interpretPoll, clientLabel, EXIT_STILL_PENDING, } from './lib/device-login.js';
82
84
  // The in-package starter template ships alongside `dist/` and `src/` (both one
83
85
  // level under the package root), so `../templates/starter` resolves for the
84
86
  // built CLI and `tsx` dev alike.
@@ -181,7 +183,7 @@ async function fetchOrDie(url, init, what) {
181
183
  * unauthenticated), so say so instead of sending the author on a re-login hunt. */
182
184
  function authFailureHint(status, url) {
183
185
  return status === 401
184
- ? `the token was rejected — invalid / expired / revoked. If it JUST worked, this can be a one-off platform hiccup: retry once before re-logging in (octwin login --url ${url} --token oct_…)`
186
+ ? `the token was rejected — invalid / expired / revoked. If it JUST worked, this can be a one-off platform hiccup: retry once, then sign in again with \`octwin login --url ${url}\` (a browser-issued token lasts 30 days, so renewal is that one command)`
185
187
  : `the token is valid but not authorized here (missing scope, plan feature, or role)`;
186
188
  }
187
189
  /**
@@ -541,9 +543,8 @@ async function latestPublishedVersion() {
541
543
  }
542
544
  }
543
545
  /** True when running via `npx` — the CLI's own file lives in npx's cache dir, or npm
544
- * ran it as `npm exec`. Under npx there is no persistent install to upgrade
545
- * (`@latest` already resolves the newest), so an upgrade notice would be
546
- * 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. */
547
548
  function isNpx() {
548
549
  try {
549
550
  return fileURLToPath(import.meta.url).includes('_npx') || process.env.npm_command === 'exec';
@@ -554,23 +555,31 @@ function isNpx() {
554
555
  }
555
556
  /** A one-line upgrade notice when a newer octwin-cli is published, or [] when current.
556
557
  *
557
- * Skipped only under npx, where there is genuinely nothing to upgrade. It USED to bail on
558
- * `!process.stdout.isTTY` as well, which silently made this the one notice a piped reader never
559
- * saw the opposite of the rule its two siblings already carry in their docblocks, and measured
560
- * 2026-08-26: with a newer version cached, `octwin whoami | tail` printed the KB nudge and not
561
- * this one. An agent driving the CLI cannot notice an outdated CLI on its own, so it is exactly
562
- * 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.
563
572
  *
564
573
  * Never throws — a version check must never break a command. */
565
574
  async function outdatedNotice() {
566
- if (isNpx())
567
- return [];
568
575
  try {
569
576
  const latest = await latestPublishedVersion();
570
577
  if (latest && isNewer(latest, VERSION)) {
571
578
  return [
572
579
  `\n⬆ octwin-cli ${latest} is available (you have ${VERSION}).`,
573
- ' 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)',
574
583
  ];
575
584
  }
576
585
  }
@@ -584,6 +593,18 @@ async function outdatedNotice() {
584
593
  // catalogs only) describes how to enumerate a catalog so we can EXPLODE it into
585
594
  // one file per entry; the PLATFORM supplies it rather than this CLI hardcoding
586
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;
587
608
  /** A previously-pulled KB's identity in `<packDir>/.octwin/platform-kb/index.json`
588
609
  * (content hash + per-entry index), or null if nothing has been pulled yet. */
589
610
  function readLocalKb(packDir) {
@@ -657,8 +678,9 @@ async function fetchKbMeta(t, timeoutMs = 2_000) {
657
678
  * `octwin platform-kb pull`. The sibling of `notifyIfOutdated`, for the KB instead
658
679
  * of the CLI: run only after commands that already hit the platform, so this adds
659
680
  * a single tiny `?meta=1` GET on top of work that was networked anyway. Never
660
- * throws — observing must never break a command. No-op until the author has pulled
661
- * 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.
662
684
  *
663
685
  * NOT TTY-gated, deliberately. It was, on the reasoning that a nudge is for a
664
686
  * human watching a terminal — but the primary reader of this CLI is now an
@@ -667,10 +689,24 @@ async function fetchKbMeta(t, timeoutMs = 2_000) {
667
689
  * told. It is a single line on stderr, so piped stdout stays clean either way. */
668
690
  async function kbStaleNotice(flags) {
669
691
  try {
670
- const packDir = resolve(flags.dir ?? '.');
671
- 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
+ */
672
708
  if (!local?.content_hash)
673
- return []; // never pulled → the skill already says to pull
709
+ return [];
674
710
  // URL only, deliberately: the KB poll is anonymous, so requiring a token here would
675
711
  // silence the nudge for exactly the authors who most need it. This used to call
676
712
  // `resolveTargetOrNull` (url AND token) against the tenant-scoped route, which needs
@@ -1224,10 +1260,34 @@ async function cmdValidate(flags) {
1224
1260
  }
1225
1261
  exitNow(1);
1226
1262
  }
1263
+ /**
1264
+ * Two ways in, one saved result.
1265
+ *
1266
+ * octwin login --url <u> --token oct_… the token you minted in the console
1267
+ * octwin login --url <u> approve it in a browser instead
1268
+ *
1269
+ * The token form is unchanged. The browser form is the one an agent can drive:
1270
+ * see `cmdLoginBrowser`.
1271
+ */
1227
1272
  async function cmdLogin(flags) {
1228
- const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
1273
+ const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? savedDefaultUrl();
1274
+ if (!rawUrl) {
1275
+ die('usage: octwin login --url <platformUrl> (add --token oct_… to use a token from the console)');
1276
+ }
1229
1277
  const url = rawUrl.replace(/\/$/, '');
1230
- const token = flags.token ?? process.env.PACK_TOKEN ?? die('missing --token');
1278
+ const token = flags.token ?? process.env.PACK_TOKEN;
1279
+ if (token)
1280
+ return saveLogin(url, token);
1281
+ return cmdLoginBrowser(url, flags);
1282
+ }
1283
+ /**
1284
+ * Persist a token as the default deploy target, then say what it reaches.
1285
+ *
1286
+ * Shared by both login paths deliberately: a browser sign-in and a pasted token
1287
+ * must produce the same saved state and the same confirmation, or one of them
1288
+ * becomes the odd one people distrust.
1289
+ */
1290
+ async function saveLogin(url, token) {
1231
1291
  const creds = readCreds();
1232
1292
  creds[url] = token;
1233
1293
  creds[DEFAULT_URL_KEY] = url; // login sets the default deploy target
@@ -1249,14 +1309,160 @@ async function cmdLogin(flags) {
1249
1309
  }
1250
1310
  catch { /* platform unreachable — the token is saved regardless */ }
1251
1311
  }
1252
- /** Bearer auth + the optional self-surface overrides, as request headers. */
1312
+ /** `~/.octwin/pending-login.json` a handshake in flight, per platform url. */
1313
+ function pendingLoginPath() { return join(homedir(), '.octwin', 'pending-login.json'); }
1314
+ /**
1315
+ * The gap between polls.
1316
+ *
1317
+ * **Deliberately NOT `.unref()`ed.** The timer this returns is the only pending work
1318
+ * during a wait, so an unref'd one lets Node decide the event loop is empty and exit
1319
+ * mid-handshake — silently, with code 0, printing neither the ✓ nor the "not approved
1320
+ * yet" line. Measured 2026-09-06: the first resume happened to win the race and every
1321
+ * later one exited quietly. `unref` belongs on a timer RACING something else (as in
1322
+ * `SseFrameReader`, where a pending read holds the loop open); here it is the thing
1323
+ * being waited for.
1324
+ */
1325
+ function sleep(ms) {
1326
+ return new Promise(res => { setTimeout(res, ms); });
1327
+ }
1328
+ /**
1329
+ * How long one `octwin login` waits before handing the terminal back.
1330
+ *
1331
+ * 90 seconds, because THE CLI IS MOSTLY DRIVEN BY AN AGENT and an agent's shell
1332
+ * call is killed around two minutes. A wait that outlives its caller is worse than
1333
+ * a short one: the process dies mid-handshake and the code the human is still
1334
+ * typing belongs to nothing. So the wait is bounded, the handshake is written to
1335
+ * disk, and running the command again resumes the SAME code — which the platform
1336
+ * keeps alive for its full ten minutes.
1337
+ */
1338
+ const LOGIN_WAIT_SECONDS = 90;
1339
+ async function cmdLoginBrowser(url, flags) {
1340
+ const asJson = flags.json === true;
1341
+ const path = pendingLoginPath();
1342
+ let pending = readPending(path, url);
1343
+ let resuming = pending != null;
1344
+ if (pending && isPendingExpired(pending)) {
1345
+ clearPending(path, url);
1346
+ pending = null;
1347
+ resuming = false;
1348
+ if (!asJson)
1349
+ console.log('⚠ The previous code expired. Here is a new one.');
1350
+ }
1351
+ if (!pending) {
1352
+ const res = await fetchOrDie(`${url}/api/public/cli-login/start`, {
1353
+ method: 'POST',
1354
+ headers: { 'content-type': 'application/json' },
1355
+ body: JSON.stringify({ client_label: clientLabel(hostname(), platform()) }),
1356
+ }, 'starting sign-in');
1357
+ const started = await res.json().catch(() => null);
1358
+ if (!res.ok || !started?.device_code || !started.user_code) {
1359
+ if (res.status === 404) {
1360
+ die(`this platform has no browser sign-in yet (older version) — mint a token in the console and use \`octwin login --url ${url} --token oct_…\``);
1361
+ }
1362
+ die(`could not start sign-in (HTTP ${res.status})${started?.error ? ` — ${started.error}` : ''}`);
1363
+ }
1364
+ pending = {
1365
+ device_code: started.device_code,
1366
+ user_code: started.user_code,
1367
+ verify_url: started.verify_url ?? `${url}/cli-login`,
1368
+ expires_at: started.expires_at ?? new Date(Date.now() + 600_000).toISOString(),
1369
+ interval_seconds: started.interval_seconds ?? 5,
1370
+ };
1371
+ writePending(path, url, pending);
1372
+ }
1373
+ const waitSeconds = flags.wait !== undefined ? Math.max(0, Number(flags.wait) || 0) : LOGIN_WAIT_SECONDS;
1374
+ if (asJson) {
1375
+ console.log(JSON.stringify({
1376
+ status: 'pending',
1377
+ verify_url: pending.verify_url,
1378
+ user_code: pending.user_code,
1379
+ expires_at: pending.expires_at,
1380
+ }, null, 2));
1381
+ }
1382
+ else {
1383
+ // The link and the code go on their own labelled lines because an AGENT reads
1384
+ // this and relays it into a conversation verbatim. The format is an interface.
1385
+ //
1386
+ // The code is printed even though the link already carries it: it is what the
1387
+ // approver checks the page against, and the only thing they can compare if the
1388
+ // link reached them some other way.
1389
+ console.log('');
1390
+ console.log(` Open this link to approve: ${pending.verify_url}`);
1391
+ console.log(` Code shown on that page: ${pending.user_code}`);
1392
+ console.log('');
1393
+ console.log(` Valid for ${Math.max(1, Math.round(secondsLeft(pending) / 60))} more minute(s). Approving gives this machine access to your workspace,`);
1394
+ console.log(' so only approve a link you opened because YOU ran this command.');
1395
+ if (waitSeconds > 0)
1396
+ console.log(` Waiting ${waitSeconds}s… (run \`octwin login\` again to keep waiting)`);
1397
+ }
1398
+ const intervalMs = Math.max(1, pending.interval_seconds) * 1000;
1399
+ const deadline = Date.now() + waitSeconds * 1000;
1400
+ // A resumed handshake may ALREADY be approved, so ask before waiting; a fresh one
1401
+ // cannot be, so give the human one interval first.
1402
+ let delay = resuming ? 0 : intervalMs;
1403
+ while (Date.now() + delay <= deadline) {
1404
+ await sleep(delay);
1405
+ delay = intervalMs;
1406
+ const res = await fetchOrDie(`${url}/api/public/cli-login/poll`, {
1407
+ method: 'POST',
1408
+ headers: { 'content-type': 'application/json' },
1409
+ body: JSON.stringify({ device_code: pending.device_code }),
1410
+ }, 'checking sign-in');
1411
+ const body = await res.json().catch(() => null);
1412
+ const outcome = interpretPoll(res.status, body, res.headers.get('retry-after'), intervalMs);
1413
+ switch (outcome.kind) {
1414
+ case 'approved': {
1415
+ clearPending(path, url);
1416
+ if (asJson) {
1417
+ console.log(JSON.stringify({
1418
+ status: 'approved', tenant_slug: outcome.tenantSlug,
1419
+ project_slug: outcome.projectSlug, token_expires: outcome.tokenExpires,
1420
+ }, null, 2));
1421
+ const creds = readCreds();
1422
+ creds[url] = outcome.token;
1423
+ creds[DEFAULT_URL_KEY] = url;
1424
+ writeCreds(creds);
1425
+ return;
1426
+ }
1427
+ await saveLogin(url, outcome.token);
1428
+ if (outcome.tokenExpires) {
1429
+ console.log(` → Expires ${outcome.tokenExpires.slice(0, 10)} — run \`octwin login\` again to renew.`);
1430
+ }
1431
+ return;
1432
+ }
1433
+ case 'gone':
1434
+ clearPending(path, url);
1435
+ die(outcome.reason === 'expired'
1436
+ ? 'that code expired before it was approved — run `octwin login` again for a new one'
1437
+ : 'that sign-in request is no longer on the platform — run `octwin login` again');
1438
+ break;
1439
+ case 'failed':
1440
+ die(`sign-in failed — ${outcome.detail}`);
1441
+ break;
1442
+ case 'retry':
1443
+ delay = outcome.afterMs;
1444
+ break;
1445
+ case 'pending':
1446
+ break;
1447
+ }
1448
+ }
1449
+ if (!asJson) {
1450
+ console.log('');
1451
+ console.log('⏳ Not approved yet. Run `octwin login` again to keep waiting — the same code stays valid.');
1452
+ console.log(' Running in CI? Skip login and set PACK_PLATFORM_URL + PACK_TOKEN instead.');
1453
+ }
1454
+ exitNow(EXIT_STILL_PENDING);
1455
+ }
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
+ */
1253
1464
  function authHeaders(t) {
1254
- const h = { authorization: `Bearer ${t.token}` };
1255
- if (t.tenant)
1256
- h['x-octwin-tenant'] = t.tenant;
1257
- if (t.project)
1258
- h['x-octwin-project'] = t.project;
1259
- return h;
1465
+ return buildAuthHeaders(t, { cliVersion: VERSION, kbContentHash: LOCAL_KB?.content_hash ?? null });
1260
1466
  }
1261
1467
  /** Resolve platform url + token (+ optional tenant/project overrides):
1262
1468
  * flags > env > saved login. Tenant is derived from the token server-side, so
@@ -2412,7 +2618,9 @@ async function cmdFeedback(flags) {
2412
2618
  const doc = parseYaml(readFileSync(manifestPath, 'utf8'));
2413
2619
  const packId = typeof doc?.id === 'string' ? doc.id : die('manifest.yaml must declare a string `id`');
2414
2620
  const packVersion = typeof doc?.version === 'string' ? doc.version : undefined;
2415
- 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;
2416
2624
  console.log(`→ Submitting ${Math.round(Buffer.byteLength(report, 'utf8') / 1024)}KB of feedback on ${packId} to ${targetLabel(t)} …`);
2417
2625
  const { status, json } = await apiSend('POST', `${url}/api/self/p/packs/feedback`, {
2418
2626
  pack_id: packId,
@@ -4975,7 +5183,8 @@ function help() {
4975
5183
  octwin --version # print the CLI version (+ any upgrade notice)
4976
5184
  octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
4977
5185
  octwin validate [--dir .] [--remote] [--require-kb] # --remote runs the platform's FULL schema check + lint (all errors at once)
4978
- octwin login --url <platformUrl> --token oct_… # a deploy token from the console
5186
+ octwin login --url <platformUrl> # approve in a browser; the CLI collects the token
5187
+ octwin login --url <platformUrl> --token oct_… # or paste a deploy token from the console
4979
5188
  octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
4980
5189
  octwin projects [--archived] [--json] # the --project slugs this token can name
4981
5190
  octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
@@ -5021,7 +5230,8 @@ Writes — exercise the state your pack creates (each needs the matching :write
5021
5230
  Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
5022
5231
  \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
5023
5232
  button/row with \`--tap "<tap-id>"\` (chat prints every tap id).
5024
- Get a deploy token: console your workspace Settings API tokens Generate (tick records:read to inspect data).
5233
+ Signing in: \`octwin login --url <u>\` prints a link + code to approve in a browser. To pick your own
5234
+ scopes instead, mint a token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
5025
5235
  octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
5026
5236
  Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
5027
5237
  Per-command usage: octwin <command> --help`);
@@ -5040,10 +5250,24 @@ const COMMAND_HELP = {
5040
5250
  --strict-primitives (with --remote) additionally type-checks LITERAL args:
5041
5251
  values against each primitive's declared input schema; expression strings
5042
5252
  ('$found.id', '{$t(…)}') are always exempt.`,
5043
- login: `octwin login --url <platformUrl> --token oct_…
5044
- Save a deploy token (console Settings API tokens) for that platform url,
5045
- make that url the DEFAULT deploy target for every later command, and echo the
5046
- workspace + project pin + scopes the token reaches.`,
5253
+ login: `octwin login --url <platformUrl> [--token oct_…] [--wait <seconds>] [--json]
5254
+ Sign in to a platform and make that url the DEFAULT deploy target for every
5255
+ later command.
5256
+
5257
+ WITH --token: saves a token you minted yourself (console → Settings → API
5258
+ tokens). Unchanged, and still the right answer for CI — there, skip login and
5259
+ set PACK_PLATFORM_URL + PACK_TOKEN instead.
5260
+
5261
+ WITHOUT --token: prints a link to approve in a browser. A workspace admin opens
5262
+ it, checks which machine is asking, and clicks Approve; the CLI then collects a
5263
+ 30-day token by itself, so no token is ever pasted between people. The code is
5264
+ printed too — it is what the page shows, so you can tell it is your own request.
5265
+ Approve only a link you opened because YOU ran this command: a link someone
5266
+ sends you would connect THEIR machine to your workspace.
5267
+
5268
+ The wait is bounded (90s by default) and RESUMABLE: if nobody has approved yet
5269
+ it exits 75 and keeps the request, so running \`octwin login\` again picks up the
5270
+ same code. --wait 0 prints the code and returns at once.`,
5047
5271
  whoami: `octwin whoami [--url <url>] [--tenant <slug>]
5048
5272
  Verify the resolved token authenticates against the tenant.`,
5049
5273
  projects: `octwin projects [--archived] [--json]
@@ -5365,6 +5589,9 @@ async function main() {
5365
5589
  // exit code with 127, so a failing `octwin deploy` in CI reported the wrong thing. Measured
5366
5590
  // 2026-08-26: one pre-command poll exits 1 cleanly, two concurrent ones abort. Awaiting them in
5367
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 ?? '.'));
5368
5595
  const networked = commandTouchesPlatform(command, flags);
5369
5596
  if (networked) {
5370
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
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The pure half of a browser `octwin login` — everything about the device-code
3
+ * handshake that isn't a socket.
4
+ *
5
+ * `index.ts` keeps the URL construction and the actual `fetch` calls (the route
6
+ * guard in `cli-routes.test.ts` reads that file and only that file, so a URL that
7
+ * moved here would stop being checked against the platform's route table). What
8
+ * lives here is the part worth testing without a server: how a poll response is
9
+ * read, how long to wait next, and the on-disk record of a handshake in flight.
10
+ *
11
+ * ## Why a handshake is written to disk at all
12
+ *
13
+ * Because the CLI is mostly driven by an agent, and an agent's shell call is cut
14
+ * off long before a human finishes clicking. So `octwin login` waits a bounded
15
+ * time and then RETURNS, keeping the request; running it again resumes the same
16
+ * one with the same code. Without a file, a second run would mint a second code
17
+ * and the code the human is looking at would be dead.
18
+ */
19
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
20
+ import { dirname } from 'node:path';
21
+ /**
22
+ * `~/.octwin/pending-login.json`, a map of platform url → handshake.
23
+ *
24
+ * Deliberately NOT `credentials.json`: every value in that file is a token, and its
25
+ * one reserved key is safe only because a url always contains `://` and the key
26
+ * never does. A second value shape there would make that argument stop holding.
27
+ */
28
+ export function readPending(path, url) {
29
+ let raw;
30
+ try {
31
+ raw = readFileSync(path, 'utf8');
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ try {
37
+ const map = JSON.parse(raw);
38
+ const found = map[url];
39
+ return found && typeof found.device_code === 'string' ? found : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ } // a corrupt file just means "no handshake in flight"
44
+ }
45
+ export function writePending(path, url, pending) {
46
+ let map = {};
47
+ try {
48
+ map = JSON.parse(readFileSync(path, 'utf8'));
49
+ }
50
+ catch { /* first one */ }
51
+ map[url] = pending;
52
+ mkdirSync(dirname(path), { recursive: true });
53
+ writeFileSync(path, JSON.stringify(map, null, 2), 'utf8');
54
+ }
55
+ export function clearPending(path, url) {
56
+ let map;
57
+ try {
58
+ map = JSON.parse(readFileSync(path, 'utf8'));
59
+ }
60
+ catch {
61
+ return;
62
+ }
63
+ if (!(url in map))
64
+ return;
65
+ delete map[url];
66
+ mkdirSync(dirname(path), { recursive: true });
67
+ writeFileSync(path, JSON.stringify(map, null, 2), 'utf8');
68
+ }
69
+ /** True once the platform would refuse this handshake — checked before reusing one. */
70
+ export function isPendingExpired(pending, now = Date.now()) {
71
+ const at = Date.parse(pending.expires_at);
72
+ return Number.isNaN(at) || at <= now;
73
+ }
74
+ /** Whole seconds left, floored at 0 — for the "expires in 7 min" line. */
75
+ export function secondsLeft(pending, now = Date.now()) {
76
+ const at = Date.parse(pending.expires_at);
77
+ if (Number.isNaN(at))
78
+ return 0;
79
+ return Math.max(0, Math.round((at - now) / 1000));
80
+ }
81
+ /**
82
+ * Read one poll response.
83
+ *
84
+ * A 429 is NOT a failure — the platform's own limiter sets `Retry-After`, and a
85
+ * client that treats being asked to slow down as an error turns a wait into a
86
+ * broken login. 5xx is the same shape for a different reason.
87
+ */
88
+ export function interpretPoll(status, json, retryAfterHeader, defaultDelayMs) {
89
+ if (status === 429 || status >= 500) {
90
+ const secs = Number(retryAfterHeader);
91
+ const afterMs = Number.isFinite(secs) && secs > 0 ? secs * 1000 : defaultDelayMs * 2;
92
+ return { kind: 'retry', afterMs };
93
+ }
94
+ const body = (json ?? {});
95
+ if (status !== 200) {
96
+ return { kind: 'failed', detail: String(body.error ?? `the platform answered HTTP ${status}`) };
97
+ }
98
+ switch (body.status) {
99
+ case 'approved':
100
+ if (typeof body.token !== 'string' || !body.token) {
101
+ return { kind: 'failed', detail: 'the platform approved the login but returned no token' };
102
+ }
103
+ return {
104
+ kind: 'approved',
105
+ token: body.token,
106
+ tenantSlug: typeof body.tenant_slug === 'string' ? body.tenant_slug : '',
107
+ projectSlug: typeof body.project_slug === 'string' ? body.project_slug : null,
108
+ tokenExpires: typeof body.token_expires === 'string' ? body.token_expires : null,
109
+ };
110
+ case 'pending': return { kind: 'pending' };
111
+ case 'expired': return { kind: 'gone', reason: 'expired' };
112
+ case 'not_found': return { kind: 'gone', reason: 'not_found' };
113
+ default:
114
+ return { kind: 'failed', detail: `the platform answered with an unknown status '${String(body.status)}'` };
115
+ }
116
+ }
117
+ /**
118
+ * How this machine introduces itself on the approval screen. Untrusted by the
119
+ * platform (this call carries no credential), so it is a courtesy to the approver,
120
+ * never a claim — which is exactly how the console renders it.
121
+ */
122
+ export function clientLabel(hostname, platform) {
123
+ const os = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' }[platform] ?? platform;
124
+ return `${hostname || 'unknown host'} · ${os}`;
125
+ }
126
+ /** Exit code for "the wait ended, nothing is wrong" — `EX_TEMPFAIL`. An agent branches on it. */
127
+ export const EXIT_STILL_PENDING = 75;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octwin-cli",
3
- "version": "0.8.7",
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": {