@quatronic/qore 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { homedir as homedir2 } from "os";
5
5
  import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
6
6
 
7
7
  // src/cli/io.ts
8
- import { confirm, select } from "@inquirer/prompts";
8
+ import { confirm, search, select } from "@inquirer/prompts";
9
9
  import { createInterface } from "readline";
10
10
 
11
11
  // src/platform/browser.ts
@@ -52,7 +52,17 @@ var EXIT = {
52
52
  QORE_UNSUPPORTED_BY_SERVER: 7,
53
53
  QORE_UPGRADE_REQUIRED: 7,
54
54
  QORE_PORTAL_UNTRUSTED: 7,
55
- QORE_RATE_LIMITED: 8
55
+ QORE_RATE_LIMITED: 8,
56
+ QORE_PORT_IN_USE: 2,
57
+ QORE_DEV_UNSUPPORTED: 2,
58
+ QORE_INSTALL_FAILED: 1,
59
+ QORE_PACKAGE_MANAGER: 1,
60
+ QORE_DOCKER_MISSING: 1,
61
+ QORE_DOCKER_NOT_RUNNING: 1,
62
+ QORE_DOCKER_COMPOSE_MISSING: 1,
63
+ QORE_SERVICE_DATA_INCOMPATIBLE: 1,
64
+ QORE_NODE_VERSION: 1,
65
+ QORE_SERVICE_UNHEALTHY: 1
56
66
  };
57
67
  var QoreError = class extends Error {
58
68
  code;
@@ -100,7 +110,7 @@ function toJsonError(err) {
100
110
  function isInteractive(o) {
101
111
  return o.stdinTTY && o.stdoutTTY && !o.json && !o.noInput && !o.env.CI;
102
112
  }
103
- function processIo(interactive) {
113
+ function processIo(interactive, stderrTTY = Boolean(process.stderr.isTTY)) {
104
114
  return {
105
115
  out: (s) => {
106
116
  process.stdout.write(s + "\n");
@@ -109,6 +119,8 @@ function processIo(interactive) {
109
119
  process.stderr.write(s + "\n");
110
120
  },
111
121
  interactive,
122
+ stderrTTY,
123
+ stderrRaw: process.stderr,
112
124
  async select(message, choices) {
113
125
  try {
114
126
  return await select({ message, choices });
@@ -117,14 +129,22 @@ function processIo(interactive) {
117
129
  throw e;
118
130
  }
119
131
  },
120
- async confirm(message) {
132
+ async search(message, source) {
121
133
  try {
122
- return await confirm({ message, default: false });
134
+ return await search({ message, source: (term) => source(term ?? "") });
123
135
  } catch (e) {
124
136
  if (e.name === "ExitPromptError") throw new CancelledError();
125
137
  throw e;
126
138
  }
127
139
  },
140
+ async confirm(message, signal, o) {
141
+ try {
142
+ return await confirm({ message, default: o?.default ?? false }, { signal });
143
+ } catch (e) {
144
+ if (e.name === "ExitPromptError" || e.name === "AbortPromptError") throw new CancelledError();
145
+ throw e;
146
+ }
147
+ },
128
148
  waitForEnter: createWaitForEnter(process.stdin, process.stderr),
129
149
  openUrl: openBrowser
130
150
  };
@@ -176,7 +196,11 @@ function isAbortError(e) {
176
196
  function fetchError(e, host) {
177
197
  return isAbortError(e) ? new CancelledError() : networkError(e, host);
178
198
  }
179
- function networkError(e, host) {
199
+ function proxyHint(proxy, platform) {
200
+ const cmd = platform === "win32" ? `set HTTPS_PROXY=http://${proxy}` : `export HTTPS_PROXY=http://${proxy}`;
201
+ return `Your system uses the proxy ${proxy}, but qore reads only HTTPS_PROXY. Run: ${cmd}`;
202
+ }
203
+ function networkError(e, host, systemProxy2) {
180
204
  const code = causeCode(e) ?? "";
181
205
  let reason = "connect";
182
206
  let hint = "Check your network or VPN.";
@@ -188,10 +212,24 @@ function networkError(e, host) {
188
212
  hint = "A proxy is re-signing TLS. Point NODE_EXTRA_CA_CERTS at your organisation's CA certificate.";
189
213
  } else if (code.includes("TIMEOUT") || code === "ETIMEDOUT") {
190
214
  reason = "timeout";
215
+ } else if (systemProxy2) {
216
+ reason = "proxy";
217
+ hint = proxyHint(systemProxy2);
191
218
  } else if (!process.env.HTTPS_PROXY && !process.env.https_proxy) {
192
219
  hint = "Check your network. Behind a corporate proxy, set HTTPS_PROXY.";
193
220
  }
194
- return new QoreError("QORE_API_UNREACHABLE", `Could not reach ${host}`, { hint, details: { reason, host, errno: code || void 0 }, cause: e });
221
+ const details = { reason, host, errno: code || void 0 };
222
+ if (systemProxy2) details.proxy = systemProxy2;
223
+ return new QoreError("QORE_API_UNREACHABLE", `Could not reach ${host}`, { hint, details, cause: e });
224
+ }
225
+ function withSystemProxy(err, proxy, platform) {
226
+ return new QoreError(err.code, err.message, {
227
+ status: err.status,
228
+ requestId: err.requestId,
229
+ cause: err.cause,
230
+ details: { ...err.details, reason: "proxy", proxy },
231
+ hint: proxyHint(proxy, platform)
232
+ });
195
233
  }
196
234
  var BY_SERVER_CODE = {
197
235
  "app.access.denied": "QORE_ACCESS_DENIED",
@@ -263,6 +301,98 @@ function renderError(io2, err, json2, colorEnabled = pc.isColorSupported) {
263
301
  io2.err(c.red(`Error: ${e.message}`));
264
302
  if (e.hint) io2.err(e.hint);
265
303
  io2.err(c.dim(`Code: ${e.code}${e.requestId ? ` (request ${e.requestId})` : ""}`));
304
+ if (e.code === "QORE_SERVICE_UNHEALTHY" && Array.isArray(e.details?.logs)) {
305
+ for (const l of e.details.logs) io2.err(c.dim(` ${String(l)}`));
306
+ }
307
+ }
308
+ function renderDoctorJson(io2, report) {
309
+ const failing = report.results.filter((r) => r.status === "fail");
310
+ if (failing.length === 0) {
311
+ io2.out(JSON.stringify({ ok: true, data: report }));
312
+ return false;
313
+ }
314
+ const first = failing.find((r) => r.error);
315
+ const code = first?.error?.code ?? "QORE_INTERNAL";
316
+ const message = first?.error?.message ?? `${failing.length} of ${report.results.length} checks failed`;
317
+ const hint = first?.error?.hint;
318
+ const err = new QoreError(code, message, { hint, details: { results: report.results, context: report.context } });
319
+ io2.out(JSON.stringify({ ok: false, error: toJsonError(err) }));
320
+ return true;
321
+ }
322
+ var SYMBOL = { ok: "\u2713", warn: "!", fail: "\u2717" };
323
+ var TOOL_IDS = ["qore", "node", "git", "github-ssh", "docker"];
324
+ var ROW_WIDTH = 80;
325
+ var COL_B = 36;
326
+ function rawLine(r) {
327
+ return `${SYMBOL[r.status]} ${r.label}`;
328
+ }
329
+ function colorFor(c, status2) {
330
+ return status2 === "ok" ? c.green : status2 === "warn" ? c.yellow : c.red;
331
+ }
332
+ function coloredLine(c, r) {
333
+ return `${colorFor(c, r.status)(SYMBOL[r.status])} ${r.label}`;
334
+ }
335
+ function printCheck(io2, c, indent, r) {
336
+ io2.out(`${indent}${coloredLine(c, r)}`);
337
+ const hint = r.status === "fail" ? r.error?.hint : void 0;
338
+ if (typeof hint === "string") io2.out(c.dim(`${indent} ${hint}`));
339
+ }
340
+ function toolRows(io2, c, tools) {
341
+ for (let i = 0; i < tools.length; i += 2) {
342
+ const a = tools[i];
343
+ const b = tools[i + 1];
344
+ if (!b) {
345
+ printCheck(io2, c, "", a);
346
+ continue;
347
+ }
348
+ const rawA = rawLine(a);
349
+ const rawB = rawLine(b);
350
+ const colB = Math.max(COL_B, rawA.length + 2);
351
+ if (colB + rawB.length > ROW_WIDTH) {
352
+ printCheck(io2, c, "", a);
353
+ printCheck(io2, c, "", b);
354
+ continue;
355
+ }
356
+ const pad = " ".repeat(colB - rawA.length);
357
+ io2.out(`${coloredLine(c, a)}${pad}${coloredLine(c, b)}`);
358
+ if (a.status === "fail" && typeof a.error?.hint === "string") io2.out(c.dim(` ${a.error.hint}`));
359
+ if (b.status === "fail" && typeof b.error?.hint === "string") io2.out(c.dim(` ${b.error.hint}`));
360
+ }
361
+ }
362
+ function groupByModule(results) {
363
+ const order = [];
364
+ const groups = /* @__PURE__ */ new Map();
365
+ for (const r of results) {
366
+ const key = r.module ?? r.id;
367
+ if (!groups.has(key)) {
368
+ groups.set(key, []);
369
+ order.push(key);
370
+ }
371
+ groups.get(key).push(r);
372
+ }
373
+ const isSub = (r) => r.id.startsWith("pm:") || r.id.startsWith("node:");
374
+ return order.flatMap((key) => {
375
+ const g = groups.get(key);
376
+ return [...g.filter((r) => !isSub(r)), ...g.filter(isSub)];
377
+ });
378
+ }
379
+ function renderDoctorHuman(io2, report, colorEnabled = pc.isColorSupported) {
380
+ const c = createColors(colorEnabled);
381
+ const byId = new Map(report.results.map((r) => [r.id, r]));
382
+ const tools = TOOL_IDS.map((id) => byId.get(id)).filter((r) => r !== void 0);
383
+ toolRows(io2, c, tools);
384
+ for (const r of report.results.filter((r2) => r2.group === "session")) printCheck(io2, c, "", r);
385
+ const clock = byId.get("clock");
386
+ if (clock) printCheck(io2, c, "", clock);
387
+ const workspace = groupByModule(report.results.filter((r) => r.group === "workspace"));
388
+ if (workspace.length === 0) return;
389
+ if (report.context.kind !== "none") {
390
+ io2.out(`\u2192 ${report.context.kind === "workspace" ? "Workspace" : "Module"} ${report.context.label ?? ""}`);
391
+ }
392
+ for (const r of workspace) {
393
+ const indent = r.id.startsWith("pm:") || r.id.startsWith("node:") ? " " : " ";
394
+ printCheck(io2, c, indent, r);
395
+ }
266
396
  }
267
397
 
268
398
  // src/cli/program.ts
@@ -273,9 +403,32 @@ function argvBeforeSeparator(argv2) {
273
403
  function jsonFromArgv(argv2) {
274
404
  return argvBeforeSeparator(argv2).some((a) => a === "--json" || a.startsWith("--json="));
275
405
  }
406
+ var DOCTOR_CODES = /* @__PURE__ */ new Set([
407
+ "QORE_DOCKER_MISSING",
408
+ "QORE_DOCKER_NOT_RUNNING",
409
+ "QORE_DOCKER_COMPOSE_MISSING",
410
+ "QORE_NODE_VERSION",
411
+ "QORE_PACKAGE_MANAGER",
412
+ "QORE_INSTALL_FAILED",
413
+ "QORE_PORT_IN_USE",
414
+ "QORE_SERVICE_UNHEALTHY"
415
+ ]);
416
+ var machineFailure = (e) => DOCTOR_CODES.has(e.code) && !(e.code === "QORE_PORT_IN_USE" && e.details?.owner === "qore");
417
+ function needsDoctor(e) {
418
+ if (!(e instanceof QoreError)) return false;
419
+ const problems = e.details?.problems;
420
+ if (Array.isArray(problems)) return problems.some((p) => machineFailure(p));
421
+ return machineFailure(e);
422
+ }
276
423
  function requestLog(ctx) {
277
424
  return ctx.opts().verbose ? (line2) => ctx.io.err(line2) : void 0;
278
425
  }
426
+ async function withProxyHint(deps2, e) {
427
+ if (!(e instanceof QoreError) || e.code !== "QORE_API_UNREACHABLE" || e.details?.reason !== "connect") return e;
428
+ if (deps2.env.HTTPS_PROXY || deps2.env.https_proxy || deps2.env.HTTP_PROXY || deps2.env.http_proxy) return e;
429
+ const proxy = await deps2.systemProxy();
430
+ return proxy ? withSystemProxy(e, proxy, deps2.platform) : e;
431
+ }
279
432
  function buildProgram(deps2, io2, signal, register, setExitCode) {
280
433
  const program = new Command("qore").description("Qore developer CLI").option("--portal <name|url>", "prod, staging, or a portal URL (default: the portal you last logged in to, else prod)").option("--json", "print exactly one JSON document on stdout").option("--no-input", "never prompt").option("--verbose", "log request lines (never secrets)").option("--no-color", "disable colours").exitOverride().configureOutput({ writeOut: (s) => io2.out(s.trimEnd()), writeErr: (s) => io2.err(s.trimEnd()) });
281
434
  const ctx = { deps: deps2, io: io2, signal, opts: () => program.opts(), setExitCode };
@@ -290,11 +443,14 @@ async function run(argv2, deps2, io2, signal, register) {
290
443
  const program = buildProgram(deps2, parseIo, signal, register, (code) => {
291
444
  state.exitCode = code;
292
445
  });
446
+ program.hook("preAction", (_program, action) => {
447
+ state.command = action.name();
448
+ });
293
449
  try {
294
450
  await program.parseAsync(argv2, { from: "user" });
295
451
  return state.exitCode ?? 0;
296
452
  } catch (caught) {
297
- const e = signal.aborted && isAbortError(caught) ? new CancelledError() : caught;
453
+ const e = await withProxyHint(deps2, signal.aborted && isAbortError(caught) ? new CancelledError() : caught);
298
454
  if (e instanceof CommanderError) {
299
455
  if (e.exitCode === 0) return 0;
300
456
  if (json2) io2.out(JSON.stringify({ ok: false, error: { code: "QORE_CONTEXT_INVALID", message: e.message } }));
@@ -302,6 +458,7 @@ async function run(argv2, deps2, io2, signal, register) {
302
458
  }
303
459
  const opts = program.opts();
304
460
  renderError(io2, e, Boolean(opts.json), pc2.isColorSupported && opts.color !== false);
461
+ if (!opts.json && needsDoctor(e) && state.command !== "doctor") io2.err("Run qore doctor for a full check of your machine.");
305
462
  return exitCodeFor(e);
306
463
  }
307
464
  }
@@ -325,6 +482,9 @@ function compareVersions(a, b) {
325
482
  return 0;
326
483
  }
327
484
  async function fetchMeta(fetch3, apiOrigin) {
485
+ return (await fetchMetaWithDate(fetch3, apiOrigin)).meta;
486
+ }
487
+ async function fetchMetaWithDate(fetch3, apiOrigin) {
328
488
  let res;
329
489
  try {
330
490
  res = await fetch3(`${apiOrigin}/api/cli/meta`, { headers: { accept: "application/json" } });
@@ -339,7 +499,9 @@ async function fetchMeta(fetch3, apiOrigin) {
339
499
  });
340
500
  }
341
501
  if (!res.ok) throw await problemToError(res);
342
- return await res.json();
502
+ const header = res.headers.get("date");
503
+ const ms = header ? Date.parse(header) : NaN;
504
+ return { meta: await res.json(), date: Number.isNaN(ms) ? null : new Date(ms) };
343
505
  }
344
506
  function negotiate(meta, portal, cliVersion) {
345
507
  if (portal.pinnedIssuer && meta.auth.issuer !== portal.pinnedIssuer) {
@@ -369,9 +531,9 @@ function isMeta(doc) {
369
531
  return !!doc && typeof doc === "object" && Array.isArray(doc.capabilities);
370
532
  }
371
533
  async function loadMeta(deps2, apiOrigin, refresh) {
372
- const cached = (await deps2.config.read()).profiles[apiOrigin]?.meta;
373
- if (!refresh && cached && isMeta(cached.doc) && deps2.now().getTime() - new Date(cached.fetchedAt).getTime() < TTL_MS) {
374
- return { meta: cached.doc, cached: true };
534
+ const cached2 = (await deps2.config.read()).profiles[apiOrigin]?.meta;
535
+ if (!refresh && cached2 && isMeta(cached2.doc) && deps2.now().getTime() - new Date(cached2.fetchedAt).getTime() < TTL_MS) {
536
+ return { meta: cached2.doc, cached: true };
375
537
  }
376
538
  const meta = await fetchMeta(deps2.fetch, apiOrigin);
377
539
  await deps2.config.update((c) => {
@@ -380,6 +542,9 @@ async function loadMeta(deps2, apiOrigin, refresh) {
380
542
  });
381
543
  return { meta, cached: false };
382
544
  }
545
+ async function metaFor(deps2, apiOrigin) {
546
+ return (await loadMeta(deps2, apiOrigin, false)).meta;
547
+ }
383
548
  async function ensureCapability(deps2, apiOrigin, capability) {
384
549
  const first = await loadMeta(deps2, apiOrigin, false);
385
550
  let meta = first.meta;
@@ -440,6 +605,12 @@ function profileHash(apiOrigin, issuer) {
440
605
  }
441
606
 
442
607
  // src/core/api/client.ts
608
+ function clockSkewSeconds(dateHeader, now) {
609
+ if (!dateHeader) return null;
610
+ const serverMs = Date.parse(dateHeader);
611
+ if (Number.isNaN(serverMs)) return null;
612
+ return Math.round((serverMs - now.getTime()) / 1e3);
613
+ }
443
614
  var QoreApiClient = class {
444
615
  constructor(o) {
445
616
  this.o = o;
@@ -474,6 +645,15 @@ var QoreApiClient = class {
474
645
  if (res.status === 401) {
475
646
  res = await this.once(method, path, body, true);
476
647
  if (res.status === 401) {
648
+ const skew = clockSkewSeconds(res.headers.get("date"), (this.o.now ?? (() => /* @__PURE__ */ new Date()))());
649
+ if (skew !== null && Math.abs(skew) > 60) {
650
+ const minutes = Math.max(1, Math.round(Math.abs(skew) / 60));
651
+ throw new QoreError("QORE_CLOCK_SKEW", `Your clock is off by ${minutes} min; tokens look expired`, {
652
+ hint: "Turn on automatic date and time, then run the command again.",
653
+ status: 401,
654
+ details: { skewSeconds: skew }
655
+ });
656
+ }
477
657
  throw new QoreError("QORE_SESSION_EXPIRED", "The portal no longer accepts your session", {
478
658
  hint: "Run `qore login`.",
479
659
  status: 401,
@@ -597,7 +777,8 @@ async function openSession(deps2, portal, command, onRequest) {
597
777
  cliVersion: deps2.version,
598
778
  command,
599
779
  platform: `${deps2.platform} ${process.arch}; node ${process.versions.node}`,
600
- onRequest
780
+ onRequest,
781
+ now: deps2.now
601
782
  });
602
783
  return { profile, api, tokens, refresh };
603
784
  }
@@ -605,8 +786,7 @@ async function openSession(deps2, portal, command, onRequest) {
605
786
  // src/core/workspace/apps.ts
606
787
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
607
788
  var tenantCandidates = (tenants) => tenants.map((t) => ({ kind: "tenant", id: t.id, slug: t.slug, name: t.name }));
608
- async function resolveAppRef(deps2, session, arg, tenantFlag) {
609
- if (UUID.test(arg)) return { appId: arg, tenant: null };
789
+ async function resolveTenant(deps2, session, tenantFlag, whatFor) {
610
790
  const me = await session.api.get("/api/cli/v1/me");
611
791
  let tenant;
612
792
  if (tenantFlag) {
@@ -630,11 +810,16 @@ async function resolveAppRef(deps2, session, arg, tenantFlag) {
630
810
  } else if (me.tenants.length === 1) {
631
811
  tenant = { id: me.tenants[0].id, slug: me.tenants[0].slug };
632
812
  } else {
633
- throw new QoreError("QORE_CONTEXT_REQUIRED", `Which tenant is "${arg}" in?`, {
813
+ throw new QoreError("QORE_CONTEXT_REQUIRED", `Which tenant is "${whatFor}" in?`, {
634
814
  hint: "Run `qore tenant use <slug>`, or pass --tenant <slug>.",
635
815
  candidates: tenantCandidates(me.tenants)
636
816
  });
637
817
  }
818
+ return { tenant, me };
819
+ }
820
+ async function resolveAppRef(deps2, session, arg, tenantFlag) {
821
+ if (UUID.test(arg)) return { appId: arg, tenant: null };
822
+ const { tenant } = await resolveTenant(deps2, session, tenantFlag, arg);
638
823
  try {
639
824
  const { id } = await session.api.get(
640
825
  `/api/cli/v1/tenants/${encodeURIComponent(tenant.id)}/apps/resolve?slug=${encodeURIComponent(arg)}`
@@ -657,7 +842,7 @@ async function resolveAppRef(deps2, session, arg, tenantFlag) {
657
842
  }
658
843
 
659
844
  // src/core/workspace/apply.ts
660
- import { join as join4, relative } from "path";
845
+ import { join as join4 } from "path";
661
846
 
662
847
  // src/core/context/cache.ts
663
848
  import { join as join2 } from "path";
@@ -807,7 +992,7 @@ async function trust(deps2, root, moduleId) {
807
992
  }
808
993
 
809
994
  // src/core/workspace/plan.ts
810
- import { join as join3 } from "path";
995
+ import { join as join3, relative } from "path";
811
996
  var SEGMENT = /^[A-Za-z0-9_.-]+$/;
812
997
  function isSupportedRepoUrl(repoUrl) {
813
998
  let u;
@@ -832,6 +1017,13 @@ function cloneUrl(repoUrl, protocol) {
832
1017
  const u = new URL(repoUrl);
833
1018
  return `git@${u.hostname}:${u.pathname.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "")}.git`;
834
1019
  }
1020
+ function conflictText(cwd, action) {
1021
+ const dir2 = relative(cwd, action.dir) || ".";
1022
+ const notRepo = action.reason === "not a git repository";
1023
+ const what = notRepo ? "it is not a git repository" : action.origin ? `its origin is ${action.origin}, not ${action.module.repoUrl}` : "it has no origin remote";
1024
+ const message = notRepo ? `${dir2} is not a git repository` : action.origin ? `${dir2} holds another repository (its origin is ${action.origin}, not ${action.module.repoUrl})` : `${dir2} has no origin remote`;
1025
+ return { message, hint: `Move ${dir2} aside (${what}), then run \`qore sync\`.` };
1026
+ }
835
1027
  function planWorkspace(root, modules, dirs, strayRepos2) {
836
1028
  const actions = modules.map((module) => {
837
1029
  const dir2 = join3(root, module.slug);
@@ -848,6 +1040,10 @@ function planWorkspace(root, modules, dirs, strayRepos2) {
848
1040
  const slugs = new Set(modules.map((m) => m.slug));
849
1041
  return { actions, removed: strayRepos2.filter((s) => !slugs.has(s)) };
850
1042
  }
1043
+ function cloneProtocol(dirs) {
1044
+ const origins = Object.values(dirs).flatMap((d) => d.remotes.filter((r) => r.name === "origin").map((r) => r.url));
1045
+ return origins.some((u) => /^(git@|ssh:\/\/)/.test(u)) ? "ssh" : "https";
1046
+ }
851
1047
 
852
1048
  // src/core/workspace/diagnose.ts
853
1049
  var ACCESS_CODES = /* @__PURE__ */ new Set(["QORE_GITHUB_NOT_LINKED", "QORE_REPO_NO_ACCESS", "QORE_REPO_ACCESS_PENDING"]);
@@ -923,15 +1119,8 @@ function toFailure(slug, e) {
923
1119
  throw e;
924
1120
  }
925
1121
  function conflictResult(deps2, action) {
926
- const dir2 = relative(deps2.cwd, action.dir) || ".";
927
- const what = action.reason === "not a git repository" ? "it is not a git repository" : action.origin ? `its origin is ${action.origin}, not ${action.module.repoUrl}` : "it has no origin remote";
928
- const message = action.reason === "not a git repository" ? `${dir2} is not a git repository` : action.origin ? `${dir2} holds another repository (its origin is ${action.origin}, not ${action.module.repoUrl})` : `${dir2} has no origin remote`;
929
- return {
930
- slug: action.module.slug,
931
- status: "conflict",
932
- reason: action.reason,
933
- error: { code: "QORE_WORKSPACE_CONFLICT", message, hint: `Move ${dir2} aside (${what}), then run \`qore sync\`.` }
934
- };
1122
+ const { message, hint } = conflictText(deps2.cwd, action);
1123
+ return { slug: action.module.slug, status: "conflict", reason: action.reason, error: { code: "QORE_WORKSPACE_CONFLICT", message, hint } };
935
1124
  }
936
1125
  async function applyActions(deps2, o) {
937
1126
  const cache = new ResolutionCache(join4(deps2.config.dir, "cache"), deps2.now);
@@ -1124,10 +1313,10 @@ function unsafeSlugError(slug) {
1124
1313
  function exitFor(results) {
1125
1314
  const failures = results.filter((r) => r.status === "failed" || r.status === "conflict");
1126
1315
  if (failures.length === 0) return null;
1127
- const ok = results.some((r) => ["cloned", "present", "fetched", "pulled", "skipped"].includes(r.status));
1316
+ const ok2 = results.some((r) => ["cloned", "present", "fetched", "pulled", "skipped"].includes(r.status));
1128
1317
  const code = (r) => r.status === "conflict" ? "QORE_WORKSPACE_CONFLICT" : r.error?.code ?? "QORE_INTERNAL";
1129
1318
  const nonAccess = failures.find((r) => !ACCESS_CODES.has(code(r)));
1130
- if (ok && !nonAccess) return null;
1319
+ if (ok2 && !nonAccess) return null;
1131
1320
  const first = nonAccess ?? failures[0];
1132
1321
  const moduleCount = results.filter((r) => r.status !== "removed").length;
1133
1322
  return new QoreError(code(first), `${failures.length} of ${moduleCount} modules need attention`, {
@@ -1233,8 +1422,34 @@ async function resolveApp(deps2, io2, session, appArg, tenantFlag) {
1233
1422
  return resolveAppRef(deps2, session, appArg, slug);
1234
1423
  }
1235
1424
  }
1425
+ async function pickApp(deps2, io2, session, tenantFlag) {
1426
+ if (!io2.interactive) {
1427
+ throw new QoreError("QORE_CONTEXT_REQUIRED", "An app is required", {
1428
+ hint: "Pass an app slug: `qore clone <app>`. Interactively, omitting it opens a picker."
1429
+ });
1430
+ }
1431
+ let tenant, me;
1432
+ try {
1433
+ ({ tenant, me } = await resolveTenant(deps2, session, tenantFlag, "the app you want to clone"));
1434
+ } catch (e) {
1435
+ if (!(e instanceof QoreError) || e.code !== "QORE_CONTEXT_REQUIRED" && e.code !== "QORE_TENANT_ACCESS_LOST" || !e.candidates?.length) throw e;
1436
+ const slug = await io2.select("Select a tenant", e.candidates.map((c) => ({ name: c.name ?? c.slug, value: c.slug })));
1437
+ ({ tenant, me } = await resolveTenant(deps2, session, slug, "the app you want to clone"));
1438
+ }
1439
+ const apps = me.tenants.find((t) => t.id === tenant.id)?.apps ?? [];
1440
+ if (apps.length === 0) {
1441
+ throw new QoreError("QORE_CONTEXT_REQUIRED", `No apps in ${tenant.slug} to clone`, {
1442
+ hint: "Run `qore whoami` to see what you have access to."
1443
+ });
1444
+ }
1445
+ const app = await io2.search("Select an app", (term) => {
1446
+ const q = term.toLowerCase();
1447
+ return apps.filter((a) => !q || a.slug.toLowerCase().includes(q) || a.name.toLowerCase().includes(q)).map((a) => ({ name: `${a.name} (${a.slug})`, value: a.slug }));
1448
+ });
1449
+ return { tenant: tenant.slug, app };
1450
+ }
1236
1451
  function registerClone(program, ctx) {
1237
- program.command("clone").description("Clone an app's modules into a new workspace directory").argument("<app>", "app slug or id").argument("[dir]", "target directory (default: the app's slug)").option("--tenant <slug>", "tenant the app slug belongs to, when it is not your active one").addOption(new Option("--protocol <https|ssh>", "clone URL protocol").choices(["https", "ssh"]).default("https")).action(async (appArg, dirArg, flags) => {
1452
+ program.command("clone").description("Clone an app's modules into a new workspace directory").argument("[app]", "app slug or id (omit to pick one interactively)").argument("[dir]", "target directory (default: the app's slug)").option("--tenant <slug>", "tenant the app slug belongs to, when it is not your active one").addOption(new Option("--protocol <https|ssh>", "clone URL protocol").choices(["https", "ssh"]).default("https")).action(async (appArg, dirArg, flags) => {
1238
1453
  const { deps: deps2, io: io2 } = ctx;
1239
1454
  const g = ctx.opts();
1240
1455
  const cfg = await deps2.config.read();
@@ -1242,7 +1457,9 @@ function registerClone(program, ctx) {
1242
1457
  const onRequest = g.verbose ? (l) => io2.err(l) : void 0;
1243
1458
  const session = await openSession(deps2, portal, "clone", onRequest);
1244
1459
  await ensureCapability(deps2, portal.apiOrigin, "app.modules");
1245
- const ref = await resolveApp(deps2, io2, session, appArg, flags.tenant);
1460
+ const picked = appArg === void 0 ? await pickApp(deps2, io2, session, flags.tenant) : void 0;
1461
+ const appSlug = picked?.app ?? appArg;
1462
+ const ref = await resolveApp(deps2, io2, session, appSlug, picked?.tenant ?? flags.tenant);
1246
1463
  const app = await session.api.get(`/api/cli/v1/apps/${encodeURIComponent(ref.appId)}/modules`);
1247
1464
  if (dirArg === void 0 && !isSafeSlug(app.app.slug)) throw unsafeSlugError(app.app.slug);
1248
1465
  const dirLabel = dirArg ?? app.app.slug;
@@ -1252,7 +1469,7 @@ function registerClone(program, ctx) {
1252
1469
  const ws = await readWorkspace(root);
1253
1470
  if (!ws || ws.appId !== app.app.id || ws.portal !== portal.apiOrigin) {
1254
1471
  throw new QoreError("QORE_WORKSPACE_CONFLICT", `${root} exists and is not the workspace of ${app.app.slug}`, {
1255
- hint: `Pick another directory: qore clone ${appArg} <dir>.`
1472
+ hint: `Pick another directory: qore clone ${appSlug} <dir>.`
1256
1473
  });
1257
1474
  }
1258
1475
  }
@@ -1289,16 +1506,19 @@ function registerClone(program, ctx) {
1289
1506
  });
1290
1507
  }
1291
1508
 
1292
- // src/cli/commands/env.ts
1293
- import { randomBytes as randomBytes3 } from "crypto";
1294
- import { chmod as chmod2, readFile as readFile4, realpath as realpath3, rename as rename3, unlink, writeFile as writeFile3 } from "fs/promises";
1295
- import { basename as basename2, dirname as dirname4, join as join10, relative as relative5 } from "path";
1509
+ // src/cli/commands/dev.ts
1510
+ import { randomBytes as randomBytes5 } from "crypto";
1511
+ import { realpath as realpath5 } from "fs/promises";
1512
+ import { constants as constants3 } from "os";
1513
+ import { dirname as dirname6, join as join18, relative as relative6 } from "path";
1514
+ import { StringDecoder } from "string_decoder";
1296
1515
 
1297
1516
  // src/core/context/repoConfig.ts
1298
1517
  import { readFile as readFile3 } from "fs/promises";
1299
1518
  import { join as join7 } from "path";
1300
1519
  var FORBIDDEN = ["portal", "api", "apiOrigin", "issuer", "tenant", "tenantId", "app", "appId", "module", "moduleId"];
1301
1520
  var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
1521
+ var SCRIPT = /^[A-Za-z0-9:_.-]+$/;
1302
1522
  var invalid2 = (msg, details) => new QoreError("QORE_CONTEXT_INVALID", `.qore.json: ${msg}`, { hint: "Fix .qore.json in the repository root; see the $schema it names.", details });
1303
1523
  function moduleDefaults(type) {
1304
1524
  return type === "frontend" ? { envFile: ".env.local", localSecrets: ["SESSION_COOKIE_SECRET"] } : { envFile: ".env", localSecrets: [] };
@@ -1336,12 +1556,30 @@ async function readRepoConfig(root) {
1336
1556
  if (new Set(doc.localSecrets).size !== doc.localSecrets.length) throw invalid2("localSecrets must not contain duplicates");
1337
1557
  out.localSecrets = doc.localSecrets;
1338
1558
  }
1559
+ if (doc.dev !== void 0) {
1560
+ const d = doc.dev;
1561
+ if (!d || typeof d !== "object" || Array.isArray(d)) throw invalid2("dev must be an object");
1562
+ const dev = {};
1563
+ if (d.install !== void 0) {
1564
+ if (typeof d.install !== "boolean") throw invalid2("dev.install must be true or false");
1565
+ dev.install = d.install;
1566
+ }
1567
+ if (d.services !== void 0) {
1568
+ if (!Array.isArray(d.services) || !d.services.every((s) => typeof s === "string" && SCRIPT.test(s))) {
1569
+ throw invalid2("dev.services must be a list of script names");
1570
+ }
1571
+ if (new Set(d.services).size !== d.services.length) throw invalid2("dev.services must not contain duplicates");
1572
+ dev.services = d.services;
1573
+ }
1574
+ if (d.script !== void 0) {
1575
+ if (typeof d.script !== "string" || !SCRIPT.test(d.script)) throw invalid2("dev.script must be a script name");
1576
+ dev.script = d.script;
1577
+ }
1578
+ out.dev = dev;
1579
+ }
1339
1580
  return out;
1340
1581
  }
1341
1582
 
1342
- // src/core/context/resolver.ts
1343
- import { isAbsolute, join as join9, relative as relative3 } from "path";
1344
-
1345
1583
  // src/core/context/repoRoot.ts
1346
1584
  import { stat } from "fs/promises";
1347
1585
  import { dirname as dirname2, join as join8, resolve as resolve3 } from "path";
@@ -1360,6 +1598,7 @@ async function findRepoRoot(start) {
1360
1598
  }
1361
1599
 
1362
1600
  // src/core/context/resolver.ts
1601
+ import { isAbsolute, join as join9, relative as relative3 } from "path";
1363
1602
  var UUID3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1364
1603
  var HINT_FLAG = "cd into a module repository, or pass --module <slug|id>.";
1365
1604
  var RESOLVE = "/api/cli/v1/repos/resolve";
@@ -1503,8 +1742,8 @@ function profileOrder(cfg, cachedOrigins, portalArg, wsPortal) {
1503
1742
  if (portalArg) return [resolvePortal(portalArg, cfg.defaultProfile)];
1504
1743
  const rank = (o) => o === BUILTIN_PORTALS.prod.apiOrigin ? 0 : o === BUILTIN_PORTALS.staging.apiOrigin ? 1 : 2;
1505
1744
  const rest = Object.keys(cfg.profiles).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
1506
- const cached = cachedOrigins.filter((c) => cfg.profiles[c]);
1507
- const origins = [...wsPortal ? [wsPortal.apiOrigin] : [], ...cached, resolvePortal(void 0, cfg.defaultProfile).apiOrigin, ...rest];
1745
+ const cached2 = cachedOrigins.filter((c) => cfg.profiles[c]);
1746
+ const origins = [...wsPortal ? [wsPortal.apiOrigin] : [], ...cached2, resolvePortal(void 0, cfg.defaultProfile).apiOrigin, ...rest];
1508
1747
  return [...new Set(origins)].filter((c) => cfg.profiles[c]).map((c) => resolvePortal(c, void 0));
1509
1748
  }
1510
1749
  function withDetails(e, extra, hint) {
@@ -1518,13 +1757,13 @@ function withDetails(e, extra, hint) {
1518
1757
  cause: e
1519
1758
  });
1520
1759
  }
1521
- async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, workspace, unsupported) {
1760
+ async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, workspace, unsupported2) {
1522
1761
  const session = await openSession(deps2, portal, o.command, o.onRequest);
1523
1762
  try {
1524
1763
  await ensureCapability(deps2, portal.apiOrigin, "repo.resolve");
1525
1764
  } catch (e) {
1526
1765
  if (!(e instanceof QoreError) || e.code !== "QORE_UNSUPPORTED_BY_SERVER") throw e;
1527
- unsupported(e);
1766
+ unsupported2(e);
1528
1767
  return null;
1529
1768
  }
1530
1769
  const hits = [];
@@ -1598,14 +1837,14 @@ async function resolveModuleContext(deps2, o) {
1598
1837
  const answered = [];
1599
1838
  const skipped = [];
1600
1839
  const skipErrors = [];
1601
- let unsupported;
1840
+ let unsupported2;
1602
1841
  for (const portal of profileOrder(cfg, await cache.originsFor(keys), o.portal, wsPortal)) {
1603
1842
  tried.push(label(portal));
1604
1843
  let capabilityMissing = false;
1605
1844
  try {
1606
1845
  const mc = await onProfile(deps2, o, cache, portal, keyed, keys, root, ws, (e) => {
1607
1846
  capabilityMissing = true;
1608
- unsupported ??= e;
1847
+ unsupported2 ??= e;
1609
1848
  skipped.push({ profile: label(portal), code: e.code, message: e.message });
1610
1849
  });
1611
1850
  if (mc) return mc;
@@ -1619,7 +1858,7 @@ async function resolveModuleContext(deps2, o) {
1619
1858
  const details = { triedProfiles: tried };
1620
1859
  if (skipped.length > 0) details.skippedProfiles = skipped;
1621
1860
  if (answered.length === 0) {
1622
- const cause = skipErrors[0] ?? unsupported;
1861
+ const cause = skipErrors[0] ?? unsupported2;
1623
1862
  if (cause) throw withDetails(cause, details);
1624
1863
  }
1625
1864
  const unchecked = skipped.length ? ` ${skipped.map((s) => s.profile).join(", ")} could not be checked (${skipped.map((s) => s.code).join(", ")}).` : "";
@@ -1630,7 +1869,7 @@ async function resolveModuleContext(deps2, o) {
1630
1869
  });
1631
1870
  }
1632
1871
  var appCandidates = (apps) => apps.map((a) => ({ kind: "app", id: a.id, slug: a.slug, name: a.name }));
1633
- function pickApp(mc, app) {
1872
+ function pickApp2(mc, app) {
1634
1873
  const apps = mc.module.apps;
1635
1874
  if (app) {
1636
1875
  const found = apps.find((a) => a.id === app || a.slug === app);
@@ -1648,6 +1887,83 @@ function pickApp(mc, app) {
1648
1887
  throw new QoreError("QORE_APP_AMBIGUOUS", `${mc.module.slug} is part of several apps`, { hint: "Pass --app <slug>.", candidates: appCandidates(apps) });
1649
1888
  }
1650
1889
 
1890
+ // src/core/diagnostics/node.ts
1891
+ var KEEP_LINES = 50;
1892
+ function exitHint(tail, h) {
1893
+ const text2 = tail.join("\n");
1894
+ const missing = text2.match(/Cannot find (?:module|package) '([^']+)'/);
1895
+ if (missing) {
1896
+ return { reason: "missing-module", hint: `A dependency is missing (${missing[1]}). Run \`${h.installCommand}\`, then qore dev again.` };
1897
+ }
1898
+ if (/ZodError:/.test(text2)) {
1899
+ const keys = [...text2.matchAll(/"path":\s*\[\s*"([^"]+)"/g)].map((m) => m[1]);
1900
+ if (keys.length > 0) {
1901
+ return {
1902
+ reason: "invalid-env",
1903
+ keys,
1904
+ 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.`
1905
+ };
1906
+ }
1907
+ }
1908
+ const addrInUse = text2.match(/listen EADDRINUSE: address already in use (?:\S*:)?(\d+)/);
1909
+ if (addrInUse) {
1910
+ const port = Number(addrInUse[1]);
1911
+ return {
1912
+ reason: "port-in-use",
1913
+ port,
1914
+ hint: `Port ${port} was taken while the server started. Run qore dev again; if it repeats, lsof -iTCP:${port} -sTCP:LISTEN names the holder.`
1915
+ };
1916
+ }
1917
+ if (/ECONNREFUSED/.test(text2)) {
1918
+ const m = text2.match(/ECONNREFUSED\s+\S*:(\d+)/) ?? text2.match(/code: 'ECONNREFUSED'[^}]*?port: (\d+)/);
1919
+ if (!m) return { reason: "conn-refused", hint: "A connection was refused (ECONNREFUSED) \u2014 a service this module calls is not running." };
1920
+ const port = Number(m[1]);
1921
+ const role = h.ports.get(port);
1922
+ if (role?.kind === "database") {
1923
+ const up = h.runCommand("db:up");
1924
+ return {
1925
+ reason: "conn-refused",
1926
+ port,
1927
+ 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.")
1928
+ };
1929
+ }
1930
+ if (role?.kind === "provider") {
1931
+ return {
1932
+ reason: "conn-refused",
1933
+ port,
1934
+ hint: `${role.key} points at :${port}, where nothing listens (ECONNREFUSED). Start that module \u2014 qore dev in the workspace root starts providers first.`
1935
+ };
1936
+ }
1937
+ return { reason: "conn-refused", port, hint: `Nothing listens on :${port} (ECONNREFUSED) \u2014 a service this module calls is not running.` };
1938
+ }
1939
+ const auth = text2.match(/password authentication failed for user "([^"]+)"/);
1940
+ if (auth) {
1941
+ const reset = h.runCommand("db:reset");
1942
+ return {
1943
+ reason: "db-auth",
1944
+ 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.` : "")
1945
+ };
1946
+ }
1947
+ const relation = text2.match(/relation "([^"]+)" does not exist/);
1948
+ if (relation) {
1949
+ const reset = h.runCommand("db:reset");
1950
+ return {
1951
+ reason: "db-schema",
1952
+ 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).` : "")
1953
+ };
1954
+ }
1955
+ if (/ENOSPC: System limit for number of file watchers reached/.test(text2)) {
1956
+ return {
1957
+ reason: "watcher-limit",
1958
+ 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"
1959
+ };
1960
+ }
1961
+ return null;
1962
+ }
1963
+
1964
+ // src/core/dev/env.ts
1965
+ import { dirname as dirname5, join as join12 } from "path";
1966
+
1651
1967
  // src/core/env/fetch.ts
1652
1968
  async function fetchLocalEnv(deps2, mc, app) {
1653
1969
  await ensureCapability(deps2, mc.portal.apiOrigin, "module.local-env");
@@ -1737,12 +2053,12 @@ function renderEnv(original, merged) {
1737
2053
  for (const [key, value] of merged) {
1738
2054
  if (!seen.has(key)) appended.push(`${key}=${value}`);
1739
2055
  }
1740
- let text = out.join("\n");
2056
+ let text2 = out.join("\n");
1741
2057
  if (appended.length) {
1742
- if (text.length && !text.endsWith("\n")) text += "\n";
1743
- text += appended.join("\n") + "\n";
2058
+ if (text2.length && !text2.endsWith("\n")) text2 += "\n";
2059
+ text2 += appended.join("\n") + "\n";
1744
2060
  }
1745
- return text;
2061
+ return text2;
1746
2062
  }
1747
2063
 
1748
2064
  // src/core/env/pull.ts
@@ -1812,6 +2128,730 @@ function planPull(o) {
1812
2128
  };
1813
2129
  }
1814
2130
 
2131
+ // src/core/env/write.ts
2132
+ import { chmod as chmod2, readFile as readFile4, rename as rename3, unlink, writeFile as writeFile3 } from "fs/promises";
2133
+ import { randomBytes as randomBytes3 } from "crypto";
2134
+ import { basename as basename2, dirname as dirname4, join as join10 } from "path";
2135
+ async function readOrNull(p) {
2136
+ try {
2137
+ return await readFile4(p, "utf8");
2138
+ } catch (e) {
2139
+ if (e.code === "ENOENT") return null;
2140
+ throw e;
2141
+ }
2142
+ }
2143
+ async function writeSecurely(target, text2) {
2144
+ const base = basename2(target);
2145
+ const tmpName = base.endsWith(".local") ? `${base.slice(0, -".local".length)}.qore-${randomBytes3(6).toString("hex")}.local` : `${base}.qore-${randomBytes3(6).toString("hex")}.tmp`;
2146
+ const tmp = join10(dirname4(target), tmpName);
2147
+ try {
2148
+ await writeFile3(tmp, text2, { mode: 384, flag: "wx" });
2149
+ await chmod2(tmp, 384);
2150
+ await rename3(tmp, target);
2151
+ } finally {
2152
+ await unlink(tmp).catch(() => {
2153
+ });
2154
+ }
2155
+ }
2156
+ async function syncEnvFile(o) {
2157
+ const existing = await readOrNull(o.target);
2158
+ const plan = planPull({
2159
+ existing,
2160
+ example: existing === null ? o.example : null,
2161
+ fetched: o.fetched,
2162
+ managed: o.managed,
2163
+ localSecrets: o.localSecrets,
2164
+ defaultChoice: "keep",
2165
+ random: o.random
2166
+ });
2167
+ if (existing !== null && plan.text === existing) await chmod2(o.target, 384);
2168
+ else await writeSecurely(o.target, plan.text);
2169
+ return { created: plan.created, kept: plan.kept };
2170
+ }
2171
+
2172
+ // src/core/dev/lastStart.ts
2173
+ import { createHash as createHash2 } from "crypto";
2174
+ import { realpath as realpath3 } from "fs/promises";
2175
+ import { join as join11 } from "path";
2176
+ var NETWORK_REASONS = /* @__PURE__ */ new Set(["connect", "dns", "tls", "timeout"]);
2177
+ function isOffline(e) {
2178
+ if (!(e instanceof QoreError)) return false;
2179
+ if (e.code === "QORE_API_UNREACHABLE" || e.code === "QORE_API_UNAVAILABLE") return true;
2180
+ if (e.code !== "QORE_AUTH_UNAVAILABLE") return false;
2181
+ return (e.status ?? 0) >= 500 || NETWORK_REASONS.has(String(e.details?.reason));
2182
+ }
2183
+ async function stateKey(deps2, root) {
2184
+ const real = await realpath3(root).catch(() => root);
2185
+ return createHash2("sha256").update(trustKey(real, deps2.platform)).digest("hex").slice(0, 16);
2186
+ }
2187
+ async function saveLastStart(deps2, root, rec) {
2188
+ await writeJsonAtomic(join11(deps2.config.dir, "dev"), `${await stateKey(deps2, root)}.json`, rec);
2189
+ }
2190
+ async function loadLastStart(deps2, root) {
2191
+ const rec = await readJson(join11(deps2.config.dir, "dev", `${await stateKey(deps2, root)}.json`)).catch(() => null);
2192
+ return rec && rec.schemaVersion === 1 ? rec : null;
2193
+ }
2194
+
2195
+ // src/core/dev/env.ts
2196
+ function envPort(env, key) {
2197
+ const raw = env[key];
2198
+ if (raw === void 0) return null;
2199
+ const v = raw.trim().replace(/^["']|["']$/g, "");
2200
+ if (!/^\d+$/.test(v)) return null;
2201
+ const n = Number(v);
2202
+ return n >= 1024 && n <= 65535 ? n : null;
2203
+ }
2204
+ async function devEnv(deps2, o) {
2205
+ const defaults = moduleDefaults(o.type);
2206
+ const target = await resolveTarget(o.root, o.cfg.envFile ?? defaults.envFile);
2207
+ let cause = null;
2208
+ if (o.mc && o.app) {
2209
+ try {
2210
+ const local = await fetchLocalEnv(deps2, o.mc, o.app);
2211
+ const example = await readOrNull(join12(dirname5(target), ".env.example"));
2212
+ const w = await syncEnvFile({
2213
+ target,
2214
+ example,
2215
+ fetched: local.env,
2216
+ managed: local.managedKeys,
2217
+ localSecrets: o.cfg.localSecrets ?? defaults.localSecrets,
2218
+ random: o.random
2219
+ });
2220
+ return {
2221
+ env: local.env,
2222
+ offline: false,
2223
+ envFile: target,
2224
+ port: envPort(local.env, "PORT"),
2225
+ dbPort: envPort(local.env, "DB_PORT"),
2226
+ ...w
2227
+ };
2228
+ } catch (e) {
2229
+ if (!isOffline(e)) throw e;
2230
+ cause = e;
2231
+ }
2232
+ }
2233
+ const text2 = await readOrNull(target);
2234
+ if (text2 === null || !await isTrusted(deps2, o.root, o.moduleId)) {
2235
+ throw cause ?? new QoreError("QORE_API_UNREACHABLE", "The portal is unreachable", {
2236
+ hint: "The portal is unreachable and there is no env file to start from yet."
2237
+ });
2238
+ }
2239
+ const env = Object.fromEntries(parseEnv(text2));
2240
+ return { env, offline: true, envFile: target, port: envPort(env, "PORT"), dbPort: envPort(env, "DB_PORT"), created: false, kept: [] };
2241
+ }
2242
+
2243
+ // src/core/workspace/access.ts
2244
+ function devTeamsOf(am, m) {
2245
+ const own = m.devTeamIds?.length ? am.devTeams.filter((t) => m.devTeamIds.includes(t.id)) : [];
2246
+ return own.length ? own : am.devTeams;
2247
+ }
2248
+ function askMaintainer(am, m) {
2249
+ const teams = devTeamsOf(am, m);
2250
+ const names = teams.map((t) => t.name);
2251
+ const maintainers = [...new Set(teams.flatMap((t) => t.maintainers))];
2252
+ const who = names.length ? names.join(", ") : "the app's dev team";
2253
+ return { teams: names, maintainers, text: `ask a maintainer of ${who}${maintainers.length ? ` (${maintainers.join(", ")})` : ""}` };
2254
+ }
2255
+ function notClonedReason(m, am) {
2256
+ if (am && !m.githubLinked) return `GitHub account not linked (${am.accountUrl})`;
2257
+ if (am && m.repoAccess === "none") {
2258
+ const names = devTeamsOf(am, m).map((t) => t.name);
2259
+ return names.length ? `no repository access (dev team ${names.join(", ")})` : "no repository access (ask your tenant owner)";
2260
+ }
2261
+ return "run qore sync";
2262
+ }
2263
+
2264
+ // src/core/dev/findings.ts
2265
+ var plural = (n, one, many) => n === 1 ? one : many;
2266
+ function modulesMissing(o) {
2267
+ const actionFor = new Map(o.actions.map((a) => [a.module.slug, a]));
2268
+ const findings = [];
2269
+ const fixable = [];
2270
+ const blocked = [];
2271
+ const reasons = {};
2272
+ const own = (m, reason, rest) => {
2273
+ reasons[m.slug] = reason;
2274
+ return { code: "dev.modules-missing", severity: "warn", module: m.slug, modules: [m.slug], message: `${m.slug} is not cloned \u2014 ${reason}`, ...rest };
2275
+ };
2276
+ for (const m of o.missing) {
2277
+ const a = actionFor.get(m.slug);
2278
+ if (a && a.kind !== "clone") {
2279
+ if (a.kind === "conflict") {
2280
+ const c = conflictText(o.cwd, a);
2281
+ blocked.push(own(m, c.message, { action: { command: c.hint } }));
2282
+ } else if (a.kind === "unsupported") {
2283
+ blocked.push(own(m, "its repository URL is not supported", { action: { command: unsupportedRepoUrlError(m.slug, "").hint } }));
2284
+ } else {
2285
+ blocked.push(own(m, a.reason ?? "no repository yet", { why: "It will not run in this session." }));
2286
+ }
2287
+ } else if (o.am !== null && (!m.githubLinked || m.repoAccess === "none")) {
2288
+ const am = o.am;
2289
+ blocked.push(own(m, notClonedReason(m, am), { action: { command: !m.githubLinked ? `Link your GitHub account: ${am.accountUrl}` : askMaintainer(am, m).text } }));
2290
+ } else fixable.push(m);
2291
+ }
2292
+ if (fixable.length > 0) {
2293
+ const slugs = fixable.map((m) => m.slug);
2294
+ const deps2 = slugs.flatMap((s) => (o.consumers[s] ?? []).map((c) => `${c} depends on ${s}`));
2295
+ findings.push({
2296
+ code: "dev.modules-missing",
2297
+ severity: "warn",
2298
+ message: `${fixable.length} ${plural(fixable.length, "module of this app is", "modules of this app are")} not cloned: ${slugs.join(", ")}`,
2299
+ why: deps2.length > 0 ? `${deps2.join("; ")}.` : `${plural(fixable.length, "It", "They")} will not run in this session.`,
2300
+ modules: slugs,
2301
+ action: o.am ? { command: "qore sync", fix: "sync" } : { command: "qore sync" }
2302
+ });
2303
+ }
2304
+ return { findings: [...findings, ...blocked], fixable, reasons };
2305
+ }
2306
+ function strayClone(name, appName) {
2307
+ return { code: "dev.stray-clone", severity: "info", module: name, message: `${name} is no longer part of ${appName} \u2014 you can remove the directory.` };
2308
+ }
2309
+ function repoBehind(slug, s, o) {
2310
+ if (!s.ok || !s.upstream || !s.behind) return null;
2311
+ return {
2312
+ code: "dev.repo-behind",
2313
+ severity: "warn",
2314
+ module: slug,
2315
+ message: `${slug} is ${s.behind} ${plural(s.behind, "commit", "commits")} behind ${s.upstreamRef ?? "its upstream"} (as of your last fetch).`,
2316
+ action: { command: o.workspace ? "qore sync --pull" : "git pull" }
2317
+ };
2318
+ }
2319
+ function servicesMissing(slug, env, recipe, o) {
2320
+ const db = envPort(env, "DB_PORT");
2321
+ if (db === null || recipe.services.length > 0 || !o.servicesFlag || o.configured) return null;
2322
+ return {
2323
+ code: "dev.services-missing",
2324
+ severity: "warn",
2325
+ module: slug,
2326
+ message: `The portal gives ${slug} a database (DB_PORT=${db}), but ${slug} has no db:up script \u2014 no database will be started.`,
2327
+ why: "This repository is probably older than its template.",
2328
+ action: { command: 'add a "db:up" script (see the current app template), or set dev.services in .qore.json' }
2329
+ };
2330
+ }
2331
+ function providerDown(o) {
2332
+ const single = o.providers.length === 1 && o.keys.length === 1;
2333
+ return {
2334
+ code: "dev.provider-down",
2335
+ severity: "warn",
2336
+ module: o.slug,
2337
+ modules: o.providers,
2338
+ 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(", ")}).`,
2339
+ 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.`,
2340
+ action: { command: o.fix }
2341
+ };
2342
+ }
2343
+
2344
+ // src/core/dev/project.ts
2345
+ import { access, readFile as readFile5 } from "fs/promises";
2346
+ import { join as join13 } from "path";
2347
+ var LEGACY_DEV_SCRIPTS = ["node --env-file=.env --import ./dist/instrumentation.js dist/index.js"];
2348
+ var LOCKFILES = [["pnpm-lock.yaml", "pnpm"], ["package-lock.json", "npm"], ["yarn.lock", "yarn"]];
2349
+ var exists = (p) => access(p).then(() => true, () => false);
2350
+ var unsupported = (message, hint, details) => new QoreError("QORE_DEV_UNSUPPORTED", message, { hint, details });
2351
+ async function readPackageJson(root) {
2352
+ let raw;
2353
+ try {
2354
+ raw = await readFile5(join13(root, "package.json"), "utf8");
2355
+ } catch {
2356
+ throw unsupported("This repository has no package.json", "qore dev starts Node repositories through their package.json scripts.");
2357
+ }
2358
+ try {
2359
+ return JSON.parse(raw);
2360
+ } catch {
2361
+ throw unsupported("package.json is not valid JSON", "Fix package.json, then run qore dev again.");
2362
+ }
2363
+ }
2364
+ async function detectPackageManager(root, pkg) {
2365
+ if (typeof pkg.packageManager === "string") {
2366
+ const m = /^(pnpm|npm|yarn)@(.+)$/.exec(pkg.packageManager.trim());
2367
+ if (!m) throw unsupported(`packageManager "${pkg.packageManager}" is not pnpm, npm or yarn`, "qore dev supports pnpm, npm and yarn.");
2368
+ return { name: m[1], version: m[2].split("+")[0] };
2369
+ }
2370
+ for (const [file, name] of LOCKFILES) if (await exists(join13(root, file))) return { name, version: null };
2371
+ throw unsupported("No package manager declared and no lockfile found", 'Add "packageManager": "pnpm@<version>" to package.json.');
2372
+ }
2373
+ function planRecipe(o) {
2374
+ const scripts = o.pkg.scripts ?? {};
2375
+ const script = o.cfg.dev?.script ?? "dev";
2376
+ const found = scripts[script];
2377
+ if (found === void 0) {
2378
+ throw unsupported(`${o.slug} has no "${script}" script in package.json`, 'Add a "dev" script, or set dev.script in .qore.json.', { script });
2379
+ }
2380
+ if (LEGACY_DEV_SCRIPTS.includes(found.trim())) {
2381
+ throw unsupported(
2382
+ `${o.slug}'s dev script is the old backend one, which needs a build and a .env first`,
2383
+ 'Update it to the current template: "dev": "node scripts/dev.mjs" plus scripts/dev.mjs.',
2384
+ { script, found }
2385
+ );
2386
+ }
2387
+ const configured = o.cfg.dev?.services;
2388
+ for (const s of configured ?? []) {
2389
+ if (scripts[s] === void 0) {
2390
+ throw new QoreError("QORE_CONTEXT_INVALID", `.qore.json: dev.services names "${s}", which is not a script in package.json`, {
2391
+ hint: "Fix dev.services in .qore.json."
2392
+ });
2393
+ }
2394
+ }
2395
+ const services = !o.flags.services ? [] : configured ?? (scripts["db:up"] !== void 0 ? ["db:up"] : []);
2396
+ return {
2397
+ root: o.root,
2398
+ pm: o.pm,
2399
+ script,
2400
+ services,
2401
+ install: (o.cfg.dev?.install ?? true) && o.flags.install,
2402
+ dockerServices: services.some((s) => /\bdocker\b/.test(scripts[s] ?? ""))
2403
+ };
2404
+ }
2405
+ function runArgs(pm, script) {
2406
+ return { cmd: pm.name, args: ["run", script] };
2407
+ }
2408
+ function installArgs(pm) {
2409
+ return { cmd: pm.name, args: ["install"] };
2410
+ }
2411
+
2412
+ // src/core/dev/install.ts
2413
+ import { stat as stat2 } from "fs/promises";
2414
+ import { join as join14 } from "path";
2415
+ var MARKERS = {
2416
+ pnpm: [".modules.yaml"],
2417
+ npm: [".package-lock.json"],
2418
+ yarn: [".yarn-state.yml", ".yarn-integrity"]
2419
+ };
2420
+ var LOCK = { pnpm: "pnpm-lock.yaml", npm: "package-lock.json", yarn: "yarn.lock" };
2421
+ var mtime = (p) => stat2(p).then((s) => s.mtimeMs, () => null);
2422
+ async function needsInstall(root, pm) {
2423
+ if (await mtime(join14(root, "node_modules")) === null) return true;
2424
+ let marker = null;
2425
+ for (const m of MARKERS[pm.name]) marker ??= await mtime(join14(root, "node_modules", m));
2426
+ if (marker === null) return true;
2427
+ const lock = await mtime(join14(root, LOCK[pm.name]));
2428
+ return lock !== null && lock > marker;
2429
+ }
2430
+
2431
+ // src/core/diagnostics/docker.ts
2432
+ function dockerInstallUrl(platform) {
2433
+ if (platform === "darwin") return "https://docs.docker.com/desktop/setup/install/mac-install/";
2434
+ if (platform === "win32") return "https://docs.docker.com/desktop/setup/install/windows-install/";
2435
+ return "https://docs.docker.com/engine/install/";
2436
+ }
2437
+ function classifyDockerInfo(r, platform) {
2438
+ if (r.spawnError === "ENOENT") {
2439
+ return new QoreError("QORE_DOCKER_MISSING", "Docker is not installed", {
2440
+ hint: `Install Docker: ${dockerInstallUrl(platform)} \u2014 or run qore dev --no-services.`
2441
+ });
2442
+ }
2443
+ if (r.code === 0) return null;
2444
+ const hint = "Start Docker Desktop, then run qore dev again \u2014 or qore dev --no-services.";
2445
+ if (/Cannot connect to the Docker daemon|Is the docker daemon running|error during connect/i.test(r.tail.join("\n"))) {
2446
+ return new QoreError("QORE_DOCKER_NOT_RUNNING", "Docker is not running", { hint, details: { reason: "daemon" } });
2447
+ }
2448
+ return new QoreError("QORE_DOCKER_NOT_RUNNING", "Docker is not running", { hint, details: { stderr: r.tail } });
2449
+ }
2450
+ function classifyComposeVersion(r) {
2451
+ if (r.code === 0) return null;
2452
+ const hint = "Update Docker Desktop (or install the compose plugin). The legacy docker-compose v1 is not supported.";
2453
+ if (/unknown command: docker compose|'compose' is not a docker command/.test(r.tail.join("\n"))) {
2454
+ return new QoreError("QORE_DOCKER_COMPOSE_MISSING", "Docker Compose v2 is missing", { hint });
2455
+ }
2456
+ return new QoreError("QORE_DOCKER_COMPOSE_MISSING", "Docker Compose v2 is missing", { hint, details: { stderr: r.tail } });
2457
+ }
2458
+
2459
+ // src/core/diagnostics/postgres.ts
2460
+ 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/;
2461
+ function classifyComposeLogs(logs, o) {
2462
+ const text2 = logs.join("\n");
2463
+ if (!INCOMPATIBLE.test(text2)) return null;
2464
+ const details = { reason: "pg-data-version" };
2465
+ const m = text2.match(/initialized by PostgreSQL version (\d+).*this version (\d+)/);
2466
+ if (m) {
2467
+ details.found = m[1];
2468
+ details.expected = m[2];
2469
+ }
2470
+ return new QoreError("QORE_SERVICE_DATA_INCOMPATIBLE", `The local database volume of ${o.slug} was created by another Postgres version`, {
2471
+ hint: "pnpm db:reset recreates it \u2014 this deletes all local data in it. Qore never runs it for you.",
2472
+ details
2473
+ });
2474
+ }
2475
+
2476
+ // src/core/diagnostics/ports.ts
2477
+ function parseDockerPs(stdout) {
2478
+ const line2 = stdout.split(/\r?\n/).find((l) => l.trim());
2479
+ if (!line2) return null;
2480
+ const [id, name, project, workingDir] = line2.split(" ");
2481
+ if (!id || !name) return null;
2482
+ return { id, name, project: project || null, workingDir: workingDir || null };
2483
+ }
2484
+ function parseLsofCwd(stdout) {
2485
+ const m = stdout.match(/^fcwd\nn(.+)$/m);
2486
+ return m ? m[1] : null;
2487
+ }
2488
+ function parseEtime(s) {
2489
+ const m = s.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
2490
+ if (!m) return null;
2491
+ const days = m[1] ? Number(m[1]) : 0;
2492
+ const hours = m[2] ? Number(m[2]) : 0;
2493
+ const minutes = Number(m[3]);
2494
+ const seconds = Number(m[4]);
2495
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds;
2496
+ }
2497
+ function humanAge(seconds) {
2498
+ if (seconds < 60) return `${seconds} s`;
2499
+ if (seconds < 3600) return `${Math.round(seconds / 60)} min`;
2500
+ if (seconds < 86400) return `${Math.round(seconds / 3600)} h`;
2501
+ return `${Math.round(seconds / 86400)} d`;
2502
+ }
2503
+
2504
+ // src/core/diagnostics/pm.ts
2505
+ function pmVersionMismatch(pm, printed) {
2506
+ if (pm.version === null) return null;
2507
+ const lines = printed.split("\n");
2508
+ let found = null;
2509
+ for (let i = lines.length - 1; i >= 0; i--) {
2510
+ const line2 = lines[i].trim();
2511
+ if (/^\d+\.\d+\.\d+/.test(line2)) {
2512
+ found = line2;
2513
+ break;
2514
+ }
2515
+ }
2516
+ if (found === null) return null;
2517
+ if (found.split(".")[0] === pm.version.split(".")[0]) return null;
2518
+ return new QoreError("QORE_PACKAGE_MANAGER", `${pm.name} ${found} does not match the pinned ${pm.name}@${pm.version}`, {
2519
+ 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.)`,
2520
+ details: { found, pinned: pm.version }
2521
+ });
2522
+ }
2523
+ function classifyInstall(tail, command, root) {
2524
+ const joined = tail.join("\n");
2525
+ if (/ERR_PNPM_OUTDATED_LOCKFILE/.test(joined)) {
2526
+ return new QoreError("QORE_INSTALL_FAILED", "The lockfile does not match package.json", {
2527
+ hint: `Someone committed one without the other. Run \`${command} --no-frozen-lockfile\` in ${root} and commit the lockfile.`,
2528
+ details: { reason: "outdated-lockfile", command, stderr: tail }
2529
+ });
2530
+ }
2531
+ return new QoreError("QORE_INSTALL_FAILED", `${command} failed`, {
2532
+ hint: `Run \`${command}\` in ${root} to see the full output.`,
2533
+ details: { command, stderr: tail }
2534
+ });
2535
+ }
2536
+
2537
+ // src/core/diagnostics/versions.ts
2538
+ import semver from "semver";
2539
+ function nodeSatisfies(found, range) {
2540
+ if (semver.validRange(range) === null) return null;
2541
+ return semver.satisfies(found, range);
2542
+ }
2543
+ function nodeVersionError(found, range, slug) {
2544
+ const min = semver.minVersion(range);
2545
+ const major = min ? min.major : range;
2546
+ return new QoreError("QORE_NODE_VERSION", `${slug} needs Node ${range}, found ${found}`, {
2547
+ hint: `nvm install ${major} / fnm use ${major}, then run qore dev again.`,
2548
+ details: { found, required: range }
2549
+ });
2550
+ }
2551
+
2552
+ // src/core/dev/preflight.ts
2553
+ var pmMajor = (v) => v ? v.split(".")[0] : "latest";
2554
+ var GRACE_MS = 1e4;
2555
+ var secs = (ms) => `${Math.round(ms / 1e3)} s`;
2556
+ async function runTool(deps2, cmd, args, o) {
2557
+ if (o.signal.aborted) throw new CancelledError();
2558
+ const tail = [];
2559
+ const c = deps2.launch.launch(cmd, args, { cwd: o.cwd, env: { ...o.env, LC_ALL: "C" }, onLine: (l) => {
2560
+ tail.push(l.line);
2561
+ if (tail.length > 50) tail.shift();
2562
+ o.onLine(l);
2563
+ } });
2564
+ let kill;
2565
+ const onAbort = () => {
2566
+ c.stop("SIGTERM");
2567
+ kill = setTimeout(() => c.stop("SIGKILL"), GRACE_MS);
2568
+ };
2569
+ o.signal.addEventListener("abort", onAbort, { once: true });
2570
+ let exit;
2571
+ try {
2572
+ exit = await c.exited;
2573
+ } finally {
2574
+ o.signal.removeEventListener("abort", onAbort);
2575
+ clearTimeout(kill);
2576
+ }
2577
+ if (o.signal.aborted) throw new CancelledError();
2578
+ return { code: exit.code, spawnError: exit.spawnError, tail };
2579
+ }
2580
+ async function checkPackageManager(deps2, r, onLine, signal) {
2581
+ const res = await runTool(deps2, r.pm.name, ["--version"], { cwd: r.root, env: deps2.env, onLine, signal });
2582
+ const hint = `Install it: npm i -g ${r.pm.name}@${pmMajor(r.pm.version)}`;
2583
+ if (res.spawnError === "ENOENT") {
2584
+ throw new QoreError("QORE_PACKAGE_MANAGER", `${r.pm.name} is not installed`, { hint, details: { packageManager: r.pm } });
2585
+ }
2586
+ if (res.code !== 0) {
2587
+ throw new QoreError("QORE_PACKAGE_MANAGER", `${r.pm.name} is not installed`, { hint, details: { packageManager: r.pm, stderr: res.tail } });
2588
+ }
2589
+ const mismatch = pmVersionMismatch(r.pm, res.tail.join("\n"));
2590
+ if (mismatch) throw mismatch;
2591
+ return res.tail.map((l) => l.trim()).findLast((l) => /^\d+\.\d+\.\d+/.test(l)) ?? null;
2592
+ }
2593
+ async function checkNode(deps2, r, pkg, slug, signal) {
2594
+ const range = pkg.engines?.node;
2595
+ if (!range) return null;
2596
+ const res = await runTool(deps2, "node", ["--version"], { cwd: r.root, env: deps2.env, onLine: () => {
2597
+ }, signal });
2598
+ if (res.spawnError === "ENOENT") {
2599
+ throw new QoreError("QORE_NODE_VERSION", "node is not on PATH", {
2600
+ hint: "Install Node (https://nodejs.org) or run nvm use, then reinstall qore.",
2601
+ details: { required: range }
2602
+ });
2603
+ }
2604
+ if (res.spawnError || res.code !== 0) {
2605
+ throw new QoreError("QORE_NODE_VERSION", "node --version failed", { details: { required: range, stderr: res.tail } });
2606
+ }
2607
+ const found = (res.tail.at(-1) ?? "").trim().replace(/^v/, "");
2608
+ if (nodeSatisfies(found, range) === false) throw nodeVersionError(found, range, slug);
2609
+ return found;
2610
+ }
2611
+ async function installIfNeeded(deps2, r, slug, onLine, report, signal) {
2612
+ if (!r.install) {
2613
+ report({ module: slug, step: "install", status: "skip", detail: "disabled" });
2614
+ return;
2615
+ }
2616
+ if (!await needsInstall(r.root, r.pm)) {
2617
+ report({ module: slug, step: "install", status: "skip", detail: "up to date" });
2618
+ return;
2619
+ }
2620
+ report({ module: slug, step: "install", status: "start" });
2621
+ const began = deps2.now().getTime();
2622
+ const { cmd, args } = installArgs(r.pm);
2623
+ const res = await runTool(deps2, cmd, args, { cwd: r.root, env: deps2.env, onLine, signal });
2624
+ if (res.code !== 0) {
2625
+ const command = [cmd, ...args].join(" ");
2626
+ const err = classifyInstall(res.tail, command, r.root);
2627
+ if (err.details?.reason === "outdated-lockfile") throw err;
2628
+ throw new QoreError("QORE_INSTALL_FAILED", `${command} failed in ${slug}`, { hint: err.hint, details: err.details });
2629
+ }
2630
+ report({ module: slug, step: "install", status: "done", detail: secs(deps2.now().getTime() - began) });
2631
+ }
2632
+ async function portInUseError(deps2, o) {
2633
+ const owner = await deps2.ports.owner(o.port);
2634
+ 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)));
2635
+ if (qoreRun) {
2636
+ return new QoreError("QORE_PORT_IN_USE", `A previous qore dev is still running (pid ${qoreRun.qorePid})`, {
2637
+ hint: `Stop it (Ctrl+C in its terminal, or kill ${qoreRun.qorePid}), then run qore dev again.`,
2638
+ details: { port: o.port, module: o.slug, key: o.key, owner: "qore", pid: qoreRun.qorePid }
2639
+ });
2640
+ }
2641
+ if (owner) {
2642
+ if (/^(com\.docker\.backend|com\.docker\.vpnkit|docker-proxy|vpnkit|dockerd)$/.test(owner.command)) {
2643
+ const pub = await deps2.ports.dockerPublisher(o.port);
2644
+ if (pub) {
2645
+ return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is held by the Docker container ${pub.name} (needed by ${o.slug})`, {
2646
+ 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}.`,
2647
+ details: { port: o.port, module: o.slug, key: o.key, owner: "docker", ...pub }
2648
+ });
2649
+ }
2650
+ }
2651
+ const detail2 = await deps2.ports.ownerDetail(owner.pid);
2652
+ const extras = [];
2653
+ if (detail2.ageSeconds !== null) extras.push(`started ${humanAge(detail2.ageSeconds)} ago`);
2654
+ if (detail2.cwd !== null) {
2655
+ const cwd = deps2.home && detail2.cwd.startsWith(deps2.home) ? `~${detail2.cwd.slice(deps2.home.length)}` : detail2.cwd;
2656
+ extras.push(`in ${cwd}`);
2657
+ }
2658
+ const suffix = extras.length ? `, ${extras.join(" ")}` : "";
2659
+ return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is already in use (needed by ${o.slug})`, {
2660
+ 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}.`,
2661
+ details: {
2662
+ port: o.port,
2663
+ module: o.slug,
2664
+ key: o.key,
2665
+ owner: "process",
2666
+ pid: owner.pid,
2667
+ command: owner.command,
2668
+ ...detail2.cwd !== null ? { cwd: detail2.cwd } : {},
2669
+ ...detail2.ageSeconds !== null ? { ageSeconds: detail2.ageSeconds } : {}
2670
+ }
2671
+ });
2672
+ }
2673
+ const findHint = deps2.platform === "win32" ? `netstat -ano | findstr :${o.port}` : `lsof -iTCP:${o.port} -sTCP:LISTEN`;
2674
+ return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is already in use (needed by ${o.slug})`, {
2675
+ hint: `Find it with: ${findHint}`,
2676
+ details: { port: o.port, module: o.slug, key: o.key }
2677
+ });
2678
+ }
2679
+ async function composeFailure(deps2, r, slug, env, signal, message, command, stderr) {
2680
+ if (!r.dockerServices) {
2681
+ return new QoreError("QORE_SERVICE_UNHEALTHY", message, { details: { command, stderr } });
2682
+ }
2683
+ const logs = await runTool(deps2, "docker", ["compose", "logs", "--no-color", "--tail", "50"], { cwd: r.root, env, onLine: () => {
2684
+ }, signal });
2685
+ const dataErr = classifyComposeLogs(logs.tail, { slug, script: "db:up" });
2686
+ if (dataErr) return dataErr;
2687
+ return new QoreError("QORE_SERVICE_UNHEALTHY", message, {
2688
+ hint: `The last lines of docker compose logs are in details.logs; run \`docker compose logs\` in ${r.root} for all of them.`,
2689
+ details: { command, stderr, logs: logs.tail.slice(-20) }
2690
+ });
2691
+ }
2692
+ async function startServices(deps2, r, slug, env, dbPort, waitMs, signal, onLine, report) {
2693
+ if (r.services.length === 0) {
2694
+ report({ module: slug, step: "services", status: "skip" });
2695
+ return;
2696
+ }
2697
+ report({ module: slug, step: "services", status: "start", ...dbPort !== null ? { detail: `:${dbPort}` } : {} });
2698
+ const began = deps2.now().getTime();
2699
+ if (r.dockerServices) {
2700
+ const res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: r.root, env, onLine, signal });
2701
+ const dockerErr = classifyDockerInfo(res, deps2.platform);
2702
+ if (dockerErr) throw dockerErr;
2703
+ const compose = await runTool(deps2, "docker", ["compose", "version"], { cwd: r.root, env, onLine: () => {
2704
+ }, signal });
2705
+ const composeErr = classifyComposeVersion(compose);
2706
+ if (composeErr) throw composeErr;
2707
+ }
2708
+ for (const script of r.services) {
2709
+ const { cmd, args } = runArgs(r.pm, script);
2710
+ const res = await runTool(deps2, cmd, args, { cwd: r.root, env, onLine, signal });
2711
+ if (res.code !== 0) {
2712
+ if (dbPort !== null && /port is already allocated|address already in use/i.test(res.tail.join("\n"))) {
2713
+ throw await portInUseError(deps2, { slug, port: dbPort, key: "DB_PORT" });
2714
+ }
2715
+ throw await composeFailure(deps2, r, slug, env, signal, `${script} failed in ${slug}`, `${cmd} ${args.join(" ")}`, res.tail);
2716
+ }
2717
+ }
2718
+ if (dbPort !== null) {
2719
+ const ok2 = await deps2.ports.waitFor(dbPort, waitMs, signal);
2720
+ if (signal.aborted) throw new CancelledError();
2721
+ if (!ok2) {
2722
+ throw await composeFailure(
2723
+ deps2,
2724
+ r,
2725
+ slug,
2726
+ env,
2727
+ signal,
2728
+ `The database for ${slug} did not accept connections on port ${dbPort} within ${waitMs / 1e3} s`,
2729
+ void 0,
2730
+ []
2731
+ );
2732
+ }
2733
+ }
2734
+ const took = secs(deps2.now().getTime() - began);
2735
+ report({ module: slug, step: "services", status: "done", detail: dbPort !== null ? `:${dbPort} (${took})` : `(${took})` });
2736
+ }
2737
+ async function checkPort(deps2, o) {
2738
+ if (o.port === null) return;
2739
+ if (!await deps2.ports.inUse(o.port)) return;
2740
+ throw await portInUseError(deps2, { slug: o.slug, port: o.port, key: o.key, runs: o.runs });
2741
+ }
2742
+ function abortableDelay(signal) {
2743
+ return (ms) => new Promise((resolve5) => {
2744
+ const done = () => {
2745
+ clearTimeout(t);
2746
+ signal.removeEventListener("abort", done);
2747
+ resolve5();
2748
+ };
2749
+ const t = setTimeout(done, ms);
2750
+ signal.addEventListener("abort", done, { once: true });
2751
+ });
2752
+ }
2753
+ async function waitForDocker(deps2, o) {
2754
+ const delay = o.delay ?? abortableDelay(o.signal);
2755
+ const now = o.now ?? Date.now;
2756
+ const deadline = now() + o.timeoutMs;
2757
+ for (; ; ) {
2758
+ await delay(Math.min(o.pollMs, Math.max(0, deadline - now())));
2759
+ if (o.signal.aborted) throw new CancelledError();
2760
+ const remaining = deadline - now();
2761
+ if (remaining <= 0) return false;
2762
+ const poll = AbortSignal.any([o.signal, AbortSignal.timeout(remaining)]);
2763
+ let res;
2764
+ try {
2765
+ res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: o.cwd, env: o.env ?? deps2.env, onLine: () => {
2766
+ }, signal: poll });
2767
+ } catch (e) {
2768
+ if (e instanceof CancelledError && !o.signal.aborted) return false;
2769
+ throw e;
2770
+ }
2771
+ if (classifyDockerInfo(res, deps2.platform) === null) return true;
2772
+ }
2773
+ }
2774
+ var MAX_PARENT_HOPS = 10;
2775
+ var AGE_SLACK_S = 5;
2776
+ async function leftoverOwnsPort(deps2, run4, port) {
2777
+ if (deps2.platform === "win32" || !deps2.launch.signal(run4.qorePid, 0)) return false;
2778
+ const { ageSeconds } = await deps2.ports.ownerDetail(run4.qorePid);
2779
+ const recordAge = (deps2.now().getTime() - Date.parse(run4.startedAt)) / 1e3;
2780
+ if (ageSeconds === null || !Number.isFinite(recordAge) || ageSeconds < recordAge - AGE_SLACK_S) return false;
2781
+ const ours = /* @__PURE__ */ new Set([run4.qorePid, ...run4.children.map((c) => c.pid).filter((p) => p !== null)]);
2782
+ let pid = (await deps2.ports.owner(port))?.pid ?? null;
2783
+ for (let hop = 0; pid !== null && pid > 1 && hop <= MAX_PARENT_HOPS; hop++) {
2784
+ if (ours.has(pid)) return true;
2785
+ pid = await deps2.ports.parentPid(pid);
2786
+ }
2787
+ return false;
2788
+ }
2789
+
2790
+ // src/core/dev/providers.ts
2791
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
2792
+ function connPorts(env) {
2793
+ const out = [];
2794
+ for (const key of Object.keys(env).sort()) {
2795
+ const m = /^CONN_(.+)_URL$/.exec(key);
2796
+ if (!m) continue;
2797
+ let url;
2798
+ try {
2799
+ url = new URL(env[key].trim().replace(/^["']|["']$/g, ""));
2800
+ } catch {
2801
+ continue;
2802
+ }
2803
+ const port = Number(url.port);
2804
+ if (port > 0 && LOCAL_HOSTS.has(url.hostname)) out.push({ key, alias: m[1], port });
2805
+ }
2806
+ return out;
2807
+ }
2808
+ function deadAliases(env, listening) {
2809
+ return connPorts(env).filter((c) => !listening.has(c.port)).map((c) => c.key);
2810
+ }
2811
+ function hintContext(recipe, pkg, env) {
2812
+ const { cmd, args } = installArgs(recipe.pm);
2813
+ const ports = /* @__PURE__ */ new Map();
2814
+ for (const c of connPorts(env)) ports.set(c.port, { kind: "provider", key: c.key });
2815
+ const db = envPort(env, "DB_PORT");
2816
+ if (db !== null) ports.set(db, { kind: "database" });
2817
+ return {
2818
+ installCommand: [cmd, ...args].join(" "),
2819
+ runCommand: (script) => {
2820
+ if (pkg.scripts?.[script] === void 0) return null;
2821
+ const r = runArgs(recipe.pm, script);
2822
+ return [r.cmd, ...r.args].join(" ");
2823
+ },
2824
+ ports
2825
+ };
2826
+ }
2827
+
2828
+ // src/core/dev/runFile.ts
2829
+ import { unlink as unlink2 } from "fs/promises";
2830
+ import { join as join15 } from "path";
2831
+ async function runFilePath(deps2, root) {
2832
+ const dir2 = join15(deps2.config.dir, "run");
2833
+ const name = `${await stateKey(deps2, root)}.json`;
2834
+ return { dir: dir2, name, path: join15(dir2, name) };
2835
+ }
2836
+ async function writeRunFile(deps2, root, rec) {
2837
+ const { dir: dir2, name } = await runFilePath(deps2, root);
2838
+ await writeJsonAtomic(dir2, name, rec);
2839
+ }
2840
+ async function removeRunFile(deps2, root) {
2841
+ const { path } = await runFilePath(deps2, root);
2842
+ await unlink2(path).catch(() => {
2843
+ });
2844
+ }
2845
+ async function leftoverRun(deps2, root, o = {}) {
2846
+ const { path } = await runFilePath(deps2, root);
2847
+ const rec = await readJson(path).catch(() => null);
2848
+ if (!rec || rec.schemaVersion !== 1) return null;
2849
+ if (deps2.launch.isAlive(rec.qorePid)) return rec;
2850
+ if (o.prune !== false) await unlink2(path).catch(() => {
2851
+ });
2852
+ return null;
2853
+ }
2854
+
1815
2855
  // src/cli/context.ts
1816
2856
  var allOfKind = (c, kind) => !!c && c.length > 0 && c.every((x) => x.kind === kind && typeof x.id === "string");
1817
2857
  async function moduleContextFor(ctx, flags, command) {
@@ -1838,11 +2878,11 @@ async function moduleContextFor(ctx, flags, command) {
1838
2878
  }
1839
2879
  async function appFor(ctx, mc, app) {
1840
2880
  try {
1841
- return pickApp(mc, app);
2881
+ return pickApp2(mc, app);
1842
2882
  } catch (e) {
1843
2883
  if (!(e instanceof QoreError) || e.code !== "QORE_APP_AMBIGUOUS" || !ctx.io.interactive) throw e;
1844
2884
  const id = await ctx.io.select("Select an app", mc.module.apps.map((a) => ({ name: a.name, value: a.id })));
1845
- return pickApp(mc, id);
2885
+ return pickApp2(mc, id);
1846
2886
  }
1847
2887
  }
1848
2888
  function sourceText(mc) {
@@ -1855,63 +2895,1704 @@ function contextLine(mc, app) {
1855
2895
  return `\u2192 ${parts.join(" / ")} (${sourceText(mc)})`;
1856
2896
  }
1857
2897
  function contextJson(mc, app) {
1858
- const { id, slug, name, type, access } = mc.module;
2898
+ const { id, slug, name, type, access: access2 } = mc.module;
1859
2899
  return {
1860
2900
  portal: mc.portal.builtin ?? mc.portal.apiOrigin,
1861
2901
  tenant: mc.module.tenant,
1862
2902
  app: app ?? null,
1863
- module: { id, slug, name, type, access },
2903
+ module: { id, slug, name, type, access: access2 },
1864
2904
  source: mc.source.kind === "remote" ? mc.source.remote : mc.source.kind === "workspace" ? WORKSPACE_FILE : "--module",
1865
2905
  ...mc.source.kind === "flag" && mc.source.overrides ? { overrides: mc.source.overrides } : {}
1866
2906
  };
1867
2907
  }
1868
2908
 
1869
- // src/cli/trustPrompt.ts
1870
- async function ensureTrusted(ctx, mc, app, o) {
1871
- const root = mc.repoRoot ?? ctx.deps.cwd;
1872
- if (!ctx.opts().json) ctx.io.err(contextLine(mc, app));
1873
- if (await isTrusted(ctx.deps, root, mc.module.id)) return;
1874
- if (o.yes) {
1875
- await trust(ctx.deps, root, mc.module.id);
1876
- return;
1877
- }
2909
+ // src/cli/findings.ts
2910
+ function canOffer(ctx) {
2911
+ const g = ctx.opts();
2912
+ return ctx.io.interactive && !g.json && g.input !== false;
2913
+ }
2914
+ function findingEvent(f) {
2915
+ const { severity, ...rest } = f;
2916
+ return { type: severity === "info" ? "info" : "warning", ...rest };
2917
+ }
2918
+ function renderFinding(ctx, f) {
2919
+ if (ctx.opts().json) {
2920
+ ctx.io.out(JSON.stringify(findingEvent(f)));
2921
+ return;
2922
+ }
2923
+ ctx.io.err(`${f.severity === "info" ? "\u2192" : "!"} ${f.message}`);
2924
+ if (f.why) ctx.io.err(` ${f.why}`);
2925
+ if (f.action) ctx.io.err(` Fix: ${f.action.command}`);
2926
+ }
2927
+ async function offerFix(ctx, question, o) {
2928
+ if (!canOffer(ctx)) return false;
2929
+ return ctx.io.confirm(question, o.signal, { default: o.default });
2930
+ }
2931
+
2932
+ // src/cli/devRecover.ts
2933
+ var DOCKER_WAIT = { timeoutMs: 12e4, pollMs: 2e3 };
2934
+ var STOP_WAIT_MS = 1e4;
2935
+ var daemonDown = (e) => e instanceof QoreError && e.code === "QORE_DOCKER_NOT_RUNNING" && e.details?.reason === "daemon";
2936
+ function dockerRecovery(ctx, signal) {
2937
+ let answer;
2938
+ return async (at, fn) => {
2939
+ try {
2940
+ await fn();
2941
+ } catch (e) {
2942
+ if (!daemonDown(e) || !canOffer(ctx)) throw e;
2943
+ answer ??= (async () => {
2944
+ if (!await offerFix(ctx, "Docker is not running. Wait for it to start?", { default: true, signal })) return false;
2945
+ ctx.io.err("Waiting for Docker\u2026 (Ctrl+C to stop)");
2946
+ const ok2 = await waitForDocker(ctx.deps, { ...at, signal, ...DOCKER_WAIT });
2947
+ if (ok2) ctx.io.err("\u2713 Docker is running");
2948
+ return ok2;
2949
+ })();
2950
+ if (!await answer) throw e;
2951
+ await fn();
2952
+ }
2953
+ };
2954
+ }
2955
+ async function stopLeftover(ctx, e, runs, signal) {
2956
+ const { deps: deps2, io: io2 } = ctx;
2957
+ const port = e.details?.port;
2958
+ const run4 = runs.find((r) => r.qorePid === e.details?.pid);
2959
+ if (!run4 || typeof port !== "number" || !await leftoverOwnsPort(deps2, run4, port)) return false;
2960
+ if (!await offerFix(ctx, `Stop the previous qore dev (pid ${run4.qorePid})?`, { default: false, signal })) return false;
2961
+ if (!deps2.launch.signal(run4.qorePid, "SIGTERM")) {
2962
+ io2.err(`! pid ${run4.qorePid} could not be signalled`);
2963
+ return false;
2964
+ }
2965
+ if (await deps2.ports.waitFree(port, STOP_WAIT_MS, signal)) return true;
2966
+ if (!signal.aborted) io2.err(`! pid ${run4.qorePid} did not exit within ${STOP_WAIT_MS / 1e3} s`);
2967
+ return false;
2968
+ }
2969
+ async function checkPortOrStopLeftover(ctx, o, signal) {
2970
+ try {
2971
+ await checkPort(ctx.deps, o);
2972
+ } catch (e) {
2973
+ if (!(e instanceof QoreError) || e.code !== "QORE_PORT_IN_USE" || e.details?.owner !== "qore" || !canOffer(ctx)) throw e;
2974
+ const freed = await stopLeftover(ctx, e, o.runs, signal);
2975
+ if (signal.aborted) throw new CancelledError();
2976
+ if (!freed) throw e;
2977
+ await checkPort(ctx.deps, o);
2978
+ }
2979
+ }
2980
+
2981
+ // src/cli/progress.ts
2982
+ function text(e, o) {
2983
+ switch (`${e.step}:${e.status}`) {
2984
+ case "pm:done":
2985
+ return `\u2713 ${e.detail}`;
2986
+ case "install:skip":
2987
+ return e.detail === "up to date" ? "\u2713 dependencies up to date" : `- install skipped (${o.installFlag ? "dev.install is false in .qore.json" : "--no-install"})`;
2988
+ case "install:start":
2989
+ return "\u22EF installing dependencies\u2026";
2990
+ case "install:done":
2991
+ return `\u2713 dependencies installed (${e.detail})`;
2992
+ case "env:done":
2993
+ return `\u2713 env ${e.detail}`;
2994
+ case "services:skip":
2995
+ return o.servicesFlag ? null : "- services skipped (--no-services)";
2996
+ case "services:start":
2997
+ return e.detail ? `\u22EF database waiting for ${e.detail}\u2026` : "\u22EF starting services\u2026";
2998
+ case "services:done":
2999
+ return e.detail?.startsWith(":") ? `\u2713 database ready on ${e.detail}` : `\u2713 services up ${e.detail}`;
3000
+ case "ports:done":
3001
+ return `\u2713 port ${e.detail} free`;
3002
+ default:
3003
+ return null;
3004
+ }
3005
+ }
3006
+ function progressReporter(ctx, o) {
3007
+ return (e) => {
3008
+ if (ctx.opts().json) {
3009
+ ctx.io.out(JSON.stringify({ type: "step", ...e }));
3010
+ return;
3011
+ }
3012
+ const line2 = text(e, o);
3013
+ if (line2 !== null) ctx.io.err(o.prefix ? o.prefix(e.module, line2) : line2);
3014
+ };
3015
+ }
3016
+
3017
+ // src/cli/termination.ts
3018
+ var preflightHook = {};
3019
+ function preflightTermination(abort, raise) {
3020
+ let by;
3021
+ preflightHook.onSignal = (sig) => {
3022
+ by ??= sig;
3023
+ abort();
3024
+ };
3025
+ return { reRaise: async (flush) => {
3026
+ if (!by) return;
3027
+ await flush();
3028
+ raise(by);
3029
+ } };
3030
+ }
3031
+ async function inPreflight(signals, outer, fn) {
3032
+ const ac2 = new AbortController();
3033
+ const onOuter = () => ac2.abort();
3034
+ outer.addEventListener("abort", onOuter);
3035
+ if (outer.aborted) ac2.abort();
3036
+ const handlers = ["SIGTERM", "SIGHUP"].map((sig) => [sig, () => {
3037
+ preflightHook.onSignal?.(sig);
3038
+ ac2.abort();
3039
+ }]);
3040
+ let armed = false;
3041
+ const arm = () => {
3042
+ if (armed) return;
3043
+ armed = true;
3044
+ for (const [sig, h] of handlers) signals.on(sig, h);
3045
+ };
3046
+ try {
3047
+ return await fn({ signal: ac2.signal, arm });
3048
+ } finally {
3049
+ if (armed) for (const [sig, h] of handlers) signals.off(sig, h);
3050
+ outer.removeEventListener("abort", onOuter);
3051
+ }
3052
+ }
3053
+
3054
+ // src/cli/trustPrompt.ts
3055
+ async function ensureTrusted(ctx, mc, app, o) {
3056
+ const root = mc.repoRoot ?? ctx.deps.cwd;
3057
+ if (!ctx.opts().json) ctx.io.err(contextLine(mc, app));
3058
+ if (await isTrusted(ctx.deps, root, mc.module.id)) return;
3059
+ if (o.yes) {
3060
+ await trust(ctx.deps, root, mc.module.id);
3061
+ return;
3062
+ }
1878
3063
  if (!ctx.io.interactive) {
1879
3064
  throw new QoreError("QORE_CONTEXT_REQUIRED", `This repository is not trusted for ${mc.module.slug} yet`, {
1880
3065
  hint: "Run interactively once, or pass --yes.",
1881
3066
  details: { reason: "untrusted", repository: root }
1882
3067
  });
1883
3068
  }
1884
- if (mc.source.kind === "remote") {
1885
- const key = repoKey(mc.source.url);
1886
- ctx.io.err(` from remote ${mc.source.remote}: ${key ?? "(unparsable remote URL)"}`);
3069
+ if (mc.source.kind === "remote") {
3070
+ const key = repoKey(mc.source.url);
3071
+ ctx.io.err(` from remote ${mc.source.remote}: ${key ?? "(unparsable remote URL)"}`);
3072
+ }
3073
+ if (!await ctx.io.confirm("Pull local secrets for this module into this repository?")) throw new CancelledError();
3074
+ await trust(ctx.deps, root, mc.module.id);
3075
+ }
3076
+
3077
+ // src/cli/commands/devWorkspace.ts
3078
+ import { randomBytes as randomBytes4 } from "crypto";
3079
+ import { realpath as realpath4 } from "fs/promises";
3080
+ import { join as join17, relative as relative5 } from "path";
3081
+
3082
+ // src/core/dev/order.ts
3083
+ function selectSkips(modules, sel) {
3084
+ if (sel.only && sel.skip) {
3085
+ throw new QoreError("QORE_CONTEXT_INVALID", "--only and --skip cannot be used together", {
3086
+ hint: "Pass either --only or --skip, not both."
3087
+ });
3088
+ }
3089
+ const slugs = modules.map((m) => m.slug).sort();
3090
+ const known = new Set(slugs);
3091
+ for (const s of [...sel.only ?? [], ...sel.skip ?? []]) {
3092
+ if (!known.has(s)) {
3093
+ throw new QoreError("QORE_CONTEXT_INVALID", `Unknown module: ${s}`, {
3094
+ hint: `Valid slugs: ${slugs.join(", ")}`
3095
+ });
3096
+ }
3097
+ }
3098
+ const onlySet = sel.only ? new Set(sel.only) : null;
3099
+ const skipSet = sel.skip ? new Set(sel.skip) : null;
3100
+ const reasons = /* @__PURE__ */ new Map();
3101
+ for (const m of modules) {
3102
+ if (onlySet && !onlySet.has(m.slug) || skipSet && skipSet.has(m.slug)) {
3103
+ reasons.set(m.slug, "excluded");
3104
+ } else if (!m.present) {
3105
+ reasons.set(m.slug, "not-cloned");
3106
+ } else if (!m.canWrite) {
3107
+ reasons.set(m.slug, "no-write");
3108
+ }
3109
+ }
3110
+ return reasons;
3111
+ }
3112
+ function tarjanSccs(starting, byId, startingIds) {
3113
+ let index = 0;
3114
+ const indices = /* @__PURE__ */ new Map();
3115
+ const lowlink = /* @__PURE__ */ new Map();
3116
+ const onStack = /* @__PURE__ */ new Map();
3117
+ const stack = [];
3118
+ const sccs = [];
3119
+ function strongconnect(id) {
3120
+ indices.set(id, index);
3121
+ lowlink.set(id, index);
3122
+ index++;
3123
+ stack.push(id);
3124
+ onStack.set(id, true);
3125
+ const m = byId.get(id);
3126
+ for (const dep of m.dependsOn) {
3127
+ if (!startingIds.has(dep)) continue;
3128
+ if (!indices.has(dep)) {
3129
+ strongconnect(dep);
3130
+ lowlink.set(id, Math.min(lowlink.get(id), lowlink.get(dep)));
3131
+ } else if (onStack.get(dep)) {
3132
+ lowlink.set(id, Math.min(lowlink.get(id), indices.get(dep)));
3133
+ }
3134
+ }
3135
+ if (lowlink.get(id) === indices.get(id)) {
3136
+ const comp = [];
3137
+ let w;
3138
+ do {
3139
+ w = stack.pop();
3140
+ onStack.set(w, false);
3141
+ comp.push(w);
3142
+ } while (w !== id);
3143
+ sccs.push(comp);
3144
+ }
3145
+ }
3146
+ for (const m of starting) {
3147
+ if (!indices.has(m.id)) strongconnect(m.id);
3148
+ }
3149
+ return sccs;
3150
+ }
3151
+ function planDev(modules, sel) {
3152
+ const reasons = selectSkips(modules, sel);
3153
+ const byId = new Map(modules.map((m) => [m.id, m]));
3154
+ const starting = modules.filter((m) => !reasons.has(m.slug));
3155
+ const startingIds = new Set(starting.map((m) => m.id));
3156
+ const sccs = tarjanSccs(starting, byId, startingIds);
3157
+ const sccOf = /* @__PURE__ */ new Map();
3158
+ const sccSlugs = sccs.map((comp) => comp.map((id) => byId.get(id).slug).sort());
3159
+ sccs.forEach((comp, i) => comp.forEach((id) => sccOf.set(id, i)));
3160
+ const cycles = sccSlugs.filter((slugs, i) => slugs.length > 1 || sccs[i].some((id) => byId.get(id).dependsOn.includes(id))).map((slugs) => slugs);
3161
+ const providerSccsOf = /* @__PURE__ */ new Map();
3162
+ sccs.forEach((comp, i) => {
3163
+ const set = /* @__PURE__ */ new Set();
3164
+ for (const id of comp) {
3165
+ for (const dep of byId.get(id).dependsOn) {
3166
+ if (!startingIds.has(dep)) continue;
3167
+ const j = sccOf.get(dep);
3168
+ if (j !== i) set.add(j);
3169
+ }
3170
+ }
3171
+ providerSccsOf.set(i, set);
3172
+ });
3173
+ const minSlug = sccSlugs.map((slugs) => slugs[0]);
3174
+ const visited = new Array(sccs.length).fill(false);
3175
+ const orderedSccIndices = [];
3176
+ function visit(i) {
3177
+ if (visited[i]) return;
3178
+ visited[i] = true;
3179
+ const providers = [...providerSccsOf.get(i)].sort((a, b) => minSlug[a].localeCompare(minSlug[b]));
3180
+ for (const p of providers) visit(p);
3181
+ orderedSccIndices.push(i);
3182
+ }
3183
+ const order = sccs.map((_, i) => i).sort((a, b) => minSlug[a].localeCompare(minSlug[b]));
3184
+ for (const i of order) visit(i);
3185
+ const start = [];
3186
+ for (const i of orderedSccIndices) start.push(...sccSlugs[i]);
3187
+ const waitFor = {};
3188
+ for (const m of starting) {
3189
+ const mySccId = sccOf.get(m.id);
3190
+ const providers = m.dependsOn.filter((d) => startingIds.has(d) && sccOf.get(d) !== mySccId).map((d) => byId.get(d).slug);
3191
+ waitFor[m.slug] = [...new Set(providers)].sort();
3192
+ }
3193
+ const skipped = [...reasons.entries()].map(([slug, reason]) => ({ slug, reason })).sort((a, b) => a.slug.localeCompare(b.slug));
3194
+ const missingProviders = {};
3195
+ for (const m of starting) {
3196
+ const missing = m.dependsOn.map((d) => byId.get(d)).filter((dep) => dep !== void 0 && reasons.has(dep.slug)).map((dep) => dep.slug);
3197
+ if (missing.length > 0) {
3198
+ missingProviders[m.slug] = [...new Set(missing)].sort();
3199
+ }
3200
+ }
3201
+ return { start, waitFor, cycles, skipped, missingProviders };
3202
+ }
3203
+
3204
+ // src/core/dev/supervisor.ts
3205
+ import { constants } from "os";
3206
+ var EARLY_MS = 1e4;
3207
+ var GRACE_MS2 = 1e4;
3208
+ function exitCode(e) {
3209
+ if (e.spawnError) return 1;
3210
+ if (e.code !== null) return e.code;
3211
+ const n = e.signal ? constants.signals[e.signal] : void 0;
3212
+ return n === void 0 ? 1 : 128 + n;
3213
+ }
3214
+ function supervise(o) {
3215
+ const now = o.now ?? Date.now;
3216
+ const delay = o.delay ?? ((ms) => new Promise((r) => {
3217
+ setTimeout(r, ms).unref();
3218
+ }));
3219
+ const abort = new AbortController();
3220
+ const emit = (e) => {
3221
+ try {
3222
+ o.onEvent(e);
3223
+ } catch {
3224
+ }
3225
+ };
3226
+ const running = [];
3227
+ const tails = /* @__PURE__ */ new Map();
3228
+ let unlaunched = o.modules.length;
3229
+ let stopCode = null;
3230
+ let firstFailure = null;
3231
+ let resolveDone;
3232
+ const done = new Promise((r) => {
3233
+ resolveDone = r;
3234
+ });
3235
+ const readiness = /* @__PURE__ */ new Map();
3236
+ for (const m of o.modules) {
3237
+ let resolve5;
3238
+ let settled = false;
3239
+ const promise = new Promise((r) => {
3240
+ resolve5 = r;
3241
+ });
3242
+ readiness.set(m.slug, { promise, settle: (r) => {
3243
+ if (settled) return false;
3244
+ settled = true;
3245
+ resolve5(r);
3246
+ return true;
3247
+ } });
3248
+ }
3249
+ const live = () => running.filter((c) => c.alive);
3250
+ const checkDone = () => {
3251
+ if (live().length > 0) return;
3252
+ if (stopCode !== null) resolveDone(stopCode);
3253
+ else if (unlaunched === 0) resolveDone(firstFailure ?? 0);
3254
+ };
3255
+ const beginStop = (reason, code) => {
3256
+ if (stopCode !== null) return;
3257
+ stopCode = code;
3258
+ abort.abort();
3259
+ for (const r of readiness.values()) r.settle("timeout");
3260
+ emit({ type: "stopping", reason });
3261
+ const targets = live().reverse();
3262
+ for (const c of targets) c.launched.stop("SIGTERM");
3263
+ if (targets.length > 0) {
3264
+ void delay(GRACE_MS2).then(() => {
3265
+ for (const c of live().reverse()) c.launched.stop("SIGKILL");
3266
+ });
3267
+ }
3268
+ checkDone();
3269
+ };
3270
+ const launch = (m) => {
3271
+ unlaunched--;
3272
+ const ready = readiness.get(m.slug);
3273
+ const startedAt = now();
3274
+ const launched = o.launcher.launch(m.cmd, m.args, {
3275
+ cwd: m.cwd,
3276
+ env: m.env,
3277
+ onLine: (l) => {
3278
+ const tail = tails.get(m.slug) ?? [];
3279
+ tail.push(l.line);
3280
+ if (tail.length > KEEP_LINES) tail.shift();
3281
+ tails.set(m.slug, tail);
3282
+ emit({ type: "line", module: m.slug, stream: l.stream, line: l.line });
3283
+ }
3284
+ });
3285
+ const child = { mod: m, launched, alive: true };
3286
+ running.push(child);
3287
+ emit({ type: "start", module: m.slug, pid: launched.pid });
3288
+ if (m.port === null) ready.settle("ready");
3289
+ else {
3290
+ const port = m.port;
3291
+ void o.ports.waitFor(port, o.waitMs, abort.signal).then((ok2) => {
3292
+ if (ready.settle(ok2 ? "ready" : "timeout") && ok2) emit({ type: "ready", module: m.slug, port });
3293
+ });
3294
+ }
3295
+ void launched.exited.then((e) => {
3296
+ child.alive = false;
3297
+ ready.settle("exited");
3298
+ const code = exitCode(e);
3299
+ const early = !!e.spawnError || now() - startedAt < EARLY_MS;
3300
+ const rawCode = e.spawnError ? null : e.code;
3301
+ const rawSignal = e.spawnError ? null : e.signal;
3302
+ const hint = stopCode === null && rawSignal === null && rawCode !== null && rawCode !== 0 ? exitHint(tails.get(m.slug) ?? [], m.hint) ?? void 0 : void 0;
3303
+ emit({ type: "exit", module: m.slug, code: rawCode, signal: rawSignal, early, hint });
3304
+ if (code !== 0 && firstFailure === null) firstFailure = code;
3305
+ if (o.killOthers) beginStop("kill-others", code);
3306
+ checkDone();
3307
+ });
3308
+ };
3309
+ const start = async (m) => {
3310
+ const providers = m.waitFor.filter((p) => readiness.has(p));
3311
+ const results = await Promise.all(providers.map((p) => readiness.get(p).promise));
3312
+ if (stopCode !== null) return;
3313
+ providers.forEach((p, i) => {
3314
+ const why = results[i];
3315
+ if (why !== "ready") emit({ type: "provider-missing", module: m.slug, provider: p, why });
3316
+ });
3317
+ launch(m);
3318
+ };
3319
+ for (const m of o.modules) void start(m);
3320
+ checkDone();
3321
+ return {
3322
+ done,
3323
+ stop(reason) {
3324
+ if (reason === "interrupt") beginStop("interrupt", 130);
3325
+ else beginStop("signal", 128 + constants.signals[reason]);
3326
+ },
3327
+ escalate() {
3328
+ beginStop("interrupt", 130);
3329
+ for (const c of live().reverse()) c.launched.stop("SIGKILL");
3330
+ },
3331
+ children: () => live().map((c) => ({ module: c.mod.slug, pid: c.launched.pid, port: c.mod.port }))
3332
+ };
3333
+ }
3334
+
3335
+ // src/core/workspace/stray.ts
3336
+ import { lstat as lstat2, readdir } from "fs/promises";
3337
+ import { join as join16 } from "path";
3338
+ async function strayRepos(root, slugs) {
3339
+ let entries;
3340
+ try {
3341
+ entries = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
3342
+ } catch {
3343
+ return [];
3344
+ }
3345
+ const names = [];
3346
+ for (const name of entries) {
3347
+ if (slugs.has(name)) continue;
3348
+ try {
3349
+ await lstat2(join16(root, name, ".git"));
3350
+ names.push(name);
3351
+ } catch {
3352
+ }
3353
+ }
3354
+ return names.sort();
3355
+ }
3356
+
3357
+ // src/cli/devOutput.ts
3358
+ import { createColors as createColors2 } from "picocolors";
3359
+ function renderProblems(io2, problems, color) {
3360
+ problems.forEach((p, i) => {
3361
+ if (i > 0) io2.err("");
3362
+ renderError(io2, p, false, color);
3363
+ });
3364
+ }
3365
+ function combineProblems(problems) {
3366
+ if (problems.length === 0) throw new Error("combineProblems needs at least one problem");
3367
+ if (problems.length === 1) return problems[0];
3368
+ const first = problems[0];
3369
+ return new QoreError(first.code, `${problems.length} problems before starting`, {
3370
+ details: { problems: problems.map((p) => toJsonError(p)) }
3371
+ });
3372
+ }
3373
+ function prefixer(slugs, color) {
3374
+ const c = createColors2(color);
3375
+ const palette = [c.cyan, c.magenta, c.yellow, c.green, c.blue, c.red];
3376
+ const sorted = [...new Set(slugs)].sort();
3377
+ const width = Math.max(0, ...sorted.map((s) => s.length));
3378
+ return (slug, line2) => {
3379
+ const i = sorted.indexOf(slug);
3380
+ const paint = i === -1 ? (s) => s : palette[i % palette.length];
3381
+ return `${paint(slug.padEnd(width))} \u2502 ${line2}`;
3382
+ };
3383
+ }
3384
+
3385
+ // src/cli/commands/run.ts
3386
+ import { constants as constants2 } from "os";
3387
+ var childRunning = { current: false };
3388
+ function registerRun(program, ctx) {
3389
+ program.command("run").description("Run a command with this module's local secrets in its environment").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("--yes", "trust this repository without asking").argument("<command>", "the command to run (after --)").argument("[args...]", "its arguments, passed verbatim").action(async (cmd, args, f) => {
3390
+ const { deps: deps2 } = ctx;
3391
+ if (ctx.opts().json) {
3392
+ throw new QoreError("QORE_CONTEXT_INVALID", "run has no --json output", {
3393
+ hint: "Drop --json; the child's own output passes through."
3394
+ });
3395
+ }
3396
+ const mc = await moduleContextFor(ctx, f, "run");
3397
+ const app = await appFor(ctx, mc, f.app);
3398
+ await ensureTrusted(ctx, mc, app, { yes: f.yes });
3399
+ const local = await fetchLocalEnv(deps2, mc, app);
3400
+ childRunning.current = true;
3401
+ let exit;
3402
+ try {
3403
+ exit = await deps2.spawn.run(cmd, args, { ...deps2.env, ...local.env });
3404
+ } finally {
3405
+ childRunning.current = false;
3406
+ }
3407
+ if (exit.signal) {
3408
+ deps2.raise(exit.signal);
3409
+ ctx.setExitCode(128 + (constants2.signals[exit.signal] ?? 0));
3410
+ return;
3411
+ }
3412
+ ctx.setExitCode(exit.code ?? 1);
3413
+ });
3414
+ }
3415
+
3416
+ // src/cli/commands/devWorkspace.ts
3417
+ var INSTALL_PARALLELISM = 4;
3418
+ var str = (v) => typeof v === "string" && v.length > 0;
3419
+ var slugList = (raw) => raw?.split(",").map((s) => s.trim()).filter(Boolean);
3420
+ function usableLast(last, appId) {
3421
+ if (!last || last.kind !== "workspace") return false;
3422
+ const a = last.app;
3423
+ 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));
3424
+ }
3425
+ async function workspaceContext(ctx, ws) {
3426
+ const { deps: deps2 } = ctx;
3427
+ try {
3428
+ const portal = await flagPortal(deps2, ctx.opts().portal, ws);
3429
+ const session = await openSession(deps2, portal, "dev", requestLog(ctx));
3430
+ await ensureCapability(deps2, portal.apiOrigin, "app.modules");
3431
+ await ensureCapability(deps2, portal.apiOrigin, "module.local-env");
3432
+ return { am: await workspaceAppModules(session, portal, ws), online: { portal, session }, offlineCause: null };
3433
+ } catch (e) {
3434
+ if (!isOffline(e)) throw e;
3435
+ const last = await loadLastStart(deps2, ws.root);
3436
+ if (!usableLast(last, ws.appId)) throw e;
3437
+ const modules = last.modules.map((m) => ({
3438
+ id: m.id,
3439
+ slug: m.slug,
3440
+ name: m.name,
3441
+ type: m.type,
3442
+ access: m.access,
3443
+ repoUrl: m.repoUrl,
3444
+ repoAccess: "none",
3445
+ githubLinked: false,
3446
+ dependsOn: m.dependsOn
3447
+ }));
3448
+ if (!ctx.opts().json) ctx.io.err(`\u2192 ${last.app.tenant.name} / ${last.app.name} (offline \u2014 last started ${last.savedAt})`);
3449
+ return { am: { app: last.app, accountUrl: "", devTeams: [], modules }, online: null, offlineCause: e };
3450
+ }
3451
+ }
3452
+ async function pool(items, n, fn) {
3453
+ let next = 0;
3454
+ const results = await Promise.allSettled(Array.from({ length: Math.min(n, items.length) }, async () => {
3455
+ while (next < items.length) await fn(items[next++]);
3456
+ }));
3457
+ const failed = results.find((r) => r.status === "rejected");
3458
+ if (failed) throw failed.reason;
3459
+ }
3460
+ async function workspaceDev(ctx, ws, f, waitTimeout) {
3461
+ const ready = await inPreflight(ctx.deps.signals, ctx.signal, (p) => prepareWorkspace(ctx, ws, f, waitTimeout, p));
3462
+ await superviseAll(ctx, ws, ready.units, ready.waitFor, ready.o, ready.notRunning);
3463
+ }
3464
+ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
3465
+ const { deps: deps2, io: io2 } = ctx;
3466
+ const g = ctx.opts();
3467
+ const json2 = Boolean(g.json);
3468
+ if (f.app !== void 0) {
3469
+ throw new QoreError("QORE_CONTEXT_INVALID", "--app is for a module repository", {
3470
+ hint: "Drop --app; a workspace root starts the modules of its own app."
3471
+ });
3472
+ }
3473
+ const note = (human, event) => json2 ? io2.out(JSON.stringify(event)) : io2.err(human);
3474
+ const warn = (message, extra = {}) => note(`! ${message}`, { type: "warning", ...extra, message });
3475
+ const { am, online, offlineCause } = await workspaceContext(ctx, ws);
3476
+ if (online && !json2) io2.err(`\u2192 ${am.app.tenant.name} / ${am.app.name} (qore.workspace.json)`);
3477
+ if (!online) warn("The portal is unreachable \u2014 starting every module from its env file as last written; values may be stale.");
3478
+ const color = !json2 && g.color !== false && !deps2.env.NO_COLOR;
3479
+ const problems = [];
3480
+ const attempt = async (slug, fn) => {
3481
+ try {
3482
+ await fn();
3483
+ return true;
3484
+ } catch (e) {
3485
+ if (!(e instanceof QoreError)) throw e;
3486
+ problems.push(withDetails(e, { module: slug }));
3487
+ return false;
3488
+ }
3489
+ };
3490
+ const fail = async (last) => {
3491
+ const all = last ? [...problems, last] : problems;
3492
+ const enriched = await Promise.all(all.map((p2) => withProxyHint(deps2, p2)));
3493
+ if (!json2 && enriched.length > 1) {
3494
+ renderProblems(io2, enriched, color);
3495
+ io2.err("");
3496
+ }
3497
+ throw combineProblems(enriched);
3498
+ };
3499
+ const dirs = {};
3500
+ const safe = [];
3501
+ for (const m of am.modules) {
3502
+ if (!isSafeSlug(m.slug)) {
3503
+ warn(`${JSON.stringify(m.slug)} is not a safe directory name \u2014 skipped`, { module: m.slug });
3504
+ continue;
3505
+ }
3506
+ try {
3507
+ dirs[m.slug] = await deps2.git.inspect(join17(ws.root, m.slug));
3508
+ safe.push(m);
3509
+ } catch (e) {
3510
+ if (!(e instanceof QoreError) || e.code === "QORE_GIT_UNAVAILABLE") throw e;
3511
+ problems.push(withDetails(e, { module: m.slug }));
3512
+ }
3513
+ }
3514
+ const bySlug = new Map(safe.map((m) => [m.slug, m]));
3515
+ const computePlan = () => {
3516
+ const present2 = new Map(planWorkspace(ws.root, safe, dirs, []).actions.filter((a) => a.kind === "present").map((a) => [a.module.slug, a.dir]));
3517
+ const plan2 = planDev(safe.map((m) => ({
3518
+ id: m.id,
3519
+ slug: m.slug,
3520
+ present: present2.has(m.slug),
3521
+ canWrite: m.access === "write" || m.access === "maintain",
3522
+ dependsOn: m.dependsOn
3523
+ })), { only: slugList(f.only), skip: slugList(f.skip) });
3524
+ return { present: present2, plan: plan2 };
3525
+ };
3526
+ let { present, plan } = computePlan();
3527
+ const missingFindings = () => {
3528
+ const missing2 = plan.skipped.filter((s) => s.reason === "not-cloned").map((s) => bySlug.get(s.slug));
3529
+ const consumers = {};
3530
+ for (const [consumer, providers] of Object.entries(plan.missingProviders)) {
3531
+ for (const p2 of providers) (consumers[p2] ??= []).push(consumer);
3532
+ }
3533
+ const actions = planWorkspace(ws.root, missing2, dirs, []).actions;
3534
+ return modulesMissing({ missing: missing2, actions, cwd: deps2.cwd, consumers, am: online ? am : null });
3535
+ };
3536
+ const missing = missingFindings();
3537
+ for (const finding of missing.findings) renderFinding(ctx, finding);
3538
+ const canSync = online !== null && missing.findings.some((x) => x.action?.fix === "sync");
3539
+ if (canSync && await offerFix(ctx, `Clone ${missing.fixable.map((m) => m.slug).join(", ")} now with qore sync?`, { default: true, signal: ctx.signal })) {
3540
+ const actions = planWorkspace(ws.root, missing.fixable, dirs, []).actions;
3541
+ const results = await applyActions(deps2, { portal: online.portal, app: am, actions, protocol: cloneProtocol(dirs), onResult: () => {
3542
+ } });
3543
+ renderResults(io2, results, false);
3544
+ for (const r of results) if (r.status === "cloned") dirs[r.slug] = await deps2.git.inspect(join17(ws.root, r.slug));
3545
+ ({ present, plan } = computePlan());
3546
+ }
3547
+ for (const name of await strayRepos(ws.root, new Set(am.modules.map((m) => m.slug)))) renderFinding(ctx, strayClone(name, am.app.name));
3548
+ if (ctx.signal.aborted) throw new CancelledError();
3549
+ for (const s of plan.skipped) {
3550
+ const m = bySlug.get(s.slug);
3551
+ if (s.reason === "no-write") {
3552
+ const ask = askMaintainer(am, m);
3553
+ note(
3554
+ `- ${s.slug} no write access \u2014 local secrets need write; ${ask.text}`,
3555
+ { type: "skip", module: s.slug, reason: s.reason, devTeams: ask.teams, maintainers: ask.maintainers }
3556
+ );
3557
+ } else if (s.reason === "not-cloned") {
3558
+ if (json2) io2.out(JSON.stringify({ type: "skip", module: s.slug, reason: s.reason }));
3559
+ } else {
3560
+ note(`- ${s.slug} excluded`, { type: "skip", module: s.slug, reason: s.reason });
3561
+ }
3562
+ }
3563
+ for (const c of plan.cycles) warn(`${c.join(", ")} depend on each other \u2014 they start together, without waiting`, { modules: c });
3564
+ if (plan.start.length === 0) {
3565
+ await fail(new QoreError("QORE_CONTEXT_REQUIRED", "No module of this workspace can be started", {
3566
+ hint: "Clone the modules with qore sync, or change --only / --skip.",
3567
+ details: { skipped: plan.skipped }
3568
+ }));
3569
+ }
3570
+ const units = plan.start.map((slug) => ({ m: bySlug.get(slug), dir: present.get(slug) }));
3571
+ if (online) {
3572
+ const untrusted = [];
3573
+ for (const u of units) if (!await isTrusted(deps2, u.dir, u.m.id)) untrusted.push(u);
3574
+ if (untrusted.length > 0) {
3575
+ const names = untrusted.map((u) => u.m.slug).join(", ");
3576
+ if (!f.yes) {
3577
+ if (!io2.interactive) {
3578
+ await fail(new QoreError("QORE_CONTEXT_REQUIRED", `These repositories are not trusted yet: ${names}`, {
3579
+ hint: "Run interactively once, or pass --yes.",
3580
+ details: { reason: "untrusted", modules: untrusted.map((u) => u.m.slug) }
3581
+ }));
3582
+ }
3583
+ if (!await io2.confirm(`Pull local secrets for ${names} into their repositories?`)) throw new CancelledError();
3584
+ }
3585
+ for (const u of untrusted) await trust(deps2, u.dir, u.m.id);
3586
+ }
3587
+ }
3588
+ p.arm();
3589
+ const prefix = prefixer(plan.start, color);
3590
+ const onLine = (slug) => (l) => {
3591
+ if (json2) io2.out(JSON.stringify({ type: "line", module: slug, stream: l.stream, line: l.line }));
3592
+ else if (l.stream === "stdout") io2.out(prefix(slug, l.line));
3593
+ else io2.err(prefix(slug, l.line));
3594
+ };
3595
+ const report = progressReporter(ctx, { prefix: json2 ? void 0 : prefix, installFlag: f.install, servicesFlag: f.services });
3596
+ for (const u of units) {
3597
+ await attempt(u.m.slug, async () => {
3598
+ u.cfg = await readRepoConfig(u.dir);
3599
+ u.pkg = await readPackageJson(u.dir);
3600
+ u.recipe = planRecipe({
3601
+ root: u.dir,
3602
+ pkg: u.pkg,
3603
+ pm: await detectPackageManager(u.dir, u.pkg),
3604
+ cfg: u.cfg,
3605
+ slug: u.m.slug,
3606
+ flags: { install: f.install, services: f.services }
3607
+ });
3608
+ });
3609
+ }
3610
+ for (const u of units) {
3611
+ u.trusted = !offlineCause || await isTrusted(deps2, u.dir, u.m.id);
3612
+ if (!u.trusted) problems.push(withDetails(offlineCause, { module: u.m.slug }));
3613
+ }
3614
+ const withRecipe = units.filter((u) => u.recipe && u.trusted);
3615
+ for (const name of [...new Set(withRecipe.map((u) => u.recipe.pm.name))]) {
3616
+ const users = withRecipe.filter((u) => u.recipe.pm.name === name);
3617
+ let version = null;
3618
+ const pmOk = await attempt(users[0].m.slug, async () => {
3619
+ version = await checkPackageManager(deps2, users[0].recipe, () => {
3620
+ }, p.signal);
3621
+ });
3622
+ for (const u of users) {
3623
+ u.pmOk = pmOk;
3624
+ u.pmVersion = version;
3625
+ }
3626
+ }
3627
+ for (const u of withRecipe) {
3628
+ const nodeOk = await attempt(u.m.slug, async () => {
3629
+ u.node = await checkNode(deps2, u.recipe, u.pkg, u.m.slug, p.signal);
3630
+ });
3631
+ u.pmOk = u.pmOk && nodeOk;
3632
+ if (u.pmOk) report({
3633
+ module: u.m.slug,
3634
+ step: "pm",
3635
+ status: "done",
3636
+ detail: `${u.recipe.pm.name}${u.pmVersion ? ` ${u.pmVersion}` : ""}${u.node ? ` \xB7 node ${u.node}` : ""}`
3637
+ });
3638
+ }
3639
+ await pool(units.filter((u) => u.pmOk && u.trusted), INSTALL_PARALLELISM, async (u) => {
3640
+ u.installed = await attempt(u.m.slug, () => installIfNeeded(deps2, u.recipe, u.m.slug, onLine(u.m.slug), report, p.signal));
3641
+ });
3642
+ const runs = [await leftoverRun(deps2, ws.root)].filter((r) => r !== null);
3643
+ const appRef = { id: am.app.id, slug: am.app.slug, name: am.app.name };
3644
+ const mcFor = (u) => ({
3645
+ portal: online.portal,
3646
+ session: online.session,
3647
+ source: { kind: "workspace" },
3648
+ repoRoot: u.dir,
3649
+ workspace: ws,
3650
+ module: { id: u.m.id, slug: u.m.slug, name: u.m.name, type: u.m.type, access: u.m.access, tenant: am.app.tenant, apps: [appRef] }
3651
+ });
3652
+ const rootReal = await realpath4(ws.root);
3653
+ const withDocker = dockerRecovery(ctx, p.signal);
3654
+ for (const u of units.filter((x) => x.recipe && x.trusted)) {
3655
+ const slug = u.m.slug;
3656
+ await attempt(slug, async () => {
3657
+ try {
3658
+ u.dev = await devEnv(deps2, {
3659
+ mc: online ? mcFor(u) : null,
3660
+ app: online ? appRef : null,
3661
+ root: u.dir,
3662
+ type: u.m.type,
3663
+ moduleId: u.m.id,
3664
+ cfg: u.cfg,
3665
+ random: () => randomBytes4(32).toString("hex")
3666
+ });
3667
+ } catch (e) {
3668
+ if (offlineCause && isOffline(e)) throw offlineCause;
3669
+ throw e;
3670
+ }
3671
+ });
3672
+ const dev = u.dev;
3673
+ if (!dev) continue;
3674
+ if (online && dev.offline) {
3675
+ warn(`${slug}: the portal is unreachable \u2014 starting from ${relative5(rootReal, dev.envFile)} as last written; values may be stale.`, { module: slug });
3676
+ }
3677
+ if (dev.kept.length) {
3678
+ warn(
3679
+ `${slug}: ${dev.kept.length} key(s) differ locally and were kept: ${dev.kept.join(", ")} \u2014 qore env pull --use-portal takes the portal's values`,
3680
+ { module: slug, keys: dev.kept }
3681
+ );
3682
+ }
3683
+ report({ module: slug, step: "env", status: "done", detail: relative5(rootReal, dev.envFile) });
3684
+ const services = servicesMissing(slug, dev.env, u.recipe, { servicesFlag: f.services, configured: u.cfg.dev?.services !== void 0 });
3685
+ if (services) renderFinding(ctx, services);
3686
+ try {
3687
+ const behind = repoBehind(slug, await deps2.git.status(u.dir), { workspace: true });
3688
+ if (behind) renderFinding(ctx, behind);
3689
+ } catch {
3690
+ }
3691
+ if (u.installed) {
3692
+ const env = { ...deps2.env, ...dev.env };
3693
+ await attempt(slug, () => withDocker({ cwd: u.dir, env }, () => startServices(deps2, u.recipe, slug, env, dev.dbPort, waitTimeout * 1e3, p.signal, onLine(slug), report)));
3694
+ }
3695
+ if (await attempt(slug, () => checkPortOrStopLeftover(ctx, { slug, port: dev.port, key: "PORT", runs }, p.signal)) && dev.port !== null) {
3696
+ report({ module: slug, step: "ports", status: "done", detail: String(dev.port) });
3697
+ }
3698
+ }
3699
+ if (p.signal.aborted) throw new CancelledError();
3700
+ const byPort = /* @__PURE__ */ new Map();
3701
+ for (const u of units) if (u.dev?.port != null) byPort.set(u.dev.port, [...byPort.get(u.dev.port) ?? [], u.m.slug]);
3702
+ for (const [port, slugs] of byPort) {
3703
+ if (slugs.length < 2) continue;
3704
+ problems.push(new QoreError("QORE_PORT_IN_USE", `${slugs.slice(0, -1).join(", ")} and ${slugs.at(-1)} both have port ${port}`, {
3705
+ hint: "Ports are unique per organisation; a module from another organisation can share one. Run one of them (--only / --skip).",
3706
+ details: { port, module: slugs[0], modules: slugs }
3707
+ }));
3708
+ }
3709
+ if (problems.length > 0) await fail();
3710
+ const listening = new Set(units.map((u) => u.dev.port).filter((p2) => p2 !== null));
3711
+ for (const [consumer, providers] of Object.entries(plan.missingProviders)) {
3712
+ const env = units.find((u) => u.m.slug === consumer).dev.env;
3713
+ const aliases = providers.length === 1 ? deadAliases(env, listening) : [];
3714
+ for (const provider of providers) {
3715
+ const what = aliases.length ? `${aliases.join(", ")} ${aliases.length === 1 ? "points" : "point"}` : "its CONN_*_URL points";
3716
+ warn(
3717
+ `${consumer}: ${provider} will not run \u2014 ${what} at a port nothing listens on. Qore does not fall back to staging.`,
3718
+ { module: consumer, provider, aliases }
3719
+ );
3720
+ }
3721
+ }
3722
+ if (online && units.every((u) => !u.dev.offline)) {
3723
+ const envFile = (m) => {
3724
+ const u = units.find((x) => x.m.id === m.id);
3725
+ return u ? relative5(rootReal, u.dev.envFile) : join17(m.slug, moduleDefaults(m.type).envFile);
3726
+ };
3727
+ await saveLastStart(deps2, ws.root, {
3728
+ schemaVersion: 1,
3729
+ kind: "workspace",
3730
+ portal: online.portal.apiOrigin,
3731
+ savedAt: deps2.now().toISOString(),
3732
+ app: { ...appRef, tenant: { id: am.app.tenant.id, slug: am.app.tenant.slug, name: am.app.tenant.name } },
3733
+ modules: safe.map((m) => ({
3734
+ id: m.id,
3735
+ slug: m.slug,
3736
+ name: m.name,
3737
+ type: m.type,
3738
+ access: m.access,
3739
+ repoUrl: m.repoUrl,
3740
+ dependsOn: m.dependsOn,
3741
+ envFile: envFile(m)
3742
+ }))
3743
+ });
3744
+ }
3745
+ if (p.signal.aborted) throw new CancelledError();
3746
+ const { reasons } = missingFindings();
3747
+ const notRunning = plan.skipped.filter((s) => s.reason !== "excluded").map((s) => {
3748
+ const m = bySlug.get(s.slug);
3749
+ return { slug: s.slug, reason: s.reason === "no-write" ? `no write access \u2014 ${askMaintainer(am, m).text}` : `not cloned \u2014 ${reasons[s.slug] ?? "Fix: qore sync"}` };
3750
+ });
3751
+ return { units, waitFor: plan.waitFor, notRunning, o: { waitTimeout, killOthers: Boolean(f.killOthers), json: json2, color, prefix } };
3752
+ }
3753
+ async function superviseAll(ctx, ws, units, waitFor, o, notRunning) {
3754
+ const { deps: deps2, io: io2 } = ctx;
3755
+ const startedAt = deps2.now().toISOString();
3756
+ const began = /* @__PURE__ */ new Map();
3757
+ const ref = {};
3758
+ let stopping = false;
3759
+ let runWrite = Promise.resolve();
3760
+ const writeRun = () => {
3761
+ runWrite = runWrite.then(() => writeRunFile(deps2, ws.root, {
3762
+ schemaVersion: 1,
3763
+ qorePid: deps2.pid,
3764
+ root: ws.root,
3765
+ startedAt,
3766
+ children: ref.sup?.children() ?? []
3767
+ })).catch(() => {
3768
+ });
3769
+ };
3770
+ const render = (e) => {
3771
+ switch (e.type) {
3772
+ case "line":
3773
+ return e.stream === "stdout" ? io2.out(o.prefix(e.module, e.line)) : io2.err(o.prefix(e.module, e.line));
3774
+ case "start":
3775
+ return io2.err(`\u25B6 starting ${e.module}${e.pid === null ? "" : ` (pid ${e.pid})`}`);
3776
+ case "ready":
3777
+ return io2.err(`\u2713 ${e.module} ready`);
3778
+ case "provider-missing":
3779
+ return io2.err(`! ${e.provider} did not come up within ${o.waitTimeout} s (or exited); starting ${e.module} anyway \u2014 see its output above`);
3780
+ case "stopping":
3781
+ return io2.err("\u25A0 stopping");
3782
+ case "exit": {
3783
+ if (stopping) return io2.err(`\u25A0 ${e.module} stopped`);
3784
+ if (e.code === null && e.signal === null) return io2.err(`\u2717 ${e.module} could not be started`);
3785
+ const after = e.early ? ` after ${Math.round((deps2.now().getTime() - (began.get(e.module) ?? deps2.now().getTime())) / 1e3)} s` : "";
3786
+ const how = e.signal ? `was ended by ${e.signal}` : `exited with code ${e.code}`;
3787
+ io2.err(`${e.code === 0 ? "\u25A0" : "\u2717"} ${e.module} ${how}${after}`);
3788
+ if (e.hint) io2.err(` \u2192 ${e.hint.hint}`);
3789
+ if (e.code !== 0 && !o.killOthers && (ref.sup?.children().length ?? 0) > 0) {
3790
+ io2.err(` Restart it on its own: qore dev --only ${e.module} (in another terminal; the others keep running)`);
3791
+ }
3792
+ return;
3793
+ }
3794
+ }
3795
+ };
3796
+ const withPort = new Set(units.filter((u) => u.dev.port !== null).map((u) => u.m.slug));
3797
+ const readyPorts = /* @__PURE__ */ new Map();
3798
+ let summarized = false;
3799
+ const summarize = () => {
3800
+ if (summarized || o.json || readyPorts.size < withPort.size) return;
3801
+ summarized = true;
3802
+ const width = Math.max(0, ...[...readyPorts.keys(), ...notRunning.map((n) => n.slug)].map((s2) => s2.length));
3803
+ io2.err(notRunning.length === 0 ? "\u2714 All modules running:" : "\u2714 Running:");
3804
+ for (const slug of [...readyPorts.keys()].sort()) io2.err(` ${slug.padEnd(width)} http://localhost:${readyPorts.get(slug)}`);
3805
+ if (notRunning.length > 0) {
3806
+ io2.err("! Not running:");
3807
+ for (const n of notRunning) io2.err(` ${n.slug.padEnd(width)} ${n.reason}`);
3808
+ }
3809
+ };
3810
+ const crashed = /* @__PURE__ */ new Map();
3811
+ const onEvent = (e) => {
3812
+ if (e.type === "start") {
3813
+ began.set(e.module, deps2.now().getTime());
3814
+ writeRun();
3815
+ }
3816
+ if (e.type === "stopping") stopping = true;
3817
+ if (e.type === "ready") readyPorts.set(e.module, e.port);
3818
+ if (e.type === "exit" && !stopping && (e.code !== 0 || e.signal !== null)) {
3819
+ crashed.set(e.module, { code: e.code, signal: e.signal, ms: deps2.now().getTime() - (began.get(e.module) ?? deps2.now().getTime()) });
3820
+ }
3821
+ if (o.json) io2.out(JSON.stringify(e));
3822
+ else render(e);
3823
+ if (e.type === "ready") summarize();
3824
+ };
3825
+ const s = ref.sup = supervise({
3826
+ modules: units.map((u) => {
3827
+ const { cmd, args } = runArgs(u.recipe.pm, u.recipe.script);
3828
+ return {
3829
+ slug: u.m.slug,
3830
+ cmd,
3831
+ args,
3832
+ cwd: u.dir,
3833
+ port: u.dev.port,
3834
+ waitFor: waitFor[u.m.slug] ?? [],
3835
+ hint: hintContext(u.recipe, u.pkg, u.dev.env),
3836
+ env: { ...deps2.env, ...u.dev.env, ...o.color ? { FORCE_COLOR: "1" } : {} }
3837
+ };
3838
+ }),
3839
+ launcher: deps2.launch,
3840
+ ports: deps2.ports,
3841
+ waitMs: o.waitTimeout * 1e3,
3842
+ killOthers: o.killOthers,
3843
+ onEvent
3844
+ });
3845
+ const onTerm = () => s.stop("SIGTERM");
3846
+ const onHup = () => s.stop("SIGHUP");
3847
+ const onAbort = () => s.stop("interrupt");
3848
+ deps2.signals.on("SIGTERM", onTerm);
3849
+ deps2.signals.on("SIGHUP", onHup);
3850
+ ctx.signal.addEventListener("abort", onAbort);
3851
+ childRunning.current = true;
3852
+ childRunning.onInterrupt = () => s.escalate();
3853
+ try {
3854
+ if (ctx.signal.aborted) onAbort();
3855
+ writeRun();
3856
+ ctx.setExitCode(await s.done);
3857
+ if (!o.json && crashed.size > 0) {
3858
+ const stopped = units.map((u) => u.m.slug).filter((s2) => !crashed.has(s2)).sort();
3859
+ const what = [...crashed].map(([s2, c]) => `${s2} (${c.code === null && c.signal === null ? "could not be started" : `${c.signal ?? `code ${c.code}`}, after ${humanAge(Math.round(c.ms / 1e3))}`})`).join(", ");
3860
+ io2.err(`${stopped.length ? `\u25A0 stopped: ${stopped.join(", ")} ` : ""}\u2717 crashed: ${what}`);
3861
+ }
3862
+ } finally {
3863
+ deps2.signals.off("SIGTERM", onTerm);
3864
+ deps2.signals.off("SIGHUP", onHup);
3865
+ ctx.signal.removeEventListener("abort", onAbort);
3866
+ childRunning.current = false;
3867
+ childRunning.onInterrupt = void 0;
3868
+ await runWrite;
3869
+ await removeRunFile(deps2, ws.root);
3870
+ }
3871
+ }
3872
+
3873
+ // src/cli/commands/dev.ts
3874
+ var EARLY_EXIT_MS = 1e4;
3875
+ function makeStderrTee(sink) {
3876
+ const decoder = new StringDecoder("utf8");
3877
+ let buf = "";
3878
+ let forwarding = true;
3879
+ const tail = [];
3880
+ const record = (line2) => {
3881
+ tail.push(line2);
3882
+ if (tail.length > KEEP_LINES) tail.shift();
3883
+ };
3884
+ const onError = () => {
3885
+ forwarding = false;
3886
+ };
3887
+ sink.on("error", onError);
3888
+ return {
3889
+ push: (chunk) => {
3890
+ if (forwarding) {
3891
+ try {
3892
+ sink.write(chunk);
3893
+ } catch {
3894
+ forwarding = false;
3895
+ }
3896
+ }
3897
+ buf += decoder.write(chunk);
3898
+ let i;
3899
+ while ((i = buf.indexOf("\n")) >= 0) {
3900
+ record(buf.slice(0, i).replace(/\r$/, ""));
3901
+ buf = buf.slice(i + 1);
3902
+ }
3903
+ },
3904
+ lines: () => {
3905
+ const rest = (buf + decoder.end()).replace(/\r$/, "");
3906
+ return rest ? [...tail, rest].slice(-KEEP_LINES) : tail.slice();
3907
+ },
3908
+ dispose: () => {
3909
+ sink.removeListener("error", onError);
3910
+ }
3911
+ };
3912
+ }
3913
+ function registerDev(program, ctx) {
3914
+ program.command("dev").description("Start this module's dev server \u2014 or, in a workspace root, every module of the app").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("--only <slugs>", "workspace: start only these modules (comma-separated)").option("--skip <slugs>", "workspace: start all but these modules (comma-separated)").option("--no-install", "do not run the package manager's install").option("--no-services", "do not start local services (db:up)").option("--kill-others", "workspace: the first module to exit stops the rest").option("--wait-timeout <s>", "workspace: seconds to wait for a provider's port", "60").option("--yes", "trust the repositories without asking").action(async (f) => {
3915
+ const waitTimeout = parseWaitTimeout(f.waitTimeout);
3916
+ const ws = await workspaceRoot(ctx.deps);
3917
+ if (ws && f.module === void 0) return workspaceDev(ctx, ws, f, waitTimeout);
3918
+ return moduleDev(ctx, f, waitTimeout);
3919
+ });
3920
+ }
3921
+ function parseWaitTimeout(raw) {
3922
+ const n = /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
3923
+ if (!Number.isInteger(n) || n < 1 || n > 600) {
3924
+ throw new QoreError("QORE_CONTEXT_INVALID", `--wait-timeout must be a whole number of seconds from 1 to 600, not "${raw}"`, {
3925
+ hint: "Pass e.g. --wait-timeout 60."
3926
+ });
3927
+ }
3928
+ return n;
3929
+ }
3930
+ var str2 = (v) => typeof v === "string" && v.length > 0;
3931
+ var SHELL_SAFE = /^[A-Za-z0-9_./,:@%+=-]*$/;
3932
+ var shellQuote = (s) => SHELL_SAFE.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
3933
+ function cdAndDev(home, dir2) {
3934
+ if (/^[A-Za-z]:[\\/]|^\\\\/.test(dir2)) return `cd ${/\s/.test(dir2) ? `"${dir2}"` : dir2} && qore dev`;
3935
+ const h = home.length > 1 ? home.replace(/\/+$/, "") : home;
3936
+ let target = shellQuote(dir2);
3937
+ if (h && dir2 === h) target = "~";
3938
+ else if (h && dir2.startsWith(h + "/")) target = `~/${shellQuote(dir2.slice(h.length + 1))}`;
3939
+ return `cd ${target} && qore dev`;
3940
+ }
3941
+ async function providerFindings(ctx, root, mc, app, env) {
3942
+ const { deps: deps2 } = ctx;
3943
+ try {
3944
+ if (!(await metaFor(deps2, mc.portal.apiOrigin)).capabilities.includes("app.modules")) return null;
3945
+ const am = await mc.session.api.get(`/api/cli/v1/apps/${encodeURIComponent(app.id)}/modules`);
3946
+ const self = am.modules.find((m) => m.id === mc.module.id);
3947
+ const providers = am.modules.filter((m) => self?.dependsOn.includes(m.id));
3948
+ if (providers.length === 0) return null;
3949
+ const conns = connPorts(env);
3950
+ const listening = /* @__PURE__ */ new Set();
3951
+ for (const c of conns) if (await deps2.ports.inUse(c.port)) listening.add(c.port);
3952
+ const keys = deadAliases(env, listening);
3953
+ if (keys.length === 0) return null;
3954
+ const ws = await findWorkspace(root, deps2.home);
3955
+ let fix = `qore clone ${app.slug}`;
3956
+ if (ws && ws.appId === app.id) fix = cdAndDev(deps2.home, ws.root);
3957
+ else if (providers.length === 1) {
3958
+ const sibling = join18(dirname6(root), providers[0].slug);
3959
+ if ((await deps2.git.inspect(sibling)).isRepo) fix = cdAndDev(deps2.home, sibling);
3960
+ }
3961
+ return providerDown({
3962
+ slug: mc.module.slug,
3963
+ providers: providers.map((p) => p.slug),
3964
+ keys,
3965
+ ports: new Map(conns.map((c) => [c.key, c.port])),
3966
+ fix
3967
+ });
3968
+ } catch (e) {
3969
+ if (e instanceof QoreError) return null;
3970
+ throw e;
3971
+ }
3972
+ }
3973
+ function usableLast2(last) {
3974
+ if (!last || last.kind !== "module") return false;
3975
+ const m = last.module;
3976
+ 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);
3977
+ }
3978
+ async function devContext(ctx, f, root) {
3979
+ let mc;
3980
+ try {
3981
+ mc = await moduleContextFor(ctx, f, "dev");
3982
+ } catch (e) {
3983
+ if (!isOffline(e) || f.module) throw e;
3984
+ const last = await loadLastStart(ctx.deps, root);
3985
+ if (!usableLast2(last)) throw e;
3986
+ if (f.app && f.app !== last.app.id && f.app !== last.app.slug) throw e;
3987
+ if (!await isTrusted(ctx.deps, root, last.module.id)) throw e;
3988
+ ctx.io.err(`\u2192 ${last.module.tenant.name} / ${last.app.name} / ${last.module.slug} (offline \u2014 last started ${last.savedAt})`);
3989
+ return { online: null, module: last.module, offlineCause: e };
3990
+ }
3991
+ const app = await appFor(ctx, mc, f.app);
3992
+ await ensureTrusted(ctx, mc, app, { yes: f.yes });
3993
+ return { online: { mc, app }, module: mc.module, offlineCause: null };
3994
+ }
3995
+ async function moduleDev(ctx, f, waitTimeout) {
3996
+ const { deps: deps2, io: io2 } = ctx;
3997
+ const { root, slug, recipe, env, port, pkg, devEnv: devEnv2 } = await inPreflight(deps2.signals, ctx.signal, (p) => prepareModule(ctx, f, waitTimeout, p));
3998
+ const { cmd, args } = runArgs(recipe.pm, recipe.script);
3999
+ io2.err(`\u25B6 starting ${slug}${port === null ? "" : ` \u2014 http://localhost:${port}`}`);
4000
+ const startedAt = deps2.now();
4001
+ await writeRunFile(deps2, root, {
4002
+ schemaVersion: 1,
4003
+ qorePid: deps2.pid,
4004
+ root,
4005
+ startedAt: startedAt.toISOString(),
4006
+ children: [{ module: slug, pid: null, port }]
4007
+ });
4008
+ childRunning.current = true;
4009
+ const tee = makeStderrTee(io2.stderrRaw);
4010
+ const spawnEnv = ctx.opts().color !== false && io2.stderrTTY && env.FORCE_COLOR === void 0 && env.NO_COLOR === void 0 ? { ...env, FORCE_COLOR: "1" } : env;
4011
+ let exit;
4012
+ try {
4013
+ exit = await deps2.spawn.run(cmd, args, spawnEnv, { cwd: root, onStderr: tee.push });
4014
+ } finally {
4015
+ tee.dispose();
4016
+ childRunning.current = false;
4017
+ await removeRunFile(deps2, root);
4018
+ }
4019
+ const ms = deps2.now().getTime() - startedAt.getTime();
4020
+ if (exit.code !== 0 && exit.signal === null) {
4021
+ io2.err(ms < EARLY_EXIT_MS ? `\u2717 ${slug} exited after ${Math.round(ms / 1e3)} s with code ${exit.code ?? 1} \u2014 its output above says why.` : `\u2717 ${slug} exited with code ${exit.code ?? 1}.`);
4022
+ const hint = exitHint(tee.lines(), hintContext(recipe, pkg, devEnv2));
4023
+ if (hint) io2.err(` \u2192 ${hint.hint}`);
4024
+ }
4025
+ if (exit.signal) {
4026
+ deps2.raise(exit.signal);
4027
+ ctx.setExitCode(128 + (constants3.signals[exit.signal] ?? 0));
4028
+ return;
4029
+ }
4030
+ ctx.setExitCode(exit.code ?? 1);
4031
+ }
4032
+ async function prepareModule(ctx, f, waitTimeout, p) {
4033
+ const { deps: deps2, io: io2 } = ctx;
4034
+ if (ctx.opts().json) {
4035
+ throw new QoreError("QORE_CONTEXT_INVALID", "dev in a module repository has no --json; the dev server's output passes through", {
4036
+ hint: "Drop --json."
4037
+ });
4038
+ }
4039
+ for (const [flag, set] of [["--only", f.only !== void 0], ["--skip", f.skip !== void 0], ["--kill-others", Boolean(f.killOthers)]]) {
4040
+ if (set) {
4041
+ throw new QoreError("QORE_CONTEXT_INVALID", `${flag} is for a workspace root`, {
4042
+ hint: `Drop ${flag}, or run qore dev in the workspace root (where qore.workspace.json is).`
4043
+ });
4044
+ }
4045
+ }
4046
+ const root = await findRepoRoot(deps2.cwd);
4047
+ if (!root) {
4048
+ throw new QoreError("QORE_CONTEXT_REQUIRED", "Not inside a module repository", {
4049
+ hint: "cd into a module repository, or run qore dev in a workspace root."
4050
+ });
4051
+ }
4052
+ const c = await devContext(ctx, f, root);
4053
+ p.arm();
4054
+ const slug = c.module.slug;
4055
+ const cfg = await readRepoConfig(root);
4056
+ const pkg = await readPackageJson(root);
4057
+ const recipe = planRecipe({
4058
+ root,
4059
+ pkg,
4060
+ pm: await detectPackageManager(root, pkg),
4061
+ cfg,
4062
+ slug,
4063
+ flags: { install: f.install, services: f.services }
4064
+ });
4065
+ const runs = [await leftoverRun(deps2, root)].filter((r) => r !== null);
4066
+ const onLine = (l) => l.stream === "stdout" ? io2.out(l.line) : io2.err(l.line);
4067
+ const report = progressReporter(ctx, { installFlag: f.install, servicesFlag: f.services });
4068
+ const pmVersion = await checkPackageManager(deps2, recipe, () => {
4069
+ }, p.signal);
4070
+ const nodeVersion = await checkNode(deps2, recipe, pkg, slug, p.signal);
4071
+ report({
4072
+ module: slug,
4073
+ step: "pm",
4074
+ status: "done",
4075
+ detail: `${recipe.pm.name}${pmVersion ? ` ${pmVersion}` : ""}${nodeVersion ? ` \xB7 node ${nodeVersion}` : ""}`
4076
+ });
4077
+ await installIfNeeded(deps2, recipe, slug, onLine, report, p.signal);
4078
+ let dev;
4079
+ try {
4080
+ dev = await devEnv(deps2, {
4081
+ mc: c.online?.mc ?? null,
4082
+ app: c.online?.app ?? null,
4083
+ root,
4084
+ type: c.module.type,
4085
+ moduleId: c.module.id,
4086
+ cfg,
4087
+ random: () => randomBytes5(32).toString("hex")
4088
+ });
4089
+ } catch (e) {
4090
+ if (c.offlineCause && isOffline(e)) throw c.offlineCause;
4091
+ throw e;
4092
+ }
4093
+ const envFile = relative6(await realpath5(root), dev.envFile);
4094
+ if (dev.offline) io2.err(`! The portal is unreachable \u2014 starting from ${envFile} as last written; values may be stale.`);
4095
+ if (dev.kept.length) {
4096
+ io2.err(`! ${dev.kept.length} key(s) differ locally and were kept: ${dev.kept.join(", ")} \u2014 qore env pull --use-portal takes the portal's values`);
4097
+ }
4098
+ report({ module: slug, step: "env", status: "done", detail: dev.offline ? `${envFile} (as last written \u2014 offline)` : envFile });
4099
+ const env = { ...deps2.env, ...dev.env };
4100
+ await dockerRecovery(ctx, p.signal)({ cwd: root, env }, () => startServices(deps2, recipe, slug, env, dev.dbPort, waitTimeout * 1e3, p.signal, onLine, report));
4101
+ await checkPortOrStopLeftover(ctx, { slug, port: dev.port, key: "PORT", runs }, p.signal);
4102
+ if (dev.port !== null) report({ module: slug, step: "ports", status: "done", detail: String(dev.port) });
4103
+ if (c.online && !dev.offline) {
4104
+ const provider = await providerFindings(ctx, root, c.online.mc, c.online.app, dev.env);
4105
+ if (provider) renderFinding(ctx, provider);
4106
+ }
4107
+ const services = servicesMissing(slug, dev.env, recipe, { servicesFlag: f.services, configured: cfg.dev?.services !== void 0 });
4108
+ if (services) renderFinding(ctx, services);
4109
+ try {
4110
+ const behind = repoBehind(slug, await deps2.git.status(root), { workspace: false });
4111
+ if (behind) renderFinding(ctx, behind);
4112
+ } catch {
4113
+ }
4114
+ if (c.online && !dev.offline) {
4115
+ const { mc, app } = c.online;
4116
+ const { id, name, type, access: access2, tenant, apps } = mc.module;
4117
+ await saveLastStart(deps2, root, {
4118
+ schemaVersion: 1,
4119
+ kind: "module",
4120
+ portal: mc.portal.apiOrigin,
4121
+ savedAt: deps2.now().toISOString(),
4122
+ module: {
4123
+ id,
4124
+ slug,
4125
+ name,
4126
+ type,
4127
+ access: access2,
4128
+ tenant: { id: tenant.id, slug: tenant.slug, name: tenant.name },
4129
+ apps: apps.map((a) => ({ id: a.id, slug: a.slug, name: a.name }))
4130
+ },
4131
+ app: { id: app.id, slug: app.slug, name: app.name },
4132
+ envFile
4133
+ });
4134
+ }
4135
+ if (p.signal.aborted) throw new CancelledError();
4136
+ return { root, slug, recipe, env, port: dev.port, pkg, devEnv: dev.env };
4137
+ }
4138
+
4139
+ // src/cli/commands/doctor.ts
4140
+ import pc3 from "picocolors";
4141
+
4142
+ // src/core/doctor/checks.ts
4143
+ import { realpath as realpath6 } from "fs/promises";
4144
+ import { basename as basename3, join as join19 } from "path";
4145
+
4146
+ // src/core/diagnostics/ssh.ts
4147
+ var SSH_TEST_ARGS = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "git@github.com"];
4148
+ var COMMAND = "ssh -T git@github.com";
4149
+ function classifySshTest(r) {
4150
+ const text2 = r.tail.join("\n");
4151
+ const hi = /Hi ([^!\s]+)! You've successfully authenticated/.exec(text2);
4152
+ if (hi) return { ok: true, user: hi[1] };
4153
+ const details = { protocol: "ssh" };
4154
+ if (r.spawnError) {
4155
+ return { ok: false, error: new QoreError("QORE_GIT_AUTH", "ssh is not installed or not on PATH", {
4156
+ hint: "Clone over https instead (the default for qore clone and qore sync).",
4157
+ details: { ...details, reason: "ssh-missing" }
4158
+ }) };
4159
+ }
4160
+ if (/Permission denied \(publickey\)/.test(text2)) {
4161
+ return { ok: false, error: new QoreError("QORE_GIT_AUTH", "GitHub refused your SSH key", {
4162
+ hint: "Add your key to GitHub (https://github.com/settings/keys) and your ssh agent, or keep cloning over https.",
4163
+ details
4164
+ }) };
1887
4165
  }
1888
- if (!await ctx.io.confirm("Pull local secrets for this module into this repository?")) throw new CancelledError();
1889
- await trust(ctx.deps, root, mc.module.id);
4166
+ if (/Host key verification failed/.test(text2)) {
4167
+ return { ok: false, error: new QoreError("QORE_GIT_AUTH", "ssh could not verify github.com's host key", {
4168
+ hint: `Run \`${COMMAND}\` once to accept its host key (or to see why it changed).`,
4169
+ details: { ...details, reason: "host-key" }
4170
+ }) };
4171
+ }
4172
+ const last = r.tail.map((l) => l.trim()).filter(Boolean).slice(-3);
4173
+ return { ok: false, error: new QoreError("QORE_GIT_AUTH", "GitHub SSH did not answer as expected", {
4174
+ hint: [...last, `\`${COMMAND}\``].join("\n"),
4175
+ details: { ...details, stderr: r.tail }
4176
+ }) };
1890
4177
  }
1891
4178
 
1892
- // src/cli/commands/env.ts
1893
- var maskLen = (v) => v === void 0 ? "(unset)" : `\u2022\u2022\u2022\u2022 (${v.length} chars)`;
1894
- async function readOrNull(p) {
4179
+ // src/core/doctor/checks.ts
4180
+ var NODE_RANGE = ">=22";
4181
+ var SKEW_S = 60;
4182
+ var TOOL_TIMEOUT_MS = 1e4;
4183
+ var IDLE_DAYS = 30;
4184
+ var DAY_MS = 864e5;
4185
+ var UPGRADE = "npm i -g @quatronic/qore";
4186
+ var RANK = { ok: 0, warn: 1, fail: 2 };
4187
+ var noop = () => {
4188
+ };
4189
+ var ok = (label2, details) => ({ status: "ok", label: label2, ...details ? { details } : {} });
4190
+ var withError = (status2, label2, e) => ({ status: status2, label: label2, error: toJsonError(e) });
4191
+ var isCancel = (e, signal) => e instanceof CancelledError || signal.aborted;
4192
+ var portalLabel = (p) => p.builtin ?? p.apiOrigin;
4193
+ var ToolTimeout = class extends Error {
4194
+ constructor(tool, ms) {
4195
+ super(`${tool} did not answer within ${Math.round(ms / 1e3)} s`);
4196
+ this.tool = tool;
4197
+ this.ms = ms;
4198
+ }
4199
+ tool;
4200
+ ms;
4201
+ };
4202
+ async function timed(tool, signal, ms, fn) {
1895
4203
  try {
1896
- return await readFile4(p, "utf8");
4204
+ return await fn(AbortSignal.any([signal, AbortSignal.timeout(ms)]));
1897
4205
  } catch (e) {
1898
- if (e.code === "ENOENT") return null;
4206
+ if (e instanceof CancelledError && !signal.aborted) throw new ToolTimeout(tool, ms);
1899
4207
  throw e;
1900
4208
  }
1901
4209
  }
1902
- async function writeSecurely(target, text) {
1903
- const base = basename2(target);
1904
- const tmpName = base.endsWith(".local") ? `${base.slice(0, -".local".length)}.qore-${randomBytes3(6).toString("hex")}.local` : `${base}.qore-${randomBytes3(6).toString("hex")}.tmp`;
1905
- const tmp = join10(dirname4(target), tmpName);
4210
+ function errorLabel(e) {
4211
+ return e instanceof Error ? e.message : String(e);
4212
+ }
4213
+ function checkQore(deps2, meta) {
4214
+ const v = deps2.version;
4215
+ if (!meta.ok) return withError("warn", `qore ${v} \u2014 could not check for updates`, meta.error);
4216
+ const { minCliVersion: min, latestCliVersion: latest } = meta.meta;
4217
+ if (!v.endsWith("-dev") && compareVersions(v, min) < 0) {
4218
+ const e = new QoreError("QORE_UPGRADE_REQUIRED", `qore ${v} is too old for this portal (needs ${min})`, { hint: `Run: ${UPGRADE}` });
4219
+ return withError("fail", `qore ${v} is too old for this portal (needs ${min}) \u2014 ${UPGRADE}`, e);
4220
+ }
4221
+ if (!v.endsWith("-dev") && compareVersions(v, latest) < 0) {
4222
+ return { status: "warn", label: `qore ${v} \u2014 ${latest} is available (${UPGRADE})`, details: { latest } };
4223
+ }
4224
+ return ok(`qore ${v} (latest)`);
4225
+ }
4226
+ function checkOwnNode(deps2) {
4227
+ const v = deps2.nodeVersion;
4228
+ if (nodeSatisfies(v, NODE_RANGE) === false) {
4229
+ const e = new QoreError("QORE_NODE_VERSION", `qore needs Node ${NODE_RANGE}, found ${v}`, {
4230
+ hint: "nvm install 22 / fnm use 22, then reinstall qore.",
4231
+ details: { found: v, required: NODE_RANGE }
4232
+ });
4233
+ return withError("fail", e.message, e);
4234
+ }
4235
+ return ok(`Node ${v} (\u226522 required)`);
4236
+ }
4237
+ async function checkGit(deps2, signal, ms) {
4238
+ const res = await timed("git", signal, ms, (s) => runTool(deps2, "git", ["--version"], { cwd: deps2.home, env: deps2.env, onLine: noop, signal: s }));
4239
+ if (res.spawnError === "ENOENT") {
4240
+ const e = new QoreError("QORE_GIT_UNAVAILABLE", "git is not installed or not on PATH", {
4241
+ hint: "Install git (https://git-scm.com/downloads) and open a new terminal."
4242
+ });
4243
+ return withError("fail", e.message, e);
4244
+ }
4245
+ if (res.code !== 0) {
4246
+ const e = new QoreError("QORE_GIT_UNAVAILABLE", "git --version failed", { details: { stderr: res.tail } });
4247
+ return withError("fail", e.message, e);
4248
+ }
4249
+ const m = /git version (\d+)\.(\d+)/.exec(res.tail.join("\n"));
4250
+ return ok(m ? `git ${m[1]}.${m[2]}` : "git");
4251
+ }
4252
+ async function checkGithubSsh(deps2, signal, ms) {
4253
+ const res = await timed("ssh", signal, ms, (s) => runTool(deps2, "ssh", SSH_TEST_ARGS, { cwd: deps2.home, env: deps2.env, onLine: noop, signal: s }));
4254
+ const r = classifySshTest(res);
4255
+ if (r.ok) return ok(`GitHub SSH ok (${r.user})`, { user: r.user });
4256
+ return withError("warn", `GitHub SSH: ${r.error.message} (https still works)`, r.error);
4257
+ }
4258
+ async function checkDocker(deps2, signal, ms) {
4259
+ return timed("docker", signal, ms, async (s) => {
4260
+ const o = { cwd: deps2.home, env: deps2.env, onLine: noop, signal: s };
4261
+ const info = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], o);
4262
+ const infoErr = classifyDockerInfo(info, deps2.platform);
4263
+ if (infoErr) return withError("warn", `${infoErr.message} (only needed for db:up)`, infoErr);
4264
+ const compose = await runTool(deps2, "docker", ["compose", "version"], o);
4265
+ return dockerBody(info.tail, compose);
4266
+ });
4267
+ }
4268
+ function dockerBody(infoTail, compose) {
4269
+ const composeErr = classifyComposeVersion(compose);
4270
+ if (composeErr) return withError("fail", composeErr.message, composeErr);
4271
+ const version = infoTail.map((l) => l.trim()).findLast(Boolean) ?? "";
4272
+ const major = /v?(\d+)\.\d+/.exec(compose.tail.join("\n"))?.[1];
4273
+ return ok(`Docker ${version} running \xB7 compose ${major ? `v${major}` : "ok"}`);
4274
+ }
4275
+ function jwtExp(token) {
4276
+ const payload = token.split(".")[1];
4277
+ if (!payload) return null;
1906
4278
  try {
1907
- await writeFile3(tmp, text, { mode: 384, flag: "wx" });
1908
- await chmod2(tmp, 384);
1909
- await rename3(tmp, target);
1910
- } finally {
1911
- await unlink(tmp).catch(() => {
4279
+ const exp = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).exp;
4280
+ return typeof exp === "number" && exp > 0 ? exp : null;
4281
+ } catch {
4282
+ return null;
4283
+ }
4284
+ }
4285
+ function validDays(rt, now) {
4286
+ const exp = jwtExp(rt.rt);
4287
+ const end = exp !== null ? exp * 1e3 : Date.parse(rt.obtainedAt) + IDLE_DAYS * DAY_MS;
4288
+ if (Number.isNaN(end)) return null;
4289
+ return Math.max(0, Math.floor((end - now.getTime()) / DAY_MS));
4290
+ }
4291
+ async function checkSession(deps2, portal, onRequest, sessions, selected, signal) {
4292
+ try {
4293
+ const session = await openSession(deps2, portal, "doctor", onRequest);
4294
+ const me = await session.api.get("/api/cli/v1/me");
4295
+ sessions.set(portal.apiOrigin, session);
4296
+ const rt = await session.tokens.getRefresh();
4297
+ const days = rt ? validDays(rt, deps2.now()) : null;
4298
+ const valid = days === null ? "" : ` \xB7 session valid ${days} ${days === 1 ? "day" : "days"}`;
4299
+ return ok(`Logged in to ${portalLabel(portal)} as ${me.email}${valid}`, { email: me.email, ...days !== null ? { validDays: days } : {} });
4300
+ } catch (e) {
4301
+ if (isCancel(e, signal) || selected) throw e;
4302
+ return withError("warn", `${portalLabel(portal)}: ${errorLabel(e)}`, e);
4303
+ }
4304
+ }
4305
+ function checkClock(meta) {
4306
+ if (!meta.ok) return withError("warn", "Clock \u2014 could not check (the portal did not answer)", meta.error);
4307
+ if (!meta.date) return { status: "warn", label: "Clock \u2014 could not check (no Date header)" };
4308
+ const skew = Math.round((meta.date.getTime() - meta.fetchedAt.getTime()) / 1e3);
4309
+ if (Math.abs(skew) > SKEW_S) {
4310
+ const minutes = Math.max(1, Math.round(Math.abs(skew) / 60));
4311
+ const e = new QoreError("QORE_CLOCK_SKEW", `Your clock is off by ${minutes} min`, {
4312
+ hint: "Turn on automatic date and time.",
4313
+ details: { skewSeconds: skew }
4314
+ });
4315
+ return withError("fail", e.message, e);
4316
+ }
4317
+ return ok("Clock in sync", { skewSeconds: skew });
4318
+ }
4319
+ function ownerText(e) {
4320
+ const d = e.details ?? {};
4321
+ if (d.owner === "process") return ` by ${String(d.command)} (pid ${String(d.pid)})`;
4322
+ if (d.owner === "docker") return ` by the Docker container ${String(d.name)}`;
4323
+ if (d.owner === "qore") return ` by a previous qore dev (pid ${String(d.pid)})`;
4324
+ return "";
4325
+ }
4326
+ var sameDir = async (a, b) => await realpath6(a).catch(() => a) === await realpath6(b).catch(() => b);
4327
+ async function moduleBody(deps2, m, runs, signal) {
4328
+ const parts = [];
4329
+ const part = async (fn) => {
4330
+ try {
4331
+ parts.push(await fn());
4332
+ } catch (e) {
4333
+ if (isCancel(e, signal)) throw e;
4334
+ parts.push({ status: "fail", label: errorLabel(e), error: e });
4335
+ }
4336
+ };
4337
+ const cfg = await readRepoConfig(m.root);
4338
+ let env = null;
4339
+ await part(async () => {
4340
+ const target = await resolveTarget(m.root, cfg.envFile ?? moduleDefaults(m.type).envFile);
4341
+ const text2 = await readOrNull(target);
4342
+ if (text2 === null) return { status: "warn", label: "port unknown (run qore env pull)" };
4343
+ env = Object.fromEntries(parseEnv(text2));
4344
+ const port = envPort(env, "PORT");
4345
+ if (port === null) return { status: "warn", label: `port unknown (no PORT in ${basename3(target)})` };
4346
+ if (!await deps2.ports.inUse(port)) return { status: "ok", label: `port ${port} free` };
4347
+ const e = await portInUseError(deps2, { slug: m.slug, port, key: "PORT", runs: await runs() });
4348
+ return { status: "fail", label: `port ${port} in use${ownerText(e)}`, error: e };
4349
+ });
4350
+ await part(() => m.access === "write" || m.access === "maintain" ? { status: "ok", label: "write" } : { status: "warn", label: m.access === "read" ? "read only \u2014 qore dev needs write" : `${m.access} access \u2014 qore dev needs write` });
4351
+ let recipe;
4352
+ let pkg;
4353
+ await part(async () => {
4354
+ pkg = await readPackageJson(m.root);
4355
+ recipe = planRecipe({
4356
+ root: m.root,
4357
+ pkg,
4358
+ pm: await detectPackageManager(m.root, pkg),
4359
+ cfg,
4360
+ slug: m.slug,
4361
+ flags: { install: true, services: true }
4362
+ });
4363
+ if (recipe.services.length === 0) return { status: "ok", label: "db none" };
4364
+ const dbPort = env ? envPort(env, "DB_PORT") : null;
4365
+ if (dbPort === null) return { status: "warn", label: "db port unknown (run qore env pull)" };
4366
+ if (!await deps2.ports.inUse(dbPort)) return { status: "ok", label: `db:up (DB_PORT ${dbPort} free)` };
4367
+ const pub = await deps2.ports.dockerPublisher(dbPort);
4368
+ if (pub?.workingDir && await sameDir(pub.workingDir, m.root)) return { status: "ok", label: "db running" };
4369
+ const e = await portInUseError(deps2, { slug: m.slug, port: dbPort, key: "DB_PORT" });
4370
+ return { status: "fail", label: `DB_PORT ${dbPort} in use${ownerText(e)}`, error: e };
4371
+ });
4372
+ const status2 = parts.reduce((w, p) => RANK[p.status] > RANK[w] ? p.status : w, "ok");
4373
+ const shown = parts.filter((p) => p.status !== "ok");
4374
+ const first = parts.find((p) => p.status === "fail" && p.error) ?? parts.find((p) => p.error);
4375
+ return {
4376
+ recipe,
4377
+ pkg,
4378
+ body: {
4379
+ status: status2,
4380
+ label: (shown.length ? shown : parts).map((p) => p.label).join(" \xB7 "),
4381
+ ...first ? { error: toJsonError(first.error) } : {},
4382
+ details: { parts: parts.map((p) => ({ status: p.status, label: p.label, ...p.error ? { error: toJsonError(p.error) } : {} })) }
4383
+ }
4384
+ };
4385
+ }
4386
+ function runsAt(deps2, root) {
4387
+ let p;
4388
+ return () => p ??= leftoverRun(deps2, root, { prune: false }).then((r) => r ? [r] : []);
4389
+ }
4390
+ var UNTRUSTED = { status: "warn", label: "not checked (repository not trusted)" };
4391
+ async function checkRepoPm(deps2, m, recipe, signal, ms) {
4392
+ if (!await isTrusted(deps2, m.root, m.id)) return UNTRUSTED;
4393
+ const found = await timed(recipe.pm.name, signal, ms, (s) => checkPackageManager(deps2, recipe, noop, s));
4394
+ const { name, version } = recipe.pm;
4395
+ if (found === null) return ok(`${name} (version unknown)`);
4396
+ return ok(version ? `${name} ${found} (matches)` : `${name} ${found}`, { found, ...version ? { pinned: version } : {} });
4397
+ }
4398
+ async function checkRepoNode(deps2, m, recipe, pkg, signal, ms) {
4399
+ if (!await isTrusted(deps2, m.root, m.id)) return UNTRUSTED;
4400
+ const found = await timed("node", signal, ms, (s) => checkNode(deps2, recipe, pkg, m.slug, s));
4401
+ return ok(`Node ${found ?? "?"} (engines ${pkg.engines.node})`);
4402
+ }
4403
+ async function loggedInPortals(deps2, profiles) {
4404
+ const rank = (o) => o === BUILTIN_PORTALS.prod.apiOrigin ? 0 : o === BUILTIN_PORTALS.staging.apiOrigin ? 1 : 2;
4405
+ const out = [];
4406
+ for (const origin of Object.keys(profiles).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))) {
4407
+ const p = profiles[origin];
4408
+ if (await new TokenStore(deps2.keychain, profileHash(p.apiOrigin, p.issuer)).getRefresh()) out.push(resolvePortal(origin, void 0));
4409
+ }
4410
+ return out;
4411
+ }
4412
+ async function resolveRepoModule(deps2, session, portal, meta, root) {
4413
+ if (meta.ok) requireCapability(meta.meta, "repo.resolve", portal.apiOrigin);
4414
+ const keys = [...new Set((await deps2.git.remotes(root)).map((r) => repoKey(r.url)).filter((k) => k !== null))];
4415
+ if (keys.length === 0) return { status: "warn", label: "this repository has no remote Qore can match" };
4416
+ const { matches } = await session.api.post("/api/cli/v1/repos/resolve", { repoKeys: keys });
4417
+ const ids = [...new Set(matches.map((x) => x.module.id))];
4418
+ if (ids.length === 0) return { status: "warn", label: `this repository matches no module on ${portalLabel(portal)}` };
4419
+ if (ids.length > 1) {
4420
+ return { status: "warn", label: `this repository matches several modules (${[...new Set(matches.map((x) => x.module.slug))].join(", ")})` };
4421
+ }
4422
+ return session.api.get(`/api/cli/v1/modules/${encodeURIComponent(ids[0])}`);
4423
+ }
4424
+ async function runDoctor(deps2, o = {}) {
4425
+ const signal = o.signal ?? new AbortController().signal;
4426
+ const toolMs = o.toolTimeoutMs ?? TOOL_TIMEOUT_MS;
4427
+ const results = [];
4428
+ const context = { kind: "none" };
4429
+ const push = async (id, group, fn, module) => {
4430
+ let body;
4431
+ try {
4432
+ body = await fn();
4433
+ } catch (e) {
4434
+ if (e instanceof ToolTimeout) body = { status: "warn", label: e.message, details: { timeoutMs: e.ms } };
4435
+ else if (isCancel(e, signal)) throw e;
4436
+ else body = withError("fail", errorLabel(e), e);
4437
+ }
4438
+ const r = { id, group, ...module ? { module } : {}, ...body };
4439
+ results.push(r);
4440
+ return r;
4441
+ };
4442
+ let cfg;
4443
+ try {
4444
+ cfg = await deps2.config.read();
4445
+ } catch (e) {
4446
+ if (isCancel(e, signal)) throw e;
4447
+ await push("config", "tools", () => withError("fail", errorLabel(e), e));
4448
+ cfg = { schemaVersion: 1, profiles: {} };
4449
+ }
4450
+ let ws = null;
4451
+ let wsError = null;
4452
+ try {
4453
+ ws = await findWorkspace(deps2.cwd, deps2.home);
4454
+ } catch (e) {
4455
+ wsError = e;
4456
+ }
4457
+ const portal = o.portal ? resolvePortal(o.portal, cfg.defaultProfile) : ws ? resolvePortal(ws.portal, void 0) : resolvePortal(void 0, cfg.defaultProfile);
4458
+ let meta;
4459
+ try {
4460
+ meta = { ok: true, ...await fetchMetaWithDate(deps2.fetch, portal.apiOrigin), fetchedAt: deps2.now() };
4461
+ } catch (e) {
4462
+ if (isCancel(e, signal)) throw e;
4463
+ meta = { ok: false, error: e };
4464
+ }
4465
+ await push("qore", "tools", () => checkQore(deps2, meta));
4466
+ await push("node", "tools", () => checkOwnNode(deps2));
4467
+ await push("git", "tools", () => checkGit(deps2, signal, toolMs));
4468
+ await push("github-ssh", "tools", () => checkGithubSsh(deps2, signal, toolMs));
4469
+ await push("docker", "tools", () => checkDocker(deps2, signal, toolMs));
4470
+ const sessions = /* @__PURE__ */ new Map();
4471
+ let loggedIn = [];
4472
+ try {
4473
+ loggedIn = await loggedInPortals(deps2, cfg.profiles);
4474
+ if (loggedIn.length === 0) await push("session", "session", () => ({ status: "warn", label: "not logged in \u2014 qore login" }));
4475
+ } catch (e) {
4476
+ if (isCancel(e, signal)) throw e;
4477
+ await push("session", "session", () => {
4478
+ throw e;
4479
+ });
4480
+ }
4481
+ for (const p of loggedIn) {
4482
+ await push(
4483
+ `session:${portalLabel(p)}`,
4484
+ "session",
4485
+ () => checkSession(deps2, p, o.onRequest, sessions, p.apiOrigin === portal.apiOrigin, signal)
4486
+ );
4487
+ }
4488
+ await push("clock", "tools", () => checkClock(meta));
4489
+ const session = sessions.get(portal.apiOrigin);
4490
+ const notChecked = () => push(
4491
+ "modules",
4492
+ "workspace",
4493
+ () => ({ status: "warn", label: `modules not checked \u2014 no working session for ${portalLabel(portal)}` })
4494
+ );
4495
+ const perModule = async (m, prepared) => {
4496
+ if (!prepared?.recipe) return;
4497
+ const { recipe, pkg } = prepared;
4498
+ await push(`pm:${m.slug}`, "workspace", () => checkRepoPm(deps2, m, recipe, signal, toolMs), m.slug);
4499
+ if (pkg?.engines?.node) await push(`node:${m.slug}`, "workspace", () => checkRepoNode(deps2, m, recipe, pkg, signal, toolMs), m.slug);
4500
+ };
4501
+ if (wsError) {
4502
+ await push("context", "workspace", () => {
4503
+ throw wsError;
1912
4504
  });
4505
+ return { results, context };
4506
+ }
4507
+ const wsRoot = ws ? await workspaceRoot(deps2).catch(() => null) : null;
4508
+ const repoRoot = wsRoot ? null : await findRepoRoot(deps2.cwd);
4509
+ if (wsRoot) {
4510
+ context.kind = "workspace";
4511
+ context.label = basename3(wsRoot.root);
4512
+ if (!session) {
4513
+ await notChecked();
4514
+ return { results, context };
4515
+ }
4516
+ let am;
4517
+ try {
4518
+ if (meta.ok) requireCapability(meta.meta, "app.modules", portal.apiOrigin);
4519
+ am = await workspaceAppModules(session, portal, wsRoot);
4520
+ } catch (e) {
4521
+ await push("modules", "workspace", () => {
4522
+ throw e;
4523
+ });
4524
+ return { results, context };
4525
+ }
4526
+ context.label = `${am.app.slug} (${am.app.tenant.name} / ${am.app.name})`;
4527
+ const runs = runsAt(deps2, wsRoot.root);
4528
+ for (const m of am.modules) await workspaceModule(deps2, wsRoot, am, m, runs, signal, push, perModule);
4529
+ } else if (repoRoot) {
4530
+ context.kind = "module";
4531
+ context.label = basename3(repoRoot);
4532
+ if (!session) {
4533
+ await notChecked();
4534
+ return { results, context };
4535
+ }
4536
+ let d = null;
4537
+ try {
4538
+ const resolved = await resolveRepoModule(deps2, session, portal, meta, repoRoot);
4539
+ if ("status" in resolved) await push("module", "workspace", () => resolved);
4540
+ else d = resolved;
4541
+ } catch (e) {
4542
+ if (isCancel(e, signal)) throw e;
4543
+ await push("module", "workspace", () => {
4544
+ throw e;
4545
+ });
4546
+ }
4547
+ if (!d) return { results, context };
4548
+ context.label = `${d.tenant.name} / ${d.slug}`;
4549
+ const m = { root: repoRoot, slug: d.slug, type: d.type, id: d.id, access: d.access };
4550
+ const runs = runsAt(deps2, repoRoot);
4551
+ let prepared = null;
4552
+ await push(`module:${m.slug}`, "workspace", async () => (prepared = await moduleBody(deps2, m, runs, signal)).body, m.slug);
4553
+ await perModule(m, prepared);
4554
+ }
4555
+ return { results, context };
4556
+ }
4557
+ async function workspaceModule(deps2, ws, am, m, runs, signal, push, perModule) {
4558
+ if (!isSafeSlug(m.slug)) {
4559
+ await push(`module:${m.slug}`, "workspace", () => ({ status: "warn", label: "not a safe directory name \u2014 skipped" }), m.slug);
4560
+ return;
1913
4561
  }
4562
+ const mod = { root: join19(ws.root, m.slug), slug: m.slug, type: m.type, id: m.id, access: m.access };
4563
+ let prepared = null;
4564
+ await push(`module:${m.slug}`, "workspace", async () => {
4565
+ const info = await deps2.git.inspect(mod.root);
4566
+ const action = planWorkspace(ws.root, [m], { [m.slug]: info }, []).actions[0];
4567
+ if (action.kind === "clone") return { status: "warn", label: `not cloned \u2014 ${notClonedReason(m, am)}` };
4568
+ if (action.kind !== "present") return { status: "warn", label: action.reason ?? action.kind };
4569
+ prepared = await moduleBody(deps2, mod, runs, signal);
4570
+ return prepared.body;
4571
+ }, m.slug);
4572
+ await perModule(mod, prepared);
4573
+ }
4574
+
4575
+ // src/cli/commands/doctor.ts
4576
+ function registerDoctor(program, ctx) {
4577
+ program.command("doctor").description("Check qore's own health: tools, session, and this workspace or module").action(async () => {
4578
+ const { deps: deps2, io: io2, signal } = ctx;
4579
+ const g = ctx.opts();
4580
+ const report = await runDoctor(deps2, { portal: g.portal, onRequest: requestLog(ctx), signal });
4581
+ const failed = report.results.some((r) => r.status === "fail");
4582
+ if (g.json) {
4583
+ renderDoctorJson(io2, report);
4584
+ } else {
4585
+ renderDoctorHuman(io2, report, pc3.isColorSupported && g.color !== false);
4586
+ }
4587
+ ctx.setExitCode(failed ? 1 : 0);
4588
+ });
1914
4589
  }
4590
+
4591
+ // src/cli/commands/env.ts
4592
+ import { randomBytes as randomBytes6 } from "crypto";
4593
+ import { chmod as chmod3, realpath as realpath7 } from "fs/promises";
4594
+ import { dirname as dirname7, join as join20, relative as relative7 } from "path";
4595
+ var maskLen = (v) => v === void 0 ? "(unset)" : `\u2022\u2022\u2022\u2022 (${v.length} chars)`;
1915
4596
  function registerEnv(program, ctx) {
1916
4597
  const env = program.command("env").description("Local environment");
1917
4598
  env.command("pull").description("Write this module's local secrets into its env file").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("--target <path>", "env file to write (inside the repository)").option("--use-portal", "take the portal's value for keys that differ locally").option("--yes", "trust this repository without asking").action(async (f) => {
@@ -1927,7 +4608,7 @@ function registerEnv(program, ctx) {
1927
4608
  const target = f.target ? await resolveTarget(mc.repoRoot, f.target, deps2.cwd) : await resolveTarget(mc.repoRoot, cfg.envFile ?? defaults.envFile);
1928
4609
  await ensureTrusted(ctx, mc, app, { yes: f.yes });
1929
4610
  const existing = await readOrNull(target);
1930
- const example = existing === null ? await readOrNull(join10(dirname4(target), ".env.example")) : null;
4611
+ const example = existing === null ? await readOrNull(join20(dirname7(target), ".env.example")) : null;
1931
4612
  const local = await fetchLocalEnv(deps2, mc, app);
1932
4613
  const inputs = {
1933
4614
  existing,
@@ -1936,7 +4617,7 @@ function registerEnv(program, ctx) {
1936
4617
  managed: local.managedKeys,
1937
4618
  localSecrets: cfg.localSecrets ?? defaults.localSecrets,
1938
4619
  defaultChoice: f.usePortal ? "use-portal" : "keep",
1939
- random: () => randomBytes3(32).toString("hex")
4620
+ random: () => randomBytes6(32).toString("hex")
1940
4621
  };
1941
4622
  let plan = planPull(inputs);
1942
4623
  if (plan.conflicts.length && io2.interactive && !f.usePortal) {
@@ -1964,11 +4645,11 @@ function registerEnv(program, ctx) {
1964
4645
  plan = planPull({ ...inputs, choices });
1965
4646
  }
1966
4647
  if (existing !== null && plan.text === existing) {
1967
- await chmod2(target, 384);
4648
+ await chmod3(target, 384);
1968
4649
  } else {
1969
4650
  await writeSecurely(target, plan.text);
1970
4651
  }
1971
- const rel = relative5(await realpath3(mc.repoRoot), target);
4652
+ const rel = relative7(await realpath7(mc.repoRoot), target);
1972
4653
  if (g.json) {
1973
4654
  const names = {
1974
4655
  created: plan.created,
@@ -2029,8 +4710,9 @@ function mapOidcError(e, host) {
2029
4710
  if (err === "access_denied") return new QoreError("QORE_LOGIN_DENIED", "The login was denied in the browser");
2030
4711
  if (err === "expired_token") return new QoreError("QORE_LOGIN_EXPIRED", "The login code expired", { hint: "Run `qore login` again." });
2031
4712
  if (err === "invalid_grant") return new QoreError("QORE_SESSION_EXPIRED", "Your session has ended", { hint: "Run `qore login`." });
2032
- const status2 = e.status;
2033
- if (status2 && status2 >= 500) return new QoreError("QORE_AUTH_UNAVAILABLE", "Keycloak is unavailable", { cause: e });
4713
+ const res = e.code === "OAUTH_RESPONSE_IS_NOT_CONFORM" ? e.cause : void 0;
4714
+ const status2 = e.status ?? (res instanceof Response ? res.status : void 0);
4715
+ if (status2 && status2 >= 500) return new QoreError("QORE_AUTH_UNAVAILABLE", "Keycloak is unavailable", { status: status2, cause: e });
2034
4716
  if (e instanceof TypeError) return unreachable(e, host);
2035
4717
  const cause = e?.cause;
2036
4718
  if (cause instanceof TypeError) return unreachable(cause, host);
@@ -2234,37 +4916,6 @@ function registerLogout(program, ctx) {
2234
4916
  });
2235
4917
  }
2236
4918
 
2237
- // src/cli/commands/run.ts
2238
- import { constants } from "os";
2239
- var childRunning = { current: false };
2240
- function registerRun(program, ctx) {
2241
- program.command("run").description("Run a command with this module's local secrets in its environment").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("--yes", "trust this repository without asking").argument("<command>", "the command to run (after --)").argument("[args...]", "its arguments, passed verbatim").action(async (cmd, args, f) => {
2242
- const { deps: deps2 } = ctx;
2243
- if (ctx.opts().json) {
2244
- throw new QoreError("QORE_CONTEXT_INVALID", "run has no --json output", {
2245
- hint: "Drop --json; the child's own output passes through."
2246
- });
2247
- }
2248
- const mc = await moduleContextFor(ctx, f, "run");
2249
- const app = await appFor(ctx, mc, f.app);
2250
- await ensureTrusted(ctx, mc, app, { yes: f.yes });
2251
- const local = await fetchLocalEnv(deps2, mc, app);
2252
- childRunning.current = true;
2253
- let exit;
2254
- try {
2255
- exit = await deps2.spawn.run(cmd, args, { ...deps2.env, ...local.env });
2256
- } finally {
2257
- childRunning.current = false;
2258
- }
2259
- if (exit.signal) {
2260
- deps2.raise(exit.signal);
2261
- ctx.setExitCode(128 + (constants.signals[exit.signal] ?? 0));
2262
- return;
2263
- }
2264
- ctx.setExitCode(exit.code ?? 1);
2265
- });
2266
- }
2267
-
2268
4919
  // src/cli/format.ts
2269
4920
  function ago(iso, now) {
2270
4921
  if (!iso) return "\u2013";
@@ -2356,8 +5007,7 @@ function registerStatus(program, ctx) {
2356
5007
  }
2357
5008
 
2358
5009
  // src/cli/commands/sync.ts
2359
- import { lstat as lstat2, readdir } from "fs/promises";
2360
- import { join as join11, relative as relative6 } from "path";
5010
+ import { join as join21, relative as relative8 } from "path";
2361
5011
  import { Option as Option2 } from "commander";
2362
5012
  function asFailure2(slug, e) {
2363
5013
  if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") {
@@ -2365,24 +5015,6 @@ function asFailure2(slug, e) {
2365
5015
  }
2366
5016
  throw e;
2367
5017
  }
2368
- async function strayRepos(root, slugs) {
2369
- let entries;
2370
- try {
2371
- entries = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
2372
- } catch {
2373
- return [];
2374
- }
2375
- const names = [];
2376
- for (const name of entries) {
2377
- if (slugs.has(name)) continue;
2378
- try {
2379
- await lstat2(join11(root, name, ".git"));
2380
- names.push(name);
2381
- } catch {
2382
- }
2383
- }
2384
- return names.sort();
2385
- }
2386
5018
  function registerSync(program, ctx) {
2387
5019
  program.command("sync").description("Clone new modules, fetch present ones, report removed and re-pointed ones").option("--pull", "fast-forward every clean present repository with an upstream").addOption(new Option2("--protocol <https|ssh>", "clone URL protocol").choices(["https", "ssh"]).default("https")).action(async (flags) => {
2388
5020
  const { deps: deps2, io: io2 } = ctx;
@@ -2407,7 +5039,7 @@ function registerSync(program, ctx) {
2407
5039
  continue;
2408
5040
  }
2409
5041
  try {
2410
- dirs[m.slug] = await deps2.git.inspect(join11(ws.root, m.slug));
5042
+ dirs[m.slug] = await deps2.git.inspect(join21(ws.root, m.slug));
2411
5043
  modulesForPlan.push(m);
2412
5044
  } catch (e) {
2413
5045
  earlyFailures.push(asFailure2(m.slug, e));
@@ -2419,7 +5051,7 @@ function registerSync(program, ctx) {
2419
5051
  } });
2420
5052
  await syncPresent(deps2, applied, actions, Boolean(flags.pull), { app, protocol: flags.protocol });
2421
5053
  for (const r of applied) {
2422
- if (r.status === "cloned" && r.dir) r.dir = relative6(deps2.cwd, r.dir) || ".";
5054
+ if (r.status === "cloned" && r.dir) r.dir = relative8(deps2.cwd, r.dir) || ".";
2423
5055
  }
2424
5056
  const removedResults = removed.map((slug) => ({ slug, status: "removed", reason: "not a module of this app \u2014 left in place" }));
2425
5057
  const results = [...earlyFailures, ...applied, ...removedResults];
@@ -2543,20 +5175,22 @@ var COMMANDS = [
2543
5175
  registerStatus,
2544
5176
  registerEnv,
2545
5177
  registerRun,
5178
+ registerDev,
2546
5179
  registerClone,
2547
5180
  registerSync,
2548
- registerVersion
5181
+ registerVersion,
5182
+ registerDoctor
2549
5183
  ];
2550
5184
 
2551
5185
  // src/cli/updateCheck.ts
2552
5186
  var REGISTRY_URL = "https://registry.npmjs.org/@quatronic%2Fqore/latest";
2553
- var DAY_MS = 24 * 60 * 60 * 1e3;
5187
+ var DAY_MS2 = 24 * 60 * 60 * 1e3;
2554
5188
  var DEFAULT_TIMEOUT_MS = 1500;
2555
5189
  async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS) {
2556
5190
  if (o.json || deps2.env.CI || deps2.env.QORE_NO_UPDATE_CHECK === "1" || !io2.interactive) return;
2557
5191
  const cfg = await deps2.config.read();
2558
5192
  const last = cfg.updateCheck?.lastCheckedAt;
2559
- if (last && deps2.now().getTime() - new Date(last).getTime() < DAY_MS) return;
5193
+ if (last && deps2.now().getTime() - new Date(last).getTime() < DAY_MS2) return;
2560
5194
  const controller = new AbortController();
2561
5195
  const timer = setTimeout(() => controller.abort(), timeoutMs);
2562
5196
  let latest;
@@ -2580,8 +5214,8 @@ async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS)
2580
5214
  }
2581
5215
 
2582
5216
  // src/core/config.ts
2583
- import { readFile as readFile5 } from "fs/promises";
2584
- import { join as join12 } from "path";
5217
+ import { readFile as readFile6 } from "fs/promises";
5218
+ import { join as join22 } from "path";
2585
5219
  var empty = () => ({ schemaVersion: 1, profiles: {} });
2586
5220
  var ConfigStore = class {
2587
5221
  constructor(dir2) {
@@ -2589,12 +5223,12 @@ var ConfigStore = class {
2589
5223
  }
2590
5224
  dir;
2591
5225
  get file() {
2592
- return join12(this.dir, "config.json");
5226
+ return join22(this.dir, "config.json");
2593
5227
  }
2594
5228
  async read() {
2595
5229
  let raw;
2596
5230
  try {
2597
- raw = await readFile5(this.file, "utf8");
5231
+ raw = await readFile6(this.file, "utf8");
2598
5232
  } catch (e) {
2599
5233
  if (e.code === "ENOENT") return empty();
2600
5234
  throw e;
@@ -2620,9 +5254,9 @@ var ConfigStore = class {
2620
5254
 
2621
5255
  // src/platform/git.ts
2622
5256
  import { execFile, execFileSync } from "child_process";
2623
- import { mkdir as mkdir3, readdir as readdir2, stat as stat2 } from "fs/promises";
5257
+ import { mkdir as mkdir3, readdir as readdir2, stat as stat3 } from "fs/promises";
2624
5258
  import { homedir } from "os";
2625
- import { dirname as dirname5, join as join13 } from "path";
5259
+ import { dirname as dirname8, join as join23 } from "path";
2626
5260
  function parseRemotes(stdout) {
2627
5261
  const seen = /* @__PURE__ */ new Set();
2628
5262
  const out = [];
@@ -2634,6 +5268,30 @@ function parseRemotes(stdout) {
2634
5268
  }
2635
5269
  return out;
2636
5270
  }
5271
+ function parseStatus(stdout) {
5272
+ const s = { ok: true, dirty: false, branch: null, upstream: false };
5273
+ for (const raw of stdout.split("\n")) {
5274
+ const line2 = raw.replace(/\r$/, "");
5275
+ if (line2.startsWith("# branch.head ")) {
5276
+ const name = line2.slice("# branch.head ".length).trim();
5277
+ s.branch = name === "(detached)" ? null : name;
5278
+ } else if (line2.startsWith("# branch.upstream ")) {
5279
+ s.upstream = true;
5280
+ s.upstreamRef = line2.slice("# branch.upstream ".length).trim();
5281
+ } else if (line2.startsWith("# branch.ab ")) {
5282
+ const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line2.trim());
5283
+ if (m) {
5284
+ s.ahead = Number(m[1]);
5285
+ s.behind = Number(m[2]);
5286
+ }
5287
+ } else if (line2.startsWith("#")) {
5288
+ continue;
5289
+ } else if (line2.trim().length > 0) {
5290
+ s.dirty = true;
5291
+ }
5292
+ }
5293
+ return s;
5294
+ }
2637
5295
  var configuredSshCommand = /* @__PURE__ */ new Map();
2638
5296
  function hasConfiguredSshCommand(repoDir) {
2639
5297
  const key = repoDir ?? "";
@@ -2692,7 +5350,7 @@ async function remotes(repoRoot) {
2692
5350
  return parseRemotes(r.stdout);
2693
5351
  }
2694
5352
  async function clone(url, dir2) {
2695
- await mkdir3(dirname5(dir2), { recursive: true });
5353
+ await mkdir3(dirname8(dir2), { recursive: true });
2696
5354
  const r = await runGit(["clone", "--", url, dir2]);
2697
5355
  return toResult(r, "git clone");
2698
5356
  }
@@ -2708,26 +5366,11 @@ async function status(repoRoot) {
2708
5366
  const command = "git status --porcelain=v2 --branch";
2709
5367
  const r = await runGit(["--no-optional-locks", "-C", repoRoot, "status", "--porcelain=v2", "--branch"], repoRoot);
2710
5368
  if (r.code !== 0) return { ok: false, stderr: r.stderr, command };
2711
- let branch = null;
2712
- let upstream = false;
2713
- let dirty = false;
2714
- for (const line2 of r.stdout.split(/\r?\n/)) {
2715
- if (line2.startsWith("# branch.head ")) {
2716
- const name = line2.slice("# branch.head ".length).trim();
2717
- branch = name === "(detached)" ? null : name;
2718
- } else if (line2.startsWith("# branch.upstream ")) {
2719
- upstream = true;
2720
- } else if (line2.startsWith("#")) {
2721
- continue;
2722
- } else if (line2.trim().length > 0) {
2723
- dirty = true;
2724
- }
2725
- }
2726
- return { ok: true, dirty, branch, upstream };
5369
+ return parseStatus(r.stdout);
2727
5370
  }
2728
5371
  async function statOrThrow(path) {
2729
5372
  try {
2730
- return await stat2(path);
5373
+ return await stat3(path);
2731
5374
  } catch (err) {
2732
5375
  if (err.code === "ENOENT") return void 0;
2733
5376
  throw new QoreError("QORE_INTERNAL", `could not stat ${path}`, { cause: err });
@@ -2744,7 +5387,7 @@ async function inspect(dir2) {
2744
5387
  throw new QoreError("QORE_INTERNAL", `could not read ${dir2}`, { cause: err });
2745
5388
  }
2746
5389
  const empty2 = entries.length === 0;
2747
- const isRepo = await statOrThrow(join13(dir2, ".git")) !== void 0;
5390
+ const isRepo = await statOrThrow(join23(dir2, ".git")) !== void 0;
2748
5391
  const dirRemotes = isRepo ? await remotes(dir2) : [];
2749
5392
  return { exists: true, empty: empty2, isRepo, remotes: dirRemotes };
2750
5393
  }
@@ -2806,13 +5449,13 @@ var OsKeychain = class {
2806
5449
 
2807
5450
  // src/platform/lock.ts
2808
5451
  import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
2809
- import { join as join14 } from "path";
5452
+ import { join as join24 } from "path";
2810
5453
  import lockfile from "proper-lockfile";
2811
5454
  function fileLock(dir2) {
2812
5455
  return async (name, fn) => {
2813
- const locks = join14(dir2, "locks");
5456
+ const locks = join24(dir2, "locks");
2814
5457
  await mkdir4(locks, { recursive: true, mode: 448 });
2815
- const target = join14(locks, `${name}.lock`);
5458
+ const target = join24(locks, `${name}.lock`);
2816
5459
  await writeFile4(target, "", { flag: "a" });
2817
5460
  let release;
2818
5461
  let compromised;
@@ -2852,17 +5495,25 @@ function signalsFor(platform) {
2852
5495
  return platform === "win32" ? { ignore: ["SIGINT", "SIGBREAK"], forward: [] } : { ignore: ["SIGINT", "SIGQUIT"], forward: ["SIGTERM", "SIGHUP"] };
2853
5496
  }
2854
5497
  var { ignore: IGNORE, forward: FORWARD } = signalsFor(process.platform);
5498
+ var STDERR_DRAIN_MS = 500;
2855
5499
  function makeSpawner(spawnFn, proc) {
2856
5500
  return {
2857
- run(cmd, args, env) {
5501
+ run(cmd, args, env, o) {
2858
5502
  return new Promise((resolve5, reject) => {
2859
5503
  let child;
2860
5504
  try {
2861
- child = spawnFn(cmd, args, { stdio: "inherit", shell: false, env, windowsHide: true });
5505
+ child = spawnFn(cmd, args, {
5506
+ stdio: o?.onStderr ? ["inherit", "inherit", "pipe"] : "inherit",
5507
+ shell: false,
5508
+ env,
5509
+ windowsHide: true,
5510
+ cwd: o?.cwd
5511
+ });
2862
5512
  } catch (e) {
2863
5513
  reject(new QoreError("QORE_INTERNAL", `Could not start ${cmd}: ${e.code ?? "error"}`));
2864
5514
  return;
2865
5515
  }
5516
+ if (o?.onStderr) child.stderr?.on("data", o.onStderr);
2866
5517
  const ignore = () => {
2867
5518
  };
2868
5519
  const forwarders = FORWARD.map((sig) => [sig, () => {
@@ -2880,7 +5531,22 @@ function makeSpawner(spawnFn, proc) {
2880
5531
  });
2881
5532
  child.once("exit", (code, signal) => {
2882
5533
  cleanup();
2883
- resolve5({ code, signal });
5534
+ const stderr = child.stderr;
5535
+ if (!o?.onStderr || !stderr || stderr.readableEnded) {
5536
+ resolve5({ code, signal });
5537
+ return;
5538
+ }
5539
+ let settled = false;
5540
+ const finish = () => {
5541
+ if (settled) return;
5542
+ settled = true;
5543
+ clearTimeout(timer);
5544
+ resolve5({ code, signal });
5545
+ };
5546
+ const timer = setTimeout(finish, STDERR_DRAIN_MS);
5547
+ timer.unref?.();
5548
+ stderr.once("end", finish);
5549
+ stderr.once("close", finish);
2884
5550
  });
2885
5551
  });
2886
5552
  }
@@ -2888,8 +5554,243 @@ function makeSpawner(spawnFn, proc) {
2888
5554
  }
2889
5555
  var crossSpawner = makeSpawner(spawn, process);
2890
5556
 
5557
+ // src/platform/launch.ts
5558
+ import spawn2 from "cross-spawn";
5559
+ var RESET_OR_CLEAR = /\x1Bc|\x1B\[[23]J/g;
5560
+ function stripScreenClears(line2) {
5561
+ return line2.includes("\x1B") ? line2.replace(RESET_OR_CLEAR, "") : line2;
5562
+ }
5563
+ function lineSplitter(stream, onLine) {
5564
+ let buf = "";
5565
+ return {
5566
+ push(chunk) {
5567
+ buf += chunk;
5568
+ let i;
5569
+ while ((i = buf.indexOf("\n")) >= 0) {
5570
+ onLine({ stream, line: stripScreenClears(buf.slice(0, i).replace(/\r$/, "")) });
5571
+ buf = buf.slice(i + 1);
5572
+ }
5573
+ },
5574
+ flush() {
5575
+ if (buf) {
5576
+ onLine({ stream, line: stripScreenClears(buf.replace(/\r$/, "")) });
5577
+ buf = "";
5578
+ }
5579
+ }
5580
+ };
5581
+ }
5582
+ var systemLauncher = {
5583
+ launch(cmd, args, o) {
5584
+ const posix2 = process.platform !== "win32";
5585
+ let child;
5586
+ try {
5587
+ child = spawn2(cmd, args, { cwd: o.cwd, env: o.env, stdio: ["ignore", "pipe", "pipe"], shell: false, detached: posix2, windowsHide: true });
5588
+ } catch (e) {
5589
+ return { pid: null, exited: Promise.resolve({ code: null, signal: null, spawnError: e.code ?? "error" }), stop() {
5590
+ } };
5591
+ }
5592
+ const out = lineSplitter("stdout", o.onLine);
5593
+ const err = lineSplitter("stderr", o.onLine);
5594
+ child.stdout?.setEncoding("utf8");
5595
+ child.stderr?.setEncoding("utf8");
5596
+ child.stdout?.on("data", (c) => out.push(c));
5597
+ child.stderr?.on("data", (c) => err.push(c));
5598
+ let closed = false;
5599
+ const exited = new Promise((resolve5) => {
5600
+ child.once("error", (e) => resolve5({ code: null, signal: null, spawnError: e.code ?? "error" }));
5601
+ child.once("close", (code, signal) => {
5602
+ closed = true;
5603
+ out.flush();
5604
+ err.flush();
5605
+ resolve5({ code, signal });
5606
+ });
5607
+ });
5608
+ return {
5609
+ pid: child.pid ?? null,
5610
+ exited,
5611
+ stop(signal) {
5612
+ if (child.pid === void 0 || closed) return;
5613
+ if (posix2) {
5614
+ try {
5615
+ process.kill(-child.pid, signal);
5616
+ } catch {
5617
+ }
5618
+ } else {
5619
+ spawn2(
5620
+ "taskkill",
5621
+ ["/pid", String(child.pid), "/T", ...signal === "SIGKILL" ? ["/F"] : []],
5622
+ { stdio: "ignore", windowsHide: true }
5623
+ ).on("error", () => {
5624
+ });
5625
+ }
5626
+ }
5627
+ };
5628
+ },
5629
+ isAlive(pid) {
5630
+ try {
5631
+ process.kill(pid, 0);
5632
+ return true;
5633
+ } catch (e) {
5634
+ return e.code === "EPERM";
5635
+ }
5636
+ },
5637
+ signal(pid, sig) {
5638
+ try {
5639
+ process.kill(pid, sig);
5640
+ return true;
5641
+ } catch {
5642
+ return false;
5643
+ }
5644
+ }
5645
+ };
5646
+
5647
+ // src/platform/ports.ts
5648
+ import { execFile as execFile2 } from "child_process";
5649
+ import { connect } from "net";
5650
+ function accepts(host, port) {
5651
+ return new Promise((resolve5) => {
5652
+ const s = connect({ host, port });
5653
+ const done = (v) => {
5654
+ s.destroy();
5655
+ resolve5(v);
5656
+ };
5657
+ s.setTimeout(500, () => done(false));
5658
+ s.once("connect", () => done(true));
5659
+ s.once("error", () => done(false));
5660
+ });
5661
+ }
5662
+ function run2(cmd, args) {
5663
+ return new Promise((resolve5) => {
5664
+ execFile2(
5665
+ cmd,
5666
+ args,
5667
+ { timeout: 2e3, env: { ...process.env, LC_ALL: "C" }, windowsHide: true },
5668
+ (e, stdout) => resolve5(e && !stdout ? null : String(stdout))
5669
+ );
5670
+ });
5671
+ }
5672
+ var systemPorts = {
5673
+ async inUse(port) {
5674
+ return await accepts("127.0.0.1", port) || await accepts("::1", port);
5675
+ },
5676
+ async waitFor(port, timeoutMs, signal) {
5677
+ const until = Date.now() + timeoutMs;
5678
+ while (!signal.aborted) {
5679
+ if (await this.inUse(port)) return true;
5680
+ if (Date.now() >= until) return false;
5681
+ await new Promise((r) => setTimeout(r, 250));
5682
+ }
5683
+ return false;
5684
+ },
5685
+ async waitFree(port, timeoutMs, signal) {
5686
+ const until = Date.now() + timeoutMs;
5687
+ while (!signal.aborted) {
5688
+ if (!await this.inUse(port)) return true;
5689
+ if (Date.now() >= until) return false;
5690
+ await new Promise((r) => setTimeout(r, 250));
5691
+ }
5692
+ return false;
5693
+ },
5694
+ async owner(port) {
5695
+ if (process.platform === "win32") {
5696
+ const out2 = await run2("netstat", ["-ano", "-p", "TCP"]);
5697
+ const line2 = out2?.split(/\r?\n/).find((l) => /LISTENING/.test(l) && new RegExp(`[:.]${port}\\s`).test(l));
5698
+ const pid2 = line2 ? Number(line2.trim().split(/\s+/).pop()) : NaN;
5699
+ if (!Number.isInteger(pid2) || pid2 <= 0) return null;
5700
+ const task = await run2("tasklist", ["/FI", `PID eq ${pid2}`, "/FO", "CSV", "/NH"]);
5701
+ return { pid: pid2, command: task?.split(",")[0]?.replace(/"/g, "").trim() || "unknown" };
5702
+ }
5703
+ const out = await run2("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"]);
5704
+ const pid = Number(out?.match(/^p(\d+)$/m)?.[1]);
5705
+ if (!Number.isInteger(pid) || pid <= 0) return null;
5706
+ return { pid, command: out?.match(/^c(.+)$/m)?.[1] ?? "unknown" };
5707
+ },
5708
+ async ownerDetail(pid) {
5709
+ if (process.platform === "win32") return { cwd: null, ageSeconds: null };
5710
+ const lsof = await run2("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]);
5711
+ const ps = await run2("ps", ["-o", "etime=", "-p", String(pid)]);
5712
+ return { cwd: lsof ? parseLsofCwd(lsof) : null, ageSeconds: ps ? parseEtime(ps.trim()) : null };
5713
+ },
5714
+ async dockerPublisher(port) {
5715
+ const out = await run2("docker", [
5716
+ "ps",
5717
+ "--filter",
5718
+ `publish=${port}`,
5719
+ "--format",
5720
+ '{{.ID}} {{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.working_dir"}}'
5721
+ ]);
5722
+ return out ? parseDockerPs(out) : null;
5723
+ },
5724
+ async parentPid(pid) {
5725
+ if (process.platform === "win32") return null;
5726
+ const n = Number((await run2("ps", ["-o", "ppid=", "-p", String(pid)]))?.trim());
5727
+ return Number.isInteger(n) && n > 0 ? n : null;
5728
+ }
5729
+ };
5730
+
5731
+ // src/platform/proxy.ts
5732
+ import { execFile as execFile3 } from "child_process";
5733
+
5734
+ // src/core/diagnostics/proxy.ts
5735
+ function parseScutilProxy(stdout) {
5736
+ const value = (key) => stdout.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, "m"))?.[1]?.trim();
5737
+ if (value("HTTPSEnable") === "1") {
5738
+ const host = value("HTTPSProxy");
5739
+ const port = value("HTTPSPort");
5740
+ if (host && port) return `${host}:${port}`;
5741
+ }
5742
+ if (value("HTTPEnable") === "1") {
5743
+ const host = value("HTTPProxy");
5744
+ const port = value("HTTPPort");
5745
+ if (host && port) return `${host}:${port}`;
5746
+ }
5747
+ return null;
5748
+ }
5749
+ function parseRegProxy(stdout) {
5750
+ const enabled = /ProxyEnable\s+REG_DWORD\s+(0x[0-9a-fA-F]+)/.exec(stdout)?.[1];
5751
+ if (enabled?.toLowerCase() !== "0x1") return null;
5752
+ return /ProxyServer\s+REG_SZ\s+(\S+)/.exec(stdout)?.[1] ?? null;
5753
+ }
5754
+
5755
+ // src/platform/proxy.ts
5756
+ function run3(cmd, args) {
5757
+ return new Promise((resolve5) => {
5758
+ execFile3(
5759
+ cmd,
5760
+ args,
5761
+ { timeout: 2e3, env: { ...process.env, LC_ALL: "C" }, windowsHide: true },
5762
+ (e, stdout) => resolve5(e && !stdout ? null : String(stdout))
5763
+ );
5764
+ });
5765
+ }
5766
+ async function detect(platform) {
5767
+ try {
5768
+ if (platform === "darwin") {
5769
+ const out = await run3("scutil", ["--proxy"]);
5770
+ return out ? parseScutilProxy(out) : null;
5771
+ }
5772
+ if (platform === "win32") {
5773
+ const key = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
5774
+ const [enable, server] = await Promise.all([
5775
+ run3("reg", ["query", key, "/v", "ProxyEnable"]),
5776
+ run3("reg", ["query", key, "/v", "ProxyServer"])
5777
+ ]);
5778
+ const combined = [enable, server].filter(Boolean).join("\n");
5779
+ return combined ? parseRegProxy(combined) : null;
5780
+ }
5781
+ return null;
5782
+ } catch {
5783
+ return null;
5784
+ }
5785
+ }
5786
+ var cached;
5787
+ function systemProxy(platform = process.platform) {
5788
+ if (!cached) cached = detect(platform).catch(() => null);
5789
+ return cached;
5790
+ }
5791
+
2891
5792
  // src/version.ts
2892
- var VERSION = true ? "0.1.2" : "0.0.0-dev";
5793
+ var VERSION = true ? "0.1.4" : "0.0.0-dev";
2893
5794
 
2894
5795
  // src/cli.ts
2895
5796
  if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY) setGlobalDispatcher(new EnvHttpProxyAgent());
@@ -2900,7 +5801,10 @@ var json = preSeparatorArgv.includes("--json");
2900
5801
  var ac = new AbortController();
2901
5802
  process.on("SIGINT", () => {
2902
5803
  if (ac.signal.aborted) {
2903
- if (childRunning.current) return;
5804
+ if (childRunning.current) {
5805
+ childRunning.onInterrupt?.();
5806
+ return;
5807
+ }
2904
5808
  process.exit(130);
2905
5809
  }
2906
5810
  ac.abort();
@@ -2916,15 +5820,22 @@ var deps = {
2916
5820
  env: process.env,
2917
5821
  platform: process.platform,
2918
5822
  version: VERSION,
5823
+ nodeVersion: process.versions.node,
2919
5824
  cwd: process.cwd(),
2920
5825
  home: homedir2(),
2921
5826
  git: systemGit,
2922
5827
  spawn: crossSpawner,
5828
+ launch: systemLauncher,
5829
+ ports: systemPorts,
5830
+ signals: process,
2923
5831
  raise: (signal) => {
2924
5832
  process.removeAllListeners(signal);
2925
5833
  process.kill(process.pid, signal);
2926
- }
5834
+ },
5835
+ pid: process.pid,
5836
+ systemProxy: () => systemProxy(process.platform)
2927
5837
  };
5838
+ var termination = preflightTermination(() => ac.abort(), deps.raise);
2928
5839
  var io = processIo(isInteractive({
2929
5840
  stdinTTY: Boolean(process.stdin.isTTY),
2930
5841
  stdoutTTY: Boolean(process.stdout.isTTY),
@@ -2933,6 +5844,11 @@ var io = processIo(isInteractive({
2933
5844
  env: process.env
2934
5845
  }));
2935
5846
  process.exitCode = await run(argv, deps, io, ac.signal, COMMANDS);
5847
+ await termination.reRaise(async () => {
5848
+ await Promise.all([process.stdout, process.stderr].map((s) => new Promise((r) => {
5849
+ s.write("", () => r());
5850
+ })));
5851
+ });
2936
5852
  if (!ac.signal.aborted) {
2937
5853
  try {
2938
5854
  await maybeNotifyUpdate(deps, io, { json });