vercel-vm-factory 0.1.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/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # Vercel VM Factory
2
+
3
+ Create a tiny Vercel Container deployment: copy `wsterm` from `ghcr.io/v1xingyue/ws-shell:v1.1.alpine` into a selected base image, then deploy with Vercel CLI.
4
+
5
+ ```bash
6
+ npx vercel-vm-factory create \
7
+ --base ubuntu \
8
+ --project ws-shell-ubuntu \
9
+ --client-id YOUR_GITHUB_CLIENT_ID \
10
+ --client-secret YOUR_GITHUB_CLIENT_SECRET \
11
+ --github-userid 12345678
12
+ ```
13
+
14
+ Run without flags for prompts:
15
+
16
+ ```bash
17
+ npx vercel-vm-factory create
18
+ ```
19
+
20
+ Check local setup:
21
+
22
+ ```bash
23
+ npx vercel-vm-factory doctor
24
+ ```
25
+
26
+ The script checks `vercel --version` and `vercel whoami`; if you are not logged in, it runs `vercel login`.
27
+
28
+ Use `--help` to show all flags.
29
+
30
+ Entered GitHub values are reused from `~/.vercel-vm-factory/defaults.json`; press Enter to keep the placeholder value or skip an empty one.
31
+
32
+ The generated project contains only `Dockerfile.vercel`.
33
+
34
+ CLI mapping:
35
+
36
+ - Vercel Team -> `--scope TEAM_SLUG` when needed; omit it to use the CLI default scope
37
+ - Project Name -> `--project x-shell`
38
+ - Application Preset -> patched through Vercel API as `framework=container`
39
+ - Root Directory -> generated project directory
40
+
41
+ Base presets:
42
+
43
+ - `alpine` -> `alpine:3.23`
44
+ - `ubuntu` -> `ubuntu:24.04`
45
+ - `debian` -> `debian:13-slim`
46
+
47
+ Custom base:
48
+
49
+ ```bash
50
+ npx vercel-vm-factory create --base fedora:42 --project ws-shell-fedora
51
+ ```
52
+
53
+ Before deploying, set the GitHub OAuth callback URL to:
54
+
55
+ ```text
56
+ https://PROJECT.vercel.app/auth/github/callback
57
+ ```
58
+
59
+ Use `--dry-run` to generate files without deploying.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../deploy-vm.mjs";
package/deploy-vm.mjs ADDED
@@ -0,0 +1,427 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import { homedir } from "node:os";
7
+ import path from "node:path";
8
+
9
+ const bases = {
10
+ alpine: "alpine:3.23",
11
+ ubuntu: "ubuntu:24.04",
12
+ debian: "debian:13-slim",
13
+ };
14
+
15
+ const { command, args } = parseCommand(process.argv.slice(2));
16
+ const scriptRoot = path.resolve(import.meta.dirname);
17
+ const workspaceRoot = process.cwd();
18
+ const stateRoot = path.join(homedir(), ".vercel-vm-factory");
19
+ const defaultsPath = path.join(stateRoot, "defaults.json");
20
+ const legacyDefaultsPath = path.join(scriptRoot, ".defaults.json");
21
+ const defaults = { ...(await readDefaults(legacyDefaultsPath)), ...(await readDefaults(defaultsPath)) };
22
+ const colorEnabled = output.isTTY && !process.env.NO_COLOR;
23
+ const color = {
24
+ dim: (text) => paint(text, "2"),
25
+ cyan: (text) => paint(text, "36"),
26
+ green: (text) => paint(text, "32"),
27
+ yellow: (text) => paint(text, "33"),
28
+ red: (text) => paint(text, "31"),
29
+ bold: (text) => paint(text, "1"),
30
+ };
31
+
32
+ try {
33
+ if (args.help || args.h || command === "help") {
34
+ printHelp();
35
+ process.exit(0);
36
+ }
37
+ if (!["create", "doctor"].includes(command)) {
38
+ throw new Error(`Unknown command: ${command}. Run vercel-vm-factory help`);
39
+ }
40
+ await main();
41
+ } catch (error) {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ if (message.includes("scope does not exist")) {
44
+ console.error(`\nScope not found. Leave scope empty, or set the real CLI slug with --scope.`);
45
+ console.error(`If it was saved before, edit or delete: ${defaultsPath}`);
46
+ }
47
+ console.error(message);
48
+ process.exit(1);
49
+ }
50
+
51
+ async function main() {
52
+ printHeader();
53
+
54
+ if (args.doctor || command === "doctor") {
55
+ await doctor();
56
+ return;
57
+ }
58
+
59
+ const baseName = await chooseBase(args.base ?? defaults.base ?? "alpine");
60
+ const baseImage = bases[baseName] ?? baseName;
61
+ const scope = await optionalValue("scope", "Vercel team/scope", defaults.scope);
62
+ const project = await value("project", "Vercel project name", defaults.project ?? `ws-shell-${baseName}`);
63
+ const wsShellImage = args.from ?? process.env.WS_SHELL_IMAGE ?? defaults.from ?? "ghcr.io/v1xingyue/ws-shell:v1.1.alpine";
64
+ const prod = args.prod !== "false";
65
+ const dryRun = Boolean(args["dry-run"]);
66
+ const skipLink = Boolean(args["skip-link"]);
67
+
68
+ const oauthRedirectUrl =
69
+ args["redirect-url"] ??
70
+ process.env.OAUTH_REDIRECT_URL ??
71
+ defaults["redirect-url"] ??
72
+ `https://${project}.vercel.app/auth/github/callback`;
73
+
74
+ const appDir = path.join(workspaceRoot, ".vercel-vm-factory", ".generated", project);
75
+ const dockerfile = makeDockerfile({ baseImage, wsShellImage });
76
+
77
+ await rm(appDir, { recursive: true, force: true });
78
+ await mkdir(appDir, { recursive: true });
79
+ await writeFile(path.join(appDir, "Dockerfile.vercel"), dockerfile);
80
+
81
+ printSummary({
82
+ base: `${baseName} -> ${baseImage}`,
83
+ project,
84
+ scope: scope || "default",
85
+ source: wsShellImage,
86
+ callback: oauthRedirectUrl,
87
+ dockerfile: path.join(appDir, "Dockerfile.vercel"),
88
+ target: prod ? "production" : "preview",
89
+ });
90
+
91
+ if (dryRun) {
92
+ ok("dry run: skipped vercel deploy");
93
+ return;
94
+ }
95
+
96
+ await ensureVercelReady();
97
+
98
+ const githubClientId = await secret("client-id", "GitHub client id", process.env.GITHUB_CLIENT_ID ?? defaults["client-id"]);
99
+ const githubClientSecret = await secret(
100
+ "client-secret",
101
+ "GitHub client secret",
102
+ process.env.GITHUB_CLIENT_SECRET ?? defaults["client-secret"],
103
+ );
104
+ const allowedUserIds = await secret("github-userid", "Allowed GitHub numeric user id(s)", process.env.ALLOWED_USER_IDS ?? defaults["github-userid"]);
105
+
106
+ await writeDefaults(defaultsPath, {
107
+ ...defaults,
108
+ base: baseName,
109
+ scope: scope || undefined,
110
+ project,
111
+ from: wsShellImage,
112
+ "client-id": githubClientId,
113
+ "client-secret": githubClientSecret,
114
+ "github-userid": allowedUserIds,
115
+ });
116
+
117
+ const commonArgs = [];
118
+ if (args.token) commonArgs.push("--token", args.token);
119
+ if (scope) commonArgs.push("--scope", scope);
120
+
121
+ if (!skipLink) {
122
+ step("Linking Vercel project");
123
+ await runNoUrl("vercel", ["link", "--yes", "--project", project, "--cwd", appDir, ...commonArgs]);
124
+ } else {
125
+ warn("skip-link enabled; using existing .vercel/project.json");
126
+ }
127
+
128
+ step("Setting project framework=container");
129
+ await setContainerFramework(appDir, commonArgs);
130
+
131
+ const vercelArgs = [
132
+ "deploy",
133
+ appDir,
134
+ "--yes",
135
+ "--logs",
136
+ "--env",
137
+ `OAUTH_REDIRECT_URL=${oauthRedirectUrl}`,
138
+ ];
139
+
140
+ if (githubClientId) vercelArgs.push("--env", `GITHUB_CLIENT_ID=${githubClientId}`);
141
+ if (githubClientSecret) vercelArgs.push("--env", `GITHUB_CLIENT_SECRET=${githubClientSecret}`);
142
+ if (allowedUserIds) vercelArgs.push("--env", `ALLOWED_USER_IDS=${allowedUserIds}`);
143
+ if (prod) vercelArgs.push("--prod");
144
+ vercelArgs.push(...commonArgs);
145
+
146
+ step("Deploying");
147
+ const deploymentUrl = await run("vercel", vercelArgs);
148
+ console.log(`\n${color.green("Deployment URL:")} ${color.bold(deploymentUrl)}`);
149
+ }
150
+
151
+ async function setContainerFramework(appDir, commonArgs) {
152
+ const projectFile = path.join(appDir, ".vercel", "project.json");
153
+ const projectConfig = JSON.parse(await readFile(projectFile, "utf8"));
154
+ if (!projectConfig.projectId) throw new Error(`Missing projectId in ${projectFile}`);
155
+
156
+ await runNoUrl("vercel", [
157
+ "api",
158
+ `/v10/projects/${projectConfig.projectId}`,
159
+ "-X",
160
+ "PATCH",
161
+ "-F",
162
+ "framework=container",
163
+ ...commonArgs,
164
+ ]);
165
+ }
166
+
167
+ async function ensureVercelReady() {
168
+ step("Checking Vercel CLI");
169
+ try {
170
+ const version = await runCapture("vercel", ["--version"]);
171
+ ok(version.split("\n").filter(Boolean).at(-1) || "vercel installed");
172
+ } catch {
173
+ throw new Error("Vercel CLI is not installed. Install it with: pnpm add -g vercel");
174
+ }
175
+
176
+ step("Checking Vercel login");
177
+ try {
178
+ const whoami = await runCapture("vercel", ["whoami"]);
179
+ ok(`logged in as ${whoami.trim()}`);
180
+ } catch {
181
+ warn("Vercel CLI is not logged in. Starting vercel login...");
182
+ await runNoUrl("vercel", ["login"]);
183
+ }
184
+ }
185
+
186
+ async function doctor() {
187
+ await ensureVercelReady();
188
+ console.log("");
189
+ console.log(color.bold("Saved defaults"));
190
+ printKeyValue("defaults file", defaultsPath);
191
+ printKeyValue("base", defaults.base || "not set");
192
+ printKeyValue("project", defaults.project || "not set");
193
+ printKeyValue("scope", defaults.scope || "not set");
194
+ printKeyValue("source image", defaults.from || "ghcr.io/v1xingyue/ws-shell:v1.1.alpine");
195
+ printKeyValue("client id", defaults["client-id"] ? mask(defaults["client-id"]) : "not set");
196
+ printKeyValue("client secret", defaults["client-secret"] ? mask(defaults["client-secret"]) : "not set");
197
+ printKeyValue("github userid", defaults["github-userid"] ? mask(defaults["github-userid"]) : "not set");
198
+ }
199
+
200
+ function makeDockerfile({ baseImage, wsShellImage }) {
201
+ return `ARG WS_SHELL_IMAGE=${wsShellImage}
202
+ ARG BASE_IMAGE=${baseImage}
203
+
204
+ FROM \${WS_SHELL_IMAGE} AS ws-shell
205
+ FROM \${BASE_IMAGE} AS base
206
+
207
+ # wsterm already embeds the web UI; runtime config comes from environment variables.
208
+ COPY --from=ws-shell /app/bin/wsterm /app/bin/wsterm
209
+
210
+ WORKDIR /app
211
+ ENV ENABLE_SSL=false
212
+ EXPOSE 80
213
+ CMD ["/app/bin/wsterm","-bind",":80","-fork","/bin/sh"]
214
+ `;
215
+ }
216
+
217
+ async function value(name, question, fallback) {
218
+ const current = args[name] ?? fallback;
219
+ if (args[name]) return args[name];
220
+ if (!input.isTTY) return current || "";
221
+
222
+ const rl = createInterface({ input, output });
223
+ const answer = (await rl.question(`${question}${fallback ? ` [${fallback}]` : ""}: `)).trim();
224
+ rl.close();
225
+ return answer || current || "";
226
+ }
227
+
228
+ async function secret(name, question, fallback) {
229
+ if (args[name]) return args[name];
230
+ if (!input.isTTY) return fallback || "";
231
+
232
+ const rl = createInterface({ input, output });
233
+ const placeholder = fallback ? mask(fallback) : "skip";
234
+ const answer = (await rl.question(`${question} [${placeholder}]: `)).trim();
235
+ rl.close();
236
+ return answer || fallback || "";
237
+ }
238
+
239
+ async function optionalValue(name, question, fallback) {
240
+ if (args[name] !== undefined) return args[name];
241
+ if (!input.isTTY) return "";
242
+
243
+ const rl = createInterface({ input, output });
244
+ const suffix = fallback ? ` [${fallback}; Enter to skip]` : " [skip]";
245
+ const answer = (await rl.question(`${question}${suffix}: `)).trim();
246
+ rl.close();
247
+ return answer;
248
+ }
249
+
250
+ async function chooseBase(fallback) {
251
+ if (args.base) return args.base;
252
+ if (!input.isTTY) return fallback;
253
+
254
+ const names = Object.keys(bases);
255
+ console.log(color.bold("Choose base image"));
256
+ names.forEach((name, index) => console.log(` ${color.cyan(String(index + 1))}. ${name} ${color.dim(`(${bases[name]})`)}`));
257
+ console.log(` ${color.cyan("4")}. custom image`);
258
+
259
+ const rl = createInterface({ input, output });
260
+ const answer = (await rl.question(`Base [${fallback}]: `)).trim();
261
+ rl.close();
262
+
263
+ if (!answer) return fallback;
264
+ if (bases[answer]) return answer;
265
+ if (/^[1-3]$/.test(answer)) return names[Number(answer) - 1];
266
+ if (answer === "4") return value("custom-base", "Custom base image", defaults["custom-base"]);
267
+ return answer;
268
+ }
269
+
270
+ async function readDefaults(file) {
271
+ try {
272
+ return JSON.parse(await readFile(file, "utf8"));
273
+ } catch {
274
+ return {};
275
+ }
276
+ }
277
+
278
+ async function writeDefaults(file, data) {
279
+ const clean = Object.fromEntries(Object.entries(data).filter(([, value]) => value));
280
+ await mkdir(path.dirname(file), { recursive: true });
281
+ await writeFile(file, `${JSON.stringify(clean, null, 2)}\n`);
282
+ }
283
+
284
+ function mask(value) {
285
+ if (value.length <= 8) return "********";
286
+ return `${value.slice(0, 4)}...${value.slice(-4)}`;
287
+ }
288
+
289
+ function parseArgs(argv) {
290
+ const out = {};
291
+ for (let i = 0; i < argv.length; i += 1) {
292
+ const arg = argv[i];
293
+ if (!arg.startsWith("--")) continue;
294
+
295
+ const [rawKey, rawValue] = arg.slice(2).split("=", 2);
296
+ out[rawKey] = rawValue ?? (argv[i + 1]?.startsWith("--") ? true : argv[i + 1]) ?? true;
297
+ if (rawValue === undefined && argv[i + 1] && !argv[i + 1].startsWith("--")) i += 1;
298
+ }
299
+ return out;
300
+ }
301
+
302
+ function parseCommand(argv) {
303
+ const known = new Set(["create", "doctor", "help"]);
304
+ const first = argv[0];
305
+ if (first && !first.startsWith("--")) {
306
+ return { command: known.has(first) ? first : first, args: parseArgs(argv.slice(1)) };
307
+ }
308
+ return { command: "create", args: parseArgs(argv) };
309
+ }
310
+
311
+ function run(command, commandArgs) {
312
+ return new Promise((resolve, reject) => {
313
+ let seenUrl = "";
314
+ const child = spawn(command, commandArgs, { stdio: ["inherit", "pipe", "pipe"] });
315
+
316
+ child.stdout.on("data", (chunk) => {
317
+ const text = chunk.toString();
318
+ output.write(text);
319
+ seenUrl = findLastUrl(text) || seenUrl;
320
+ });
321
+
322
+ child.stderr.on("data", (chunk) => {
323
+ const text = chunk.toString();
324
+ output.write(text);
325
+ seenUrl = findLastUrl(text) || seenUrl;
326
+ });
327
+
328
+ child.on("error", reject);
329
+ child.on("close", (code) => {
330
+ if (code === 0 && seenUrl) resolve(seenUrl);
331
+ else if (code === 0) reject(new Error("vercel finished but no deployment url was found"));
332
+ else reject(new Error(`vercel exited with code ${code}`));
333
+ });
334
+ });
335
+ }
336
+
337
+ function runCapture(command, commandArgs) {
338
+ return new Promise((resolve, reject) => {
339
+ let text = "";
340
+ const child = spawn(command, commandArgs, { stdio: ["ignore", "pipe", "pipe"] });
341
+ child.stdout.on("data", (chunk) => {
342
+ text += chunk.toString();
343
+ });
344
+ child.stderr.on("data", (chunk) => {
345
+ text += chunk.toString();
346
+ });
347
+ child.on("error", reject);
348
+ child.on("close", (code) => {
349
+ if (code === 0) resolve(text.trim());
350
+ else reject(new Error(text.trim() || `${command} exited with code ${code}`));
351
+ });
352
+ });
353
+ }
354
+
355
+ function runNoUrl(command, commandArgs) {
356
+ return new Promise((resolve, reject) => {
357
+ const child = spawn(command, commandArgs, { stdio: "inherit" });
358
+ child.on("error", reject);
359
+ child.on("close", (code) => {
360
+ if (code === 0) resolve();
361
+ else reject(new Error(`${command} exited with code ${code}`));
362
+ });
363
+ });
364
+ }
365
+
366
+ function findLastUrl(text) {
367
+ return text.match(/https:\/\/[^\s]+\.vercel\.app/g)?.at(-1) ?? "";
368
+ }
369
+
370
+ function printHeader() {
371
+ console.log(color.bold(color.cyan("Vercel VM Factory")));
372
+ console.log(color.dim("Build a Container deployment from a tiny Dockerfile.vercel"));
373
+ console.log("");
374
+ }
375
+
376
+ function printSummary(items) {
377
+ console.log(color.bold("Deployment plan"));
378
+ for (const [key, value] of Object.entries(items)) {
379
+ printKeyValue(key, value);
380
+ }
381
+ console.log("");
382
+ }
383
+
384
+ function printKeyValue(key, value) {
385
+ console.log(`${color.dim(`${key.padEnd(14)} `)}${value}`);
386
+ }
387
+
388
+ function step(text) {
389
+ console.log(`${color.cyan("->")} ${text}`);
390
+ }
391
+
392
+ function ok(text) {
393
+ console.log(`${color.green("OK")} ${text}`);
394
+ }
395
+
396
+ function warn(text) {
397
+ console.log(`${color.yellow("!")} ${text}`);
398
+ }
399
+
400
+ function paint(text, code) {
401
+ return colorEnabled ? `\u001b[${code}m${text}\u001b[0m` : text;
402
+ }
403
+
404
+ function printHelp() {
405
+ printHeader();
406
+ console.log(`Usage:
407
+ vercel-vm-factory create
408
+ vercel-vm-factory create --base ubuntu --project x-shell
409
+ vercel-vm-factory doctor
410
+ npx vercel-vm-factory create
411
+
412
+ Options:
413
+ --base NAME alpine, ubuntu, debian, or a custom image
414
+ --project NAME Vercel project name
415
+ --scope SLUG Optional Vercel team/user scope slug
416
+ --from IMAGE Source image for /app/bin/wsterm
417
+ --client-id VALUE GitHub OAuth client id
418
+ --client-secret VAL GitHub OAuth client secret
419
+ --github-userid VAL Allowed GitHub numeric user id(s)
420
+ --redirect-url URL OAuth redirect URL
421
+ --prod=false Deploy preview instead of production
422
+ --skip-link Reuse existing generated .vercel/project.json
423
+ --dry-run Generate Dockerfile.vercel only
424
+ --doctor Check Vercel CLI/login and saved defaults
425
+ --help Show this help
426
+ `);
427
+ }
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "vercel-vm-factory",
3
+ "version": "0.1.0",
4
+ "description": "Create Vercel Container deployments for ws-shell from selectable base images.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "vercel-vm-factory": "bin/vercel-vm-factory.js"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "deploy-vm.mjs",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "deploy": "node deploy-vm.mjs create",
17
+ "doctor": "node deploy-vm.mjs doctor",
18
+ "pack:check": "npm pack --dry-run"
19
+ }
20
+ }