@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/README.md +224 -2
- package/dist/cli.js +2136 -278
- 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,
|
|
@@ -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((
|
|
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
|
-
|
|
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(
|
|
143
|
-
return (input, init) =>
|
|
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(
|
|
187
|
-
if (
|
|
188
|
-
if (
|
|
189
|
-
if (
|
|
190
|
-
if (
|
|
191
|
-
if (
|
|
205
|
+
function byStatus(status2) {
|
|
206
|
+
if (status2 === 403) return "QORE_ACCESS_DENIED";
|
|
207
|
+
if (status2 === 404) return "QORE_RESOURCE_NOT_FOUND";
|
|
208
|
+
if (status2 === 426) return "QORE_UPGRADE_REQUIRED";
|
|
209
|
+
if (status2 === 429) return "QORE_RATE_LIMITED";
|
|
210
|
+
if (status2 === 502 || status2 === 503 || status2 === 504) return "QORE_API_UNAVAILABLE";
|
|
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
|
|
206
|
-
if (
|
|
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 &&
|
|
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 ? (
|
|
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
|
|
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(
|
|
327
|
+
async function fetchMeta(fetch3, apiOrigin) {
|
|
294
328
|
let res;
|
|
295
329
|
try {
|
|
296
|
-
res = await
|
|
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/
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
return
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
|
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/
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
const
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
|
728
|
-
if (g.json) renderJson(io2, { portal:
|
|
729
|
-
else io2.out(`\u2713 Logged in as ${me.email} (${
|
|
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
|
|
2496
|
+
const label2 = portal.builtin ?? portal.apiOrigin;
|
|
831
2497
|
if (portalInfo) {
|
|
832
|
-
io2.out(`portal ${
|
|
2498
|
+
io2.out(`portal ${label2}: api ${portalInfo.apiVersions.join(",")}, min ${portalInfo.minCliVersion}, latest ${portalInfo.latestCliVersion}`);
|
|
833
2499
|
} else {
|
|
834
|
-
io2.out(`portal ${
|
|
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
|
|
2518
|
+
const label2 = portal.builtin ?? portal.apiOrigin;
|
|
853
2519
|
if (g.json) {
|
|
854
|
-
renderJson(io2, { portal:
|
|
2520
|
+
renderJson(io2, { portal: label2, activeTenant: activeTenant ?? null, me });
|
|
855
2521
|
return;
|
|
856
2522
|
}
|
|
857
|
-
io2.out(`${me.email} (${
|
|
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 = [
|
|
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 {
|
|
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
|
|
2592
|
+
return join12(this.dir, "config.json");
|
|
917
2593
|
}
|
|
918
2594
|
async read() {
|
|
919
2595
|
let raw;
|
|
920
2596
|
try {
|
|
921
|
-
raw = await
|
|
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
|
|
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
|
|
1005
|
-
import { join as
|
|
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 =
|
|
1010
|
-
await
|
|
1011
|
-
const target =
|
|
1012
|
-
await
|
|
2813
|
+
const locks = join14(dir2, "locks");
|
|
2814
|
+
await mkdir4(locks, { recursive: true, mode: 448 });
|
|
2815
|
+
const target = join14(locks, `${name}.lock`);
|
|
2816
|
+
await writeFile4(target, "", { flag: "a" });
|
|
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.
|
|
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,
|
|
2896
|
+
var dir = configDir(process.env, process.platform, homedir2());
|
|
1051
2897
|
var argv = process.argv.slice(2);
|
|
1052
|
-
var
|
|
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)
|
|
2902
|
+
if (ac.signal.aborted) {
|
|
2903
|
+
if (childRunning.current) return;
|
|
2904
|
+
process.exit(130);
|
|
2905
|
+
}
|
|
1056
2906
|
ac.abort();
|
|
1057
2907
|
});
|
|
1058
|
-
var
|
|
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:
|
|
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);
|