vercel-vm-factory 0.8.8 → 0.10.8

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.
Files changed (3) hide show
  1. package/README.md +36 -2
  2. package/deploy-vm.mjs +178 -73
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -6,6 +6,7 @@ Create a tiny Vercel Container deployment: copy `wsterm` from `ghcr.io/v1xingyue
6
6
  npx vercel-vm-factory create \
7
7
  --vm-image ubuntu \
8
8
  --shell /bin/bash \
9
+ --tools nodejs,codex \
9
10
  --project ws-shell-ubuntu \
10
11
  --auth-mode basic \
11
12
  --auth-user admin \
@@ -28,7 +29,7 @@ Run without flags for prompts:
28
29
  npx vercel-vm-factory create
29
30
  ```
30
31
 
31
- The prompt asks for authentication first: `basic`, `github`, `both`, or `none`, then only asks for the fields that mode needs.
32
+ The prompt walks through VM image, project, shell, optional preinstalled tools, and authentication. For list prompts, enter either names or numbers; tool choices can be comma-separated, for example `1,3` or `nodejs,claude-code`.
32
33
 
33
34
  Check local setup:
34
35
 
@@ -40,6 +41,16 @@ The script checks `vercel --version` and `vercel whoami`; if you are not logged
40
41
 
41
42
  Use `--help` to show all flags.
42
43
 
44
+ Common flags:
45
+
46
+ - `--vm-image alpine|ubuntu|debian|IMAGE`
47
+ - `--shell /bin/bash|/bin/zsh|/bin/sh`
48
+ - `--tools nodejs,codex,claude-code`
49
+ - `--project NAME`
50
+ - `--scope TEAM_SLUG`
51
+ - `--auth-mode basic|github|both|none`
52
+ - `--dry-run`
53
+
43
54
  Entered auth values are reused from `~/.vercel-vm-factory/defaults.json`; press Enter to keep the placeholder value or skip an empty one.
44
55
 
45
56
  The generated project contains only `Dockerfile.vercel`.
@@ -63,7 +74,30 @@ Shell options:
63
74
  - `/bin/zsh`
64
75
  - `/bin/sh`
65
76
 
66
- Choosing bash or zsh adds the matching package to the generated Dockerfile when the VM image does not already include it.
77
+ Choosing bash or zsh adds the matching package to the generated Dockerfile when the VM image does not already include it. Choosing zsh also installs oh-my-zsh.
78
+
79
+ Generated shell setup examples:
80
+
81
+ - Alpine + `/bin/zsh`: installs `zsh curl git`, installs oh-my-zsh unattended, and writes `/root/.zshrc`.
82
+ - Ubuntu/Debian + `/bin/bash`: installs `bash` with `apt-get`.
83
+ - `/bin/sh`: no extra shell package is installed.
84
+
85
+ The generated Dockerfile sets `HOME=/root` and `SHELL` to the selected shell path.
86
+
87
+ Preinstall tools:
88
+
89
+ - `nodejs`
90
+ - `codex`
91
+ - `claude-code`
92
+
93
+ Choosing codex or claude-code also installs Node.js/npm.
94
+
95
+ Generated tool setup examples:
96
+
97
+ - `--tools nodejs`: installs `nodejs npm`
98
+ - `--tools codex`: installs `nodejs npm`, then `npm install -g @openai/codex`
99
+ - `--tools claude-code`: installs `nodejs npm`, then `npm install -g @anthropic-ai/claude-code`
100
+ - `--tools codex,claude-code`: installs both CLIs
67
101
 
68
102
  Custom VM image:
69
103
 
package/deploy-vm.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
- import { createInterface } from "node:readline/promises";
5
4
  import { stdin as input, stdout as output } from "node:process";
6
5
  import { homedir } from "node:os";
7
6
  import path from "node:path";
7
+ import * as p from "@clack/prompts";
8
8
 
9
9
  const defaultWsShellImage = "ghcr.io/v1xingyue/ws-shell:v1.8.alpine";
10
10
 
@@ -14,6 +14,11 @@ const vmImages = {
14
14
  debian: "debian:13-slim",
15
15
  };
16
16
  const shells = ["/bin/bash", "/bin/zsh", "/bin/sh"];
17
+ const toolChoices = {
18
+ nodejs: "Node.js + npm",
19
+ codex: "OpenAI Codex CLI",
20
+ "claude-code": "Claude Code",
21
+ };
17
22
 
18
23
  const { command, args } = parseCommand(process.argv.slice(2));
19
24
  const scriptRoot = path.resolve(import.meta.dirname);
@@ -96,6 +101,7 @@ async function main() {
96
101
  defaults.from ??
97
102
  defaultWsShellImage;
98
103
  const shell = await chooseShell(args.shell ?? defaults.shell ?? "/bin/sh");
104
+ const tools = await chooseTools(args.tools ?? defaults.tools ?? "");
99
105
  const prod = args.prod !== "false";
100
106
  const dryRun = Boolean(args["dry-run"]);
101
107
  const skipLink = Boolean(args["skip-link"]);
@@ -115,6 +121,7 @@ async function main() {
115
121
  );
116
122
  const dockerfile = makeDockerfile({
117
123
  shell,
124
+ tools,
118
125
  vmImage,
119
126
  vmImageName,
120
127
  wsShellImage,
@@ -130,6 +137,7 @@ async function main() {
130
137
  scope: scope || "default",
131
138
  source: wsShellImage,
132
139
  shell,
140
+ tools: tools || "none",
133
141
  auth: authMode,
134
142
  ...(usesGitHubAuth(authMode) ? { callback: oauthRedirectUrl } : {}),
135
143
  dockerfile: path.join(appDir, "Dockerfile.vercel"),
@@ -186,6 +194,7 @@ async function main() {
186
194
  project,
187
195
  from: wsShellImage,
188
196
  shell,
197
+ tools,
189
198
  "auth-mode": authMode,
190
199
  "auth-user": authUsername,
191
200
  "auth-password": authPassword,
@@ -285,15 +294,14 @@ async function installVercelCli() {
285
294
  );
286
295
 
287
296
  const install = await choosePackageInstall();
288
- const answer = (
289
- await askText(
290
- `Vercel CLI is not installed. Install with ${install.command} ${install.args.join(" ")}?`,
291
- "N",
292
- )
293
- ).toLowerCase();
297
+ const answer = await promptResult(
298
+ p.confirm({
299
+ message: `Vercel CLI is not installed. Install with ${install.command} ${install.args.join(" ")}?`,
300
+ initialValue: false,
301
+ }),
302
+ );
294
303
 
295
- if (answer !== "y" && answer !== "yes")
296
- throw new Error("Vercel CLI is required. Exiting.");
304
+ if (!answer) throw new Error("Vercel CLI is required. Exiting.");
297
305
 
298
306
  step(`Installing Vercel CLI with ${install.command}`);
299
307
  await runNoUrl(install.command, install.args);
@@ -330,6 +338,7 @@ async function doctor() {
330
338
  printKeyValue("scope", defaults.scope || "not set");
331
339
  printKeyValue("source image", defaults.from || defaultWsShellImage);
332
340
  printKeyValue("shell", defaults.shell || "/bin/sh");
341
+ printKeyValue("tools", defaults.tools || "none");
333
342
  printKeyValue("auth mode", defaults["auth-mode"] || "not set");
334
343
  printKeyValue(
335
344
  "auth user",
@@ -353,43 +362,98 @@ async function doctor() {
353
362
  );
354
363
  }
355
364
 
356
- function makeDockerfile({ shell, vmImage, vmImageName, wsShellImage }) {
365
+ function makeDockerfile({ shell, tools, vmImage, vmImageName, wsShellImage }) {
357
366
  const shellInstall = makeShellInstall({ shell, vmImageName });
367
+ const ohMyZshInstall =
368
+ path.basename(shell) === "zsh"
369
+ ? `RUN RUNZSH=no CHSH=no KEEP_ZSHRC=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended \\
370
+ && printf '%s\\n' \\
371
+ 'export ZSH="$HOME/.oh-my-zsh"' \\
372
+ 'ZSH_THEME="robbyrussell"' \\
373
+ 'plugins=(git)' \\
374
+ 'source "$ZSH/oh-my-zsh.sh"' \\
375
+ > /root/.zshrc`
376
+ : "";
377
+ const toolInstall = makeToolInstall({ tools, vmImageName });
378
+ const shellSetup = [shellInstall, ohMyZshInstall, toolInstall]
379
+ .filter(Boolean)
380
+ .join("\n");
358
381
  return `ARG WS_SHELL_IMAGE=${wsShellImage}
359
382
  ARG VM_IMAGE=${vmImage}
360
383
 
361
384
  FROM \${WS_SHELL_IMAGE} AS ws-shell
362
385
  FROM \${VM_IMAGE} AS vm
363
- ${shellInstall ? `\n${shellInstall}\n` : ""}
386
+ ${shellSetup ? `\n${shellSetup}\n` : ""}
364
387
  # wsterm already embeds the web UI; runtime config comes from environment variables.
365
388
  COPY --from=ws-shell /app/bin/wsterm /app/bin/wsterm
366
389
 
367
390
  WORKDIR /app
368
391
  ENV ENABLE_SSL=false
392
+ ENV HOME=/root
393
+ ENV SHELL=${shell}
369
394
  EXPOSE 80
370
395
  CMD ["/app/bin/wsterm","-bind",":80","-fork","${shell}"]
371
396
  `;
372
397
  }
373
398
 
399
+ function makeToolInstall({ tools, vmImageName }) {
400
+ const selected = new Set(parseTools(tools));
401
+ if (selected.has("codex") || selected.has("claude-code"))
402
+ selected.add("nodejs");
403
+ if (!selected.size) return "";
404
+
405
+ const packages = [];
406
+ if (selected.has("nodejs")) packages.push("nodejs", "npm");
407
+ const npmPackages = [];
408
+ if (selected.has("codex")) npmPackages.push("@openai/codex");
409
+ if (selected.has("claude-code")) npmPackages.push("@anthropic-ai/claude-code");
410
+
411
+ const installNode =
412
+ packages.length && vmImageName === "alpine"
413
+ ? `RUN apk add --no-cache ${packages.join(" ")}`
414
+ : packages.length && (vmImageName === "ubuntu" || vmImageName === "debian")
415
+ ? `RUN apt-get update \\
416
+ && apt-get install -y --no-install-recommends ${packages.join(" ")} \\
417
+ && rm -rf /var/lib/apt/lists/*`
418
+ : packages.length
419
+ ? `RUN if command -v apk >/dev/null 2>&1; then \\
420
+ apk add --no-cache ${packages.join(" ")}; \\
421
+ elif command -v apt-get >/dev/null 2>&1; then \\
422
+ apt-get update \\
423
+ && apt-get install -y --no-install-recommends ${packages.join(" ")} \\
424
+ && rm -rf /var/lib/apt/lists/*; \\
425
+ else \\
426
+ echo "Cannot install nodejs/npm: unsupported VM image package manager" >&2; \\
427
+ exit 1; \\
428
+ fi`
429
+ : "";
430
+
431
+ const installCli = npmPackages.length
432
+ ? `RUN npm install -g ${npmPackages.join(" ")}`
433
+ : "";
434
+ return [installNode, installCli].filter(Boolean).join("\n");
435
+ }
436
+
374
437
  function makeShellInstall({ shell, vmImageName }) {
375
438
  const packageName = path.basename(shell);
376
439
  if (packageName === "sh") return "";
440
+ const packages = packageName === "zsh" ? "zsh curl git" : packageName;
377
441
 
378
442
  if (vmImageName === "alpine")
379
- return `RUN apk add --no-cache ${packageName}`;
443
+ return `RUN apk add --no-cache ${packages}`;
380
444
 
381
445
  if (vmImageName === "ubuntu" || vmImageName === "debian")
382
446
  return `RUN apt-get update \\
383
- && apt-get install -y --no-install-recommends ${packageName} \\
447
+ && apt-get install -y --no-install-recommends ${packages} \\
384
448
  && rm -rf /var/lib/apt/lists/*`;
385
449
 
386
450
  return `RUN if command -v ${shell} >/dev/null 2>&1; then \\
387
451
  true; \\
388
452
  elif command -v apk >/dev/null 2>&1; then \\
389
- apk add --no-cache ${packageName}; \\
453
+ apk add --no-cache ${packages}; \\
390
454
  elif command -v apt-get >/dev/null 2>&1; then \\
391
455
  apt-get update \\
392
- && apt-get install -y --no-install-recommends ${packageName} \\
456
+ && apt-get install -y --no-install-recommends ${packages} \\
393
457
  && rm -rf /var/lib/apt/lists/*; \\
394
458
  else \\
395
459
  echo "Cannot install ${packageName}: unsupported VM image package manager" >&2; \\
@@ -411,7 +475,7 @@ async function secret(name, question, fallback) {
411
475
  if (!input.isTTY) return fallback || "";
412
476
 
413
477
  const placeholder = fallback ? mask(fallback) : "skip";
414
- const answer = await askText(question, placeholder);
478
+ const answer = await askSecret(question, placeholder);
415
479
  return answer || fallback || "";
416
480
  }
417
481
 
@@ -456,22 +520,18 @@ async function chooseAuthMode(fallback) {
456
520
  }
457
521
  if (!input.isTTY) return modes.has(fallback) ? fallback : "basic";
458
522
 
459
- printChoices("Authentication", [
460
- ["1", "basic", "username/password"],
461
- ["2", "github", "GitHub OAuth"],
462
- ["3", "both", "basic + GitHub OAuth"],
463
- ["4", "none", "no app auth"],
464
- ]);
465
-
466
- const answer = await askText("Authentication", fallback);
467
-
468
- if (!answer) return modes.has(fallback) ? fallback : "basic";
469
- if (modes.has(answer)) return answer;
470
- if (answer === "1") return "basic";
471
- if (answer === "2") return "github";
472
- if (answer === "3") return "both";
473
- if (answer === "4") return "none";
474
- throw new Error("Authentication must be basic, github, both, or none");
523
+ return promptResult(
524
+ p.select({
525
+ message: "Authentication",
526
+ initialValue: modes.has(fallback) ? fallback : "basic",
527
+ options: [
528
+ { value: "basic", label: "Basic", hint: "username/password" },
529
+ { value: "github", label: "GitHub OAuth" },
530
+ { value: "both", label: "Basic + GitHub OAuth" },
531
+ { value: "none", label: "None", hint: "no app auth" },
532
+ ],
533
+ }),
534
+ );
475
535
  }
476
536
 
477
537
  function usesBasicAuth(mode) {
@@ -488,25 +548,23 @@ async function chooseVmImage(fallback) {
488
548
  if (!input.isTTY) return fallback;
489
549
 
490
550
  const names = Object.keys(vmImages);
491
- printChoices(
492
- "VM image",
493
- names.map((name, index) => [
494
- String(index + 1),
495
- name,
496
- vmImages[name],
497
- ]).concat([["4", "custom", "enter a full image name"]]),
551
+ const answer = await promptResult(
552
+ p.select({
553
+ message: "VM image",
554
+ initialValue: vmImages[fallback] ? fallback : "custom",
555
+ options: names
556
+ .map((name) => ({ value: name, label: name, hint: vmImages[name] }))
557
+ .concat([
558
+ { value: "custom", label: "Custom", hint: "enter a full image name" },
559
+ ]),
560
+ }),
498
561
  );
499
562
 
500
- const answer = await askText("VM image", fallback);
501
-
502
- if (!answer) return fallback;
503
- if (vmImages[answer]) return answer;
504
- if (/^[1-3]$/.test(answer)) return names[Number(answer) - 1];
505
- if (answer === "4")
563
+ if (answer === "custom")
506
564
  return value(
507
565
  "custom-vm-image",
508
566
  "Custom VM image",
509
- defaults["custom-vm-image"],
567
+ vmImages[fallback] ? defaults["custom-vm-image"] : fallback,
510
568
  );
511
569
  return answer;
512
570
  }
@@ -515,21 +573,53 @@ async function chooseShell(fallback) {
515
573
  if (args.shell) return args.shell;
516
574
  if (!input.isTTY) return fallback;
517
575
 
518
- printChoices(
519
- "Shell",
520
- shells.map((shell, index) => [
521
- String(index + 1),
522
- shell,
523
- index === shells.length - 1 ? "default" : "",
524
- ]),
576
+ return promptResult(
577
+ p.select({
578
+ message: "Shell",
579
+ initialValue: shells.includes(fallback) ? fallback : "/bin/sh",
580
+ options: shells.map((shell, index) => ({
581
+ value: shell,
582
+ label: shell,
583
+ hint: index === shells.length - 1 ? "default" : undefined,
584
+ })),
585
+ }),
525
586
  );
587
+ }
526
588
 
527
- const answer = await askText("Shell", fallback);
589
+ async function chooseTools(fallback) {
590
+ if (args.tools !== undefined) return parseTools(args.tools).join(",");
591
+ if (!input.isTTY) return parseTools(fallback).join(",");
592
+
593
+ const names = Object.keys(toolChoices);
594
+ const selected = await promptResult(
595
+ p.multiselect({
596
+ message: "Preinstall tools",
597
+ required: false,
598
+ initialValues: parseTools(fallback),
599
+ options: names.map((name) => ({
600
+ value: name,
601
+ label: name,
602
+ hint: toolChoices[name],
603
+ })),
604
+ }),
605
+ );
528
606
 
529
- if (!answer) return fallback;
530
- if (shells.includes(answer)) return answer;
531
- if (/^[1-3]$/.test(answer)) return shells[Number(answer) - 1];
532
- return answer;
607
+ return selected.join(",");
608
+ }
609
+
610
+ function parseTools(value) {
611
+ const names = Object.keys(toolChoices);
612
+ const selected = String(value || "")
613
+ .split(",")
614
+ .map((item) => item.trim())
615
+ .filter(Boolean)
616
+ .flatMap((item) => {
617
+ if (item === "0" || item === "none") return [];
618
+ if (/^[1-3]$/.test(item)) return [names[Number(item) - 1]];
619
+ return [item];
620
+ })
621
+ .filter((item) => names.includes(item));
622
+ return [...new Set(selected)];
533
623
  }
534
624
 
535
625
  async function readDefaults(file) {
@@ -689,6 +779,10 @@ function findLastUrl(text) {
689
779
  }
690
780
 
691
781
  function printHeader() {
782
+ if (input.isTTY) {
783
+ p.intro("Vercel VM Factory");
784
+ return;
785
+ }
692
786
  console.log("");
693
787
  console.log(color.cyan(" __ ____ __ _____ _ "));
694
788
  console.log(color.cyan(" \\ \\ / / \\/ | | ___|_ _ ___| |_ ___ _ __ _ _ "));
@@ -740,25 +834,35 @@ function printKeyValue(key, value) {
740
834
  }
741
835
 
742
836
  async function askText(question, fallback = "") {
743
- const suffix = fallback ? color.dim(` [${fallback}]`) : "";
744
- const rl = createInterface({ input, output });
745
- const answer = (
746
- await rl.question(
747
- `${color.cyan("?")} ${color.bold(question)}${suffix}\n${color.dim("> ")} `,
748
- )
837
+ return String(
838
+ await promptResult(
839
+ p.text({
840
+ message: question,
841
+ placeholder: fallback || undefined,
842
+ }),
843
+ ),
749
844
  ).trim();
750
- rl.close();
751
- return answer;
752
845
  }
753
846
 
754
- function printChoices(title, choices) {
755
- console.log("");
756
- console.log(color.bold(title));
757
- for (const [key, label, detail] of choices) {
758
- const hint = detail ? ` ${color.dim(detail)}` : "";
759
- console.log(` ${color.cyan(key)} ${label}${hint}`);
847
+ async function askSecret(question, fallback = "") {
848
+ return String(
849
+ await promptResult(
850
+ p.password({
851
+ message: question,
852
+ mask: "*",
853
+ placeholder: fallback || undefined,
854
+ }),
855
+ ),
856
+ ).trim();
857
+ }
858
+
859
+ async function promptResult(resultPromise) {
860
+ const result = await resultPromise;
861
+ if (p.isCancel(result)) {
862
+ p.cancel("Operation cancelled.");
863
+ process.exit(0);
760
864
  }
761
- console.log("");
865
+ return result;
762
866
  }
763
867
 
764
868
  function step(text) {
@@ -792,6 +896,7 @@ Options:
792
896
  --scope SLUG Optional Vercel team/user scope slug
793
897
  --from IMAGE Source image for /app/bin/wsterm
794
898
  --shell PATH /bin/bash, /bin/zsh, or /bin/sh
899
+ --tools LIST nodejs,codex,claude-code
795
900
  --auth-mode MODE basic, github, both, or none
796
901
  --auth-user VALUE Username/password auth user
797
902
  --auth-password VAL Username/password auth password
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vercel-vm-factory",
3
- "version": "0.8.8",
3
+ "version": "0.10.8",
4
4
  "description": "Create Vercel Container deployments for ws-shell from selectable VM images.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,9 @@
12
12
  "deploy-vm.mjs",
13
13
  "README.md"
14
14
  ],
15
+ "dependencies": {
16
+ "@clack/prompts": "^0.11.0"
17
+ },
15
18
  "scripts": {
16
19
  "deploy": "node deploy-vm.mjs create",
17
20
  "doctor": "node deploy-vm.mjs doctor",