@quatronic/qore 0.1.7 → 0.1.9

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 (76) hide show
  1. package/README.md +83 -2
  2. package/dist/cli.js +1947 -1142
  3. package/package.json +24 -5
  4. package/viewer/dist/assets/elk.bundled-C8gcDXx2.js +24 -0
  5. package/viewer/dist/assets/index-BRgYtLRN.css +1 -0
  6. package/viewer/dist/assets/index-VDGZO23h.js +15 -0
  7. package/viewer/dist/assets/inter-cyrillic-400-normal-HOLc17fK.woff +0 -0
  8. package/viewer/dist/assets/inter-cyrillic-400-normal-obahsSVq.woff2 +0 -0
  9. package/viewer/dist/assets/inter-cyrillic-500-normal-BasfLYem.woff2 +0 -0
  10. package/viewer/dist/assets/inter-cyrillic-500-normal-CxZf_p3X.woff +0 -0
  11. package/viewer/dist/assets/inter-cyrillic-600-normal-4D_pXhcN.woff +0 -0
  12. package/viewer/dist/assets/inter-cyrillic-600-normal-CWCymEST.woff2 +0 -0
  13. package/viewer/dist/assets/inter-cyrillic-ext-400-normal-BQZuk6qB.woff2 +0 -0
  14. package/viewer/dist/assets/inter-cyrillic-ext-400-normal-DQukG94-.woff +0 -0
  15. package/viewer/dist/assets/inter-cyrillic-ext-500-normal-B0yAr1jD.woff2 +0 -0
  16. package/viewer/dist/assets/inter-cyrillic-ext-500-normal-BmqWE9Dz.woff +0 -0
  17. package/viewer/dist/assets/inter-cyrillic-ext-600-normal-Bcila6Z-.woff +0 -0
  18. package/viewer/dist/assets/inter-cyrillic-ext-600-normal-Dfes3d0z.woff2 +0 -0
  19. package/viewer/dist/assets/inter-greek-400-normal-B4URO6DV.woff2 +0 -0
  20. package/viewer/dist/assets/inter-greek-400-normal-q2sYcFCs.woff +0 -0
  21. package/viewer/dist/assets/inter-greek-500-normal-BIZE56-Y.woff2 +0 -0
  22. package/viewer/dist/assets/inter-greek-500-normal-Xzm54t5V.woff +0 -0
  23. package/viewer/dist/assets/inter-greek-600-normal-BZpKdvQh.woff +0 -0
  24. package/viewer/dist/assets/inter-greek-600-normal-plRanbMR.woff2 +0 -0
  25. package/viewer/dist/assets/inter-greek-ext-400-normal-DGGRlc-M.woff2 +0 -0
  26. package/viewer/dist/assets/inter-greek-ext-400-normal-KugGGMne.woff +0 -0
  27. package/viewer/dist/assets/inter-greek-ext-500-normal-2j5mBUwD.woff +0 -0
  28. package/viewer/dist/assets/inter-greek-ext-500-normal-C4iEst2y.woff2 +0 -0
  29. package/viewer/dist/assets/inter-greek-ext-600-normal-B8X0CLgF.woff +0 -0
  30. package/viewer/dist/assets/inter-greek-ext-600-normal-DRtmH8MT.woff2 +0 -0
  31. package/viewer/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  32. package/viewer/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  33. package/viewer/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  34. package/viewer/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  35. package/viewer/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  36. package/viewer/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  37. package/viewer/dist/assets/inter-latin-ext-400-normal-77YHD8bZ.woff +0 -0
  38. package/viewer/dist/assets/inter-latin-ext-400-normal-C1nco2VV.woff2 +0 -0
  39. package/viewer/dist/assets/inter-latin-ext-500-normal-BxGbmqWO.woff +0 -0
  40. package/viewer/dist/assets/inter-latin-ext-500-normal-CV4jyFjo.woff2 +0 -0
  41. package/viewer/dist/assets/inter-latin-ext-600-normal-CIVaiw4L.woff +0 -0
  42. package/viewer/dist/assets/inter-latin-ext-600-normal-D2bJ5OIk.woff2 +0 -0
  43. package/viewer/dist/assets/inter-vietnamese-400-normal-Bbgyi5SW.woff +0 -0
  44. package/viewer/dist/assets/inter-vietnamese-400-normal-DMkecbls.woff2 +0 -0
  45. package/viewer/dist/assets/inter-vietnamese-500-normal-DOriooB6.woff2 +0 -0
  46. package/viewer/dist/assets/inter-vietnamese-500-normal-mJboJaSs.woff +0 -0
  47. package/viewer/dist/assets/inter-vietnamese-600-normal-BuLX-rYi.woff +0 -0
  48. package/viewer/dist/assets/inter-vietnamese-600-normal-Cc8MFFhd.woff2 +0 -0
  49. package/viewer/dist/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  50. package/viewer/dist/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  51. package/viewer/dist/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
  52. package/viewer/dist/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
  53. package/viewer/dist/assets/jetbrains-mono-cyrillic-600-normal-8K4wrrwR.woff +0 -0
  54. package/viewer/dist/assets/jetbrains-mono-cyrillic-600-normal-EVf6-Yzo.woff2 +0 -0
  55. package/viewer/dist/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  56. package/viewer/dist/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  57. package/viewer/dist/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
  58. package/viewer/dist/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
  59. package/viewer/dist/assets/jetbrains-mono-greek-600-normal-H7WoG9Et.woff2 +0 -0
  60. package/viewer/dist/assets/jetbrains-mono-greek-600-normal-mc2nkWzM.woff +0 -0
  61. package/viewer/dist/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  62. package/viewer/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  63. package/viewer/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  64. package/viewer/dist/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  65. package/viewer/dist/assets/jetbrains-mono-latin-600-normal-BfsvjouI.woff +0 -0
  66. package/viewer/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  67. package/viewer/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  68. package/viewer/dist/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  69. package/viewer/dist/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  70. package/viewer/dist/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
  71. package/viewer/dist/assets/jetbrains-mono-latin-ext-600-normal-BfB_LPfz.woff2 +0 -0
  72. package/viewer/dist/assets/jetbrains-mono-latin-ext-600-normal-DObL3zCW.woff +0 -0
  73. package/viewer/dist/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  74. package/viewer/dist/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
  75. package/viewer/dist/assets/jetbrains-mono-vietnamese-600-normal-OWROknRo.woff +0 -0
  76. package/viewer/dist/index.html +13 -0
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="bf813c0e-4054-532d-9e74-62fbe8673876")}catch(e){}}();
3
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f7b490f6-b0af-5874-9ffa-f8010e3ba137")}catch(e){}}();
4
4
  import { createRequire as __qoreCreateRequire } from "node:module"; const require = __qoreCreateRequire(import.meta.url);
5
5
  import {
6
6
  addSecretValues,
@@ -9,6 +9,7 @@ import {
9
9
 
10
10
  // src/cli.ts
11
11
  import { homedir as homedir2 } from "os";
12
+ import { fileURLToPath } from "url";
12
13
  import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
13
14
 
14
15
  // src/cli/io.ts
@@ -169,7 +170,7 @@ function processIo(interactive, stderrTTY = Boolean(process.stderr.isTTY)) {
169
170
  };
170
171
  }
171
172
  function createWaitForEnter(input2, output) {
172
- return (message, signal) => new Promise((resolve6, reject) => {
173
+ return (message, signal) => new Promise((resolve7, reject) => {
173
174
  const rl = createInterface({ input: input2, output, terminal: true });
174
175
  const onAbort = () => {
175
176
  rl.close();
@@ -178,7 +179,7 @@ function createWaitForEnter(input2, output) {
178
179
  const done = () => {
179
180
  signal.removeEventListener("abort", onAbort);
180
181
  rl.close();
181
- resolve6();
182
+ resolve7();
182
183
  };
183
184
  signal.addEventListener("abort", onAbort, { once: true });
184
185
  rl.on("SIGINT", () => {
@@ -382,16 +383,16 @@ function groupByModule(results) {
382
383
  const order = [];
383
384
  const groups = /* @__PURE__ */ new Map();
384
385
  for (const r of results) {
385
- const key = r.module ?? r.id;
386
- if (!groups.has(key)) {
387
- groups.set(key, []);
388
- order.push(key);
386
+ const key2 = r.module ?? r.id;
387
+ if (!groups.has(key2)) {
388
+ groups.set(key2, []);
389
+ order.push(key2);
389
390
  }
390
- groups.get(key).push(r);
391
+ groups.get(key2).push(r);
391
392
  }
392
393
  const isSub = (r) => r.id.startsWith("pm:") || r.id.startsWith("node:");
393
- return order.flatMap((key) => {
394
- const g = groups.get(key);
394
+ return order.flatMap((key2) => {
395
+ const g = groups.get(key2);
395
396
  return [...g.filter((r) => !isSub(r)), ...g.filter(isSub)];
396
397
  });
397
398
  }
@@ -654,7 +655,7 @@ var QoreApiClient = class {
654
655
  this.o = o;
655
656
  }
656
657
  o;
657
- async once(method, path, body, force) {
658
+ async once(method, path2, body, force) {
658
659
  const token = await this.o.token(force);
659
660
  const headers = {
660
661
  authorization: `Bearer ${token}`,
@@ -666,7 +667,7 @@ var QoreApiClient = class {
666
667
  if (body !== void 0) headers["content-type"] = "application/json";
667
668
  let res;
668
669
  try {
669
- res = await this.o.fetch(`${this.o.apiOrigin}${path}`, {
670
+ res = await this.o.fetch(`${this.o.apiOrigin}${path2}`, {
670
671
  method,
671
672
  headers,
672
673
  body: body === void 0 ? void 0 : JSON.stringify(body)
@@ -675,13 +676,13 @@ var QoreApiClient = class {
675
676
  throw fetchError(e, new URL(this.o.apiOrigin).host);
676
677
  }
677
678
  const rid = res.headers.get("x-request-id");
678
- this.o.onRequest?.(`${method} ${path} \u2192 ${res.status}${rid ? ` (${rid})` : ""}`);
679
+ this.o.onRequest?.(`${method} ${path2} \u2192 ${res.status}${rid ? ` (${rid})` : ""}`);
679
680
  return res;
680
681
  }
681
- async request(method, path, body, parse2 = (r) => r.json()) {
682
- let res = await this.once(method, path, body, false);
682
+ async request(method, path2, body, parse2 = (r) => r.json()) {
683
+ let res = await this.once(method, path2, body, false);
683
684
  if (res.status === 401) {
684
- res = await this.once(method, path, body, true);
685
+ res = await this.once(method, path2, body, true);
685
686
  if (res.status === 401) {
686
687
  const skew = clockSkewSeconds(res.headers.get("date"), (this.o.now ?? (() => /* @__PURE__ */ new Date()))());
687
688
  if (skew !== null && Math.abs(skew) > 60) {
@@ -702,14 +703,14 @@ var QoreApiClient = class {
702
703
  if (!res.ok) throw await problemToError(res);
703
704
  return parse2(res);
704
705
  }
705
- get(path) {
706
- return this.request("GET", path);
706
+ get(path2) {
707
+ return this.request("GET", path2);
707
708
  }
708
- getText(path) {
709
- return this.request("GET", path, void 0, (r) => r.text());
709
+ getText(path2) {
710
+ return this.request("GET", path2, void 0, (r) => r.text());
710
711
  }
711
- post(path, body) {
712
- return this.request("POST", path, body);
712
+ post(path2, body) {
713
+ return this.request("POST", path2, body);
713
714
  }
714
715
  };
715
716
 
@@ -930,23 +931,23 @@ var ResolutionCache = class {
930
931
  fresh(e) {
931
932
  return !!e && this.now().getTime() - new Date(e.resolvedAt).getTime() < TTL_MS2;
932
933
  }
933
- async get(origin, key) {
934
- const e = (await this.load()).entries[`${origin}|${key}`];
934
+ async get(origin, key2) {
935
+ const e = (await this.load()).entries[`${origin}|${key2}`];
935
936
  return this.fresh(e) ? e.moduleId : null;
936
937
  }
937
938
  /** Never throws: a read-only config dir, a full disk or a Windows EPERM on rename
938
939
  * (another qore holding the file) must not fail a command that already resolved. */
939
- async put(origin, key, moduleId) {
940
+ async put(origin, key2, moduleId) {
940
941
  const f = await this.load();
941
- f.entries[`${origin}|${key}`] = { moduleId, resolvedAt: this.now().toISOString() };
942
+ f.entries[`${origin}|${key2}`] = { moduleId, resolvedAt: this.now().toISOString() };
942
943
  for (const [k, e] of Object.entries(f.entries)) if (!this.fresh(e)) delete f.entries[k];
943
944
  await this.save(f);
944
945
  }
945
946
  /** Never throws, like `put`. */
946
- async drop(origin, key) {
947
+ async drop(origin, key2) {
947
948
  const f = await this.load();
948
- if (!(`${origin}|${key}` in f.entries)) return;
949
- delete f.entries[`${origin}|${key}`];
949
+ if (!(`${origin}|${key2}` in f.entries)) return;
950
+ delete f.entries[`${origin}|${key2}`];
950
951
  await this.save(f);
951
952
  }
952
953
  async save(f) {
@@ -959,8 +960,8 @@ var ResolutionCache = class {
959
960
  const f = await this.load();
960
961
  const hits = [];
961
962
  for (const [k, e] of Object.entries(f.entries)) {
962
- const sep3 = k.lastIndexOf("|");
963
- if (keys.includes(k.slice(sep3 + 1)) && this.fresh(e)) hits.push({ origin: k.slice(0, sep3), at: new Date(e.resolvedAt).getTime() });
963
+ const sep4 = k.lastIndexOf("|");
964
+ if (keys.includes(k.slice(sep4 + 1)) && this.fresh(e)) hits.push({ origin: k.slice(0, sep4), at: new Date(e.resolvedAt).getTime() });
964
965
  }
965
966
  hits.sort((a, b) => b.at - a.at);
966
967
  return [...new Set(hits.map((h) => h.origin))];
@@ -977,7 +978,7 @@ function repoKey(url) {
977
978
  if (raw.startsWith("git+")) raw = raw.slice(4);
978
979
  if (!raw || raw.includes("\\")) return null;
979
980
  let host;
980
- let path;
981
+ let path2;
981
982
  if (raw.includes("://")) {
982
983
  if (hasDotSegment(raw.slice(raw.indexOf("://") + 3).split("/").slice(1))) return null;
983
984
  let u;
@@ -988,14 +989,14 @@ function repoKey(url) {
988
989
  }
989
990
  if (!SCHEMES.has(u.protocol.toLowerCase())) return null;
990
991
  host = u.hostname;
991
- path = u.pathname;
992
+ path2 = u.pathname;
992
993
  } else {
993
994
  const m = SCP.exec(raw);
994
995
  if (!m) return null;
995
996
  host = m[1];
996
- path = m[2];
997
+ path2 = m[2];
997
998
  }
998
- const segments = path.split("/").filter(Boolean);
999
+ const segments = path2.split("/").filter(Boolean);
999
1000
  if (!host || segments.length !== 2 || hasDotSegment(segments)) return null;
1000
1001
  const owner = segments[0];
1001
1002
  let repo = segments[1];
@@ -1023,12 +1024,12 @@ async function isTrusted(deps2, root, moduleId) {
1023
1024
  return (cfg.trustedRepos?.[await keyOf(deps2, root)] ?? []).includes(moduleId);
1024
1025
  }
1025
1026
  async function trust(deps2, root, moduleId) {
1026
- const key = await keyOf(deps2, root);
1027
+ const key2 = await keyOf(deps2, root);
1027
1028
  await deps2.config.update((c) => {
1028
1029
  const repos = c.trustedRepos ??= {};
1029
- const list = new Set(repos[key] ?? []);
1030
+ const list = new Set(repos[key2] ?? []);
1030
1031
  list.add(moduleId);
1031
- repos[key] = [...list];
1032
+ repos[key2] = [...list];
1032
1033
  });
1033
1034
  }
1034
1035
 
@@ -1189,8 +1190,8 @@ async function applyActions(deps2, o) {
1189
1190
  const r = await deps2.git.clone(url, action.dir);
1190
1191
  if (r.ok) {
1191
1192
  await trust(deps2, action.dir, action.module.id);
1192
- const key = repoKey(action.module.repoUrl);
1193
- if (key) await cache.put(o.portal.apiOrigin, key, action.module.id);
1193
+ const key2 = repoKey(action.module.repoUrl);
1194
+ if (key2) await cache.put(o.portal.apiOrigin, key2, action.module.id);
1194
1195
  result = { slug: action.module.slug, status: "cloned", dir: action.dir };
1195
1196
  } else {
1196
1197
  const err = classifyCloneFailure(r.stderr, action.module, o.app, o.protocol);
@@ -1358,10 +1359,10 @@ async function scanRoot(root) {
1358
1359
  const out = [];
1359
1360
  for (const tenant of (await subdirs(root)).sort()) {
1360
1361
  for (const app of (await subdirs(join6(root, tenant))).sort()) {
1361
- const path = join6(root, tenant, app);
1362
+ const path2 = join6(root, tenant, app);
1362
1363
  try {
1363
- const ws = await readWorkspace(path);
1364
- if (ws) out.push({ kind: "workspace", path, portal: ws.portal, tenantSlug: tenant, appId: ws.appId, appSlug: app });
1364
+ const ws = await readWorkspace(path2);
1365
+ if (ws) out.push({ kind: "workspace", path: path2, portal: ws.portal, tenantSlug: tenant, appId: ws.appId, appSlug: app });
1365
1366
  } catch {
1366
1367
  }
1367
1368
  }
@@ -1435,12 +1436,12 @@ function expandPath(deps2, input2) {
1435
1436
  }
1436
1437
  function trimSeparator(p) {
1437
1438
  const root = parse(p).root;
1438
- let end = p.length;
1439
- while (end > root.length && (p[end - 1] === "/" || p[end - 1] === "\\")) end--;
1440
- return p.slice(0, end);
1439
+ let end2 = p.length;
1440
+ while (end2 > root.length && (p[end2 - 1] === "/" || p[end2 - 1] === "\\")) end2--;
1441
+ return p.slice(0, end2);
1441
1442
  }
1442
- async function underAFile(path) {
1443
- for (let p = dirname3(path); ; p = dirname3(p)) {
1443
+ async function underAFile(path2) {
1444
+ for (let p = dirname3(path2); ; p = dirname3(p)) {
1444
1445
  const s = await stat2(p).catch((e) => e.code === "ENOENT" ? void 0 : null);
1445
1446
  if (s === null) return false;
1446
1447
  if (s) return !s.isDirectory();
@@ -1455,8 +1456,8 @@ function scannedBy(root, e) {
1455
1456
  if (e.kind !== "workspace" || !isInside(root, e.path)) return false;
1456
1457
  return relative2(root, e.path).split(sep).filter(Boolean).length === 2;
1457
1458
  }
1458
- async function writableError(path) {
1459
- const probe = join8(path, `.qore-write-test-${randomBytes3(6).toString("hex")}`);
1459
+ async function writableError(path2) {
1460
+ const probe = join8(path2, `.qore-write-test-${randomBytes3(6).toString("hex")}`);
1460
1461
  try {
1461
1462
  await writeFile3(probe, "");
1462
1463
  await rm2(probe, { force: true });
@@ -1465,42 +1466,42 @@ async function writableError(path) {
1465
1466
  return e;
1466
1467
  }
1467
1468
  }
1468
- var invalid2 = (path, why) => new QoreError("QORE_ROOT_INVALID", `${path} cannot be the projects root: ${why}`, { hint: "Pick another folder: qore init --root <path>." });
1469
- var notWritable = (path, e) => new QoreError("QORE_ROOT_NOT_WRITABLE", `${path} is not writable (${e.code ?? e.message})`, {
1469
+ var invalid2 = (path2, why) => new QoreError("QORE_ROOT_INVALID", `${path2} cannot be the projects root: ${why}`, { hint: "Pick another folder: qore init --root <path>." });
1470
+ var notWritable = (path2, e) => new QoreError("QORE_ROOT_NOT_WRITABLE", `${path2} is not writable (${e.code ?? e.message})`, {
1470
1471
  hint: "Fix its permissions, or pick another folder: qore init --root <path>.",
1471
1472
  cause: e
1472
1473
  });
1473
1474
  async function checkRoot(deps2, input2) {
1474
- const path = expandPath(deps2, input2);
1475
- if (dirname3(path) === path) throw invalid2(path, "it is the filesystem root");
1475
+ const path2 = expandPath(deps2, input2);
1476
+ if (dirname3(path2) === path2) throw invalid2(path2, "it is the filesystem root");
1476
1477
  try {
1477
- if (!(await stat2(path)).isDirectory()) throw invalid2(path, "it is a file");
1478
+ if (!(await stat2(path2)).isDirectory()) throw invalid2(path2, "it is a file");
1478
1479
  } catch (e) {
1479
1480
  if (e instanceof QoreError) throw e;
1480
1481
  const err = e;
1481
- if (err.code === "ENOTDIR") throw invalid2(path, "a parent of it is a file");
1482
- if (NOT_WRITABLE.has(err.code ?? "")) throw notWritable(path, err);
1482
+ if (err.code === "ENOTDIR") throw invalid2(path2, "a parent of it is a file");
1483
+ if (NOT_WRITABLE.has(err.code ?? "")) throw notWritable(path2, err);
1483
1484
  if (err.code !== "ENOENT") throw e;
1484
- if (await underAFile(path)) throw invalid2(path, "a parent of it is a file");
1485
+ if (await underAFile(path2)) throw invalid2(path2, "a parent of it is a file");
1485
1486
  }
1486
- const repo = await findRepoRoot(path);
1487
- if (await findWorkspace(path, deps2.home) || repo && !isInside(repo, deps2.home)) {
1488
- throw invalid2(path, "it lies inside a project (an app workspace or a git repository)");
1487
+ const repo = await findRepoRoot(path2);
1488
+ if (await findWorkspace(path2, deps2.home) || repo && !isInside(repo, deps2.home)) {
1489
+ throw invalid2(path2, "it lies inside a project (an app workspace or a git repository)");
1489
1490
  }
1490
- return path;
1491
+ return path2;
1491
1492
  }
1492
- async function createRoot(path) {
1493
+ async function createRoot(path2) {
1493
1494
  try {
1494
- await mkdir3(path, { recursive: true });
1495
+ await mkdir3(path2, { recursive: true });
1495
1496
  } catch (e) {
1496
1497
  const err2 = e;
1497
- if (NOT_WRITABLE.has(err2.code ?? "")) throw notWritable(path, err2);
1498
- if (err2.code === "ENOTDIR" || err2.code === "EEXIST") throw invalid2(path, "a parent of it is a file");
1498
+ if (NOT_WRITABLE.has(err2.code ?? "")) throw notWritable(path2, err2);
1499
+ if (err2.code === "ENOTDIR" || err2.code === "EEXIST") throw invalid2(path2, "a parent of it is a file");
1499
1500
  throw e;
1500
1501
  }
1501
- const err = await writableError(path);
1502
- if (err) throw notWritable(path, err);
1503
- return realpath3(path);
1502
+ const err = await writableError(path2);
1503
+ if (err) throw notWritable(path2, err);
1504
+ return realpath3(path2);
1504
1505
  }
1505
1506
  async function validateRoot(deps2, input2) {
1506
1507
  return createRoot(await checkRoot(deps2, input2));
@@ -1518,15 +1519,15 @@ async function quietly(fn) {
1518
1519
  }
1519
1520
  async function remember(deps2, e) {
1520
1521
  await quietly(async () => {
1521
- const path = await realpath4(e.path);
1522
- const entry = { ...e, path };
1522
+ const path2 = await realpath4(e.path);
1523
+ const entry = { ...e, path: path2 };
1523
1524
  const cfg = await deps2.config.read();
1524
1525
  const root = cfg.projectsRoot ? await realpath4(cfg.projectsRoot).catch(() => cfg.projectsRoot) : void 0;
1525
1526
  if (root && scannedBy(root, entry)) return;
1526
- const current = cfg.projects?.find((p) => p.path === path);
1527
+ const current = cfg.projects?.find((p) => p.path === path2);
1527
1528
  if (current && same(current, entry)) return;
1528
1529
  await deps2.withLock(LOCK, () => deps2.config.update((c) => {
1529
- c.projects = [...(c.projects ?? []).filter((p) => p.path !== path), entry];
1530
+ c.projects = [...(c.projects ?? []).filter((p) => p.path !== path2), entry];
1530
1531
  }).then(() => void 0));
1531
1532
  });
1532
1533
  }
@@ -1824,12 +1825,12 @@ function registerConfig(program, ctx) {
1824
1825
  io2.out(describeConsent(consent, built));
1825
1826
  if (state && consent.source !== "config") io2.err(`${consent.source} in your environment overrides this setting.`);
1826
1827
  });
1827
- config.command("root").description("Show or set the projects folder qore clones into (see qore init)").argument("[path]", "the new projects folder").option("--unset", "clear it; projects already there stay findable").action(async (path, f) => {
1828
+ config.command("root").description("Show or set the projects folder qore clones into (see qore init)").argument("[path]", "the new projects folder").option("--unset", "clear it; projects already there stay findable").action(async (path2, f) => {
1828
1829
  const { deps: deps2, io: io2 } = ctx;
1829
- if (path !== void 0 && f.unset) {
1830
+ if (path2 !== void 0 && f.unset) {
1830
1831
  throw new QoreError("QORE_CONTEXT_INVALID", "Pass a path or --unset, not both", { hint: "qore config root <path> | qore config root --unset" });
1831
1832
  }
1832
- if (path !== void 0) await setRoot(deps2, await validateRoot(deps2, path));
1833
+ if (path2 !== void 0) await setRoot(deps2, await validateRoot(deps2, path2));
1833
1834
  if (f.unset) await setRoot(deps2, void 0);
1834
1835
  const d = await describeRoot(deps2);
1835
1836
  if (ctx.opts().json) {
@@ -1841,82 +1842,13 @@ function registerConfig(program, ctx) {
1841
1842
  });
1842
1843
  }
1843
1844
 
1844
- // src/cli/commands/dev.ts
1845
- import { randomBytes as randomBytes6 } from "crypto";
1846
- import { realpath as realpath8 } from "fs/promises";
1847
- import { constants as constants3 } from "os";
1848
- import { dirname as dirname7, join as join21, relative as relative7 } from "path";
1849
- import { StringDecoder } from "string_decoder";
1850
-
1851
- // src/core/context/repoConfig.ts
1852
- import { readFile as readFile3 } from "fs/promises";
1853
- import { join as join10 } from "path";
1854
- var FORBIDDEN = ["portal", "api", "apiOrigin", "issuer", "tenant", "tenantId", "app", "appId", "module", "moduleId"];
1855
- var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
1856
- var SCRIPT = /^[A-Za-z0-9:_.-]+$/;
1857
- var invalid3 = (msg, details) => new QoreError("QORE_CONTEXT_INVALID", `.qore.json: ${msg}`, { hint: "Fix .qore.json in the repository root; see the $schema it names.", details });
1858
- function moduleDefaults(type) {
1859
- return type === "frontend" ? { envFile: ".env.local", localSecrets: ["SESSION_COOKIE_SECRET"] } : { envFile: ".env", localSecrets: [] };
1860
- }
1861
- async function readRepoConfig(root) {
1862
- let raw;
1863
- try {
1864
- raw = await readFile3(join10(root, ".qore.json"), "utf8");
1865
- } catch (e) {
1866
- if (e.code === "ENOENT") return {};
1867
- throw e;
1868
- }
1869
- let doc;
1870
- try {
1871
- doc = JSON.parse(raw);
1872
- } catch {
1873
- throw invalid3("not valid JSON");
1874
- }
1875
- if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw invalid3("must be an object");
1876
- const v = doc.schemaVersion === void 0 ? 1 : doc.schemaVersion;
1877
- if (!Number.isInteger(v) || v < 1) throw invalid3("schemaVersion must be a positive integer");
1878
- if (v > 1) {
1879
- throw new QoreError("QORE_CONTEXT_UNSUPPORTED", `.qore.json schemaVersion ${v} is newer than this qore`, { hint: "Run: npm i -g @quatronic/qore" });
1880
- }
1881
- for (const f of FORBIDDEN) if (f in doc) throw invalid3(`"${f}" is not allowed \u2014 .qore.json never selects a portal or a resource`, { field: f });
1882
- const out = {};
1883
- if (doc.envFile !== void 0) {
1884
- if (typeof doc.envFile !== "string" || !doc.envFile) throw invalid3("envFile must be a non-empty string");
1885
- out.envFile = doc.envFile;
1886
- }
1887
- if (doc.localSecrets !== void 0) {
1888
- if (!Array.isArray(doc.localSecrets) || !doc.localSecrets.every((k) => typeof k === "string" && KEY.test(k))) {
1889
- throw invalid3("localSecrets must be a list of env key names");
1890
- }
1891
- if (new Set(doc.localSecrets).size !== doc.localSecrets.length) throw invalid3("localSecrets must not contain duplicates");
1892
- out.localSecrets = doc.localSecrets;
1893
- }
1894
- if (doc.dev !== void 0) {
1895
- const d = doc.dev;
1896
- if (!d || typeof d !== "object" || Array.isArray(d)) throw invalid3("dev must be an object");
1897
- const dev = {};
1898
- if (d.install !== void 0) {
1899
- if (typeof d.install !== "boolean") throw invalid3("dev.install must be true or false");
1900
- dev.install = d.install;
1901
- }
1902
- if (d.services !== void 0) {
1903
- if (!Array.isArray(d.services) || !d.services.every((s) => typeof s === "string" && SCRIPT.test(s))) {
1904
- throw invalid3("dev.services must be a list of script names");
1905
- }
1906
- if (new Set(d.services).size !== d.services.length) throw invalid3("dev.services must not contain duplicates");
1907
- dev.services = d.services;
1908
- }
1909
- if (d.script !== void 0) {
1910
- if (typeof d.script !== "string" || !SCRIPT.test(d.script)) throw invalid3("dev.script must be a script name");
1911
- dev.script = d.script;
1912
- }
1913
- out.dev = dev;
1914
- }
1915
- return out;
1916
- }
1845
+ // src/cli/commands/db.ts
1846
+ import { stat as stat4 } from "fs/promises";
1847
+ import { basename as basename3, join as join15 } from "path";
1848
+ import { InvalidArgumentError } from "commander";
1917
1849
 
1918
1850
  // src/core/context/resolver.ts
1919
- import { isAbsolute as isAbsolute2, join as join11, relative as relative4 } from "path";
1851
+ import { isAbsolute as isAbsolute2, join as join10, relative as relative4 } from "path";
1920
1852
  var UUID3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1921
1853
  var HINT_FLAG = "cd into a module repository, or pass --module <slug|id>.";
1922
1854
  var RESOLVE = "/api/cli/v1/repos/resolve";
@@ -1954,10 +1886,10 @@ async function flagOverrides(deps2, session, portal, cache, moduleId) {
1954
1886
  const keyed = (await repoRemotes(deps2))?.keyed ?? [];
1955
1887
  const derived = /* @__PURE__ */ new Map();
1956
1888
  const uncached = [];
1957
- for (const key of new Set(keyed.map((r) => r.key))) {
1958
- const id = await cache.get(portal.apiOrigin, key);
1959
- if (id) derived.set(key, /* @__PURE__ */ new Set([id]));
1960
- else uncached.push(key);
1889
+ for (const key2 of new Set(keyed.map((r) => r.key))) {
1890
+ const id = await cache.get(portal.apiOrigin, key2);
1891
+ if (id) derived.set(key2, /* @__PURE__ */ new Set([id]));
1892
+ else uncached.push(key2);
1961
1893
  }
1962
1894
  if (uncached.length > 0) {
1963
1895
  try {
@@ -2130,7 +2062,7 @@ async function anyLoggedIn(deps2, cfg) {
2130
2062
  }
2131
2063
  var isForeignRepo = (e) => e instanceof QoreError && e.code === "QORE_CONTEXT_REQUIRED" && e.details?.foreignRepo === true;
2132
2064
  async function resolveModuleContext(deps2, o) {
2133
- const cache = new ResolutionCache(join11(deps2.config.dir, "cache"), deps2.now);
2065
+ const cache = new ResolutionCache(join10(deps2.config.dir, "cache"), deps2.now);
2134
2066
  const ws = await findWorkspace(deps2.cwd, deps2.home);
2135
2067
  if (o.module) return byFlag(deps2, o, cache, ws);
2136
2068
  const cfg = await deps2.config.read();
@@ -2207,82 +2139,356 @@ function pickApp2(mc, app) {
2207
2139
  throw new QoreError("QORE_APP_AMBIGUOUS", `${mc.module.slug} is part of several apps`, { hint: "Pass --app <slug>.", candidates: appCandidates(apps) });
2208
2140
  }
2209
2141
 
2210
- // src/core/diagnostics/node.ts
2211
- var KEEP_LINES = 50;
2212
- function exitHint(tail, h) {
2213
- const text2 = tail.join("\n");
2214
- const missing = text2.match(/Cannot find (?:module|package) '([^']+)'/);
2215
- if (missing) {
2216
- return { reason: "missing-module", hint: `A dependency is missing (${missing[1]}). Run \`${h.installCommand}\`, then qore dev again.` };
2142
+ // src/core/erd/dbErrors.ts
2143
+ function dbPort(url) {
2144
+ try {
2145
+ const u = new URL(url);
2146
+ return u.port ? Number(u.port) : 5432;
2147
+ } catch {
2148
+ return null;
2217
2149
  }
2218
- if (/ZodError:/.test(text2)) {
2219
- const keys = [...text2.matchAll(/"path":\s*\[\s*"([^"]+)"/g)].map((m) => m[1]);
2220
- if (keys.length > 0) {
2221
- return {
2222
- reason: "invalid-env",
2223
- keys,
2224
- hint: `Missing or invalid environment variables: ${keys.join(", ")}. Set them in the portal's local secrets (then qore env pull), or in your env file.`
2225
- };
2226
- }
2150
+ }
2151
+ function connectionOf(url) {
2152
+ try {
2153
+ const u = new URL(url);
2154
+ return { host: u.hostname, port: u.port ? Number(u.port) : 5432, database: decodeURIComponent(u.pathname.replace(/^\//, "")) };
2155
+ } catch {
2156
+ return { host: "", port: 0, database: "" };
2227
2157
  }
2228
- const addrInUse = text2.match(/listen EADDRINUSE: address already in use (?:\S*:)?(\d+)/);
2229
- if (addrInUse) {
2230
- const port = Number(addrInUse[1]);
2231
- return {
2232
- reason: "port-in-use",
2233
- port,
2234
- hint: `Port ${port} was taken while the server started. Run qore dev again; if it repeats, lsof -iTCP:${port} -sTCP:LISTEN names the holder.`
2235
- };
2158
+ }
2159
+ function classifyDbError(e, url) {
2160
+ const { code, message } = e;
2161
+ const port = dbPort(url);
2162
+ const where = port === null ? "The database" : `The database on :${port}`;
2163
+ if (code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT" || /timeout expired/i.test(message ?? "")) {
2164
+ return new QoreError("QORE_SERVICE_UNHEALTHY", `${where} is not accepting connections`, {
2165
+ hint: "Start it with `qore dev` (or `pnpm db:up`), then run qore db erd again.",
2166
+ details: { reason: "conn-refused", port }
2167
+ });
2236
2168
  }
2237
- if (/ECONNREFUSED/.test(text2)) {
2238
- const m = text2.match(/ECONNREFUSED\s+\S*:(\d+)/) ?? text2.match(/code: 'ECONNREFUSED'[^}]*?port: (\d+)/);
2239
- if (!m) return { reason: "conn-refused", hint: "A connection was refused (ECONNREFUSED) \u2014 a service this module calls is not running." };
2240
- const port = Number(m[1]);
2241
- const role = h.ports.get(port);
2242
- if (role?.kind === "database") {
2243
- const up = h.runCommand("db:up");
2244
- return {
2245
- reason: "conn-refused",
2246
- port,
2247
- hint: `The database on :${port} is not accepting connections (ECONNREFUSED). ` + (up ? `Fix: ${up} (docker compose ps shows its state)` : "docker compose ps shows its state.")
2248
- };
2169
+ if (code === "28P01" || code === "28000") {
2170
+ return new QoreError("QORE_SERVICE_UNHEALTHY", `${where} rejected the credentials in DATABASE_URL`, {
2171
+ hint: "The local database was created with other credentials than the portal now provides. `pnpm db:reset` recreates it \u2014 this deletes local data.",
2172
+ details: { reason: "db-auth", port }
2173
+ });
2174
+ }
2175
+ if (code === "3D000") {
2176
+ return new QoreError("QORE_SERVICE_UNHEALTHY", "The database named in DATABASE_URL does not exist", {
2177
+ hint: "Start the app once with `qore dev` so its migrations create it, or run `pnpm db:reset`.",
2178
+ details: { reason: "db-schema", port }
2179
+ });
2180
+ }
2181
+ return new QoreError("QORE_SERVICE_UNHEALTHY", `Could not read the database: ${message ?? String(e)}`, {
2182
+ hint: "Check DATABASE_URL in the env file, then run qore db erd again.",
2183
+ details: { port }
2184
+ });
2185
+ }
2186
+
2187
+ // src/core/erd/app.ts
2188
+ function orderModules(modules) {
2189
+ const bySlug = new Map(modules.map((m) => [m.slug, m]));
2190
+ const out = [];
2191
+ const seen = /* @__PURE__ */ new Set();
2192
+ const visit = (m, trail) => {
2193
+ if (seen.has(m.slug) || trail.has(m.slug)) return;
2194
+ trail.add(m.slug);
2195
+ for (const dep of m.dependsOn) {
2196
+ const d = bySlug.get(dep);
2197
+ if (d) visit(d, trail);
2249
2198
  }
2250
- if (role?.kind === "provider") {
2251
- return {
2252
- reason: "conn-refused",
2253
- port,
2254
- hint: `${role.key} points at :${port}, where nothing listens (ECONNREFUSED). Start that module \u2014 qore dev in the workspace root starts providers first.`
2255
- };
2199
+ seen.add(m.slug);
2200
+ out.push(m);
2201
+ };
2202
+ for (const m of modules) visit(m, /* @__PURE__ */ new Set());
2203
+ return out;
2204
+ }
2205
+ function groupDatabases(modules) {
2206
+ const groups = [];
2207
+ for (const m of modules) {
2208
+ if (!m.url) continue;
2209
+ const c = connectionOf(m.url);
2210
+ const same2 = groups.find((g) => g.connection.host === c.host && g.connection.port === c.port && g.connection.database === c.database);
2211
+ if (same2) {
2212
+ same2.modules.push(m.slug);
2213
+ continue;
2256
2214
  }
2257
- return { reason: "conn-refused", port, hint: `Nothing listens on :${port} (ECONNREFUSED) \u2014 a service this module calls is not running.` };
2215
+ groups.push({ key: m.slug, modules: [m.slug], url: m.url, connection: c });
2258
2216
  }
2259
- const auth = text2.match(/password authentication failed for user "([^"]+)"/);
2260
- if (auth) {
2261
- const reset = h.runCommand("db:reset");
2262
- return {
2263
- reason: "db-auth",
2264
- hint: `The local database was created with other credentials than the portal now provides (user ${auth[1]}).` + (reset ? ` Fix: ${reset} \u2014 this wipes local data.` : "")
2265
- };
2217
+ return groups;
2218
+ }
2219
+ var REF = /(?:^|\s)->\s*(?:([A-Za-z0-9_-]+):)?([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)/;
2220
+ function parseRef(comment) {
2221
+ const m = comment ? REF.exec(comment) : null;
2222
+ return m ? { module: m[1] ?? null, schema: m[2], table: m[3], column: m[4] } : null;
2223
+ }
2224
+ function findEnd(dbs, ref, from) {
2225
+ const has = (d) => d.model?.tables.some((t) => t.schema === ref.schema && t.name === ref.table && t.columns.some((c) => c.name === ref.column)) ?? false;
2226
+ const candidates = ref.module ? dbs.filter((d) => d.modules.includes(ref.module)) : [...dbs.filter((d) => d.key !== from), ...dbs.filter((d) => d.key === from)];
2227
+ const hit2 = candidates.find(has);
2228
+ return hit2 ? { database: hit2.key, schema: ref.schema, table: ref.table, column: ref.column } : null;
2229
+ }
2230
+ function resolveRefs(dbs) {
2231
+ const refs = [];
2232
+ for (const d of dbs) for (const t of d.model?.tables ?? []) for (const c of t.columns) {
2233
+ const ref = parseRef(c.comment);
2234
+ const to = ref && findEnd(dbs, ref, d.key);
2235
+ if (to) refs.push({ from: { database: d.key, schema: t.schema, table: t.name, column: c.name }, to });
2266
2236
  }
2267
- const relation = text2.match(/relation "([^"]+)" does not exist/);
2268
- if (relation) {
2269
- const reset = h.runCommand("db:reset");
2270
- return {
2271
- reason: "db-schema",
2272
- hint: `The database schema is behind the code (relation ${relation[1]} does not exist). Migrations run when the app starts; if they failed above, that error is the cause.` + (reset ? ` To start clean: ${reset} (wipes local data).` : "")
2273
- };
2237
+ return refs;
2238
+ }
2239
+ function combineModels(dbs) {
2240
+ return {
2241
+ tables: dbs.flatMap((d) => (d.model?.tables ?? []).map((t) => ({ ...t, database: d.key }))),
2242
+ enums: dbs.flatMap((d) => (d.model?.enums ?? []).map((e) => ({ ...e, database: d.key }))),
2243
+ databases: dbs.map((d) => ({
2244
+ key: d.key,
2245
+ modules: d.modules,
2246
+ connection: d.connection,
2247
+ ...d.error ? { error: d.error } : {},
2248
+ ...d.migrations ? { migrations: d.migrations } : {}
2249
+ })),
2250
+ refs: resolveRefs(dbs)
2251
+ };
2252
+ }
2253
+
2254
+ // src/core/erd/introspect.ts
2255
+ var USER_SCHEMAS = `n.nspname not in ('pg_catalog', 'information_schema') and n.nspname not like 'pg_toast%' and n.nspname not like 'pg_temp%'`;
2256
+ var TABLE_KIND = `c.relkind in ('r', 'p') and not c.relispartition`;
2257
+ var TABLES = `
2258
+ select n.nspname as schema, c.relname as name, obj_description(c.oid, 'pg_class') as comment
2259
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
2260
+ where ${TABLE_KIND} and ${USER_SCHEMAS}
2261
+ order by 1, 2`;
2262
+ var COLUMNS = `
2263
+ select n.nspname as schema, c.relname as table, a.attname as name,
2264
+ format_type(a.atttypid, a.atttypmod) as type, a.attnotnull as not_null,
2265
+ pg_get_expr(d.adbin, d.adrelid) as "default", col_description(c.oid, a.attnum) as comment
2266
+ from pg_attribute a
2267
+ join pg_class c on c.oid = a.attrelid
2268
+ join pg_namespace n on n.oid = c.relnamespace
2269
+ left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
2270
+ where ${TABLE_KIND} and a.attnum > 0 and not a.attisdropped and ${USER_SCHEMAS}
2271
+ order by n.nspname, c.relname, a.attnum`;
2272
+ var CONSTRAINTS = `
2273
+ select n.nspname as schema, c.relname as table, con.conname as name, con.contype::text as type,
2274
+ (select array_agg(a.attname::text order by k.ord)
2275
+ from unnest(con.conkey) with ordinality as k(attnum, ord)
2276
+ join pg_attribute a on a.attrelid = con.conrelid and a.attnum = k.attnum) as columns,
2277
+ fn.nspname as target_schema, fc.relname as target_table,
2278
+ (select array_agg(a.attname::text order by k.ord)
2279
+ from unnest(con.confkey) with ordinality as k(attnum, ord)
2280
+ join pg_attribute a on a.attrelid = con.confrelid and a.attnum = k.attnum) as target_columns,
2281
+ con.confupdtype::text as on_update, con.confdeltype::text as on_delete,
2282
+ pg_get_constraintdef(con.oid) as definition
2283
+ from pg_constraint con
2284
+ join pg_class c on c.oid = con.conrelid
2285
+ join pg_namespace n on n.oid = c.relnamespace
2286
+ left join pg_class fc on fc.oid = con.confrelid
2287
+ left join pg_namespace fn on fn.oid = fc.relnamespace
2288
+ where con.contype in ('p', 'u', 'f', 'c') and not c.relispartition and ${USER_SCHEMAS}
2289
+ order by n.nspname, c.relname, con.conname`;
2290
+ var INDEXES = `
2291
+ select n.nspname as schema, c.relname as table, ic.relname as name, i.indisunique as "unique", am.amname as method,
2292
+ (select array_agg(pg_get_indexdef(i.indexrelid, k.n, true) order by k.n)
2293
+ from generate_series(1, i.indnkeyatts) as k(n)) as columns
2294
+ from pg_index i
2295
+ join pg_class c on c.oid = i.indrelid
2296
+ join pg_class ic on ic.oid = i.indexrelid
2297
+ join pg_namespace n on n.oid = c.relnamespace
2298
+ join pg_am am on am.oid = ic.relam
2299
+ where ${TABLE_KIND} and not i.indisprimary
2300
+ and not exists (select 1 from pg_constraint x where x.conindid = i.indexrelid and x.contype in ('p', 'u'))
2301
+ and ${USER_SCHEMAS}
2302
+ order by n.nspname, c.relname, ic.relname`;
2303
+ var ENUMS = `
2304
+ select n.nspname as schema, t.typname as name, array_agg(e.enumlabel::text order by e.enumsortorder) as "values"
2305
+ from pg_type t
2306
+ join pg_namespace n on n.oid = t.typnamespace
2307
+ join pg_enum e on e.enumtypid = t.oid
2308
+ where t.typtype = 'e' and ${USER_SCHEMAS}
2309
+ group by 1, 2 order by 1, 2`;
2310
+ var FK_ACTION = { a: "NO_ACTION", r: "RESTRICT", c: "CASCADE", n: "SET_NULL", d: "SET_DEFAULT" };
2311
+ var str = (v) => String(v);
2312
+ var strOrNull = (v) => v === null || v === void 0 ? null : String(v);
2313
+ function strArray(v) {
2314
+ if (Array.isArray(v)) return v.map(str);
2315
+ if (typeof v === "string" && v.startsWith("{") && v.endsWith("}")) {
2316
+ return v.length === 2 ? [] : v.slice(1, -1).split(",").map((s) => s.replace(/^"|"$/g, ""));
2274
2317
  }
2275
- if (/ENOSPC: System limit for number of file watchers reached/.test(text2)) {
2276
- return {
2277
- reason: "watcher-limit",
2278
- hint: "Linux ran out of file watchers. Raise the limit: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p"
2318
+ return [];
2319
+ }
2320
+ var key = (r) => `${str(r.schema)}.${str(r.table)}`;
2321
+ var checkExpression = (definition) => definition.replace(/^CHECK \((.*)\)$/s, "$1");
2322
+ async function introspect(query) {
2323
+ const rows = [];
2324
+ for (const sql of [TABLES, COLUMNS, CONSTRAINTS, INDEXES, ENUMS]) rows.push((await query(sql)).rows);
2325
+ const [tables, columns, constraints, indexes, enums] = rows;
2326
+ const byKey = /* @__PURE__ */ new Map();
2327
+ for (const r of tables) {
2328
+ const t = {
2329
+ schema: str(r.schema),
2330
+ name: str(r.name),
2331
+ comment: strOrNull(r.comment),
2332
+ columns: [],
2333
+ primaryKey: null,
2334
+ uniques: [],
2335
+ foreignKeys: [],
2336
+ checks: [],
2337
+ indexes: []
2279
2338
  };
2339
+ byKey.set(`${t.schema}.${t.name}`, t);
2340
+ }
2341
+ for (const r of columns) {
2342
+ byKey.get(key(r))?.columns.push({
2343
+ name: str(r.name),
2344
+ type: str(r.type),
2345
+ notNull: Boolean(r.not_null),
2346
+ default: strOrNull(r.default),
2347
+ comment: strOrNull(r.comment)
2348
+ });
2280
2349
  }
2281
- return null;
2350
+ for (const r of constraints) {
2351
+ const t = byKey.get(key(r));
2352
+ if (!t) continue;
2353
+ const name = str(r.name);
2354
+ const cols = strArray(r.columns);
2355
+ switch (str(r.type)) {
2356
+ case "p":
2357
+ t.primaryKey = { name, columns: cols };
2358
+ break;
2359
+ case "u":
2360
+ t.uniques.push({ name, columns: cols });
2361
+ break;
2362
+ case "c":
2363
+ t.checks.push({ name, expression: checkExpression(str(r.definition)) });
2364
+ break;
2365
+ case "f":
2366
+ t.foreignKeys.push({
2367
+ name,
2368
+ columns: cols,
2369
+ targetSchema: str(r.target_schema),
2370
+ targetTable: str(r.target_table),
2371
+ targetColumns: strArray(r.target_columns),
2372
+ onUpdate: FK_ACTION[str(r.on_update)] ?? "NO_ACTION",
2373
+ onDelete: FK_ACTION[str(r.on_delete)] ?? "NO_ACTION"
2374
+ });
2375
+ break;
2376
+ }
2377
+ }
2378
+ for (const r of indexes) {
2379
+ byKey.get(key(r))?.indexes.push({ name: str(r.name), unique: Boolean(r.unique), columns: strArray(r.columns), method: str(r.method) });
2380
+ }
2381
+ return {
2382
+ tables: [...byKey.values()],
2383
+ enums: enums.map((r) => ({ schema: str(r.schema), name: str(r.name), values: strArray(r.values) }))
2384
+ };
2282
2385
  }
2283
2386
 
2284
- // src/core/dev/env.ts
2285
- import { dirname as dirname6, join as join14 } from "path";
2387
+ // src/core/erd/migrations.ts
2388
+ import { readFile as readFile3 } from "fs/promises";
2389
+ import { join as join11 } from "path";
2390
+ var JOURNAL = join11("src", "db", "migrations", "meta", "_journal.json");
2391
+ async function readJournal(repoRoot) {
2392
+ let text2;
2393
+ try {
2394
+ text2 = await readFile3(join11(repoRoot, JOURNAL), "utf8");
2395
+ } catch {
2396
+ return null;
2397
+ }
2398
+ try {
2399
+ return JSON.parse(text2).entries ?? [];
2400
+ } catch {
2401
+ return null;
2402
+ }
2403
+ }
2404
+ async function appliedMigrations(query) {
2405
+ const { rows: [ledger] } = await query(`select to_regclass('drizzle.__drizzle_migrations') is not null as present`);
2406
+ if (!ledger?.present) return [];
2407
+ const { rows } = await query(`select created_at::text as at from drizzle.__drizzle_migrations`);
2408
+ return rows.map((r) => Number(r.at));
2409
+ }
2410
+ function compareMigrations(journal, applied) {
2411
+ const latest = applied.reduce((a, b) => Math.max(a, b), 0);
2412
+ const done = new Set(applied);
2413
+ const known = new Set(journal.map((e) => e.when));
2414
+ const missing = journal.filter((e) => !done.has(e.when));
2415
+ return {
2416
+ pending: missing.filter((e) => e.when > latest).map((e) => e.tag),
2417
+ skipped: missing.filter((e) => e.when <= latest).map((e) => e.tag),
2418
+ unknown: applied.filter((a) => !known.has(a)).length
2419
+ };
2420
+ }
2421
+ async function checkMigrations(repoRoot, query) {
2422
+ const journal = repoRoot ? await readJournal(repoRoot) : null;
2423
+ return journal ? compareMigrations(journal, await appliedMigrations(query)) : void 0;
2424
+ }
2425
+
2426
+ // src/core/context/repoConfig.ts
2427
+ import { readFile as readFile4 } from "fs/promises";
2428
+ import { join as join12 } from "path";
2429
+ var FORBIDDEN = ["portal", "api", "apiOrigin", "issuer", "tenant", "tenantId", "app", "appId", "module", "moduleId"];
2430
+ var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
2431
+ var SCRIPT = /^[A-Za-z0-9:_.-]+$/;
2432
+ var invalid3 = (msg, details) => new QoreError("QORE_CONTEXT_INVALID", `.qore.json: ${msg}`, { hint: "Fix .qore.json in the repository root; see the $schema it names.", details });
2433
+ function moduleDefaults(type) {
2434
+ return type === "frontend" ? { envFile: ".env.local", localSecrets: ["SESSION_COOKIE_SECRET"] } : { envFile: ".env", localSecrets: [] };
2435
+ }
2436
+ async function readRepoConfig(root) {
2437
+ let raw;
2438
+ try {
2439
+ raw = await readFile4(join12(root, ".qore.json"), "utf8");
2440
+ } catch (e) {
2441
+ if (e.code === "ENOENT") return {};
2442
+ throw e;
2443
+ }
2444
+ let doc;
2445
+ try {
2446
+ doc = JSON.parse(raw);
2447
+ } catch {
2448
+ throw invalid3("not valid JSON");
2449
+ }
2450
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw invalid3("must be an object");
2451
+ const v = doc.schemaVersion === void 0 ? 1 : doc.schemaVersion;
2452
+ if (!Number.isInteger(v) || v < 1) throw invalid3("schemaVersion must be a positive integer");
2453
+ if (v > 1) {
2454
+ throw new QoreError("QORE_CONTEXT_UNSUPPORTED", `.qore.json schemaVersion ${v} is newer than this qore`, { hint: "Run: npm i -g @quatronic/qore" });
2455
+ }
2456
+ for (const f of FORBIDDEN) if (f in doc) throw invalid3(`"${f}" is not allowed \u2014 .qore.json never selects a portal or a resource`, { field: f });
2457
+ const out = {};
2458
+ if (doc.envFile !== void 0) {
2459
+ if (typeof doc.envFile !== "string" || !doc.envFile) throw invalid3("envFile must be a non-empty string");
2460
+ out.envFile = doc.envFile;
2461
+ }
2462
+ if (doc.localSecrets !== void 0) {
2463
+ if (!Array.isArray(doc.localSecrets) || !doc.localSecrets.every((k) => typeof k === "string" && KEY.test(k))) {
2464
+ throw invalid3("localSecrets must be a list of env key names");
2465
+ }
2466
+ if (new Set(doc.localSecrets).size !== doc.localSecrets.length) throw invalid3("localSecrets must not contain duplicates");
2467
+ out.localSecrets = doc.localSecrets;
2468
+ }
2469
+ if (doc.dev !== void 0) {
2470
+ const d = doc.dev;
2471
+ if (!d || typeof d !== "object" || Array.isArray(d)) throw invalid3("dev must be an object");
2472
+ const dev = {};
2473
+ if (d.install !== void 0) {
2474
+ if (typeof d.install !== "boolean") throw invalid3("dev.install must be true or false");
2475
+ dev.install = d.install;
2476
+ }
2477
+ if (d.services !== void 0) {
2478
+ if (!Array.isArray(d.services) || !d.services.every((s) => typeof s === "string" && SCRIPT.test(s))) {
2479
+ throw invalid3("dev.services must be a list of script names");
2480
+ }
2481
+ if (new Set(d.services).size !== d.services.length) throw invalid3("dev.services must not contain duplicates");
2482
+ dev.services = d.services;
2483
+ }
2484
+ if (d.script !== void 0) {
2485
+ if (typeof d.script !== "string" || !SCRIPT.test(d.script)) throw invalid3("dev.script must be a script name");
2486
+ dev.script = d.script;
2487
+ }
2488
+ out.dev = dev;
2489
+ }
2490
+ return out;
2491
+ }
2286
2492
 
2287
2493
  // src/core/env/fetch.ts
2288
2494
  async function fetchLocalEnv(deps2, mc, app) {
@@ -2316,11 +2522,11 @@ function parseEnv(contents) {
2316
2522
  }
2317
2523
  return map;
2318
2524
  }
2319
- function isManagedKey(key, managed) {
2525
+ function isManagedKey(key2, managed) {
2320
2526
  for (const pattern of managed) {
2321
2527
  if (pattern.endsWith("*")) {
2322
- if (key.startsWith(pattern.slice(0, -1))) return true;
2323
- } else if (key === pattern) return true;
2528
+ if (key2.startsWith(pattern.slice(0, -1))) return true;
2529
+ } else if (key2 === pattern) return true;
2324
2530
  }
2325
2531
  return false;
2326
2532
  }
@@ -2336,27 +2542,27 @@ function planEnvUpdate({
2336
2542
  const added = [];
2337
2543
  const unchanged = [];
2338
2544
  const overwritten = [];
2339
- for (const [key, portalValue] of Object.entries(fetched)) {
2340
- const hasLocal = current.has(key);
2341
- const localValue = current.get(key);
2545
+ for (const [key2, portalValue] of Object.entries(fetched)) {
2546
+ const hasLocal = current.has(key2);
2547
+ const localValue = current.get(key2);
2342
2548
  const localEmpty = !hasLocal || localValue === "" || localValue === '""' || localValue === "''";
2343
2549
  if (localEmpty) {
2344
- merged.set(key, portalValue);
2345
- added.push(key);
2550
+ merged.set(key2, portalValue);
2551
+ added.push(key2);
2346
2552
  continue;
2347
2553
  }
2348
2554
  if (localValue === portalValue) {
2349
- unchanged.push(key);
2555
+ unchanged.push(key2);
2350
2556
  continue;
2351
2557
  }
2352
- if (isManagedKey(key, managed)) {
2353
- merged.set(key, portalValue);
2354
- overwritten.push(key);
2558
+ if (isManagedKey(key2, managed)) {
2559
+ merged.set(key2, portalValue);
2560
+ overwritten.push(key2);
2355
2561
  continue;
2356
2562
  }
2357
- conflicts.push(key);
2358
- const choice = choices[key] ?? defaultChoice;
2359
- merged.set(key, choice === "use-portal" ? portalValue : localValue);
2563
+ conflicts.push(key2);
2564
+ const choice = choices[key2] ?? defaultChoice;
2565
+ merged.set(key2, choice === "use-portal" ? portalValue : localValue);
2360
2566
  }
2361
2567
  return { merged, conflicts, added, unchanged, overwritten };
2362
2568
  }
@@ -2366,14 +2572,14 @@ function renderEnv(original, merged) {
2366
2572
  const out = lines.map((line2) => {
2367
2573
  const m = line2.replace(/\r$/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
2368
2574
  if (!m?.[1]) return line2;
2369
- const key = m[1];
2370
- if (!merged.has(key)) return line2;
2371
- seen.add(key);
2372
- return `${key}=${merged.get(key) ?? ""}`;
2575
+ const key2 = m[1];
2576
+ if (!merged.has(key2)) return line2;
2577
+ seen.add(key2);
2578
+ return `${key2}=${merged.get(key2) ?? ""}`;
2373
2579
  });
2374
2580
  const appended = [];
2375
- for (const [key, value] of merged) {
2376
- if (!seen.has(key)) appended.push(`${key}=${value}`);
2581
+ for (const [key2, value] of merged) {
2582
+ if (!seen.has(key2)) appended.push(`${key2}=${value}`);
2377
2583
  }
2378
2584
  let text2 = out.join("\n");
2379
2585
  if (appended.length) {
@@ -2429,11 +2635,11 @@ function planPull(o) {
2429
2635
  const r = planEnvUpdate({ current, fetched: o.fetched, choices: o.choices ?? {}, defaultChoice: o.defaultChoice, managed: o.managed });
2430
2636
  const choiceOf = (k) => o.choices?.[k] ?? o.defaultChoice;
2431
2637
  const generated = [];
2432
- for (const key of o.localSecrets) {
2433
- const v = r.merged.get(key);
2638
+ for (const key2 of o.localSecrets) {
2639
+ const v = r.merged.get(key2);
2434
2640
  if (v === void 0 || v === "" || v === '""' || v === "''") {
2435
- r.merged.set(key, o.random());
2436
- generated.push(key);
2641
+ r.merged.set(key2, o.random());
2642
+ generated.push(key2);
2437
2643
  }
2438
2644
  }
2439
2645
  return {
@@ -2451,12 +2657,12 @@ function planPull(o) {
2451
2657
  }
2452
2658
 
2453
2659
  // src/core/env/write.ts
2454
- import { chmod as chmod2, readFile as readFile4, rename as rename3, unlink, writeFile as writeFile4 } from "fs/promises";
2660
+ import { chmod as chmod2, readFile as readFile5, rename as rename3, unlink, writeFile as writeFile4 } from "fs/promises";
2455
2661
  import { randomBytes as randomBytes4 } from "crypto";
2456
- import { basename as basename2, dirname as dirname5, join as join12 } from "path";
2662
+ import { basename as basename2, dirname as dirname5, join as join13 } from "path";
2457
2663
  async function readOrNull(p) {
2458
2664
  try {
2459
- return await readFile4(p, "utf8");
2665
+ return await readFile5(p, "utf8");
2460
2666
  } catch (e) {
2461
2667
  if (e.code === "ENOENT") return null;
2462
2668
  throw e;
@@ -2465,7 +2671,7 @@ async function readOrNull(p) {
2465
2671
  async function writeSecurely(target, text2) {
2466
2672
  const base = basename2(target);
2467
2673
  const tmpName = base.endsWith(".local") ? `${base.slice(0, -".local".length)}.qore-${randomBytes4(6).toString("hex")}.local` : `${base}.qore-${randomBytes4(6).toString("hex")}.tmp`;
2468
- const tmp = join12(dirname5(target), tmpName);
2674
+ const tmp = join13(dirname5(target), tmpName);
2469
2675
  try {
2470
2676
  await writeFile4(tmp, text2, { mode: 384, flag: "wx" });
2471
2677
  await chmod2(tmp, 384);
@@ -2491,934 +2697,1359 @@ async function syncEnvFile(o) {
2491
2697
  return { created: plan.created, kept: plan.kept };
2492
2698
  }
2493
2699
 
2494
- // src/core/dev/lastStart.ts
2495
- import { createHash as createHash2 } from "crypto";
2496
- import { realpath as realpath6 } from "fs/promises";
2497
- import { join as join13 } from "path";
2498
- var NETWORK_REASONS = /* @__PURE__ */ new Set(["connect", "dns", "tls", "timeout"]);
2499
- function isOffline(e) {
2500
- if (!(e instanceof QoreError)) return false;
2501
- if (e.code === "QORE_API_UNREACHABLE" || e.code === "QORE_API_UNAVAILABLE") return true;
2502
- if (e.code !== "QORE_AUTH_UNAVAILABLE") return false;
2503
- return (e.status ?? 0) >= 500 || NETWORK_REASONS.has(String(e.details?.reason));
2504
- }
2505
- async function stateKey(deps2, root) {
2506
- const real = await realpath6(root).catch(() => root);
2507
- return createHash2("sha256").update(trustKey(real, deps2.platform)).digest("hex").slice(0, 16);
2508
- }
2509
- async function saveLastStart(deps2, root, rec) {
2510
- await writeJsonAtomic(join13(deps2.config.dir, "dev"), `${await stateKey(deps2, root)}.json`, rec);
2700
+ // src/core/erd/source.ts
2701
+ function noDatabase(slug) {
2702
+ return new QoreError("QORE_CONTEXT_UNSUPPORTED", `${slug} has no database`, {
2703
+ hint: "qore db erd shows a module's own Postgres (DATABASE_URL); this module declares none."
2704
+ });
2511
2705
  }
2512
- async function loadLastStart(deps2, root) {
2513
- const rec = await readJson(join13(deps2.config.dir, "dev", `${await stateKey(deps2, root)}.json`)).catch(() => null);
2514
- return rec && rec.schemaVersion === 1 ? rec : null;
2706
+ async function resolveDatabaseUrl(deps2, mc, app) {
2707
+ if (mc.repoRoot) {
2708
+ const cfg = await readRepoConfig(mc.repoRoot);
2709
+ const envFile = await resolveTarget(mc.repoRoot, cfg.envFile ?? moduleDefaults(mc.module.type).envFile);
2710
+ const text2 = await readOrNull(envFile);
2711
+ const url2 = text2 === null ? void 0 : parseEnv(text2).get("DATABASE_URL")?.trim().replace(/^["']|["']$/g, "");
2712
+ if (url2) {
2713
+ deps2.registerSecrets([url2]);
2714
+ return { url: url2, source: "env-file", envFile };
2715
+ }
2716
+ }
2717
+ const local = await fetchLocalEnv(deps2, mc, app);
2718
+ const url = local.env.DATABASE_URL?.trim();
2719
+ if (!url) throw noDatabase(mc.module.slug);
2720
+ return { url, source: "portal", envFile: null };
2515
2721
  }
2516
2722
 
2517
- // src/core/dev/env.ts
2518
- function envPort(env, key) {
2519
- const raw = env[key];
2520
- if (raw === void 0) return null;
2521
- const v = raw.trim().replace(/^["']|["']$/g, "");
2522
- if (!/^\d+$/.test(v)) return null;
2523
- const n = Number(v);
2524
- return n >= 1024 && n <= 65535 ? n : null;
2525
- }
2526
- async function devEnv(deps2, o) {
2527
- const defaults = moduleDefaults(o.type);
2528
- const target = await resolveTarget(o.root, o.cfg.envFile ?? defaults.envFile);
2529
- let cause = null;
2530
- if (o.mc && o.app) {
2723
+ // src/platform/httpServe.ts
2724
+ import { readFile as readFile6 } from "fs/promises";
2725
+ import { createServer } from "http";
2726
+ import { extname, resolve as resolve6, sep as sep3 } from "path";
2727
+ var TYPES = {
2728
+ ".html": "text/html; charset=utf-8",
2729
+ ".js": "text/javascript; charset=utf-8",
2730
+ ".css": "text/css; charset=utf-8",
2731
+ ".json": "application/json",
2732
+ ".ico": "image/x-icon",
2733
+ ".png": "image/png",
2734
+ ".svg": "image/svg+xml",
2735
+ ".woff": "font/woff",
2736
+ ".woff2": "font/woff2"
2737
+ };
2738
+ async function serveErd(o) {
2739
+ const root = resolve6(o.dir);
2740
+ let port = 0;
2741
+ const server = createServer((req, res) => {
2742
+ void handle(req, res);
2743
+ });
2744
+ await new Promise((ok2, fail) => {
2745
+ server.once("error", fail);
2746
+ server.listen(o.port, "127.0.0.1", () => {
2747
+ server.off("error", fail);
2748
+ ok2();
2749
+ });
2750
+ });
2751
+ port = server.address().port;
2752
+ async function handle(req, res) {
2531
2753
  try {
2532
- const local = await fetchLocalEnv(deps2, o.mc, o.app);
2533
- const example = await readOrNull(join14(dirname6(target), ".env.example"));
2534
- const w = await syncEnvFile({
2535
- target,
2536
- example,
2537
- fetched: local.env,
2538
- managed: local.managedKeys,
2539
- localSecrets: o.cfg.localSecrets ?? defaults.localSecrets,
2540
- random: o.random
2541
- });
2542
- return {
2543
- env: local.env,
2544
- offline: false,
2545
- envFile: target,
2546
- port: envPort(local.env, "PORT"),
2547
- dbPort: envPort(local.env, "DB_PORT"),
2548
- ...w
2549
- };
2754
+ await route(req, res);
2550
2755
  } catch (e) {
2551
- if (!isOffline(e)) throw e;
2552
- cause = e;
2756
+ if (res.headersSent) res.destroy();
2757
+ else end(res, e instanceof TypeError ? 400 : 500);
2553
2758
  }
2554
2759
  }
2555
- const text2 = await readOrNull(target);
2556
- if (text2 === null || !await isTrusted(deps2, o.root, o.moduleId)) {
2557
- throw cause ?? new QoreError("QORE_API_UNREACHABLE", "The portal is unreachable", {
2558
- hint: "The portal is unreachable and there is no env file to start from yet."
2559
- });
2760
+ async function route(req, res) {
2761
+ const host = req.headers.host;
2762
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return end(res, 403);
2763
+ if (req.method !== "GET" && req.method !== "HEAD") return end(res, 405);
2764
+ const path2 = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
2765
+ const produce = o.routes[path2];
2766
+ if (produce) {
2767
+ try {
2768
+ const body = JSON.stringify(await produce());
2769
+ res.writeHead(200, { "content-type": TYPES[".json"], "cache-control": "no-store" });
2770
+ res.end(body);
2771
+ return;
2772
+ } catch (e) {
2773
+ try {
2774
+ o.onError(e);
2775
+ } catch {
2776
+ }
2777
+ res.writeHead(500, { "content-type": TYPES[".json"] });
2778
+ res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
2779
+ return;
2780
+ }
2781
+ }
2782
+ const file = resolve6(root, `.${path2 === "/" ? "/index.html" : path2}`);
2783
+ if (file !== root && !file.startsWith(root + sep3)) return end(res, 404);
2784
+ try {
2785
+ const body = await readFile6(file);
2786
+ res.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream" });
2787
+ res.end(body);
2788
+ } catch {
2789
+ end(res, 404);
2790
+ }
2560
2791
  }
2561
- const env = Object.fromEntries(parseEnv(text2));
2562
- return { env, offline: true, envFile: target, port: envPort(env, "PORT"), dbPort: envPort(env, "DB_PORT"), created: false, kept: [] };
2792
+ return {
2793
+ port,
2794
+ close: () => new Promise((ok2) => {
2795
+ server.closeAllConnections();
2796
+ server.close(() => ok2());
2797
+ })
2798
+ };
2563
2799
  }
2564
-
2565
- // src/core/workspace/access.ts
2566
- function devTeamsOf(am, m) {
2567
- const own = m.devTeamIds?.length ? am.devTeams.filter((t) => m.devTeamIds.includes(t.id)) : [];
2568
- return own.length ? own : am.devTeams;
2800
+ function end(res, status2) {
2801
+ res.writeHead(status2);
2802
+ res.end();
2569
2803
  }
2570
- function askMaintainer(am, m) {
2571
- const teams = devTeamsOf(am, m);
2572
- const names = teams.map((t) => t.name);
2573
- const maintainers = [...new Set(teams.flatMap((t) => t.maintainers))];
2574
- const who = names.length ? names.join(", ") : "the app's dev team";
2575
- return { teams: names, maintainers, text: `ask a maintainer of ${who}${maintainers.length ? ` (${maintainers.join(", ")})` : ""}` };
2804
+
2805
+ // src/cli/context.ts
2806
+ var allOfKind = (c, kind) => !!c && c.length > 0 && c.every((x) => x.kind === kind && typeof x.id === "string");
2807
+ async function pick(ctx, err, message, choices) {
2808
+ if (!ctx.io.interactive) throw err;
2809
+ return ctx.io.select(message, choices);
2576
2810
  }
2577
- function notClonedReason(m, am) {
2578
- if (am && !m.githubLinked) return `GitHub account not linked (${am.accountUrl})`;
2579
- if (am && m.repoAccess === "none") {
2580
- const names = devTeamsOf(am, m).map((t) => t.name);
2581
- return names.length ? `no repository access (dev team ${names.join(", ")})` : "no repository access (ask your tenant owner)";
2811
+ async function moduleContextFor(ctx, flags, command) {
2812
+ const g = ctx.opts();
2813
+ const onRequest = g.verbose ? (l) => ctx.io.err(l) : void 0;
2814
+ const base = { portal: g.portal, module: flags.module, command, onRequest };
2815
+ try {
2816
+ return await resolveModuleContext(ctx.deps, base);
2817
+ } catch (e) {
2818
+ if (!(e instanceof QoreError) || e.code !== "QORE_CONTEXT_REQUIRED" || !ctx.io.interactive) throw e;
2819
+ if (allOfKind(e.candidates, "module")) {
2820
+ const choose2 = await pick(
2821
+ ctx,
2822
+ e,
2823
+ "Select a module",
2824
+ e.candidates.map((c) => ({ name: `${c.tenant ? `${c.tenant} / ` : ""}${c.slug} (${c.remote})`, value: c.id }))
2825
+ );
2826
+ return resolveModuleContext(ctx.deps, { ...base, choose: choose2 });
2827
+ }
2828
+ if (allOfKind(e.candidates, "tenant")) {
2829
+ const tenant = await pick(ctx, e, "Select a tenant", e.candidates.map((c) => ({ name: c.name ?? c.slug, value: c.id })));
2830
+ return resolveModuleContext(ctx.deps, { ...base, tenant });
2831
+ }
2832
+ throw e;
2582
2833
  }
2583
- return "run qore sync";
2584
2834
  }
2585
-
2586
- // src/core/dev/findings.ts
2587
- var plural = (n, one2, many) => n === 1 ? one2 : many;
2588
- function modulesMissing(o) {
2589
- const actionFor = new Map(o.actions.map((a) => [a.module.slug, a]));
2590
- const findings = [];
2591
- const fixable = [];
2592
- const blocked = [];
2593
- const reasons = {};
2594
- const own = (m, reason, rest) => {
2595
- reasons[m.slug] = reason;
2596
- return { code: "dev.modules-missing", severity: "warn", module: m.slug, modules: [m.slug], message: `${m.slug} is not cloned \u2014 ${reason}`, ...rest };
2597
- };
2598
- for (const m of o.missing) {
2599
- const a = actionFor.get(m.slug);
2600
- if (a && a.kind !== "clone") {
2601
- if (a.kind === "conflict") {
2602
- const c = conflictText(o.cwd, a);
2603
- blocked.push(own(m, c.message, { action: { command: c.hint } }));
2604
- } else if (a.kind === "unsupported") {
2605
- blocked.push(own(m, "its repository URL is not supported", { action: { command: unsupportedRepoUrlError(m.slug, "").hint } }));
2606
- } else {
2607
- blocked.push(own(m, a.reason ?? "no repository yet", { why: "It will not run in this session." }));
2608
- }
2609
- } else if (o.am !== null && (!m.githubLinked || m.repoAccess === "none")) {
2610
- const am = o.am;
2611
- blocked.push(own(m, notClonedReason(m, am), { action: { command: !m.githubLinked ? `Link your GitHub account: ${am.accountUrl}` : askMaintainer(am, m).text } }));
2612
- } else fixable.push(m);
2613
- }
2614
- if (fixable.length > 0) {
2615
- const slugs = fixable.map((m) => m.slug);
2616
- const deps2 = slugs.flatMap((s) => (o.consumers[s] ?? []).map((c) => `${c} depends on ${s}`));
2617
- findings.push({
2618
- code: "dev.modules-missing",
2619
- severity: "warn",
2620
- message: `${fixable.length} ${plural(fixable.length, "module of this app is", "modules of this app are")} not cloned: ${slugs.join(", ")}`,
2621
- why: deps2.length > 0 ? `${deps2.join("; ")}.` : `${plural(fixable.length, "It", "They")} will not run in this session.`,
2622
- modules: slugs,
2623
- action: o.am ? { command: "qore sync", fix: "sync" } : { command: "qore sync" }
2624
- });
2835
+ async function appFor(ctx, mc, app) {
2836
+ try {
2837
+ return pickApp2(mc, app);
2838
+ } catch (e) {
2839
+ if (!(e instanceof QoreError) || e.code !== "QORE_APP_AMBIGUOUS" || !ctx.io.interactive) throw e;
2840
+ const id = await pick(ctx, e, "Select an app", mc.module.apps.map((a) => ({ name: a.name, value: a.id })));
2841
+ return pickApp2(mc, id);
2625
2842
  }
2626
- return { findings: [...findings, ...blocked], fixable, reasons };
2627
- }
2628
- function strayClone(name, appName) {
2629
- return { code: "dev.stray-clone", severity: "info", module: name, message: `${name} is no longer part of ${appName} \u2014 you can remove the directory.` };
2630
2843
  }
2631
- function repoBehind(slug, s, o) {
2632
- if (!s.ok || !s.upstream || !s.behind) return null;
2633
- return {
2634
- code: "dev.repo-behind",
2635
- severity: "warn",
2636
- module: slug,
2637
- message: `${slug} is ${s.behind} ${plural(s.behind, "commit", "commits")} behind ${s.upstreamRef ?? "its upstream"} (as of your last fetch).`,
2638
- action: { command: o.workspace ? "qore sync --pull" : "git pull" }
2639
- };
2844
+ function sourceText(mc) {
2845
+ if (mc.source.kind === "remote") return mc.source.remote;
2846
+ if (mc.source.kind === "workspace") return WORKSPACE_FILE;
2847
+ return mc.source.overrides ? `--module, overrides ${mc.source.overrides}` : "--module";
2640
2848
  }
2641
- function servicesMissing(slug, env, recipe, o) {
2642
- const db = envPort(env, "DB_PORT");
2643
- if (db === null || recipe.services.length > 0 || !o.servicesFlag || o.configured) return null;
2644
- return {
2645
- code: "dev.services-missing",
2646
- severity: "warn",
2647
- module: slug,
2648
- message: `The portal gives ${slug} a database (DB_PORT=${db}), but ${slug} has no db:up script \u2014 no database will be started.`,
2649
- why: "This repository is probably older than its template.",
2650
- action: { command: 'add a "db:up" script (see the current app template), or set dev.services in .qore.json' }
2651
- };
2849
+ function contextLine(mc, app) {
2850
+ const parts = [mc.module.tenant.name, ...app ? [app.name] : [], mc.module.slug];
2851
+ return `\u2192 ${parts.join(" / ")} (${sourceText(mc)})`;
2652
2852
  }
2653
- function providerDown(o) {
2654
- const single = o.providers.length === 1 && o.keys.length === 1;
2853
+ function contextJson(mc, app) {
2854
+ const { id, slug, name, type, access: access2 } = mc.module;
2655
2855
  return {
2656
- code: "dev.provider-down",
2657
- severity: "warn",
2658
- module: o.slug,
2659
- modules: o.providers,
2660
- message: single ? `${o.providers[0]} is not running \u2014 ${o.keys[0]} points at :${o.ports.get(o.keys[0])}, where nothing listens.` : `${o.keys.join(", ")} ${plural(o.keys.length, "points", "point")} at ports nothing listens on (depends on: ${o.providers.join(", ")}).`,
2661
- why: single ? `${o.slug} will start, but calls to ${o.providers[0]} will fail. Qore does not fall back to staging.` : `${o.slug} will start, but calls through ${o.keys.join(", ")} will fail. Qore does not fall back to staging.`,
2662
- action: { command: o.fix }
2856
+ portal: mc.portal.builtin ?? mc.portal.apiOrigin,
2857
+ tenant: mc.module.tenant,
2858
+ app: app ?? null,
2859
+ module: { id, slug, name, type, access: access2 },
2860
+ source: mc.source.kind === "remote" ? mc.source.remote : mc.source.kind === "workspace" ? WORKSPACE_FILE : "--module",
2861
+ ...mc.source.kind === "flag" && mc.source.overrides ? { overrides: mc.source.overrides } : {}
2663
2862
  };
2664
2863
  }
2665
2864
 
2666
- // src/core/dev/project.ts
2667
- import { access, readFile as readFile5 } from "fs/promises";
2668
- import { join as join15 } from "path";
2669
- var LEGACY_DEV_SCRIPTS = ["node --env-file=.env --import ./dist/instrumentation.js dist/index.js"];
2670
- var LOCKFILES = [["pnpm-lock.yaml", "pnpm"], ["package-lock.json", "npm"], ["yarn.lock", "yarn"]];
2671
- var exists = (p) => access(p).then(() => true, () => false);
2672
- var unsupported = (message, hint, details) => new QoreError("QORE_DEV_UNSUPPORTED", message, { hint, details });
2673
- async function readPackageJson(root) {
2674
- let raw;
2675
- try {
2676
- raw = await readFile5(join15(root, "package.json"), "utf8");
2677
- } catch {
2678
- throw unsupported("This repository has no package.json", "qore dev starts Node repositories through their package.json scripts.");
2679
- }
2680
- try {
2681
- return JSON.parse(raw);
2682
- } catch {
2683
- throw unsupported("package.json is not valid JSON", "Fix package.json, then run qore dev again.");
2684
- }
2865
+ // src/cli/project.ts
2866
+ import { readdir as readdir2, stat as stat3 } from "fs/promises";
2867
+ import { join as join14 } from "path";
2868
+
2869
+ // src/core/projects/match.ts
2870
+ function readings(ref) {
2871
+ const s = ref.split("/").map((x) => x.trim());
2872
+ if (s.some((x) => x === "")) return [];
2873
+ if (s.length === 1) return [{ app: s[0] }];
2874
+ if (s.length === 2) return [{ app: s[0], module: s[1] }, { tenant: s[0], app: s[1] }];
2875
+ if (s.length === 3) return [{ tenant: s[0], app: s[1], module: s[2] }];
2876
+ return [];
2685
2877
  }
2686
- async function detectPackageManager(root, pkg) {
2687
- if (typeof pkg.packageManager === "string") {
2688
- const m = /^(pnpm|npm|yarn)@(.+)$/.exec(pkg.packageManager.trim());
2689
- if (!m) throw unsupported(`packageManager "${pkg.packageManager}" is not pnpm, npm or yarn`, "qore dev supports pnpm, npm and yarn.");
2690
- return { name: m[1], version: m[2].split("+")[0] };
2691
- }
2692
- for (const [file, name] of LOCKFILES) if (await exists(join15(root, file))) return { name, version: null };
2693
- throw unsupported("No package manager declared and no lockfile found", 'Add "packageManager": "pnpm@<version>" to package.json.');
2878
+ function hit(p, w) {
2879
+ if (p.appSlug !== w.app || w.tenant !== void 0 && p.tenantSlug !== w.tenant) return null;
2880
+ if (p.kind === "module") return w.module === p.moduleSlug ? { project: p } : null;
2881
+ return w.module ? { project: p, module: w.module } : { project: p };
2694
2882
  }
2695
- function planRecipe(o) {
2696
- const scripts = o.pkg.scripts ?? {};
2697
- const script = o.cfg.dev?.script ?? "dev";
2698
- const found = scripts[script];
2699
- if (found === void 0) {
2700
- throw unsupported(`${o.slug} has no "${script}" script in package.json`, 'Add a "dev" script, or set dev.script in .qore.json.', { script });
2701
- }
2702
- if (LEGACY_DEV_SCRIPTS.includes(found.trim())) {
2703
- throw unsupported(
2704
- `${o.slug}'s dev script is the old backend one, which needs a build and a .env first`,
2705
- 'Update it to the current template: "dev": "node scripts/dev.mjs" plus scripts/dev.mjs.',
2706
- { script, found }
2707
- );
2708
- }
2709
- const configured = o.cfg.dev?.services;
2710
- for (const s of configured ?? []) {
2711
- if (scripts[s] === void 0) {
2712
- throw new QoreError("QORE_CONTEXT_INVALID", `.qore.json: dev.services names "${s}", which is not a script in package.json`, {
2713
- hint: "Fix dev.services in .qore.json."
2714
- });
2883
+ function matchProjects(projects, ref) {
2884
+ const out = [];
2885
+ for (const w of readings(ref)) {
2886
+ for (const p of projects) {
2887
+ const m = hit(p, w);
2888
+ if (m) out.push(m);
2715
2889
  }
2716
2890
  }
2717
- const services = !o.flags.services ? [] : configured ?? (scripts["db:up"] !== void 0 ? ["db:up"] : []);
2718
- return {
2719
- root: o.root,
2720
- pm: o.pm,
2721
- script,
2722
- services,
2723
- install: (o.cfg.dev?.install ?? true) && o.flags.install,
2724
- dockerServices: services.some((s) => /\bdocker\b/.test(scripts[s] ?? ""))
2725
- };
2891
+ return out;
2726
2892
  }
2727
- function runArgs(pm, script) {
2728
- return { cmd: pm.name, args: ["run", script] };
2893
+
2894
+ // src/cli/project.ts
2895
+ async function loggedInAs(ctx) {
2896
+ const cfg = await ctx.deps.config.read();
2897
+ const portal = resolvePortal(ctx.opts().portal, cfg.defaultProfile);
2898
+ const p = cfg.profiles[portal.apiOrigin];
2899
+ if (!p || !await new TokenStore(ctx.deps.keychain, profileHash(p.apiOrigin, p.issuer)).getRefresh()) return null;
2900
+ return p.email ?? p.sub ?? portal.apiOrigin;
2729
2901
  }
2730
- function installArgs(pm) {
2731
- return { cmd: pm.name, args: ["install"] };
2902
+ var isDir = async (p) => (await stat3(p).catch(() => null))?.isDirectory() ?? false;
2903
+ var label2 = (p) => `${p.tenantSlug} / ${p.appSlug}${p.kind === "module" ? ` / ${p.moduleSlug}` : ""}${p.source === "registry" ? ` (${p.path})` : ""}`;
2904
+ var candidate = (p) => p.kind === "module" ? { kind: "module", id: p.moduleId, slug: p.moduleSlug, tenant: p.tenantSlug, name: p.path } : { kind: "app", slug: p.appSlug, tenant: p.tenantSlug, name: p.path };
2905
+ var withCwd = (ctx, cwd) => ({ ...ctx, deps: { ...ctx.deps, cwd } });
2906
+ async function standingIn(ctx) {
2907
+ if (await findWorkspace(ctx.deps.cwd, ctx.deps.home)) return "workspace";
2908
+ const repo = await findRepoRoot(ctx.deps.cwd);
2909
+ return repo !== null && !isInside(repo, ctx.deps.home) ? { repo } : null;
2732
2910
  }
2733
-
2734
- // src/core/dev/install.ts
2735
- import { stat as stat3 } from "fs/promises";
2736
- import { join as join16 } from "path";
2737
- var MARKERS = {
2738
- pnpm: [".modules.yaml"],
2739
- npm: [".package-lock.json"],
2740
- yarn: [".yarn-state.yml", ".yarn-integrity"]
2741
- };
2742
- var LOCK2 = { pnpm: "pnpm-lock.yaml", npm: "package-lock.json", yarn: "yarn.lock" };
2743
- var mtime = (p) => stat3(p).then((s) => s.mtimeMs, () => null);
2744
- async function needsInstall(root, pm) {
2745
- if (await mtime(join16(root, "node_modules")) === null) return true;
2746
- let marker = null;
2747
- for (const m of MARKERS[pm.name]) marker ??= await mtime(join16(root, "node_modules", m));
2748
- if (marker === null) return true;
2749
- const lock = await mtime(join16(root, LOCK2[pm.name]));
2750
- return lock !== null && lock > marker;
2911
+ async function isForeign(ctx, command) {
2912
+ const g = ctx.opts();
2913
+ try {
2914
+ await resolveModuleContext(ctx.deps, { portal: g.portal, command, onRequest: g.verbose ? (l) => ctx.io.err(l) : void 0 });
2915
+ return false;
2916
+ } catch (e) {
2917
+ if (e instanceof CancelledError) throw e;
2918
+ return isForeignRepo(e);
2919
+ }
2751
2920
  }
2752
-
2753
- // src/core/diagnostics/docker.ts
2754
- function dockerInstallUrl(platform) {
2755
- if (platform === "darwin") return "https://docs.docker.com/desktop/setup/install/mac-install/";
2756
- if (platform === "win32") return "https://docs.docker.com/desktop/setup/install/windows-install/";
2757
- return "https://docs.docker.com/engine/install/";
2921
+ async function moduleDirs(wsPath) {
2922
+ const out = [];
2923
+ for (const d of await readdir2(wsPath, { withFileTypes: true }).catch(() => [])) {
2924
+ if (d.isDirectory() && !d.name.startsWith(".") && await stat3(join14(wsPath, d.name, ".git")).catch(() => null)) out.push(d.name);
2925
+ }
2926
+ return out.sort();
2758
2927
  }
2759
- function classifyDockerInfo(r, platform) {
2760
- if (r.spawnError === "ENOENT") {
2761
- return new QoreError("QORE_DOCKER_MISSING", "Docker is not installed", {
2762
- hint: `Install Docker: ${dockerInstallUrl(platform)} \u2014 or run qore dev --no-services.`
2928
+ async function pathFor(ctx, m, needModule) {
2929
+ const p = m.project;
2930
+ if (p.kind === "module") return p.path;
2931
+ if (m.module) {
2932
+ const dir2 = join14(p.path, m.module);
2933
+ if (!await isDir(dir2)) {
2934
+ throw new QoreError("QORE_PROJECT_NOT_FOUND", `${p.appSlug} has no checkout of module ${m.module} in ${p.path}`, {
2935
+ hint: `Run \`qore sync\` in ${p.path} to clone the app's missing modules.`
2936
+ });
2937
+ }
2938
+ return dir2;
2939
+ }
2940
+ if (!needModule) return p.path;
2941
+ const dirs = await moduleDirs(p.path);
2942
+ if (dirs.length === 1) return join14(p.path, dirs[0]);
2943
+ if (dirs.length === 0) {
2944
+ throw new QoreError("QORE_PROJECT_NOT_FOUND", `${p.appSlug} has no module checkouts in ${p.path}`, {
2945
+ hint: `Run \`qore sync\` in ${p.path}.`
2763
2946
  });
2764
2947
  }
2765
- if (r.code === 0) return null;
2766
- const hint = "Start Docker Desktop, then run qore dev again \u2014 or qore dev --no-services.";
2767
- if (/Cannot connect to the Docker daemon|Is the docker daemon running|error during connect/i.test(r.tail.join("\n"))) {
2768
- return new QoreError("QORE_DOCKER_NOT_RUNNING", "Docker is not running", { hint, details: { reason: "daemon" } });
2948
+ if (!ctx.io.interactive) {
2949
+ throw new QoreError("QORE_CONTEXT_REQUIRED", `${p.appSlug} has several modules \u2014 which one?`, {
2950
+ hint: `Pass ${p.appSlug}/<module>.`,
2951
+ candidates: dirs.map((slug) => ({ kind: "module", slug, tenant: p.tenantSlug, name: join14(p.path, slug) }))
2952
+ });
2769
2953
  }
2770
- return new QoreError("QORE_DOCKER_NOT_RUNNING", "Docker is not running", { hint, details: { stderr: r.tail } });
2954
+ return join14(p.path, await ctx.io.select("Select a module", dirs.map((d) => ({ name: d, value: d }))));
2771
2955
  }
2772
- function classifyComposeVersion(r) {
2773
- if (r.code === 0) return null;
2774
- const hint = "Update Docker Desktop (or install the compose plugin). The legacy docker-compose v1 is not supported.";
2775
- if (/unknown command: docker compose|'compose' is not a docker command/.test(r.tail.join("\n"))) {
2776
- return new QoreError("QORE_DOCKER_COMPOSE_MISSING", "Docker Compose v2 is missing", { hint });
2956
+ async function liveMatches(matches) {
2957
+ const live = [];
2958
+ for (const m of matches) {
2959
+ if (m.project.kind === "workspace" && m.module && !await isDir(join14(m.project.path, m.module))) continue;
2960
+ live.push(m);
2777
2961
  }
2778
- return new QoreError("QORE_DOCKER_COMPOSE_MISSING", "Docker Compose v2 is missing", { hint, details: { stderr: r.tail } });
2962
+ return live.length > 0 ? live : matches;
2779
2963
  }
2780
-
2781
- // src/core/diagnostics/postgres.ts
2782
- var INCOMPATIBLE = /database files are incompatible with server|Counter to that, there appears to be PostgreSQL data in|in 18\+, these Docker images are configured to store database data/;
2783
- function classifyComposeLogs(logs, o) {
2784
- const text2 = logs.join("\n");
2785
- if (!INCOMPATIBLE.test(text2)) return null;
2786
- const details = { reason: "pg-data-version" };
2787
- const m = text2.match(/initialized by PostgreSQL version (\d+).*this version (\d+)/);
2788
- if (m) {
2789
- details.found = m[1];
2790
- details.expected = m[2];
2964
+ async function choose(ctx, matches, what, explicit) {
2965
+ if (explicit && matches.length === 1) return matches[0];
2966
+ if (!ctx.io.interactive) {
2967
+ throw new QoreError("QORE_CONTEXT_REQUIRED", what, {
2968
+ hint: "Pass a more specific reference: tenant/app or app/module.",
2969
+ candidates: matches.map((m) => candidate(m.project))
2970
+ });
2791
2971
  }
2792
- return new QoreError("QORE_SERVICE_DATA_INCOMPATIBLE", `The local database volume of ${o.slug} was created by another Postgres version`, {
2793
- hint: "pnpm db:reset recreates it \u2014 this deletes all local data in it. Qore never runs it for you.",
2794
- details
2795
- });
2796
- }
2797
-
2798
- // src/core/diagnostics/ports.ts
2799
- function parseDockerPs(stdout) {
2800
- const line2 = stdout.split(/\r?\n/).find((l) => l.trim());
2801
- if (!line2) return null;
2802
- const [id, name, project, workingDir] = line2.split(" ");
2803
- if (!id || !name) return null;
2804
- return { id, name, project: project || null, workingDir: workingDir || null };
2805
- }
2806
- function parseLsofCwd(stdout) {
2807
- const m = stdout.match(/^fcwd\nn(.+)$/m);
2808
- return m ? m[1] : null;
2809
- }
2810
- function parseEtime(s) {
2811
- const m = s.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
2812
- if (!m) return null;
2813
- const days = m[1] ? Number(m[1]) : 0;
2814
- const hours = m[2] ? Number(m[2]) : 0;
2815
- const minutes = Number(m[3]);
2816
- const seconds = Number(m[4]);
2817
- return days * 86400 + hours * 3600 + minutes * 60 + seconds;
2818
- }
2819
- function humanAge(seconds) {
2820
- if (seconds < 60) return `${seconds} s`;
2821
- if (seconds < 3600) return `${Math.round(seconds / 60)} min`;
2822
- if (seconds < 86400) return `${Math.round(seconds / 3600)} h`;
2823
- return `${Math.round(seconds / 86400)} d`;
2972
+ const i = await ctx.io.select("Select a project", matches.map((m, idx) => ({
2973
+ name: m.module ? `${label2(m.project)} / ${m.module}` : label2(m.project),
2974
+ value: idx
2975
+ })));
2976
+ return matches[i];
2824
2977
  }
2825
-
2826
- // src/core/diagnostics/pm.ts
2827
- function pmVersionMismatch(pm, printed) {
2828
- if (pm.version === null) return null;
2829
- const lines = printed.split("\n");
2830
- let found = null;
2831
- for (let i = lines.length - 1; i >= 0; i--) {
2832
- const line2 = lines[i].trim();
2833
- if (/^\d+\.\d+\.\d+/.test(line2)) {
2834
- found = line2;
2835
- break;
2978
+ async function projectCtx(ctx, o) {
2979
+ const g = ctx.opts();
2980
+ const cfg = await ctx.deps.config.read();
2981
+ const portal = g.portal ? resolvePortal(g.portal, cfg.defaultProfile).apiOrigin : void 0;
2982
+ if (o.ref) {
2983
+ const matches = matchProjects(await knownProjects(ctx.deps, { portal }), o.ref);
2984
+ if (matches.length === 0) {
2985
+ const parts = o.ref.split("/");
2986
+ const app = parts.length === 1 ? o.ref : parts.length === 3 ? parts[1] : "<app>";
2987
+ throw new QoreError("QORE_PROJECT_NOT_FOUND", `No local project "${o.ref}"`, {
2988
+ hint: cfg.projectsRoot ? `Run \`qore clone ${app}\`.` : `Run \`qore init\`, or \`qore clone ${app}\`.`
2989
+ });
2836
2990
  }
2991
+ const m2 = await choose(ctx, await liveMatches(matches), `"${o.ref}" matches several projects`, true);
2992
+ return relocate(ctx, await pathFor(ctx, m2, o.needModule));
2837
2993
  }
2838
- if (found === null) return null;
2839
- if (found.split(".")[0] === pm.version.split(".")[0]) return null;
2840
- return new QoreError("QORE_PACKAGE_MANAGER", `${pm.name} ${found} does not match the pinned ${pm.name}@${pm.version}`, {
2841
- hint: `Install the pinned major: npm i -g ${pm.name}@${pm.version.split(".")[0]}. (pnpm \u2265 9.7 switches to the pinned version by itself.)`,
2842
- details: { found, pinned: pm.version }
2843
- });
2844
- }
2845
- function classifyInstall(tail, command, root) {
2846
- const joined = tail.join("\n");
2847
- if (/ERR_PNPM_OUTDATED_LOCKFILE/.test(joined)) {
2848
- return new QoreError("QORE_INSTALL_FAILED", "The lockfile does not match package.json", {
2849
- hint: `Someone committed one without the other. Run \`${command} --no-frozen-lockfile\` in ${root} and commit the lockfile.`,
2850
- details: { reason: "outdated-lockfile", command, stderr: tail }
2994
+ if (o.module || o.app) return ctx;
2995
+ const here = await standingIn(ctx);
2996
+ if (here === "workspace") return ctx;
2997
+ const known = await knownProjects(ctx.deps, { portal });
2998
+ if (here && (known.length === 0 || known.some((p) => isInside(p.path, here.repo)) || !await isForeign(ctx, o.command ?? "project"))) return ctx;
2999
+ if (known.length === 0) {
3000
+ if (!cfg.projectsRoot) {
3001
+ if (!await loggedInAs(ctx)) return ctx;
3002
+ throw new QoreError("QORE_ROOT_NOT_SET", "Not inside a project, and qore knows no projects yet", {
3003
+ hint: "Run `qore init` to choose a projects folder, or cd into a project."
3004
+ });
3005
+ }
3006
+ throw new QoreError("QORE_PROJECT_NOT_FOUND", `Not inside a project, and ${cfg.projectsRoot} holds none yet`, {
3007
+ hint: "Run `qore clone <app>`."
2851
3008
  });
2852
3009
  }
2853
- return new QoreError("QORE_INSTALL_FAILED", `${command} failed`, {
2854
- hint: `Run \`${command}\` in ${root} to see the full output.`,
2855
- details: { command, stderr: tail }
2856
- });
2857
- }
2858
-
2859
- // src/core/diagnostics/versions.ts
2860
- import semver from "semver";
2861
- function nodeSatisfies(found, range) {
2862
- if (semver.validRange(range) === null) return null;
2863
- return semver.satisfies(found, range);
3010
+ const what = here ? "This repository is not a Qore module \u2014 which project?" : "Not inside a project \u2014 which one?";
3011
+ const m = await choose(ctx, known.map((project) => ({ project })), what, false);
3012
+ return relocate(ctx, await pathFor(ctx, m, o.needModule));
2864
3013
  }
2865
- function nodeVersionError(found, range, slug) {
2866
- const min = semver.minVersion(range);
2867
- const major = min ? min.major : range;
2868
- return new QoreError("QORE_NODE_VERSION", `${slug} needs Node ${range}, found ${found}`, {
2869
- hint: `nvm install ${major} / fnm use ${major}, then run qore dev again.`,
2870
- details: { found, required: range }
2871
- });
3014
+ function relocate(ctx, path2) {
3015
+ if (path2 === ctx.deps.cwd) return ctx;
3016
+ if (!ctx.opts().json) ctx.io.err(`\u2192 in ${path2}`);
3017
+ return withCwd(ctx, path2);
2872
3018
  }
2873
-
2874
- // src/core/dev/preflight.ts
2875
- var pmMajor = (v) => v ? v.split(".")[0] : "latest";
2876
- var GRACE_MS = 1e4;
2877
- var secs = (ms2) => `${Math.round(ms2 / 1e3)} s`;
2878
- async function runTool(deps2, cmd, args, o) {
2879
- if (o.signal.aborted) throw new CancelledError();
2880
- const tail = [];
2881
- const c = deps2.launch.launch(cmd, args, { cwd: o.cwd, env: { ...o.env, LC_ALL: "C" }, onLine: (l) => {
2882
- tail.push(l.line);
2883
- if (tail.length > 50) tail.shift();
2884
- o.onLine(l);
2885
- } });
2886
- let kill;
2887
- const onAbort = () => {
2888
- c.stop("SIGTERM");
2889
- kill = setTimeout(() => c.stop("SIGKILL"), GRACE_MS);
2890
- };
2891
- o.signal.addEventListener("abort", onAbort, { once: true });
2892
- let exit;
2893
- try {
2894
- exit = await c.exited;
2895
- } finally {
2896
- o.signal.removeEventListener("abort", onAbort);
2897
- clearTimeout(kill);
2898
- }
2899
- if (o.signal.aborted) throw new CancelledError();
2900
- return { code: exit.code, spawnError: exit.spawnError, tail };
2901
- }
2902
- async function checkPackageManager(deps2, r, onLine, signal) {
2903
- const res = await runTool(deps2, r.pm.name, ["--version"], { cwd: r.root, env: deps2.env, onLine, signal });
2904
- const hint = `Install it: npm i -g ${r.pm.name}@${pmMajor(r.pm.version)}`;
2905
- if (res.spawnError === "ENOENT") {
2906
- throw new QoreError("QORE_PACKAGE_MANAGER", `${r.pm.name} is not installed`, { hint, details: { packageManager: r.pm } });
3019
+ async function rememberContext(ctx, mc, app) {
3020
+ const tenantSlug = mc.module.tenant.slug;
3021
+ if (mc.workspace && mc.workspace.appId === app.id) {
3022
+ await remember(ctx.deps, {
3023
+ kind: "workspace",
3024
+ path: mc.workspace.root,
3025
+ portal: mc.workspace.portal,
3026
+ tenantSlug,
3027
+ appId: app.id,
3028
+ appSlug: app.slug
3029
+ });
3030
+ return;
2907
3031
  }
2908
- if (res.code !== 0) {
2909
- throw new QoreError("QORE_PACKAGE_MANAGER", `${r.pm.name} is not installed`, { hint, details: { packageManager: r.pm, stderr: res.tail } });
3032
+ if (mc.repoRoot) {
3033
+ await remember(ctx.deps, {
3034
+ kind: "module",
3035
+ path: mc.repoRoot,
3036
+ portal: mc.portal.apiOrigin,
3037
+ tenantSlug,
3038
+ appId: app.id,
3039
+ appSlug: app.slug,
3040
+ moduleId: mc.module.id,
3041
+ moduleSlug: mc.module.slug
3042
+ });
2910
3043
  }
2911
- const mismatch = pmVersionMismatch(r.pm, res.tail.join("\n"));
2912
- if (mismatch) throw mismatch;
2913
- return res.tail.map((l) => l.trim()).findLast((l) => /^\d+\.\d+\.\d+/.test(l)) ?? null;
2914
3044
  }
2915
- async function checkNode(deps2, r, pkg, slug, signal) {
2916
- const range = pkg.engines?.node;
2917
- if (!range) return null;
2918
- const res = await runTool(deps2, "node", ["--version"], { cwd: r.root, env: deps2.env, onLine: () => {
2919
- }, signal });
2920
- if (res.spawnError === "ENOENT") {
2921
- throw new QoreError("QORE_NODE_VERSION", "node is not on PATH", {
2922
- hint: "Install Node (https://nodejs.org) or run nvm use, then reinstall qore.",
2923
- details: { required: range }
3045
+ async function rememberWorkspace(ctx, ws, app) {
3046
+ if (ws.appId !== app.id) return;
3047
+ await remember(ctx.deps, { kind: "workspace", path: ws.root, portal: ws.portal, tenantSlug: app.tenant.slug, appId: app.id, appSlug: app.slug });
3048
+ }
3049
+
3050
+ // src/cli/trustPrompt.ts
3051
+ async function ensureTrusted(ctx, mc, app, o) {
3052
+ const root = mc.repoRoot ?? ctx.deps.cwd;
3053
+ if (!ctx.opts().json) ctx.io.err(contextLine(mc, app));
3054
+ if (await isTrusted(ctx.deps, root, mc.module.id)) return;
3055
+ if (o.yes) {
3056
+ await trust(ctx.deps, root, mc.module.id);
3057
+ return;
3058
+ }
3059
+ if (!ctx.io.interactive) {
3060
+ throw new QoreError("QORE_CONTEXT_REQUIRED", `This repository is not trusted for ${mc.module.slug} yet`, {
3061
+ hint: "Run interactively once, or pass --yes.",
3062
+ details: { reason: "untrusted", repository: root }
2924
3063
  });
2925
3064
  }
2926
- if (res.spawnError || res.code !== 0) {
2927
- throw new QoreError("QORE_NODE_VERSION", "node --version failed", { details: { required: range, stderr: res.tail } });
3065
+ if (mc.source.kind === "remote") {
3066
+ const key2 = repoKey(mc.source.url);
3067
+ ctx.io.err(` from remote ${mc.source.remote}: ${key2 ?? "(unparsable remote URL)"}`);
2928
3068
  }
2929
- const found = (res.tail.at(-1) ?? "").trim().replace(/^v/, "");
2930
- if (nodeSatisfies(found, range) === false) throw nodeVersionError(found, range, slug);
2931
- return found;
3069
+ if (!await ctx.io.confirm("Pull local secrets for this module into this repository?")) throw new CancelledError();
3070
+ await trust(ctx.deps, root, mc.module.id);
2932
3071
  }
2933
- async function installIfNeeded(deps2, r, slug, onLine, report, signal) {
2934
- if (!r.install) {
2935
- report({ module: slug, step: "install", status: "skip", detail: "disabled" });
2936
- return;
3072
+
3073
+ // src/cli/commands/db.ts
3074
+ var isDir2 = async (p) => stat4(p).then((s) => s.isDirectory(), () => false);
3075
+ function parsePort(raw) {
3076
+ const n = /^\d+$/.test(raw) ? Number(raw) : NaN;
3077
+ if (!Number.isInteger(n) || n < 1 || n > 65535) throw new InvalidArgumentError("a port is a number from 1 to 65535");
3078
+ return n;
3079
+ }
3080
+ var aborted = (signal) => signal.aborted ? Promise.resolve() : new Promise((r) => signal.addEventListener("abort", () => r(), { once: true }));
3081
+ var plural = (n) => `${n} migration${n === 1 ? "" : "s"}`;
3082
+ function migrationLines(m, who) {
3083
+ if (!m) return [];
3084
+ const lines = [];
3085
+ if (m.pending.length) lines.push(`${who} lacks ${plural(m.pending.length)} of this checkout (${m.pending.join(", ")}); they apply on the next start of \`qore dev\`.`);
3086
+ if (m.skipped.length) lines.push(`${who} lacks ${plural(m.skipped.length)} older than its latest (${m.skipped.join(", ")}); Drizzle never applies those, so reset it with \`pnpm db:reset\`.`);
3087
+ if (m.unknown) lines.push(`${who} has ${plural(m.unknown)} this checkout does not know; applied from another branch?`);
3088
+ return lines;
3089
+ }
3090
+ var portInUse = (port) => new QoreError("QORE_PORT_IN_USE", `Port ${port} is already in use`, { hint: "Pick another --port, or leave it out to get a free one." });
3091
+ function registerDb(program, ctx) {
3092
+ const db = program.command("db").description("Work with the local databases of a module or an app");
3093
+ db.command("erd").description("Show this module's data model in the browser \u2014 or, in a workspace root, the whole app's").argument("[project]", "app or app/module to show, from anywhere (see qore init)").option("--module <slug|id>", "the module (default: from this repository's git remote)").option("--app <slug|id>", "the app, when the module is part of several").option("--project <ref>", "same as the [project] argument").option("--yes", "trust this repository without asking").option("--port <n>", "listen on this port (default: a free one)", parsePort).option("--no-open", "print the URL, do not open a browser").action(async (project, f) => {
3094
+ if (project !== void 0 && f.project !== void 0 && project !== f.project) {
3095
+ throw new QoreError("QORE_CONTEXT_INVALID", `Pass the project once: "${project}" and --project "${f.project}" differ`, {
3096
+ hint: "qore db erd <project> | qore db erd --project <project>"
3097
+ });
3098
+ }
3099
+ await erd(ctx, { ...f, project: project ?? f.project });
3100
+ });
3101
+ }
3102
+ async function erd(outer, f) {
3103
+ const json2 = Boolean(outer.opts().json);
3104
+ if (json2 && (f.port !== void 0 || !f.open)) {
3105
+ throw new QoreError("QORE_CONTEXT_INVALID", "--json writes the model and starts no server", {
3106
+ hint: "Drop --port and --no-open, or drop --json."
3107
+ });
2937
3108
  }
2938
- if (!await needsInstall(r.root, r.pm)) {
2939
- report({ module: slug, step: "install", status: "skip", detail: "up to date" });
3109
+ if (f.port !== void 0 && await outer.deps.ports.inUse(f.port)) throw portInUse(f.port);
3110
+ const ctx = await projectCtx(outer, { ref: f.project, module: f.module, app: f.app, needModule: false, command: "db erd" });
3111
+ const { deps: deps2, io: io2 } = ctx;
3112
+ if (!f.module) {
3113
+ const chosen = Boolean(f.project) || ctx !== outer || Boolean(f.app);
3114
+ const ws = await workspaceRoot(deps2) ?? (chosen ? null : await wholeAppWanted(ctx));
3115
+ if (ws) {
3116
+ await erdApp(ctx, f, ws);
3117
+ return;
3118
+ }
3119
+ }
3120
+ const mc = await moduleContextFor(ctx, f, "db erd");
3121
+ const app = await appFor(ctx, mc, f.app);
3122
+ await ensureTrusted(ctx, mc, app, { yes: f.yes });
3123
+ const src = await resolveDatabaseUrl(deps2, mc, app);
3124
+ const model = async () => {
3125
+ const conn = await deps2.postgres(src.url, { timeoutMs: 3e3 });
3126
+ try {
3127
+ const m = await introspect(conn.query);
3128
+ const migrations = await checkMigrations(mc.repoRoot, conn.query);
3129
+ return migrations ? { ...m, migrations } : m;
3130
+ } finally {
3131
+ await conn.close().catch(() => {
3132
+ });
3133
+ }
3134
+ };
3135
+ const fresh = () => model().catch((e) => {
3136
+ throw classifyDbError(e, src.url);
3137
+ });
3138
+ const first = await model().catch((e) => {
3139
+ throw ctx.signal.aborted ? new CancelledError() : classifyDbError(e, src.url);
3140
+ });
3141
+ if (json2) {
3142
+ renderJson(io2, { context: contextJson(mc, app), source: src.source, model: first });
2940
3143
  return;
2941
3144
  }
2942
- report({ module: slug, step: "install", status: "start" });
2943
- const began = deps2.now().getTime();
2944
- const { cmd, args } = installArgs(r.pm);
2945
- const res = await runTool(deps2, cmd, args, { cwd: r.root, env: deps2.env, onLine, signal });
2946
- if (res.code !== 0) {
2947
- const command = [cmd, ...args].join(" ");
2948
- const err = classifyInstall(res.tail, command, r.root);
2949
- if (err.details?.reason === "outdated-lockfile") throw err;
2950
- throw new QoreError("QORE_INSTALL_FAILED", `${command} failed in ${slug}`, { hint: err.hint, details: err.details });
3145
+ if (first.tables.length === 0) io2.err("No tables yet \u2014 have the migrations run? Start the app once with `qore dev`.");
3146
+ for (const line2 of migrationLines(first.migrations, "The database")) io2.err(line2);
3147
+ if (ctx.signal.aborted) throw new CancelledError();
3148
+ const meta = { context: contextJson(mc, app), connection: connectionOf(src.url) };
3149
+ const server = await serveErd({
3150
+ dir: deps2.viewerDir,
3151
+ port: f.port ?? 0,
3152
+ routes: { "/model.json": fresh, "/meta.json": async () => meta },
3153
+ onError: (e) => {
3154
+ if (!ctx.signal.aborted) io2.err(`Could not refresh the model: ${e instanceof Error ? e.message : String(e)}`);
3155
+ }
3156
+ }).catch((e) => {
3157
+ const code = e.code;
3158
+ throw f.port !== void 0 && (code === "EADDRINUSE" || code === "EACCES") ? portInUse(f.port) : e;
3159
+ });
3160
+ await serve(ctx, f, server);
3161
+ }
3162
+ async function wholeAppWanted(ctx) {
3163
+ if (!ctx.io.interactive) return null;
3164
+ const ws = await findWorkspace(ctx.deps.cwd, ctx.deps.home);
3165
+ const repo = ws && await findRepoRoot(ctx.deps.cwd);
3166
+ if (!ws || !repo || repo === ws.root || !isInside(ws.root, repo)) return null;
3167
+ const pick2 = await ctx.io.select("Which data model?", [
3168
+ { name: "the whole app", value: "app" },
3169
+ { name: `only ${basename3(repo)}`, value: "module" }
3170
+ ]);
3171
+ return pick2 === "app" ? ws : null;
3172
+ }
3173
+ async function serve(ctx, f, server) {
3174
+ try {
3175
+ const url = `http://127.0.0.1:${server.port}`;
3176
+ ctx.io.err(`\u2192 ${url} (Ctrl+C stops)`);
3177
+ if (f.open) await ctx.io.openUrl(url);
3178
+ await aborted(ctx.signal);
3179
+ throw new CancelledError();
3180
+ } finally {
3181
+ await server.close();
2951
3182
  }
2952
- report({ module: slug, step: "install", status: "done", detail: secs(deps2.now().getTime() - began) });
2953
3183
  }
2954
- async function portInUseError(deps2, o) {
2955
- const owner = await deps2.ports.owner(o.port);
2956
- const qoreRun = (o.runs ?? []).find((r) => r.children.some((c) => c.port === o.port) || owner !== null && (r.qorePid === owner.pid || r.children.some((c) => c.pid === owner.pid)));
2957
- if (qoreRun) {
2958
- return new QoreError("QORE_PORT_IN_USE", `A previous qore dev is still running (pid ${qoreRun.qorePid})`, {
2959
- hint: `Stop it (Ctrl+C in its terminal, or kill ${qoreRun.qorePid}), then run qore dev again.`,
2960
- details: { port: o.port, module: o.slug, key: o.key, owner: "qore", pid: qoreRun.qorePid }
2961
- });
3184
+ async function erdApp(ctx, f, ws) {
3185
+ const { deps: deps2, io: io2 } = ctx;
3186
+ const g = ctx.opts();
3187
+ const json2 = Boolean(g.json);
3188
+ const portal = await flagPortal(deps2, g.portal, ws);
3189
+ const session = await openSession(deps2, portal, "db erd", g.verbose ? (l) => io2.err(l) : void 0);
3190
+ await ensureCapability(deps2, portal.apiOrigin, "app.modules");
3191
+ const am = await workspaceAppModules(session, portal, ws);
3192
+ const { tenant, ...app } = am.app;
3193
+ if (f.app && f.app !== app.id && f.app !== app.slug) {
3194
+ throw new QoreError("QORE_RESOURCE_NOT_FOUND", `This workspace is for ${app.slug}, not "${f.app}"`, { hint: "Pass --module <slug> --app <slug>." });
2962
3195
  }
2963
- if (owner) {
2964
- if (/^(com\.docker\.backend|com\.docker\.vpnkit|docker-proxy|vpnkit|dockerd)$/.test(owner.command)) {
2965
- const pub = await deps2.ports.dockerPublisher(o.port);
2966
- if (pub) {
2967
- return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is held by the Docker container ${pub.name} (needed by ${o.slug})`, {
2968
- hint: `It belongs to compose project ${pub.project} in ${pub.workingDir} \u2014 typically another module's or tenant's database. Stop it there with docker compose down, or docker stop ${pub.name}.`,
2969
- details: { port: o.port, module: o.slug, key: o.key, owner: "docker", ...pub }
2970
- });
3196
+ const modules = [];
3197
+ const sources = /* @__PURE__ */ new Map();
3198
+ const roots = /* @__PURE__ */ new Map();
3199
+ for (const m of orderModules(am.modules)) {
3200
+ const dir2 = join15(ws.root, m.slug);
3201
+ const repoRoot = isSafeSlug(m.slug) && await isDir2(dir2) ? dir2 : null;
3202
+ const mc = { portal, session, module: { ...m, tenant, apps: [app] }, source: { kind: "workspace" }, repoRoot, workspace: ws };
3203
+ roots.set(m.slug, repoRoot);
3204
+ if (repoRoot) await ensureTrusted(ctx, mc, app, { yes: f.yes });
3205
+ try {
3206
+ const src = await resolveDatabaseUrl(deps2, mc, app);
3207
+ modules.push({ slug: m.slug, dependsOn: m.dependsOn, url: src.url });
3208
+ sources.set(m.slug, src.source);
3209
+ } catch (e) {
3210
+ if (e instanceof QoreError && e.code === "QORE_CONTEXT_UNSUPPORTED") {
3211
+ modules.push({ slug: m.slug, dependsOn: m.dependsOn, url: null });
3212
+ continue;
2971
3213
  }
2972
- }
2973
- const detail2 = await deps2.ports.ownerDetail(owner.pid);
2974
- const extras = [];
2975
- if (detail2.ageSeconds !== null) extras.push(`started ${humanAge(detail2.ageSeconds)} ago`);
2976
- if (detail2.cwd !== null) {
2977
- const cwd = deps2.home && detail2.cwd.startsWith(deps2.home) ? `~${detail2.cwd.slice(deps2.home.length)}` : detail2.cwd;
2978
- extras.push(`in ${cwd}`);
2979
- }
2980
- const suffix = extras.length ? `, ${extras.join(" ")}` : "";
2981
- return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is already in use (needed by ${o.slug})`, {
2982
- hint: `Held by: ${owner.command} (pid ${owner.pid})${suffix}. Stop that process. Qore never moves a port: other modules and the local login rely on ${o.port}.`,
2983
- details: {
2984
- port: o.port,
2985
- module: o.slug,
2986
- key: o.key,
2987
- owner: "process",
2988
- pid: owner.pid,
2989
- command: owner.command,
2990
- ...detail2.cwd !== null ? { cwd: detail2.cwd } : {},
2991
- ...detail2.ageSeconds !== null ? { ageSeconds: detail2.ageSeconds } : {}
3214
+ if (!json2 && e instanceof QoreError && e.code === "QORE_ACCESS_DENIED") {
3215
+ io2.err(`${m.slug} is left out: ${e.message}${e.hint ? `
3216
+ ${e.hint}` : ""}`);
3217
+ modules.push({ slug: m.slug, dependsOn: m.dependsOn, url: null });
3218
+ continue;
2992
3219
  }
3220
+ throw e;
3221
+ }
3222
+ }
3223
+ const groups = groupDatabases(modules);
3224
+ if (groups.length === 0) {
3225
+ throw new QoreError("QORE_CONTEXT_UNSUPPORTED", `${app.slug} has no module with a database`, {
3226
+ hint: "qore db erd shows the Postgres of an app's modules; none of them declares a DATABASE_URL."
2993
3227
  });
2994
3228
  }
2995
- const findHint = deps2.platform === "win32" ? `netstat -ano | findstr :${o.port}` : `lsof -iTCP:${o.port} -sTCP:LISTEN`;
2996
- return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is already in use (needed by ${o.slug})`, {
2997
- hint: `Find it with: ${findHint}`,
2998
- details: { port: o.port, module: o.slug, key: o.key }
3229
+ for (const gdb of groups) deps2.registerSecrets([gdb.url]);
3230
+ if (!json2) for (const { modules: mods, connection: c } of groups) {
3231
+ if (mods.length > 1) io2.err(`${mods.join(" and ")} use the same database (${c.host}:${c.port}/${c.database}); each module normally has its own \u2014 check their DATABASE_URL.`);
3232
+ }
3233
+ const readAll = (strict) => Promise.all(groups.map(async (gdb) => {
3234
+ try {
3235
+ const conn = await deps2.postgres(gdb.url, { timeoutMs: 3e3 });
3236
+ try {
3237
+ const model = await introspect(conn.query);
3238
+ const migrations = gdb.modules.length === 1 ? await checkMigrations(roots.get(gdb.modules[0]) ?? null, conn.query) : void 0;
3239
+ return { ...gdb, model, ...migrations ? { migrations } : {} };
3240
+ } finally {
3241
+ await conn.close().catch(() => {
3242
+ });
3243
+ }
3244
+ } catch (e) {
3245
+ const err = classifyDbError(e, gdb.url);
3246
+ if (strict) throw err;
3247
+ return { ...gdb, model: null, error: err.message };
3248
+ }
3249
+ }));
3250
+ const first = await readAll(json2).catch((e) => {
3251
+ throw ctx.signal.aborted ? new CancelledError() : e;
2999
3252
  });
3000
- }
3001
- async function composeFailure(deps2, r, slug, env, signal, message, command, stderr) {
3002
- if (!r.dockerServices) {
3003
- return new QoreError("QORE_SERVICE_UNHEALTHY", message, { details: { command, stderr } });
3253
+ if (first.every((d) => d.model === null)) throw new QoreError("QORE_SERVICE_UNHEALTHY", `No database of ${app.slug} is reachable`, { hint: first.map((d) => `${d.key}: ${d.error}`).join("\n") });
3254
+ const context = {
3255
+ portal: portal.builtin ?? portal.apiOrigin,
3256
+ tenant,
3257
+ app,
3258
+ module: null,
3259
+ source: WORKSPACE_FILE,
3260
+ modules: am.modules.map(({ id, slug, name, type }) => ({ id, slug, name, type, database: groups.find((gdb) => gdb.modules.includes(slug))?.key ?? null }))
3261
+ };
3262
+ const databases = groups.map(({ key: key2, modules: mods, connection }) => ({ key: key2, modules: mods, connection, source: sources.get(mods[0]) }));
3263
+ if (json2) {
3264
+ renderJson(io2, { context, databases, model: combineModels(first) });
3265
+ return;
3004
3266
  }
3005
- const logs = await runTool(deps2, "docker", ["compose", "logs", "--no-color", "--tail", "50"], { cwd: r.root, env, onLine: () => {
3006
- }, signal });
3007
- const dataErr = classifyComposeLogs(logs.tail, { slug, script: "db:up" });
3008
- if (dataErr) return dataErr;
3009
- return new QoreError("QORE_SERVICE_UNHEALTHY", message, {
3010
- hint: `The last lines of docker compose logs are in details.logs; run \`docker compose logs\` in ${r.root} for all of them.`,
3011
- details: { command, stderr, logs: logs.tail.slice(-20) }
3267
+ for (const d of first) if (d.error) io2.err(`${d.key} (${d.modules.join(", ")}): ${d.error}`);
3268
+ for (const d of first) for (const line2 of migrationLines(d.migrations, `The database of ${d.modules.join(", ")}`)) io2.err(line2);
3269
+ if (first.every((d) => !d.model || d.model.tables.length === 0)) io2.err("No tables yet \u2014 have the migrations run? Start the app once with `qore dev`.");
3270
+ if (ctx.signal.aborted) throw new CancelledError();
3271
+ const meta = { context, connection: null, databases: databases.map(({ key: key2, modules: mods, connection }) => ({ key: key2, modules: mods, connection })) };
3272
+ const server = await serveErd({
3273
+ dir: deps2.viewerDir,
3274
+ port: f.port ?? 0,
3275
+ routes: { "/model.json": () => readAll(false).then(combineModels), "/meta.json": async () => meta },
3276
+ onError: (e) => {
3277
+ if (!ctx.signal.aborted) io2.err(`Could not refresh the model: ${e instanceof Error ? e.message : String(e)}`);
3278
+ }
3279
+ }).catch((e) => {
3280
+ const code = e.code;
3281
+ throw f.port !== void 0 && (code === "EADDRINUSE" || code === "EACCES") ? portInUse(f.port) : e;
3012
3282
  });
3283
+ await serve(ctx, f, server);
3013
3284
  }
3014
- async function startServices(deps2, r, slug, env, dbPort, waitMs, signal, onLine, report) {
3015
- if (r.services.length === 0) {
3016
- report({ module: slug, step: "services", status: "skip" });
3017
- return;
3285
+
3286
+ // src/cli/commands/dev.ts
3287
+ import { randomBytes as randomBytes6 } from "crypto";
3288
+ import { realpath as realpath8 } from "fs/promises";
3289
+ import { constants as constants3 } from "os";
3290
+ import { dirname as dirname7, join as join23, relative as relative7 } from "path";
3291
+ import { StringDecoder } from "string_decoder";
3292
+
3293
+ // src/core/diagnostics/node.ts
3294
+ var KEEP_LINES = 50;
3295
+ function exitHint(tail, h) {
3296
+ const text2 = tail.join("\n");
3297
+ const missing = text2.match(/Cannot find (?:module|package) '([^']+)'/);
3298
+ if (missing) {
3299
+ return { reason: "missing-module", hint: `A dependency is missing (${missing[1]}). Run \`${h.installCommand}\`, then qore dev again.` };
3018
3300
  }
3019
- report({ module: slug, step: "services", status: "start", ...dbPort !== null ? { detail: `:${dbPort}` } : {} });
3020
- const began = deps2.now().getTime();
3021
- if (r.dockerServices) {
3022
- const res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: r.root, env, onLine, signal });
3023
- const dockerErr = classifyDockerInfo(res, deps2.platform);
3024
- if (dockerErr) throw dockerErr;
3025
- const compose = await runTool(deps2, "docker", ["compose", "version"], { cwd: r.root, env, onLine: () => {
3026
- }, signal });
3027
- const composeErr = classifyComposeVersion(compose);
3028
- if (composeErr) throw composeErr;
3301
+ if (/ZodError:/.test(text2)) {
3302
+ const keys = [...text2.matchAll(/"path":\s*\[\s*"([^"]+)"/g)].map((m) => m[1]);
3303
+ if (keys.length > 0) {
3304
+ return {
3305
+ reason: "invalid-env",
3306
+ keys,
3307
+ hint: `Missing or invalid environment variables: ${keys.join(", ")}. Set them in the portal's local secrets (then qore env pull), or in your env file.`
3308
+ };
3309
+ }
3029
3310
  }
3030
- for (const script of r.services) {
3031
- const { cmd, args } = runArgs(r.pm, script);
3032
- const res = await runTool(deps2, cmd, args, { cwd: r.root, env, onLine, signal });
3033
- if (res.code !== 0) {
3034
- if (dbPort !== null && /port is already allocated|address already in use/i.test(res.tail.join("\n"))) {
3035
- throw await portInUseError(deps2, { slug, port: dbPort, key: "DB_PORT" });
3036
- }
3037
- throw await composeFailure(deps2, r, slug, env, signal, `${script} failed in ${slug}`, `${cmd} ${args.join(" ")}`, res.tail);
3311
+ const addrInUse = text2.match(/listen EADDRINUSE: address already in use (?:\S*:)?(\d+)/);
3312
+ if (addrInUse) {
3313
+ const port = Number(addrInUse[1]);
3314
+ return {
3315
+ reason: "port-in-use",
3316
+ port,
3317
+ hint: `Port ${port} was taken while the server started. Run qore dev again; if it repeats, lsof -iTCP:${port} -sTCP:LISTEN names the holder.`
3318
+ };
3319
+ }
3320
+ if (/ECONNREFUSED/.test(text2)) {
3321
+ const m = text2.match(/ECONNREFUSED\s+\S*:(\d+)/) ?? text2.match(/code: 'ECONNREFUSED'[^}]*?port: (\d+)/);
3322
+ if (!m) return { reason: "conn-refused", hint: "A connection was refused (ECONNREFUSED) \u2014 a service this module calls is not running." };
3323
+ const port = Number(m[1]);
3324
+ const role = h.ports.get(port);
3325
+ if (role?.kind === "database") {
3326
+ const up = h.runCommand("db:up");
3327
+ return {
3328
+ reason: "conn-refused",
3329
+ port,
3330
+ hint: `The database on :${port} is not accepting connections (ECONNREFUSED). ` + (up ? `Fix: ${up} (docker compose ps shows its state)` : "docker compose ps shows its state.")
3331
+ };
3038
3332
  }
3333
+ if (role?.kind === "provider") {
3334
+ return {
3335
+ reason: "conn-refused",
3336
+ port,
3337
+ hint: `${role.key} points at :${port}, where nothing listens (ECONNREFUSED). Start that module \u2014 qore dev in the workspace root starts providers first.`
3338
+ };
3339
+ }
3340
+ return { reason: "conn-refused", port, hint: `Nothing listens on :${port} (ECONNREFUSED) \u2014 a service this module calls is not running.` };
3039
3341
  }
3040
- if (dbPort !== null) {
3041
- const ok2 = await deps2.ports.waitFor(dbPort, waitMs, signal);
3042
- if (signal.aborted) throw new CancelledError();
3043
- if (!ok2) {
3044
- throw await composeFailure(
3045
- deps2,
3046
- r,
3047
- slug,
3048
- env,
3049
- signal,
3050
- `The database for ${slug} did not accept connections on port ${dbPort} within ${waitMs / 1e3} s`,
3051
- void 0,
3052
- []
3053
- );
3342
+ const auth = text2.match(/password authentication failed for user "([^"]+)"/);
3343
+ if (auth) {
3344
+ const reset = h.runCommand("db:reset");
3345
+ return {
3346
+ reason: "db-auth",
3347
+ hint: `The local database was created with other credentials than the portal now provides (user ${auth[1]}).` + (reset ? ` Fix: ${reset} \u2014 this wipes local data.` : "")
3348
+ };
3349
+ }
3350
+ const relation = text2.match(/relation "([^"]+)" does not exist/);
3351
+ if (relation) {
3352
+ const reset = h.runCommand("db:reset");
3353
+ return {
3354
+ reason: "db-schema",
3355
+ hint: `The database schema is behind the code (relation ${relation[1]} does not exist). Migrations run when the app starts; if they failed above, that error is the cause.` + (reset ? ` To start clean: ${reset} (wipes local data).` : "")
3356
+ };
3357
+ }
3358
+ if (/ENOSPC: System limit for number of file watchers reached/.test(text2)) {
3359
+ return {
3360
+ reason: "watcher-limit",
3361
+ hint: "Linux ran out of file watchers. Raise the limit: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p"
3362
+ };
3363
+ }
3364
+ return null;
3365
+ }
3366
+
3367
+ // src/core/dev/env.ts
3368
+ import { dirname as dirname6, join as join17 } from "path";
3369
+
3370
+ // src/core/dev/lastStart.ts
3371
+ import { createHash as createHash2 } from "crypto";
3372
+ import { realpath as realpath6 } from "fs/promises";
3373
+ import { join as join16 } from "path";
3374
+ var NETWORK_REASONS = /* @__PURE__ */ new Set(["connect", "dns", "tls", "timeout"]);
3375
+ function isOffline(e) {
3376
+ if (!(e instanceof QoreError)) return false;
3377
+ if (e.code === "QORE_API_UNREACHABLE" || e.code === "QORE_API_UNAVAILABLE") return true;
3378
+ if (e.code !== "QORE_AUTH_UNAVAILABLE") return false;
3379
+ return (e.status ?? 0) >= 500 || NETWORK_REASONS.has(String(e.details?.reason));
3380
+ }
3381
+ async function stateKey(deps2, root) {
3382
+ const real = await realpath6(root).catch(() => root);
3383
+ return createHash2("sha256").update(trustKey(real, deps2.platform)).digest("hex").slice(0, 16);
3384
+ }
3385
+ async function saveLastStart(deps2, root, rec) {
3386
+ await writeJsonAtomic(join16(deps2.config.dir, "dev"), `${await stateKey(deps2, root)}.json`, rec);
3387
+ }
3388
+ async function loadLastStart(deps2, root) {
3389
+ const rec = await readJson(join16(deps2.config.dir, "dev", `${await stateKey(deps2, root)}.json`)).catch(() => null);
3390
+ return rec && rec.schemaVersion === 1 ? rec : null;
3391
+ }
3392
+
3393
+ // src/core/dev/env.ts
3394
+ function envPort(env, key2) {
3395
+ const raw = env[key2];
3396
+ if (raw === void 0) return null;
3397
+ const v = raw.trim().replace(/^["']|["']$/g, "");
3398
+ if (!/^\d+$/.test(v)) return null;
3399
+ const n = Number(v);
3400
+ return n >= 1024 && n <= 65535 ? n : null;
3401
+ }
3402
+ async function devEnv(deps2, o) {
3403
+ const defaults = moduleDefaults(o.type);
3404
+ const target = await resolveTarget(o.root, o.cfg.envFile ?? defaults.envFile);
3405
+ let cause = null;
3406
+ if (o.mc && o.app) {
3407
+ try {
3408
+ const local = await fetchLocalEnv(deps2, o.mc, o.app);
3409
+ const example = await readOrNull(join17(dirname6(target), ".env.example"));
3410
+ const w = await syncEnvFile({
3411
+ target,
3412
+ example,
3413
+ fetched: local.env,
3414
+ managed: local.managedKeys,
3415
+ localSecrets: o.cfg.localSecrets ?? defaults.localSecrets,
3416
+ random: o.random
3417
+ });
3418
+ return {
3419
+ env: local.env,
3420
+ offline: false,
3421
+ envFile: target,
3422
+ port: envPort(local.env, "PORT"),
3423
+ dbPort: envPort(local.env, "DB_PORT"),
3424
+ ...w
3425
+ };
3426
+ } catch (e) {
3427
+ if (!isOffline(e)) throw e;
3428
+ cause = e;
3054
3429
  }
3055
3430
  }
3056
- const took = secs(deps2.now().getTime() - began);
3057
- report({ module: slug, step: "services", status: "done", detail: dbPort !== null ? `:${dbPort} (${took})` : `(${took})` });
3431
+ const text2 = await readOrNull(target);
3432
+ if (text2 === null || !await isTrusted(deps2, o.root, o.moduleId)) {
3433
+ throw cause ?? new QoreError("QORE_API_UNREACHABLE", "The portal is unreachable", {
3434
+ hint: "The portal is unreachable and there is no env file to start from yet."
3435
+ });
3436
+ }
3437
+ const env = Object.fromEntries(parseEnv(text2));
3438
+ return { env, offline: true, envFile: target, port: envPort(env, "PORT"), dbPort: envPort(env, "DB_PORT"), created: false, kept: [] };
3058
3439
  }
3059
- async function checkPort(deps2, o) {
3060
- if (o.port === null) return;
3061
- if (!await deps2.ports.inUse(o.port)) return;
3062
- throw await portInUseError(deps2, { slug: o.slug, port: o.port, key: o.key, runs: o.runs });
3440
+
3441
+ // src/core/workspace/access.ts
3442
+ function devTeamsOf(am, m) {
3443
+ const own = m.devTeamIds?.length ? am.devTeams.filter((t) => m.devTeamIds.includes(t.id)) : [];
3444
+ return own.length ? own : am.devTeams;
3063
3445
  }
3064
- function abortableDelay(signal) {
3065
- return (ms2) => new Promise((resolve6) => {
3066
- const done = () => {
3067
- clearTimeout(t);
3068
- signal.removeEventListener("abort", done);
3069
- resolve6();
3070
- };
3071
- const t = setTimeout(done, ms2);
3072
- signal.addEventListener("abort", done, { once: true });
3073
- });
3446
+ function askMaintainer(am, m) {
3447
+ const teams = devTeamsOf(am, m);
3448
+ const names = teams.map((t) => t.name);
3449
+ const maintainers = [...new Set(teams.flatMap((t) => t.maintainers))];
3450
+ const who = names.length ? names.join(", ") : "the app's dev team";
3451
+ return { teams: names, maintainers, text: `ask a maintainer of ${who}${maintainers.length ? ` (${maintainers.join(", ")})` : ""}` };
3452
+ }
3453
+ function notClonedReason(m, am) {
3454
+ if (am && !m.githubLinked) return `GitHub account not linked (${am.accountUrl})`;
3455
+ if (am && m.repoAccess === "none") {
3456
+ const names = devTeamsOf(am, m).map((t) => t.name);
3457
+ return names.length ? `no repository access (dev team ${names.join(", ")})` : "no repository access (ask your tenant owner)";
3458
+ }
3459
+ return "run qore sync";
3460
+ }
3461
+
3462
+ // src/core/dev/findings.ts
3463
+ var plural2 = (n, one2, many) => n === 1 ? one2 : many;
3464
+ function modulesMissing(o) {
3465
+ const actionFor = new Map(o.actions.map((a) => [a.module.slug, a]));
3466
+ const findings = [];
3467
+ const fixable = [];
3468
+ const blocked = [];
3469
+ const reasons = {};
3470
+ const own = (m, reason, rest) => {
3471
+ reasons[m.slug] = reason;
3472
+ return { code: "dev.modules-missing", severity: "warn", module: m.slug, modules: [m.slug], message: `${m.slug} is not cloned \u2014 ${reason}`, ...rest };
3473
+ };
3474
+ for (const m of o.missing) {
3475
+ const a = actionFor.get(m.slug);
3476
+ if (a && a.kind !== "clone") {
3477
+ if (a.kind === "conflict") {
3478
+ const c = conflictText(o.cwd, a);
3479
+ blocked.push(own(m, c.message, { action: { command: c.hint } }));
3480
+ } else if (a.kind === "unsupported") {
3481
+ blocked.push(own(m, "its repository URL is not supported", { action: { command: unsupportedRepoUrlError(m.slug, "").hint } }));
3482
+ } else {
3483
+ blocked.push(own(m, a.reason ?? "no repository yet", { why: "It will not run in this session." }));
3484
+ }
3485
+ } else if (o.am !== null && (!m.githubLinked || m.repoAccess === "none")) {
3486
+ const am = o.am;
3487
+ blocked.push(own(m, notClonedReason(m, am), { action: { command: !m.githubLinked ? `Link your GitHub account: ${am.accountUrl}` : askMaintainer(am, m).text } }));
3488
+ } else fixable.push(m);
3489
+ }
3490
+ if (fixable.length > 0) {
3491
+ const slugs = fixable.map((m) => m.slug);
3492
+ const deps2 = slugs.flatMap((s) => (o.consumers[s] ?? []).map((c) => `${c} depends on ${s}`));
3493
+ findings.push({
3494
+ code: "dev.modules-missing",
3495
+ severity: "warn",
3496
+ message: `${fixable.length} ${plural2(fixable.length, "module of this app is", "modules of this app are")} not cloned: ${slugs.join(", ")}`,
3497
+ why: deps2.length > 0 ? `${deps2.join("; ")}.` : `${plural2(fixable.length, "It", "They")} will not run in this session.`,
3498
+ modules: slugs,
3499
+ action: o.am ? { command: "qore sync", fix: "sync" } : { command: "qore sync" }
3500
+ });
3501
+ }
3502
+ return { findings: [...findings, ...blocked], fixable, reasons };
3503
+ }
3504
+ function strayClone(name, appName) {
3505
+ return { code: "dev.stray-clone", severity: "info", module: name, message: `${name} is no longer part of ${appName} \u2014 you can remove the directory.` };
3506
+ }
3507
+ function repoBehind(slug, s, o) {
3508
+ if (!s.ok || !s.upstream || !s.behind) return null;
3509
+ return {
3510
+ code: "dev.repo-behind",
3511
+ severity: "warn",
3512
+ module: slug,
3513
+ message: `${slug} is ${s.behind} ${plural2(s.behind, "commit", "commits")} behind ${s.upstreamRef ?? "its upstream"} (as of your last fetch).`,
3514
+ action: { command: o.workspace ? "qore sync --pull" : "git pull" }
3515
+ };
3516
+ }
3517
+ function servicesMissing(slug, env, recipe, o) {
3518
+ const db = envPort(env, "DB_PORT");
3519
+ if (db === null || recipe.services.length > 0 || !o.servicesFlag || o.configured) return null;
3520
+ return {
3521
+ code: "dev.services-missing",
3522
+ severity: "warn",
3523
+ module: slug,
3524
+ message: `The portal gives ${slug} a database (DB_PORT=${db}), but ${slug} has no db:up script \u2014 no database will be started.`,
3525
+ why: "This repository is probably older than its template.",
3526
+ action: { command: 'add a "db:up" script (see the current app template), or set dev.services in .qore.json' }
3527
+ };
3528
+ }
3529
+ function providerDown(o) {
3530
+ const single = o.providers.length === 1 && o.keys.length === 1;
3531
+ return {
3532
+ code: "dev.provider-down",
3533
+ severity: "warn",
3534
+ module: o.slug,
3535
+ modules: o.providers,
3536
+ message: single ? `${o.providers[0]} is not running \u2014 ${o.keys[0]} points at :${o.ports.get(o.keys[0])}, where nothing listens.` : `${o.keys.join(", ")} ${plural2(o.keys.length, "points", "point")} at ports nothing listens on (depends on: ${o.providers.join(", ")}).`,
3537
+ why: single ? `${o.slug} will start, but calls to ${o.providers[0]} will fail. Qore does not fall back to staging.` : `${o.slug} will start, but calls through ${o.keys.join(", ")} will fail. Qore does not fall back to staging.`,
3538
+ action: { command: o.fix }
3539
+ };
3074
3540
  }
3075
- async function waitForDocker(deps2, o) {
3076
- const delay = o.delay ?? abortableDelay(o.signal);
3077
- const now = o.now ?? Date.now;
3078
- const deadline = now() + o.timeoutMs;
3079
- for (; ; ) {
3080
- await delay(Math.min(o.pollMs, Math.max(0, deadline - now())));
3081
- if (o.signal.aborted) throw new CancelledError();
3082
- const remaining = deadline - now();
3083
- if (remaining <= 0) return false;
3084
- const poll = AbortSignal.any([o.signal, AbortSignal.timeout(remaining)]);
3085
- let res;
3086
- try {
3087
- res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: o.cwd, env: o.env ?? deps2.env, onLine: () => {
3088
- }, signal: poll });
3089
- } catch (e) {
3090
- if (e instanceof CancelledError && !o.signal.aborted) return false;
3091
- throw e;
3092
- }
3093
- if (classifyDockerInfo(res, deps2.platform) === null) return true;
3541
+
3542
+ // src/core/dev/project.ts
3543
+ import { access, readFile as readFile7 } from "fs/promises";
3544
+ import { join as join18 } from "path";
3545
+ var LEGACY_DEV_SCRIPTS = ["node --env-file=.env --import ./dist/instrumentation.js dist/index.js"];
3546
+ var LOCKFILES = [["pnpm-lock.yaml", "pnpm"], ["package-lock.json", "npm"], ["yarn.lock", "yarn"]];
3547
+ var exists = (p) => access(p).then(() => true, () => false);
3548
+ var unsupported = (message, hint, details) => new QoreError("QORE_DEV_UNSUPPORTED", message, { hint, details });
3549
+ async function readPackageJson(root) {
3550
+ let raw;
3551
+ try {
3552
+ raw = await readFile7(join18(root, "package.json"), "utf8");
3553
+ } catch {
3554
+ throw unsupported("This repository has no package.json", "qore dev starts Node repositories through their package.json scripts.");
3555
+ }
3556
+ try {
3557
+ return JSON.parse(raw);
3558
+ } catch {
3559
+ throw unsupported("package.json is not valid JSON", "Fix package.json, then run qore dev again.");
3094
3560
  }
3095
3561
  }
3096
- var MAX_PARENT_HOPS = 10;
3097
- var AGE_SLACK_S = 5;
3098
- async function leftoverOwnsPort(deps2, run4, port) {
3099
- if (deps2.platform === "win32" || !deps2.launch.signal(run4.qorePid, 0)) return false;
3100
- const { ageSeconds } = await deps2.ports.ownerDetail(run4.qorePid);
3101
- const recordAge = (deps2.now().getTime() - Date.parse(run4.startedAt)) / 1e3;
3102
- if (ageSeconds === null || !Number.isFinite(recordAge) || ageSeconds < recordAge - AGE_SLACK_S) return false;
3103
- const ours = /* @__PURE__ */ new Set([run4.qorePid, ...run4.children.map((c) => c.pid).filter((p) => p !== null)]);
3104
- let pid = (await deps2.ports.owner(port))?.pid ?? null;
3105
- for (let hop = 0; pid !== null && pid > 1 && hop <= MAX_PARENT_HOPS; hop++) {
3106
- if (ours.has(pid)) return true;
3107
- pid = await deps2.ports.parentPid(pid);
3562
+ async function detectPackageManager(root, pkg) {
3563
+ if (typeof pkg.packageManager === "string") {
3564
+ const m = /^(pnpm|npm|yarn)@(.+)$/.exec(pkg.packageManager.trim());
3565
+ if (!m) throw unsupported(`packageManager "${pkg.packageManager}" is not pnpm, npm or yarn`, "qore dev supports pnpm, npm and yarn.");
3566
+ return { name: m[1], version: m[2].split("+")[0] };
3108
3567
  }
3109
- return false;
3568
+ for (const [file, name] of LOCKFILES) if (await exists(join18(root, file))) return { name, version: null };
3569
+ throw unsupported("No package manager declared and no lockfile found", 'Add "packageManager": "pnpm@<version>" to package.json.');
3110
3570
  }
3111
-
3112
- // src/core/dev/providers.ts
3113
- var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
3114
- function connPorts(env) {
3115
- const out = [];
3116
- for (const key of Object.keys(env).sort()) {
3117
- const m = /^CONN_(.+)_URL$/.exec(key);
3118
- if (!m) continue;
3119
- let url;
3120
- try {
3121
- url = new URL(env[key].trim().replace(/^["']|["']$/g, ""));
3122
- } catch {
3123
- continue;
3571
+ function planRecipe(o) {
3572
+ const scripts = o.pkg.scripts ?? {};
3573
+ const script = o.cfg.dev?.script ?? "dev";
3574
+ const found = scripts[script];
3575
+ if (found === void 0) {
3576
+ throw unsupported(`${o.slug} has no "${script}" script in package.json`, 'Add a "dev" script, or set dev.script in .qore.json.', { script });
3577
+ }
3578
+ if (LEGACY_DEV_SCRIPTS.includes(found.trim())) {
3579
+ throw unsupported(
3580
+ `${o.slug}'s dev script is the old backend one, which needs a build and a .env first`,
3581
+ 'Update it to the current template: "dev": "node scripts/dev.mjs" plus scripts/dev.mjs.',
3582
+ { script, found }
3583
+ );
3584
+ }
3585
+ const configured = o.cfg.dev?.services;
3586
+ for (const s of configured ?? []) {
3587
+ if (scripts[s] === void 0) {
3588
+ throw new QoreError("QORE_CONTEXT_INVALID", `.qore.json: dev.services names "${s}", which is not a script in package.json`, {
3589
+ hint: "Fix dev.services in .qore.json."
3590
+ });
3124
3591
  }
3125
- const port = Number(url.port);
3126
- if (port > 0 && LOCAL_HOSTS.has(url.hostname)) out.push({ key, alias: m[1], port });
3127
3592
  }
3128
- return out;
3129
- }
3130
- function deadAliases(env, listening) {
3131
- return connPorts(env).filter((c) => !listening.has(c.port)).map((c) => c.key);
3132
- }
3133
- function hintContext(recipe, pkg, env) {
3134
- const { cmd, args } = installArgs(recipe.pm);
3135
- const ports = /* @__PURE__ */ new Map();
3136
- for (const c of connPorts(env)) ports.set(c.port, { kind: "provider", key: c.key });
3137
- const db = envPort(env, "DB_PORT");
3138
- if (db !== null) ports.set(db, { kind: "database" });
3593
+ const services = !o.flags.services ? [] : configured ?? (scripts["db:up"] !== void 0 ? ["db:up"] : []);
3139
3594
  return {
3140
- installCommand: [cmd, ...args].join(" "),
3141
- runCommand: (script) => {
3142
- if (pkg.scripts?.[script] === void 0) return null;
3143
- const r = runArgs(recipe.pm, script);
3144
- return [r.cmd, ...r.args].join(" ");
3145
- },
3146
- ports
3595
+ root: o.root,
3596
+ pm: o.pm,
3597
+ script,
3598
+ services,
3599
+ install: (o.cfg.dev?.install ?? true) && o.flags.install,
3600
+ dockerServices: services.some((s) => /\bdocker\b/.test(scripts[s] ?? ""))
3147
3601
  };
3148
3602
  }
3603
+ function runArgs(pm, script) {
3604
+ return { cmd: pm.name, args: ["run", script] };
3605
+ }
3606
+ function installArgs(pm) {
3607
+ return { cmd: pm.name, args: ["install"] };
3608
+ }
3149
3609
 
3150
- // src/core/dev/runFile.ts
3151
- import { unlink as unlink2 } from "fs/promises";
3152
- import { join as join17 } from "path";
3153
- async function runFilePath(deps2, root) {
3154
- const dir2 = join17(deps2.config.dir, "run");
3155
- const name = `${await stateKey(deps2, root)}.json`;
3156
- return { dir: dir2, name, path: join17(dir2, name) };
3610
+ // src/core/dev/install.ts
3611
+ import { stat as stat5 } from "fs/promises";
3612
+ import { join as join19 } from "path";
3613
+ var MARKERS = {
3614
+ pnpm: [".modules.yaml"],
3615
+ npm: [".package-lock.json"],
3616
+ yarn: [".yarn-state.yml", ".yarn-integrity"]
3617
+ };
3618
+ var LOCK2 = { pnpm: "pnpm-lock.yaml", npm: "package-lock.json", yarn: "yarn.lock" };
3619
+ var mtime = (p) => stat5(p).then((s) => s.mtimeMs, () => null);
3620
+ async function needsInstall(root, pm) {
3621
+ if (await mtime(join19(root, "node_modules")) === null) return true;
3622
+ let marker = null;
3623
+ for (const m of MARKERS[pm.name]) marker ??= await mtime(join19(root, "node_modules", m));
3624
+ if (marker === null) return true;
3625
+ const lock = await mtime(join19(root, LOCK2[pm.name]));
3626
+ return lock !== null && lock > marker;
3157
3627
  }
3158
- async function writeRunFile(deps2, root, rec) {
3159
- const { dir: dir2, name } = await runFilePath(deps2, root);
3160
- await writeJsonAtomic(dir2, name, rec);
3628
+
3629
+ // src/core/diagnostics/docker.ts
3630
+ function dockerInstallUrl(platform) {
3631
+ if (platform === "darwin") return "https://docs.docker.com/desktop/setup/install/mac-install/";
3632
+ if (platform === "win32") return "https://docs.docker.com/desktop/setup/install/windows-install/";
3633
+ return "https://docs.docker.com/engine/install/";
3161
3634
  }
3162
- async function removeRunFile(deps2, root) {
3163
- const { path } = await runFilePath(deps2, root);
3164
- await unlink2(path).catch(() => {
3165
- });
3635
+ function classifyDockerInfo(r, platform) {
3636
+ if (r.spawnError === "ENOENT") {
3637
+ return new QoreError("QORE_DOCKER_MISSING", "Docker is not installed", {
3638
+ hint: `Install Docker: ${dockerInstallUrl(platform)} \u2014 or run qore dev --no-services.`
3639
+ });
3640
+ }
3641
+ if (r.code === 0) return null;
3642
+ const hint = "Start Docker Desktop, then run qore dev again \u2014 or qore dev --no-services.";
3643
+ if (/Cannot connect to the Docker daemon|Is the docker daemon running|error during connect/i.test(r.tail.join("\n"))) {
3644
+ return new QoreError("QORE_DOCKER_NOT_RUNNING", "Docker is not running", { hint, details: { reason: "daemon" } });
3645
+ }
3646
+ return new QoreError("QORE_DOCKER_NOT_RUNNING", "Docker is not running", { hint, details: { stderr: r.tail } });
3166
3647
  }
3167
- async function leftoverRun(deps2, root, o = {}) {
3168
- const { path } = await runFilePath(deps2, root);
3169
- const rec = await readJson(path).catch(() => null);
3170
- if (!rec || rec.schemaVersion !== 1) return null;
3171
- if (deps2.launch.isAlive(rec.qorePid)) return rec;
3172
- if (o.prune !== false) await unlink2(path).catch(() => {
3173
- });
3174
- return null;
3648
+ function classifyComposeVersion(r) {
3649
+ if (r.code === 0) return null;
3650
+ const hint = "Update Docker Desktop (or install the compose plugin). The legacy docker-compose v1 is not supported.";
3651
+ if (/unknown command: docker compose|'compose' is not a docker command/.test(r.tail.join("\n"))) {
3652
+ return new QoreError("QORE_DOCKER_COMPOSE_MISSING", "Docker Compose v2 is missing", { hint });
3653
+ }
3654
+ return new QoreError("QORE_DOCKER_COMPOSE_MISSING", "Docker Compose v2 is missing", { hint, details: { stderr: r.tail } });
3175
3655
  }
3176
3656
 
3177
- // src/cli/project.ts
3178
- import { readdir as readdir2, stat as stat4 } from "fs/promises";
3179
- import { join as join18 } from "path";
3657
+ // src/core/diagnostics/postgres.ts
3658
+ var INCOMPATIBLE = /database files are incompatible with server|Counter to that, there appears to be PostgreSQL data in|in 18\+, these Docker images are configured to store database data/;
3659
+ function classifyComposeLogs(logs, o) {
3660
+ const text2 = logs.join("\n");
3661
+ if (!INCOMPATIBLE.test(text2)) return null;
3662
+ const details = { reason: "pg-data-version" };
3663
+ const m = text2.match(/initialized by PostgreSQL version (\d+).*this version (\d+)/);
3664
+ if (m) {
3665
+ details.found = m[1];
3666
+ details.expected = m[2];
3667
+ }
3668
+ return new QoreError("QORE_SERVICE_DATA_INCOMPATIBLE", `The local database volume of ${o.slug} was created by another Postgres version`, {
3669
+ hint: "pnpm db:reset recreates it \u2014 this deletes all local data in it. Qore never runs it for you.",
3670
+ details
3671
+ });
3672
+ }
3180
3673
 
3181
- // src/core/projects/match.ts
3182
- function readings(ref) {
3183
- const s = ref.split("/").map((x) => x.trim());
3184
- if (s.some((x) => x === "")) return [];
3185
- if (s.length === 1) return [{ app: s[0] }];
3186
- if (s.length === 2) return [{ app: s[0], module: s[1] }, { tenant: s[0], app: s[1] }];
3187
- if (s.length === 3) return [{ tenant: s[0], app: s[1], module: s[2] }];
3188
- return [];
3674
+ // src/core/diagnostics/ports.ts
3675
+ function parseDockerPs(stdout) {
3676
+ const line2 = stdout.split(/\r?\n/).find((l) => l.trim());
3677
+ if (!line2) return null;
3678
+ const [id, name, project, workingDir] = line2.split(" ");
3679
+ if (!id || !name) return null;
3680
+ return { id, name, project: project || null, workingDir: workingDir || null };
3189
3681
  }
3190
- function hit(p, w) {
3191
- if (p.appSlug !== w.app || w.tenant !== void 0 && p.tenantSlug !== w.tenant) return null;
3192
- if (p.kind === "module") return w.module === p.moduleSlug ? { project: p } : null;
3193
- return w.module ? { project: p, module: w.module } : { project: p };
3682
+ function parseLsofCwd(stdout) {
3683
+ const m = stdout.match(/^fcwd\nn(.+)$/m);
3684
+ return m ? m[1] : null;
3194
3685
  }
3195
- function matchProjects(projects, ref) {
3196
- const out = [];
3197
- for (const w of readings(ref)) {
3198
- for (const p of projects) {
3199
- const m = hit(p, w);
3200
- if (m) out.push(m);
3686
+ function parseEtime(s) {
3687
+ const m = s.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
3688
+ if (!m) return null;
3689
+ const days = m[1] ? Number(m[1]) : 0;
3690
+ const hours = m[2] ? Number(m[2]) : 0;
3691
+ const minutes = Number(m[3]);
3692
+ const seconds = Number(m[4]);
3693
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds;
3694
+ }
3695
+ function humanAge(seconds) {
3696
+ if (seconds < 60) return `${seconds} s`;
3697
+ if (seconds < 3600) return `${Math.round(seconds / 60)} min`;
3698
+ if (seconds < 86400) return `${Math.round(seconds / 3600)} h`;
3699
+ return `${Math.round(seconds / 86400)} d`;
3700
+ }
3701
+
3702
+ // src/core/diagnostics/pm.ts
3703
+ function pmVersionMismatch(pm, printed) {
3704
+ if (pm.version === null) return null;
3705
+ const lines = printed.split("\n");
3706
+ let found = null;
3707
+ for (let i = lines.length - 1; i >= 0; i--) {
3708
+ const line2 = lines[i].trim();
3709
+ if (/^\d+\.\d+\.\d+/.test(line2)) {
3710
+ found = line2;
3711
+ break;
3201
3712
  }
3202
3713
  }
3203
- return out;
3714
+ if (found === null) return null;
3715
+ if (found.split(".")[0] === pm.version.split(".")[0]) return null;
3716
+ return new QoreError("QORE_PACKAGE_MANAGER", `${pm.name} ${found} does not match the pinned ${pm.name}@${pm.version}`, {
3717
+ hint: `Install the pinned major: npm i -g ${pm.name}@${pm.version.split(".")[0]}. (pnpm \u2265 9.7 switches to the pinned version by itself.)`,
3718
+ details: { found, pinned: pm.version }
3719
+ });
3720
+ }
3721
+ function classifyInstall(tail, command, root) {
3722
+ const joined = tail.join("\n");
3723
+ if (/ERR_PNPM_OUTDATED_LOCKFILE/.test(joined)) {
3724
+ return new QoreError("QORE_INSTALL_FAILED", "The lockfile does not match package.json", {
3725
+ hint: `Someone committed one without the other. Run \`${command} --no-frozen-lockfile\` in ${root} and commit the lockfile.`,
3726
+ details: { reason: "outdated-lockfile", command, stderr: tail }
3727
+ });
3728
+ }
3729
+ return new QoreError("QORE_INSTALL_FAILED", `${command} failed`, {
3730
+ hint: `Run \`${command}\` in ${root} to see the full output.`,
3731
+ details: { command, stderr: tail }
3732
+ });
3204
3733
  }
3205
3734
 
3206
- // src/cli/project.ts
3207
- async function loggedInAs(ctx) {
3208
- const cfg = await ctx.deps.config.read();
3209
- const portal = resolvePortal(ctx.opts().portal, cfg.defaultProfile);
3210
- const p = cfg.profiles[portal.apiOrigin];
3211
- if (!p || !await new TokenStore(ctx.deps.keychain, profileHash(p.apiOrigin, p.issuer)).getRefresh()) return null;
3212
- return p.email ?? p.sub ?? portal.apiOrigin;
3735
+ // src/core/diagnostics/versions.ts
3736
+ import semver from "semver";
3737
+ function nodeSatisfies(found, range) {
3738
+ if (semver.validRange(range) === null) return null;
3739
+ return semver.satisfies(found, range);
3213
3740
  }
3214
- var isDir = async (p) => (await stat4(p).catch(() => null))?.isDirectory() ?? false;
3215
- var label2 = (p) => `${p.tenantSlug} / ${p.appSlug}${p.kind === "module" ? ` / ${p.moduleSlug}` : ""}${p.source === "registry" ? ` (${p.path})` : ""}`;
3216
- var candidate = (p) => p.kind === "module" ? { kind: "module", id: p.moduleId, slug: p.moduleSlug, tenant: p.tenantSlug, name: p.path } : { kind: "app", slug: p.appSlug, tenant: p.tenantSlug, name: p.path };
3217
- var withCwd = (ctx, cwd) => ({ ...ctx, deps: { ...ctx.deps, cwd } });
3218
- async function standingIn(ctx) {
3219
- if (await findWorkspace(ctx.deps.cwd, ctx.deps.home)) return "workspace";
3220
- const repo = await findRepoRoot(ctx.deps.cwd);
3221
- return repo !== null && !isInside(repo, ctx.deps.home) ? { repo } : null;
3741
+ function nodeVersionError(found, range, slug) {
3742
+ const min = semver.minVersion(range);
3743
+ const major = min ? min.major : range;
3744
+ return new QoreError("QORE_NODE_VERSION", `${slug} needs Node ${range}, found ${found}`, {
3745
+ hint: `nvm install ${major} / fnm use ${major}, then run qore dev again.`,
3746
+ details: { found, required: range }
3747
+ });
3222
3748
  }
3223
- async function isForeign(ctx, command) {
3224
- const g = ctx.opts();
3749
+
3750
+ // src/core/dev/preflight.ts
3751
+ var pmMajor = (v) => v ? v.split(".")[0] : "latest";
3752
+ var GRACE_MS = 1e4;
3753
+ var secs = (ms2) => `${Math.round(ms2 / 1e3)} s`;
3754
+ async function runTool(deps2, cmd, args, o) {
3755
+ if (o.signal.aborted) throw new CancelledError();
3756
+ const tail = [];
3757
+ const c = deps2.launch.launch(cmd, args, { cwd: o.cwd, env: { ...o.env, LC_ALL: "C" }, onLine: (l) => {
3758
+ tail.push(l.line);
3759
+ if (tail.length > 50) tail.shift();
3760
+ o.onLine(l);
3761
+ } });
3762
+ let kill;
3763
+ const onAbort = () => {
3764
+ c.stop("SIGTERM");
3765
+ kill = setTimeout(() => c.stop("SIGKILL"), GRACE_MS);
3766
+ };
3767
+ o.signal.addEventListener("abort", onAbort, { once: true });
3768
+ let exit;
3225
3769
  try {
3226
- await resolveModuleContext(ctx.deps, { portal: g.portal, command, onRequest: g.verbose ? (l) => ctx.io.err(l) : void 0 });
3227
- return false;
3228
- } catch (e) {
3229
- if (e instanceof CancelledError) throw e;
3230
- return isForeignRepo(e);
3770
+ exit = await c.exited;
3771
+ } finally {
3772
+ o.signal.removeEventListener("abort", onAbort);
3773
+ clearTimeout(kill);
3231
3774
  }
3775
+ if (o.signal.aborted) throw new CancelledError();
3776
+ return { code: exit.code, spawnError: exit.spawnError, tail };
3232
3777
  }
3233
- async function moduleDirs(wsPath) {
3234
- const out = [];
3235
- for (const d of await readdir2(wsPath, { withFileTypes: true }).catch(() => [])) {
3236
- if (d.isDirectory() && !d.name.startsWith(".") && await stat4(join18(wsPath, d.name, ".git")).catch(() => null)) out.push(d.name);
3778
+ async function checkPackageManager(deps2, r, onLine, signal) {
3779
+ const res = await runTool(deps2, r.pm.name, ["--version"], { cwd: r.root, env: deps2.env, onLine, signal });
3780
+ const hint = `Install it: npm i -g ${r.pm.name}@${pmMajor(r.pm.version)}`;
3781
+ if (res.spawnError === "ENOENT") {
3782
+ throw new QoreError("QORE_PACKAGE_MANAGER", `${r.pm.name} is not installed`, { hint, details: { packageManager: r.pm } });
3237
3783
  }
3238
- return out.sort();
3239
- }
3240
- async function pathFor(ctx, m, needModule) {
3241
- const p = m.project;
3242
- if (p.kind === "module") return p.path;
3243
- if (m.module) {
3244
- const dir2 = join18(p.path, m.module);
3245
- if (!await isDir(dir2)) {
3246
- throw new QoreError("QORE_PROJECT_NOT_FOUND", `${p.appSlug} has no checkout of module ${m.module} in ${p.path}`, {
3247
- hint: `Run \`qore sync\` in ${p.path} to clone the app's missing modules.`
3248
- });
3249
- }
3250
- return dir2;
3784
+ if (res.code !== 0) {
3785
+ throw new QoreError("QORE_PACKAGE_MANAGER", `${r.pm.name} is not installed`, { hint, details: { packageManager: r.pm, stderr: res.tail } });
3251
3786
  }
3252
- if (!needModule) return p.path;
3253
- const dirs = await moduleDirs(p.path);
3254
- if (dirs.length === 1) return join18(p.path, dirs[0]);
3255
- if (dirs.length === 0) {
3256
- throw new QoreError("QORE_PROJECT_NOT_FOUND", `${p.appSlug} has no module checkouts in ${p.path}`, {
3257
- hint: `Run \`qore sync\` in ${p.path}.`
3787
+ const mismatch = pmVersionMismatch(r.pm, res.tail.join("\n"));
3788
+ if (mismatch) throw mismatch;
3789
+ return res.tail.map((l) => l.trim()).findLast((l) => /^\d+\.\d+\.\d+/.test(l)) ?? null;
3790
+ }
3791
+ async function checkNode(deps2, r, pkg, slug, signal) {
3792
+ const range = pkg.engines?.node;
3793
+ if (!range) return null;
3794
+ const res = await runTool(deps2, "node", ["--version"], { cwd: r.root, env: deps2.env, onLine: () => {
3795
+ }, signal });
3796
+ if (res.spawnError === "ENOENT") {
3797
+ throw new QoreError("QORE_NODE_VERSION", "node is not on PATH", {
3798
+ hint: "Install Node (https://nodejs.org) or run nvm use, then reinstall qore.",
3799
+ details: { required: range }
3258
3800
  });
3259
3801
  }
3260
- if (!ctx.io.interactive) {
3261
- throw new QoreError("QORE_CONTEXT_REQUIRED", `${p.appSlug} has several modules \u2014 which one?`, {
3262
- hint: `Pass ${p.appSlug}/<module>.`,
3263
- candidates: dirs.map((slug) => ({ kind: "module", slug, tenant: p.tenantSlug, name: join18(p.path, slug) }))
3264
- });
3802
+ if (res.spawnError || res.code !== 0) {
3803
+ throw new QoreError("QORE_NODE_VERSION", "node --version failed", { details: { required: range, stderr: res.tail } });
3265
3804
  }
3266
- return join18(p.path, await ctx.io.select("Select a module", dirs.map((d) => ({ name: d, value: d }))));
3805
+ const found = (res.tail.at(-1) ?? "").trim().replace(/^v/, "");
3806
+ if (nodeSatisfies(found, range) === false) throw nodeVersionError(found, range, slug);
3807
+ return found;
3267
3808
  }
3268
- async function liveMatches(matches) {
3269
- const live = [];
3270
- for (const m of matches) {
3271
- if (m.project.kind === "workspace" && m.module && !await isDir(join18(m.project.path, m.module))) continue;
3272
- live.push(m);
3809
+ async function installIfNeeded(deps2, r, slug, onLine, report, signal) {
3810
+ if (!r.install) {
3811
+ report({ module: slug, step: "install", status: "skip", detail: "disabled" });
3812
+ return;
3273
3813
  }
3274
- return live.length > 0 ? live : matches;
3814
+ if (!await needsInstall(r.root, r.pm)) {
3815
+ report({ module: slug, step: "install", status: "skip", detail: "up to date" });
3816
+ return;
3817
+ }
3818
+ report({ module: slug, step: "install", status: "start" });
3819
+ const began = deps2.now().getTime();
3820
+ const { cmd, args } = installArgs(r.pm);
3821
+ const res = await runTool(deps2, cmd, args, { cwd: r.root, env: deps2.env, onLine, signal });
3822
+ if (res.code !== 0) {
3823
+ const command = [cmd, ...args].join(" ");
3824
+ const err = classifyInstall(res.tail, command, r.root);
3825
+ if (err.details?.reason === "outdated-lockfile") throw err;
3826
+ throw new QoreError("QORE_INSTALL_FAILED", `${command} failed in ${slug}`, { hint: err.hint, details: err.details });
3827
+ }
3828
+ report({ module: slug, step: "install", status: "done", detail: secs(deps2.now().getTime() - began) });
3275
3829
  }
3276
- async function choose(ctx, matches, what, explicit) {
3277
- if (explicit && matches.length === 1) return matches[0];
3278
- if (!ctx.io.interactive) {
3279
- throw new QoreError("QORE_CONTEXT_REQUIRED", what, {
3280
- hint: "Pass a more specific reference: tenant/app or app/module.",
3281
- candidates: matches.map((m) => candidate(m.project))
3830
+ async function portInUseError(deps2, o) {
3831
+ const owner = await deps2.ports.owner(o.port);
3832
+ const qoreRun = (o.runs ?? []).find((r) => r.children.some((c) => c.port === o.port) || owner !== null && (r.qorePid === owner.pid || r.children.some((c) => c.pid === owner.pid)));
3833
+ if (qoreRun) {
3834
+ return new QoreError("QORE_PORT_IN_USE", `A previous qore dev is still running (pid ${qoreRun.qorePid})`, {
3835
+ hint: `Stop it (Ctrl+C in its terminal, or kill ${qoreRun.qorePid}), then run qore dev again.`,
3836
+ details: { port: o.port, module: o.slug, key: o.key, owner: "qore", pid: qoreRun.qorePid }
3282
3837
  });
3283
3838
  }
3284
- const i = await ctx.io.select("Select a project", matches.map((m, idx) => ({
3285
- name: m.module ? `${label2(m.project)} / ${m.module}` : label2(m.project),
3286
- value: idx
3287
- })));
3288
- return matches[i];
3289
- }
3290
- async function projectCtx(ctx, o) {
3291
- const g = ctx.opts();
3292
- const cfg = await ctx.deps.config.read();
3293
- const portal = g.portal ? resolvePortal(g.portal, cfg.defaultProfile).apiOrigin : void 0;
3294
- if (o.ref) {
3295
- const matches = matchProjects(await knownProjects(ctx.deps, { portal }), o.ref);
3296
- if (matches.length === 0) {
3297
- const parts = o.ref.split("/");
3298
- const app = parts.length === 1 ? o.ref : parts.length === 3 ? parts[1] : "<app>";
3299
- throw new QoreError("QORE_PROJECT_NOT_FOUND", `No local project "${o.ref}"`, {
3300
- hint: cfg.projectsRoot ? `Run \`qore clone ${app}\`.` : `Run \`qore init\`, or \`qore clone ${app}\`.`
3301
- });
3839
+ if (owner) {
3840
+ if (/^(com\.docker\.backend|com\.docker\.vpnkit|docker-proxy|vpnkit|dockerd)$/.test(owner.command)) {
3841
+ const pub = await deps2.ports.dockerPublisher(o.port);
3842
+ if (pub) {
3843
+ return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is held by the Docker container ${pub.name} (needed by ${o.slug})`, {
3844
+ hint: `It belongs to compose project ${pub.project} in ${pub.workingDir} \u2014 typically another module's or tenant's database. Stop it there with docker compose down, or docker stop ${pub.name}.`,
3845
+ details: { port: o.port, module: o.slug, key: o.key, owner: "docker", ...pub }
3846
+ });
3847
+ }
3302
3848
  }
3303
- const m2 = await choose(ctx, await liveMatches(matches), `"${o.ref}" matches several projects`, true);
3304
- return relocate(ctx, await pathFor(ctx, m2, o.needModule));
3305
- }
3306
- if (o.module || o.app) return ctx;
3307
- const here = await standingIn(ctx);
3308
- if (here === "workspace") return ctx;
3309
- const known = await knownProjects(ctx.deps, { portal });
3310
- if (here && (known.length === 0 || known.some((p) => isInside(p.path, here.repo)) || !await isForeign(ctx, o.command ?? "project"))) return ctx;
3311
- if (known.length === 0) {
3312
- if (!cfg.projectsRoot) {
3313
- if (!await loggedInAs(ctx)) return ctx;
3314
- throw new QoreError("QORE_ROOT_NOT_SET", "Not inside a project, and qore knows no projects yet", {
3315
- hint: "Run `qore init` to choose a projects folder, or cd into a project."
3316
- });
3849
+ const detail2 = await deps2.ports.ownerDetail(owner.pid);
3850
+ const extras = [];
3851
+ if (detail2.ageSeconds !== null) extras.push(`started ${humanAge(detail2.ageSeconds)} ago`);
3852
+ if (detail2.cwd !== null) {
3853
+ const cwd = deps2.home && detail2.cwd.startsWith(deps2.home) ? `~${detail2.cwd.slice(deps2.home.length)}` : detail2.cwd;
3854
+ extras.push(`in ${cwd}`);
3317
3855
  }
3318
- throw new QoreError("QORE_PROJECT_NOT_FOUND", `Not inside a project, and ${cfg.projectsRoot} holds none yet`, {
3319
- hint: "Run `qore clone <app>`."
3856
+ const suffix = extras.length ? `, ${extras.join(" ")}` : "";
3857
+ return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is already in use (needed by ${o.slug})`, {
3858
+ hint: `Held by: ${owner.command} (pid ${owner.pid})${suffix}. Stop that process. Qore never moves a port: other modules and the local login rely on ${o.port}.`,
3859
+ details: {
3860
+ port: o.port,
3861
+ module: o.slug,
3862
+ key: o.key,
3863
+ owner: "process",
3864
+ pid: owner.pid,
3865
+ command: owner.command,
3866
+ ...detail2.cwd !== null ? { cwd: detail2.cwd } : {},
3867
+ ...detail2.ageSeconds !== null ? { ageSeconds: detail2.ageSeconds } : {}
3868
+ }
3320
3869
  });
3321
3870
  }
3322
- const what = here ? "This repository is not a Qore module \u2014 which project?" : "Not inside a project \u2014 which one?";
3323
- const m = await choose(ctx, known.map((project) => ({ project })), what, false);
3324
- return relocate(ctx, await pathFor(ctx, m, o.needModule));
3871
+ const findHint = deps2.platform === "win32" ? `netstat -ano | findstr :${o.port}` : `lsof -iTCP:${o.port} -sTCP:LISTEN`;
3872
+ return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is already in use (needed by ${o.slug})`, {
3873
+ hint: `Find it with: ${findHint}`,
3874
+ details: { port: o.port, module: o.slug, key: o.key }
3875
+ });
3325
3876
  }
3326
- function relocate(ctx, path) {
3327
- if (path === ctx.deps.cwd) return ctx;
3328
- if (!ctx.opts().json) ctx.io.err(`\u2192 in ${path}`);
3329
- return withCwd(ctx, path);
3877
+ async function composeFailure(deps2, r, slug, env, signal, message, command, stderr) {
3878
+ if (!r.dockerServices) {
3879
+ return new QoreError("QORE_SERVICE_UNHEALTHY", message, { details: { command, stderr } });
3880
+ }
3881
+ const logs = await runTool(deps2, "docker", ["compose", "logs", "--no-color", "--tail", "50"], { cwd: r.root, env, onLine: () => {
3882
+ }, signal });
3883
+ const dataErr = classifyComposeLogs(logs.tail, { slug, script: "db:up" });
3884
+ if (dataErr) return dataErr;
3885
+ return new QoreError("QORE_SERVICE_UNHEALTHY", message, {
3886
+ hint: `The last lines of docker compose logs are in details.logs; run \`docker compose logs\` in ${r.root} for all of them.`,
3887
+ details: { command, stderr, logs: logs.tail.slice(-20) }
3888
+ });
3330
3889
  }
3331
- async function rememberContext(ctx, mc, app) {
3332
- const tenantSlug = mc.module.tenant.slug;
3333
- if (mc.workspace && mc.workspace.appId === app.id) {
3334
- await remember(ctx.deps, {
3335
- kind: "workspace",
3336
- path: mc.workspace.root,
3337
- portal: mc.workspace.portal,
3338
- tenantSlug,
3339
- appId: app.id,
3340
- appSlug: app.slug
3341
- });
3890
+ async function startServices(deps2, r, slug, env, dbPort2, waitMs, signal, onLine, report) {
3891
+ if (r.services.length === 0) {
3892
+ report({ module: slug, step: "services", status: "skip" });
3342
3893
  return;
3343
3894
  }
3344
- if (mc.repoRoot) {
3345
- await remember(ctx.deps, {
3346
- kind: "module",
3347
- path: mc.repoRoot,
3348
- portal: mc.portal.apiOrigin,
3349
- tenantSlug,
3350
- appId: app.id,
3351
- appSlug: app.slug,
3352
- moduleId: mc.module.id,
3353
- moduleSlug: mc.module.slug
3354
- });
3895
+ report({ module: slug, step: "services", status: "start", ...dbPort2 !== null ? { detail: `:${dbPort2}` } : {} });
3896
+ const began = deps2.now().getTime();
3897
+ if (r.dockerServices) {
3898
+ const res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: r.root, env, onLine, signal });
3899
+ const dockerErr = classifyDockerInfo(res, deps2.platform);
3900
+ if (dockerErr) throw dockerErr;
3901
+ const compose = await runTool(deps2, "docker", ["compose", "version"], { cwd: r.root, env, onLine: () => {
3902
+ }, signal });
3903
+ const composeErr = classifyComposeVersion(compose);
3904
+ if (composeErr) throw composeErr;
3905
+ }
3906
+ for (const script of r.services) {
3907
+ const { cmd, args } = runArgs(r.pm, script);
3908
+ const res = await runTool(deps2, cmd, args, { cwd: r.root, env, onLine, signal });
3909
+ if (res.code !== 0) {
3910
+ if (dbPort2 !== null && /port is already allocated|address already in use/i.test(res.tail.join("\n"))) {
3911
+ throw await portInUseError(deps2, { slug, port: dbPort2, key: "DB_PORT" });
3912
+ }
3913
+ throw await composeFailure(deps2, r, slug, env, signal, `${script} failed in ${slug}`, `${cmd} ${args.join(" ")}`, res.tail);
3914
+ }
3915
+ }
3916
+ if (dbPort2 !== null) {
3917
+ const ok2 = await deps2.ports.waitFor(dbPort2, waitMs, signal);
3918
+ if (signal.aborted) throw new CancelledError();
3919
+ if (!ok2) {
3920
+ throw await composeFailure(
3921
+ deps2,
3922
+ r,
3923
+ slug,
3924
+ env,
3925
+ signal,
3926
+ `The database for ${slug} did not accept connections on port ${dbPort2} within ${waitMs / 1e3} s`,
3927
+ void 0,
3928
+ []
3929
+ );
3930
+ }
3355
3931
  }
3932
+ const took = secs(deps2.now().getTime() - began);
3933
+ report({ module: slug, step: "services", status: "done", detail: dbPort2 !== null ? `:${dbPort2} (${took})` : `(${took})` });
3356
3934
  }
3357
- async function rememberWorkspace(ctx, ws, app) {
3358
- if (ws.appId !== app.id) return;
3359
- await remember(ctx.deps, { kind: "workspace", path: ws.root, portal: ws.portal, tenantSlug: app.tenant.slug, appId: app.id, appSlug: app.slug });
3935
+ async function checkPort(deps2, o) {
3936
+ if (o.port === null) return;
3937
+ if (!await deps2.ports.inUse(o.port)) return;
3938
+ throw await portInUseError(deps2, { slug: o.slug, port: o.port, key: o.key, runs: o.runs });
3360
3939
  }
3361
-
3362
- // src/cli/context.ts
3363
- var allOfKind = (c, kind) => !!c && c.length > 0 && c.every((x) => x.kind === kind && typeof x.id === "string");
3364
- async function pick(ctx, err, message, choices) {
3365
- if (!ctx.io.interactive) throw err;
3366
- return ctx.io.select(message, choices);
3940
+ function abortableDelay(signal) {
3941
+ return (ms2) => new Promise((resolve7) => {
3942
+ const done = () => {
3943
+ clearTimeout(t);
3944
+ signal.removeEventListener("abort", done);
3945
+ resolve7();
3946
+ };
3947
+ const t = setTimeout(done, ms2);
3948
+ signal.addEventListener("abort", done, { once: true });
3949
+ });
3367
3950
  }
3368
- async function moduleContextFor(ctx, flags, command) {
3369
- const g = ctx.opts();
3370
- const onRequest = g.verbose ? (l) => ctx.io.err(l) : void 0;
3371
- const base = { portal: g.portal, module: flags.module, command, onRequest };
3372
- try {
3373
- return await resolveModuleContext(ctx.deps, base);
3374
- } catch (e) {
3375
- if (!(e instanceof QoreError) || e.code !== "QORE_CONTEXT_REQUIRED" || !ctx.io.interactive) throw e;
3376
- if (allOfKind(e.candidates, "module")) {
3377
- const choose2 = await pick(
3378
- ctx,
3379
- e,
3380
- "Select a module",
3381
- e.candidates.map((c) => ({ name: `${c.tenant ? `${c.tenant} / ` : ""}${c.slug} (${c.remote})`, value: c.id }))
3382
- );
3383
- return resolveModuleContext(ctx.deps, { ...base, choose: choose2 });
3384
- }
3385
- if (allOfKind(e.candidates, "tenant")) {
3386
- const tenant = await pick(ctx, e, "Select a tenant", e.candidates.map((c) => ({ name: c.name ?? c.slug, value: c.id })));
3387
- return resolveModuleContext(ctx.deps, { ...base, tenant });
3951
+ async function waitForDocker(deps2, o) {
3952
+ const delay = o.delay ?? abortableDelay(o.signal);
3953
+ const now = o.now ?? Date.now;
3954
+ const deadline = now() + o.timeoutMs;
3955
+ for (; ; ) {
3956
+ await delay(Math.min(o.pollMs, Math.max(0, deadline - now())));
3957
+ if (o.signal.aborted) throw new CancelledError();
3958
+ const remaining = deadline - now();
3959
+ if (remaining <= 0) return false;
3960
+ const poll = AbortSignal.any([o.signal, AbortSignal.timeout(remaining)]);
3961
+ let res;
3962
+ try {
3963
+ res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: o.cwd, env: o.env ?? deps2.env, onLine: () => {
3964
+ }, signal: poll });
3965
+ } catch (e) {
3966
+ if (e instanceof CancelledError && !o.signal.aborted) return false;
3967
+ throw e;
3388
3968
  }
3389
- throw e;
3969
+ if (classifyDockerInfo(res, deps2.platform) === null) return true;
3390
3970
  }
3391
3971
  }
3392
- async function appFor(ctx, mc, app) {
3393
- try {
3394
- return pickApp2(mc, app);
3395
- } catch (e) {
3396
- if (!(e instanceof QoreError) || e.code !== "QORE_APP_AMBIGUOUS" || !ctx.io.interactive) throw e;
3397
- const id = await pick(ctx, e, "Select an app", mc.module.apps.map((a) => ({ name: a.name, value: a.id })));
3398
- return pickApp2(mc, id);
3972
+ var MAX_PARENT_HOPS = 10;
3973
+ var AGE_SLACK_S = 5;
3974
+ async function leftoverOwnsPort(deps2, run4, port) {
3975
+ if (deps2.platform === "win32" || !deps2.launch.signal(run4.qorePid, 0)) return false;
3976
+ const { ageSeconds } = await deps2.ports.ownerDetail(run4.qorePid);
3977
+ const recordAge = (deps2.now().getTime() - Date.parse(run4.startedAt)) / 1e3;
3978
+ if (ageSeconds === null || !Number.isFinite(recordAge) || ageSeconds < recordAge - AGE_SLACK_S) return false;
3979
+ const ours = /* @__PURE__ */ new Set([run4.qorePid, ...run4.children.map((c) => c.pid).filter((p) => p !== null)]);
3980
+ let pid = (await deps2.ports.owner(port))?.pid ?? null;
3981
+ for (let hop = 0; pid !== null && pid > 1 && hop <= MAX_PARENT_HOPS; hop++) {
3982
+ if (ours.has(pid)) return true;
3983
+ pid = await deps2.ports.parentPid(pid);
3399
3984
  }
3985
+ return false;
3400
3986
  }
3401
- function sourceText(mc) {
3402
- if (mc.source.kind === "remote") return mc.source.remote;
3403
- if (mc.source.kind === "workspace") return WORKSPACE_FILE;
3404
- return mc.source.overrides ? `--module, overrides ${mc.source.overrides}` : "--module";
3987
+
3988
+ // src/core/dev/providers.ts
3989
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
3990
+ function connPorts(env) {
3991
+ const out = [];
3992
+ for (const key2 of Object.keys(env).sort()) {
3993
+ const m = /^CONN_(.+)_URL$/.exec(key2);
3994
+ if (!m) continue;
3995
+ let url;
3996
+ try {
3997
+ url = new URL(env[key2].trim().replace(/^["']|["']$/g, ""));
3998
+ } catch {
3999
+ continue;
4000
+ }
4001
+ const port = Number(url.port);
4002
+ if (port > 0 && LOCAL_HOSTS.has(url.hostname)) out.push({ key: key2, alias: m[1], port });
4003
+ }
4004
+ return out;
3405
4005
  }
3406
- function contextLine(mc, app) {
3407
- const parts = [mc.module.tenant.name, ...app ? [app.name] : [], mc.module.slug];
3408
- return `\u2192 ${parts.join(" / ")} (${sourceText(mc)})`;
4006
+ function deadAliases(env, listening) {
4007
+ return connPorts(env).filter((c) => !listening.has(c.port)).map((c) => c.key);
3409
4008
  }
3410
- function contextJson(mc, app) {
3411
- const { id, slug, name, type, access: access2 } = mc.module;
4009
+ function hintContext(recipe, pkg, env) {
4010
+ const { cmd, args } = installArgs(recipe.pm);
4011
+ const ports = /* @__PURE__ */ new Map();
4012
+ for (const c of connPorts(env)) ports.set(c.port, { kind: "provider", key: c.key });
4013
+ const db = envPort(env, "DB_PORT");
4014
+ if (db !== null) ports.set(db, { kind: "database" });
3412
4015
  return {
3413
- portal: mc.portal.builtin ?? mc.portal.apiOrigin,
3414
- tenant: mc.module.tenant,
3415
- app: app ?? null,
3416
- module: { id, slug, name, type, access: access2 },
3417
- source: mc.source.kind === "remote" ? mc.source.remote : mc.source.kind === "workspace" ? WORKSPACE_FILE : "--module",
3418
- ...mc.source.kind === "flag" && mc.source.overrides ? { overrides: mc.source.overrides } : {}
4016
+ installCommand: [cmd, ...args].join(" "),
4017
+ runCommand: (script) => {
4018
+ if (pkg.scripts?.[script] === void 0) return null;
4019
+ const r = runArgs(recipe.pm, script);
4020
+ return [r.cmd, ...r.args].join(" ");
4021
+ },
4022
+ ports
3419
4023
  };
3420
4024
  }
3421
4025
 
4026
+ // src/core/dev/runFile.ts
4027
+ import { unlink as unlink2 } from "fs/promises";
4028
+ import { join as join20 } from "path";
4029
+ async function runFilePath(deps2, root) {
4030
+ const dir2 = join20(deps2.config.dir, "run");
4031
+ const name = `${await stateKey(deps2, root)}.json`;
4032
+ return { dir: dir2, name, path: join20(dir2, name) };
4033
+ }
4034
+ async function writeRunFile(deps2, root, rec) {
4035
+ const { dir: dir2, name } = await runFilePath(deps2, root);
4036
+ await writeJsonAtomic(dir2, name, rec);
4037
+ }
4038
+ async function removeRunFile(deps2, root) {
4039
+ const { path: path2 } = await runFilePath(deps2, root);
4040
+ await unlink2(path2).catch(() => {
4041
+ });
4042
+ }
4043
+ async function leftoverRun(deps2, root, o = {}) {
4044
+ const { path: path2 } = await runFilePath(deps2, root);
4045
+ const rec = await readJson(path2).catch(() => null);
4046
+ if (!rec || rec.schemaVersion !== 1) return null;
4047
+ if (deps2.launch.isAlive(rec.qorePid)) return rec;
4048
+ if (o.prune !== false) await unlink2(path2).catch(() => {
4049
+ });
4050
+ return null;
4051
+ }
4052
+
3422
4053
  // src/cli/findings.ts
3423
4054
  function canOffer(ctx) {
3424
4055
  const g = ctx.opts();
@@ -3564,33 +4195,10 @@ async function inPreflight(signals, outer, fn) {
3564
4195
  }
3565
4196
  }
3566
4197
 
3567
- // src/cli/trustPrompt.ts
3568
- async function ensureTrusted(ctx, mc, app, o) {
3569
- const root = mc.repoRoot ?? ctx.deps.cwd;
3570
- if (!ctx.opts().json) ctx.io.err(contextLine(mc, app));
3571
- if (await isTrusted(ctx.deps, root, mc.module.id)) return;
3572
- if (o.yes) {
3573
- await trust(ctx.deps, root, mc.module.id);
3574
- return;
3575
- }
3576
- if (!ctx.io.interactive) {
3577
- throw new QoreError("QORE_CONTEXT_REQUIRED", `This repository is not trusted for ${mc.module.slug} yet`, {
3578
- hint: "Run interactively once, or pass --yes.",
3579
- details: { reason: "untrusted", repository: root }
3580
- });
3581
- }
3582
- if (mc.source.kind === "remote") {
3583
- const key = repoKey(mc.source.url);
3584
- ctx.io.err(` from remote ${mc.source.remote}: ${key ?? "(unparsable remote URL)"}`);
3585
- }
3586
- if (!await ctx.io.confirm("Pull local secrets for this module into this repository?")) throw new CancelledError();
3587
- await trust(ctx.deps, root, mc.module.id);
3588
- }
3589
-
3590
4198
  // src/cli/commands/devWorkspace.ts
3591
4199
  import { randomBytes as randomBytes5 } from "crypto";
3592
4200
  import { realpath as realpath7 } from "fs/promises";
3593
- import { join as join20, relative as relative6 } from "path";
4201
+ import { join as join22, relative as relative6 } from "path";
3594
4202
 
3595
4203
  // src/core/dev/order.ts
3596
4204
  function selectSkips(modules, sel) {
@@ -3747,15 +4355,15 @@ function supervise(o) {
3747
4355
  });
3748
4356
  const readiness = /* @__PURE__ */ new Map();
3749
4357
  for (const m of o.modules) {
3750
- let resolve6;
4358
+ let resolve7;
3751
4359
  let settled = false;
3752
4360
  const promise = new Promise((r) => {
3753
- resolve6 = r;
4361
+ resolve7 = r;
3754
4362
  });
3755
4363
  readiness.set(m.slug, { promise, settle: (r) => {
3756
4364
  if (settled) return false;
3757
4365
  settled = true;
3758
- resolve6(r);
4366
+ resolve7(r);
3759
4367
  return true;
3760
4368
  } });
3761
4369
  }
@@ -3847,7 +4455,7 @@ function supervise(o) {
3847
4455
 
3848
4456
  // src/core/workspace/stray.ts
3849
4457
  import { lstat as lstat2, readdir as readdir3 } from "fs/promises";
3850
- import { join as join19 } from "path";
4458
+ import { join as join21 } from "path";
3851
4459
  async function strayRepos(root, slugs) {
3852
4460
  let entries;
3853
4461
  try {
@@ -3859,7 +4467,7 @@ async function strayRepos(root, slugs) {
3859
4467
  for (const name of entries) {
3860
4468
  if (slugs.has(name)) continue;
3861
4469
  try {
3862
- await lstat2(join19(root, name, ".git"));
4470
+ await lstat2(join21(root, name, ".git"));
3863
4471
  names.push(name);
3864
4472
  } catch {
3865
4473
  }
@@ -3930,12 +4538,12 @@ function registerRun(program, ctx) {
3930
4538
 
3931
4539
  // src/cli/commands/devWorkspace.ts
3932
4540
  var INSTALL_PARALLELISM = 4;
3933
- var str = (v) => typeof v === "string" && v.length > 0;
4541
+ var str2 = (v) => typeof v === "string" && v.length > 0;
3934
4542
  var slugList = (raw) => raw?.split(",").map((s) => s.trim()).filter(Boolean);
3935
4543
  function usableLast(last, appId) {
3936
4544
  if (!last || last.kind !== "workspace") return false;
3937
4545
  const a = last.app;
3938
- return str(last.savedAt) && !!a && a.id === appId && str(a.slug) && str(a.name) && !!a.tenant && str(a.tenant.name) && Array.isArray(last.modules) && last.modules.every((m) => !!m && str(m.id) && str(m.slug) && str(m.type) && str(m.access) && Array.isArray(m.dependsOn));
4546
+ return str2(last.savedAt) && !!a && a.id === appId && str2(a.slug) && str2(a.name) && !!a.tenant && str2(a.tenant.name) && Array.isArray(last.modules) && last.modules.every((m) => !!m && str2(m.id) && str2(m.slug) && str2(m.type) && str2(m.access) && Array.isArray(m.dependsOn));
3939
4547
  }
3940
4548
  async function workspaceContext(ctx, ws) {
3941
4549
  const { deps: deps2 } = ctx;
@@ -4021,7 +4629,7 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
4021
4629
  continue;
4022
4630
  }
4023
4631
  try {
4024
- dirs[m.slug] = await deps2.git.inspect(join20(ws.root, m.slug));
4632
+ dirs[m.slug] = await deps2.git.inspect(join22(ws.root, m.slug));
4025
4633
  safe.push(m);
4026
4634
  } catch (e) {
4027
4635
  if (!(e instanceof QoreError) || e.code === "QORE_GIT_UNAVAILABLE") throw e;
@@ -4058,7 +4666,7 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
4058
4666
  const results = await applyActions(deps2, { portal: online.portal, app: am, actions, protocol: cloneProtocol(dirs), onResult: () => {
4059
4667
  } });
4060
4668
  renderResults(io2, results, false);
4061
- for (const r of results) if (r.status === "cloned") dirs[r.slug] = await deps2.git.inspect(join20(ws.root, r.slug));
4669
+ for (const r of results) if (r.status === "cloned") dirs[r.slug] = await deps2.git.inspect(join22(ws.root, r.slug));
4062
4670
  ({ present, plan } = computePlan());
4063
4671
  }
4064
4672
  for (const name of await strayRepos(ws.root, new Set(am.modules.map((m) => m.slug)))) renderFinding(ctx, strayClone(name, am.app.name));
@@ -4239,7 +4847,7 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
4239
4847
  if (online && units.every((u) => !u.dev.offline)) {
4240
4848
  const envFile = (m) => {
4241
4849
  const u = units.find((x) => x.m.id === m.id);
4242
- return u ? relative6(rootReal, u.dev.envFile) : join20(m.slug, moduleDefaults(m.type).envFile);
4850
+ return u ? relative6(rootReal, u.dev.envFile) : join22(m.slug, moduleDefaults(m.type).envFile);
4243
4851
  };
4244
4852
  await saveLastStart(deps2, ws.root, {
4245
4853
  schemaVersion: 1,
@@ -4450,7 +5058,7 @@ function parseWaitTimeout(raw) {
4450
5058
  }
4451
5059
  return n;
4452
5060
  }
4453
- var str2 = (v) => typeof v === "string" && v.length > 0;
5061
+ var str3 = (v) => typeof v === "string" && v.length > 0;
4454
5062
  var SHELL_SAFE = /^[A-Za-z0-9_./,:@%+=-]*$/;
4455
5063
  var shellQuote = (s) => SHELL_SAFE.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
4456
5064
  function cdAndDev(home, dir2) {
@@ -4478,7 +5086,7 @@ async function providerFindings(ctx, root, mc, app, env) {
4478
5086
  let fix = `qore clone ${app.slug}`;
4479
5087
  if (ws && ws.appId === app.id) fix = cdAndDev(deps2.home, ws.root);
4480
5088
  else if (providers.length === 1) {
4481
- const sibling = join21(dirname7(root), providers[0].slug);
5089
+ const sibling = join23(dirname7(root), providers[0].slug);
4482
5090
  if ((await deps2.git.inspect(sibling)).isRepo) fix = cdAndDev(deps2.home, sibling);
4483
5091
  }
4484
5092
  return providerDown({
@@ -4496,7 +5104,7 @@ async function providerFindings(ctx, root, mc, app, env) {
4496
5104
  function usableLast2(last) {
4497
5105
  if (!last || last.kind !== "module") return false;
4498
5106
  const m = last.module;
4499
- return str2(last.savedAt) && !!m && str2(m.id) && str2(m.slug) && str2(m.type) && !!m.tenant && str2(m.tenant.name) && !!last.app && str2(last.app.id) && str2(last.app.slug) && str2(last.app.name);
5107
+ return str3(last.savedAt) && !!m && str3(m.id) && str3(m.slug) && str3(m.type) && !!m.tenant && str3(m.tenant.name) && !!last.app && str3(last.app.id) && str3(last.app.slug) && str3(last.app.name);
4500
5108
  }
4501
5109
  async function devContext(ctx, f, root) {
4502
5110
  let mc;
@@ -4672,8 +5280,8 @@ function registerDebugTelemetry(program, ctx) {
4672
5280
  import pc3 from "picocolors";
4673
5281
 
4674
5282
  // src/core/doctor/checks.ts
4675
- import { realpath as realpath9, stat as stat5 } from "fs/promises";
4676
- import { basename as basename3, join as join22 } from "path";
5283
+ import { realpath as realpath9, stat as stat6 } from "fs/promises";
5284
+ import { basename as basename4, join as join24 } from "path";
4677
5285
 
4678
5286
  // src/core/diagnostics/ssh.ts
4679
5287
  var SSH_TEST_ARGS = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "git@github.com"];
@@ -4816,9 +5424,9 @@ function jwtExp(token) {
4816
5424
  }
4817
5425
  function validDays(rt, now) {
4818
5426
  const exp = jwtExp(rt.rt);
4819
- const end = exp !== null ? exp * 1e3 : Date.parse(rt.obtainedAt) + IDLE_DAYS * DAY_MS;
4820
- if (Number.isNaN(end)) return null;
4821
- return Math.max(0, Math.floor((end - now.getTime()) / DAY_MS));
5427
+ const end2 = exp !== null ? exp * 1e3 : Date.parse(rt.obtainedAt) + IDLE_DAYS * DAY_MS;
5428
+ if (Number.isNaN(end2)) return null;
5429
+ return Math.max(0, Math.floor((end2 - now.getTime()) / DAY_MS));
4822
5430
  }
4823
5431
  async function checkSession(deps2, portal, onRequest, sessions, selected, signal) {
4824
5432
  try {
@@ -4874,7 +5482,7 @@ async function moduleBody(deps2, m, runs, signal) {
4874
5482
  if (text2 === null) return { status: "warn", label: "port unknown (run qore env pull)" };
4875
5483
  env = Object.fromEntries(parseEnv(text2));
4876
5484
  const port = envPort(env, "PORT");
4877
- if (port === null) return { status: "warn", label: `port unknown (no PORT in ${basename3(target)})` };
5485
+ if (port === null) return { status: "warn", label: `port unknown (no PORT in ${basename4(target)})` };
4878
5486
  if (!await deps2.ports.inUse(port)) return { status: "ok", label: `port ${port} free` };
4879
5487
  const e = await portInUseError(deps2, { slug: m.slug, port, key: "PORT", runs: await runs() });
4880
5488
  return { status: "fail", label: `port ${port} in use${ownerText(e)}`, error: e };
@@ -4893,13 +5501,13 @@ async function moduleBody(deps2, m, runs, signal) {
4893
5501
  flags: { install: true, services: true }
4894
5502
  });
4895
5503
  if (recipe.services.length === 0) return { status: "ok", label: "db none" };
4896
- const dbPort = env ? envPort(env, "DB_PORT") : null;
4897
- if (dbPort === null) return { status: "warn", label: "db port unknown (run qore env pull)" };
4898
- if (!await deps2.ports.inUse(dbPort)) return { status: "ok", label: `db:up (DB_PORT ${dbPort} free)` };
4899
- const pub = await deps2.ports.dockerPublisher(dbPort);
5504
+ const dbPort2 = env ? envPort(env, "DB_PORT") : null;
5505
+ if (dbPort2 === null) return { status: "warn", label: "db port unknown (run qore env pull)" };
5506
+ if (!await deps2.ports.inUse(dbPort2)) return { status: "ok", label: `db:up (DB_PORT ${dbPort2} free)` };
5507
+ const pub = await deps2.ports.dockerPublisher(dbPort2);
4900
5508
  if (pub?.workingDir && await sameDir(pub.workingDir, m.root)) return { status: "ok", label: "db running" };
4901
- const e = await portInUseError(deps2, { slug: m.slug, port: dbPort, key: "DB_PORT" });
4902
- return { status: "fail", label: `DB_PORT ${dbPort} in use${ownerText(e)}`, error: e };
5509
+ const e = await portInUseError(deps2, { slug: m.slug, port: dbPort2, key: "DB_PORT" });
5510
+ return { status: "fail", label: `DB_PORT ${dbPort2} in use${ownerText(e)}`, error: e };
4903
5511
  });
4904
5512
  const status2 = parts.reduce((w, p) => RANK[p.status] > RANK[w] ? p.status : w, "ok");
4905
5513
  const shown = parts.filter((p) => p.status !== "ok");
@@ -4956,7 +5564,7 @@ async function resolveRepoModule(deps2, session, portal, meta, root) {
4956
5564
  async function checkProjectsRoot(cfg) {
4957
5565
  const root = cfg?.projectsRoot;
4958
5566
  if (!root) return ok("projects folder not set (optional \u2014 qore init)");
4959
- const s = await stat5(root).catch(() => null);
5567
+ const s = await stat6(root).catch(() => null);
4960
5568
  if (!s?.isDirectory()) return { status: "warn", label: `projects folder ${root} does not exist \u2014 qore init` };
4961
5569
  const e = await writableError(root);
4962
5570
  if (e) return { status: "fail", label: `projects folder ${root} is not writable (${e.code ?? e.message})` };
@@ -5059,7 +5667,7 @@ async function runDoctor(deps2, o = {}) {
5059
5667
  const repoRoot = wsRoot ? null : await findRepoRoot(deps2.cwd);
5060
5668
  if (wsRoot) {
5061
5669
  context.kind = "workspace";
5062
- context.label = basename3(wsRoot.root);
5670
+ context.label = basename4(wsRoot.root);
5063
5671
  if (!session) {
5064
5672
  await notChecked();
5065
5673
  return { results, context };
@@ -5079,7 +5687,7 @@ async function runDoctor(deps2, o = {}) {
5079
5687
  for (const m of am.modules) await workspaceModule(deps2, wsRoot, am, m, runs, signal, push, perModule);
5080
5688
  } else if (repoRoot) {
5081
5689
  context.kind = "module";
5082
- context.label = basename3(repoRoot);
5690
+ context.label = basename4(repoRoot);
5083
5691
  if (!session) {
5084
5692
  await notChecked();
5085
5693
  return { results, context };
@@ -5110,7 +5718,7 @@ async function workspaceModule(deps2, ws, am, m, runs, signal, push, perModule)
5110
5718
  await push(`module:${m.slug}`, "workspace", () => ({ status: "warn", label: "not a safe directory name \u2014 skipped" }), m.slug);
5111
5719
  return;
5112
5720
  }
5113
- const mod = { root: join22(ws.root, m.slug), slug: m.slug, type: m.type, id: m.id, access: m.access };
5721
+ const mod = { root: join24(ws.root, m.slug), slug: m.slug, type: m.type, id: m.id, access: m.access };
5114
5722
  let prepared = null;
5115
5723
  await push(`module:${m.slug}`, "workspace", async () => {
5116
5724
  const info = await deps2.git.inspect(mod.root);
@@ -5142,7 +5750,7 @@ function registerDoctor(program, ctx) {
5142
5750
  // src/cli/commands/env.ts
5143
5751
  import { randomBytes as randomBytes7 } from "crypto";
5144
5752
  import { chmod as chmod3, realpath as realpath10 } from "fs/promises";
5145
- import { dirname as dirname8, join as join23, relative as relative8 } from "path";
5753
+ import { dirname as dirname8, join as join25, relative as relative8 } from "path";
5146
5754
  var maskLen = (v) => v === void 0 ? "(unset)" : `\u2022\u2022\u2022\u2022 (${v.length} chars)`;
5147
5755
  function registerEnv(program, ctx) {
5148
5756
  const env = program.command("env").description("Local environment");
@@ -5161,7 +5769,7 @@ function registerEnv(program, ctx) {
5161
5769
  const target = f.target ? await resolveTarget(mc.repoRoot, f.target, deps2.cwd) : await resolveTarget(mc.repoRoot, cfg.envFile ?? defaults.envFile);
5162
5770
  await ensureTrusted(c, mc, app, { yes: f.yes });
5163
5771
  const existing = await readOrNull(target);
5164
- const example = existing === null ? await readOrNull(join23(dirname8(target), ".env.example")) : null;
5772
+ const example = existing === null ? await readOrNull(join25(dirname8(target), ".env.example")) : null;
5165
5773
  const local = await fetchLocalEnv(deps2, mc, app);
5166
5774
  const inputs = {
5167
5775
  existing,
@@ -5177,13 +5785,13 @@ function registerEnv(program, ctx) {
5177
5785
  const current = parseEnv(existing ?? example ?? "");
5178
5786
  const choices = {};
5179
5787
  let bulk;
5180
- for (const key of plan.conflicts) {
5788
+ for (const key2 of plan.conflicts) {
5181
5789
  if (bulk) {
5182
- choices[key] = bulk;
5790
+ choices[key2] = bulk;
5183
5791
  continue;
5184
5792
  }
5185
5793
  const pick2 = await io2.select(
5186
- `${key} differs (local ${maskLen(current.get(key))}, portal ${maskLen(local.env[key])})`,
5794
+ `${key2} differs (local ${maskLen(current.get(key2))}, portal ${maskLen(local.env[key2])})`,
5187
5795
  [
5188
5796
  { name: "keep local", value: "keep" },
5189
5797
  { name: "use portal", value: "portal" },
@@ -5193,7 +5801,7 @@ function registerEnv(program, ctx) {
5193
5801
  );
5194
5802
  if (pick2 === "all") bulk = "use-portal";
5195
5803
  if (pick2 === "skip") bulk = "keep";
5196
- choices[key] = pick2 === "portal" || pick2 === "all" ? "use-portal" : "keep";
5804
+ choices[key2] = pick2 === "portal" || pick2 === "all" ? "use-portal" : "keep";
5197
5805
  }
5198
5806
  plan = planPull({ ...inputs, choices });
5199
5807
  }
@@ -6097,6 +6705,179 @@ function registerLogout(program, ctx) {
6097
6705
  });
6098
6706
  }
6099
6707
 
6708
+ // src/core/roles/api.ts
6709
+ var CAPABILITY = "module.local-roles";
6710
+ var path = (mc, app) => `/api/cli/v1/modules/${encodeURIComponent(mc.module.id)}/local-roles?app_id=${encodeURIComponent(app.id)}`;
6711
+ async function fetchLocalRoles(deps2, mc, app) {
6712
+ await ensureCapability(deps2, mc.portal.apiOrigin, CAPABILITY);
6713
+ return explained(mc, () => mc.session.api.get(path(mc, app)));
6714
+ }
6715
+ async function useLocalRoles(deps2, mc, app, roles, use) {
6716
+ await ensureCapability(deps2, mc.portal.apiOrigin, CAPABILITY);
6717
+ return explained(mc, () => mc.session.api.post(path(mc, app), { roles, use }));
6718
+ }
6719
+ async function explained(mc, call) {
6720
+ try {
6721
+ return await call();
6722
+ } catch (e) {
6723
+ if (!(e instanceof QoreError)) throw e;
6724
+ if (e.serverCode === "app.access.denied") {
6725
+ throw withDetails(
6726
+ e,
6727
+ { required: "write", have: mc.module.access },
6728
+ `You have ${mc.module.access} on ${mc.module.slug}; local roles need write. Ask a maintainer of the app's dev team for write access.`
6729
+ );
6730
+ }
6731
+ if (e.serverCode === "membership.permission.denied") {
6732
+ throw withDetails(e, {}, "Taking roles on this app needs write on one of its dev teams.");
6733
+ }
6734
+ if (e.serverCode === "auth.scope.insufficient") throw withDetails(e, {}, "Run `qore login` again.");
6735
+ if (e.serverCode === "app.no_platform_identity") {
6736
+ throw new QoreError("QORE_CONTEXT_UNSUPPORTED", "This app manages its own users; the portal hands out no roles for it", {
6737
+ hint: "Give yourself the role in the app's own user admin.",
6738
+ serverCode: e.serverCode,
6739
+ requestId: e.requestId
6740
+ });
6741
+ }
6742
+ throw e;
6743
+ }
6744
+ }
6745
+
6746
+ // src/core/roles/read.ts
6747
+ import { join as join26 } from "path";
6748
+
6749
+ // src/core/roles/extract.ts
6750
+ var KNOWN = { user: "User", admin: "App admin" };
6751
+ function defaultLabel(slug) {
6752
+ return KNOWN[slug] ?? slug.charAt(0).toUpperCase() + slug.slice(1);
6753
+ }
6754
+ async function extractRoles(source) {
6755
+ const { default: ts } = await import("typescript");
6756
+ const sf = ts.createSourceFile("permissions.ts", source, ts.ScriptTarget.Latest, true);
6757
+ let obj;
6758
+ const visit = (node) => {
6759
+ const first = ts.isCallExpression(node) ? node.arguments[0] : void 0;
6760
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "definePermissions" && first !== void 0 && ts.isObjectLiteralExpression(first)) {
6761
+ obj = first;
6762
+ }
6763
+ if (!obj) ts.forEachChild(node, visit);
6764
+ };
6765
+ visit(sf);
6766
+ if (!obj) return null;
6767
+ const slugs = [];
6768
+ for (const prop of obj.properties) {
6769
+ if (ts.isPropertyAssignment(prop) && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))) {
6770
+ slugs.push(prop.name.text);
6771
+ }
6772
+ }
6773
+ return slugs.map((slug) => ({ slug, label: defaultLabel(slug) }));
6774
+ }
6775
+
6776
+ // src/core/roles/read.ts
6777
+ var PERMISSIONS_FILE = "src/lib/permissions.ts";
6778
+ var SLUG = /^[a-z0-9][a-z0-9_-]*$/;
6779
+ var MAX_SLUG = 64;
6780
+ var MAX_ROLES = 100;
6781
+ async function readCodeRoles(repoRoot) {
6782
+ const source = await readOrNull(join26(repoRoot, PERMISSIONS_FILE));
6783
+ if (source === null) {
6784
+ throw new QoreError("QORE_CONTEXT_UNSUPPORTED", `No ${PERMISSIONS_FILE} in this module`, {
6785
+ hint: "App roles live in the app's frontend module; run this there."
6786
+ });
6787
+ }
6788
+ const roles = await extractRoles(source);
6789
+ if (!roles?.length) {
6790
+ throw new QoreError("QORE_CONTEXT_INVALID", `${PERMISSIONS_FILE} declares no roles in a definePermissions({ ... }) object`, {
6791
+ hint: "The portal reads only an object literal passed straight to definePermissions \u2014 no variable, no spread, no shorthand ({ admin, user })."
6792
+ });
6793
+ }
6794
+ const invalid4 = roles.filter((r) => !SLUG.test(r.slug) || r.slug.length > MAX_SLUG).map((r) => r.slug);
6795
+ if (invalid4.length) {
6796
+ throw new QoreError("QORE_CONTEXT_INVALID", `The portal refuses these role names: ${invalid4.join(", ")}`, {
6797
+ hint: `Use lowercase letters, digits, - and _, starting with a letter or digit, at most ${MAX_SLUG} characters.`,
6798
+ details: { invalid: invalid4 }
6799
+ });
6800
+ }
6801
+ const unique = [...new Map(roles.map((r) => [r.slug, r])).values()];
6802
+ if (unique.length > MAX_ROLES) {
6803
+ throw new QoreError("QORE_CONTEXT_INVALID", `The portal refuses more than ${MAX_ROLES} roles; ${PERMISSIONS_FILE} declares ${unique.length}`, {
6804
+ hint: "Merge roles that grant the same permissions.",
6805
+ details: { count: unique.length, max: MAX_ROLES }
6806
+ });
6807
+ }
6808
+ return unique;
6809
+ }
6810
+
6811
+ // src/cli/commands/roles.ts
6812
+ async function rolesContext(ctx, f, command) {
6813
+ const c = await projectCtx(ctx, { ref: f.project, module: f.module, app: f.app, needModule: true, command });
6814
+ const mc = await moduleContextFor(c, f, command);
6815
+ if (!mc.repoRoot) {
6816
+ throw new QoreError("QORE_CONTEXT_REQUIRED", `${command} reads the roles in a module repository`, {
6817
+ hint: "cd into the app's frontend module."
6818
+ });
6819
+ }
6820
+ const app = await appFor(c, mc, f.app);
6821
+ await rememberContext(c, mc, app);
6822
+ return { c, mc, app, code: await readCodeRoles(mc.repoRoot) };
6823
+ }
6824
+ function withFlags(cmd) {
6825
+ return cmd.option("--module <slug|id>", "the module (default: from this repository's git remote)").option("--app <slug|id>", "the app, when the module is part of several").option("--project <ref>", "the project, from anywhere (see qore init)");
6826
+ }
6827
+ function registerRoles(program, ctx) {
6828
+ const roles = program.command("roles").description("App roles on your own machine (the local environment)");
6829
+ withFlags(roles.command("list").description("Roles in your code, roles local knows, and yours there")).action(async (f) => {
6830
+ const { c, mc, app, code } = await rolesContext(ctx, f, "roles list");
6831
+ const local = await fetchLocalRoles(c.deps, mc, app);
6832
+ if (c.opts().json) {
6833
+ renderJson(c.io, {
6834
+ context: contextJson(mc, app),
6835
+ code: code.map((r) => r.slug),
6836
+ local: local.roles,
6837
+ direct: local.direct,
6838
+ effective: local.effective
6839
+ });
6840
+ return;
6841
+ }
6842
+ const known = new Set(local.roles.map((r) => r.slug));
6843
+ const fresh = code.filter((r) => !known.has(r.slug)).map((r) => r.slug);
6844
+ c.io.err(contextLine(mc, app));
6845
+ c.io.out(`In your code ${code.map((r) => r.slug).join(", ")}`);
6846
+ c.io.out(`Known on local ${[...known].join(", ") || "\u2014"}`);
6847
+ c.io.out(`Yours on local ${local.effective.join(", ") || "\u2014"}`);
6848
+ if (fresh.length) {
6849
+ c.io.out(`New in your code: ${fresh.join(", ")} \u2014 \`qore roles use <role...>\` announces them and makes them yours on local.`);
6850
+ }
6851
+ });
6852
+ withFlags(roles.command("use").description("Announce your code's roles for local and make exactly these yours there").argument("<role...>", "the roles you want on local")).action(async (wanted, f) => {
6853
+ const { c, mc, app, code } = await rolesContext(ctx, f, "roles use");
6854
+ const inCode = new Set(code.map((r) => r.slug));
6855
+ const unknown = wanted.filter((r) => !inCode.has(r));
6856
+ if (unknown.length) {
6857
+ throw new QoreError("QORE_CONTEXT_INVALID", `Not a role in ${PERMISSIONS_FILE}: ${unknown.join(", ")}`, {
6858
+ details: { unknown, known: [...inCode] }
6859
+ });
6860
+ }
6861
+ const before = await fetchLocalRoles(c.deps, mc, app);
6862
+ const res = await useLocalRoles(c.deps, mc, app, code, [...new Set(wanted)]);
6863
+ const changed = !sameSet(before.direct, res.direct);
6864
+ const viaGroups = res.effective.filter((r) => !res.direct.includes(r));
6865
+ if (c.opts().json) {
6866
+ renderJson(c.io, { context: contextJson(mc, app), added: res.added, direct: res.direct, effective: res.effective });
6867
+ return;
6868
+ }
6869
+ c.io.err(contextLine(mc, app));
6870
+ c.io.out(`\u2713 Your roles on local: ${res.direct.join(", ")}`);
6871
+ if (res.added.length) c.io.out(` New on local: ${res.added.join(", ")} \u2014 teammates can pick it in the Access tab too.`);
6872
+ if (viaGroups.length) c.io.err(`! You also hold ${viaGroups.join(", ")} on local through a group.`);
6873
+ if (changed) c.io.err("! Sign out and back in to your local app: your current session still has your old roles.");
6874
+ });
6875
+ }
6876
+ function sameSet(a, b) {
6877
+ const s = new Set(a);
6878
+ return s.size === new Set(b).size && b.every((x) => s.has(x));
6879
+ }
6880
+
6100
6881
  // src/cli/commands/status.ts
6101
6882
  var HEAD = ["ENV", "DEPLOY", "COMMIT", "DEPLOYED", "APP HEALTH"];
6102
6883
  var envRow = (e, now) => [e.name, e.paused ? "paused" : e.status, e.commit?.sha ?? "\u2013", ago(e.commit?.deployedAt ?? null, now), uptime(e.health)];
@@ -6171,7 +6952,7 @@ function registerStatus(program, ctx) {
6171
6952
  }
6172
6953
 
6173
6954
  // src/cli/commands/sync.ts
6174
- import { join as join24, relative as relative9 } from "path";
6955
+ import { join as join27, relative as relative9 } from "path";
6175
6956
  import { Option as Option2 } from "commander";
6176
6957
  function asFailure2(slug, e) {
6177
6958
  if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") {
@@ -6203,7 +6984,7 @@ function registerSync(program, ctx) {
6203
6984
  continue;
6204
6985
  }
6205
6986
  try {
6206
- dirs[m.slug] = await deps2.git.inspect(join24(ws.root, m.slug));
6987
+ dirs[m.slug] = await deps2.git.inspect(join27(ws.root, m.slug));
6207
6988
  modulesForPlan.push(m);
6208
6989
  } catch (e) {
6209
6990
  earlyFailures.push(asFailure2(m.slug, e));
@@ -6399,8 +7180,10 @@ var COMMANDS = [
6399
7180
  registerTraces,
6400
7181
  registerErrors,
6401
7182
  registerEnv,
7183
+ registerRoles,
6402
7184
  registerRun,
6403
7185
  registerDev,
7186
+ registerDb,
6404
7187
  registerClone,
6405
7188
  registerSync,
6406
7189
  registerVersion,
@@ -6486,11 +7269,11 @@ async function start(deps2, argv2, command, load, onScrub) {
6486
7269
  }
6487
7270
  function withDeadline(work, ms2, signal) {
6488
7271
  if (signal?.aborted) return Promise.resolve(false);
6489
- return new Promise((resolve6) => {
7272
+ return new Promise((resolve7) => {
6490
7273
  const done = (v) => {
6491
7274
  clearTimeout(timer);
6492
7275
  signal?.removeEventListener("abort", onAbort);
6493
- resolve6(v);
7276
+ resolve7(v);
6494
7277
  };
6495
7278
  const onAbort = () => done(false);
6496
7279
  const timer = setTimeout(() => done(false), ms2);
@@ -6541,8 +7324,8 @@ async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS)
6541
7324
  }
6542
7325
 
6543
7326
  // src/core/config.ts
6544
- import { readFile as readFile6 } from "fs/promises";
6545
- import { join as join25 } from "path";
7327
+ import { readFile as readFile8 } from "fs/promises";
7328
+ import { join as join28 } from "path";
6546
7329
  var empty = () => ({ schemaVersion: 1, profiles: {} });
6547
7330
  var ConfigStore = class {
6548
7331
  constructor(dir2) {
@@ -6550,12 +7333,12 @@ var ConfigStore = class {
6550
7333
  }
6551
7334
  dir;
6552
7335
  get file() {
6553
- return join25(this.dir, "config.json");
7336
+ return join28(this.dir, "config.json");
6554
7337
  }
6555
7338
  async read() {
6556
7339
  let raw;
6557
7340
  try {
6558
- raw = await readFile6(this.file, "utf8");
7341
+ raw = await readFile8(this.file, "utf8");
6559
7342
  } catch (e) {
6560
7343
  if (e.code === "ENOENT") return empty();
6561
7344
  throw e;
@@ -6581,9 +7364,9 @@ var ConfigStore = class {
6581
7364
 
6582
7365
  // src/platform/git.ts
6583
7366
  import { execFile, execFileSync } from "child_process";
6584
- import { mkdir as mkdir4, readdir as readdir4, stat as stat6 } from "fs/promises";
7367
+ import { mkdir as mkdir4, readdir as readdir4, stat as stat7 } from "fs/promises";
6585
7368
  import { homedir } from "os";
6586
- import { dirname as dirname9, join as join26 } from "path";
7369
+ import { dirname as dirname9, join as join29 } from "path";
6587
7370
  function parseRemotes(stdout) {
6588
7371
  const seen = /* @__PURE__ */ new Set();
6589
7372
  const out = [];
@@ -6621,8 +7404,8 @@ function parseStatus(stdout) {
6621
7404
  }
6622
7405
  var configuredSshCommand = /* @__PURE__ */ new Map();
6623
7406
  function hasConfiguredSshCommand(repoDir) {
6624
- const key = repoDir ?? "";
6625
- let v = configuredSshCommand.get(key);
7407
+ const key2 = repoDir ?? "";
7408
+ let v = configuredSshCommand.get(key2);
6626
7409
  if (v === void 0) {
6627
7410
  const args = repoDir ? ["-C", repoDir, "config", "--get", "core.sshCommand"] : ["config", "--get", "core.sshCommand"];
6628
7411
  try {
@@ -6636,7 +7419,7 @@ function hasConfiguredSshCommand(repoDir) {
6636
7419
  } catch {
6637
7420
  v = false;
6638
7421
  }
6639
- configuredSshCommand.set(key, v);
7422
+ configuredSshCommand.set(key2, v);
6640
7423
  }
6641
7424
  return v;
6642
7425
  }
@@ -6650,7 +7433,7 @@ function gitExecOptions(repoDir) {
6650
7433
  return { cwd: homedir(), env, windowsHide: true, maxBuffer: 8 * 1024 * 1024 };
6651
7434
  }
6652
7435
  function runGit(args, repoDir) {
6653
- return new Promise((resolve6, reject) => {
7436
+ return new Promise((resolve7, reject) => {
6654
7437
  execFile("git", args, gitExecOptions(repoDir), (err, stdout, stderr) => {
6655
7438
  if (err && err.code === "ENOENT") {
6656
7439
  reject(new QoreError("QORE_GIT_UNAVAILABLE", "git is not installed or not on PATH", {
@@ -6659,7 +7442,7 @@ function runGit(args, repoDir) {
6659
7442
  return;
6660
7443
  }
6661
7444
  const code = err ? typeof err.code === "number" ? err.code : 1 : 0;
6662
- resolve6({ code, stdout: String(stdout), stderr: String(stderr), err: err ?? void 0 });
7445
+ resolve7({ code, stdout: String(stdout), stderr: String(stderr), err: err ?? void 0 });
6663
7446
  });
6664
7447
  });
6665
7448
  }
@@ -6695,12 +7478,12 @@ async function status(repoRoot) {
6695
7478
  if (r.code !== 0) return { ok: false, stderr: r.stderr, command };
6696
7479
  return parseStatus(r.stdout);
6697
7480
  }
6698
- async function statOrThrow(path) {
7481
+ async function statOrThrow(path2) {
6699
7482
  try {
6700
- return await stat6(path);
7483
+ return await stat7(path2);
6701
7484
  } catch (err) {
6702
7485
  if (err.code === "ENOENT") return void 0;
6703
- throw new QoreError("QORE_INTERNAL", `could not stat ${path}`, { cause: err });
7486
+ throw new QoreError("QORE_INTERNAL", `could not stat ${path2}`, { cause: err });
6704
7487
  }
6705
7488
  }
6706
7489
  async function inspect(dir2) {
@@ -6714,7 +7497,7 @@ async function inspect(dir2) {
6714
7497
  throw new QoreError("QORE_INTERNAL", `could not read ${dir2}`, { cause: err });
6715
7498
  }
6716
7499
  const empty2 = entries.length === 0;
6717
- const isRepo = await statOrThrow(join26(dir2, ".git")) !== void 0;
7500
+ const isRepo = await statOrThrow(join29(dir2, ".git")) !== void 0;
6718
7501
  const dirRemotes = isRepo ? await remotes(dir2) : [];
6719
7502
  return { exists: true, empty: empty2, isRepo, remotes: dirRemotes };
6720
7503
  }
@@ -6776,13 +7559,13 @@ var OsKeychain = class {
6776
7559
 
6777
7560
  // src/platform/lock.ts
6778
7561
  import { mkdir as mkdir5, writeFile as writeFile5 } from "fs/promises";
6779
- import { join as join27 } from "path";
7562
+ import { join as join30 } from "path";
6780
7563
  import lockfile from "proper-lockfile";
6781
7564
  function fileLock(dir2) {
6782
7565
  return async (name, fn) => {
6783
- const locks = join27(dir2, "locks");
7566
+ const locks = join30(dir2, "locks");
6784
7567
  await mkdir5(locks, { recursive: true, mode: 448 });
6785
- const target = join27(locks, `${name}.lock`);
7568
+ const target = join30(locks, `${name}.lock`);
6786
7569
  await writeFile5(target, "", { flag: "a" });
6787
7570
  let release;
6788
7571
  let compromised;
@@ -6826,7 +7609,7 @@ var STDERR_DRAIN_MS = 500;
6826
7609
  function makeSpawner(spawnFn, proc) {
6827
7610
  return {
6828
7611
  run(cmd, args, env, o) {
6829
- return new Promise((resolve6, reject) => {
7612
+ return new Promise((resolve7, reject) => {
6830
7613
  let child;
6831
7614
  try {
6832
7615
  child = spawnFn(cmd, args, {
@@ -6860,7 +7643,7 @@ function makeSpawner(spawnFn, proc) {
6860
7643
  cleanup();
6861
7644
  const stderr = child.stderr;
6862
7645
  if (!o?.onStderr || !stderr || stderr.readableEnded) {
6863
- resolve6({ code, signal });
7646
+ resolve7({ code, signal });
6864
7647
  return;
6865
7648
  }
6866
7649
  let settled = false;
@@ -6868,7 +7651,7 @@ function makeSpawner(spawnFn, proc) {
6868
7651
  if (settled) return;
6869
7652
  settled = true;
6870
7653
  clearTimeout(timer);
6871
- resolve6({ code, signal });
7654
+ resolve7({ code, signal });
6872
7655
  };
6873
7656
  const timer = setTimeout(finish, STDERR_DRAIN_MS);
6874
7657
  timer.unref?.();
@@ -6923,13 +7706,13 @@ var systemLauncher = {
6923
7706
  child.stdout?.on("data", (c) => out.push(c));
6924
7707
  child.stderr?.on("data", (c) => err.push(c));
6925
7708
  let closed = false;
6926
- const exited = new Promise((resolve6) => {
6927
- child.once("error", (e) => resolve6({ code: null, signal: null, spawnError: e.code ?? "error" }));
7709
+ const exited = new Promise((resolve7) => {
7710
+ child.once("error", (e) => resolve7({ code: null, signal: null, spawnError: e.code ?? "error" }));
6928
7711
  child.once("close", (code, signal) => {
6929
7712
  closed = true;
6930
7713
  out.flush();
6931
7714
  err.flush();
6932
- resolve6({ code, signal });
7715
+ resolve7({ code, signal });
6933
7716
  });
6934
7717
  });
6935
7718
  return {
@@ -6975,11 +7758,11 @@ var systemLauncher = {
6975
7758
  import { execFile as execFile2 } from "child_process";
6976
7759
  import { connect } from "net";
6977
7760
  function accepts(host, port) {
6978
- return new Promise((resolve6) => {
7761
+ return new Promise((resolve7) => {
6979
7762
  const s = connect({ host, port });
6980
7763
  const done = (v) => {
6981
7764
  s.destroy();
6982
- resolve6(v);
7765
+ resolve7(v);
6983
7766
  };
6984
7767
  s.setTimeout(500, () => done(false));
6985
7768
  s.once("connect", () => done(true));
@@ -6987,12 +7770,12 @@ function accepts(host, port) {
6987
7770
  });
6988
7771
  }
6989
7772
  function run2(cmd, args) {
6990
- return new Promise((resolve6) => {
7773
+ return new Promise((resolve7) => {
6991
7774
  execFile2(
6992
7775
  cmd,
6993
7776
  args,
6994
7777
  { timeout: 2e3, env: { ...process.env, LC_ALL: "C" }, windowsHide: true },
6995
- (e, stdout) => resolve6(e && !stdout ? null : String(stdout))
7778
+ (e, stdout) => resolve7(e && !stdout ? null : String(stdout))
6996
7779
  );
6997
7780
  });
6998
7781
  }
@@ -7055,6 +7838,26 @@ var systemPorts = {
7055
7838
  }
7056
7839
  };
7057
7840
 
7841
+ // src/platform/postgres.ts
7842
+ import pg from "pg";
7843
+ async function connectPostgres(url, o) {
7844
+ const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: o.timeoutMs });
7845
+ client.on("error", () => {
7846
+ });
7847
+ try {
7848
+ await client.connect();
7849
+ await client.query("set default_transaction_read_only = on");
7850
+ } catch (e) {
7851
+ await client.end().catch(() => {
7852
+ });
7853
+ throw e;
7854
+ }
7855
+ return {
7856
+ query: async (sql, params) => ({ rows: (await client.query(sql, params)).rows }),
7857
+ close: () => client.end()
7858
+ };
7859
+ }
7860
+
7058
7861
  // src/buildInfo.ts
7059
7862
  var SENTRY_DSN = true ? "https://50b592efb53dbc1a31cb10d3945c6d52@o4512129906049024.ingest.de.sentry.io/4512174341750864" : "";
7060
7863
  var SENTRY_ENVIRONMENT = true ? "production" : "development";
@@ -7064,7 +7867,7 @@ import { execFile as execFile3 } from "child_process";
7064
7867
 
7065
7868
  // src/core/diagnostics/proxy.ts
7066
7869
  function parseScutilProxy(stdout) {
7067
- const value = (key) => stdout.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, "m"))?.[1]?.trim();
7870
+ const value = (key2) => stdout.match(new RegExp(`^\\s*${key2}\\s*:\\s*(.+)$`, "m"))?.[1]?.trim();
7068
7871
  if (value("HTTPSEnable") === "1") {
7069
7872
  const host = value("HTTPSProxy");
7070
7873
  const port = value("HTTPSPort");
@@ -7085,12 +7888,12 @@ function parseRegProxy(stdout) {
7085
7888
 
7086
7889
  // src/platform/proxy.ts
7087
7890
  function run3(cmd, args) {
7088
- return new Promise((resolve6) => {
7891
+ return new Promise((resolve7) => {
7089
7892
  execFile3(
7090
7893
  cmd,
7091
7894
  args,
7092
7895
  { timeout: 2e3, env: { ...process.env, LC_ALL: "C" }, windowsHide: true },
7093
- (e, stdout) => resolve6(e && !stdout ? null : String(stdout))
7896
+ (e, stdout) => resolve7(e && !stdout ? null : String(stdout))
7094
7897
  );
7095
7898
  });
7096
7899
  }
@@ -7101,10 +7904,10 @@ async function detect(platform) {
7101
7904
  return out ? parseScutilProxy(out) : null;
7102
7905
  }
7103
7906
  if (platform === "win32") {
7104
- const key = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
7907
+ const key2 = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
7105
7908
  const [enable, server] = await Promise.all([
7106
- run3("reg", ["query", key, "/v", "ProxyEnable"]),
7107
- run3("reg", ["query", key, "/v", "ProxyServer"])
7909
+ run3("reg", ["query", key2, "/v", "ProxyEnable"]),
7910
+ run3("reg", ["query", key2, "/v", "ProxyServer"])
7108
7911
  ]);
7109
7912
  const combined = [enable, server].filter(Boolean).join("\n");
7110
7913
  return combined ? parseRegProxy(combined) : null;
@@ -7121,7 +7924,7 @@ function systemProxy(platform = process.platform) {
7121
7924
  }
7122
7925
 
7123
7926
  // src/version.ts
7124
- var VERSION = true ? "0.1.7" : "0.0.0-dev";
7927
+ var VERSION = true ? "0.1.9" : "0.0.0-dev";
7125
7928
 
7126
7929
  // src/cli.ts
7127
7930
  if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY) setGlobalDispatcher(new EnvHttpProxyAgent());
@@ -7169,7 +7972,9 @@ var deps = {
7169
7972
  },
7170
7973
  sentryDsn: SENTRY_DSN,
7171
7974
  sentryEnvironment: SENTRY_ENVIRONMENT,
7172
- systemProxy: () => systemProxy(process.platform)
7975
+ systemProxy: () => systemProxy(process.platform),
7976
+ viewerDir: fileURLToPath(new URL("../viewer/dist/", import.meta.url)),
7977
+ postgres: connectPostgres
7173
7978
  };
7174
7979
  var termination = preflightTermination(() => ac.abort(), deps.raise);
7175
7980
  var io = processIo(isInteractive({
@@ -7199,4 +8004,4 @@ if (!drained) {
7199
8004
  process.exit();
7200
8005
  }
7201
8006
  //# sourceMappingURL=cli.js.map
7202
- //# debugId=bf813c0e-4054-532d-9e74-62fbe8673876
8007
+ //# debugId=f7b490f6-b0af-5874-9ffa-f8010e3ba137