@quatronic/qore 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -2
- package/dist/cli.js +1302 -243
- package/dist/cli.js.map +1 -1
- package/package.json +3 -1
- package/schema/qore.schema.json +17 -0
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,
|
|
@@ -109,12 +112,20 @@ function processIo(interactive) {
|
|
|
109
112
|
throw e;
|
|
110
113
|
}
|
|
111
114
|
},
|
|
115
|
+
async confirm(message) {
|
|
116
|
+
try {
|
|
117
|
+
return await confirm({ message, default: false });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
if (e.name === "ExitPromptError") throw new CancelledError();
|
|
120
|
+
throw e;
|
|
121
|
+
}
|
|
122
|
+
},
|
|
112
123
|
waitForEnter: createWaitForEnter(process.stdin, process.stderr),
|
|
113
124
|
openUrl: openBrowser
|
|
114
125
|
};
|
|
115
126
|
}
|
|
116
127
|
function createWaitForEnter(input, output) {
|
|
117
|
-
return (message, signal) => new Promise((
|
|
128
|
+
return (message, signal) => new Promise((resolve3, reject) => {
|
|
118
129
|
const rl = createInterface({ input, output, terminal: true });
|
|
119
130
|
const onAbort = () => {
|
|
120
131
|
rl.close();
|
|
@@ -123,7 +134,7 @@ function createWaitForEnter(input, output) {
|
|
|
123
134
|
const done = () => {
|
|
124
135
|
signal.removeEventListener("abort", onAbort);
|
|
125
136
|
rl.close();
|
|
126
|
-
|
|
137
|
+
resolve3();
|
|
127
138
|
};
|
|
128
139
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
129
140
|
rl.on("SIGINT", () => {
|
|
@@ -181,7 +192,10 @@ var BY_SERVER_CODE = {
|
|
|
181
192
|
"app.access.denied": "QORE_ACCESS_DENIED",
|
|
182
193
|
"auth.scope.insufficient": "QORE_ACCESS_DENIED",
|
|
183
194
|
"cli.upgrade_required": "QORE_UPGRADE_REQUIRED",
|
|
184
|
-
"rate_limited": "QORE_RATE_LIMITED"
|
|
195
|
+
"rate_limited": "QORE_RATE_LIMITED",
|
|
196
|
+
"module.not_found": "QORE_RESOURCE_NOT_FOUND",
|
|
197
|
+
"tenant.not_found": "QORE_RESOURCE_NOT_FOUND",
|
|
198
|
+
"module.app_ambiguous": "QORE_APP_AMBIGUOUS"
|
|
185
199
|
};
|
|
186
200
|
function byStatus(status) {
|
|
187
201
|
if (status === 403) return "QORE_ACCESS_DENIED";
|
|
@@ -202,18 +216,22 @@ async function problemToError(res) {
|
|
|
202
216
|
const requestId = res.headers.get("x-request-id") ?? void 0;
|
|
203
217
|
const details = {};
|
|
204
218
|
if (body.detail && typeof body.detail === "object") Object.assign(details, body.detail);
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
219
|
+
const detail2 = typeof body.detail === "string" && body.detail ? body.detail : void 0;
|
|
220
|
+
if (detail2) details.detail = detail2;
|
|
207
221
|
const retry = res.headers.get("retry-after");
|
|
208
222
|
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;
|
|
223
|
+
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;
|
|
224
|
+
const apps = body.detail?.apps;
|
|
225
|
+
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;
|
|
226
|
+
if (candidates) delete details.apps;
|
|
210
227
|
const genericTitle = !body.title || body.title === "HTTP error" || body.title === serverCode;
|
|
211
|
-
const message = genericTitle &&
|
|
228
|
+
const message = genericTitle && detail2 || body.title || `Request failed (${res.status})`;
|
|
212
229
|
return new QoreError(code, message, {
|
|
213
230
|
status: res.status,
|
|
214
231
|
requestId,
|
|
215
232
|
serverCode,
|
|
216
233
|
hint,
|
|
234
|
+
candidates,
|
|
217
235
|
details: Object.keys(details).length ? details : void 0
|
|
218
236
|
});
|
|
219
237
|
}
|
|
@@ -243,15 +261,19 @@ function renderError(io2, err, json2, colorEnabled = pc.isColorSupported) {
|
|
|
243
261
|
}
|
|
244
262
|
|
|
245
263
|
// src/cli/program.ts
|
|
264
|
+
function argvBeforeSeparator(argv2) {
|
|
265
|
+
const i = argv2.indexOf("--");
|
|
266
|
+
return i === -1 ? argv2 : argv2.slice(0, i);
|
|
267
|
+
}
|
|
246
268
|
function jsonFromArgv(argv2) {
|
|
247
|
-
return argv2.some((a) => a === "--json" || a.startsWith("--json="));
|
|
269
|
+
return argvBeforeSeparator(argv2).some((a) => a === "--json" || a.startsWith("--json="));
|
|
248
270
|
}
|
|
249
271
|
function requestLog(ctx) {
|
|
250
272
|
return ctx.opts().verbose ? (line) => ctx.io.err(line) : void 0;
|
|
251
273
|
}
|
|
252
|
-
function buildProgram(deps2, io2, signal, register) {
|
|
274
|
+
function buildProgram(deps2, io2, signal, register, setExitCode) {
|
|
253
275
|
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() };
|
|
276
|
+
const ctx = { deps: deps2, io: io2, signal, opts: () => program.opts(), setExitCode };
|
|
255
277
|
for (const r of register) r(program, ctx);
|
|
256
278
|
return program;
|
|
257
279
|
}
|
|
@@ -259,10 +281,13 @@ async function run(argv2, deps2, io2, signal, register) {
|
|
|
259
281
|
const json2 = jsonFromArgv(argv2);
|
|
260
282
|
const parseIo = json2 ? { ...io2, err: () => {
|
|
261
283
|
} } : io2;
|
|
262
|
-
const
|
|
284
|
+
const state = {};
|
|
285
|
+
const program = buildProgram(deps2, parseIo, signal, register, (code) => {
|
|
286
|
+
state.exitCode = code;
|
|
287
|
+
});
|
|
263
288
|
try {
|
|
264
289
|
await program.parseAsync(argv2, { from: "user" });
|
|
265
|
-
return 0;
|
|
290
|
+
return state.exitCode ?? 0;
|
|
266
291
|
} catch (caught) {
|
|
267
292
|
const e = signal.aborted && isAbortError(caught) ? new CancelledError() : caught;
|
|
268
293
|
if (e instanceof CommanderError) {
|
|
@@ -276,6 +301,56 @@ async function run(argv2, deps2, io2, signal, register) {
|
|
|
276
301
|
}
|
|
277
302
|
}
|
|
278
303
|
|
|
304
|
+
// src/cli/commands/env.ts
|
|
305
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
306
|
+
import { chmod as chmod2, readFile as readFile3, realpath as realpath3, rename as rename2, unlink, writeFile as writeFile2 } from "fs/promises";
|
|
307
|
+
import { basename as basename2, dirname as dirname3, join as join6, relative as relative2 } from "path";
|
|
308
|
+
|
|
309
|
+
// src/core/context/repoConfig.ts
|
|
310
|
+
import { readFile } from "fs/promises";
|
|
311
|
+
import { join } from "path";
|
|
312
|
+
var FORBIDDEN = ["portal", "api", "apiOrigin", "issuer", "tenant", "tenantId", "app", "appId", "module", "moduleId"];
|
|
313
|
+
var KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
314
|
+
var invalid = (msg, details) => new QoreError("QORE_CONTEXT_INVALID", `.qore.json: ${msg}`, { hint: "Fix .qore.json in the repository root; see the $schema it names.", details });
|
|
315
|
+
function moduleDefaults(type) {
|
|
316
|
+
return type === "frontend" ? { envFile: ".env.local", localSecrets: ["SESSION_COOKIE_SECRET"] } : { envFile: ".env", localSecrets: [] };
|
|
317
|
+
}
|
|
318
|
+
async function readRepoConfig(root) {
|
|
319
|
+
let raw;
|
|
320
|
+
try {
|
|
321
|
+
raw = await readFile(join(root, ".qore.json"), "utf8");
|
|
322
|
+
} catch (e) {
|
|
323
|
+
if (e.code === "ENOENT") return {};
|
|
324
|
+
throw e;
|
|
325
|
+
}
|
|
326
|
+
let doc;
|
|
327
|
+
try {
|
|
328
|
+
doc = JSON.parse(raw);
|
|
329
|
+
} catch {
|
|
330
|
+
throw invalid("not valid JSON");
|
|
331
|
+
}
|
|
332
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw invalid("must be an object");
|
|
333
|
+
const v = doc.schemaVersion === void 0 ? 1 : doc.schemaVersion;
|
|
334
|
+
if (!Number.isInteger(v) || v < 1) throw invalid("schemaVersion must be a positive integer");
|
|
335
|
+
if (v > 1) {
|
|
336
|
+
throw new QoreError("QORE_CONTEXT_UNSUPPORTED", `.qore.json schemaVersion ${v} is newer than this qore`, { hint: "Run: npm i -g @quatronic/qore" });
|
|
337
|
+
}
|
|
338
|
+
for (const f of FORBIDDEN) if (f in doc) throw invalid(`"${f}" is not allowed \u2014 .qore.json never selects a portal or a resource`, { field: f });
|
|
339
|
+
const out = {};
|
|
340
|
+
if (doc.envFile !== void 0) {
|
|
341
|
+
if (typeof doc.envFile !== "string" || !doc.envFile) throw invalid("envFile must be a non-empty string");
|
|
342
|
+
out.envFile = doc.envFile;
|
|
343
|
+
}
|
|
344
|
+
if (doc.localSecrets !== void 0) {
|
|
345
|
+
if (!Array.isArray(doc.localSecrets) || !doc.localSecrets.every((k) => typeof k === "string" && KEY.test(k))) {
|
|
346
|
+
throw invalid("localSecrets must be a list of env key names");
|
|
347
|
+
}
|
|
348
|
+
if (new Set(doc.localSecrets).size !== doc.localSecrets.length) throw invalid("localSecrets must not contain duplicates");
|
|
349
|
+
out.localSecrets = doc.localSecrets;
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
279
354
|
// src/core/api/meta.ts
|
|
280
355
|
var SUPPORTED = ["v1"];
|
|
281
356
|
var SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/;
|
|
@@ -326,7 +401,997 @@ function negotiate(meta, portal, cliVersion) {
|
|
|
326
401
|
hint: "Run: npm i -g @quatronic/qore"
|
|
327
402
|
});
|
|
328
403
|
}
|
|
329
|
-
return { apiVersion };
|
|
404
|
+
return { apiVersion };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// src/core/api/capabilities.ts
|
|
408
|
+
var TTL_MS = 36e5;
|
|
409
|
+
function isMeta(doc) {
|
|
410
|
+
return !!doc && typeof doc === "object" && Array.isArray(doc.capabilities);
|
|
411
|
+
}
|
|
412
|
+
async function loadMeta(deps2, apiOrigin, refresh) {
|
|
413
|
+
const cached = (await deps2.config.read()).profiles[apiOrigin]?.meta;
|
|
414
|
+
if (!refresh && cached && isMeta(cached.doc) && deps2.now().getTime() - new Date(cached.fetchedAt).getTime() < TTL_MS) {
|
|
415
|
+
return { meta: cached.doc, cached: true };
|
|
416
|
+
}
|
|
417
|
+
const meta = await fetchMeta(deps2.fetch, apiOrigin);
|
|
418
|
+
await deps2.config.update((c) => {
|
|
419
|
+
const p = c.profiles[apiOrigin];
|
|
420
|
+
if (p) p.meta = { fetchedAt: deps2.now().toISOString(), doc: meta };
|
|
421
|
+
});
|
|
422
|
+
return { meta, cached: false };
|
|
423
|
+
}
|
|
424
|
+
async function ensureCapability(deps2, apiOrigin, capability) {
|
|
425
|
+
const first = await loadMeta(deps2, apiOrigin, false);
|
|
426
|
+
let meta = first.meta;
|
|
427
|
+
if (!meta.capabilities.includes(capability) && first.cached) meta = (await loadMeta(deps2, apiOrigin, true)).meta;
|
|
428
|
+
requireCapability(meta, capability, apiOrigin);
|
|
429
|
+
return meta;
|
|
430
|
+
}
|
|
431
|
+
function requireCapability(meta, capability, apiOrigin) {
|
|
432
|
+
if (meta.capabilities.includes(capability)) return;
|
|
433
|
+
throw new QoreError("QORE_UNSUPPORTED_BY_SERVER", `This portal does not support ${capability} yet`, {
|
|
434
|
+
hint: `The portal at ${apiOrigin} is older than this qore. Try again once it has been updated.`,
|
|
435
|
+
details: { capability, portal: apiOrigin }
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/core/context/resolver.ts
|
|
440
|
+
import { join as join5 } from "path";
|
|
441
|
+
|
|
442
|
+
// src/core/auth/tokenStore.ts
|
|
443
|
+
var TokenStore = class {
|
|
444
|
+
constructor(kc, hash) {
|
|
445
|
+
this.kc = kc;
|
|
446
|
+
this.hash = hash;
|
|
447
|
+
}
|
|
448
|
+
kc;
|
|
449
|
+
hash;
|
|
450
|
+
async read(suffix) {
|
|
451
|
+
const raw = await this.kc.get(`${this.hash}:${suffix}`);
|
|
452
|
+
if (raw === null) return null;
|
|
453
|
+
try {
|
|
454
|
+
return JSON.parse(raw);
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
getRefresh() {
|
|
460
|
+
return this.read("refresh");
|
|
461
|
+
}
|
|
462
|
+
getAccess() {
|
|
463
|
+
return this.read("access");
|
|
464
|
+
}
|
|
465
|
+
setRefresh(v) {
|
|
466
|
+
return this.kc.set(`${this.hash}:refresh`, JSON.stringify(v));
|
|
467
|
+
}
|
|
468
|
+
setAccess(v) {
|
|
469
|
+
return this.kc.set(`${this.hash}:access`, JSON.stringify(v));
|
|
470
|
+
}
|
|
471
|
+
async clear() {
|
|
472
|
+
await this.kc.delete(`${this.hash}:access`);
|
|
473
|
+
await this.kc.delete(`${this.hash}:refresh`);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// src/core/profiles.ts
|
|
478
|
+
import { createHash } from "crypto";
|
|
479
|
+
var BUILTIN_PORTALS = {
|
|
480
|
+
prod: {
|
|
481
|
+
apiOrigin: "https://api.quatronic.app",
|
|
482
|
+
pinnedIssuer: "https://auth.quatronic.app/realms/platform-prod"
|
|
483
|
+
},
|
|
484
|
+
staging: {
|
|
485
|
+
apiOrigin: "https://p01--portal-api-staging--hcywmtb2vksv.code.run",
|
|
486
|
+
pinnedIssuer: "https://auth-staging.quatronic.app/realms/platform-staging"
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
function builtinFor(origin) {
|
|
490
|
+
for (const [name, p] of Object.entries(BUILTIN_PORTALS)) {
|
|
491
|
+
if (p.apiOrigin === origin) return { ...p, builtin: name };
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
function toOrigin(raw) {
|
|
496
|
+
let url;
|
|
497
|
+
try {
|
|
498
|
+
url = new URL(raw);
|
|
499
|
+
} catch {
|
|
500
|
+
throw new QoreError("QORE_CONTEXT_INVALID", `Not a portal name or URL: ${raw}`, {
|
|
501
|
+
hint: "Use --portal prod, --portal staging or --portal https://\u2026"
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
505
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
506
|
+
throw new QoreError("QORE_CONTEXT_INVALID", "A portal URL must use https (http only for localhost)");
|
|
507
|
+
}
|
|
508
|
+
return url.origin.toLowerCase();
|
|
509
|
+
}
|
|
510
|
+
function resolvePortal(arg, defaultProfile) {
|
|
511
|
+
const chosen = arg ?? defaultProfile;
|
|
512
|
+
if (chosen === void 0 || chosen === "prod") return { ...BUILTIN_PORTALS.prod, builtin: "prod" };
|
|
513
|
+
if (chosen === "staging") return { ...BUILTIN_PORTALS.staging, builtin: "staging" };
|
|
514
|
+
const origin = toOrigin(chosen);
|
|
515
|
+
return builtinFor(origin) ?? { apiOrigin: origin, pinnedIssuer: null, builtin: null };
|
|
516
|
+
}
|
|
517
|
+
function profileHash(apiOrigin, issuer) {
|
|
518
|
+
return createHash("sha256").update(`${apiOrigin}|${issuer}`).digest("hex").slice(0, 16);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/core/api/client.ts
|
|
522
|
+
var QoreApiClient = class {
|
|
523
|
+
constructor(o) {
|
|
524
|
+
this.o = o;
|
|
525
|
+
}
|
|
526
|
+
o;
|
|
527
|
+
async once(method, path, body, force) {
|
|
528
|
+
const token = await this.o.token(force);
|
|
529
|
+
const headers = {
|
|
530
|
+
authorization: `Bearer ${token}`,
|
|
531
|
+
accept: "application/json",
|
|
532
|
+
"qore-cli-version": this.o.cliVersion,
|
|
533
|
+
"qore-command": this.o.command,
|
|
534
|
+
"user-agent": `qore/${this.o.cliVersion} (${this.o.platform})`
|
|
535
|
+
};
|
|
536
|
+
if (body !== void 0) headers["content-type"] = "application/json";
|
|
537
|
+
let res;
|
|
538
|
+
try {
|
|
539
|
+
res = await this.o.fetch(`${this.o.apiOrigin}${path}`, {
|
|
540
|
+
method,
|
|
541
|
+
headers,
|
|
542
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
543
|
+
});
|
|
544
|
+
} catch (e) {
|
|
545
|
+
throw fetchError(e, new URL(this.o.apiOrigin).host);
|
|
546
|
+
}
|
|
547
|
+
const rid = res.headers.get("x-request-id");
|
|
548
|
+
this.o.onRequest?.(`${method} ${path} \u2192 ${res.status}${rid ? ` (${rid})` : ""}`);
|
|
549
|
+
return res;
|
|
550
|
+
}
|
|
551
|
+
async request(method, path, body) {
|
|
552
|
+
let res = await this.once(method, path, body, false);
|
|
553
|
+
if (res.status === 401) {
|
|
554
|
+
res = await this.once(method, path, body, true);
|
|
555
|
+
if (res.status === 401) {
|
|
556
|
+
throw new QoreError("QORE_SESSION_EXPIRED", "The portal no longer accepts your session", {
|
|
557
|
+
hint: "Run `qore login`.",
|
|
558
|
+
status: 401,
|
|
559
|
+
requestId: res.headers.get("x-request-id") ?? void 0
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (!res.ok) throw await problemToError(res);
|
|
564
|
+
return await res.json();
|
|
565
|
+
}
|
|
566
|
+
get(path) {
|
|
567
|
+
return this.request("GET", path);
|
|
568
|
+
}
|
|
569
|
+
post(path, body) {
|
|
570
|
+
return this.request("POST", path, body);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
// src/core/auth/refresh.ts
|
|
575
|
+
var SKEW_MS = 6e4;
|
|
576
|
+
var RefreshCoordinator = class {
|
|
577
|
+
constructor(o) {
|
|
578
|
+
this.o = o;
|
|
579
|
+
}
|
|
580
|
+
o;
|
|
581
|
+
fresh(expiresAt) {
|
|
582
|
+
return new Date(expiresAt).getTime() - this.o.now().getTime() > SKEW_MS;
|
|
583
|
+
}
|
|
584
|
+
async rotate(scope) {
|
|
585
|
+
let stored = await this.o.store.getRefresh();
|
|
586
|
+
if (!stored) throw new QoreError("QORE_NOT_LOGGED_IN", "Not logged in", { hint: "Run `qore login`." });
|
|
587
|
+
let t;
|
|
588
|
+
for (let attempt = 0; ; attempt++) {
|
|
589
|
+
try {
|
|
590
|
+
t = await (await this.o.oidc()).refresh(stored.rt, scope);
|
|
591
|
+
break;
|
|
592
|
+
} catch (e) {
|
|
593
|
+
if (!(e instanceof QoreError && e.code === "QORE_SESSION_EXPIRED")) throw e;
|
|
594
|
+
const now = await this.o.store.getRefresh();
|
|
595
|
+
if (now && now.rt !== stored.rt) {
|
|
596
|
+
if (attempt === 0) {
|
|
597
|
+
stored = now;
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
throw e;
|
|
601
|
+
}
|
|
602
|
+
await this.o.store.clear();
|
|
603
|
+
throw e;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
await this.o.store.setRefresh({ rt: t.refreshToken, sub: t.sub, scope: t.scope || (scope ?? stored.scope), obtainedAt: this.o.now().toISOString() });
|
|
607
|
+
return t;
|
|
608
|
+
}
|
|
609
|
+
async accessToken(opts = {}) {
|
|
610
|
+
const seen = await this.o.store.getAccess();
|
|
611
|
+
if (!opts.force && seen && this.fresh(seen.expiresAt)) return seen.at;
|
|
612
|
+
return this.o.withLock(this.o.lockName, async () => {
|
|
613
|
+
const again = await this.o.store.getAccess();
|
|
614
|
+
if (again && again.at !== seen?.at && this.fresh(again.expiresAt)) return again.at;
|
|
615
|
+
const t = await this.rotate();
|
|
616
|
+
await this.o.store.setAccess({ at: t.accessToken, expiresAt: new Date(this.o.now().getTime() + t.expiresIn * 1e3).toISOString() });
|
|
617
|
+
return t.accessToken;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
/** A token carrying only `scopes`, held in memory by the caller (qore mcp). */
|
|
621
|
+
async downscoped(scopes) {
|
|
622
|
+
return this.o.withLock(this.o.lockName, async () => (await this.rotate(scopes.join(" "))).accessToken);
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// src/core/session.ts
|
|
627
|
+
function loginHint(portal) {
|
|
628
|
+
return portal.builtin === "prod" ? "Run `qore login`." : `Run \`qore login --portal ${portal.builtin ?? portal.apiOrigin}\`.`;
|
|
629
|
+
}
|
|
630
|
+
async function openSession(deps2, portal, command, onRequest) {
|
|
631
|
+
const profile = (await deps2.config.read()).profiles[portal.apiOrigin];
|
|
632
|
+
if (!profile) throw new QoreError("QORE_NOT_LOGGED_IN", `Not logged in to ${portal.apiOrigin}`, { hint: loginHint(portal) });
|
|
633
|
+
const hash = profileHash(profile.apiOrigin, profile.issuer);
|
|
634
|
+
const tokens = new TokenStore(deps2.keychain, hash);
|
|
635
|
+
if (!await tokens.getRefresh()) throw new QoreError("QORE_NOT_LOGGED_IN", `Not logged in to ${portal.apiOrigin}`, { hint: loginHint(portal) });
|
|
636
|
+
const refresh = new RefreshCoordinator({ store: tokens, oidc: () => deps2.oidcFor(profile.issuer), withLock: deps2.withLock, lockName: hash, now: deps2.now });
|
|
637
|
+
const api = new QoreApiClient({
|
|
638
|
+
apiOrigin: profile.apiOrigin,
|
|
639
|
+
fetch: deps2.fetch,
|
|
640
|
+
token: (force) => refresh.accessToken({ force }),
|
|
641
|
+
cliVersion: deps2.version,
|
|
642
|
+
command,
|
|
643
|
+
platform: `${deps2.platform} ${process.arch}; node ${process.versions.node}`,
|
|
644
|
+
onRequest
|
|
645
|
+
});
|
|
646
|
+
return { profile, api, tokens, refresh };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// src/core/context/cache.ts
|
|
650
|
+
import { join as join3 } from "path";
|
|
651
|
+
|
|
652
|
+
// src/core/jsonFile.ts
|
|
653
|
+
import { chmod, mkdir, readFile as readFile2, rename, writeFile } from "fs/promises";
|
|
654
|
+
import { join as join2 } from "path";
|
|
655
|
+
import { randomBytes } from "crypto";
|
|
656
|
+
async function readJson(file) {
|
|
657
|
+
let raw;
|
|
658
|
+
try {
|
|
659
|
+
raw = await readFile2(file, "utf8");
|
|
660
|
+
} catch (e) {
|
|
661
|
+
if (e.code === "ENOENT") return null;
|
|
662
|
+
throw e;
|
|
663
|
+
}
|
|
664
|
+
return JSON.parse(raw);
|
|
665
|
+
}
|
|
666
|
+
async function writeJsonAtomic(dir2, name, data) {
|
|
667
|
+
await mkdir(dir2, { recursive: true, mode: 448 });
|
|
668
|
+
const tmp = join2(dir2, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
|
|
669
|
+
await writeFile(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
|
|
670
|
+
await chmod(tmp, 384);
|
|
671
|
+
await rename(tmp, join2(dir2, name));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// src/core/context/cache.ts
|
|
675
|
+
var TTL_MS2 = 24 * 36e5;
|
|
676
|
+
var ResolutionCache = class {
|
|
677
|
+
constructor(dir2, now) {
|
|
678
|
+
this.dir = dir2;
|
|
679
|
+
this.now = now;
|
|
680
|
+
}
|
|
681
|
+
dir;
|
|
682
|
+
now;
|
|
683
|
+
async load() {
|
|
684
|
+
try {
|
|
685
|
+
const f = await readJson(join3(this.dir, "repos.json"));
|
|
686
|
+
if (f && f.schemaVersion === 1 && typeof f.entries === "object" && f.entries) return f;
|
|
687
|
+
} catch {
|
|
688
|
+
}
|
|
689
|
+
return { schemaVersion: 1, entries: {} };
|
|
690
|
+
}
|
|
691
|
+
fresh(e) {
|
|
692
|
+
return !!e && this.now().getTime() - new Date(e.resolvedAt).getTime() < TTL_MS2;
|
|
693
|
+
}
|
|
694
|
+
async get(origin, key) {
|
|
695
|
+
const e = (await this.load()).entries[`${origin}|${key}`];
|
|
696
|
+
return this.fresh(e) ? e.moduleId : null;
|
|
697
|
+
}
|
|
698
|
+
/** Never throws: a read-only config dir, a full disk or a Windows EPERM on rename
|
|
699
|
+
* (another qore holding the file) must not fail a command that already resolved. */
|
|
700
|
+
async put(origin, key, moduleId) {
|
|
701
|
+
const f = await this.load();
|
|
702
|
+
f.entries[`${origin}|${key}`] = { moduleId, resolvedAt: this.now().toISOString() };
|
|
703
|
+
for (const [k, e] of Object.entries(f.entries)) if (!this.fresh(e)) delete f.entries[k];
|
|
704
|
+
await this.save(f);
|
|
705
|
+
}
|
|
706
|
+
/** Never throws, like `put`. */
|
|
707
|
+
async drop(origin, key) {
|
|
708
|
+
const f = await this.load();
|
|
709
|
+
if (!(`${origin}|${key}` in f.entries)) return;
|
|
710
|
+
delete f.entries[`${origin}|${key}`];
|
|
711
|
+
await this.save(f);
|
|
712
|
+
}
|
|
713
|
+
async save(f) {
|
|
714
|
+
try {
|
|
715
|
+
await writeJsonAtomic(this.dir, "repos.json", f);
|
|
716
|
+
} catch {
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
async originsFor(keys) {
|
|
720
|
+
const f = await this.load();
|
|
721
|
+
const hits = [];
|
|
722
|
+
for (const [k, e] of Object.entries(f.entries)) {
|
|
723
|
+
const sep2 = k.lastIndexOf("|");
|
|
724
|
+
if (keys.includes(k.slice(sep2 + 1)) && this.fresh(e)) hits.push({ origin: k.slice(0, sep2), at: new Date(e.resolvedAt).getTime() });
|
|
725
|
+
}
|
|
726
|
+
hits.sort((a, b) => b.at - a.at);
|
|
727
|
+
return [...new Set(hits.map((h) => h.origin))];
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// src/core/context/repoKey.ts
|
|
732
|
+
var SCP = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/;
|
|
733
|
+
var SCHEMES = /* @__PURE__ */ new Set(["https:", "http:", "ssh:", "git:"]);
|
|
734
|
+
var DOT_SEGMENTS = /* @__PURE__ */ new Set([".", "..", "%2e", "%2e%2e", ".%2e", "%2e."]);
|
|
735
|
+
var hasDotSegment = (pieces) => pieces.some((p) => DOT_SEGMENTS.has(p.toLowerCase()));
|
|
736
|
+
function repoKey(url) {
|
|
737
|
+
let raw = url.trim();
|
|
738
|
+
if (raw.startsWith("git+")) raw = raw.slice(4);
|
|
739
|
+
if (!raw || raw.includes("\\")) return null;
|
|
740
|
+
let host;
|
|
741
|
+
let path;
|
|
742
|
+
if (raw.includes("://")) {
|
|
743
|
+
if (hasDotSegment(raw.slice(raw.indexOf("://") + 3).split("/").slice(1))) return null;
|
|
744
|
+
let u;
|
|
745
|
+
try {
|
|
746
|
+
u = new URL(raw);
|
|
747
|
+
} catch {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
if (!SCHEMES.has(u.protocol.toLowerCase())) return null;
|
|
751
|
+
host = u.hostname;
|
|
752
|
+
path = u.pathname;
|
|
753
|
+
} else {
|
|
754
|
+
const m = SCP.exec(raw);
|
|
755
|
+
if (!m) return null;
|
|
756
|
+
host = m[1];
|
|
757
|
+
path = m[2];
|
|
758
|
+
}
|
|
759
|
+
const segments = path.split("/").filter(Boolean);
|
|
760
|
+
if (!host || segments.length !== 2 || hasDotSegment(segments)) return null;
|
|
761
|
+
const owner = segments[0];
|
|
762
|
+
let repo = segments[1];
|
|
763
|
+
if (repo.toLowerCase().endsWith(".git")) repo = repo.slice(0, -4);
|
|
764
|
+
if (!repo) return null;
|
|
765
|
+
return `${host.toLowerCase()}/${owner.toLowerCase()}/${repo.toLowerCase()}`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// src/core/context/repoRoot.ts
|
|
769
|
+
import { stat } from "fs/promises";
|
|
770
|
+
import { dirname, join as join4, resolve } from "path";
|
|
771
|
+
async function findRepoRoot(start) {
|
|
772
|
+
let dir2 = resolve(start);
|
|
773
|
+
for (; ; ) {
|
|
774
|
+
try {
|
|
775
|
+
await stat(join4(dir2, ".git"));
|
|
776
|
+
return dir2;
|
|
777
|
+
} catch {
|
|
778
|
+
}
|
|
779
|
+
const up = dirname(dir2);
|
|
780
|
+
if (up === dir2) return null;
|
|
781
|
+
dir2 = up;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/core/context/resolver.ts
|
|
786
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
787
|
+
var HINT_FLAG = "cd into a module repository, or pass --module <slug|id>.";
|
|
788
|
+
var RESOLVE = "/api/cli/v1/repos/resolve";
|
|
789
|
+
var SKIPPABLE = /* @__PURE__ */ new Set([
|
|
790
|
+
"QORE_API_UNREACHABLE",
|
|
791
|
+
"QORE_API_UNAVAILABLE",
|
|
792
|
+
"QORE_AUTH_UNAVAILABLE",
|
|
793
|
+
"QORE_NOT_LOGGED_IN",
|
|
794
|
+
"QORE_SESSION_EXPIRED"
|
|
795
|
+
]);
|
|
796
|
+
var label = (p) => p.builtin ?? p.apiOrigin;
|
|
797
|
+
function byPreference(remotes) {
|
|
798
|
+
const rank = (r) => r.name === "origin" ? 0 : r.name === "upstream" ? 1 : 2;
|
|
799
|
+
return [...remotes].sort((a, b) => rank(a) - rank(b));
|
|
800
|
+
}
|
|
801
|
+
var preferred = (remotes) => byPreference(remotes)[0];
|
|
802
|
+
async function repoRemotes(deps2) {
|
|
803
|
+
const root = await findRepoRoot(deps2.cwd);
|
|
804
|
+
if (!root) return null;
|
|
805
|
+
let remotes;
|
|
806
|
+
try {
|
|
807
|
+
remotes = await deps2.git.remotes(root);
|
|
808
|
+
} catch (e) {
|
|
809
|
+
if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") return { root, keyed: [], gitError: e };
|
|
810
|
+
throw e;
|
|
811
|
+
}
|
|
812
|
+
const keyed = remotes.map((r) => ({ ...r, key: repoKey(r.url) })).filter((r) => r.key !== null);
|
|
813
|
+
return { root, keyed };
|
|
814
|
+
}
|
|
815
|
+
function detail(session, id) {
|
|
816
|
+
return session.api.get(`/api/cli/v1/modules/${encodeURIComponent(id)}`);
|
|
817
|
+
}
|
|
818
|
+
async function flagOverrides(deps2, session, portal, cache, moduleId) {
|
|
819
|
+
try {
|
|
820
|
+
const keyed = (await repoRemotes(deps2))?.keyed ?? [];
|
|
821
|
+
const derived = /* @__PURE__ */ new Map();
|
|
822
|
+
const uncached = [];
|
|
823
|
+
for (const key of new Set(keyed.map((r) => r.key))) {
|
|
824
|
+
const id = await cache.get(portal.apiOrigin, key);
|
|
825
|
+
if (id) derived.set(key, /* @__PURE__ */ new Set([id]));
|
|
826
|
+
else uncached.push(key);
|
|
827
|
+
}
|
|
828
|
+
if (uncached.length > 0) {
|
|
829
|
+
try {
|
|
830
|
+
const { matches } = await session.api.post(RESOLVE, { repoKeys: uncached });
|
|
831
|
+
for (const m of matches) derived.set(m.repoKey, (derived.get(m.repoKey) ?? /* @__PURE__ */ new Set()).add(m.module.id));
|
|
832
|
+
const ids = new Set(matches.map((m) => m.module.id));
|
|
833
|
+
if (ids.size === 1) for (const m of matches) await cache.put(portal.apiOrigin, m.repoKey, m.module.id);
|
|
834
|
+
} catch (e) {
|
|
835
|
+
if (e instanceof CancelledError) throw e;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return byPreference(keyed).find((r) => [...derived.get(r.key) ?? []].some((id) => id !== moduleId))?.name;
|
|
839
|
+
} catch (e) {
|
|
840
|
+
if (e instanceof CancelledError) throw e;
|
|
841
|
+
return void 0;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async function byFlag(deps2, o, cache) {
|
|
845
|
+
const cfg = await deps2.config.read();
|
|
846
|
+
const portal = resolvePortal(o.portal, cfg.defaultProfile);
|
|
847
|
+
const session = await openSession(deps2, portal, o.command, o.onRequest);
|
|
848
|
+
await ensureCapability(deps2, portal.apiOrigin, "repo.resolve");
|
|
849
|
+
let id = o.module;
|
|
850
|
+
if (!UUID.test(id)) {
|
|
851
|
+
let tenantId = o.tenant ?? session.profile.activeTenant?.id;
|
|
852
|
+
if (!tenantId) {
|
|
853
|
+
const me = await session.api.get("/api/cli/v1/me");
|
|
854
|
+
if (me.tenants.length === 0) {
|
|
855
|
+
throw new QoreError("QORE_NO_DEVELOPER_ACCESS", "Your account is not a developer in any organisation", {
|
|
856
|
+
hint: "Ask an admin to invite you as a developer."
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
if (me.tenants.length > 1) {
|
|
860
|
+
const candidates = me.tenants.map((t) => ({ kind: "tenant", id: t.id, slug: t.slug, name: t.name }));
|
|
861
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", `Which tenant is "${id}" in?`, {
|
|
862
|
+
hint: "Run `qore tenant use <slug>`, or pass the module id.",
|
|
863
|
+
candidates
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
tenantId = me.tenants[0].id;
|
|
867
|
+
}
|
|
868
|
+
id = (await session.api.get(
|
|
869
|
+
`/api/cli/v1/tenants/${encodeURIComponent(tenantId)}/modules/resolve?slug=${encodeURIComponent(id)}`
|
|
870
|
+
)).id;
|
|
871
|
+
}
|
|
872
|
+
const module = await detail(session, id);
|
|
873
|
+
const overrides = await flagOverrides(deps2, session, portal, cache, module.id);
|
|
874
|
+
const repoRoot = await findRepoRoot(deps2.cwd);
|
|
875
|
+
return { portal, session, module, source: overrides ? { kind: "flag", overrides } : { kind: "flag" }, repoRoot };
|
|
876
|
+
}
|
|
877
|
+
function profileOrder(cfg, cachedOrigins, portalArg) {
|
|
878
|
+
if (portalArg) return [resolvePortal(portalArg, cfg.defaultProfile)];
|
|
879
|
+
const rank = (o) => o === BUILTIN_PORTALS.prod.apiOrigin ? 0 : o === BUILTIN_PORTALS.staging.apiOrigin ? 1 : 2;
|
|
880
|
+
const rest = Object.keys(cfg.profiles).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
881
|
+
const cached = cachedOrigins.filter((c) => cfg.profiles[c]);
|
|
882
|
+
const origins = [...cached, resolvePortal(void 0, cfg.defaultProfile).apiOrigin, ...rest];
|
|
883
|
+
return [...new Set(origins)].filter((c) => cfg.profiles[c]).map((c) => resolvePortal(c, void 0));
|
|
884
|
+
}
|
|
885
|
+
function withDetails(e, extra, hint) {
|
|
886
|
+
return new QoreError(e.code, e.message, {
|
|
887
|
+
hint: hint ?? e.hint,
|
|
888
|
+
status: e.status,
|
|
889
|
+
requestId: e.requestId,
|
|
890
|
+
candidates: e.candidates,
|
|
891
|
+
serverCode: e.serverCode,
|
|
892
|
+
details: { ...e.details, ...extra },
|
|
893
|
+
cause: e
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
async function onProfile(deps2, o, cache, portal, keyed, keys, repoRoot, unsupported) {
|
|
897
|
+
const session = await openSession(deps2, portal, o.command, o.onRequest);
|
|
898
|
+
try {
|
|
899
|
+
await ensureCapability(deps2, portal.apiOrigin, "repo.resolve");
|
|
900
|
+
} catch (e) {
|
|
901
|
+
if (!(e instanceof QoreError) || e.code !== "QORE_UNSUPPORTED_BY_SERVER") throw e;
|
|
902
|
+
unsupported(e);
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
const hits = [];
|
|
906
|
+
for (const r of byPreference(keyed)) {
|
|
907
|
+
const id2 = await cache.get(portal.apiOrigin, r.key);
|
|
908
|
+
if (id2) hits.push({ remote: r, id: id2 });
|
|
909
|
+
}
|
|
910
|
+
if (hits.length > 0 && hits.every((h) => h.id === hits[0].id)) {
|
|
911
|
+
const { remote: r, id: id2 } = hits[0];
|
|
912
|
+
try {
|
|
913
|
+
return { portal, session, module: await detail(session, id2), source: { kind: "remote", remote: r.name, url: r.url }, repoRoot };
|
|
914
|
+
} catch (e) {
|
|
915
|
+
if (!(e instanceof QoreError) || e.code !== "QORE_RESOURCE_NOT_FOUND") throw e;
|
|
916
|
+
for (const h of hits) await cache.drop(portal.apiOrigin, h.remote.key);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const { matches } = await session.api.post(RESOLVE, { repoKeys: keys });
|
|
920
|
+
const ids = [...new Set(matches.map((m) => m.module.id))];
|
|
921
|
+
if (ids.length === 0) return null;
|
|
922
|
+
let id = ids[0];
|
|
923
|
+
if (ids.length > 1) {
|
|
924
|
+
if (o.choose && ids.includes(o.choose)) id = o.choose;
|
|
925
|
+
else {
|
|
926
|
+
const candidates = ids.map((mid) => {
|
|
927
|
+
const own = matches.filter((m2) => m2.module.id === mid);
|
|
928
|
+
const m = own[0];
|
|
929
|
+
const remote2 = preferred(keyed.filter((r) => own.some((x) => x.repoKey === r.key))).name;
|
|
930
|
+
return { kind: "module", id: m.module.id, slug: m.module.slug, name: m.module.name, remote: remote2, tenant: m.tenant.slug };
|
|
931
|
+
});
|
|
932
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", "This repository's remotes match several modules", {
|
|
933
|
+
hint: "Pass --module <slug|id>.",
|
|
934
|
+
candidates
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const matchedKeys = new Set(matches.filter((m) => m.module.id === id).map((m) => m.repoKey));
|
|
939
|
+
if (ids.length === 1) for (const k of matchedKeys) await cache.put(portal.apiOrigin, k, id);
|
|
940
|
+
const remote = preferred(keyed.filter((r) => matchedKeys.has(r.key)));
|
|
941
|
+
return { portal, session, module: await detail(session, id), source: { kind: "remote", remote: remote.name, url: remote.url }, repoRoot };
|
|
942
|
+
}
|
|
943
|
+
async function anyLoggedIn(deps2, cfg) {
|
|
944
|
+
for (const p of Object.values(cfg.profiles)) {
|
|
945
|
+
if (await new TokenStore(deps2.keychain, profileHash(p.apiOrigin, p.issuer)).getRefresh()) return true;
|
|
946
|
+
}
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
async function resolveModuleContext(deps2, o) {
|
|
950
|
+
const cache = new ResolutionCache(join5(deps2.config.dir, "cache"), deps2.now);
|
|
951
|
+
if (o.module) return byFlag(deps2, o, cache);
|
|
952
|
+
const cfg = await deps2.config.read();
|
|
953
|
+
const first = resolvePortal(o.portal, cfg.defaultProfile);
|
|
954
|
+
if (o.portal || !await anyLoggedIn(deps2, cfg)) {
|
|
955
|
+
await openSession(deps2, first, o.command);
|
|
956
|
+
}
|
|
957
|
+
const repo = await repoRemotes(deps2);
|
|
958
|
+
if (repo === null) throw new QoreError("QORE_CONTEXT_REQUIRED", "Not inside a module repository", { hint: HINT_FLAG });
|
|
959
|
+
const { root, keyed, gitError } = repo;
|
|
960
|
+
if (keyed.length === 0) {
|
|
961
|
+
const said = gitError?.hint;
|
|
962
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", "This repository has no remote Qore can match", {
|
|
963
|
+
hint: said ? `${HINT_FLAG} git said: ${said}` : HINT_FLAG,
|
|
964
|
+
details: gitError ? { git: said ?? gitError.message } : void 0,
|
|
965
|
+
cause: gitError
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
const keys = [...new Set(keyed.map((r) => r.key))];
|
|
969
|
+
const tried = [];
|
|
970
|
+
const answered = [];
|
|
971
|
+
const skipped = [];
|
|
972
|
+
const skipErrors = [];
|
|
973
|
+
let unsupported;
|
|
974
|
+
for (const portal of profileOrder(cfg, await cache.originsFor(keys), o.portal)) {
|
|
975
|
+
tried.push(label(portal));
|
|
976
|
+
let capabilityMissing = false;
|
|
977
|
+
try {
|
|
978
|
+
const mc = await onProfile(deps2, o, cache, portal, keyed, keys, root, (e) => {
|
|
979
|
+
capabilityMissing = true;
|
|
980
|
+
unsupported ??= e;
|
|
981
|
+
skipped.push({ profile: label(portal), code: e.code, message: e.message });
|
|
982
|
+
});
|
|
983
|
+
if (mc) return mc;
|
|
984
|
+
if (!capabilityMissing) answered.push(label(portal));
|
|
985
|
+
} catch (e) {
|
|
986
|
+
if (!(e instanceof QoreError) || !SKIPPABLE.has(e.code)) throw e;
|
|
987
|
+
skipped.push({ profile: label(portal), code: e.code, message: e.message });
|
|
988
|
+
skipErrors.push(e);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
const details = { triedProfiles: tried };
|
|
992
|
+
if (skipped.length > 0) details.skippedProfiles = skipped;
|
|
993
|
+
if (answered.length === 0) {
|
|
994
|
+
const cause = skipErrors[0] ?? unsupported;
|
|
995
|
+
if (cause) throw withDetails(cause, details);
|
|
996
|
+
}
|
|
997
|
+
const unchecked = skipped.length ? ` ${skipped.map((s) => s.profile).join(", ")} could not be checked (${skipped.map((s) => s.code).join(", ")}).` : "";
|
|
998
|
+
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"));
|
|
999
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", "No module selected", {
|
|
1000
|
+
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`."),
|
|
1001
|
+
details
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
var appCandidates = (apps) => apps.map((a) => ({ kind: "app", id: a.id, slug: a.slug, name: a.name }));
|
|
1005
|
+
function pickApp(mc, app) {
|
|
1006
|
+
const apps = mc.module.apps;
|
|
1007
|
+
if (app) {
|
|
1008
|
+
const found = apps.find((a) => a.id === app || a.slug === app);
|
|
1009
|
+
if (!found) {
|
|
1010
|
+
throw new QoreError("QORE_RESOURCE_NOT_FOUND", `${mc.module.slug} is not part of an app "${app}" you can read`, {
|
|
1011
|
+
candidates: appCandidates(apps)
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
return found;
|
|
1015
|
+
}
|
|
1016
|
+
if (apps.length === 1) return apps[0];
|
|
1017
|
+
if (apps.length === 0) throw new QoreError("QORE_RESOURCE_NOT_FOUND", `${mc.module.slug} is not part of an app you can read`);
|
|
1018
|
+
throw new QoreError("QORE_APP_AMBIGUOUS", `${mc.module.slug} is part of several apps`, { hint: "Pass --app <slug>.", candidates: appCandidates(apps) });
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// src/core/env/fetch.ts
|
|
1022
|
+
async function fetchLocalEnv(deps2, mc, app) {
|
|
1023
|
+
await ensureCapability(deps2, mc.portal.apiOrigin, "module.local-env");
|
|
1024
|
+
try {
|
|
1025
|
+
return await mc.session.api.get(
|
|
1026
|
+
`/api/cli/v1/modules/${encodeURIComponent(mc.module.id)}/local-env?app_id=${encodeURIComponent(app.id)}`
|
|
1027
|
+
);
|
|
1028
|
+
} catch (e) {
|
|
1029
|
+
if (!(e instanceof QoreError) || e.code !== "QORE_ACCESS_DENIED") throw e;
|
|
1030
|
+
if (e.serverCode === "app.access.denied") {
|
|
1031
|
+
throw withDetails(
|
|
1032
|
+
e,
|
|
1033
|
+
{ required: "write", have: mc.module.access },
|
|
1034
|
+
`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.`
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
if (e.serverCode === "auth.scope.insufficient") throw withDetails(e, {}, "Run `qore login` again.");
|
|
1038
|
+
throw e;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// src/core/env/merge.ts
|
|
1043
|
+
function parseEnv(contents) {
|
|
1044
|
+
const map = /* @__PURE__ */ new Map();
|
|
1045
|
+
for (const line of contents.split("\n")) {
|
|
1046
|
+
const m = line.replace(/\r$/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
1047
|
+
if (m?.[1] !== void 0 && m[2] !== void 0) map.set(m[1], m[2]);
|
|
1048
|
+
}
|
|
1049
|
+
return map;
|
|
1050
|
+
}
|
|
1051
|
+
function isManagedKey(key, managed) {
|
|
1052
|
+
for (const pattern of managed) {
|
|
1053
|
+
if (pattern.endsWith("*")) {
|
|
1054
|
+
if (key.startsWith(pattern.slice(0, -1))) return true;
|
|
1055
|
+
} else if (key === pattern) return true;
|
|
1056
|
+
}
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
function planEnvUpdate({
|
|
1060
|
+
current,
|
|
1061
|
+
fetched,
|
|
1062
|
+
choices = {},
|
|
1063
|
+
defaultChoice = "keep",
|
|
1064
|
+
managed = []
|
|
1065
|
+
}) {
|
|
1066
|
+
const merged = new Map(current);
|
|
1067
|
+
const conflicts = [];
|
|
1068
|
+
const added = [];
|
|
1069
|
+
const unchanged = [];
|
|
1070
|
+
const overwritten = [];
|
|
1071
|
+
for (const [key, portalValue] of Object.entries(fetched)) {
|
|
1072
|
+
const hasLocal = current.has(key);
|
|
1073
|
+
const localValue = current.get(key);
|
|
1074
|
+
const localEmpty = !hasLocal || localValue === "" || localValue === '""' || localValue === "''";
|
|
1075
|
+
if (localEmpty) {
|
|
1076
|
+
merged.set(key, portalValue);
|
|
1077
|
+
added.push(key);
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
if (localValue === portalValue) {
|
|
1081
|
+
unchanged.push(key);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
if (isManagedKey(key, managed)) {
|
|
1085
|
+
merged.set(key, portalValue);
|
|
1086
|
+
overwritten.push(key);
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
conflicts.push(key);
|
|
1090
|
+
const choice = choices[key] ?? defaultChoice;
|
|
1091
|
+
merged.set(key, choice === "use-portal" ? portalValue : localValue);
|
|
1092
|
+
}
|
|
1093
|
+
return { merged, conflicts, added, unchanged, overwritten };
|
|
1094
|
+
}
|
|
1095
|
+
function renderEnv(original, merged) {
|
|
1096
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1097
|
+
const lines = original.split("\n");
|
|
1098
|
+
const out = lines.map((line) => {
|
|
1099
|
+
const m = line.replace(/\r$/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
1100
|
+
if (!m?.[1]) return line;
|
|
1101
|
+
const key = m[1];
|
|
1102
|
+
if (!merged.has(key)) return line;
|
|
1103
|
+
seen.add(key);
|
|
1104
|
+
return `${key}=${merged.get(key) ?? ""}`;
|
|
1105
|
+
});
|
|
1106
|
+
const appended = [];
|
|
1107
|
+
for (const [key, value] of merged) {
|
|
1108
|
+
if (!seen.has(key)) appended.push(`${key}=${value}`);
|
|
1109
|
+
}
|
|
1110
|
+
let text = out.join("\n");
|
|
1111
|
+
if (appended.length) {
|
|
1112
|
+
if (text.length && !text.endsWith("\n")) text += "\n";
|
|
1113
|
+
text += appended.join("\n") + "\n";
|
|
1114
|
+
}
|
|
1115
|
+
return text;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/core/env/pull.ts
|
|
1119
|
+
import { lstat, realpath } from "fs/promises";
|
|
1120
|
+
import { basename, dirname as dirname2, isAbsolute, relative, resolve as resolve2, sep } from "path";
|
|
1121
|
+
var outside = (target) => new QoreError("QORE_CONTEXT_INVALID", `The env file must be inside the repository (got ${target})`, {
|
|
1122
|
+
hint: "Use a path inside the repository for --target or .qore.json envFile."
|
|
1123
|
+
});
|
|
1124
|
+
var symlinked = (target) => new QoreError("QORE_CONTEXT_INVALID", `The env file target must not be a symlink (got ${target})`, {
|
|
1125
|
+
hint: "Use a path inside the repository for --target or .qore.json envFile."
|
|
1126
|
+
});
|
|
1127
|
+
function inside(root, p) {
|
|
1128
|
+
const rel = relative(root, p);
|
|
1129
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel);
|
|
1130
|
+
}
|
|
1131
|
+
async function resolveTarget(root, target, base = root) {
|
|
1132
|
+
const realRoot = await realpath(root);
|
|
1133
|
+
const realBase = base === root ? realRoot : await realpath(base);
|
|
1134
|
+
const abs = resolve2(realBase, target);
|
|
1135
|
+
if (!isAbsolute(target) && !inside(realRoot, abs)) throw outside(target);
|
|
1136
|
+
let parent;
|
|
1137
|
+
try {
|
|
1138
|
+
parent = await realpath(dirname2(abs));
|
|
1139
|
+
} catch (e) {
|
|
1140
|
+
if (e.code !== "ENOENT") throw e;
|
|
1141
|
+
throw new QoreError("QORE_CONTEXT_INVALID", `The directory for ${target} does not exist`, {
|
|
1142
|
+
hint: "Create the parent directory, or point --target / .qore.json envFile at an existing one."
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
const final = resolve2(parent, basename(abs));
|
|
1146
|
+
if (!inside(realRoot, final)) throw outside(target);
|
|
1147
|
+
try {
|
|
1148
|
+
const st = await lstat(final);
|
|
1149
|
+
if (st.isSymbolicLink()) throw symlinked(target);
|
|
1150
|
+
const realFinal = await realpath(final);
|
|
1151
|
+
if (!inside(realRoot, realFinal)) throw outside(target);
|
|
1152
|
+
} catch (e) {
|
|
1153
|
+
if (e instanceof QoreError) throw e;
|
|
1154
|
+
if (e.code !== "ENOENT") throw e;
|
|
1155
|
+
}
|
|
1156
|
+
return final;
|
|
1157
|
+
}
|
|
1158
|
+
function planPull(o) {
|
|
1159
|
+
const original = o.existing ?? o.example ?? "";
|
|
1160
|
+
const current = parseEnv(original);
|
|
1161
|
+
const r = planEnvUpdate({ current, fetched: o.fetched, choices: o.choices ?? {}, defaultChoice: o.defaultChoice, managed: o.managed });
|
|
1162
|
+
const choiceOf = (k) => o.choices?.[k] ?? o.defaultChoice;
|
|
1163
|
+
const generated = [];
|
|
1164
|
+
for (const key of o.localSecrets) {
|
|
1165
|
+
const v = r.merged.get(key);
|
|
1166
|
+
if (v === void 0 || v === "" || v === '""' || v === "''") {
|
|
1167
|
+
r.merged.set(key, o.random());
|
|
1168
|
+
generated.push(key);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
text: renderEnv(original, r.merged),
|
|
1173
|
+
created: o.existing === null,
|
|
1174
|
+
seededFromExample: o.existing === null && o.example !== null,
|
|
1175
|
+
added: r.added,
|
|
1176
|
+
updated: r.overwritten,
|
|
1177
|
+
unchanged: r.unchanged,
|
|
1178
|
+
conflicts: r.conflicts,
|
|
1179
|
+
kept: r.conflicts.filter((k) => choiceOf(k) === "keep"),
|
|
1180
|
+
replaced: r.conflicts.filter((k) => choiceOf(k) === "use-portal"),
|
|
1181
|
+
generated
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// src/cli/context.ts
|
|
1186
|
+
var allOfKind = (c, kind) => !!c && c.length > 0 && c.every((x) => x.kind === kind && typeof x.id === "string");
|
|
1187
|
+
async function moduleContextFor(ctx, flags, command) {
|
|
1188
|
+
const g = ctx.opts();
|
|
1189
|
+
const onRequest = g.verbose ? (l) => ctx.io.err(l) : void 0;
|
|
1190
|
+
const base = { portal: g.portal, module: flags.module, command, onRequest };
|
|
1191
|
+
try {
|
|
1192
|
+
return await resolveModuleContext(ctx.deps, base);
|
|
1193
|
+
} catch (e) {
|
|
1194
|
+
if (!(e instanceof QoreError) || e.code !== "QORE_CONTEXT_REQUIRED" || !ctx.io.interactive) throw e;
|
|
1195
|
+
if (allOfKind(e.candidates, "module")) {
|
|
1196
|
+
const choose = await ctx.io.select(
|
|
1197
|
+
"Select a module",
|
|
1198
|
+
e.candidates.map((c) => ({ name: `${c.tenant ? `${c.tenant} / ` : ""}${c.slug} (${c.remote})`, value: c.id }))
|
|
1199
|
+
);
|
|
1200
|
+
return resolveModuleContext(ctx.deps, { ...base, choose });
|
|
1201
|
+
}
|
|
1202
|
+
if (allOfKind(e.candidates, "tenant")) {
|
|
1203
|
+
const tenant = await ctx.io.select("Select a tenant", e.candidates.map((c) => ({ name: c.name ?? c.slug, value: c.id })));
|
|
1204
|
+
return resolveModuleContext(ctx.deps, { ...base, tenant });
|
|
1205
|
+
}
|
|
1206
|
+
throw e;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
async function appFor(ctx, mc, app) {
|
|
1210
|
+
try {
|
|
1211
|
+
return pickApp(mc, app);
|
|
1212
|
+
} catch (e) {
|
|
1213
|
+
if (!(e instanceof QoreError) || e.code !== "QORE_APP_AMBIGUOUS" || !ctx.io.interactive) throw e;
|
|
1214
|
+
const id = await ctx.io.select("Select an app", mc.module.apps.map((a) => ({ name: a.name, value: a.id })));
|
|
1215
|
+
return pickApp(mc, id);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
function sourceText(mc) {
|
|
1219
|
+
if (mc.source.kind === "remote") return mc.source.remote;
|
|
1220
|
+
return mc.source.overrides ? `--module, overrides ${mc.source.overrides}` : "--module";
|
|
1221
|
+
}
|
|
1222
|
+
function contextLine(mc, app) {
|
|
1223
|
+
const parts = [mc.module.tenant.name, ...app ? [app.name] : [], mc.module.slug];
|
|
1224
|
+
return `\u2192 ${parts.join(" / ")} (${sourceText(mc)})`;
|
|
1225
|
+
}
|
|
1226
|
+
function contextJson(mc, app) {
|
|
1227
|
+
const { id, slug, name, type, access } = mc.module;
|
|
1228
|
+
return {
|
|
1229
|
+
portal: mc.portal.builtin ?? mc.portal.apiOrigin,
|
|
1230
|
+
tenant: mc.module.tenant,
|
|
1231
|
+
app: app ?? null,
|
|
1232
|
+
module: { id, slug, name, type, access },
|
|
1233
|
+
source: mc.source.kind === "remote" ? mc.source.remote : "--module",
|
|
1234
|
+
...mc.source.kind === "flag" && mc.source.overrides ? { overrides: mc.source.overrides } : {}
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// src/core/trust.ts
|
|
1239
|
+
import { realpath as realpath2 } from "fs/promises";
|
|
1240
|
+
function trustKey(root, platform) {
|
|
1241
|
+
return platform === "win32" ? root.toLowerCase() : root;
|
|
1242
|
+
}
|
|
1243
|
+
async function keyOf(deps2, root) {
|
|
1244
|
+
let real = root;
|
|
1245
|
+
try {
|
|
1246
|
+
real = await realpath2(root);
|
|
1247
|
+
} catch (e) {
|
|
1248
|
+
if (e.code !== "ENOENT") throw e;
|
|
1249
|
+
}
|
|
1250
|
+
return trustKey(real, deps2.platform);
|
|
1251
|
+
}
|
|
1252
|
+
async function isTrusted(deps2, root, moduleId) {
|
|
1253
|
+
const cfg = await deps2.config.read();
|
|
1254
|
+
return (cfg.trustedRepos?.[await keyOf(deps2, root)] ?? []).includes(moduleId);
|
|
1255
|
+
}
|
|
1256
|
+
async function trust(deps2, root, moduleId) {
|
|
1257
|
+
const key = await keyOf(deps2, root);
|
|
1258
|
+
await deps2.config.update((c) => {
|
|
1259
|
+
const repos = c.trustedRepos ??= {};
|
|
1260
|
+
const list = new Set(repos[key] ?? []);
|
|
1261
|
+
list.add(moduleId);
|
|
1262
|
+
repos[key] = [...list];
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// src/cli/trustPrompt.ts
|
|
1267
|
+
async function ensureTrusted(ctx, mc, app, o) {
|
|
1268
|
+
const root = mc.repoRoot ?? ctx.deps.cwd;
|
|
1269
|
+
if (!ctx.opts().json) ctx.io.err(contextLine(mc, app));
|
|
1270
|
+
if (await isTrusted(ctx.deps, root, mc.module.id)) return;
|
|
1271
|
+
if (o.yes) {
|
|
1272
|
+
await trust(ctx.deps, root, mc.module.id);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (!ctx.io.interactive) {
|
|
1276
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", `This repository is not trusted for ${mc.module.slug} yet`, {
|
|
1277
|
+
hint: "Run interactively once, or pass --yes.",
|
|
1278
|
+
details: { reason: "untrusted", repository: root }
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
if (mc.source.kind === "remote") {
|
|
1282
|
+
const key = repoKey(mc.source.url);
|
|
1283
|
+
ctx.io.err(` from remote ${mc.source.remote}: ${key ?? "(unparsable remote URL)"}`);
|
|
1284
|
+
}
|
|
1285
|
+
if (!await ctx.io.confirm("Pull local secrets for this module into this repository?")) throw new CancelledError();
|
|
1286
|
+
await trust(ctx.deps, root, mc.module.id);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// src/cli/commands/env.ts
|
|
1290
|
+
var maskLen = (v) => v === void 0 ? "(unset)" : `\u2022\u2022\u2022\u2022 (${v.length} chars)`;
|
|
1291
|
+
async function readOrNull(p) {
|
|
1292
|
+
try {
|
|
1293
|
+
return await readFile3(p, "utf8");
|
|
1294
|
+
} catch (e) {
|
|
1295
|
+
if (e.code === "ENOENT") return null;
|
|
1296
|
+
throw e;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
async function writeSecurely(target, text) {
|
|
1300
|
+
const base = basename2(target);
|
|
1301
|
+
const tmpName = base.endsWith(".local") ? `${base.slice(0, -".local".length)}.qore-${randomBytes2(6).toString("hex")}.local` : `${base}.qore-${randomBytes2(6).toString("hex")}.tmp`;
|
|
1302
|
+
const tmp = join6(dirname3(target), tmpName);
|
|
1303
|
+
try {
|
|
1304
|
+
await writeFile2(tmp, text, { mode: 384, flag: "wx" });
|
|
1305
|
+
await chmod2(tmp, 384);
|
|
1306
|
+
await rename2(tmp, target);
|
|
1307
|
+
} finally {
|
|
1308
|
+
await unlink(tmp).catch(() => {
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
function registerEnv(program, ctx) {
|
|
1313
|
+
const env = program.command("env").description("Local environment");
|
|
1314
|
+
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) => {
|
|
1315
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
1316
|
+
const g = ctx.opts();
|
|
1317
|
+
const mc = await moduleContextFor(ctx, f, "env pull");
|
|
1318
|
+
if (!mc.repoRoot) {
|
|
1319
|
+
throw new QoreError("QORE_CONTEXT_REQUIRED", "env pull writes into a module repository", { hint: "cd into the module's repository." });
|
|
1320
|
+
}
|
|
1321
|
+
const app = await appFor(ctx, mc, f.app);
|
|
1322
|
+
const cfg = await readRepoConfig(mc.repoRoot);
|
|
1323
|
+
const defaults = moduleDefaults(mc.module.type);
|
|
1324
|
+
const target = f.target ? await resolveTarget(mc.repoRoot, f.target, deps2.cwd) : await resolveTarget(mc.repoRoot, cfg.envFile ?? defaults.envFile);
|
|
1325
|
+
await ensureTrusted(ctx, mc, app, { yes: f.yes });
|
|
1326
|
+
const existing = await readOrNull(target);
|
|
1327
|
+
const example = existing === null ? await readOrNull(join6(dirname3(target), ".env.example")) : null;
|
|
1328
|
+
const local = await fetchLocalEnv(deps2, mc, app);
|
|
1329
|
+
const inputs = {
|
|
1330
|
+
existing,
|
|
1331
|
+
example,
|
|
1332
|
+
fetched: local.env,
|
|
1333
|
+
managed: local.managedKeys,
|
|
1334
|
+
localSecrets: cfg.localSecrets ?? defaults.localSecrets,
|
|
1335
|
+
defaultChoice: f.usePortal ? "use-portal" : "keep",
|
|
1336
|
+
random: () => randomBytes2(32).toString("hex")
|
|
1337
|
+
};
|
|
1338
|
+
let plan = planPull(inputs);
|
|
1339
|
+
if (plan.conflicts.length && io2.interactive && !f.usePortal) {
|
|
1340
|
+
const current = parseEnv(existing ?? example ?? "");
|
|
1341
|
+
const choices = {};
|
|
1342
|
+
let bulk;
|
|
1343
|
+
for (const key of plan.conflicts) {
|
|
1344
|
+
if (bulk) {
|
|
1345
|
+
choices[key] = bulk;
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
const pick = await io2.select(
|
|
1349
|
+
`${key} differs (local ${maskLen(current.get(key))}, portal ${maskLen(local.env[key])})`,
|
|
1350
|
+
[
|
|
1351
|
+
{ name: "keep local", value: "keep" },
|
|
1352
|
+
{ name: "use portal", value: "portal" },
|
|
1353
|
+
{ name: "use portal for all", value: "all" },
|
|
1354
|
+
{ name: "keep local for all", value: "skip" }
|
|
1355
|
+
]
|
|
1356
|
+
);
|
|
1357
|
+
if (pick === "all") bulk = "use-portal";
|
|
1358
|
+
if (pick === "skip") bulk = "keep";
|
|
1359
|
+
choices[key] = pick === "portal" || pick === "all" ? "use-portal" : "keep";
|
|
1360
|
+
}
|
|
1361
|
+
plan = planPull({ ...inputs, choices });
|
|
1362
|
+
}
|
|
1363
|
+
if (existing !== null && plan.text === existing) {
|
|
1364
|
+
await chmod2(target, 384);
|
|
1365
|
+
} else {
|
|
1366
|
+
await writeSecurely(target, plan.text);
|
|
1367
|
+
}
|
|
1368
|
+
const rel = relative2(await realpath3(mc.repoRoot), target);
|
|
1369
|
+
if (g.json) {
|
|
1370
|
+
const names = {
|
|
1371
|
+
created: plan.created,
|
|
1372
|
+
seededFromExample: plan.seededFromExample,
|
|
1373
|
+
added: plan.added,
|
|
1374
|
+
updated: plan.updated,
|
|
1375
|
+
kept: plan.kept,
|
|
1376
|
+
replaced: plan.replaced,
|
|
1377
|
+
unchanged: plan.unchanged,
|
|
1378
|
+
generated: plan.generated
|
|
1379
|
+
};
|
|
1380
|
+
renderJson(io2, { context: contextJson(mc, app), target: rel, ...names });
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (plan.kept.length && !io2.interactive) {
|
|
1384
|
+
io2.err(`! ${plan.kept.length} key(s) differ locally and were kept: ${plan.kept.join(", ")} \u2014 --use-portal takes the portal's values`);
|
|
1385
|
+
}
|
|
1386
|
+
const parts = [
|
|
1387
|
+
plan.added.length && `${plan.added.length} added`,
|
|
1388
|
+
plan.updated.length && `${plan.updated.length} updated (managed)`,
|
|
1389
|
+
plan.replaced.length && `${plan.replaced.length} replaced`,
|
|
1390
|
+
plan.kept.length && `${plan.kept.length} kept (differs locally; --use-portal to overwrite)`,
|
|
1391
|
+
plan.generated.length && `${plan.generated.length} generated`
|
|
1392
|
+
].filter(Boolean);
|
|
1393
|
+
io2.out(`\u2713 ${rel}${plan.seededFromExample ? " (created from .env.example)" : ""} \u2014 ${parts.length ? parts.join(", ") : "up to date"}`);
|
|
1394
|
+
});
|
|
330
1395
|
}
|
|
331
1396
|
|
|
332
1397
|
// src/core/auth/oidc.ts
|
|
@@ -458,202 +1523,6 @@ async function deviceLogin(opts) {
|
|
|
458
1523
|
return t;
|
|
459
1524
|
}
|
|
460
1525
|
|
|
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
|
-
// src/core/profiles.ts
|
|
497
|
-
import { createHash } from "crypto";
|
|
498
|
-
var BUILTIN_PORTALS = {
|
|
499
|
-
prod: {
|
|
500
|
-
apiOrigin: "https://api.quatronic.app",
|
|
501
|
-
pinnedIssuer: "https://auth.quatronic.app/realms/platform-prod"
|
|
502
|
-
},
|
|
503
|
-
staging: {
|
|
504
|
-
apiOrigin: "https://p01--portal-api-staging--hcywmtb2vksv.code.run",
|
|
505
|
-
pinnedIssuer: "https://auth-staging.quatronic.app/realms/platform-staging"
|
|
506
|
-
}
|
|
507
|
-
};
|
|
508
|
-
function builtinFor(origin) {
|
|
509
|
-
for (const [name, p] of Object.entries(BUILTIN_PORTALS)) {
|
|
510
|
-
if (p.apiOrigin === origin) return { ...p, builtin: name };
|
|
511
|
-
}
|
|
512
|
-
return null;
|
|
513
|
-
}
|
|
514
|
-
function toOrigin(raw) {
|
|
515
|
-
let url;
|
|
516
|
-
try {
|
|
517
|
-
url = new URL(raw);
|
|
518
|
-
} catch {
|
|
519
|
-
throw new QoreError("QORE_CONTEXT_INVALID", `Not a portal name or URL: ${raw}`, {
|
|
520
|
-
hint: "Use --portal prod, --portal staging or --portal https://\u2026"
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
524
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
525
|
-
throw new QoreError("QORE_CONTEXT_INVALID", "A portal URL must use https (http only for localhost)");
|
|
526
|
-
}
|
|
527
|
-
return url.origin.toLowerCase();
|
|
528
|
-
}
|
|
529
|
-
function resolvePortal(arg, defaultProfile) {
|
|
530
|
-
const chosen = arg ?? defaultProfile;
|
|
531
|
-
if (chosen === void 0 || chosen === "prod") return { ...BUILTIN_PORTALS.prod, builtin: "prod" };
|
|
532
|
-
if (chosen === "staging") return { ...BUILTIN_PORTALS.staging, builtin: "staging" };
|
|
533
|
-
const origin = toOrigin(chosen);
|
|
534
|
-
return builtinFor(origin) ?? { apiOrigin: origin, pinnedIssuer: null, builtin: null };
|
|
535
|
-
}
|
|
536
|
-
function profileHash(apiOrigin, issuer) {
|
|
537
|
-
return createHash("sha256").update(`${apiOrigin}|${issuer}`).digest("hex").slice(0, 16);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// src/core/api/client.ts
|
|
541
|
-
var QoreApiClient = class {
|
|
542
|
-
constructor(o) {
|
|
543
|
-
this.o = o;
|
|
544
|
-
}
|
|
545
|
-
o;
|
|
546
|
-
async once(path, force) {
|
|
547
|
-
const token = await this.o.token(force);
|
|
548
|
-
let res;
|
|
549
|
-
try {
|
|
550
|
-
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
|
-
}
|
|
558
|
-
});
|
|
559
|
-
} catch (e) {
|
|
560
|
-
throw fetchError(e, new URL(this.o.apiOrigin).host);
|
|
561
|
-
}
|
|
562
|
-
this.o.onRequest?.(`GET ${path} \u2192 ${res.status}${res.headers.get("x-request-id") ? ` (${res.headers.get("x-request-id")})` : ""}`);
|
|
563
|
-
return res;
|
|
564
|
-
}
|
|
565
|
-
async get(path) {
|
|
566
|
-
let res = await this.once(path, false);
|
|
567
|
-
if (res.status === 401) {
|
|
568
|
-
res = await this.once(path, true);
|
|
569
|
-
if (res.status === 401) {
|
|
570
|
-
throw new QoreError("QORE_SESSION_EXPIRED", "The portal no longer accepts your session", {
|
|
571
|
-
hint: "Run `qore login`.",
|
|
572
|
-
status: 401,
|
|
573
|
-
requestId: res.headers.get("x-request-id") ?? void 0
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
if (!res.ok) throw await problemToError(res);
|
|
578
|
-
return await res.json();
|
|
579
|
-
}
|
|
580
|
-
};
|
|
581
|
-
|
|
582
|
-
// src/core/auth/refresh.ts
|
|
583
|
-
var SKEW_MS = 6e4;
|
|
584
|
-
var RefreshCoordinator = class {
|
|
585
|
-
constructor(o) {
|
|
586
|
-
this.o = o;
|
|
587
|
-
}
|
|
588
|
-
o;
|
|
589
|
-
fresh(expiresAt) {
|
|
590
|
-
return new Date(expiresAt).getTime() - this.o.now().getTime() > SKEW_MS;
|
|
591
|
-
}
|
|
592
|
-
async rotate(scope) {
|
|
593
|
-
let stored = await this.o.store.getRefresh();
|
|
594
|
-
if (!stored) throw new QoreError("QORE_NOT_LOGGED_IN", "Not logged in", { hint: "Run `qore login`." });
|
|
595
|
-
let t;
|
|
596
|
-
for (let attempt = 0; ; attempt++) {
|
|
597
|
-
try {
|
|
598
|
-
t = await (await this.o.oidc()).refresh(stored.rt, scope);
|
|
599
|
-
break;
|
|
600
|
-
} catch (e) {
|
|
601
|
-
if (!(e instanceof QoreError && e.code === "QORE_SESSION_EXPIRED")) throw e;
|
|
602
|
-
const now = await this.o.store.getRefresh();
|
|
603
|
-
if (now && now.rt !== stored.rt) {
|
|
604
|
-
if (attempt === 0) {
|
|
605
|
-
stored = now;
|
|
606
|
-
continue;
|
|
607
|
-
}
|
|
608
|
-
throw e;
|
|
609
|
-
}
|
|
610
|
-
await this.o.store.clear();
|
|
611
|
-
throw e;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
await this.o.store.setRefresh({ rt: t.refreshToken, sub: t.sub, scope: t.scope || (scope ?? stored.scope), obtainedAt: this.o.now().toISOString() });
|
|
615
|
-
return t;
|
|
616
|
-
}
|
|
617
|
-
async accessToken(opts = {}) {
|
|
618
|
-
const seen = await this.o.store.getAccess();
|
|
619
|
-
if (!opts.force && seen && this.fresh(seen.expiresAt)) return seen.at;
|
|
620
|
-
return this.o.withLock(this.o.lockName, async () => {
|
|
621
|
-
const again = await this.o.store.getAccess();
|
|
622
|
-
if (again && again.at !== seen?.at && this.fresh(again.expiresAt)) return again.at;
|
|
623
|
-
const t = await this.rotate();
|
|
624
|
-
await this.o.store.setAccess({ at: t.accessToken, expiresAt: new Date(this.o.now().getTime() + t.expiresIn * 1e3).toISOString() });
|
|
625
|
-
return t.accessToken;
|
|
626
|
-
});
|
|
627
|
-
}
|
|
628
|
-
/** A token carrying only `scopes`, held in memory by the caller (qore mcp). */
|
|
629
|
-
async downscoped(scopes) {
|
|
630
|
-
return this.o.withLock(this.o.lockName, async () => (await this.rotate(scopes.join(" "))).accessToken);
|
|
631
|
-
}
|
|
632
|
-
};
|
|
633
|
-
|
|
634
|
-
// src/core/session.ts
|
|
635
|
-
function loginHint(portal) {
|
|
636
|
-
return portal.builtin === "prod" ? "Run `qore login`." : `Run \`qore login --portal ${portal.builtin ?? portal.apiOrigin}\`.`;
|
|
637
|
-
}
|
|
638
|
-
async function openSession(deps2, portal, command, onRequest) {
|
|
639
|
-
const profile = (await deps2.config.read()).profiles[portal.apiOrigin];
|
|
640
|
-
if (!profile) throw new QoreError("QORE_NOT_LOGGED_IN", `Not logged in to ${portal.apiOrigin}`, { hint: loginHint(portal) });
|
|
641
|
-
const hash = profileHash(profile.apiOrigin, profile.issuer);
|
|
642
|
-
const tokens = new TokenStore(deps2.keychain, hash);
|
|
643
|
-
if (!await tokens.getRefresh()) throw new QoreError("QORE_NOT_LOGGED_IN", `Not logged in to ${portal.apiOrigin}`, { hint: loginHint(portal) });
|
|
644
|
-
const refresh = new RefreshCoordinator({ store: tokens, oidc: () => deps2.oidcFor(profile.issuer), withLock: deps2.withLock, lockName: hash, now: deps2.now });
|
|
645
|
-
const api = new QoreApiClient({
|
|
646
|
-
apiOrigin: profile.apiOrigin,
|
|
647
|
-
fetch: deps2.fetch,
|
|
648
|
-
token: (force) => refresh.accessToken({ force }),
|
|
649
|
-
cliVersion: deps2.version,
|
|
650
|
-
command,
|
|
651
|
-
platform: `${deps2.platform} ${process.arch}; node ${process.versions.node}`,
|
|
652
|
-
onRequest
|
|
653
|
-
});
|
|
654
|
-
return { profile, api, tokens, refresh };
|
|
655
|
-
}
|
|
656
|
-
|
|
657
1526
|
// src/cli/commands/login.ts
|
|
658
1527
|
function registerLogin(program, ctx) {
|
|
659
1528
|
program.command("login").description("Log in with your Qore account").option("--no-browser", "only print the URL").action(async (cmd) => {
|
|
@@ -724,9 +1593,9 @@ function registerLogin(program, ctx) {
|
|
|
724
1593
|
await deps2.config.update((c) => {
|
|
725
1594
|
c.profiles[portal.apiOrigin].activeTenant = active;
|
|
726
1595
|
});
|
|
727
|
-
const
|
|
728
|
-
if (g.json) renderJson(io2, { portal:
|
|
729
|
-
else io2.out(`\u2713 Logged in as ${me.email} (${
|
|
1596
|
+
const label2 = portal.builtin ?? portal.apiOrigin;
|
|
1597
|
+
if (g.json) renderJson(io2, { portal: label2, email: me.email, activeTenant: active ?? null });
|
|
1598
|
+
else io2.out(`\u2713 Logged in as ${me.email} (${label2})`);
|
|
730
1599
|
});
|
|
731
1600
|
}
|
|
732
1601
|
|
|
@@ -762,6 +1631,92 @@ function registerLogout(program, ctx) {
|
|
|
762
1631
|
});
|
|
763
1632
|
}
|
|
764
1633
|
|
|
1634
|
+
// src/cli/commands/run.ts
|
|
1635
|
+
import { constants } from "os";
|
|
1636
|
+
var childRunning = { current: false };
|
|
1637
|
+
function registerRun(program, ctx) {
|
|
1638
|
+
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) => {
|
|
1639
|
+
const { deps: deps2 } = ctx;
|
|
1640
|
+
if (ctx.opts().json) {
|
|
1641
|
+
throw new QoreError("QORE_CONTEXT_INVALID", "run has no --json output", {
|
|
1642
|
+
hint: "Drop --json; the child's own output passes through."
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
const mc = await moduleContextFor(ctx, f, "run");
|
|
1646
|
+
const app = await appFor(ctx, mc, f.app);
|
|
1647
|
+
await ensureTrusted(ctx, mc, app, { yes: f.yes });
|
|
1648
|
+
const local = await fetchLocalEnv(deps2, mc, app);
|
|
1649
|
+
childRunning.current = true;
|
|
1650
|
+
let exit;
|
|
1651
|
+
try {
|
|
1652
|
+
exit = await deps2.spawn.run(cmd, args, { ...deps2.env, ...local.env });
|
|
1653
|
+
} finally {
|
|
1654
|
+
childRunning.current = false;
|
|
1655
|
+
}
|
|
1656
|
+
if (exit.signal) {
|
|
1657
|
+
deps2.raise(exit.signal);
|
|
1658
|
+
ctx.setExitCode(128 + (constants.signals[exit.signal] ?? 0));
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
ctx.setExitCode(exit.code ?? 1);
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// src/cli/format.ts
|
|
1666
|
+
function ago(iso, now) {
|
|
1667
|
+
if (!iso) return "\u2013";
|
|
1668
|
+
const s = Math.max(0, Math.round((now.getTime() - new Date(iso).getTime()) / 1e3));
|
|
1669
|
+
if (s < 60) return "just now";
|
|
1670
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
1671
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
1672
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
1673
|
+
}
|
|
1674
|
+
var pct = (f) => String(Number((f * 100).toFixed(2)));
|
|
1675
|
+
function uptime(h) {
|
|
1676
|
+
if (!h) return "\u2013";
|
|
1677
|
+
const kind = h.cause && h.kind !== "up" ? `${h.kind}: ${h.cause}` : h.kind;
|
|
1678
|
+
return h.uptime24h === null ? kind : `${kind} (${pct(h.uptime24h)}% 24h)`;
|
|
1679
|
+
}
|
|
1680
|
+
function table(rows) {
|
|
1681
|
+
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => (r[i] ?? "").length)));
|
|
1682
|
+
return rows.map((r) => " " + r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" "));
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// src/cli/commands/status.ts
|
|
1686
|
+
function registerStatus(program, ctx) {
|
|
1687
|
+
program.command("status").description("Deployment and health per environment for this module").option("--module <slug|id>", "the module (default: from this repository's git remote)").option("--app <slug|id>", "the app, when the module is part of several").action(async (flags) => {
|
|
1688
|
+
const { deps: deps2, io: io2 } = ctx;
|
|
1689
|
+
const g = ctx.opts();
|
|
1690
|
+
const mc = await moduleContextFor(ctx, flags, "status");
|
|
1691
|
+
await ensureCapability(deps2, mc.portal.apiOrigin, "module.status");
|
|
1692
|
+
const app = await appFor(ctx, mc, flags.app);
|
|
1693
|
+
if (!g.json) io2.err(contextLine(mc, app));
|
|
1694
|
+
const st = await mc.session.api.get(
|
|
1695
|
+
`/api/cli/v1/modules/${encodeURIComponent(mc.module.id)}/status?app_id=${encodeURIComponent(app.id)}`
|
|
1696
|
+
);
|
|
1697
|
+
if (g.json) {
|
|
1698
|
+
renderJson(io2, { context: contextJson(mc, app), environments: st.environments });
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
if (st.environments.length === 0) {
|
|
1702
|
+
io2.out("No environments yet.");
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
const now = deps2.now();
|
|
1706
|
+
const rows = [
|
|
1707
|
+
["ENV", "DEPLOY", "COMMIT", "DEPLOYED", "APP HEALTH"],
|
|
1708
|
+
...st.environments.map((e) => [
|
|
1709
|
+
e.name,
|
|
1710
|
+
e.paused ? "paused" : e.status,
|
|
1711
|
+
e.commit?.sha ?? "\u2013",
|
|
1712
|
+
ago(e.commit?.deployedAt ?? null, now),
|
|
1713
|
+
uptime(e.health)
|
|
1714
|
+
])
|
|
1715
|
+
];
|
|
1716
|
+
for (const line of table(rows)) io2.out(line);
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
|
|
765
1720
|
// src/cli/commands/tenant.ts
|
|
766
1721
|
function registerTenant(program, ctx) {
|
|
767
1722
|
const tenant = program.command("tenant").description("Manage the active tenant");
|
|
@@ -789,7 +1744,7 @@ function registerTenant(program, ctx) {
|
|
|
789
1744
|
const portal = resolvePortal(g.portal, cfg.defaultProfile);
|
|
790
1745
|
const { api } = await openSession(deps2, portal, "tenant use", requestLog(ctx));
|
|
791
1746
|
const me = await api.get("/api/cli/v1/me");
|
|
792
|
-
const candidates = me.tenants.map((t) => t.slug);
|
|
1747
|
+
const candidates = me.tenants.map((t) => ({ kind: "tenant", id: t.id, slug: t.slug, name: t.name }));
|
|
793
1748
|
let chosen;
|
|
794
1749
|
if (slug) {
|
|
795
1750
|
const found = me.tenants.find((t) => t.slug === slug);
|
|
@@ -827,11 +1782,11 @@ function registerVersion(program, ctx) {
|
|
|
827
1782
|
return;
|
|
828
1783
|
}
|
|
829
1784
|
io2.out(`qore ${deps2.version}`);
|
|
830
|
-
const
|
|
1785
|
+
const label2 = portal.builtin ?? portal.apiOrigin;
|
|
831
1786
|
if (portalInfo) {
|
|
832
|
-
io2.out(`portal ${
|
|
1787
|
+
io2.out(`portal ${label2}: api ${portalInfo.apiVersions.join(",")}, min ${portalInfo.minCliVersion}, latest ${portalInfo.latestCliVersion}`);
|
|
833
1788
|
} else {
|
|
834
|
-
io2.out(`portal ${
|
|
1789
|
+
io2.out(`portal ${label2}: unreachable`);
|
|
835
1790
|
}
|
|
836
1791
|
});
|
|
837
1792
|
}
|
|
@@ -849,12 +1804,12 @@ function registerWhoami(program, ctx) {
|
|
|
849
1804
|
if (activeTenant && !me.tenants.some((t) => t.id === activeTenant.id)) {
|
|
850
1805
|
io2.err(`! You no longer have developer access to ${activeTenant.slug}`);
|
|
851
1806
|
}
|
|
852
|
-
const
|
|
1807
|
+
const label2 = portal.builtin ?? portal.apiOrigin;
|
|
853
1808
|
if (g.json) {
|
|
854
|
-
renderJson(io2, { portal:
|
|
1809
|
+
renderJson(io2, { portal: label2, activeTenant: activeTenant ?? null, me });
|
|
855
1810
|
return;
|
|
856
1811
|
}
|
|
857
|
-
io2.out(`${me.email} (${
|
|
1812
|
+
io2.out(`${me.email} (${label2})`);
|
|
858
1813
|
for (const t of me.tenants) {
|
|
859
1814
|
const mark = activeTenant?.id === t.id ? "*" : " ";
|
|
860
1815
|
io2.out(`${mark} ${t.name} ${t.role}`);
|
|
@@ -869,7 +1824,16 @@ function registerWhoami(program, ctx) {
|
|
|
869
1824
|
}
|
|
870
1825
|
|
|
871
1826
|
// src/cli/commands/index.ts
|
|
872
|
-
var COMMANDS = [
|
|
1827
|
+
var COMMANDS = [
|
|
1828
|
+
registerLogin,
|
|
1829
|
+
registerLogout,
|
|
1830
|
+
registerWhoami,
|
|
1831
|
+
registerTenant,
|
|
1832
|
+
registerStatus,
|
|
1833
|
+
registerEnv,
|
|
1834
|
+
registerRun,
|
|
1835
|
+
registerVersion
|
|
1836
|
+
];
|
|
873
1837
|
|
|
874
1838
|
// src/cli/updateCheck.ts
|
|
875
1839
|
var REGISTRY_URL = "https://registry.npmjs.org/@quatronic%2Fqore/latest";
|
|
@@ -903,9 +1867,8 @@ async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS)
|
|
|
903
1867
|
}
|
|
904
1868
|
|
|
905
1869
|
// src/core/config.ts
|
|
906
|
-
import {
|
|
907
|
-
import { join } from "path";
|
|
908
|
-
import { randomBytes } from "crypto";
|
|
1870
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1871
|
+
import { join as join7 } from "path";
|
|
909
1872
|
var empty = () => ({ schemaVersion: 1, profiles: {} });
|
|
910
1873
|
var ConfigStore = class {
|
|
911
1874
|
constructor(dir2) {
|
|
@@ -913,12 +1876,12 @@ var ConfigStore = class {
|
|
|
913
1876
|
}
|
|
914
1877
|
dir;
|
|
915
1878
|
get file() {
|
|
916
|
-
return
|
|
1879
|
+
return join7(this.dir, "config.json");
|
|
917
1880
|
}
|
|
918
1881
|
async read() {
|
|
919
1882
|
let raw;
|
|
920
1883
|
try {
|
|
921
|
-
raw = await
|
|
1884
|
+
raw = await readFile4(this.file, "utf8");
|
|
922
1885
|
} catch (e) {
|
|
923
1886
|
if (e.code === "ENOENT") return empty();
|
|
924
1887
|
throw e;
|
|
@@ -937,15 +1900,58 @@ var ConfigStore = class {
|
|
|
937
1900
|
async update(fn) {
|
|
938
1901
|
const current = await this.read();
|
|
939
1902
|
const next = fn(current) ?? current;
|
|
940
|
-
await
|
|
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);
|
|
1903
|
+
await writeJsonAtomic(this.dir, "config.json", next);
|
|
945
1904
|
return next;
|
|
946
1905
|
}
|
|
947
1906
|
};
|
|
948
1907
|
|
|
1908
|
+
// src/platform/git.ts
|
|
1909
|
+
import { execFile } from "child_process";
|
|
1910
|
+
import { homedir } from "os";
|
|
1911
|
+
function parseRemotes(stdout) {
|
|
1912
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1913
|
+
const out = [];
|
|
1914
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
1915
|
+
const m = /^(\S+)\t(.+) \(fetch\)$/.exec(line);
|
|
1916
|
+
if (!m || seen.has(m[1])) continue;
|
|
1917
|
+
seen.add(m[1]);
|
|
1918
|
+
out.push({ name: m[1], url: m[2] });
|
|
1919
|
+
}
|
|
1920
|
+
return out;
|
|
1921
|
+
}
|
|
1922
|
+
function gitExecOptions() {
|
|
1923
|
+
return { cwd: homedir(), env: { ...process.env, LC_ALL: "C" }, windowsHide: true };
|
|
1924
|
+
}
|
|
1925
|
+
var systemGit = {
|
|
1926
|
+
remotes(repoRoot) {
|
|
1927
|
+
return new Promise((resolve3, reject) => {
|
|
1928
|
+
execFile(
|
|
1929
|
+
"git",
|
|
1930
|
+
["-C", repoRoot, "remote", "-v"],
|
|
1931
|
+
gitExecOptions(),
|
|
1932
|
+
(err, stdout, stderr) => {
|
|
1933
|
+
if (err) {
|
|
1934
|
+
if (err.code === "ENOENT") {
|
|
1935
|
+
reject(new QoreError("QORE_GIT_UNAVAILABLE", "git is not installed or not on PATH", {
|
|
1936
|
+
hint: "Install git (https://git-scm.com/downloads) and open a new terminal."
|
|
1937
|
+
}));
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
const last = String(stderr).trim().split(/\r?\n/).pop() ?? "";
|
|
1941
|
+
reject(new QoreError("QORE_INTERNAL", `git remote -v failed in ${repoRoot}`, {
|
|
1942
|
+
hint: last || void 0,
|
|
1943
|
+
details: { command: "git remote -v", cwd: repoRoot },
|
|
1944
|
+
cause: err
|
|
1945
|
+
}));
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
resolve3(parseRemotes(String(stdout)));
|
|
1949
|
+
}
|
|
1950
|
+
);
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
};
|
|
1954
|
+
|
|
949
1955
|
// src/platform/keychain.ts
|
|
950
1956
|
import { Entry } from "@napi-rs/keyring";
|
|
951
1957
|
var SERVICE = "qore";
|
|
@@ -1001,15 +2007,15 @@ var OsKeychain = class {
|
|
|
1001
2007
|
};
|
|
1002
2008
|
|
|
1003
2009
|
// src/platform/lock.ts
|
|
1004
|
-
import { mkdir as mkdir2, writeFile as
|
|
1005
|
-
import { join as
|
|
2010
|
+
import { mkdir as mkdir2, writeFile as writeFile3 } from "fs/promises";
|
|
2011
|
+
import { join as join8 } from "path";
|
|
1006
2012
|
import lockfile from "proper-lockfile";
|
|
1007
2013
|
function fileLock(dir2) {
|
|
1008
2014
|
return async (name, fn) => {
|
|
1009
|
-
const locks =
|
|
2015
|
+
const locks = join8(dir2, "locks");
|
|
1010
2016
|
await mkdir2(locks, { recursive: true, mode: 448 });
|
|
1011
|
-
const target =
|
|
1012
|
-
await
|
|
2017
|
+
const target = join8(locks, `${name}.lock`);
|
|
2018
|
+
await writeFile3(target, "", { flag: "a" });
|
|
1013
2019
|
let release;
|
|
1014
2020
|
let compromised;
|
|
1015
2021
|
try {
|
|
@@ -1042,17 +2048,63 @@ function configDir(env, platform, home) {
|
|
|
1042
2048
|
return posix.join(xdg ?? posix.join(home, ".config"), "qore");
|
|
1043
2049
|
}
|
|
1044
2050
|
|
|
2051
|
+
// src/platform/spawn.ts
|
|
2052
|
+
import spawn from "cross-spawn";
|
|
2053
|
+
function signalsFor(platform) {
|
|
2054
|
+
return platform === "win32" ? { ignore: ["SIGINT", "SIGBREAK"], forward: [] } : { ignore: ["SIGINT", "SIGQUIT"], forward: ["SIGTERM", "SIGHUP"] };
|
|
2055
|
+
}
|
|
2056
|
+
var { ignore: IGNORE, forward: FORWARD } = signalsFor(process.platform);
|
|
2057
|
+
function makeSpawner(spawnFn, proc) {
|
|
2058
|
+
return {
|
|
2059
|
+
run(cmd, args, env) {
|
|
2060
|
+
return new Promise((resolve3, reject) => {
|
|
2061
|
+
let child;
|
|
2062
|
+
try {
|
|
2063
|
+
child = spawnFn(cmd, args, { stdio: "inherit", shell: false, env, windowsHide: true });
|
|
2064
|
+
} catch (e) {
|
|
2065
|
+
reject(new QoreError("QORE_INTERNAL", `Could not start ${cmd}: ${e.code ?? "error"}`));
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
const ignore = () => {
|
|
2069
|
+
};
|
|
2070
|
+
const forwarders = FORWARD.map((sig) => [sig, () => {
|
|
2071
|
+
child.kill(sig);
|
|
2072
|
+
}]);
|
|
2073
|
+
for (const sig of IGNORE) proc.on(sig, ignore);
|
|
2074
|
+
for (const [sig, fn] of forwarders) proc.on(sig, fn);
|
|
2075
|
+
const cleanup = () => {
|
|
2076
|
+
for (const sig of IGNORE) proc.off(sig, ignore);
|
|
2077
|
+
for (const [sig, fn] of forwarders) proc.off(sig, fn);
|
|
2078
|
+
};
|
|
2079
|
+
child.once("error", (e) => {
|
|
2080
|
+
cleanup();
|
|
2081
|
+
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"}`));
|
|
2082
|
+
});
|
|
2083
|
+
child.once("exit", (code, signal) => {
|
|
2084
|
+
cleanup();
|
|
2085
|
+
resolve3({ code, signal });
|
|
2086
|
+
});
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
var crossSpawner = makeSpawner(spawn, process);
|
|
2092
|
+
|
|
1045
2093
|
// src/version.ts
|
|
1046
|
-
var VERSION = true ? "0.1.
|
|
2094
|
+
var VERSION = true ? "0.1.1" : "0.0.0-dev";
|
|
1047
2095
|
|
|
1048
2096
|
// src/cli.ts
|
|
1049
2097
|
if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY) setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
1050
|
-
var dir = configDir(process.env, process.platform,
|
|
2098
|
+
var dir = configDir(process.env, process.platform, homedir2());
|
|
1051
2099
|
var argv = process.argv.slice(2);
|
|
1052
|
-
var
|
|
2100
|
+
var preSeparatorArgv = argvBeforeSeparator(argv);
|
|
2101
|
+
var json = preSeparatorArgv.includes("--json");
|
|
1053
2102
|
var ac = new AbortController();
|
|
1054
2103
|
process.on("SIGINT", () => {
|
|
1055
|
-
if (ac.signal.aborted)
|
|
2104
|
+
if (ac.signal.aborted) {
|
|
2105
|
+
if (childRunning.current) return;
|
|
2106
|
+
process.exit(130);
|
|
2107
|
+
}
|
|
1056
2108
|
ac.abort();
|
|
1057
2109
|
});
|
|
1058
2110
|
var fetch = abortableFetch(globalThis.fetch, ac.signal);
|
|
@@ -1065,13 +2117,20 @@ var deps = {
|
|
|
1065
2117
|
oidcFor: (issuer) => createOidc({ issuer, clientId: "qore-cli", fetch }),
|
|
1066
2118
|
env: process.env,
|
|
1067
2119
|
platform: process.platform,
|
|
1068
|
-
version: VERSION
|
|
2120
|
+
version: VERSION,
|
|
2121
|
+
cwd: process.cwd(),
|
|
2122
|
+
git: systemGit,
|
|
2123
|
+
spawn: crossSpawner,
|
|
2124
|
+
raise: (signal) => {
|
|
2125
|
+
process.removeAllListeners(signal);
|
|
2126
|
+
process.kill(process.pid, signal);
|
|
2127
|
+
}
|
|
1069
2128
|
};
|
|
1070
2129
|
var io = processIo(isInteractive({
|
|
1071
2130
|
stdinTTY: Boolean(process.stdin.isTTY),
|
|
1072
2131
|
stdoutTTY: Boolean(process.stdout.isTTY),
|
|
1073
2132
|
json,
|
|
1074
|
-
noInput:
|
|
2133
|
+
noInput: preSeparatorArgv.includes("--no-input"),
|
|
1075
2134
|
env: process.env
|
|
1076
2135
|
}));
|
|
1077
2136
|
process.exitCode = await run(argv, deps, io, ac.signal, COMMANDS);
|