@quatronic/qore 0.1.1 → 0.1.2
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 +79 -0
- package/dist/cli.js +1048 -249
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -39,6 +39,11 @@ var EXIT = {
|
|
|
39
39
|
QORE_RESOURCE_NOT_FOUND: 5,
|
|
40
40
|
QORE_ACCESS_DENIED: 5,
|
|
41
41
|
QORE_NO_DEVELOPER_ACCESS: 5,
|
|
42
|
+
QORE_GITHUB_NOT_LINKED: 5,
|
|
43
|
+
QORE_REPO_NO_ACCESS: 5,
|
|
44
|
+
QORE_REPO_ACCESS_PENDING: 5,
|
|
45
|
+
QORE_GIT_AUTH: 5,
|
|
46
|
+
QORE_WORKSPACE_CONFLICT: 2,
|
|
42
47
|
QORE_AUTH_UNAVAILABLE: 6,
|
|
43
48
|
QORE_AUTH_BUSY: 6,
|
|
44
49
|
QORE_API_UNREACHABLE: 6,
|
|
@@ -125,7 +130,7 @@ function processIo(interactive) {
|
|
|
125
130
|
};
|
|
126
131
|
}
|
|
127
132
|
function createWaitForEnter(input, output) {
|
|
128
|
-
return (message, signal) => new Promise((
|
|
133
|
+
return (message, signal) => new Promise((resolve5, reject) => {
|
|
129
134
|
const rl = createInterface({ input, output, terminal: true });
|
|
130
135
|
const onAbort = () => {
|
|
131
136
|
rl.close();
|
|
@@ -134,7 +139,7 @@ function createWaitForEnter(input, output) {
|
|
|
134
139
|
const done = () => {
|
|
135
140
|
signal.removeEventListener("abort", onAbort);
|
|
136
141
|
rl.close();
|
|
137
|
-
|
|
142
|
+
resolve5();
|
|
138
143
|
};
|
|
139
144
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
140
145
|
rl.on("SIGINT", () => {
|
|
@@ -150,8 +155,8 @@ function createWaitForEnter(input, output) {
|
|
|
150
155
|
import { Command, CommanderError } from "commander";
|
|
151
156
|
|
|
152
157
|
// src/core/api/http.ts
|
|
153
|
-
function abortableFetch(
|
|
154
|
-
return (input, init) =>
|
|
158
|
+
function abortableFetch(fetch3, signal) {
|
|
159
|
+
return (input, init) => fetch3(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal });
|
|
155
160
|
}
|
|
156
161
|
var TLS = /* @__PURE__ */ new Set([
|
|
157
162
|
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
@@ -197,12 +202,12 @@ var BY_SERVER_CODE = {
|
|
|
197
202
|
"tenant.not_found": "QORE_RESOURCE_NOT_FOUND",
|
|
198
203
|
"module.app_ambiguous": "QORE_APP_AMBIGUOUS"
|
|
199
204
|
};
|
|
200
|
-
function byStatus(
|
|
201
|
-
if (
|
|
202
|
-
if (
|
|
203
|
-
if (
|
|
204
|
-
if (
|
|
205
|
-
if (
|
|
205
|
+
function byStatus(status2) {
|
|
206
|
+
if (status2 === 403) return "QORE_ACCESS_DENIED";
|
|
207
|
+
if (status2 === 404) return "QORE_RESOURCE_NOT_FOUND";
|
|
208
|
+
if (status2 === 426) return "QORE_UPGRADE_REQUIRED";
|
|
209
|
+
if (status2 === 429) return "QORE_RATE_LIMITED";
|
|
210
|
+
if (status2 === 502 || status2 === 503 || status2 === 504) return "QORE_API_UNAVAILABLE";
|
|
206
211
|
return "QORE_INTERNAL";
|
|
207
212
|
}
|
|
208
213
|
async function problemToError(res) {
|
|
@@ -269,7 +274,7 @@ function jsonFromArgv(argv2) {
|
|
|
269
274
|
return argvBeforeSeparator(argv2).some((a) => a === "--json" || a.startsWith("--json="));
|
|
270
275
|
}
|
|
271
276
|
function requestLog(ctx) {
|
|
272
|
-
return ctx.opts().verbose ? (
|
|
277
|
+
return ctx.opts().verbose ? (line2) => ctx.io.err(line2) : void 0;
|
|
273
278
|
}
|
|
274
279
|
function buildProgram(deps2, io2, signal, register, setExitCode) {
|
|
275
280
|
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()) });
|
|
@@ -301,55 +306,9 @@ async function run(argv2, deps2, io2, signal, register) {
|
|
|
301
306
|
}
|
|
302
307
|
}
|
|
303
308
|
|
|
304
|
-
// src/cli/commands/
|
|
305
|
-
import {
|
|
306
|
-
import {
|
|
307
|
-
import { basename as basename2, dirname as dirname3, join as join6, relative as relative2 } from "path";
|
|
308
|
-
|
|
309
|
-
// src/core/context/repoConfig.ts
|
|
310
|
-
import { readFile } from "fs/promises";
|
|
311
|
-
import { join } from "path";
|
|
312
|
-
var FORBIDDEN = ["portal", "api", "apiOrigin", "issuer", "tenant", "tenantId", "app", "appId", "module", "moduleId"];
|
|
313
|
-
var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
314
|
-
var invalid = (msg, details) => new QoreError("QORE_CONTEXT_INVALID", `.qore.json: ${msg}`, { hint: "Fix .qore.json in the repository root; see the $schema it names.", details });
|
|
315
|
-
function moduleDefaults(type) {
|
|
316
|
-
return type === "frontend" ? { envFile: ".env.local", localSecrets: ["SESSION_COOKIE_SECRET"] } : { envFile: ".env", localSecrets: [] };
|
|
317
|
-
}
|
|
318
|
-
async function readRepoConfig(root) {
|
|
319
|
-
let raw;
|
|
320
|
-
try {
|
|
321
|
-
raw = await readFile(join(root, ".qore.json"), "utf8");
|
|
322
|
-
} catch (e) {
|
|
323
|
-
if (e.code === "ENOENT") return {};
|
|
324
|
-
throw e;
|
|
325
|
-
}
|
|
326
|
-
let doc;
|
|
327
|
-
try {
|
|
328
|
-
doc = JSON.parse(raw);
|
|
329
|
-
} catch {
|
|
330
|
-
throw invalid("not valid JSON");
|
|
331
|
-
}
|
|
332
|
-
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw invalid("must be an object");
|
|
333
|
-
const v = doc.schemaVersion === void 0 ? 1 : doc.schemaVersion;
|
|
334
|
-
if (!Number.isInteger(v) || v < 1) throw invalid("schemaVersion must be a positive integer");
|
|
335
|
-
if (v > 1) {
|
|
336
|
-
throw new QoreError("QORE_CONTEXT_UNSUPPORTED", `.qore.json schemaVersion ${v} is newer than this qore`, { hint: "Run: npm i -g @quatronic/qore" });
|
|
337
|
-
}
|
|
338
|
-
for (const f of FORBIDDEN) if (f in doc) throw invalid(`"${f}" is not allowed \u2014 .qore.json never selects a portal or a resource`, { field: f });
|
|
339
|
-
const out = {};
|
|
340
|
-
if (doc.envFile !== void 0) {
|
|
341
|
-
if (typeof doc.envFile !== "string" || !doc.envFile) throw invalid("envFile must be a non-empty string");
|
|
342
|
-
out.envFile = doc.envFile;
|
|
343
|
-
}
|
|
344
|
-
if (doc.localSecrets !== void 0) {
|
|
345
|
-
if (!Array.isArray(doc.localSecrets) || !doc.localSecrets.every((k) => typeof k === "string" && KEY.test(k))) {
|
|
346
|
-
throw invalid("localSecrets must be a list of env key names");
|
|
347
|
-
}
|
|
348
|
-
if (new Set(doc.localSecrets).size !== doc.localSecrets.length) throw invalid("localSecrets must not contain duplicates");
|
|
349
|
-
out.localSecrets = doc.localSecrets;
|
|
350
|
-
}
|
|
351
|
-
return out;
|
|
352
|
-
}
|
|
309
|
+
// src/cli/commands/clone.ts
|
|
310
|
+
import { join as join6, relative as relative2, resolve as resolve2 } from "path";
|
|
311
|
+
import { Option } from "commander";
|
|
353
312
|
|
|
354
313
|
// src/core/api/meta.ts
|
|
355
314
|
var SUPPORTED = ["v1"];
|
|
@@ -365,10 +324,10 @@ function compareVersions(a, b) {
|
|
|
365
324
|
if (!pa[4] && pb[4]) return 1;
|
|
366
325
|
return 0;
|
|
367
326
|
}
|
|
368
|
-
async function fetchMeta(
|
|
327
|
+
async function fetchMeta(fetch3, apiOrigin) {
|
|
369
328
|
let res;
|
|
370
329
|
try {
|
|
371
|
-
res = await
|
|
330
|
+
res = await fetch3(`${apiOrigin}/api/cli/meta`, { headers: { accept: "application/json" } });
|
|
372
331
|
} catch (e) {
|
|
373
332
|
throw fetchError(e, new URL(apiOrigin).host);
|
|
374
333
|
}
|
|
@@ -436,44 +395,6 @@ function requireCapability(meta, capability, apiOrigin) {
|
|
|
436
395
|
});
|
|
437
396
|
}
|
|
438
397
|
|
|
439
|
-
// src/core/context/resolver.ts
|
|
440
|
-
import { join as join5 } from "path";
|
|
441
|
-
|
|
442
|
-
// src/core/auth/tokenStore.ts
|
|
443
|
-
var TokenStore = class {
|
|
444
|
-
constructor(kc, hash) {
|
|
445
|
-
this.kc = kc;
|
|
446
|
-
this.hash = hash;
|
|
447
|
-
}
|
|
448
|
-
kc;
|
|
449
|
-
hash;
|
|
450
|
-
async read(suffix) {
|
|
451
|
-
const raw = await this.kc.get(`${this.hash}:${suffix}`);
|
|
452
|
-
if (raw === null) return null;
|
|
453
|
-
try {
|
|
454
|
-
return JSON.parse(raw);
|
|
455
|
-
} catch {
|
|
456
|
-
return null;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
getRefresh() {
|
|
460
|
-
return this.read("refresh");
|
|
461
|
-
}
|
|
462
|
-
getAccess() {
|
|
463
|
-
return this.read("access");
|
|
464
|
-
}
|
|
465
|
-
setRefresh(v) {
|
|
466
|
-
return this.kc.set(`${this.hash}:refresh`, JSON.stringify(v));
|
|
467
|
-
}
|
|
468
|
-
setAccess(v) {
|
|
469
|
-
return this.kc.set(`${this.hash}:access`, JSON.stringify(v));
|
|
470
|
-
}
|
|
471
|
-
async clear() {
|
|
472
|
-
await this.kc.delete(`${this.hash}:access`);
|
|
473
|
-
await this.kc.delete(`${this.hash}:refresh`);
|
|
474
|
-
}
|
|
475
|
-
};
|
|
476
|
-
|
|
477
398
|
// src/core/profiles.ts
|
|
478
399
|
import { createHash } from "crypto";
|
|
479
400
|
var BUILTIN_PORTALS = {
|
|
@@ -623,6 +544,41 @@ var RefreshCoordinator = class {
|
|
|
623
544
|
}
|
|
624
545
|
};
|
|
625
546
|
|
|
547
|
+
// src/core/auth/tokenStore.ts
|
|
548
|
+
var TokenStore = class {
|
|
549
|
+
constructor(kc, hash) {
|
|
550
|
+
this.kc = kc;
|
|
551
|
+
this.hash = hash;
|
|
552
|
+
}
|
|
553
|
+
kc;
|
|
554
|
+
hash;
|
|
555
|
+
async read(suffix) {
|
|
556
|
+
const raw = await this.kc.get(`${this.hash}:${suffix}`);
|
|
557
|
+
if (raw === null) return null;
|
|
558
|
+
try {
|
|
559
|
+
return JSON.parse(raw);
|
|
560
|
+
} catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
getRefresh() {
|
|
565
|
+
return this.read("refresh");
|
|
566
|
+
}
|
|
567
|
+
getAccess() {
|
|
568
|
+
return this.read("access");
|
|
569
|
+
}
|
|
570
|
+
setRefresh(v) {
|
|
571
|
+
return this.kc.set(`${this.hash}:refresh`, JSON.stringify(v));
|
|
572
|
+
}
|
|
573
|
+
setAccess(v) {
|
|
574
|
+
return this.kc.set(`${this.hash}:access`, JSON.stringify(v));
|
|
575
|
+
}
|
|
576
|
+
async clear() {
|
|
577
|
+
await this.kc.delete(`${this.hash}:access`);
|
|
578
|
+
await this.kc.delete(`${this.hash}:refresh`);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
626
582
|
// src/core/session.ts
|
|
627
583
|
function loginHint(portal) {
|
|
628
584
|
return portal.builtin === "prod" ? "Run `qore login`." : `Run \`qore login --portal ${portal.builtin ?? portal.apiOrigin}\`.`;
|
|
@@ -646,17 +602,74 @@ async function openSession(deps2, portal, command, onRequest) {
|
|
|
646
602
|
return { profile, api, tokens, refresh };
|
|
647
603
|
}
|
|
648
604
|
|
|
605
|
+
// src/core/workspace/apps.ts
|
|
606
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
607
|
+
var tenantCandidates = (tenants) => tenants.map((t) => ({ kind: "tenant", id: t.id, slug: t.slug, name: t.name }));
|
|
608
|
+
async function resolveAppRef(deps2, session, arg, tenantFlag) {
|
|
609
|
+
if (UUID.test(arg)) return { appId: arg, tenant: null };
|
|
610
|
+
const me = await session.api.get("/api/cli/v1/me");
|
|
611
|
+
let tenant;
|
|
612
|
+
if (tenantFlag) {
|
|
613
|
+
const found = me.tenants.find((t) => t.slug === tenantFlag);
|
|
614
|
+
if (!found) {
|
|
615
|
+
throw new QoreError("QORE_RESOURCE_NOT_FOUND", `No tenant "${tenantFlag}" for this account`, {
|
|
616
|
+
candidates: tenantCandidates(me.tenants)
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
tenant = { id: found.id, slug: found.slug };
|
|
620
|
+
} else if (session.profile.activeTenant) {
|
|
621
|
+
const active = session.profile.activeTenant;
|
|
622
|
+
const found = me.tenants.find((t) => t.id === active.id);
|
|
623
|
+
if (!found) {
|
|
624
|
+
throw new QoreError("QORE_TENANT_ACCESS_LOST", `You no longer have developer access to ${active.slug}`, {
|
|
625
|
+
hint: "Run `qore tenant use <slug>`.",
|
|
626
|
+
candidates: tenantCandidates(me.tenants)
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
tenant = { id: found.id, slug: found.slug };
|
|
630
|
+
} else if (me.tenants.length === 1) {
|
|
631
|
+
tenant = { id: me.tenants[0].id, slug: me.tenants[0].slug };
|
|
632
|
+
} else {
|
|
633
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", `Which tenant is "${arg}" in?`, {
|
|
634
|
+
hint: "Run `qore tenant use <slug>`, or pass --tenant <slug>.",
|
|
635
|
+
candidates: tenantCandidates(me.tenants)
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
const { id } = await session.api.get(
|
|
640
|
+
`/api/cli/v1/tenants/${encodeURIComponent(tenant.id)}/apps/resolve?slug=${encodeURIComponent(arg)}`
|
|
641
|
+
);
|
|
642
|
+
return { appId: id, tenant };
|
|
643
|
+
} catch (e) {
|
|
644
|
+
if (e instanceof QoreError && e.code === "QORE_RESOURCE_NOT_FOUND" && !e.hint) {
|
|
645
|
+
throw new QoreError(e.code, e.message, {
|
|
646
|
+
hint: "Check the slug; `qore whoami` lists the apps you can see.",
|
|
647
|
+
status: e.status,
|
|
648
|
+
requestId: e.requestId,
|
|
649
|
+
candidates: e.candidates,
|
|
650
|
+
serverCode: e.serverCode,
|
|
651
|
+
details: e.details,
|
|
652
|
+
cause: e.cause
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
throw e;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/core/workspace/apply.ts
|
|
660
|
+
import { join as join4, relative } from "path";
|
|
661
|
+
|
|
649
662
|
// src/core/context/cache.ts
|
|
650
|
-
import { join as
|
|
663
|
+
import { join as join2 } from "path";
|
|
651
664
|
|
|
652
665
|
// src/core/jsonFile.ts
|
|
653
|
-
import { chmod, mkdir, readFile
|
|
654
|
-
import { join
|
|
666
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
667
|
+
import { join } from "path";
|
|
655
668
|
import { randomBytes } from "crypto";
|
|
656
669
|
async function readJson(file) {
|
|
657
670
|
let raw;
|
|
658
671
|
try {
|
|
659
|
-
raw = await
|
|
672
|
+
raw = await readFile(file, "utf8");
|
|
660
673
|
} catch (e) {
|
|
661
674
|
if (e.code === "ENOENT") return null;
|
|
662
675
|
throw e;
|
|
@@ -665,10 +678,10 @@ async function readJson(file) {
|
|
|
665
678
|
}
|
|
666
679
|
async function writeJsonAtomic(dir2, name, data) {
|
|
667
680
|
await mkdir(dir2, { recursive: true, mode: 448 });
|
|
668
|
-
const tmp =
|
|
681
|
+
const tmp = join(dir2, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
|
|
669
682
|
await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
|
|
670
683
|
await chmod(tmp, 384);
|
|
671
|
-
await rename(tmp,
|
|
684
|
+
await rename(tmp, join(dir2, name));
|
|
672
685
|
}
|
|
673
686
|
|
|
674
687
|
// src/core/context/cache.ts
|
|
@@ -682,7 +695,7 @@ var ResolutionCache = class {
|
|
|
682
695
|
now;
|
|
683
696
|
async load() {
|
|
684
697
|
try {
|
|
685
|
-
const f = await readJson(
|
|
698
|
+
const f = await readJson(join2(this.dir, "repos.json"));
|
|
686
699
|
if (f && f.schemaVersion === 1 && typeof f.entries === "object" && f.entries) return f;
|
|
687
700
|
} catch {
|
|
688
701
|
}
|
|
@@ -765,25 +778,589 @@ function repoKey(url) {
|
|
|
765
778
|
return `${host.toLowerCase()}/${owner.toLowerCase()}/${repo.toLowerCase()}`;
|
|
766
779
|
}
|
|
767
780
|
|
|
781
|
+
// src/core/trust.ts
|
|
782
|
+
import { realpath } from "fs/promises";
|
|
783
|
+
function trustKey(root, platform) {
|
|
784
|
+
return platform === "win32" ? root.toLowerCase() : root;
|
|
785
|
+
}
|
|
786
|
+
async function keyOf(deps2, root) {
|
|
787
|
+
let real = root;
|
|
788
|
+
try {
|
|
789
|
+
real = await realpath(root);
|
|
790
|
+
} catch (e) {
|
|
791
|
+
if (e.code !== "ENOENT") throw e;
|
|
792
|
+
}
|
|
793
|
+
return trustKey(real, deps2.platform);
|
|
794
|
+
}
|
|
795
|
+
async function isTrusted(deps2, root, moduleId) {
|
|
796
|
+
const cfg = await deps2.config.read();
|
|
797
|
+
return (cfg.trustedRepos?.[await keyOf(deps2, root)] ?? []).includes(moduleId);
|
|
798
|
+
}
|
|
799
|
+
async function trust(deps2, root, moduleId) {
|
|
800
|
+
const key = await keyOf(deps2, root);
|
|
801
|
+
await deps2.config.update((c) => {
|
|
802
|
+
const repos = c.trustedRepos ??= {};
|
|
803
|
+
const list = new Set(repos[key] ?? []);
|
|
804
|
+
list.add(moduleId);
|
|
805
|
+
repos[key] = [...list];
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// src/core/workspace/plan.ts
|
|
810
|
+
import { join as join3 } from "path";
|
|
811
|
+
var SEGMENT = /^[A-Za-z0-9_.-]+$/;
|
|
812
|
+
function isSupportedRepoUrl(repoUrl) {
|
|
813
|
+
let u;
|
|
814
|
+
try {
|
|
815
|
+
u = new URL(repoUrl);
|
|
816
|
+
} catch {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
if (u.protocol !== "https:" || !u.hostname || u.hostname.startsWith("-")) return false;
|
|
820
|
+
if (u.username || u.password || u.search || u.hash) return false;
|
|
821
|
+
const segments = u.pathname.replace(/\/+$/, "").split("/").slice(1);
|
|
822
|
+
return segments.length === 2 && segments.every((s) => SEGMENT.test(s) && !s.startsWith("-") && s !== "." && s !== "..");
|
|
823
|
+
}
|
|
824
|
+
function unsupportedRepoUrlError(slug, repoUrl) {
|
|
825
|
+
return new QoreError("QORE_INTERNAL", `${slug}: unsupported repository URL ${JSON.stringify(repoUrl)}`, {
|
|
826
|
+
hint: "This looks like a portal bug \u2014 please report it."
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
function cloneUrl(repoUrl, protocol) {
|
|
830
|
+
if (!isSupportedRepoUrl(repoUrl)) throw unsupportedRepoUrlError("module", repoUrl);
|
|
831
|
+
if (protocol === "https") return repoUrl;
|
|
832
|
+
const u = new URL(repoUrl);
|
|
833
|
+
return `git@${u.hostname}:${u.pathname.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "")}.git`;
|
|
834
|
+
}
|
|
835
|
+
function planWorkspace(root, modules, dirs, strayRepos2) {
|
|
836
|
+
const actions = modules.map((module) => {
|
|
837
|
+
const dir2 = join3(root, module.slug);
|
|
838
|
+
if (!module.repoUrl) return { module, dir: dir2, kind: "no-repo", reason: "no repository yet" };
|
|
839
|
+
if (!isSupportedRepoUrl(module.repoUrl)) return { module, dir: dir2, kind: "unsupported", reason: "unsupported repository URL" };
|
|
840
|
+
const info = dirs[module.slug] ?? { exists: false, empty: false, isRepo: false, remotes: [] };
|
|
841
|
+
if (!info.exists || info.empty) return { module, dir: dir2, kind: "clone" };
|
|
842
|
+
if (!info.isRepo) return { module, dir: dir2, kind: "conflict", reason: "not a git repository" };
|
|
843
|
+
const want = repoKey(module.repoUrl);
|
|
844
|
+
const origin = info.remotes.find((r) => r.name === "origin");
|
|
845
|
+
if (want !== null && origin && repoKey(origin.url) === want) return { module, dir: dir2, kind: "present" };
|
|
846
|
+
return { module, dir: dir2, kind: "conflict", reason: `re-pointed: ${origin?.url ?? "no origin remote"}`, origin: origin?.url ?? null };
|
|
847
|
+
});
|
|
848
|
+
const slugs = new Set(modules.map((m) => m.slug));
|
|
849
|
+
return { actions, removed: strayRepos2.filter((s) => !slugs.has(s)) };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// src/core/workspace/diagnose.ts
|
|
853
|
+
var ACCESS_CODES = /* @__PURE__ */ new Set(["QORE_GITHUB_NOT_LINKED", "QORE_REPO_NO_ACCESS", "QORE_REPO_ACCESS_PENDING"]);
|
|
854
|
+
var lastLines = (s, n) => s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(-n);
|
|
855
|
+
function classifyCloneFailure(stderr, m, app, protocol) {
|
|
856
|
+
const url = isSupportedRepoUrl(m.repoUrl) ? cloneUrl(m.repoUrl, protocol) : m.repoUrl;
|
|
857
|
+
const details = { module: m.slug, protocol, url };
|
|
858
|
+
if (/SAML SSO|single sign-on/i.test(stderr)) {
|
|
859
|
+
const link = /https:\/\/\S+/.exec(stderr)?.[0];
|
|
860
|
+
return new QoreError("QORE_GIT_AUTH", `GitHub wants SSO authorisation for ${m.slug}`, {
|
|
861
|
+
hint: `Authorise your ${protocol === "ssh" ? "SSH key" : "token"} for the organisation${link ? `: ${link}` : ""}.`,
|
|
862
|
+
details: { ...details, reason: "sso" }
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
if (/Host key verification failed/.test(stderr)) {
|
|
866
|
+
const host = /\bfor ([\w.-]+) and you have requested strict checking/.exec(stderr)?.[1] ?? "github.com";
|
|
867
|
+
return new QoreError("QORE_GIT_AUTH", `ssh could not verify ${host}'s host key (${m.slug})`, {
|
|
868
|
+
hint: `Run \`ssh -T git@${host}\` once to accept its host key (or to see why it changed), then run \`qore sync\`.`,
|
|
869
|
+
details: { ...details, protocol: "ssh", reason: "host-key" }
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
if (/Permission denied \(publickey\)/.test(stderr)) {
|
|
873
|
+
return new QoreError("QORE_GIT_AUTH", `GitHub refused your SSH key for ${m.slug}`, {
|
|
874
|
+
hint: "Check with `ssh -T git@github.com`, or clone over https: `--protocol https` after `gh auth login`.",
|
|
875
|
+
details: { ...details, protocol: "ssh" }
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
if (/could not read Username|Authentication failed|Invalid username or (password|token)/i.test(stderr)) {
|
|
879
|
+
return new QoreError("QORE_GIT_AUTH", `git has no GitHub credentials for ${m.slug}`, {
|
|
880
|
+
hint: "`gh auth login` sets up git credentials; or use `--protocol ssh`.",
|
|
881
|
+
details: { ...details, protocol: "https" }
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
if (/Repository not found|repository '.*' not found/i.test(stderr)) {
|
|
885
|
+
if (!m.githubLinked) {
|
|
886
|
+
return new QoreError("QORE_GITHUB_NOT_LINKED", `Your GitHub account is not linked in Qore (${m.slug})`, {
|
|
887
|
+
hint: `Link your GitHub account at ${app.accountUrl}, then run \`qore sync\`.`,
|
|
888
|
+
details
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
if (m.repoAccess === "none") {
|
|
892
|
+
const filtered = m.devTeamIds?.length ? app.devTeams.filter((t) => m.devTeamIds.includes(t.id)) : [];
|
|
893
|
+
const moduleTeams = filtered.length ? filtered : app.devTeams;
|
|
894
|
+
const teams = moduleTeams.map((t) => `"${t.name}"`).join(" or ");
|
|
895
|
+
const emails = [...new Set(moduleTeams.flatMap((t) => t.maintainers))].join(", ");
|
|
896
|
+
return new QoreError("QORE_REPO_NO_ACCESS", `No repository access to ${m.slug}`, {
|
|
897
|
+
hint: teams ? `Ask a maintainer of dev team ${teams} for a seat${emails ? `: ${emails}` : ""}.` : `Ask your tenant owner which dev team builds ${m.slug}.`,
|
|
898
|
+
details: { ...details, devTeams: moduleTeams.map((t) => t.name) }
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
const owner = repoKey(m.repoUrl)?.split("/")[1];
|
|
902
|
+
const invitation = owner ? ` https://github.com/orgs/${owner}/invitation` : "";
|
|
903
|
+
return new QoreError("QORE_REPO_ACCESS_PENDING", `GitHub does not grant ${m.slug} yet`, {
|
|
904
|
+
hint: `Access was granted recently, or your invitation to the GitHub organisation is not accepted yet:${invitation} \u2014 then retry in a minute.`,
|
|
905
|
+
details
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return new QoreError("QORE_INTERNAL", `Cloning ${m.slug} failed`, {
|
|
909
|
+
hint: [...lastLines(stderr, 3), `\`git clone ${url}\``].join("\n"),
|
|
910
|
+
details
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
function classifyPullFailure(stderr) {
|
|
914
|
+
if (/Not possible to fast-forward/i.test(stderr)) return "diverged";
|
|
915
|
+
return lastLines(stderr, 1)[0] ?? "pull failed";
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/core/workspace/apply.ts
|
|
919
|
+
function toFailure(slug, e) {
|
|
920
|
+
if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") {
|
|
921
|
+
return { slug, status: "failed", error: { code: e.code, message: e.message, hint: e.hint } };
|
|
922
|
+
}
|
|
923
|
+
throw e;
|
|
924
|
+
}
|
|
925
|
+
function conflictResult(deps2, action) {
|
|
926
|
+
const dir2 = relative(deps2.cwd, action.dir) || ".";
|
|
927
|
+
const what = action.reason === "not a git repository" ? "it is not a git repository" : action.origin ? `its origin is ${action.origin}, not ${action.module.repoUrl}` : "it has no origin remote";
|
|
928
|
+
const message = action.reason === "not a git repository" ? `${dir2} is not a git repository` : action.origin ? `${dir2} holds another repository (its origin is ${action.origin}, not ${action.module.repoUrl})` : `${dir2} has no origin remote`;
|
|
929
|
+
return {
|
|
930
|
+
slug: action.module.slug,
|
|
931
|
+
status: "conflict",
|
|
932
|
+
reason: action.reason,
|
|
933
|
+
error: { code: "QORE_WORKSPACE_CONFLICT", message, hint: `Move ${dir2} aside (${what}), then run \`qore sync\`.` }
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
async function applyActions(deps2, o) {
|
|
937
|
+
const cache = new ResolutionCache(join4(deps2.config.dir, "cache"), deps2.now);
|
|
938
|
+
const results = [];
|
|
939
|
+
for (const action of o.actions) {
|
|
940
|
+
let result;
|
|
941
|
+
switch (action.kind) {
|
|
942
|
+
case "present":
|
|
943
|
+
result = { slug: action.module.slug, status: "present" };
|
|
944
|
+
break;
|
|
945
|
+
case "conflict":
|
|
946
|
+
result = conflictResult(deps2, action);
|
|
947
|
+
break;
|
|
948
|
+
case "unsupported": {
|
|
949
|
+
const e = unsupportedRepoUrlError(action.module.slug, action.module.repoUrl ?? "");
|
|
950
|
+
result = { slug: action.module.slug, status: "failed", reason: action.reason, error: { code: e.code, message: e.message, hint: e.hint } };
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
case "no-repo":
|
|
954
|
+
result = { slug: action.module.slug, status: "no-repo", reason: action.reason };
|
|
955
|
+
break;
|
|
956
|
+
case "clone": {
|
|
957
|
+
try {
|
|
958
|
+
const url = cloneUrl(action.module.repoUrl, o.protocol);
|
|
959
|
+
const r = await deps2.git.clone(url, action.dir);
|
|
960
|
+
if (r.ok) {
|
|
961
|
+
await trust(deps2, action.dir, action.module.id);
|
|
962
|
+
const key = repoKey(action.module.repoUrl);
|
|
963
|
+
if (key) await cache.put(o.portal.apiOrigin, key, action.module.id);
|
|
964
|
+
result = { slug: action.module.slug, status: "cloned", dir: action.dir };
|
|
965
|
+
} else {
|
|
966
|
+
const err = classifyCloneFailure(r.stderr, action.module, o.app, o.protocol);
|
|
967
|
+
result = { slug: action.module.slug, status: "failed", error: { code: err.code, message: err.message, hint: err.hint } };
|
|
968
|
+
}
|
|
969
|
+
} catch (e) {
|
|
970
|
+
result = toFailure(action.module.slug, e);
|
|
971
|
+
}
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
results.push(result);
|
|
976
|
+
o.onResult(result);
|
|
977
|
+
}
|
|
978
|
+
return results;
|
|
979
|
+
}
|
|
980
|
+
async function syncPresent(deps2, results, actions, pull, ctx) {
|
|
981
|
+
const bySlug = new Map(actions.map((a) => [a.module.slug, a]));
|
|
982
|
+
for (const result of results) {
|
|
983
|
+
if (result.status !== "present") continue;
|
|
984
|
+
const action = bySlug.get(result.slug);
|
|
985
|
+
if (!action) continue;
|
|
986
|
+
let fetchRes;
|
|
987
|
+
try {
|
|
988
|
+
fetchRes = await deps2.git.fetch(action.dir);
|
|
989
|
+
} catch (e) {
|
|
990
|
+
Object.assign(result, toFailure(result.slug, e));
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
if (!fetchRes.ok) {
|
|
994
|
+
const err = classifyCloneFailure(fetchRes.stderr, action.module, ctx.app, ctx.protocol);
|
|
995
|
+
result.status = "failed";
|
|
996
|
+
result.error = { code: err.code, message: err.message, hint: err.hint };
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
result.status = "fetched";
|
|
1000
|
+
if (!pull) continue;
|
|
1001
|
+
let st;
|
|
1002
|
+
try {
|
|
1003
|
+
st = await deps2.git.status(action.dir);
|
|
1004
|
+
} catch (e) {
|
|
1005
|
+
Object.assign(result, toFailure(result.slug, e));
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
if (!st.ok) {
|
|
1009
|
+
result.status = "skipped";
|
|
1010
|
+
result.reason = classifyPullFailure(st.stderr);
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (st.dirty) {
|
|
1014
|
+
result.status = "skipped";
|
|
1015
|
+
result.reason = "uncommitted changes";
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
if (!st.upstream) {
|
|
1019
|
+
result.status = "skipped";
|
|
1020
|
+
result.reason = "no upstream branch";
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
let pullRes;
|
|
1024
|
+
try {
|
|
1025
|
+
pullRes = await deps2.git.pullFfOnly(action.dir);
|
|
1026
|
+
} catch (e) {
|
|
1027
|
+
Object.assign(result, toFailure(result.slug, e));
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (!pullRes.ok) {
|
|
1031
|
+
result.status = "skipped";
|
|
1032
|
+
result.reason = classifyPullFailure(pullRes.stderr);
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
result.status = "pulled";
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// src/core/workspace/file.ts
|
|
1040
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
1041
|
+
import { mkdir as mkdir2, readFile as readFile2, rename as rename2, rm, writeFile as writeFile2 } from "fs/promises";
|
|
1042
|
+
import { dirname, join as join5, resolve } from "path";
|
|
1043
|
+
var WORKSPACE_FILE = "qore.workspace.json";
|
|
1044
|
+
var UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1045
|
+
var invalid = (file, msg) => new QoreError("QORE_CONTEXT_INVALID", `${file}: ${msg}`, { hint: "Fix or delete the file; `qore clone <app>` writes a fresh one." });
|
|
1046
|
+
async function readAt(dir2) {
|
|
1047
|
+
const file = join5(dir2, WORKSPACE_FILE);
|
|
1048
|
+
let raw;
|
|
1049
|
+
try {
|
|
1050
|
+
raw = await readFile2(file, "utf8");
|
|
1051
|
+
} catch (e) {
|
|
1052
|
+
const code = e.code;
|
|
1053
|
+
if (code === "ENOENT" || code === "ENOTDIR") return null;
|
|
1054
|
+
throw new QoreError("QORE_CONTEXT_INVALID", `${file}: cannot be read (${code ?? "unknown error"})`, {
|
|
1055
|
+
hint: "Fix its permissions, or remove it; `qore clone <app>` writes a fresh one.",
|
|
1056
|
+
cause: e
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
let doc;
|
|
1060
|
+
try {
|
|
1061
|
+
doc = JSON.parse(raw);
|
|
1062
|
+
} catch {
|
|
1063
|
+
throw invalid(file, "not valid JSON");
|
|
1064
|
+
}
|
|
1065
|
+
const v = doc?.schemaVersion;
|
|
1066
|
+
if (!Number.isInteger(v) || v < 1) throw invalid(file, "schemaVersion must be a positive integer");
|
|
1067
|
+
if (v > 1) throw new QoreError("QORE_CONTEXT_UNSUPPORTED", `${file} is newer than this qore`, { hint: "Run: npm i -g @quatronic/qore" });
|
|
1068
|
+
if (typeof doc.portal !== "string" || !/^https?:\/\//.test(doc.portal)) throw invalid(file, "portal must be a portal URL");
|
|
1069
|
+
if (typeof doc.appId !== "string" || !UUID2.test(doc.appId)) throw invalid(file, "appId must be an app id");
|
|
1070
|
+
return { root: dir2, portal: new URL(doc.portal).origin.toLowerCase(), appId: doc.appId };
|
|
1071
|
+
}
|
|
1072
|
+
async function readWorkspace(dir2) {
|
|
1073
|
+
return readAt(dir2);
|
|
1074
|
+
}
|
|
1075
|
+
async function findWorkspace(start, home) {
|
|
1076
|
+
let dir2 = resolve(start);
|
|
1077
|
+
const stop = resolve(home);
|
|
1078
|
+
for (; ; ) {
|
|
1079
|
+
const ws = await readAt(dir2);
|
|
1080
|
+
if (ws) return ws;
|
|
1081
|
+
const up = dirname(dir2);
|
|
1082
|
+
if (dir2 === stop || up === dir2) return null;
|
|
1083
|
+
dir2 = up;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async function writeWorkspace(root, o) {
|
|
1087
|
+
await mkdir2(root, { recursive: true });
|
|
1088
|
+
const tmp = join5(root, `.${WORKSPACE_FILE}.${randomBytes2(6).toString("hex")}.tmp`);
|
|
1089
|
+
try {
|
|
1090
|
+
await writeFile2(tmp, JSON.stringify({ schemaVersion: 1, portal: o.portal, appId: o.appId }, null, 2) + "\n", { mode: 438 });
|
|
1091
|
+
await rename2(tmp, join5(root, WORKSPACE_FILE));
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
await rm(tmp, { force: true }).catch(() => {
|
|
1094
|
+
});
|
|
1095
|
+
throw new QoreError("QORE_INTERNAL", `could not write ${join5(root, WORKSPACE_FILE)}`, { cause: e });
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
async function workspacePortal(deps2, ws) {
|
|
1099
|
+
const portal = resolvePortal(ws.portal, void 0);
|
|
1100
|
+
const label2 = portal.builtin ?? portal.apiOrigin;
|
|
1101
|
+
const profile = (await deps2.config.read()).profiles[portal.apiOrigin];
|
|
1102
|
+
const loggedIn = profile && await new TokenStore(deps2.keychain, profileHash(profile.apiOrigin, profile.issuer)).getRefresh();
|
|
1103
|
+
if (!loggedIn) {
|
|
1104
|
+
throw new QoreError("QORE_NOT_LOGGED_IN", `This workspace belongs to ${label2}, which you are not logged in to`, {
|
|
1105
|
+
hint: `Run \`qore login --portal ${label2}\`.`
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
return portal;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// src/core/workspace/safety.ts
|
|
1112
|
+
var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/;
|
|
1113
|
+
var WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
|
|
1114
|
+
function isSafeSlug(slug) {
|
|
1115
|
+
return SAFE_SEGMENT.test(slug) && !WINDOWS_RESERVED.test(slug);
|
|
1116
|
+
}
|
|
1117
|
+
function unsafeSlugError(slug) {
|
|
1118
|
+
return new QoreError("QORE_INTERNAL", `"${slug}" is not a safe directory name`, {
|
|
1119
|
+
hint: "This looks like a portal bug \u2014 please report it."
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// src/cli/workspaceReport.ts
|
|
1124
|
+
function exitFor(results) {
|
|
1125
|
+
const failures = results.filter((r) => r.status === "failed" || r.status === "conflict");
|
|
1126
|
+
if (failures.length === 0) return null;
|
|
1127
|
+
const ok = results.some((r) => ["cloned", "present", "fetched", "pulled", "skipped"].includes(r.status));
|
|
1128
|
+
const code = (r) => r.status === "conflict" ? "QORE_WORKSPACE_CONFLICT" : r.error?.code ?? "QORE_INTERNAL";
|
|
1129
|
+
const nonAccess = failures.find((r) => !ACCESS_CODES.has(code(r)));
|
|
1130
|
+
if (ok && !nonAccess) return null;
|
|
1131
|
+
const first = nonAccess ?? failures[0];
|
|
1132
|
+
const moduleCount = results.filter((r) => r.status !== "removed").length;
|
|
1133
|
+
return new QoreError(code(first), `${failures.length} of ${moduleCount} modules need attention`, {
|
|
1134
|
+
hint: first.error?.hint,
|
|
1135
|
+
details: { results }
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
var FAILURE_LABELS = {
|
|
1139
|
+
QORE_GITHUB_NOT_LINKED: "GitHub not linked",
|
|
1140
|
+
QORE_REPO_NO_ACCESS: "no repository access",
|
|
1141
|
+
QORE_REPO_ACCESS_PENDING: "access pending",
|
|
1142
|
+
QORE_GIT_AUTH: "git authentication"
|
|
1143
|
+
};
|
|
1144
|
+
function firstSentenceLower(hint) {
|
|
1145
|
+
if (!hint) return "";
|
|
1146
|
+
let sentence = hint;
|
|
1147
|
+
const re = /\. (?=\S)/g;
|
|
1148
|
+
let m;
|
|
1149
|
+
while (m = re.exec(hint)) {
|
|
1150
|
+
const before = hint.slice(0, m.index);
|
|
1151
|
+
const backtickCount = (before.match(/`/g) ?? []).length;
|
|
1152
|
+
if (backtickCount % 2 === 0) {
|
|
1153
|
+
sentence = hint.slice(0, m.index + 1);
|
|
1154
|
+
break;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
sentence = sentence.trim();
|
|
1158
|
+
return sentence.length ? sentence[0].toLowerCase() + sentence.slice(1) : sentence;
|
|
1159
|
+
}
|
|
1160
|
+
function failedLine(r) {
|
|
1161
|
+
const label2 = FAILURE_LABELS[r.error?.code ?? ""] ?? "failed";
|
|
1162
|
+
const detail2 = firstSentenceLower(r.error?.hint);
|
|
1163
|
+
return `\u2717 ${r.slug} ${label2}${detail2 ? ` \u2014 ${detail2}` : ""}`;
|
|
1164
|
+
}
|
|
1165
|
+
function line(r, conflictCount) {
|
|
1166
|
+
switch (r.status) {
|
|
1167
|
+
case "cloned":
|
|
1168
|
+
return `\u2713 ${r.slug} cloned${r.dir ? ` \u2192 ${r.dir}` : ""}`;
|
|
1169
|
+
case "pulled":
|
|
1170
|
+
return `\u2713 ${r.slug} pulled`;
|
|
1171
|
+
case "present":
|
|
1172
|
+
return ` ${r.slug} present`;
|
|
1173
|
+
case "skipped":
|
|
1174
|
+
return `! ${r.slug} skipped \u2014 ${r.reason}`;
|
|
1175
|
+
case "no-repo":
|
|
1176
|
+
return ` ${r.slug} ${r.reason ?? "no repository yet"}`;
|
|
1177
|
+
case "removed":
|
|
1178
|
+
return `! ${r.slug} not a module of this app \u2014 left in place`;
|
|
1179
|
+
case "conflict": {
|
|
1180
|
+
const detail2 = conflictCount > 1 ? firstSentenceLower(r.error?.hint) : "";
|
|
1181
|
+
return `\u2717 ${r.slug} ${r.reason ?? "conflict"}${detail2 ? ` \u2014 ${detail2}` : ""}`;
|
|
1182
|
+
}
|
|
1183
|
+
case "failed":
|
|
1184
|
+
return failedLine(r);
|
|
1185
|
+
case "fetched":
|
|
1186
|
+
return ` ${r.slug} fetched`;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
function renderResults(io2, results, json2) {
|
|
1190
|
+
if (json2) {
|
|
1191
|
+
renderJson(io2, { results });
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const conflictCount = results.filter((r) => r.status === "conflict").length;
|
|
1195
|
+
let i = 0;
|
|
1196
|
+
while (i < results.length) {
|
|
1197
|
+
const r = results[i];
|
|
1198
|
+
if (r.status === "fetched" || r.status === "pulled") {
|
|
1199
|
+
const group = [r];
|
|
1200
|
+
let j = i + 1;
|
|
1201
|
+
while (j < results.length && results[j].status === r.status) {
|
|
1202
|
+
group.push(results[j]);
|
|
1203
|
+
j++;
|
|
1204
|
+
}
|
|
1205
|
+
io2.out(` ${group.map((g) => g.slug).join(", ")} ${r.status}`);
|
|
1206
|
+
i = j;
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
io2.out(line(r, conflictCount));
|
|
1210
|
+
i++;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
function reportWorkspaceRun(io2, results, json2) {
|
|
1214
|
+
const err = exitFor(results);
|
|
1215
|
+
if (!err || !json2) renderResults(io2, results, json2);
|
|
1216
|
+
return err;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// src/cli/commands/clone.ts
|
|
1220
|
+
function asFailure(slug, e) {
|
|
1221
|
+
if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") {
|
|
1222
|
+
return { slug, status: "failed", error: { code: e.code, message: e.message, hint: e.hint } };
|
|
1223
|
+
}
|
|
1224
|
+
throw e;
|
|
1225
|
+
}
|
|
1226
|
+
async function resolveApp(deps2, io2, session, appArg, tenantFlag) {
|
|
1227
|
+
try {
|
|
1228
|
+
return await resolveAppRef(deps2, session, appArg, tenantFlag);
|
|
1229
|
+
} catch (e) {
|
|
1230
|
+
if (!(e instanceof QoreError) || e.code !== "QORE_CONTEXT_REQUIRED" && e.code !== "QORE_TENANT_ACCESS_LOST") throw e;
|
|
1231
|
+
if (!io2.interactive || !e.candidates?.length) throw e;
|
|
1232
|
+
const slug = await io2.select("Select a tenant", e.candidates.map((c) => ({ name: c.name ?? c.slug, value: c.slug })));
|
|
1233
|
+
return resolveAppRef(deps2, session, appArg, slug);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
function registerClone(program, ctx) {
|
|
1237
|
+
program.command("clone").description("Clone an app's modules into a new workspace directory").argument("<app>", "app slug or id").argument("[dir]", "target directory (default: the app's slug)").option("--tenant <slug>", "tenant the app slug belongs to, when it is not your active one").addOption(new Option("--protocol <https|ssh>", "clone URL protocol").choices(["https", "ssh"]).default("https")).action(async (appArg, dirArg, flags) => {
|
|
1238
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
1239
|
+
const g = ctx.opts();
|
|
1240
|
+
const cfg = await deps2.config.read();
|
|
1241
|
+
const portal = resolvePortal(g.portal, cfg.defaultProfile);
|
|
1242
|
+
const onRequest = g.verbose ? (l) => io2.err(l) : void 0;
|
|
1243
|
+
const session = await openSession(deps2, portal, "clone", onRequest);
|
|
1244
|
+
await ensureCapability(deps2, portal.apiOrigin, "app.modules");
|
|
1245
|
+
const ref = await resolveApp(deps2, io2, session, appArg, flags.tenant);
|
|
1246
|
+
const app = await session.api.get(`/api/cli/v1/apps/${encodeURIComponent(ref.appId)}/modules`);
|
|
1247
|
+
if (dirArg === void 0 && !isSafeSlug(app.app.slug)) throw unsafeSlugError(app.app.slug);
|
|
1248
|
+
const dirLabel = dirArg ?? app.app.slug;
|
|
1249
|
+
const root = resolve2(deps2.cwd, dirLabel);
|
|
1250
|
+
const info = await deps2.git.inspect(root);
|
|
1251
|
+
if (info.exists && !info.empty) {
|
|
1252
|
+
const ws = await readWorkspace(root);
|
|
1253
|
+
if (!ws || ws.appId !== app.app.id || ws.portal !== portal.apiOrigin) {
|
|
1254
|
+
throw new QoreError("QORE_WORKSPACE_CONFLICT", `${root} exists and is not the workspace of ${app.app.slug}`, {
|
|
1255
|
+
hint: `Pick another directory: qore clone ${appArg} <dir>.`
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
await writeWorkspace(root, { portal: portal.apiOrigin, appId: app.app.id });
|
|
1260
|
+
const dirs = {};
|
|
1261
|
+
const modulesForPlan = [];
|
|
1262
|
+
const earlyFailures = [];
|
|
1263
|
+
for (const m of app.modules) {
|
|
1264
|
+
if (!isSafeSlug(m.slug)) {
|
|
1265
|
+
earlyFailures.push(asFailure(m.slug, unsafeSlugError(m.slug)));
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
dirs[m.slug] = await deps2.git.inspect(join6(root, m.slug));
|
|
1270
|
+
modulesForPlan.push(m);
|
|
1271
|
+
} catch (e) {
|
|
1272
|
+
earlyFailures.push(asFailure(m.slug, e));
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
const { actions } = planWorkspace(root, modulesForPlan, dirs, []);
|
|
1276
|
+
const applied = await applyActions(deps2, { portal, app, actions, protocol: flags.protocol, onResult: () => {
|
|
1277
|
+
} });
|
|
1278
|
+
for (const r of applied) {
|
|
1279
|
+
if (r.status === "cloned" && r.dir) r.dir = relative2(deps2.cwd, r.dir) || ".";
|
|
1280
|
+
}
|
|
1281
|
+
const results = [...earlyFailures, ...applied];
|
|
1282
|
+
const err = reportWorkspaceRun(io2, results, Boolean(g.json));
|
|
1283
|
+
if (!g.json) {
|
|
1284
|
+
io2.out(`\u2713 Wrote ${join6(dirLabel, "qore.workspace.json")}`);
|
|
1285
|
+
const first = results.find((r) => r.status === "cloned" || r.status === "present");
|
|
1286
|
+
if (first && !err) io2.out(`Next: cd ${join6(dirLabel, first.slug)} && qore env pull`);
|
|
1287
|
+
}
|
|
1288
|
+
if (err) throw err;
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// src/cli/commands/env.ts
|
|
1293
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
1294
|
+
import { chmod as chmod2, readFile as readFile4, realpath as realpath3, rename as rename3, unlink, writeFile as writeFile3 } from "fs/promises";
|
|
1295
|
+
import { basename as basename2, dirname as dirname4, join as join10, relative as relative5 } from "path";
|
|
1296
|
+
|
|
1297
|
+
// src/core/context/repoConfig.ts
|
|
1298
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1299
|
+
import { join as join7 } from "path";
|
|
1300
|
+
var FORBIDDEN = ["portal", "api", "apiOrigin", "issuer", "tenant", "tenantId", "app", "appId", "module", "moduleId"];
|
|
1301
|
+
var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
1302
|
+
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
|
+
function moduleDefaults(type) {
|
|
1304
|
+
return type === "frontend" ? { envFile: ".env.local", localSecrets: ["SESSION_COOKIE_SECRET"] } : { envFile: ".env", localSecrets: [] };
|
|
1305
|
+
}
|
|
1306
|
+
async function readRepoConfig(root) {
|
|
1307
|
+
let raw;
|
|
1308
|
+
try {
|
|
1309
|
+
raw = await readFile3(join7(root, ".qore.json"), "utf8");
|
|
1310
|
+
} catch (e) {
|
|
1311
|
+
if (e.code === "ENOENT") return {};
|
|
1312
|
+
throw e;
|
|
1313
|
+
}
|
|
1314
|
+
let doc;
|
|
1315
|
+
try {
|
|
1316
|
+
doc = JSON.parse(raw);
|
|
1317
|
+
} catch {
|
|
1318
|
+
throw invalid2("not valid JSON");
|
|
1319
|
+
}
|
|
1320
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw invalid2("must be an object");
|
|
1321
|
+
const v = doc.schemaVersion === void 0 ? 1 : doc.schemaVersion;
|
|
1322
|
+
if (!Number.isInteger(v) || v < 1) throw invalid2("schemaVersion must be a positive integer");
|
|
1323
|
+
if (v > 1) {
|
|
1324
|
+
throw new QoreError("QORE_CONTEXT_UNSUPPORTED", `.qore.json schemaVersion ${v} is newer than this qore`, { hint: "Run: npm i -g @quatronic/qore" });
|
|
1325
|
+
}
|
|
1326
|
+
for (const f of FORBIDDEN) if (f in doc) throw invalid2(`"${f}" is not allowed \u2014 .qore.json never selects a portal or a resource`, { field: f });
|
|
1327
|
+
const out = {};
|
|
1328
|
+
if (doc.envFile !== void 0) {
|
|
1329
|
+
if (typeof doc.envFile !== "string" || !doc.envFile) throw invalid2("envFile must be a non-empty string");
|
|
1330
|
+
out.envFile = doc.envFile;
|
|
1331
|
+
}
|
|
1332
|
+
if (doc.localSecrets !== void 0) {
|
|
1333
|
+
if (!Array.isArray(doc.localSecrets) || !doc.localSecrets.every((k) => typeof k === "string" && KEY.test(k))) {
|
|
1334
|
+
throw invalid2("localSecrets must be a list of env key names");
|
|
1335
|
+
}
|
|
1336
|
+
if (new Set(doc.localSecrets).size !== doc.localSecrets.length) throw invalid2("localSecrets must not contain duplicates");
|
|
1337
|
+
out.localSecrets = doc.localSecrets;
|
|
1338
|
+
}
|
|
1339
|
+
return out;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// src/core/context/resolver.ts
|
|
1343
|
+
import { isAbsolute, join as join9, relative as relative3 } from "path";
|
|
1344
|
+
|
|
768
1345
|
// src/core/context/repoRoot.ts
|
|
769
1346
|
import { stat } from "fs/promises";
|
|
770
|
-
import { dirname, join as
|
|
1347
|
+
import { dirname as dirname2, join as join8, resolve as resolve3 } from "path";
|
|
771
1348
|
async function findRepoRoot(start) {
|
|
772
|
-
let dir2 =
|
|
1349
|
+
let dir2 = resolve3(start);
|
|
773
1350
|
for (; ; ) {
|
|
774
1351
|
try {
|
|
775
|
-
await stat(
|
|
1352
|
+
await stat(join8(dir2, ".git"));
|
|
776
1353
|
return dir2;
|
|
777
1354
|
} catch {
|
|
778
1355
|
}
|
|
779
|
-
const up =
|
|
1356
|
+
const up = dirname2(dir2);
|
|
780
1357
|
if (up === dir2) return null;
|
|
781
1358
|
dir2 = up;
|
|
782
1359
|
}
|
|
783
1360
|
}
|
|
784
1361
|
|
|
785
1362
|
// src/core/context/resolver.ts
|
|
786
|
-
var
|
|
1363
|
+
var UUID3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
787
1364
|
var HINT_FLAG = "cd into a module repository, or pass --module <slug|id>.";
|
|
788
1365
|
var RESOLVE = "/api/cli/v1/repos/resolve";
|
|
789
1366
|
var SKIPPABLE = /* @__PURE__ */ new Set([
|
|
@@ -794,22 +1371,22 @@ var SKIPPABLE = /* @__PURE__ */ new Set([
|
|
|
794
1371
|
"QORE_SESSION_EXPIRED"
|
|
795
1372
|
]);
|
|
796
1373
|
var label = (p) => p.builtin ?? p.apiOrigin;
|
|
797
|
-
function byPreference(
|
|
1374
|
+
function byPreference(remotes2) {
|
|
798
1375
|
const rank = (r) => r.name === "origin" ? 0 : r.name === "upstream" ? 1 : 2;
|
|
799
|
-
return [...
|
|
1376
|
+
return [...remotes2].sort((a, b) => rank(a) - rank(b));
|
|
800
1377
|
}
|
|
801
|
-
var preferred = (
|
|
1378
|
+
var preferred = (remotes2) => byPreference(remotes2)[0];
|
|
802
1379
|
async function repoRemotes(deps2) {
|
|
803
1380
|
const root = await findRepoRoot(deps2.cwd);
|
|
804
1381
|
if (!root) return null;
|
|
805
|
-
let
|
|
1382
|
+
let remotes2;
|
|
806
1383
|
try {
|
|
807
|
-
|
|
1384
|
+
remotes2 = await deps2.git.remotes(root);
|
|
808
1385
|
} catch (e) {
|
|
809
1386
|
if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") return { root, keyed: [], gitError: e };
|
|
810
1387
|
throw e;
|
|
811
1388
|
}
|
|
812
|
-
const keyed =
|
|
1389
|
+
const keyed = remotes2.map((r) => ({ ...r, key: repoKey(r.url) })).filter((r) => r.key !== null);
|
|
813
1390
|
return { root, keyed };
|
|
814
1391
|
}
|
|
815
1392
|
function detail(session, id) {
|
|
@@ -841,13 +1418,28 @@ async function flagOverrides(deps2, session, portal, cache, moduleId) {
|
|
|
841
1418
|
return void 0;
|
|
842
1419
|
}
|
|
843
1420
|
}
|
|
844
|
-
async function
|
|
845
|
-
|
|
846
|
-
|
|
1421
|
+
async function flagPortal(deps2, portalArg, ws) {
|
|
1422
|
+
if (!portalArg && ws) return workspacePortal(deps2, ws);
|
|
1423
|
+
return resolvePortal(portalArg, (await deps2.config.read()).defaultProfile);
|
|
1424
|
+
}
|
|
1425
|
+
async function workspaceAppModules(session, portal, ws) {
|
|
1426
|
+
try {
|
|
1427
|
+
return await session.api.get(`/api/cli/v1/apps/${encodeURIComponent(ws.appId)}/modules`);
|
|
1428
|
+
} catch (e) {
|
|
1429
|
+
if (e instanceof QoreError && e.code === "QORE_RESOURCE_NOT_FOUND" && portal.apiOrigin !== ws.portal) {
|
|
1430
|
+
const p = resolvePortal(ws.portal, void 0);
|
|
1431
|
+
const name = p.builtin ?? p.apiOrigin;
|
|
1432
|
+
throw withDetails(e, { workspacePortal: p.apiOrigin }, `This workspace belongs to ${name}; drop --portal, or pass --portal ${name}.`);
|
|
1433
|
+
}
|
|
1434
|
+
throw e;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
async function byFlag(deps2, o, cache, ws) {
|
|
1438
|
+
const portal = await flagPortal(deps2, o.portal, ws);
|
|
847
1439
|
const session = await openSession(deps2, portal, o.command, o.onRequest);
|
|
848
1440
|
await ensureCapability(deps2, portal.apiOrigin, "repo.resolve");
|
|
849
1441
|
let id = o.module;
|
|
850
|
-
if (!
|
|
1442
|
+
if (!UUID3.test(id)) {
|
|
851
1443
|
let tenantId = o.tenant ?? session.profile.activeTenant?.id;
|
|
852
1444
|
if (!tenantId) {
|
|
853
1445
|
const me = await session.api.get("/api/cli/v1/me");
|
|
@@ -872,14 +1464,47 @@ async function byFlag(deps2, o, cache) {
|
|
|
872
1464
|
const module = await detail(session, id);
|
|
873
1465
|
const overrides = await flagOverrides(deps2, session, portal, cache, module.id);
|
|
874
1466
|
const repoRoot = await findRepoRoot(deps2.cwd);
|
|
875
|
-
return { portal, session, module, source: overrides ? { kind: "flag", overrides } : { kind: "flag" }, repoRoot };
|
|
1467
|
+
return { portal, session, module, source: overrides ? { kind: "flag", overrides } : { kind: "flag" }, repoRoot, workspace: ws };
|
|
1468
|
+
}
|
|
1469
|
+
var isBelow = (root, dir2) => {
|
|
1470
|
+
const rel = relative3(root, dir2);
|
|
1471
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
1472
|
+
};
|
|
1473
|
+
async function inWorkspaceRoot(deps2, ws) {
|
|
1474
|
+
if (!ws) return false;
|
|
1475
|
+
const repoRoot = await findRepoRoot(deps2.cwd);
|
|
1476
|
+
return repoRoot === null || isBelow(repoRoot, ws.root);
|
|
1477
|
+
}
|
|
1478
|
+
async function inWorkspace(deps2, o, ws, portal) {
|
|
1479
|
+
const session = await openSession(deps2, portal, o.command, o.onRequest);
|
|
1480
|
+
if (o.choose) {
|
|
1481
|
+
return { portal, session, module: await detail(session, o.choose), source: { kind: "workspace" }, repoRoot: null, workspace: ws };
|
|
1482
|
+
}
|
|
1483
|
+
await ensureCapability(deps2, portal.apiOrigin, "app.modules");
|
|
1484
|
+
const app = await workspaceAppModules(session, portal, ws);
|
|
1485
|
+
const candidates = app.modules.map((m) => ({
|
|
1486
|
+
kind: "module",
|
|
1487
|
+
id: m.id,
|
|
1488
|
+
slug: m.slug,
|
|
1489
|
+
name: m.name,
|
|
1490
|
+
tenant: app.app.tenant.slug,
|
|
1491
|
+
remote: "workspace"
|
|
1492
|
+
}));
|
|
1493
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", "In a workspace root \u2014 which module?", {
|
|
1494
|
+
hint: "cd into a module, or pass --module <slug>.",
|
|
1495
|
+
candidates
|
|
1496
|
+
});
|
|
876
1497
|
}
|
|
877
|
-
function
|
|
1498
|
+
async function workspaceRoot(deps2) {
|
|
1499
|
+
const ws = await readWorkspace(deps2.cwd);
|
|
1500
|
+
return ws && await inWorkspaceRoot(deps2, ws) ? ws : null;
|
|
1501
|
+
}
|
|
1502
|
+
function profileOrder(cfg, cachedOrigins, portalArg, wsPortal) {
|
|
878
1503
|
if (portalArg) return [resolvePortal(portalArg, cfg.defaultProfile)];
|
|
879
1504
|
const rank = (o) => o === BUILTIN_PORTALS.prod.apiOrigin ? 0 : o === BUILTIN_PORTALS.staging.apiOrigin ? 1 : 2;
|
|
880
1505
|
const rest = Object.keys(cfg.profiles).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
881
1506
|
const cached = cachedOrigins.filter((c) => cfg.profiles[c]);
|
|
882
|
-
const origins = [...cached, resolvePortal(void 0, cfg.defaultProfile).apiOrigin, ...rest];
|
|
1507
|
+
const origins = [...wsPortal ? [wsPortal.apiOrigin] : [], ...cached, resolvePortal(void 0, cfg.defaultProfile).apiOrigin, ...rest];
|
|
883
1508
|
return [...new Set(origins)].filter((c) => cfg.profiles[c]).map((c) => resolvePortal(c, void 0));
|
|
884
1509
|
}
|
|
885
1510
|
function withDetails(e, extra, hint) {
|
|
@@ -893,7 +1518,7 @@ function withDetails(e, extra, hint) {
|
|
|
893
1518
|
cause: e
|
|
894
1519
|
});
|
|
895
1520
|
}
|
|
896
|
-
async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, unsupported) {
|
|
1521
|
+
async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, workspace, unsupported) {
|
|
897
1522
|
const session = await openSession(deps2, portal, o.command, o.onRequest);
|
|
898
1523
|
try {
|
|
899
1524
|
await ensureCapability(deps2, portal.apiOrigin, "repo.resolve");
|
|
@@ -910,7 +1535,7 @@ async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, unsuppo
|
|
|
910
1535
|
if (hits.length > 0 && hits.every((h) => h.id === hits[0].id)) {
|
|
911
1536
|
const { remote: r, id: id2 } = hits[0];
|
|
912
1537
|
try {
|
|
913
|
-
return { portal, session, module: await detail(session, id2), source: { kind: "remote", remote: r.name, url: r.url }, repoRoot };
|
|
1538
|
+
return { portal, session, module: await detail(session, id2), source: { kind: "remote", remote: r.name, url: r.url }, repoRoot, workspace };
|
|
914
1539
|
} catch (e) {
|
|
915
1540
|
if (!(e instanceof QoreError) || e.code !== "QORE_RESOURCE_NOT_FOUND") throw e;
|
|
916
1541
|
for (const h of hits) await cache.drop(portal.apiOrigin, h.remote.key);
|
|
@@ -938,7 +1563,7 @@ async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, unsuppo
|
|
|
938
1563
|
const matchedKeys = new Set(matches.filter((m) => m.module.id === id).map((m) => m.repoKey));
|
|
939
1564
|
if (ids.length === 1) for (const k of matchedKeys) await cache.put(portal.apiOrigin, k, id);
|
|
940
1565
|
const remote = preferred(keyed.filter((r) => matchedKeys.has(r.key)));
|
|
941
|
-
return { portal, session, module: await detail(session, id), source: { kind: "remote", remote: remote.name, url: remote.url }, repoRoot };
|
|
1566
|
+
return { portal, session, module: await detail(session, id), source: { kind: "remote", remote: remote.name, url: remote.url }, repoRoot, workspace };
|
|
942
1567
|
}
|
|
943
1568
|
async function anyLoggedIn(deps2, cfg) {
|
|
944
1569
|
for (const p of Object.values(cfg.profiles)) {
|
|
@@ -947,13 +1572,16 @@ async function anyLoggedIn(deps2, cfg) {
|
|
|
947
1572
|
return false;
|
|
948
1573
|
}
|
|
949
1574
|
async function resolveModuleContext(deps2, o) {
|
|
950
|
-
const cache = new ResolutionCache(
|
|
951
|
-
|
|
1575
|
+
const cache = new ResolutionCache(join9(deps2.config.dir, "cache"), deps2.now);
|
|
1576
|
+
const ws = await findWorkspace(deps2.cwd, deps2.home);
|
|
1577
|
+
if (o.module) return byFlag(deps2, o, cache, ws);
|
|
952
1578
|
const cfg = await deps2.config.read();
|
|
953
1579
|
const first = resolvePortal(o.portal, cfg.defaultProfile);
|
|
1580
|
+
const wsPortal = ws && !o.portal ? await workspacePortal(deps2, ws) : void 0;
|
|
954
1581
|
if (o.portal || !await anyLoggedIn(deps2, cfg)) {
|
|
955
1582
|
await openSession(deps2, first, o.command);
|
|
956
1583
|
}
|
|
1584
|
+
if (ws && await inWorkspaceRoot(deps2, ws)) return inWorkspace(deps2, o, ws, wsPortal ?? first);
|
|
957
1585
|
const repo = await repoRemotes(deps2);
|
|
958
1586
|
if (repo === null) throw new QoreError("QORE_CONTEXT_REQUIRED", "Not inside a module repository", { hint: HINT_FLAG });
|
|
959
1587
|
const { root, keyed, gitError } = repo;
|
|
@@ -971,11 +1599,11 @@ async function resolveModuleContext(deps2, o) {
|
|
|
971
1599
|
const skipped = [];
|
|
972
1600
|
const skipErrors = [];
|
|
973
1601
|
let unsupported;
|
|
974
|
-
for (const portal of profileOrder(cfg, await cache.originsFor(keys), o.portal)) {
|
|
1602
|
+
for (const portal of profileOrder(cfg, await cache.originsFor(keys), o.portal, wsPortal)) {
|
|
975
1603
|
tried.push(label(portal));
|
|
976
1604
|
let capabilityMissing = false;
|
|
977
1605
|
try {
|
|
978
|
-
const mc = await onProfile(deps2, o, cache, portal, keyed, keys, root, (e) => {
|
|
1606
|
+
const mc = await onProfile(deps2, o, cache, portal, keyed, keys, root, ws, (e) => {
|
|
979
1607
|
capabilityMissing = true;
|
|
980
1608
|
unsupported ??= e;
|
|
981
1609
|
skipped.push({ profile: label(portal), code: e.code, message: e.message });
|
|
@@ -1015,6 +1643,8 @@ function pickApp(mc, app) {
|
|
|
1015
1643
|
}
|
|
1016
1644
|
if (apps.length === 1) return apps[0];
|
|
1017
1645
|
if (apps.length === 0) throw new QoreError("QORE_RESOURCE_NOT_FOUND", `${mc.module.slug} is not part of an app you can read`);
|
|
1646
|
+
const wsApp = mc.workspace && apps.find((a) => a.id === mc.workspace.appId);
|
|
1647
|
+
if (wsApp) return wsApp;
|
|
1018
1648
|
throw new QoreError("QORE_APP_AMBIGUOUS", `${mc.module.slug} is part of several apps`, { hint: "Pass --app <slug>.", candidates: appCandidates(apps) });
|
|
1019
1649
|
}
|
|
1020
1650
|
|
|
@@ -1042,8 +1672,8 @@ async function fetchLocalEnv(deps2, mc, app) {
|
|
|
1042
1672
|
// src/core/env/merge.ts
|
|
1043
1673
|
function parseEnv(contents) {
|
|
1044
1674
|
const map = /* @__PURE__ */ new Map();
|
|
1045
|
-
for (const
|
|
1046
|
-
const m =
|
|
1675
|
+
for (const line2 of contents.split("\n")) {
|
|
1676
|
+
const m = line2.replace(/\r$/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
1047
1677
|
if (m?.[1] !== void 0 && m[2] !== void 0) map.set(m[1], m[2]);
|
|
1048
1678
|
}
|
|
1049
1679
|
return map;
|
|
@@ -1095,11 +1725,11 @@ function planEnvUpdate({
|
|
|
1095
1725
|
function renderEnv(original, merged) {
|
|
1096
1726
|
const seen = /* @__PURE__ */ new Set();
|
|
1097
1727
|
const lines = original.split("\n");
|
|
1098
|
-
const out = lines.map((
|
|
1099
|
-
const m =
|
|
1100
|
-
if (!m?.[1]) return
|
|
1728
|
+
const out = lines.map((line2) => {
|
|
1729
|
+
const m = line2.replace(/\r$/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
1730
|
+
if (!m?.[1]) return line2;
|
|
1101
1731
|
const key = m[1];
|
|
1102
|
-
if (!merged.has(key)) return
|
|
1732
|
+
if (!merged.has(key)) return line2;
|
|
1103
1733
|
seen.add(key);
|
|
1104
1734
|
return `${key}=${merged.get(key) ?? ""}`;
|
|
1105
1735
|
});
|
|
@@ -1116,8 +1746,8 @@ function renderEnv(original, merged) {
|
|
|
1116
1746
|
}
|
|
1117
1747
|
|
|
1118
1748
|
// src/core/env/pull.ts
|
|
1119
|
-
import { lstat, realpath } from "fs/promises";
|
|
1120
|
-
import { basename, dirname as
|
|
1749
|
+
import { lstat, realpath as realpath2 } from "fs/promises";
|
|
1750
|
+
import { basename, dirname as dirname3, isAbsolute as isAbsolute2, relative as relative4, resolve as resolve4, sep } from "path";
|
|
1121
1751
|
var outside = (target) => new QoreError("QORE_CONTEXT_INVALID", `The env file must be inside the repository (got ${target})`, {
|
|
1122
1752
|
hint: "Use a path inside the repository for --target or .qore.json envFile."
|
|
1123
1753
|
});
|
|
@@ -1125,29 +1755,29 @@ var symlinked = (target) => new QoreError("QORE_CONTEXT_INVALID", `The env file
|
|
|
1125
1755
|
hint: "Use a path inside the repository for --target or .qore.json envFile."
|
|
1126
1756
|
});
|
|
1127
1757
|
function inside(root, p) {
|
|
1128
|
-
const rel =
|
|
1129
|
-
return rel !== "" && rel !== ".." && !rel.startsWith(".." + sep) && !
|
|
1758
|
+
const rel = relative4(root, p);
|
|
1759
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute2(rel);
|
|
1130
1760
|
}
|
|
1131
1761
|
async function resolveTarget(root, target, base = root) {
|
|
1132
|
-
const realRoot = await
|
|
1133
|
-
const realBase = base === root ? realRoot : await
|
|
1134
|
-
const abs =
|
|
1135
|
-
if (!
|
|
1762
|
+
const realRoot = await realpath2(root);
|
|
1763
|
+
const realBase = base === root ? realRoot : await realpath2(base);
|
|
1764
|
+
const abs = resolve4(realBase, target);
|
|
1765
|
+
if (!isAbsolute2(target) && !inside(realRoot, abs)) throw outside(target);
|
|
1136
1766
|
let parent;
|
|
1137
1767
|
try {
|
|
1138
|
-
parent = await
|
|
1768
|
+
parent = await realpath2(dirname3(abs));
|
|
1139
1769
|
} catch (e) {
|
|
1140
1770
|
if (e.code !== "ENOENT") throw e;
|
|
1141
1771
|
throw new QoreError("QORE_CONTEXT_INVALID", `The directory for ${target} does not exist`, {
|
|
1142
1772
|
hint: "Create the parent directory, or point --target / .qore.json envFile at an existing one."
|
|
1143
1773
|
});
|
|
1144
1774
|
}
|
|
1145
|
-
const final =
|
|
1775
|
+
const final = resolve4(parent, basename(abs));
|
|
1146
1776
|
if (!inside(realRoot, final)) throw outside(target);
|
|
1147
1777
|
try {
|
|
1148
1778
|
const st = await lstat(final);
|
|
1149
1779
|
if (st.isSymbolicLink()) throw symlinked(target);
|
|
1150
|
-
const realFinal = await
|
|
1780
|
+
const realFinal = await realpath2(final);
|
|
1151
1781
|
if (!inside(realRoot, realFinal)) throw outside(target);
|
|
1152
1782
|
} catch (e) {
|
|
1153
1783
|
if (e instanceof QoreError) throw e;
|
|
@@ -1217,6 +1847,7 @@ async function appFor(ctx, mc, app) {
|
|
|
1217
1847
|
}
|
|
1218
1848
|
function sourceText(mc) {
|
|
1219
1849
|
if (mc.source.kind === "remote") return mc.source.remote;
|
|
1850
|
+
if (mc.source.kind === "workspace") return WORKSPACE_FILE;
|
|
1220
1851
|
return mc.source.overrides ? `--module, overrides ${mc.source.overrides}` : "--module";
|
|
1221
1852
|
}
|
|
1222
1853
|
function contextLine(mc, app) {
|
|
@@ -1230,39 +1861,11 @@ function contextJson(mc, app) {
|
|
|
1230
1861
|
tenant: mc.module.tenant,
|
|
1231
1862
|
app: app ?? null,
|
|
1232
1863
|
module: { id, slug, name, type, access },
|
|
1233
|
-
source: mc.source.kind === "remote" ? mc.source.remote : "--module",
|
|
1864
|
+
source: mc.source.kind === "remote" ? mc.source.remote : mc.source.kind === "workspace" ? WORKSPACE_FILE : "--module",
|
|
1234
1865
|
...mc.source.kind === "flag" && mc.source.overrides ? { overrides: mc.source.overrides } : {}
|
|
1235
1866
|
};
|
|
1236
1867
|
}
|
|
1237
1868
|
|
|
1238
|
-
// src/core/trust.ts
|
|
1239
|
-
import { realpath as realpath2 } from "fs/promises";
|
|
1240
|
-
function trustKey(root, platform) {
|
|
1241
|
-
return platform === "win32" ? root.toLowerCase() : root;
|
|
1242
|
-
}
|
|
1243
|
-
async function keyOf(deps2, root) {
|
|
1244
|
-
let real = root;
|
|
1245
|
-
try {
|
|
1246
|
-
real = await realpath2(root);
|
|
1247
|
-
} catch (e) {
|
|
1248
|
-
if (e.code !== "ENOENT") throw e;
|
|
1249
|
-
}
|
|
1250
|
-
return trustKey(real, deps2.platform);
|
|
1251
|
-
}
|
|
1252
|
-
async function isTrusted(deps2, root, moduleId) {
|
|
1253
|
-
const cfg = await deps2.config.read();
|
|
1254
|
-
return (cfg.trustedRepos?.[await keyOf(deps2, root)] ?? []).includes(moduleId);
|
|
1255
|
-
}
|
|
1256
|
-
async function trust(deps2, root, moduleId) {
|
|
1257
|
-
const key = await keyOf(deps2, root);
|
|
1258
|
-
await deps2.config.update((c) => {
|
|
1259
|
-
const repos = c.trustedRepos ??= {};
|
|
1260
|
-
const list = new Set(repos[key] ?? []);
|
|
1261
|
-
list.add(moduleId);
|
|
1262
|
-
repos[key] = [...list];
|
|
1263
|
-
});
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
1869
|
// src/cli/trustPrompt.ts
|
|
1267
1870
|
async function ensureTrusted(ctx, mc, app, o) {
|
|
1268
1871
|
const root = mc.repoRoot ?? ctx.deps.cwd;
|
|
@@ -1290,7 +1893,7 @@ async function ensureTrusted(ctx, mc, app, o) {
|
|
|
1290
1893
|
var maskLen = (v) => v === void 0 ? "(unset)" : `\u2022\u2022\u2022\u2022 (${v.length} chars)`;
|
|
1291
1894
|
async function readOrNull(p) {
|
|
1292
1895
|
try {
|
|
1293
|
-
return await
|
|
1896
|
+
return await readFile4(p, "utf8");
|
|
1294
1897
|
} catch (e) {
|
|
1295
1898
|
if (e.code === "ENOENT") return null;
|
|
1296
1899
|
throw e;
|
|
@@ -1298,12 +1901,12 @@ async function readOrNull(p) {
|
|
|
1298
1901
|
}
|
|
1299
1902
|
async function writeSecurely(target, text) {
|
|
1300
1903
|
const base = basename2(target);
|
|
1301
|
-
const tmpName = base.endsWith(".local") ? `${base.slice(0, -".local".length)}.qore-${
|
|
1302
|
-
const tmp =
|
|
1904
|
+
const tmpName = base.endsWith(".local") ? `${base.slice(0, -".local".length)}.qore-${randomBytes3(6).toString("hex")}.local` : `${base}.qore-${randomBytes3(6).toString("hex")}.tmp`;
|
|
1905
|
+
const tmp = join10(dirname4(target), tmpName);
|
|
1303
1906
|
try {
|
|
1304
|
-
await
|
|
1907
|
+
await writeFile3(tmp, text, { mode: 384, flag: "wx" });
|
|
1305
1908
|
await chmod2(tmp, 384);
|
|
1306
|
-
await
|
|
1909
|
+
await rename3(tmp, target);
|
|
1307
1910
|
} finally {
|
|
1308
1911
|
await unlink(tmp).catch(() => {
|
|
1309
1912
|
});
|
|
@@ -1324,7 +1927,7 @@ function registerEnv(program, ctx) {
|
|
|
1324
1927
|
const target = f.target ? await resolveTarget(mc.repoRoot, f.target, deps2.cwd) : await resolveTarget(mc.repoRoot, cfg.envFile ?? defaults.envFile);
|
|
1325
1928
|
await ensureTrusted(ctx, mc, app, { yes: f.yes });
|
|
1326
1929
|
const existing = await readOrNull(target);
|
|
1327
|
-
const example = existing === null ? await readOrNull(
|
|
1930
|
+
const example = existing === null ? await readOrNull(join10(dirname4(target), ".env.example")) : null;
|
|
1328
1931
|
const local = await fetchLocalEnv(deps2, mc, app);
|
|
1329
1932
|
const inputs = {
|
|
1330
1933
|
existing,
|
|
@@ -1333,7 +1936,7 @@ function registerEnv(program, ctx) {
|
|
|
1333
1936
|
managed: local.managedKeys,
|
|
1334
1937
|
localSecrets: cfg.localSecrets ?? defaults.localSecrets,
|
|
1335
1938
|
defaultChoice: f.usePortal ? "use-portal" : "keep",
|
|
1336
|
-
random: () =>
|
|
1939
|
+
random: () => randomBytes3(32).toString("hex")
|
|
1337
1940
|
};
|
|
1338
1941
|
let plan = planPull(inputs);
|
|
1339
1942
|
if (plan.conflicts.length && io2.interactive && !f.usePortal) {
|
|
@@ -1365,7 +1968,7 @@ function registerEnv(program, ctx) {
|
|
|
1365
1968
|
} else {
|
|
1366
1969
|
await writeSecurely(target, plan.text);
|
|
1367
1970
|
}
|
|
1368
|
-
const rel =
|
|
1971
|
+
const rel = relative5(await realpath3(mc.repoRoot), target);
|
|
1369
1972
|
if (g.json) {
|
|
1370
1973
|
const names = {
|
|
1371
1974
|
created: plan.created,
|
|
@@ -1426,8 +2029,8 @@ function mapOidcError(e, host) {
|
|
|
1426
2029
|
if (err === "access_denied") return new QoreError("QORE_LOGIN_DENIED", "The login was denied in the browser");
|
|
1427
2030
|
if (err === "expired_token") return new QoreError("QORE_LOGIN_EXPIRED", "The login code expired", { hint: "Run `qore login` again." });
|
|
1428
2031
|
if (err === "invalid_grant") return new QoreError("QORE_SESSION_EXPIRED", "Your session has ended", { hint: "Run `qore login`." });
|
|
1429
|
-
const
|
|
1430
|
-
if (
|
|
2032
|
+
const status2 = e.status;
|
|
2033
|
+
if (status2 && status2 >= 500) return new QoreError("QORE_AUTH_UNAVAILABLE", "Keycloak is unavailable", { cause: e });
|
|
1431
2034
|
if (e instanceof TypeError) return unreachable(e, host);
|
|
1432
2035
|
const cause = e?.cause;
|
|
1433
2036
|
if (cause instanceof TypeError) return unreachable(cause, host);
|
|
@@ -1683,10 +2286,54 @@ function table(rows) {
|
|
|
1683
2286
|
}
|
|
1684
2287
|
|
|
1685
2288
|
// src/cli/commands/status.ts
|
|
2289
|
+
var HEAD = ["ENV", "DEPLOY", "COMMIT", "DEPLOYED", "APP HEALTH"];
|
|
2290
|
+
var envRow = (e, now) => [e.name, e.paused ? "paused" : e.status, e.commit?.sha ?? "\u2013", ago(e.commit?.deployedAt ?? null, now), uptime(e.health)];
|
|
2291
|
+
async function appStatus(ctx, ws, appFlag) {
|
|
2292
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
2293
|
+
const g = ctx.opts();
|
|
2294
|
+
const portal = await flagPortal(deps2, g.portal, ws);
|
|
2295
|
+
const onRequest = g.verbose ? (l) => io2.err(l) : void 0;
|
|
2296
|
+
const session = await openSession(deps2, portal, "status", onRequest);
|
|
2297
|
+
await ensureCapability(deps2, portal.apiOrigin, "app.modules");
|
|
2298
|
+
await ensureCapability(deps2, portal.apiOrigin, "module.status");
|
|
2299
|
+
const am = await workspaceAppModules(session, portal, ws);
|
|
2300
|
+
const { tenant, ...app } = am.app;
|
|
2301
|
+
if (appFlag && appFlag !== app.id && appFlag !== app.slug) {
|
|
2302
|
+
throw new QoreError("QORE_RESOURCE_NOT_FOUND", `This workspace is for ${app.slug}, not "${appFlag}"`, {
|
|
2303
|
+
hint: "Pass --module <slug> --app <slug>."
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
if (!g.json) io2.err(`\u2192 ${tenant.name} / ${app.name} (${WORKSPACE_FILE})`);
|
|
2307
|
+
const modules = [];
|
|
2308
|
+
for (const m of am.modules.filter((x) => x.repoUrl !== null)) {
|
|
2309
|
+
const st = await session.api.get(
|
|
2310
|
+
`/api/cli/v1/modules/${encodeURIComponent(m.id)}/status?app_id=${encodeURIComponent(app.id)}`
|
|
2311
|
+
);
|
|
2312
|
+
modules.push({ module: { id: m.id, slug: m.slug, name: m.name, type: m.type }, environments: st.environments });
|
|
2313
|
+
}
|
|
2314
|
+
if (g.json) {
|
|
2315
|
+
renderJson(io2, { context: { portal: portal.builtin ?? portal.apiOrigin, tenant, app, source: WORKSPACE_FILE }, modules });
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
if (modules.length === 0) {
|
|
2319
|
+
io2.out("No modules with a repository yet.");
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
const now = deps2.now();
|
|
2323
|
+
const rows = [["MODULE", ...HEAD], ...modules.flatMap(({ module, environments }) => environments.length === 0 ? [[module.slug, "\u2013", "no environments yet", "\u2013", "\u2013", "\u2013"]] : environments.map((e) => [module.slug, ...envRow(e, now)]))];
|
|
2324
|
+
for (const line2 of table(rows)) io2.out(line2);
|
|
2325
|
+
}
|
|
1686
2326
|
function registerStatus(program, ctx) {
|
|
1687
|
-
program.command("status").description("Deployment and health per environment for this module").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").action(async (flags) => {
|
|
2327
|
+
program.command("status").description("Deployment and health per environment for this module (or, in a workspace root, 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").action(async (flags) => {
|
|
1688
2328
|
const { deps: deps2, io: io2 } = ctx;
|
|
1689
2329
|
const g = ctx.opts();
|
|
2330
|
+
if (!flags.module) {
|
|
2331
|
+
const ws = await workspaceRoot(deps2);
|
|
2332
|
+
if (ws) {
|
|
2333
|
+
await appStatus(ctx, ws, flags.app);
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
1690
2337
|
const mc = await moduleContextFor(ctx, flags, "status");
|
|
1691
2338
|
await ensureCapability(deps2, mc.portal.apiOrigin, "module.status");
|
|
1692
2339
|
const app = await appFor(ctx, mc, flags.app);
|
|
@@ -1703,17 +2350,81 @@ function registerStatus(program, ctx) {
|
|
|
1703
2350
|
return;
|
|
1704
2351
|
}
|
|
1705
2352
|
const now = deps2.now();
|
|
1706
|
-
const rows = [
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
2353
|
+
const rows = [HEAD, ...st.environments.map((e) => envRow(e, now))];
|
|
2354
|
+
for (const line2 of table(rows)) io2.out(line2);
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// src/cli/commands/sync.ts
|
|
2359
|
+
import { lstat as lstat2, readdir } from "fs/promises";
|
|
2360
|
+
import { join as join11, relative as relative6 } from "path";
|
|
2361
|
+
import { Option as Option2 } from "commander";
|
|
2362
|
+
function asFailure2(slug, e) {
|
|
2363
|
+
if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") {
|
|
2364
|
+
return { slug, status: "failed", error: { code: e.code, message: e.message, hint: e.hint } };
|
|
2365
|
+
}
|
|
2366
|
+
throw e;
|
|
2367
|
+
}
|
|
2368
|
+
async function strayRepos(root, slugs) {
|
|
2369
|
+
let entries;
|
|
2370
|
+
try {
|
|
2371
|
+
entries = (await readdir(root, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
2372
|
+
} catch {
|
|
2373
|
+
return [];
|
|
2374
|
+
}
|
|
2375
|
+
const names = [];
|
|
2376
|
+
for (const name of entries) {
|
|
2377
|
+
if (slugs.has(name)) continue;
|
|
2378
|
+
try {
|
|
2379
|
+
await lstat2(join11(root, name, ".git"));
|
|
2380
|
+
names.push(name);
|
|
2381
|
+
} catch {
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
return names.sort();
|
|
2385
|
+
}
|
|
2386
|
+
function registerSync(program, ctx) {
|
|
2387
|
+
program.command("sync").description("Clone new modules, fetch present ones, report removed and re-pointed ones").option("--pull", "fast-forward every clean present repository with an upstream").addOption(new Option2("--protocol <https|ssh>", "clone URL protocol").choices(["https", "ssh"]).default("https")).action(async (flags) => {
|
|
2388
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
2389
|
+
const g = ctx.opts();
|
|
2390
|
+
const ws = await findWorkspace(deps2.cwd, deps2.home);
|
|
2391
|
+
if (!ws) {
|
|
2392
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", "Not inside a workspace", { hint: "Run `qore clone <app>` first." });
|
|
2393
|
+
}
|
|
2394
|
+
const portal = await flagPortal(deps2, g.portal, ws);
|
|
2395
|
+
const onRequest = g.verbose ? (l) => io2.err(l) : void 0;
|
|
2396
|
+
const session = await openSession(deps2, portal, "sync", onRequest);
|
|
2397
|
+
await ensureCapability(deps2, portal.apiOrigin, "app.modules");
|
|
2398
|
+
const app = await workspaceAppModules(session, portal, ws);
|
|
2399
|
+
if (!g.json) io2.err(`\u2192 ${app.app.tenant.name} / ${app.app.name} (qore.workspace.json)`);
|
|
2400
|
+
const slugs = new Set(app.modules.map((m) => m.slug));
|
|
2401
|
+
const dirs = {};
|
|
2402
|
+
const earlyFailures = [];
|
|
2403
|
+
const modulesForPlan = [];
|
|
2404
|
+
for (const m of app.modules) {
|
|
2405
|
+
if (!isSafeSlug(m.slug)) {
|
|
2406
|
+
earlyFailures.push(asFailure2(m.slug, unsafeSlugError(m.slug)));
|
|
2407
|
+
continue;
|
|
2408
|
+
}
|
|
2409
|
+
try {
|
|
2410
|
+
dirs[m.slug] = await deps2.git.inspect(join11(ws.root, m.slug));
|
|
2411
|
+
modulesForPlan.push(m);
|
|
2412
|
+
} catch (e) {
|
|
2413
|
+
earlyFailures.push(asFailure2(m.slug, e));
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
const strays = await strayRepos(ws.root, slugs);
|
|
2417
|
+
const { actions, removed } = planWorkspace(ws.root, modulesForPlan, dirs, strays);
|
|
2418
|
+
const applied = await applyActions(deps2, { portal, app, actions, protocol: flags.protocol, onResult: () => {
|
|
2419
|
+
} });
|
|
2420
|
+
await syncPresent(deps2, applied, actions, Boolean(flags.pull), { app, protocol: flags.protocol });
|
|
2421
|
+
for (const r of applied) {
|
|
2422
|
+
if (r.status === "cloned" && r.dir) r.dir = relative6(deps2.cwd, r.dir) || ".";
|
|
2423
|
+
}
|
|
2424
|
+
const removedResults = removed.map((slug) => ({ slug, status: "removed", reason: "not a module of this app \u2014 left in place" }));
|
|
2425
|
+
const results = [...earlyFailures, ...applied, ...removedResults];
|
|
2426
|
+
const err = reportWorkspaceRun(io2, results, Boolean(g.json));
|
|
2427
|
+
if (err) throw err;
|
|
1717
2428
|
});
|
|
1718
2429
|
}
|
|
1719
2430
|
|
|
@@ -1832,6 +2543,8 @@ var COMMANDS = [
|
|
|
1832
2543
|
registerStatus,
|
|
1833
2544
|
registerEnv,
|
|
1834
2545
|
registerRun,
|
|
2546
|
+
registerClone,
|
|
2547
|
+
registerSync,
|
|
1835
2548
|
registerVersion
|
|
1836
2549
|
];
|
|
1837
2550
|
|
|
@@ -1867,8 +2580,8 @@ async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS)
|
|
|
1867
2580
|
}
|
|
1868
2581
|
|
|
1869
2582
|
// src/core/config.ts
|
|
1870
|
-
import { readFile as
|
|
1871
|
-
import { join as
|
|
2583
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
2584
|
+
import { join as join12 } from "path";
|
|
1872
2585
|
var empty = () => ({ schemaVersion: 1, profiles: {} });
|
|
1873
2586
|
var ConfigStore = class {
|
|
1874
2587
|
constructor(dir2) {
|
|
@@ -1876,12 +2589,12 @@ var ConfigStore = class {
|
|
|
1876
2589
|
}
|
|
1877
2590
|
dir;
|
|
1878
2591
|
get file() {
|
|
1879
|
-
return
|
|
2592
|
+
return join12(this.dir, "config.json");
|
|
1880
2593
|
}
|
|
1881
2594
|
async read() {
|
|
1882
2595
|
let raw;
|
|
1883
2596
|
try {
|
|
1884
|
-
raw = await
|
|
2597
|
+
raw = await readFile5(this.file, "utf8");
|
|
1885
2598
|
} catch (e) {
|
|
1886
2599
|
if (e.code === "ENOENT") return empty();
|
|
1887
2600
|
throw e;
|
|
@@ -1906,51 +2619,136 @@ var ConfigStore = class {
|
|
|
1906
2619
|
};
|
|
1907
2620
|
|
|
1908
2621
|
// src/platform/git.ts
|
|
1909
|
-
import { execFile } from "child_process";
|
|
2622
|
+
import { execFile, execFileSync } from "child_process";
|
|
2623
|
+
import { mkdir as mkdir3, readdir as readdir2, stat as stat2 } from "fs/promises";
|
|
1910
2624
|
import { homedir } from "os";
|
|
2625
|
+
import { dirname as dirname5, join as join13 } from "path";
|
|
1911
2626
|
function parseRemotes(stdout) {
|
|
1912
2627
|
const seen = /* @__PURE__ */ new Set();
|
|
1913
2628
|
const out = [];
|
|
1914
|
-
for (const
|
|
1915
|
-
const m = /^(\S+)\t(.+) \(fetch\)$/.exec(
|
|
2629
|
+
for (const line2 of stdout.split(/\r?\n/)) {
|
|
2630
|
+
const m = /^(\S+)\t(.+) \(fetch\)$/.exec(line2);
|
|
1916
2631
|
if (!m || seen.has(m[1])) continue;
|
|
1917
2632
|
seen.add(m[1]);
|
|
1918
2633
|
out.push({ name: m[1], url: m[2] });
|
|
1919
2634
|
}
|
|
1920
2635
|
return out;
|
|
1921
2636
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
2637
|
+
var configuredSshCommand = /* @__PURE__ */ new Map();
|
|
2638
|
+
function hasConfiguredSshCommand(repoDir) {
|
|
2639
|
+
const key = repoDir ?? "";
|
|
2640
|
+
let v = configuredSshCommand.get(key);
|
|
2641
|
+
if (v === void 0) {
|
|
2642
|
+
const args = repoDir ? ["-C", repoDir, "config", "--get", "core.sshCommand"] : ["config", "--get", "core.sshCommand"];
|
|
2643
|
+
try {
|
|
2644
|
+
v = execFileSync("git", args, {
|
|
2645
|
+
cwd: homedir(),
|
|
2646
|
+
env: { ...process.env, LC_ALL: "C", GIT_TERMINAL_PROMPT: "0" },
|
|
2647
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
2648
|
+
windowsHide: true,
|
|
2649
|
+
timeout: 5e3
|
|
2650
|
+
}).toString().trim().length > 0;
|
|
2651
|
+
} catch {
|
|
2652
|
+
v = false;
|
|
2653
|
+
}
|
|
2654
|
+
configuredSshCommand.set(key, v);
|
|
2655
|
+
}
|
|
2656
|
+
return v;
|
|
2657
|
+
}
|
|
2658
|
+
function gitExecOptions(repoDir) {
|
|
2659
|
+
const env = { ...process.env, LC_ALL: "C", GIT_TERMINAL_PROMPT: "0" };
|
|
2660
|
+
if (!env.GIT_HTTP_LOW_SPEED_LIMIT) env.GIT_HTTP_LOW_SPEED_LIMIT = "1000";
|
|
2661
|
+
if (!env.GIT_HTTP_LOW_SPEED_TIME) env.GIT_HTTP_LOW_SPEED_TIME = "60";
|
|
2662
|
+
if (!env.GIT_SSH_COMMAND && !env.GIT_SSH && !hasConfiguredSshCommand(repoDir)) {
|
|
2663
|
+
env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes";
|
|
2664
|
+
}
|
|
2665
|
+
return { cwd: homedir(), env, windowsHide: true, maxBuffer: 8 * 1024 * 1024 };
|
|
2666
|
+
}
|
|
2667
|
+
function runGit(args, repoDir) {
|
|
2668
|
+
return new Promise((resolve5, reject) => {
|
|
2669
|
+
execFile("git", args, gitExecOptions(repoDir), (err, stdout, stderr) => {
|
|
2670
|
+
if (err && err.code === "ENOENT") {
|
|
2671
|
+
reject(new QoreError("QORE_GIT_UNAVAILABLE", "git is not installed or not on PATH", {
|
|
2672
|
+
hint: "Install git (https://git-scm.com/downloads) and open a new terminal."
|
|
2673
|
+
}));
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
const code = err ? typeof err.code === "number" ? err.code : 1 : 0;
|
|
2677
|
+
resolve5({ code, stdout: String(stdout), stderr: String(stderr), err: err ?? void 0 });
|
|
2678
|
+
});
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
var toResult = (r, command) => r.code === 0 ? { ok: true } : { ok: false, stderr: r.stderr, command };
|
|
2682
|
+
async function remotes(repoRoot) {
|
|
2683
|
+
const r = await runGit(["-C", repoRoot, "remote", "-v"], repoRoot);
|
|
2684
|
+
if (r.code !== 0) {
|
|
2685
|
+
const last = r.stderr.trim().split(/\r?\n/).pop() ?? "";
|
|
2686
|
+
throw new QoreError("QORE_INTERNAL", `git remote -v failed in ${repoRoot}`, {
|
|
2687
|
+
hint: last || void 0,
|
|
2688
|
+
details: { command: "git remote -v", cwd: repoRoot },
|
|
2689
|
+
cause: r.err
|
|
1951
2690
|
});
|
|
1952
2691
|
}
|
|
1953
|
-
|
|
2692
|
+
return parseRemotes(r.stdout);
|
|
2693
|
+
}
|
|
2694
|
+
async function clone(url, dir2) {
|
|
2695
|
+
await mkdir3(dirname5(dir2), { recursive: true });
|
|
2696
|
+
const r = await runGit(["clone", "--", url, dir2]);
|
|
2697
|
+
return toResult(r, "git clone");
|
|
2698
|
+
}
|
|
2699
|
+
async function fetch(repoRoot) {
|
|
2700
|
+
const r = await runGit(["-C", repoRoot, "fetch"], repoRoot);
|
|
2701
|
+
return toResult(r, "git fetch");
|
|
2702
|
+
}
|
|
2703
|
+
async function pullFfOnly(repoRoot) {
|
|
2704
|
+
const r = await runGit(["-C", repoRoot, "pull", "--no-rebase", "--ff-only"], repoRoot);
|
|
2705
|
+
return toResult(r, "git pull --no-rebase --ff-only");
|
|
2706
|
+
}
|
|
2707
|
+
async function status(repoRoot) {
|
|
2708
|
+
const command = "git status --porcelain=v2 --branch";
|
|
2709
|
+
const r = await runGit(["--no-optional-locks", "-C", repoRoot, "status", "--porcelain=v2", "--branch"], repoRoot);
|
|
2710
|
+
if (r.code !== 0) return { ok: false, stderr: r.stderr, command };
|
|
2711
|
+
let branch = null;
|
|
2712
|
+
let upstream = false;
|
|
2713
|
+
let dirty = false;
|
|
2714
|
+
for (const line2 of r.stdout.split(/\r?\n/)) {
|
|
2715
|
+
if (line2.startsWith("# branch.head ")) {
|
|
2716
|
+
const name = line2.slice("# branch.head ".length).trim();
|
|
2717
|
+
branch = name === "(detached)" ? null : name;
|
|
2718
|
+
} else if (line2.startsWith("# branch.upstream ")) {
|
|
2719
|
+
upstream = true;
|
|
2720
|
+
} else if (line2.startsWith("#")) {
|
|
2721
|
+
continue;
|
|
2722
|
+
} else if (line2.trim().length > 0) {
|
|
2723
|
+
dirty = true;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
return { ok: true, dirty, branch, upstream };
|
|
2727
|
+
}
|
|
2728
|
+
async function statOrThrow(path) {
|
|
2729
|
+
try {
|
|
2730
|
+
return await stat2(path);
|
|
2731
|
+
} catch (err) {
|
|
2732
|
+
if (err.code === "ENOENT") return void 0;
|
|
2733
|
+
throw new QoreError("QORE_INTERNAL", `could not stat ${path}`, { cause: err });
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
async function inspect(dir2) {
|
|
2737
|
+
const st = await statOrThrow(dir2);
|
|
2738
|
+
if (!st) return { exists: false, empty: false, isRepo: false, remotes: [] };
|
|
2739
|
+
if (!st.isDirectory()) return { exists: true, empty: false, isRepo: false, remotes: [] };
|
|
2740
|
+
let entries;
|
|
2741
|
+
try {
|
|
2742
|
+
entries = await readdir2(dir2);
|
|
2743
|
+
} catch (err) {
|
|
2744
|
+
throw new QoreError("QORE_INTERNAL", `could not read ${dir2}`, { cause: err });
|
|
2745
|
+
}
|
|
2746
|
+
const empty2 = entries.length === 0;
|
|
2747
|
+
const isRepo = await statOrThrow(join13(dir2, ".git")) !== void 0;
|
|
2748
|
+
const dirRemotes = isRepo ? await remotes(dir2) : [];
|
|
2749
|
+
return { exists: true, empty: empty2, isRepo, remotes: dirRemotes };
|
|
2750
|
+
}
|
|
2751
|
+
var systemGit = { remotes, clone, fetch, pullFfOnly, status, inspect };
|
|
1954
2752
|
|
|
1955
2753
|
// src/platform/keychain.ts
|
|
1956
2754
|
import { Entry } from "@napi-rs/keyring";
|
|
@@ -2007,15 +2805,15 @@ var OsKeychain = class {
|
|
|
2007
2805
|
};
|
|
2008
2806
|
|
|
2009
2807
|
// src/platform/lock.ts
|
|
2010
|
-
import { mkdir as
|
|
2011
|
-
import { join as
|
|
2808
|
+
import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
|
|
2809
|
+
import { join as join14 } from "path";
|
|
2012
2810
|
import lockfile from "proper-lockfile";
|
|
2013
2811
|
function fileLock(dir2) {
|
|
2014
2812
|
return async (name, fn) => {
|
|
2015
|
-
const locks =
|
|
2016
|
-
await
|
|
2017
|
-
const target =
|
|
2018
|
-
await
|
|
2813
|
+
const locks = join14(dir2, "locks");
|
|
2814
|
+
await mkdir4(locks, { recursive: true, mode: 448 });
|
|
2815
|
+
const target = join14(locks, `${name}.lock`);
|
|
2816
|
+
await writeFile4(target, "", { flag: "a" });
|
|
2019
2817
|
let release;
|
|
2020
2818
|
let compromised;
|
|
2021
2819
|
try {
|
|
@@ -2057,7 +2855,7 @@ var { ignore: IGNORE, forward: FORWARD } = signalsFor(process.platform);
|
|
|
2057
2855
|
function makeSpawner(spawnFn, proc) {
|
|
2058
2856
|
return {
|
|
2059
2857
|
run(cmd, args, env) {
|
|
2060
|
-
return new Promise((
|
|
2858
|
+
return new Promise((resolve5, reject) => {
|
|
2061
2859
|
let child;
|
|
2062
2860
|
try {
|
|
2063
2861
|
child = spawnFn(cmd, args, { stdio: "inherit", shell: false, env, windowsHide: true });
|
|
@@ -2082,7 +2880,7 @@ function makeSpawner(spawnFn, proc) {
|
|
|
2082
2880
|
});
|
|
2083
2881
|
child.once("exit", (code, signal) => {
|
|
2084
2882
|
cleanup();
|
|
2085
|
-
|
|
2883
|
+
resolve5({ code, signal });
|
|
2086
2884
|
});
|
|
2087
2885
|
});
|
|
2088
2886
|
}
|
|
@@ -2091,7 +2889,7 @@ function makeSpawner(spawnFn, proc) {
|
|
|
2091
2889
|
var crossSpawner = makeSpawner(spawn, process);
|
|
2092
2890
|
|
|
2093
2891
|
// src/version.ts
|
|
2094
|
-
var VERSION = true ? "0.1.
|
|
2892
|
+
var VERSION = true ? "0.1.2" : "0.0.0-dev";
|
|
2095
2893
|
|
|
2096
2894
|
// src/cli.ts
|
|
2097
2895
|
if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY) setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
@@ -2107,18 +2905,19 @@ process.on("SIGINT", () => {
|
|
|
2107
2905
|
}
|
|
2108
2906
|
ac.abort();
|
|
2109
2907
|
});
|
|
2110
|
-
var
|
|
2908
|
+
var fetch2 = abortableFetch(globalThis.fetch, ac.signal);
|
|
2111
2909
|
var deps = {
|
|
2112
|
-
fetch,
|
|
2910
|
+
fetch: fetch2,
|
|
2113
2911
|
keychain: new OsKeychain(),
|
|
2114
2912
|
config: new ConfigStore(dir),
|
|
2115
2913
|
withLock: fileLock(dir),
|
|
2116
2914
|
now: () => /* @__PURE__ */ new Date(),
|
|
2117
|
-
oidcFor: (issuer) => createOidc({ issuer, clientId: "qore-cli", fetch }),
|
|
2915
|
+
oidcFor: (issuer) => createOidc({ issuer, clientId: "qore-cli", fetch: fetch2 }),
|
|
2118
2916
|
env: process.env,
|
|
2119
2917
|
platform: process.platform,
|
|
2120
2918
|
version: VERSION,
|
|
2121
2919
|
cwd: process.cwd(),
|
|
2920
|
+
home: homedir2(),
|
|
2122
2921
|
git: systemGit,
|
|
2123
2922
|
spawn: crossSpawner,
|
|
2124
2923
|
raise: (signal) => {
|