vercel-vm-factory 0.6.8 → 0.8.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 +9 -0
  2. package/deploy-vm.mjs +132 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,6 +5,7 @@ Create a tiny Vercel Container deployment: copy `wsterm` from `ghcr.io/v1xingyue
5
5
  ```bash
6
6
  npx vercel-vm-factory create \
7
7
  --vm-image ubuntu \
8
+ --shell /bin/bash \
8
9
  --project ws-shell-ubuntu \
9
10
  --auth-mode basic \
10
11
  --auth-user admin \
@@ -56,6 +57,14 @@ VM image presets:
56
57
  - `ubuntu` -> `ubuntu:24.04`
57
58
  - `debian` -> `debian:13-slim`
58
59
 
60
+ Shell options:
61
+
62
+ - `/bin/bash`
63
+ - `/bin/zsh`
64
+ - `/bin/sh`
65
+
66
+ Choosing bash or zsh adds the matching package to the generated Dockerfile when the VM image does not already include it.
67
+
59
68
  Custom VM image:
60
69
 
61
70
  ```bash
package/deploy-vm.mjs CHANGED
@@ -13,6 +13,7 @@ const vmImages = {
13
13
  ubuntu: "ubuntu:24.04",
14
14
  debian: "debian:13-slim",
15
15
  };
16
+ const shells = ["/bin/bash", "/bin/zsh", "/bin/sh"];
16
17
 
17
18
  const { command, args } = parseCommand(process.argv.slice(2));
18
19
  const scriptRoot = path.resolve(import.meta.dirname);
@@ -20,7 +21,11 @@ const workspaceRoot = process.cwd();
20
21
  const stateRoot = path.join(homedir(), ".vercel-vm-factory");
21
22
  const defaultsPath = path.join(stateRoot, "defaults.json");
22
23
  const legacyDefaultsPath = path.join(scriptRoot, ".defaults.json");
23
- const codeDefaults = { "vm-image": "alpine", from: defaultWsShellImage };
24
+ const codeDefaults = {
25
+ "vm-image": "alpine",
26
+ from: defaultWsShellImage,
27
+ shell: "/bin/sh",
28
+ };
24
29
  const packagedDefaults = {
25
30
  ...(await readDefaults(legacyDefaultsPath)),
26
31
  ...codeDefaults,
@@ -90,6 +95,7 @@ async function main() {
90
95
  process.env.WS_SHELL_IMAGE ??
91
96
  defaults.from ??
92
97
  defaultWsShellImage;
98
+ const shell = await chooseShell(args.shell ?? defaults.shell ?? "/bin/sh");
93
99
  const prod = args.prod !== "false";
94
100
  const dryRun = Boolean(args["dry-run"]);
95
101
  const skipLink = Boolean(args["skip-link"]);
@@ -107,7 +113,12 @@ async function main() {
107
113
  ".generated",
108
114
  project,
109
115
  );
110
- const dockerfile = makeDockerfile({ vmImage, wsShellImage });
116
+ const dockerfile = makeDockerfile({
117
+ shell,
118
+ vmImage,
119
+ vmImageName,
120
+ wsShellImage,
121
+ });
111
122
 
112
123
  await rm(appDir, { recursive: true, force: true });
113
124
  await mkdir(appDir, { recursive: true });
@@ -118,6 +129,7 @@ async function main() {
118
129
  project,
119
130
  scope: scope || "default",
120
131
  source: wsShellImage,
132
+ shell,
121
133
  auth: authMode,
122
134
  ...(usesGitHubAuth(authMode) ? { callback: oauthRedirectUrl } : {}),
123
135
  dockerfile: path.join(appDir, "Dockerfile.vercel"),
@@ -173,6 +185,7 @@ async function main() {
173
185
  scope: scope || undefined,
174
186
  project,
175
187
  from: wsShellImage,
188
+ shell,
176
189
  "auth-mode": authMode,
177
190
  "auth-user": authUsername,
178
191
  "auth-password": authPassword,
@@ -272,15 +285,12 @@ async function installVercelCli() {
272
285
  );
273
286
 
274
287
  const install = await choosePackageInstall();
275
- const rl = createInterface({ input, output });
276
288
  const answer = (
277
- await rl.question(
278
- `Vercel CLI is not installed. Install it with "${install.command} ${install.args.join(" ")}"? [y/N]: `,
289
+ await askText(
290
+ `Vercel CLI is not installed. Install with ${install.command} ${install.args.join(" ")}?`,
291
+ "N",
279
292
  )
280
- )
281
- .trim()
282
- .toLowerCase();
283
- rl.close();
293
+ ).toLowerCase();
284
294
 
285
295
  if (answer !== "y" && answer !== "yes")
286
296
  throw new Error("Vercel CLI is required. Exiting.");
@@ -319,6 +329,7 @@ async function doctor() {
319
329
  printKeyValue("project", defaults.project || "not set");
320
330
  printKeyValue("scope", defaults.scope || "not set");
321
331
  printKeyValue("source image", defaults.from || defaultWsShellImage);
332
+ printKeyValue("shell", defaults.shell || "/bin/sh");
322
333
  printKeyValue("auth mode", defaults["auth-mode"] || "not set");
323
334
  printKeyValue(
324
335
  "auth user",
@@ -342,33 +353,56 @@ async function doctor() {
342
353
  );
343
354
  }
344
355
 
345
- function makeDockerfile({ vmImage, wsShellImage }) {
356
+ function makeDockerfile({ shell, vmImage, vmImageName, wsShellImage }) {
357
+ const shellInstall = makeShellInstall({ shell, vmImageName });
346
358
  return `ARG WS_SHELL_IMAGE=${wsShellImage}
347
359
  ARG VM_IMAGE=${vmImage}
348
360
 
349
361
  FROM \${WS_SHELL_IMAGE} AS ws-shell
350
362
  FROM \${VM_IMAGE} AS vm
351
-
363
+ ${shellInstall ? `\n${shellInstall}\n` : ""}
352
364
  # wsterm already embeds the web UI; runtime config comes from environment variables.
353
365
  COPY --from=ws-shell /app/bin/wsterm /app/bin/wsterm
354
366
 
355
367
  WORKDIR /app
356
368
  ENV ENABLE_SSL=false
357
369
  EXPOSE 80
358
- CMD ["/app/bin/wsterm","-bind",":80","-fork","/bin/sh"]
370
+ CMD ["/app/bin/wsterm","-bind",":80","-fork","${shell}"]
359
371
  `;
360
372
  }
361
373
 
374
+ function makeShellInstall({ shell, vmImageName }) {
375
+ const packageName = path.basename(shell);
376
+ if (packageName === "sh") return "";
377
+
378
+ if (vmImageName === "alpine")
379
+ return `RUN apk add --no-cache ${packageName}`;
380
+
381
+ if (vmImageName === "ubuntu" || vmImageName === "debian")
382
+ return `RUN apt-get update \\
383
+ && apt-get install -y --no-install-recommends ${packageName} \\
384
+ && rm -rf /var/lib/apt/lists/*`;
385
+
386
+ return `RUN if command -v ${shell} >/dev/null 2>&1; then \\
387
+ true; \\
388
+ elif command -v apk >/dev/null 2>&1; then \\
389
+ apk add --no-cache ${packageName}; \\
390
+ elif command -v apt-get >/dev/null 2>&1; then \\
391
+ apt-get update \\
392
+ && apt-get install -y --no-install-recommends ${packageName} \\
393
+ && rm -rf /var/lib/apt/lists/*; \\
394
+ else \\
395
+ echo "Cannot install ${packageName}: unsupported VM image package manager" >&2; \\
396
+ exit 1; \\
397
+ fi`;
398
+ }
399
+
362
400
  async function value(name, question, fallback) {
363
401
  const current = args[name] ?? fallback;
364
402
  if (args[name]) return args[name];
365
403
  if (!input.isTTY) return current || "";
366
404
 
367
- const rl = createInterface({ input, output });
368
- const answer = (
369
- await rl.question(`${question}${fallback ? ` [${fallback}]` : ""}: `)
370
- ).trim();
371
- rl.close();
405
+ const answer = await askText(question, fallback);
372
406
  return answer || current || "";
373
407
  }
374
408
 
@@ -376,10 +410,8 @@ async function secret(name, question, fallback) {
376
410
  if (args[name]) return args[name];
377
411
  if (!input.isTTY) return fallback || "";
378
412
 
379
- const rl = createInterface({ input, output });
380
413
  const placeholder = fallback ? mask(fallback) : "skip";
381
- const answer = (await rl.question(`${question} [${placeholder}]: `)).trim();
382
- rl.close();
414
+ const answer = await askText(question, placeholder);
383
415
  return answer || fallback || "";
384
416
  }
385
417
 
@@ -387,10 +419,10 @@ async function optionalValue(name, question, fallback) {
387
419
  if (args[name] !== undefined) return args[name];
388
420
  if (!input.isTTY) return "";
389
421
 
390
- const rl = createInterface({ input, output });
391
- const suffix = fallback ? ` [${fallback}; Enter to skip]` : " [skip]";
392
- const answer = (await rl.question(`${question}${suffix}: `)).trim();
393
- rl.close();
422
+ const answer = await askText(
423
+ question,
424
+ fallback ? `${fallback}; Enter to skip` : "skip",
425
+ );
394
426
  return answer;
395
427
  }
396
428
 
@@ -424,15 +456,14 @@ async function chooseAuthMode(fallback) {
424
456
  }
425
457
  if (!input.isTTY) return modes.has(fallback) ? fallback : "basic";
426
458
 
427
- console.log(color.bold("Choose authentication"));
428
- console.log(` ${color.cyan("1")}. basic username/password`);
429
- console.log(` ${color.cyan("2")}. GitHub OAuth`);
430
- console.log(` ${color.cyan("3")}. both`);
431
- console.log(` ${color.cyan("4")}. none`);
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
+ ]);
432
465
 
433
- const rl = createInterface({ input, output });
434
- const answer = (await rl.question(`Authentication [${fallback}]: `)).trim();
435
- rl.close();
466
+ const answer = await askText("Authentication", fallback);
436
467
 
437
468
  if (!answer) return modes.has(fallback) ? fallback : "basic";
438
469
  if (modes.has(answer)) return answer;
@@ -457,17 +488,16 @@ async function chooseVmImage(fallback) {
457
488
  if (!input.isTTY) return fallback;
458
489
 
459
490
  const names = Object.keys(vmImages);
460
- console.log(color.bold("Choose VM image"));
461
- names.forEach((name, index) =>
462
- console.log(
463
- ` ${color.cyan(String(index + 1))}. ${name} ${color.dim(`(${vmImages[name]})`)}`,
464
- ),
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"]]),
465
498
  );
466
- console.log(` ${color.cyan("4")}. custom image`);
467
499
 
468
- const rl = createInterface({ input, output });
469
- const answer = (await rl.question(`VM image [${fallback}]: `)).trim();
470
- rl.close();
500
+ const answer = await askText("VM image", fallback);
471
501
 
472
502
  if (!answer) return fallback;
473
503
  if (vmImages[answer]) return answer;
@@ -481,6 +511,27 @@ async function chooseVmImage(fallback) {
481
511
  return answer;
482
512
  }
483
513
 
514
+ async function chooseShell(fallback) {
515
+ if (args.shell) return args.shell;
516
+ if (!input.isTTY) return fallback;
517
+
518
+ printChoices(
519
+ "Shell",
520
+ shells.map((shell, index) => [
521
+ String(index + 1),
522
+ shell,
523
+ index === shells.length - 1 ? "default" : "",
524
+ ]),
525
+ );
526
+
527
+ const answer = await askText("Shell", fallback);
528
+
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;
533
+ }
534
+
484
535
  async function readDefaults(file) {
485
536
  try {
486
537
  return JSON.parse(await readFile(file, "utf8"));
@@ -638,18 +689,30 @@ function findLastUrl(text) {
638
689
  }
639
690
 
640
691
  function printHeader() {
641
- console.log(color.bold(color.cyan("Vercel VM Factory")));
642
- console.log(
643
- color.dim("Build a Container deployment from a tiny Dockerfile.vercel"),
644
- );
692
+ console.log("");
693
+ console.log(color.cyan(" __ ____ __ _____ _ "));
694
+ console.log(color.cyan(" \\ \\ / / \\/ | | ___|_ _ ___| |_ ___ _ __ _ _ "));
695
+ console.log(color.cyan(" \\ \\ / /| |\\/| | | |_ / _` |/ __| __/ _ \\| '__| | | |"));
696
+ console.log(color.cyan(" \\ V / | | | | | _| (_| | (__| || (_) | | | |_| |"));
697
+ console.log(color.cyan(" \\_/ |_| |_| |_| \\__,_|\\___|\\__\\___/|_| \\__, |"));
698
+ console.log(color.cyan(" |___/ "));
699
+ console.log(color.dim(" Vercel Container VM deployment helper"));
645
700
  console.log("");
646
701
  }
647
702
 
648
703
  function printSummary(items) {
704
+ const entries = Object.entries(items);
705
+ const keyWidth = Math.max(...entries.map(([key]) => key.length), 1);
706
+ const valueWidth = Math.max(...entries.map(([, value]) => String(value).length), 1);
707
+ const width = keyWidth + valueWidth + 5;
708
+
649
709
  console.log(color.bold("Deployment plan"));
710
+ console.log(color.cyan(`+${"-".repeat(width)}+`));
650
711
  for (const [key, value] of Object.entries(items)) {
651
- printKeyValue(key, value);
712
+ const line = `${key.padEnd(keyWidth)} ${String(value).padEnd(valueWidth)}`;
713
+ console.log(`${color.cyan("|")} ${line} ${color.cyan("|")}`);
652
714
  }
715
+ console.log(color.cyan(`+${"-".repeat(width)}+`));
653
716
  console.log("");
654
717
  }
655
718
 
@@ -676,6 +739,28 @@ function printKeyValue(key, value) {
676
739
  console.log(`${color.dim(`${key.padEnd(14)} `)}${value}`);
677
740
  }
678
741
 
742
+ 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
+ )
749
+ ).trim();
750
+ rl.close();
751
+ return answer;
752
+ }
753
+
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}`);
760
+ }
761
+ console.log("");
762
+ }
763
+
679
764
  function step(text) {
680
765
  console.log(`${color.cyan("->")} ${text}`);
681
766
  }
@@ -706,6 +791,7 @@ Options:
706
791
  --project NAME Vercel project name
707
792
  --scope SLUG Optional Vercel team/user scope slug
708
793
  --from IMAGE Source image for /app/bin/wsterm
794
+ --shell PATH /bin/bash, /bin/zsh, or /bin/sh
709
795
  --auth-mode MODE basic, github, both, or none
710
796
  --auth-user VALUE Username/password auth user
711
797
  --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.6.8",
3
+ "version": "0.8.8",
4
4
  "description": "Create Vercel Container deployments for ws-shell from selectable VM images.",
5
5
  "license": "MIT",
6
6
  "type": "module",