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 +61 -0
- package/README.md +30 -9
- package/dist/index.js +263 -36
- package/dist/lib/client-headers.js +80 -0
- package/dist/lib/device-login.js +127 -0
- package/package.json +1 -1
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
|
-
#
|
|
51
|
-
|
|
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` |
|
|
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
|
|
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
|
-
|
|
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.
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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`.
|
|
545
|
-
*
|
|
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
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
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
|
-
|
|
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
|
|
661
|
-
*
|
|
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
|
|
671
|
-
|
|
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 [];
|
|
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 ??
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
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