@quatronic/qore 0.1.0 → 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
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { homedir } from "os";
4
+ import { homedir as homedir2 } from "os";
5
5
  import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
6
6
 
7
7
  // src/cli/io.ts
8
- import { select } from "@inquirer/prompts";
8
+ import { confirm, select } from "@inquirer/prompts";
9
9
  import { createInterface } from "readline";
10
10
 
11
11
  // src/platform/browser.ts
@@ -25,8 +25,11 @@ var EXIT = {
25
25
  QORE_INTERNAL: 1,
26
26
  QORE_KEYCHAIN_UNAVAILABLE: 1,
27
27
  QORE_KEYCHAIN_DENIED: 1,
28
+ QORE_GIT_UNAVAILABLE: 1,
28
29
  QORE_CONTEXT_REQUIRED: 2,
29
30
  QORE_CONTEXT_INVALID: 2,
31
+ QORE_CONTEXT_UNSUPPORTED: 2,
32
+ QORE_APP_AMBIGUOUS: 2,
30
33
  QORE_NOT_LOGGED_IN: 4,
31
34
  QORE_SESSION_EXPIRED: 4,
32
35
  QORE_LOGIN_EXPIRED: 4,
@@ -36,6 +39,11 @@ var EXIT = {
36
39
  QORE_RESOURCE_NOT_FOUND: 5,
37
40
  QORE_ACCESS_DENIED: 5,
38
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,
39
47
  QORE_AUTH_UNAVAILABLE: 6,
40
48
  QORE_AUTH_BUSY: 6,
41
49
  QORE_API_UNREACHABLE: 6,
@@ -109,12 +117,20 @@ function processIo(interactive) {
109
117
  throw e;
110
118
  }
111
119
  },
120
+ async confirm(message) {
121
+ try {
122
+ return await confirm({ message, default: false });
123
+ } catch (e) {
124
+ if (e.name === "ExitPromptError") throw new CancelledError();
125
+ throw e;
126
+ }
127
+ },
112
128
  waitForEnter: createWaitForEnter(process.stdin, process.stderr),
113
129
  openUrl: openBrowser
114
130
  };
115
131
  }
116
132
  function createWaitForEnter(input, output) {
117
- return (message, signal) => new Promise((resolve, reject) => {
133
+ return (message, signal) => new Promise((resolve5, reject) => {
118
134
  const rl = createInterface({ input, output, terminal: true });
119
135
  const onAbort = () => {
120
136
  rl.close();
@@ -123,7 +139,7 @@ function createWaitForEnter(input, output) {
123
139
  const done = () => {
124
140
  signal.removeEventListener("abort", onAbort);
125
141
  rl.close();
126
- resolve();
142
+ resolve5();
127
143
  };
128
144
  signal.addEventListener("abort", onAbort, { once: true });
129
145
  rl.on("SIGINT", () => {
@@ -139,8 +155,8 @@ function createWaitForEnter(input, output) {
139
155
  import { Command, CommanderError } from "commander";
140
156
 
141
157
  // src/core/api/http.ts
142
- function abortableFetch(fetch2, signal) {
143
- 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 });
144
160
  }
145
161
  var TLS = /* @__PURE__ */ new Set([
146
162
  "SELF_SIGNED_CERT_IN_CHAIN",
@@ -181,14 +197,17 @@ var BY_SERVER_CODE = {
181
197
  "app.access.denied": "QORE_ACCESS_DENIED",
182
198
  "auth.scope.insufficient": "QORE_ACCESS_DENIED",
183
199
  "cli.upgrade_required": "QORE_UPGRADE_REQUIRED",
184
- "rate_limited": "QORE_RATE_LIMITED"
200
+ "rate_limited": "QORE_RATE_LIMITED",
201
+ "module.not_found": "QORE_RESOURCE_NOT_FOUND",
202
+ "tenant.not_found": "QORE_RESOURCE_NOT_FOUND",
203
+ "module.app_ambiguous": "QORE_APP_AMBIGUOUS"
185
204
  };
186
- function byStatus(status) {
187
- if (status === 403) return "QORE_ACCESS_DENIED";
188
- if (status === 404) return "QORE_RESOURCE_NOT_FOUND";
189
- if (status === 426) return "QORE_UPGRADE_REQUIRED";
190
- if (status === 429) return "QORE_RATE_LIMITED";
191
- 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";
192
211
  return "QORE_INTERNAL";
193
212
  }
194
213
  async function problemToError(res) {
@@ -202,18 +221,22 @@ async function problemToError(res) {
202
221
  const requestId = res.headers.get("x-request-id") ?? void 0;
203
222
  const details = {};
204
223
  if (body.detail && typeof body.detail === "object") Object.assign(details, body.detail);
205
- const detail = typeof body.detail === "string" && body.detail ? body.detail : void 0;
206
- if (detail) details.detail = detail;
224
+ const detail2 = typeof body.detail === "string" && body.detail ? body.detail : void 0;
225
+ if (detail2) details.detail = detail2;
207
226
  const retry = res.headers.get("retry-after");
208
227
  if (retry && /^\d+$/.test(retry)) details.retryAfter = Number(retry);
209
- const hint = code === "QORE_UPGRADE_REQUIRED" ? "Run: npm i -g @quatronic/qore" : code === "QORE_RATE_LIMITED" ? `Wait ${details.retryAfter ?? "a moment"} s and retry.` : code === "QORE_API_UNAVAILABLE" ? "Qore is temporarily unavailable; retry in a minute." : void 0;
228
+ const hint = code === "QORE_UPGRADE_REQUIRED" ? "Run: npm i -g @quatronic/qore" : code === "QORE_RATE_LIMITED" ? `Wait ${details.retryAfter ?? "a moment"} s and retry.` : code === "QORE_API_UNAVAILABLE" ? "Qore is temporarily unavailable; retry in a minute." : code === "QORE_APP_AMBIGUOUS" ? "Pass --app <slug>." : void 0;
229
+ const apps = body.detail?.apps;
230
+ const candidates = code === "QORE_APP_AMBIGUOUS" && Array.isArray(apps) ? apps.map((a) => ({ kind: "app", id: a.id, slug: a.slug, name: a.name })) : void 0;
231
+ if (candidates) delete details.apps;
210
232
  const genericTitle = !body.title || body.title === "HTTP error" || body.title === serverCode;
211
- const message = genericTitle && detail || body.title || `Request failed (${res.status})`;
233
+ const message = genericTitle && detail2 || body.title || `Request failed (${res.status})`;
212
234
  return new QoreError(code, message, {
213
235
  status: res.status,
214
236
  requestId,
215
237
  serverCode,
216
238
  hint,
239
+ candidates,
217
240
  details: Object.keys(details).length ? details : void 0
218
241
  });
219
242
  }
@@ -243,15 +266,19 @@ function renderError(io2, err, json2, colorEnabled = pc.isColorSupported) {
243
266
  }
244
267
 
245
268
  // src/cli/program.ts
269
+ function argvBeforeSeparator(argv2) {
270
+ const i = argv2.indexOf("--");
271
+ return i === -1 ? argv2 : argv2.slice(0, i);
272
+ }
246
273
  function jsonFromArgv(argv2) {
247
- return argv2.some((a) => a === "--json" || a.startsWith("--json="));
274
+ return argvBeforeSeparator(argv2).some((a) => a === "--json" || a.startsWith("--json="));
248
275
  }
249
276
  function requestLog(ctx) {
250
- return ctx.opts().verbose ? (line) => ctx.io.err(line) : void 0;
277
+ return ctx.opts().verbose ? (line2) => ctx.io.err(line2) : void 0;
251
278
  }
252
- function buildProgram(deps2, io2, signal, register) {
279
+ function buildProgram(deps2, io2, signal, register, setExitCode) {
253
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()) });
254
- const ctx = { deps: deps2, io: io2, signal, opts: () => program.opts() };
281
+ const ctx = { deps: deps2, io: io2, signal, opts: () => program.opts(), setExitCode };
255
282
  for (const r of register) r(program, ctx);
256
283
  return program;
257
284
  }
@@ -259,10 +286,13 @@ async function run(argv2, deps2, io2, signal, register) {
259
286
  const json2 = jsonFromArgv(argv2);
260
287
  const parseIo = json2 ? { ...io2, err: () => {
261
288
  } } : io2;
262
- const program = buildProgram(deps2, parseIo, signal, register);
289
+ const state = {};
290
+ const program = buildProgram(deps2, parseIo, signal, register, (code) => {
291
+ state.exitCode = code;
292
+ });
263
293
  try {
264
294
  await program.parseAsync(argv2, { from: "user" });
265
- return 0;
295
+ return state.exitCode ?? 0;
266
296
  } catch (caught) {
267
297
  const e = signal.aborted && isAbortError(caught) ? new CancelledError() : caught;
268
298
  if (e instanceof CommanderError) {
@@ -276,6 +306,10 @@ async function run(argv2, deps2, io2, signal, register) {
276
306
  }
277
307
  }
278
308
 
309
+ // src/cli/commands/clone.ts
310
+ import { join as join6, relative as relative2, resolve as resolve2 } from "path";
311
+ import { Option } from "commander";
312
+
279
313
  // src/core/api/meta.ts
280
314
  var SUPPORTED = ["v1"];
281
315
  var SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/;
@@ -290,10 +324,10 @@ function compareVersions(a, b) {
290
324
  if (!pa[4] && pb[4]) return 1;
291
325
  return 0;
292
326
  }
293
- async function fetchMeta(fetch2, apiOrigin) {
327
+ async function fetchMeta(fetch3, apiOrigin) {
294
328
  let res;
295
329
  try {
296
- res = await fetch2(`${apiOrigin}/api/cli/meta`, { headers: { accept: "application/json" } });
330
+ res = await fetch3(`${apiOrigin}/api/cli/meta`, { headers: { accept: "application/json" } });
297
331
  } catch (e) {
298
332
  throw fetchError(e, new URL(apiOrigin).host);
299
333
  }
@@ -329,170 +363,38 @@ function negotiate(meta, portal, cliVersion) {
329
363
  return { apiVersion };
330
364
  }
331
365
 
332
- // src/core/auth/oidc.ts
333
- import * as oc from "openid-client";
334
- var LOGIN_SCOPE = "openid profile email offline_access qore:read qore:local-env";
335
- function decodeSub(accessToken) {
336
- const part = accessToken.split(".")[1] ?? "";
337
- const claims = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
338
- return { sub: claims.sub, email: claims.email };
339
- }
340
- function toTokenSet(t, fallbackRt) {
341
- const rt = t.refresh_token ?? fallbackRt;
342
- if (!rt) throw new QoreError("QORE_INTERNAL", "Keycloak returned no refresh token");
343
- return {
344
- accessToken: t.access_token,
345
- refreshToken: rt,
346
- expiresIn: t.expires_in ?? 600,
347
- scope: t.scope ?? "",
348
- ...decodeSub(t.access_token)
349
- };
350
- }
351
- function unreachable(e, host) {
352
- const n = networkError(e, host);
353
- return new QoreError("QORE_AUTH_UNAVAILABLE", n.message, { hint: n.hint, details: n.details, cause: e });
366
+ // src/core/api/capabilities.ts
367
+ var TTL_MS = 36e5;
368
+ function isMeta(doc) {
369
+ return !!doc && typeof doc === "object" && Array.isArray(doc.capabilities);
354
370
  }
355
- function mapOidcError(e, host) {
356
- if (e instanceof QoreError || e instanceof CancelledError) return e;
357
- if (isAbortError(e) || e?.code === "OAUTH_ABORT" || isAbortError(e?.cause)) {
358
- return new CancelledError();
371
+ async function loadMeta(deps2, apiOrigin, refresh) {
372
+ const cached = (await deps2.config.read()).profiles[apiOrigin]?.meta;
373
+ if (!refresh && cached && isMeta(cached.doc) && deps2.now().getTime() - new Date(cached.fetchedAt).getTime() < TTL_MS) {
374
+ return { meta: cached.doc, cached: true };
359
375
  }
360
- const err = e.error;
361
- if (err === "access_denied") return new QoreError("QORE_LOGIN_DENIED", "The login was denied in the browser");
362
- if (err === "expired_token") return new QoreError("QORE_LOGIN_EXPIRED", "The login code expired", { hint: "Run `qore login` again." });
363
- if (err === "invalid_grant") return new QoreError("QORE_SESSION_EXPIRED", "Your session has ended", { hint: "Run `qore login`." });
364
- const status = e.status;
365
- if (status && status >= 500) return new QoreError("QORE_AUTH_UNAVAILABLE", "Keycloak is unavailable", { cause: e });
366
- if (e instanceof TypeError) return unreachable(e, host);
367
- const cause = e?.cause;
368
- if (cause instanceof TypeError) return unreachable(cause, host);
369
- return new QoreError("QORE_AUTH_UNAVAILABLE", "Login failed", { cause: e });
376
+ const meta = await fetchMeta(deps2.fetch, apiOrigin);
377
+ await deps2.config.update((c) => {
378
+ const p = c.profiles[apiOrigin];
379
+ if (p) p.meta = { fetchedAt: deps2.now().toISOString(), doc: meta };
380
+ });
381
+ return { meta, cached: false };
370
382
  }
371
- async function createOidc(opts) {
372
- const host = new URL(opts.issuer).host;
373
- const execute = new URL(opts.issuer).protocol === "http:" ? [oc.allowInsecureRequests] : [];
374
- let config;
375
- try {
376
- config = await oc.discovery(new URL(opts.issuer), opts.clientId, void 0, oc.None(), {
377
- execute,
378
- [oc.customFetch]: opts.fetch
379
- });
380
- } catch (e) {
381
- throw mapOidcError(e, host);
382
- }
383
- return {
384
- async startDevice(scope) {
385
- try {
386
- const r = await oc.initiateDeviceAuthorization(config, { scope });
387
- return {
388
- userCode: r.user_code,
389
- verificationUri: r.verification_uri,
390
- verificationUriComplete: r.verification_uri_complete,
391
- expiresIn: r.expires_in,
392
- handle: r
393
- };
394
- } catch (e) {
395
- throw mapOidcError(e, host);
396
- }
397
- },
398
- async pollDevice(start, signal) {
399
- try {
400
- const t = await oc.pollDeviceAuthorizationGrant(config, start.handle, void 0, { signal });
401
- return toTokenSet(t);
402
- } catch (e) {
403
- throw mapOidcError(e, host);
404
- }
405
- },
406
- async refresh(rt, scope) {
407
- try {
408
- const t = await oc.refreshTokenGrant(config, rt, scope ? { scope } : void 0);
409
- return toTokenSet(t, rt);
410
- } catch (e) {
411
- throw mapOidcError(e, host);
412
- }
413
- },
414
- async revoke(rt) {
415
- try {
416
- await oc.tokenRevocation(config, rt, { token_type_hint: "refresh_token" });
417
- } catch (e) {
418
- throw mapOidcError(e, host);
419
- }
420
- }
421
- };
383
+ async function ensureCapability(deps2, apiOrigin, capability) {
384
+ const first = await loadMeta(deps2, apiOrigin, false);
385
+ let meta = first.meta;
386
+ if (!meta.capabilities.includes(capability) && first.cached) meta = (await loadMeta(deps2, apiOrigin, true)).meta;
387
+ requireCapability(meta, capability, apiOrigin);
388
+ return meta;
422
389
  }
423
-
424
- // src/core/auth/login.ts
425
- async function deviceLogin(opts) {
426
- await opts.store.getRefresh();
427
- const start = await opts.oidc.startDevice(LOGIN_SCOPE);
428
- const stopPoll = new AbortController();
429
- const settled = new AbortController();
430
- const poll = opts.oidc.pollDevice(start, AbortSignal.any([opts.signal, stopPoll.signal])).finally(() => settled.abort());
431
- poll.catch(() => {
390
+ function requireCapability(meta, capability, apiOrigin) {
391
+ if (meta.capabilities.includes(capability)) return;
392
+ throw new QoreError("QORE_UNSUPPORTED_BY_SERVER", `This portal does not support ${capability} yet`, {
393
+ hint: `The portal at ${apiOrigin} is older than this qore. Try again once it has been updated.`,
394
+ details: { capability, portal: apiOrigin }
432
395
  });
433
- try {
434
- await opts.onCode(start, settled.signal);
435
- } catch (e) {
436
- stopPoll.abort();
437
- const late = await poll.catch(() => null);
438
- if (late) await opts.oidc.revoke(late.refreshToken).catch(() => {
439
- });
440
- throw e;
441
- }
442
- const t = await poll;
443
- const at = opts.now();
444
- const write = async () => {
445
- await opts.store.setRefresh({ rt: t.refreshToken, sub: t.sub, scope: t.scope, obtainedAt: at.toISOString() });
446
- await opts.store.setAccess({ at: t.accessToken, expiresAt: new Date(at.getTime() + t.expiresIn * 1e3).toISOString() });
447
- };
448
- try {
449
- if (opts.withLock && opts.lockName) await opts.withLock(opts.lockName, write);
450
- else await write();
451
- } catch (e) {
452
- await opts.oidc.revoke(t.refreshToken).catch(() => {
453
- });
454
- await opts.store.clear().catch(() => {
455
- });
456
- throw e;
457
- }
458
- return t;
459
396
  }
460
397
 
461
- // src/core/auth/tokenStore.ts
462
- var TokenStore = class {
463
- constructor(kc, hash) {
464
- this.kc = kc;
465
- this.hash = hash;
466
- }
467
- kc;
468
- hash;
469
- async read(suffix) {
470
- const raw = await this.kc.get(`${this.hash}:${suffix}`);
471
- if (raw === null) return null;
472
- try {
473
- return JSON.parse(raw);
474
- } catch {
475
- return null;
476
- }
477
- }
478
- getRefresh() {
479
- return this.read("refresh");
480
- }
481
- getAccess() {
482
- return this.read("access");
483
- }
484
- setRefresh(v) {
485
- return this.kc.set(`${this.hash}:refresh`, JSON.stringify(v));
486
- }
487
- setAccess(v) {
488
- return this.kc.set(`${this.hash}:access`, JSON.stringify(v));
489
- }
490
- async clear() {
491
- await this.kc.delete(`${this.hash}:access`);
492
- await this.kc.delete(`${this.hash}:refresh`);
493
- }
494
- };
495
-
496
398
  // src/core/profiles.ts
497
399
  import { createHash } from "crypto";
498
400
  var BUILTIN_PORTALS = {
@@ -543,29 +445,34 @@ var QoreApiClient = class {
543
445
  this.o = o;
544
446
  }
545
447
  o;
546
- async once(path, force) {
448
+ async once(method, path, body, force) {
547
449
  const token = await this.o.token(force);
450
+ const headers = {
451
+ authorization: `Bearer ${token}`,
452
+ accept: "application/json",
453
+ "qore-cli-version": this.o.cliVersion,
454
+ "qore-command": this.o.command,
455
+ "user-agent": `qore/${this.o.cliVersion} (${this.o.platform})`
456
+ };
457
+ if (body !== void 0) headers["content-type"] = "application/json";
548
458
  let res;
549
459
  try {
550
460
  res = await this.o.fetch(`${this.o.apiOrigin}${path}`, {
551
- headers: {
552
- authorization: `Bearer ${token}`,
553
- accept: "application/json",
554
- "qore-cli-version": this.o.cliVersion,
555
- "qore-command": this.o.command,
556
- "user-agent": `qore/${this.o.cliVersion} (${this.o.platform})`
557
- }
461
+ method,
462
+ headers,
463
+ body: body === void 0 ? void 0 : JSON.stringify(body)
558
464
  });
559
465
  } catch (e) {
560
466
  throw fetchError(e, new URL(this.o.apiOrigin).host);
561
467
  }
562
- this.o.onRequest?.(`GET ${path} \u2192 ${res.status}${res.headers.get("x-request-id") ? ` (${res.headers.get("x-request-id")})` : ""}`);
468
+ const rid = res.headers.get("x-request-id");
469
+ this.o.onRequest?.(`${method} ${path} \u2192 ${res.status}${rid ? ` (${rid})` : ""}`);
563
470
  return res;
564
471
  }
565
- async get(path) {
566
- let res = await this.once(path, false);
472
+ async request(method, path, body) {
473
+ let res = await this.once(method, path, body, false);
567
474
  if (res.status === 401) {
568
- res = await this.once(path, true);
475
+ res = await this.once(method, path, body, true);
569
476
  if (res.status === 401) {
570
477
  throw new QoreError("QORE_SESSION_EXPIRED", "The portal no longer accepts your session", {
571
478
  hint: "Run `qore login`.",
@@ -577,6 +484,12 @@ var QoreApiClient = class {
577
484
  if (!res.ok) throw await problemToError(res);
578
485
  return await res.json();
579
486
  }
487
+ get(path) {
488
+ return this.request("GET", path);
489
+ }
490
+ post(path, body) {
491
+ return this.request("POST", path, body);
492
+ }
580
493
  };
581
494
 
582
495
  // src/core/auth/refresh.ts
@@ -631,6 +544,41 @@ var RefreshCoordinator = class {
631
544
  }
632
545
  };
633
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
+
634
582
  // src/core/session.ts
635
583
  function loginHint(portal) {
636
584
  return portal.builtin === "prod" ? "Run `qore login`." : `Run \`qore login --portal ${portal.builtin ?? portal.apiOrigin}\`.`;
@@ -654,56 +602,1580 @@ async function openSession(deps2, portal, command, onRequest) {
654
602
  return { profile, api, tokens, refresh };
655
603
  }
656
604
 
657
- // src/cli/commands/login.ts
658
- function registerLogin(program, ctx) {
659
- program.command("login").description("Log in with your Qore account").option("--no-browser", "only print the URL").action(async (cmd) => {
660
- const { deps: deps2, io: io2, signal } = ctx;
661
- const g = ctx.opts();
662
- const cfg = await deps2.config.read();
663
- const portal = resolvePortal(g.portal, cfg.defaultProfile);
664
- const meta = await fetchMeta(deps2.fetch, portal.apiOrigin);
665
- const known = cfg.profiles[portal.apiOrigin]?.issuer;
666
- const pinned = { ...portal, pinnedIssuer: portal.pinnedIssuer ?? known ?? null };
667
- if (!pinned.pinnedIssuer && portal.builtin === null) {
668
- io2.err(`! Logging in to ${portal.apiOrigin}, identity provider ${meta.auth.issuer}`);
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
+ });
669
618
  }
670
- negotiate(meta, pinned, deps2.version);
671
- const issuer = pinned.pinnedIssuer ?? meta.auth.issuer;
672
- const hash = profileHash(portal.apiOrigin, issuer);
673
- const store = new TokenStore(deps2.keychain, hash);
674
- const t = await deviceLogin({
675
- oidc: await deps2.oidcFor(issuer),
676
- store,
677
- now: deps2.now,
678
- signal,
679
- withLock: deps2.withLock,
680
- lockName: hash,
681
- onCode: async (s, settled) => {
682
- const url = s.verificationUriComplete ?? s.verificationUri;
683
- io2.err(`! First copy your one-time code: ${s.userCode}`);
684
- if (io2.interactive && cmd.browser) {
685
- try {
686
- await io2.waitForEnter(` Press Enter to open ${url} (or open it yourself)`, AbortSignal.any([signal, settled]));
687
- } catch (e) {
688
- if (settled.aborted && !signal.aborted) return;
689
- throw e;
690
- }
691
- if (settled.aborted) return;
692
- if (!await io2.openUrl(url)) io2.err(` Open ${url} in a browser`);
693
- } else {
694
- io2.err(` Open ${url} in a browser`);
695
- }
696
- }
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)
697
636
  });
698
- await deps2.config.update((c) => {
699
- c.profiles[portal.apiOrigin] = {
700
- ...c.profiles[portal.apiOrigin],
701
- apiOrigin: portal.apiOrigin,
702
- issuer,
703
- sub: t.sub,
704
- email: t.email,
705
- meta: { fetchedAt: deps2.now().toISOString(), doc: meta }
706
- };
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
+
662
+ // src/core/context/cache.ts
663
+ import { join as join2 } from "path";
664
+
665
+ // src/core/jsonFile.ts
666
+ import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
667
+ import { join } from "path";
668
+ import { randomBytes } from "crypto";
669
+ async function readJson(file) {
670
+ let raw;
671
+ try {
672
+ raw = await readFile(file, "utf8");
673
+ } catch (e) {
674
+ if (e.code === "ENOENT") return null;
675
+ throw e;
676
+ }
677
+ return JSON.parse(raw);
678
+ }
679
+ async function writeJsonAtomic(dir2, name, data) {
680
+ await mkdir(dir2, { recursive: true, mode: 448 });
681
+ const tmp = join(dir2, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
682
+ await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
683
+ await chmod(tmp, 384);
684
+ await rename(tmp, join(dir2, name));
685
+ }
686
+
687
+ // src/core/context/cache.ts
688
+ var TTL_MS2 = 24 * 36e5;
689
+ var ResolutionCache = class {
690
+ constructor(dir2, now) {
691
+ this.dir = dir2;
692
+ this.now = now;
693
+ }
694
+ dir;
695
+ now;
696
+ async load() {
697
+ try {
698
+ const f = await readJson(join2(this.dir, "repos.json"));
699
+ if (f && f.schemaVersion === 1 && typeof f.entries === "object" && f.entries) return f;
700
+ } catch {
701
+ }
702
+ return { schemaVersion: 1, entries: {} };
703
+ }
704
+ fresh(e) {
705
+ return !!e && this.now().getTime() - new Date(e.resolvedAt).getTime() < TTL_MS2;
706
+ }
707
+ async get(origin, key) {
708
+ const e = (await this.load()).entries[`${origin}|${key}`];
709
+ return this.fresh(e) ? e.moduleId : null;
710
+ }
711
+ /** Never throws: a read-only config dir, a full disk or a Windows EPERM on rename
712
+ * (another qore holding the file) must not fail a command that already resolved. */
713
+ async put(origin, key, moduleId) {
714
+ const f = await this.load();
715
+ f.entries[`${origin}|${key}`] = { moduleId, resolvedAt: this.now().toISOString() };
716
+ for (const [k, e] of Object.entries(f.entries)) if (!this.fresh(e)) delete f.entries[k];
717
+ await this.save(f);
718
+ }
719
+ /** Never throws, like `put`. */
720
+ async drop(origin, key) {
721
+ const f = await this.load();
722
+ if (!(`${origin}|${key}` in f.entries)) return;
723
+ delete f.entries[`${origin}|${key}`];
724
+ await this.save(f);
725
+ }
726
+ async save(f) {
727
+ try {
728
+ await writeJsonAtomic(this.dir, "repos.json", f);
729
+ } catch {
730
+ }
731
+ }
732
+ async originsFor(keys) {
733
+ const f = await this.load();
734
+ const hits = [];
735
+ for (const [k, e] of Object.entries(f.entries)) {
736
+ const sep2 = k.lastIndexOf("|");
737
+ if (keys.includes(k.slice(sep2 + 1)) && this.fresh(e)) hits.push({ origin: k.slice(0, sep2), at: new Date(e.resolvedAt).getTime() });
738
+ }
739
+ hits.sort((a, b) => b.at - a.at);
740
+ return [...new Set(hits.map((h) => h.origin))];
741
+ }
742
+ };
743
+
744
+ // src/core/context/repoKey.ts
745
+ var SCP = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/;
746
+ var SCHEMES = /* @__PURE__ */ new Set(["https:", "http:", "ssh:", "git:"]);
747
+ var DOT_SEGMENTS = /* @__PURE__ */ new Set([".", "..", "%2e", "%2e%2e", ".%2e", "%2e."]);
748
+ var hasDotSegment = (pieces) => pieces.some((p) => DOT_SEGMENTS.has(p.toLowerCase()));
749
+ function repoKey(url) {
750
+ let raw = url.trim();
751
+ if (raw.startsWith("git+")) raw = raw.slice(4);
752
+ if (!raw || raw.includes("\\")) return null;
753
+ let host;
754
+ let path;
755
+ if (raw.includes("://")) {
756
+ if (hasDotSegment(raw.slice(raw.indexOf("://") + 3).split("/").slice(1))) return null;
757
+ let u;
758
+ try {
759
+ u = new URL(raw);
760
+ } catch {
761
+ return null;
762
+ }
763
+ if (!SCHEMES.has(u.protocol.toLowerCase())) return null;
764
+ host = u.hostname;
765
+ path = u.pathname;
766
+ } else {
767
+ const m = SCP.exec(raw);
768
+ if (!m) return null;
769
+ host = m[1];
770
+ path = m[2];
771
+ }
772
+ const segments = path.split("/").filter(Boolean);
773
+ if (!host || segments.length !== 2 || hasDotSegment(segments)) return null;
774
+ const owner = segments[0];
775
+ let repo = segments[1];
776
+ if (repo.toLowerCase().endsWith(".git")) repo = repo.slice(0, -4);
777
+ if (!repo) return null;
778
+ return `${host.toLowerCase()}/${owner.toLowerCase()}/${repo.toLowerCase()}`;
779
+ }
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
+
1345
+ // src/core/context/repoRoot.ts
1346
+ import { stat } from "fs/promises";
1347
+ import { dirname as dirname2, join as join8, resolve as resolve3 } from "path";
1348
+ async function findRepoRoot(start) {
1349
+ let dir2 = resolve3(start);
1350
+ for (; ; ) {
1351
+ try {
1352
+ await stat(join8(dir2, ".git"));
1353
+ return dir2;
1354
+ } catch {
1355
+ }
1356
+ const up = dirname2(dir2);
1357
+ if (up === dir2) return null;
1358
+ dir2 = up;
1359
+ }
1360
+ }
1361
+
1362
+ // src/core/context/resolver.ts
1363
+ var UUID3 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1364
+ var HINT_FLAG = "cd into a module repository, or pass --module <slug|id>.";
1365
+ var RESOLVE = "/api/cli/v1/repos/resolve";
1366
+ var SKIPPABLE = /* @__PURE__ */ new Set([
1367
+ "QORE_API_UNREACHABLE",
1368
+ "QORE_API_UNAVAILABLE",
1369
+ "QORE_AUTH_UNAVAILABLE",
1370
+ "QORE_NOT_LOGGED_IN",
1371
+ "QORE_SESSION_EXPIRED"
1372
+ ]);
1373
+ var label = (p) => p.builtin ?? p.apiOrigin;
1374
+ function byPreference(remotes2) {
1375
+ const rank = (r) => r.name === "origin" ? 0 : r.name === "upstream" ? 1 : 2;
1376
+ return [...remotes2].sort((a, b) => rank(a) - rank(b));
1377
+ }
1378
+ var preferred = (remotes2) => byPreference(remotes2)[0];
1379
+ async function repoRemotes(deps2) {
1380
+ const root = await findRepoRoot(deps2.cwd);
1381
+ if (!root) return null;
1382
+ let remotes2;
1383
+ try {
1384
+ remotes2 = await deps2.git.remotes(root);
1385
+ } catch (e) {
1386
+ if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") return { root, keyed: [], gitError: e };
1387
+ throw e;
1388
+ }
1389
+ const keyed = remotes2.map((r) => ({ ...r, key: repoKey(r.url) })).filter((r) => r.key !== null);
1390
+ return { root, keyed };
1391
+ }
1392
+ function detail(session, id) {
1393
+ return session.api.get(`/api/cli/v1/modules/${encodeURIComponent(id)}`);
1394
+ }
1395
+ async function flagOverrides(deps2, session, portal, cache, moduleId) {
1396
+ try {
1397
+ const keyed = (await repoRemotes(deps2))?.keyed ?? [];
1398
+ const derived = /* @__PURE__ */ new Map();
1399
+ const uncached = [];
1400
+ for (const key of new Set(keyed.map((r) => r.key))) {
1401
+ const id = await cache.get(portal.apiOrigin, key);
1402
+ if (id) derived.set(key, /* @__PURE__ */ new Set([id]));
1403
+ else uncached.push(key);
1404
+ }
1405
+ if (uncached.length > 0) {
1406
+ try {
1407
+ const { matches } = await session.api.post(RESOLVE, { repoKeys: uncached });
1408
+ for (const m of matches) derived.set(m.repoKey, (derived.get(m.repoKey) ?? /* @__PURE__ */ new Set()).add(m.module.id));
1409
+ const ids = new Set(matches.map((m) => m.module.id));
1410
+ if (ids.size === 1) for (const m of matches) await cache.put(portal.apiOrigin, m.repoKey, m.module.id);
1411
+ } catch (e) {
1412
+ if (e instanceof CancelledError) throw e;
1413
+ }
1414
+ }
1415
+ return byPreference(keyed).find((r) => [...derived.get(r.key) ?? []].some((id) => id !== moduleId))?.name;
1416
+ } catch (e) {
1417
+ if (e instanceof CancelledError) throw e;
1418
+ return void 0;
1419
+ }
1420
+ }
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);
1439
+ const session = await openSession(deps2, portal, o.command, o.onRequest);
1440
+ await ensureCapability(deps2, portal.apiOrigin, "repo.resolve");
1441
+ let id = o.module;
1442
+ if (!UUID3.test(id)) {
1443
+ let tenantId = o.tenant ?? session.profile.activeTenant?.id;
1444
+ if (!tenantId) {
1445
+ const me = await session.api.get("/api/cli/v1/me");
1446
+ if (me.tenants.length === 0) {
1447
+ throw new QoreError("QORE_NO_DEVELOPER_ACCESS", "Your account is not a developer in any organisation", {
1448
+ hint: "Ask an admin to invite you as a developer."
1449
+ });
1450
+ }
1451
+ if (me.tenants.length > 1) {
1452
+ const candidates = me.tenants.map((t) => ({ kind: "tenant", id: t.id, slug: t.slug, name: t.name }));
1453
+ throw new QoreError("QORE_CONTEXT_REQUIRED", `Which tenant is "${id}" in?`, {
1454
+ hint: "Run `qore tenant use <slug>`, or pass the module id.",
1455
+ candidates
1456
+ });
1457
+ }
1458
+ tenantId = me.tenants[0].id;
1459
+ }
1460
+ id = (await session.api.get(
1461
+ `/api/cli/v1/tenants/${encodeURIComponent(tenantId)}/modules/resolve?slug=${encodeURIComponent(id)}`
1462
+ )).id;
1463
+ }
1464
+ const module = await detail(session, id);
1465
+ const overrides = await flagOverrides(deps2, session, portal, cache, module.id);
1466
+ const repoRoot = await findRepoRoot(deps2.cwd);
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
+ });
1497
+ }
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) {
1503
+ if (portalArg) return [resolvePortal(portalArg, cfg.defaultProfile)];
1504
+ const rank = (o) => o === BUILTIN_PORTALS.prod.apiOrigin ? 0 : o === BUILTIN_PORTALS.staging.apiOrigin ? 1 : 2;
1505
+ const rest = Object.keys(cfg.profiles).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
1506
+ const cached = cachedOrigins.filter((c) => cfg.profiles[c]);
1507
+ const origins = [...wsPortal ? [wsPortal.apiOrigin] : [], ...cached, resolvePortal(void 0, cfg.defaultProfile).apiOrigin, ...rest];
1508
+ return [...new Set(origins)].filter((c) => cfg.profiles[c]).map((c) => resolvePortal(c, void 0));
1509
+ }
1510
+ function withDetails(e, extra, hint) {
1511
+ return new QoreError(e.code, e.message, {
1512
+ hint: hint ?? e.hint,
1513
+ status: e.status,
1514
+ requestId: e.requestId,
1515
+ candidates: e.candidates,
1516
+ serverCode: e.serverCode,
1517
+ details: { ...e.details, ...extra },
1518
+ cause: e
1519
+ });
1520
+ }
1521
+ async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, workspace, unsupported) {
1522
+ const session = await openSession(deps2, portal, o.command, o.onRequest);
1523
+ try {
1524
+ await ensureCapability(deps2, portal.apiOrigin, "repo.resolve");
1525
+ } catch (e) {
1526
+ if (!(e instanceof QoreError) || e.code !== "QORE_UNSUPPORTED_BY_SERVER") throw e;
1527
+ unsupported(e);
1528
+ return null;
1529
+ }
1530
+ const hits = [];
1531
+ for (const r of byPreference(keyed)) {
1532
+ const id2 = await cache.get(portal.apiOrigin, r.key);
1533
+ if (id2) hits.push({ remote: r, id: id2 });
1534
+ }
1535
+ if (hits.length > 0 && hits.every((h) => h.id === hits[0].id)) {
1536
+ const { remote: r, id: id2 } = hits[0];
1537
+ try {
1538
+ return { portal, session, module: await detail(session, id2), source: { kind: "remote", remote: r.name, url: r.url }, repoRoot, workspace };
1539
+ } catch (e) {
1540
+ if (!(e instanceof QoreError) || e.code !== "QORE_RESOURCE_NOT_FOUND") throw e;
1541
+ for (const h of hits) await cache.drop(portal.apiOrigin, h.remote.key);
1542
+ }
1543
+ }
1544
+ const { matches } = await session.api.post(RESOLVE, { repoKeys: keys });
1545
+ const ids = [...new Set(matches.map((m) => m.module.id))];
1546
+ if (ids.length === 0) return null;
1547
+ let id = ids[0];
1548
+ if (ids.length > 1) {
1549
+ if (o.choose && ids.includes(o.choose)) id = o.choose;
1550
+ else {
1551
+ const candidates = ids.map((mid) => {
1552
+ const own = matches.filter((m2) => m2.module.id === mid);
1553
+ const m = own[0];
1554
+ const remote2 = preferred(keyed.filter((r) => own.some((x) => x.repoKey === r.key))).name;
1555
+ return { kind: "module", id: m.module.id, slug: m.module.slug, name: m.module.name, remote: remote2, tenant: m.tenant.slug };
1556
+ });
1557
+ throw new QoreError("QORE_CONTEXT_REQUIRED", "This repository's remotes match several modules", {
1558
+ hint: "Pass --module <slug|id>.",
1559
+ candidates
1560
+ });
1561
+ }
1562
+ }
1563
+ const matchedKeys = new Set(matches.filter((m) => m.module.id === id).map((m) => m.repoKey));
1564
+ if (ids.length === 1) for (const k of matchedKeys) await cache.put(portal.apiOrigin, k, id);
1565
+ const remote = preferred(keyed.filter((r) => matchedKeys.has(r.key)));
1566
+ return { portal, session, module: await detail(session, id), source: { kind: "remote", remote: remote.name, url: remote.url }, repoRoot, workspace };
1567
+ }
1568
+ async function anyLoggedIn(deps2, cfg) {
1569
+ for (const p of Object.values(cfg.profiles)) {
1570
+ if (await new TokenStore(deps2.keychain, profileHash(p.apiOrigin, p.issuer)).getRefresh()) return true;
1571
+ }
1572
+ return false;
1573
+ }
1574
+ async function resolveModuleContext(deps2, o) {
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);
1578
+ const cfg = await deps2.config.read();
1579
+ const first = resolvePortal(o.portal, cfg.defaultProfile);
1580
+ const wsPortal = ws && !o.portal ? await workspacePortal(deps2, ws) : void 0;
1581
+ if (o.portal || !await anyLoggedIn(deps2, cfg)) {
1582
+ await openSession(deps2, first, o.command);
1583
+ }
1584
+ if (ws && await inWorkspaceRoot(deps2, ws)) return inWorkspace(deps2, o, ws, wsPortal ?? first);
1585
+ const repo = await repoRemotes(deps2);
1586
+ if (repo === null) throw new QoreError("QORE_CONTEXT_REQUIRED", "Not inside a module repository", { hint: HINT_FLAG });
1587
+ const { root, keyed, gitError } = repo;
1588
+ if (keyed.length === 0) {
1589
+ const said = gitError?.hint;
1590
+ throw new QoreError("QORE_CONTEXT_REQUIRED", "This repository has no remote Qore can match", {
1591
+ hint: said ? `${HINT_FLAG} git said: ${said}` : HINT_FLAG,
1592
+ details: gitError ? { git: said ?? gitError.message } : void 0,
1593
+ cause: gitError
1594
+ });
1595
+ }
1596
+ const keys = [...new Set(keyed.map((r) => r.key))];
1597
+ const tried = [];
1598
+ const answered = [];
1599
+ const skipped = [];
1600
+ const skipErrors = [];
1601
+ let unsupported;
1602
+ for (const portal of profileOrder(cfg, await cache.originsFor(keys), o.portal, wsPortal)) {
1603
+ tried.push(label(portal));
1604
+ let capabilityMissing = false;
1605
+ try {
1606
+ const mc = await onProfile(deps2, o, cache, portal, keyed, keys, root, ws, (e) => {
1607
+ capabilityMissing = true;
1608
+ unsupported ??= e;
1609
+ skipped.push({ profile: label(portal), code: e.code, message: e.message });
1610
+ });
1611
+ if (mc) return mc;
1612
+ if (!capabilityMissing) answered.push(label(portal));
1613
+ } catch (e) {
1614
+ if (!(e instanceof QoreError) || !SKIPPABLE.has(e.code)) throw e;
1615
+ skipped.push({ profile: label(portal), code: e.code, message: e.message });
1616
+ skipErrors.push(e);
1617
+ }
1618
+ }
1619
+ const details = { triedProfiles: tried };
1620
+ if (skipped.length > 0) details.skippedProfiles = skipped;
1621
+ if (answered.length === 0) {
1622
+ const cause = skipErrors[0] ?? unsupported;
1623
+ if (cause) throw withDetails(cause, details);
1624
+ }
1625
+ const unchecked = skipped.length ? ` ${skipped.map((s) => s.profile).join(", ")} could not be checked (${skipped.map((s) => s.code).join(", ")}).` : "";
1626
+ const stagingLoggedIn = Boolean(cfg.profiles[BUILTIN_PORTALS.staging.apiOrigin]) && !skipped.some((s) => s.profile === "staging" && (s.code === "QORE_NOT_LOGGED_IN" || s.code === "QORE_SESSION_EXPIRED"));
1627
+ throw new QoreError("QORE_CONTEXT_REQUIRED", "No module selected", {
1628
+ hint: `This repository's remotes match no module you can access on ${answered.join(", ")}; pass --module <slug|id>.` + unchecked + (stagingLoggedIn ? "" : " If it is a staging repository, run `qore login --portal staging`."),
1629
+ details
1630
+ });
1631
+ }
1632
+ var appCandidates = (apps) => apps.map((a) => ({ kind: "app", id: a.id, slug: a.slug, name: a.name }));
1633
+ function pickApp(mc, app) {
1634
+ const apps = mc.module.apps;
1635
+ if (app) {
1636
+ const found = apps.find((a) => a.id === app || a.slug === app);
1637
+ if (!found) {
1638
+ throw new QoreError("QORE_RESOURCE_NOT_FOUND", `${mc.module.slug} is not part of an app "${app}" you can read`, {
1639
+ candidates: appCandidates(apps)
1640
+ });
1641
+ }
1642
+ return found;
1643
+ }
1644
+ if (apps.length === 1) return apps[0];
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;
1648
+ throw new QoreError("QORE_APP_AMBIGUOUS", `${mc.module.slug} is part of several apps`, { hint: "Pass --app <slug>.", candidates: appCandidates(apps) });
1649
+ }
1650
+
1651
+ // src/core/env/fetch.ts
1652
+ async function fetchLocalEnv(deps2, mc, app) {
1653
+ await ensureCapability(deps2, mc.portal.apiOrigin, "module.local-env");
1654
+ try {
1655
+ return await mc.session.api.get(
1656
+ `/api/cli/v1/modules/${encodeURIComponent(mc.module.id)}/local-env?app_id=${encodeURIComponent(app.id)}`
1657
+ );
1658
+ } catch (e) {
1659
+ if (!(e instanceof QoreError) || e.code !== "QORE_ACCESS_DENIED") throw e;
1660
+ if (e.serverCode === "app.access.denied") {
1661
+ throw withDetails(
1662
+ e,
1663
+ { required: "write", have: mc.module.access },
1664
+ `You have ${mc.module.access} on ${mc.module.slug}; local secrets need write. Ask a maintainer of the app's dev team for write access.`
1665
+ );
1666
+ }
1667
+ if (e.serverCode === "auth.scope.insufficient") throw withDetails(e, {}, "Run `qore login` again.");
1668
+ throw e;
1669
+ }
1670
+ }
1671
+
1672
+ // src/core/env/merge.ts
1673
+ function parseEnv(contents) {
1674
+ const map = /* @__PURE__ */ new Map();
1675
+ for (const line2 of contents.split("\n")) {
1676
+ const m = line2.replace(/\r$/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
1677
+ if (m?.[1] !== void 0 && m[2] !== void 0) map.set(m[1], m[2]);
1678
+ }
1679
+ return map;
1680
+ }
1681
+ function isManagedKey(key, managed) {
1682
+ for (const pattern of managed) {
1683
+ if (pattern.endsWith("*")) {
1684
+ if (key.startsWith(pattern.slice(0, -1))) return true;
1685
+ } else if (key === pattern) return true;
1686
+ }
1687
+ return false;
1688
+ }
1689
+ function planEnvUpdate({
1690
+ current,
1691
+ fetched,
1692
+ choices = {},
1693
+ defaultChoice = "keep",
1694
+ managed = []
1695
+ }) {
1696
+ const merged = new Map(current);
1697
+ const conflicts = [];
1698
+ const added = [];
1699
+ const unchanged = [];
1700
+ const overwritten = [];
1701
+ for (const [key, portalValue] of Object.entries(fetched)) {
1702
+ const hasLocal = current.has(key);
1703
+ const localValue = current.get(key);
1704
+ const localEmpty = !hasLocal || localValue === "" || localValue === '""' || localValue === "''";
1705
+ if (localEmpty) {
1706
+ merged.set(key, portalValue);
1707
+ added.push(key);
1708
+ continue;
1709
+ }
1710
+ if (localValue === portalValue) {
1711
+ unchanged.push(key);
1712
+ continue;
1713
+ }
1714
+ if (isManagedKey(key, managed)) {
1715
+ merged.set(key, portalValue);
1716
+ overwritten.push(key);
1717
+ continue;
1718
+ }
1719
+ conflicts.push(key);
1720
+ const choice = choices[key] ?? defaultChoice;
1721
+ merged.set(key, choice === "use-portal" ? portalValue : localValue);
1722
+ }
1723
+ return { merged, conflicts, added, unchanged, overwritten };
1724
+ }
1725
+ function renderEnv(original, merged) {
1726
+ const seen = /* @__PURE__ */ new Set();
1727
+ const lines = original.split("\n");
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;
1731
+ const key = m[1];
1732
+ if (!merged.has(key)) return line2;
1733
+ seen.add(key);
1734
+ return `${key}=${merged.get(key) ?? ""}`;
1735
+ });
1736
+ const appended = [];
1737
+ for (const [key, value] of merged) {
1738
+ if (!seen.has(key)) appended.push(`${key}=${value}`);
1739
+ }
1740
+ let text = out.join("\n");
1741
+ if (appended.length) {
1742
+ if (text.length && !text.endsWith("\n")) text += "\n";
1743
+ text += appended.join("\n") + "\n";
1744
+ }
1745
+ return text;
1746
+ }
1747
+
1748
+ // src/core/env/pull.ts
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";
1751
+ var outside = (target) => new QoreError("QORE_CONTEXT_INVALID", `The env file must be inside the repository (got ${target})`, {
1752
+ hint: "Use a path inside the repository for --target or .qore.json envFile."
1753
+ });
1754
+ var symlinked = (target) => new QoreError("QORE_CONTEXT_INVALID", `The env file target must not be a symlink (got ${target})`, {
1755
+ hint: "Use a path inside the repository for --target or .qore.json envFile."
1756
+ });
1757
+ function inside(root, p) {
1758
+ const rel = relative4(root, p);
1759
+ return rel !== "" && rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute2(rel);
1760
+ }
1761
+ async function resolveTarget(root, target, base = root) {
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);
1766
+ let parent;
1767
+ try {
1768
+ parent = await realpath2(dirname3(abs));
1769
+ } catch (e) {
1770
+ if (e.code !== "ENOENT") throw e;
1771
+ throw new QoreError("QORE_CONTEXT_INVALID", `The directory for ${target} does not exist`, {
1772
+ hint: "Create the parent directory, or point --target / .qore.json envFile at an existing one."
1773
+ });
1774
+ }
1775
+ const final = resolve4(parent, basename(abs));
1776
+ if (!inside(realRoot, final)) throw outside(target);
1777
+ try {
1778
+ const st = await lstat(final);
1779
+ if (st.isSymbolicLink()) throw symlinked(target);
1780
+ const realFinal = await realpath2(final);
1781
+ if (!inside(realRoot, realFinal)) throw outside(target);
1782
+ } catch (e) {
1783
+ if (e instanceof QoreError) throw e;
1784
+ if (e.code !== "ENOENT") throw e;
1785
+ }
1786
+ return final;
1787
+ }
1788
+ function planPull(o) {
1789
+ const original = o.existing ?? o.example ?? "";
1790
+ const current = parseEnv(original);
1791
+ const r = planEnvUpdate({ current, fetched: o.fetched, choices: o.choices ?? {}, defaultChoice: o.defaultChoice, managed: o.managed });
1792
+ const choiceOf = (k) => o.choices?.[k] ?? o.defaultChoice;
1793
+ const generated = [];
1794
+ for (const key of o.localSecrets) {
1795
+ const v = r.merged.get(key);
1796
+ if (v === void 0 || v === "" || v === '""' || v === "''") {
1797
+ r.merged.set(key, o.random());
1798
+ generated.push(key);
1799
+ }
1800
+ }
1801
+ return {
1802
+ text: renderEnv(original, r.merged),
1803
+ created: o.existing === null,
1804
+ seededFromExample: o.existing === null && o.example !== null,
1805
+ added: r.added,
1806
+ updated: r.overwritten,
1807
+ unchanged: r.unchanged,
1808
+ conflicts: r.conflicts,
1809
+ kept: r.conflicts.filter((k) => choiceOf(k) === "keep"),
1810
+ replaced: r.conflicts.filter((k) => choiceOf(k) === "use-portal"),
1811
+ generated
1812
+ };
1813
+ }
1814
+
1815
+ // src/cli/context.ts
1816
+ var allOfKind = (c, kind) => !!c && c.length > 0 && c.every((x) => x.kind === kind && typeof x.id === "string");
1817
+ async function moduleContextFor(ctx, flags, command) {
1818
+ const g = ctx.opts();
1819
+ const onRequest = g.verbose ? (l) => ctx.io.err(l) : void 0;
1820
+ const base = { portal: g.portal, module: flags.module, command, onRequest };
1821
+ try {
1822
+ return await resolveModuleContext(ctx.deps, base);
1823
+ } catch (e) {
1824
+ if (!(e instanceof QoreError) || e.code !== "QORE_CONTEXT_REQUIRED" || !ctx.io.interactive) throw e;
1825
+ if (allOfKind(e.candidates, "module")) {
1826
+ const choose = await ctx.io.select(
1827
+ "Select a module",
1828
+ e.candidates.map((c) => ({ name: `${c.tenant ? `${c.tenant} / ` : ""}${c.slug} (${c.remote})`, value: c.id }))
1829
+ );
1830
+ return resolveModuleContext(ctx.deps, { ...base, choose });
1831
+ }
1832
+ if (allOfKind(e.candidates, "tenant")) {
1833
+ const tenant = await ctx.io.select("Select a tenant", e.candidates.map((c) => ({ name: c.name ?? c.slug, value: c.id })));
1834
+ return resolveModuleContext(ctx.deps, { ...base, tenant });
1835
+ }
1836
+ throw e;
1837
+ }
1838
+ }
1839
+ async function appFor(ctx, mc, app) {
1840
+ try {
1841
+ return pickApp(mc, app);
1842
+ } catch (e) {
1843
+ if (!(e instanceof QoreError) || e.code !== "QORE_APP_AMBIGUOUS" || !ctx.io.interactive) throw e;
1844
+ const id = await ctx.io.select("Select an app", mc.module.apps.map((a) => ({ name: a.name, value: a.id })));
1845
+ return pickApp(mc, id);
1846
+ }
1847
+ }
1848
+ function sourceText(mc) {
1849
+ if (mc.source.kind === "remote") return mc.source.remote;
1850
+ if (mc.source.kind === "workspace") return WORKSPACE_FILE;
1851
+ return mc.source.overrides ? `--module, overrides ${mc.source.overrides}` : "--module";
1852
+ }
1853
+ function contextLine(mc, app) {
1854
+ const parts = [mc.module.tenant.name, ...app ? [app.name] : [], mc.module.slug];
1855
+ return `\u2192 ${parts.join(" / ")} (${sourceText(mc)})`;
1856
+ }
1857
+ function contextJson(mc, app) {
1858
+ const { id, slug, name, type, access } = mc.module;
1859
+ return {
1860
+ portal: mc.portal.builtin ?? mc.portal.apiOrigin,
1861
+ tenant: mc.module.tenant,
1862
+ app: app ?? null,
1863
+ module: { id, slug, name, type, access },
1864
+ source: mc.source.kind === "remote" ? mc.source.remote : mc.source.kind === "workspace" ? WORKSPACE_FILE : "--module",
1865
+ ...mc.source.kind === "flag" && mc.source.overrides ? { overrides: mc.source.overrides } : {}
1866
+ };
1867
+ }
1868
+
1869
+ // src/cli/trustPrompt.ts
1870
+ async function ensureTrusted(ctx, mc, app, o) {
1871
+ const root = mc.repoRoot ?? ctx.deps.cwd;
1872
+ if (!ctx.opts().json) ctx.io.err(contextLine(mc, app));
1873
+ if (await isTrusted(ctx.deps, root, mc.module.id)) return;
1874
+ if (o.yes) {
1875
+ await trust(ctx.deps, root, mc.module.id);
1876
+ return;
1877
+ }
1878
+ if (!ctx.io.interactive) {
1879
+ throw new QoreError("QORE_CONTEXT_REQUIRED", `This repository is not trusted for ${mc.module.slug} yet`, {
1880
+ hint: "Run interactively once, or pass --yes.",
1881
+ details: { reason: "untrusted", repository: root }
1882
+ });
1883
+ }
1884
+ if (mc.source.kind === "remote") {
1885
+ const key = repoKey(mc.source.url);
1886
+ ctx.io.err(` from remote ${mc.source.remote}: ${key ?? "(unparsable remote URL)"}`);
1887
+ }
1888
+ if (!await ctx.io.confirm("Pull local secrets for this module into this repository?")) throw new CancelledError();
1889
+ await trust(ctx.deps, root, mc.module.id);
1890
+ }
1891
+
1892
+ // src/cli/commands/env.ts
1893
+ var maskLen = (v) => v === void 0 ? "(unset)" : `\u2022\u2022\u2022\u2022 (${v.length} chars)`;
1894
+ async function readOrNull(p) {
1895
+ try {
1896
+ return await readFile4(p, "utf8");
1897
+ } catch (e) {
1898
+ if (e.code === "ENOENT") return null;
1899
+ throw e;
1900
+ }
1901
+ }
1902
+ async function writeSecurely(target, text) {
1903
+ const base = basename2(target);
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);
1906
+ try {
1907
+ await writeFile3(tmp, text, { mode: 384, flag: "wx" });
1908
+ await chmod2(tmp, 384);
1909
+ await rename3(tmp, target);
1910
+ } finally {
1911
+ await unlink(tmp).catch(() => {
1912
+ });
1913
+ }
1914
+ }
1915
+ function registerEnv(program, ctx) {
1916
+ const env = program.command("env").description("Local environment");
1917
+ env.command("pull").description("Write this module's local secrets into its env file").option("--module <slug|id>", "the module (default: from this repository's git remote)").option("--app <slug|id>", "the app, when the module is part of several").option("--target <path>", "env file to write (inside the repository)").option("--use-portal", "take the portal's value for keys that differ locally").option("--yes", "trust this repository without asking").action(async (f) => {
1918
+ const { deps: deps2, io: io2 } = ctx;
1919
+ const g = ctx.opts();
1920
+ const mc = await moduleContextFor(ctx, f, "env pull");
1921
+ if (!mc.repoRoot) {
1922
+ throw new QoreError("QORE_CONTEXT_REQUIRED", "env pull writes into a module repository", { hint: "cd into the module's repository." });
1923
+ }
1924
+ const app = await appFor(ctx, mc, f.app);
1925
+ const cfg = await readRepoConfig(mc.repoRoot);
1926
+ const defaults = moduleDefaults(mc.module.type);
1927
+ const target = f.target ? await resolveTarget(mc.repoRoot, f.target, deps2.cwd) : await resolveTarget(mc.repoRoot, cfg.envFile ?? defaults.envFile);
1928
+ await ensureTrusted(ctx, mc, app, { yes: f.yes });
1929
+ const existing = await readOrNull(target);
1930
+ const example = existing === null ? await readOrNull(join10(dirname4(target), ".env.example")) : null;
1931
+ const local = await fetchLocalEnv(deps2, mc, app);
1932
+ const inputs = {
1933
+ existing,
1934
+ example,
1935
+ fetched: local.env,
1936
+ managed: local.managedKeys,
1937
+ localSecrets: cfg.localSecrets ?? defaults.localSecrets,
1938
+ defaultChoice: f.usePortal ? "use-portal" : "keep",
1939
+ random: () => randomBytes3(32).toString("hex")
1940
+ };
1941
+ let plan = planPull(inputs);
1942
+ if (plan.conflicts.length && io2.interactive && !f.usePortal) {
1943
+ const current = parseEnv(existing ?? example ?? "");
1944
+ const choices = {};
1945
+ let bulk;
1946
+ for (const key of plan.conflicts) {
1947
+ if (bulk) {
1948
+ choices[key] = bulk;
1949
+ continue;
1950
+ }
1951
+ const pick = await io2.select(
1952
+ `${key} differs (local ${maskLen(current.get(key))}, portal ${maskLen(local.env[key])})`,
1953
+ [
1954
+ { name: "keep local", value: "keep" },
1955
+ { name: "use portal", value: "portal" },
1956
+ { name: "use portal for all", value: "all" },
1957
+ { name: "keep local for all", value: "skip" }
1958
+ ]
1959
+ );
1960
+ if (pick === "all") bulk = "use-portal";
1961
+ if (pick === "skip") bulk = "keep";
1962
+ choices[key] = pick === "portal" || pick === "all" ? "use-portal" : "keep";
1963
+ }
1964
+ plan = planPull({ ...inputs, choices });
1965
+ }
1966
+ if (existing !== null && plan.text === existing) {
1967
+ await chmod2(target, 384);
1968
+ } else {
1969
+ await writeSecurely(target, plan.text);
1970
+ }
1971
+ const rel = relative5(await realpath3(mc.repoRoot), target);
1972
+ if (g.json) {
1973
+ const names = {
1974
+ created: plan.created,
1975
+ seededFromExample: plan.seededFromExample,
1976
+ added: plan.added,
1977
+ updated: plan.updated,
1978
+ kept: plan.kept,
1979
+ replaced: plan.replaced,
1980
+ unchanged: plan.unchanged,
1981
+ generated: plan.generated
1982
+ };
1983
+ renderJson(io2, { context: contextJson(mc, app), target: rel, ...names });
1984
+ return;
1985
+ }
1986
+ if (plan.kept.length && !io2.interactive) {
1987
+ io2.err(`! ${plan.kept.length} key(s) differ locally and were kept: ${plan.kept.join(", ")} \u2014 --use-portal takes the portal's values`);
1988
+ }
1989
+ const parts = [
1990
+ plan.added.length && `${plan.added.length} added`,
1991
+ plan.updated.length && `${plan.updated.length} updated (managed)`,
1992
+ plan.replaced.length && `${plan.replaced.length} replaced`,
1993
+ plan.kept.length && `${plan.kept.length} kept (differs locally; --use-portal to overwrite)`,
1994
+ plan.generated.length && `${plan.generated.length} generated`
1995
+ ].filter(Boolean);
1996
+ io2.out(`\u2713 ${rel}${plan.seededFromExample ? " (created from .env.example)" : ""} \u2014 ${parts.length ? parts.join(", ") : "up to date"}`);
1997
+ });
1998
+ }
1999
+
2000
+ // src/core/auth/oidc.ts
2001
+ import * as oc from "openid-client";
2002
+ var LOGIN_SCOPE = "openid profile email offline_access qore:read qore:local-env";
2003
+ function decodeSub(accessToken) {
2004
+ const part = accessToken.split(".")[1] ?? "";
2005
+ const claims = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
2006
+ return { sub: claims.sub, email: claims.email };
2007
+ }
2008
+ function toTokenSet(t, fallbackRt) {
2009
+ const rt = t.refresh_token ?? fallbackRt;
2010
+ if (!rt) throw new QoreError("QORE_INTERNAL", "Keycloak returned no refresh token");
2011
+ return {
2012
+ accessToken: t.access_token,
2013
+ refreshToken: rt,
2014
+ expiresIn: t.expires_in ?? 600,
2015
+ scope: t.scope ?? "",
2016
+ ...decodeSub(t.access_token)
2017
+ };
2018
+ }
2019
+ function unreachable(e, host) {
2020
+ const n = networkError(e, host);
2021
+ return new QoreError("QORE_AUTH_UNAVAILABLE", n.message, { hint: n.hint, details: n.details, cause: e });
2022
+ }
2023
+ function mapOidcError(e, host) {
2024
+ if (e instanceof QoreError || e instanceof CancelledError) return e;
2025
+ if (isAbortError(e) || e?.code === "OAUTH_ABORT" || isAbortError(e?.cause)) {
2026
+ return new CancelledError();
2027
+ }
2028
+ const err = e.error;
2029
+ if (err === "access_denied") return new QoreError("QORE_LOGIN_DENIED", "The login was denied in the browser");
2030
+ if (err === "expired_token") return new QoreError("QORE_LOGIN_EXPIRED", "The login code expired", { hint: "Run `qore login` again." });
2031
+ if (err === "invalid_grant") return new QoreError("QORE_SESSION_EXPIRED", "Your session has ended", { hint: "Run `qore login`." });
2032
+ const status2 = e.status;
2033
+ if (status2 && status2 >= 500) return new QoreError("QORE_AUTH_UNAVAILABLE", "Keycloak is unavailable", { cause: e });
2034
+ if (e instanceof TypeError) return unreachable(e, host);
2035
+ const cause = e?.cause;
2036
+ if (cause instanceof TypeError) return unreachable(cause, host);
2037
+ return new QoreError("QORE_AUTH_UNAVAILABLE", "Login failed", { cause: e });
2038
+ }
2039
+ async function createOidc(opts) {
2040
+ const host = new URL(opts.issuer).host;
2041
+ const execute = new URL(opts.issuer).protocol === "http:" ? [oc.allowInsecureRequests] : [];
2042
+ let config;
2043
+ try {
2044
+ config = await oc.discovery(new URL(opts.issuer), opts.clientId, void 0, oc.None(), {
2045
+ execute,
2046
+ [oc.customFetch]: opts.fetch
2047
+ });
2048
+ } catch (e) {
2049
+ throw mapOidcError(e, host);
2050
+ }
2051
+ return {
2052
+ async startDevice(scope) {
2053
+ try {
2054
+ const r = await oc.initiateDeviceAuthorization(config, { scope });
2055
+ return {
2056
+ userCode: r.user_code,
2057
+ verificationUri: r.verification_uri,
2058
+ verificationUriComplete: r.verification_uri_complete,
2059
+ expiresIn: r.expires_in,
2060
+ handle: r
2061
+ };
2062
+ } catch (e) {
2063
+ throw mapOidcError(e, host);
2064
+ }
2065
+ },
2066
+ async pollDevice(start, signal) {
2067
+ try {
2068
+ const t = await oc.pollDeviceAuthorizationGrant(config, start.handle, void 0, { signal });
2069
+ return toTokenSet(t);
2070
+ } catch (e) {
2071
+ throw mapOidcError(e, host);
2072
+ }
2073
+ },
2074
+ async refresh(rt, scope) {
2075
+ try {
2076
+ const t = await oc.refreshTokenGrant(config, rt, scope ? { scope } : void 0);
2077
+ return toTokenSet(t, rt);
2078
+ } catch (e) {
2079
+ throw mapOidcError(e, host);
2080
+ }
2081
+ },
2082
+ async revoke(rt) {
2083
+ try {
2084
+ await oc.tokenRevocation(config, rt, { token_type_hint: "refresh_token" });
2085
+ } catch (e) {
2086
+ throw mapOidcError(e, host);
2087
+ }
2088
+ }
2089
+ };
2090
+ }
2091
+
2092
+ // src/core/auth/login.ts
2093
+ async function deviceLogin(opts) {
2094
+ await opts.store.getRefresh();
2095
+ const start = await opts.oidc.startDevice(LOGIN_SCOPE);
2096
+ const stopPoll = new AbortController();
2097
+ const settled = new AbortController();
2098
+ const poll = opts.oidc.pollDevice(start, AbortSignal.any([opts.signal, stopPoll.signal])).finally(() => settled.abort());
2099
+ poll.catch(() => {
2100
+ });
2101
+ try {
2102
+ await opts.onCode(start, settled.signal);
2103
+ } catch (e) {
2104
+ stopPoll.abort();
2105
+ const late = await poll.catch(() => null);
2106
+ if (late) await opts.oidc.revoke(late.refreshToken).catch(() => {
2107
+ });
2108
+ throw e;
2109
+ }
2110
+ const t = await poll;
2111
+ const at = opts.now();
2112
+ const write = async () => {
2113
+ await opts.store.setRefresh({ rt: t.refreshToken, sub: t.sub, scope: t.scope, obtainedAt: at.toISOString() });
2114
+ await opts.store.setAccess({ at: t.accessToken, expiresAt: new Date(at.getTime() + t.expiresIn * 1e3).toISOString() });
2115
+ };
2116
+ try {
2117
+ if (opts.withLock && opts.lockName) await opts.withLock(opts.lockName, write);
2118
+ else await write();
2119
+ } catch (e) {
2120
+ await opts.oidc.revoke(t.refreshToken).catch(() => {
2121
+ });
2122
+ await opts.store.clear().catch(() => {
2123
+ });
2124
+ throw e;
2125
+ }
2126
+ return t;
2127
+ }
2128
+
2129
+ // src/cli/commands/login.ts
2130
+ function registerLogin(program, ctx) {
2131
+ program.command("login").description("Log in with your Qore account").option("--no-browser", "only print the URL").action(async (cmd) => {
2132
+ const { deps: deps2, io: io2, signal } = ctx;
2133
+ const g = ctx.opts();
2134
+ const cfg = await deps2.config.read();
2135
+ const portal = resolvePortal(g.portal, cfg.defaultProfile);
2136
+ const meta = await fetchMeta(deps2.fetch, portal.apiOrigin);
2137
+ const known = cfg.profiles[portal.apiOrigin]?.issuer;
2138
+ const pinned = { ...portal, pinnedIssuer: portal.pinnedIssuer ?? known ?? null };
2139
+ if (!pinned.pinnedIssuer && portal.builtin === null) {
2140
+ io2.err(`! Logging in to ${portal.apiOrigin}, identity provider ${meta.auth.issuer}`);
2141
+ }
2142
+ negotiate(meta, pinned, deps2.version);
2143
+ const issuer = pinned.pinnedIssuer ?? meta.auth.issuer;
2144
+ const hash = profileHash(portal.apiOrigin, issuer);
2145
+ const store = new TokenStore(deps2.keychain, hash);
2146
+ const t = await deviceLogin({
2147
+ oidc: await deps2.oidcFor(issuer),
2148
+ store,
2149
+ now: deps2.now,
2150
+ signal,
2151
+ withLock: deps2.withLock,
2152
+ lockName: hash,
2153
+ onCode: async (s, settled) => {
2154
+ const url = s.verificationUriComplete ?? s.verificationUri;
2155
+ io2.err(`! First copy your one-time code: ${s.userCode}`);
2156
+ if (io2.interactive && cmd.browser) {
2157
+ try {
2158
+ await io2.waitForEnter(` Press Enter to open ${url} (or open it yourself)`, AbortSignal.any([signal, settled]));
2159
+ } catch (e) {
2160
+ if (settled.aborted && !signal.aborted) return;
2161
+ throw e;
2162
+ }
2163
+ if (settled.aborted) return;
2164
+ if (!await io2.openUrl(url)) io2.err(` Open ${url} in a browser`);
2165
+ } else {
2166
+ io2.err(` Open ${url} in a browser`);
2167
+ }
2168
+ }
2169
+ });
2170
+ await deps2.config.update((c) => {
2171
+ c.profiles[portal.apiOrigin] = {
2172
+ ...c.profiles[portal.apiOrigin],
2173
+ apiOrigin: portal.apiOrigin,
2174
+ issuer,
2175
+ sub: t.sub,
2176
+ email: t.email,
2177
+ meta: { fetchedAt: deps2.now().toISOString(), doc: meta }
2178
+ };
707
2179
  if (g.portal || !c.defaultProfile) c.defaultProfile = portal.apiOrigin;
708
2180
  });
709
2181
  const { api } = await openSession(deps2, { ...portal, pinnedIssuer: issuer }, "login", requestLog(ctx));
@@ -724,9 +2196,9 @@ function registerLogin(program, ctx) {
724
2196
  await deps2.config.update((c) => {
725
2197
  c.profiles[portal.apiOrigin].activeTenant = active;
726
2198
  });
727
- const label = portal.builtin ?? portal.apiOrigin;
728
- if (g.json) renderJson(io2, { portal: label, email: me.email, activeTenant: active ?? null });
729
- else io2.out(`\u2713 Logged in as ${me.email} (${label})`);
2199
+ const label2 = portal.builtin ?? portal.apiOrigin;
2200
+ if (g.json) renderJson(io2, { portal: label2, email: me.email, activeTenant: active ?? null });
2201
+ else io2.out(`\u2713 Logged in as ${me.email} (${label2})`);
730
2202
  });
731
2203
  }
732
2204
 
@@ -762,6 +2234,200 @@ function registerLogout(program, ctx) {
762
2234
  });
763
2235
  }
764
2236
 
2237
+ // src/cli/commands/run.ts
2238
+ import { constants } from "os";
2239
+ var childRunning = { current: false };
2240
+ function registerRun(program, ctx) {
2241
+ program.command("run").description("Run a command with this module's local secrets in its environment").option("--module <slug|id>", "the module (default: from this repository's git remote)").option("--app <slug|id>", "the app, when the module is part of several").option("--yes", "trust this repository without asking").argument("<command>", "the command to run (after --)").argument("[args...]", "its arguments, passed verbatim").action(async (cmd, args, f) => {
2242
+ const { deps: deps2 } = ctx;
2243
+ if (ctx.opts().json) {
2244
+ throw new QoreError("QORE_CONTEXT_INVALID", "run has no --json output", {
2245
+ hint: "Drop --json; the child's own output passes through."
2246
+ });
2247
+ }
2248
+ const mc = await moduleContextFor(ctx, f, "run");
2249
+ const app = await appFor(ctx, mc, f.app);
2250
+ await ensureTrusted(ctx, mc, app, { yes: f.yes });
2251
+ const local = await fetchLocalEnv(deps2, mc, app);
2252
+ childRunning.current = true;
2253
+ let exit;
2254
+ try {
2255
+ exit = await deps2.spawn.run(cmd, args, { ...deps2.env, ...local.env });
2256
+ } finally {
2257
+ childRunning.current = false;
2258
+ }
2259
+ if (exit.signal) {
2260
+ deps2.raise(exit.signal);
2261
+ ctx.setExitCode(128 + (constants.signals[exit.signal] ?? 0));
2262
+ return;
2263
+ }
2264
+ ctx.setExitCode(exit.code ?? 1);
2265
+ });
2266
+ }
2267
+
2268
+ // src/cli/format.ts
2269
+ function ago(iso, now) {
2270
+ if (!iso) return "\u2013";
2271
+ const s = Math.max(0, Math.round((now.getTime() - new Date(iso).getTime()) / 1e3));
2272
+ if (s < 60) return "just now";
2273
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
2274
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
2275
+ return `${Math.floor(s / 86400)}d ago`;
2276
+ }
2277
+ var pct = (f) => String(Number((f * 100).toFixed(2)));
2278
+ function uptime(h) {
2279
+ if (!h) return "\u2013";
2280
+ const kind = h.cause && h.kind !== "up" ? `${h.kind}: ${h.cause}` : h.kind;
2281
+ return h.uptime24h === null ? kind : `${kind} (${pct(h.uptime24h)}% 24h)`;
2282
+ }
2283
+ function table(rows) {
2284
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length)));
2285
+ return rows.map((r) => " " + r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" "));
2286
+ }
2287
+
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
+ }
2326
+ function registerStatus(program, ctx) {
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) => {
2328
+ const { deps: deps2, io: io2 } = ctx;
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
+ }
2337
+ const mc = await moduleContextFor(ctx, flags, "status");
2338
+ await ensureCapability(deps2, mc.portal.apiOrigin, "module.status");
2339
+ const app = await appFor(ctx, mc, flags.app);
2340
+ if (!g.json) io2.err(contextLine(mc, app));
2341
+ const st = await mc.session.api.get(
2342
+ `/api/cli/v1/modules/${encodeURIComponent(mc.module.id)}/status?app_id=${encodeURIComponent(app.id)}`
2343
+ );
2344
+ if (g.json) {
2345
+ renderJson(io2, { context: contextJson(mc, app), environments: st.environments });
2346
+ return;
2347
+ }
2348
+ if (st.environments.length === 0) {
2349
+ io2.out("No environments yet.");
2350
+ return;
2351
+ }
2352
+ const now = deps2.now();
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;
2428
+ });
2429
+ }
2430
+
765
2431
  // src/cli/commands/tenant.ts
766
2432
  function registerTenant(program, ctx) {
767
2433
  const tenant = program.command("tenant").description("Manage the active tenant");
@@ -789,7 +2455,7 @@ function registerTenant(program, ctx) {
789
2455
  const portal = resolvePortal(g.portal, cfg.defaultProfile);
790
2456
  const { api } = await openSession(deps2, portal, "tenant use", requestLog(ctx));
791
2457
  const me = await api.get("/api/cli/v1/me");
792
- const candidates = me.tenants.map((t) => t.slug);
2458
+ const candidates = me.tenants.map((t) => ({ kind: "tenant", id: t.id, slug: t.slug, name: t.name }));
793
2459
  let chosen;
794
2460
  if (slug) {
795
2461
  const found = me.tenants.find((t) => t.slug === slug);
@@ -827,11 +2493,11 @@ function registerVersion(program, ctx) {
827
2493
  return;
828
2494
  }
829
2495
  io2.out(`qore ${deps2.version}`);
830
- const label = portal.builtin ?? portal.apiOrigin;
2496
+ const label2 = portal.builtin ?? portal.apiOrigin;
831
2497
  if (portalInfo) {
832
- io2.out(`portal ${label}: api ${portalInfo.apiVersions.join(",")}, min ${portalInfo.minCliVersion}, latest ${portalInfo.latestCliVersion}`);
2498
+ io2.out(`portal ${label2}: api ${portalInfo.apiVersions.join(",")}, min ${portalInfo.minCliVersion}, latest ${portalInfo.latestCliVersion}`);
833
2499
  } else {
834
- io2.out(`portal ${label}: unreachable`);
2500
+ io2.out(`portal ${label2}: unreachable`);
835
2501
  }
836
2502
  });
837
2503
  }
@@ -849,12 +2515,12 @@ function registerWhoami(program, ctx) {
849
2515
  if (activeTenant && !me.tenants.some((t) => t.id === activeTenant.id)) {
850
2516
  io2.err(`! You no longer have developer access to ${activeTenant.slug}`);
851
2517
  }
852
- const label = portal.builtin ?? portal.apiOrigin;
2518
+ const label2 = portal.builtin ?? portal.apiOrigin;
853
2519
  if (g.json) {
854
- renderJson(io2, { portal: label, activeTenant: activeTenant ?? null, me });
2520
+ renderJson(io2, { portal: label2, activeTenant: activeTenant ?? null, me });
855
2521
  return;
856
2522
  }
857
- io2.out(`${me.email} (${label})`);
2523
+ io2.out(`${me.email} (${label2})`);
858
2524
  for (const t of me.tenants) {
859
2525
  const mark = activeTenant?.id === t.id ? "*" : " ";
860
2526
  io2.out(`${mark} ${t.name} ${t.role}`);
@@ -869,7 +2535,18 @@ function registerWhoami(program, ctx) {
869
2535
  }
870
2536
 
871
2537
  // src/cli/commands/index.ts
872
- var COMMANDS = [registerLogin, registerLogout, registerWhoami, registerTenant, registerVersion];
2538
+ var COMMANDS = [
2539
+ registerLogin,
2540
+ registerLogout,
2541
+ registerWhoami,
2542
+ registerTenant,
2543
+ registerStatus,
2544
+ registerEnv,
2545
+ registerRun,
2546
+ registerClone,
2547
+ registerSync,
2548
+ registerVersion
2549
+ ];
873
2550
 
874
2551
  // src/cli/updateCheck.ts
875
2552
  var REGISTRY_URL = "https://registry.npmjs.org/@quatronic%2Fqore/latest";
@@ -903,9 +2580,8 @@ async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS)
903
2580
  }
904
2581
 
905
2582
  // src/core/config.ts
906
- import { mkdir, readFile, rename, writeFile, chmod } from "fs/promises";
907
- import { join } from "path";
908
- import { randomBytes } from "crypto";
2583
+ import { readFile as readFile5 } from "fs/promises";
2584
+ import { join as join12 } from "path";
909
2585
  var empty = () => ({ schemaVersion: 1, profiles: {} });
910
2586
  var ConfigStore = class {
911
2587
  constructor(dir2) {
@@ -913,12 +2589,12 @@ var ConfigStore = class {
913
2589
  }
914
2590
  dir;
915
2591
  get file() {
916
- return join(this.dir, "config.json");
2592
+ return join12(this.dir, "config.json");
917
2593
  }
918
2594
  async read() {
919
2595
  let raw;
920
2596
  try {
921
- raw = await readFile(this.file, "utf8");
2597
+ raw = await readFile5(this.file, "utf8");
922
2598
  } catch (e) {
923
2599
  if (e.code === "ENOENT") return empty();
924
2600
  throw e;
@@ -937,15 +2613,143 @@ var ConfigStore = class {
937
2613
  async update(fn) {
938
2614
  const current = await this.read();
939
2615
  const next = fn(current) ?? current;
940
- await mkdir(this.dir, { recursive: true, mode: 448 });
941
- const tmp = join(this.dir, `.config.${randomBytes(6).toString("hex")}.tmp`);
942
- await writeFile(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
943
- await chmod(tmp, 384);
944
- await rename(tmp, this.file);
2616
+ await writeJsonAtomic(this.dir, "config.json", next);
945
2617
  return next;
946
2618
  }
947
2619
  };
948
2620
 
2621
+ // src/platform/git.ts
2622
+ import { execFile, execFileSync } from "child_process";
2623
+ import { mkdir as mkdir3, readdir as readdir2, stat as stat2 } from "fs/promises";
2624
+ import { homedir } from "os";
2625
+ import { dirname as dirname5, join as join13 } from "path";
2626
+ function parseRemotes(stdout) {
2627
+ const seen = /* @__PURE__ */ new Set();
2628
+ const out = [];
2629
+ for (const line2 of stdout.split(/\r?\n/)) {
2630
+ const m = /^(\S+)\t(.+) \(fetch\)$/.exec(line2);
2631
+ if (!m || seen.has(m[1])) continue;
2632
+ seen.add(m[1]);
2633
+ out.push({ name: m[1], url: m[2] });
2634
+ }
2635
+ return out;
2636
+ }
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
2690
+ });
2691
+ }
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 };
2752
+
949
2753
  // src/platform/keychain.ts
950
2754
  import { Entry } from "@napi-rs/keyring";
951
2755
  var SERVICE = "qore";
@@ -1001,15 +2805,15 @@ var OsKeychain = class {
1001
2805
  };
1002
2806
 
1003
2807
  // src/platform/lock.ts
1004
- import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
1005
- import { join as join2 } from "path";
2808
+ import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
2809
+ import { join as join14 } from "path";
1006
2810
  import lockfile from "proper-lockfile";
1007
2811
  function fileLock(dir2) {
1008
2812
  return async (name, fn) => {
1009
- const locks = join2(dir2, "locks");
1010
- await mkdir2(locks, { recursive: true, mode: 448 });
1011
- const target = join2(locks, `${name}.lock`);
1012
- await writeFile2(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" });
1013
2817
  let release;
1014
2818
  let compromised;
1015
2819
  try {
@@ -1042,36 +2846,90 @@ function configDir(env, platform, home) {
1042
2846
  return posix.join(xdg ?? posix.join(home, ".config"), "qore");
1043
2847
  }
1044
2848
 
2849
+ // src/platform/spawn.ts
2850
+ import spawn from "cross-spawn";
2851
+ function signalsFor(platform) {
2852
+ return platform === "win32" ? { ignore: ["SIGINT", "SIGBREAK"], forward: [] } : { ignore: ["SIGINT", "SIGQUIT"], forward: ["SIGTERM", "SIGHUP"] };
2853
+ }
2854
+ var { ignore: IGNORE, forward: FORWARD } = signalsFor(process.platform);
2855
+ function makeSpawner(spawnFn, proc) {
2856
+ return {
2857
+ run(cmd, args, env) {
2858
+ return new Promise((resolve5, reject) => {
2859
+ let child;
2860
+ try {
2861
+ child = spawnFn(cmd, args, { stdio: "inherit", shell: false, env, windowsHide: true });
2862
+ } catch (e) {
2863
+ reject(new QoreError("QORE_INTERNAL", `Could not start ${cmd}: ${e.code ?? "error"}`));
2864
+ return;
2865
+ }
2866
+ const ignore = () => {
2867
+ };
2868
+ const forwarders = FORWARD.map((sig) => [sig, () => {
2869
+ child.kill(sig);
2870
+ }]);
2871
+ for (const sig of IGNORE) proc.on(sig, ignore);
2872
+ for (const [sig, fn] of forwarders) proc.on(sig, fn);
2873
+ const cleanup = () => {
2874
+ for (const sig of IGNORE) proc.off(sig, ignore);
2875
+ for (const [sig, fn] of forwarders) proc.off(sig, fn);
2876
+ };
2877
+ child.once("error", (e) => {
2878
+ cleanup();
2879
+ reject(e.code === "ENOENT" ? new QoreError("QORE_INTERNAL", `Command not found: ${cmd}`, { hint: "Check the command after --; it runs without a shell." }) : new QoreError("QORE_INTERNAL", `Could not start ${cmd}: ${e.code ?? "error"}`));
2880
+ });
2881
+ child.once("exit", (code, signal) => {
2882
+ cleanup();
2883
+ resolve5({ code, signal });
2884
+ });
2885
+ });
2886
+ }
2887
+ };
2888
+ }
2889
+ var crossSpawner = makeSpawner(spawn, process);
2890
+
1045
2891
  // src/version.ts
1046
- var VERSION = true ? "0.1.0" : "0.0.0-dev";
2892
+ var VERSION = true ? "0.1.2" : "0.0.0-dev";
1047
2893
 
1048
2894
  // src/cli.ts
1049
2895
  if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY) setGlobalDispatcher(new EnvHttpProxyAgent());
1050
- var dir = configDir(process.env, process.platform, homedir());
2896
+ var dir = configDir(process.env, process.platform, homedir2());
1051
2897
  var argv = process.argv.slice(2);
1052
- var json = argv.includes("--json");
2898
+ var preSeparatorArgv = argvBeforeSeparator(argv);
2899
+ var json = preSeparatorArgv.includes("--json");
1053
2900
  var ac = new AbortController();
1054
2901
  process.on("SIGINT", () => {
1055
- if (ac.signal.aborted) process.exit(130);
2902
+ if (ac.signal.aborted) {
2903
+ if (childRunning.current) return;
2904
+ process.exit(130);
2905
+ }
1056
2906
  ac.abort();
1057
2907
  });
1058
- var fetch = abortableFetch(globalThis.fetch, ac.signal);
2908
+ var fetch2 = abortableFetch(globalThis.fetch, ac.signal);
1059
2909
  var deps = {
1060
- fetch,
2910
+ fetch: fetch2,
1061
2911
  keychain: new OsKeychain(),
1062
2912
  config: new ConfigStore(dir),
1063
2913
  withLock: fileLock(dir),
1064
2914
  now: () => /* @__PURE__ */ new Date(),
1065
- oidcFor: (issuer) => createOidc({ issuer, clientId: "qore-cli", fetch }),
2915
+ oidcFor: (issuer) => createOidc({ issuer, clientId: "qore-cli", fetch: fetch2 }),
1066
2916
  env: process.env,
1067
2917
  platform: process.platform,
1068
- version: VERSION
2918
+ version: VERSION,
2919
+ cwd: process.cwd(),
2920
+ home: homedir2(),
2921
+ git: systemGit,
2922
+ spawn: crossSpawner,
2923
+ raise: (signal) => {
2924
+ process.removeAllListeners(signal);
2925
+ process.kill(process.pid, signal);
2926
+ }
1069
2927
  };
1070
2928
  var io = processIo(isInteractive({
1071
2929
  stdinTTY: Boolean(process.stdin.isTTY),
1072
2930
  stdoutTTY: Boolean(process.stdout.isTTY),
1073
2931
  json,
1074
- noInput: argv.includes("--no-input"),
2932
+ noInput: preSeparatorArgv.includes("--no-input"),
1075
2933
  env: process.env
1076
2934
  }));
1077
2935
  process.exitCode = await run(argv, deps, io, ac.signal, COMMANDS);