@hellix-ai/cubes 0.0.0-stage → 0.1.1
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/README.md +57 -2
- package/dist/api.js +58 -0
- package/dist/args.js +37 -0
- package/dist/attach.js +318 -0
- package/dist/auth.js +206 -0
- package/dist/cli.js +309 -0
- package/dist/clipboard.js +67 -0
- package/dist/config.js +40 -0
- package/dist/keys.js +68 -0
- package/dist/sessions.js +83 -0
- package/dist/upgrade.js +105 -0
- package/package.json +37 -4
package/README.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cubes
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Attach to your [Cubes](https://cubes.hellix.ai) sessions from your own terminal (Warp, iTerm2, Ghostty,
|
|
4
|
+
Terminal.app, anything) with your own font, colors and keyboard shortcuts.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
npm install -g @hellix-ai/cubes # needs Node.js 22.4+
|
|
8
|
+
|
|
9
|
+
cubes login --host cubes.hellix.ai # prints a code; approve it in the browser
|
|
10
|
+
cubes ls # your sessions
|
|
11
|
+
cubes attach # pick a running one
|
|
12
|
+
cubes attach 7ff4 # or name one: an id prefix, or part of its title
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
No install: `npx @hellix-ai/cubes attach <session>`.
|
|
16
|
+
|
|
17
|
+
## While attached
|
|
18
|
+
|
|
19
|
+
Every key goes to the session, exactly as if Claude Code were running locally, except:
|
|
20
|
+
|
|
21
|
+
| key | does |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `Ctrl-]` | detach. The session keeps running. |
|
|
24
|
+
| `Ctrl-V` | paste the image on your clipboard: it is uploaded to the session and its path typed in (macOS, and Linux with `wl-paste` or `xclip`). With no image on the clipboard, Ctrl-V goes through unchanged. |
|
|
25
|
+
|
|
26
|
+
**One keyboard per session.** Attaching takes the keyboard from any browser tab open on the same session
|
|
27
|
+
(that tab becomes read-only, with a Take over button) and fits the session to your window. If someone
|
|
28
|
+
takes it back, the terminal says so and goes read-only; detach and attach again to reclaim it.
|
|
29
|
+
`cubes attach --view` watches without taking it.
|
|
30
|
+
|
|
31
|
+
A stopped or hibernated session is resumed before attaching (`--no-resume` to refuse). A dropped
|
|
32
|
+
connection reconnects on its own and replays the screen.
|
|
33
|
+
|
|
34
|
+
Scrollback lives in the session's tmux: scroll with the mouse wheel or trackpad (tmux copy-mode), not
|
|
35
|
+
your terminal's own scrollback.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
cubes login [--host URL] [--org ALIAS] [--no-browser]
|
|
41
|
+
cubes logout
|
|
42
|
+
cubes whoami
|
|
43
|
+
cubes org [ALIAS]
|
|
44
|
+
cubes ls [--all] [--json]
|
|
45
|
+
cubes attach [SESSION] [--view] [--no-resume]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`--org ALIAS` works on any command. If you belong to several organizations, `cubes org` lists them and
|
|
49
|
+
`cubes org <alias>` changes the default.
|
|
50
|
+
|
|
51
|
+
## Sign-in
|
|
52
|
+
|
|
53
|
+
`cubes login` uses the OAuth device flow against your Cubes install's own sign-in: the CLI never sees your
|
|
54
|
+
password. The login is stored in `~/.config/cubes/config.json` (mode 0600) and lasts until you
|
|
55
|
+
`cubes logout`, which revokes it, or until it goes unused for 30 days.
|
|
56
|
+
|
|
57
|
+
For scripts and CI, skip the stored login: set `CUBES_HOST`, `CUBES_TOKEN` (an access token) and
|
|
58
|
+
optionally `CUBES_ORG`.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The REST half: the same routes the web app calls, with the same Bearer + `x-org` pair.
|
|
2
|
+
import { CliError } from "./auth.js";
|
|
3
|
+
export function makeApi(s, fetchImpl = fetch) {
|
|
4
|
+
async function call(method, path, body, init) {
|
|
5
|
+
const send = async (force) => {
|
|
6
|
+
const headers = { authorization: `Bearer ${await s.token(force)}` };
|
|
7
|
+
if (s.org)
|
|
8
|
+
headers["x-org"] = s.org;
|
|
9
|
+
let payload;
|
|
10
|
+
if (init?.raw) {
|
|
11
|
+
headers["content-type"] = init.contentType ?? "application/octet-stream";
|
|
12
|
+
payload = init.raw;
|
|
13
|
+
}
|
|
14
|
+
else if (body !== undefined) {
|
|
15
|
+
headers["content-type"] = "application/json";
|
|
16
|
+
payload = JSON.stringify(body);
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return await fetchImpl(`${s.host}/api${path}`, { method, headers, body: payload });
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
throw new CliError(`cannot reach ${s.host}: ${e.message}`);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
let res = await send(false);
|
|
26
|
+
// A token the server refuses may simply be one our clock thinks is still good. One forced
|
|
27
|
+
// refresh, then the refusal stands.
|
|
28
|
+
if (res.status === 401)
|
|
29
|
+
res = await send(true);
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
let data = undefined;
|
|
32
|
+
try {
|
|
33
|
+
data = text ? JSON.parse(text) : undefined;
|
|
34
|
+
}
|
|
35
|
+
catch { /* not JSON */ }
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const msg = data?.error;
|
|
38
|
+
throw new ApiError(res.status, typeof msg === "string" ? msg : `HTTP ${res.status}`, path);
|
|
39
|
+
}
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
get: (path) => call("GET", path),
|
|
44
|
+
post: (path, body, init) => call("POST", path, body, init),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export class ApiError extends CliError {
|
|
48
|
+
status;
|
|
49
|
+
path;
|
|
50
|
+
constructor(status, message, path) {
|
|
51
|
+
super(status === 400 && /organization/.test(message)
|
|
52
|
+
? `${message} - pick one with \`cubes org <alias>\``
|
|
53
|
+
: message);
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.path = path;
|
|
56
|
+
this.name = "ApiError";
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Argument parsing, small enough not to need a dependency.
|
|
2
|
+
import { CliError } from "./auth.js";
|
|
3
|
+
/** Flags that take a value (`--host X` or `--host=X`); every other flag is a switch. */
|
|
4
|
+
const VALUED = new Set(["host", "org"]);
|
|
5
|
+
export function parseArgs(argv) {
|
|
6
|
+
const pos = [];
|
|
7
|
+
const flags = new Map();
|
|
8
|
+
for (let i = 0; i < argv.length; i++) {
|
|
9
|
+
const a = argv[i];
|
|
10
|
+
if (a === "-h") {
|
|
11
|
+
flags.set("help", true);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (a === "-v") {
|
|
15
|
+
flags.set("version", true);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (a.startsWith("--")) {
|
|
19
|
+
const eq = a.indexOf("=");
|
|
20
|
+
const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
|
|
21
|
+
if (eq > 0)
|
|
22
|
+
flags.set(name, a.slice(eq + 1));
|
|
23
|
+
else if (VALUED.has(name)) {
|
|
24
|
+
const v = argv[i + 1];
|
|
25
|
+
if (v === undefined || v.startsWith("--"))
|
|
26
|
+
throw new CliError(`--${name} needs a value`);
|
|
27
|
+
flags.set(name, v);
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
else
|
|
31
|
+
flags.set(name, true);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
pos.push(a);
|
|
35
|
+
}
|
|
36
|
+
return { cmd: pos.shift(), pos, flags };
|
|
37
|
+
}
|
package/dist/attach.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// The bridge: your terminal <-> the session's live TTY, over the same websocket the web terminal uses.
|
|
2
|
+
//
|
|
3
|
+
// The wire (server/src/ws.ts + engine/tty.ts) is already exactly what a terminal needs:
|
|
4
|
+
// • the upgrade carries a single-use ~30s ticket as the SECOND subprotocol, after the constant
|
|
5
|
+
// `cube.tty.v1` (the server echoes only the marker, never the secret);
|
|
6
|
+
// • BINARY frames are raw terminal bytes - our keystrokes in, the screen out;
|
|
7
|
+
// • TEXT frames are JSON control: we send {type:'resize'|'takeover'}, the server sends
|
|
8
|
+
// {type:'role', writer, cols, rows} and {type:'resize', cols, rows}.
|
|
9
|
+
// So there is nothing to emulate here. Raw mode on, bytes both ways, a resize on SIGWINCH.
|
|
10
|
+
//
|
|
11
|
+
// ONE KEYBOARD PER SESSION. The server gives the writer token to the first viewer and makes every
|
|
12
|
+
// later one a read-only mirror. Someone who runs `cubes attach` means to type, so unless `--view` is
|
|
13
|
+
// passed we send `takeover` the moment we are told we are a mirror - the same button the web shows
|
|
14
|
+
// on a read-only tile. The writer also decides the pane's size, so the session re-fits to THIS window.
|
|
15
|
+
import { ApiError } from "./api.js";
|
|
16
|
+
import { InputFilter, PASTE_IMAGE_BYTE } from "./keys.js";
|
|
17
|
+
export const TTY_SUBPROTOCOL = "cube.tty.v1";
|
|
18
|
+
const OPEN = 1;
|
|
19
|
+
/** Close codes (server/src/engine/tty.ts, web/src/cubes/Terminal.tsx). */
|
|
20
|
+
const WS_CLOSE_BACKPRESSURE = 4001;
|
|
21
|
+
const WS_CLOSE_FULL = 4002;
|
|
22
|
+
const WS_CLOSE_PARKED = 4004;
|
|
23
|
+
const WS_CLOSE_UNAUTHORIZED = 4403;
|
|
24
|
+
/** Drops that mean "the session is fine, the socket is not": re-mint a ticket and re-attach; the
|
|
25
|
+
* server replays its scrollback ring. 1001/1012/1013 are a server going away or restarting. */
|
|
26
|
+
const TRANSIENT = new Set([WS_CLOSE_BACKPRESSURE, 1001, 1005, 1006, 1011, 1012, 1013]);
|
|
27
|
+
const DEFAULT_BACKOFF = [300, 600, 1200, 2400, 5000, 5000, 5000, 5000];
|
|
28
|
+
/** Everything an attached session may have switched on in YOUR terminal (mouse reporting, bracketed
|
|
29
|
+
* paste, focus events, app cursor keys, kitty/modifyOtherKeys keyboard modes, a hidden cursor, a
|
|
30
|
+
* scroll region) - switched back off on the way out, so detaching never leaves Warp/iTerm in a
|
|
31
|
+
* state where the wheel prints garbage. */
|
|
32
|
+
export const RESTORE_TERMINAL = "\x1b[0m\x1b[?25h\x1b[0 q\x1b[r" +
|
|
33
|
+
"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l" +
|
|
34
|
+
"\x1b[?2004l\x1b[?1004l\x1b[?1l\x1b>\x1b[<u\x1b[>4;0m";
|
|
35
|
+
const ENTER_ALT = "\x1b[?1049h\x1b[H\x1b[2J";
|
|
36
|
+
const LEAVE_ALT = "\x1b[?1049l";
|
|
37
|
+
const CLEAR = "\x1b[H\x1b[2J";
|
|
38
|
+
export function wsUrlFor(host, id) {
|
|
39
|
+
return `${host.replace(/^http/i, "ws")}/api/sessions/${encodeURIComponent(id)}/tty`;
|
|
40
|
+
}
|
|
41
|
+
export function attach(deps, session, opts = {}) {
|
|
42
|
+
const { api, stdin, stdout, stderr } = deps;
|
|
43
|
+
const backoff = opts.backoffMs ?? DEFAULT_BACKOFF;
|
|
44
|
+
const filter = new InputFilter({ pasteImage: !opts.view });
|
|
45
|
+
const enc = new TextEncoder();
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
let ws = null;
|
|
48
|
+
let role = null;
|
|
49
|
+
let askedTakeover = false;
|
|
50
|
+
let takeoverPending = false;
|
|
51
|
+
/** Someone took the keyboard FROM us. Sticky for the whole attach, reconnects included: a person
|
|
52
|
+
* who clicked "take over" in the browser must not have it snatched back by a network blip. */
|
|
53
|
+
let yielded = false;
|
|
54
|
+
let entered = false;
|
|
55
|
+
let done = false;
|
|
56
|
+
let attempts = 0;
|
|
57
|
+
let timer = null;
|
|
58
|
+
// Keystrokes that must wait their turn: until the server says who holds the keyboard, and while
|
|
59
|
+
// an image paste is uploading (so what is typed after Ctrl-V lands after the path, not before).
|
|
60
|
+
let pending = [];
|
|
61
|
+
let pasting = false;
|
|
62
|
+
const note = (msg) => {
|
|
63
|
+
stderr.write(entered ? `\r\n\x1b[2m[cubes] ${msg}\x1b[0m\r\n` : `[cubes] ${msg}\n`);
|
|
64
|
+
};
|
|
65
|
+
const enter = () => {
|
|
66
|
+
if (entered)
|
|
67
|
+
return;
|
|
68
|
+
entered = true;
|
|
69
|
+
if (stdin.isTTY)
|
|
70
|
+
stdin.setRawMode?.(true);
|
|
71
|
+
stdout.write(ENTER_ALT);
|
|
72
|
+
};
|
|
73
|
+
const finish = (code, message) => {
|
|
74
|
+
if (done)
|
|
75
|
+
return;
|
|
76
|
+
done = true;
|
|
77
|
+
if (timer)
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
stdin.off("data", onData);
|
|
80
|
+
stdout.off("resize", onResize);
|
|
81
|
+
const sock = ws;
|
|
82
|
+
ws = null;
|
|
83
|
+
if (sock) {
|
|
84
|
+
sock.onclose = null;
|
|
85
|
+
sock.onmessage = null;
|
|
86
|
+
try {
|
|
87
|
+
sock.close(1000, "detached");
|
|
88
|
+
}
|
|
89
|
+
catch { /* already gone */ }
|
|
90
|
+
}
|
|
91
|
+
if (entered) {
|
|
92
|
+
stdout.write(RESTORE_TERMINAL + LEAVE_ALT);
|
|
93
|
+
if (stdin.isTTY)
|
|
94
|
+
stdin.setRawMode?.(false);
|
|
95
|
+
}
|
|
96
|
+
stdin.pause();
|
|
97
|
+
resolve({ code, message });
|
|
98
|
+
};
|
|
99
|
+
const sendRaw = (b) => {
|
|
100
|
+
if (ws && ws.readyState === OPEN)
|
|
101
|
+
ws.send(b);
|
|
102
|
+
};
|
|
103
|
+
// Hold keys while we cannot yet say where they go: before the first role, while our takeover is
|
|
104
|
+
// in flight (a mirror that asked for the keyboard is about to be the writer), and mid-paste.
|
|
105
|
+
const holding = () => role === null || (role === "mirror" && takeoverPending) || pasting;
|
|
106
|
+
const flushPending = () => {
|
|
107
|
+
if (holding())
|
|
108
|
+
return;
|
|
109
|
+
const q = pending;
|
|
110
|
+
pending = [];
|
|
111
|
+
if (role === "writer")
|
|
112
|
+
for (const b of q)
|
|
113
|
+
sendRaw(b);
|
|
114
|
+
};
|
|
115
|
+
const sendInput = (b) => {
|
|
116
|
+
if (opts.view)
|
|
117
|
+
return;
|
|
118
|
+
if (holding()) {
|
|
119
|
+
if (pending.reduce((n, x) => n + x.length, 0) < 64 * 1024)
|
|
120
|
+
pending.push(b.slice());
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (role === "writer")
|
|
124
|
+
sendRaw(b);
|
|
125
|
+
};
|
|
126
|
+
const sendResize = () => {
|
|
127
|
+
const cols = stdout.columns ?? 80;
|
|
128
|
+
const rows = stdout.rows ?? 24;
|
|
129
|
+
if (ws && ws.readyState === OPEN)
|
|
130
|
+
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
131
|
+
};
|
|
132
|
+
const pasteImage = async () => {
|
|
133
|
+
pasting = true;
|
|
134
|
+
let typed = Uint8Array.of(PASTE_IMAGE_BYTE);
|
|
135
|
+
try {
|
|
136
|
+
const img = await deps.readClipboardImage();
|
|
137
|
+
if (img) {
|
|
138
|
+
note(`uploading the image on your clipboard (${Math.ceil(img.bytes.length / 1024)} KB)…`);
|
|
139
|
+
const r = await api.post(`/sessions/${encodeURIComponent(session.id)}/paste-image`, undefined, { contentType: img.type, raw: img.bytes });
|
|
140
|
+
// A trailing space so the person can keep typing after the path - the web does the same.
|
|
141
|
+
typed = enc.encode(r.path + " ");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
note(`image paste failed: ${e.message}`);
|
|
146
|
+
typed = new Uint8Array(0);
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
pasting = false;
|
|
150
|
+
}
|
|
151
|
+
if (done)
|
|
152
|
+
return;
|
|
153
|
+
if (typed.length) {
|
|
154
|
+
if (role === "writer")
|
|
155
|
+
sendRaw(typed);
|
|
156
|
+
else
|
|
157
|
+
pending.unshift(typed);
|
|
158
|
+
}
|
|
159
|
+
flushPending();
|
|
160
|
+
};
|
|
161
|
+
function onData(chunk) {
|
|
162
|
+
for (const a of filter.feed(chunk)) {
|
|
163
|
+
if (a.kind === "detach") {
|
|
164
|
+
finish(0, "detached");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (a.kind === "paste-image") {
|
|
168
|
+
if (pasting)
|
|
169
|
+
continue;
|
|
170
|
+
void pasteImage();
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
sendInput(a.bytes);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function onResize() { sendResize(); }
|
|
177
|
+
const retry = (ms) => {
|
|
178
|
+
if (done)
|
|
179
|
+
return;
|
|
180
|
+
timer = setTimeout(() => { timer = null; void connect(); }, ms);
|
|
181
|
+
};
|
|
182
|
+
const onClose = (code, reason) => {
|
|
183
|
+
ws = null;
|
|
184
|
+
role = null;
|
|
185
|
+
if (done)
|
|
186
|
+
return;
|
|
187
|
+
if (code === 1000) {
|
|
188
|
+
finish(0, reason ? `session ended (${reason})` : "session ended");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (code === WS_CLOSE_UNAUTHORIZED) {
|
|
192
|
+
finish(1, "the server refused the attach - your sign-in or org membership may have changed");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (code === WS_CLOSE_PARKED) {
|
|
196
|
+
note("this session's terminal is parked (its machine stopped answering). Waiting for it to come back - Ctrl-] to give up.");
|
|
197
|
+
retry(opts.parkedRetryMs ?? 15_000);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (code === WS_CLOSE_FULL) {
|
|
201
|
+
note("this session already has the most viewers it allows - waiting for one to leave. Ctrl-] to give up.");
|
|
202
|
+
retry(opts.fullRetryMs ?? 5_000);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (TRANSIENT.has(code) && attempts < backoff.length) {
|
|
206
|
+
const ms = backoff[attempts];
|
|
207
|
+
attempts += 1;
|
|
208
|
+
note(`connection dropped (${code}) - reconnecting (${attempts}/${backoff.length})…`);
|
|
209
|
+
retry(ms);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
finish(1, TRANSIENT.has(code)
|
|
213
|
+
? "lost the connection to the session and could not get it back"
|
|
214
|
+
: `the session closed the connection (${code}${reason ? ` ${reason}` : ""})`);
|
|
215
|
+
};
|
|
216
|
+
const connect = async () => {
|
|
217
|
+
if (done)
|
|
218
|
+
return;
|
|
219
|
+
let ticket;
|
|
220
|
+
try {
|
|
221
|
+
ticket = (await api.post(`/sessions/${encodeURIComponent(session.id)}/terminal/ticket`)).ticket;
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
if (done)
|
|
225
|
+
return;
|
|
226
|
+
if (e instanceof ApiError && e.status === 409) {
|
|
227
|
+
finish(1, "the session is not running any more");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (e instanceof ApiError && e.status < 500) {
|
|
231
|
+
finish(1, e.message);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// The server itself is restarting or unreachable: same ladder as a dropped socket.
|
|
235
|
+
if (attempts < backoff.length) {
|
|
236
|
+
const ms = backoff[attempts];
|
|
237
|
+
attempts += 1;
|
|
238
|
+
note(`${e.message} - retrying (${attempts}/${backoff.length})…`);
|
|
239
|
+
retry(ms);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
finish(1, e.message);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (done)
|
|
246
|
+
return;
|
|
247
|
+
const sock = deps.openWs(wsUrlFor(deps.host, session.id), [TTY_SUBPROTOCOL, ticket]);
|
|
248
|
+
sock.binaryType = "arraybuffer";
|
|
249
|
+
ws = sock;
|
|
250
|
+
askedTakeover = false;
|
|
251
|
+
takeoverPending = false;
|
|
252
|
+
sock.onopen = () => {
|
|
253
|
+
if (ws !== sock)
|
|
254
|
+
return;
|
|
255
|
+
if (!entered)
|
|
256
|
+
enter();
|
|
257
|
+
else
|
|
258
|
+
stdout.write(CLEAR); // the server replays its ring; paint it on a clean screen
|
|
259
|
+
sendResize();
|
|
260
|
+
};
|
|
261
|
+
sock.onmessage = (ev) => {
|
|
262
|
+
if (ws !== sock)
|
|
263
|
+
return;
|
|
264
|
+
const data = ev.data;
|
|
265
|
+
if (typeof data === "string") {
|
|
266
|
+
let msg;
|
|
267
|
+
try {
|
|
268
|
+
msg = JSON.parse(data);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (msg.type === "role") {
|
|
274
|
+
// A role is what proves the attach stuck (the viewer-cap refusal arrives AFTER open),
|
|
275
|
+
// so this - not onopen - is where the reconnect ladder resets.
|
|
276
|
+
attempts = 0;
|
|
277
|
+
const prev = role;
|
|
278
|
+
role = msg.writer ? "writer" : "mirror";
|
|
279
|
+
if (role === "mirror" && prev === "writer") {
|
|
280
|
+
// Demoted: another viewer took the keyboard. Never take it back automatically - two
|
|
281
|
+
// terminals that each did would pass it back and forth forever.
|
|
282
|
+
yielded = true;
|
|
283
|
+
takeoverPending = false;
|
|
284
|
+
note("someone else took the keyboard - this terminal is read-only now. Ctrl-] and attach again to take it back.");
|
|
285
|
+
}
|
|
286
|
+
else if (role === "mirror" && !opts.view && !askedTakeover && !yielded) {
|
|
287
|
+
askedTakeover = true;
|
|
288
|
+
takeoverPending = true;
|
|
289
|
+
sock.send(JSON.stringify({ type: "takeover" }));
|
|
290
|
+
}
|
|
291
|
+
if (role === "writer") {
|
|
292
|
+
takeoverPending = false;
|
|
293
|
+
sendResize(); // the pane now sizes to us
|
|
294
|
+
}
|
|
295
|
+
flushPending();
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (data instanceof ArrayBuffer)
|
|
300
|
+
stdout.write(new Uint8Array(data));
|
|
301
|
+
else if (ArrayBuffer.isView(data))
|
|
302
|
+
stdout.write(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
303
|
+
};
|
|
304
|
+
sock.onclose = (ev) => { if (ws === sock)
|
|
305
|
+
onClose(ev.code, ev.reason); };
|
|
306
|
+
sock.onerror = () => { };
|
|
307
|
+
};
|
|
308
|
+
stdin.on("data", onData);
|
|
309
|
+
stdout.on("resize", onResize);
|
|
310
|
+
stdin.resume();
|
|
311
|
+
if (opts.signal?.aborted) {
|
|
312
|
+
finish(0, "detached");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
opts.signal?.addEventListener("abort", () => finish(0, "detached"), { once: true });
|
|
316
|
+
void connect();
|
|
317
|
+
});
|
|
318
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Signing in: the OAuth 2.0 Device Authorization Grant (RFC 8628) against the app's own Keycloak.
|
|
2
|
+
//
|
|
3
|
+
// The CLI is told nothing but the app's URL. `/api/client-config` names the realm issuer and the
|
|
4
|
+
// public client (`cubes-cli`), the issuer's discovery document names the endpoints, and the person
|
|
5
|
+
// approves a short code in the browser where they are already signed in. So the CLI never sees a
|
|
6
|
+
// password, a Google login never has to happen inside a terminal, and the same binary works against
|
|
7
|
+
// any install.
|
|
8
|
+
//
|
|
9
|
+
// It asks for `offline_access`: a laptop CLI is used in bursts days apart, and an ordinary refresh
|
|
10
|
+
// token dies with the browser-length SSO session. `cubes logout` revokes the offline token.
|
|
11
|
+
import { loadLogin, saveLogin } from "./config.js";
|
|
12
|
+
export const SCOPE = "openid profile email organization offline_access";
|
|
13
|
+
/** Refresh this long before the access token actually expires, so a request never races the edge. */
|
|
14
|
+
const EXP_SKEW_S = 60;
|
|
15
|
+
export class CliError extends Error {
|
|
16
|
+
exitCode;
|
|
17
|
+
constructor(message, exitCode = 1) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.exitCode = exitCode;
|
|
20
|
+
this.name = "CliError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function normalizeHost(raw) {
|
|
24
|
+
let h = raw.trim();
|
|
25
|
+
if (!/^https?:\/\//i.test(h))
|
|
26
|
+
h = `https://${h}`;
|
|
27
|
+
const u = new URL(h);
|
|
28
|
+
return u.origin;
|
|
29
|
+
}
|
|
30
|
+
/** The payload of a JWT, unverified. Only ever used to read OUR OWN token's exp + display claims -
|
|
31
|
+
* the server verifies every token it is handed; nothing here makes an authorization decision. */
|
|
32
|
+
export function decodeJwt(token) {
|
|
33
|
+
const part = token.split(".")[1];
|
|
34
|
+
if (!part)
|
|
35
|
+
return {};
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** The org aliases a token is a member of. Keycloak's membership mapper emits a flat string array;
|
|
44
|
+
* it can also be configured to emit an object keyed by alias, so both are read. */
|
|
45
|
+
export function orgsOf(token) {
|
|
46
|
+
const c = decodeJwt(token).organization;
|
|
47
|
+
if (Array.isArray(c))
|
|
48
|
+
return c.filter((x) => typeof x === "string");
|
|
49
|
+
if (c && typeof c === "object")
|
|
50
|
+
return Object.keys(c);
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
export function whoOf(token) {
|
|
54
|
+
const p = decodeJwt(token);
|
|
55
|
+
const name = p.preferred_username ?? p.email ?? p.sub;
|
|
56
|
+
return typeof name === "string" ? name : "unknown";
|
|
57
|
+
}
|
|
58
|
+
async function json(res, what) {
|
|
59
|
+
const text = await res.text();
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new CliError(`${what}: HTTP ${res.status} (not JSON)`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function discover(host) {
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
res = await fetch(`${host}/api/client-config`);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
throw new CliError(`cannot reach ${host}: ${e.message}`);
|
|
74
|
+
}
|
|
75
|
+
if (!res.ok)
|
|
76
|
+
throw new CliError(`${host} did not answer /api/client-config (HTTP ${res.status}) - is that a Cubes URL?`);
|
|
77
|
+
const cfg = await json(res, "client-config");
|
|
78
|
+
if (cfg.cli === undefined)
|
|
79
|
+
throw new CliError(`${host} is too old for the CLI (no login settings in /api/client-config)`);
|
|
80
|
+
if (cfg.cli === null)
|
|
81
|
+
throw new CliError(`${host} has no sign-in configured`);
|
|
82
|
+
const wk = await fetch(`${cfg.cli.issuer}/.well-known/openid-configuration`);
|
|
83
|
+
if (!wk.ok)
|
|
84
|
+
throw new CliError(`cannot read the sign-in settings at ${cfg.cli.issuer} (HTTP ${wk.status})`);
|
|
85
|
+
const d = await json(wk, "openid-configuration");
|
|
86
|
+
if (!d.device_authorization_endpoint)
|
|
87
|
+
throw new CliError("this install's sign-in does not support device login");
|
|
88
|
+
return {
|
|
89
|
+
issuer: cfg.cli.issuer,
|
|
90
|
+
clientId: cfg.cli.clientId,
|
|
91
|
+
endpoints: { token: d.token_endpoint, device: d.device_authorization_endpoint, revoke: d.revocation_endpoint },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export async function startDevice(d) {
|
|
95
|
+
const res = await fetch(d.endpoints.device, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
98
|
+
body: new URLSearchParams({ client_id: d.clientId, scope: SCOPE }),
|
|
99
|
+
});
|
|
100
|
+
const body = await json(res, "device login");
|
|
101
|
+
if (!res.ok || !body.device_code) {
|
|
102
|
+
throw new CliError(`device login refused: ${body.error_description ?? body.error ?? `HTTP ${res.status}`}`);
|
|
103
|
+
}
|
|
104
|
+
return body;
|
|
105
|
+
}
|
|
106
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
107
|
+
/** Poll the token endpoint until the person approves (or refuses, or the code expires). */
|
|
108
|
+
export async function pollDevice(d, start) {
|
|
109
|
+
let interval = Math.max(1, start.interval ?? 5) * 1000;
|
|
110
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
await sleep(interval);
|
|
113
|
+
const res = await fetch(d.endpoints.token, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
116
|
+
body: new URLSearchParams({
|
|
117
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
118
|
+
device_code: start.device_code,
|
|
119
|
+
client_id: d.clientId,
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
const t = await json(res, "token");
|
|
123
|
+
if (res.ok && t.access_token)
|
|
124
|
+
return t;
|
|
125
|
+
if (t.error === "authorization_pending")
|
|
126
|
+
continue;
|
|
127
|
+
if (t.error === "slow_down") {
|
|
128
|
+
interval += 5000;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (t.error === "access_denied")
|
|
132
|
+
throw new CliError("sign-in was declined in the browser");
|
|
133
|
+
if (t.error === "expired_token")
|
|
134
|
+
break;
|
|
135
|
+
throw new CliError(`sign-in failed: ${t.error_description ?? t.error ?? `HTTP ${res.status}`}`);
|
|
136
|
+
}
|
|
137
|
+
throw new CliError("the code expired before it was approved - run `cubes login` again");
|
|
138
|
+
}
|
|
139
|
+
export function loginFromTokens(host, d, t, org) {
|
|
140
|
+
if (!t.access_token || !t.refresh_token)
|
|
141
|
+
throw new CliError("sign-in returned no tokens");
|
|
142
|
+
const exp = decodeJwt(t.access_token).exp;
|
|
143
|
+
return {
|
|
144
|
+
host, issuer: d.issuer, clientId: d.clientId, endpoints: d.endpoints,
|
|
145
|
+
refreshToken: t.refresh_token, accessToken: t.access_token,
|
|
146
|
+
accessExp: typeof exp === "number" ? exp : Math.floor(Date.now() / 1000) + (t.expires_in ?? 300),
|
|
147
|
+
org,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export async function refresh(login) {
|
|
151
|
+
const res = await fetch(login.endpoints.token, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
154
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: login.refreshToken, client_id: login.clientId }),
|
|
155
|
+
});
|
|
156
|
+
const t = await json(res, "token refresh");
|
|
157
|
+
if (!res.ok || !t.access_token) {
|
|
158
|
+
throw new CliError(`your sign-in to ${login.host} has expired or was revoked - run \`cubes login\``);
|
|
159
|
+
}
|
|
160
|
+
const exp = decodeJwt(t.access_token).exp;
|
|
161
|
+
return {
|
|
162
|
+
...login,
|
|
163
|
+
accessToken: t.access_token,
|
|
164
|
+
// Keycloak may or may not rotate; keep the old one when it does not send a new one.
|
|
165
|
+
refreshToken: t.refresh_token ?? login.refreshToken,
|
|
166
|
+
accessExp: typeof exp === "number" ? exp : Math.floor(Date.now() / 1000) + (t.expires_in ?? 300),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export async function revoke(login) {
|
|
170
|
+
if (!login.endpoints.revoke)
|
|
171
|
+
return;
|
|
172
|
+
await fetch(login.endpoints.revoke, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
175
|
+
body: new URLSearchParams({ token: login.refreshToken, token_type_hint: "refresh_token", client_id: login.clientId }),
|
|
176
|
+
}).catch(() => undefined);
|
|
177
|
+
}
|
|
178
|
+
/** The login a command runs under. `CUBES_TOKEN` (+ `CUBES_HOST`, `CUBES_ORG`) bypasses the stored
|
|
179
|
+
* login entirely - for CI and scripts, where nobody is there to approve a device code. */
|
|
180
|
+
export function currentSession(opts = {}, env = process.env) {
|
|
181
|
+
if (env.CUBES_TOKEN) {
|
|
182
|
+
const tok = env.CUBES_TOKEN;
|
|
183
|
+
if (!env.CUBES_HOST)
|
|
184
|
+
throw new CliError("CUBES_TOKEN needs CUBES_HOST too");
|
|
185
|
+
const host = normalizeHost(env.CUBES_HOST);
|
|
186
|
+
const org = opts.org || env.CUBES_ORG || orgsOf(tok)[0];
|
|
187
|
+
return { host, org, token: async () => tok, who: () => whoOf(tok) };
|
|
188
|
+
}
|
|
189
|
+
let login = loadLogin();
|
|
190
|
+
if (!login)
|
|
191
|
+
throw new CliError("not signed in - run `cubes login --host <your Cubes URL>`");
|
|
192
|
+
const org = opts.org || env.CUBES_ORG || login.org;
|
|
193
|
+
return {
|
|
194
|
+
host: login.host,
|
|
195
|
+
org,
|
|
196
|
+
async token(force = false) {
|
|
197
|
+
const now = Math.floor(Date.now() / 1000);
|
|
198
|
+
if (!force && login.accessToken && (login.accessExp ?? 0) - EXP_SKEW_S > now)
|
|
199
|
+
return login.accessToken;
|
|
200
|
+
login = await refresh(login);
|
|
201
|
+
saveLogin(login);
|
|
202
|
+
return login.accessToken;
|
|
203
|
+
},
|
|
204
|
+
who: () => (login.accessToken ? whoOf(login.accessToken) : "unknown"),
|
|
205
|
+
};
|
|
206
|
+
}
|