@quatronic/qore 0.1.7 → 0.1.8

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 +70 -2
  2. package/dist/cli.js +1720 -1089
  3. package/package.json +23 -4
  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]="c4bda056-b13b-52b5-831f-2afd3f6c6867")}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
  }
@@ -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))];
@@ -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);
@@ -1435,9 +1436,9 @@ 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
1443
  async function underAFile(path) {
1443
1444
  for (let p = dirname3(path); ; p = dirname3(p)) {
@@ -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 path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
2765
+ const produce = o.routes[path];
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, `.${path === "/" ? "/index.html" : path}`);
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, path) {
3015
+ if (path === ctx.deps.cwd) return ctx;
3016
+ if (!ctx.opts().json) ctx.io.err(`\u2192 in ${path}`);
3017
+ return withCwd(ctx, path);
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 } });
2907
- }
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 } });
2910
- }
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
- }
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 }
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
2924
3029
  });
3030
+ return;
2925
3031
  }
2926
- if (res.spawnError || res.code !== 0) {
2927
- throw new QoreError("QORE_NODE_VERSION", "node --version failed", { details: { required: range, 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
+ });
2928
3043
  }
2929
- const found = (res.tail.at(-1) ?? "").trim().replace(/^v/, "");
2930
- if (nodeSatisfies(found, range) === false) throw nodeVersionError(found, range, slug);
2931
- return found;
2932
3044
  }
2933
- async function installIfNeeded(deps2, r, slug, onLine, report, signal) {
2934
- if (!r.install) {
2935
- report({ module: slug, step: "install", status: "skip", detail: "disabled" });
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);
2936
3057
  return;
2937
3058
  }
2938
- if (!await needsInstall(r.root, r.pm)) {
2939
- report({ module: slug, step: "install", status: "skip", detail: "up to date" });
2940
- return;
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 }
3063
+ });
2941
3064
  }
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 });
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)"}`);
2951
3068
  }
2952
- report({ module: slug, step: "install", status: "done", detail: secs(deps2.now().getTime() - began) });
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);
2953
3071
  }
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 }
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."
2961
3107
  });
2962
3108
  }
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
- });
2971
- }
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}`);
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;
2979
3118
  }
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 } : {}
2992
- }
2993
- });
2994
3119
  }
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 }
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);
2999
3137
  });
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 } });
3004
- }
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) }
3138
+ const first = await model().catch((e) => {
3139
+ throw ctx.signal.aborted ? new CancelledError() : classifyDbError(e, src.url);
3012
3140
  });
3013
- }
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" });
3141
+ if (json2) {
3142
+ renderJson(io2, { context: contextJson(mc, app), source: src.source, model: first });
3017
3143
  return;
3018
3144
  }
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;
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();
3029
3182
  }
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" });
3183
+ }
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>." });
3195
+ }
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;
3036
3213
  }
3037
- throw await composeFailure(deps2, r, slug, env, signal, `${script} failed in ${slug}`, `${cmd} ${args.join(" ")}`, res.tail);
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;
3219
+ }
3220
+ throw e;
3038
3221
  }
3039
3222
  }
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
- );
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."
3227
+ });
3228
+ }
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 };
3054
3248
  }
3249
+ }));
3250
+ const first = await readAll(json2).catch((e) => {
3251
+ throw ctx.signal.aborted ? new CancelledError() : e;
3252
+ });
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;
3055
3266
  }
3056
- const took = secs(deps2.now().getTime() - began);
3057
- report({ module: slug, step: "services", status: "done", detail: dbPort !== null ? `:${dbPort} (${took})` : `(${took})` });
3058
- }
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 });
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;
3282
+ });
3283
+ await serve(ctx, f, server);
3063
3284
  }
3064
- function abortableDelay(signal) {
3065
- return (ms2) => new Promise((resolve6) => {
3066
- const done = () => {
3067
- clearTimeout(t);
3068
- signal.removeEventListener("abort", done);
3069
- resolve6();
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.` };
3300
+ }
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
+ }
3310
+ }
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.`
3070
3318
  };
3071
- const t = setTimeout(done, ms2);
3072
- signal.addEventListener("abort", done, { once: true });
3073
- });
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
+ };
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.` };
3341
+ }
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;
3074
3365
  }
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;
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) {
3086
3407
  try {
3087
- res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: o.cwd, env: o.env ?? deps2.env, onLine: () => {
3088
- }, signal: poll });
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
+ };
3089
3426
  } catch (e) {
3090
- if (e instanceof CancelledError && !o.signal.aborted) return false;
3091
- throw e;
3427
+ if (!isOffline(e)) throw e;
3428
+ cause = e;
3092
3429
  }
3093
- if (classifyDockerInfo(res, deps2.platform) === null) return true;
3094
3430
  }
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: [] };
3095
3439
  }
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);
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;
3445
+ }
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)";
3108
3458
  }
3109
- return false;
3459
+ return "run qore sync";
3110
3460
  }
3111
3461
 
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;
3124
- }
3125
- const port = Number(url.port);
3126
- if (port > 0 && LOCAL_HOSTS.has(url.hostname)) out.push({ key, alias: m[1], port });
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);
3127
3489
  }
3128
- return out;
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 };
3129
3503
  }
3130
- function deadAliases(env, listening) {
3131
- return connPorts(env).filter((c) => !listening.has(c.port)).map((c) => c.key);
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.` };
3132
3506
  }
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 });
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) {
3137
3518
  const db = envPort(env, "DB_PORT");
3138
- if (db !== null) ports.set(db, { kind: "database" });
3519
+ if (db === null || recipe.services.length > 0 || !o.servicesFlag || o.configured) return null;
3139
3520
  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
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 }
3147
3539
  };
3148
3540
  }
3149
3541
 
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) };
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.");
3560
+ }
3157
3561
  }
3158
- async function writeRunFile(deps2, root, rec) {
3159
- const { dir: dir2, name } = await runFilePath(deps2, root);
3160
- await writeJsonAtomic(dir2, name, rec);
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] };
3567
+ }
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.');
3161
3570
  }
3162
- async function removeRunFile(deps2, root) {
3163
- const { path } = await runFilePath(deps2, root);
3164
- await unlink2(path).catch(() => {
3165
- });
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
+ });
3591
+ }
3592
+ }
3593
+ const services = !o.flags.services ? [] : configured ?? (scripts["db:up"] !== void 0 ? ["db:up"] : []);
3594
+ return {
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] ?? ""))
3601
+ };
3166
3602
  }
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;
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"] };
3175
3608
  }
3176
3609
 
3177
- // src/cli/project.ts
3178
- import { readdir as readdir2, stat as stat4 } from "fs/promises";
3179
- import { join as join18 } from "path";
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;
3627
+ }
3180
3628
 
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 [];
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/";
3189
3634
  }
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 };
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 } });
3647
+ }
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 } });
3655
+ }
3656
+
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
+ }
3673
+
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 };
3681
+ }
3682
+ function parseLsofCwd(stdout) {
3683
+ const m = stdout.match(/^fcwd\nn(.+)$/m);
3684
+ return m ? m[1] : null;
3685
+ }
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`;
3194
3700
  }
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);
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 } = await runFilePath(deps2, root);
4040
+ await unlink2(path).catch(() => {
4041
+ });
4042
+ }
4043
+ async function leftoverRun(deps2, root, o = {}) {
4044
+ const { path } = await runFilePath(deps2, root);
4045
+ const rec = await readJson(path).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(path).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
  }
@@ -6171,7 +6779,7 @@ function registerStatus(program, ctx) {
6171
6779
  }
6172
6780
 
6173
6781
  // src/cli/commands/sync.ts
6174
- import { join as join24, relative as relative9 } from "path";
6782
+ import { join as join26, relative as relative9 } from "path";
6175
6783
  import { Option as Option2 } from "commander";
6176
6784
  function asFailure2(slug, e) {
6177
6785
  if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") {
@@ -6203,7 +6811,7 @@ function registerSync(program, ctx) {
6203
6811
  continue;
6204
6812
  }
6205
6813
  try {
6206
- dirs[m.slug] = await deps2.git.inspect(join24(ws.root, m.slug));
6814
+ dirs[m.slug] = await deps2.git.inspect(join26(ws.root, m.slug));
6207
6815
  modulesForPlan.push(m);
6208
6816
  } catch (e) {
6209
6817
  earlyFailures.push(asFailure2(m.slug, e));
@@ -6401,6 +7009,7 @@ var COMMANDS = [
6401
7009
  registerEnv,
6402
7010
  registerRun,
6403
7011
  registerDev,
7012
+ registerDb,
6404
7013
  registerClone,
6405
7014
  registerSync,
6406
7015
  registerVersion,
@@ -6486,11 +7095,11 @@ async function start(deps2, argv2, command, load, onScrub) {
6486
7095
  }
6487
7096
  function withDeadline(work, ms2, signal) {
6488
7097
  if (signal?.aborted) return Promise.resolve(false);
6489
- return new Promise((resolve6) => {
7098
+ return new Promise((resolve7) => {
6490
7099
  const done = (v) => {
6491
7100
  clearTimeout(timer);
6492
7101
  signal?.removeEventListener("abort", onAbort);
6493
- resolve6(v);
7102
+ resolve7(v);
6494
7103
  };
6495
7104
  const onAbort = () => done(false);
6496
7105
  const timer = setTimeout(() => done(false), ms2);
@@ -6541,8 +7150,8 @@ async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS)
6541
7150
  }
6542
7151
 
6543
7152
  // src/core/config.ts
6544
- import { readFile as readFile6 } from "fs/promises";
6545
- import { join as join25 } from "path";
7153
+ import { readFile as readFile8 } from "fs/promises";
7154
+ import { join as join27 } from "path";
6546
7155
  var empty = () => ({ schemaVersion: 1, profiles: {} });
6547
7156
  var ConfigStore = class {
6548
7157
  constructor(dir2) {
@@ -6550,12 +7159,12 @@ var ConfigStore = class {
6550
7159
  }
6551
7160
  dir;
6552
7161
  get file() {
6553
- return join25(this.dir, "config.json");
7162
+ return join27(this.dir, "config.json");
6554
7163
  }
6555
7164
  async read() {
6556
7165
  let raw;
6557
7166
  try {
6558
- raw = await readFile6(this.file, "utf8");
7167
+ raw = await readFile8(this.file, "utf8");
6559
7168
  } catch (e) {
6560
7169
  if (e.code === "ENOENT") return empty();
6561
7170
  throw e;
@@ -6581,9 +7190,9 @@ var ConfigStore = class {
6581
7190
 
6582
7191
  // src/platform/git.ts
6583
7192
  import { execFile, execFileSync } from "child_process";
6584
- import { mkdir as mkdir4, readdir as readdir4, stat as stat6 } from "fs/promises";
7193
+ import { mkdir as mkdir4, readdir as readdir4, stat as stat7 } from "fs/promises";
6585
7194
  import { homedir } from "os";
6586
- import { dirname as dirname9, join as join26 } from "path";
7195
+ import { dirname as dirname9, join as join28 } from "path";
6587
7196
  function parseRemotes(stdout) {
6588
7197
  const seen = /* @__PURE__ */ new Set();
6589
7198
  const out = [];
@@ -6621,8 +7230,8 @@ function parseStatus(stdout) {
6621
7230
  }
6622
7231
  var configuredSshCommand = /* @__PURE__ */ new Map();
6623
7232
  function hasConfiguredSshCommand(repoDir) {
6624
- const key = repoDir ?? "";
6625
- let v = configuredSshCommand.get(key);
7233
+ const key2 = repoDir ?? "";
7234
+ let v = configuredSshCommand.get(key2);
6626
7235
  if (v === void 0) {
6627
7236
  const args = repoDir ? ["-C", repoDir, "config", "--get", "core.sshCommand"] : ["config", "--get", "core.sshCommand"];
6628
7237
  try {
@@ -6636,7 +7245,7 @@ function hasConfiguredSshCommand(repoDir) {
6636
7245
  } catch {
6637
7246
  v = false;
6638
7247
  }
6639
- configuredSshCommand.set(key, v);
7248
+ configuredSshCommand.set(key2, v);
6640
7249
  }
6641
7250
  return v;
6642
7251
  }
@@ -6650,7 +7259,7 @@ function gitExecOptions(repoDir) {
6650
7259
  return { cwd: homedir(), env, windowsHide: true, maxBuffer: 8 * 1024 * 1024 };
6651
7260
  }
6652
7261
  function runGit(args, repoDir) {
6653
- return new Promise((resolve6, reject) => {
7262
+ return new Promise((resolve7, reject) => {
6654
7263
  execFile("git", args, gitExecOptions(repoDir), (err, stdout, stderr) => {
6655
7264
  if (err && err.code === "ENOENT") {
6656
7265
  reject(new QoreError("QORE_GIT_UNAVAILABLE", "git is not installed or not on PATH", {
@@ -6659,7 +7268,7 @@ function runGit(args, repoDir) {
6659
7268
  return;
6660
7269
  }
6661
7270
  const code = err ? typeof err.code === "number" ? err.code : 1 : 0;
6662
- resolve6({ code, stdout: String(stdout), stderr: String(stderr), err: err ?? void 0 });
7271
+ resolve7({ code, stdout: String(stdout), stderr: String(stderr), err: err ?? void 0 });
6663
7272
  });
6664
7273
  });
6665
7274
  }
@@ -6697,7 +7306,7 @@ async function status(repoRoot) {
6697
7306
  }
6698
7307
  async function statOrThrow(path) {
6699
7308
  try {
6700
- return await stat6(path);
7309
+ return await stat7(path);
6701
7310
  } catch (err) {
6702
7311
  if (err.code === "ENOENT") return void 0;
6703
7312
  throw new QoreError("QORE_INTERNAL", `could not stat ${path}`, { cause: err });
@@ -6714,7 +7323,7 @@ async function inspect(dir2) {
6714
7323
  throw new QoreError("QORE_INTERNAL", `could not read ${dir2}`, { cause: err });
6715
7324
  }
6716
7325
  const empty2 = entries.length === 0;
6717
- const isRepo = await statOrThrow(join26(dir2, ".git")) !== void 0;
7326
+ const isRepo = await statOrThrow(join28(dir2, ".git")) !== void 0;
6718
7327
  const dirRemotes = isRepo ? await remotes(dir2) : [];
6719
7328
  return { exists: true, empty: empty2, isRepo, remotes: dirRemotes };
6720
7329
  }
@@ -6776,13 +7385,13 @@ var OsKeychain = class {
6776
7385
 
6777
7386
  // src/platform/lock.ts
6778
7387
  import { mkdir as mkdir5, writeFile as writeFile5 } from "fs/promises";
6779
- import { join as join27 } from "path";
7388
+ import { join as join29 } from "path";
6780
7389
  import lockfile from "proper-lockfile";
6781
7390
  function fileLock(dir2) {
6782
7391
  return async (name, fn) => {
6783
- const locks = join27(dir2, "locks");
7392
+ const locks = join29(dir2, "locks");
6784
7393
  await mkdir5(locks, { recursive: true, mode: 448 });
6785
- const target = join27(locks, `${name}.lock`);
7394
+ const target = join29(locks, `${name}.lock`);
6786
7395
  await writeFile5(target, "", { flag: "a" });
6787
7396
  let release;
6788
7397
  let compromised;
@@ -6826,7 +7435,7 @@ var STDERR_DRAIN_MS = 500;
6826
7435
  function makeSpawner(spawnFn, proc) {
6827
7436
  return {
6828
7437
  run(cmd, args, env, o) {
6829
- return new Promise((resolve6, reject) => {
7438
+ return new Promise((resolve7, reject) => {
6830
7439
  let child;
6831
7440
  try {
6832
7441
  child = spawnFn(cmd, args, {
@@ -6860,7 +7469,7 @@ function makeSpawner(spawnFn, proc) {
6860
7469
  cleanup();
6861
7470
  const stderr = child.stderr;
6862
7471
  if (!o?.onStderr || !stderr || stderr.readableEnded) {
6863
- resolve6({ code, signal });
7472
+ resolve7({ code, signal });
6864
7473
  return;
6865
7474
  }
6866
7475
  let settled = false;
@@ -6868,7 +7477,7 @@ function makeSpawner(spawnFn, proc) {
6868
7477
  if (settled) return;
6869
7478
  settled = true;
6870
7479
  clearTimeout(timer);
6871
- resolve6({ code, signal });
7480
+ resolve7({ code, signal });
6872
7481
  };
6873
7482
  const timer = setTimeout(finish, STDERR_DRAIN_MS);
6874
7483
  timer.unref?.();
@@ -6923,13 +7532,13 @@ var systemLauncher = {
6923
7532
  child.stdout?.on("data", (c) => out.push(c));
6924
7533
  child.stderr?.on("data", (c) => err.push(c));
6925
7534
  let closed = false;
6926
- const exited = new Promise((resolve6) => {
6927
- child.once("error", (e) => resolve6({ code: null, signal: null, spawnError: e.code ?? "error" }));
7535
+ const exited = new Promise((resolve7) => {
7536
+ child.once("error", (e) => resolve7({ code: null, signal: null, spawnError: e.code ?? "error" }));
6928
7537
  child.once("close", (code, signal) => {
6929
7538
  closed = true;
6930
7539
  out.flush();
6931
7540
  err.flush();
6932
- resolve6({ code, signal });
7541
+ resolve7({ code, signal });
6933
7542
  });
6934
7543
  });
6935
7544
  return {
@@ -6975,11 +7584,11 @@ var systemLauncher = {
6975
7584
  import { execFile as execFile2 } from "child_process";
6976
7585
  import { connect } from "net";
6977
7586
  function accepts(host, port) {
6978
- return new Promise((resolve6) => {
7587
+ return new Promise((resolve7) => {
6979
7588
  const s = connect({ host, port });
6980
7589
  const done = (v) => {
6981
7590
  s.destroy();
6982
- resolve6(v);
7591
+ resolve7(v);
6983
7592
  };
6984
7593
  s.setTimeout(500, () => done(false));
6985
7594
  s.once("connect", () => done(true));
@@ -6987,12 +7596,12 @@ function accepts(host, port) {
6987
7596
  });
6988
7597
  }
6989
7598
  function run2(cmd, args) {
6990
- return new Promise((resolve6) => {
7599
+ return new Promise((resolve7) => {
6991
7600
  execFile2(
6992
7601
  cmd,
6993
7602
  args,
6994
7603
  { timeout: 2e3, env: { ...process.env, LC_ALL: "C" }, windowsHide: true },
6995
- (e, stdout) => resolve6(e && !stdout ? null : String(stdout))
7604
+ (e, stdout) => resolve7(e && !stdout ? null : String(stdout))
6996
7605
  );
6997
7606
  });
6998
7607
  }
@@ -7055,6 +7664,26 @@ var systemPorts = {
7055
7664
  }
7056
7665
  };
7057
7666
 
7667
+ // src/platform/postgres.ts
7668
+ import pg from "pg";
7669
+ async function connectPostgres(url, o) {
7670
+ const client = new pg.Client({ connectionString: url, connectionTimeoutMillis: o.timeoutMs });
7671
+ client.on("error", () => {
7672
+ });
7673
+ try {
7674
+ await client.connect();
7675
+ await client.query("set default_transaction_read_only = on");
7676
+ } catch (e) {
7677
+ await client.end().catch(() => {
7678
+ });
7679
+ throw e;
7680
+ }
7681
+ return {
7682
+ query: async (sql, params) => ({ rows: (await client.query(sql, params)).rows }),
7683
+ close: () => client.end()
7684
+ };
7685
+ }
7686
+
7058
7687
  // src/buildInfo.ts
7059
7688
  var SENTRY_DSN = true ? "https://50b592efb53dbc1a31cb10d3945c6d52@o4512129906049024.ingest.de.sentry.io/4512174341750864" : "";
7060
7689
  var SENTRY_ENVIRONMENT = true ? "production" : "development";
@@ -7064,7 +7693,7 @@ import { execFile as execFile3 } from "child_process";
7064
7693
 
7065
7694
  // src/core/diagnostics/proxy.ts
7066
7695
  function parseScutilProxy(stdout) {
7067
- const value = (key) => stdout.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, "m"))?.[1]?.trim();
7696
+ const value = (key2) => stdout.match(new RegExp(`^\\s*${key2}\\s*:\\s*(.+)$`, "m"))?.[1]?.trim();
7068
7697
  if (value("HTTPSEnable") === "1") {
7069
7698
  const host = value("HTTPSProxy");
7070
7699
  const port = value("HTTPSPort");
@@ -7085,12 +7714,12 @@ function parseRegProxy(stdout) {
7085
7714
 
7086
7715
  // src/platform/proxy.ts
7087
7716
  function run3(cmd, args) {
7088
- return new Promise((resolve6) => {
7717
+ return new Promise((resolve7) => {
7089
7718
  execFile3(
7090
7719
  cmd,
7091
7720
  args,
7092
7721
  { timeout: 2e3, env: { ...process.env, LC_ALL: "C" }, windowsHide: true },
7093
- (e, stdout) => resolve6(e && !stdout ? null : String(stdout))
7722
+ (e, stdout) => resolve7(e && !stdout ? null : String(stdout))
7094
7723
  );
7095
7724
  });
7096
7725
  }
@@ -7101,10 +7730,10 @@ async function detect(platform) {
7101
7730
  return out ? parseScutilProxy(out) : null;
7102
7731
  }
7103
7732
  if (platform === "win32") {
7104
- const key = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
7733
+ const key2 = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
7105
7734
  const [enable, server] = await Promise.all([
7106
- run3("reg", ["query", key, "/v", "ProxyEnable"]),
7107
- run3("reg", ["query", key, "/v", "ProxyServer"])
7735
+ run3("reg", ["query", key2, "/v", "ProxyEnable"]),
7736
+ run3("reg", ["query", key2, "/v", "ProxyServer"])
7108
7737
  ]);
7109
7738
  const combined = [enable, server].filter(Boolean).join("\n");
7110
7739
  return combined ? parseRegProxy(combined) : null;
@@ -7121,7 +7750,7 @@ function systemProxy(platform = process.platform) {
7121
7750
  }
7122
7751
 
7123
7752
  // src/version.ts
7124
- var VERSION = true ? "0.1.7" : "0.0.0-dev";
7753
+ var VERSION = true ? "0.1.8" : "0.0.0-dev";
7125
7754
 
7126
7755
  // src/cli.ts
7127
7756
  if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY) setGlobalDispatcher(new EnvHttpProxyAgent());
@@ -7169,7 +7798,9 @@ var deps = {
7169
7798
  },
7170
7799
  sentryDsn: SENTRY_DSN,
7171
7800
  sentryEnvironment: SENTRY_ENVIRONMENT,
7172
- systemProxy: () => systemProxy(process.platform)
7801
+ systemProxy: () => systemProxy(process.platform),
7802
+ viewerDir: fileURLToPath(new URL("../viewer/dist/", import.meta.url)),
7803
+ postgres: connectPostgres
7173
7804
  };
7174
7805
  var termination = preflightTermination(() => ac.abort(), deps.raise);
7175
7806
  var io = processIo(isInteractive({
@@ -7199,4 +7830,4 @@ if (!drained) {
7199
7830
  process.exit();
7200
7831
  }
7201
7832
  //# sourceMappingURL=cli.js.map
7202
- //# debugId=bf813c0e-4054-532d-9e74-62fbe8673876
7833
+ //# debugId=c4bda056-b13b-52b5-831f-2afd3f6c6867