octwin-cli 0.8.7 → 0.8.8

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,43 @@ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucket
5
5
  **Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
6
6
  repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
7
7
 
8
+ ## [0.8.8] - 2026-09-06
9
+
10
+ ### Added
11
+ - **`octwin login` can sign you in through a browser — no token is pasted anywhere.** Run it with
12
+ no `--token` and it prints an approval link:
13
+
14
+ ```
15
+ Open this link to approve: https://app.octwin.ai/cli-login?code=WDJB-MJHT
16
+ Code shown on that page: WDJB-MJHT
17
+ ```
18
+
19
+ A workspace admin opens it, checks which machine is asking, and clicks Approve; the CLI collects a
20
+ 30-day token by itself and saves it exactly where `--token` would have. `octwin login --token oct_…`
21
+ is unchanged, and CI should still skip login entirely and set `PACK_PLATFORM_URL` + `PACK_TOKEN`.
22
+
23
+ **The wait is bounded and resumable, because this CLI is mostly driven by an agent.** An agent's
24
+ shell call is killed after about two minutes, and a wait that outlives its caller is worse than a
25
+ short one — the process dies mid-handshake and the code the human is still typing belongs to
26
+ nothing. So `login` waits 90 seconds, then exits **75** (`EX_TEMPFAIL`) with the handshake kept in
27
+ `~/.octwin/pending-login.json`; running it again resumes the **same** code, which the platform
28
+ keeps alive for ten minutes. `0` still means signed in and `1` still means failed, so an agent
29
+ branches on the exit code instead of parsing prose. `--wait <seconds>` overrides the 90 (`--wait 0`
30
+ prints the code and returns at once), and `--json` prints the same facts as an object.
31
+
32
+ **Approve only a link you opened because you ran the command.** The link carries the code
33
+ (RFC 8628 `verification_uri_complete`), so the click *is* the consent: approving a link somebody
34
+ sent you would connect their machine to your workspace. The console says so on the page. The code
35
+ is printed here too, and shown there, so you can always tell one request from another.
36
+
37
+ The pending handshake gets its own file rather than a new key in `credentials.json`: every value
38
+ in that file is a token, and its one reserved key (`default_url`) is collision-free only because a
39
+ platform url always contains `://`. A second value shape there would end that argument.
40
+
41
+ - **A 401 now points at `octwin login`, not at the console.** With a 30-day browser token, renewing is
42
+ one command; the old hint sent people to mint a replacement by hand. Mint a token yourself if you
43
+ want a different lifetime — the console offers 30 / 90 days / a year / never.
44
+
8
45
  ## [0.8.7] - 2026-09-06
9
46
 
10
47
  ### 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';
@@ -79,6 +79,7 @@ import { classifyPackPath, isSkippedDir } from './lib/pack-source.js';
79
79
  import { readPage, morePageHint } from './lib/page.js';
80
80
  import { kbOneLiner, buildKbIndexMarkdown, buildKbOutlineMarkdown, } from './lib/kb-index.js';
81
81
  import { buildSymbols, linkExplainers, renderSymbolsMarkdown } from './lib/kb-symbols.js';
82
+ import { readPending, writePending, clearPending, isPendingExpired, secondsLeft, interpretPoll, clientLabel, EXIT_STILL_PENDING, } from './lib/device-login.js';
82
83
  // The in-package starter template ships alongside `dist/` and `src/` (both one
83
84
  // level under the package root), so `../templates/starter` resolves for the
84
85
  // built CLI and `tsx` dev alike.
@@ -181,7 +182,7 @@ async function fetchOrDie(url, init, what) {
181
182
  * unauthenticated), so say so instead of sending the author on a re-login hunt. */
182
183
  function authFailureHint(status, url) {
183
184
  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_…)`
185
+ ? `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
186
  : `the token is valid but not authorized here (missing scope, plan feature, or role)`;
186
187
  }
187
188
  /**
@@ -1224,10 +1225,34 @@ async function cmdValidate(flags) {
1224
1225
  }
1225
1226
  exitNow(1);
1226
1227
  }
1228
+ /**
1229
+ * Two ways in, one saved result.
1230
+ *
1231
+ * octwin login --url <u> --token oct_… the token you minted in the console
1232
+ * octwin login --url <u> approve it in a browser instead
1233
+ *
1234
+ * The token form is unchanged. The browser form is the one an agent can drive:
1235
+ * see `cmdLoginBrowser`.
1236
+ */
1227
1237
  async function cmdLogin(flags) {
1228
- const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? die('usage: octwin login --url <platformUrl> --token <t>');
1238
+ const rawUrl = flags.url ?? process.env.PACK_PLATFORM_URL ?? savedDefaultUrl();
1239
+ if (!rawUrl) {
1240
+ die('usage: octwin login --url <platformUrl> (add --token oct_… to use a token from the console)');
1241
+ }
1229
1242
  const url = rawUrl.replace(/\/$/, '');
1230
- const token = flags.token ?? process.env.PACK_TOKEN ?? die('missing --token');
1243
+ const token = flags.token ?? process.env.PACK_TOKEN;
1244
+ if (token)
1245
+ return saveLogin(url, token);
1246
+ return cmdLoginBrowser(url, flags);
1247
+ }
1248
+ /**
1249
+ * Persist a token as the default deploy target, then say what it reaches.
1250
+ *
1251
+ * Shared by both login paths deliberately: a browser sign-in and a pasted token
1252
+ * must produce the same saved state and the same confirmation, or one of them
1253
+ * becomes the odd one people distrust.
1254
+ */
1255
+ async function saveLogin(url, token) {
1231
1256
  const creds = readCreds();
1232
1257
  creds[url] = token;
1233
1258
  creds[DEFAULT_URL_KEY] = url; // login sets the default deploy target
@@ -1249,6 +1274,150 @@ async function cmdLogin(flags) {
1249
1274
  }
1250
1275
  catch { /* platform unreachable — the token is saved regardless */ }
1251
1276
  }
1277
+ /** `~/.octwin/pending-login.json` — a handshake in flight, per platform url. */
1278
+ function pendingLoginPath() { return join(homedir(), '.octwin', 'pending-login.json'); }
1279
+ /**
1280
+ * The gap between polls.
1281
+ *
1282
+ * **Deliberately NOT `.unref()`ed.** The timer this returns is the only pending work
1283
+ * during a wait, so an unref'd one lets Node decide the event loop is empty and exit
1284
+ * mid-handshake — silently, with code 0, printing neither the ✓ nor the "not approved
1285
+ * yet" line. Measured 2026-09-06: the first resume happened to win the race and every
1286
+ * later one exited quietly. `unref` belongs on a timer RACING something else (as in
1287
+ * `SseFrameReader`, where a pending read holds the loop open); here it is the thing
1288
+ * being waited for.
1289
+ */
1290
+ function sleep(ms) {
1291
+ return new Promise(res => { setTimeout(res, ms); });
1292
+ }
1293
+ /**
1294
+ * How long one `octwin login` waits before handing the terminal back.
1295
+ *
1296
+ * 90 seconds, because THE CLI IS MOSTLY DRIVEN BY AN AGENT and an agent's shell
1297
+ * call is killed around two minutes. A wait that outlives its caller is worse than
1298
+ * a short one: the process dies mid-handshake and the code the human is still
1299
+ * typing belongs to nothing. So the wait is bounded, the handshake is written to
1300
+ * disk, and running the command again resumes the SAME code — which the platform
1301
+ * keeps alive for its full ten minutes.
1302
+ */
1303
+ const LOGIN_WAIT_SECONDS = 90;
1304
+ async function cmdLoginBrowser(url, flags) {
1305
+ const asJson = flags.json === true;
1306
+ const path = pendingLoginPath();
1307
+ let pending = readPending(path, url);
1308
+ let resuming = pending != null;
1309
+ if (pending && isPendingExpired(pending)) {
1310
+ clearPending(path, url);
1311
+ pending = null;
1312
+ resuming = false;
1313
+ if (!asJson)
1314
+ console.log('⚠ The previous code expired. Here is a new one.');
1315
+ }
1316
+ if (!pending) {
1317
+ const res = await fetchOrDie(`${url}/api/public/cli-login/start`, {
1318
+ method: 'POST',
1319
+ headers: { 'content-type': 'application/json' },
1320
+ body: JSON.stringify({ client_label: clientLabel(hostname(), platform()) }),
1321
+ }, 'starting sign-in');
1322
+ const started = await res.json().catch(() => null);
1323
+ if (!res.ok || !started?.device_code || !started.user_code) {
1324
+ if (res.status === 404) {
1325
+ 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_…\``);
1326
+ }
1327
+ die(`could not start sign-in (HTTP ${res.status})${started?.error ? ` — ${started.error}` : ''}`);
1328
+ }
1329
+ pending = {
1330
+ device_code: started.device_code,
1331
+ user_code: started.user_code,
1332
+ verify_url: started.verify_url ?? `${url}/cli-login`,
1333
+ expires_at: started.expires_at ?? new Date(Date.now() + 600_000).toISOString(),
1334
+ interval_seconds: started.interval_seconds ?? 5,
1335
+ };
1336
+ writePending(path, url, pending);
1337
+ }
1338
+ const waitSeconds = flags.wait !== undefined ? Math.max(0, Number(flags.wait) || 0) : LOGIN_WAIT_SECONDS;
1339
+ if (asJson) {
1340
+ console.log(JSON.stringify({
1341
+ status: 'pending',
1342
+ verify_url: pending.verify_url,
1343
+ user_code: pending.user_code,
1344
+ expires_at: pending.expires_at,
1345
+ }, null, 2));
1346
+ }
1347
+ else {
1348
+ // The link and the code go on their own labelled lines because an AGENT reads
1349
+ // this and relays it into a conversation verbatim. The format is an interface.
1350
+ //
1351
+ // The code is printed even though the link already carries it: it is what the
1352
+ // approver checks the page against, and the only thing they can compare if the
1353
+ // link reached them some other way.
1354
+ console.log('');
1355
+ console.log(` Open this link to approve: ${pending.verify_url}`);
1356
+ console.log(` Code shown on that page: ${pending.user_code}`);
1357
+ console.log('');
1358
+ console.log(` Valid for ${Math.max(1, Math.round(secondsLeft(pending) / 60))} more minute(s). Approving gives this machine access to your workspace,`);
1359
+ console.log(' so only approve a link you opened because YOU ran this command.');
1360
+ if (waitSeconds > 0)
1361
+ console.log(` Waiting ${waitSeconds}s… (run \`octwin login\` again to keep waiting)`);
1362
+ }
1363
+ const intervalMs = Math.max(1, pending.interval_seconds) * 1000;
1364
+ const deadline = Date.now() + waitSeconds * 1000;
1365
+ // A resumed handshake may ALREADY be approved, so ask before waiting; a fresh one
1366
+ // cannot be, so give the human one interval first.
1367
+ let delay = resuming ? 0 : intervalMs;
1368
+ while (Date.now() + delay <= deadline) {
1369
+ await sleep(delay);
1370
+ delay = intervalMs;
1371
+ const res = await fetchOrDie(`${url}/api/public/cli-login/poll`, {
1372
+ method: 'POST',
1373
+ headers: { 'content-type': 'application/json' },
1374
+ body: JSON.stringify({ device_code: pending.device_code }),
1375
+ }, 'checking sign-in');
1376
+ const body = await res.json().catch(() => null);
1377
+ const outcome = interpretPoll(res.status, body, res.headers.get('retry-after'), intervalMs);
1378
+ switch (outcome.kind) {
1379
+ case 'approved': {
1380
+ clearPending(path, url);
1381
+ if (asJson) {
1382
+ console.log(JSON.stringify({
1383
+ status: 'approved', tenant_slug: outcome.tenantSlug,
1384
+ project_slug: outcome.projectSlug, token_expires: outcome.tokenExpires,
1385
+ }, null, 2));
1386
+ const creds = readCreds();
1387
+ creds[url] = outcome.token;
1388
+ creds[DEFAULT_URL_KEY] = url;
1389
+ writeCreds(creds);
1390
+ return;
1391
+ }
1392
+ await saveLogin(url, outcome.token);
1393
+ if (outcome.tokenExpires) {
1394
+ console.log(` → Expires ${outcome.tokenExpires.slice(0, 10)} — run \`octwin login\` again to renew.`);
1395
+ }
1396
+ return;
1397
+ }
1398
+ case 'gone':
1399
+ clearPending(path, url);
1400
+ die(outcome.reason === 'expired'
1401
+ ? 'that code expired before it was approved — run `octwin login` again for a new one'
1402
+ : 'that sign-in request is no longer on the platform — run `octwin login` again');
1403
+ break;
1404
+ case 'failed':
1405
+ die(`sign-in failed — ${outcome.detail}`);
1406
+ break;
1407
+ case 'retry':
1408
+ delay = outcome.afterMs;
1409
+ break;
1410
+ case 'pending':
1411
+ break;
1412
+ }
1413
+ }
1414
+ if (!asJson) {
1415
+ console.log('');
1416
+ console.log('⏳ Not approved yet. Run `octwin login` again to keep waiting — the same code stays valid.');
1417
+ console.log(' Running in CI? Skip login and set PACK_PLATFORM_URL + PACK_TOKEN instead.');
1418
+ }
1419
+ exitNow(EXIT_STILL_PENDING);
1420
+ }
1252
1421
  /** Bearer auth + the optional self-surface overrides, as request headers. */
1253
1422
  function authHeaders(t) {
1254
1423
  const h = { authorization: `Bearer ${t.token}` };
@@ -4975,7 +5144,8 @@ function help() {
4975
5144
  octwin --version # print the CLI version (+ any upgrade notice)
4976
5145
  octwin init <dir> [--id my-pack] [--description "..."] [--display-name "..."]
4977
5146
  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
5147
+ octwin login --url <platformUrl> # approve in a browser; the CLI collects the token
5148
+ octwin login --url <platformUrl> --token oct_… # or paste a deploy token from the console
4979
5149
  octwin whoami [--url <url>] [--tenant <slug>] # verify the token works
4980
5150
  octwin projects [--archived] [--json] # the --project slugs this token can name
4981
5151
  octwin deploy [--dir .] [--url <url>] [--tenant <slug>] [--project <slug>] [--token <t>] [--seed]
@@ -5021,7 +5191,8 @@ Writes — exercise the state your pack creates (each needs the matching :write
5021
5191
  Multi-turn: the platform keeps ONE open conversation per --as handle — consecutive
5022
5192
  \`octwin chat --as <h>\` calls continue the same conversation; press a rendered
5023
5193
  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).
5194
+ Signing in: \`octwin login --url <u>\` prints a link + code to approve in a browser. To pick your own
5195
+ scopes instead, mint a token: console → your workspace → Settings → API tokens → Generate (tick records:read to inspect data).
5025
5196
  octwin platform-kb pull → writes the platform capability reference into .octwin/platform-kb/ (for the octwin-pack skill).
5026
5197
  Config (deploy): flags > env (PACK_PLATFORM_URL/PACK_TENANT/PACK_PROJECT/PACK_TOKEN) > saved login (\`octwin login\` sets the default target).
5027
5198
  Per-command usage: octwin <command> --help`);
@@ -5040,10 +5211,24 @@ const COMMAND_HELP = {
5040
5211
  --strict-primitives (with --remote) additionally type-checks LITERAL args:
5041
5212
  values against each primitive's declared input schema; expression strings
5042
5213
  ('$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.`,
5214
+ login: `octwin login --url <platformUrl> [--token oct_…] [--wait <seconds>] [--json]
5215
+ Sign in to a platform and make that url the DEFAULT deploy target for every
5216
+ later command.
5217
+
5218
+ WITH --token: saves a token you minted yourself (console → Settings → API
5219
+ tokens). Unchanged, and still the right answer for CI — there, skip login and
5220
+ set PACK_PLATFORM_URL + PACK_TOKEN instead.
5221
+
5222
+ WITHOUT --token: prints a link to approve in a browser. A workspace admin opens
5223
+ it, checks which machine is asking, and clicks Approve; the CLI then collects a
5224
+ 30-day token by itself, so no token is ever pasted between people. The code is
5225
+ printed too — it is what the page shows, so you can tell it is your own request.
5226
+ Approve only a link you opened because YOU ran this command: a link someone
5227
+ sends you would connect THEIR machine to your workspace.
5228
+
5229
+ The wait is bounded (90s by default) and RESUMABLE: if nobody has approved yet
5230
+ it exits 75 and keeps the request, so running \`octwin login\` again picks up the
5231
+ same code. --wait 0 prints the code and returns at once.`,
5047
5232
  whoami: `octwin whoami [--url <url>] [--tenant <slug>]
5048
5233
  Verify the resolved token authenticates against the tenant.`,
5049
5234
  projects: `octwin projects [--archived] [--json]
@@ -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.8",
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": {