@quatronic/qore 0.1.2 → 0.1.3
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 +198 -4
- package/dist/cli.js +2597 -120
- package/dist/cli.js.map +1 -1
- package/package.json +3 -1
- package/schema/qore.schema.json +10 -1
package/dist/cli.js
CHANGED
|
@@ -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,11 +129,11 @@ function processIo(interactive) {
|
|
|
117
129
|
throw e;
|
|
118
130
|
}
|
|
119
131
|
},
|
|
120
|
-
async confirm(message) {
|
|
132
|
+
async confirm(message, signal) {
|
|
121
133
|
try {
|
|
122
|
-
return await confirm({ message, default: false });
|
|
134
|
+
return await confirm({ message, default: false }, { signal });
|
|
123
135
|
} catch (e) {
|
|
124
|
-
if (e.name === "ExitPromptError") throw new CancelledError();
|
|
136
|
+
if (e.name === "ExitPromptError" || e.name === "AbortPromptError") throw new CancelledError();
|
|
125
137
|
throw e;
|
|
126
138
|
}
|
|
127
139
|
},
|
|
@@ -176,7 +188,11 @@ function isAbortError(e) {
|
|
|
176
188
|
function fetchError(e, host) {
|
|
177
189
|
return isAbortError(e) ? new CancelledError() : networkError(e, host);
|
|
178
190
|
}
|
|
179
|
-
function
|
|
191
|
+
function proxyHint(proxy, platform) {
|
|
192
|
+
const cmd = platform === "win32" ? `set HTTPS_PROXY=http://${proxy}` : `export HTTPS_PROXY=http://${proxy}`;
|
|
193
|
+
return `Your system uses the proxy ${proxy}, but qore reads only HTTPS_PROXY. Run: ${cmd}`;
|
|
194
|
+
}
|
|
195
|
+
function networkError(e, host, systemProxy2) {
|
|
180
196
|
const code = causeCode(e) ?? "";
|
|
181
197
|
let reason = "connect";
|
|
182
198
|
let hint = "Check your network or VPN.";
|
|
@@ -188,10 +204,24 @@ function networkError(e, host) {
|
|
|
188
204
|
hint = "A proxy is re-signing TLS. Point NODE_EXTRA_CA_CERTS at your organisation's CA certificate.";
|
|
189
205
|
} else if (code.includes("TIMEOUT") || code === "ETIMEDOUT") {
|
|
190
206
|
reason = "timeout";
|
|
207
|
+
} else if (systemProxy2) {
|
|
208
|
+
reason = "proxy";
|
|
209
|
+
hint = proxyHint(systemProxy2);
|
|
191
210
|
} else if (!process.env.HTTPS_PROXY && !process.env.https_proxy) {
|
|
192
211
|
hint = "Check your network. Behind a corporate proxy, set HTTPS_PROXY.";
|
|
193
212
|
}
|
|
194
|
-
|
|
213
|
+
const details = { reason, host, errno: code || void 0 };
|
|
214
|
+
if (systemProxy2) details.proxy = systemProxy2;
|
|
215
|
+
return new QoreError("QORE_API_UNREACHABLE", `Could not reach ${host}`, { hint, details, cause: e });
|
|
216
|
+
}
|
|
217
|
+
function withSystemProxy(err, proxy, platform) {
|
|
218
|
+
return new QoreError(err.code, err.message, {
|
|
219
|
+
status: err.status,
|
|
220
|
+
requestId: err.requestId,
|
|
221
|
+
cause: err.cause,
|
|
222
|
+
details: { ...err.details, reason: "proxy", proxy },
|
|
223
|
+
hint: proxyHint(proxy, platform)
|
|
224
|
+
});
|
|
195
225
|
}
|
|
196
226
|
var BY_SERVER_CODE = {
|
|
197
227
|
"app.access.denied": "QORE_ACCESS_DENIED",
|
|
@@ -263,6 +293,98 @@ function renderError(io2, err, json2, colorEnabled = pc.isColorSupported) {
|
|
|
263
293
|
io2.err(c.red(`Error: ${e.message}`));
|
|
264
294
|
if (e.hint) io2.err(e.hint);
|
|
265
295
|
io2.err(c.dim(`Code: ${e.code}${e.requestId ? ` (request ${e.requestId})` : ""}`));
|
|
296
|
+
if (e.code === "QORE_SERVICE_UNHEALTHY" && Array.isArray(e.details?.logs)) {
|
|
297
|
+
for (const l of e.details.logs) io2.err(c.dim(` ${String(l)}`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function renderDoctorJson(io2, report) {
|
|
301
|
+
const failing = report.results.filter((r) => r.status === "fail");
|
|
302
|
+
if (failing.length === 0) {
|
|
303
|
+
io2.out(JSON.stringify({ ok: true, data: report }));
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const first = failing.find((r) => r.error);
|
|
307
|
+
const code = first?.error?.code ?? "QORE_INTERNAL";
|
|
308
|
+
const message = first?.error?.message ?? `${failing.length} of ${report.results.length} checks failed`;
|
|
309
|
+
const hint = first?.error?.hint;
|
|
310
|
+
const err = new QoreError(code, message, { hint, details: { results: report.results, context: report.context } });
|
|
311
|
+
io2.out(JSON.stringify({ ok: false, error: toJsonError(err) }));
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
var SYMBOL = { ok: "\u2713", warn: "!", fail: "\u2717" };
|
|
315
|
+
var TOOL_IDS = ["qore", "node", "git", "github-ssh", "docker"];
|
|
316
|
+
var ROW_WIDTH = 80;
|
|
317
|
+
var COL_B = 36;
|
|
318
|
+
function rawLine(r) {
|
|
319
|
+
return `${SYMBOL[r.status]} ${r.label}`;
|
|
320
|
+
}
|
|
321
|
+
function colorFor(c, status2) {
|
|
322
|
+
return status2 === "ok" ? c.green : status2 === "warn" ? c.yellow : c.red;
|
|
323
|
+
}
|
|
324
|
+
function coloredLine(c, r) {
|
|
325
|
+
return `${colorFor(c, r.status)(SYMBOL[r.status])} ${r.label}`;
|
|
326
|
+
}
|
|
327
|
+
function printCheck(io2, c, indent, r) {
|
|
328
|
+
io2.out(`${indent}${coloredLine(c, r)}`);
|
|
329
|
+
const hint = r.status === "fail" ? r.error?.hint : void 0;
|
|
330
|
+
if (typeof hint === "string") io2.out(c.dim(`${indent} ${hint}`));
|
|
331
|
+
}
|
|
332
|
+
function toolRows(io2, c, tools) {
|
|
333
|
+
for (let i = 0; i < tools.length; i += 2) {
|
|
334
|
+
const a = tools[i];
|
|
335
|
+
const b = tools[i + 1];
|
|
336
|
+
if (!b) {
|
|
337
|
+
printCheck(io2, c, "", a);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const rawA = rawLine(a);
|
|
341
|
+
const rawB = rawLine(b);
|
|
342
|
+
const colB = Math.max(COL_B, rawA.length + 2);
|
|
343
|
+
if (colB + rawB.length > ROW_WIDTH) {
|
|
344
|
+
printCheck(io2, c, "", a);
|
|
345
|
+
printCheck(io2, c, "", b);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const pad = " ".repeat(colB - rawA.length);
|
|
349
|
+
io2.out(`${coloredLine(c, a)}${pad}${coloredLine(c, b)}`);
|
|
350
|
+
if (a.status === "fail" && typeof a.error?.hint === "string") io2.out(c.dim(` ${a.error.hint}`));
|
|
351
|
+
if (b.status === "fail" && typeof b.error?.hint === "string") io2.out(c.dim(` ${b.error.hint}`));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function groupByModule(results) {
|
|
355
|
+
const order = [];
|
|
356
|
+
const groups = /* @__PURE__ */ new Map();
|
|
357
|
+
for (const r of results) {
|
|
358
|
+
const key = r.module ?? r.id;
|
|
359
|
+
if (!groups.has(key)) {
|
|
360
|
+
groups.set(key, []);
|
|
361
|
+
order.push(key);
|
|
362
|
+
}
|
|
363
|
+
groups.get(key).push(r);
|
|
364
|
+
}
|
|
365
|
+
const isSub = (r) => r.id.startsWith("pm:") || r.id.startsWith("node:");
|
|
366
|
+
return order.flatMap((key) => {
|
|
367
|
+
const g = groups.get(key);
|
|
368
|
+
return [...g.filter((r) => !isSub(r)), ...g.filter(isSub)];
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
function renderDoctorHuman(io2, report, colorEnabled = pc.isColorSupported) {
|
|
372
|
+
const c = createColors(colorEnabled);
|
|
373
|
+
const byId = new Map(report.results.map((r) => [r.id, r]));
|
|
374
|
+
const tools = TOOL_IDS.map((id) => byId.get(id)).filter((r) => r !== void 0);
|
|
375
|
+
toolRows(io2, c, tools);
|
|
376
|
+
for (const r of report.results.filter((r2) => r2.group === "session")) printCheck(io2, c, "", r);
|
|
377
|
+
const clock = byId.get("clock");
|
|
378
|
+
if (clock) printCheck(io2, c, "", clock);
|
|
379
|
+
const workspace = groupByModule(report.results.filter((r) => r.group === "workspace"));
|
|
380
|
+
if (workspace.length === 0) return;
|
|
381
|
+
if (report.context.kind !== "none") {
|
|
382
|
+
io2.out(`\u2192 ${report.context.kind === "workspace" ? "Workspace" : "Module"} ${report.context.label ?? ""}`);
|
|
383
|
+
}
|
|
384
|
+
for (const r of workspace) {
|
|
385
|
+
const indent = r.id.startsWith("pm:") || r.id.startsWith("node:") ? " " : " ";
|
|
386
|
+
printCheck(io2, c, indent, r);
|
|
387
|
+
}
|
|
266
388
|
}
|
|
267
389
|
|
|
268
390
|
// src/cli/program.ts
|
|
@@ -276,6 +398,12 @@ function jsonFromArgv(argv2) {
|
|
|
276
398
|
function requestLog(ctx) {
|
|
277
399
|
return ctx.opts().verbose ? (line2) => ctx.io.err(line2) : void 0;
|
|
278
400
|
}
|
|
401
|
+
async function withProxyHint(deps2, e) {
|
|
402
|
+
if (!(e instanceof QoreError) || e.code !== "QORE_API_UNREACHABLE" || e.details?.reason !== "connect") return e;
|
|
403
|
+
if (deps2.env.HTTPS_PROXY || deps2.env.https_proxy || deps2.env.HTTP_PROXY || deps2.env.http_proxy) return e;
|
|
404
|
+
const proxy = await deps2.systemProxy();
|
|
405
|
+
return proxy ? withSystemProxy(e, proxy, deps2.platform) : e;
|
|
406
|
+
}
|
|
279
407
|
function buildProgram(deps2, io2, signal, register, setExitCode) {
|
|
280
408
|
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
409
|
const ctx = { deps: deps2, io: io2, signal, opts: () => program.opts(), setExitCode };
|
|
@@ -294,7 +422,7 @@ async function run(argv2, deps2, io2, signal, register) {
|
|
|
294
422
|
await program.parseAsync(argv2, { from: "user" });
|
|
295
423
|
return state.exitCode ?? 0;
|
|
296
424
|
} catch (caught) {
|
|
297
|
-
const e = signal.aborted && isAbortError(caught) ? new CancelledError() : caught;
|
|
425
|
+
const e = await withProxyHint(deps2, signal.aborted && isAbortError(caught) ? new CancelledError() : caught);
|
|
298
426
|
if (e instanceof CommanderError) {
|
|
299
427
|
if (e.exitCode === 0) return 0;
|
|
300
428
|
if (json2) io2.out(JSON.stringify({ ok: false, error: { code: "QORE_CONTEXT_INVALID", message: e.message } }));
|
|
@@ -325,6 +453,9 @@ function compareVersions(a, b) {
|
|
|
325
453
|
return 0;
|
|
326
454
|
}
|
|
327
455
|
async function fetchMeta(fetch3, apiOrigin) {
|
|
456
|
+
return (await fetchMetaWithDate(fetch3, apiOrigin)).meta;
|
|
457
|
+
}
|
|
458
|
+
async function fetchMetaWithDate(fetch3, apiOrigin) {
|
|
328
459
|
let res;
|
|
329
460
|
try {
|
|
330
461
|
res = await fetch3(`${apiOrigin}/api/cli/meta`, { headers: { accept: "application/json" } });
|
|
@@ -339,7 +470,9 @@ async function fetchMeta(fetch3, apiOrigin) {
|
|
|
339
470
|
});
|
|
340
471
|
}
|
|
341
472
|
if (!res.ok) throw await problemToError(res);
|
|
342
|
-
|
|
473
|
+
const header = res.headers.get("date");
|
|
474
|
+
const ms = header ? Date.parse(header) : NaN;
|
|
475
|
+
return { meta: await res.json(), date: Number.isNaN(ms) ? null : new Date(ms) };
|
|
343
476
|
}
|
|
344
477
|
function negotiate(meta, portal, cliVersion) {
|
|
345
478
|
if (portal.pinnedIssuer && meta.auth.issuer !== portal.pinnedIssuer) {
|
|
@@ -369,9 +502,9 @@ function isMeta(doc) {
|
|
|
369
502
|
return !!doc && typeof doc === "object" && Array.isArray(doc.capabilities);
|
|
370
503
|
}
|
|
371
504
|
async function loadMeta(deps2, apiOrigin, refresh) {
|
|
372
|
-
const
|
|
373
|
-
if (!refresh &&
|
|
374
|
-
return { meta:
|
|
505
|
+
const cached2 = (await deps2.config.read()).profiles[apiOrigin]?.meta;
|
|
506
|
+
if (!refresh && cached2 && isMeta(cached2.doc) && deps2.now().getTime() - new Date(cached2.fetchedAt).getTime() < TTL_MS) {
|
|
507
|
+
return { meta: cached2.doc, cached: true };
|
|
375
508
|
}
|
|
376
509
|
const meta = await fetchMeta(deps2.fetch, apiOrigin);
|
|
377
510
|
await deps2.config.update((c) => {
|
|
@@ -440,6 +573,12 @@ function profileHash(apiOrigin, issuer) {
|
|
|
440
573
|
}
|
|
441
574
|
|
|
442
575
|
// src/core/api/client.ts
|
|
576
|
+
function clockSkewSeconds(dateHeader, now) {
|
|
577
|
+
if (!dateHeader) return null;
|
|
578
|
+
const serverMs = Date.parse(dateHeader);
|
|
579
|
+
if (Number.isNaN(serverMs)) return null;
|
|
580
|
+
return Math.round((serverMs - now.getTime()) / 1e3);
|
|
581
|
+
}
|
|
443
582
|
var QoreApiClient = class {
|
|
444
583
|
constructor(o) {
|
|
445
584
|
this.o = o;
|
|
@@ -474,6 +613,15 @@ var QoreApiClient = class {
|
|
|
474
613
|
if (res.status === 401) {
|
|
475
614
|
res = await this.once(method, path, body, true);
|
|
476
615
|
if (res.status === 401) {
|
|
616
|
+
const skew = clockSkewSeconds(res.headers.get("date"), (this.o.now ?? (() => /* @__PURE__ */ new Date()))());
|
|
617
|
+
if (skew !== null && Math.abs(skew) > 60) {
|
|
618
|
+
const minutes = Math.max(1, Math.round(Math.abs(skew) / 60));
|
|
619
|
+
throw new QoreError("QORE_CLOCK_SKEW", `Your clock is off by ${minutes} min; tokens look expired`, {
|
|
620
|
+
hint: "Turn on automatic date and time, then run the command again.",
|
|
621
|
+
status: 401,
|
|
622
|
+
details: { skewSeconds: skew }
|
|
623
|
+
});
|
|
624
|
+
}
|
|
477
625
|
throw new QoreError("QORE_SESSION_EXPIRED", "The portal no longer accepts your session", {
|
|
478
626
|
hint: "Run `qore login`.",
|
|
479
627
|
status: 401,
|
|
@@ -597,7 +745,8 @@ async function openSession(deps2, portal, command, onRequest) {
|
|
|
597
745
|
cliVersion: deps2.version,
|
|
598
746
|
command,
|
|
599
747
|
platform: `${deps2.platform} ${process.arch}; node ${process.versions.node}`,
|
|
600
|
-
onRequest
|
|
748
|
+
onRequest,
|
|
749
|
+
now: deps2.now
|
|
601
750
|
});
|
|
602
751
|
return { profile, api, tokens, refresh };
|
|
603
752
|
}
|
|
@@ -1124,10 +1273,10 @@ function unsafeSlugError(slug) {
|
|
|
1124
1273
|
function exitFor(results) {
|
|
1125
1274
|
const failures = results.filter((r) => r.status === "failed" || r.status === "conflict");
|
|
1126
1275
|
if (failures.length === 0) return null;
|
|
1127
|
-
const
|
|
1276
|
+
const ok2 = results.some((r) => ["cloned", "present", "fetched", "pulled", "skipped"].includes(r.status));
|
|
1128
1277
|
const code = (r) => r.status === "conflict" ? "QORE_WORKSPACE_CONFLICT" : r.error?.code ?? "QORE_INTERNAL";
|
|
1129
1278
|
const nonAccess = failures.find((r) => !ACCESS_CODES.has(code(r)));
|
|
1130
|
-
if (
|
|
1279
|
+
if (ok2 && !nonAccess) return null;
|
|
1131
1280
|
const first = nonAccess ?? failures[0];
|
|
1132
1281
|
const moduleCount = results.filter((r) => r.status !== "removed").length;
|
|
1133
1282
|
return new QoreError(code(first), `${failures.length} of ${moduleCount} modules need attention`, {
|
|
@@ -1289,16 +1438,19 @@ function registerClone(program, ctx) {
|
|
|
1289
1438
|
});
|
|
1290
1439
|
}
|
|
1291
1440
|
|
|
1292
|
-
// src/cli/commands/
|
|
1293
|
-
import { randomBytes as
|
|
1294
|
-
import {
|
|
1295
|
-
import {
|
|
1441
|
+
// src/cli/commands/dev.ts
|
|
1442
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
1443
|
+
import { realpath as realpath5 } from "fs/promises";
|
|
1444
|
+
import { constants as constants3 } from "os";
|
|
1445
|
+
import { relative as relative6 } from "path";
|
|
1446
|
+
import { StringDecoder } from "string_decoder";
|
|
1296
1447
|
|
|
1297
1448
|
// src/core/context/repoConfig.ts
|
|
1298
1449
|
import { readFile as readFile3 } from "fs/promises";
|
|
1299
1450
|
import { join as join7 } from "path";
|
|
1300
1451
|
var FORBIDDEN = ["portal", "api", "apiOrigin", "issuer", "tenant", "tenantId", "app", "appId", "module", "moduleId"];
|
|
1301
1452
|
var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1453
|
+
var SCRIPT = /^[A-Za-z0-9:_.-]+$/;
|
|
1302
1454
|
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
1455
|
function moduleDefaults(type) {
|
|
1304
1456
|
return type === "frontend" ? { envFile: ".env.local", localSecrets: ["SESSION_COOKIE_SECRET"] } : { envFile: ".env", localSecrets: [] };
|
|
@@ -1336,12 +1488,30 @@ async function readRepoConfig(root) {
|
|
|
1336
1488
|
if (new Set(doc.localSecrets).size !== doc.localSecrets.length) throw invalid2("localSecrets must not contain duplicates");
|
|
1337
1489
|
out.localSecrets = doc.localSecrets;
|
|
1338
1490
|
}
|
|
1491
|
+
if (doc.dev !== void 0) {
|
|
1492
|
+
const d = doc.dev;
|
|
1493
|
+
if (!d || typeof d !== "object" || Array.isArray(d)) throw invalid2("dev must be an object");
|
|
1494
|
+
const dev = {};
|
|
1495
|
+
if (d.install !== void 0) {
|
|
1496
|
+
if (typeof d.install !== "boolean") throw invalid2("dev.install must be true or false");
|
|
1497
|
+
dev.install = d.install;
|
|
1498
|
+
}
|
|
1499
|
+
if (d.services !== void 0) {
|
|
1500
|
+
if (!Array.isArray(d.services) || !d.services.every((s) => typeof s === "string" && SCRIPT.test(s))) {
|
|
1501
|
+
throw invalid2("dev.services must be a list of script names");
|
|
1502
|
+
}
|
|
1503
|
+
if (new Set(d.services).size !== d.services.length) throw invalid2("dev.services must not contain duplicates");
|
|
1504
|
+
dev.services = d.services;
|
|
1505
|
+
}
|
|
1506
|
+
if (d.script !== void 0) {
|
|
1507
|
+
if (typeof d.script !== "string" || !SCRIPT.test(d.script)) throw invalid2("dev.script must be a script name");
|
|
1508
|
+
dev.script = d.script;
|
|
1509
|
+
}
|
|
1510
|
+
out.dev = dev;
|
|
1511
|
+
}
|
|
1339
1512
|
return out;
|
|
1340
1513
|
}
|
|
1341
1514
|
|
|
1342
|
-
// src/core/context/resolver.ts
|
|
1343
|
-
import { isAbsolute, join as join9, relative as relative3 } from "path";
|
|
1344
|
-
|
|
1345
1515
|
// src/core/context/repoRoot.ts
|
|
1346
1516
|
import { stat } from "fs/promises";
|
|
1347
1517
|
import { dirname as dirname2, join as join8, resolve as resolve3 } from "path";
|
|
@@ -1360,6 +1530,7 @@ async function findRepoRoot(start) {
|
|
|
1360
1530
|
}
|
|
1361
1531
|
|
|
1362
1532
|
// src/core/context/resolver.ts
|
|
1533
|
+
import { isAbsolute, join as join9, relative as relative3 } from "path";
|
|
1363
1534
|
var UUID3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1364
1535
|
var HINT_FLAG = "cd into a module repository, or pass --module <slug|id>.";
|
|
1365
1536
|
var RESOLVE = "/api/cli/v1/repos/resolve";
|
|
@@ -1503,8 +1674,8 @@ function profileOrder(cfg, cachedOrigins, portalArg, wsPortal) {
|
|
|
1503
1674
|
if (portalArg) return [resolvePortal(portalArg, cfg.defaultProfile)];
|
|
1504
1675
|
const rank = (o) => o === BUILTIN_PORTALS.prod.apiOrigin ? 0 : o === BUILTIN_PORTALS.staging.apiOrigin ? 1 : 2;
|
|
1505
1676
|
const rest = Object.keys(cfg.profiles).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
1506
|
-
const
|
|
1507
|
-
const origins = [...wsPortal ? [wsPortal.apiOrigin] : [], ...
|
|
1677
|
+
const cached2 = cachedOrigins.filter((c) => cfg.profiles[c]);
|
|
1678
|
+
const origins = [...wsPortal ? [wsPortal.apiOrigin] : [], ...cached2, resolvePortal(void 0, cfg.defaultProfile).apiOrigin, ...rest];
|
|
1508
1679
|
return [...new Set(origins)].filter((c) => cfg.profiles[c]).map((c) => resolvePortal(c, void 0));
|
|
1509
1680
|
}
|
|
1510
1681
|
function withDetails(e, extra, hint) {
|
|
@@ -1518,13 +1689,13 @@ function withDetails(e, extra, hint) {
|
|
|
1518
1689
|
cause: e
|
|
1519
1690
|
});
|
|
1520
1691
|
}
|
|
1521
|
-
async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, workspace,
|
|
1692
|
+
async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, workspace, unsupported2) {
|
|
1522
1693
|
const session = await openSession(deps2, portal, o.command, o.onRequest);
|
|
1523
1694
|
try {
|
|
1524
1695
|
await ensureCapability(deps2, portal.apiOrigin, "repo.resolve");
|
|
1525
1696
|
} catch (e) {
|
|
1526
1697
|
if (!(e instanceof QoreError) || e.code !== "QORE_UNSUPPORTED_BY_SERVER") throw e;
|
|
1527
|
-
|
|
1698
|
+
unsupported2(e);
|
|
1528
1699
|
return null;
|
|
1529
1700
|
}
|
|
1530
1701
|
const hits = [];
|
|
@@ -1598,14 +1769,14 @@ async function resolveModuleContext(deps2, o) {
|
|
|
1598
1769
|
const answered = [];
|
|
1599
1770
|
const skipped = [];
|
|
1600
1771
|
const skipErrors = [];
|
|
1601
|
-
let
|
|
1772
|
+
let unsupported2;
|
|
1602
1773
|
for (const portal of profileOrder(cfg, await cache.originsFor(keys), o.portal, wsPortal)) {
|
|
1603
1774
|
tried.push(label(portal));
|
|
1604
1775
|
let capabilityMissing = false;
|
|
1605
1776
|
try {
|
|
1606
1777
|
const mc = await onProfile(deps2, o, cache, portal, keyed, keys, root, ws, (e) => {
|
|
1607
1778
|
capabilityMissing = true;
|
|
1608
|
-
|
|
1779
|
+
unsupported2 ??= e;
|
|
1609
1780
|
skipped.push({ profile: label(portal), code: e.code, message: e.message });
|
|
1610
1781
|
});
|
|
1611
1782
|
if (mc) return mc;
|
|
@@ -1619,7 +1790,7 @@ async function resolveModuleContext(deps2, o) {
|
|
|
1619
1790
|
const details = { triedProfiles: tried };
|
|
1620
1791
|
if (skipped.length > 0) details.skippedProfiles = skipped;
|
|
1621
1792
|
if (answered.length === 0) {
|
|
1622
|
-
const cause = skipErrors[0] ??
|
|
1793
|
+
const cause = skipErrors[0] ?? unsupported2;
|
|
1623
1794
|
if (cause) throw withDetails(cause, details);
|
|
1624
1795
|
}
|
|
1625
1796
|
const unchecked = skipped.length ? ` ${skipped.map((s) => s.profile).join(", ")} could not be checked (${skipped.map((s) => s.code).join(", ")}).` : "";
|
|
@@ -1648,6 +1819,45 @@ function pickApp(mc, app) {
|
|
|
1648
1819
|
throw new QoreError("QORE_APP_AMBIGUOUS", `${mc.module.slug} is part of several apps`, { hint: "Pass --app <slug>.", candidates: appCandidates(apps) });
|
|
1649
1820
|
}
|
|
1650
1821
|
|
|
1822
|
+
// src/core/diagnostics/node.ts
|
|
1823
|
+
var KEEP_LINES = 50;
|
|
1824
|
+
function earlyExitHint(tail, o) {
|
|
1825
|
+
const text = tail.join("\n");
|
|
1826
|
+
const missing = text.match(/Cannot find (?:module|package) '([^']+)'/);
|
|
1827
|
+
if (missing) {
|
|
1828
|
+
return { reason: "missing-module", hint: `A dependency is missing (${missing[1]}). Run \`${o.installCommand}\`, then qore dev again.` };
|
|
1829
|
+
}
|
|
1830
|
+
if (/ZodError:/.test(text)) {
|
|
1831
|
+
const keys = [...text.matchAll(/"path":\s*\[\s*"([^"]+)"/g)].map((m) => m[1]);
|
|
1832
|
+
if (keys.length > 0) {
|
|
1833
|
+
return {
|
|
1834
|
+
reason: "invalid-env",
|
|
1835
|
+
keys,
|
|
1836
|
+
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.`
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
const addrInUse = text.match(/listen EADDRINUSE: address already in use (?:\S*:)?(\d+)/);
|
|
1841
|
+
if (addrInUse) {
|
|
1842
|
+
const port = Number(addrInUse[1]);
|
|
1843
|
+
return {
|
|
1844
|
+
reason: "port-in-use",
|
|
1845
|
+
port,
|
|
1846
|
+
hint: `Port ${port} was taken while the server started. Run qore dev again; if it repeats, lsof -iTCP:${port} -sTCP:LISTEN names the holder.`
|
|
1847
|
+
};
|
|
1848
|
+
}
|
|
1849
|
+
if (/ENOSPC: System limit for number of file watchers reached/.test(text)) {
|
|
1850
|
+
return {
|
|
1851
|
+
reason: "watcher-limit",
|
|
1852
|
+
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"
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
return null;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// src/core/dev/env.ts
|
|
1859
|
+
import { dirname as dirname5, join as join12 } from "path";
|
|
1860
|
+
|
|
1651
1861
|
// src/core/env/fetch.ts
|
|
1652
1862
|
async function fetchLocalEnv(deps2, mc, app) {
|
|
1653
1863
|
await ensureCapability(deps2, mc.portal.apiOrigin, "module.local-env");
|
|
@@ -1812,6 +2022,579 @@ function planPull(o) {
|
|
|
1812
2022
|
};
|
|
1813
2023
|
}
|
|
1814
2024
|
|
|
2025
|
+
// src/core/env/write.ts
|
|
2026
|
+
import { chmod as chmod2, readFile as readFile4, rename as rename3, unlink, writeFile as writeFile3 } from "fs/promises";
|
|
2027
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
2028
|
+
import { basename as basename2, dirname as dirname4, join as join10 } from "path";
|
|
2029
|
+
async function readOrNull(p) {
|
|
2030
|
+
try {
|
|
2031
|
+
return await readFile4(p, "utf8");
|
|
2032
|
+
} catch (e) {
|
|
2033
|
+
if (e.code === "ENOENT") return null;
|
|
2034
|
+
throw e;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
async function writeSecurely(target, text) {
|
|
2038
|
+
const base = basename2(target);
|
|
2039
|
+
const tmpName = base.endsWith(".local") ? `${base.slice(0, -".local".length)}.qore-${randomBytes3(6).toString("hex")}.local` : `${base}.qore-${randomBytes3(6).toString("hex")}.tmp`;
|
|
2040
|
+
const tmp = join10(dirname4(target), tmpName);
|
|
2041
|
+
try {
|
|
2042
|
+
await writeFile3(tmp, text, { mode: 384, flag: "wx" });
|
|
2043
|
+
await chmod2(tmp, 384);
|
|
2044
|
+
await rename3(tmp, target);
|
|
2045
|
+
} finally {
|
|
2046
|
+
await unlink(tmp).catch(() => {
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
async function syncEnvFile(o) {
|
|
2051
|
+
const existing = await readOrNull(o.target);
|
|
2052
|
+
const plan = planPull({
|
|
2053
|
+
existing,
|
|
2054
|
+
example: existing === null ? o.example : null,
|
|
2055
|
+
fetched: o.fetched,
|
|
2056
|
+
managed: o.managed,
|
|
2057
|
+
localSecrets: o.localSecrets,
|
|
2058
|
+
defaultChoice: "keep",
|
|
2059
|
+
random: o.random
|
|
2060
|
+
});
|
|
2061
|
+
if (existing !== null && plan.text === existing) await chmod2(o.target, 384);
|
|
2062
|
+
else await writeSecurely(o.target, plan.text);
|
|
2063
|
+
return { created: plan.created, kept: plan.kept };
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// src/core/dev/lastStart.ts
|
|
2067
|
+
import { createHash as createHash2 } from "crypto";
|
|
2068
|
+
import { realpath as realpath3 } from "fs/promises";
|
|
2069
|
+
import { join as join11 } from "path";
|
|
2070
|
+
var NETWORK_REASONS = /* @__PURE__ */ new Set(["connect", "dns", "tls", "timeout"]);
|
|
2071
|
+
function isOffline(e) {
|
|
2072
|
+
if (!(e instanceof QoreError)) return false;
|
|
2073
|
+
if (e.code === "QORE_API_UNREACHABLE" || e.code === "QORE_API_UNAVAILABLE") return true;
|
|
2074
|
+
if (e.code !== "QORE_AUTH_UNAVAILABLE") return false;
|
|
2075
|
+
return (e.status ?? 0) >= 500 || NETWORK_REASONS.has(String(e.details?.reason));
|
|
2076
|
+
}
|
|
2077
|
+
async function stateKey(deps2, root) {
|
|
2078
|
+
const real = await realpath3(root).catch(() => root);
|
|
2079
|
+
return createHash2("sha256").update(trustKey(real, deps2.platform)).digest("hex").slice(0, 16);
|
|
2080
|
+
}
|
|
2081
|
+
async function saveLastStart(deps2, root, rec) {
|
|
2082
|
+
await writeJsonAtomic(join11(deps2.config.dir, "dev"), `${await stateKey(deps2, root)}.json`, rec);
|
|
2083
|
+
}
|
|
2084
|
+
async function loadLastStart(deps2, root) {
|
|
2085
|
+
const rec = await readJson(join11(deps2.config.dir, "dev", `${await stateKey(deps2, root)}.json`)).catch(() => null);
|
|
2086
|
+
return rec && rec.schemaVersion === 1 ? rec : null;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// src/core/dev/env.ts
|
|
2090
|
+
function envPort(env, key) {
|
|
2091
|
+
const raw = env[key];
|
|
2092
|
+
if (raw === void 0) return null;
|
|
2093
|
+
const v = raw.trim().replace(/^["']|["']$/g, "");
|
|
2094
|
+
if (!/^\d+$/.test(v)) return null;
|
|
2095
|
+
const n = Number(v);
|
|
2096
|
+
return n >= 1024 && n <= 65535 ? n : null;
|
|
2097
|
+
}
|
|
2098
|
+
async function devEnv(deps2, o) {
|
|
2099
|
+
const defaults = moduleDefaults(o.type);
|
|
2100
|
+
const target = await resolveTarget(o.root, o.cfg.envFile ?? defaults.envFile);
|
|
2101
|
+
let cause = null;
|
|
2102
|
+
if (o.mc && o.app) {
|
|
2103
|
+
try {
|
|
2104
|
+
const local = await fetchLocalEnv(deps2, o.mc, o.app);
|
|
2105
|
+
const example = await readOrNull(join12(dirname5(target), ".env.example"));
|
|
2106
|
+
const w = await syncEnvFile({
|
|
2107
|
+
target,
|
|
2108
|
+
example,
|
|
2109
|
+
fetched: local.env,
|
|
2110
|
+
managed: local.managedKeys,
|
|
2111
|
+
localSecrets: o.cfg.localSecrets ?? defaults.localSecrets,
|
|
2112
|
+
random: o.random
|
|
2113
|
+
});
|
|
2114
|
+
return {
|
|
2115
|
+
env: local.env,
|
|
2116
|
+
offline: false,
|
|
2117
|
+
envFile: target,
|
|
2118
|
+
port: envPort(local.env, "PORT"),
|
|
2119
|
+
dbPort: envPort(local.env, "DB_PORT"),
|
|
2120
|
+
...w
|
|
2121
|
+
};
|
|
2122
|
+
} catch (e) {
|
|
2123
|
+
if (!isOffline(e)) throw e;
|
|
2124
|
+
cause = e;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
const text = await readOrNull(target);
|
|
2128
|
+
if (text === null || !await isTrusted(deps2, o.root, o.moduleId)) {
|
|
2129
|
+
throw cause ?? new QoreError("QORE_API_UNREACHABLE", "The portal is unreachable", {
|
|
2130
|
+
hint: "The portal is unreachable and there is no env file to start from yet."
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
const env = Object.fromEntries(parseEnv(text));
|
|
2134
|
+
return { env, offline: true, envFile: target, port: envPort(env, "PORT"), dbPort: envPort(env, "DB_PORT"), created: false, kept: [] };
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// src/core/dev/project.ts
|
|
2138
|
+
import { access, readFile as readFile5 } from "fs/promises";
|
|
2139
|
+
import { join as join13 } from "path";
|
|
2140
|
+
var LEGACY_DEV_SCRIPTS = ["node --env-file=.env --import ./dist/instrumentation.js dist/index.js"];
|
|
2141
|
+
var LOCKFILES = [["pnpm-lock.yaml", "pnpm"], ["package-lock.json", "npm"], ["yarn.lock", "yarn"]];
|
|
2142
|
+
var exists = (p) => access(p).then(() => true, () => false);
|
|
2143
|
+
var unsupported = (message, hint, details) => new QoreError("QORE_DEV_UNSUPPORTED", message, { hint, details });
|
|
2144
|
+
async function readPackageJson(root) {
|
|
2145
|
+
let raw;
|
|
2146
|
+
try {
|
|
2147
|
+
raw = await readFile5(join13(root, "package.json"), "utf8");
|
|
2148
|
+
} catch {
|
|
2149
|
+
throw unsupported("This repository has no package.json", "qore dev starts Node repositories through their package.json scripts.");
|
|
2150
|
+
}
|
|
2151
|
+
try {
|
|
2152
|
+
return JSON.parse(raw);
|
|
2153
|
+
} catch {
|
|
2154
|
+
throw unsupported("package.json is not valid JSON", "Fix package.json, then run qore dev again.");
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
async function detectPackageManager(root, pkg) {
|
|
2158
|
+
if (typeof pkg.packageManager === "string") {
|
|
2159
|
+
const m = /^(pnpm|npm|yarn)@(.+)$/.exec(pkg.packageManager.trim());
|
|
2160
|
+
if (!m) throw unsupported(`packageManager "${pkg.packageManager}" is not pnpm, npm or yarn`, "qore dev supports pnpm, npm and yarn.");
|
|
2161
|
+
return { name: m[1], version: m[2].split("+")[0] };
|
|
2162
|
+
}
|
|
2163
|
+
for (const [file, name] of LOCKFILES) if (await exists(join13(root, file))) return { name, version: null };
|
|
2164
|
+
throw unsupported("No package manager declared and no lockfile found", 'Add "packageManager": "pnpm@<version>" to package.json.');
|
|
2165
|
+
}
|
|
2166
|
+
function planRecipe(o) {
|
|
2167
|
+
const scripts = o.pkg.scripts ?? {};
|
|
2168
|
+
const script = o.cfg.dev?.script ?? "dev";
|
|
2169
|
+
const found = scripts[script];
|
|
2170
|
+
if (found === void 0) {
|
|
2171
|
+
throw unsupported(`${o.slug} has no "${script}" script in package.json`, 'Add a "dev" script, or set dev.script in .qore.json.', { script });
|
|
2172
|
+
}
|
|
2173
|
+
if (LEGACY_DEV_SCRIPTS.includes(found.trim())) {
|
|
2174
|
+
throw unsupported(
|
|
2175
|
+
`${o.slug}'s dev script is the old backend one, which needs a build and a .env first`,
|
|
2176
|
+
'Update it to the current template: "dev": "node scripts/dev.mjs" plus scripts/dev.mjs.',
|
|
2177
|
+
{ script, found }
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
const configured = o.cfg.dev?.services;
|
|
2181
|
+
for (const s of configured ?? []) {
|
|
2182
|
+
if (scripts[s] === void 0) {
|
|
2183
|
+
throw new QoreError("QORE_CONTEXT_INVALID", `.qore.json: dev.services names "${s}", which is not a script in package.json`, {
|
|
2184
|
+
hint: "Fix dev.services in .qore.json."
|
|
2185
|
+
});
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
const services = !o.flags.services ? [] : configured ?? (scripts["db:up"] !== void 0 ? ["db:up"] : []);
|
|
2189
|
+
return {
|
|
2190
|
+
root: o.root,
|
|
2191
|
+
pm: o.pm,
|
|
2192
|
+
script,
|
|
2193
|
+
services,
|
|
2194
|
+
install: (o.cfg.dev?.install ?? true) && o.flags.install,
|
|
2195
|
+
dockerServices: services.some((s) => /\bdocker\b/.test(scripts[s] ?? ""))
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
function runArgs(pm, script) {
|
|
2199
|
+
return { cmd: pm.name, args: ["run", script] };
|
|
2200
|
+
}
|
|
2201
|
+
function installArgs(pm) {
|
|
2202
|
+
return { cmd: pm.name, args: ["install"] };
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// src/core/dev/install.ts
|
|
2206
|
+
import { stat as stat2 } from "fs/promises";
|
|
2207
|
+
import { join as join14 } from "path";
|
|
2208
|
+
var MARKERS = {
|
|
2209
|
+
pnpm: [".modules.yaml"],
|
|
2210
|
+
npm: [".package-lock.json"],
|
|
2211
|
+
yarn: [".yarn-state.yml", ".yarn-integrity"]
|
|
2212
|
+
};
|
|
2213
|
+
var LOCK = { pnpm: "pnpm-lock.yaml", npm: "package-lock.json", yarn: "yarn.lock" };
|
|
2214
|
+
var mtime = (p) => stat2(p).then((s) => s.mtimeMs, () => null);
|
|
2215
|
+
async function needsInstall(root, pm) {
|
|
2216
|
+
if (await mtime(join14(root, "node_modules")) === null) return true;
|
|
2217
|
+
let marker = null;
|
|
2218
|
+
for (const m of MARKERS[pm.name]) marker ??= await mtime(join14(root, "node_modules", m));
|
|
2219
|
+
if (marker === null) return true;
|
|
2220
|
+
const lock = await mtime(join14(root, LOCK[pm.name]));
|
|
2221
|
+
return lock !== null && lock > marker;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// src/core/diagnostics/docker.ts
|
|
2225
|
+
function dockerInstallUrl(platform) {
|
|
2226
|
+
if (platform === "darwin") return "https://docs.docker.com/desktop/setup/install/mac-install/";
|
|
2227
|
+
if (platform === "win32") return "https://docs.docker.com/desktop/setup/install/windows-install/";
|
|
2228
|
+
return "https://docs.docker.com/engine/install/";
|
|
2229
|
+
}
|
|
2230
|
+
function classifyDockerInfo(r, platform) {
|
|
2231
|
+
if (r.spawnError === "ENOENT") {
|
|
2232
|
+
return new QoreError("QORE_DOCKER_MISSING", "Docker is not installed", {
|
|
2233
|
+
hint: `Install Docker: ${dockerInstallUrl(platform)} \u2014 or run qore dev --no-services.`
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
if (r.code === 0) return null;
|
|
2237
|
+
const hint = "Start Docker Desktop, then run qore dev again \u2014 or qore dev --no-services.";
|
|
2238
|
+
if (/Cannot connect to the Docker daemon|Is the docker daemon running|error during connect/i.test(r.tail.join("\n"))) {
|
|
2239
|
+
return new QoreError("QORE_DOCKER_NOT_RUNNING", "Docker is not running", { hint, details: { reason: "daemon" } });
|
|
2240
|
+
}
|
|
2241
|
+
return new QoreError("QORE_DOCKER_NOT_RUNNING", "Docker is not running", { hint, details: { stderr: r.tail } });
|
|
2242
|
+
}
|
|
2243
|
+
function classifyComposeVersion(r) {
|
|
2244
|
+
if (r.code === 0) return null;
|
|
2245
|
+
const hint = "Update Docker Desktop (or install the compose plugin). The legacy docker-compose v1 is not supported.";
|
|
2246
|
+
if (/unknown command: docker compose|'compose' is not a docker command/.test(r.tail.join("\n"))) {
|
|
2247
|
+
return new QoreError("QORE_DOCKER_COMPOSE_MISSING", "Docker Compose v2 is missing", { hint });
|
|
2248
|
+
}
|
|
2249
|
+
return new QoreError("QORE_DOCKER_COMPOSE_MISSING", "Docker Compose v2 is missing", { hint, details: { stderr: r.tail } });
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
// src/core/diagnostics/postgres.ts
|
|
2253
|
+
var INCOMPATIBLE = /database files are incompatible with server|Counter to that, there appears to be PostgreSQL data in|in 18\+, these Docker images are configured to store database data/;
|
|
2254
|
+
function classifyComposeLogs(logs, o) {
|
|
2255
|
+
const text = logs.join("\n");
|
|
2256
|
+
if (!INCOMPATIBLE.test(text)) return null;
|
|
2257
|
+
const details = { reason: "pg-data-version" };
|
|
2258
|
+
const m = text.match(/initialized by PostgreSQL version (\d+).*this version (\d+)/);
|
|
2259
|
+
if (m) {
|
|
2260
|
+
details.found = m[1];
|
|
2261
|
+
details.expected = m[2];
|
|
2262
|
+
}
|
|
2263
|
+
return new QoreError("QORE_SERVICE_DATA_INCOMPATIBLE", `The local database volume of ${o.slug} was created by another Postgres version`, {
|
|
2264
|
+
hint: "pnpm db:reset recreates it \u2014 this deletes all local data in it. Qore never runs it for you.",
|
|
2265
|
+
details
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
// src/core/diagnostics/ports.ts
|
|
2270
|
+
function parseDockerPs(stdout) {
|
|
2271
|
+
const line2 = stdout.split(/\r?\n/).find((l) => l.trim());
|
|
2272
|
+
if (!line2) return null;
|
|
2273
|
+
const [id, name, project, workingDir] = line2.split(" ");
|
|
2274
|
+
if (!id || !name) return null;
|
|
2275
|
+
return { id, name, project: project || null, workingDir: workingDir || null };
|
|
2276
|
+
}
|
|
2277
|
+
function parseLsofCwd(stdout) {
|
|
2278
|
+
const m = stdout.match(/^fcwd\nn(.+)$/m);
|
|
2279
|
+
return m ? m[1] : null;
|
|
2280
|
+
}
|
|
2281
|
+
function parseEtime(s) {
|
|
2282
|
+
const m = s.trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
|
|
2283
|
+
if (!m) return null;
|
|
2284
|
+
const days = m[1] ? Number(m[1]) : 0;
|
|
2285
|
+
const hours = m[2] ? Number(m[2]) : 0;
|
|
2286
|
+
const minutes = Number(m[3]);
|
|
2287
|
+
const seconds = Number(m[4]);
|
|
2288
|
+
return days * 86400 + hours * 3600 + minutes * 60 + seconds;
|
|
2289
|
+
}
|
|
2290
|
+
function humanAge(seconds) {
|
|
2291
|
+
if (seconds < 60) return `${seconds} s`;
|
|
2292
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)} min`;
|
|
2293
|
+
if (seconds < 86400) return `${Math.round(seconds / 3600)} h`;
|
|
2294
|
+
return `${Math.round(seconds / 86400)} d`;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// src/core/diagnostics/pm.ts
|
|
2298
|
+
function pmVersionMismatch(pm, printed) {
|
|
2299
|
+
if (pm.version === null) return null;
|
|
2300
|
+
const lines = printed.split("\n");
|
|
2301
|
+
let found = null;
|
|
2302
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2303
|
+
const line2 = lines[i].trim();
|
|
2304
|
+
if (/^\d+\.\d+\.\d+/.test(line2)) {
|
|
2305
|
+
found = line2;
|
|
2306
|
+
break;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
if (found === null) return null;
|
|
2310
|
+
if (found.split(".")[0] === pm.version.split(".")[0]) return null;
|
|
2311
|
+
return new QoreError("QORE_PACKAGE_MANAGER", `${pm.name} ${found} does not match the pinned ${pm.name}@${pm.version}`, {
|
|
2312
|
+
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.)`,
|
|
2313
|
+
details: { found, pinned: pm.version }
|
|
2314
|
+
});
|
|
2315
|
+
}
|
|
2316
|
+
function classifyInstall(tail, command, root) {
|
|
2317
|
+
const joined = tail.join("\n");
|
|
2318
|
+
if (/ERR_PNPM_OUTDATED_LOCKFILE/.test(joined)) {
|
|
2319
|
+
return new QoreError("QORE_INSTALL_FAILED", "The lockfile does not match package.json", {
|
|
2320
|
+
hint: `Someone committed one without the other. Run \`${command} --no-frozen-lockfile\` in ${root} and commit the lockfile.`,
|
|
2321
|
+
details: { reason: "outdated-lockfile", command, stderr: tail }
|
|
2322
|
+
});
|
|
2323
|
+
}
|
|
2324
|
+
return new QoreError("QORE_INSTALL_FAILED", `${command} failed`, {
|
|
2325
|
+
hint: `Run \`${command}\` in ${root} to see the full output.`,
|
|
2326
|
+
details: { command, stderr: tail }
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// src/core/diagnostics/versions.ts
|
|
2331
|
+
import semver from "semver";
|
|
2332
|
+
function nodeSatisfies(found, range) {
|
|
2333
|
+
if (semver.validRange(range) === null) return null;
|
|
2334
|
+
return semver.satisfies(found, range);
|
|
2335
|
+
}
|
|
2336
|
+
function nodeVersionError(found, range, slug) {
|
|
2337
|
+
const min = semver.minVersion(range);
|
|
2338
|
+
const major = min ? min.major : range;
|
|
2339
|
+
return new QoreError("QORE_NODE_VERSION", `${slug} needs Node ${range}, found ${found}`, {
|
|
2340
|
+
hint: `nvm install ${major} / fnm use ${major}, then run qore dev again.`,
|
|
2341
|
+
details: { found, required: range }
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// src/core/dev/preflight.ts
|
|
2346
|
+
var pmMajor = (v) => v ? v.split(".")[0] : "latest";
|
|
2347
|
+
var GRACE_MS = 1e4;
|
|
2348
|
+
async function runTool(deps2, cmd, args, o) {
|
|
2349
|
+
if (o.signal.aborted) throw new CancelledError();
|
|
2350
|
+
const tail = [];
|
|
2351
|
+
const c = deps2.launch.launch(cmd, args, { cwd: o.cwd, env: { ...o.env, LC_ALL: "C" }, onLine: (l) => {
|
|
2352
|
+
tail.push(l.line);
|
|
2353
|
+
if (tail.length > 50) tail.shift();
|
|
2354
|
+
o.onLine(l);
|
|
2355
|
+
} });
|
|
2356
|
+
let kill;
|
|
2357
|
+
const onAbort = () => {
|
|
2358
|
+
c.stop("SIGTERM");
|
|
2359
|
+
kill = setTimeout(() => c.stop("SIGKILL"), GRACE_MS);
|
|
2360
|
+
};
|
|
2361
|
+
o.signal.addEventListener("abort", onAbort, { once: true });
|
|
2362
|
+
let exit;
|
|
2363
|
+
try {
|
|
2364
|
+
exit = await c.exited;
|
|
2365
|
+
} finally {
|
|
2366
|
+
o.signal.removeEventListener("abort", onAbort);
|
|
2367
|
+
clearTimeout(kill);
|
|
2368
|
+
}
|
|
2369
|
+
if (o.signal.aborted) throw new CancelledError();
|
|
2370
|
+
return { code: exit.code, spawnError: exit.spawnError, tail };
|
|
2371
|
+
}
|
|
2372
|
+
async function checkPackageManager(deps2, r, onLine, signal) {
|
|
2373
|
+
const res = await runTool(deps2, r.pm.name, ["--version"], { cwd: r.root, env: deps2.env, onLine, signal });
|
|
2374
|
+
const hint = `Install it: npm i -g ${r.pm.name}@${pmMajor(r.pm.version)}`;
|
|
2375
|
+
if (res.spawnError === "ENOENT") {
|
|
2376
|
+
throw new QoreError("QORE_PACKAGE_MANAGER", `${r.pm.name} is not installed`, { hint, details: { packageManager: r.pm } });
|
|
2377
|
+
}
|
|
2378
|
+
if (res.code !== 0) {
|
|
2379
|
+
throw new QoreError("QORE_PACKAGE_MANAGER", `${r.pm.name} is not installed`, { hint, details: { packageManager: r.pm, stderr: res.tail } });
|
|
2380
|
+
}
|
|
2381
|
+
const mismatch = pmVersionMismatch(r.pm, res.tail.join("\n"));
|
|
2382
|
+
if (mismatch) throw mismatch;
|
|
2383
|
+
return res.tail.map((l) => l.trim()).findLast((l) => /^\d+\.\d+\.\d+/.test(l)) ?? null;
|
|
2384
|
+
}
|
|
2385
|
+
async function checkNode(deps2, r, pkg, slug, signal) {
|
|
2386
|
+
const range = pkg.engines?.node;
|
|
2387
|
+
if (!range) return null;
|
|
2388
|
+
const res = await runTool(deps2, "node", ["--version"], { cwd: r.root, env: deps2.env, onLine: () => {
|
|
2389
|
+
}, signal });
|
|
2390
|
+
if (res.spawnError === "ENOENT") {
|
|
2391
|
+
throw new QoreError("QORE_NODE_VERSION", "node is not on PATH", {
|
|
2392
|
+
hint: "Install Node (https://nodejs.org) or run nvm use, then reinstall qore.",
|
|
2393
|
+
details: { required: range }
|
|
2394
|
+
});
|
|
2395
|
+
}
|
|
2396
|
+
if (res.spawnError || res.code !== 0) {
|
|
2397
|
+
throw new QoreError("QORE_NODE_VERSION", "node --version failed", { details: { required: range, stderr: res.tail } });
|
|
2398
|
+
}
|
|
2399
|
+
const found = (res.tail.at(-1) ?? "").trim().replace(/^v/, "");
|
|
2400
|
+
if (nodeSatisfies(found, range) === false) throw nodeVersionError(found, range, slug);
|
|
2401
|
+
return found;
|
|
2402
|
+
}
|
|
2403
|
+
async function installIfNeeded(deps2, r, slug, onLine, report, signal) {
|
|
2404
|
+
report({ module: slug, step: "install", status: "start" });
|
|
2405
|
+
if (!r.install || !await needsInstall(r.root, r.pm)) {
|
|
2406
|
+
report({ module: slug, step: "install", status: "skip" });
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
const { cmd, args } = installArgs(r.pm);
|
|
2410
|
+
const res = await runTool(deps2, cmd, args, { cwd: r.root, env: deps2.env, onLine, signal });
|
|
2411
|
+
if (res.code !== 0) {
|
|
2412
|
+
const command = [cmd, ...args].join(" ");
|
|
2413
|
+
const err = classifyInstall(res.tail, command, r.root);
|
|
2414
|
+
if (err.details?.reason === "outdated-lockfile") throw err;
|
|
2415
|
+
throw new QoreError("QORE_INSTALL_FAILED", `${command} failed in ${slug}`, { hint: err.hint, details: err.details });
|
|
2416
|
+
}
|
|
2417
|
+
report({ module: slug, step: "install", status: "done" });
|
|
2418
|
+
}
|
|
2419
|
+
async function portInUseError(deps2, o) {
|
|
2420
|
+
const owner = await deps2.ports.owner(o.port);
|
|
2421
|
+
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)));
|
|
2422
|
+
if (qoreRun) {
|
|
2423
|
+
return new QoreError("QORE_PORT_IN_USE", `A previous qore dev is still running (pid ${qoreRun.qorePid})`, {
|
|
2424
|
+
hint: `Stop it (Ctrl+C in its terminal, or kill ${qoreRun.qorePid}), then run qore dev again.`,
|
|
2425
|
+
details: { port: o.port, module: o.slug, key: o.key, owner: "qore", pid: qoreRun.qorePid }
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
if (owner) {
|
|
2429
|
+
if (/^(com\.docker\.backend|com\.docker\.vpnkit|docker-proxy|vpnkit|dockerd)$/.test(owner.command)) {
|
|
2430
|
+
const pub = await deps2.ports.dockerPublisher(o.port);
|
|
2431
|
+
if (pub) {
|
|
2432
|
+
return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is held by the Docker container ${pub.name} (needed by ${o.slug})`, {
|
|
2433
|
+
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}.`,
|
|
2434
|
+
details: { port: o.port, module: o.slug, key: o.key, owner: "docker", ...pub }
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
const detail2 = await deps2.ports.ownerDetail(owner.pid);
|
|
2439
|
+
const extras = [];
|
|
2440
|
+
if (detail2.ageSeconds !== null) extras.push(`started ${humanAge(detail2.ageSeconds)} ago`);
|
|
2441
|
+
if (detail2.cwd !== null) {
|
|
2442
|
+
const cwd = deps2.home && detail2.cwd.startsWith(deps2.home) ? `~${detail2.cwd.slice(deps2.home.length)}` : detail2.cwd;
|
|
2443
|
+
extras.push(`in ${cwd}`);
|
|
2444
|
+
}
|
|
2445
|
+
const suffix = extras.length ? `, ${extras.join(" ")}` : "";
|
|
2446
|
+
return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is already in use (needed by ${o.slug})`, {
|
|
2447
|
+
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}.`,
|
|
2448
|
+
details: {
|
|
2449
|
+
port: o.port,
|
|
2450
|
+
module: o.slug,
|
|
2451
|
+
key: o.key,
|
|
2452
|
+
owner: "process",
|
|
2453
|
+
pid: owner.pid,
|
|
2454
|
+
command: owner.command,
|
|
2455
|
+
...detail2.cwd !== null ? { cwd: detail2.cwd } : {},
|
|
2456
|
+
...detail2.ageSeconds !== null ? { ageSeconds: detail2.ageSeconds } : {}
|
|
2457
|
+
}
|
|
2458
|
+
});
|
|
2459
|
+
}
|
|
2460
|
+
const findHint = deps2.platform === "win32" ? `netstat -ano | findstr :${o.port}` : `lsof -iTCP:${o.port} -sTCP:LISTEN`;
|
|
2461
|
+
return new QoreError("QORE_PORT_IN_USE", `Port ${o.port} is already in use (needed by ${o.slug})`, {
|
|
2462
|
+
hint: `Find it with: ${findHint}`,
|
|
2463
|
+
details: { port: o.port, module: o.slug, key: o.key }
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
async function composeFailure(deps2, r, slug, env, signal, message, command, stderr) {
|
|
2467
|
+
if (!r.dockerServices) {
|
|
2468
|
+
return new QoreError("QORE_SERVICE_UNHEALTHY", message, { details: { command, stderr } });
|
|
2469
|
+
}
|
|
2470
|
+
const logs = await runTool(deps2, "docker", ["compose", "logs", "--no-color", "--tail", "50"], { cwd: r.root, env, onLine: () => {
|
|
2471
|
+
}, signal });
|
|
2472
|
+
const dataErr = classifyComposeLogs(logs.tail, { slug, script: "db:up" });
|
|
2473
|
+
if (dataErr) return dataErr;
|
|
2474
|
+
return new QoreError("QORE_SERVICE_UNHEALTHY", message, {
|
|
2475
|
+
hint: `The last lines of docker compose logs are in details.logs; run \`docker compose logs\` in ${r.root} for all of them.`,
|
|
2476
|
+
details: { command, stderr, logs: logs.tail.slice(-20) }
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
async function startServices(deps2, r, slug, env, dbPort, waitMs, signal, onLine, report) {
|
|
2480
|
+
report({ module: slug, step: "services", status: "start" });
|
|
2481
|
+
if (r.dockerServices) {
|
|
2482
|
+
const res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: r.root, env, onLine, signal });
|
|
2483
|
+
const dockerErr = classifyDockerInfo(res, deps2.platform);
|
|
2484
|
+
if (dockerErr) throw dockerErr;
|
|
2485
|
+
const compose = await runTool(deps2, "docker", ["compose", "version"], { cwd: r.root, env, onLine: () => {
|
|
2486
|
+
}, signal });
|
|
2487
|
+
const composeErr = classifyComposeVersion(compose);
|
|
2488
|
+
if (composeErr) throw composeErr;
|
|
2489
|
+
}
|
|
2490
|
+
for (const script of r.services) {
|
|
2491
|
+
const { cmd, args } = runArgs(r.pm, script);
|
|
2492
|
+
const res = await runTool(deps2, cmd, args, { cwd: r.root, env, onLine, signal });
|
|
2493
|
+
if (res.code !== 0) {
|
|
2494
|
+
if (dbPort !== null && /port is already allocated|address already in use/i.test(res.tail.join("\n"))) {
|
|
2495
|
+
throw await portInUseError(deps2, { slug, port: dbPort, key: "DB_PORT" });
|
|
2496
|
+
}
|
|
2497
|
+
throw await composeFailure(deps2, r, slug, env, signal, `${script} failed in ${slug}`, `${cmd} ${args.join(" ")}`, res.tail);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
if (dbPort !== null && r.services.length > 0) {
|
|
2501
|
+
const ok2 = await deps2.ports.waitFor(dbPort, waitMs, signal);
|
|
2502
|
+
if (signal.aborted) throw new CancelledError();
|
|
2503
|
+
if (!ok2) {
|
|
2504
|
+
throw await composeFailure(
|
|
2505
|
+
deps2,
|
|
2506
|
+
r,
|
|
2507
|
+
slug,
|
|
2508
|
+
env,
|
|
2509
|
+
signal,
|
|
2510
|
+
`The database for ${slug} did not accept connections on port ${dbPort} within ${waitMs / 1e3} s`,
|
|
2511
|
+
void 0,
|
|
2512
|
+
[]
|
|
2513
|
+
);
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
report({ module: slug, step: "services", status: "done" });
|
|
2517
|
+
}
|
|
2518
|
+
async function checkPort(deps2, o) {
|
|
2519
|
+
if (o.port === null) return;
|
|
2520
|
+
if (!await deps2.ports.inUse(o.port)) return;
|
|
2521
|
+
throw await portInUseError(deps2, { slug: o.slug, port: o.port, key: o.key, runs: o.runs });
|
|
2522
|
+
}
|
|
2523
|
+
function abortableDelay(signal) {
|
|
2524
|
+
return (ms) => new Promise((resolve5) => {
|
|
2525
|
+
const done = () => {
|
|
2526
|
+
clearTimeout(t);
|
|
2527
|
+
signal.removeEventListener("abort", done);
|
|
2528
|
+
resolve5();
|
|
2529
|
+
};
|
|
2530
|
+
const t = setTimeout(done, ms);
|
|
2531
|
+
signal.addEventListener("abort", done, { once: true });
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
async function waitForDocker(deps2, o) {
|
|
2535
|
+
const delay = o.delay ?? abortableDelay(o.signal);
|
|
2536
|
+
const now = o.now ?? Date.now;
|
|
2537
|
+
const deadline = now() + o.timeoutMs;
|
|
2538
|
+
for (; ; ) {
|
|
2539
|
+
await delay(Math.min(o.pollMs, Math.max(0, deadline - now())));
|
|
2540
|
+
if (o.signal.aborted) throw new CancelledError();
|
|
2541
|
+
const remaining = deadline - now();
|
|
2542
|
+
if (remaining <= 0) return false;
|
|
2543
|
+
const poll = AbortSignal.any([o.signal, AbortSignal.timeout(remaining)]);
|
|
2544
|
+
let res;
|
|
2545
|
+
try {
|
|
2546
|
+
res = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], { cwd: o.cwd, env: o.env ?? deps2.env, onLine: () => {
|
|
2547
|
+
}, signal: poll });
|
|
2548
|
+
} catch (e) {
|
|
2549
|
+
if (e instanceof CancelledError && !o.signal.aborted) return false;
|
|
2550
|
+
throw e;
|
|
2551
|
+
}
|
|
2552
|
+
if (classifyDockerInfo(res, deps2.platform) === null) return true;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
var MAX_PARENT_HOPS = 10;
|
|
2556
|
+
var AGE_SLACK_S = 5;
|
|
2557
|
+
async function leftoverOwnsPort(deps2, run4, port) {
|
|
2558
|
+
if (deps2.platform === "win32" || !deps2.launch.signal(run4.qorePid, 0)) return false;
|
|
2559
|
+
const { ageSeconds } = await deps2.ports.ownerDetail(run4.qorePid);
|
|
2560
|
+
const recordAge = (deps2.now().getTime() - Date.parse(run4.startedAt)) / 1e3;
|
|
2561
|
+
if (ageSeconds === null || !Number.isFinite(recordAge) || ageSeconds < recordAge - AGE_SLACK_S) return false;
|
|
2562
|
+
const ours = /* @__PURE__ */ new Set([run4.qorePid, ...run4.children.map((c) => c.pid).filter((p) => p !== null)]);
|
|
2563
|
+
let pid = (await deps2.ports.owner(port))?.pid ?? null;
|
|
2564
|
+
for (let hop = 0; pid !== null && pid > 1 && hop <= MAX_PARENT_HOPS; hop++) {
|
|
2565
|
+
if (ours.has(pid)) return true;
|
|
2566
|
+
pid = await deps2.ports.parentPid(pid);
|
|
2567
|
+
}
|
|
2568
|
+
return false;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// src/core/dev/runFile.ts
|
|
2572
|
+
import { unlink as unlink2 } from "fs/promises";
|
|
2573
|
+
import { join as join15 } from "path";
|
|
2574
|
+
async function runFilePath(deps2, root) {
|
|
2575
|
+
const dir2 = join15(deps2.config.dir, "run");
|
|
2576
|
+
const name = `${await stateKey(deps2, root)}.json`;
|
|
2577
|
+
return { dir: dir2, name, path: join15(dir2, name) };
|
|
2578
|
+
}
|
|
2579
|
+
async function writeRunFile(deps2, root, rec) {
|
|
2580
|
+
const { dir: dir2, name } = await runFilePath(deps2, root);
|
|
2581
|
+
await writeJsonAtomic(dir2, name, rec);
|
|
2582
|
+
}
|
|
2583
|
+
async function removeRunFile(deps2, root) {
|
|
2584
|
+
const { path } = await runFilePath(deps2, root);
|
|
2585
|
+
await unlink2(path).catch(() => {
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
async function leftoverRun(deps2, root, o = {}) {
|
|
2589
|
+
const { path } = await runFilePath(deps2, root);
|
|
2590
|
+
const rec = await readJson(path).catch(() => null);
|
|
2591
|
+
if (!rec || rec.schemaVersion !== 1) return null;
|
|
2592
|
+
if (deps2.launch.isAlive(rec.qorePid)) return rec;
|
|
2593
|
+
if (o.prune !== false) await unlink2(path).catch(() => {
|
|
2594
|
+
});
|
|
2595
|
+
return null;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
1815
2598
|
// src/cli/context.ts
|
|
1816
2599
|
var allOfKind = (c, kind) => !!c && c.length > 0 && c.every((x) => x.kind === kind && typeof x.id === "string");
|
|
1817
2600
|
async function moduleContextFor(ctx, flags, command) {
|
|
@@ -1855,17 +2638,106 @@ function contextLine(mc, app) {
|
|
|
1855
2638
|
return `\u2192 ${parts.join(" / ")} (${sourceText(mc)})`;
|
|
1856
2639
|
}
|
|
1857
2640
|
function contextJson(mc, app) {
|
|
1858
|
-
const { id, slug, name, type, access } = mc.module;
|
|
2641
|
+
const { id, slug, name, type, access: access2 } = mc.module;
|
|
1859
2642
|
return {
|
|
1860
2643
|
portal: mc.portal.builtin ?? mc.portal.apiOrigin,
|
|
1861
2644
|
tenant: mc.module.tenant,
|
|
1862
2645
|
app: app ?? null,
|
|
1863
|
-
module: { id, slug, name, type, access },
|
|
2646
|
+
module: { id, slug, name, type, access: access2 },
|
|
1864
2647
|
source: mc.source.kind === "remote" ? mc.source.remote : mc.source.kind === "workspace" ? WORKSPACE_FILE : "--module",
|
|
1865
2648
|
...mc.source.kind === "flag" && mc.source.overrides ? { overrides: mc.source.overrides } : {}
|
|
1866
2649
|
};
|
|
1867
2650
|
}
|
|
1868
2651
|
|
|
2652
|
+
// src/cli/devRecover.ts
|
|
2653
|
+
var DOCKER_WAIT = { timeoutMs: 12e4, pollMs: 2e3 };
|
|
2654
|
+
var STOP_WAIT_MS = 1e4;
|
|
2655
|
+
function canRecover(ctx) {
|
|
2656
|
+
const g = ctx.opts();
|
|
2657
|
+
return ctx.io.interactive && !g.json && g.input !== false;
|
|
2658
|
+
}
|
|
2659
|
+
var daemonDown = (e) => e instanceof QoreError && e.code === "QORE_DOCKER_NOT_RUNNING" && e.details?.reason === "daemon";
|
|
2660
|
+
function dockerRecovery(ctx, signal) {
|
|
2661
|
+
let answer;
|
|
2662
|
+
return async (at, fn) => {
|
|
2663
|
+
try {
|
|
2664
|
+
await fn();
|
|
2665
|
+
} catch (e) {
|
|
2666
|
+
if (!daemonDown(e) || !canRecover(ctx)) throw e;
|
|
2667
|
+
answer ??= (async () => {
|
|
2668
|
+
ctx.io.err("Waiting for Docker\u2026 (Ctrl+C to stop)");
|
|
2669
|
+
const ok2 = await waitForDocker(ctx.deps, { ...at, signal, ...DOCKER_WAIT });
|
|
2670
|
+
if (ok2) ctx.io.err("\u2713 Docker is running");
|
|
2671
|
+
return ok2;
|
|
2672
|
+
})();
|
|
2673
|
+
if (!await answer) throw e;
|
|
2674
|
+
await fn();
|
|
2675
|
+
}
|
|
2676
|
+
};
|
|
2677
|
+
}
|
|
2678
|
+
async function stopLeftover(ctx, e, runs, signal) {
|
|
2679
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
2680
|
+
const port = e.details?.port;
|
|
2681
|
+
const run4 = runs.find((r) => r.qorePid === e.details?.pid);
|
|
2682
|
+
if (!run4 || typeof port !== "number" || !await leftoverOwnsPort(deps2, run4, port)) return false;
|
|
2683
|
+
if (!await io2.confirm(`Stop the previous qore dev (pid ${run4.qorePid})?`, signal)) return false;
|
|
2684
|
+
if (!deps2.launch.signal(run4.qorePid, "SIGTERM")) {
|
|
2685
|
+
io2.err(`! pid ${run4.qorePid} could not be signalled`);
|
|
2686
|
+
return false;
|
|
2687
|
+
}
|
|
2688
|
+
if (await deps2.ports.waitFree(port, STOP_WAIT_MS, signal)) return true;
|
|
2689
|
+
if (!signal.aborted) io2.err(`! pid ${run4.qorePid} did not exit within ${STOP_WAIT_MS / 1e3} s`);
|
|
2690
|
+
return false;
|
|
2691
|
+
}
|
|
2692
|
+
async function checkPortOrStopLeftover(ctx, o, signal) {
|
|
2693
|
+
try {
|
|
2694
|
+
await checkPort(ctx.deps, o);
|
|
2695
|
+
} catch (e) {
|
|
2696
|
+
if (!(e instanceof QoreError) || e.code !== "QORE_PORT_IN_USE" || e.details?.owner !== "qore" || !canRecover(ctx)) throw e;
|
|
2697
|
+
const freed = await stopLeftover(ctx, e, o.runs, signal);
|
|
2698
|
+
if (signal.aborted) throw new CancelledError();
|
|
2699
|
+
if (!freed) throw e;
|
|
2700
|
+
await checkPort(ctx.deps, o);
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// src/cli/termination.ts
|
|
2705
|
+
var preflightHook = {};
|
|
2706
|
+
function preflightTermination(abort, raise) {
|
|
2707
|
+
let by;
|
|
2708
|
+
preflightHook.onSignal = (sig) => {
|
|
2709
|
+
by ??= sig;
|
|
2710
|
+
abort();
|
|
2711
|
+
};
|
|
2712
|
+
return { reRaise: async (flush) => {
|
|
2713
|
+
if (!by) return;
|
|
2714
|
+
await flush();
|
|
2715
|
+
raise(by);
|
|
2716
|
+
} };
|
|
2717
|
+
}
|
|
2718
|
+
async function inPreflight(signals, outer, fn) {
|
|
2719
|
+
const ac2 = new AbortController();
|
|
2720
|
+
const onOuter = () => ac2.abort();
|
|
2721
|
+
outer.addEventListener("abort", onOuter);
|
|
2722
|
+
if (outer.aborted) ac2.abort();
|
|
2723
|
+
const handlers = ["SIGTERM", "SIGHUP"].map((sig) => [sig, () => {
|
|
2724
|
+
preflightHook.onSignal?.(sig);
|
|
2725
|
+
ac2.abort();
|
|
2726
|
+
}]);
|
|
2727
|
+
let armed = false;
|
|
2728
|
+
const arm = () => {
|
|
2729
|
+
if (armed) return;
|
|
2730
|
+
armed = true;
|
|
2731
|
+
for (const [sig, h] of handlers) signals.on(sig, h);
|
|
2732
|
+
};
|
|
2733
|
+
try {
|
|
2734
|
+
return await fn({ signal: ac2.signal, arm });
|
|
2735
|
+
} finally {
|
|
2736
|
+
if (armed) for (const [sig, h] of handlers) signals.off(sig, h);
|
|
2737
|
+
outer.removeEventListener("abort", onOuter);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
|
|
1869
2741
|
// src/cli/trustPrompt.ts
|
|
1870
2742
|
async function ensureTrusted(ctx, mc, app, o) {
|
|
1871
2743
|
const root = mc.repoRoot ?? ctx.deps.cwd;
|
|
@@ -1889,37 +2761,1401 @@ async function ensureTrusted(ctx, mc, app, o) {
|
|
|
1889
2761
|
await trust(ctx.deps, root, mc.module.id);
|
|
1890
2762
|
}
|
|
1891
2763
|
|
|
1892
|
-
// src/cli/commands/
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2764
|
+
// src/cli/commands/devWorkspace.ts
|
|
2765
|
+
import { randomBytes as randomBytes4 } from "crypto";
|
|
2766
|
+
import { realpath as realpath4 } from "fs/promises";
|
|
2767
|
+
import { join as join16, relative as relative5 } from "path";
|
|
2768
|
+
|
|
2769
|
+
// src/core/dev/order.ts
|
|
2770
|
+
function selectSkips(modules, sel) {
|
|
2771
|
+
if (sel.only && sel.skip) {
|
|
2772
|
+
throw new QoreError("QORE_CONTEXT_INVALID", "--only and --skip cannot be used together", {
|
|
2773
|
+
hint: "Pass either --only or --skip, not both."
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
const slugs = modules.map((m) => m.slug).sort();
|
|
2777
|
+
const known = new Set(slugs);
|
|
2778
|
+
for (const s of [...sel.only ?? [], ...sel.skip ?? []]) {
|
|
2779
|
+
if (!known.has(s)) {
|
|
2780
|
+
throw new QoreError("QORE_CONTEXT_INVALID", `Unknown module: ${s}`, {
|
|
2781
|
+
hint: `Valid slugs: ${slugs.join(", ")}`
|
|
2782
|
+
});
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
const onlySet = sel.only ? new Set(sel.only) : null;
|
|
2786
|
+
const skipSet = sel.skip ? new Set(sel.skip) : null;
|
|
2787
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
2788
|
+
for (const m of modules) {
|
|
2789
|
+
if (onlySet && !onlySet.has(m.slug) || skipSet && skipSet.has(m.slug)) {
|
|
2790
|
+
reasons.set(m.slug, "excluded");
|
|
2791
|
+
} else if (!m.present) {
|
|
2792
|
+
reasons.set(m.slug, "not-cloned");
|
|
2793
|
+
} else if (!m.canWrite) {
|
|
2794
|
+
reasons.set(m.slug, "no-write");
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
return reasons;
|
|
2798
|
+
}
|
|
2799
|
+
function tarjanSccs(starting, byId, startingIds) {
|
|
2800
|
+
let index = 0;
|
|
2801
|
+
const indices = /* @__PURE__ */ new Map();
|
|
2802
|
+
const lowlink = /* @__PURE__ */ new Map();
|
|
2803
|
+
const onStack = /* @__PURE__ */ new Map();
|
|
2804
|
+
const stack = [];
|
|
2805
|
+
const sccs = [];
|
|
2806
|
+
function strongconnect(id) {
|
|
2807
|
+
indices.set(id, index);
|
|
2808
|
+
lowlink.set(id, index);
|
|
2809
|
+
index++;
|
|
2810
|
+
stack.push(id);
|
|
2811
|
+
onStack.set(id, true);
|
|
2812
|
+
const m = byId.get(id);
|
|
2813
|
+
for (const dep of m.dependsOn) {
|
|
2814
|
+
if (!startingIds.has(dep)) continue;
|
|
2815
|
+
if (!indices.has(dep)) {
|
|
2816
|
+
strongconnect(dep);
|
|
2817
|
+
lowlink.set(id, Math.min(lowlink.get(id), lowlink.get(dep)));
|
|
2818
|
+
} else if (onStack.get(dep)) {
|
|
2819
|
+
lowlink.set(id, Math.min(lowlink.get(id), indices.get(dep)));
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
if (lowlink.get(id) === indices.get(id)) {
|
|
2823
|
+
const comp = [];
|
|
2824
|
+
let w;
|
|
2825
|
+
do {
|
|
2826
|
+
w = stack.pop();
|
|
2827
|
+
onStack.set(w, false);
|
|
2828
|
+
comp.push(w);
|
|
2829
|
+
} while (w !== id);
|
|
2830
|
+
sccs.push(comp);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
for (const m of starting) {
|
|
2834
|
+
if (!indices.has(m.id)) strongconnect(m.id);
|
|
2835
|
+
}
|
|
2836
|
+
return sccs;
|
|
2837
|
+
}
|
|
2838
|
+
function planDev(modules, sel) {
|
|
2839
|
+
const reasons = selectSkips(modules, sel);
|
|
2840
|
+
const byId = new Map(modules.map((m) => [m.id, m]));
|
|
2841
|
+
const starting = modules.filter((m) => !reasons.has(m.slug));
|
|
2842
|
+
const startingIds = new Set(starting.map((m) => m.id));
|
|
2843
|
+
const sccs = tarjanSccs(starting, byId, startingIds);
|
|
2844
|
+
const sccOf = /* @__PURE__ */ new Map();
|
|
2845
|
+
const sccSlugs = sccs.map((comp) => comp.map((id) => byId.get(id).slug).sort());
|
|
2846
|
+
sccs.forEach((comp, i) => comp.forEach((id) => sccOf.set(id, i)));
|
|
2847
|
+
const cycles = sccSlugs.filter((slugs, i) => slugs.length > 1 || sccs[i].some((id) => byId.get(id).dependsOn.includes(id))).map((slugs) => slugs);
|
|
2848
|
+
const providerSccsOf = /* @__PURE__ */ new Map();
|
|
2849
|
+
sccs.forEach((comp, i) => {
|
|
2850
|
+
const set = /* @__PURE__ */ new Set();
|
|
2851
|
+
for (const id of comp) {
|
|
2852
|
+
for (const dep of byId.get(id).dependsOn) {
|
|
2853
|
+
if (!startingIds.has(dep)) continue;
|
|
2854
|
+
const j = sccOf.get(dep);
|
|
2855
|
+
if (j !== i) set.add(j);
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
providerSccsOf.set(i, set);
|
|
2859
|
+
});
|
|
2860
|
+
const minSlug = sccSlugs.map((slugs) => slugs[0]);
|
|
2861
|
+
const visited = new Array(sccs.length).fill(false);
|
|
2862
|
+
const orderedSccIndices = [];
|
|
2863
|
+
function visit(i) {
|
|
2864
|
+
if (visited[i]) return;
|
|
2865
|
+
visited[i] = true;
|
|
2866
|
+
const providers = [...providerSccsOf.get(i)].sort((a, b) => minSlug[a].localeCompare(minSlug[b]));
|
|
2867
|
+
for (const p of providers) visit(p);
|
|
2868
|
+
orderedSccIndices.push(i);
|
|
2869
|
+
}
|
|
2870
|
+
const order = sccs.map((_, i) => i).sort((a, b) => minSlug[a].localeCompare(minSlug[b]));
|
|
2871
|
+
for (const i of order) visit(i);
|
|
2872
|
+
const start = [];
|
|
2873
|
+
for (const i of orderedSccIndices) start.push(...sccSlugs[i]);
|
|
2874
|
+
const waitFor = {};
|
|
2875
|
+
for (const m of starting) {
|
|
2876
|
+
const mySccId = sccOf.get(m.id);
|
|
2877
|
+
const providers = m.dependsOn.filter((d) => startingIds.has(d) && sccOf.get(d) !== mySccId).map((d) => byId.get(d).slug);
|
|
2878
|
+
waitFor[m.slug] = [...new Set(providers)].sort();
|
|
2879
|
+
}
|
|
2880
|
+
const skipped = [...reasons.entries()].map(([slug, reason]) => ({ slug, reason })).sort((a, b) => a.slug.localeCompare(b.slug));
|
|
2881
|
+
const missingProviders = {};
|
|
2882
|
+
for (const m of starting) {
|
|
2883
|
+
const missing = m.dependsOn.map((d) => byId.get(d)).filter((dep) => dep !== void 0 && reasons.has(dep.slug)).map((dep) => dep.slug);
|
|
2884
|
+
if (missing.length > 0) {
|
|
2885
|
+
missingProviders[m.slug] = [...new Set(missing)].sort();
|
|
2886
|
+
}
|
|
1900
2887
|
}
|
|
2888
|
+
return { start, waitFor, cycles, skipped, missingProviders };
|
|
1901
2889
|
}
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
2890
|
+
|
|
2891
|
+
// src/core/dev/supervisor.ts
|
|
2892
|
+
import { constants } from "os";
|
|
2893
|
+
var EARLY_MS = 1e4;
|
|
2894
|
+
var GRACE_MS2 = 1e4;
|
|
2895
|
+
function exitCode(e) {
|
|
2896
|
+
if (e.spawnError) return 1;
|
|
2897
|
+
if (e.code !== null) return e.code;
|
|
2898
|
+
const n = e.signal ? constants.signals[e.signal] : void 0;
|
|
2899
|
+
return n === void 0 ? 1 : 128 + n;
|
|
2900
|
+
}
|
|
2901
|
+
function supervise(o) {
|
|
2902
|
+
const now = o.now ?? Date.now;
|
|
2903
|
+
const delay = o.delay ?? ((ms) => new Promise((r) => {
|
|
2904
|
+
setTimeout(r, ms).unref();
|
|
2905
|
+
}));
|
|
2906
|
+
const abort = new AbortController();
|
|
2907
|
+
const emit = (e) => {
|
|
2908
|
+
try {
|
|
2909
|
+
o.onEvent(e);
|
|
2910
|
+
} catch {
|
|
2911
|
+
}
|
|
2912
|
+
};
|
|
2913
|
+
const running = [];
|
|
2914
|
+
const tails = /* @__PURE__ */ new Map();
|
|
2915
|
+
let unlaunched = o.modules.length;
|
|
2916
|
+
let stopCode = null;
|
|
2917
|
+
let firstFailure = null;
|
|
2918
|
+
let resolveDone;
|
|
2919
|
+
const done = new Promise((r) => {
|
|
2920
|
+
resolveDone = r;
|
|
2921
|
+
});
|
|
2922
|
+
const readiness = /* @__PURE__ */ new Map();
|
|
2923
|
+
for (const m of o.modules) {
|
|
2924
|
+
let resolve5;
|
|
2925
|
+
let settled = false;
|
|
2926
|
+
const promise = new Promise((r) => {
|
|
2927
|
+
resolve5 = r;
|
|
1912
2928
|
});
|
|
2929
|
+
readiness.set(m.slug, { promise, settle: (r) => {
|
|
2930
|
+
if (settled) return false;
|
|
2931
|
+
settled = true;
|
|
2932
|
+
resolve5(r);
|
|
2933
|
+
return true;
|
|
2934
|
+
} });
|
|
1913
2935
|
}
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
if (
|
|
1922
|
-
|
|
2936
|
+
const live = () => running.filter((c) => c.alive);
|
|
2937
|
+
const checkDone = () => {
|
|
2938
|
+
if (live().length > 0) return;
|
|
2939
|
+
if (stopCode !== null) resolveDone(stopCode);
|
|
2940
|
+
else if (unlaunched === 0) resolveDone(firstFailure ?? 0);
|
|
2941
|
+
};
|
|
2942
|
+
const beginStop = (reason, code) => {
|
|
2943
|
+
if (stopCode !== null) return;
|
|
2944
|
+
stopCode = code;
|
|
2945
|
+
abort.abort();
|
|
2946
|
+
for (const r of readiness.values()) r.settle("timeout");
|
|
2947
|
+
emit({ type: "stopping", reason });
|
|
2948
|
+
const targets = live().reverse();
|
|
2949
|
+
for (const c of targets) c.launched.stop("SIGTERM");
|
|
2950
|
+
if (targets.length > 0) {
|
|
2951
|
+
void delay(GRACE_MS2).then(() => {
|
|
2952
|
+
for (const c of live().reverse()) c.launched.stop("SIGKILL");
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
checkDone();
|
|
2956
|
+
};
|
|
2957
|
+
const launch = (m) => {
|
|
2958
|
+
unlaunched--;
|
|
2959
|
+
const ready = readiness.get(m.slug);
|
|
2960
|
+
const startedAt = now();
|
|
2961
|
+
const launched = o.launcher.launch(m.cmd, m.args, {
|
|
2962
|
+
cwd: m.cwd,
|
|
2963
|
+
env: m.env,
|
|
2964
|
+
onLine: (l) => {
|
|
2965
|
+
const tail = tails.get(m.slug) ?? [];
|
|
2966
|
+
tail.push(l.line);
|
|
2967
|
+
if (tail.length > KEEP_LINES) tail.shift();
|
|
2968
|
+
tails.set(m.slug, tail);
|
|
2969
|
+
emit({ type: "line", module: m.slug, stream: l.stream, line: l.line });
|
|
2970
|
+
}
|
|
2971
|
+
});
|
|
2972
|
+
const child = { mod: m, launched, alive: true };
|
|
2973
|
+
running.push(child);
|
|
2974
|
+
emit({ type: "start", module: m.slug, pid: launched.pid });
|
|
2975
|
+
if (m.port === null) ready.settle("ready");
|
|
2976
|
+
else {
|
|
2977
|
+
const port = m.port;
|
|
2978
|
+
void o.ports.waitFor(port, o.waitMs, abort.signal).then((ok2) => {
|
|
2979
|
+
if (ready.settle(ok2 ? "ready" : "timeout") && ok2) emit({ type: "ready", module: m.slug, port });
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
void launched.exited.then((e) => {
|
|
2983
|
+
child.alive = false;
|
|
2984
|
+
ready.settle("exited");
|
|
2985
|
+
const code = exitCode(e);
|
|
2986
|
+
const early = !!e.spawnError || now() - startedAt < EARLY_MS;
|
|
2987
|
+
const rawCode = e.spawnError ? null : e.code;
|
|
2988
|
+
const rawSignal = e.spawnError ? null : e.signal;
|
|
2989
|
+
const hint = early && rawSignal === null && rawCode !== null && rawCode !== 0 ? earlyExitHint(tails.get(m.slug) ?? [], { installCommand: m.installCmd }) ?? void 0 : void 0;
|
|
2990
|
+
emit({ type: "exit", module: m.slug, code: rawCode, signal: rawSignal, early, hint });
|
|
2991
|
+
if (code !== 0 && firstFailure === null) firstFailure = code;
|
|
2992
|
+
if (o.killOthers) beginStop("kill-others", code);
|
|
2993
|
+
checkDone();
|
|
2994
|
+
});
|
|
2995
|
+
};
|
|
2996
|
+
const start = async (m) => {
|
|
2997
|
+
const providers = m.waitFor.filter((p) => readiness.has(p));
|
|
2998
|
+
const results = await Promise.all(providers.map((p) => readiness.get(p).promise));
|
|
2999
|
+
if (stopCode !== null) return;
|
|
3000
|
+
providers.forEach((p, i) => {
|
|
3001
|
+
const why = results[i];
|
|
3002
|
+
if (why !== "ready") emit({ type: "provider-missing", module: m.slug, provider: p, why });
|
|
3003
|
+
});
|
|
3004
|
+
launch(m);
|
|
3005
|
+
};
|
|
3006
|
+
for (const m of o.modules) void start(m);
|
|
3007
|
+
checkDone();
|
|
3008
|
+
return {
|
|
3009
|
+
done,
|
|
3010
|
+
stop(reason) {
|
|
3011
|
+
if (reason === "interrupt") beginStop("interrupt", 130);
|
|
3012
|
+
else beginStop("signal", 128 + constants.signals[reason]);
|
|
3013
|
+
},
|
|
3014
|
+
escalate() {
|
|
3015
|
+
beginStop("interrupt", 130);
|
|
3016
|
+
for (const c of live().reverse()) c.launched.stop("SIGKILL");
|
|
3017
|
+
},
|
|
3018
|
+
children: () => live().map((c) => ({ module: c.mod.slug, pid: c.launched.pid, port: c.mod.port }))
|
|
3019
|
+
};
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
// src/core/workspace/access.ts
|
|
3023
|
+
function devTeamsOf(am, m) {
|
|
3024
|
+
const own = m.devTeamIds?.length ? am.devTeams.filter((t) => m.devTeamIds.includes(t.id)) : [];
|
|
3025
|
+
return own.length ? own : am.devTeams;
|
|
3026
|
+
}
|
|
3027
|
+
function askMaintainer(am, m) {
|
|
3028
|
+
const teams = devTeamsOf(am, m);
|
|
3029
|
+
const names = teams.map((t) => t.name);
|
|
3030
|
+
const maintainers = [...new Set(teams.flatMap((t) => t.maintainers))];
|
|
3031
|
+
const who = names.length ? names.join(", ") : "the app's dev team";
|
|
3032
|
+
return { teams: names, maintainers, text: `ask a maintainer of ${who}${maintainers.length ? ` (${maintainers.join(", ")})` : ""}` };
|
|
3033
|
+
}
|
|
3034
|
+
function notClonedReason(m, am) {
|
|
3035
|
+
if (am && !m.githubLinked) return `GitHub account not linked (${am.accountUrl})`;
|
|
3036
|
+
if (am && m.repoAccess === "none") {
|
|
3037
|
+
const names = devTeamsOf(am, m).map((t) => t.name);
|
|
3038
|
+
return names.length ? `no repository access (dev team ${names.join(", ")})` : "no repository access (ask your tenant owner)";
|
|
3039
|
+
}
|
|
3040
|
+
return "run qore sync";
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
// src/cli/devOutput.ts
|
|
3044
|
+
import { createColors as createColors2 } from "picocolors";
|
|
3045
|
+
function renderProblems(io2, problems, color) {
|
|
3046
|
+
problems.forEach((p, i) => {
|
|
3047
|
+
if (i > 0) io2.err("");
|
|
3048
|
+
renderError(io2, p, false, color);
|
|
3049
|
+
});
|
|
3050
|
+
}
|
|
3051
|
+
function combineProblems(problems) {
|
|
3052
|
+
if (problems.length === 0) throw new Error("combineProblems needs at least one problem");
|
|
3053
|
+
if (problems.length === 1) return problems[0];
|
|
3054
|
+
const first = problems[0];
|
|
3055
|
+
return new QoreError(first.code, `${problems.length} problems before starting`, {
|
|
3056
|
+
details: { problems: problems.map((p) => toJsonError(p)) }
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
function prefixer(slugs, color) {
|
|
3060
|
+
const c = createColors2(color);
|
|
3061
|
+
const palette = [c.cyan, c.magenta, c.yellow, c.green, c.blue, c.red];
|
|
3062
|
+
const sorted = [...new Set(slugs)].sort();
|
|
3063
|
+
const width = Math.max(0, ...sorted.map((s) => s.length));
|
|
3064
|
+
return (slug, line2) => {
|
|
3065
|
+
const i = sorted.indexOf(slug);
|
|
3066
|
+
const paint = i === -1 ? (s) => s : palette[i % palette.length];
|
|
3067
|
+
return `${paint(slug.padEnd(width))} \u2502 ${line2}`;
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
// src/cli/commands/run.ts
|
|
3072
|
+
import { constants as constants2 } from "os";
|
|
3073
|
+
var childRunning = { current: false };
|
|
3074
|
+
function registerRun(program, ctx) {
|
|
3075
|
+
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) => {
|
|
3076
|
+
const { deps: deps2 } = ctx;
|
|
3077
|
+
if (ctx.opts().json) {
|
|
3078
|
+
throw new QoreError("QORE_CONTEXT_INVALID", "run has no --json output", {
|
|
3079
|
+
hint: "Drop --json; the child's own output passes through."
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
const mc = await moduleContextFor(ctx, f, "run");
|
|
3083
|
+
const app = await appFor(ctx, mc, f.app);
|
|
3084
|
+
await ensureTrusted(ctx, mc, app, { yes: f.yes });
|
|
3085
|
+
const local = await fetchLocalEnv(deps2, mc, app);
|
|
3086
|
+
childRunning.current = true;
|
|
3087
|
+
let exit;
|
|
3088
|
+
try {
|
|
3089
|
+
exit = await deps2.spawn.run(cmd, args, { ...deps2.env, ...local.env });
|
|
3090
|
+
} finally {
|
|
3091
|
+
childRunning.current = false;
|
|
3092
|
+
}
|
|
3093
|
+
if (exit.signal) {
|
|
3094
|
+
deps2.raise(exit.signal);
|
|
3095
|
+
ctx.setExitCode(128 + (constants2.signals[exit.signal] ?? 0));
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
ctx.setExitCode(exit.code ?? 1);
|
|
3099
|
+
});
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
// src/cli/commands/devWorkspace.ts
|
|
3103
|
+
var INSTALL_PARALLELISM = 4;
|
|
3104
|
+
var str = (v) => typeof v === "string" && v.length > 0;
|
|
3105
|
+
var slugList = (raw) => raw?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
3106
|
+
function usableLast(last, appId) {
|
|
3107
|
+
if (!last || last.kind !== "workspace") return false;
|
|
3108
|
+
const a = last.app;
|
|
3109
|
+
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));
|
|
3110
|
+
}
|
|
3111
|
+
async function workspaceContext(ctx, ws) {
|
|
3112
|
+
const { deps: deps2 } = ctx;
|
|
3113
|
+
try {
|
|
3114
|
+
const portal = await flagPortal(deps2, ctx.opts().portal, ws);
|
|
3115
|
+
const session = await openSession(deps2, portal, "dev", requestLog(ctx));
|
|
3116
|
+
await ensureCapability(deps2, portal.apiOrigin, "app.modules");
|
|
3117
|
+
await ensureCapability(deps2, portal.apiOrigin, "module.local-env");
|
|
3118
|
+
return { am: await workspaceAppModules(session, portal, ws), online: { portal, session }, offlineCause: null };
|
|
3119
|
+
} catch (e) {
|
|
3120
|
+
if (!isOffline(e)) throw e;
|
|
3121
|
+
const last = await loadLastStart(deps2, ws.root);
|
|
3122
|
+
if (!usableLast(last, ws.appId)) throw e;
|
|
3123
|
+
const modules = last.modules.map((m) => ({
|
|
3124
|
+
id: m.id,
|
|
3125
|
+
slug: m.slug,
|
|
3126
|
+
name: m.name,
|
|
3127
|
+
type: m.type,
|
|
3128
|
+
access: m.access,
|
|
3129
|
+
repoUrl: m.repoUrl,
|
|
3130
|
+
repoAccess: "none",
|
|
3131
|
+
githubLinked: false,
|
|
3132
|
+
dependsOn: m.dependsOn
|
|
3133
|
+
}));
|
|
3134
|
+
if (!ctx.opts().json) ctx.io.err(`\u2192 ${last.app.tenant.name} / ${last.app.name} (offline \u2014 last started ${last.savedAt})`);
|
|
3135
|
+
return { am: { app: last.app, accountUrl: "", devTeams: [], modules }, online: null, offlineCause: e };
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
function deadAliases(env, ports) {
|
|
3139
|
+
return Object.keys(env).filter((k) => {
|
|
3140
|
+
if (!/^CONN_(.+)_URL$/.test(k)) return false;
|
|
3141
|
+
let port;
|
|
3142
|
+
try {
|
|
3143
|
+
port = Number(new URL(env[k].trim().replace(/^["']|["']$/g, "")).port);
|
|
3144
|
+
} catch {
|
|
3145
|
+
return false;
|
|
3146
|
+
}
|
|
3147
|
+
return port > 0 && !ports.has(port);
|
|
3148
|
+
}).sort();
|
|
3149
|
+
}
|
|
3150
|
+
async function pool(items, n, fn) {
|
|
3151
|
+
let next = 0;
|
|
3152
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(n, items.length) }, async () => {
|
|
3153
|
+
while (next < items.length) await fn(items[next++]);
|
|
3154
|
+
}));
|
|
3155
|
+
const failed = results.find((r) => r.status === "rejected");
|
|
3156
|
+
if (failed) throw failed.reason;
|
|
3157
|
+
}
|
|
3158
|
+
async function workspaceDev(ctx, ws, f, waitTimeout) {
|
|
3159
|
+
const ready = await inPreflight(ctx.deps.signals, ctx.signal, (p) => prepareWorkspace(ctx, ws, f, waitTimeout, p));
|
|
3160
|
+
await superviseAll(ctx, ws, ready.units, ready.waitFor, ready.o);
|
|
3161
|
+
}
|
|
3162
|
+
async function prepareWorkspace(ctx, ws, f, waitTimeout, p) {
|
|
3163
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
3164
|
+
const g = ctx.opts();
|
|
3165
|
+
const json2 = Boolean(g.json);
|
|
3166
|
+
if (f.app !== void 0) {
|
|
3167
|
+
throw new QoreError("QORE_CONTEXT_INVALID", "--app is for a module repository", {
|
|
3168
|
+
hint: "Drop --app; a workspace root starts the modules of its own app."
|
|
3169
|
+
});
|
|
3170
|
+
}
|
|
3171
|
+
const note = (human, event) => json2 ? io2.out(JSON.stringify(event)) : io2.err(human);
|
|
3172
|
+
const warn = (message, extra = {}) => note(`! ${message}`, { type: "warning", ...extra, message });
|
|
3173
|
+
const { am, online, offlineCause } = await workspaceContext(ctx, ws);
|
|
3174
|
+
if (online && !json2) io2.err(`\u2192 ${am.app.tenant.name} / ${am.app.name} (qore.workspace.json)`);
|
|
3175
|
+
if (!online) warn("The portal is unreachable \u2014 starting every module from its env file as last written; values may be stale.");
|
|
3176
|
+
const color = !json2 && g.color !== false && !deps2.env.NO_COLOR;
|
|
3177
|
+
const problems = [];
|
|
3178
|
+
const attempt = async (slug, fn) => {
|
|
3179
|
+
try {
|
|
3180
|
+
await fn();
|
|
3181
|
+
return true;
|
|
3182
|
+
} catch (e) {
|
|
3183
|
+
if (!(e instanceof QoreError)) throw e;
|
|
3184
|
+
problems.push(withDetails(e, { module: slug }));
|
|
3185
|
+
return false;
|
|
3186
|
+
}
|
|
3187
|
+
};
|
|
3188
|
+
const fail = async (last) => {
|
|
3189
|
+
const all = last ? [...problems, last] : problems;
|
|
3190
|
+
const enriched = await Promise.all(all.map((p2) => withProxyHint(deps2, p2)));
|
|
3191
|
+
if (!json2 && enriched.length > 1) {
|
|
3192
|
+
renderProblems(io2, enriched, color);
|
|
3193
|
+
io2.err("");
|
|
3194
|
+
}
|
|
3195
|
+
throw combineProblems(enriched);
|
|
3196
|
+
};
|
|
3197
|
+
const dirs = {};
|
|
3198
|
+
const safe = [];
|
|
3199
|
+
for (const m of am.modules) {
|
|
3200
|
+
if (!isSafeSlug(m.slug)) {
|
|
3201
|
+
warn(`${JSON.stringify(m.slug)} is not a safe directory name \u2014 skipped`, { module: m.slug });
|
|
3202
|
+
continue;
|
|
3203
|
+
}
|
|
3204
|
+
try {
|
|
3205
|
+
dirs[m.slug] = await deps2.git.inspect(join16(ws.root, m.slug));
|
|
3206
|
+
safe.push(m);
|
|
3207
|
+
} catch (e) {
|
|
3208
|
+
if (!(e instanceof QoreError) || e.code === "QORE_GIT_UNAVAILABLE") throw e;
|
|
3209
|
+
problems.push(withDetails(e, { module: m.slug }));
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
const present = new Map(planWorkspace(ws.root, safe, dirs, []).actions.filter((a) => a.kind === "present").map((a) => [a.module.slug, a.dir]));
|
|
3213
|
+
const bySlug = new Map(safe.map((m) => [m.slug, m]));
|
|
3214
|
+
const plan = planDev(safe.map((m) => ({
|
|
3215
|
+
id: m.id,
|
|
3216
|
+
slug: m.slug,
|
|
3217
|
+
present: present.has(m.slug),
|
|
3218
|
+
canWrite: m.access === "write" || m.access === "maintain",
|
|
3219
|
+
dependsOn: m.dependsOn
|
|
3220
|
+
})), { only: slugList(f.only), skip: slugList(f.skip) });
|
|
3221
|
+
for (const s of plan.skipped) {
|
|
3222
|
+
const m = bySlug.get(s.slug);
|
|
3223
|
+
if (s.reason === "no-write") {
|
|
3224
|
+
const ask = askMaintainer(am, m);
|
|
3225
|
+
note(
|
|
3226
|
+
`- ${s.slug} no write access \u2014 local secrets need write; ${ask.text}`,
|
|
3227
|
+
{ type: "skip", module: s.slug, reason: s.reason, devTeams: ask.teams, maintainers: ask.maintainers }
|
|
3228
|
+
);
|
|
3229
|
+
} else {
|
|
3230
|
+
note(`- ${s.slug} ${s.reason === "not-cloned" ? `not cloned \u2014 ${notClonedReason(m, online ? am : void 0)}` : "excluded"}`, { type: "skip", module: s.slug, reason: s.reason });
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
for (const c of plan.cycles) warn(`${c.join(", ")} depend on each other \u2014 they start together, without waiting`, { modules: c });
|
|
3234
|
+
if (plan.start.length === 0) {
|
|
3235
|
+
await fail(new QoreError("QORE_CONTEXT_REQUIRED", "No module of this workspace can be started", {
|
|
3236
|
+
hint: "Clone the modules with qore sync, or change --only / --skip.",
|
|
3237
|
+
details: { skipped: plan.skipped }
|
|
3238
|
+
}));
|
|
3239
|
+
}
|
|
3240
|
+
const units = plan.start.map((slug) => ({ m: bySlug.get(slug), dir: present.get(slug) }));
|
|
3241
|
+
if (online) {
|
|
3242
|
+
const untrusted = [];
|
|
3243
|
+
for (const u of units) if (!await isTrusted(deps2, u.dir, u.m.id)) untrusted.push(u);
|
|
3244
|
+
if (untrusted.length > 0) {
|
|
3245
|
+
const names = untrusted.map((u) => u.m.slug).join(", ");
|
|
3246
|
+
if (!f.yes) {
|
|
3247
|
+
if (!io2.interactive) {
|
|
3248
|
+
await fail(new QoreError("QORE_CONTEXT_REQUIRED", `These repositories are not trusted yet: ${names}`, {
|
|
3249
|
+
hint: "Run interactively once, or pass --yes.",
|
|
3250
|
+
details: { reason: "untrusted", modules: untrusted.map((u) => u.m.slug) }
|
|
3251
|
+
}));
|
|
3252
|
+
}
|
|
3253
|
+
if (!await io2.confirm(`Pull local secrets for ${names} into their repositories?`)) throw new CancelledError();
|
|
3254
|
+
}
|
|
3255
|
+
for (const u of untrusted) await trust(deps2, u.dir, u.m.id);
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
p.arm();
|
|
3259
|
+
const prefix = prefixer(plan.start, color);
|
|
3260
|
+
const onLine = (slug) => (l) => {
|
|
3261
|
+
if (json2) io2.out(JSON.stringify({ type: "line", module: slug, stream: l.stream, line: l.line }));
|
|
3262
|
+
else if (l.stream === "stdout") io2.out(prefix(slug, l.line));
|
|
3263
|
+
else io2.err(prefix(slug, l.line));
|
|
3264
|
+
};
|
|
3265
|
+
const report = () => {
|
|
3266
|
+
};
|
|
3267
|
+
for (const u of units) {
|
|
3268
|
+
await attempt(u.m.slug, async () => {
|
|
3269
|
+
u.cfg = await readRepoConfig(u.dir);
|
|
3270
|
+
u.pkg = await readPackageJson(u.dir);
|
|
3271
|
+
u.recipe = planRecipe({
|
|
3272
|
+
root: u.dir,
|
|
3273
|
+
pkg: u.pkg,
|
|
3274
|
+
pm: await detectPackageManager(u.dir, u.pkg),
|
|
3275
|
+
cfg: u.cfg,
|
|
3276
|
+
slug: u.m.slug,
|
|
3277
|
+
flags: { install: f.install, services: f.services }
|
|
3278
|
+
});
|
|
3279
|
+
});
|
|
3280
|
+
}
|
|
3281
|
+
for (const u of units) {
|
|
3282
|
+
u.trusted = !offlineCause || await isTrusted(deps2, u.dir, u.m.id);
|
|
3283
|
+
if (!u.trusted) problems.push(withDetails(offlineCause, { module: u.m.slug }));
|
|
3284
|
+
}
|
|
3285
|
+
const withRecipe = units.filter((u) => u.recipe && u.trusted);
|
|
3286
|
+
for (const name of [...new Set(withRecipe.map((u) => u.recipe.pm.name))]) {
|
|
3287
|
+
const users = withRecipe.filter((u) => u.recipe.pm.name === name);
|
|
3288
|
+
const pmOk = await attempt(users[0].m.slug, () => checkPackageManager(deps2, users[0].recipe, () => {
|
|
3289
|
+
}, p.signal));
|
|
3290
|
+
for (const u of users) u.pmOk = pmOk;
|
|
3291
|
+
}
|
|
3292
|
+
for (const u of withRecipe) {
|
|
3293
|
+
const nodeOk = await attempt(u.m.slug, () => checkNode(deps2, u.recipe, u.pkg, u.m.slug, p.signal));
|
|
3294
|
+
u.pmOk = u.pmOk && nodeOk;
|
|
3295
|
+
}
|
|
3296
|
+
await pool(units.filter((u) => u.pmOk && u.trusted), INSTALL_PARALLELISM, async (u) => {
|
|
3297
|
+
u.installed = await attempt(u.m.slug, () => installIfNeeded(deps2, u.recipe, u.m.slug, onLine(u.m.slug), report, p.signal));
|
|
3298
|
+
});
|
|
3299
|
+
const runs = [await leftoverRun(deps2, ws.root)].filter((r) => r !== null);
|
|
3300
|
+
const appRef = { id: am.app.id, slug: am.app.slug, name: am.app.name };
|
|
3301
|
+
const mcFor = (u) => ({
|
|
3302
|
+
portal: online.portal,
|
|
3303
|
+
session: online.session,
|
|
3304
|
+
source: { kind: "workspace" },
|
|
3305
|
+
repoRoot: u.dir,
|
|
3306
|
+
workspace: ws,
|
|
3307
|
+
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] }
|
|
3308
|
+
});
|
|
3309
|
+
const rootReal = await realpath4(ws.root);
|
|
3310
|
+
const withDocker = dockerRecovery(ctx, p.signal);
|
|
3311
|
+
for (const u of units.filter((x) => x.recipe && x.trusted)) {
|
|
3312
|
+
const slug = u.m.slug;
|
|
3313
|
+
await attempt(slug, async () => {
|
|
3314
|
+
try {
|
|
3315
|
+
u.dev = await devEnv(deps2, {
|
|
3316
|
+
mc: online ? mcFor(u) : null,
|
|
3317
|
+
app: online ? appRef : null,
|
|
3318
|
+
root: u.dir,
|
|
3319
|
+
type: u.m.type,
|
|
3320
|
+
moduleId: u.m.id,
|
|
3321
|
+
cfg: u.cfg,
|
|
3322
|
+
random: () => randomBytes4(32).toString("hex")
|
|
3323
|
+
});
|
|
3324
|
+
} catch (e) {
|
|
3325
|
+
if (offlineCause && isOffline(e)) throw offlineCause;
|
|
3326
|
+
throw e;
|
|
3327
|
+
}
|
|
3328
|
+
});
|
|
3329
|
+
const dev = u.dev;
|
|
3330
|
+
if (!dev) continue;
|
|
3331
|
+
if (online && dev.offline) {
|
|
3332
|
+
warn(`${slug}: the portal is unreachable \u2014 starting from ${relative5(rootReal, dev.envFile)} as last written; values may be stale.`, { module: slug });
|
|
3333
|
+
}
|
|
3334
|
+
if (dev.kept.length) {
|
|
3335
|
+
warn(
|
|
3336
|
+
`${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`,
|
|
3337
|
+
{ module: slug, keys: dev.kept }
|
|
3338
|
+
);
|
|
3339
|
+
}
|
|
3340
|
+
if (u.installed) {
|
|
3341
|
+
const env = { ...deps2.env, ...dev.env };
|
|
3342
|
+
await attempt(slug, () => withDocker({ cwd: u.dir, env }, () => startServices(deps2, u.recipe, slug, env, dev.dbPort, waitTimeout * 1e3, p.signal, onLine(slug), report)));
|
|
3343
|
+
}
|
|
3344
|
+
await attempt(slug, () => checkPortOrStopLeftover(ctx, { slug, port: dev.port, key: "PORT", runs }, p.signal));
|
|
3345
|
+
}
|
|
3346
|
+
if (p.signal.aborted) throw new CancelledError();
|
|
3347
|
+
const byPort = /* @__PURE__ */ new Map();
|
|
3348
|
+
for (const u of units) if (u.dev?.port != null) byPort.set(u.dev.port, [...byPort.get(u.dev.port) ?? [], u.m.slug]);
|
|
3349
|
+
for (const [port, slugs] of byPort) {
|
|
3350
|
+
if (slugs.length < 2) continue;
|
|
3351
|
+
problems.push(new QoreError("QORE_PORT_IN_USE", `${slugs.slice(0, -1).join(", ")} and ${slugs.at(-1)} both have port ${port}`, {
|
|
3352
|
+
hint: "Ports are unique per organisation; a module from another organisation can share one. Run one of them (--only / --skip).",
|
|
3353
|
+
details: { port, module: slugs[0], modules: slugs }
|
|
3354
|
+
}));
|
|
3355
|
+
}
|
|
3356
|
+
if (problems.length > 0) await fail();
|
|
3357
|
+
const listening = new Set(units.map((u) => u.dev.port).filter((p2) => p2 !== null));
|
|
3358
|
+
for (const [consumer, providers] of Object.entries(plan.missingProviders)) {
|
|
3359
|
+
const env = units.find((u) => u.m.slug === consumer).dev.env;
|
|
3360
|
+
const aliases = providers.length === 1 ? deadAliases(env, listening) : [];
|
|
3361
|
+
for (const provider of providers) {
|
|
3362
|
+
const what = aliases.length ? `${aliases.join(", ")} ${aliases.length === 1 ? "points" : "point"}` : "its CONN_*_URL points";
|
|
3363
|
+
warn(
|
|
3364
|
+
`${consumer}: ${provider} will not run \u2014 ${what} at a port nothing listens on. Qore does not fall back to staging.`,
|
|
3365
|
+
{ module: consumer, provider, aliases }
|
|
3366
|
+
);
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
if (online && units.every((u) => !u.dev.offline)) {
|
|
3370
|
+
const envFile = (m) => {
|
|
3371
|
+
const u = units.find((x) => x.m.id === m.id);
|
|
3372
|
+
return u ? relative5(rootReal, u.dev.envFile) : join16(m.slug, moduleDefaults(m.type).envFile);
|
|
3373
|
+
};
|
|
3374
|
+
await saveLastStart(deps2, ws.root, {
|
|
3375
|
+
schemaVersion: 1,
|
|
3376
|
+
kind: "workspace",
|
|
3377
|
+
portal: online.portal.apiOrigin,
|
|
3378
|
+
savedAt: deps2.now().toISOString(),
|
|
3379
|
+
app: { ...appRef, tenant: { id: am.app.tenant.id, slug: am.app.tenant.slug, name: am.app.tenant.name } },
|
|
3380
|
+
modules: safe.map((m) => ({
|
|
3381
|
+
id: m.id,
|
|
3382
|
+
slug: m.slug,
|
|
3383
|
+
name: m.name,
|
|
3384
|
+
type: m.type,
|
|
3385
|
+
access: m.access,
|
|
3386
|
+
repoUrl: m.repoUrl,
|
|
3387
|
+
dependsOn: m.dependsOn,
|
|
3388
|
+
envFile: envFile(m)
|
|
3389
|
+
}))
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
if (p.signal.aborted) throw new CancelledError();
|
|
3393
|
+
return { units, waitFor: plan.waitFor, o: { waitTimeout, killOthers: Boolean(f.killOthers), json: json2, color, prefix } };
|
|
3394
|
+
}
|
|
3395
|
+
async function superviseAll(ctx, ws, units, waitFor, o) {
|
|
3396
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
3397
|
+
const startedAt = deps2.now().toISOString();
|
|
3398
|
+
const began = /* @__PURE__ */ new Map();
|
|
3399
|
+
const ref = {};
|
|
3400
|
+
let stopping = false;
|
|
3401
|
+
let runWrite = Promise.resolve();
|
|
3402
|
+
const writeRun = () => {
|
|
3403
|
+
runWrite = runWrite.then(() => writeRunFile(deps2, ws.root, {
|
|
3404
|
+
schemaVersion: 1,
|
|
3405
|
+
qorePid: deps2.pid,
|
|
3406
|
+
root: ws.root,
|
|
3407
|
+
startedAt,
|
|
3408
|
+
children: ref.sup?.children() ?? []
|
|
3409
|
+
})).catch(() => {
|
|
3410
|
+
});
|
|
3411
|
+
};
|
|
3412
|
+
const render = (e) => {
|
|
3413
|
+
switch (e.type) {
|
|
3414
|
+
case "line":
|
|
3415
|
+
return e.stream === "stdout" ? io2.out(o.prefix(e.module, e.line)) : io2.err(o.prefix(e.module, e.line));
|
|
3416
|
+
case "start":
|
|
3417
|
+
return io2.err(`\u25B6 ${e.module}${e.pid === null ? "" : ` (pid ${e.pid})`}`);
|
|
3418
|
+
case "ready":
|
|
3419
|
+
return io2.err(`\u2713 ${e.module} is listening on ${e.port}`);
|
|
3420
|
+
case "provider-missing":
|
|
3421
|
+
return io2.err(`! ${e.provider} did not come up within ${o.waitTimeout} s (or exited); starting ${e.module} anyway \u2014 see its output above`);
|
|
3422
|
+
case "stopping":
|
|
3423
|
+
return io2.err("\u25A0 stopping");
|
|
3424
|
+
case "exit": {
|
|
3425
|
+
if (stopping) return io2.err(`\u25A0 ${e.module} stopped`);
|
|
3426
|
+
if (e.code === null && e.signal === null) return io2.err(`\u2717 ${e.module} could not be started`);
|
|
3427
|
+
const after = e.early ? ` after ${Math.round((deps2.now().getTime() - (began.get(e.module) ?? deps2.now().getTime())) / 1e3)} s` : "";
|
|
3428
|
+
const how = e.signal ? `was ended by ${e.signal}` : `exited with code ${e.code}`;
|
|
3429
|
+
io2.err(`${e.code === 0 ? "\u25A0" : "\u2717"} ${e.module} ${how}${after}`);
|
|
3430
|
+
if (e.hint) io2.err(` \u2192 ${e.hint.hint}`);
|
|
3431
|
+
return;
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
};
|
|
3435
|
+
const onEvent = (e) => {
|
|
3436
|
+
if (e.type === "start") {
|
|
3437
|
+
began.set(e.module, deps2.now().getTime());
|
|
3438
|
+
writeRun();
|
|
3439
|
+
}
|
|
3440
|
+
if (e.type === "stopping") stopping = true;
|
|
3441
|
+
if (o.json) io2.out(JSON.stringify(e));
|
|
3442
|
+
else render(e);
|
|
3443
|
+
};
|
|
3444
|
+
const s = ref.sup = supervise({
|
|
3445
|
+
modules: units.map((u) => {
|
|
3446
|
+
const { cmd, args } = runArgs(u.recipe.pm, u.recipe.script);
|
|
3447
|
+
const { cmd: installCmd, args: installArgv } = installArgs(u.recipe.pm);
|
|
3448
|
+
return {
|
|
3449
|
+
slug: u.m.slug,
|
|
3450
|
+
cmd,
|
|
3451
|
+
args,
|
|
3452
|
+
cwd: u.dir,
|
|
3453
|
+
port: u.dev.port,
|
|
3454
|
+
waitFor: waitFor[u.m.slug] ?? [],
|
|
3455
|
+
installCmd: [installCmd, ...installArgv].join(" "),
|
|
3456
|
+
env: { ...deps2.env, ...u.dev.env, ...o.color ? { FORCE_COLOR: "1" } : {} }
|
|
3457
|
+
};
|
|
3458
|
+
}),
|
|
3459
|
+
launcher: deps2.launch,
|
|
3460
|
+
ports: deps2.ports,
|
|
3461
|
+
waitMs: o.waitTimeout * 1e3,
|
|
3462
|
+
killOthers: o.killOthers,
|
|
3463
|
+
onEvent
|
|
3464
|
+
});
|
|
3465
|
+
const onTerm = () => s.stop("SIGTERM");
|
|
3466
|
+
const onHup = () => s.stop("SIGHUP");
|
|
3467
|
+
const onAbort = () => s.stop("interrupt");
|
|
3468
|
+
deps2.signals.on("SIGTERM", onTerm);
|
|
3469
|
+
deps2.signals.on("SIGHUP", onHup);
|
|
3470
|
+
ctx.signal.addEventListener("abort", onAbort);
|
|
3471
|
+
childRunning.current = true;
|
|
3472
|
+
childRunning.onInterrupt = () => s.escalate();
|
|
3473
|
+
try {
|
|
3474
|
+
if (ctx.signal.aborted) onAbort();
|
|
3475
|
+
writeRun();
|
|
3476
|
+
ctx.setExitCode(await s.done);
|
|
3477
|
+
} finally {
|
|
3478
|
+
deps2.signals.off("SIGTERM", onTerm);
|
|
3479
|
+
deps2.signals.off("SIGHUP", onHup);
|
|
3480
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
3481
|
+
childRunning.current = false;
|
|
3482
|
+
childRunning.onInterrupt = void 0;
|
|
3483
|
+
await runWrite;
|
|
3484
|
+
await removeRunFile(deps2, ws.root);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
// src/cli/commands/dev.ts
|
|
3489
|
+
var EARLY_EXIT_MS = 1e4;
|
|
3490
|
+
function makeStderrTee(sink) {
|
|
3491
|
+
const decoder = new StringDecoder("utf8");
|
|
3492
|
+
let buf = "";
|
|
3493
|
+
let forwarding = true;
|
|
3494
|
+
const tail = [];
|
|
3495
|
+
const record = (line2) => {
|
|
3496
|
+
tail.push(line2);
|
|
3497
|
+
if (tail.length > KEEP_LINES) tail.shift();
|
|
3498
|
+
};
|
|
3499
|
+
const onError = () => {
|
|
3500
|
+
forwarding = false;
|
|
3501
|
+
};
|
|
3502
|
+
sink.on("error", onError);
|
|
3503
|
+
return {
|
|
3504
|
+
push: (chunk) => {
|
|
3505
|
+
if (forwarding) {
|
|
3506
|
+
try {
|
|
3507
|
+
sink.write(chunk);
|
|
3508
|
+
} catch {
|
|
3509
|
+
forwarding = false;
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
buf += decoder.write(chunk);
|
|
3513
|
+
let i;
|
|
3514
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
3515
|
+
record(buf.slice(0, i).replace(/\r$/, ""));
|
|
3516
|
+
buf = buf.slice(i + 1);
|
|
3517
|
+
}
|
|
3518
|
+
},
|
|
3519
|
+
lines: () => {
|
|
3520
|
+
const rest = (buf + decoder.end()).replace(/\r$/, "");
|
|
3521
|
+
return rest ? [...tail, rest].slice(-KEEP_LINES) : tail.slice();
|
|
3522
|
+
},
|
|
3523
|
+
dispose: () => {
|
|
3524
|
+
sink.removeListener("error", onError);
|
|
3525
|
+
}
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
function registerDev(program, ctx) {
|
|
3529
|
+
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) => {
|
|
3530
|
+
const waitTimeout = parseWaitTimeout(f.waitTimeout);
|
|
3531
|
+
const ws = await workspaceRoot(ctx.deps);
|
|
3532
|
+
if (ws && f.module === void 0) return workspaceDev(ctx, ws, f, waitTimeout);
|
|
3533
|
+
return moduleDev(ctx, f, waitTimeout);
|
|
3534
|
+
});
|
|
3535
|
+
}
|
|
3536
|
+
function parseWaitTimeout(raw) {
|
|
3537
|
+
const n = /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
|
|
3538
|
+
if (!Number.isInteger(n) || n < 1 || n > 600) {
|
|
3539
|
+
throw new QoreError("QORE_CONTEXT_INVALID", `--wait-timeout must be a whole number of seconds from 1 to 600, not "${raw}"`, {
|
|
3540
|
+
hint: "Pass e.g. --wait-timeout 60."
|
|
3541
|
+
});
|
|
3542
|
+
}
|
|
3543
|
+
return n;
|
|
3544
|
+
}
|
|
3545
|
+
var str2 = (v) => typeof v === "string" && v.length > 0;
|
|
3546
|
+
function usableLast2(last) {
|
|
3547
|
+
if (!last || last.kind !== "module") return false;
|
|
3548
|
+
const m = last.module;
|
|
3549
|
+
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);
|
|
3550
|
+
}
|
|
3551
|
+
async function devContext(ctx, f, root) {
|
|
3552
|
+
let mc;
|
|
3553
|
+
try {
|
|
3554
|
+
mc = await moduleContextFor(ctx, f, "dev");
|
|
3555
|
+
} catch (e) {
|
|
3556
|
+
if (!isOffline(e) || f.module) throw e;
|
|
3557
|
+
const last = await loadLastStart(ctx.deps, root);
|
|
3558
|
+
if (!usableLast2(last)) throw e;
|
|
3559
|
+
if (f.app && f.app !== last.app.id && f.app !== last.app.slug) throw e;
|
|
3560
|
+
if (!await isTrusted(ctx.deps, root, last.module.id)) throw e;
|
|
3561
|
+
ctx.io.err(`\u2192 ${last.module.tenant.name} / ${last.app.name} / ${last.module.slug} (offline \u2014 last started ${last.savedAt})`);
|
|
3562
|
+
return { online: null, module: last.module, offlineCause: e };
|
|
3563
|
+
}
|
|
3564
|
+
const app = await appFor(ctx, mc, f.app);
|
|
3565
|
+
await ensureTrusted(ctx, mc, app, { yes: f.yes });
|
|
3566
|
+
return { online: { mc, app }, module: mc.module, offlineCause: null };
|
|
3567
|
+
}
|
|
3568
|
+
async function moduleDev(ctx, f, waitTimeout) {
|
|
3569
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
3570
|
+
const { root, slug, recipe, env, port } = await inPreflight(deps2.signals, ctx.signal, (p) => prepareModule(ctx, f, waitTimeout, p));
|
|
3571
|
+
const { cmd, args } = runArgs(recipe.pm, recipe.script);
|
|
3572
|
+
const startedAt = deps2.now();
|
|
3573
|
+
await writeRunFile(deps2, root, {
|
|
3574
|
+
schemaVersion: 1,
|
|
3575
|
+
qorePid: deps2.pid,
|
|
3576
|
+
root,
|
|
3577
|
+
startedAt: startedAt.toISOString(),
|
|
3578
|
+
children: [{ module: slug, pid: null, port }]
|
|
3579
|
+
});
|
|
3580
|
+
childRunning.current = true;
|
|
3581
|
+
const tee = makeStderrTee(io2.stderrRaw);
|
|
3582
|
+
const spawnEnv = ctx.opts().color !== false && io2.stderrTTY && env.FORCE_COLOR === void 0 && env.NO_COLOR === void 0 ? { ...env, FORCE_COLOR: "1" } : env;
|
|
3583
|
+
let exit;
|
|
3584
|
+
try {
|
|
3585
|
+
exit = await deps2.spawn.run(cmd, args, spawnEnv, { cwd: root, onStderr: tee.push });
|
|
3586
|
+
} finally {
|
|
3587
|
+
tee.dispose();
|
|
3588
|
+
childRunning.current = false;
|
|
3589
|
+
await removeRunFile(deps2, root);
|
|
3590
|
+
}
|
|
3591
|
+
const ms = deps2.now().getTime() - startedAt.getTime();
|
|
3592
|
+
if (exit.code !== 0 && exit.signal === null && ms < EARLY_EXIT_MS) {
|
|
3593
|
+
io2.err(`\u2717 ${slug} exited after ${Math.round(ms / 1e3)} s with code ${exit.code ?? 1} \u2014 its output above says why.`);
|
|
3594
|
+
const { cmd: installCmd, args: installArgv } = installArgs(recipe.pm);
|
|
3595
|
+
const hint = earlyExitHint(tee.lines(), { installCommand: [installCmd, ...installArgv].join(" ") });
|
|
3596
|
+
if (hint) io2.err(` \u2192 ${hint.hint}`);
|
|
3597
|
+
}
|
|
3598
|
+
if (exit.signal) {
|
|
3599
|
+
deps2.raise(exit.signal);
|
|
3600
|
+
ctx.setExitCode(128 + (constants3.signals[exit.signal] ?? 0));
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
ctx.setExitCode(exit.code ?? 1);
|
|
3604
|
+
}
|
|
3605
|
+
async function prepareModule(ctx, f, waitTimeout, p) {
|
|
3606
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
3607
|
+
if (ctx.opts().json) {
|
|
3608
|
+
throw new QoreError("QORE_CONTEXT_INVALID", "dev in a module repository has no --json; the dev server's output passes through", {
|
|
3609
|
+
hint: "Drop --json."
|
|
3610
|
+
});
|
|
3611
|
+
}
|
|
3612
|
+
for (const [flag, set] of [["--only", f.only !== void 0], ["--skip", f.skip !== void 0], ["--kill-others", Boolean(f.killOthers)]]) {
|
|
3613
|
+
if (set) {
|
|
3614
|
+
throw new QoreError("QORE_CONTEXT_INVALID", `${flag} is for a workspace root`, {
|
|
3615
|
+
hint: `Drop ${flag}, or run qore dev in the workspace root (where qore.workspace.json is).`
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
const root = await findRepoRoot(deps2.cwd);
|
|
3620
|
+
if (!root) {
|
|
3621
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", "Not inside a module repository", {
|
|
3622
|
+
hint: "cd into a module repository, or run qore dev in a workspace root."
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
const c = await devContext(ctx, f, root);
|
|
3626
|
+
p.arm();
|
|
3627
|
+
const slug = c.module.slug;
|
|
3628
|
+
const cfg = await readRepoConfig(root);
|
|
3629
|
+
const pkg = await readPackageJson(root);
|
|
3630
|
+
const recipe = planRecipe({
|
|
3631
|
+
root,
|
|
3632
|
+
pkg,
|
|
3633
|
+
pm: await detectPackageManager(root, pkg),
|
|
3634
|
+
cfg,
|
|
3635
|
+
slug,
|
|
3636
|
+
flags: { install: f.install, services: f.services }
|
|
3637
|
+
});
|
|
3638
|
+
const runs = [await leftoverRun(deps2, root)].filter((r) => r !== null);
|
|
3639
|
+
const onLine = (l) => l.stream === "stdout" ? io2.out(l.line) : io2.err(l.line);
|
|
3640
|
+
const report = () => {
|
|
3641
|
+
};
|
|
3642
|
+
await checkPackageManager(deps2, recipe, () => {
|
|
3643
|
+
}, p.signal);
|
|
3644
|
+
await checkNode(deps2, recipe, pkg, slug, p.signal);
|
|
3645
|
+
await installIfNeeded(deps2, recipe, slug, onLine, report, p.signal);
|
|
3646
|
+
let dev;
|
|
3647
|
+
try {
|
|
3648
|
+
dev = await devEnv(deps2, {
|
|
3649
|
+
mc: c.online?.mc ?? null,
|
|
3650
|
+
app: c.online?.app ?? null,
|
|
3651
|
+
root,
|
|
3652
|
+
type: c.module.type,
|
|
3653
|
+
moduleId: c.module.id,
|
|
3654
|
+
cfg,
|
|
3655
|
+
random: () => randomBytes5(32).toString("hex")
|
|
3656
|
+
});
|
|
3657
|
+
} catch (e) {
|
|
3658
|
+
if (c.offlineCause && isOffline(e)) throw c.offlineCause;
|
|
3659
|
+
throw e;
|
|
3660
|
+
}
|
|
3661
|
+
const envFile = relative6(await realpath5(root), dev.envFile);
|
|
3662
|
+
if (dev.offline) io2.err(`! The portal is unreachable \u2014 starting from ${envFile} as last written; values may be stale.`);
|
|
3663
|
+
if (dev.kept.length) {
|
|
3664
|
+
io2.err(`! ${dev.kept.length} key(s) differ locally and were kept: ${dev.kept.join(", ")} \u2014 qore env pull --use-portal takes the portal's values`);
|
|
3665
|
+
}
|
|
3666
|
+
const env = { ...deps2.env, ...dev.env };
|
|
3667
|
+
await dockerRecovery(ctx, p.signal)({ cwd: root, env }, () => startServices(deps2, recipe, slug, env, dev.dbPort, waitTimeout * 1e3, p.signal, onLine, report));
|
|
3668
|
+
await checkPortOrStopLeftover(ctx, { slug, port: dev.port, key: "PORT", runs }, p.signal);
|
|
3669
|
+
if (c.online && !dev.offline) {
|
|
3670
|
+
const { mc, app } = c.online;
|
|
3671
|
+
const { id, name, type, access: access2, tenant, apps } = mc.module;
|
|
3672
|
+
await saveLastStart(deps2, root, {
|
|
3673
|
+
schemaVersion: 1,
|
|
3674
|
+
kind: "module",
|
|
3675
|
+
portal: mc.portal.apiOrigin,
|
|
3676
|
+
savedAt: deps2.now().toISOString(),
|
|
3677
|
+
module: {
|
|
3678
|
+
id,
|
|
3679
|
+
slug,
|
|
3680
|
+
name,
|
|
3681
|
+
type,
|
|
3682
|
+
access: access2,
|
|
3683
|
+
tenant: { id: tenant.id, slug: tenant.slug, name: tenant.name },
|
|
3684
|
+
apps: apps.map((a) => ({ id: a.id, slug: a.slug, name: a.name }))
|
|
3685
|
+
},
|
|
3686
|
+
app: { id: app.id, slug: app.slug, name: app.name },
|
|
3687
|
+
envFile
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
if (p.signal.aborted) throw new CancelledError();
|
|
3691
|
+
return { root, slug, recipe, env, port: dev.port };
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
// src/cli/commands/doctor.ts
|
|
3695
|
+
import pc3 from "picocolors";
|
|
3696
|
+
|
|
3697
|
+
// src/core/doctor/checks.ts
|
|
3698
|
+
import { realpath as realpath6 } from "fs/promises";
|
|
3699
|
+
import { basename as basename3, join as join17 } from "path";
|
|
3700
|
+
|
|
3701
|
+
// src/core/diagnostics/ssh.ts
|
|
3702
|
+
var SSH_TEST_ARGS = ["-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "git@github.com"];
|
|
3703
|
+
var COMMAND = "ssh -T git@github.com";
|
|
3704
|
+
function classifySshTest(r) {
|
|
3705
|
+
const text = r.tail.join("\n");
|
|
3706
|
+
const hi = /Hi ([^!\s]+)! You've successfully authenticated/.exec(text);
|
|
3707
|
+
if (hi) return { ok: true, user: hi[1] };
|
|
3708
|
+
const details = { protocol: "ssh" };
|
|
3709
|
+
if (r.spawnError) {
|
|
3710
|
+
return { ok: false, error: new QoreError("QORE_GIT_AUTH", "ssh is not installed or not on PATH", {
|
|
3711
|
+
hint: "Clone over https instead (the default for qore clone and qore sync).",
|
|
3712
|
+
details: { ...details, reason: "ssh-missing" }
|
|
3713
|
+
}) };
|
|
3714
|
+
}
|
|
3715
|
+
if (/Permission denied \(publickey\)/.test(text)) {
|
|
3716
|
+
return { ok: false, error: new QoreError("QORE_GIT_AUTH", "GitHub refused your SSH key", {
|
|
3717
|
+
hint: "Add your key to GitHub (https://github.com/settings/keys) and your ssh agent, or keep cloning over https.",
|
|
3718
|
+
details
|
|
3719
|
+
}) };
|
|
3720
|
+
}
|
|
3721
|
+
if (/Host key verification failed/.test(text)) {
|
|
3722
|
+
return { ok: false, error: new QoreError("QORE_GIT_AUTH", "ssh could not verify github.com's host key", {
|
|
3723
|
+
hint: `Run \`${COMMAND}\` once to accept its host key (or to see why it changed).`,
|
|
3724
|
+
details: { ...details, reason: "host-key" }
|
|
3725
|
+
}) };
|
|
3726
|
+
}
|
|
3727
|
+
const last = r.tail.map((l) => l.trim()).filter(Boolean).slice(-3);
|
|
3728
|
+
return { ok: false, error: new QoreError("QORE_GIT_AUTH", "GitHub SSH did not answer as expected", {
|
|
3729
|
+
hint: [...last, `\`${COMMAND}\``].join("\n"),
|
|
3730
|
+
details: { ...details, stderr: r.tail }
|
|
3731
|
+
}) };
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3734
|
+
// src/core/doctor/checks.ts
|
|
3735
|
+
var NODE_RANGE = ">=22";
|
|
3736
|
+
var SKEW_S = 60;
|
|
3737
|
+
var TOOL_TIMEOUT_MS = 1e4;
|
|
3738
|
+
var IDLE_DAYS = 30;
|
|
3739
|
+
var DAY_MS = 864e5;
|
|
3740
|
+
var UPGRADE = "npm i -g @quatronic/qore";
|
|
3741
|
+
var RANK = { ok: 0, warn: 1, fail: 2 };
|
|
3742
|
+
var noop = () => {
|
|
3743
|
+
};
|
|
3744
|
+
var ok = (label2, details) => ({ status: "ok", label: label2, ...details ? { details } : {} });
|
|
3745
|
+
var withError = (status2, label2, e) => ({ status: status2, label: label2, error: toJsonError(e) });
|
|
3746
|
+
var isCancel = (e, signal) => e instanceof CancelledError || signal.aborted;
|
|
3747
|
+
var portalLabel = (p) => p.builtin ?? p.apiOrigin;
|
|
3748
|
+
var ToolTimeout = class extends Error {
|
|
3749
|
+
constructor(tool, ms) {
|
|
3750
|
+
super(`${tool} did not answer within ${Math.round(ms / 1e3)} s`);
|
|
3751
|
+
this.tool = tool;
|
|
3752
|
+
this.ms = ms;
|
|
3753
|
+
}
|
|
3754
|
+
tool;
|
|
3755
|
+
ms;
|
|
3756
|
+
};
|
|
3757
|
+
async function timed(tool, signal, ms, fn) {
|
|
3758
|
+
try {
|
|
3759
|
+
return await fn(AbortSignal.any([signal, AbortSignal.timeout(ms)]));
|
|
3760
|
+
} catch (e) {
|
|
3761
|
+
if (e instanceof CancelledError && !signal.aborted) throw new ToolTimeout(tool, ms);
|
|
3762
|
+
throw e;
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
function errorLabel(e) {
|
|
3766
|
+
return e instanceof Error ? e.message : String(e);
|
|
3767
|
+
}
|
|
3768
|
+
function checkQore(deps2, meta) {
|
|
3769
|
+
const v = deps2.version;
|
|
3770
|
+
if (!meta.ok) return withError("warn", `qore ${v} \u2014 could not check for updates`, meta.error);
|
|
3771
|
+
const { minCliVersion: min, latestCliVersion: latest } = meta.meta;
|
|
3772
|
+
if (!v.endsWith("-dev") && compareVersions(v, min) < 0) {
|
|
3773
|
+
const e = new QoreError("QORE_UPGRADE_REQUIRED", `qore ${v} is too old for this portal (needs ${min})`, { hint: `Run: ${UPGRADE}` });
|
|
3774
|
+
return withError("fail", `qore ${v} is too old for this portal (needs ${min}) \u2014 ${UPGRADE}`, e);
|
|
3775
|
+
}
|
|
3776
|
+
if (!v.endsWith("-dev") && compareVersions(v, latest) < 0) {
|
|
3777
|
+
return { status: "warn", label: `qore ${v} \u2014 ${latest} is available (${UPGRADE})`, details: { latest } };
|
|
3778
|
+
}
|
|
3779
|
+
return ok(`qore ${v} (latest)`);
|
|
3780
|
+
}
|
|
3781
|
+
function checkOwnNode(deps2) {
|
|
3782
|
+
const v = deps2.nodeVersion;
|
|
3783
|
+
if (nodeSatisfies(v, NODE_RANGE) === false) {
|
|
3784
|
+
const e = new QoreError("QORE_NODE_VERSION", `qore needs Node ${NODE_RANGE}, found ${v}`, {
|
|
3785
|
+
hint: "nvm install 22 / fnm use 22, then reinstall qore.",
|
|
3786
|
+
details: { found: v, required: NODE_RANGE }
|
|
3787
|
+
});
|
|
3788
|
+
return withError("fail", e.message, e);
|
|
3789
|
+
}
|
|
3790
|
+
return ok(`Node ${v} (\u226522 required)`);
|
|
3791
|
+
}
|
|
3792
|
+
async function checkGit(deps2, signal, ms) {
|
|
3793
|
+
const res = await timed("git", signal, ms, (s) => runTool(deps2, "git", ["--version"], { cwd: deps2.home, env: deps2.env, onLine: noop, signal: s }));
|
|
3794
|
+
if (res.spawnError === "ENOENT") {
|
|
3795
|
+
const e = new QoreError("QORE_GIT_UNAVAILABLE", "git is not installed or not on PATH", {
|
|
3796
|
+
hint: "Install git (https://git-scm.com/downloads) and open a new terminal."
|
|
3797
|
+
});
|
|
3798
|
+
return withError("fail", e.message, e);
|
|
3799
|
+
}
|
|
3800
|
+
if (res.code !== 0) {
|
|
3801
|
+
const e = new QoreError("QORE_GIT_UNAVAILABLE", "git --version failed", { details: { stderr: res.tail } });
|
|
3802
|
+
return withError("fail", e.message, e);
|
|
3803
|
+
}
|
|
3804
|
+
const m = /git version (\d+)\.(\d+)/.exec(res.tail.join("\n"));
|
|
3805
|
+
return ok(m ? `git ${m[1]}.${m[2]}` : "git");
|
|
3806
|
+
}
|
|
3807
|
+
async function checkGithubSsh(deps2, signal, ms) {
|
|
3808
|
+
const res = await timed("ssh", signal, ms, (s) => runTool(deps2, "ssh", SSH_TEST_ARGS, { cwd: deps2.home, env: deps2.env, onLine: noop, signal: s }));
|
|
3809
|
+
const r = classifySshTest(res);
|
|
3810
|
+
if (r.ok) return ok(`GitHub SSH ok (${r.user})`, { user: r.user });
|
|
3811
|
+
return withError("warn", `GitHub SSH: ${r.error.message} (https still works)`, r.error);
|
|
3812
|
+
}
|
|
3813
|
+
async function checkDocker(deps2, signal, ms) {
|
|
3814
|
+
return timed("docker", signal, ms, async (s) => {
|
|
3815
|
+
const o = { cwd: deps2.home, env: deps2.env, onLine: noop, signal: s };
|
|
3816
|
+
const info = await runTool(deps2, "docker", ["info", "--format", "{{.ServerVersion}}"], o);
|
|
3817
|
+
const infoErr = classifyDockerInfo(info, deps2.platform);
|
|
3818
|
+
if (infoErr) return withError("warn", `${infoErr.message} (only needed for db:up)`, infoErr);
|
|
3819
|
+
const compose = await runTool(deps2, "docker", ["compose", "version"], o);
|
|
3820
|
+
return dockerBody(info.tail, compose);
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
3823
|
+
function dockerBody(infoTail, compose) {
|
|
3824
|
+
const composeErr = classifyComposeVersion(compose);
|
|
3825
|
+
if (composeErr) return withError("fail", composeErr.message, composeErr);
|
|
3826
|
+
const version = infoTail.map((l) => l.trim()).findLast(Boolean) ?? "";
|
|
3827
|
+
const major = /v?(\d+)\.\d+/.exec(compose.tail.join("\n"))?.[1];
|
|
3828
|
+
return ok(`Docker ${version} running \xB7 compose ${major ? `v${major}` : "ok"}`);
|
|
3829
|
+
}
|
|
3830
|
+
function jwtExp(token) {
|
|
3831
|
+
const payload = token.split(".")[1];
|
|
3832
|
+
if (!payload) return null;
|
|
3833
|
+
try {
|
|
3834
|
+
const exp = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).exp;
|
|
3835
|
+
return typeof exp === "number" && exp > 0 ? exp : null;
|
|
3836
|
+
} catch {
|
|
3837
|
+
return null;
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
function validDays(rt, now) {
|
|
3841
|
+
const exp = jwtExp(rt.rt);
|
|
3842
|
+
const end = exp !== null ? exp * 1e3 : Date.parse(rt.obtainedAt) + IDLE_DAYS * DAY_MS;
|
|
3843
|
+
if (Number.isNaN(end)) return null;
|
|
3844
|
+
return Math.max(0, Math.floor((end - now.getTime()) / DAY_MS));
|
|
3845
|
+
}
|
|
3846
|
+
async function checkSession(deps2, portal, onRequest, sessions, selected, signal) {
|
|
3847
|
+
try {
|
|
3848
|
+
const session = await openSession(deps2, portal, "doctor", onRequest);
|
|
3849
|
+
const me = await session.api.get("/api/cli/v1/me");
|
|
3850
|
+
sessions.set(portal.apiOrigin, session);
|
|
3851
|
+
const rt = await session.tokens.getRefresh();
|
|
3852
|
+
const days = rt ? validDays(rt, deps2.now()) : null;
|
|
3853
|
+
const valid = days === null ? "" : ` \xB7 session valid ${days} ${days === 1 ? "day" : "days"}`;
|
|
3854
|
+
return ok(`Logged in to ${portalLabel(portal)} as ${me.email}${valid}`, { email: me.email, ...days !== null ? { validDays: days } : {} });
|
|
3855
|
+
} catch (e) {
|
|
3856
|
+
if (isCancel(e, signal) || selected) throw e;
|
|
3857
|
+
return withError("warn", `${portalLabel(portal)}: ${errorLabel(e)}`, e);
|
|
3858
|
+
}
|
|
3859
|
+
}
|
|
3860
|
+
function checkClock(meta) {
|
|
3861
|
+
if (!meta.ok) return withError("warn", "Clock \u2014 could not check (the portal did not answer)", meta.error);
|
|
3862
|
+
if (!meta.date) return { status: "warn", label: "Clock \u2014 could not check (no Date header)" };
|
|
3863
|
+
const skew = Math.round((meta.date.getTime() - meta.fetchedAt.getTime()) / 1e3);
|
|
3864
|
+
if (Math.abs(skew) > SKEW_S) {
|
|
3865
|
+
const minutes = Math.max(1, Math.round(Math.abs(skew) / 60));
|
|
3866
|
+
const e = new QoreError("QORE_CLOCK_SKEW", `Your clock is off by ${minutes} min`, {
|
|
3867
|
+
hint: "Turn on automatic date and time.",
|
|
3868
|
+
details: { skewSeconds: skew }
|
|
3869
|
+
});
|
|
3870
|
+
return withError("fail", e.message, e);
|
|
3871
|
+
}
|
|
3872
|
+
return ok("Clock in sync", { skewSeconds: skew });
|
|
3873
|
+
}
|
|
3874
|
+
function ownerText(e) {
|
|
3875
|
+
const d = e.details ?? {};
|
|
3876
|
+
if (d.owner === "process") return ` by ${String(d.command)} (pid ${String(d.pid)})`;
|
|
3877
|
+
if (d.owner === "docker") return ` by the Docker container ${String(d.name)}`;
|
|
3878
|
+
if (d.owner === "qore") return ` by a previous qore dev (pid ${String(d.pid)})`;
|
|
3879
|
+
return "";
|
|
3880
|
+
}
|
|
3881
|
+
var sameDir = async (a, b) => await realpath6(a).catch(() => a) === await realpath6(b).catch(() => b);
|
|
3882
|
+
async function moduleBody(deps2, m, runs, signal) {
|
|
3883
|
+
const parts = [];
|
|
3884
|
+
const part = async (fn) => {
|
|
3885
|
+
try {
|
|
3886
|
+
parts.push(await fn());
|
|
3887
|
+
} catch (e) {
|
|
3888
|
+
if (isCancel(e, signal)) throw e;
|
|
3889
|
+
parts.push({ status: "fail", label: errorLabel(e), error: e });
|
|
3890
|
+
}
|
|
3891
|
+
};
|
|
3892
|
+
const cfg = await readRepoConfig(m.root);
|
|
3893
|
+
let env = null;
|
|
3894
|
+
await part(async () => {
|
|
3895
|
+
const target = await resolveTarget(m.root, cfg.envFile ?? moduleDefaults(m.type).envFile);
|
|
3896
|
+
const text = await readOrNull(target);
|
|
3897
|
+
if (text === null) return { status: "warn", label: "port unknown (run qore env pull)" };
|
|
3898
|
+
env = Object.fromEntries(parseEnv(text));
|
|
3899
|
+
const port = envPort(env, "PORT");
|
|
3900
|
+
if (port === null) return { status: "warn", label: `port unknown (no PORT in ${basename3(target)})` };
|
|
3901
|
+
if (!await deps2.ports.inUse(port)) return { status: "ok", label: `port ${port} free` };
|
|
3902
|
+
const e = await portInUseError(deps2, { slug: m.slug, port, key: "PORT", runs: await runs() });
|
|
3903
|
+
return { status: "fail", label: `port ${port} in use${ownerText(e)}`, error: e };
|
|
3904
|
+
});
|
|
3905
|
+
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` });
|
|
3906
|
+
let recipe;
|
|
3907
|
+
let pkg;
|
|
3908
|
+
await part(async () => {
|
|
3909
|
+
pkg = await readPackageJson(m.root);
|
|
3910
|
+
recipe = planRecipe({
|
|
3911
|
+
root: m.root,
|
|
3912
|
+
pkg,
|
|
3913
|
+
pm: await detectPackageManager(m.root, pkg),
|
|
3914
|
+
cfg,
|
|
3915
|
+
slug: m.slug,
|
|
3916
|
+
flags: { install: true, services: true }
|
|
3917
|
+
});
|
|
3918
|
+
if (recipe.services.length === 0) return { status: "ok", label: "db none" };
|
|
3919
|
+
const dbPort = env ? envPort(env, "DB_PORT") : null;
|
|
3920
|
+
if (dbPort === null) return { status: "warn", label: "db port unknown (run qore env pull)" };
|
|
3921
|
+
if (!await deps2.ports.inUse(dbPort)) return { status: "ok", label: `db:up (DB_PORT ${dbPort} free)` };
|
|
3922
|
+
const pub = await deps2.ports.dockerPublisher(dbPort);
|
|
3923
|
+
if (pub?.workingDir && await sameDir(pub.workingDir, m.root)) return { status: "ok", label: "db running" };
|
|
3924
|
+
const e = await portInUseError(deps2, { slug: m.slug, port: dbPort, key: "DB_PORT" });
|
|
3925
|
+
return { status: "fail", label: `DB_PORT ${dbPort} in use${ownerText(e)}`, error: e };
|
|
3926
|
+
});
|
|
3927
|
+
const status2 = parts.reduce((w, p) => RANK[p.status] > RANK[w] ? p.status : w, "ok");
|
|
3928
|
+
const shown = parts.filter((p) => p.status !== "ok");
|
|
3929
|
+
const first = parts.find((p) => p.status === "fail" && p.error) ?? parts.find((p) => p.error);
|
|
3930
|
+
return {
|
|
3931
|
+
recipe,
|
|
3932
|
+
pkg,
|
|
3933
|
+
body: {
|
|
3934
|
+
status: status2,
|
|
3935
|
+
label: (shown.length ? shown : parts).map((p) => p.label).join(" \xB7 "),
|
|
3936
|
+
...first ? { error: toJsonError(first.error) } : {},
|
|
3937
|
+
details: { parts: parts.map((p) => ({ status: p.status, label: p.label, ...p.error ? { error: toJsonError(p.error) } : {} })) }
|
|
3938
|
+
}
|
|
3939
|
+
};
|
|
3940
|
+
}
|
|
3941
|
+
function runsAt(deps2, root) {
|
|
3942
|
+
let p;
|
|
3943
|
+
return () => p ??= leftoverRun(deps2, root, { prune: false }).then((r) => r ? [r] : []);
|
|
3944
|
+
}
|
|
3945
|
+
var UNTRUSTED = { status: "warn", label: "not checked (repository not trusted)" };
|
|
3946
|
+
async function checkRepoPm(deps2, m, recipe, signal, ms) {
|
|
3947
|
+
if (!await isTrusted(deps2, m.root, m.id)) return UNTRUSTED;
|
|
3948
|
+
const found = await timed(recipe.pm.name, signal, ms, (s) => checkPackageManager(deps2, recipe, noop, s));
|
|
3949
|
+
const { name, version } = recipe.pm;
|
|
3950
|
+
if (found === null) return ok(`${name} (version unknown)`);
|
|
3951
|
+
return ok(version ? `${name} ${found} (matches)` : `${name} ${found}`, { found, ...version ? { pinned: version } : {} });
|
|
3952
|
+
}
|
|
3953
|
+
async function checkRepoNode(deps2, m, recipe, pkg, signal, ms) {
|
|
3954
|
+
if (!await isTrusted(deps2, m.root, m.id)) return UNTRUSTED;
|
|
3955
|
+
const found = await timed("node", signal, ms, (s) => checkNode(deps2, recipe, pkg, m.slug, s));
|
|
3956
|
+
return ok(`Node ${found ?? "?"} (engines ${pkg.engines.node})`);
|
|
3957
|
+
}
|
|
3958
|
+
async function loggedInPortals(deps2, profiles) {
|
|
3959
|
+
const rank = (o) => o === BUILTIN_PORTALS.prod.apiOrigin ? 0 : o === BUILTIN_PORTALS.staging.apiOrigin ? 1 : 2;
|
|
3960
|
+
const out = [];
|
|
3961
|
+
for (const origin of Object.keys(profiles).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))) {
|
|
3962
|
+
const p = profiles[origin];
|
|
3963
|
+
if (await new TokenStore(deps2.keychain, profileHash(p.apiOrigin, p.issuer)).getRefresh()) out.push(resolvePortal(origin, void 0));
|
|
3964
|
+
}
|
|
3965
|
+
return out;
|
|
3966
|
+
}
|
|
3967
|
+
async function resolveRepoModule(deps2, session, portal, meta, root) {
|
|
3968
|
+
if (meta.ok) requireCapability(meta.meta, "repo.resolve", portal.apiOrigin);
|
|
3969
|
+
const keys = [...new Set((await deps2.git.remotes(root)).map((r) => repoKey(r.url)).filter((k) => k !== null))];
|
|
3970
|
+
if (keys.length === 0) return { status: "warn", label: "this repository has no remote Qore can match" };
|
|
3971
|
+
const { matches } = await session.api.post("/api/cli/v1/repos/resolve", { repoKeys: keys });
|
|
3972
|
+
const ids = [...new Set(matches.map((x) => x.module.id))];
|
|
3973
|
+
if (ids.length === 0) return { status: "warn", label: `this repository matches no module on ${portalLabel(portal)}` };
|
|
3974
|
+
if (ids.length > 1) {
|
|
3975
|
+
return { status: "warn", label: `this repository matches several modules (${[...new Set(matches.map((x) => x.module.slug))].join(", ")})` };
|
|
3976
|
+
}
|
|
3977
|
+
return session.api.get(`/api/cli/v1/modules/${encodeURIComponent(ids[0])}`);
|
|
3978
|
+
}
|
|
3979
|
+
async function runDoctor(deps2, o = {}) {
|
|
3980
|
+
const signal = o.signal ?? new AbortController().signal;
|
|
3981
|
+
const toolMs = o.toolTimeoutMs ?? TOOL_TIMEOUT_MS;
|
|
3982
|
+
const results = [];
|
|
3983
|
+
const context = { kind: "none" };
|
|
3984
|
+
const push = async (id, group, fn, module) => {
|
|
3985
|
+
let body;
|
|
3986
|
+
try {
|
|
3987
|
+
body = await fn();
|
|
3988
|
+
} catch (e) {
|
|
3989
|
+
if (e instanceof ToolTimeout) body = { status: "warn", label: e.message, details: { timeoutMs: e.ms } };
|
|
3990
|
+
else if (isCancel(e, signal)) throw e;
|
|
3991
|
+
else body = withError("fail", errorLabel(e), e);
|
|
3992
|
+
}
|
|
3993
|
+
const r = { id, group, ...module ? { module } : {}, ...body };
|
|
3994
|
+
results.push(r);
|
|
3995
|
+
return r;
|
|
3996
|
+
};
|
|
3997
|
+
let cfg;
|
|
3998
|
+
try {
|
|
3999
|
+
cfg = await deps2.config.read();
|
|
4000
|
+
} catch (e) {
|
|
4001
|
+
if (isCancel(e, signal)) throw e;
|
|
4002
|
+
await push("config", "tools", () => withError("fail", errorLabel(e), e));
|
|
4003
|
+
cfg = { schemaVersion: 1, profiles: {} };
|
|
4004
|
+
}
|
|
4005
|
+
let ws = null;
|
|
4006
|
+
let wsError = null;
|
|
4007
|
+
try {
|
|
4008
|
+
ws = await findWorkspace(deps2.cwd, deps2.home);
|
|
4009
|
+
} catch (e) {
|
|
4010
|
+
wsError = e;
|
|
4011
|
+
}
|
|
4012
|
+
const portal = o.portal ? resolvePortal(o.portal, cfg.defaultProfile) : ws ? resolvePortal(ws.portal, void 0) : resolvePortal(void 0, cfg.defaultProfile);
|
|
4013
|
+
let meta;
|
|
4014
|
+
try {
|
|
4015
|
+
meta = { ok: true, ...await fetchMetaWithDate(deps2.fetch, portal.apiOrigin), fetchedAt: deps2.now() };
|
|
4016
|
+
} catch (e) {
|
|
4017
|
+
if (isCancel(e, signal)) throw e;
|
|
4018
|
+
meta = { ok: false, error: e };
|
|
4019
|
+
}
|
|
4020
|
+
await push("qore", "tools", () => checkQore(deps2, meta));
|
|
4021
|
+
await push("node", "tools", () => checkOwnNode(deps2));
|
|
4022
|
+
await push("git", "tools", () => checkGit(deps2, signal, toolMs));
|
|
4023
|
+
await push("github-ssh", "tools", () => checkGithubSsh(deps2, signal, toolMs));
|
|
4024
|
+
await push("docker", "tools", () => checkDocker(deps2, signal, toolMs));
|
|
4025
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
4026
|
+
let loggedIn = [];
|
|
4027
|
+
try {
|
|
4028
|
+
loggedIn = await loggedInPortals(deps2, cfg.profiles);
|
|
4029
|
+
if (loggedIn.length === 0) await push("session", "session", () => ({ status: "warn", label: "not logged in \u2014 qore login" }));
|
|
4030
|
+
} catch (e) {
|
|
4031
|
+
if (isCancel(e, signal)) throw e;
|
|
4032
|
+
await push("session", "session", () => {
|
|
4033
|
+
throw e;
|
|
4034
|
+
});
|
|
4035
|
+
}
|
|
4036
|
+
for (const p of loggedIn) {
|
|
4037
|
+
await push(
|
|
4038
|
+
`session:${portalLabel(p)}`,
|
|
4039
|
+
"session",
|
|
4040
|
+
() => checkSession(deps2, p, o.onRequest, sessions, p.apiOrigin === portal.apiOrigin, signal)
|
|
4041
|
+
);
|
|
4042
|
+
}
|
|
4043
|
+
await push("clock", "tools", () => checkClock(meta));
|
|
4044
|
+
const session = sessions.get(portal.apiOrigin);
|
|
4045
|
+
const notChecked = () => push(
|
|
4046
|
+
"modules",
|
|
4047
|
+
"workspace",
|
|
4048
|
+
() => ({ status: "warn", label: `modules not checked \u2014 no working session for ${portalLabel(portal)}` })
|
|
4049
|
+
);
|
|
4050
|
+
const perModule = async (m, prepared) => {
|
|
4051
|
+
if (!prepared?.recipe) return;
|
|
4052
|
+
const { recipe, pkg } = prepared;
|
|
4053
|
+
await push(`pm:${m.slug}`, "workspace", () => checkRepoPm(deps2, m, recipe, signal, toolMs), m.slug);
|
|
4054
|
+
if (pkg?.engines?.node) await push(`node:${m.slug}`, "workspace", () => checkRepoNode(deps2, m, recipe, pkg, signal, toolMs), m.slug);
|
|
4055
|
+
};
|
|
4056
|
+
if (wsError) {
|
|
4057
|
+
await push("context", "workspace", () => {
|
|
4058
|
+
throw wsError;
|
|
4059
|
+
});
|
|
4060
|
+
return { results, context };
|
|
4061
|
+
}
|
|
4062
|
+
const wsRoot = ws ? await workspaceRoot(deps2).catch(() => null) : null;
|
|
4063
|
+
const repoRoot = wsRoot ? null : await findRepoRoot(deps2.cwd);
|
|
4064
|
+
if (wsRoot) {
|
|
4065
|
+
context.kind = "workspace";
|
|
4066
|
+
context.label = basename3(wsRoot.root);
|
|
4067
|
+
if (!session) {
|
|
4068
|
+
await notChecked();
|
|
4069
|
+
return { results, context };
|
|
4070
|
+
}
|
|
4071
|
+
let am;
|
|
4072
|
+
try {
|
|
4073
|
+
if (meta.ok) requireCapability(meta.meta, "app.modules", portal.apiOrigin);
|
|
4074
|
+
am = await workspaceAppModules(session, portal, wsRoot);
|
|
4075
|
+
} catch (e) {
|
|
4076
|
+
await push("modules", "workspace", () => {
|
|
4077
|
+
throw e;
|
|
4078
|
+
});
|
|
4079
|
+
return { results, context };
|
|
4080
|
+
}
|
|
4081
|
+
context.label = `${am.app.slug} (${am.app.tenant.name} / ${am.app.name})`;
|
|
4082
|
+
const runs = runsAt(deps2, wsRoot.root);
|
|
4083
|
+
for (const m of am.modules) await workspaceModule(deps2, wsRoot, am, m, runs, signal, push, perModule);
|
|
4084
|
+
} else if (repoRoot) {
|
|
4085
|
+
context.kind = "module";
|
|
4086
|
+
context.label = basename3(repoRoot);
|
|
4087
|
+
if (!session) {
|
|
4088
|
+
await notChecked();
|
|
4089
|
+
return { results, context };
|
|
4090
|
+
}
|
|
4091
|
+
let d = null;
|
|
4092
|
+
try {
|
|
4093
|
+
const resolved = await resolveRepoModule(deps2, session, portal, meta, repoRoot);
|
|
4094
|
+
if ("status" in resolved) await push("module", "workspace", () => resolved);
|
|
4095
|
+
else d = resolved;
|
|
4096
|
+
} catch (e) {
|
|
4097
|
+
if (isCancel(e, signal)) throw e;
|
|
4098
|
+
await push("module", "workspace", () => {
|
|
4099
|
+
throw e;
|
|
4100
|
+
});
|
|
4101
|
+
}
|
|
4102
|
+
if (!d) return { results, context };
|
|
4103
|
+
context.label = `${d.tenant.name} / ${d.slug}`;
|
|
4104
|
+
const m = { root: repoRoot, slug: d.slug, type: d.type, id: d.id, access: d.access };
|
|
4105
|
+
const runs = runsAt(deps2, repoRoot);
|
|
4106
|
+
let prepared = null;
|
|
4107
|
+
await push(`module:${m.slug}`, "workspace", async () => (prepared = await moduleBody(deps2, m, runs, signal)).body, m.slug);
|
|
4108
|
+
await perModule(m, prepared);
|
|
4109
|
+
}
|
|
4110
|
+
return { results, context };
|
|
4111
|
+
}
|
|
4112
|
+
async function workspaceModule(deps2, ws, am, m, runs, signal, push, perModule) {
|
|
4113
|
+
if (!isSafeSlug(m.slug)) {
|
|
4114
|
+
await push(`module:${m.slug}`, "workspace", () => ({ status: "warn", label: "not a safe directory name \u2014 skipped" }), m.slug);
|
|
4115
|
+
return;
|
|
4116
|
+
}
|
|
4117
|
+
const mod = { root: join17(ws.root, m.slug), slug: m.slug, type: m.type, id: m.id, access: m.access };
|
|
4118
|
+
let prepared = null;
|
|
4119
|
+
await push(`module:${m.slug}`, "workspace", async () => {
|
|
4120
|
+
const info = await deps2.git.inspect(mod.root);
|
|
4121
|
+
const action = planWorkspace(ws.root, [m], { [m.slug]: info }, []).actions[0];
|
|
4122
|
+
if (action.kind === "clone") return { status: "warn", label: `not cloned \u2014 ${notClonedReason(m, am)}` };
|
|
4123
|
+
if (action.kind !== "present") return { status: "warn", label: action.reason ?? action.kind };
|
|
4124
|
+
prepared = await moduleBody(deps2, mod, runs, signal);
|
|
4125
|
+
return prepared.body;
|
|
4126
|
+
}, m.slug);
|
|
4127
|
+
await perModule(mod, prepared);
|
|
4128
|
+
}
|
|
4129
|
+
|
|
4130
|
+
// src/cli/commands/doctor.ts
|
|
4131
|
+
function registerDoctor(program, ctx) {
|
|
4132
|
+
program.command("doctor").description("Check qore's own health: tools, session, and this workspace or module").action(async () => {
|
|
4133
|
+
const { deps: deps2, io: io2, signal } = ctx;
|
|
4134
|
+
const g = ctx.opts();
|
|
4135
|
+
const report = await runDoctor(deps2, { portal: g.portal, onRequest: requestLog(ctx), signal });
|
|
4136
|
+
const failed = report.results.some((r) => r.status === "fail");
|
|
4137
|
+
if (g.json) {
|
|
4138
|
+
renderDoctorJson(io2, report);
|
|
4139
|
+
} else {
|
|
4140
|
+
renderDoctorHuman(io2, report, pc3.isColorSupported && g.color !== false);
|
|
4141
|
+
}
|
|
4142
|
+
ctx.setExitCode(failed ? 1 : 0);
|
|
4143
|
+
});
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
// src/cli/commands/env.ts
|
|
4147
|
+
import { randomBytes as randomBytes6 } from "crypto";
|
|
4148
|
+
import { chmod as chmod3, realpath as realpath7 } from "fs/promises";
|
|
4149
|
+
import { dirname as dirname6, join as join18, relative as relative7 } from "path";
|
|
4150
|
+
var maskLen = (v) => v === void 0 ? "(unset)" : `\u2022\u2022\u2022\u2022 (${v.length} chars)`;
|
|
4151
|
+
function registerEnv(program, ctx) {
|
|
4152
|
+
const env = program.command("env").description("Local environment");
|
|
4153
|
+
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) => {
|
|
4154
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
4155
|
+
const g = ctx.opts();
|
|
4156
|
+
const mc = await moduleContextFor(ctx, f, "env pull");
|
|
4157
|
+
if (!mc.repoRoot) {
|
|
4158
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", "env pull writes into a module repository", { hint: "cd into the module's repository." });
|
|
1923
4159
|
}
|
|
1924
4160
|
const app = await appFor(ctx, mc, f.app);
|
|
1925
4161
|
const cfg = await readRepoConfig(mc.repoRoot);
|
|
@@ -1927,7 +4163,7 @@ function registerEnv(program, ctx) {
|
|
|
1927
4163
|
const target = f.target ? await resolveTarget(mc.repoRoot, f.target, deps2.cwd) : await resolveTarget(mc.repoRoot, cfg.envFile ?? defaults.envFile);
|
|
1928
4164
|
await ensureTrusted(ctx, mc, app, { yes: f.yes });
|
|
1929
4165
|
const existing = await readOrNull(target);
|
|
1930
|
-
const example = existing === null ? await readOrNull(
|
|
4166
|
+
const example = existing === null ? await readOrNull(join18(dirname6(target), ".env.example")) : null;
|
|
1931
4167
|
const local = await fetchLocalEnv(deps2, mc, app);
|
|
1932
4168
|
const inputs = {
|
|
1933
4169
|
existing,
|
|
@@ -1936,7 +4172,7 @@ function registerEnv(program, ctx) {
|
|
|
1936
4172
|
managed: local.managedKeys,
|
|
1937
4173
|
localSecrets: cfg.localSecrets ?? defaults.localSecrets,
|
|
1938
4174
|
defaultChoice: f.usePortal ? "use-portal" : "keep",
|
|
1939
|
-
random: () =>
|
|
4175
|
+
random: () => randomBytes6(32).toString("hex")
|
|
1940
4176
|
};
|
|
1941
4177
|
let plan = planPull(inputs);
|
|
1942
4178
|
if (plan.conflicts.length && io2.interactive && !f.usePortal) {
|
|
@@ -1964,11 +4200,11 @@ function registerEnv(program, ctx) {
|
|
|
1964
4200
|
plan = planPull({ ...inputs, choices });
|
|
1965
4201
|
}
|
|
1966
4202
|
if (existing !== null && plan.text === existing) {
|
|
1967
|
-
await
|
|
4203
|
+
await chmod3(target, 384);
|
|
1968
4204
|
} else {
|
|
1969
4205
|
await writeSecurely(target, plan.text);
|
|
1970
4206
|
}
|
|
1971
|
-
const rel =
|
|
4207
|
+
const rel = relative7(await realpath7(mc.repoRoot), target);
|
|
1972
4208
|
if (g.json) {
|
|
1973
4209
|
const names = {
|
|
1974
4210
|
created: plan.created,
|
|
@@ -2029,8 +4265,9 @@ function mapOidcError(e, host) {
|
|
|
2029
4265
|
if (err === "access_denied") return new QoreError("QORE_LOGIN_DENIED", "The login was denied in the browser");
|
|
2030
4266
|
if (err === "expired_token") return new QoreError("QORE_LOGIN_EXPIRED", "The login code expired", { hint: "Run `qore login` again." });
|
|
2031
4267
|
if (err === "invalid_grant") return new QoreError("QORE_SESSION_EXPIRED", "Your session has ended", { hint: "Run `qore login`." });
|
|
2032
|
-
const
|
|
2033
|
-
|
|
4268
|
+
const res = e.code === "OAUTH_RESPONSE_IS_NOT_CONFORM" ? e.cause : void 0;
|
|
4269
|
+
const status2 = e.status ?? (res instanceof Response ? res.status : void 0);
|
|
4270
|
+
if (status2 && status2 >= 500) return new QoreError("QORE_AUTH_UNAVAILABLE", "Keycloak is unavailable", { status: status2, cause: e });
|
|
2034
4271
|
if (e instanceof TypeError) return unreachable(e, host);
|
|
2035
4272
|
const cause = e?.cause;
|
|
2036
4273
|
if (cause instanceof TypeError) return unreachable(cause, host);
|
|
@@ -2234,37 +4471,6 @@ function registerLogout(program, ctx) {
|
|
|
2234
4471
|
});
|
|
2235
4472
|
}
|
|
2236
4473
|
|
|
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
4474
|
// src/cli/format.ts
|
|
2269
4475
|
function ago(iso, now) {
|
|
2270
4476
|
if (!iso) return "\u2013";
|
|
@@ -2357,7 +4563,7 @@ function registerStatus(program, ctx) {
|
|
|
2357
4563
|
|
|
2358
4564
|
// src/cli/commands/sync.ts
|
|
2359
4565
|
import { lstat as lstat2, readdir } from "fs/promises";
|
|
2360
|
-
import { join as
|
|
4566
|
+
import { join as join19, relative as relative8 } from "path";
|
|
2361
4567
|
import { Option as Option2 } from "commander";
|
|
2362
4568
|
function asFailure2(slug, e) {
|
|
2363
4569
|
if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") {
|
|
@@ -2376,7 +4582,7 @@ async function strayRepos(root, slugs) {
|
|
|
2376
4582
|
for (const name of entries) {
|
|
2377
4583
|
if (slugs.has(name)) continue;
|
|
2378
4584
|
try {
|
|
2379
|
-
await lstat2(
|
|
4585
|
+
await lstat2(join19(root, name, ".git"));
|
|
2380
4586
|
names.push(name);
|
|
2381
4587
|
} catch {
|
|
2382
4588
|
}
|
|
@@ -2407,7 +4613,7 @@ function registerSync(program, ctx) {
|
|
|
2407
4613
|
continue;
|
|
2408
4614
|
}
|
|
2409
4615
|
try {
|
|
2410
|
-
dirs[m.slug] = await deps2.git.inspect(
|
|
4616
|
+
dirs[m.slug] = await deps2.git.inspect(join19(ws.root, m.slug));
|
|
2411
4617
|
modulesForPlan.push(m);
|
|
2412
4618
|
} catch (e) {
|
|
2413
4619
|
earlyFailures.push(asFailure2(m.slug, e));
|
|
@@ -2419,7 +4625,7 @@ function registerSync(program, ctx) {
|
|
|
2419
4625
|
} });
|
|
2420
4626
|
await syncPresent(deps2, applied, actions, Boolean(flags.pull), { app, protocol: flags.protocol });
|
|
2421
4627
|
for (const r of applied) {
|
|
2422
|
-
if (r.status === "cloned" && r.dir) r.dir =
|
|
4628
|
+
if (r.status === "cloned" && r.dir) r.dir = relative8(deps2.cwd, r.dir) || ".";
|
|
2423
4629
|
}
|
|
2424
4630
|
const removedResults = removed.map((slug) => ({ slug, status: "removed", reason: "not a module of this app \u2014 left in place" }));
|
|
2425
4631
|
const results = [...earlyFailures, ...applied, ...removedResults];
|
|
@@ -2543,20 +4749,22 @@ var COMMANDS = [
|
|
|
2543
4749
|
registerStatus,
|
|
2544
4750
|
registerEnv,
|
|
2545
4751
|
registerRun,
|
|
4752
|
+
registerDev,
|
|
2546
4753
|
registerClone,
|
|
2547
4754
|
registerSync,
|
|
2548
|
-
registerVersion
|
|
4755
|
+
registerVersion,
|
|
4756
|
+
registerDoctor
|
|
2549
4757
|
];
|
|
2550
4758
|
|
|
2551
4759
|
// src/cli/updateCheck.ts
|
|
2552
4760
|
var REGISTRY_URL = "https://registry.npmjs.org/@quatronic%2Fqore/latest";
|
|
2553
|
-
var
|
|
4761
|
+
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
2554
4762
|
var DEFAULT_TIMEOUT_MS = 1500;
|
|
2555
4763
|
async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
2556
4764
|
if (o.json || deps2.env.CI || deps2.env.QORE_NO_UPDATE_CHECK === "1" || !io2.interactive) return;
|
|
2557
4765
|
const cfg = await deps2.config.read();
|
|
2558
4766
|
const last = cfg.updateCheck?.lastCheckedAt;
|
|
2559
|
-
if (last && deps2.now().getTime() - new Date(last).getTime() <
|
|
4767
|
+
if (last && deps2.now().getTime() - new Date(last).getTime() < DAY_MS2) return;
|
|
2560
4768
|
const controller = new AbortController();
|
|
2561
4769
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2562
4770
|
let latest;
|
|
@@ -2580,8 +4788,8 @@ async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS)
|
|
|
2580
4788
|
}
|
|
2581
4789
|
|
|
2582
4790
|
// src/core/config.ts
|
|
2583
|
-
import { readFile as
|
|
2584
|
-
import { join as
|
|
4791
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
4792
|
+
import { join as join20 } from "path";
|
|
2585
4793
|
var empty = () => ({ schemaVersion: 1, profiles: {} });
|
|
2586
4794
|
var ConfigStore = class {
|
|
2587
4795
|
constructor(dir2) {
|
|
@@ -2589,12 +4797,12 @@ var ConfigStore = class {
|
|
|
2589
4797
|
}
|
|
2590
4798
|
dir;
|
|
2591
4799
|
get file() {
|
|
2592
|
-
return
|
|
4800
|
+
return join20(this.dir, "config.json");
|
|
2593
4801
|
}
|
|
2594
4802
|
async read() {
|
|
2595
4803
|
let raw;
|
|
2596
4804
|
try {
|
|
2597
|
-
raw = await
|
|
4805
|
+
raw = await readFile6(this.file, "utf8");
|
|
2598
4806
|
} catch (e) {
|
|
2599
4807
|
if (e.code === "ENOENT") return empty();
|
|
2600
4808
|
throw e;
|
|
@@ -2620,9 +4828,9 @@ var ConfigStore = class {
|
|
|
2620
4828
|
|
|
2621
4829
|
// src/platform/git.ts
|
|
2622
4830
|
import { execFile, execFileSync } from "child_process";
|
|
2623
|
-
import { mkdir as mkdir3, readdir as readdir2, stat as
|
|
4831
|
+
import { mkdir as mkdir3, readdir as readdir2, stat as stat3 } from "fs/promises";
|
|
2624
4832
|
import { homedir } from "os";
|
|
2625
|
-
import { dirname as
|
|
4833
|
+
import { dirname as dirname7, join as join21 } from "path";
|
|
2626
4834
|
function parseRemotes(stdout) {
|
|
2627
4835
|
const seen = /* @__PURE__ */ new Set();
|
|
2628
4836
|
const out = [];
|
|
@@ -2692,7 +4900,7 @@ async function remotes(repoRoot) {
|
|
|
2692
4900
|
return parseRemotes(r.stdout);
|
|
2693
4901
|
}
|
|
2694
4902
|
async function clone(url, dir2) {
|
|
2695
|
-
await mkdir3(
|
|
4903
|
+
await mkdir3(dirname7(dir2), { recursive: true });
|
|
2696
4904
|
const r = await runGit(["clone", "--", url, dir2]);
|
|
2697
4905
|
return toResult(r, "git clone");
|
|
2698
4906
|
}
|
|
@@ -2727,7 +4935,7 @@ async function status(repoRoot) {
|
|
|
2727
4935
|
}
|
|
2728
4936
|
async function statOrThrow(path) {
|
|
2729
4937
|
try {
|
|
2730
|
-
return await
|
|
4938
|
+
return await stat3(path);
|
|
2731
4939
|
} catch (err) {
|
|
2732
4940
|
if (err.code === "ENOENT") return void 0;
|
|
2733
4941
|
throw new QoreError("QORE_INTERNAL", `could not stat ${path}`, { cause: err });
|
|
@@ -2744,7 +4952,7 @@ async function inspect(dir2) {
|
|
|
2744
4952
|
throw new QoreError("QORE_INTERNAL", `could not read ${dir2}`, { cause: err });
|
|
2745
4953
|
}
|
|
2746
4954
|
const empty2 = entries.length === 0;
|
|
2747
|
-
const isRepo = await statOrThrow(
|
|
4955
|
+
const isRepo = await statOrThrow(join21(dir2, ".git")) !== void 0;
|
|
2748
4956
|
const dirRemotes = isRepo ? await remotes(dir2) : [];
|
|
2749
4957
|
return { exists: true, empty: empty2, isRepo, remotes: dirRemotes };
|
|
2750
4958
|
}
|
|
@@ -2806,13 +5014,13 @@ var OsKeychain = class {
|
|
|
2806
5014
|
|
|
2807
5015
|
// src/platform/lock.ts
|
|
2808
5016
|
import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
|
|
2809
|
-
import { join as
|
|
5017
|
+
import { join as join22 } from "path";
|
|
2810
5018
|
import lockfile from "proper-lockfile";
|
|
2811
5019
|
function fileLock(dir2) {
|
|
2812
5020
|
return async (name, fn) => {
|
|
2813
|
-
const locks =
|
|
5021
|
+
const locks = join22(dir2, "locks");
|
|
2814
5022
|
await mkdir4(locks, { recursive: true, mode: 448 });
|
|
2815
|
-
const target =
|
|
5023
|
+
const target = join22(locks, `${name}.lock`);
|
|
2816
5024
|
await writeFile4(target, "", { flag: "a" });
|
|
2817
5025
|
let release;
|
|
2818
5026
|
let compromised;
|
|
@@ -2852,17 +5060,25 @@ function signalsFor(platform) {
|
|
|
2852
5060
|
return platform === "win32" ? { ignore: ["SIGINT", "SIGBREAK"], forward: [] } : { ignore: ["SIGINT", "SIGQUIT"], forward: ["SIGTERM", "SIGHUP"] };
|
|
2853
5061
|
}
|
|
2854
5062
|
var { ignore: IGNORE, forward: FORWARD } = signalsFor(process.platform);
|
|
5063
|
+
var STDERR_DRAIN_MS = 500;
|
|
2855
5064
|
function makeSpawner(spawnFn, proc) {
|
|
2856
5065
|
return {
|
|
2857
|
-
run(cmd, args, env) {
|
|
5066
|
+
run(cmd, args, env, o) {
|
|
2858
5067
|
return new Promise((resolve5, reject) => {
|
|
2859
5068
|
let child;
|
|
2860
5069
|
try {
|
|
2861
|
-
child = spawnFn(cmd, args, {
|
|
5070
|
+
child = spawnFn(cmd, args, {
|
|
5071
|
+
stdio: o?.onStderr ? ["inherit", "inherit", "pipe"] : "inherit",
|
|
5072
|
+
shell: false,
|
|
5073
|
+
env,
|
|
5074
|
+
windowsHide: true,
|
|
5075
|
+
cwd: o?.cwd
|
|
5076
|
+
});
|
|
2862
5077
|
} catch (e) {
|
|
2863
5078
|
reject(new QoreError("QORE_INTERNAL", `Could not start ${cmd}: ${e.code ?? "error"}`));
|
|
2864
5079
|
return;
|
|
2865
5080
|
}
|
|
5081
|
+
if (o?.onStderr) child.stderr?.on("data", o.onStderr);
|
|
2866
5082
|
const ignore = () => {
|
|
2867
5083
|
};
|
|
2868
5084
|
const forwarders = FORWARD.map((sig) => [sig, () => {
|
|
@@ -2880,7 +5096,22 @@ function makeSpawner(spawnFn, proc) {
|
|
|
2880
5096
|
});
|
|
2881
5097
|
child.once("exit", (code, signal) => {
|
|
2882
5098
|
cleanup();
|
|
2883
|
-
|
|
5099
|
+
const stderr = child.stderr;
|
|
5100
|
+
if (!o?.onStderr || !stderr || stderr.readableEnded) {
|
|
5101
|
+
resolve5({ code, signal });
|
|
5102
|
+
return;
|
|
5103
|
+
}
|
|
5104
|
+
let settled = false;
|
|
5105
|
+
const finish = () => {
|
|
5106
|
+
if (settled) return;
|
|
5107
|
+
settled = true;
|
|
5108
|
+
clearTimeout(timer);
|
|
5109
|
+
resolve5({ code, signal });
|
|
5110
|
+
};
|
|
5111
|
+
const timer = setTimeout(finish, STDERR_DRAIN_MS);
|
|
5112
|
+
timer.unref?.();
|
|
5113
|
+
stderr.once("end", finish);
|
|
5114
|
+
stderr.once("close", finish);
|
|
2884
5115
|
});
|
|
2885
5116
|
});
|
|
2886
5117
|
}
|
|
@@ -2888,8 +5119,239 @@ function makeSpawner(spawnFn, proc) {
|
|
|
2888
5119
|
}
|
|
2889
5120
|
var crossSpawner = makeSpawner(spawn, process);
|
|
2890
5121
|
|
|
5122
|
+
// src/platform/launch.ts
|
|
5123
|
+
import spawn2 from "cross-spawn";
|
|
5124
|
+
function lineSplitter(stream, onLine) {
|
|
5125
|
+
let buf = "";
|
|
5126
|
+
return {
|
|
5127
|
+
push(chunk) {
|
|
5128
|
+
buf += chunk;
|
|
5129
|
+
let i;
|
|
5130
|
+
while ((i = buf.indexOf("\n")) >= 0) {
|
|
5131
|
+
onLine({ stream, line: buf.slice(0, i).replace(/\r$/, "") });
|
|
5132
|
+
buf = buf.slice(i + 1);
|
|
5133
|
+
}
|
|
5134
|
+
},
|
|
5135
|
+
flush() {
|
|
5136
|
+
if (buf) {
|
|
5137
|
+
onLine({ stream, line: buf.replace(/\r$/, "") });
|
|
5138
|
+
buf = "";
|
|
5139
|
+
}
|
|
5140
|
+
}
|
|
5141
|
+
};
|
|
5142
|
+
}
|
|
5143
|
+
var systemLauncher = {
|
|
5144
|
+
launch(cmd, args, o) {
|
|
5145
|
+
const posix2 = process.platform !== "win32";
|
|
5146
|
+
let child;
|
|
5147
|
+
try {
|
|
5148
|
+
child = spawn2(cmd, args, { cwd: o.cwd, env: o.env, stdio: ["ignore", "pipe", "pipe"], shell: false, detached: posix2, windowsHide: true });
|
|
5149
|
+
} catch (e) {
|
|
5150
|
+
return { pid: null, exited: Promise.resolve({ code: null, signal: null, spawnError: e.code ?? "error" }), stop() {
|
|
5151
|
+
} };
|
|
5152
|
+
}
|
|
5153
|
+
const out = lineSplitter("stdout", o.onLine);
|
|
5154
|
+
const err = lineSplitter("stderr", o.onLine);
|
|
5155
|
+
child.stdout?.setEncoding("utf8");
|
|
5156
|
+
child.stderr?.setEncoding("utf8");
|
|
5157
|
+
child.stdout?.on("data", (c) => out.push(c));
|
|
5158
|
+
child.stderr?.on("data", (c) => err.push(c));
|
|
5159
|
+
let closed = false;
|
|
5160
|
+
const exited = new Promise((resolve5) => {
|
|
5161
|
+
child.once("error", (e) => resolve5({ code: null, signal: null, spawnError: e.code ?? "error" }));
|
|
5162
|
+
child.once("close", (code, signal) => {
|
|
5163
|
+
closed = true;
|
|
5164
|
+
out.flush();
|
|
5165
|
+
err.flush();
|
|
5166
|
+
resolve5({ code, signal });
|
|
5167
|
+
});
|
|
5168
|
+
});
|
|
5169
|
+
return {
|
|
5170
|
+
pid: child.pid ?? null,
|
|
5171
|
+
exited,
|
|
5172
|
+
stop(signal) {
|
|
5173
|
+
if (child.pid === void 0 || closed) return;
|
|
5174
|
+
if (posix2) {
|
|
5175
|
+
try {
|
|
5176
|
+
process.kill(-child.pid, signal);
|
|
5177
|
+
} catch {
|
|
5178
|
+
}
|
|
5179
|
+
} else {
|
|
5180
|
+
spawn2(
|
|
5181
|
+
"taskkill",
|
|
5182
|
+
["/pid", String(child.pid), "/T", ...signal === "SIGKILL" ? ["/F"] : []],
|
|
5183
|
+
{ stdio: "ignore", windowsHide: true }
|
|
5184
|
+
).on("error", () => {
|
|
5185
|
+
});
|
|
5186
|
+
}
|
|
5187
|
+
}
|
|
5188
|
+
};
|
|
5189
|
+
},
|
|
5190
|
+
isAlive(pid) {
|
|
5191
|
+
try {
|
|
5192
|
+
process.kill(pid, 0);
|
|
5193
|
+
return true;
|
|
5194
|
+
} catch (e) {
|
|
5195
|
+
return e.code === "EPERM";
|
|
5196
|
+
}
|
|
5197
|
+
},
|
|
5198
|
+
signal(pid, sig) {
|
|
5199
|
+
try {
|
|
5200
|
+
process.kill(pid, sig);
|
|
5201
|
+
return true;
|
|
5202
|
+
} catch {
|
|
5203
|
+
return false;
|
|
5204
|
+
}
|
|
5205
|
+
}
|
|
5206
|
+
};
|
|
5207
|
+
|
|
5208
|
+
// src/platform/ports.ts
|
|
5209
|
+
import { execFile as execFile2 } from "child_process";
|
|
5210
|
+
import { connect } from "net";
|
|
5211
|
+
function accepts(host, port) {
|
|
5212
|
+
return new Promise((resolve5) => {
|
|
5213
|
+
const s = connect({ host, port });
|
|
5214
|
+
const done = (v) => {
|
|
5215
|
+
s.destroy();
|
|
5216
|
+
resolve5(v);
|
|
5217
|
+
};
|
|
5218
|
+
s.setTimeout(500, () => done(false));
|
|
5219
|
+
s.once("connect", () => done(true));
|
|
5220
|
+
s.once("error", () => done(false));
|
|
5221
|
+
});
|
|
5222
|
+
}
|
|
5223
|
+
function run2(cmd, args) {
|
|
5224
|
+
return new Promise((resolve5) => {
|
|
5225
|
+
execFile2(
|
|
5226
|
+
cmd,
|
|
5227
|
+
args,
|
|
5228
|
+
{ timeout: 2e3, env: { ...process.env, LC_ALL: "C" }, windowsHide: true },
|
|
5229
|
+
(e, stdout) => resolve5(e && !stdout ? null : String(stdout))
|
|
5230
|
+
);
|
|
5231
|
+
});
|
|
5232
|
+
}
|
|
5233
|
+
var systemPorts = {
|
|
5234
|
+
async inUse(port) {
|
|
5235
|
+
return await accepts("127.0.0.1", port) || await accepts("::1", port);
|
|
5236
|
+
},
|
|
5237
|
+
async waitFor(port, timeoutMs, signal) {
|
|
5238
|
+
const until = Date.now() + timeoutMs;
|
|
5239
|
+
while (!signal.aborted) {
|
|
5240
|
+
if (await this.inUse(port)) return true;
|
|
5241
|
+
if (Date.now() >= until) return false;
|
|
5242
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
5243
|
+
}
|
|
5244
|
+
return false;
|
|
5245
|
+
},
|
|
5246
|
+
async waitFree(port, timeoutMs, signal) {
|
|
5247
|
+
const until = Date.now() + timeoutMs;
|
|
5248
|
+
while (!signal.aborted) {
|
|
5249
|
+
if (!await this.inUse(port)) return true;
|
|
5250
|
+
if (Date.now() >= until) return false;
|
|
5251
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
5252
|
+
}
|
|
5253
|
+
return false;
|
|
5254
|
+
},
|
|
5255
|
+
async owner(port) {
|
|
5256
|
+
if (process.platform === "win32") {
|
|
5257
|
+
const out2 = await run2("netstat", ["-ano", "-p", "TCP"]);
|
|
5258
|
+
const line2 = out2?.split(/\r?\n/).find((l) => /LISTENING/.test(l) && new RegExp(`[:.]${port}\\s`).test(l));
|
|
5259
|
+
const pid2 = line2 ? Number(line2.trim().split(/\s+/).pop()) : NaN;
|
|
5260
|
+
if (!Number.isInteger(pid2) || pid2 <= 0) return null;
|
|
5261
|
+
const task = await run2("tasklist", ["/FI", `PID eq ${pid2}`, "/FO", "CSV", "/NH"]);
|
|
5262
|
+
return { pid: pid2, command: task?.split(",")[0]?.replace(/"/g, "").trim() || "unknown" };
|
|
5263
|
+
}
|
|
5264
|
+
const out = await run2("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fpc"]);
|
|
5265
|
+
const pid = Number(out?.match(/^p(\d+)$/m)?.[1]);
|
|
5266
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
5267
|
+
return { pid, command: out?.match(/^c(.+)$/m)?.[1] ?? "unknown" };
|
|
5268
|
+
},
|
|
5269
|
+
async ownerDetail(pid) {
|
|
5270
|
+
if (process.platform === "win32") return { cwd: null, ageSeconds: null };
|
|
5271
|
+
const lsof = await run2("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]);
|
|
5272
|
+
const ps = await run2("ps", ["-o", "etime=", "-p", String(pid)]);
|
|
5273
|
+
return { cwd: lsof ? parseLsofCwd(lsof) : null, ageSeconds: ps ? parseEtime(ps.trim()) : null };
|
|
5274
|
+
},
|
|
5275
|
+
async dockerPublisher(port) {
|
|
5276
|
+
const out = await run2("docker", [
|
|
5277
|
+
"ps",
|
|
5278
|
+
"--filter",
|
|
5279
|
+
`publish=${port}`,
|
|
5280
|
+
"--format",
|
|
5281
|
+
'{{.ID}} {{.Names}} {{.Label "com.docker.compose.project"}} {{.Label "com.docker.compose.project.working_dir"}}'
|
|
5282
|
+
]);
|
|
5283
|
+
return out ? parseDockerPs(out) : null;
|
|
5284
|
+
},
|
|
5285
|
+
async parentPid(pid) {
|
|
5286
|
+
if (process.platform === "win32") return null;
|
|
5287
|
+
const n = Number((await run2("ps", ["-o", "ppid=", "-p", String(pid)]))?.trim());
|
|
5288
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
5289
|
+
}
|
|
5290
|
+
};
|
|
5291
|
+
|
|
5292
|
+
// src/platform/proxy.ts
|
|
5293
|
+
import { execFile as execFile3 } from "child_process";
|
|
5294
|
+
|
|
5295
|
+
// src/core/diagnostics/proxy.ts
|
|
5296
|
+
function parseScutilProxy(stdout) {
|
|
5297
|
+
const value = (key) => stdout.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, "m"))?.[1]?.trim();
|
|
5298
|
+
if (value("HTTPSEnable") === "1") {
|
|
5299
|
+
const host = value("HTTPSProxy");
|
|
5300
|
+
const port = value("HTTPSPort");
|
|
5301
|
+
if (host && port) return `${host}:${port}`;
|
|
5302
|
+
}
|
|
5303
|
+
if (value("HTTPEnable") === "1") {
|
|
5304
|
+
const host = value("HTTPProxy");
|
|
5305
|
+
const port = value("HTTPPort");
|
|
5306
|
+
if (host && port) return `${host}:${port}`;
|
|
5307
|
+
}
|
|
5308
|
+
return null;
|
|
5309
|
+
}
|
|
5310
|
+
function parseRegProxy(stdout) {
|
|
5311
|
+
const enabled = /ProxyEnable\s+REG_DWORD\s+(0x[0-9a-fA-F]+)/.exec(stdout)?.[1];
|
|
5312
|
+
if (enabled?.toLowerCase() !== "0x1") return null;
|
|
5313
|
+
return /ProxyServer\s+REG_SZ\s+(\S+)/.exec(stdout)?.[1] ?? null;
|
|
5314
|
+
}
|
|
5315
|
+
|
|
5316
|
+
// src/platform/proxy.ts
|
|
5317
|
+
function run3(cmd, args) {
|
|
5318
|
+
return new Promise((resolve5) => {
|
|
5319
|
+
execFile3(
|
|
5320
|
+
cmd,
|
|
5321
|
+
args,
|
|
5322
|
+
{ timeout: 2e3, env: { ...process.env, LC_ALL: "C" }, windowsHide: true },
|
|
5323
|
+
(e, stdout) => resolve5(e && !stdout ? null : String(stdout))
|
|
5324
|
+
);
|
|
5325
|
+
});
|
|
5326
|
+
}
|
|
5327
|
+
async function detect(platform) {
|
|
5328
|
+
try {
|
|
5329
|
+
if (platform === "darwin") {
|
|
5330
|
+
const out = await run3("scutil", ["--proxy"]);
|
|
5331
|
+
return out ? parseScutilProxy(out) : null;
|
|
5332
|
+
}
|
|
5333
|
+
if (platform === "win32") {
|
|
5334
|
+
const key = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
|
|
5335
|
+
const [enable, server] = await Promise.all([
|
|
5336
|
+
run3("reg", ["query", key, "/v", "ProxyEnable"]),
|
|
5337
|
+
run3("reg", ["query", key, "/v", "ProxyServer"])
|
|
5338
|
+
]);
|
|
5339
|
+
const combined = [enable, server].filter(Boolean).join("\n");
|
|
5340
|
+
return combined ? parseRegProxy(combined) : null;
|
|
5341
|
+
}
|
|
5342
|
+
return null;
|
|
5343
|
+
} catch {
|
|
5344
|
+
return null;
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
5347
|
+
var cached;
|
|
5348
|
+
function systemProxy(platform = process.platform) {
|
|
5349
|
+
if (!cached) cached = detect(platform).catch(() => null);
|
|
5350
|
+
return cached;
|
|
5351
|
+
}
|
|
5352
|
+
|
|
2891
5353
|
// src/version.ts
|
|
2892
|
-
var VERSION = true ? "0.1.
|
|
5354
|
+
var VERSION = true ? "0.1.3" : "0.0.0-dev";
|
|
2893
5355
|
|
|
2894
5356
|
// src/cli.ts
|
|
2895
5357
|
if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY) setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
@@ -2900,7 +5362,10 @@ var json = preSeparatorArgv.includes("--json");
|
|
|
2900
5362
|
var ac = new AbortController();
|
|
2901
5363
|
process.on("SIGINT", () => {
|
|
2902
5364
|
if (ac.signal.aborted) {
|
|
2903
|
-
if (childRunning.current)
|
|
5365
|
+
if (childRunning.current) {
|
|
5366
|
+
childRunning.onInterrupt?.();
|
|
5367
|
+
return;
|
|
5368
|
+
}
|
|
2904
5369
|
process.exit(130);
|
|
2905
5370
|
}
|
|
2906
5371
|
ac.abort();
|
|
@@ -2916,15 +5381,22 @@ var deps = {
|
|
|
2916
5381
|
env: process.env,
|
|
2917
5382
|
platform: process.platform,
|
|
2918
5383
|
version: VERSION,
|
|
5384
|
+
nodeVersion: process.versions.node,
|
|
2919
5385
|
cwd: process.cwd(),
|
|
2920
5386
|
home: homedir2(),
|
|
2921
5387
|
git: systemGit,
|
|
2922
5388
|
spawn: crossSpawner,
|
|
5389
|
+
launch: systemLauncher,
|
|
5390
|
+
ports: systemPorts,
|
|
5391
|
+
signals: process,
|
|
2923
5392
|
raise: (signal) => {
|
|
2924
5393
|
process.removeAllListeners(signal);
|
|
2925
5394
|
process.kill(process.pid, signal);
|
|
2926
|
-
}
|
|
5395
|
+
},
|
|
5396
|
+
pid: process.pid,
|
|
5397
|
+
systemProxy: () => systemProxy(process.platform)
|
|
2927
5398
|
};
|
|
5399
|
+
var termination = preflightTermination(() => ac.abort(), deps.raise);
|
|
2928
5400
|
var io = processIo(isInteractive({
|
|
2929
5401
|
stdinTTY: Boolean(process.stdin.isTTY),
|
|
2930
5402
|
stdoutTTY: Boolean(process.stdout.isTTY),
|
|
@@ -2933,6 +5405,11 @@ var io = processIo(isInteractive({
|
|
|
2933
5405
|
env: process.env
|
|
2934
5406
|
}));
|
|
2935
5407
|
process.exitCode = await run(argv, deps, io, ac.signal, COMMANDS);
|
|
5408
|
+
await termination.reRaise(async () => {
|
|
5409
|
+
await Promise.all([process.stdout, process.stderr].map((s) => new Promise((r) => {
|
|
5410
|
+
s.write("", () => r());
|
|
5411
|
+
})));
|
|
5412
|
+
});
|
|
2936
5413
|
if (!ac.signal.aborted) {
|
|
2937
5414
|
try {
|
|
2938
5415
|
await maybeNotifyUpdate(deps, io, { json });
|