@quatronic/qore 0.1.8 → 0.1.9

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.
Files changed (3) hide show
  1. package/README.md +13 -0
  2. package/dist/cli.js +250 -76
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -23,6 +23,8 @@ qore version # CLI + portal version info
23
23
 
24
24
  qore env pull # write this module's local secrets into an env file
25
25
  qore run -- <command> # run a command with those secrets in its environment
26
+ qore roles list # roles in your code, on local, and yours there
27
+ qore roles use <role…> # announce your code's roles for local and take these
26
28
 
27
29
  qore errors --env stag # recent unresolved errors
28
30
  qore errors FRONTEND-1A --env prod --with trace,logs # one error, its trace and logs
@@ -145,6 +147,17 @@ not in the interactive conflict prompt, not in `--verbose` logs, and not in
145
147
  the portal's own audit trail, which records key names only (see
146
148
  `app.local_env.fetched`).
147
149
 
150
+ #### `qore roles`
151
+
152
+ Run in the app's frontend module. `qore roles use inspector` reads the roles in
153
+ `src/lib/permissions.ts`, tells the portal they exist on `local` (only there —
154
+ staging and production learn roles from the code that environment deploys:
155
+ `main` for staging, the `production` branch for production), and makes exactly
156
+ the named roles yours on `local`. Sign out and back in to the local app
157
+ afterwards. `qore roles list` shows the roles in your code, the ones `local`
158
+ knows, and yours. Needs write on the module, like `qore env pull`. An announced
159
+ role stays on `local`; nothing removes it automatically.
160
+
148
161
  #### `qore run`
149
162
 
150
163
  Runs a command with this module's local secrets injected into its
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="c4bda056-b13b-52b5-831f-2afd3f6c6867")}catch(e){}}();
3
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f7b490f6-b0af-5874-9ffa-f8010e3ba137")}catch(e){}}();
4
4
  import { createRequire as __qoreCreateRequire } from "node:module"; const require = __qoreCreateRequire(import.meta.url);
5
5
  import {
6
6
  addSecretValues,
@@ -655,7 +655,7 @@ var QoreApiClient = class {
655
655
  this.o = o;
656
656
  }
657
657
  o;
658
- async once(method, path, body, force) {
658
+ async once(method, path2, body, force) {
659
659
  const token = await this.o.token(force);
660
660
  const headers = {
661
661
  authorization: `Bearer ${token}`,
@@ -667,7 +667,7 @@ var QoreApiClient = class {
667
667
  if (body !== void 0) headers["content-type"] = "application/json";
668
668
  let res;
669
669
  try {
670
- res = await this.o.fetch(`${this.o.apiOrigin}${path}`, {
670
+ res = await this.o.fetch(`${this.o.apiOrigin}${path2}`, {
671
671
  method,
672
672
  headers,
673
673
  body: body === void 0 ? void 0 : JSON.stringify(body)
@@ -676,13 +676,13 @@ var QoreApiClient = class {
676
676
  throw fetchError(e, new URL(this.o.apiOrigin).host);
677
677
  }
678
678
  const rid = res.headers.get("x-request-id");
679
- this.o.onRequest?.(`${method} ${path} \u2192 ${res.status}${rid ? ` (${rid})` : ""}`);
679
+ this.o.onRequest?.(`${method} ${path2} \u2192 ${res.status}${rid ? ` (${rid})` : ""}`);
680
680
  return res;
681
681
  }
682
- async request(method, path, body, parse2 = (r) => r.json()) {
683
- let res = await this.once(method, path, body, false);
682
+ async request(method, path2, body, parse2 = (r) => r.json()) {
683
+ let res = await this.once(method, path2, body, false);
684
684
  if (res.status === 401) {
685
- res = await this.once(method, path, body, true);
685
+ res = await this.once(method, path2, body, true);
686
686
  if (res.status === 401) {
687
687
  const skew = clockSkewSeconds(res.headers.get("date"), (this.o.now ?? (() => /* @__PURE__ */ new Date()))());
688
688
  if (skew !== null && Math.abs(skew) > 60) {
@@ -703,14 +703,14 @@ var QoreApiClient = class {
703
703
  if (!res.ok) throw await problemToError(res);
704
704
  return parse2(res);
705
705
  }
706
- get(path) {
707
- return this.request("GET", path);
706
+ get(path2) {
707
+ return this.request("GET", path2);
708
708
  }
709
- getText(path) {
710
- return this.request("GET", path, void 0, (r) => r.text());
709
+ getText(path2) {
710
+ return this.request("GET", path2, void 0, (r) => r.text());
711
711
  }
712
- post(path, body) {
713
- return this.request("POST", path, body);
712
+ post(path2, body) {
713
+ return this.request("POST", path2, body);
714
714
  }
715
715
  };
716
716
 
@@ -978,7 +978,7 @@ function repoKey(url) {
978
978
  if (raw.startsWith("git+")) raw = raw.slice(4);
979
979
  if (!raw || raw.includes("\\")) return null;
980
980
  let host;
981
- let path;
981
+ let path2;
982
982
  if (raw.includes("://")) {
983
983
  if (hasDotSegment(raw.slice(raw.indexOf("://") + 3).split("/").slice(1))) return null;
984
984
  let u;
@@ -989,14 +989,14 @@ function repoKey(url) {
989
989
  }
990
990
  if (!SCHEMES.has(u.protocol.toLowerCase())) return null;
991
991
  host = u.hostname;
992
- path = u.pathname;
992
+ path2 = u.pathname;
993
993
  } else {
994
994
  const m = SCP.exec(raw);
995
995
  if (!m) return null;
996
996
  host = m[1];
997
- path = m[2];
997
+ path2 = m[2];
998
998
  }
999
- const segments = path.split("/").filter(Boolean);
999
+ const segments = path2.split("/").filter(Boolean);
1000
1000
  if (!host || segments.length !== 2 || hasDotSegment(segments)) return null;
1001
1001
  const owner = segments[0];
1002
1002
  let repo = segments[1];
@@ -1359,10 +1359,10 @@ async function scanRoot(root) {
1359
1359
  const out = [];
1360
1360
  for (const tenant of (await subdirs(root)).sort()) {
1361
1361
  for (const app of (await subdirs(join6(root, tenant))).sort()) {
1362
- const path = join6(root, tenant, app);
1362
+ const path2 = join6(root, tenant, app);
1363
1363
  try {
1364
- const ws = await readWorkspace(path);
1365
- if (ws) out.push({ kind: "workspace", path, portal: ws.portal, tenantSlug: tenant, appId: ws.appId, appSlug: app });
1364
+ const ws = await readWorkspace(path2);
1365
+ if (ws) out.push({ kind: "workspace", path: path2, portal: ws.portal, tenantSlug: tenant, appId: ws.appId, appSlug: app });
1366
1366
  } catch {
1367
1367
  }
1368
1368
  }
@@ -1440,8 +1440,8 @@ function trimSeparator(p) {
1440
1440
  while (end2 > root.length && (p[end2 - 1] === "/" || p[end2 - 1] === "\\")) end2--;
1441
1441
  return p.slice(0, end2);
1442
1442
  }
1443
- async function underAFile(path) {
1444
- for (let p = dirname3(path); ; p = dirname3(p)) {
1443
+ async function underAFile(path2) {
1444
+ for (let p = dirname3(path2); ; p = dirname3(p)) {
1445
1445
  const s = await stat2(p).catch((e) => e.code === "ENOENT" ? void 0 : null);
1446
1446
  if (s === null) return false;
1447
1447
  if (s) return !s.isDirectory();
@@ -1456,8 +1456,8 @@ function scannedBy(root, e) {
1456
1456
  if (e.kind !== "workspace" || !isInside(root, e.path)) return false;
1457
1457
  return relative2(root, e.path).split(sep).filter(Boolean).length === 2;
1458
1458
  }
1459
- async function writableError(path) {
1460
- const probe = join8(path, `.qore-write-test-${randomBytes3(6).toString("hex")}`);
1459
+ async function writableError(path2) {
1460
+ const probe = join8(path2, `.qore-write-test-${randomBytes3(6).toString("hex")}`);
1461
1461
  try {
1462
1462
  await writeFile3(probe, "");
1463
1463
  await rm2(probe, { force: true });
@@ -1466,42 +1466,42 @@ async function writableError(path) {
1466
1466
  return e;
1467
1467
  }
1468
1468
  }
1469
- var invalid2 = (path, why) => new QoreError("QORE_ROOT_INVALID", `${path} cannot be the projects root: ${why}`, { hint: "Pick another folder: qore init --root <path>." });
1470
- var notWritable = (path, e) => new QoreError("QORE_ROOT_NOT_WRITABLE", `${path} is not writable (${e.code ?? e.message})`, {
1469
+ var invalid2 = (path2, why) => new QoreError("QORE_ROOT_INVALID", `${path2} cannot be the projects root: ${why}`, { hint: "Pick another folder: qore init --root <path>." });
1470
+ var notWritable = (path2, e) => new QoreError("QORE_ROOT_NOT_WRITABLE", `${path2} is not writable (${e.code ?? e.message})`, {
1471
1471
  hint: "Fix its permissions, or pick another folder: qore init --root <path>.",
1472
1472
  cause: e
1473
1473
  });
1474
1474
  async function checkRoot(deps2, input2) {
1475
- const path = expandPath(deps2, input2);
1476
- if (dirname3(path) === path) throw invalid2(path, "it is the filesystem root");
1475
+ const path2 = expandPath(deps2, input2);
1476
+ if (dirname3(path2) === path2) throw invalid2(path2, "it is the filesystem root");
1477
1477
  try {
1478
- if (!(await stat2(path)).isDirectory()) throw invalid2(path, "it is a file");
1478
+ if (!(await stat2(path2)).isDirectory()) throw invalid2(path2, "it is a file");
1479
1479
  } catch (e) {
1480
1480
  if (e instanceof QoreError) throw e;
1481
1481
  const err = e;
1482
- if (err.code === "ENOTDIR") throw invalid2(path, "a parent of it is a file");
1483
- if (NOT_WRITABLE.has(err.code ?? "")) throw notWritable(path, err);
1482
+ if (err.code === "ENOTDIR") throw invalid2(path2, "a parent of it is a file");
1483
+ if (NOT_WRITABLE.has(err.code ?? "")) throw notWritable(path2, err);
1484
1484
  if (err.code !== "ENOENT") throw e;
1485
- if (await underAFile(path)) throw invalid2(path, "a parent of it is a file");
1485
+ if (await underAFile(path2)) throw invalid2(path2, "a parent of it is a file");
1486
1486
  }
1487
- const repo = await findRepoRoot(path);
1488
- if (await findWorkspace(path, deps2.home) || repo && !isInside(repo, deps2.home)) {
1489
- throw invalid2(path, "it lies inside a project (an app workspace or a git repository)");
1487
+ const repo = await findRepoRoot(path2);
1488
+ if (await findWorkspace(path2, deps2.home) || repo && !isInside(repo, deps2.home)) {
1489
+ throw invalid2(path2, "it lies inside a project (an app workspace or a git repository)");
1490
1490
  }
1491
- return path;
1491
+ return path2;
1492
1492
  }
1493
- async function createRoot(path) {
1493
+ async function createRoot(path2) {
1494
1494
  try {
1495
- await mkdir3(path, { recursive: true });
1495
+ await mkdir3(path2, { recursive: true });
1496
1496
  } catch (e) {
1497
1497
  const err2 = e;
1498
- if (NOT_WRITABLE.has(err2.code ?? "")) throw notWritable(path, err2);
1499
- if (err2.code === "ENOTDIR" || err2.code === "EEXIST") throw invalid2(path, "a parent of it is a file");
1498
+ if (NOT_WRITABLE.has(err2.code ?? "")) throw notWritable(path2, err2);
1499
+ if (err2.code === "ENOTDIR" || err2.code === "EEXIST") throw invalid2(path2, "a parent of it is a file");
1500
1500
  throw e;
1501
1501
  }
1502
- const err = await writableError(path);
1503
- if (err) throw notWritable(path, err);
1504
- return realpath3(path);
1502
+ const err = await writableError(path2);
1503
+ if (err) throw notWritable(path2, err);
1504
+ return realpath3(path2);
1505
1505
  }
1506
1506
  async function validateRoot(deps2, input2) {
1507
1507
  return createRoot(await checkRoot(deps2, input2));
@@ -1519,15 +1519,15 @@ async function quietly(fn) {
1519
1519
  }
1520
1520
  async function remember(deps2, e) {
1521
1521
  await quietly(async () => {
1522
- const path = await realpath4(e.path);
1523
- const entry = { ...e, path };
1522
+ const path2 = await realpath4(e.path);
1523
+ const entry = { ...e, path: path2 };
1524
1524
  const cfg = await deps2.config.read();
1525
1525
  const root = cfg.projectsRoot ? await realpath4(cfg.projectsRoot).catch(() => cfg.projectsRoot) : void 0;
1526
1526
  if (root && scannedBy(root, entry)) return;
1527
- const current = cfg.projects?.find((p) => p.path === path);
1527
+ const current = cfg.projects?.find((p) => p.path === path2);
1528
1528
  if (current && same(current, entry)) return;
1529
1529
  await deps2.withLock(LOCK, () => deps2.config.update((c) => {
1530
- c.projects = [...(c.projects ?? []).filter((p) => p.path !== path), entry];
1530
+ c.projects = [...(c.projects ?? []).filter((p) => p.path !== path2), entry];
1531
1531
  }).then(() => void 0));
1532
1532
  });
1533
1533
  }
@@ -1825,12 +1825,12 @@ function registerConfig(program, ctx) {
1825
1825
  io2.out(describeConsent(consent, built));
1826
1826
  if (state && consent.source !== "config") io2.err(`${consent.source} in your environment overrides this setting.`);
1827
1827
  });
1828
- config.command("root").description("Show or set the projects folder qore clones into (see qore init)").argument("[path]", "the new projects folder").option("--unset", "clear it; projects already there stay findable").action(async (path, f) => {
1828
+ config.command("root").description("Show or set the projects folder qore clones into (see qore init)").argument("[path]", "the new projects folder").option("--unset", "clear it; projects already there stay findable").action(async (path2, f) => {
1829
1829
  const { deps: deps2, io: io2 } = ctx;
1830
- if (path !== void 0 && f.unset) {
1830
+ if (path2 !== void 0 && f.unset) {
1831
1831
  throw new QoreError("QORE_CONTEXT_INVALID", "Pass a path or --unset, not both", { hint: "qore config root <path> | qore config root --unset" });
1832
1832
  }
1833
- if (path !== void 0) await setRoot(deps2, await validateRoot(deps2, path));
1833
+ if (path2 !== void 0) await setRoot(deps2, await validateRoot(deps2, path2));
1834
1834
  if (f.unset) await setRoot(deps2, void 0);
1835
1835
  const d = await describeRoot(deps2);
1836
1836
  if (ctx.opts().json) {
@@ -2761,8 +2761,8 @@ async function serveErd(o) {
2761
2761
  const host = req.headers.host;
2762
2762
  if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return end(res, 403);
2763
2763
  if (req.method !== "GET" && req.method !== "HEAD") return end(res, 405);
2764
- const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
2765
- const produce = o.routes[path];
2764
+ const path2 = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
2765
+ const produce = o.routes[path2];
2766
2766
  if (produce) {
2767
2767
  try {
2768
2768
  const body = JSON.stringify(await produce());
@@ -2779,7 +2779,7 @@ async function serveErd(o) {
2779
2779
  return;
2780
2780
  }
2781
2781
  }
2782
- const file = resolve6(root, `.${path === "/" ? "/index.html" : path}`);
2782
+ const file = resolve6(root, `.${path2 === "/" ? "/index.html" : path2}`);
2783
2783
  if (file !== root && !file.startsWith(root + sep3)) return end(res, 404);
2784
2784
  try {
2785
2785
  const body = await readFile6(file);
@@ -3011,10 +3011,10 @@ async function projectCtx(ctx, o) {
3011
3011
  const m = await choose(ctx, known.map((project) => ({ project })), what, false);
3012
3012
  return relocate(ctx, await pathFor(ctx, m, o.needModule));
3013
3013
  }
3014
- function relocate(ctx, path) {
3015
- if (path === ctx.deps.cwd) return ctx;
3016
- if (!ctx.opts().json) ctx.io.err(`\u2192 in ${path}`);
3017
- return withCwd(ctx, path);
3014
+ function relocate(ctx, path2) {
3015
+ if (path2 === ctx.deps.cwd) return ctx;
3016
+ if (!ctx.opts().json) ctx.io.err(`\u2192 in ${path2}`);
3017
+ return withCwd(ctx, path2);
3018
3018
  }
3019
3019
  async function rememberContext(ctx, mc, app) {
3020
3020
  const tenantSlug = mc.module.tenant.slug;
@@ -4036,16 +4036,16 @@ async function writeRunFile(deps2, root, rec) {
4036
4036
  await writeJsonAtomic(dir2, name, rec);
4037
4037
  }
4038
4038
  async function removeRunFile(deps2, root) {
4039
- const { path } = await runFilePath(deps2, root);
4040
- await unlink2(path).catch(() => {
4039
+ const { path: path2 } = await runFilePath(deps2, root);
4040
+ await unlink2(path2).catch(() => {
4041
4041
  });
4042
4042
  }
4043
4043
  async function leftoverRun(deps2, root, o = {}) {
4044
- const { path } = await runFilePath(deps2, root);
4045
- const rec = await readJson(path).catch(() => null);
4044
+ const { path: path2 } = await runFilePath(deps2, root);
4045
+ const rec = await readJson(path2).catch(() => null);
4046
4046
  if (!rec || rec.schemaVersion !== 1) return null;
4047
4047
  if (deps2.launch.isAlive(rec.qorePid)) return rec;
4048
- if (o.prune !== false) await unlink2(path).catch(() => {
4048
+ if (o.prune !== false) await unlink2(path2).catch(() => {
4049
4049
  });
4050
4050
  return null;
4051
4051
  }
@@ -6705,6 +6705,179 @@ function registerLogout(program, ctx) {
6705
6705
  });
6706
6706
  }
6707
6707
 
6708
+ // src/core/roles/api.ts
6709
+ var CAPABILITY = "module.local-roles";
6710
+ var path = (mc, app) => `/api/cli/v1/modules/${encodeURIComponent(mc.module.id)}/local-roles?app_id=${encodeURIComponent(app.id)}`;
6711
+ async function fetchLocalRoles(deps2, mc, app) {
6712
+ await ensureCapability(deps2, mc.portal.apiOrigin, CAPABILITY);
6713
+ return explained(mc, () => mc.session.api.get(path(mc, app)));
6714
+ }
6715
+ async function useLocalRoles(deps2, mc, app, roles, use) {
6716
+ await ensureCapability(deps2, mc.portal.apiOrigin, CAPABILITY);
6717
+ return explained(mc, () => mc.session.api.post(path(mc, app), { roles, use }));
6718
+ }
6719
+ async function explained(mc, call) {
6720
+ try {
6721
+ return await call();
6722
+ } catch (e) {
6723
+ if (!(e instanceof QoreError)) throw e;
6724
+ if (e.serverCode === "app.access.denied") {
6725
+ throw withDetails(
6726
+ e,
6727
+ { required: "write", have: mc.module.access },
6728
+ `You have ${mc.module.access} on ${mc.module.slug}; local roles need write. Ask a maintainer of the app's dev team for write access.`
6729
+ );
6730
+ }
6731
+ if (e.serverCode === "membership.permission.denied") {
6732
+ throw withDetails(e, {}, "Taking roles on this app needs write on one of its dev teams.");
6733
+ }
6734
+ if (e.serverCode === "auth.scope.insufficient") throw withDetails(e, {}, "Run `qore login` again.");
6735
+ if (e.serverCode === "app.no_platform_identity") {
6736
+ throw new QoreError("QORE_CONTEXT_UNSUPPORTED", "This app manages its own users; the portal hands out no roles for it", {
6737
+ hint: "Give yourself the role in the app's own user admin.",
6738
+ serverCode: e.serverCode,
6739
+ requestId: e.requestId
6740
+ });
6741
+ }
6742
+ throw e;
6743
+ }
6744
+ }
6745
+
6746
+ // src/core/roles/read.ts
6747
+ import { join as join26 } from "path";
6748
+
6749
+ // src/core/roles/extract.ts
6750
+ var KNOWN = { user: "User", admin: "App admin" };
6751
+ function defaultLabel(slug) {
6752
+ return KNOWN[slug] ?? slug.charAt(0).toUpperCase() + slug.slice(1);
6753
+ }
6754
+ async function extractRoles(source) {
6755
+ const { default: ts } = await import("typescript");
6756
+ const sf = ts.createSourceFile("permissions.ts", source, ts.ScriptTarget.Latest, true);
6757
+ let obj;
6758
+ const visit = (node) => {
6759
+ const first = ts.isCallExpression(node) ? node.arguments[0] : void 0;
6760
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "definePermissions" && first !== void 0 && ts.isObjectLiteralExpression(first)) {
6761
+ obj = first;
6762
+ }
6763
+ if (!obj) ts.forEachChild(node, visit);
6764
+ };
6765
+ visit(sf);
6766
+ if (!obj) return null;
6767
+ const slugs = [];
6768
+ for (const prop of obj.properties) {
6769
+ if (ts.isPropertyAssignment(prop) && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))) {
6770
+ slugs.push(prop.name.text);
6771
+ }
6772
+ }
6773
+ return slugs.map((slug) => ({ slug, label: defaultLabel(slug) }));
6774
+ }
6775
+
6776
+ // src/core/roles/read.ts
6777
+ var PERMISSIONS_FILE = "src/lib/permissions.ts";
6778
+ var SLUG = /^[a-z0-9][a-z0-9_-]*$/;
6779
+ var MAX_SLUG = 64;
6780
+ var MAX_ROLES = 100;
6781
+ async function readCodeRoles(repoRoot) {
6782
+ const source = await readOrNull(join26(repoRoot, PERMISSIONS_FILE));
6783
+ if (source === null) {
6784
+ throw new QoreError("QORE_CONTEXT_UNSUPPORTED", `No ${PERMISSIONS_FILE} in this module`, {
6785
+ hint: "App roles live in the app's frontend module; run this there."
6786
+ });
6787
+ }
6788
+ const roles = await extractRoles(source);
6789
+ if (!roles?.length) {
6790
+ throw new QoreError("QORE_CONTEXT_INVALID", `${PERMISSIONS_FILE} declares no roles in a definePermissions({ ... }) object`, {
6791
+ hint: "The portal reads only an object literal passed straight to definePermissions \u2014 no variable, no spread, no shorthand ({ admin, user })."
6792
+ });
6793
+ }
6794
+ const invalid4 = roles.filter((r) => !SLUG.test(r.slug) || r.slug.length > MAX_SLUG).map((r) => r.slug);
6795
+ if (invalid4.length) {
6796
+ throw new QoreError("QORE_CONTEXT_INVALID", `The portal refuses these role names: ${invalid4.join(", ")}`, {
6797
+ hint: `Use lowercase letters, digits, - and _, starting with a letter or digit, at most ${MAX_SLUG} characters.`,
6798
+ details: { invalid: invalid4 }
6799
+ });
6800
+ }
6801
+ const unique = [...new Map(roles.map((r) => [r.slug, r])).values()];
6802
+ if (unique.length > MAX_ROLES) {
6803
+ throw new QoreError("QORE_CONTEXT_INVALID", `The portal refuses more than ${MAX_ROLES} roles; ${PERMISSIONS_FILE} declares ${unique.length}`, {
6804
+ hint: "Merge roles that grant the same permissions.",
6805
+ details: { count: unique.length, max: MAX_ROLES }
6806
+ });
6807
+ }
6808
+ return unique;
6809
+ }
6810
+
6811
+ // src/cli/commands/roles.ts
6812
+ async function rolesContext(ctx, f, command) {
6813
+ const c = await projectCtx(ctx, { ref: f.project, module: f.module, app: f.app, needModule: true, command });
6814
+ const mc = await moduleContextFor(c, f, command);
6815
+ if (!mc.repoRoot) {
6816
+ throw new QoreError("QORE_CONTEXT_REQUIRED", `${command} reads the roles in a module repository`, {
6817
+ hint: "cd into the app's frontend module."
6818
+ });
6819
+ }
6820
+ const app = await appFor(c, mc, f.app);
6821
+ await rememberContext(c, mc, app);
6822
+ return { c, mc, app, code: await readCodeRoles(mc.repoRoot) };
6823
+ }
6824
+ function withFlags(cmd) {
6825
+ return cmd.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("--project <ref>", "the project, from anywhere (see qore init)");
6826
+ }
6827
+ function registerRoles(program, ctx) {
6828
+ const roles = program.command("roles").description("App roles on your own machine (the local environment)");
6829
+ withFlags(roles.command("list").description("Roles in your code, roles local knows, and yours there")).action(async (f) => {
6830
+ const { c, mc, app, code } = await rolesContext(ctx, f, "roles list");
6831
+ const local = await fetchLocalRoles(c.deps, mc, app);
6832
+ if (c.opts().json) {
6833
+ renderJson(c.io, {
6834
+ context: contextJson(mc, app),
6835
+ code: code.map((r) => r.slug),
6836
+ local: local.roles,
6837
+ direct: local.direct,
6838
+ effective: local.effective
6839
+ });
6840
+ return;
6841
+ }
6842
+ const known = new Set(local.roles.map((r) => r.slug));
6843
+ const fresh = code.filter((r) => !known.has(r.slug)).map((r) => r.slug);
6844
+ c.io.err(contextLine(mc, app));
6845
+ c.io.out(`In your code ${code.map((r) => r.slug).join(", ")}`);
6846
+ c.io.out(`Known on local ${[...known].join(", ") || "\u2014"}`);
6847
+ c.io.out(`Yours on local ${local.effective.join(", ") || "\u2014"}`);
6848
+ if (fresh.length) {
6849
+ c.io.out(`New in your code: ${fresh.join(", ")} \u2014 \`qore roles use <role...>\` announces them and makes them yours on local.`);
6850
+ }
6851
+ });
6852
+ withFlags(roles.command("use").description("Announce your code's roles for local and make exactly these yours there").argument("<role...>", "the roles you want on local")).action(async (wanted, f) => {
6853
+ const { c, mc, app, code } = await rolesContext(ctx, f, "roles use");
6854
+ const inCode = new Set(code.map((r) => r.slug));
6855
+ const unknown = wanted.filter((r) => !inCode.has(r));
6856
+ if (unknown.length) {
6857
+ throw new QoreError("QORE_CONTEXT_INVALID", `Not a role in ${PERMISSIONS_FILE}: ${unknown.join(", ")}`, {
6858
+ details: { unknown, known: [...inCode] }
6859
+ });
6860
+ }
6861
+ const before = await fetchLocalRoles(c.deps, mc, app);
6862
+ const res = await useLocalRoles(c.deps, mc, app, code, [...new Set(wanted)]);
6863
+ const changed = !sameSet(before.direct, res.direct);
6864
+ const viaGroups = res.effective.filter((r) => !res.direct.includes(r));
6865
+ if (c.opts().json) {
6866
+ renderJson(c.io, { context: contextJson(mc, app), added: res.added, direct: res.direct, effective: res.effective });
6867
+ return;
6868
+ }
6869
+ c.io.err(contextLine(mc, app));
6870
+ c.io.out(`\u2713 Your roles on local: ${res.direct.join(", ")}`);
6871
+ if (res.added.length) c.io.out(` New on local: ${res.added.join(", ")} \u2014 teammates can pick it in the Access tab too.`);
6872
+ if (viaGroups.length) c.io.err(`! You also hold ${viaGroups.join(", ")} on local through a group.`);
6873
+ if (changed) c.io.err("! Sign out and back in to your local app: your current session still has your old roles.");
6874
+ });
6875
+ }
6876
+ function sameSet(a, b) {
6877
+ const s = new Set(a);
6878
+ return s.size === new Set(b).size && b.every((x) => s.has(x));
6879
+ }
6880
+
6708
6881
  // src/cli/commands/status.ts
6709
6882
  var HEAD = ["ENV", "DEPLOY", "COMMIT", "DEPLOYED", "APP HEALTH"];
6710
6883
  var envRow = (e, now) => [e.name, e.paused ? "paused" : e.status, e.commit?.sha ?? "\u2013", ago(e.commit?.deployedAt ?? null, now), uptime(e.health)];
@@ -6779,7 +6952,7 @@ function registerStatus(program, ctx) {
6779
6952
  }
6780
6953
 
6781
6954
  // src/cli/commands/sync.ts
6782
- import { join as join26, relative as relative9 } from "path";
6955
+ import { join as join27, relative as relative9 } from "path";
6783
6956
  import { Option as Option2 } from "commander";
6784
6957
  function asFailure2(slug, e) {
6785
6958
  if (e instanceof QoreError && e.code !== "QORE_GIT_UNAVAILABLE") {
@@ -6811,7 +6984,7 @@ function registerSync(program, ctx) {
6811
6984
  continue;
6812
6985
  }
6813
6986
  try {
6814
- dirs[m.slug] = await deps2.git.inspect(join26(ws.root, m.slug));
6987
+ dirs[m.slug] = await deps2.git.inspect(join27(ws.root, m.slug));
6815
6988
  modulesForPlan.push(m);
6816
6989
  } catch (e) {
6817
6990
  earlyFailures.push(asFailure2(m.slug, e));
@@ -7007,6 +7180,7 @@ var COMMANDS = [
7007
7180
  registerTraces,
7008
7181
  registerErrors,
7009
7182
  registerEnv,
7183
+ registerRoles,
7010
7184
  registerRun,
7011
7185
  registerDev,
7012
7186
  registerDb,
@@ -7151,7 +7325,7 @@ async function maybeNotifyUpdate(deps2, io2, o, timeoutMs = DEFAULT_TIMEOUT_MS)
7151
7325
 
7152
7326
  // src/core/config.ts
7153
7327
  import { readFile as readFile8 } from "fs/promises";
7154
- import { join as join27 } from "path";
7328
+ import { join as join28 } from "path";
7155
7329
  var empty = () => ({ schemaVersion: 1, profiles: {} });
7156
7330
  var ConfigStore = class {
7157
7331
  constructor(dir2) {
@@ -7159,7 +7333,7 @@ var ConfigStore = class {
7159
7333
  }
7160
7334
  dir;
7161
7335
  get file() {
7162
- return join27(this.dir, "config.json");
7336
+ return join28(this.dir, "config.json");
7163
7337
  }
7164
7338
  async read() {
7165
7339
  let raw;
@@ -7192,7 +7366,7 @@ var ConfigStore = class {
7192
7366
  import { execFile, execFileSync } from "child_process";
7193
7367
  import { mkdir as mkdir4, readdir as readdir4, stat as stat7 } from "fs/promises";
7194
7368
  import { homedir } from "os";
7195
- import { dirname as dirname9, join as join28 } from "path";
7369
+ import { dirname as dirname9, join as join29 } from "path";
7196
7370
  function parseRemotes(stdout) {
7197
7371
  const seen = /* @__PURE__ */ new Set();
7198
7372
  const out = [];
@@ -7304,12 +7478,12 @@ async function status(repoRoot) {
7304
7478
  if (r.code !== 0) return { ok: false, stderr: r.stderr, command };
7305
7479
  return parseStatus(r.stdout);
7306
7480
  }
7307
- async function statOrThrow(path) {
7481
+ async function statOrThrow(path2) {
7308
7482
  try {
7309
- return await stat7(path);
7483
+ return await stat7(path2);
7310
7484
  } catch (err) {
7311
7485
  if (err.code === "ENOENT") return void 0;
7312
- throw new QoreError("QORE_INTERNAL", `could not stat ${path}`, { cause: err });
7486
+ throw new QoreError("QORE_INTERNAL", `could not stat ${path2}`, { cause: err });
7313
7487
  }
7314
7488
  }
7315
7489
  async function inspect(dir2) {
@@ -7323,7 +7497,7 @@ async function inspect(dir2) {
7323
7497
  throw new QoreError("QORE_INTERNAL", `could not read ${dir2}`, { cause: err });
7324
7498
  }
7325
7499
  const empty2 = entries.length === 0;
7326
- const isRepo = await statOrThrow(join28(dir2, ".git")) !== void 0;
7500
+ const isRepo = await statOrThrow(join29(dir2, ".git")) !== void 0;
7327
7501
  const dirRemotes = isRepo ? await remotes(dir2) : [];
7328
7502
  return { exists: true, empty: empty2, isRepo, remotes: dirRemotes };
7329
7503
  }
@@ -7385,13 +7559,13 @@ var OsKeychain = class {
7385
7559
 
7386
7560
  // src/platform/lock.ts
7387
7561
  import { mkdir as mkdir5, writeFile as writeFile5 } from "fs/promises";
7388
- import { join as join29 } from "path";
7562
+ import { join as join30 } from "path";
7389
7563
  import lockfile from "proper-lockfile";
7390
7564
  function fileLock(dir2) {
7391
7565
  return async (name, fn) => {
7392
- const locks = join29(dir2, "locks");
7566
+ const locks = join30(dir2, "locks");
7393
7567
  await mkdir5(locks, { recursive: true, mode: 448 });
7394
- const target = join29(locks, `${name}.lock`);
7568
+ const target = join30(locks, `${name}.lock`);
7395
7569
  await writeFile5(target, "", { flag: "a" });
7396
7570
  let release;
7397
7571
  let compromised;
@@ -7750,7 +7924,7 @@ function systemProxy(platform = process.platform) {
7750
7924
  }
7751
7925
 
7752
7926
  // src/version.ts
7753
- var VERSION = true ? "0.1.8" : "0.0.0-dev";
7927
+ var VERSION = true ? "0.1.9" : "0.0.0-dev";
7754
7928
 
7755
7929
  // src/cli.ts
7756
7930
  if (process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY) setGlobalDispatcher(new EnvHttpProxyAgent());
@@ -7830,4 +8004,4 @@ if (!drained) {
7830
8004
  process.exit();
7831
8005
  }
7832
8006
  //# sourceMappingURL=cli.js.map
7833
- //# debugId=c4bda056-b13b-52b5-831f-2afd3f6c6867
8007
+ //# debugId=f7b490f6-b0af-5874-9ffa-f8010e3ba137
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quatronic/qore",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Qore developer CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,7 @@
47
47
  "picocolors": "^1.1.1",
48
48
  "proper-lockfile": "^4.1.2",
49
49
  "semver": "^7.8.5",
50
+ "typescript": "^5.9.2",
50
51
  "undici": "^7.29.1"
51
52
  },
52
53
  "devDependencies": {
@@ -74,7 +75,6 @@
74
75
  "react": "19.3.0",
75
76
  "react-dom": "19.3.0",
76
77
  "tsup": "^8.5.0",
77
- "typescript": "^5.9.2",
78
78
  "typescript-eslint": "^8.40.0",
79
79
  "vite": "8.3.4",
80
80
  "vitest": "^3.2.4"