aios-dashboard 0.2.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/LICENSE +25 -0
- package/README.md +91 -0
- package/bin/aios-dashboard.mjs +17 -0
- package/lib/connect.mjs +559 -0
- package/lib/env.mjs +111 -0
- package/lib/installer.mjs +698 -0
- package/lib/lifecycle.mjs +500 -0
- package/lib/pairing.mjs +107 -0
- package/lib/paths.mjs +104 -0
- package/lib/prerequisites.mjs +183 -0
- package/lib/service.mjs +141 -0
- package/lib/source.mjs +245 -0
- package/lib/tunnel.mjs +250 -0
- package/lib/zip.mjs +277 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AIOS Dashboard CLI contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this installer command-line software and associated documentation files
|
|
7
|
+
(the "Software"), to deal in the Software without restriction, including
|
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute,
|
|
9
|
+
sublicense, and/or sell copies of the Software, and to permit persons to whom
|
|
10
|
+
the Software is furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
This license applies only to the files distributed in the aios-dashboard npm
|
|
24
|
+
CLI package. It does not license the AIOS Dashboard application downloaded by
|
|
25
|
+
the CLI.
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# aios-dashboard
|
|
2
|
+
|
|
3
|
+
Public, dependency-free installer and local process controller for the AIOS
|
|
4
|
+
Dashboard. Requires Node.js 22.22.0 or newer.
|
|
5
|
+
|
|
6
|
+
This package contains no Dashboard application source. Active members receive a
|
|
7
|
+
short-lived application URL and SHA-256 digest from the AIOS MCP. Claude runs:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx --yes --loglevel=error --package aios-dashboard@0.2.0 aios-dashboard init \
|
|
11
|
+
--dir /path/to/aios \
|
|
12
|
+
--source '<signed-MCP-URL>' \
|
|
13
|
+
--source-sha256 '<64-hex-digest>' \
|
|
14
|
+
--yes \
|
|
15
|
+
--start
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The CLI verifies the archive before extraction, builds in staging, preserves
|
|
19
|
+
the installed `.env`, `data/`, and `learnings.md` on update, atomically swaps
|
|
20
|
+
the app, starts the production server on loopback, and opens
|
|
21
|
+
`http://127.0.0.1:8080`. It redacts signed URL queries and does not forward them
|
|
22
|
+
through cross-origin redirects. The MCP command also lowers npm's log level so
|
|
23
|
+
npx does not echo the bearer URL in its command notice. Downloads are limited
|
|
24
|
+
to 250 MiB and five minutes; ZIP entry count and expanded size are bounded
|
|
25
|
+
before extraction.
|
|
26
|
+
|
|
27
|
+
Installation uses exactly pnpm 10.30.3 with `--frozen-lockfile --prod=false`.
|
|
28
|
+
For `node-pty`, Linux needs Python 3, make, and a C/C++ compiler; Windows uses
|
|
29
|
+
its prebuild, and the installer repairs the macOS spawn-helper execute bit. A
|
|
30
|
+
configured member database URL is retained even when it is temporarily
|
|
31
|
+
unreachable.
|
|
32
|
+
|
|
33
|
+
Bare `npx aios-dashboard init` does not fetch a private GitHub release; it tells
|
|
34
|
+
the member to ask Claude for an entitled install plan. `--ref` exists only for
|
|
35
|
+
maintainer development.
|
|
36
|
+
|
|
37
|
+
Manage an installed local Dashboard with:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx --yes --package aios-dashboard@0.2.0 aios-dashboard start --dir /path/to/aios
|
|
41
|
+
npx --yes --package aios-dashboard@0.2.0 aios-dashboard status --dir /path/to/aios
|
|
42
|
+
npx --yes --package aios-dashboard@0.2.0 aios-dashboard open --dir /path/to/aios
|
|
43
|
+
npx --yes --package aios-dashboard@0.2.0 aios-dashboard stop --dir /path/to/aios
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`AUTH_DISABLED=true` is written only for a local, single-user install and must
|
|
47
|
+
never be exposed publicly. Without a member database, the app opens a setup
|
|
48
|
+
experience and reports live data as not connected rather than as zero.
|
|
49
|
+
Lifecycle ownership is proven with a per-launch loopback runtime token plus OS
|
|
50
|
+
process identity before a live PID is signalled.
|
|
51
|
+
|
|
52
|
+
## Hosted runner
|
|
53
|
+
|
|
54
|
+
Connect a member's local Claude Code to their hosted Dashboard:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx --yes --package aios-dashboard@0.2.0 aios-dashboard connect --dir /path/to/aios
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The command starts the installed app as a local runner on `127.0.0.1:8099`,
|
|
61
|
+
opens a Cloudflare quick tunnel, and prints a pairing block. Paste the whole
|
|
62
|
+
block into **Settings → Deploy → Connect your computer** in the hosted
|
|
63
|
+
Dashboard. Leave the command running; Ctrl+C disconnects.
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
|
|
67
|
+
- `--port <port>` — local runner port (default `8099`).
|
|
68
|
+
- `--tunnel-url <origin>` — use a tunnel you already run.
|
|
69
|
+
- `--tunnel-name <name>` and `--tunnel-hostname <host>` — use a stable named
|
|
70
|
+
Cloudflare tunnel.
|
|
71
|
+
- `--no-download` — do not download cloudflared automatically.
|
|
72
|
+
- `--rotate` — mint a new pairing code and invalidate the old one.
|
|
73
|
+
- `--daemon` — install a systemd (Linux) or launchd (macOS) user service.
|
|
74
|
+
- `--print-pairing` — print the current pairing block without starting.
|
|
75
|
+
- `--dry-run` — show the plan without changing anything.
|
|
76
|
+
|
|
77
|
+
cloudflared is taken from PATH, this CLI's data directory, or
|
|
78
|
+
`node_modules/.bin`. If absent, the official platform binary is downloaded once
|
|
79
|
+
into the CLI data directory. If that is unavailable, install it yourself or
|
|
80
|
+
pass `--tunnel-url`.
|
|
81
|
+
|
|
82
|
+
The pairing token is a credential with access to Claude Code in the workspace.
|
|
83
|
+
Do not post it or store it in a shared channel. Runner credentials live outside
|
|
84
|
+
the replaceable app directory with mode `0600`; `connect` forces
|
|
85
|
+
`AUTH_DISABLED=false` and a separate auth secret before exposing any tunnel.
|
|
86
|
+
|
|
87
|
+
## License boundary
|
|
88
|
+
|
|
89
|
+
The files in this npm package are MIT-licensed. The private Dashboard application
|
|
90
|
+
downloaded for an entitled member is a separate artifact and is not licensed by
|
|
91
|
+
this package's `LICENSE`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { parseArgs, runInstaller } from "../lib/installer.mjs";
|
|
8
|
+
|
|
9
|
+
const packagePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
10
|
+
const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
await runInstaller(parseArgs(process.argv.slice(2)), packageJson.version);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error(`aios-dashboard: ${error instanceof Error ? error.message : String(error)}`);
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
}
|
package/lib/connect.mjs
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
/**
|
|
5
|
+
* `aios-dashboard connect` — make the hosted dashboard reach this computer.
|
|
6
|
+
*
|
|
7
|
+
* Before this command, connecting was: start the app in local mode with a
|
|
8
|
+
* hand-made 48-character secret, start a tunnel in a second terminal, copy the
|
|
9
|
+
* tunnel origin and the secret into the host's environment variables, and
|
|
10
|
+
* redeploy. Four steps, two terminals, one redeploy, and a secret that
|
|
11
|
+
* inevitably ends up in shell history.
|
|
12
|
+
*
|
|
13
|
+
* Now it is one foreground command that starts the runner, opens the tunnel,
|
|
14
|
+
* and prints a pairing block to paste into Settings → Deploy. The block carries
|
|
15
|
+
* the *pairing code* — the same secret in a spelling that survives a copy and
|
|
16
|
+
* cannot be typo'd — and the hosted copy stores it in the member's own
|
|
17
|
+
* database, so there is nothing to redeploy.
|
|
18
|
+
*
|
|
19
|
+
* ## Why the runner keeps its login wall
|
|
20
|
+
*
|
|
21
|
+
* A local install writes `AUTH_DISABLED=true`, which is fine for an app
|
|
22
|
+
* reachable only from the machine it runs on. A tunnel makes it reachable from
|
|
23
|
+
* everywhere. This command therefore starts the runner with auth *enabled* and
|
|
24
|
+
* its own persistent `BETTER_AUTH_SECRET`, so the runner's UI stays behind
|
|
25
|
+
* sign-in while the bearer token opens exactly the three bridge paths and
|
|
26
|
+
* nothing else.
|
|
27
|
+
*/
|
|
28
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
import { generatePairingSecret, pairingCodeFromToken } from "./pairing.mjs";
|
|
32
|
+
import {
|
|
33
|
+
dataDir,
|
|
34
|
+
dashboardPath,
|
|
35
|
+
portableCommand,
|
|
36
|
+
workspacePath,
|
|
37
|
+
} from "./paths.mjs";
|
|
38
|
+
import { choosePackageManager, supportsNode } from "./prerequisites.mjs";
|
|
39
|
+
import { ensureCloudflared, originFromOutput, startTunnel } from "./tunnel.mjs";
|
|
40
|
+
|
|
41
|
+
export const DEFAULT_RUNNER_PORT = 8099;
|
|
42
|
+
const RUNNER_READY_TIMEOUT_MS = 300_000;
|
|
43
|
+
/** Cloudflare drops quick tunnels without warning; come back, do not give up. */
|
|
44
|
+
const TUNNEL_RESTART_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
|
|
45
|
+
|
|
46
|
+
export function connectHelpText() {
|
|
47
|
+
return `Connect this computer to a hosted AIOS Dashboard
|
|
48
|
+
|
|
49
|
+
Usage:
|
|
50
|
+
aios-dashboard connect [--dir <workspace>] [--port <port>]
|
|
51
|
+
|
|
52
|
+
Options:
|
|
53
|
+
--dir <workspace> AIOS workspace holding dashboard/ (default: current directory)
|
|
54
|
+
--port <port> Local port for the runner (default: ${DEFAULT_RUNNER_PORT})
|
|
55
|
+
--tunnel-url <origin> Use a tunnel you already run instead of cloudflared
|
|
56
|
+
--tunnel-name <name> Run a named Cloudflare tunnel instead of a quick one
|
|
57
|
+
--tunnel-hostname <h> The stable hostname of that named tunnel
|
|
58
|
+
--no-download Never fetch cloudflared; fail with instructions instead
|
|
59
|
+
--rotate Mint a new pairing code, invalidating the old one
|
|
60
|
+
--daemon Install a user service so this survives a reboot
|
|
61
|
+
--print-pairing Print the current pairing block and exit
|
|
62
|
+
--dry-run Show what would run without starting anything
|
|
63
|
+
--help, -h Show this help
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ------------------------------------------------------------------ *
|
|
68
|
+
* The credential, kept out of the app directory on purpose
|
|
69
|
+
* ------------------------------------------------------------------ */
|
|
70
|
+
|
|
71
|
+
export function credentialPath(platform = process.platform, env = process.env) {
|
|
72
|
+
return path.join(dataDir(platform, env), "runner.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read the stored runner credential, or make one.
|
|
77
|
+
*
|
|
78
|
+
* It lives in the data dir rather than `dashboard/.env` because
|
|
79
|
+
* `aios-dashboard init` replaces the app directory on update — a pairing that
|
|
80
|
+
* lived there would silently die on the next upgrade. Keeping it here also
|
|
81
|
+
* means a restart does *not* invalidate the pairing: only `--rotate` does, and
|
|
82
|
+
* that is the documented way to revoke a runner.
|
|
83
|
+
*/
|
|
84
|
+
export async function loadRunnerCredential({
|
|
85
|
+
rotate = false,
|
|
86
|
+
platform = process.platform,
|
|
87
|
+
env = process.env,
|
|
88
|
+
now = () => new Date().toISOString(),
|
|
89
|
+
random = randomBytes,
|
|
90
|
+
} = {}) {
|
|
91
|
+
const file = credentialPath(platform, env);
|
|
92
|
+
let stored = null;
|
|
93
|
+
if (!rotate && existsSync(file)) {
|
|
94
|
+
try {
|
|
95
|
+
stored = JSON.parse(await readFile(file, "utf8"));
|
|
96
|
+
} catch {
|
|
97
|
+
stored = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const token =
|
|
102
|
+
typeof stored?.token === "string" && pairingCodeFromToken(stored.token)
|
|
103
|
+
? stored.token
|
|
104
|
+
: generatePairingSecret(random).token;
|
|
105
|
+
const authSecret =
|
|
106
|
+
typeof stored?.authSecret === "string" && stored.authSecret.length >= 32
|
|
107
|
+
? stored.authSecret
|
|
108
|
+
: random(32).toString("hex");
|
|
109
|
+
|
|
110
|
+
const record = {
|
|
111
|
+
token,
|
|
112
|
+
authSecret,
|
|
113
|
+
createdAt: stored?.createdAt ?? now(),
|
|
114
|
+
rotatedAt:
|
|
115
|
+
rotate || !stored ? now() : (stored.rotatedAt ?? stored.createdAt),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
119
|
+
await writeFile(file, `${JSON.stringify(record, null, 2)}\n`, {
|
|
120
|
+
mode: 0o600,
|
|
121
|
+
});
|
|
122
|
+
await chmod(file, 0o600).catch(() => undefined);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
token: record.token,
|
|
126
|
+
code: pairingCodeFromToken(record.token),
|
|
127
|
+
authSecret: record.authSecret,
|
|
128
|
+
file,
|
|
129
|
+
rotated: rotate,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ------------------------------------------------------------------ *
|
|
134
|
+
* What the member copies
|
|
135
|
+
* ------------------------------------------------------------------ */
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The block printed to the terminal.
|
|
139
|
+
*
|
|
140
|
+
* Two spellings of the same thing: an `aios://connect` link that the pairing
|
|
141
|
+
* dialog parses in one paste, and the two fields underneath for anyone whose
|
|
142
|
+
* terminal mangles long lines. The code is a credential, and the block says so
|
|
143
|
+
* — the one line of security copy a person will actually read is the one next
|
|
144
|
+
* to the secret.
|
|
145
|
+
*/
|
|
146
|
+
export function pairingBlock({ origin, code, hostname, workspace }) {
|
|
147
|
+
const link = `aios://connect?origin=${encodeURIComponent(origin)}&code=${code.replace(/-/g, "")}`;
|
|
148
|
+
return [
|
|
149
|
+
"",
|
|
150
|
+
" ┌─ Connect your dashboard ───────────────────────────────────",
|
|
151
|
+
" │",
|
|
152
|
+
" │ In the hosted dashboard, open Settings → Deploy →",
|
|
153
|
+
' │ "Connect your computer" and paste this line:',
|
|
154
|
+
" │",
|
|
155
|
+
` │ ${link}`,
|
|
156
|
+
" │",
|
|
157
|
+
" │ Or paste the two fields:",
|
|
158
|
+
` │ Address ${origin}`,
|
|
159
|
+
` │ Pairing code ${code}`,
|
|
160
|
+
" │",
|
|
161
|
+
` │ Runner ${hostname}${workspace ? ` · ${workspace}` : ""}`,
|
|
162
|
+
" │ The pairing code is a password. It lets the dashboard run",
|
|
163
|
+
" │ Claude Code in your workspace. Do not paste it anywhere else.",
|
|
164
|
+
" │",
|
|
165
|
+
" └────────────────────────────────────────────────────────────",
|
|
166
|
+
"",
|
|
167
|
+
].join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ------------------------------------------------------------------ *
|
|
171
|
+
* The runner process
|
|
172
|
+
* ------------------------------------------------------------------ */
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The environment the runner child gets.
|
|
176
|
+
*
|
|
177
|
+
* `AUTH_DISABLED: "false"` is the important line and is set explicitly rather
|
|
178
|
+
* than deleted, because the value in `dashboard/.env` would otherwise win. See
|
|
179
|
+
* the file header.
|
|
180
|
+
*/
|
|
181
|
+
export function runnerEnv({ workspace, port, token, authSecret, base = {} }) {
|
|
182
|
+
return {
|
|
183
|
+
...base,
|
|
184
|
+
AIOS_MODE: "local",
|
|
185
|
+
AIOS_ROOT: workspace,
|
|
186
|
+
AIOS_AGENT_RUNNER_TOKEN: token,
|
|
187
|
+
// A runner must never be a hosted copy's remote target as well.
|
|
188
|
+
AIOS_AGENT_REMOTE_URL: "",
|
|
189
|
+
AIOS_AGENT_REMOTE_TOKEN: "",
|
|
190
|
+
AUTH_DISABLED: "false",
|
|
191
|
+
BETTER_AUTH_SECRET: authSecret,
|
|
192
|
+
HOST: "127.0.0.1",
|
|
193
|
+
PORT: String(port),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** `agent-native dev …`, spelled the way each package manager wants it. */
|
|
198
|
+
export function runnerArgs(packageManagerName, port) {
|
|
199
|
+
const dev = [
|
|
200
|
+
"agent-native",
|
|
201
|
+
"dev",
|
|
202
|
+
"--host",
|
|
203
|
+
"127.0.0.1",
|
|
204
|
+
"--port",
|
|
205
|
+
String(port),
|
|
206
|
+
];
|
|
207
|
+
return packageManagerName === "pnpm"
|
|
208
|
+
? ["exec", ...dev]
|
|
209
|
+
: ["exec", "--", ...dev];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function startRunner({ target, packageManager, env, port, onLine }) {
|
|
213
|
+
const args = runnerArgs(packageManager.name, port);
|
|
214
|
+
const invocation = portableCommand(packageManager.command, [
|
|
215
|
+
...(packageManager.commandArgs || []),
|
|
216
|
+
...args,
|
|
217
|
+
]);
|
|
218
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
219
|
+
cwd: target,
|
|
220
|
+
env,
|
|
221
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
222
|
+
shell: false,
|
|
223
|
+
});
|
|
224
|
+
const read = (chunk) => onLine(chunk.toString("utf8"));
|
|
225
|
+
child.stdout?.on("data", read);
|
|
226
|
+
child.stderr?.on("data", read);
|
|
227
|
+
return child;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Wait until the runner answers its own `hello` with the token.
|
|
232
|
+
*
|
|
233
|
+
* This is a better readiness check than "the port is open": it proves the app
|
|
234
|
+
* booted in local mode, that bearer auth is wired, and that the token in the
|
|
235
|
+
* data dir is the one the process is actually using. If this never passes,
|
|
236
|
+
* nothing downstream would have worked anyway.
|
|
237
|
+
*/
|
|
238
|
+
export async function waitForRunner({
|
|
239
|
+
port,
|
|
240
|
+
token,
|
|
241
|
+
timeoutMs = RUNNER_READY_TIMEOUT_MS,
|
|
242
|
+
fetchImpl = fetch,
|
|
243
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
244
|
+
alive = () => true,
|
|
245
|
+
}) {
|
|
246
|
+
const deadline = Date.now() + timeoutMs;
|
|
247
|
+
let lastStatus = 0;
|
|
248
|
+
while (Date.now() < deadline) {
|
|
249
|
+
if (!alive()) throw new Error("The runner exited before it was ready.");
|
|
250
|
+
try {
|
|
251
|
+
const response = await fetchImpl(
|
|
252
|
+
`http://127.0.0.1:${port}/api/claude-code/hello`,
|
|
253
|
+
{
|
|
254
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
255
|
+
signal: AbortSignal.timeout(20_000),
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
lastStatus = response.status;
|
|
259
|
+
if (response.ok) return await response.json();
|
|
260
|
+
// 401/403 mean the app is up but disagrees about the credential — no
|
|
261
|
+
// amount of waiting fixes that, so say it now.
|
|
262
|
+
if (response.status === 401 || response.status === 403) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`The runner rejected its own token (HTTP ${response.status}). Try \`aios-dashboard connect --rotate\`.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (
|
|
269
|
+
error instanceof Error &&
|
|
270
|
+
error.message.startsWith("The runner rejected")
|
|
271
|
+
) {
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
// Still compiling; the first boot of a dev server takes a while.
|
|
275
|
+
}
|
|
276
|
+
await sleep(500);
|
|
277
|
+
}
|
|
278
|
+
throw new Error(
|
|
279
|
+
`The runner did not become ready within ${Math.round(timeoutMs / 1000)}s${lastStatus ? ` (last status ${lastStatus})` : ""}.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* ------------------------------------------------------------------ *
|
|
284
|
+
* The command
|
|
285
|
+
* ------------------------------------------------------------------ */
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The same origin rule the hosted dashboard applies, checked here so a typo
|
|
289
|
+
* fails in the terminal rather than as a rejected paste two steps later.
|
|
290
|
+
* Mirrors `normalizeRunnerOrigin` in `shared/agent-runner-pairing.ts`.
|
|
291
|
+
*/
|
|
292
|
+
export function usableTunnelOrigin(raw) {
|
|
293
|
+
let url;
|
|
294
|
+
try {
|
|
295
|
+
url = new URL(String(raw ?? "").trim());
|
|
296
|
+
} catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
const localHttp = url.protocol === "http:" && url.hostname === "localhost";
|
|
300
|
+
if (url.protocol !== "https:" && !localHttp) return false;
|
|
301
|
+
return !(
|
|
302
|
+
url.username ||
|
|
303
|
+
url.password ||
|
|
304
|
+
(url.pathname !== "" && url.pathname !== "/") ||
|
|
305
|
+
url.search ||
|
|
306
|
+
url.hash
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Recognise the line the runner logs when a paired dashboard reaches it. */
|
|
311
|
+
export const RUNNER_AUTHENTICATED_MARKER =
|
|
312
|
+
"[aios] runner request authenticated";
|
|
313
|
+
|
|
314
|
+
/** cloudflared chatter that is not a problem the member can act on. */
|
|
315
|
+
export const TUNNEL_NOISE_PATTERN = /receive buffer size|quic-go/i;
|
|
316
|
+
|
|
317
|
+
/** Runner output worth interrupting a quiet terminal for. */
|
|
318
|
+
export const RUNNER_PROBLEM_PATTERN =
|
|
319
|
+
/\b(?:EADDRINUSE|ELIFECYCLE|ERR_[A-Z_]+|Cannot find|error:|Error:|failed)\b/;
|
|
320
|
+
|
|
321
|
+
export async function runConnect(
|
|
322
|
+
options,
|
|
323
|
+
{ log = console.log, error = console.error } = {},
|
|
324
|
+
) {
|
|
325
|
+
if (options.help) {
|
|
326
|
+
log(connectHelpText());
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!supportsNode()) throw new Error("Node.js 22 or newer is required.");
|
|
330
|
+
|
|
331
|
+
const workspace = workspacePath(options.dir);
|
|
332
|
+
const target = dashboardPath(workspace);
|
|
333
|
+
if (!existsSync(path.join(target, "package.json"))) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`No dashboard found at ${target}. Run \`aios-dashboard init\` in the workspace first.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const port = Number(options.port || DEFAULT_RUNNER_PORT);
|
|
340
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`--port must be a number between 1 and 65535 (got ${options.port}).`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (options.tunnelUrl && !usableTunnelOrigin(options.tunnelUrl)) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`--tunnel-url must be an HTTPS origin with no path (got ${options.tunnelUrl}). Plain HTTP is accepted only for http://localhost:<port>.`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const credential = await loadRunnerCredential({
|
|
353
|
+
rotate: options.rotate === true,
|
|
354
|
+
});
|
|
355
|
+
if (options.rotate) {
|
|
356
|
+
log(
|
|
357
|
+
"Minted a new pairing code. Any dashboard paired with the old one is now disconnected.",
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (options.printPairing) {
|
|
362
|
+
log(
|
|
363
|
+
pairingBlock({
|
|
364
|
+
origin: options.tunnelUrl || "https://<start connect to get one>",
|
|
365
|
+
code: credential.code,
|
|
366
|
+
hostname: (await import("node:os")).hostname(),
|
|
367
|
+
workspace: path.basename(workspace),
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const packageManager = choosePackageManager();
|
|
374
|
+
|
|
375
|
+
if (options.dryRun) {
|
|
376
|
+
log("\naios-dashboard connect plan");
|
|
377
|
+
log(` Workspace: ${workspace}`);
|
|
378
|
+
log(` Runner: ${target} on http://127.0.0.1:${port}`);
|
|
379
|
+
log(` Auth: login wall enabled, secret from ${credential.file}`);
|
|
380
|
+
log(` Credential: ${credential.file} (mode 0600)`);
|
|
381
|
+
log(
|
|
382
|
+
` Tunnel: ${
|
|
383
|
+
options.tunnelUrl
|
|
384
|
+
? `supplied (${options.tunnelUrl})`
|
|
385
|
+
: options.tunnelName
|
|
386
|
+
? `named cloudflared tunnel "${options.tunnelName}"`
|
|
387
|
+
: "cloudflared quick tunnel"
|
|
388
|
+
}`,
|
|
389
|
+
);
|
|
390
|
+
log("\nDry run complete; nothing was started.");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (options.daemon) {
|
|
395
|
+
const { installConnectService } = await import("./service.mjs");
|
|
396
|
+
await installConnectService({ workspace, port, options, log });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/* ---- runner ---- */
|
|
401
|
+
// The readiness check below is itself an authenticated request, so the
|
|
402
|
+
// "Connected" line only means something once a pairing block exists to have
|
|
403
|
+
// been pasted. Announcing before that would greet the CLI's own probe.
|
|
404
|
+
let watchingForDashboard = false;
|
|
405
|
+
let reachedAtLeastOnce = false;
|
|
406
|
+
const runner = startRunner({
|
|
407
|
+
target,
|
|
408
|
+
packageManager,
|
|
409
|
+
port,
|
|
410
|
+
env: runnerEnv({
|
|
411
|
+
workspace,
|
|
412
|
+
port,
|
|
413
|
+
token: credential.token,
|
|
414
|
+
authSecret: credential.authSecret,
|
|
415
|
+
base: process.env,
|
|
416
|
+
}),
|
|
417
|
+
onLine: (text) => {
|
|
418
|
+
// The runner's own output is otherwise swallowed by this process, which
|
|
419
|
+
// would leave a member staring at a silent terminal while the app failed
|
|
420
|
+
// to boot. Anything that looks like a problem is surfaced verbatim.
|
|
421
|
+
if (RUNNER_PROBLEM_PATTERN.test(text)) error(text.trimEnd());
|
|
422
|
+
if (!watchingForDashboard || reachedAtLeastOnce) return;
|
|
423
|
+
if (!text.includes(RUNNER_AUTHENTICATED_MARKER)) return;
|
|
424
|
+
reachedAtLeastOnce = true;
|
|
425
|
+
log("Connected · hosted dashboard reached the runner");
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
let stopping = false;
|
|
430
|
+
let tunnel = null;
|
|
431
|
+
const stop = () => {
|
|
432
|
+
if (stopping) return;
|
|
433
|
+
stopping = true;
|
|
434
|
+
tunnel?.kill("SIGTERM");
|
|
435
|
+
runner.kill("SIGTERM");
|
|
436
|
+
};
|
|
437
|
+
process.once("SIGINT", () => {
|
|
438
|
+
log("\nStopping. The hosted dashboard will show its not-connected state.");
|
|
439
|
+
stop();
|
|
440
|
+
process.exitCode = 0;
|
|
441
|
+
});
|
|
442
|
+
process.once("SIGTERM", stop);
|
|
443
|
+
runner.once("exit", (code) => {
|
|
444
|
+
if (stopping) return;
|
|
445
|
+
error(`The runner exited with code ${code}.`);
|
|
446
|
+
stop();
|
|
447
|
+
process.exitCode = typeof code === "number" ? code : 1;
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
log(`Starting the runner on http://127.0.0.1:${port} …`);
|
|
451
|
+
const identity = await waitForRunner({
|
|
452
|
+
port,
|
|
453
|
+
token: credential.token,
|
|
454
|
+
alive: () => runner.exitCode === null,
|
|
455
|
+
}).catch((cause) => {
|
|
456
|
+
stop();
|
|
457
|
+
throw cause;
|
|
458
|
+
});
|
|
459
|
+
log(
|
|
460
|
+
`Runner ready · ${identity.hostname}${identity.workspace ? ` · ${identity.workspace}` : ""}${
|
|
461
|
+
identity.claude ? ` · claude ${identity.claude}` : " · claude not found"
|
|
462
|
+
}`,
|
|
463
|
+
);
|
|
464
|
+
if (!identity.claude) {
|
|
465
|
+
log(
|
|
466
|
+
"! The `claude` CLI was not found. Chat and commands will reach this computer and then fail honestly.",
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/* ---- tunnel ---- */
|
|
471
|
+
let origin = options.tunnelUrl ?? null;
|
|
472
|
+
const openTunnel = async () => {
|
|
473
|
+
const binary = await ensureCloudflared({
|
|
474
|
+
allowDownload: options.noDownload !== true,
|
|
475
|
+
log,
|
|
476
|
+
});
|
|
477
|
+
const started = await startTunnel({
|
|
478
|
+
binary: binary.path,
|
|
479
|
+
port,
|
|
480
|
+
name: options.tunnelName ?? null,
|
|
481
|
+
hostname: options.tunnelHostname ?? null,
|
|
482
|
+
onLine: (text) => {
|
|
483
|
+
// Surface only what a person can act on; the rest is cloudflared's
|
|
484
|
+
// own connection bookkeeping. The UDP receive-buffer notice is loud,
|
|
485
|
+
// harmless, and not something a member can fix, so it stays quiet.
|
|
486
|
+
if (TUNNEL_NOISE_PATTERN.test(text)) return;
|
|
487
|
+
if (/ERR |error=|failed to/i.test(text)) error(text.trim());
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
tunnel = started.child;
|
|
491
|
+
return started.origin;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
if (!origin) {
|
|
495
|
+
origin = await openTunnel().catch((cause) => {
|
|
496
|
+
stop();
|
|
497
|
+
throw cause;
|
|
498
|
+
});
|
|
499
|
+
} else {
|
|
500
|
+
log(`Using the tunnel you supplied: ${origin}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const printBlock = (currentOrigin, note) => {
|
|
504
|
+
if (note) log(note);
|
|
505
|
+
log(
|
|
506
|
+
pairingBlock({
|
|
507
|
+
origin: currentOrigin,
|
|
508
|
+
code: credential.code,
|
|
509
|
+
hostname: identity.hostname,
|
|
510
|
+
workspace: identity.workspace,
|
|
511
|
+
}),
|
|
512
|
+
);
|
|
513
|
+
};
|
|
514
|
+
printBlock(origin);
|
|
515
|
+
log("Leave this running. Press Ctrl+C to disconnect.");
|
|
516
|
+
watchingForDashboard = true;
|
|
517
|
+
|
|
518
|
+
/* ---- keep the tunnel alive ---- */
|
|
519
|
+
if (tunnel) {
|
|
520
|
+
let attempt = 0;
|
|
521
|
+
const watch = (child) => {
|
|
522
|
+
child.once("exit", async () => {
|
|
523
|
+
if (stopping) return;
|
|
524
|
+
const delay =
|
|
525
|
+
TUNNEL_RESTART_DELAYS_MS[
|
|
526
|
+
Math.min(attempt, TUNNEL_RESTART_DELAYS_MS.length - 1)
|
|
527
|
+
];
|
|
528
|
+
attempt += 1;
|
|
529
|
+
error(
|
|
530
|
+
`The tunnel dropped. Reopening in ${Math.round(delay / 1000)}s …`,
|
|
531
|
+
);
|
|
532
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
533
|
+
if (stopping) return;
|
|
534
|
+
try {
|
|
535
|
+
const next = await openTunnel();
|
|
536
|
+
attempt = 0;
|
|
537
|
+
if (next === origin) {
|
|
538
|
+
log("Tunnel reopened on the same address; nothing to re-paste.");
|
|
539
|
+
} else {
|
|
540
|
+
origin = next;
|
|
541
|
+
printBlock(
|
|
542
|
+
origin,
|
|
543
|
+
"The tunnel address changed. Paste this block again in Settings → Deploy:",
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
watch(tunnel);
|
|
547
|
+
} catch (cause) {
|
|
548
|
+
error(cause instanceof Error ? cause.message : String(cause));
|
|
549
|
+
watch(child);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
};
|
|
553
|
+
watch(tunnel);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
await new Promise((resolve) => runner.once("exit", resolve));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export { originFromOutput };
|