@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/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((resolve3, reject) => {
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
- resolve3();
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(fetch2, signal) {
154
- return (input, init) => fetch2(input, { ...init, signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal });
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(status) {
201
- if (status === 403) return "QORE_ACCESS_DENIED";
202
- if (status === 404) return "QORE_RESOURCE_NOT_FOUND";
203
- if (status === 426) return "QORE_UPGRADE_REQUIRED";
204
- if (status === 429) return "QORE_RATE_LIMITED";
205
- if (status === 502 || status === 503 || status === 504) return "QORE_API_UNAVAILABLE";
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 ? (line) => ctx.io.err(line) : void 0;
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/env.ts
305
- import { randomBytes as randomBytes2 } from "crypto";
306
- import { chmod as chmod2, readFile as readFile3, realpath as realpath3, rename as rename2, unlink, writeFile as writeFile2 } from "fs/promises";
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(fetch2, apiOrigin) {
327
+ async function fetchMeta(fetch3, apiOrigin) {
369
328
  let res;
370
329
  try {
371
- res = await fetch2(`${apiOrigin}/api/cli/meta`, { headers: { accept: "application/json" } });
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 join3 } from "path";
663
+ import { join as join2 } from "path";
651
664
 
652
665
  // src/core/jsonFile.ts
653
- import { chmod, mkdir, readFile as readFile2, rename, writeFile } from "fs/promises";
654
- import { join as join2 } from "path";
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 readFile2(file, "utf8");
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 = join2(dir2, `.${name}.${randomBytes(6).toString("hex")}.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, join2(dir2, name));
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(join3(this.dir, "repos.json"));
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 join4, resolve } from "path";
1347
+ import { dirname as dirname2, join as join8, resolve as resolve3 } from "path";
771
1348
  async function findRepoRoot(start) {
772
- let dir2 = resolve(start);
1349
+ let dir2 = resolve3(start);
773
1350
  for (; ; ) {
774
1351
  try {
775
- await stat(join4(dir2, ".git"));
1352
+ await stat(join8(dir2, ".git"));
776
1353
  return dir2;
777
1354
  } catch {
778
1355
  }
779
- const up = dirname(dir2);
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 UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
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(remotes) {
1374
+ function byPreference(remotes2) {
798
1375
  const rank = (r) => r.name === "origin" ? 0 : r.name === "upstream" ? 1 : 2;
799
- return [...remotes].sort((a, b) => rank(a) - rank(b));
1376
+ return [...remotes2].sort((a, b) => rank(a) - rank(b));
800
1377
  }
801
- var preferred = (remotes) => byPreference(remotes)[0];
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 remotes;
1382
+ let remotes2;
806
1383
  try {
807
- remotes = await deps2.git.remotes(root);
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 = remotes.map((r) => ({ ...r, key: repoKey(r.url) })).filter((r) => r.key !== null);
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 byFlag(deps2, o, cache) {
845
- const cfg = await deps2.config.read();
846
- const portal = resolvePortal(o.portal, cfg.defaultProfile);
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 (!UUID.test(id)) {
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 profileOrder(cfg, cachedOrigins, portalArg) {
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(join5(deps2.config.dir, "cache"), deps2.now);
951
- if (o.module) return byFlag(deps2, o, cache);
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 line of contents.split("\n")) {
1046
- const m = line.replace(/\r$/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
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((line) => {
1099
- const m = line.replace(/\r$/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
1100
- if (!m?.[1]) return line;
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 line;
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 dirname2, isAbsolute, relative, resolve as resolve2, sep } from "path";
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 = relative(root, p);
1129
- return rel !== "" && rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel);
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 realpath(root);
1133
- const realBase = base === root ? realRoot : await realpath(base);
1134
- const abs = resolve2(realBase, target);
1135
- if (!isAbsolute(target) && !inside(realRoot, abs)) throw outside(target);
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 realpath(dirname2(abs));
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 = resolve2(parent, basename(abs));
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 realpath(final);
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 readFile3(p, "utf8");
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-${randomBytes2(6).toString("hex")}.local` : `${base}.qore-${randomBytes2(6).toString("hex")}.tmp`;
1302
- const tmp = join6(dirname3(target), tmpName);
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 writeFile2(tmp, text, { mode: 384, flag: "wx" });
1907
+ await writeFile3(tmp, text, { mode: 384, flag: "wx" });
1305
1908
  await chmod2(tmp, 384);
1306
- await rename2(tmp, target);
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(join6(dirname3(target), ".env.example")) : null;
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: () => randomBytes2(32).toString("hex")
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 = relative2(await realpath3(mc.repoRoot), target);
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 status = e.status;
1430
- if (status && status >= 500) return new QoreError("QORE_AUTH_UNAVAILABLE", "Keycloak is unavailable", { cause: e });
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
- ["ENV", "DEPLOY", "COMMIT", "DEPLOYED", "APP HEALTH"],
1708
- ...st.environments.map((e) => [
1709
- e.name,
1710
- e.paused ? "paused" : e.status,
1711
- e.commit?.sha ?? "\u2013",
1712
- ago(e.commit?.deployedAt ?? null, now),
1713
- uptime(e.health)
1714
- ])
1715
- ];
1716
- for (const line of table(rows)) io2.out(line);
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 readFile4 } from "fs/promises";
1871
- import { join as join7 } from "path";
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 join7(this.dir, "config.json");
2592
+ return join12(this.dir, "config.json");
1880
2593
  }
1881
2594
  async read() {
1882
2595
  let raw;
1883
2596
  try {
1884
- raw = await readFile4(this.file, "utf8");
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 line of stdout.split(/\r?\n/)) {
1915
- const m = /^(\S+)\t(.+) \(fetch\)$/.exec(line);
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
- function gitExecOptions() {
1923
- return { cwd: homedir(), env: { ...process.env, LC_ALL: "C" }, windowsHide: true };
1924
- }
1925
- var systemGit = {
1926
- remotes(repoRoot) {
1927
- return new Promise((resolve3, reject) => {
1928
- execFile(
1929
- "git",
1930
- ["-C", repoRoot, "remote", "-v"],
1931
- gitExecOptions(),
1932
- (err, stdout, stderr) => {
1933
- if (err) {
1934
- if (err.code === "ENOENT") {
1935
- reject(new QoreError("QORE_GIT_UNAVAILABLE", "git is not installed or not on PATH", {
1936
- hint: "Install git (https://git-scm.com/downloads) and open a new terminal."
1937
- }));
1938
- return;
1939
- }
1940
- const last = String(stderr).trim().split(/\r?\n/).pop() ?? "";
1941
- reject(new QoreError("QORE_INTERNAL", `git remote -v failed in ${repoRoot}`, {
1942
- hint: last || void 0,
1943
- details: { command: "git remote -v", cwd: repoRoot },
1944
- cause: err
1945
- }));
1946
- return;
1947
- }
1948
- resolve3(parseRemotes(String(stdout)));
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 mkdir2, writeFile as writeFile3 } from "fs/promises";
2011
- import { join as join8 } from "path";
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 = join8(dir2, "locks");
2016
- await mkdir2(locks, { recursive: true, mode: 448 });
2017
- const target = join8(locks, `${name}.lock`);
2018
- await writeFile3(target, "", { flag: "a" });
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((resolve3, reject) => {
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
- resolve3({ code, signal });
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.1" : "0.0.0-dev";
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 fetch = abortableFetch(globalThis.fetch, ac.signal);
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) => {