openship 0.6.0 → 0.6.5

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 (63) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-FSUAM5V5.js → bare-XQIPDO4R.js} +10 -9
  3. package/dist/{chunk-PSZSKEPA.js → chunk-3ZAGC5NT.js} +64 -17
  4. package/dist/{chunk-6ODUBKUB.js → chunk-5OUWIPOB.js} +69 -7
  5. package/dist/chunk-6HOA5JT7.js +124 -0
  6. package/dist/{chunk-TWJCLD4W.js → chunk-6JELW75A.js} +4 -4
  7. package/dist/{chunk-GUQ3QCJ7.js → chunk-7334NSJL.js} +399 -369
  8. package/dist/chunk-7S4USQYF.js +1753 -0
  9. package/dist/{chunk-3LDWWY7U.js → chunk-A7EWM2KA.js} +41 -7
  10. package/dist/chunk-ELGRMZOW.js +25 -0
  11. package/dist/chunk-EZPPTWJQ.js +292 -0
  12. package/dist/{chunk-IXM5245F.js → chunk-GRTXMUHA.js} +553 -538
  13. package/dist/chunk-HPXP6LXZ.js +428 -0
  14. package/dist/{chunk-OTLSJYAC.js → chunk-LEYSL46B.js} +2135 -85
  15. package/dist/{chunk-EMXI4UCU.js → chunk-LFCSC4IU.js} +29 -10
  16. package/dist/{chunk-JIMUYEKN.js → chunk-MSZUCNPV.js} +1 -1
  17. package/dist/{chunk-OGY63DGD.js → chunk-PZRRL5PC.js} +71 -28
  18. package/dist/{chunk-VE2YOK4I.js → chunk-QLX7NBEA.js} +1218 -588
  19. package/dist/chunk-RFWJE7ZN.js +68 -0
  20. package/dist/{chunk-VDDMADOF.js → chunk-TM2IQPYU.js} +16 -2
  21. package/dist/{chunk-3FYIXVX5.js → chunk-VDESJSG6.js} +451 -106
  22. package/dist/{chunk-B3CE2OMR.js → chunk-VHMOQL5Y.js} +276 -50
  23. package/dist/{chunk-QEH4QUBX.js → chunk-VLQ3UIVO.js} +393 -53
  24. package/dist/{chunk-VZ2ZHPS2.js → chunk-WE7BXW6L.js} +3 -9
  25. package/dist/chunk-WFRXYWK2.js +444 -0
  26. package/dist/chunk-XRNRT37W.js +316 -0
  27. package/dist/{chunk-EP2TNALB.js → chunk-XVOBL7BC.js} +31 -8
  28. package/dist/cloud-3DODOWTR.js +36 -0
  29. package/dist/{detect-ALF4GXRX.js → detect-P4CIQBRH.js} +3 -3
  30. package/dist/{docker-QECQJ5P4.js → docker-3K2NEBD6.js} +15 -8
  31. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-YZZ6P7IC.js} +5 -5
  32. package/dist/{edge-import-CQWHN2Q3.js → edge-import-MCJNVTZ2.js} +26 -23
  33. package/dist/edge-preflight-ES6FH4JK.js +42 -0
  34. package/dist/{ensure-container-edge-MJVXJQG5.js → ensure-container-edge-YPITNH45.js} +9 -8
  35. package/dist/{executor-JELARVKC.js → executor-DZGX55K2.js} +12 -5
  36. package/dist/host-channel-preflight-MUXE3KZU.js +49 -0
  37. package/dist/index.js +3668 -4063
  38. package/dist/{nginx-AXGNWRKZ.js → nginx-NSAHWGJV.js} +11 -5
  39. package/dist/node-bootstrap.js +241 -0
  40. package/dist/node-entry.js +52 -1
  41. package/dist/{openresty-lua-IE4P5YQX.js → openresty-lua-DR4D3EZ6.js} +6 -2
  42. package/dist/privilege-YTVI4SKF.js +19 -0
  43. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  44. package/dist/server/index.js +21501 -13137
  45. package/dist/server/lua/geo_country.lua +4 -3
  46. package/dist/server/lua/pipe_stream.lua +52 -39
  47. package/dist/server/lua/site_logger.lua +22 -24
  48. package/dist/server/migrations/0098_instance_product_mode.sql +12 -0
  49. package/dist/server/migrations/0099_mail_webmail_project.sql +24 -0
  50. package/dist/server/migrations/0100_server_ssh_private_key.sql +11 -0
  51. package/dist/server/migrations/0101_mail_resume_step.sql +12 -0
  52. package/dist/server/migrations/0102_project_workload_axes.sql +19 -0
  53. package/dist/server/migrations/0103_dns_credential.sql +41 -0
  54. package/dist/server/migrations/0104_instance_host_control.sql +15 -0
  55. package/dist/server/migrations/meta/_journal.json +49 -0
  56. package/dist/{setup-FA4PW4NU.js → setup-WUUZURHS.js} +10 -9
  57. package/dist/up-XEO3SR6Z.js +58 -0
  58. package/package.json +4 -2
  59. package/dist/chunk-IW2UKBVF.js +0 -144
  60. package/dist/chunk-ZCFVHA25.js +0 -89
  61. package/dist/cloud-E5M6VMCG.js +0 -31
  62. package/dist/server/lua/pipe_log.lua +0 -77
  63. /package/dist/{dist-es-JGBPABJI.js → dist-es-ZP5QDSJ3.js} +0 -0
@@ -7,31 +7,38 @@ import {
7
7
  resolveProjectDir,
8
8
  resolveStaticOutputPath,
9
9
  runLocalBuild
10
- } from "./chunk-JIMUYEKN.js";
11
- import {
12
- resolveEnvironment
13
- } from "./chunk-IW2UKBVF.js";
10
+ } from "./chunk-MSZUCNPV.js";
14
11
  import {
15
12
  BuildCancelledError,
16
13
  BuildLogger,
17
14
  detectBuildKillHint,
15
+ killProcessesUnderDir,
18
16
  parseLogLevel,
19
17
  runBuildPipeline,
20
18
  sq,
21
19
  transferLocalDirectory
22
- } from "./chunk-VDDMADOF.js";
23
- import {
24
- formatDuration,
25
- isRemoteConnectionError,
26
- isRetryableRemoteConnectionError,
27
- systemDebug
28
- } from "./chunk-ZCFVHA25.js";
20
+ } from "./chunk-TM2IQPYU.js";
29
21
  import {
30
22
  LocalExecutor
31
23
  } from "./chunk-6FH4AXVY.js";
32
24
  import {
33
- probeListeningPort
34
- } from "./chunk-3LDWWY7U.js";
25
+ privilegedExecutor
26
+ } from "./chunk-RFWJE7ZN.js";
27
+ import {
28
+ probeExec,
29
+ probeListeningPort,
30
+ withReason
31
+ } from "./chunk-A7EWM2KA.js";
32
+ import {
33
+ HOST_STATE_DIR,
34
+ envOps,
35
+ hostRefusal,
36
+ opScript
37
+ } from "./chunk-EZPPTWJQ.js";
38
+ import {
39
+ ensureOwnedDir,
40
+ resolveEnvironment
41
+ } from "./chunk-WFRXYWK2.js";
35
42
  import {
36
43
  sq as sq2,
37
44
  wrapLocalBuildCommand
@@ -40,23 +47,30 @@ import {
40
47
  DeployError,
41
48
  LANGUAGES,
42
49
  STACKS,
50
+ answered,
43
51
  appVolumeTargets,
44
52
  buildOutputTransferExcludes,
53
+ formatDuration,
54
+ isRemoteConnectionError,
55
+ isRetryableRemoteConnectionError,
45
56
  missingOutputDirectoryMessage,
46
57
  packageManagerEnsureCommand,
47
- safeErrorMessage
48
- } from "./chunk-OTLSJYAC.js";
58
+ refused,
59
+ safeErrorMessage,
60
+ shellQuote,
61
+ systemDebug
62
+ } from "./chunk-LEYSL46B.js";
49
63
 
50
64
  // ../../packages/adapters/src/system/remote-journal.ts
51
65
  var OPSH_RUN_VERSION = 1;
52
- var DEFAULT_JOURNAL_BASE = "/root/.openship";
66
+ var DEFAULT_JOURNAL_BASE = HOST_STATE_DIR;
53
67
  var REMOTE_ENV_PREFIX = "export DEBIAN_FRONTEND=noninteractive DPKG_FORCE=confnew && ";
54
68
  var OPSH_RUN_SCRIPT = `#!/bin/sh
55
69
  # opsh-run \u2014 openship reliable command journal. Managed file; do not edit.
56
70
  VERSION=${OPSH_RUN_VERSION}
57
71
 
58
72
  BASE="$OPSH_BASE"
59
- [ -z "$BASE" ] && BASE=/root/.openship
73
+ [ -z "$BASE" ] && BASE=${HOST_STATE_DIR}
60
74
  OPS="$BASE/ops"
61
75
 
62
76
  if [ "$1" = "--version" ]; then echo "$VERSION"; exit 0; fi
@@ -139,6 +153,14 @@ ERR="$(openssl base64 -A < "$DIR/stderr" 2>/dev/null)"
139
153
  echo "OPSH1 $CODE $OUT $ERR"
140
154
  exit 0
141
155
  `;
156
+ async function resolveJournalBase(exec) {
157
+ try {
158
+ return envOps(await resolveEnvironment(exec)).scratchDir();
159
+ } catch (err) {
160
+ if (isRemoteConnectionError(err)) throw err;
161
+ return DEFAULT_JOURNAL_BASE;
162
+ }
163
+ }
142
164
  async function ensureRemoteJournal(exec, baseDir) {
143
165
  const runner = `${baseDir}/bin/opsh-run`;
144
166
  let current = -1;
@@ -213,7 +235,7 @@ function sleep(ms) {
213
235
  });
214
236
  }
215
237
  async function runReliable(acquire, opId, command, opts) {
216
- const baseDir = opts.baseDir ?? DEFAULT_JOURNAL_BASE;
238
+ let baseDir = opts.baseDir ?? null;
217
239
  const timeoutMs = opts.timeoutMs ?? DEFAULT_RELIABLE_TIMEOUT_MS;
218
240
  const deadline = Date.now() + timeoutMs;
219
241
  const minMs = opts.reconnectMinMs ?? DEFAULT_RECONNECT_MIN_MS;
@@ -235,6 +257,7 @@ async function runReliable(acquire, opId, command, opts) {
235
257
  continue;
236
258
  }
237
259
  try {
260
+ baseDir ??= await resolveJournalBase(executor);
238
261
  if (!opts.ensured || !opts.ensured.has(executor)) {
239
262
  await ensureRemoteJournal(executor, baseDir);
240
263
  opts.ensured?.add(executor);
@@ -290,472 +313,475 @@ async function execReliable(executor, opId, command, opts = {}) {
290
313
  }
291
314
 
292
315
  // ../../packages/adapters/src/toolchain/catalog.ts
293
- function nodeInstallPlan(profile) {
294
- if (profile.os === "linux" && ["apt", "dnf", "yum"].includes(profile.packageManager)) {
295
- const installCommands = {
296
- // NodeSource's `setup_lts.x` pins the apt repo to $(lsb_release -sc), so it
297
- // breaks on a new/unpublished Ubuntu codename exactly like the OpenResty
298
- // #86 bug (and can leave a poisoned nodesource.list that fails later apt
299
- // runs). Their node_XX.x repos use the distro-agnostic `nodistro` suite, so
300
- // add it directly (no codename, no `curl | bash` of a remote script) and
301
- // heal a stale list first. Pinned to the current LTS major (bump on new LTS).
302
- apt: [
303
- "set -e",
304
- "rm -f /etc/apt/sources.list.d/nodesource.list",
305
- "apt-get update -qq && apt-get install -y -qq curl gnupg ca-certificates",
306
- "curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor --yes -o /usr/share/keyrings/nodesource.gpg",
307
- 'echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > /etc/apt/sources.list.d/nodesource.list',
308
- "apt-get update -qq && apt-get install -y -qq nodejs"
309
- ].join("\n"),
310
- dnf: "curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && dnf install -y nodejs",
311
- yum: "curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && yum install -y nodejs"
312
- };
313
- return {
314
- supported: true,
315
- installCommand: installCommands[profile.packageManager],
316
- verifyCommand: "node --version"
317
- };
318
- }
319
- const fallbacks = {
320
- brew: "brew install node"
321
- };
322
- const fallback = fallbacks[profile.packageManager];
323
- if (!fallback) {
324
- return {
325
- supported: false,
326
- unsupportedReason: "No supported package manager found for Node.js installation"
327
- };
328
- }
329
- return {
330
- supported: true,
331
- installCommand: fallback,
332
- verifyCommand: "node --version"
333
- };
334
- }
335
- function goInstallPlan(profile) {
336
- if (profile.os === "linux") {
337
- const arch = profile.arch === "arm64" ? "arm64" : "amd64";
338
- return {
339
- supported: true,
340
- installCommand: [
341
- `curl -fsSL https://go.dev/dl/go1.22.5.linux-${arch}.tar.gz -o /tmp/go.tar.gz`,
342
- "rm -rf /usr/local/go",
343
- "tar -C /usr/local -xzf /tmp/go.tar.gz",
344
- "rm /tmp/go.tar.gz",
345
- "echo 'export PATH=$PATH:/usr/local/go/bin' >> /etc/profile.d/go.sh",
346
- "export PATH=$PATH:/usr/local/go/bin"
347
- ].join(" && "),
348
- verifyCommand: "/usr/local/go/bin/go version || go version"
349
- };
350
- }
351
- if (profile.packageManager === "brew") {
352
- return {
353
- supported: true,
354
- installCommand: "brew install go",
355
- verifyCommand: "go version"
356
- };
316
+ var CHECKS = {
317
+ node: {
318
+ label: "Node.js",
319
+ versionCommand: "node --version",
320
+ parseVersion: (output) => output.replace(/^v/, "").trim(),
321
+ missingMessage: "Node.js is not installed",
322
+ installable: true
323
+ },
324
+ bun: {
325
+ label: "Bun",
326
+ versionCommand: "bun --version",
327
+ parseVersion: (output) => output.trim(),
328
+ missingMessage: "Bun is not installed",
329
+ installable: true
330
+ },
331
+ npm: {
332
+ label: "npm",
333
+ versionCommand: "npm --version",
334
+ parseVersion: (output) => output.trim(),
335
+ missingMessage: "npm is not installed",
336
+ installable: false,
337
+ providedBy: "node"
338
+ },
339
+ go: {
340
+ label: "Go",
341
+ versionCommand: "go version",
342
+ parseVersion: (output) => output.match(/go(\d+\.\d+(?:\.\d+)?)/)?.[1] ?? output.trim(),
343
+ missingMessage: "Go is not installed",
344
+ installable: true
345
+ },
346
+ rustc: {
347
+ label: "Rust compiler",
348
+ versionCommand: "rustc --version",
349
+ parseVersion: (output) => output.match(/rustc (\S+)/)?.[1] ?? output.trim(),
350
+ missingMessage: "Rust compiler is not installed",
351
+ installable: true
352
+ },
353
+ cargo: {
354
+ label: "Cargo",
355
+ versionCommand: "cargo --version",
356
+ parseVersion: (output) => output.match(/cargo (\S+)/)?.[1] ?? output.trim(),
357
+ missingMessage: "Cargo is not installed",
358
+ installable: false,
359
+ providedBy: "rustc"
360
+ },
361
+ python3: {
362
+ label: "Python 3",
363
+ versionCommand: "python3 --version",
364
+ parseVersion: (output) => output.match(/Python (\S+)/)?.[1] ?? output.trim(),
365
+ missingMessage: "Python 3 is not installed",
366
+ installable: true
367
+ },
368
+ pip: {
369
+ label: "pip",
370
+ versionCommand: "pip3 --version || pip --version",
371
+ parseVersion: (output) => output.match(/pip (\S+)/)?.[1] ?? output.trim(),
372
+ missingMessage: "pip is not installed",
373
+ installable: true,
374
+ providedBy: "python3"
375
+ },
376
+ ruby: {
377
+ label: "Ruby",
378
+ versionCommand: "ruby --version",
379
+ parseVersion: (output) => output.match(/ruby (\S+)/)?.[1] ?? output.trim(),
380
+ missingMessage: "Ruby is not installed",
381
+ installable: true
382
+ },
383
+ bundler: {
384
+ label: "Bundler",
385
+ versionCommand: "bundler --version",
386
+ parseVersion: (output) => output.match(/(\d+\.\d+\.\d+)/)?.[1] ?? output.trim(),
387
+ missingMessage: "Bundler is not installed",
388
+ installable: true,
389
+ providedBy: "ruby"
390
+ },
391
+ php: {
392
+ label: "PHP",
393
+ versionCommand: "php --version",
394
+ parseVersion: (output) => output.match(/PHP (\S+)/)?.[1] ?? output.trim(),
395
+ missingMessage: "PHP is not installed",
396
+ installable: true
397
+ },
398
+ composer: {
399
+ label: "Composer",
400
+ versionCommand: "composer --version",
401
+ parseVersion: (output) => output.match(/(\d+\.\d+\.\d+)/)?.[1] ?? output.trim(),
402
+ missingMessage: "Composer is not installed",
403
+ installable: true,
404
+ providedBy: "php"
405
+ },
406
+ java: {
407
+ label: "Java",
408
+ versionCommand: "java --version 2>&1 || java -version 2>&1",
409
+ parseVersion: (output) => output.match(/(\d+\.\d+)/)?.[1] ?? output.trim(),
410
+ missingMessage: "Java is not installed",
411
+ installable: true
412
+ },
413
+ javac: {
414
+ label: "Java compiler",
415
+ versionCommand: "javac --version 2>&1 || javac -version 2>&1",
416
+ parseVersion: (output) => output.match(/javac (\S+)/)?.[1] ?? output.trim(),
417
+ missingMessage: "Java compiler (javac) is not installed",
418
+ installable: false,
419
+ providedBy: "java"
420
+ },
421
+ maven: {
422
+ label: "Maven",
423
+ versionCommand: "mvn -version 2>&1",
424
+ parseVersion: (output) => output.match(/Apache Maven (\S+)/)?.[1] ?? output.trim(),
425
+ missingMessage: "Maven is not installed",
426
+ installable: true
427
+ },
428
+ gradle: {
429
+ label: "Gradle",
430
+ versionCommand: "gradle --version 2>&1",
431
+ parseVersion: (output) => output.match(/Gradle (\S+)/)?.[1] ?? output.trim(),
432
+ missingMessage: "Gradle is not installed",
433
+ installable: true
434
+ },
435
+ dotnet: {
436
+ label: ".NET SDK",
437
+ versionCommand: "dotnet --version",
438
+ parseVersion: (output) => output.trim(),
439
+ missingMessage: ".NET SDK is not installed",
440
+ installable: true
441
+ },
442
+ elixir: {
443
+ label: "Elixir",
444
+ versionCommand: "elixir --version 2>&1",
445
+ parseVersion: (output) => output.match(/Elixir (\S+)/)?.[1] ?? output.trim(),
446
+ missingMessage: "Elixir is not installed",
447
+ installable: true
448
+ },
449
+ mix: {
450
+ label: "Mix",
451
+ versionCommand: "mix --version 2>&1",
452
+ parseVersion: (output) => output.match(/Mix (\S+)/)?.[1] ?? output.trim(),
453
+ missingMessage: "Mix is not installed",
454
+ installable: false,
455
+ providedBy: "elixir"
357
456
  }
358
- return {
359
- supported: false,
360
- unsupportedReason: "Go installation is only supported on Linux and macOS (brew)"
361
- };
362
- }
363
- function rustInstallPlan(_profile) {
364
- return {
365
- supported: true,
366
- installCommand: "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && . $HOME/.cargo/env",
367
- verifyCommand: ". $HOME/.cargo/env && rustc --version"
368
- };
369
- }
370
- function pythonInstallPlan(profile) {
371
- const commands = {
372
- apt: "apt-get update -qq && apt-get install -y -qq python3 python3-pip python3-venv",
373
- dnf: "dnf install -y python3 python3-pip",
374
- yum: "yum install -y python3 python3-pip",
375
- brew: "brew install python3"
376
- };
377
- const installCommand = commands[profile.packageManager];
378
- if (!installCommand) {
379
- return {
380
- supported: false,
381
- unsupportedReason: "No supported package manager found for Python installation"
382
- };
457
+ };
458
+ function label(as, parts) {
459
+ const steps = [];
460
+ for (const part of parts) {
461
+ if (typeof part === "string") {
462
+ steps.push({ command: part, as });
463
+ continue;
464
+ }
465
+ if (!part.supported) return part;
466
+ for (const command of part.value) steps.push({ command, as });
383
467
  }
384
- return {
385
- supported: true,
386
- installCommand,
387
- verifyCommand: "python3 --version"
388
- };
468
+ return answered(steps);
389
469
  }
390
- function rubyInstallPlan(profile) {
391
- const commands = {
392
- apt: "apt-get update -qq && apt-get install -y -qq ruby-full build-essential",
393
- dnf: "dnf install -y ruby ruby-devel",
394
- yum: "yum install -y ruby ruby-devel",
395
- brew: "brew install ruby"
396
- };
397
- const installCommand = commands[profile.packageManager];
398
- if (!installCommand) {
399
- return {
400
- supported: false,
401
- unsupportedReason: "No supported package manager found for Ruby installation"
402
- };
403
- }
404
- return {
405
- supported: true,
406
- installCommand,
407
- verifyCommand: "ruby --version"
408
- };
470
+ function asRoot(...parts) {
471
+ return label("root", parts);
409
472
  }
410
- function phpInstallPlan(profile) {
411
- if (profile.packageManager === "apt") {
412
- return {
413
- supported: true,
414
- installCommand: [
415
- "apt-get update -qq",
416
- "apt-get install -y -qq php-cli php-mbstring php-xml php-curl unzip",
417
- "curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer"
418
- ].join(" && "),
419
- verifyCommand: "php --version && composer --version"
420
- };
421
- }
422
- const commands = {
423
- dnf: "dnf install -y php-cli php-mbstring php-xml php-curl composer",
424
- yum: "yum install -y php-cli php-mbstring php-xml php-curl",
425
- brew: "brew install php composer"
426
- };
427
- const installCommand = commands[profile.packageManager];
428
- if (!installCommand) {
429
- return {
430
- supported: false,
431
- unsupportedReason: "No supported package manager found for PHP installation"
432
- };
433
- }
434
- return {
435
- supported: true,
436
- installCommand,
437
- verifyCommand: "php --version"
438
- };
473
+ function asLogin(...parts) {
474
+ return label("login", parts);
439
475
  }
440
- function javaInstallPlan(profile) {
441
- const commands = {
442
- apt: "apt-get update -qq && apt-get install -y -qq openjdk-21-jdk",
443
- dnf: "dnf install -y java-21-openjdk-devel",
444
- yum: "yum install -y java-21-openjdk-devel",
445
- brew: "brew install openjdk@21"
446
- };
447
- const installCommand = commands[profile.packageManager];
448
- if (!installCommand) {
449
- return {
450
- supported: false,
451
- unsupportedReason: "No supported package manager found for Java installation"
452
- };
476
+ function chain(...parts) {
477
+ const steps = [];
478
+ for (const part of parts) {
479
+ if (!part.supported) return part;
480
+ steps.push(...part.value);
453
481
  }
454
- return {
455
- supported: true,
456
- installCommand,
457
- verifyCommand: "java --version 2>&1"
458
- };
482
+ return answered(steps);
459
483
  }
460
- function mavenInstallPlan(profile) {
461
- const commands = {
462
- apt: "apt-get update -qq && apt-get install -y -qq maven",
463
- dnf: "dnf install -y maven",
464
- yum: "yum install -y maven",
465
- brew: "brew install maven"
466
- };
467
- const installCommand = commands[profile.packageManager];
468
- if (!installCommand) {
469
- return {
470
- supported: false,
471
- unsupportedReason: "No supported package manager found for Maven installation"
472
- };
473
- }
474
- return {
475
- supported: true,
476
- installCommand,
477
- verifyCommand: "mvn -version"
478
- };
484
+ function isAmazon(profile) {
485
+ return profile.distroId?.trim().toLowerCase() === "amzn";
479
486
  }
480
- function gradleInstallPlan(profile) {
481
- const commands = {
482
- apt: "apt-get update -qq && apt-get install -y -qq gradle",
483
- brew: "brew install gradle"
484
- };
485
- const installCommand = commands[profile.packageManager];
486
- if (!installCommand) {
487
- return {
488
- supported: false,
489
- unsupportedReason: "No supported package manager found for Gradle installation (use the ./gradlew wrapper instead)"
490
- };
491
- }
492
- return {
493
- supported: true,
494
- installCommand,
495
- verifyCommand: "gradle --version"
496
- };
487
+ var NODE_LTS_MAJOR = 22;
488
+ var GO_VERSION = "1.22.5";
489
+ var BUN_VERSION = "1.2.0";
490
+ var DOTNET_CHANNEL = "8.0";
491
+ var COMPOSER_INSTALL = "curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer";
492
+ var NO_EPEL = "and Openship does not enable third-party repositories (EPEL) on a host it did not provision. Install it yourself, or build in a container.";
493
+ var NO_GRADLE_RPM = `The RHEL family ships no gradle package ${NO_EPEL} Most projects include a ./gradlew wrapper, which the build prefers anyway and which needs only a JDK.`;
494
+ var NO_ELIXIR_RPM = `The RHEL family ships no elixir or erlang package ${NO_EPEL}`;
495
+ function ensureUnzip(ops) {
496
+ if (ops.profile.os === "darwin") return answered([]);
497
+ const install = ops.pkgInstall(["unzip"]);
498
+ if (!install.supported) return answered([]);
499
+ return asRoot(`command -v unzip >/dev/null 2>&1 || { ${opScript(install.value)}; }`);
497
500
  }
498
- function dotnetInstallPlan(profile) {
499
- if (profile.os === "linux") {
500
- return {
501
- supported: true,
502
- installCommand: "curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel 8.0 --install-dir /usr/local/share/dotnet && ln -sf /usr/local/share/dotnet/dotnet /usr/local/bin/dotnet",
503
- verifyCommand: "dotnet --version"
504
- };
505
- }
506
- if (profile.packageManager === "brew") {
507
- return {
508
- supported: true,
509
- installCommand: "brew install dotnet-sdk",
510
- verifyCommand: "dotnet --version"
511
- };
512
- }
513
- return {
514
- supported: false,
515
- unsupportedReason: ".NET SDK installation is only supported on Linux and macOS (brew)"
516
- };
517
- }
518
- function elixirInstallPlan(profile) {
519
- const commands = {
520
- apt: "apt-get update -qq && apt-get install -y -qq erlang elixir",
521
- dnf: "dnf install -y erlang elixir",
522
- brew: "brew install elixir"
523
- };
524
- const installCommand = commands[profile.packageManager];
525
- if (!installCommand) {
526
- return {
527
- supported: false,
528
- unsupportedReason: "No supported package manager found for Elixir installation"
529
- };
530
- }
531
- return {
532
- supported: true,
533
- installCommand,
534
- verifyCommand: "elixir --version"
535
- };
536
- }
537
- function bundlerInstallPlan(_profile) {
538
- return {
539
- supported: true,
540
- installCommand: "gem install bundler",
541
- verifyCommand: "bundler --version"
542
- };
543
- }
544
- function composerInstallPlan(profile) {
545
- if (profile.packageManager === "brew") {
546
- return {
547
- supported: true,
548
- installCommand: "brew install composer",
549
- verifyCommand: "composer --version"
550
- };
551
- }
552
- return {
553
- supported: true,
554
- installCommand: "curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer",
555
- verifyCommand: "composer --version"
556
- };
557
- }
558
- function bunInstallPlan(_profile) {
559
- return {
560
- supported: true,
561
- installCommand: [
562
- "curl -fsSL https://bun.sh/install | bash -s 'bun-v1.2.0'",
563
- "ln -sf $HOME/.bun/bin/bun /usr/local/bin/bun"
564
- ].join(" && "),
565
- verifyCommand: "bun --version"
566
- };
567
- }
568
- function pipInstallPlan(profile) {
569
- const commands = {
570
- apt: "apt-get update -qq && apt-get install -y -qq python3-pip",
571
- dnf: "dnf install -y python3-pip",
572
- yum: "yum install -y python3-pip",
573
- brew: "python3 -m ensurepip --upgrade"
574
- };
575
- const installCommand = commands[profile.packageManager];
576
- if (!installCommand) {
577
- return {
578
- supported: false,
579
- unsupportedReason: "No supported package manager found for pip installation"
580
- };
501
+ var RECIPES = {
502
+ node: {
503
+ verify: "node --version",
504
+ // Total over `SystemPackageManager` with no `default`, so a new manager is a
505
+ // "function lacks ending return statement" error rather than a silent fallthrough.
506
+ steps: (ops) => {
507
+ switch (ops.profile.packageManager) {
508
+ case "apt":
509
+ return asRoot(
510
+ "rm -f /etc/apt/sources.list.d/nodesource.list",
511
+ ops.pkgInstall(["curl", "gnupg", "ca-certificates"]),
512
+ "curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor --yes -o /usr/share/keyrings/nodesource.gpg",
513
+ `echo "deb [signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_LTS_MAJOR}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list`,
514
+ ops.pkgInstall(["nodejs"])
515
+ );
516
+ case "dnf":
517
+ return asRoot(
518
+ `curl -fsSL https://rpm.nodesource.com/setup_${NODE_LTS_MAJOR}.x | bash -`,
519
+ ops.pkgInstall(["nodejs"])
520
+ );
521
+ case "yum":
522
+ return refused(
523
+ `Node ${NODE_LTS_MAJOR} needs glibc 2.28 or newer and this host's release (${ops.profile.prettyName ?? ops.profile.distroId ?? "unknown"}) ships an older one, so the vendor package would install and then fail to load. Use a newer OS release, or build in a container.`
524
+ );
525
+ case "apk":
526
+ return asRoot(ops.pkgInstall(["nodejs", "npm"]));
527
+ case "brew":
528
+ return asRoot(ops.pkgInstall(["node"]));
529
+ case "none":
530
+ return asRoot(ops.pkgInstall(["nodejs"]));
531
+ }
532
+ }
533
+ },
534
+ bun: {
535
+ verify: "bun --version",
536
+ steps: (ops) => {
537
+ if (ops.profile.libc === "musl") {
538
+ return refused(
539
+ "Bun's official installer is a bash script and this host is musl-based (Alpine), which ships busybox ash and no bash. Openship will not add a shell to a host to satisfy an installer. Use Node for this project, or build in a container."
540
+ );
541
+ }
542
+ return chain(
543
+ ensureUnzip(ops),
544
+ // The download lands in the login user's own home, so it must NOT be elevated:
545
+ // under sudo, HOME becomes root's and root would own ~/.bun, leaving `bun install`
546
+ // unable to write its own cache while `verify` still passes because it only reads.
547
+ //
548
+ // HOME is pinned to `home()` rather than left to `$HOME` so the install prefix and
549
+ // the symlink target below are derived from the same value and cannot diverge. The
550
+ // assignment sits on the `bash`, not the `curl` — it prefixes exactly one command,
551
+ // and it is the interpreter that chooses the prefix.
552
+ asLogin(
553
+ `curl -fsSL https://bun.sh/install | HOME=${shellQuote(ops.home())} bash -s 'bun-v${BUN_VERSION}'`
554
+ ),
555
+ // /usr/local/bin is root-owned, and this is what puts bun on PATH for a build.
556
+ asRoot(`ln -sf ${shellQuote(`${ops.home()}/.bun/bin/bun`)} /usr/local/bin/bun`)
557
+ );
558
+ }
559
+ },
560
+ go: {
561
+ verify: "go version",
562
+ steps: (ops) => {
563
+ if (ops.profile.libc === "musl") return asRoot(ops.pkgInstall(["go"]));
564
+ if (ops.profile.os === "darwin") return asRoot(ops.pkgInstall(["go"]));
565
+ const arch = ops.releaseArch();
566
+ if (!arch.supported) return arch;
567
+ return asRoot(
568
+ `curl -fsSL https://go.dev/dl/go${GO_VERSION}.linux-${arch.value}.tar.gz -o /tmp/go.tar.gz`,
569
+ "rm -rf /usr/local/go",
570
+ "tar -C /usr/local -xzf /tmp/go.tar.gz",
571
+ "rm -f /tmp/go.tar.gz",
572
+ // The profile.d line is for an operator's interactive login only. A build runs
573
+ // through a NON-login `ssh host 'go build'`, which sources nothing, so this was
574
+ // the only recipe of the fifteen whose payload never reached a build's PATH: the
575
+ // install went green and `go build` then exited 127. The symlink is what actually
576
+ // puts it there — Go resolves GOROOT from /proc/self/exe, so a link is transparent.
577
+ "ln -sf /usr/local/go/bin/go /usr/local/bin/go",
578
+ "ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt",
579
+ "echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh"
580
+ );
581
+ }
582
+ },
583
+ rustc: {
584
+ verify: ". $HOME/.cargo/env 2>/dev/null; rustc --version",
585
+ steps: (ops) => (
586
+ // rustup's default host triple is gnu, so on musl it fetches a toolchain that
587
+ // cannot run. Alpine's rust package is built for the box it's on.
588
+ ops.profile.libc === "musl" ? asRoot(ops.pkgInstall(["rust", "cargo"])) : chain(
589
+ // Unelevated for the same reason as bun, and doubly so here: rustup owns BOTH
590
+ // ~/.cargo and ~/.rustup, and `cargo build` writes into the registry cache
591
+ // under ~/.cargo on every build. Pinned HOME also makes `verify`
592
+ // (`. $HOME/.cargo/env`, run unelevated) look where the toolchain landed.
593
+ asLogin(
594
+ `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | HOME=${shellQuote(ops.home())} sh -s -- -y --no-modify-path`
595
+ ),
596
+ asRoot(
597
+ `ln -sf ${shellQuote(`${ops.home()}/.cargo/bin/cargo`)} ${shellQuote(`${ops.home()}/.cargo/bin/rustc`)} /usr/local/bin/`
598
+ )
599
+ )
600
+ )
601
+ },
602
+ python3: {
603
+ verify: "python3 --version",
604
+ steps: (ops) => asRoot(
605
+ ops.pkgInstallVariants({
606
+ apt: answered(["python3", "python3-pip", "python3-venv"]),
607
+ // No `python3-venv` on the RPM side — venv lives in `python3-libs` there.
608
+ dnf: answered(["python3", "python3-pip"]),
609
+ yum: answered(["python3", "python3-pip"]),
610
+ apk: answered(["python3", "py3-pip"]),
611
+ brew: answered(["python3"])
612
+ })
613
+ )
614
+ },
615
+ pip: {
616
+ verify: "pip3 --version || pip --version",
617
+ steps: (ops) => asRoot(
618
+ ops.pkgInstallVariants({
619
+ apt: answered(["python3-pip"]),
620
+ dnf: answered(["python3-pip"]),
621
+ yum: answered(["python3-pip"]),
622
+ apk: answered(["py3-pip"]),
623
+ // Homebrew has no `pip` formula; pip arrives with its python.
624
+ brew: refused(
625
+ "Homebrew installs pip as part of Python \u2014 install the python3 tool instead."
626
+ )
627
+ })
628
+ )
629
+ },
630
+ ruby: {
631
+ verify: "ruby --version",
632
+ steps: (ops) => asRoot(
633
+ ops.pkgInstallVariants({
634
+ apt: answered(["ruby-full", "build-essential"]),
635
+ // AL2023 has no unversioned `ruby`; Rocky/Alma/RHEL/Fedora do. Both are dnf,
636
+ // which is exactly the split a package-manager-keyed table cannot express.
637
+ dnf: answered(
638
+ isAmazon(ops.profile) ? ["ruby3.2", "ruby3.2-devel"] : ["ruby", "ruby-devel"]
639
+ ),
640
+ yum: answered(["ruby", "ruby-devel"]),
641
+ // `build-base` because musl has no prebuilt native gems to fall back on.
642
+ apk: answered(["ruby", "ruby-dev", "build-base"]),
643
+ brew: answered(["ruby"])
644
+ })
645
+ )
646
+ },
647
+ bundler: {
648
+ verify: "bundler --version",
649
+ // Ships with every supported Ruby as a default gem; `gem install` only upgrades it.
650
+ // No distro package is worth preferring here.
651
+ steps: () => asRoot("gem install bundler")
652
+ },
653
+ php: {
654
+ verify: "php --version",
655
+ steps: (ops) => asRoot(
656
+ ops.pkgInstallVariants({
657
+ apt: answered(["php-cli", "php-mbstring", "php-xml", "php-curl", "unzip"]),
658
+ // There is no `php-curl` anywhere on the RPM side — curl support ships in
659
+ // `php-common`, which the cli package pulls in. AL2023 versions every name.
660
+ dnf: answered(
661
+ isAmazon(ops.profile) ? ["php8.2-cli", "php8.2-mbstring", "php8.2-xml"] : ["php-cli", "php-mbstring", "php-xml"]
662
+ ),
663
+ yum: answered(["php-cli", "php-mbstring", "php-xml"]),
664
+ // Alpine versions the package and symlinks /usr/bin/php → php83, so `verify`
665
+ // holds without the recipe naming a version.
666
+ apk: answered(["php83"]),
667
+ brew: answered(["php"])
668
+ })
669
+ )
670
+ },
671
+ composer: {
672
+ verify: "composer --version",
673
+ steps: (ops) => {
674
+ if (ops.profile.packageManager === "brew") return asRoot(ops.pkgInstall(["composer"]));
675
+ if (ops.profile.libc === "musl") {
676
+ return refused(
677
+ "Composer needs PHP's phar and openssl extensions, and on musl hosts (Alpine) those ship as separate packages that Openship does not install. Add php-phar and php-openssl for your PHP version yourself, or build in a container."
678
+ );
679
+ }
680
+ return asRoot(COMPOSER_INSTALL);
681
+ }
682
+ },
683
+ java: {
684
+ verify: "java --version 2>&1",
685
+ steps: (ops) => asRoot(
686
+ ops.pkgInstallVariants({
687
+ apt: answered(["openjdk-21-jdk"]),
688
+ // Amazon ships Corretto in place of OpenJDK — `java-21-openjdk-devel` does not
689
+ // exist on AL2023, so a Java build there installed nothing and then failed on a
690
+ // missing `javac`.
691
+ dnf: answered(
692
+ isAmazon(ops.profile) ? ["java-21-amazon-corretto-devel"] : ["java-21-openjdk-devel"]
693
+ ),
694
+ // AL2 and CentOS 7 top out far below 21 (AL2's newest is
695
+ // `java-1.8.0-openjdk-devel`). Installing Java 8 for a recipe that promises 21 is
696
+ // the silent wrong answer, so name the release as the problem instead.
697
+ yum: refused(
698
+ "This host's release carries no OpenJDK 21 package \u2014 the newest it has is Java 8/11, and Openship will not install a JDK older than the toolchain asks for. Use a newer OS release, or build in a container."
699
+ ),
700
+ apk: answered(["openjdk21-jdk"]),
701
+ brew: answered(["openjdk@21"])
702
+ })
703
+ )
704
+ },
705
+ maven: {
706
+ verify: "mvn -version",
707
+ steps: (ops) => asRoot(ops.pkgInstall(["maven"]))
708
+ },
709
+ gradle: {
710
+ verify: "gradle --version",
711
+ steps: (ops) => asRoot(
712
+ ops.pkgInstallVariants({
713
+ apt: answered(["gradle"]),
714
+ dnf: refused(NO_GRADLE_RPM),
715
+ yum: refused(NO_GRADLE_RPM),
716
+ apk: answered(["gradle"]),
717
+ brew: answered(["gradle"])
718
+ })
719
+ )
720
+ },
721
+ dotnet: {
722
+ verify: "dotnet --version",
723
+ steps: (ops) => {
724
+ if (ops.profile.libc === "musl") return asRoot(ops.pkgInstall(["dotnet8-sdk"]));
725
+ if (ops.profile.packageManager === "brew") return asRoot(ops.pkgInstall(["dotnet"]));
726
+ if (ops.profile.os !== "linux") {
727
+ return refused(
728
+ `Openship installs the .NET SDK on Linux, and on macOS through Homebrew. This host reports os=${ops.profile.os}.`
729
+ );
730
+ }
731
+ return asRoot(
732
+ "curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh",
733
+ `bash /tmp/dotnet-install.sh --channel ${DOTNET_CHANNEL} --install-dir /usr/local/share/dotnet`,
734
+ "rm -f /tmp/dotnet-install.sh",
735
+ "ln -sf /usr/local/share/dotnet/dotnet /usr/local/bin/dotnet"
736
+ );
737
+ }
738
+ },
739
+ elixir: {
740
+ verify: "elixir --version 2>&1",
741
+ steps: (ops) => asRoot(
742
+ ops.pkgInstallVariants({
743
+ apt: answered(["erlang", "elixir"]),
744
+ // Keyed on the distro, not the package manager, because the two disagree here:
745
+ // Fedora carries elixir and erlang in its own repos, while AL2023 and Rocky 9
746
+ // — same `dnf` — carry neither. Refusing all of dnf told a Fedora operator to go
747
+ // enable EPEL for a package Fedora already has, and EPEL does not target Fedora
748
+ // at all, so the advice was not just unnecessary but impossible to follow.
749
+ //
750
+ // This is the first production reader of `profile.distro`.
751
+ dnf: ops.profile.distro === "fedora" ? answered(["erlang", "elixir"]) : refused(NO_ELIXIR_RPM),
752
+ yum: refused(NO_ELIXIR_RPM),
753
+ apk: answered(["elixir", "erlang"]),
754
+ brew: answered(["elixir"])
755
+ })
756
+ )
581
757
  }
582
- return {
583
- supported: true,
584
- installCommand,
585
- verifyCommand: "pip3 --version || pip --version"
586
- };
587
- }
758
+ };
759
+ var INSTALLS = Object.fromEntries(
760
+ Object.entries(RECIPES).map(([tool, recipe]) => [
761
+ tool,
762
+ (profile) => {
763
+ const gate = hostRefusal(profile);
764
+ if (gate !== null) return refused(gate);
765
+ const ops = envOps(profile);
766
+ const steps = recipe.steps(ops);
767
+ return steps.supported ? answered({ steps: steps.value, verifyCommand: recipe.verify }) : steps;
768
+ }
769
+ ])
770
+ );
588
771
  var toolchainCatalog = {
589
772
  /**
590
- * Check recipes - how to detect each tool and parse its version.
773
+ * Check recipes how to detect each tool and parse its version.
591
774
  *
592
- * Every tool in LANGUAGES[lang].requiredTools must have an entry here.
775
+ * Every tool named in `LANGUAGES[lang].requiredTools` must have an entry here.
593
776
  */
594
- checks: {
595
- node: {
596
- label: "Node.js",
597
- versionCommand: "node --version",
598
- parseVersion: (output) => output.replace(/^v/, "").trim(),
599
- missingMessage: "Node.js is not installed",
600
- installable: true
601
- },
602
- bun: {
603
- label: "Bun",
604
- versionCommand: "bun --version",
605
- parseVersion: (output) => output.trim(),
606
- missingMessage: "Bun is not installed",
607
- installable: true
608
- },
609
- npm: {
610
- label: "npm",
611
- versionCommand: "npm --version",
612
- parseVersion: (output) => output.trim(),
613
- missingMessage: "npm is not installed",
614
- installable: false,
615
- providedBy: "node"
616
- },
617
- go: {
618
- label: "Go",
619
- versionCommand: "go version",
620
- parseVersion: (output) => output.match(/go(\d+\.\d+(\.\d+)?)/)?.[1] ?? output.trim(),
621
- missingMessage: "Go is not installed",
622
- installable: true
623
- },
624
- rustc: {
625
- label: "Rust compiler",
626
- versionCommand: "rustc --version",
627
- parseVersion: (output) => output.match(/rustc (\S+)/)?.[1] ?? output.trim(),
628
- missingMessage: "Rust compiler is not installed",
629
- installable: true
630
- },
631
- cargo: {
632
- label: "Cargo",
633
- versionCommand: "cargo --version",
634
- parseVersion: (output) => output.match(/cargo (\S+)/)?.[1] ?? output.trim(),
635
- missingMessage: "Cargo is not installed",
636
- installable: false,
637
- providedBy: "rustc"
638
- },
639
- python3: {
640
- label: "Python 3",
641
- versionCommand: "python3 --version",
642
- parseVersion: (output) => output.match(/Python (\S+)/)?.[1] ?? output.trim(),
643
- missingMessage: "Python 3 is not installed",
644
- installable: true
645
- },
646
- pip: {
647
- label: "pip",
648
- versionCommand: "pip3 --version || pip --version",
649
- parseVersion: (output) => output.match(/pip (\S+)/)?.[1] ?? output.trim(),
650
- missingMessage: "pip is not installed",
651
- installable: true,
652
- providedBy: "python3"
653
- },
654
- ruby: {
655
- label: "Ruby",
656
- versionCommand: "ruby --version",
657
- parseVersion: (output) => output.match(/ruby (\S+)/)?.[1] ?? output.trim(),
658
- missingMessage: "Ruby is not installed",
659
- installable: true
660
- },
661
- bundler: {
662
- label: "Bundler",
663
- versionCommand: "bundler --version",
664
- parseVersion: (output) => output.match(/(\d+\.\d+\.\d+)/)?.[1] ?? output.trim(),
665
- missingMessage: "Bundler is not installed",
666
- installable: true,
667
- providedBy: "ruby"
668
- },
669
- php: {
670
- label: "PHP",
671
- versionCommand: "php --version",
672
- parseVersion: (output) => output.match(/PHP (\S+)/)?.[1] ?? output.trim(),
673
- missingMessage: "PHP is not installed",
674
- installable: true
675
- },
676
- composer: {
677
- label: "Composer",
678
- versionCommand: "composer --version",
679
- parseVersion: (output) => output.match(/(\d+\.\d+\.\d+)/)?.[1] ?? output.trim(),
680
- missingMessage: "Composer is not installed",
681
- installable: true,
682
- providedBy: "php"
683
- },
684
- java: {
685
- label: "Java",
686
- versionCommand: "java --version 2>&1 || java -version 2>&1",
687
- parseVersion: (output) => output.match(/(\d+\.\d+)/)?.[1] ?? output.trim(),
688
- missingMessage: "Java is not installed",
689
- installable: true
690
- },
691
- javac: {
692
- label: "Java compiler",
693
- versionCommand: "javac --version 2>&1 || javac -version 2>&1",
694
- parseVersion: (output) => output.match(/javac (\S+)/)?.[1] ?? output.trim(),
695
- missingMessage: "Java compiler (javac) is not installed",
696
- installable: false,
697
- providedBy: "java"
698
- },
699
- maven: {
700
- label: "Maven",
701
- versionCommand: "mvn -version 2>&1",
702
- parseVersion: (output) => output.match(/Apache Maven (\S+)/)?.[1] ?? output.trim(),
703
- missingMessage: "Maven is not installed",
704
- installable: true
705
- },
706
- gradle: {
707
- label: "Gradle",
708
- versionCommand: "gradle --version 2>&1",
709
- parseVersion: (output) => output.match(/Gradle (\S+)/)?.[1] ?? output.trim(),
710
- missingMessage: "Gradle is not installed",
711
- installable: true
712
- },
713
- dotnet: {
714
- label: ".NET SDK",
715
- versionCommand: "dotnet --version",
716
- parseVersion: (output) => output.trim(),
717
- missingMessage: ".NET SDK is not installed",
718
- installable: true
719
- },
720
- elixir: {
721
- label: "Elixir",
722
- versionCommand: "elixir --version 2>&1",
723
- parseVersion: (output) => output.match(/Elixir (\S+)/)?.[1] ?? output.trim(),
724
- missingMessage: "Elixir is not installed",
725
- installable: true
726
- },
727
- mix: {
728
- label: "Mix",
729
- versionCommand: "mix --version 2>&1",
730
- parseVersion: (output) => output.match(/Mix (\S+)/)?.[1] ?? output.trim(),
731
- missingMessage: "Mix is not installed",
732
- installable: false,
733
- providedBy: "elixir"
734
- }
735
- },
777
+ checks: CHECKS,
736
778
  /**
737
- * Install plan factories - how to install each tool on a given OS.
779
+ * Install plan factories how to install each tool on a given host.
738
780
  *
739
- * Only tools with `installable: true` (and no `providedBy`) need an entry.
740
- * Tools like npm, cargo, mix are installed as part of their parent.
781
+ * Only `installable` tools have one. npm, cargo, javac and mix arrive with their
782
+ * parent and say so through `providedBy`.
741
783
  */
742
- installs: {
743
- node: nodeInstallPlan,
744
- bun: bunInstallPlan,
745
- go: goInstallPlan,
746
- rustc: rustInstallPlan,
747
- python3: pythonInstallPlan,
748
- pip: pipInstallPlan,
749
- ruby: rubyInstallPlan,
750
- bundler: bundlerInstallPlan,
751
- php: phpInstallPlan,
752
- composer: composerInstallPlan,
753
- java: javaInstallPlan,
754
- maven: mavenInstallPlan,
755
- gradle: gradleInstallPlan,
756
- dotnet: dotnetInstallPlan,
757
- elixir: elixirInstallPlan
758
- }
784
+ installs: INSTALLS
759
785
  };
760
786
 
761
787
  // ../../packages/adapters/src/toolchain/checks.ts
@@ -775,31 +801,8 @@ function compareVersions(actual, minimum) {
775
801
  }
776
802
  return 0;
777
803
  }
778
- async function tryExec(executor, command) {
779
- const startedAt = Date.now();
780
- systemDebug("toolchain", `exec:start ${command}`);
781
- try {
782
- const result = await executor.exec(command, { timeout: 1e4 });
783
- systemDebug(
784
- "toolchain",
785
- `exec:ok ${command} (${formatDuration(startedAt)})`
786
- );
787
- return result;
788
- } catch (err) {
789
- if (isRemoteConnectionError(err)) {
790
- systemDebug(
791
- "toolchain",
792
- `exec:abort ${command} (${formatDuration(startedAt)}) ${safeErrorMessage(err)}`
793
- );
794
- throw err;
795
- }
796
- const msg = safeErrorMessage(err);
797
- systemDebug(
798
- "toolchain",
799
- `exec:fail ${command} (${formatDuration(startedAt)}) ${msg}`
800
- );
801
- return null;
802
- }
804
+ function meetsMinVersion(actual, minimum) {
805
+ return compareVersions(actual, minimum) >= 0;
803
806
  }
804
807
  async function checkTool(executor, name, opts) {
805
808
  const recipe = toolchainCatalog.checks[name];
@@ -812,18 +815,21 @@ async function checkTool(executor, name, opts) {
812
815
  message: `Unknown tool: ${name}`
813
816
  };
814
817
  }
815
- const output = await tryExec(executor, recipe.versionCommand);
816
- if (!output) {
817
- systemDebug("toolchain", `${name}:missing`);
818
+ const probe = await probeExec(executor, recipe.versionCommand, "toolchain");
819
+ if (!probe.output) {
820
+ systemDebug("toolchain", `${name}:missing ${probe.error ?? "exited 0 with no version"}`);
818
821
  return {
819
822
  name,
820
823
  label: recipe.label,
821
824
  installed: false,
822
825
  healthy: false,
823
- message: recipe.missingMessage
826
+ // A tool that isn't there and a tool whose `--version` was refused ("permission
827
+ // denied", "Command timed out") both land here, and only the second can say why —
828
+ // #408, the same conflation `system/checks.ts` reports its way out of.
829
+ message: withReason(recipe.missingMessage, probe.error)
824
830
  };
825
831
  }
826
- const version = recipe.parseVersion(output);
832
+ const version = recipe.parseVersion(probe.output);
827
833
  if (opts?.minVersion && compareVersions(version, opts.minVersion) < 0) {
828
834
  systemDebug("toolchain", `${name}:outdated v${version} < ${opts.minVersion}`);
829
835
  return {
@@ -883,6 +889,16 @@ async function checkToolchainForStack(executor, stackId) {
883
889
  }
884
890
 
885
891
  // ../../packages/adapters/src/toolchain/installer.ts
892
+ function groupStepsByAuthority(steps, hostNeedsRoot) {
893
+ const groups = [];
894
+ for (const step of steps) {
895
+ const as = step.as === "root" && hostNeedsRoot ? "root" : "login";
896
+ const open = groups.at(-1);
897
+ if (open && open.as === as) open.commands.push(step.command);
898
+ else groups.push({ as, commands: [step.command] });
899
+ }
900
+ return groups;
901
+ }
886
902
  async function installTool(executor, name, onLog, requiredVersion) {
887
903
  const startedAt = Date.now();
888
904
  const recipe = toolchainCatalog.checks[name];
@@ -907,13 +923,26 @@ async function installTool(executor, name, onLog, requiredVersion) {
907
923
  const profile = await resolveEnvironment(executor);
908
924
  const plan = factory(profile);
909
925
  if (!plan.supported) {
910
- const msg = plan.unsupportedReason ?? `${recipe.label} installation not supported on this system`;
911
926
  onLog?.({
912
927
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
913
- message: msg,
928
+ message: `Cannot install ${recipe.label} on ${envOps(profile).describe()}: ${plan.reason}`,
914
929
  level: "error"
915
930
  });
916
- return { tool: name, success: false, error: msg };
931
+ return { tool: name, success: false, error: plan.reason };
932
+ }
933
+ const groups = groupStepsByAuthority(plan.value.steps, envOps(profile).installsNeedRoot());
934
+ let elevated = null;
935
+ if (groups.some((group) => group.as === "root")) {
936
+ const grant = await privilegedExecutor(executor, `Installing ${recipe.label}`);
937
+ if (!grant.supported) {
938
+ onLog?.({
939
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
940
+ message: grant.reason,
941
+ level: "error"
942
+ });
943
+ return { tool: name, success: false, error: grant.reason };
944
+ }
945
+ elevated = grant.value.executor;
917
946
  }
918
947
  systemDebug("toolchain", `install:start ${name}`);
919
948
  onLog?.({
@@ -922,33 +951,25 @@ async function installTool(executor, name, onLog, requiredVersion) {
922
951
  level: "info"
923
952
  });
924
953
  try {
925
- const { code } = await executor.streamExec(plan.installCommand, (entry) => {
926
- onLog?.(entry);
927
- });
928
- if (code !== 0) {
929
- if (plan.fallbackInstallCommands?.length) {
930
- for (const fallback of plan.fallbackInstallCommands) {
931
- onLog?.({
932
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
933
- message: `Primary install failed, trying fallback...`,
934
- level: "warn"
935
- });
936
- const fb = await executor.streamExec(fallback, (entry) => onLog?.(entry));
937
- if (fb.code === 0) break;
938
- }
939
- } else {
954
+ for (const group of groups) {
955
+ const runner = group.as === "root" ? elevated : executor;
956
+ const { code } = await runner.streamExec(opScript(group.commands), (entry) => {
957
+ onLog?.(entry);
958
+ });
959
+ if (code !== 0) {
940
960
  throw new Error(`Install command failed with exit code ${code}`);
941
961
  }
942
962
  }
943
- if (plan.startCommand) {
944
- await executor.streamExec(plan.startCommand, (entry) => onLog?.(entry));
945
- }
946
- const verifyCmd = plan.verifyCommand ?? recipe.versionCommand;
947
- const verifyResult = await executor.exec(verifyCmd, { timeout: 1e4 }).catch(() => null);
963
+ const verifyResult = await executor.exec(plan.value.verifyCommand, { timeout: 1e4 }).catch(() => null);
948
964
  if (!verifyResult) {
949
965
  throw new Error(`${recipe.label} installed but verification failed`);
950
966
  }
951
967
  const version = recipe.parseVersion(verifyResult);
968
+ if (requiredVersion && !meetsMinVersion(version, requiredVersion)) {
969
+ throw new Error(
970
+ `${recipe.label} ${version} was installed but this project needs ${requiredVersion} or newer, and this host has no newer package. Use a newer OS release, or build in a container.`
971
+ );
972
+ }
952
973
  systemDebug("toolchain", `install:done ${name} v${version} (${formatDuration(startedAt)})`);
953
974
  onLog?.({
954
975
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1503,21 +1524,18 @@ ${hint}` : "Process exited immediately after spawn (no output captured)";
1503
1524
 
1504
1525
  // ../../packages/adapters/src/runtime/supervisor/detect.ts
1505
1526
  async function detectSupervisor(executor, workDir) {
1506
- const hasSystemd = await probeSystemd(executor);
1507
- if (hasSystemd) {
1508
- return new SystemdSupervisor(executor, workDir);
1509
- }
1510
- return new NohupSupervisor(executor, workDir);
1511
- }
1512
- async function probeSystemd(executor) {
1513
- try {
1514
- const result = await executor.exec(
1515
- `[ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1 && echo y || echo n`
1527
+ const profile = await resolveEnvironment(executor).catch((err) => {
1528
+ systemDebug("supervisor", `detect: host unreachable, using nohup \u2014 ${safeErrorMessage(err)}`);
1529
+ return null;
1530
+ });
1531
+ if (profile?.serviceManager === "systemd") return new SystemdSupervisor(executor, workDir);
1532
+ const unmeasured = profile?.probeError ?? (profile?.supported === false ? profile.unsupportedReason : null);
1533
+ if (unmeasured) {
1534
+ console.warn(
1535
+ `[supervisor] could not confirm this host's init system, so the service will run under nohup and will NOT restart after a reboot: ${unmeasured}`
1516
1536
  );
1517
- return result.trim() === "y";
1518
- } catch {
1519
- return false;
1520
1537
  }
1538
+ return new NohupSupervisor(executor, workDir);
1521
1539
  }
1522
1540
 
1523
1541
  // ../../packages/adapters/src/runtime/bare.ts
@@ -1672,7 +1690,7 @@ var BareRuntime = class {
1672
1690
  async promoteBuildArtifact(artifactPath, deploymentId, previousDeploymentId) {
1673
1691
  const releaseDir = this.releaseDir(deploymentId);
1674
1692
  if (artifactPath === releaseDir) return releaseDir;
1675
- await this.executor.mkdir(`${this.workDir}/releases`);
1693
+ await ensureOwnedDir(this.executor, `${this.workDir}/releases`);
1676
1694
  await this.executor.rm(releaseDir);
1677
1695
  const previousReleaseDir = previousDeploymentId ? this.releaseDir(previousDeploymentId) : void 0;
1678
1696
  if (previousReleaseDir && await this.executor.exists(previousReleaseDir)) {
@@ -1898,10 +1916,7 @@ var BareRuntime = class {
1898
1916
  abort.abort();
1899
1917
  this.activeBuilds.delete(sessionId);
1900
1918
  }
1901
- const dir = sq(this.buildDir(sessionId));
1902
- const scan = (sig) => `for p in /proc/[0-9]*; do c=$(readlink "$p/cwd" 2>/dev/null); case "$c" in ${dir}|${dir}/*) kill -${sig} "\${p##*/}" 2>/dev/null || true;; esac; done`;
1903
- await this.executor.exec(`${scan("TERM")}; sleep 2; ${scan("KILL")}`).catch(() => {
1904
- });
1919
+ await killProcessesUnderDir(this.executor, this.buildDir(sessionId));
1905
1920
  }
1906
1921
  async getBuildLogs(sessionId) {
1907
1922
  void sessionId;