@quatronic/qore 0.1.3 → 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
@@ -129,9 +129,17 @@ function processIo(interactive, stderrTTY = Boolean(process.stderr.isTTY)) {
129
129
  throw e;
130
130
  }
131
131
  },
132
- async confirm(message, signal) {
132
+ async search(message, source) {
133
133
  try {
134
- return await confirm({ message, default: false }, { signal });
134
+ return await search({ message, source: (term) => source(term ?? "") });
135
+ } catch (e) {
136
+ if (e.name === "ExitPromptError") throw new CancelledError();
137
+ throw e;
138
+ }
139
+ },
140
+ async confirm(message, signal, o) {
141
+ try {
142
+ return await confirm({ message, default: o?.default ?? false }, { signal });
135
143
  } catch (e) {
136
144
  if (e.name === "ExitPromptError" || e.name === "AbortPromptError") throw new CancelledError();
137
145
  throw e;
@@ -395,6 +403,23 @@ function argvBeforeSeparator(argv2) {
395
403
  function jsonFromArgv(argv2) {
396
404
  return argvBeforeSeparator(argv2).some((a) => a === "--json" || a.startsWith("--json="));
397
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
+ }
398
423
  function requestLog(ctx) {
399
424
  return ctx.opts().verbose ? (line2) => ctx.io.err(line2) : void 0;
400
425
  }
@@ -418,6 +443,9 @@ async function run(argv2, deps2, io2, signal, register) {
418
443
  const program = buildProgram(deps2, parseIo, signal, register, (code) => {
419
444
  state.exitCode = code;
420
445
  });
446
+ program.hook("preAction", (_program, action) => {
447
+ state.command = action.name();
448
+ });
421
449
  try {
422
450
  await program.parseAsync(argv2, { from: "user" });
423
451
  return state.exitCode ?? 0;
@@ -430,6 +458,7 @@ async function run(argv2, deps2, io2, signal, register) {
430
458
  }
431
459
  const opts = program.opts();
432
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.");
433
462
  return exitCodeFor(e);
434
463
  }
435
464
  }
@@ -513,6 +542,9 @@ async function loadMeta(deps2, apiOrigin, refresh) {
513
542
  });
514
543
  return { meta, cached: false };
515
544
  }
545
+ async function metaFor(deps2, apiOrigin) {
546
+ return (await loadMeta(deps2, apiOrigin, false)).meta;
547
+ }
516
548
  async function ensureCapability(deps2, apiOrigin, capability) {
517
549
  const first = await loadMeta(deps2, apiOrigin, false);
518
550
  let meta = first.meta;
@@ -754,8 +786,7 @@ async function openSession(deps2, portal, command, onRequest) {
754
786
  // src/core/workspace/apps.ts
755
787
  var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
756
788
  var tenantCandidates = (tenants) => tenants.map((t) => ({ kind: "tenant", id: t.id, slug: t.slug, name: t.name }));
757
- async function resolveAppRef(deps2, session, arg, tenantFlag) {
758
- if (UUID.test(arg)) return { appId: arg, tenant: null };
789
+ async function resolveTenant(deps2, session, tenantFlag, whatFor) {
759
790
  const me = await session.api.get("/api/cli/v1/me");
760
791
  let tenant;
761
792
  if (tenantFlag) {
@@ -779,11 +810,16 @@ async function resolveAppRef(deps2, session, arg, tenantFlag) {
779
810
  } else if (me.tenants.length === 1) {
780
811
  tenant = { id: me.tenants[0].id, slug: me.tenants[0].slug };
781
812
  } else {
782
- throw new QoreError("QORE_CONTEXT_REQUIRED", `Which tenant is "${arg}" in?`, {
813
+ throw new QoreError("QORE_CONTEXT_REQUIRED", `Which tenant is "${whatFor}" in?`, {
783
814
  hint: "Run `qore tenant use <slug>`, or pass --tenant <slug>.",
784
815
  candidates: tenantCandidates(me.tenants)
785
816
  });
786
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);
787
823
  try {
788
824
  const { id } = await session.api.get(
789
825
  `/api/cli/v1/tenants/${encodeURIComponent(tenant.id)}/apps/resolve?slug=${encodeURIComponent(arg)}`
@@ -806,7 +842,7 @@ async function resolveAppRef(deps2, session, arg, tenantFlag) {
806
842
  }
807
843
 
808
844
  // src/core/workspace/apply.ts
809
- import { join as join4, relative } from "path";
845
+ import { join as join4 } from "path";
810
846
 
811
847
  // src/core/context/cache.ts
812
848
  import { join as join2 } from "path";
@@ -956,7 +992,7 @@ async function trust(deps2, root, moduleId) {
956
992
  }
957
993
 
958
994
  // src/core/workspace/plan.ts
959
- import { join as join3 } from "path";
995
+ import { join as join3, relative } from "path";
960
996
  var SEGMENT = /^[A-Za-z0-9_.-]+$/;
961
997
  function isSupportedRepoUrl(repoUrl) {
962
998
  let u;
@@ -981,6 +1017,13 @@ function cloneUrl(repoUrl, protocol) {
981
1017
  const u = new URL(repoUrl);
982
1018
  return `git@${u.hostname}:${u.pathname.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "")}.git`;
983
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
+ }
984
1027
  function planWorkspace(root, modules, dirs, strayRepos2) {
985
1028
  const actions = modules.map((module) => {
986
1029
  const dir2 = join3(root, module.slug);
@@ -997,6 +1040,10 @@ function planWorkspace(root, modules, dirs, strayRepos2) {
997
1040
  const slugs = new Set(modules.map((m) => m.slug));
998
1041
  return { actions, removed: strayRepos2.filter((s) => !slugs.has(s)) };
999
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
+ }
1000
1047
 
1001
1048
  // src/core/workspace/diagnose.ts
1002
1049
  var ACCESS_CODES = /* @__PURE__ */ new Set(["QORE_GITHUB_NOT_LINKED", "QORE_REPO_NO_ACCESS", "QORE_REPO_ACCESS_PENDING"]);
@@ -1072,15 +1119,8 @@ function toFailure(slug, e) {
1072
1119
  throw e;
1073
1120
  }
1074
1121
  function conflictResult(deps2, action) {
1075
- const dir2 = relative(deps2.cwd, action.dir) || ".";
1076
- 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";
1077
- 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`;
1078
- return {
1079
- slug: action.module.slug,
1080
- status: "conflict",
1081
- reason: action.reason,
1082
- error: { code: "QORE_WORKSPACE_CONFLICT", message, hint: `Move ${dir2} aside (${what}), then run \`qore sync\`.` }
1083
- };
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 } };
1084
1124
  }
1085
1125
  async function applyActions(deps2, o) {
1086
1126
  const cache = new ResolutionCache(join4(deps2.config.dir, "cache"), deps2.now);
@@ -1382,8 +1422,34 @@ async function resolveApp(deps2, io2, session, appArg, tenantFlag) {
1382
1422
  return resolveAppRef(deps2, session, appArg, slug);
1383
1423
  }
1384
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
+ }
1385
1451
  function registerClone(program, ctx) {
1386
- 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) => {
1387
1453
  const { deps: deps2, io: io2 } = ctx;
1388
1454
  const g = ctx.opts();
1389
1455
  const cfg = await deps2.config.read();
@@ -1391,7 +1457,9 @@ function registerClone(program, ctx) {
1391
1457
  const onRequest = g.verbose ? (l) => io2.err(l) : void 0;
1392
1458
  const session = await openSession(deps2, portal, "clone", onRequest);
1393
1459
  await ensureCapability(deps2, portal.apiOrigin, "app.modules");
1394
- 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);
1395
1463
  const app = await session.api.get(`/api/cli/v1/apps/${encodeURIComponent(ref.appId)}/modules`);
1396
1464
  if (dirArg === void 0 && !isSafeSlug(app.app.slug)) throw unsafeSlugError(app.app.slug);
1397
1465
  const dirLabel = dirArg ?? app.app.slug;
@@ -1401,7 +1469,7 @@ function registerClone(program, ctx) {
1401
1469
  const ws = await readWorkspace(root);
1402
1470
  if (!ws || ws.appId !== app.app.id || ws.portal !== portal.apiOrigin) {
1403
1471
  throw new QoreError("QORE_WORKSPACE_CONFLICT", `${root} exists and is not the workspace of ${app.app.slug}`, {
1404
- hint: `Pick another directory: qore clone ${appArg} <dir>.`
1472
+ hint: `Pick another directory: qore clone ${appSlug} <dir>.`
1405
1473
  });
1406
1474
  }
1407
1475
  }
@@ -1442,7 +1510,7 @@ function registerClone(program, ctx) {
1442
1510
  import { randomBytes as randomBytes5 } from "crypto";
1443
1511
  import { realpath as realpath5 } from "fs/promises";
1444
1512
  import { constants as constants3 } from "os";
1445
- import { relative as relative6 } from "path";
1513
+ import { dirname as dirname6, join as join18, relative as relative6 } from "path";
1446
1514
  import { StringDecoder } from "string_decoder";
1447
1515
 
1448
1516
  // src/core/context/repoConfig.ts
@@ -1801,7 +1869,7 @@ async function resolveModuleContext(deps2, o) {
1801
1869
  });
1802
1870
  }
1803
1871
  var appCandidates = (apps) => apps.map((a) => ({ kind: "app", id: a.id, slug: a.slug, name: a.name }));
1804
- function pickApp(mc, app) {
1872
+ function pickApp2(mc, app) {
1805
1873
  const apps = mc.module.apps;
1806
1874
  if (app) {
1807
1875
  const found = apps.find((a) => a.id === app || a.slug === app);
@@ -1821,14 +1889,14 @@ function pickApp(mc, app) {
1821
1889
 
1822
1890
  // src/core/diagnostics/node.ts
1823
1891
  var KEEP_LINES = 50;
1824
- function earlyExitHint(tail, o) {
1825
- const text = tail.join("\n");
1826
- const missing = text.match(/Cannot find (?:module|package) '([^']+)'/);
1892
+ function exitHint(tail, h) {
1893
+ const text2 = tail.join("\n");
1894
+ const missing = text2.match(/Cannot find (?:module|package) '([^']+)'/);
1827
1895
  if (missing) {
1828
- return { reason: "missing-module", hint: `A dependency is missing (${missing[1]}). Run \`${o.installCommand}\`, then qore dev again.` };
1896
+ return { reason: "missing-module", hint: `A dependency is missing (${missing[1]}). Run \`${h.installCommand}\`, then qore dev again.` };
1829
1897
  }
1830
- if (/ZodError:/.test(text)) {
1831
- const keys = [...text.matchAll(/"path":\s*\[\s*"([^"]+)"/g)].map((m) => m[1]);
1898
+ if (/ZodError:/.test(text2)) {
1899
+ const keys = [...text2.matchAll(/"path":\s*\[\s*"([^"]+)"/g)].map((m) => m[1]);
1832
1900
  if (keys.length > 0) {
1833
1901
  return {
1834
1902
  reason: "invalid-env",
@@ -1837,7 +1905,7 @@ function earlyExitHint(tail, o) {
1837
1905
  };
1838
1906
  }
1839
1907
  }
1840
- const addrInUse = text.match(/listen EADDRINUSE: address already in use (?:\S*:)?(\d+)/);
1908
+ const addrInUse = text2.match(/listen EADDRINUSE: address already in use (?:\S*:)?(\d+)/);
1841
1909
  if (addrInUse) {
1842
1910
  const port = Number(addrInUse[1]);
1843
1911
  return {
@@ -1846,7 +1914,45 @@ function earlyExitHint(tail, o) {
1846
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.`
1847
1915
  };
1848
1916
  }
1849
- if (/ENOSPC: System limit for number of file watchers reached/.test(text)) {
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)) {
1850
1956
  return {
1851
1957
  reason: "watcher-limit",
1852
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"
@@ -1947,12 +2053,12 @@ function renderEnv(original, merged) {
1947
2053
  for (const [key, value] of merged) {
1948
2054
  if (!seen.has(key)) appended.push(`${key}=${value}`);
1949
2055
  }
1950
- let text = out.join("\n");
2056
+ let text2 = out.join("\n");
1951
2057
  if (appended.length) {
1952
- if (text.length && !text.endsWith("\n")) text += "\n";
1953
- text += appended.join("\n") + "\n";
2058
+ if (text2.length && !text2.endsWith("\n")) text2 += "\n";
2059
+ text2 += appended.join("\n") + "\n";
1954
2060
  }
1955
- return text;
2061
+ return text2;
1956
2062
  }
1957
2063
 
1958
2064
  // src/core/env/pull.ts
@@ -2034,12 +2140,12 @@ async function readOrNull(p) {
2034
2140
  throw e;
2035
2141
  }
2036
2142
  }
2037
- async function writeSecurely(target, text) {
2143
+ async function writeSecurely(target, text2) {
2038
2144
  const base = basename2(target);
2039
2145
  const tmpName = base.endsWith(".local") ? `${base.slice(0, -".local".length)}.qore-${randomBytes3(6).toString("hex")}.local` : `${base}.qore-${randomBytes3(6).toString("hex")}.tmp`;
2040
2146
  const tmp = join10(dirname4(target), tmpName);
2041
2147
  try {
2042
- await writeFile3(tmp, text, { mode: 384, flag: "wx" });
2148
+ await writeFile3(tmp, text2, { mode: 384, flag: "wx" });
2043
2149
  await chmod2(tmp, 384);
2044
2150
  await rename3(tmp, target);
2045
2151
  } finally {
@@ -2124,16 +2230,117 @@ async function devEnv(deps2, o) {
2124
2230
  cause = e;
2125
2231
  }
2126
2232
  }
2127
- const text = await readOrNull(target);
2128
- if (text === null || !await isTrusted(deps2, o.root, o.moduleId)) {
2233
+ const text2 = await readOrNull(target);
2234
+ if (text2 === null || !await isTrusted(deps2, o.root, o.moduleId)) {
2129
2235
  throw cause ?? new QoreError("QORE_API_UNREACHABLE", "The portal is unreachable", {
2130
2236
  hint: "The portal is unreachable and there is no env file to start from yet."
2131
2237
  });
2132
2238
  }
2133
- const env = Object.fromEntries(parseEnv(text));
2239
+ const env = Object.fromEntries(parseEnv(text2));
2134
2240
  return { env, offline: true, envFile: target, port: envPort(env, "PORT"), dbPort: envPort(env, "DB_PORT"), created: false, kept: [] };
2135
2241
  }
2136
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
+
2137
2344
  // src/core/dev/project.ts
2138
2345
  import { access, readFile as readFile5 } from "fs/promises";
2139
2346
  import { join as join13 } from "path";
@@ -2252,10 +2459,10 @@ function classifyComposeVersion(r) {
2252
2459
  // src/core/diagnostics/postgres.ts
2253
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/;
2254
2461
  function classifyComposeLogs(logs, o) {
2255
- const text = logs.join("\n");
2256
- if (!INCOMPATIBLE.test(text)) return null;
2462
+ const text2 = logs.join("\n");
2463
+ if (!INCOMPATIBLE.test(text2)) return null;
2257
2464
  const details = { reason: "pg-data-version" };
2258
- const m = text.match(/initialized by PostgreSQL version (\d+).*this version (\d+)/);
2465
+ const m = text2.match(/initialized by PostgreSQL version (\d+).*this version (\d+)/);
2259
2466
  if (m) {
2260
2467
  details.found = m[1];
2261
2468
  details.expected = m[2];
@@ -2345,6 +2552,7 @@ function nodeVersionError(found, range, slug) {
2345
2552
  // src/core/dev/preflight.ts
2346
2553
  var pmMajor = (v) => v ? v.split(".")[0] : "latest";
2347
2554
  var GRACE_MS = 1e4;
2555
+ var secs = (ms) => `${Math.round(ms / 1e3)} s`;
2348
2556
  async function runTool(deps2, cmd, args, o) {
2349
2557
  if (o.signal.aborted) throw new CancelledError();
2350
2558
  const tail = [];
@@ -2401,11 +2609,16 @@ async function checkNode(deps2, r, pkg, slug, signal) {
2401
2609
  return found;
2402
2610
  }
2403
2611
  async function installIfNeeded(deps2, r, slug, onLine, report, signal) {
2404
- report({ module: slug, step: "install", status: "start" });
2405
- if (!r.install || !await needsInstall(r.root, r.pm)) {
2406
- report({ module: slug, step: "install", status: "skip" });
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" });
2407
2618
  return;
2408
2619
  }
2620
+ report({ module: slug, step: "install", status: "start" });
2621
+ const began = deps2.now().getTime();
2409
2622
  const { cmd, args } = installArgs(r.pm);
2410
2623
  const res = await runTool(deps2, cmd, args, { cwd: r.root, env: deps2.env, onLine, signal });
2411
2624
  if (res.code !== 0) {
@@ -2414,7 +2627,7 @@ async function installIfNeeded(deps2, r, slug, onLine, report, signal) {
2414
2627
  if (err.details?.reason === "outdated-lockfile") throw err;
2415
2628
  throw new QoreError("QORE_INSTALL_FAILED", `${command} failed in ${slug}`, { hint: err.hint, details: err.details });
2416
2629
  }
2417
- report({ module: slug, step: "install", status: "done" });
2630
+ report({ module: slug, step: "install", status: "done", detail: secs(deps2.now().getTime() - began) });
2418
2631
  }
2419
2632
  async function portInUseError(deps2, o) {
2420
2633
  const owner = await deps2.ports.owner(o.port);
@@ -2477,7 +2690,12 @@ async function composeFailure(deps2, r, slug, env, signal, message, command, std
2477
2690
  });
2478
2691
  }
2479
2692
  async function startServices(deps2, r, slug, env, dbPort, waitMs, signal, onLine, report) {
2480
- report({ module: slug, step: "services", status: "start" });
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();
2481
2699
  if (r.dockerServices) {
2482
2700
  const res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: r.root, env, onLine, signal });
2483
2701
  const dockerErr = classifyDockerInfo(res, deps2.platform);
@@ -2497,7 +2715,7 @@ async function startServices(deps2, r, slug, env, dbPort, waitMs, signal, onLine
2497
2715
  throw await composeFailure(deps2, r, slug, env, signal, `${script} failed in ${slug}`, `${cmd} ${args.join(" ")}`, res.tail);
2498
2716
  }
2499
2717
  }
2500
- if (dbPort !== null && r.services.length > 0) {
2718
+ if (dbPort !== null) {
2501
2719
  const ok2 = await deps2.ports.waitFor(dbPort, waitMs, signal);
2502
2720
  if (signal.aborted) throw new CancelledError();
2503
2721
  if (!ok2) {
@@ -2513,7 +2731,8 @@ async function startServices(deps2, r, slug, env, dbPort, waitMs, signal, onLine
2513
2731
  );
2514
2732
  }
2515
2733
  }
2516
- report({ module: slug, step: "services", status: "done" });
2734
+ const took = secs(deps2.now().getTime() - began);
2735
+ report({ module: slug, step: "services", status: "done", detail: dbPort !== null ? `:${dbPort} (${took})` : `(${took})` });
2517
2736
  }
2518
2737
  async function checkPort(deps2, o) {
2519
2738
  if (o.port === null) return;
@@ -2568,6 +2787,44 @@ async function leftoverOwnsPort(deps2, run4, port) {
2568
2787
  return false;
2569
2788
  }
2570
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
+
2571
2828
  // src/core/dev/runFile.ts
2572
2829
  import { unlink as unlink2 } from "fs/promises";
2573
2830
  import { join as join15 } from "path";
@@ -2621,11 +2878,11 @@ async function moduleContextFor(ctx, flags, command) {
2621
2878
  }
2622
2879
  async function appFor(ctx, mc, app) {
2623
2880
  try {
2624
- return pickApp(mc, app);
2881
+ return pickApp2(mc, app);
2625
2882
  } catch (e) {
2626
2883
  if (!(e instanceof QoreError) || e.code !== "QORE_APP_AMBIGUOUS" || !ctx.io.interactive) throw e;
2627
2884
  const id = await ctx.io.select("Select an app", mc.module.apps.map((a) => ({ name: a.name, value: a.id })));
2628
- return pickApp(mc, id);
2885
+ return pickApp2(mc, id);
2629
2886
  }
2630
2887
  }
2631
2888
  function sourceText(mc) {
@@ -2649,13 +2906,32 @@ function contextJson(mc, app) {
2649
2906
  };
2650
2907
  }
2651
2908
 
2652
- // src/cli/devRecover.ts
2653
- var DOCKER_WAIT = { timeoutMs: 12e4, pollMs: 2e3 };
2654
- var STOP_WAIT_MS = 1e4;
2655
- function canRecover(ctx) {
2909
+ // src/cli/findings.ts
2910
+ function canOffer(ctx) {
2656
2911
  const g = ctx.opts();
2657
2912
  return ctx.io.interactive && !g.json && g.input !== false;
2658
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;
2659
2935
  var daemonDown = (e) => e instanceof QoreError && e.code === "QORE_DOCKER_NOT_RUNNING" && e.details?.reason === "daemon";
2660
2936
  function dockerRecovery(ctx, signal) {
2661
2937
  let answer;
@@ -2663,8 +2939,9 @@ function dockerRecovery(ctx, signal) {
2663
2939
  try {
2664
2940
  await fn();
2665
2941
  } catch (e) {
2666
- if (!daemonDown(e) || !canRecover(ctx)) throw e;
2942
+ if (!daemonDown(e) || !canOffer(ctx)) throw e;
2667
2943
  answer ??= (async () => {
2944
+ if (!await offerFix(ctx, "Docker is not running. Wait for it to start?", { default: true, signal })) return false;
2668
2945
  ctx.io.err("Waiting for Docker\u2026 (Ctrl+C to stop)");
2669
2946
  const ok2 = await waitForDocker(ctx.deps, { ...at, signal, ...DOCKER_WAIT });
2670
2947
  if (ok2) ctx.io.err("\u2713 Docker is running");
@@ -2680,7 +2957,7 @@ async function stopLeftover(ctx, e, runs, signal) {
2680
2957
  const port = e.details?.port;
2681
2958
  const run4 = runs.find((r) => r.qorePid === e.details?.pid);
2682
2959
  if (!run4 || typeof port !== "number" || !await leftoverOwnsPort(deps2, run4, port)) return false;
2683
- if (!await io2.confirm(`Stop the previous qore dev (pid ${run4.qorePid})?`, signal)) return false;
2960
+ if (!await offerFix(ctx, `Stop the previous qore dev (pid ${run4.qorePid})?`, { default: false, signal })) return false;
2684
2961
  if (!deps2.launch.signal(run4.qorePid, "SIGTERM")) {
2685
2962
  io2.err(`! pid ${run4.qorePid} could not be signalled`);
2686
2963
  return false;
@@ -2693,7 +2970,7 @@ async function checkPortOrStopLeftover(ctx, o, signal) {
2693
2970
  try {
2694
2971
  await checkPort(ctx.deps, o);
2695
2972
  } catch (e) {
2696
- if (!(e instanceof QoreError) || e.code !== "QORE_PORT_IN_USE" || e.details?.owner !== "qore" || !canRecover(ctx)) throw e;
2973
+ if (!(e instanceof QoreError) || e.code !== "QORE_PORT_IN_USE" || e.details?.owner !== "qore" || !canOffer(ctx)) throw e;
2697
2974
  const freed = await stopLeftover(ctx, e, o.runs, signal);
2698
2975
  if (signal.aborted) throw new CancelledError();
2699
2976
  if (!freed) throw e;
@@ -2701,6 +2978,42 @@ async function checkPortOrStopLeftover(ctx, o, signal) {
2701
2978
  }
2702
2979
  }
2703
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
+
2704
3017
  // src/cli/termination.ts
2705
3018
  var preflightHook = {};
2706
3019
  function preflightTermination(abort, raise) {
@@ -2764,7 +3077,7 @@ async function ensureTrusted(ctx, mc, app, o) {
2764
3077
  // src/cli/commands/devWorkspace.ts
2765
3078
  import { randomBytes as randomBytes4 } from "crypto";
2766
3079
  import { realpath as realpath4 } from "fs/promises";
2767
- import { join as join16, relative as relative5 } from "path";
3080
+ import { join as join17, relative as relative5 } from "path";
2768
3081
 
2769
3082
  // src/core/dev/order.ts
2770
3083
  function selectSkips(modules, sel) {
@@ -2986,7 +3299,7 @@ function supervise(o) {
2986
3299
  const early = !!e.spawnError || now() - startedAt < EARLY_MS;
2987
3300
  const rawCode = e.spawnError ? null : e.code;
2988
3301
  const rawSignal = e.spawnError ? null : e.signal;
2989
- const hint = early && rawSignal === null && rawCode !== null && rawCode !== 0 ? earlyExitHint(tails.get(m.slug) ?? [], { installCommand: m.installCmd }) ?? void 0 : void 0;
3302
+ const hint = stopCode === null && rawSignal === null && rawCode !== null && rawCode !== 0 ? exitHint(tails.get(m.slug) ?? [], m.hint) ?? void 0 : void 0;
2990
3303
  emit({ type: "exit", module: m.slug, code: rawCode, signal: rawSignal, early, hint });
2991
3304
  if (code !== 0 && firstFailure === null) firstFailure = code;
2992
3305
  if (o.killOthers) beginStop("kill-others", code);
@@ -3019,25 +3332,26 @@ function supervise(o) {
3019
3332
  };
3020
3333
  }
3021
3334
 
3022
- // src/core/workspace/access.ts
3023
- function devTeamsOf(am, m) {
3024
- const own = m.devTeamIds?.length ? am.devTeams.filter((t) => m.devTeamIds.includes(t.id)) : [];
3025
- return own.length ? own : am.devTeams;
3026
- }
3027
- function askMaintainer(am, m) {
3028
- const teams = devTeamsOf(am, m);
3029
- const names = teams.map((t) => t.name);
3030
- const maintainers = [...new Set(teams.flatMap((t) => t.maintainers))];
3031
- const who = names.length ? names.join(", ") : "the app's dev team";
3032
- return { teams: names, maintainers, text: `ask a maintainer of ${who}${maintainers.length ? ` (${maintainers.join(", ")})` : ""}` };
3033
- }
3034
- function notClonedReason(m, am) {
3035
- if (am && !m.githubLinked) return `GitHub account not linked (${am.accountUrl})`;
3036
- if (am && m.repoAccess === "none") {
3037
- const names = devTeamsOf(am, m).map((t) => t.name);
3038
- return names.length ? `no repository access (dev team ${names.join(", ")})` : "no repository access (ask your tenant owner)";
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 [];
3039
3344
  }
3040
- return "run qore sync";
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();
3041
3355
  }
3042
3356
 
3043
3357
  // src/cli/devOutput.ts
@@ -3135,18 +3449,6 @@ async function workspaceContext(ctx, ws) {
3135
3449
  return { am: { app: last.app, accountUrl: "", devTeams: [], modules }, online: null, offlineCause: e };
3136
3450
  }
3137
3451
  }
3138
- function deadAliases(env, ports) {
3139
- return Object.keys(env).filter((k) => {
3140
- if (!/^CONN_(.+)_URL$/.test(k)) return false;
3141
- let port;
3142
- try {
3143
- port = Number(new URL(env[k].trim().replace(/^["']|["']$/g, "")).port);
3144
- } catch {
3145
- return false;
3146
- }
3147
- return port > 0 && !ports.has(port);
3148
- }).sort();
3149
- }
3150
3452
  async function pool(items, n, fn) {
3151
3453
  let next = 0;
3152
3454
  const results = await Promise.allSettled(Array.from({ length: Math.min(n, items.length) }, async () => {
@@ -3157,7 +3459,7 @@ async function pool(items, n, fn) {
3157
3459
  }
3158
3460
  async function workspaceDev(ctx, ws, f, waitTimeout) {
3159
3461
  const ready = await inPreflight(ctx.deps.signals, ctx.signal, (p) => prepareWorkspace(ctx, ws, f, waitTimeout, p));
3160
- await superviseAll(ctx, ws, ready.units, ready.waitFor, ready.o);
3462
+ await superviseAll(ctx, ws, ready.units, ready.waitFor, ready.o, ready.notRunning);
3161
3463
  }
3162
3464
  async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
3163
3465
  const { deps: deps2, io: io2 } = ctx;
@@ -3202,22 +3504,48 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
3202
3504
  continue;
3203
3505
  }
3204
3506
  try {
3205
- dirs[m.slug] = await deps2.git.inspect(join16(ws.root, m.slug));
3507
+ dirs[m.slug] = await deps2.git.inspect(join17(ws.root, m.slug));
3206
3508
  safe.push(m);
3207
3509
  } catch (e) {
3208
3510
  if (!(e instanceof QoreError) || e.code === "QORE_GIT_UNAVAILABLE") throw e;
3209
3511
  problems.push(withDetails(e, { module: m.slug }));
3210
3512
  }
3211
3513
  }
3212
- const present = new Map(planWorkspace(ws.root, safe, dirs, []).actions.filter((a) => a.kind === "present").map((a) => [a.module.slug, a.dir]));
3213
3514
  const bySlug = new Map(safe.map((m) => [m.slug, m]));
3214
- const plan = planDev(safe.map((m) => ({
3215
- id: m.id,
3216
- slug: m.slug,
3217
- present: present.has(m.slug),
3218
- canWrite: m.access === "write" || m.access === "maintain",
3219
- dependsOn: m.dependsOn
3220
- })), { only: slugList(f.only), skip: slugList(f.skip) });
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();
3221
3549
  for (const s of plan.skipped) {
3222
3550
  const m = bySlug.get(s.slug);
3223
3551
  if (s.reason === "no-write") {
@@ -3226,8 +3554,10 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
3226
3554
  `- ${s.slug} no write access \u2014 local secrets need write; ${ask.text}`,
3227
3555
  { type: "skip", module: s.slug, reason: s.reason, devTeams: ask.teams, maintainers: ask.maintainers }
3228
3556
  );
3557
+ } else if (s.reason === "not-cloned") {
3558
+ if (json2) io2.out(JSON.stringify({ type: "skip", module: s.slug, reason: s.reason }));
3229
3559
  } else {
3230
- note(`- ${s.slug} ${s.reason === "not-cloned" ? `not cloned \u2014 ${notClonedReason(m, online ? am : void 0)}` : "excluded"}`, { type: "skip", module: s.slug, reason: s.reason });
3560
+ note(`- ${s.slug} excluded`, { type: "skip", module: s.slug, reason: s.reason });
3231
3561
  }
3232
3562
  }
3233
3563
  for (const c of plan.cycles) warn(`${c.join(", ")} depend on each other \u2014 they start together, without waiting`, { modules: c });
@@ -3262,8 +3592,7 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
3262
3592
  else if (l.stream === "stdout") io2.out(prefix(slug, l.line));
3263
3593
  else io2.err(prefix(slug, l.line));
3264
3594
  };
3265
- const report = () => {
3266
- };
3595
+ const report = progressReporter(ctx, { prefix: json2 ? void 0 : prefix, installFlag: f.install, servicesFlag: f.services });
3267
3596
  for (const u of units) {
3268
3597
  await attempt(u.m.slug, async () => {
3269
3598
  u.cfg = await readRepoConfig(u.dir);
@@ -3285,13 +3614,27 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
3285
3614
  const withRecipe = units.filter((u) => u.recipe && u.trusted);
3286
3615
  for (const name of [...new Set(withRecipe.map((u) => u.recipe.pm.name))]) {
3287
3616
  const users = withRecipe.filter((u) => u.recipe.pm.name === name);
3288
- const pmOk = await attempt(users[0].m.slug, () => checkPackageManager(deps2, users[0].recipe, () => {
3289
- }, p.signal));
3290
- for (const u of users) u.pmOk = pmOk;
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
+ }
3291
3626
  }
3292
3627
  for (const u of withRecipe) {
3293
- const nodeOk = await attempt(u.m.slug, () => checkNode(deps2, u.recipe, u.pkg, u.m.slug, p.signal));
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
+ });
3294
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
+ });
3295
3638
  }
3296
3639
  await pool(units.filter((u) => u.pmOk && u.trusted), INSTALL_PARALLELISM, async (u) => {
3297
3640
  u.installed = await attempt(u.m.slug, () => installIfNeeded(deps2, u.recipe, u.m.slug, onLine(u.m.slug), report, p.signal));
@@ -3337,11 +3680,21 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
3337
3680
  { module: slug, keys: dev.kept }
3338
3681
  );
3339
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
+ }
3340
3691
  if (u.installed) {
3341
3692
  const env = { ...deps2.env, ...dev.env };
3342
3693
  await attempt(slug, () => withDocker({ cwd: u.dir, env }, () => startServices(deps2, u.recipe, slug, env, dev.dbPort, waitTimeout * 1e3, p.signal, onLine(slug), report)));
3343
3694
  }
3344
- await attempt(slug, () => checkPortOrStopLeftover(ctx, { slug, port: dev.port, key: "PORT", runs }, p.signal));
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
+ }
3345
3698
  }
3346
3699
  if (p.signal.aborted) throw new CancelledError();
3347
3700
  const byPort = /* @__PURE__ */ new Map();
@@ -3369,7 +3722,7 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
3369
3722
  if (online && units.every((u) => !u.dev.offline)) {
3370
3723
  const envFile = (m) => {
3371
3724
  const u = units.find((x) => x.m.id === m.id);
3372
- return u ? relative5(rootReal, u.dev.envFile) : join16(m.slug, moduleDefaults(m.type).envFile);
3725
+ return u ? relative5(rootReal, u.dev.envFile) : join17(m.slug, moduleDefaults(m.type).envFile);
3373
3726
  };
3374
3727
  await saveLastStart(deps2, ws.root, {
3375
3728
  schemaVersion: 1,
@@ -3390,9 +3743,14 @@ async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
3390
3743
  });
3391
3744
  }
3392
3745
  if (p.signal.aborted) throw new CancelledError();
3393
- return { units, waitFor: plan.waitFor, o: { waitTimeout, killOthers: Boolean(f.killOthers), json: json2, color, prefix } };
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 } };
3394
3752
  }
3395
- async function superviseAll(ctx, ws, units, waitFor, o) {
3753
+ async function superviseAll(ctx, ws, units, waitFor, o, notRunning) {
3396
3754
  const { deps: deps2, io: io2 } = ctx;
3397
3755
  const startedAt = deps2.now().toISOString();
3398
3756
  const began = /* @__PURE__ */ new Map();
@@ -3414,9 +3772,9 @@ async function superviseAll(ctx, ws, units, waitFor, o) {
3414
3772
  case "line":
3415
3773
  return e.stream === "stdout" ? io2.out(o.prefix(e.module, e.line)) : io2.err(o.prefix(e.module, e.line));
3416
3774
  case "start":
3417
- return io2.err(`\u25B6 ${e.module}${e.pid === null ? "" : ` (pid ${e.pid})`}`);
3775
+ return io2.err(`\u25B6 starting ${e.module}${e.pid === null ? "" : ` (pid ${e.pid})`}`);
3418
3776
  case "ready":
3419
- return io2.err(`\u2713 ${e.module} is listening on ${e.port}`);
3777
+ return io2.err(`\u2713 ${e.module} ready`);
3420
3778
  case "provider-missing":
3421
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`);
3422
3780
  case "stopping":
@@ -3428,23 +3786,45 @@ async function superviseAll(ctx, ws, units, waitFor, o) {
3428
3786
  const how = e.signal ? `was ended by ${e.signal}` : `exited with code ${e.code}`;
3429
3787
  io2.err(`${e.code === 0 ? "\u25A0" : "\u2717"} ${e.module} ${how}${after}`);
3430
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
+ }
3431
3792
  return;
3432
3793
  }
3433
3794
  }
3434
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();
3435
3811
  const onEvent = (e) => {
3436
3812
  if (e.type === "start") {
3437
3813
  began.set(e.module, deps2.now().getTime());
3438
3814
  writeRun();
3439
3815
  }
3440
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
+ }
3441
3821
  if (o.json) io2.out(JSON.stringify(e));
3442
3822
  else render(e);
3823
+ if (e.type === "ready") summarize();
3443
3824
  };
3444
3825
  const s = ref.sup = supervise({
3445
3826
  modules: units.map((u) => {
3446
3827
  const { cmd, args } = runArgs(u.recipe.pm, u.recipe.script);
3447
- const { cmd: installCmd, args: installArgv } = installArgs(u.recipe.pm);
3448
3828
  return {
3449
3829
  slug: u.m.slug,
3450
3830
  cmd,
@@ -3452,7 +3832,7 @@ async function superviseAll(ctx, ws, units, waitFor, o) {
3452
3832
  cwd: u.dir,
3453
3833
  port: u.dev.port,
3454
3834
  waitFor: waitFor[u.m.slug] ?? [],
3455
- installCmd: [installCmd, ...installArgv].join(" "),
3835
+ hint: hintContext(u.recipe, u.pkg, u.dev.env),
3456
3836
  env: { ...deps2.env, ...u.dev.env, ...o.color ? { FORCE_COLOR: "1" } : {} }
3457
3837
  };
3458
3838
  }),
@@ -3474,6 +3854,11 @@ async function superviseAll(ctx, ws, units, waitFor, o) {
3474
3854
  if (ctx.signal.aborted) onAbort();
3475
3855
  writeRun();
3476
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
+ }
3477
3862
  } finally {
3478
3863
  deps2.signals.off("SIGTERM", onTerm);
3479
3864
  deps2.signals.off("SIGHUP", onHup);
@@ -3543,6 +3928,48 @@ function parseWaitTimeout(raw) {
3543
3928
  return n;
3544
3929
  }
3545
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
+ }
3546
3973
  function usableLast2(last) {
3547
3974
  if (!last || last.kind !== "module") return false;
3548
3975
  const m = last.module;
@@ -3567,8 +3994,9 @@ async function devContext(ctx, f, root) {
3567
3994
  }
3568
3995
  async function moduleDev(ctx, f, waitTimeout) {
3569
3996
  const { deps: deps2, io: io2 } = ctx;
3570
- const { root, slug, recipe, env, port } = await inPreflight(deps2.signals, ctx.signal, (p) => prepareModule(ctx, f, waitTimeout, p));
3997
+ const { root, slug, recipe, env, port, pkg, devEnv: devEnv2 } = await inPreflight(deps2.signals, ctx.signal, (p) => prepareModule(ctx, f, waitTimeout, p));
3571
3998
  const { cmd, args } = runArgs(recipe.pm, recipe.script);
3999
+ io2.err(`\u25B6 starting ${slug}${port === null ? "" : ` \u2014 http://localhost:${port}`}`);
3572
4000
  const startedAt = deps2.now();
3573
4001
  await writeRunFile(deps2, root, {
3574
4002
  schemaVersion: 1,
@@ -3589,10 +4017,9 @@ async function moduleDev(ctx, f, waitTimeout) {
3589
4017
  await removeRunFile(deps2, root);
3590
4018
  }
3591
4019
  const ms = deps2.now().getTime() - startedAt.getTime();
3592
- if (exit.code !== 0 && exit.signal === null && ms < EARLY_EXIT_MS) {
3593
- io2.err(`\u2717 ${slug} exited after ${Math.round(ms / 1e3)} s with code ${exit.code ?? 1} \u2014 its output above says why.`);
3594
- const { cmd: installCmd, args: installArgv } = installArgs(recipe.pm);
3595
- const hint = earlyExitHint(tee.lines(), { installCommand: [installCmd, ...installArgv].join(" ") });
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));
3596
4023
  if (hint) io2.err(` \u2192 ${hint.hint}`);
3597
4024
  }
3598
4025
  if (exit.signal) {
@@ -3637,11 +4064,16 @@ async function prepareModule(ctx, f, waitTimeout, p) {
3637
4064
  });
3638
4065
  const runs = [await leftoverRun(deps2, root)].filter((r) => r !== null);
3639
4066
  const onLine = (l) => l.stream === "stdout" ? io2.out(l.line) : io2.err(l.line);
3640
- const report = () => {
3641
- };
3642
- await checkPackageManager(deps2, recipe, () => {
4067
+ const report = progressReporter(ctx, { installFlag: f.install, servicesFlag: f.services });
4068
+ const pmVersion = await checkPackageManager(deps2, recipe, () => {
3643
4069
  }, p.signal);
3644
- await checkNode(deps2, recipe, pkg, slug, 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
+ });
3645
4077
  await installIfNeeded(deps2, recipe, slug, onLine, report, p.signal);
3646
4078
  let dev;
3647
4079
  try {
@@ -3663,9 +4095,22 @@ async function prepareModule(ctx, f, waitTimeout, p) {
3663
4095
  if (dev.kept.length) {
3664
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`);
3665
4097
  }
4098
+ report({ module: slug, step: "env", status: "done", detail: dev.offline ? `${envFile} (as last written \u2014 offline)` : envFile });
3666
4099
  const env = { ...deps2.env, ...dev.env };
3667
4100
  await dockerRecovery(ctx, p.signal)({ cwd: root, env }, () => startServices(deps2, recipe, slug, env, dev.dbPort, waitTimeout * 1e3, p.signal, onLine, report));
3668
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
+ }
3669
4114
  if (c.online && !dev.offline) {
3670
4115
  const { mc, app } = c.online;
3671
4116
  const { id, name, type, access: access2, tenant, apps } = mc.module;
@@ -3688,7 +4133,7 @@ async function prepareModule(ctx, f, waitTimeout, p) {
3688
4133
  });
3689
4134
  }
3690
4135
  if (p.signal.aborted) throw new CancelledError();
3691
- return { root, slug, recipe, env, port: dev.port };
4136
+ return { root, slug, recipe, env, port: dev.port, pkg, devEnv: dev.env };
3692
4137
  }
3693
4138
 
3694
4139
  // src/cli/commands/doctor.ts
@@ -3696,14 +4141,14 @@ import pc3 from "picocolors";
3696
4141
 
3697
4142
  // src/core/doctor/checks.ts
3698
4143
  import { realpath as realpath6 } from "fs/promises";
3699
- import { basename as basename3, join as join17 } from "path";
4144
+ import { basename as basename3, join as join19 } from "path";
3700
4145
 
3701
4146
  // src/core/diagnostics/ssh.ts
3702
4147
  var SSH_TEST_ARGS = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "git@github.com"];
3703
4148
  var COMMAND = "ssh -T git@github.com";
3704
4149
  function classifySshTest(r) {
3705
- const text = r.tail.join("\n");
3706
- const hi = /Hi ([^!\s]+)! You've successfully authenticated/.exec(text);
4150
+ const text2 = r.tail.join("\n");
4151
+ const hi = /Hi ([^!\s]+)! You've successfully authenticated/.exec(text2);
3707
4152
  if (hi) return { ok: true, user: hi[1] };
3708
4153
  const details = { protocol: "ssh" };
3709
4154
  if (r.spawnError) {
@@ -3712,13 +4157,13 @@ function classifySshTest(r) {
3712
4157
  details: { ...details, reason: "ssh-missing" }
3713
4158
  }) };
3714
4159
  }
3715
- if (/Permission denied \(publickey\)/.test(text)) {
4160
+ if (/Permission denied \(publickey\)/.test(text2)) {
3716
4161
  return { ok: false, error: new QoreError("QORE_GIT_AUTH", "GitHub refused your SSH key", {
3717
4162
  hint: "Add your key to GitHub (https://github.com/settings/keys) and your ssh agent, or keep cloning over https.",
3718
4163
  details
3719
4164
  }) };
3720
4165
  }
3721
- if (/Host key verification failed/.test(text)) {
4166
+ if (/Host key verification failed/.test(text2)) {
3722
4167
  return { ok: false, error: new QoreError("QORE_GIT_AUTH", "ssh could not verify github.com's host key", {
3723
4168
  hint: `Run \`${COMMAND}\` once to accept its host key (or to see why it changed).`,
3724
4169
  details: { ...details, reason: "host-key" }
@@ -3893,9 +4338,9 @@ async function moduleBody(deps2, m, runs, signal) {
3893
4338
  let env = null;
3894
4339
  await part(async () => {
3895
4340
  const target = await resolveTarget(m.root, cfg.envFile ?? moduleDefaults(m.type).envFile);
3896
- const text = await readOrNull(target);
3897
- if (text === null) return { status: "warn", label: "port unknown (run qore env pull)" };
3898
- env = Object.fromEntries(parseEnv(text));
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));
3899
4344
  const port = envPort(env, "PORT");
3900
4345
  if (port === null) return { status: "warn", label: `port unknown (no PORT in ${basename3(target)})` };
3901
4346
  if (!await deps2.ports.inUse(port)) return { status: "ok", label: `port ${port} free` };
@@ -4114,7 +4559,7 @@ async function workspaceModule(deps2, ws, am, m, runs, signal, push, perModule)
4114
4559
  await push(`module:${m.slug}`, "workspace", () => ({ status: "warn", label: "not a safe directory name \u2014 skipped" }), m.slug);
4115
4560
  return;
4116
4561
  }
4117
- const mod = { root: join17(ws.root, m.slug), slug: m.slug, type: m.type, id: m.id, access: m.access };
4562
+ const mod = { root: join19(ws.root, m.slug), slug: m.slug, type: m.type, id: m.id, access: m.access };
4118
4563
  let prepared = null;
4119
4564
  await push(`module:${m.slug}`, "workspace", async () => {
4120
4565
  const info = await deps2.git.inspect(mod.root);
@@ -4146,7 +4591,7 @@ function registerDoctor(program, ctx) {
4146
4591
  // src/cli/commands/env.ts
4147
4592
  import { randomBytes as randomBytes6 } from "crypto";
4148
4593
  import { chmod as chmod3, realpath as realpath7 } from "fs/promises";
4149
- import { dirname as dirname6, join as join18, relative as relative7 } from "path";
4594
+ import { dirname as dirname7, join as join20, relative as relative7 } from "path";
4150
4595
  var maskLen = (v) => v === void 0 ? "(unset)" : `\u2022\u2022\u2022\u2022 (${v.length} chars)`;
4151
4596
  function registerEnv(program, ctx) {
4152
4597
  const env = program.command("env").description("Local environment");
@@ -4163,7 +4608,7 @@ function registerEnv(program, ctx) {
4163
4608
  const target = f.target ? await resolveTarget(mc.repoRoot, f.target, deps2.cwd) : await resolveTarget(mc.repoRoot, cfg.envFile ?? defaults.envFile);
4164
4609
  await ensureTrusted(ctx, mc, app, { yes: f.yes });
4165
4610
  const existing = await readOrNull(target);
4166
- const example = existing === null ? await readOrNull(join18(dirname6(target), ".env.example")) : null;
4611
+ const example = existing === null ? await readOrNull(join20(dirname7(target), ".env.example")) : null;
4167
4612
  const local = await fetchLocalEnv(deps2, mc, app);
4168
4613
  const inputs = {
4169
4614
  existing,
@@ -4562,8 +5007,7 @@ function registerStatus(program, ctx) {
4562
5007
  }
4563
5008
 
4564
5009
  // src/cli/commands/sync.ts
4565
- import { lstat as lstat2, readdir } from "fs/promises";
4566
- import { join as join19, relative as relative8 } from "path";
5010
+ import { join as join21, relative as relative8 } from "path";
4567
5011
  import { Option as Option2 } from "commander";
4568
5012
  function asFailure2(slug, e) {
4569
5013
  if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") {
@@ -4571,24 +5015,6 @@ function asFailure2(slug, e) {
4571
5015
  }
4572
5016
  throw e;
4573
5017
  }
4574
- async function strayRepos(root, slugs) {
4575
- let entries;
4576
- try {
4577
- entries = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
4578
- } catch {
4579
- return [];
4580
- }
4581
- const names = [];
4582
- for (const name of entries) {
4583
- if (slugs.has(name)) continue;
4584
- try {
4585
- await lstat2(join19(root, name, ".git"));
4586
- names.push(name);
4587
- } catch {
4588
- }
4589
- }
4590
- return names.sort();
4591
- }
4592
5018
  function registerSync(program, ctx) {
4593
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) => {
4594
5020
  const { deps: deps2, io: io2 } = ctx;
@@ -4613,7 +5039,7 @@ function registerSync(program, ctx) {
4613
5039
  continue;
4614
5040
  }
4615
5041
  try {
4616
- dirs[m.slug] = await deps2.git.inspect(join19(ws.root, m.slug));
5042
+ dirs[m.slug] = await deps2.git.inspect(join21(ws.root, m.slug));
4617
5043
  modulesForPlan.push(m);
4618
5044
  } catch (e) {
4619
5045
  earlyFailures.push(asFailure2(m.slug, e));
@@ -4789,7 +5215,7 @@ async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS)
4789
5215
 
4790
5216
  // src/core/config.ts
4791
5217
  import { readFile as readFile6 } from "fs/promises";
4792
- import { join as join20 } from "path";
5218
+ import { join as join22 } from "path";
4793
5219
  var empty = () => ({ schemaVersion: 1, profiles: {} });
4794
5220
  var ConfigStore = class {
4795
5221
  constructor(dir2) {
@@ -4797,7 +5223,7 @@ var ConfigStore = class {
4797
5223
  }
4798
5224
  dir;
4799
5225
  get file() {
4800
- return join20(this.dir, "config.json");
5226
+ return join22(this.dir, "config.json");
4801
5227
  }
4802
5228
  async read() {
4803
5229
  let raw;
@@ -4830,7 +5256,7 @@ var ConfigStore = class {
4830
5256
  import { execFile, execFileSync } from "child_process";
4831
5257
  import { mkdir as mkdir3, readdir as readdir2, stat as stat3 } from "fs/promises";
4832
5258
  import { homedir } from "os";
4833
- import { dirname as dirname7, join as join21 } from "path";
5259
+ import { dirname as dirname8, join as join23 } from "path";
4834
5260
  function parseRemotes(stdout) {
4835
5261
  const seen = /* @__PURE__ */ new Set();
4836
5262
  const out = [];
@@ -4842,6 +5268,30 @@ function parseRemotes(stdout) {
4842
5268
  }
4843
5269
  return out;
4844
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
+ }
4845
5295
  var configuredSshCommand = /* @__PURE__ */ new Map();
4846
5296
  function hasConfiguredSshCommand(repoDir) {
4847
5297
  const key = repoDir ?? "";
@@ -4900,7 +5350,7 @@ async function remotes(repoRoot) {
4900
5350
  return parseRemotes(r.stdout);
4901
5351
  }
4902
5352
  async function clone(url, dir2) {
4903
- await mkdir3(dirname7(dir2), { recursive: true });
5353
+ await mkdir3(dirname8(dir2), { recursive: true });
4904
5354
  const r = await runGit(["clone", "--", url, dir2]);
4905
5355
  return toResult(r, "git clone");
4906
5356
  }
@@ -4916,22 +5366,7 @@ async function status(repoRoot) {
4916
5366
  const command = "git status --porcelain=v2 --branch";
4917
5367
  const r = await runGit(["--no-optional-locks", "-C", repoRoot, "status", "--porcelain=v2", "--branch"], repoRoot);
4918
5368
  if (r.code !== 0) return { ok: false, stderr: r.stderr, command };
4919
- let branch = null;
4920
- let upstream = false;
4921
- let dirty = false;
4922
- for (const line2 of r.stdout.split(/\r?\n/)) {
4923
- if (line2.startsWith("# branch.head ")) {
4924
- const name = line2.slice("# branch.head ".length).trim();
4925
- branch = name === "(detached)" ? null : name;
4926
- } else if (line2.startsWith("# branch.upstream ")) {
4927
- upstream = true;
4928
- } else if (line2.startsWith("#")) {
4929
- continue;
4930
- } else if (line2.trim().length > 0) {
4931
- dirty = true;
4932
- }
4933
- }
4934
- return { ok: true, dirty, branch, upstream };
5369
+ return parseStatus(r.stdout);
4935
5370
  }
4936
5371
  async function statOrThrow(path) {
4937
5372
  try {
@@ -4952,7 +5387,7 @@ async function inspect(dir2) {
4952
5387
  throw new QoreError("QORE_INTERNAL", `could not read ${dir2}`, { cause: err });
4953
5388
  }
4954
5389
  const empty2 = entries.length === 0;
4955
- const isRepo = await statOrThrow(join21(dir2, ".git")) !== void 0;
5390
+ const isRepo = await statOrThrow(join23(dir2, ".git")) !== void 0;
4956
5391
  const dirRemotes = isRepo ? await remotes(dir2) : [];
4957
5392
  return { exists: true, empty: empty2, isRepo, remotes: dirRemotes };
4958
5393
  }
@@ -5014,13 +5449,13 @@ var OsKeychain = class {
5014
5449
 
5015
5450
  // src/platform/lock.ts
5016
5451
  import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
5017
- import { join as join22 } from "path";
5452
+ import { join as join24 } from "path";
5018
5453
  import lockfile from "proper-lockfile";
5019
5454
  function fileLock(dir2) {
5020
5455
  return async (name, fn) => {
5021
- const locks = join22(dir2, "locks");
5456
+ const locks = join24(dir2, "locks");
5022
5457
  await mkdir4(locks, { recursive: true, mode: 448 });
5023
- const target = join22(locks, `${name}.lock`);
5458
+ const target = join24(locks, `${name}.lock`);
5024
5459
  await writeFile4(target, "", { flag: "a" });
5025
5460
  let release;
5026
5461
  let compromised;
@@ -5121,6 +5556,10 @@ var crossSpawner = makeSpawner(spawn, process);
5121
5556
 
5122
5557
  // src/platform/launch.ts
5123
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
+ }
5124
5563
  function lineSplitter(stream, onLine) {
5125
5564
  let buf = "";
5126
5565
  return {
@@ -5128,13 +5567,13 @@ function lineSplitter(stream, onLine) {
5128
5567
  buf += chunk;
5129
5568
  let i;
5130
5569
  while ((i = buf.indexOf("\n")) >= 0) {
5131
- onLine({ stream, line: buf.slice(0, i).replace(/\r$/, "") });
5570
+ onLine({ stream, line: stripScreenClears(buf.slice(0, i).replace(/\r$/, "")) });
5132
5571
  buf = buf.slice(i + 1);
5133
5572
  }
5134
5573
  },
5135
5574
  flush() {
5136
5575
  if (buf) {
5137
- onLine({ stream, line: buf.replace(/\r$/, "") });
5576
+ onLine({ stream, line: stripScreenClears(buf.replace(/\r$/, "")) });
5138
5577
  buf = "";
5139
5578
  }
5140
5579
  }
@@ -5351,7 +5790,7 @@ function systemProxy(platform = process.platform) {
5351
5790
  }
5352
5791
 
5353
5792
  // src/version.ts
5354
- var VERSION = true ? "0.1.3" : "0.0.0-dev";
5793
+ var VERSION = true ? "0.1.4" : "0.0.0-dev";
5355
5794
 
5356
5795
  // src/cli.ts
5357
5796
  if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY) setGlobalDispatcher(new EnvHttpProxyAgent());