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.
@@ -0,0 +1,183 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, readFileSync } from "node:fs";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+
6
+ import { parseEnv } from "./env.mjs";
7
+ import { executableNames, pathEntries, portableCommand } from "./paths.mjs";
8
+
9
+ export const MINIMUM_NODE_VERSION = "22.22.0";
10
+ export const PNPM_VERSION = "10.30.3";
11
+
12
+ export function nodeVersion(version = process.versions.node) {
13
+ const match = String(version || "").match(/^(?:v)?(\d+)\.(\d+)\.(\d+)/);
14
+ return match ? match.slice(1).map(Number) : null;
15
+ }
16
+
17
+ export function nodeMajor(version = process.versions.node) {
18
+ return nodeVersion(version)?.[0] ?? Number.NaN;
19
+ }
20
+
21
+ export function supportsNode(
22
+ version = process.versions.node,
23
+ minimum = MINIMUM_NODE_VERSION,
24
+ ) {
25
+ const current = nodeVersion(version);
26
+ const required = nodeVersion(minimum);
27
+ if (!current || !required) return false;
28
+ for (let index = 0; index < 3; index += 1) {
29
+ if (current[index] > required[index]) return true;
30
+ if (current[index] < required[index]) return false;
31
+ }
32
+ return true;
33
+ }
34
+
35
+ export function findExecutable(
36
+ command,
37
+ {
38
+ env = process.env,
39
+ platform = process.platform,
40
+ pathApi = platform === "win32" ? path.win32 : path,
41
+ access = accessSync,
42
+ } = {},
43
+ ) {
44
+ const names = executableNames(command, platform, env.PATHEXT);
45
+ const dirs = pathEntries(env.PATH, pathApi);
46
+ for (const dir of dirs) {
47
+ for (const name of names) {
48
+ const candidate = pathApi.join(dir, name);
49
+ try {
50
+ access(
51
+ candidate,
52
+ platform === "win32" ? constants.F_OK : constants.X_OK,
53
+ );
54
+ return candidate;
55
+ } catch {
56
+ // Try the next PATH entry.
57
+ }
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ export function workspacePresent(workspace) {
64
+ return (
65
+ existsSync(path.join(workspace, "CLAUDE.md")) &&
66
+ existsSync(path.join(workspace, "context"))
67
+ );
68
+ }
69
+
70
+ export function workspaceDatabaseUrl(workspace, env = process.env) {
71
+ if (env.AIOS_DASHBOARD_DB_URL?.trim()) {
72
+ return env.AIOS_DASHBOARD_DB_URL.trim();
73
+ }
74
+ const envPath = path.join(workspace, ".env");
75
+ if (!existsSync(envPath)) return null;
76
+ const parsed = parseEnv(readFileSync(envPath, "utf8"));
77
+ return parsed.AIOS_DASHBOARD_DB_URL?.trim() || null;
78
+ }
79
+
80
+ export function choosePackageManager({
81
+ env = process.env,
82
+ run = spawnSync,
83
+ } = {}) {
84
+ const pnpm = findExecutable("pnpm", { env });
85
+ if (pnpm) {
86
+ const invocation = portableCommand(
87
+ pnpm,
88
+ ["--version"],
89
+ process.platform,
90
+ env,
91
+ );
92
+ const version = run(invocation.command, invocation.args, {
93
+ encoding: "utf8",
94
+ env,
95
+ stdio: ["ignore", "pipe", "ignore"],
96
+ shell: false,
97
+ });
98
+ if (version.status === 0 && version.stdout.trim() === PNPM_VERSION) {
99
+ return {
100
+ name: "pnpm",
101
+ version: PNPM_VERSION,
102
+ command: pnpm,
103
+ commandArgs: [],
104
+ installArgs: ["install", "--frozen-lockfile", "--prod=false"],
105
+ };
106
+ }
107
+ }
108
+
109
+ const corepack = findExecutable("corepack", { env });
110
+ if (corepack) {
111
+ return {
112
+ name: "pnpm",
113
+ version: PNPM_VERSION,
114
+ command: corepack,
115
+ commandArgs: [`pnpm@${PNPM_VERSION}`],
116
+ installArgs: ["install", "--frozen-lockfile", "--prod=false"],
117
+ };
118
+ }
119
+
120
+ const npm = findExecutable("npm", { env });
121
+ if (npm) {
122
+ return {
123
+ name: "pnpm",
124
+ version: PNPM_VERSION,
125
+ command: npm,
126
+ commandArgs: [
127
+ "exec",
128
+ "--yes",
129
+ `--package=pnpm@${PNPM_VERSION}`,
130
+ "--",
131
+ "pnpm",
132
+ ],
133
+ installArgs: ["install", "--frozen-lockfile", "--prod=false"],
134
+ };
135
+ }
136
+ throw new Error(
137
+ `pnpm ${PNPM_VERSION} is required. Install pnpm, Corepack, or npm so the installer can invoke the pinned version.`,
138
+ );
139
+ }
140
+
141
+ export function missingNativeBuildTools({
142
+ platform = process.platform,
143
+ env = process.env,
144
+ } = {}) {
145
+ if (platform !== "linux") return [];
146
+ const missing = [];
147
+ if (!findExecutable("python3", { env })) missing.push("Python 3");
148
+ if (!findExecutable("make", { env })) missing.push("make");
149
+ if (
150
+ !findExecutable("c++", { env }) &&
151
+ !findExecutable("g++", { env }) &&
152
+ !findExecutable("clang++", { env })
153
+ ) {
154
+ missing.push("a C/C++ compiler");
155
+ }
156
+ return missing;
157
+ }
158
+
159
+ export async function databaseReachable(value, timeoutMs = 4_000) {
160
+ if (!value) return false;
161
+ let url;
162
+ try {
163
+ url = new URL(value);
164
+ } catch {
165
+ return false;
166
+ }
167
+ if (!new Set(["postgres:", "postgresql:"]).has(url.protocol)) return false;
168
+ const port = Number(url.port || 5432);
169
+ if (!url.hostname || !Number.isInteger(port)) return false;
170
+
171
+ return new Promise((resolve) => {
172
+ const socket = net.createConnection({ host: url.hostname, port });
173
+ const done = (result) => {
174
+ socket.removeAllListeners();
175
+ socket.destroy();
176
+ resolve(result);
177
+ };
178
+ socket.setTimeout(timeoutMs);
179
+ socket.once("connect", () => done(true));
180
+ socket.once("timeout", () => done(false));
181
+ socket.once("error", () => done(false));
182
+ });
183
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `--daemon`: keep the runner connected across reboots.
3
+ *
4
+ * A user-level service, never a system one. The runner needs the member's own
5
+ * logged-in `claude`, their home directory and their workspace — running it as
6
+ * root or as another user would either fail or, worse, half-work.
7
+ *
8
+ * The unit is written and enabled, then the exact commands to inspect or
9
+ * remove it are printed. Nothing here touches sudo.
10
+ */
11
+ import { mkdir, writeFile } from "node:fs/promises";
12
+ import { spawnSync } from "node:child_process";
13
+ import path from "node:path";
14
+
15
+ import { dataDir } from "./paths.mjs";
16
+
17
+ export const SERVICE_NAME = "aios-dashboard-connect";
18
+ export const LAUNCHD_LABEL = "ai.aios.dashboard.connect";
19
+
20
+ /** The `connect` invocation the service repeats, minus `--daemon` itself. */
21
+ export function serviceArguments({ workspace, port, options = {} }) {
22
+ const args = ["connect", "--dir", workspace, "--port", String(port)];
23
+ if (options.tunnelUrl) args.push("--tunnel-url", options.tunnelUrl);
24
+ if (options.tunnelName) args.push("--tunnel-name", options.tunnelName);
25
+ if (options.tunnelHostname) args.push("--tunnel-hostname", options.tunnelHostname);
26
+ if (options.noDownload) args.push("--no-download");
27
+ return args;
28
+ }
29
+
30
+ export function systemdUnit({ execPath, script, args, workspace, logDir }) {
31
+ const command = [execPath, script, ...args]
32
+ .map((part) => (/[\s"']/.test(part) ? JSON.stringify(part) : part))
33
+ .join(" ");
34
+ return `[Unit]
35
+ Description=AIOS Dashboard — connect this computer to a hosted dashboard
36
+ After=network-online.target
37
+ Wants=network-online.target
38
+
39
+ [Service]
40
+ Type=simple
41
+ WorkingDirectory=${workspace}
42
+ ExecStart=${command}
43
+ Restart=always
44
+ RestartSec=10
45
+ StandardOutput=append:${path.join(logDir, "connect.log")}
46
+ StandardError=append:${path.join(logDir, "connect.log")}
47
+
48
+ [Install]
49
+ WantedBy=default.target
50
+ `;
51
+ }
52
+
53
+ export function launchdPlist({ execPath, script, args, workspace, logDir }) {
54
+ const programArguments = [execPath, script, ...args]
55
+ .map((part) => ` <string>${part.replace(/&/g, "&amp;").replace(/</g, "&lt;")}</string>`)
56
+ .join("\n");
57
+ return `<?xml version="1.0" encoding="UTF-8"?>
58
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
59
+ <plist version="1.0">
60
+ <dict>
61
+ <key>Label</key><string>${LAUNCHD_LABEL}</string>
62
+ <key>ProgramArguments</key>
63
+ <array>
64
+ ${programArguments}
65
+ </array>
66
+ <key>WorkingDirectory</key><string>${workspace}</string>
67
+ <key>RunAtLoad</key><true/>
68
+ <key>KeepAlive</key><true/>
69
+ <key>StandardOutPath</key><string>${path.join(logDir, "connect.log")}</string>
70
+ <key>StandardErrorPath</key><string>${path.join(logDir, "connect.log")}</string>
71
+ </dict>
72
+ </plist>
73
+ `;
74
+ }
75
+
76
+ export async function installConnectService({
77
+ workspace,
78
+ port,
79
+ options = {},
80
+ log = console.log,
81
+ platform = process.platform,
82
+ env = process.env,
83
+ execPath = process.execPath,
84
+ script = process.argv[1],
85
+ run = spawnSync,
86
+ }) {
87
+ const logDir = path.join(dataDir(platform, env), "logs");
88
+ await mkdir(logDir, { recursive: true });
89
+ const args = serviceArguments({ workspace, port, options });
90
+
91
+ if (platform === "linux") {
92
+ const unitDir = path.join(
93
+ env.XDG_CONFIG_HOME || path.join(env.HOME || ".", ".config"),
94
+ "systemd",
95
+ "user",
96
+ );
97
+ await mkdir(unitDir, { recursive: true });
98
+ const unitPath = path.join(unitDir, `${SERVICE_NAME}.service`);
99
+ await writeFile(unitPath, systemdUnit({ execPath, script, args, workspace, logDir }));
100
+ const result = run("systemctl", ["--user", "enable", "--now", SERVICE_NAME], {
101
+ stdio: "inherit",
102
+ shell: false,
103
+ });
104
+ log(`\nService installed at ${unitPath}`);
105
+ if (result.status !== 0) {
106
+ log("systemctl could not enable it here. Run this once you have a user session:");
107
+ log(` systemctl --user enable --now ${SERVICE_NAME}`);
108
+ }
109
+ log(` Status systemctl --user status ${SERVICE_NAME}`);
110
+ log(` Logs tail -f ${path.join(logDir, "connect.log")}`);
111
+ log(` Remove systemctl --user disable --now ${SERVICE_NAME} && rm ${unitPath}`);
112
+ log(
113
+ "\nThe pairing block is in the log. Reboots keep the pairing code; a quick tunnel gets a new address, so re-paste after one.",
114
+ );
115
+ return { path: unitPath };
116
+ }
117
+
118
+ if (platform === "darwin") {
119
+ const agentDir = path.join(env.HOME || ".", "Library", "LaunchAgents");
120
+ await mkdir(agentDir, { recursive: true });
121
+ const plistPath = path.join(agentDir, `${LAUNCHD_LABEL}.plist`);
122
+ await writeFile(plistPath, launchdPlist({ execPath, script, args, workspace, logDir }));
123
+ run("launchctl", ["unload", plistPath], { stdio: "ignore", shell: false });
124
+ const result = run("launchctl", ["load", plistPath], { stdio: "inherit", shell: false });
125
+ log(`\nService installed at ${plistPath}`);
126
+ if (result.status !== 0) {
127
+ log("launchctl could not load it here. Run this once you are signed in:");
128
+ log(` launchctl load ${plistPath}`);
129
+ }
130
+ log(` Logs tail -f ${path.join(logDir, "connect.log")}`);
131
+ log(` Remove launchctl unload ${plistPath} && rm ${plistPath}`);
132
+ log(
133
+ "\nThe pairing block is in the log. Reboots keep the pairing code; a quick tunnel gets a new address, so re-paste after one.",
134
+ );
135
+ return { path: plistPath };
136
+ }
137
+
138
+ throw new Error(
139
+ `--daemon supports macOS and Linux. On ${platform}, run \`aios-dashboard connect\` in a terminal you leave open.`,
140
+ );
141
+ }
package/lib/source.mjs ADDED
@@ -0,0 +1,245 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash, timingSafeEqual } from "node:crypto";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const REPOSITORY = "niknorf/aios-dashboard";
7
+ export const MAX_SOURCE_BYTES = 250 * 1024 * 1024;
8
+ export const SOURCE_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1_000;
9
+
10
+ export function releaseAssetUrl(version) {
11
+ return `https://github.com/${REPOSITORY}/releases/download/v${version}/aios-dashboard-${version}.zip`;
12
+ }
13
+
14
+ export function refZipUrl(ref) {
15
+ const encoded = String(ref)
16
+ .split("/")
17
+ .map((segment) => encodeURIComponent(segment))
18
+ .join("/");
19
+ return `https://codeload.github.com/${REPOSITORY}/zip/${encoded}`;
20
+ }
21
+
22
+ function githubToken(env = process.env) {
23
+ const explicit = env.GH_TOKEN || env.GITHUB_TOKEN;
24
+ if (explicit?.trim()) return explicit.trim();
25
+ const result = spawnSync("gh", ["auth", "token"], {
26
+ encoding: "utf8",
27
+ stdio: ["ignore", "pipe", "ignore"],
28
+ shell: false,
29
+ });
30
+ return result.status === 0 ? result.stdout.trim() : "";
31
+ }
32
+
33
+ export function redactSource(value) {
34
+ if (!/^https?:\/\//i.test(value)) return value;
35
+ try {
36
+ const url = new URL(value);
37
+ return `${url.origin}${url.pathname}`;
38
+ } catch {
39
+ return "remote source";
40
+ }
41
+ }
42
+
43
+ export function normalizeSha256(value) {
44
+ const normalized = String(value || "")
45
+ .trim()
46
+ .toLowerCase();
47
+ if (!/^[a-f0-9]{64}$/.test(normalized)) {
48
+ throw new Error(
49
+ "--source-sha256 must be exactly 64 hexadecimal characters.",
50
+ );
51
+ }
52
+ return normalized;
53
+ }
54
+
55
+ export function sha256(buffer) {
56
+ return createHash("sha256").update(buffer).digest("hex");
57
+ }
58
+
59
+ export function verifySourceSha256(buffer, expected) {
60
+ const normalized = normalizeSha256(expected);
61
+ const actual = sha256(buffer);
62
+ if (
63
+ !timingSafeEqual(Buffer.from(actual, "hex"), Buffer.from(normalized, "hex"))
64
+ ) {
65
+ throw new Error(
66
+ `Dashboard source failed SHA-256 verification (expected ${normalized}, received ${actual}).`,
67
+ );
68
+ }
69
+ return actual;
70
+ }
71
+
72
+ export async function fetchBuffer(
73
+ input,
74
+ {
75
+ token = "",
76
+ sameOriginRedirectsOnly = false,
77
+ maxRedirects = 5,
78
+ timeoutMs = SOURCE_DOWNLOAD_TIMEOUT_MS,
79
+ fetchImpl = fetch,
80
+ } = {},
81
+ ) {
82
+ let original;
83
+ try {
84
+ original = new URL(input);
85
+ } catch {
86
+ throw new Error(`Invalid Dashboard source URL (${redactSource(input)}).`);
87
+ }
88
+ let current = original;
89
+ const deadline = Date.now() + timeoutMs;
90
+ for (let redirect = 0; redirect <= maxRedirects; redirect += 1) {
91
+ const sameOrigin = current.origin === original.origin;
92
+ try {
93
+ const remaining = deadline - Date.now();
94
+ if (remaining <= 0) throw new DOMException("Timed out", "TimeoutError");
95
+ const response = await fetchImpl(current, {
96
+ redirect: "manual",
97
+ signal: AbortSignal.timeout(remaining),
98
+ headers: {
99
+ Accept: "application/octet-stream",
100
+ "User-Agent": "aios-dashboard-installer",
101
+ // Never forward a GitHub credential to a release CDN (or any other
102
+ // origin). Signed MCP URLs carry their credential in the URL itself.
103
+ ...(token && sameOrigin ? { Authorization: `Bearer ${token}` } : {}),
104
+ },
105
+ });
106
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
107
+ const location = response.headers.get("location");
108
+ if (!location) {
109
+ throw new Error(
110
+ `Download redirect had no location from ${redactSource(current.href)}`,
111
+ );
112
+ }
113
+ let next;
114
+ try {
115
+ next = new URL(location, current);
116
+ } catch {
117
+ throw new Error(
118
+ `Download returned an invalid redirect from ${redactSource(current.href)}.`,
119
+ );
120
+ }
121
+ if (sameOriginRedirectsOnly && next.origin !== original.origin) {
122
+ throw new Error(
123
+ `Dashboard source refused a redirect to a different origin (${next.origin}).`,
124
+ );
125
+ }
126
+ current = next;
127
+ continue;
128
+ }
129
+ if (!response.ok) {
130
+ throw new Error(
131
+ `Download failed (${response.status}) from ${redactSource(current.href)}`,
132
+ );
133
+ }
134
+ const declaredSize = Number(response.headers.get("content-length"));
135
+ if (Number.isFinite(declaredSize) && declaredSize > MAX_SOURCE_BYTES) {
136
+ await response.body?.cancel();
137
+ throw new Error(
138
+ `Dashboard source exceeds the ${MAX_SOURCE_BYTES} byte download limit (${redactSource(current.href)}).`,
139
+ );
140
+ }
141
+ if (!response.body)
142
+ throw new Error(
143
+ `Download returned no body from ${redactSource(current.href)}`,
144
+ );
145
+ const chunks = [];
146
+ let bytes = 0;
147
+ for await (const chunk of response.body) {
148
+ const buffer = Buffer.from(chunk);
149
+ bytes += buffer.length;
150
+ if (bytes > MAX_SOURCE_BYTES) {
151
+ throw new Error(
152
+ `Dashboard source exceeds the ${MAX_SOURCE_BYTES} byte download limit (${redactSource(current.href)}).`,
153
+ );
154
+ }
155
+ chunks.push(buffer);
156
+ }
157
+ return Buffer.concat(chunks, bytes);
158
+ } catch (error) {
159
+ if (
160
+ error?.name === "TimeoutError" ||
161
+ (error?.name === "AbortError" && Date.now() >= deadline)
162
+ ) {
163
+ throw new Error(
164
+ `Dashboard source download timed out after ${Math.ceil(timeoutMs / 1_000)} seconds (${redactSource(original.href)}).`,
165
+ );
166
+ }
167
+ throw error;
168
+ }
169
+ }
170
+ throw new Error(
171
+ `Too many redirects while downloading ${redactSource(original.href)}`,
172
+ );
173
+ }
174
+
175
+ export async function downloadSource({
176
+ version,
177
+ ref,
178
+ source,
179
+ sourceSha256,
180
+ env = process.env,
181
+ }) {
182
+ if (source) {
183
+ if (!sourceSha256) {
184
+ throw new Error(
185
+ "--source requires --source-sha256 so the artifact can be verified.",
186
+ );
187
+ }
188
+ let buffer;
189
+ if (/^https?:\/\//i.test(source)) {
190
+ buffer = await fetchBuffer(source, { sameOriginRedirectsOnly: true });
191
+ } else if (source.startsWith("file:")) {
192
+ const file = fileURLToPath(source);
193
+ if ((await stat(file)).size > MAX_SOURCE_BYTES) {
194
+ throw new Error(
195
+ `Dashboard source exceeds the ${MAX_SOURCE_BYTES} byte download limit.`,
196
+ );
197
+ }
198
+ buffer = await readFile(file);
199
+ } else {
200
+ if ((await stat(source)).size > MAX_SOURCE_BYTES) {
201
+ throw new Error(
202
+ `Dashboard source exceeds the ${MAX_SOURCE_BYTES} byte download limit.`,
203
+ );
204
+ }
205
+ buffer = await readFile(source);
206
+ }
207
+ verifySourceSha256(buffer, sourceSha256);
208
+ return buffer;
209
+ }
210
+
211
+ const token = githubToken(env);
212
+ if (ref) {
213
+ try {
214
+ return await fetchBuffer(refZipUrl(ref), { token });
215
+ } catch (error) {
216
+ if (!token) throw error;
217
+ const api = `https://api.github.com/repos/${REPOSITORY}/zipball/${encodeURIComponent(ref)}`;
218
+ return fetchBuffer(api, { token });
219
+ }
220
+ }
221
+
222
+ if (token) {
223
+ const tag = `v${version}`;
224
+ const metadataResponse = await fetch(
225
+ `https://api.github.com/repos/${REPOSITORY}/releases/tags/${tag}`,
226
+ {
227
+ headers: {
228
+ Accept: "application/vnd.github+json",
229
+ Authorization: `Bearer ${token}`,
230
+ "User-Agent": "aios-dashboard-installer",
231
+ "X-GitHub-Api-Version": "2022-11-28",
232
+ },
233
+ },
234
+ );
235
+ if (metadataResponse.ok) {
236
+ const release = await metadataResponse.json();
237
+ const expected = `aios-dashboard-${version}.zip`;
238
+ const asset = release.assets?.find(
239
+ (candidate) => candidate.name === expected,
240
+ );
241
+ if (asset?.url) return fetchBuffer(asset.url, { token });
242
+ }
243
+ }
244
+ return fetchBuffer(releaseAssetUrl(version));
245
+ }