@getmonoceros/workbench 1.37.14 → 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.
@@ -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.14" : "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);