@spunto/build 0.25.0 → 0.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/build",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -447,6 +447,15 @@ function buildFeatureInstallScript(ociRef: string, options?: Record<string, stri
447
447
  function buildLocalFeatureInstallScript(featureId: string, scriptFile: string): string | null {
448
448
  const scriptContent = localFeatureScript(scriptFile)
449
449
  if (!scriptContent) return null
450
+ return buildInlineFeatureInstallScript(featureId, scriptContent)
451
+ }
452
+
453
+ /**
454
+ * A feature whose install script the caller hands over as text — embedded in the recipe like a
455
+ * local one. What an app that serves its own feature gives (Spunto IDE's `ide.feature`, RFC 0059):
456
+ * no registry in between, and the recipe's text, so its key, moves with the script.
457
+ */
458
+ function buildInlineFeatureInstallScript(featureId: string, scriptContent: string): string {
450
459
  const encoded = b64(scriptContent)
451
460
  return [
452
461
  `echo "[feature] ${featureId}: running local install script..."`,
@@ -468,11 +477,15 @@ function buildLocalFeatureInstallScript(featureId: string, scriptFile: string):
468
477
  export type ResolvedFeature = { id: string; script: string; ociRef?: string }
469
478
 
470
479
  function resolveFeatures(
471
- features: { id: string; ociRef?: string; options?: Record<string, string> }[],
480
+ features: { id: string; ociRef?: string; options?: Record<string, string>; script?: string }[],
472
481
  warnId?: string,
473
482
  ): ResolvedFeature[] {
474
483
  const resolved: ResolvedFeature[] = []
475
484
  for (const f of features) {
485
+ if (f.script) {
486
+ resolved.push({ id: f.id, script: buildInlineFeatureInstallScript(f.id, f.script) })
487
+ continue
488
+ }
476
489
  const known = AVAILABLE_FEATURES.find((af) => af.id === f.id)
477
490
  if (known?.localScript) {
478
491
  const script = buildLocalFeatureInstallScript(f.id, known.localScript)
@@ -555,8 +568,14 @@ function commonUtilsOptions(): Record<string, string> {
555
568
  * Not strict: a base image where tmux won't install must still produce a usable worker, exactly as
556
569
  * the best-effort blocks it replaces did.
557
570
  */
558
- function spuntoPackOptions(params: { sshGatewayPublicKey?: string }): Record<string, string> {
559
- return { installSshd: params.sshGatewayPublicKey ? "true" : "false" }
571
+ function spuntoPackOptions(params: { sshGatewayPublicKey?: string; codeServerInPack?: boolean }): Record<string, string> {
572
+ return {
573
+ installSshd: params.sshGatewayPublicKey ? "true" : "false",
574
+ // A platform that installs the IDE apart (`spunto-ide`, Spunto's IDE app — RFC 0059) keeps the
575
+ // pack to its plumbing: tmux, dtach, sshd. Left out otherwise, so the recipe of a caller that
576
+ // never asks (spunto-lite) stays the same text.
577
+ ...(params.codeServerInPack === false ? { installCodeServer: "false" } : {}),
578
+ }
560
579
  }
561
580
 
562
581
  // The system-wide tmux configuration (mouse, OSC 52 clipboard, 50k scrollback, warm status bar)
@@ -689,11 +708,14 @@ export function defaultVscodeUserSettings(opts?: {
689
708
  * 1 → the hand-written blocks (vscode user, code-server, tmux, dtach, sshd).
690
709
  * 2 → common-utils + spunto-pack.
691
710
  * 3 → spunto-pack 1.1: the IDE wears Spunto's name, icons and themes (coderhammer/features).
711
+ * 4 → the IDE out of the pack (`codeServerInPack: false`): code-server comes from `spunto-ide`,
712
+ * the feature of an IDE app, and only in the image of a project that has one (RFC 0059).
692
713
  */
693
- export const IMAGE_RECIPE_VERSION = 3
714
+ export const IMAGE_RECIPE_VERSION = 4
694
715
 
695
716
  export function buildImageScript(params: {
696
- features: { id: string; ociRef?: string; options?: Record<string, string> }[]
717
+ /** `script`: the feature's install script itself, embedded as is (an app that serves its own). */
718
+ features: { id: string; ociRef?: string; options?: Record<string, string>; script?: string }[]
697
719
  vscodeExtensions?: string[]
698
720
  sshGatewayPublicKey?: string
699
721
  /** Project-level DinD opt-in (base image bakes Docker itself, no feature). See projects.dind. */
@@ -703,6 +725,13 @@ export function buildImageScript(params: {
703
725
  * `--install-extension` calls below resolve ids against. Absent/unusable = Open VSX.
704
726
  */
705
727
  extensionsGallery?: string | null
728
+ /**
729
+ * Whether spunto-pack installs code-server, as it does by default. `false`: the pack is plumbing
730
+ * only (tmux, dtach, sshd), and code-server comes from a feature of the caller's — Spunto's IDE
731
+ * app puts `spunto-ide` among `features` for a project that has an IDE (RFC 0059). Extensions are
732
+ * then installed only if one of the features brought a code-server.
733
+ */
734
+ codeServerInPack?: boolean
706
735
  }): { script: string; hasDinD: boolean; resolvedFeatures: ResolvedFeature[]; steps: BuildStep[] } {
707
736
  const lines: string[] = ["set -e"]
708
737
 
@@ -818,6 +847,18 @@ export function buildImageScript(params: {
818
847
  )
819
848
  }
820
849
  extLines.push('echo "[build] Extensions installed"')
850
+ // code-server from a feature of the caller's (`codeServerInPack: false`): said plainly when none
851
+ // brought it, rather than one "command not found" per extension.
852
+ if (params.codeServerInPack === false) {
853
+ extLines.splice(
854
+ 0,
855
+ 0,
856
+ "if ! command -v code-server >/dev/null 2>&1; then",
857
+ ' echo "[build] No code-server in this image (no IDE feature) — VS Code extensions skipped"',
858
+ "else",
859
+ )
860
+ extLines.push("fi")
861
+ }
821
862
  const n = params.vscodeExtensions.length
822
863
  block("extensions", "VS Code extensions", "extensions", `${n} extension${n > 1 ? "s" : ""} · ${registry}`, extLines)
823
864
  }
@@ -1229,6 +1270,14 @@ export type StartScriptParams = {
1229
1270
  * assume). For a box where 8080 is taken by the host itself — a Blaxel sandbox's own API.
1230
1271
  */
1231
1272
  idePort?: number
1273
+ /**
1274
+ * Whether the box has an IDE to start — code-server, as it always had. `false`: a project without
1275
+ * an IDE app (RFC 0059) — no extensions copied, no editor settings, no code-server loop; the
1276
+ * terminal (tmux/dtach, the agent's) is not the IDE's and stays.
1277
+ */
1278
+ ide?: boolean
1279
+ /** Settings the IDE app adds to the seeded defaults (`ide.config` → `userSettings`), over them. */
1280
+ ideUserSettings?: Record<string, unknown>
1232
1281
  }
1233
1282
 
1234
1283
  /** code-server's port when the caller doesn't say otherwise. */
@@ -1243,6 +1292,7 @@ function idePortOf(port: number | undefined): number {
1243
1292
  export function buildStartScript(params: StartScriptParams): { script: string; hasDinD: boolean } {
1244
1293
  const { project, sshGatewayPublicKey, colorTabsByProject, extensionsGallery } = params
1245
1294
  const idePort = idePortOf(params.idePort)
1295
+ const ide = params.ide !== false
1246
1296
  const homeDir = "/home/vscode"
1247
1297
  const username = "vscode"
1248
1298
 
@@ -1333,112 +1383,119 @@ export function buildStartScript(params: StartScriptParams): { script: string; h
1333
1383
  "done",
1334
1384
  )
1335
1385
 
1336
- // ── 4. Copy prebuilt extensions ───────────────────────────────────────────
1337
- push(
1338
- "",
1339
- 'if [ -d ' + EXTENSIONS_DIR + ' ] && [ "$(ls -A ' + EXTENSIONS_DIR + ' 2>/dev/null)" ]; then',
1340
- ` mkdir -p ${homeDir}/.local/share/code-server/extensions`,
1341
- ` cp -rn ${EXTENSIONS_DIR}/. ${homeDir}/.local/share/code-server/extensions/ 2>/dev/null || true`,
1342
- ` chown -R ${username}:${username} ${homeDir}/.local`,
1343
- "fi",
1344
- )
1386
+ // ── 4–5. The IDE: code-server, its extensions and settings (none without an IDE app) ───
1387
+ if (ide) {
1388
+ // ── 4. Copy prebuilt extensions ───────────────────────────────────────────
1389
+ push(
1390
+ "",
1391
+ 'if [ -d ' + EXTENSIONS_DIR + ' ] && [ "$(ls -A ' + EXTENSIONS_DIR + ' 2>/dev/null)" ]; then',
1392
+ ` mkdir -p ${homeDir}/.local/share/code-server/extensions`,
1393
+ ` cp -rn ${EXTENSIONS_DIR}/. ${homeDir}/.local/share/code-server/extensions/ 2>/dev/null || true`,
1394
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1395
+ "fi",
1396
+ )
1345
1397
 
1346
- // ── 4b. Seed code-server user settings (no-clobber, stays user-overridable) ─
1347
- // Reordered window.title + per-project window color make parallel worker tabs
1348
- // tellable apart at a glance (see defaultVscodeUserSettings). Written no-clobber
1349
- // (guarded on the file, not `cp -n`) so a user's own settings.json survives restarts.
1350
- const vscodeSettings = JSON.stringify(
1351
- defaultVscodeUserSettings({ projectName: project.name, colorByProject: colorTabsByProject }),
1352
- null,
1353
- 2,
1354
- )
1355
- push(
1356
- "",
1357
- `mkdir -p ${homeDir}/.local/share/code-server/User`,
1358
- `if [ ! -f ${homeDir}/.local/share/code-server/User/settings.json ]; then`,
1359
- ` echo ${JSON.stringify(b64(vscodeSettings))} | base64 -d > ${homeDir}/.local/share/code-server/User/settings.json`,
1360
- ` chown -R ${username}:${username} ${homeDir}/.local`,
1361
- "fi",
1362
- )
1398
+ // ── 4b. Seed code-server user settings (no-clobber, stays user-overridable) ─
1399
+ // Reordered window.title + per-project window color make parallel worker tabs
1400
+ // tellable apart at a glance (see defaultVscodeUserSettings). Written no-clobber
1401
+ // (guarded on the file, not `cp -n`) so a user's own settings.json survives restarts.
1402
+ const vscodeSettings = JSON.stringify(
1403
+ { ...defaultVscodeUserSettings({ projectName: project.name, colorByProject: colorTabsByProject }), ...params.ideUserSettings },
1404
+ null,
1405
+ 2,
1406
+ )
1407
+ push(
1408
+ "",
1409
+ `mkdir -p ${homeDir}/.local/share/code-server/User`,
1410
+ `if [ ! -f ${homeDir}/.local/share/code-server/User/settings.json ]; then`,
1411
+ ` echo ${JSON.stringify(b64(vscodeSettings))} | base64 -d > ${homeDir}/.local/share/code-server/User/settings.json`,
1412
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1413
+ "fi",
1414
+ )
1363
1415
 
1364
- // ── 4c. Restore the user's PORT secret for code-server's own terminal panel ─
1365
- // code-server reads $PORT to override --bind-addr (see § 5 below), so its
1366
- // whole process tree — including the integrated terminal it spawns — inherits
1367
- // PORT=<idePort> unless corrected. `terminal.integrated.env.linux` lets VS Code
1368
- // inject env overrides into terminals it spawns without touching the server
1369
- // process itself, so a `PORT` project secret still reaches the user's shell.
1370
- // Uses code-server's own bundled node (guaranteed present regardless of the
1371
- // devcontainer base image) since jq/node aren't guaranteed there.
1372
- const seedTerminalPortEnv = [
1373
- "const fs = require('fs')",
1374
- "const path = process.env.MP_SETTINGS_PATH",
1375
- "let settings = {}",
1376
- "try { settings = JSON.parse(fs.readFileSync(path, 'utf8')) } catch {}",
1377
- "settings['terminal.integrated.env.linux'] = { ...settings['terminal.integrated.env.linux'], PORT: process.env.MP_ORIGINAL_PORT }",
1378
- "fs.writeFileSync(path, JSON.stringify(settings, null, 2))",
1379
- ].join("\n")
1380
- const seedTerminalPortEnvEncoded = b64(seedTerminalPortEnv)
1416
+ // ── 4c. Restore the user's PORT secret for code-server's own terminal panel ─
1417
+ // code-server reads $PORT to override --bind-addr (see § 5 below), so its
1418
+ // whole process tree — including the integrated terminal it spawns — inherits
1419
+ // PORT=<idePort> unless corrected. `terminal.integrated.env.linux` lets VS Code
1420
+ // inject env overrides into terminals it spawns without touching the server
1421
+ // process itself, so a `PORT` project secret still reaches the user's shell.
1422
+ // Uses code-server's own bundled node (guaranteed present regardless of the
1423
+ // devcontainer base image) since jq/node aren't guaranteed there.
1424
+ const seedTerminalPortEnv = [
1425
+ "const fs = require('fs')",
1426
+ "const path = process.env.MP_SETTINGS_PATH",
1427
+ "let settings = {}",
1428
+ "try { settings = JSON.parse(fs.readFileSync(path, 'utf8')) } catch {}",
1429
+ "settings['terminal.integrated.env.linux'] = { ...settings['terminal.integrated.env.linux'], PORT: process.env.MP_ORIGINAL_PORT }",
1430
+ "fs.writeFileSync(path, JSON.stringify(settings, null, 2))",
1431
+ ].join("\n")
1432
+ const seedTerminalPortEnvEncoded = b64(seedTerminalPortEnv)
1381
1433
 
1382
- push(
1383
- "",
1384
- 'if [ -n "${PORT:-}" ]; then',
1385
- " _mp_cs_bin=$(command -v code-server 2>/dev/null || true)",
1386
- ' if [ -n "$_mp_cs_bin" ]; then',
1387
- ' _mp_cs_bin=$(readlink -f "$_mp_cs_bin" 2>/dev/null || echo "$_mp_cs_bin")',
1388
- ' _mp_cs_node="$(dirname "$(dirname "$_mp_cs_bin")")/lib/node"',
1389
- ' if [ -x "$_mp_cs_node" ]; then',
1390
- ` mkdir -p ${homeDir}/.local/share/code-server/User`,
1391
- ` echo ${JSON.stringify(seedTerminalPortEnvEncoded)} | base64 -d > ${homeDir}/.mp_seed_terminal_port.js`,
1392
- ` MP_ORIGINAL_PORT="$PORT" MP_SETTINGS_PATH="${homeDir}/.local/share/code-server/User/settings.json" "$_mp_cs_node" ${homeDir}/.mp_seed_terminal_port.js`,
1393
- ` rm -f ${homeDir}/.mp_seed_terminal_port.js`,
1394
- ` chown -R ${username}:${username} ${homeDir}/.local`,
1395
- " fi",
1396
- " fi",
1397
- "fi",
1398
- )
1434
+ push(
1435
+ "",
1436
+ 'if [ -n "${PORT:-}" ]; then',
1437
+ " _mp_cs_bin=$(command -v code-server 2>/dev/null || true)",
1438
+ ' if [ -n "$_mp_cs_bin" ]; then',
1439
+ ' _mp_cs_bin=$(readlink -f "$_mp_cs_bin" 2>/dev/null || echo "$_mp_cs_bin")',
1440
+ ' _mp_cs_node="$(dirname "$(dirname "$_mp_cs_bin")")/lib/node"',
1441
+ ' if [ -x "$_mp_cs_node" ]; then',
1442
+ ` mkdir -p ${homeDir}/.local/share/code-server/User`,
1443
+ ` echo ${JSON.stringify(seedTerminalPortEnvEncoded)} | base64 -d > ${homeDir}/.mp_seed_terminal_port.js`,
1444
+ ` MP_ORIGINAL_PORT="$PORT" MP_SETTINGS_PATH="${homeDir}/.local/share/code-server/User/settings.json" "$_mp_cs_node" ${homeDir}/.mp_seed_terminal_port.js`,
1445
+ ` rm -f ${homeDir}/.mp_seed_terminal_port.js`,
1446
+ ` chown -R ${username}:${username} ${homeDir}/.local`,
1447
+ " fi",
1448
+ " fi",
1449
+ "fi",
1450
+ )
1399
1451
 
1400
- // ── 5. Start code-server as vscode ────────────────────────────────────────
1401
- // Write the restart-loop to a temp script so quoting stays clean.
1402
- // Single-quoted heredoc: $VARS are NOT expanded here, they expand when the script runs
1403
- // (env is inherited from the start script via `su vscode` without -l).
1404
- // Write the restart-loop using base64 to avoid heredoc issues in DinD contexts
1405
- // where dockerd startup can affect /tmp mount visibility for child processes.
1406
- // Script lives in home dir (persistent volume) rather than /tmp.
1407
- const codeServerLoop = [
1408
- "#!/bin/bash",
1409
- "export SHELL=$(command -v zsh 2>/dev/null || echo /bin/bash)",
1410
- // Makes the editor's own Extensions view search the same registry the picker and the image
1411
- // build used (org setting — see lib/extension-registry.ts). Nothing when the org is on Open VSX.
1412
- ...galleryExport(extensionsGallery),
1413
- // The IDE is Spunto's, not Coder's (spunto-pack rebrands the files): the name in the title,
1414
- // no "Deploy code-server for your team" on the welcome page, no "code-server vX is out"
1415
- // notification. Each flag only if this code-server knows it — an unknown one stops it dead.
1416
- '_mp_cs_flags=""',
1417
- "for _f in --app-name --disable-getting-started-override --disable-update-check; do",
1418
- ' code-server --help 2>/dev/null | grep -q -- "$_f" || continue',
1419
- ' case "$_f" in --app-name) _mp_cs_flags="$_mp_cs_flags --app-name Spunto" ;; *) _mp_cs_flags="$_mp_cs_flags $_f" ;; esac',
1420
- "done",
1421
- "while true; do",
1422
- ' VSCODE_PROXY_URI="${PUBLIC_PROTOCOL}://${WORKER_SLUG}-{{port}}.${BASE_DOMAIN}" \\',
1423
- // PORT is overridden here (not unset globally) so a project secret named
1424
- // PORT can't make code-server bind elsewhere. This also leaks PORT=<idePort>
1425
- // into code-server's own process tree (its integrated terminal included) —
1426
- // § 4c above restores the original value for that terminal specifically.
1427
- // apps/agent/src/terminal.ts (dashboard's separate terminal feature) is a
1428
- // different process tree entirely and was never affected.
1429
- ` PORT=${idePort} code-server --bind-addr 0.0.0.0:${idePort} --auth none $_mp_cs_flags /workspace 2>&1`,
1430
- ' echo "[start] code-server exited ($?), restarting in 3s..."',
1431
- " sleep 3",
1432
- "done",
1433
- ].join("\n")
1434
- const codeServerEncoded = b64(codeServerLoop)
1452
+ // ── 5. Start code-server as vscode ────────────────────────────────────────
1453
+ // Write the restart-loop to a temp script so quoting stays clean.
1454
+ // Single-quoted heredoc: $VARS are NOT expanded here, they expand when the script runs
1455
+ // (env is inherited from the start script via `su vscode` without -l).
1456
+ // Write the restart-loop using base64 to avoid heredoc issues in DinD contexts
1457
+ // where dockerd startup can affect /tmp mount visibility for child processes.
1458
+ // Script lives in home dir (persistent volume) rather than /tmp.
1459
+ const codeServerLoop = [
1460
+ "#!/bin/bash",
1461
+ "export SHELL=$(command -v zsh 2>/dev/null || echo /bin/bash)",
1462
+ // Makes the editor's own Extensions view search the same registry the picker and the image
1463
+ // build used (org setting — see lib/extension-registry.ts). Nothing when the org is on Open VSX.
1464
+ ...galleryExport(extensionsGallery),
1465
+ // The IDE is Spunto's, not Coder's (spunto-pack rebrands the files): the name in the title,
1466
+ // no "Deploy code-server for your team" on the welcome page, no "code-server vX is out"
1467
+ // notification. Each flag only if this code-server knows it — an unknown one stops it dead.
1468
+ '_mp_cs_flags=""',
1469
+ "for _f in --app-name --disable-getting-started-override --disable-update-check; do",
1470
+ ' code-server --help 2>/dev/null | grep -q -- "$_f" || continue',
1471
+ ' case "$_f" in --app-name) _mp_cs_flags="$_mp_cs_flags --app-name Spunto" ;; *) _mp_cs_flags="$_mp_cs_flags $_f" ;; esac',
1472
+ "done",
1473
+ "while true; do",
1474
+ ' VSCODE_PROXY_URI="${PUBLIC_PROTOCOL}://${WORKER_SLUG}-{{port}}.${BASE_DOMAIN}" \\',
1475
+ // PORT is overridden here (not unset globally) so a project secret named
1476
+ // PORT can't make code-server bind elsewhere. This also leaks PORT=<idePort>
1477
+ // into code-server's own process tree (its integrated terminal included) —
1478
+ // § 4c above restores the original value for that terminal specifically.
1479
+ // apps/agent/src/terminal.ts (dashboard's separate terminal feature) is a
1480
+ // different process tree entirely and was never affected.
1481
+ ` PORT=${idePort} code-server --bind-addr 0.0.0.0:${idePort} --auth none $_mp_cs_flags /workspace 2>&1`,
1482
+ ' echo "[start] code-server exited ($?), restarting in 3s..."',
1483
+ " sleep 3",
1484
+ "done",
1485
+ ].join("\n")
1486
+ const codeServerEncoded = b64(codeServerLoop)
1435
1487
 
1436
- push(
1437
- ...banner("START: CODE-SERVER"),
1438
- `echo ${JSON.stringify(codeServerEncoded)} | base64 -d > ${homeDir}/.mp_codeserver.sh`,
1439
- `chmod +x ${homeDir}/.mp_codeserver.sh`,
1440
- `su ${username} -s /bin/bash -c "${homeDir}/.mp_codeserver.sh" &`,
1441
- )
1488
+ push(
1489
+ ...banner("START: CODE-SERVER"),
1490
+ `echo ${JSON.stringify(codeServerEncoded)} | base64 -d > ${homeDir}/.mp_codeserver.sh`,
1491
+ `chmod +x ${homeDir}/.mp_codeserver.sh`,
1492
+ `su ${username} -s /bin/bash -c "${homeDir}/.mp_codeserver.sh" &`,
1493
+ )
1494
+ }
1495
+
1496
+ // No IDE: nothing else would keep the script — and so the project's process, which the agent
1497
+ // reports as `running` only while it lives — waiting at the end. A sleeper does, in its place.
1498
+ if (!ide) push("", "while true; do sleep 3600; done &")
1442
1499
 
1443
1500
  // ── 6. postStartCommand ───────────────────────────────────────────────────
1444
1501
  // Wait for Docker if feature entrypoints were started — regardless of whether