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/lib/tunnel.mjs
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTPS front door for a runner sitting on somebody's laptop.
|
|
3
|
+
*
|
|
4
|
+
* A hosted dashboard can only reach a runner over HTTPS, and a laptop has no
|
|
5
|
+
* public certificate. `cloudflared` solves that in one command and no account:
|
|
6
|
+
* a quick tunnel hands back a throwaway `https://…trycloudflare.com` origin
|
|
7
|
+
* that forwards to a local port.
|
|
8
|
+
*
|
|
9
|
+
* Three ways to get the binary, in the order that asks the member for least:
|
|
10
|
+
*
|
|
11
|
+
* 1. already on PATH, or in this CLI's data directory from a previous run;
|
|
12
|
+
* 2. the `cloudflared` npm package, if the workspace happens to have it;
|
|
13
|
+
* 3. downloaded once from Cloudflare's GitHub releases into the data dir.
|
|
14
|
+
*
|
|
15
|
+
* When all three fail the member is not stuck: `--tunnel-url` accepts an origin
|
|
16
|
+
* from any tunnel they already run (ngrok, a reverse proxy, a Tailscale funnel),
|
|
17
|
+
* and `connect` skips this file entirely.
|
|
18
|
+
*/
|
|
19
|
+
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
20
|
+
import { createWriteStream, existsSync } from "node:fs";
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { pipeline } from "node:stream/promises";
|
|
23
|
+
import { createGunzip } from "node:zlib";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
cloudflaredAssetName,
|
|
28
|
+
dataDir,
|
|
29
|
+
findExecutableOrNull,
|
|
30
|
+
portableCommand,
|
|
31
|
+
} from "./paths.mjs";
|
|
32
|
+
import { findExecutable } from "./prerequisites.mjs";
|
|
33
|
+
|
|
34
|
+
const RELEASE_BASE =
|
|
35
|
+
"https://github.com/cloudflare/cloudflared/releases/latest/download";
|
|
36
|
+
|
|
37
|
+
export const QUICK_TUNNEL_PATTERN =
|
|
38
|
+
/https:\/\/[a-z0-9][a-z0-9-]*\.trycloudflare\.com/i;
|
|
39
|
+
|
|
40
|
+
export function binaryName(platform = process.platform) {
|
|
41
|
+
return platform === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where a downloaded binary is kept between runs. */
|
|
45
|
+
export function cachedBinaryPath(platform = process.platform, env = process.env) {
|
|
46
|
+
return path.join(dataDir(platform, env), "bin", binaryName(platform));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Find cloudflared without downloading anything.
|
|
51
|
+
*
|
|
52
|
+
* Checked in this order because each step is cheaper and less surprising than
|
|
53
|
+
* the next: the member's own install wins over ours, and ours wins over
|
|
54
|
+
* fetching a fresh one.
|
|
55
|
+
*/
|
|
56
|
+
export function locateCloudflared({
|
|
57
|
+
env = process.env,
|
|
58
|
+
platform = process.platform,
|
|
59
|
+
exists = existsSync,
|
|
60
|
+
} = {}) {
|
|
61
|
+
const onPath = findExecutable("cloudflared", { env, platform });
|
|
62
|
+
if (onPath) return { path: onPath, source: "path" };
|
|
63
|
+
|
|
64
|
+
const cached = cachedBinaryPath(platform, env);
|
|
65
|
+
if (exists(cached)) return { path: cached, source: "cache" };
|
|
66
|
+
|
|
67
|
+
// The npm package installs the binary next to itself; if a workspace has it
|
|
68
|
+
// as a dependency there is no reason to fetch a second copy.
|
|
69
|
+
const packaged = findExecutableOrNull(
|
|
70
|
+
path.join(process.cwd(), "node_modules", ".bin", binaryName(platform)),
|
|
71
|
+
exists,
|
|
72
|
+
);
|
|
73
|
+
if (packaged) return { path: packaged, source: "npm" };
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const CLOUDFLARED_MISSING = [
|
|
79
|
+
"cloudflared was not found and could not be downloaded.",
|
|
80
|
+
"",
|
|
81
|
+
"Either install it once:",
|
|
82
|
+
" macOS brew install cloudflared",
|
|
83
|
+
" Linux see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
|
|
84
|
+
" npm npm install -g cloudflared",
|
|
85
|
+
"",
|
|
86
|
+
"Or run your own tunnel and pass its address:",
|
|
87
|
+
" aios-dashboard connect --tunnel-url https://runner.example.com",
|
|
88
|
+
].join("\n");
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fetch the binary for this machine into the data dir.
|
|
92
|
+
*
|
|
93
|
+
* Written to a temporary name and renamed, so an interrupted download never
|
|
94
|
+
* leaves a half-file that later runs would happily try to execute.
|
|
95
|
+
*/
|
|
96
|
+
export async function downloadCloudflared({
|
|
97
|
+
platform = process.platform,
|
|
98
|
+
arch = process.arch,
|
|
99
|
+
env = process.env,
|
|
100
|
+
log = console.log,
|
|
101
|
+
} = {}) {
|
|
102
|
+
const asset = cloudflaredAssetName(platform, arch);
|
|
103
|
+
if (!asset) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`No cloudflared build is published for ${platform}/${arch}. Use --tunnel-url with your own tunnel.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const target = cachedBinaryPath(platform, env);
|
|
109
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
110
|
+
const temporary = `${target}.download-${process.pid}`;
|
|
111
|
+
|
|
112
|
+
log(`Downloading cloudflared for ${platform}/${arch} …`);
|
|
113
|
+
const response = await fetch(`${RELEASE_BASE}/${asset}`, {
|
|
114
|
+
redirect: "follow",
|
|
115
|
+
signal: AbortSignal.timeout(180_000),
|
|
116
|
+
});
|
|
117
|
+
if (!response.ok || !response.body) {
|
|
118
|
+
throw new Error(`cloudflared download failed with HTTP ${response.status}.`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (asset.endsWith(".tgz")) {
|
|
122
|
+
// The macOS asset is a gzipped tar holding one file. Unpacking a single
|
|
123
|
+
// member is cheaper and more predictable than shelling out to tar.
|
|
124
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
125
|
+
const { extractSingleFileTgz } = await import("./zip.mjs");
|
|
126
|
+
await writeFile(temporary, await extractSingleFileTgz(buffer));
|
|
127
|
+
} else {
|
|
128
|
+
await pipeline(response.body, createWriteStream(temporary));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await chmod(temporary, 0o755).catch(() => undefined);
|
|
132
|
+
await rename(temporary, target);
|
|
133
|
+
return target;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function ensureCloudflared({
|
|
137
|
+
env = process.env,
|
|
138
|
+
platform = process.platform,
|
|
139
|
+
arch = process.arch,
|
|
140
|
+
allowDownload = true,
|
|
141
|
+
log = console.log,
|
|
142
|
+
} = {}) {
|
|
143
|
+
const found = locateCloudflared({ env, platform });
|
|
144
|
+
if (found) return found;
|
|
145
|
+
if (!allowDownload) throw new Error(CLOUDFLARED_MISSING);
|
|
146
|
+
try {
|
|
147
|
+
const downloaded = await downloadCloudflared({ platform, arch, env, log });
|
|
148
|
+
return { path: downloaded, source: "download" };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`${CLOUDFLARED_MISSING}\n\n(download error: ${error instanceof Error ? error.message : String(error)})`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The cloudflared invocation for a quick tunnel or a member's named one. */
|
|
157
|
+
export function tunnelArgs({ port, name, hostname: stableHostname }) {
|
|
158
|
+
if (name) {
|
|
159
|
+
const args = ["tunnel", "--url", `http://localhost:${port}`];
|
|
160
|
+
if (stableHostname) args.push("--hostname", stableHostname);
|
|
161
|
+
args.push("run", name);
|
|
162
|
+
return args;
|
|
163
|
+
}
|
|
164
|
+
return [
|
|
165
|
+
"tunnel",
|
|
166
|
+
"--no-autoupdate",
|
|
167
|
+
"--url",
|
|
168
|
+
`http://localhost:${port}`,
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Pull the public origin out of cloudflared's chatter.
|
|
174
|
+
*
|
|
175
|
+
* Quick tunnels announce the hostname on stderr inside a box of `|` characters,
|
|
176
|
+
* so this matches the URL anywhere in the stream rather than parsing lines. A
|
|
177
|
+
* named tunnel never announces one, which is why `--tunnel-name` also wants a
|
|
178
|
+
* hostname.
|
|
179
|
+
*/
|
|
180
|
+
export function originFromOutput(text) {
|
|
181
|
+
const match = String(text ?? "").match(QUICK_TUNNEL_PATTERN);
|
|
182
|
+
return match ? match[0] : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Start a tunnel and resolve once it has an origin.
|
|
187
|
+
*
|
|
188
|
+
* The child is returned alongside the origin so the caller owns its lifetime:
|
|
189
|
+
* `connect` keeps it, watches it, and restarts it if Cloudflare drops the
|
|
190
|
+
* quick tunnel — which they do, without warning, after a few hours.
|
|
191
|
+
*/
|
|
192
|
+
export function startTunnel({
|
|
193
|
+
binary,
|
|
194
|
+
port,
|
|
195
|
+
name = null,
|
|
196
|
+
hostname: stableHostname = null,
|
|
197
|
+
timeoutMs = 60_000,
|
|
198
|
+
onLine = () => {},
|
|
199
|
+
}) {
|
|
200
|
+
const invocation = portableCommand(binary, tunnelArgs({ port, name, hostname: stableHostname }));
|
|
201
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
202
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
203
|
+
shell: false,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
let buffer = "";
|
|
208
|
+
let settled = false;
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
if (settled) return;
|
|
211
|
+
settled = true;
|
|
212
|
+
child.kill("SIGTERM");
|
|
213
|
+
reject(new Error("The tunnel did not report a public address in time."));
|
|
214
|
+
}, timeoutMs);
|
|
215
|
+
|
|
216
|
+
const finish = (origin) => {
|
|
217
|
+
if (settled) return;
|
|
218
|
+
settled = true;
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
resolve({ child, origin });
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const read = (chunk) => {
|
|
224
|
+
const text = chunk.toString("utf8");
|
|
225
|
+
buffer = `${buffer}${text}`.slice(-8_000);
|
|
226
|
+
onLine(text);
|
|
227
|
+
if (stableHostname) {
|
|
228
|
+
finish(`https://${stableHostname}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const origin = originFromOutput(buffer);
|
|
232
|
+
if (origin) finish(origin);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
child.stdout?.on("data", read);
|
|
236
|
+
child.stderr?.on("data", read);
|
|
237
|
+
child.once("error", (error) => {
|
|
238
|
+
if (settled) return;
|
|
239
|
+
settled = true;
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
reject(error);
|
|
242
|
+
});
|
|
243
|
+
child.once("exit", (code) => {
|
|
244
|
+
if (settled) return;
|
|
245
|
+
settled = true;
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
reject(new Error(`cloudflared exited with code ${code} before opening a tunnel.`));
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
package/lib/zip.mjs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmod,
|
|
3
|
+
copyFile,
|
|
4
|
+
cp,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
stat,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { gunzipSync, inflateRawSync } from "node:zlib";
|
|
12
|
+
|
|
13
|
+
const EOCD = 0x06054b50;
|
|
14
|
+
const CENTRAL_HEADER = 0x02014b50;
|
|
15
|
+
const LOCAL_HEADER = 0x04034b50;
|
|
16
|
+
const MAX_EOCD_SEARCH = 65_557;
|
|
17
|
+
export const MAX_ZIP_ENTRIES = 20_000;
|
|
18
|
+
export const MAX_ZIP_ENTRY_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
export const MAX_ZIP_UNCOMPRESSED_BYTES = 512 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
function findEocd(buffer) {
|
|
22
|
+
if (buffer.length < 22) {
|
|
23
|
+
throw new Error("Invalid ZIP: end-of-central-directory record not found.");
|
|
24
|
+
}
|
|
25
|
+
const start = Math.max(0, buffer.length - MAX_EOCD_SEARCH);
|
|
26
|
+
for (let offset = buffer.length - 22; offset >= start; offset -= 1) {
|
|
27
|
+
if (buffer.readUInt32LE(offset) === EOCD) return offset;
|
|
28
|
+
}
|
|
29
|
+
throw new Error("Invalid ZIP: end-of-central-directory record not found.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeEntryPath(name) {
|
|
33
|
+
const normalized = name.replaceAll("\\", "/");
|
|
34
|
+
if (
|
|
35
|
+
!normalized ||
|
|
36
|
+
normalized.includes("\0") ||
|
|
37
|
+
normalized.startsWith("/") ||
|
|
38
|
+
/^[A-Za-z]:\//.test(normalized)
|
|
39
|
+
) {
|
|
40
|
+
throw new Error(`Unsafe ZIP path: ${name}`);
|
|
41
|
+
}
|
|
42
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
43
|
+
if (segments.some((segment) => segment === "..")) {
|
|
44
|
+
throw new Error(`Unsafe ZIP path: ${name}`);
|
|
45
|
+
}
|
|
46
|
+
return segments.join("/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function within(root, target) {
|
|
50
|
+
const relative = path.relative(root, target);
|
|
51
|
+
return (
|
|
52
|
+
relative === "" ||
|
|
53
|
+
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function decodeEntry(buffer, offset) {
|
|
58
|
+
if (offset < 0 || offset + 46 > buffer.length) {
|
|
59
|
+
throw new Error("Invalid ZIP: malformed central directory.");
|
|
60
|
+
}
|
|
61
|
+
if (buffer.readUInt32LE(offset) !== CENTRAL_HEADER) {
|
|
62
|
+
throw new Error("Invalid ZIP: malformed central directory.");
|
|
63
|
+
}
|
|
64
|
+
const flags = buffer.readUInt16LE(offset + 8);
|
|
65
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
66
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
67
|
+
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
|
68
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
69
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
70
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
71
|
+
const externalAttributes = buffer.readUInt32LE(offset + 38);
|
|
72
|
+
const localOffset = buffer.readUInt32LE(offset + 42);
|
|
73
|
+
const nameStart = offset + 46;
|
|
74
|
+
const next = nameStart + nameLength + extraLength + commentLength;
|
|
75
|
+
if (next > buffer.length) {
|
|
76
|
+
throw new Error("Invalid ZIP: malformed central directory.");
|
|
77
|
+
}
|
|
78
|
+
const name = buffer
|
|
79
|
+
.subarray(nameStart, nameStart + nameLength)
|
|
80
|
+
.toString("utf8");
|
|
81
|
+
return {
|
|
82
|
+
flags,
|
|
83
|
+
method,
|
|
84
|
+
compressedSize,
|
|
85
|
+
uncompressedSize,
|
|
86
|
+
externalAttributes,
|
|
87
|
+
localOffset,
|
|
88
|
+
name,
|
|
89
|
+
next,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function compressedEntryData(buffer, entry) {
|
|
94
|
+
const offset = entry.localOffset;
|
|
95
|
+
if (offset < 0 || offset + 30 > buffer.length) {
|
|
96
|
+
throw new Error(`Invalid ZIP local header for ${entry.name}`);
|
|
97
|
+
}
|
|
98
|
+
if (buffer.readUInt32LE(offset) !== LOCAL_HEADER) {
|
|
99
|
+
throw new Error(`Invalid ZIP local header for ${entry.name}`);
|
|
100
|
+
}
|
|
101
|
+
const nameLength = buffer.readUInt16LE(offset + 26);
|
|
102
|
+
const extraLength = buffer.readUInt16LE(offset + 28);
|
|
103
|
+
const start = offset + 30 + nameLength + extraLength;
|
|
104
|
+
const end = start + entry.compressedSize;
|
|
105
|
+
if (start > buffer.length || end > buffer.length) {
|
|
106
|
+
throw new Error(`Invalid ZIP compressed data for ${entry.name}`);
|
|
107
|
+
}
|
|
108
|
+
return buffer.subarray(start, end);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function entryData(entry) {
|
|
112
|
+
const compressed = entry.compressed;
|
|
113
|
+
let data;
|
|
114
|
+
if (entry.method === 0) data = compressed;
|
|
115
|
+
else if (entry.method === 8) {
|
|
116
|
+
data = inflateRawSync(compressed, {
|
|
117
|
+
maxOutputLength: Math.max(1, entry.uncompressedSize),
|
|
118
|
+
});
|
|
119
|
+
} else throw new Error(`Unsupported ZIP compression method ${entry.method}.`);
|
|
120
|
+
if (data.length !== entry.uncompressedSize) {
|
|
121
|
+
throw new Error(`Invalid ZIP size for ${entry.name}`);
|
|
122
|
+
}
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function extractZip(buffer, destination) {
|
|
127
|
+
const root = path.resolve(destination);
|
|
128
|
+
const eocd = findEocd(buffer);
|
|
129
|
+
const entryCount = buffer.readUInt16LE(eocd + 10);
|
|
130
|
+
if (entryCount > MAX_ZIP_ENTRIES) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`ZIP contains too many entries (limit ${MAX_ZIP_ENTRIES}).`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const centralSize = buffer.readUInt32LE(eocd + 12);
|
|
136
|
+
let offset = buffer.readUInt32LE(eocd + 16);
|
|
137
|
+
const centralEnd = offset + centralSize;
|
|
138
|
+
if (offset > eocd || centralEnd > eocd || centralEnd < offset) {
|
|
139
|
+
throw new Error("Invalid ZIP: central directory is outside the archive.");
|
|
140
|
+
}
|
|
141
|
+
const entries = [];
|
|
142
|
+
let totalUncompressedBytes = 0;
|
|
143
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
144
|
+
const entry = decodeEntry(buffer, offset);
|
|
145
|
+
offset = entry.next;
|
|
146
|
+
if (offset > centralEnd) {
|
|
147
|
+
throw new Error("Invalid ZIP: malformed central directory size.");
|
|
148
|
+
}
|
|
149
|
+
if (entry.flags & 0x1) {
|
|
150
|
+
throw new Error("Encrypted ZIP entries are not supported.");
|
|
151
|
+
}
|
|
152
|
+
if (![0, 8].includes(entry.method)) {
|
|
153
|
+
throw new Error(`Unsupported ZIP compression method ${entry.method}.`);
|
|
154
|
+
}
|
|
155
|
+
if (entry.uncompressedSize > MAX_ZIP_ENTRY_BYTES) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`ZIP entry exceeds the ${MAX_ZIP_ENTRY_BYTES} byte uncompressed limit: ${entry.name}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
totalUncompressedBytes += entry.uncompressedSize;
|
|
161
|
+
if (totalUncompressedBytes > MAX_ZIP_UNCOMPRESSED_BYTES) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`ZIP exceeds the ${MAX_ZIP_UNCOMPRESSED_BYTES} byte total uncompressed limit.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
entries.push({
|
|
167
|
+
...entry,
|
|
168
|
+
compressed: compressedEntryData(buffer, entry),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (offset !== centralEnd) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
"Invalid ZIP: central directory entry count does not match its size.",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
await mkdir(root, { recursive: true });
|
|
178
|
+
const deferredLinks = [];
|
|
179
|
+
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
const relative = safeEntryPath(entry.name);
|
|
182
|
+
if (!relative) continue;
|
|
183
|
+
const target = path.resolve(root, relative);
|
|
184
|
+
if (!within(root, target))
|
|
185
|
+
throw new Error(`Unsafe ZIP path: ${entry.name}`);
|
|
186
|
+
const mode = (entry.externalAttributes >>> 16) & 0xffff;
|
|
187
|
+
const fileType = mode & 0xf000;
|
|
188
|
+
const isDirectory = entry.name.endsWith("/") || fileType === 0x4000;
|
|
189
|
+
if (isDirectory) {
|
|
190
|
+
await mkdir(target, { recursive: true });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const data = entryData(entry);
|
|
195
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
196
|
+
if (fileType === 0xa000) {
|
|
197
|
+
deferredLinks.push({ target, link: data.toString("utf8") });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
await writeFile(target, data);
|
|
201
|
+
if (mode & 0o111) await chmod(target, 0o755);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// GitHub source archives preserve Git symlinks. Dereference them into ordinary
|
|
205
|
+
// files/directories so the installed dashboard also works on Windows without
|
|
206
|
+
// Developer Mode or elevated symlink privileges.
|
|
207
|
+
for (const { target, link } of deferredLinks) {
|
|
208
|
+
const source = path.resolve(path.dirname(target), link);
|
|
209
|
+
if (!within(root, source)) throw new Error(`Unsafe ZIP symlink: ${link}`);
|
|
210
|
+
const sourceStat = await stat(source);
|
|
211
|
+
if (sourceStat.isDirectory()) await cp(source, target, { recursive: true });
|
|
212
|
+
else await copyFile(source, target);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function findSourceRoot(extracted) {
|
|
217
|
+
const candidates = [path.resolve(extracted)];
|
|
218
|
+
while (candidates.length > 0) {
|
|
219
|
+
const candidate = candidates.shift();
|
|
220
|
+
try {
|
|
221
|
+
const packageJson = JSON.parse(
|
|
222
|
+
await readFile(path.join(candidate, "package.json"), "utf8"),
|
|
223
|
+
);
|
|
224
|
+
if (
|
|
225
|
+
packageJson &&
|
|
226
|
+
(await stat(path.join(candidate, "app"))).isDirectory()
|
|
227
|
+
) {
|
|
228
|
+
return candidate;
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// This directory is not the dashboard source root.
|
|
232
|
+
}
|
|
233
|
+
const relativeDepth = path
|
|
234
|
+
.relative(extracted, candidate)
|
|
235
|
+
.split(path.sep)
|
|
236
|
+
.filter(Boolean).length;
|
|
237
|
+
if (relativeDepth >= 2) continue;
|
|
238
|
+
const { readdir } = await import("node:fs/promises");
|
|
239
|
+
for (const entry of await readdir(candidate, { withFileTypes: true })) {
|
|
240
|
+
if (entry.isDirectory())
|
|
241
|
+
candidates.push(path.join(candidate, entry.name));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
throw new Error(
|
|
245
|
+
"Downloaded archive does not contain an AIOS Dashboard source tree.",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Unpack the single file inside a gzipped tar.
|
|
251
|
+
*
|
|
252
|
+
* Cloudflare ships the macOS cloudflared build as a `.tgz` holding one
|
|
253
|
+
* executable. A full tar reader is not worth carrying for that, so this walks
|
|
254
|
+
* the 512-byte headers, finds the first regular file, and returns its bytes.
|
|
255
|
+
*/
|
|
256
|
+
export async function extractSingleFileTgz(buffer) {
|
|
257
|
+
const tar = gunzipSync(buffer);
|
|
258
|
+
let offset = 0;
|
|
259
|
+
while (offset + 512 <= tar.length) {
|
|
260
|
+
const header = tar.subarray(offset, offset + 512);
|
|
261
|
+
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
|
|
262
|
+
if (!name) break;
|
|
263
|
+
const sizeField = header
|
|
264
|
+
.subarray(124, 136)
|
|
265
|
+
.toString("utf8")
|
|
266
|
+
.replace(/[\0 ]+$/, "");
|
|
267
|
+
const size = Number.parseInt(sizeField || "0", 8);
|
|
268
|
+
if (!Number.isInteger(size) || size < 0) {
|
|
269
|
+
throw new Error("Unreadable tar header in the cloudflared archive.");
|
|
270
|
+
}
|
|
271
|
+
const type = header.subarray(156, 157).toString("utf8");
|
|
272
|
+
const start = offset + 512;
|
|
273
|
+
if (type === "0" || type === "\0") return tar.subarray(start, start + size);
|
|
274
|
+
offset = start + Math.ceil(size / 512) * 512;
|
|
275
|
+
}
|
|
276
|
+
throw new Error("The cloudflared archive contained no file.");
|
|
277
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aios-dashboard",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Install the AIOS Dashboard into an AIOS workspace.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"aios",
|
|
7
|
+
"dashboard",
|
|
8
|
+
"installer",
|
|
9
|
+
"local-first"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/niknorf/aios-dashboard.git",
|
|
15
|
+
"directory": "cli"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"aios-dashboard": "bin/aios-dashboard.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin",
|
|
22
|
+
"lib",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test test/*.test.mjs"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.22.0"
|
|
35
|
+
}
|
|
36
|
+
}
|