@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/README.md +218 -4
- package/dist/cli.js +3095 -179
- package/dist/cli.js.map +1 -1
- package/package.json +3 -1
- package/schema/qore.schema.json +10 -1
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
|
|
132
|
+
async search(message, source) {
|
|
121
133
|
try {
|
|
122
|
-
return await
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
373
|
-
if (!refresh &&
|
|
374
|
-
return { meta:
|
|
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
|
|
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 "${
|
|
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
|
|
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
|
|
927
|
-
|
|
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
|
|
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 (
|
|
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("
|
|
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
|
|
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 ${
|
|
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/
|
|
1293
|
-
import { randomBytes as
|
|
1294
|
-
import {
|
|
1295
|
-
import {
|
|
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
|
|
1507
|
-
const origins = [...wsPortal ? [wsPortal.apiOrigin] : [], ...
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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] ??
|
|
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
|
|
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
|
|
2056
|
+
let text2 = out.join("\n");
|
|
1741
2057
|
if (appended.length) {
|
|
1742
|
-
if (
|
|
1743
|
-
|
|
2058
|
+
if (text2.length && !text2.endsWith("\n")) text2 += "\n";
|
|
2059
|
+
text2 += appended.join("\n") + "\n";
|
|
1744
2060
|
}
|
|
1745
|
-
return
|
|
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
|
|
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
|
|
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/
|
|
1870
|
-
|
|
1871
|
-
const
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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 (
|
|
1889
|
-
|
|
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/
|
|
1893
|
-
var
|
|
1894
|
-
|
|
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
|
|
4204
|
+
return await fn(AbortSignal.any([signal, AbortSignal.timeout(ms)]));
|
|
1897
4205
|
} catch (e) {
|
|
1898
|
-
if (e
|
|
4206
|
+
if (e instanceof CancelledError && !signal.aborted) throw new ToolTimeout(tool, ms);
|
|
1899
4207
|
throw e;
|
|
1900
4208
|
}
|
|
1901
4209
|
}
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
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
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
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(
|
|
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: () =>
|
|
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
|
|
4648
|
+
await chmod3(target, 384);
|
|
1968
4649
|
} else {
|
|
1969
4650
|
await writeSecurely(target, plan.text);
|
|
1970
4651
|
}
|
|
1971
|
-
const rel =
|
|
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
|
|
2033
|
-
|
|
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 {
|
|
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(
|
|
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 =
|
|
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
|
|
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() <
|
|
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
|
|
2584
|
-
import { join as
|
|
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
|
|
5226
|
+
return join22(this.dir, "config.json");
|
|
2593
5227
|
}
|
|
2594
5228
|
async read() {
|
|
2595
5229
|
let raw;
|
|
2596
5230
|
try {
|
|
2597
|
-
raw = await
|
|
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
|
|
5257
|
+
import { mkdir as mkdir3, readdir as readdir2, stat as stat3 } from "fs/promises";
|
|
2624
5258
|
import { homedir } from "os";
|
|
2625
|
-
import { dirname as
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
5456
|
+
const locks = join24(dir2, "locks");
|
|
2814
5457
|
await mkdir4(locks, { recursive: true, mode: 448 });
|
|
2815
|
-
const target =
|
|
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, {
|
|
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
|
-
|
|
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.
|
|
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)
|
|
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 });
|