@addai/node 0.27.1 → 0.28.0
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/assets/vault-extension/assets/inter.woff2 +0 -0
- package/assets/vault-extension/background.js +45 -0
- package/assets/vault-extension/content.js +62 -0
- package/assets/vault-extension/icons/icon-128.png +0 -0
- package/assets/vault-extension/icons/icon-16.png +0 -0
- package/assets/vault-extension/icons/icon-32.png +0 -0
- package/assets/vault-extension/icons/icon-48.png +0 -0
- package/assets/vault-extension/manifest.json +39 -0
- package/dist/capabilities.js +2 -1
- package/dist/command-runner.js +109 -2
- package/dist/desktop/docker.d.ts +4 -1
- package/dist/desktop/docker.js +7 -3
- package/dist/desktop/engine.d.ts +13 -0
- package/dist/desktop/engine.js +35 -4
- package/dist/desktop/install-engine.d.ts +33 -2
- package/dist/desktop/install-engine.js +138 -12
- package/dist/desktop/learn/browser.d.ts +41 -0
- package/dist/desktop/learn/browser.js +136 -0
- package/dist/desktop/learn/injected.mjs +173 -0
- package/dist/desktop/learn/recorder.mjs +151 -0
- package/dist/desktop/learn/session.d.ts +51 -0
- package/dist/desktop/learn/session.js +224 -0
- package/dist/desktop/provider.d.ts +4 -0
- package/dist/desktop/start-engine.d.ts +7 -4
- package/dist/desktop/start-engine.js +50 -6
- package/dist/desktop/vault-extension.d.ts +21 -0
- package/dist/desktop/vault-extension.js +112 -0
- package/dist/desktop/vault-seed.mjs +112 -0
- package/dist/desktop/vault-session.d.ts +41 -0
- package/dist/desktop/vault-session.js +164 -0
- package/package.json +4 -3
- package/scripts/copy-assets.js +18 -0
- package/dist/tui.d.ts +0 -1
- package/dist/tui.js +0 -314
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Plants the add.ai session cookie in a desktop's browser, from inside it.
|
|
2
|
+
//
|
|
3
|
+
// Runs in the container, not the daemon: the debugging port is bound to
|
|
4
|
+
// loopback there on purpose (CDP is total control of a browser, and the host
|
|
5
|
+
// must not be able to reach it), so the only place that can talk to it is the
|
|
6
|
+
// container itself. Node 22 is in the image already — this is the same
|
|
7
|
+
// arrangement +Ai Learn's recorder uses.
|
|
8
|
+
//
|
|
9
|
+
// Launched by the browser wrapper, in the background, immediately before Chrome
|
|
10
|
+
// starts. It waits for the port rather than assuming, sets the cookie, and
|
|
11
|
+
// exits. Nothing depends on its exit code: a browser that comes up without a
|
|
12
|
+
// vault session is worse than one with, but it is still a working browser.
|
|
13
|
+
import * as fs from 'node:fs';
|
|
14
|
+
|
|
15
|
+
const PORT = Number(process.env.ADDAI_CDP_PORT || 9222);
|
|
16
|
+
const SESSION_FILE = process.env.ADDAI_VAULT_SESSION || '/conf/vault/session.json';
|
|
17
|
+
const DEADLINE_MS = 60_000;
|
|
18
|
+
const POLL_MS = 250;
|
|
19
|
+
|
|
20
|
+
const log = (m) => process.stdout.write(`[vault-seed] ${m}\n`);
|
|
21
|
+
|
|
22
|
+
/** The cookie the extension reads: URI-encoded JSON with the two tokens. */
|
|
23
|
+
export function cookieValue(session) {
|
|
24
|
+
return encodeURIComponent(JSON.stringify({
|
|
25
|
+
access_token: session.access_token,
|
|
26
|
+
refresh_token: session.refresh_token,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Both add.ai itself and every *.add.ai app, which is what the session is
|
|
31
|
+
* for — signing in at auth.add.ai signs you in across all of them. */
|
|
32
|
+
export function cookieParams(session) {
|
|
33
|
+
return {
|
|
34
|
+
name: 'auth',
|
|
35
|
+
value: cookieValue(session),
|
|
36
|
+
domain: '.add.ai',
|
|
37
|
+
path: '/',
|
|
38
|
+
secure: true,
|
|
39
|
+
httpOnly: false,
|
|
40
|
+
sameSite: 'Lax',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function waitForBrowser(deadline) {
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
|
48
|
+
if (res.ok) return await res.json();
|
|
49
|
+
} catch { /* not up yet */ }
|
|
50
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function setCookie(wsUrl, params) {
|
|
56
|
+
const ws = new WebSocket(wsUrl);
|
|
57
|
+
await new Promise((resolve, reject) => {
|
|
58
|
+
ws.addEventListener('open', resolve, { once: true });
|
|
59
|
+
ws.addEventListener('error', () => reject(new Error('could not open the debugger')), { once: true });
|
|
60
|
+
});
|
|
61
|
+
const done = new Promise((resolve, reject) => {
|
|
62
|
+
const timer = setTimeout(() => reject(new Error('the browser did not answer')), 10_000);
|
|
63
|
+
ws.addEventListener('message', (ev) => {
|
|
64
|
+
let msg;
|
|
65
|
+
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
66
|
+
if (msg.id !== 1) return;
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
if (msg.error) reject(new Error(msg.error.message || 'setCookie failed'));
|
|
69
|
+
else resolve(msg.result);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
ws.send(JSON.stringify({ id: 1, method: 'Network.setCookie', params }));
|
|
73
|
+
// finally, not after the await: an error reply rejects, and a socket left
|
|
74
|
+
// open holds the event loop forever. The failure mode is a hung node process
|
|
75
|
+
// per browser launch, which is worse than the failure it is reporting.
|
|
76
|
+
try {
|
|
77
|
+
return await done;
|
|
78
|
+
} finally {
|
|
79
|
+
ws.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function main() {
|
|
84
|
+
let session;
|
|
85
|
+
try {
|
|
86
|
+
session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
|
87
|
+
} catch {
|
|
88
|
+
log('no session staged for this desktop — the vault will be empty until somebody signs in');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!session?.access_token || !session?.refresh_token) {
|
|
92
|
+
log('the staged session is not usable');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const version = await waitForBrowser(Date.now() + DEADLINE_MS);
|
|
97
|
+
if (!version?.webSocketDebuggerUrl) {
|
|
98
|
+
log('the browser never opened its debugging port');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const res = await setCookie(version.webSocketDebuggerUrl, cookieParams(session));
|
|
103
|
+
log(res?.success === false ? 'the browser refused the cookie' : 'signed in');
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log(`could not sign in: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Importable for tests; only runs when executed directly.
|
|
110
|
+
if (process.argv[1] && process.argv[1].endsWith('vault-seed.mjs')) {
|
|
111
|
+
main().catch((err) => log(`unexpected: ${err.message}`));
|
|
112
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** A GoTrue session, reduced to the two fields the cookie carries. */
|
|
2
|
+
export interface VaultSession {
|
|
3
|
+
access_token: string;
|
|
4
|
+
refresh_token: string;
|
|
5
|
+
/** Epoch ms. Used to decide whether to refresh before handing it over. */
|
|
6
|
+
expires_at: number;
|
|
7
|
+
email?: string;
|
|
8
|
+
entity_name?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function sessionPath(confDir: string): string;
|
|
11
|
+
export declare function readSession(confDir: string): VaultSession | null;
|
|
12
|
+
export declare function writeSession(confDir: string, s: VaultSession): void;
|
|
13
|
+
export declare function isFresh(s: VaultSession | null, now: number): boolean;
|
|
14
|
+
/** Exactly what the extension expects to find: the add.ai `auth` cookie is a
|
|
15
|
+
* URI-encoded JSON object with the two tokens in it. */
|
|
16
|
+
export declare function cookieValue(s: VaultSession): string;
|
|
17
|
+
type Fetcher = typeof fetch;
|
|
18
|
+
/** Trade a refresh token for a live one. Returns null when it has expired or
|
|
19
|
+
* been revoked — which is the normal way a rotation elsewhere reaches us. */
|
|
20
|
+
export declare function refreshSession(refreshToken: string, doFetch?: Fetcher): Promise<VaultSession | null>;
|
|
21
|
+
/** Sign in with a freshly rotated password. */
|
|
22
|
+
export declare function passwordSession(email: string, password: string, doFetch?: Fetcher): Promise<VaultSession | null>;
|
|
23
|
+
export interface LoginRpc {
|
|
24
|
+
(desktopId: string, rotate: boolean): Promise<any>;
|
|
25
|
+
}
|
|
26
|
+
export interface EnsureResult {
|
|
27
|
+
session: VaultSession | null;
|
|
28
|
+
/** Why there is no session, when there is none. */
|
|
29
|
+
reason?: string;
|
|
30
|
+
/** True when a rotation was performed — worth logging, it is destructive. */
|
|
31
|
+
rotated: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get a live session for this desktop, minting one only when necessary.
|
|
35
|
+
*
|
|
36
|
+
* Order matters: cached-and-fresh, then refresh, then rotate. Skipping to
|
|
37
|
+
* rotation because it is simpler would log the entity out of every other
|
|
38
|
+
* browser it is signed into, every time a desktop restarts.
|
|
39
|
+
*/
|
|
40
|
+
export declare function ensureVaultSession(confDir: string, login: LoginRpc, desktopId: string, doFetch?: Fetcher, now?: number): Promise<EnsureResult>;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.sessionPath = sessionPath;
|
|
37
|
+
exports.readSession = readSession;
|
|
38
|
+
exports.writeSession = writeSession;
|
|
39
|
+
exports.isFresh = isFresh;
|
|
40
|
+
exports.cookieValue = cookieValue;
|
|
41
|
+
exports.refreshSession = refreshSession;
|
|
42
|
+
exports.passwordSession = passwordSession;
|
|
43
|
+
exports.ensureVaultSession = ensureVaultSession;
|
|
44
|
+
// Signing a desktop's browser in, so the vault has something in it.
|
|
45
|
+
//
|
|
46
|
+
// The +Ai Vault extension shows whatever the browser's add.ai session can see,
|
|
47
|
+
// so an installed extension and no session is an empty vault. This mints the
|
|
48
|
+
// session on the host and leaves it where the desktop can pick it up.
|
|
49
|
+
//
|
|
50
|
+
// Rotation is destructive — asking for a new password drops every other
|
|
51
|
+
// session that identity holds, which would sign an entity out of every browser
|
|
52
|
+
// it uses. So the order is always: use the cached refresh token, and only ask
|
|
53
|
+
// the server for a password when there is nothing to refresh.
|
|
54
|
+
const fs = __importStar(require("fs"));
|
|
55
|
+
const path = __importStar(require("path"));
|
|
56
|
+
const config_1 = require("../config");
|
|
57
|
+
/** Refresh this long before expiry rather than at it: a browser that starts
|
|
58
|
+
* with a token about to die shows an empty vault for its first minute. */
|
|
59
|
+
const EARLY_REFRESH_MS = 5 * 60_000;
|
|
60
|
+
function sessionPath(confDir) {
|
|
61
|
+
return path.join(confDir, 'vault', 'session.json');
|
|
62
|
+
}
|
|
63
|
+
function readSession(confDir) {
|
|
64
|
+
try {
|
|
65
|
+
const raw = fs.readFileSync(sessionPath(confDir), 'utf8');
|
|
66
|
+
const s = JSON.parse(raw);
|
|
67
|
+
if (!s?.access_token || !s?.refresh_token)
|
|
68
|
+
return null;
|
|
69
|
+
return s;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function writeSession(confDir, s) {
|
|
76
|
+
const p = sessionPath(confDir);
|
|
77
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
78
|
+
// 0600 on the host side. Inside the container the entity has root and can
|
|
79
|
+
// read it, which is fine: these are its OWN credentials, and it can already
|
|
80
|
+
// rotate them through its browser MCP. What matters is that the host does
|
|
81
|
+
// not leave them world-readable in a shared runtime directory.
|
|
82
|
+
fs.writeFileSync(p, JSON.stringify(s), { mode: 0o600 });
|
|
83
|
+
}
|
|
84
|
+
function isFresh(s, now) {
|
|
85
|
+
return !!s && s.expires_at - EARLY_REFRESH_MS > now;
|
|
86
|
+
}
|
|
87
|
+
/** Exactly what the extension expects to find: the add.ai `auth` cookie is a
|
|
88
|
+
* URI-encoded JSON object with the two tokens in it. */
|
|
89
|
+
function cookieValue(s) {
|
|
90
|
+
return encodeURIComponent(JSON.stringify({
|
|
91
|
+
access_token: s.access_token,
|
|
92
|
+
refresh_token: s.refresh_token,
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
function toSession(body, extra = {}) {
|
|
96
|
+
if (!body?.access_token || !body?.refresh_token)
|
|
97
|
+
return null;
|
|
98
|
+
const ttl = Number(body.expires_in ?? 3600) * 1000;
|
|
99
|
+
return {
|
|
100
|
+
access_token: body.access_token,
|
|
101
|
+
refresh_token: body.refresh_token,
|
|
102
|
+
expires_at: Date.now() + ttl,
|
|
103
|
+
...extra,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Trade a refresh token for a live one. Returns null when it has expired or
|
|
107
|
+
* been revoked — which is the normal way a rotation elsewhere reaches us. */
|
|
108
|
+
async function refreshSession(refreshToken, doFetch = fetch) {
|
|
109
|
+
const res = await doFetch(`${config_1.SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json', apikey: config_1.SUPABASE_ANON_KEY },
|
|
112
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
113
|
+
});
|
|
114
|
+
if (!res.ok)
|
|
115
|
+
return null;
|
|
116
|
+
return toSession(await res.json());
|
|
117
|
+
}
|
|
118
|
+
/** Sign in with a freshly rotated password. */
|
|
119
|
+
async function passwordSession(email, password, doFetch = fetch) {
|
|
120
|
+
const res = await doFetch(`${config_1.SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'Content-Type': 'application/json', apikey: config_1.SUPABASE_ANON_KEY },
|
|
123
|
+
body: JSON.stringify({ email, password }),
|
|
124
|
+
});
|
|
125
|
+
if (!res.ok)
|
|
126
|
+
return null;
|
|
127
|
+
return toSession(await res.json(), { email });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get a live session for this desktop, minting one only when necessary.
|
|
131
|
+
*
|
|
132
|
+
* Order matters: cached-and-fresh, then refresh, then rotate. Skipping to
|
|
133
|
+
* rotation because it is simpler would log the entity out of every other
|
|
134
|
+
* browser it is signed into, every time a desktop restarts.
|
|
135
|
+
*/
|
|
136
|
+
async function ensureVaultSession(confDir, login, desktopId, doFetch = fetch, now = Date.now()) {
|
|
137
|
+
const cached = readSession(confDir);
|
|
138
|
+
if (isFresh(cached, now))
|
|
139
|
+
return { session: cached, rotated: false };
|
|
140
|
+
if (cached?.refresh_token) {
|
|
141
|
+
const refreshed = await refreshSession(cached.refresh_token, doFetch);
|
|
142
|
+
if (refreshed) {
|
|
143
|
+
const merged = { ...refreshed, email: cached.email, entity_name: cached.entity_name };
|
|
144
|
+
writeSession(confDir, merged);
|
|
145
|
+
return { session: merged, rotated: false };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Ask the server who this desktop belongs to AND for a password in one call.
|
|
149
|
+
// It refuses to guess on a shared desktop with no default entity, which is
|
|
150
|
+
// the case where planting one entity's session would hand its vault to
|
|
151
|
+
// another.
|
|
152
|
+
const info = await login(desktopId, true);
|
|
153
|
+
if (!info?.ok)
|
|
154
|
+
return { session: null, reason: info?.reason ?? 'login_refused', rotated: false };
|
|
155
|
+
if (!info.password || !info.email) {
|
|
156
|
+
return { session: null, reason: 'no_credentials_returned', rotated: false };
|
|
157
|
+
}
|
|
158
|
+
const fresh = await passwordSession(info.email, info.password, doFetch);
|
|
159
|
+
if (!fresh)
|
|
160
|
+
return { session: null, reason: 'sign_in_failed', rotated: true };
|
|
161
|
+
const out = { ...fresh, entity_name: info.entity_name };
|
|
162
|
+
writeSession(confDir, out);
|
|
163
|
+
return { session: out, rotated: true };
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addai/node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -34,10 +34,11 @@
|
|
|
34
34
|
"files": [
|
|
35
35
|
"dist",
|
|
36
36
|
"scripts",
|
|
37
|
-
"README.md"
|
|
37
|
+
"README.md",
|
|
38
|
+
"assets"
|
|
38
39
|
],
|
|
39
40
|
"scripts": {
|
|
40
|
-
"build": "tsc -p tsconfig.json",
|
|
41
|
+
"build": "tsc -p tsconfig.json && node scripts/copy-assets.js",
|
|
41
42
|
"start": "node dist/cli.js",
|
|
42
43
|
"dev": "tsc -p tsconfig.json && node dist/cli.js",
|
|
43
44
|
"test": "npm run build && node --test test/*.test.mjs",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// tsc compiles .ts and ignores everything else, but two files in this package
|
|
2
|
+
// are deliberately plain .mjs: they run inside a desktop container (node 22,
|
|
3
|
+
// no build step, no node_modules) rather than in the daemon. Ship them next to
|
|
4
|
+
// the compiled output so dist/ is self-contained.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const ASSETS = [
|
|
9
|
+
['src/desktop/learn/recorder.mjs', 'dist/desktop/learn/recorder.mjs'],
|
|
10
|
+
['src/desktop/learn/injected.mjs', 'dist/desktop/learn/injected.mjs'],
|
|
11
|
+
['src/desktop/vault-seed.mjs', 'dist/desktop/vault-seed.mjs'],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
for (const [from, to] of ASSETS) {
|
|
15
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
16
|
+
fs.copyFileSync(from, to);
|
|
17
|
+
}
|
|
18
|
+
console.log(`copied ${ASSETS.length} runtime assets into dist/`);
|
package/dist/tui.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function runHarnessesTui(): Promise<void>;
|
package/dist/tui.js
DELETED
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
// `entities-runtime harnesses` — interactive harness manager in the
|
|
3
|
-
// Claude Code terminal idiom: alt-screen, rounded panels, dim chrome,
|
|
4
|
-
// ❯ selection, braille spinner, ⏺ status dots. Hand-rolled ANSI — the
|
|
5
|
-
// daemon deliberately has no TUI framework dependency.
|
|
6
|
-
//
|
|
7
|
-
// Local actions hand the real terminal to the harness CLI itself (there
|
|
8
|
-
// is nothing to bridge when you're already at the machine): install
|
|
9
|
-
// streams npm output into a panel; login leaves the alt screen, runs the
|
|
10
|
-
// CLI's own login flow attached to your TTY, then re-probes on return.
|
|
11
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
-
if (k2 === undefined) k2 = k;
|
|
13
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
-
}
|
|
17
|
-
Object.defineProperty(o, k2, desc);
|
|
18
|
-
}) : (function(o, m, k, k2) {
|
|
19
|
-
if (k2 === undefined) k2 = k;
|
|
20
|
-
o[k2] = m[k];
|
|
21
|
-
}));
|
|
22
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
-
}) : function(o, v) {
|
|
25
|
-
o["default"] = v;
|
|
26
|
-
});
|
|
27
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
-
var ownKeys = function(o) {
|
|
29
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
-
var ar = [];
|
|
31
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
-
return ar;
|
|
33
|
-
};
|
|
34
|
-
return ownKeys(o);
|
|
35
|
-
};
|
|
36
|
-
return function (mod) {
|
|
37
|
-
if (mod && mod.__esModule) return mod;
|
|
38
|
-
var result = {};
|
|
39
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
-
__setModuleDefault(result, mod);
|
|
41
|
-
return result;
|
|
42
|
-
};
|
|
43
|
-
})();
|
|
44
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
-
exports.runHarnessesTui = runHarnessesTui;
|
|
46
|
-
const child_process_1 = require("child_process");
|
|
47
|
-
const readline = __importStar(require("readline"));
|
|
48
|
-
const capabilities_1 = require("./capabilities");
|
|
49
|
-
const win_1 = require("./win");
|
|
50
|
-
const fs = __importStar(require("fs"));
|
|
51
|
-
const os = __importStar(require("os"));
|
|
52
|
-
const path = __importStar(require("path"));
|
|
53
|
-
const harness_registry_1 = require("./harness-registry");
|
|
54
|
-
/* ── ansi helpers ────────────────────────────────────────────────────── */
|
|
55
|
-
const ESC = '\x1b[';
|
|
56
|
-
const dim = (s) => `${ESC}2m${s}${ESC}22m`;
|
|
57
|
-
const bold = (s) => `${ESC}1m${s}${ESC}22m`;
|
|
58
|
-
const fg = (n, s) => `${ESC}38;5;${n}m${s}${ESC}39m`;
|
|
59
|
-
const green = (s) => fg(114, s);
|
|
60
|
-
const yellow = (s) => fg(179, s);
|
|
61
|
-
const grey = (s) => fg(244, s);
|
|
62
|
-
const cyan = (s) => fg(80, s);
|
|
63
|
-
const altOn = () => process.stdout.write(`${ESC}?1049h${ESC}?25l`);
|
|
64
|
-
const altOff = () => process.stdout.write(`${ESC}?1049l${ESC}?25h`);
|
|
65
|
-
const home = () => process.stdout.write(`${ESC}H${ESC}2J`);
|
|
66
|
-
const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
67
|
-
const width = () => Math.min(process.stdout.columns || 100, 110);
|
|
68
|
-
function panel(title, lines) {
|
|
69
|
-
const w = width() - 2;
|
|
70
|
-
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
71
|
-
const top = `${dim('╭─')} ${bold(title)} ${dim('─'.repeat(Math.max(1, w - strip(title).length - 4)) + '╮')}`;
|
|
72
|
-
const body = lines.map(l => {
|
|
73
|
-
const pad = Math.max(0, w - 2 - strip(l).length);
|
|
74
|
-
return `${dim('│')} ${l}${' '.repeat(pad)} ${dim('│')}`;
|
|
75
|
-
});
|
|
76
|
-
const bottom = dim(`╰${'─'.repeat(w)}╯`);
|
|
77
|
-
return [top, ...body, bottom].join('\n');
|
|
78
|
-
}
|
|
79
|
-
function dot(p) {
|
|
80
|
-
if (!p?.available)
|
|
81
|
-
return grey('⏺');
|
|
82
|
-
return p.authed ? green('⏺') : yellow('⏺');
|
|
83
|
-
}
|
|
84
|
-
function actionFor(id, p) {
|
|
85
|
-
if (!p?.available)
|
|
86
|
-
return (0, harness_registry_1.isInstallable)(id) ? 'install' : 'how to install';
|
|
87
|
-
if (!p.authed)
|
|
88
|
-
return 'log in';
|
|
89
|
-
return harness_registry_1.HARNESSES[id].logout ? 'log out' : 'ok';
|
|
90
|
-
}
|
|
91
|
-
function row(id, p, selected) {
|
|
92
|
-
const spec = harness_registry_1.HARNESSES[id];
|
|
93
|
-
const name = spec.label.padEnd(13);
|
|
94
|
-
const ver = (p?.version ? `v${p.version}` : '—').padEnd(12).slice(0, 12);
|
|
95
|
-
const who = (p?.authed
|
|
96
|
-
? `${p.account ?? p.accountKind ?? 'logged in'}${p.plan ? ` · ${p.plan}` : ''}`
|
|
97
|
-
: p?.available ? 'not logged in' : 'not installed').padEnd(28).slice(0, 28);
|
|
98
|
-
const models = `${p?.models?.length ?? 0} models`.padEnd(10);
|
|
99
|
-
const efforts = spec.efforts.length ? spec.efforts.join('/') : '—';
|
|
100
|
-
const act = actionFor(id, p);
|
|
101
|
-
const actTxt = act === 'ok' ? green('✓') : act === 'log out' ? dim('↵ log out') : cyan(`↵ ${act}`);
|
|
102
|
-
const line = `${dot(p)} ${name} ${dim(ver)} ${who} ${dim(models)} ${dim(efforts.padEnd(26).slice(0, 26))} ${actTxt}`;
|
|
103
|
-
return selected ? `${cyan('❯')} ${bold(line)}` : ` ${line}`;
|
|
104
|
-
}
|
|
105
|
-
function render(st) {
|
|
106
|
-
home();
|
|
107
|
-
const lines = [];
|
|
108
|
-
if (!st.caps) {
|
|
109
|
-
lines.push(`${cyan(SPIN[st.spin % SPIN.length])} probing installed harnesses…`);
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
lines.push(dim(' harness version account models efforts'));
|
|
113
|
-
harness_registry_1.HARNESS_IDS.forEach((id, i) => lines.push(row(id, st.caps[id], i === st.sel)));
|
|
114
|
-
}
|
|
115
|
-
if (st.busy)
|
|
116
|
-
lines.push('', `${cyan(SPIN[st.spin % SPIN.length])} ${st.busy}`);
|
|
117
|
-
if (st.log.length) {
|
|
118
|
-
lines.push('');
|
|
119
|
-
st.log.slice(-8).forEach(l => lines.push(dim(l.slice(0, width() - 8))));
|
|
120
|
-
}
|
|
121
|
-
if (st.note)
|
|
122
|
-
lines.push('', st.note);
|
|
123
|
-
process.stdout.write(panel('Harnesses', lines) + '\n');
|
|
124
|
-
process.stdout.write(dim(' ↑/↓ move · ↵ install / log in · r refresh · q quit\n'));
|
|
125
|
-
}
|
|
126
|
-
/* ── actions ─────────────────────────────────────────────────────────── */
|
|
127
|
-
async function installSelected(st, id, redraw) {
|
|
128
|
-
const spec = harness_registry_1.HARNESSES[id];
|
|
129
|
-
if (!(0, harness_registry_1.isInstallable)(id)) {
|
|
130
|
-
st.note = yellow(`${spec.label}: ${spec.login.instructions}`);
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
st.busy = `installing ${spec.label} (npm i -g ${spec.npmPackage})`;
|
|
134
|
-
st.log = [];
|
|
135
|
-
redraw();
|
|
136
|
-
const inv = (0, win_1.resolveCliInvocation)('npm', ['install', '-g', spec.npmPackage]);
|
|
137
|
-
await new Promise(resolve => {
|
|
138
|
-
const child = (0, child_process_1.spawn)(inv.file, inv.args, { env: process.env });
|
|
139
|
-
const onChunk = (b) => {
|
|
140
|
-
st.log.push(...b.toString('utf8').split('\n').filter(Boolean));
|
|
141
|
-
redraw();
|
|
142
|
-
};
|
|
143
|
-
child.stdout?.on('data', onChunk);
|
|
144
|
-
child.stderr?.on('data', onChunk);
|
|
145
|
-
child.on('exit', code => {
|
|
146
|
-
st.busy = null;
|
|
147
|
-
st.note = code === 0 ? green(`✓ ${spec.label} installed`) : fg(203, `✗ npm exited ${code}`);
|
|
148
|
-
resolve();
|
|
149
|
-
});
|
|
150
|
-
child.on('error', err => { st.busy = null; st.note = fg(203, `✗ ${err.message}`); resolve(); });
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
function logoutSelected(id) {
|
|
154
|
-
const spec = harness_registry_1.HARNESSES[id];
|
|
155
|
-
if (!spec.logout)
|
|
156
|
-
return `${spec.label} can only be logged out on the device`;
|
|
157
|
-
if (spec.logout.cmd) {
|
|
158
|
-
const bin = spec.findBinary();
|
|
159
|
-
if (!bin)
|
|
160
|
-
return `${spec.label} is not installed`;
|
|
161
|
-
const inv = (0, win_1.resolveCliInvocation)(bin, spec.logout.cmd);
|
|
162
|
-
const res = (0, child_process_1.spawnSync)(inv.file, inv.args, { stdio: 'ignore', env: process.env, timeout: 60_000 });
|
|
163
|
-
if (res.status !== 0)
|
|
164
|
-
return `logout exited with code ${res.status ?? '?'}`;
|
|
165
|
-
}
|
|
166
|
-
for (const rel of spec.logout.files ?? []) {
|
|
167
|
-
try {
|
|
168
|
-
fs.rmSync(path.join(os.homedir(), rel), { force: true });
|
|
169
|
-
}
|
|
170
|
-
catch { /* best effort */ }
|
|
171
|
-
}
|
|
172
|
-
for (const [rel, varName] of spec.logout.envStrip ?? []) {
|
|
173
|
-
const envPath = path.join(os.homedir(), rel);
|
|
174
|
-
try {
|
|
175
|
-
const body = fs.readFileSync(envPath, 'utf8');
|
|
176
|
-
fs.writeFileSync(envPath, body.split('\n').filter(l => !l.startsWith(`${varName}=`)).join('\n'), { mode: 0o600 });
|
|
177
|
-
}
|
|
178
|
-
catch { /* nothing to strip */ }
|
|
179
|
-
}
|
|
180
|
-
return null;
|
|
181
|
-
}
|
|
182
|
-
function loginSelected(id) {
|
|
183
|
-
const spec = harness_registry_1.HARNESSES[id];
|
|
184
|
-
const bin = spec.findBinary();
|
|
185
|
-
if (!bin)
|
|
186
|
-
return `${spec.label} is not installed`;
|
|
187
|
-
// Hand the real terminal to the CLI — leave the alt screen first.
|
|
188
|
-
altOff();
|
|
189
|
-
console.log(cyan(`\n— ${spec.label} login —`));
|
|
190
|
-
console.log(dim(spec.login.instructions) + '\n');
|
|
191
|
-
const args = spec.login.strategy === 'pty-url-code' ? (spec.login.loginArgs ?? []) : [];
|
|
192
|
-
const inv = (0, win_1.resolveCliInvocation)(bin, args);
|
|
193
|
-
const res = (0, child_process_1.spawnSync)(inv.file, inv.args, { stdio: 'inherit', env: process.env });
|
|
194
|
-
altOn();
|
|
195
|
-
return res.status === 0 ? null : `login exited with code ${res.status ?? '?'}`;
|
|
196
|
-
}
|
|
197
|
-
/* ── entry ───────────────────────────────────────────────────────────── */
|
|
198
|
-
async function plainStatus() {
|
|
199
|
-
const caps = await (0, capabilities_1.probeCapabilities)();
|
|
200
|
-
for (const id of harness_registry_1.HARNESS_IDS) {
|
|
201
|
-
const p = caps[id];
|
|
202
|
-
const state = !p?.available ? 'not installed' : p.authed ? `authed (${p.account ?? p.accountKind ?? '?'})` : 'not logged in';
|
|
203
|
-
console.log(`${id.padEnd(8)} ${(p?.version ?? '—').padEnd(12)} ${state}`);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
async function runHarnessesTui() {
|
|
207
|
-
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
208
|
-
await plainStatus();
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
const st = { caps: null, sel: 0, spin: 0, busy: null, log: [], note: null, confirmLogout: null };
|
|
212
|
-
const redraw = () => render(st);
|
|
213
|
-
altOn();
|
|
214
|
-
readline.emitKeypressEvents(process.stdin);
|
|
215
|
-
process.stdin.setRawMode(true);
|
|
216
|
-
process.stdin.resume();
|
|
217
|
-
const spinTimer = setInterval(() => { st.spin++; if (st.busy || !st.caps)
|
|
218
|
-
redraw(); }, 90);
|
|
219
|
-
const quit = () => {
|
|
220
|
-
clearInterval(spinTimer);
|
|
221
|
-
process.stdin.setRawMode(false);
|
|
222
|
-
process.stdin.pause();
|
|
223
|
-
altOff();
|
|
224
|
-
process.exit(0);
|
|
225
|
-
};
|
|
226
|
-
const reprobe = async () => {
|
|
227
|
-
st.caps = null;
|
|
228
|
-
redraw();
|
|
229
|
-
st.caps = await (0, capabilities_1.probeCapabilities)();
|
|
230
|
-
redraw();
|
|
231
|
-
};
|
|
232
|
-
let acting = false;
|
|
233
|
-
process.stdin.on('keypress', (_str, key) => {
|
|
234
|
-
void (async () => {
|
|
235
|
-
if (!key || acting)
|
|
236
|
-
return;
|
|
237
|
-
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
|
238
|
-
quit();
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
if (!st.caps)
|
|
242
|
-
return;
|
|
243
|
-
if (st.confirmLogout) {
|
|
244
|
-
const target = st.confirmLogout;
|
|
245
|
-
st.confirmLogout = null;
|
|
246
|
-
if (key.name === 'y') {
|
|
247
|
-
acting = true;
|
|
248
|
-
try {
|
|
249
|
-
const err = logoutSelected(target);
|
|
250
|
-
st.note = err ? fg(203, `✗ ${err}`) : green(`✓ ${harness_registry_1.HARNESSES[target].label} logged out`);
|
|
251
|
-
await reprobe();
|
|
252
|
-
}
|
|
253
|
-
finally {
|
|
254
|
-
acting = false;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
else {
|
|
258
|
-
st.note = dim('logout cancelled');
|
|
259
|
-
redraw();
|
|
260
|
-
}
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
if (key.name === 'up' || key.name === 'k') {
|
|
264
|
-
st.sel = (st.sel + harness_registry_1.HARNESS_IDS.length - 1) % harness_registry_1.HARNESS_IDS.length;
|
|
265
|
-
redraw();
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
if (key.name === 'down' || key.name === 'j') {
|
|
269
|
-
st.sel = (st.sel + 1) % harness_registry_1.HARNESS_IDS.length;
|
|
270
|
-
redraw();
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
if (key.name === 'r') {
|
|
274
|
-
await reprobe();
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
if (key.name !== 'return')
|
|
278
|
-
return;
|
|
279
|
-
const id = harness_registry_1.HARNESS_IDS[st.sel];
|
|
280
|
-
const p = st.caps[id];
|
|
281
|
-
acting = true;
|
|
282
|
-
try {
|
|
283
|
-
if (!p?.available) {
|
|
284
|
-
await installSelected(st, id, redraw);
|
|
285
|
-
await reprobe();
|
|
286
|
-
}
|
|
287
|
-
else if (!p.authed) {
|
|
288
|
-
const err = loginSelected(id);
|
|
289
|
-
st.note = err ? fg(203, `✗ ${err}`) : green('✓ login flow finished — verifying…');
|
|
290
|
-
await reprobe();
|
|
291
|
-
const now = st.caps?.[id];
|
|
292
|
-
if (now?.authed)
|
|
293
|
-
st.note = green(`✓ ${harness_registry_1.HARNESSES[id].label} logged in as ${now.account ?? now.accountKind ?? '?'}`);
|
|
294
|
-
else if (!err)
|
|
295
|
-
st.note = yellow(`${harness_registry_1.HARNESSES[id].label} still reports unauthenticated`);
|
|
296
|
-
redraw();
|
|
297
|
-
}
|
|
298
|
-
else if (harness_registry_1.HARNESSES[id].logout) {
|
|
299
|
-
st.confirmLogout = id;
|
|
300
|
-
st.note = yellow(`log out of ${harness_registry_1.HARNESSES[id].label}? press y to confirm`);
|
|
301
|
-
redraw();
|
|
302
|
-
}
|
|
303
|
-
else {
|
|
304
|
-
st.note = green(`${harness_registry_1.HARNESSES[id].label} is installed and logged in.`);
|
|
305
|
-
redraw();
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
finally {
|
|
309
|
-
acting = false;
|
|
310
|
-
}
|
|
311
|
-
})();
|
|
312
|
-
});
|
|
313
|
-
await reprobe();
|
|
314
|
-
}
|