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 +37 -0
- package/README.md +30 -9
- package/dist/index.js +196 -11
- package/dist/lib/device-login.js +127 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ Format: [Keep a Changelog](https://keepachangelog.com/) — newest first, bucket
|
|
|
5
5
|
**Added · Changed · Deprecated · Removed · Fixed · Security**. The platform-wide view lives in the
|
|
6
6
|
repo root [`CHANGELOG.md`](../../CHANGELOG.md); this file is the CLI-only cut that ships with the package.
|
|
7
7
|
|
|
8
|
+
## [0.8.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
|
-
#
|
|
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';
|
|
@@ -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
|
|
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 ??
|
|
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
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
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