@getmonoceros/workbench 1.37.13 → 1.38.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.
@@ -40,19 +40,31 @@ options:
40
40
  description: "Optional Bitbucket app password; only needed for twg's Bitbucket commands."
41
41
  surface: env
42
42
  feature:
43
- version: 1.2.0
43
+ version: 1.2.1
44
44
  persistentHomePaths: [.config/acli, .rovodev, .config/twg, .agents]
45
45
  vscodeExtensions: [Atlassian.atlascode]
46
- # Forge is keychain-free in a container: instead of `forge login` (which
47
- # needs libsecret), Atlassian's documented path is the FORGE_EMAIL /
48
- # FORGE_API_TOKEN env vars, read at command time. We hand them to the
49
- # workspace runtime env from the shared account options, gated on the
50
- # `forge` toggle. Sibling of a service's connectionEnv (ADR 0021).
46
+ # Forge and twg are both keychain-free in a container: instead of a
47
+ # `login` step (which needs libsecret, or a TTY for twg's OAuth),
48
+ # Atlassian's documented path is env vars read at command time. We hand
49
+ # them to the workspace runtime env from the shared account options,
50
+ # each gated on its own toggle. Sibling of a service's connectionEnv
51
+ # (ADR 0021).
52
+ # - Forge reads FORGE_EMAIL / FORGE_API_TOKEN.
53
+ # - twg reads TWG_USER / TWG_SITE / TWG_TOKEN (+ optional
54
+ # TWG_BBC_TOKEN for Bitbucket). Recent twg makes `twg login`
55
+ # OAuth-only and it aborts without a TTY, so these env vars are the
56
+ # only non-interactive auth path; there is no login hook.
51
57
  workspaceEnv:
52
58
  - whenOption: forge
53
59
  vars:
54
60
  FORGE_EMAIL: '${email}'
55
61
  FORGE_API_TOKEN: '${apiToken}'
62
+ - whenOption: twg
63
+ vars:
64
+ TWG_USER: '${email}'
65
+ TWG_SITE: '${instance}'
66
+ TWG_TOKEN: '${apiToken}'
67
+ TWG_BBC_TOKEN: '${bitbucketToken}'
56
68
  briefing:
57
69
  - whenOption: rovodev
58
70
  text: 'Atlassian Rovo Dev — invoke via `acli rovodev`. Pre-authenticated against the Atlassian account configured in the feature options (shared with twg).'
@@ -95,8 +95,9 @@ if [ "${TWG}" = "true" ]; then
95
95
  # runtime — we re-record it as node in the post-create hook
96
96
  # below.)
97
97
  # - With --skip-login / --skip-skills we keep the install
98
- # non-interactive. Login + skills happen later as the node user
99
- # so they persist into the bind-mounted home.
98
+ # non-interactive. Auth is env-var based (see the workspaceEnv
99
+ # note below, no login step); skills install later as the node
100
+ # user so they persist into the bind-mounted home.
100
101
  #
101
102
  # Heredoc (not `yes ... |`) deliberately: a long-lived `yes` left
102
103
  # writing into a closed pipe after the install script exits gets
@@ -128,49 +129,34 @@ TWG_INSTALL_INPUT
128
129
  exit 1
129
130
  }
130
131
 
132
+ # twg authenticates purely from the TWG_USER / TWG_SITE / TWG_TOKEN
133
+ # (+ optional TWG_BBC_TOKEN) env vars that the workbench injects into
134
+ # the workspace runtime env from the shared account options
135
+ # (feature.workspaceEnv, gated on the `twg` toggle): the same
136
+ # keychain-free path as Forge. There is deliberately no login step.
137
+ # Recent twg makes `twg login` OAuth-only, and it aborts without a TTY,
138
+ # which is unavailable during `monoceros apply`'s postCreate. So the
139
+ # post-create hook's only job is to (re-)install twg's agent skills as
140
+ # the node user, which needs no credentials, so it runs whenever twg is
141
+ # installed regardless of whether creds are set.
131
142
  HOOK="${POST_CREATE_DIR}/atlassian-twg.sh"
132
- if [ -n "${INSTANCE}" ] && [ -n "${EMAIL}" ] && [ -n "${APITOKEN}" ]; then
133
- cat >"${HOOK}" <<EOF
143
+ cat >"${HOOK}" <<EOF
134
144
  #!/usr/bin/env bash
135
145
  # Auto-generated by the Monoceros atlassian feature (twg).
136
- # Runs every container start. Always re-runs auth login so that
137
- # rotating the apiToken / bitbucketToken in the container yml
138
- # propagates through on the next \`monoceros apply\` without
139
- # needing to delete the previous credentials file by hand. twg's
140
- # login is a single API call; the cost is negligible compared to
141
- # the container build.
146
+ # Runs every container start. twg auth comes from the TWG_* env vars
147
+ # injected via feature.workspaceEnv; there is no login step here (recent
148
+ # twg's \`twg login\` is OAuth-only and needs a TTY). This hook only
149
+ # (re-)installs twg's agent skills as the node user.
142
150
  set -euo pipefail
143
- mkdir -p "\${HOME}/.config/twg"
144
- echo "[atlassian/twg] configuring twg non-interactively for ${EMAIL} @ ${INSTANCE}"
145
- # Notes on the non-interactive login dance:
146
- # - We don't try to re-record consent here as the node user. twg's
147
- # \`consent\` subcommand demands a TTY; the install-time consent
148
- # (recorded as root during the feature build) appears to be
149
- # enough for \`twg login\` itself.
150
- # - \`twg login --force\` reads Atlassian creds from TWG_USER /
151
- # TWG_SITE / TWG_TOKEN. If TWG_BBC_TOKEN is set, twg uses that
152
- # directly and skips the Bitbucket prompt. If empty, twg prompts
153
- # interactively — we feed two blank lines via heredoc so the
154
- # "Enter = skip" branch fires and we don't hang. Future twg
155
- # versions adding another optional prompt are also covered by
156
- # the second blank line.
157
- TWG_USER='${EMAIL}' \\
158
- TWG_SITE='${INSTANCE}' \\
159
- TWG_TOKEN='${APITOKEN}' \\
160
- TWG_BBC_TOKEN='${BITBUCKETTOKEN}' \\
161
- twg login --force <<'TWG_LOGIN_INPUT'
162
-
163
-
164
- TWG_LOGIN_INPUT
165
151
 
166
152
  # (Re-)install twg's agent skills. Canonical install lands in
167
153
  # /home/node/.agents/skills/twg (persisted via the feature's
168
154
  # persistentHomePaths); .agents/skills-native agents (codex, cursor,
169
155
  # gemini, copilot, …) read it directly. Agents with their own skills
170
- # dir (claude .claude/skills, opencode .opencode/skills) need an
156
+ # dir (claude -> .claude/skills, opencode -> .opencode/skills) need an
171
157
  # explicit --agent flag, so we detect which AI CLIs are present in this
172
158
  # container and pass the matching flags. This keeps the atlassian
173
- # feature decoupled from the container's feature list it reacts to
159
+ # feature decoupled from the container's feature list: it reacts to
174
160
  # whatever is actually installed. Idempotent: re-running just refreshes
175
161
  # the wrappers.
176
162
  echo "[atlassian/twg] (re-)installing twg skills"
@@ -183,11 +169,10 @@ if command -v opencode >/dev/null 2>&1; then
183
169
  fi
184
170
  twg skills install --global --yes "\${TWG_SKILL_AGENTS[@]}"
185
171
  EOF
186
- chmod 0755 "${HOOK}"
187
- echo "[atlassian/twg] post-create login hook installed"
188
- else
189
- rm -f "${HOOK}"
190
- echo "[atlassian/twg] missing instance/email/apiToken — login is manual (\`twg login\` once in the container)"
172
+ chmod 0755 "${HOOK}"
173
+ echo "[atlassian/twg] post-create skills hook installed; auth via TWG_* workspace env"
174
+ if [ -z "${INSTANCE}" ] || [ -z "${EMAIL}" ] || [ -z "${APITOKEN}" ]; then
175
+ echo "[atlassian/twg] instance/email/apiToken not all set; set them in the container yml so TWG_USER/TWG_SITE/TWG_TOKEN reach the shell (otherwise run \`twg login\` once in the container)"
191
176
  fi
192
177
  fi
193
178
 
@@ -9,7 +9,14 @@ service:
9
9
  # Dev mode + realm import. The Keycloak image has no auto-start default,
10
10
  # so a command is mandatory. `--import-realm` reads every *.json from
11
11
  # /opt/keycloak/data/import at startup (a no-op if the dir is empty).
12
- command: start-dev --import-realm
12
+ # `--proxy-headers=xforwarded` makes Keycloak derive the issuer host and
13
+ # scheme from X-Forwarded-Host / X-Forwarded-Proto, so the realm stamps a
14
+ # matching issuer whether it is reached over http via the Traefik proxy
15
+ # (`plantlove.localhost`) or over https via `monoceros share` on a phone.
16
+ # Without it Keycloak uses the raw connection scheme (always http behind a
17
+ # TLS-terminating proxy) and the OIDC token exchange fails on the https
18
+ # client. See ADR 0033.
19
+ command: start-dev --import-realm --proxy-headers=xforwarded
13
20
  # Starts in a host-side second wave AFTER the repo clone (ADR 0025), so a
14
21
  # mounted realm.json / theme from the project is present at boot. No
15
22
  # dataMount: the dev H2 database is ephemeral and re-seeds from the
package/dist/bin.js CHANGED
@@ -4107,12 +4107,12 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
4107
4107
  const devcontainerDir = path11.join(targetDir, ".devcontainer");
4108
4108
  const monocerosDir = path11.join(targetDir, ".monoceros");
4109
4109
  const projectsDir = path11.join(targetDir, "projects");
4110
- const homeDir = path11.join(targetDir, "home");
4110
+ const homeDir2 = path11.join(targetDir, "home");
4111
4111
  const dataDir = path11.join(targetDir, "data");
4112
4112
  await fs9.mkdir(devcontainerDir, { recursive: true });
4113
4113
  await fs9.mkdir(monocerosDir, { recursive: true });
4114
4114
  await fs9.mkdir(projectsDir, { recursive: true });
4115
- await fs9.mkdir(homeDir, { recursive: true });
4115
+ await fs9.mkdir(homeDir2, { recursive: true });
4116
4116
  if (needsCompose(opts)) {
4117
4117
  await fs9.mkdir(dataDir, { recursive: true });
4118
4118
  for (const svc of opts.services) {
@@ -4176,10 +4176,10 @@ async function writeScaffold(opts, targetDir, scaffoldOpts = {}) {
4176
4176
  }
4177
4177
  for (const f of resolvedFeatures) {
4178
4178
  for (const sub of f.persistentHomePaths) {
4179
- await fs9.mkdir(path11.join(homeDir, sub), { recursive: true });
4179
+ await fs9.mkdir(path11.join(homeDir2, sub), { recursive: true });
4180
4180
  }
4181
4181
  for (const entry2 of f.persistentHomeFiles) {
4182
- const filePath = path11.join(homeDir, entry2.path);
4182
+ const filePath = path11.join(homeDir2, entry2.path);
4183
4183
  await fs9.mkdir(path11.dirname(filePath), { recursive: true });
4184
4184
  if (!existsSync8(filePath)) {
4185
4185
  await fs9.writeFile(filePath, entry2.initialContent);
@@ -5481,8 +5481,8 @@ function removeRepoFromDoc(doc, urlOrPath) {
5481
5481
  if (!isMap2(item)) return false;
5482
5482
  const url = item.get("url");
5483
5483
  if (url === urlOrPath) return true;
5484
- const path33 = item.get("path");
5485
- const effectivePath = typeof path33 === "string" ? path33 : typeof url === "string" ? deriveRepoName(url) : void 0;
5484
+ const path35 = item.get("path");
5485
+ const effectivePath = typeof path35 === "string" ? path35 : typeof url === "string" ? deriveRepoName(url) : void 0;
5486
5486
  return effectivePath === urlOrPath;
5487
5487
  });
5488
5488
  if (idx < 0) return false;
@@ -5625,7 +5625,7 @@ async function runAddRepo(input) {
5625
5625
  "Missing repo URL. Usage: monoceros add-repo <containername> <url>."
5626
5626
  );
5627
5627
  }
5628
- const path33 = (input.path ?? deriveRepoName(url)).trim();
5628
+ const path35 = (input.path ?? deriveRepoName(url)).trim();
5629
5629
  const hasName = typeof input.gitName === "string" && input.gitName.trim().length > 0;
5630
5630
  const hasEmail = typeof input.gitEmail === "string" && input.gitEmail.trim().length > 0;
5631
5631
  if (hasName !== hasEmail) {
@@ -5662,7 +5662,7 @@ async function runAddRepo(input) {
5662
5662
  const providerToWrite = !canonical && explicitProvider ? explicitProvider : void 0;
5663
5663
  const entry2 = {
5664
5664
  url,
5665
- path: path33,
5665
+ path: path35,
5666
5666
  ...hasName && hasEmail ? {
5667
5667
  gitUser: {
5668
5668
  name: input.gitName.trim(),
@@ -8974,7 +8974,7 @@ var CLI_VERSION;
8974
8974
  var init_version = __esm({
8975
8975
  "src/version.ts"() {
8976
8976
  "use strict";
8977
- CLI_VERSION = true ? "1.37.13" : "dev";
8977
+ CLI_VERSION = true ? "1.38.0" : "dev";
8978
8978
  }
8979
8979
  });
8980
8980
 
@@ -12370,9 +12370,212 @@ var init_run3 = __esm({
12370
12370
  }
12371
12371
  });
12372
12372
 
12373
+ // src/tls/ca.ts
12374
+ import { promises as fs21 } from "fs";
12375
+ import path33 from "path";
12376
+ import forge from "node-forge";
12377
+ function homeDir(monocerosHome2) {
12378
+ return monocerosHome2 ?? monocerosHome();
12379
+ }
12380
+ function randomSerial() {
12381
+ return "00" + forge.util.bytesToHex(forge.random.getBytesSync(16));
12382
+ }
12383
+ async function fileExists(p) {
12384
+ try {
12385
+ await fs21.access(p);
12386
+ return true;
12387
+ } catch {
12388
+ return false;
12389
+ }
12390
+ }
12391
+ async function loadOrCreateCa(monocerosHome2) {
12392
+ const dir = path33.join(homeDir(monocerosHome2), CA_DIR);
12393
+ const certPath = path33.join(dir, CA_CERT_FILE);
12394
+ const keyPath = path33.join(dir, CA_KEY_FILE);
12395
+ if (await fileExists(certPath) && await fileExists(keyPath)) {
12396
+ const cert2 = forge.pki.certificateFromPem(
12397
+ await fs21.readFile(certPath, "utf8")
12398
+ );
12399
+ const key = forge.pki.privateKeyFromPem(
12400
+ await fs21.readFile(keyPath, "utf8")
12401
+ );
12402
+ return { cert: cert2, key, certPath, keyPath };
12403
+ }
12404
+ const keys = forge.pki.rsa.generateKeyPair(2048);
12405
+ const cert = forge.pki.createCertificate();
12406
+ cert.publicKey = keys.publicKey;
12407
+ cert.serialNumber = randomSerial();
12408
+ cert.validity.notBefore = /* @__PURE__ */ new Date();
12409
+ cert.validity.notAfter = new Date(
12410
+ cert.validity.notBefore.getTime() + CA_DAYS * 24 * 60 * 60 * 1e3
12411
+ );
12412
+ const attrs = [{ name: "commonName", value: "Monoceros Local CA" }];
12413
+ cert.setSubject(attrs);
12414
+ cert.setIssuer(attrs);
12415
+ cert.setExtensions([
12416
+ { name: "basicConstraints", cA: true, critical: true },
12417
+ { name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
12418
+ { name: "subjectKeyIdentifier" }
12419
+ ]);
12420
+ cert.sign(keys.privateKey, forge.md.sha256.create());
12421
+ await fs21.mkdir(dir, { recursive: true });
12422
+ await fs21.writeFile(certPath, forge.pki.certificateToPem(cert), {
12423
+ mode: 420
12424
+ });
12425
+ await fs21.writeFile(keyPath, forge.pki.privateKeyToPem(keys.privateKey), {
12426
+ mode: 384
12427
+ });
12428
+ return { cert, key: keys.privateKey, certPath, keyPath };
12429
+ }
12430
+ function normalizeSans(sans) {
12431
+ return [...new Set(sans.filter((s) => s.length > 0))].sort();
12432
+ }
12433
+ async function readLeafMeta(metaPath) {
12434
+ try {
12435
+ return JSON.parse(await fs21.readFile(metaPath, "utf8"));
12436
+ } catch {
12437
+ return null;
12438
+ }
12439
+ }
12440
+ function leafStillGood(meta, sans) {
12441
+ if (!meta) return false;
12442
+ if (meta.sans.join(",") !== sans.join(",")) return false;
12443
+ const notAfter = new Date(meta.notAfter).getTime();
12444
+ if (Number.isNaN(notAfter)) return false;
12445
+ return notAfter - Date.now() > LEAF_RENEW_BEFORE_MS;
12446
+ }
12447
+ async function ensureLeafCert(opts) {
12448
+ const sans = normalizeSans(opts.sans);
12449
+ const dir = path33.join(homeDir(opts.monocerosHome), CERTS_DIR);
12450
+ const certPath = path33.join(dir, LEAF_CERT_FILE);
12451
+ const keyPath = path33.join(dir, LEAF_KEY_FILE);
12452
+ const metaPath = path33.join(dir, LEAF_META_FILE);
12453
+ const ca = await loadOrCreateCa(opts.monocerosHome);
12454
+ const haveFiles = await fileExists(certPath) && await fileExists(keyPath);
12455
+ if (haveFiles && leafStillGood(await readLeafMeta(metaPath), sans)) {
12456
+ return {
12457
+ caCertPath: ca.certPath,
12458
+ certDir: dir,
12459
+ certFile: LEAF_CERT_FILE,
12460
+ keyFile: LEAF_KEY_FILE
12461
+ };
12462
+ }
12463
+ const keys = forge.pki.rsa.generateKeyPair(2048);
12464
+ const cert = forge.pki.createCertificate();
12465
+ cert.publicKey = keys.publicKey;
12466
+ cert.serialNumber = randomSerial();
12467
+ cert.validity.notBefore = /* @__PURE__ */ new Date();
12468
+ cert.validity.notAfter = new Date(
12469
+ cert.validity.notBefore.getTime() + LEAF_DAYS * 24 * 60 * 60 * 1e3
12470
+ );
12471
+ cert.setSubject([{ name: "commonName", value: "monoceros share" }]);
12472
+ cert.setIssuer(ca.cert.subject.attributes);
12473
+ cert.setExtensions([
12474
+ { name: "basicConstraints", cA: false },
12475
+ {
12476
+ name: "keyUsage",
12477
+ digitalSignature: true,
12478
+ keyEncipherment: true,
12479
+ critical: true
12480
+ },
12481
+ { name: "extKeyUsage", serverAuth: true },
12482
+ {
12483
+ name: "subjectAltName",
12484
+ altNames: sans.map(
12485
+ (s) => IPV4_RE2.test(s) ? { type: 7, ip: s } : { type: 2, value: s }
12486
+ )
12487
+ }
12488
+ ]);
12489
+ cert.sign(ca.key, forge.md.sha256.create());
12490
+ await fs21.mkdir(dir, { recursive: true });
12491
+ await fs21.writeFile(certPath, forge.pki.certificateToPem(cert), {
12492
+ mode: 420
12493
+ });
12494
+ await fs21.writeFile(keyPath, forge.pki.privateKeyToPem(keys.privateKey), {
12495
+ mode: 384
12496
+ });
12497
+ const meta = {
12498
+ sans,
12499
+ notAfter: cert.validity.notAfter.toISOString()
12500
+ };
12501
+ await fs21.writeFile(metaPath, JSON.stringify(meta), { mode: 420 });
12502
+ return {
12503
+ caCertPath: ca.certPath,
12504
+ certDir: dir,
12505
+ certFile: LEAF_CERT_FILE,
12506
+ keyFile: LEAF_KEY_FILE
12507
+ };
12508
+ }
12509
+ async function provisionShareTls(opts) {
12510
+ return ensureLeafCert(opts);
12511
+ }
12512
+ var CA_DIR, CA_CERT_FILE, CA_KEY_FILE, CERTS_DIR, LEAF_CERT_FILE, LEAF_KEY_FILE, LEAF_META_FILE, CA_DAYS, LEAF_DAYS, LEAF_RENEW_BEFORE_MS, IPV4_RE2;
12513
+ var init_ca = __esm({
12514
+ "src/tls/ca.ts"() {
12515
+ "use strict";
12516
+ init_paths();
12517
+ CA_DIR = "ca";
12518
+ CA_CERT_FILE = "rootCA.pem";
12519
+ CA_KEY_FILE = "rootCA-key.pem";
12520
+ CERTS_DIR = "certs";
12521
+ LEAF_CERT_FILE = "leaf.pem";
12522
+ LEAF_KEY_FILE = "leaf-key.pem";
12523
+ LEAF_META_FILE = "leaf.json";
12524
+ CA_DAYS = 3650;
12525
+ LEAF_DAYS = 397;
12526
+ LEAF_RENEW_BEFORE_MS = 7 * 24 * 60 * 60 * 1e3;
12527
+ IPV4_RE2 = /^(\d{1,3}\.){3}\d{1,3}$/;
12528
+ }
12529
+ });
12530
+
12531
+ // src/share/caddy.ts
12532
+ function renderCaddyfile(sites, certFile, keyFile) {
12533
+ const blocks = sites.map(
12534
+ (s) => `:${s.port} {
12535
+ tls /certs/${certFile} /certs/${keyFile}
12536
+ reverse_proxy http://${s.targetHost}:${s.port}
12537
+ }`
12538
+ );
12539
+ return [
12540
+ "{",
12541
+ " auto_https off",
12542
+ " admin off",
12543
+ " servers {",
12544
+ " protocols h1 h2",
12545
+ " }",
12546
+ "}",
12547
+ ...blocks,
12548
+ ""
12549
+ ].join("\n");
12550
+ }
12551
+ function buildCaddyDockerArgs(input) {
12552
+ const args = ["run", "--rm", "-i", `--network=${input.network}`];
12553
+ for (const port of input.ports) {
12554
+ args.push("-p", `${input.localAddress}:${port}:${port}`);
12555
+ }
12556
+ args.push(
12557
+ "-v",
12558
+ `${input.certDir}:/certs:ro`,
12559
+ "-v",
12560
+ `${input.caddyfilePath}:/etc/caddy/Caddyfile:ro`,
12561
+ CADDY_IMAGE
12562
+ );
12563
+ return args;
12564
+ }
12565
+ var CADDY_IMAGE;
12566
+ var init_caddy = __esm({
12567
+ "src/share/caddy.ts"() {
12568
+ "use strict";
12569
+ CADDY_IMAGE = "caddy:2.11.4";
12570
+ }
12571
+ });
12572
+
12373
12573
  // src/share/run.ts
12374
12574
  import os3 from "os";
12575
+ import { spawnSync as spawnSync2 } from "child_process";
12375
12576
  import { consola as consola35 } from "consola";
12577
+ import { promises as fs22 } from "fs";
12578
+ import path34 from "path";
12376
12579
  function realHostAddresses() {
12377
12580
  let ip;
12378
12581
  for (const list of Object.values(os3.networkInterfaces())) {
@@ -12384,9 +12587,18 @@ function realHostAddresses() {
12384
12587
  }
12385
12588
  if (ip) break;
12386
12589
  }
12590
+ return { ...ip ? { ip } : {}, mdnsName: mdnsHostName() };
12591
+ }
12592
+ function mdnsHostName() {
12593
+ if (process.platform === "darwin") {
12594
+ const res = spawnSync2("scutil", ["--get", "LocalHostName"], {
12595
+ encoding: "utf8"
12596
+ });
12597
+ const name = res.status === 0 ? res.stdout.trim() : "";
12598
+ if (name) return `${name}.local`;
12599
+ }
12387
12600
  const hn = os3.hostname();
12388
- const mdnsName = hn.endsWith(".local") ? hn : `${hn}.local`;
12389
- return { ...ip ? { ip } : {}, mdnsName };
12601
+ return hn.endsWith(".local") ? hn : `${hn}.local`;
12390
12602
  }
12391
12603
  async function runShare(opts) {
12392
12604
  const log = opts.logger ?? {
@@ -12418,31 +12630,62 @@ async function runShare(opts) {
12418
12630
  for (const port of ports) {
12419
12631
  await preflight({ port, address: SHARE_ADDRESS });
12420
12632
  }
12633
+ const { ip, mdnsName } = (opts.hostAddresses ?? realHostAddresses)();
12634
+ const sans = [mdnsName, ip, "localhost", "127.0.0.1"].filter(
12635
+ (s) => typeof s === "string" && s.length > 0
12636
+ );
12637
+ const provisionTls = opts.provisionTls ?? provisionShareTls;
12638
+ const tls = await provisionTls({
12639
+ sans,
12640
+ ...opts.monocerosHome !== void 0 ? { monocerosHome: opts.monocerosHome } : {}
12641
+ });
12642
+ const sites = ports.map((port) => ({
12643
+ port,
12644
+ targetHost: base.targetHost
12645
+ }));
12646
+ const home = opts.monocerosHome ?? monocerosHome();
12647
+ const shareDir = path34.join(home, "share");
12648
+ await fs22.mkdir(shareDir, { recursive: true });
12649
+ const caddyfilePath = path34.join(
12650
+ shareDir,
12651
+ `${opts.name}__${opts.app}.Caddyfile`
12652
+ );
12653
+ await fs22.rm(caddyfilePath, { force: true });
12654
+ await fs22.writeFile(
12655
+ caddyfilePath,
12656
+ renderCaddyfile(sites, tls.certFile, tls.keyFile)
12657
+ );
12421
12658
  const dockerSpawn = opts.dockerSpawn ?? defaultDockerSpawn;
12422
- const handles = ports.map(
12423
- (port) => dockerSpawn(
12424
- buildDockerArgs({
12659
+ const handles = [
12660
+ dockerSpawn(
12661
+ buildCaddyDockerArgs({
12425
12662
  localAddress: SHARE_ADDRESS,
12426
- localPort: port,
12427
- internalPort: port,
12663
+ ports,
12428
12664
  network: base.network,
12429
- targetHost: base.targetHost
12665
+ certDir: tls.certDir,
12666
+ caddyfilePath
12430
12667
  })
12431
12668
  )
12432
- );
12433
- const { ip, mdnsName } = (opts.hostAddresses ?? realHostAddresses)();
12434
- const host = ip ?? mdnsName ?? "<host-ip>";
12669
+ ];
12670
+ const host = mdnsName ?? ip ?? "<host-ip>";
12435
12671
  log.info(
12436
12672
  `Sharing ${opts.name}/${opts.app} on the local network (Ctrl+C to stop):`
12437
12673
  );
12438
12674
  for (const t of ported) {
12439
- log.info(` ${cyan2(t.name)} http://${host}:${t.port}`);
12675
+ log.info(` ${cyan2(t.name)} https://${host}:${t.port}`);
12440
12676
  }
12441
12677
  if (mdnsName && ip) {
12442
12678
  log.info(
12443
- dim(` also reachable as http://${mdnsName}:<port> where mDNS resolves`)
12679
+ dim(
12680
+ ` also reachable as https://${ip}:<port> if .local does not resolve`
12681
+ )
12444
12682
  );
12445
12683
  }
12684
+ log.info(
12685
+ dim(
12686
+ ` First device? Trust the local CA once so HTTPS is warning-free: ${tls.caCertPath}`
12687
+ )
12688
+ );
12446
12689
  const installSignalHandler = opts.installSignalHandler ?? installSigintDefault;
12447
12690
  let stopping = false;
12448
12691
  const uninstall = installSignalHandler(() => {
@@ -12466,6 +12709,9 @@ var init_run4 = __esm({
12466
12709
  init_resolve2();
12467
12710
  init_port_check2();
12468
12711
  init_run3();
12712
+ init_ca();
12713
+ init_caddy();
12714
+ init_paths();
12469
12715
  init_format();
12470
12716
  SHARE_ADDRESS = "0.0.0.0";
12471
12717
  }
@@ -12483,7 +12729,7 @@ var init_share = __esm({
12483
12729
  meta: {
12484
12730
  name: "share",
12485
12731
  group: "discovery",
12486
- description: "Expose an app's ports to the local network (phone, tablet, other devices) so any device can open it - reached via the host's LAN IP (or `.local` name). Every target in the app's launch.json that declares a port is shared. Foreground: Ctrl+C stops sharing. See ADR 0030."
12732
+ description: "Expose an app's ports to the local network (phone, tablet, other devices) over HTTPS so any device can open it - reached via the host's LAN IP (or `.local` name). TLS is served with a machine-local CA; trust the printed rootCA.pem once per device for warning-free HTTPS (and a working PWA secure context). Every target in the app's launch.json that declares a port is shared. Foreground: Ctrl+C stops sharing. See ADR 0030 and 0033."
12487
12733
  },
12488
12734
  args: {
12489
12735
  name: {
@@ -13267,7 +13513,7 @@ var init_prune = __esm({
13267
13513
  });
13268
13514
 
13269
13515
  // src/upgrade/index.ts
13270
- import { existsSync as existsSync21, promises as fs21 } from "fs";
13516
+ import { existsSync as existsSync21, promises as fs23 } from "fs";
13271
13517
  import { consola as consola41 } from "consola";
13272
13518
  async function fetchRuntimeVersions() {
13273
13519
  const tokenUrl = `https://ghcr.io/token?service=ghcr.io&scope=repository:${RUNTIME_REPO}:pull`;
@@ -13384,10 +13630,10 @@ async function runUpgrade(opts) {
13384
13630
  for (const name of targets) {
13385
13631
  const ymlPath = containerConfigPath(name, home);
13386
13632
  if (!existsSync21(ymlPath)) continue;
13387
- const raw = await fs21.readFile(ymlPath, "utf8");
13633
+ const raw = await fs23.readFile(ymlPath, "utf8");
13388
13634
  const updated = setRuntimeVersion(raw, pinVersion);
13389
13635
  if (updated !== raw) {
13390
- await fs21.writeFile(ymlPath, updated);
13636
+ await fs23.writeFile(ymlPath, updated);
13391
13637
  bumped += 1;
13392
13638
  logger.info(`Pinned '${name}' to runtime ${pinVersion}.`);
13393
13639
  }
@@ -13429,7 +13675,7 @@ function formatPruneLine(prune) {
13429
13675
  }
13430
13676
  async function listContainerNames2(home) {
13431
13677
  try {
13432
- const entries = await fs21.readdir(containerConfigsDir(home));
13678
+ const entries = await fs23.readdir(containerConfigsDir(home));
13433
13679
  return entries.filter((e) => e.endsWith(".yml")).map((e) => e.slice(0, -".yml".length));
13434
13680
  } catch {
13435
13681
  return [];
@@ -13844,25 +14090,25 @@ function detectHelpRequest(argv, main2) {
13844
14090
  const separatorIdx = argv.indexOf("--");
13845
14091
  if (helpIdx === -1) return null;
13846
14092
  if (separatorIdx !== -1 && separatorIdx < helpIdx) return null;
13847
- const path33 = [];
14093
+ const path35 = [];
13848
14094
  const tokens = argv.slice(
13849
14095
  0,
13850
14096
  separatorIdx === -1 ? argv.length : separatorIdx
13851
14097
  );
13852
14098
  let cursor = main2;
13853
14099
  const mainName = (main2.meta ?? {}).name ?? "monoceros";
13854
- path33.push(mainName);
14100
+ path35.push(mainName);
13855
14101
  for (const tok of tokens) {
13856
14102
  if (tok.startsWith("-")) continue;
13857
14103
  const subs = cursor.subCommands ?? {};
13858
14104
  if (tok in subs) {
13859
14105
  cursor = subs[tok];
13860
- path33.push(tok);
14106
+ path35.push(tok);
13861
14107
  continue;
13862
14108
  }
13863
14109
  break;
13864
14110
  }
13865
- return { path: path33, cmd: cursor };
14111
+ return { path: path35, cmd: cursor };
13866
14112
  }
13867
14113
  async function maybeRenderHelp(argv, main2) {
13868
14114
  const hit = detectHelpRequest(argv, main2);