@tsconfigdotjson/vibe-os 0.0.0-stage → 0.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +988 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +857 -0
- package/server/config.ts +385 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +977 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +655 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/setup.ts +1151 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Compiles vibe-os into standalone executables.
|
|
3
|
+
//
|
|
4
|
+
// The output needs no Bun, no Node and no npm on the target machine — the whole
|
|
5
|
+
// web app, including the ~20MB ssh.wasm and its precompressed variants, is
|
|
6
|
+
// embedded. That makes the VPS install "download one file and run it".
|
|
7
|
+
|
|
8
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
const ROOT = path.resolve(import.meta.dir, "..");
|
|
12
|
+
const OUT = path.join(ROOT, "dist", "bin");
|
|
13
|
+
const ENTRY = path.join(ROOT, "server", "main.ts");
|
|
14
|
+
|
|
15
|
+
const TARGETS = [
|
|
16
|
+
{ target: "bun-linux-x64", name: "vibe-os-linux-x64" },
|
|
17
|
+
{ target: "bun-linux-arm64", name: "vibe-os-linux-arm64" },
|
|
18
|
+
{ target: "bun-darwin-arm64", name: "vibe-os-darwin-arm64" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const only = process.argv.slice(2).filter((a) => !a.startsWith("-"));
|
|
22
|
+
const selected =
|
|
23
|
+
only.length > 0
|
|
24
|
+
? TARGETS.filter((t) => only.some((o) => t.target.includes(o)))
|
|
25
|
+
: TARGETS;
|
|
26
|
+
|
|
27
|
+
if (selected.length === 0) {
|
|
28
|
+
console.error(
|
|
29
|
+
`no target matched. known: ${TARGETS.map((t) => t.target).join(", ")}`,
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await mkdir(OUT, { recursive: true });
|
|
35
|
+
|
|
36
|
+
for (const { target, name } of selected) {
|
|
37
|
+
const outfile = path.join(OUT, name);
|
|
38
|
+
console.log(`vibe-os: compiling ${target}…`);
|
|
39
|
+
const proc = Bun.spawn(
|
|
40
|
+
[
|
|
41
|
+
"bun",
|
|
42
|
+
"build",
|
|
43
|
+
"--compile",
|
|
44
|
+
`--target=${target}`,
|
|
45
|
+
// Stack traces would point at bundled output anyway, and the server code
|
|
46
|
+
// is a rounding error next to the embedded wasm.
|
|
47
|
+
"--minify",
|
|
48
|
+
"--sourcemap=none",
|
|
49
|
+
ENTRY,
|
|
50
|
+
"--outfile",
|
|
51
|
+
outfile,
|
|
52
|
+
],
|
|
53
|
+
{ cwd: ROOT, stdout: "inherit", stderr: "inherit" },
|
|
54
|
+
);
|
|
55
|
+
const code = await proc.exited;
|
|
56
|
+
if (code !== 0) {
|
|
57
|
+
console.error(`vibe-os: compiling ${target} failed`);
|
|
58
|
+
process.exit(code);
|
|
59
|
+
}
|
|
60
|
+
const size = (await stat(outfile)).size;
|
|
61
|
+
console.log(`vibe-os: ${name} — ${(size / 1024 / 1024).toFixed(1)} MB`);
|
|
62
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Precompresses large static assets after `vite build`.
|
|
4
|
+
//
|
|
5
|
+
// ssh.wasm is ~20MB and every first-time visitor pays for it. Compressing at
|
|
6
|
+
// build time (rather than per-request) lets the server hand out a ~5MB body
|
|
7
|
+
// with zero CPU cost at serve time.
|
|
8
|
+
|
|
9
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { brotliCompress, constants, gzip } from "node:zlib";
|
|
14
|
+
import { walk } from "./lib/walk.ts";
|
|
15
|
+
|
|
16
|
+
const br = promisify(brotliCompress);
|
|
17
|
+
const gz = promisify(gzip);
|
|
18
|
+
|
|
19
|
+
const WEB = path.resolve(
|
|
20
|
+
fileURLToPath(new URL("../dist/web", import.meta.url)),
|
|
21
|
+
);
|
|
22
|
+
const COMPRESSIBLE = /\.(wasm|js|css|html|json|svg)$/;
|
|
23
|
+
const MIN_SIZE = 4096;
|
|
24
|
+
|
|
25
|
+
const t0 = Date.now();
|
|
26
|
+
let count = 0;
|
|
27
|
+
let saved = 0;
|
|
28
|
+
|
|
29
|
+
for await (const file of walk(WEB)) {
|
|
30
|
+
if (!COMPRESSIBLE.test(file) || /\.(br|gz)$/.test(file)) continue;
|
|
31
|
+
const size = (await stat(file)).size;
|
|
32
|
+
if (size < MIN_SIZE) continue;
|
|
33
|
+
|
|
34
|
+
const raw = await readFile(file);
|
|
35
|
+
// Quality 5 rather than the default 11: on a 20MB wasm the top setting costs
|
|
36
|
+
// minutes of build time for a few percent of size.
|
|
37
|
+
const [brotli, gzipped] = await Promise.all([
|
|
38
|
+
br(raw, {
|
|
39
|
+
params: {
|
|
40
|
+
[constants.BROTLI_PARAM_QUALITY]: 5,
|
|
41
|
+
[constants.BROTLI_PARAM_SIZE_HINT]: size,
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
gz(raw, { level: 6 }),
|
|
45
|
+
]);
|
|
46
|
+
await writeFile(`${file}.br`, brotli);
|
|
47
|
+
await writeFile(`${file}.gz`, gzipped);
|
|
48
|
+
count += 1;
|
|
49
|
+
saved += size - brotli.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(
|
|
53
|
+
`vibe-os: precompressed ${count} asset(s), saving ${(saved / 1024 / 1024).toFixed(1)} MB on the wire (${((Date.now() - t0) / 1000).toFixed(1)}s)`,
|
|
54
|
+
);
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Fetches ssh.wasm + wasm_exec.js from an upstream c2FmZQ/sshterm release into
|
|
4
|
+
// public/.
|
|
5
|
+
//
|
|
6
|
+
// The two files are a matched pair: wasm_exec.js is the host half of the Go
|
|
7
|
+
// WASM ABI and is version-locked to the compiler that produced the .wasm, so
|
|
8
|
+
// they always come from the same release tarball. Building from source instead
|
|
9
|
+
// would require a Go toolchain on the target machine, which a VPS running
|
|
10
|
+
// the npm package will not have.
|
|
11
|
+
//
|
|
12
|
+
// Flags:
|
|
13
|
+
// --if-missing no-op when public/ssh.wasm already exists
|
|
14
|
+
// --soft-fail warn and exit 0 on failure (used from postinstall, where a
|
|
15
|
+
// hard failure would break `npm install` on an offline box;
|
|
16
|
+
// the server retries this at startup)
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { createWriteStream } from "node:fs";
|
|
21
|
+
import {
|
|
22
|
+
mkdir,
|
|
23
|
+
mkdtemp,
|
|
24
|
+
readdir,
|
|
25
|
+
readFile,
|
|
26
|
+
rename,
|
|
27
|
+
rm,
|
|
28
|
+
stat,
|
|
29
|
+
} from "node:fs/promises";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { pipeline } from "node:stream/promises";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
|
|
35
|
+
const ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
36
|
+
const PUBLIC = path.join(ROOT, "public");
|
|
37
|
+
const REPO = "c2FmZQ/sshterm";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The release this project is built and tested against, with the checksum of
|
|
41
|
+
* its docroot tarball.
|
|
42
|
+
*
|
|
43
|
+
* Pinned rather than tracking `latest` because this file becomes the SSH client
|
|
44
|
+
* that generates and holds every user's private key in the browser, and it is
|
|
45
|
+
* fetched automatically by `postinstall`. Tracking `latest` meant whatever
|
|
46
|
+
* upstream tagged most recently became that code, unverified — so an install
|
|
47
|
+
* today and an install tomorrow could differ with nothing recording it.
|
|
48
|
+
*
|
|
49
|
+
* To move it: set SSHTERM_VERSION to the new tag, run this script, and copy the
|
|
50
|
+
* sha256 it prints into `sha256` below.
|
|
51
|
+
*/
|
|
52
|
+
const PINNED = {
|
|
53
|
+
tag: "v0.8.3",
|
|
54
|
+
sha256: "77af7cb2582ff43588d866277019351695c23880133929136532340937dba87c",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const args = new Set(process.argv.slice(2));
|
|
58
|
+
const ifMissing = args.has("--if-missing");
|
|
59
|
+
const softFail = args.has("--soft-fail");
|
|
60
|
+
|
|
61
|
+
// Local rather than shared with server/fsx.ts: this asks a different question
|
|
62
|
+
// (non-empty, because a half-written 0-byte ssh.wasm must not count as present)
|
|
63
|
+
// and this script has to keep running under plain node, with no .ts imports.
|
|
64
|
+
async function exists(p) {
|
|
65
|
+
try {
|
|
66
|
+
const s = await stat(p);
|
|
67
|
+
return s.size > 0;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveAsset() {
|
|
74
|
+
const override = process.env.SSHTERM_VERSION;
|
|
75
|
+
const tag = override ?? PINNED.tag;
|
|
76
|
+
const url = `https://api.github.com/repos/${REPO}/releases/tags/${tag}`;
|
|
77
|
+
const res = await fetch(url, {
|
|
78
|
+
headers: {
|
|
79
|
+
accept: "application/vnd.github+json",
|
|
80
|
+
"user-agent": "vibe-os-installer",
|
|
81
|
+
...(process.env.GITHUB_TOKEN
|
|
82
|
+
? { authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
|
|
83
|
+
: {}),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok)
|
|
87
|
+
throw new Error(`GitHub API ${res.status} ${res.statusText} for ${url}`);
|
|
88
|
+
const release = await res.json();
|
|
89
|
+
const asset = release.assets?.find((a) =>
|
|
90
|
+
/^sshterm-docroot-.*\.tar\.gz$/.test(a.name),
|
|
91
|
+
);
|
|
92
|
+
if (!asset) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`release ${release.tag_name} has no sshterm-docroot tarball`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
tag: release.tag_name,
|
|
99
|
+
name: asset.name,
|
|
100
|
+
url: asset.browser_download_url,
|
|
101
|
+
// Only the pinned tag has a checksum recorded here; an override is by
|
|
102
|
+
// definition something this file has never seen.
|
|
103
|
+
sha256: override ? null : PINNED.sha256,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function sha256Of(file) {
|
|
108
|
+
return createHash("sha256")
|
|
109
|
+
.update(await readFile(file))
|
|
110
|
+
.digest("hex");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function untar(tarball, cwd) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const child = spawn("tar", ["-xzf", tarball, "-C", cwd], {
|
|
116
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
117
|
+
});
|
|
118
|
+
let stderr = "";
|
|
119
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
120
|
+
child.on("error", reject);
|
|
121
|
+
child.on("close", (code) =>
|
|
122
|
+
code === 0
|
|
123
|
+
? resolve()
|
|
124
|
+
: reject(new Error(`tar exited ${code}: ${stderr.trim()}`)),
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The tarball's internal layout is not part of any stability guarantee, so find
|
|
130
|
+
// the two files wherever they landed rather than assuming a path.
|
|
131
|
+
async function findFile(dir, name) {
|
|
132
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const full = path.join(dir, entry.name);
|
|
135
|
+
if (entry.isDirectory()) {
|
|
136
|
+
const hit = await findFile(full, name);
|
|
137
|
+
if (hit) return hit;
|
|
138
|
+
} else if (entry.isFile() && entry.name === name) {
|
|
139
|
+
// isFile() excludes symlinks, which a tarball can carry and which would
|
|
140
|
+
// otherwise let an entry point anywhere on the filesystem.
|
|
141
|
+
return full;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function main() {
|
|
148
|
+
await mkdir(PUBLIC, { recursive: true });
|
|
149
|
+
|
|
150
|
+
// Either location counts as "already have it": a git checkout keeps the pair
|
|
151
|
+
// in public/, while the published tarball ships it already copied into
|
|
152
|
+
// dist/web and does not include public/ at all. Checking only public/ would
|
|
153
|
+
// re-download 19MB on every `npm install` of the published package.
|
|
154
|
+
if (ifMissing) {
|
|
155
|
+
for (const dir of [PUBLIC, path.join(ROOT, "dist", "web")]) {
|
|
156
|
+
if (
|
|
157
|
+
(await exists(path.join(dir, "ssh.wasm"))) &&
|
|
158
|
+
(await exists(path.join(dir, "wasm_exec.js")))
|
|
159
|
+
)
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const asset = await resolveAsset();
|
|
165
|
+
console.log(`vibe-os: fetching ssh.wasm from ${REPO} ${asset.tag}`);
|
|
166
|
+
|
|
167
|
+
// mkdtemp, not a name we compose ourselves: it creates the directory
|
|
168
|
+
// atomically with an unpredictable suffix, so nothing in a world-writable
|
|
169
|
+
// /tmp can pre-create the path (or a symlink at it) and have the extracted
|
|
170
|
+
// contents land somewhere of its choosing. Same reasoning as server/ssh-ca.ts.
|
|
171
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), "vibe-os-wasm-"));
|
|
172
|
+
try {
|
|
173
|
+
const tarball = path.join(tmp, asset.name);
|
|
174
|
+
const res = await fetch(asset.url, {
|
|
175
|
+
headers: { "user-agent": "vibe-os-installer" },
|
|
176
|
+
redirect: "follow",
|
|
177
|
+
});
|
|
178
|
+
if (!res.ok || !res.body)
|
|
179
|
+
throw new Error(`download ${asset.url}: HTTP ${res.status}`);
|
|
180
|
+
await pipeline(res.body, createWriteStream(tarball));
|
|
181
|
+
|
|
182
|
+
const digest = await sha256Of(tarball);
|
|
183
|
+
if (asset.sha256 && digest !== asset.sha256) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`checksum mismatch for ${asset.name}\n` +
|
|
186
|
+
` expected ${asset.sha256}\n` +
|
|
187
|
+
` received ${digest}\n` +
|
|
188
|
+
"Refusing to install: this file becomes the SSH client that handles " +
|
|
189
|
+
"your private keys.",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (!asset.sha256) {
|
|
193
|
+
console.warn(
|
|
194
|
+
`vibe-os: SSHTERM_VERSION=${asset.tag} is not the pinned release — ` +
|
|
195
|
+
"skipping checksum verification.",
|
|
196
|
+
);
|
|
197
|
+
console.warn(`vibe-os: sha256 of ${asset.name} is ${digest}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await untar(tarball, tmp);
|
|
201
|
+
|
|
202
|
+
// Resolve both halves before moving either. They are a matched pair, and a
|
|
203
|
+
// failure between two moves would leave a new ssh.wasm beside the previous
|
|
204
|
+
// wasm_exec.js — which --if-missing then treats as "already have it", so the
|
|
205
|
+
// mismatch would be permanent and would surface only as an opaque failure
|
|
206
|
+
// in the browser.
|
|
207
|
+
const staged = [];
|
|
208
|
+
for (const name of ["ssh.wasm", "wasm_exec.js"]) {
|
|
209
|
+
const found = await findFile(tmp, name);
|
|
210
|
+
if (!found) throw new Error(`${asset.name} does not contain ${name}`);
|
|
211
|
+
staged.push([name, found]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const [name, found] of staged) {
|
|
215
|
+
await rename(found, path.join(PUBLIC, name)).catch(async (err) => {
|
|
216
|
+
// rename fails across devices; fall back to a copy.
|
|
217
|
+
if (err.code !== "EXDEV") throw err;
|
|
218
|
+
const { copyFile } = await import("node:fs/promises");
|
|
219
|
+
await copyFile(found, path.join(PUBLIC, name));
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const size = (await stat(path.join(PUBLIC, "ssh.wasm"))).size;
|
|
224
|
+
console.log(
|
|
225
|
+
`vibe-os: ssh.wasm ready (${(size / 1024 / 1024).toFixed(1)} MB)`,
|
|
226
|
+
);
|
|
227
|
+
} finally {
|
|
228
|
+
await rm(tmp, { recursive: true, force: true });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
main().catch((err) => {
|
|
233
|
+
if (softFail) {
|
|
234
|
+
console.warn(`vibe-os: could not fetch ssh.wasm (${err.message}).`);
|
|
235
|
+
console.warn(
|
|
236
|
+
"vibe-os: it will be fetched on first start, or run `vibe-os fetch-wasm`.",
|
|
237
|
+
);
|
|
238
|
+
process.exit(0);
|
|
239
|
+
}
|
|
240
|
+
console.error(`vibe-os: ${err.message}`);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Generates server/assets.generated.ts, the manifest that lets
|
|
4
|
+
// `bun build --compile` embed the whole web app inside the binary.
|
|
5
|
+
//
|
|
6
|
+
// Bun only embeds files it can see as static imports, so the manifest has to be
|
|
7
|
+
// written out rather than discovered at runtime. Filenames are content-hashed
|
|
8
|
+
// by Vite and change every build, which is exactly why this is generated.
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { stat } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { walk } from "./lib/walk.ts";
|
|
14
|
+
|
|
15
|
+
const ROOT = path.resolve(import.meta.dir, "..");
|
|
16
|
+
const WEB = path.join(ROOT, "dist", "web");
|
|
17
|
+
const OUT = path.join(ROOT, "server", "assets.generated.ts");
|
|
18
|
+
|
|
19
|
+
// A fresh clone has no dist/web yet, but static.ts imports this manifest
|
|
20
|
+
// unconditionally — so emit an empty one rather than failing. postinstall runs
|
|
21
|
+
// this, which is what lets `bun run dev:server` and `tsc` work before the first
|
|
22
|
+
// build. The empty manifest also makes the server read assets off disk, which
|
|
23
|
+
// is what you want in development.
|
|
24
|
+
const files: string[] = [];
|
|
25
|
+
try {
|
|
26
|
+
for await (const file of walk(WEB)) files.push(file);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// Only "the directory is not there yet" is the ordinary case. Anything else —
|
|
29
|
+
// an unreadable subdirectory, EMFILE partway through — leaves `files` half
|
|
30
|
+
// populated, and reporting that as "empty" would embed a subset of the web
|
|
31
|
+
// app into the binary and serve 404s for the rest.
|
|
32
|
+
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
|
|
33
|
+
if (files.length > 0) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`vibe-os: asset walk failed after ${files.length} file(s); refusing to write a partial manifest`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
console.log("vibe-os: no dist/web yet — writing an empty asset manifest");
|
|
39
|
+
}
|
|
40
|
+
files.sort();
|
|
41
|
+
|
|
42
|
+
// Embedded files have no usable mtime, so the ETag needs another source of
|
|
43
|
+
// change. Hash the manifest (names + sizes) into a per-build id.
|
|
44
|
+
const hash = createHash("sha256");
|
|
45
|
+
for (const file of files) hash.update(`${file}:${(await stat(file)).size}`);
|
|
46
|
+
const buildId = hash.digest("hex").slice(0, 12);
|
|
47
|
+
|
|
48
|
+
const imports: string[] = [];
|
|
49
|
+
const entries: string[] = [];
|
|
50
|
+
|
|
51
|
+
files.forEach((file, i) => {
|
|
52
|
+
const urlPath = `/${path.relative(WEB, file).split(path.sep).join("/")}`;
|
|
53
|
+
const specifier = `../dist/web${urlPath}`;
|
|
54
|
+
imports.push(
|
|
55
|
+
`import a${i} from ${JSON.stringify(specifier)} with { type: 'file' };`,
|
|
56
|
+
);
|
|
57
|
+
entries.push(` ${JSON.stringify(urlPath)}: a${i},`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const source = `// @ts-nocheck — generated; \`with { type: 'file' }\` imports are not modelled by tsc.
|
|
61
|
+
// Generated by scripts/gen-assets.ts — do not edit.
|
|
62
|
+
//
|
|
63
|
+
// Maps URL paths to files embedded in the compiled binary. Under \`bun run\`
|
|
64
|
+
// these resolve to real paths on disk, so the same code serves both modes.
|
|
65
|
+
|
|
66
|
+
${imports.join("\n")}
|
|
67
|
+
|
|
68
|
+
export const BUILD_ID = ${JSON.stringify(buildId)};
|
|
69
|
+
|
|
70
|
+
export const EMBEDDED: Record<string, string> = {
|
|
71
|
+
${entries.join("\n")}
|
|
72
|
+
};
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
await Bun.write(OUT, source);
|
|
76
|
+
console.log(`vibe-os: embedded manifest lists ${files.length} asset(s)`);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Every file under `dir`, depth-first.
|
|
6
|
+
*
|
|
7
|
+
* Both build scripts walk `dist/web` in the same `bun run build` pipeline, and
|
|
8
|
+
* had a byte-identical copy of this each. Symlinks come back as files rather
|
|
9
|
+
* than being followed, which is what you want for a build output tree — the
|
|
10
|
+
* caller stats them anyway.
|
|
11
|
+
*/
|
|
12
|
+
export async function* walk(dir: string): AsyncGenerator<string> {
|
|
13
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
14
|
+
const full = path.join(dir, entry.name);
|
|
15
|
+
if (entry.isDirectory()) yield* walk(full);
|
|
16
|
+
else yield full;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
// Generates THIRD_PARTY_NOTICES.md: the license of everything the binary and
|
|
4
|
+
// the web app carry that this repository did not write.
|
|
5
|
+
//
|
|
6
|
+
// Two sources. npm packages are read out of node_modules, walking runtime
|
|
7
|
+
// dependencies from the packages that actually ship: `dependencies`, which the
|
|
8
|
+
// compiled server embeds, and the browser packages Vite bundles. ssh.wasm is
|
|
9
|
+
// compiled Go, so it carries Go's standard library and the modules sshterm
|
|
10
|
+
// links; their texts are fetched from GitHub at the versions in sshterm's
|
|
11
|
+
// go.mod for the release `scripts/fetch-wasm.mjs` pins.
|
|
12
|
+
//
|
|
13
|
+
// Rerun after changing dependencies or moving the sshterm pin:
|
|
14
|
+
// bun run notices
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(import.meta.dir, "..");
|
|
20
|
+
const OUT = path.join(ROOT, "THIRD_PARTY_NOTICES.md");
|
|
21
|
+
|
|
22
|
+
/** devDependencies that end up in the browser bundle, not just the toolchain. */
|
|
23
|
+
const BUNDLED = [
|
|
24
|
+
"react",
|
|
25
|
+
"react-dom",
|
|
26
|
+
"swr",
|
|
27
|
+
"@xterm/xterm",
|
|
28
|
+
"@xterm/addon-fit",
|
|
29
|
+
"@xterm/addon-unicode11",
|
|
30
|
+
"@xterm/addon-web-links",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What ssh.wasm links, from sshterm v0.8.3's go/go.mod, keeping the modules
|
|
35
|
+
* whose package paths appear in the compiled binary. Move these with the pin.
|
|
36
|
+
*/
|
|
37
|
+
const GO_MODULES: { name: string; repo: string; ref: string }[] = [
|
|
38
|
+
{
|
|
39
|
+
name: "Go (standard library, wasm_exec.js)",
|
|
40
|
+
repo: "golang/go",
|
|
41
|
+
ref: "go1.26.0",
|
|
42
|
+
},
|
|
43
|
+
{ name: "sshterm", repo: "c2FmZQ/sshterm", ref: "v0.8.3" },
|
|
44
|
+
{ name: "golang.org/x/crypto", repo: "golang/crypto", ref: "v0.49.0" },
|
|
45
|
+
{
|
|
46
|
+
name: "github.com/fxamacker/cbor/v2",
|
|
47
|
+
repo: "fxamacker/cbor",
|
|
48
|
+
ref: "v2.9.1",
|
|
49
|
+
},
|
|
50
|
+
{ name: "github.com/x448/float16", repo: "x448/float16", ref: "v0.8.4" },
|
|
51
|
+
{ name: "github.com/urfave/cli/v2", repo: "urfave/cli", ref: "v2.27.7" },
|
|
52
|
+
{
|
|
53
|
+
name: "github.com/russross/blackfriday/v2",
|
|
54
|
+
repo: "russross/blackfriday",
|
|
55
|
+
ref: "v2.1.0",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "github.com/cpuguy83/go-md2man/v2",
|
|
59
|
+
repo: "cpuguy83/go-md2man",
|
|
60
|
+
ref: "v2.0.7",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "github.com/xrash/smetrics",
|
|
64
|
+
repo: "xrash/smetrics",
|
|
65
|
+
ref: "55b8f293f342",
|
|
66
|
+
},
|
|
67
|
+
{ name: "github.com/pkg/sftp", repo: "pkg/sftp", ref: "v1.13.10" },
|
|
68
|
+
{ name: "github.com/kr/fs", repo: "kr/fs", ref: "v0.1.0" },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const LICENSE_FILE = /^(licen[sc]e|copying|notice)(\.(md|txt))?$/i;
|
|
72
|
+
const LICENSE_NAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"];
|
|
73
|
+
|
|
74
|
+
type Entry = { name: string; version: string; license: string; text: string };
|
|
75
|
+
|
|
76
|
+
function readPackage(name: string): Record<string, unknown> | null {
|
|
77
|
+
const file = path.join(ROOT, "node_modules", name, "package.json");
|
|
78
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function repoOf(pkg: Record<string, unknown>): string | null {
|
|
82
|
+
const repo = pkg.repository;
|
|
83
|
+
const url = typeof repo === "string" ? repo : (repo as { url?: string })?.url;
|
|
84
|
+
const match = url && /github\.com[/:]([^/]+\/[^/.#]+)/.exec(url);
|
|
85
|
+
return match ? match[1] : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchLicense(repo: string, ref: string): Promise<string | null> {
|
|
89
|
+
for (const name of LICENSE_NAMES) {
|
|
90
|
+
const res = await fetch(
|
|
91
|
+
`https://raw.githubusercontent.com/${repo}/${ref}/${name}`,
|
|
92
|
+
);
|
|
93
|
+
if (res.ok) return (await res.text()).trim();
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A README's "License" section, through to the end of the file. */
|
|
99
|
+
function readmeLicense(dir: string): string | null {
|
|
100
|
+
const file = path.join(dir, "README.md");
|
|
101
|
+
if (!existsSync(file)) return null;
|
|
102
|
+
const match = /^(?:#+ *License\s*$|License\n-+$)([\s\S]+)/im.exec(
|
|
103
|
+
readFileSync(file, "utf8"),
|
|
104
|
+
);
|
|
105
|
+
return match ? match[1].trim() : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function npmEntries(): Promise<Entry[]> {
|
|
109
|
+
const root = JSON.parse(
|
|
110
|
+
readFileSync(path.join(ROOT, "package.json"), "utf8"),
|
|
111
|
+
);
|
|
112
|
+
const roots = [
|
|
113
|
+
...Object.keys((root.dependencies as Record<string, string>) ?? {}),
|
|
114
|
+
...BUNDLED,
|
|
115
|
+
];
|
|
116
|
+
const seen = new Map<string, Entry>();
|
|
117
|
+
const queue = [...roots];
|
|
118
|
+
while (queue.length > 0) {
|
|
119
|
+
const name = queue.shift() as string;
|
|
120
|
+
if (seen.has(name)) continue;
|
|
121
|
+
const pkg = readPackage(name);
|
|
122
|
+
if (!pkg) throw new Error(`${name} is not installed; run bun install`);
|
|
123
|
+
const dir = path.join(ROOT, "node_modules", name);
|
|
124
|
+
const file = readdirSync(dir).find((f) => LICENSE_FILE.test(f));
|
|
125
|
+
// A few packages publish without their license file. Older ones keep the
|
|
126
|
+
// text as the README's last section; otherwise the repository has it, at
|
|
127
|
+
// HEAD rather than the version, which is the best a tarball that omitted
|
|
128
|
+
// it allows.
|
|
129
|
+
const repo = repoOf(pkg);
|
|
130
|
+
const text = file
|
|
131
|
+
? readFileSync(path.join(dir, file), "utf8").trim()
|
|
132
|
+
: (readmeLicense(dir) ??
|
|
133
|
+
(repo ? await fetchLicense(repo, "HEAD") : null));
|
|
134
|
+
if (!text) throw new Error(`no license text found for ${name}`);
|
|
135
|
+
seen.set(name, {
|
|
136
|
+
name,
|
|
137
|
+
version: String(pkg.version),
|
|
138
|
+
license: String(pkg.license ?? "see text"),
|
|
139
|
+
text,
|
|
140
|
+
});
|
|
141
|
+
queue.push(...Object.keys((pkg.dependencies as object) ?? {}));
|
|
142
|
+
}
|
|
143
|
+
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function goEntries(): Promise<Entry[]> {
|
|
147
|
+
return Promise.all(
|
|
148
|
+
GO_MODULES.map(async ({ name, repo, ref }) => {
|
|
149
|
+
const text = await fetchLicense(repo, ref);
|
|
150
|
+
if (!text) throw new Error(`no license text found for ${repo}@${ref}`);
|
|
151
|
+
return { name, version: ref, license: "see text", text };
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const section = (entries: Entry[]) =>
|
|
157
|
+
entries
|
|
158
|
+
.map(
|
|
159
|
+
(e) =>
|
|
160
|
+
`### ${e.name} ${e.version}\n\nLicense: ${e.license}\n\n\`\`\`\n${e.text}\n\`\`\`\n`,
|
|
161
|
+
)
|
|
162
|
+
.join("\n");
|
|
163
|
+
|
|
164
|
+
const [npm, go] = await Promise.all([npmEntries(), goEntries()]);
|
|
165
|
+
|
|
166
|
+
writeFileSync(
|
|
167
|
+
OUT,
|
|
168
|
+
`# Third-party notices
|
|
169
|
+
|
|
170
|
+
vibe-os is MIT licensed (see LICENSE). The binary and the web app also carry
|
|
171
|
+
the software below, under its own terms. This file is generated by
|
|
172
|
+
\`bun run notices\`.
|
|
173
|
+
|
|
174
|
+
Where a package offers a choice of licenses, vibe-os uses it under the first
|
|
175
|
+
one that is not copyleft: node-forge under BSD-3-Clause.
|
|
176
|
+
|
|
177
|
+
## ssh.wasm and wasm_exec.js
|
|
178
|
+
|
|
179
|
+
From [c2FmZQ/sshterm](https://github.com/c2FmZQ/sshterm), compiled Go.
|
|
180
|
+
|
|
181
|
+
${section(go)}
|
|
182
|
+
## npm packages
|
|
183
|
+
|
|
184
|
+
${section(npm)}`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
console.log(
|
|
188
|
+
`vibe-os: wrote ${path.relative(ROOT, OUT)} (${go.length} Go, ${npm.length} npm)`,
|
|
189
|
+
);
|