clearotron 0.2.4 → 0.3.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +13 -2
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +62 -38
- package/bin/brandowner.mjs +18 -169
- package/bin/clearotron.mjs +3 -1
- package/bin/connect.mjs +28 -19
- package/bin/disconnect.mjs +3 -3
- package/bin/example.mjs +7 -7
- package/bin/framework-preflight.mjs +49 -0
- package/bin/grant.mjs +151 -93
- package/bin/onboard.mjs +220 -138
- package/bin/start.mjs +146 -137
- package/bin/stop.mjs +2 -2
- package/bin/update.mjs +1 -1
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +2 -2
- package/docs/E2E.md +12 -2
- package/docs/ONBOARDING.md +1 -1
- package/docs/PORTAL.md +14 -13
- package/docs/SECURITY.md +23 -24
- package/docs/architecture/04-configuration-reference.md +12 -5
- package/docs/architecture/05-config-governance.md +7 -7
- package/docs/architecture/07-quality-and-audit.md +1 -1
- package/docs/architecture/08-development-guide.md +5 -0
- package/docs/configuration.md +118 -0
- package/docs/decisions/0004-documentation-structure.md +2 -2
- package/docs/decisions/0006-what-the-public-repository-carries.md +2 -2
- package/driver/CHANGELOG.md +70 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/cancel.mjs +27 -0
- package/driver/case-law-sources.mjs +3 -3
- package/driver/company-bundle.mjs +261 -0
- package/driver/compare.mjs +1 -1
- package/driver/compose-read.mjs +2 -2
- package/driver/config-inventory.mjs +2 -2
- package/driver/contract-audit.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/contract-vocabulary.mjs +1 -1
- package/driver/declination-call.mjs +1 -1
- package/driver/deliver-trigger.sh +3 -3
- package/driver/dev-portal.mjs +3 -1
- package/driver/digest-queue.mjs +1 -1
- package/driver/disposition-tool.mjs +1 -1
- package/driver/doc-constants.mjs +1 -1
- package/driver/drain-posture.mjs +2 -2
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +44 -9
- package/driver/effective-scope.mjs +30 -1
- package/driver/effort-model.mjs +6 -6
- package/driver/engine/CONTRACT.md +2 -2
- package/driver/engine/anthropic-agent.mjs +11 -11
- package/driver/engine/jx-turn.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +29 -4
- package/driver/engine/mcp/recording-server.mjs +73 -1
- package/driver/engine/openai-agent.mjs +1 -1
- package/driver/engine/probe.mjs +28 -4
- package/driver/enqueue-schema.mjs +23 -3
- package/driver/findings-model.mjs +2 -2
- package/driver/flag-snapshot.mjs +2 -2
- package/driver/floor-duty.mjs +2 -2
- package/driver/frame-diff-model.mjs +1 -1
- package/driver/framework-preflight.mjs +143 -0
- package/driver/gateway.mjs +9 -1
- package/driver/hit-list.mjs +1 -1
- package/driver/jx-lanes.mjs +1 -1
- package/driver/jx.mjs +1 -1
- package/driver/knockout-assess-record.mjs +1 -1
- package/driver/knockout-review-record.mjs +435 -0
- package/driver/order-probe.mjs +1 -1
- package/driver/outbox-backoff.mjs +2 -2
- package/driver/owner-use-check.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +105 -8
- package/driver/pipeline.mjs +81 -30
- package/driver/plain-register.mjs +77 -3
- package/driver/portal-access.mjs +141 -74
- package/driver/portal-config-view.mjs +59 -70
- package/driver/portal-report.mjs +4 -4
- package/driver/portal-service.mjs +348 -141
- package/driver/portal-upstream.mjs +105 -17
- package/driver/predelivery-lint.mjs +43 -18
- package/driver/product-rows.mjs +1 -1
- package/driver/products.mjs +1 -1
- package/driver/profile-page.html +30 -5
- package/driver/profile-service.mjs +197 -26
- package/driver/profiles.mjs +48 -1
- package/driver/publish/index.mjs +31 -13
- package/driver/publish/knockout.mjs +9 -5
- package/driver/publish/office-record-links.mjs +189 -0
- package/driver/publish/parse.mjs +3 -3
- package/driver/publish/publish-inputs.mjs +26 -0
- package/driver/publish/render-knockout.mjs +42 -42
- package/driver/publish/render.mjs +29 -4
- package/driver/publish/report-data.mjs +2 -2
- package/driver/publish/seed-pool.mjs +1 -1
- package/driver/publish/templates/report.css +8 -8
- package/driver/publish/xlsx.mjs +49 -7
- package/driver/queue-watch-verdict.mjs +2 -2
- package/driver/recipe-service.mjs +1 -1
- package/driver/record-carry.mjs +1 -1
- package/driver/reference-score.mjs +1 -1
- package/driver/reference-strip-signatures.mjs +1 -1
- package/driver/register-availability.mjs +4 -3
- package/driver/register-count.mjs +3 -3
- package/driver/register-records.mjs +1 -1
- package/driver/repair-composers.mjs +1 -1
- package/driver/repairs.mjs +3 -3
- package/driver/replay-archive.mjs +1 -1
- package/driver/report-card-record.mjs +1 -1
- package/driver/result-noun-fields.mjs +5 -0
- package/driver/roster-verdict.mjs +48 -5
- package/driver/run-activity.mjs +1 -1
- package/driver/run-requirements.mjs +18 -5
- package/driver/runner.mjs +24 -15
- package/driver/search-policy.mjs +8 -8
- package/driver/senior-rights.mjs +1 -1
- package/driver/skills/prelim-search/delivery-contract.md +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +10 -7
- package/driver/stages-knockout.mjs +72 -6
- package/driver/stages.mjs +10 -10
- package/driver/suite-census.json +238 -70
- package/driver/synthesis-record.mjs +2 -2
- package/driver/systemd/clearotron-client-mcp.service +3 -3
- package/driver/systemd/clearotron-deploy.service +2 -2
- package/driver/systemd/clearotron-mcp-face.service +1 -1
- package/driver/systemd/clearotron-portal.service +3 -3
- package/driver/systemd/clearotron-worker.service +5 -5
- package/driver/systemd/install-census.mjs +1 -1
- package/driver/systemd/render-units.mjs +9 -9
- package/driver/terminal-clamp.mjs +1 -1
- package/driver/trigger-cap.mjs +18 -2
- package/driver/unit-inventory.mjs +8 -8
- package/driver/usage-ledger.mjs +5 -3
- package/driver/verify-knockout.mjs +7 -7
- package/driver/verify.mjs +5 -5
- package/driver/whatif-memo-run.mjs +1 -1
- package/driver/whatif-queue.mjs +3 -3
- package/driver/whatif-worker.mjs +2 -2
- package/examples/README.md +1 -1
- package/examples/grants.example.json +25 -24
- package/mcp-server/CHANGELOG.md +10 -0
- package/mcp-server/http-server.mjs +3 -3
- package/mcp-server/key-socket.mjs +1 -1
- package/mcp-server/lib/audit-view.mjs +3 -3
- package/mcp-server/lib/brief.mjs +3 -3
- package/mcp-server/lib/driver.mjs +1 -1
- package/mcp-server/lib/events.mjs +1 -1
- package/mcp-server/lib/http-handler.mjs +2 -2
- package/mcp-server/lib/instructions.mjs +2 -2
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/lib/ops.mjs +6 -3
- package/mcp-server/lib/options.mjs +15 -4
- package/mcp-server/lib/plan.mjs +7 -6
- package/mcp-server/lib/runs.mjs +10 -0
- package/mcp-server/lib/whatif.mjs +5 -5
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/README.md +1 -1
- package/mcp-server/remote/client-mcp-apikey.service +1 -1
- package/mcp-server/remote/client-mcp.service +2 -2
- package/mcp-server/remote/trademark-artifacts-http.service +1 -1
- package/mcp-server/server.mjs +38 -24
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-KFAHMgdT.js → index-CWTHP0sH.js} +3471 -1901
- package/portal-ui/dist/assets/{index-1ziUJX1E.css → index-KpytsmNH.css} +79 -26
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/lane-probe.mjs +9 -3
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
- package/scripts/citation-drift-report.mjs +1 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/drive-env-check.mjs +1 -1
- package/scripts/e2e.mjs +5 -5
- package/scripts/env-audit.mjs +13 -1
- package/scripts/headless-page.mjs +5 -5
- package/scripts/live-surface-check.mjs +26 -2
- package/scripts/mint-names-in-force.mjs +19 -5
- package/scripts/mint-reference-strip-backlog.mjs +1 -1
- package/scripts/mint-suite-census.mjs +37 -10
- package/scripts/pack-publishable.mjs +1 -1
- package/scripts/preinstall-node-check.mjs +1 -1
- package/scripts/release-await-cut.mjs +3 -3
- package/scripts/release-cut-decision.mjs +1 -1
- package/scripts/release-dist-tag.mjs +1 -1
- package/scripts/release-install-check.mjs +1 -1
- package/scripts/release-notes-lint.mjs +1 -1
- package/scripts/release-publish-guard.mjs +1 -1
- package/scripts/release-version-pr-checks.mjs +2 -2
- package/scripts/release-version.mjs +61 -5
- package/scripts/render-brand-banner.mjs +1 -1
- package/scripts/render-check.mjs +2 -2
- package/scripts/repo-writes.mjs +1 -1
- package/scripts/report-frame-check.mjs +1 -1
- package/scripts/report-screenshot.mjs +2 -2
- package/scripts/retire-bare-refs.mjs +1 -1
- package/scripts/revisit-render-check.mjs +1 -1
- package/scripts/score.mjs +1 -1
- package/scripts/strip-titles-and-attributions.mjs +389 -0
- package/scripts/strip-tracker-citations.mjs +122 -5
- package/scripts/test-run.mjs +4 -4
- package/scripts/third-party-notices.mjs +1 -1
- package/scripts/verify-publishable.mjs +1 -1
- package/shared/access-audience.mjs +2 -2
- package/shared/anon-overlay.mjs +1 -1
- package/shared/brand.mjs +15 -1
- package/shared/bundle-freshness.mjs +1 -1
- package/shared/bundle-rebuild.mjs +1 -1
- package/shared/checkout-move.mjs +2 -2
- package/shared/client-door.mjs +8 -8
- package/shared/connect-clients.mjs +7 -7
- package/shared/connector-signin-probe.mjs +1 -1
- package/shared/env-aliases.mjs +1 -1
- package/shared/env-local.mjs +5 -5
- package/shared/grants-edit.mjs +76 -0
- package/shared/install-auth.mjs +1 -1
- package/shared/listen.mjs +3 -3
- package/shared/mcp-challenge.mjs +1 -1
- package/shared/names-in-force.mjs +2 -1
- package/shared/onboarding-store.mjs +19 -2
- package/shared/reference-guard-classes.mjs +44 -2
- package/shared/register-selection.mjs +1 -1
- package/shared/scope.mjs +223 -56
- package/shared/secret-file.mjs +1 -1
- package/shared/server-units.mjs +1 -1
- package/shared/staff-domain.mjs +45 -78
- package/shared/summary-blocks.mjs +2 -2
- package/shared/systemd-failure.mjs +3 -3
- package/shared/tracked-files.mjs +1 -1
- package/shared/trigger-lane.mjs +1 -1
- package/shared/tty-style.mjs +1 -1
- package/shared/usage-block.mjs +1 -1
- package/shared/vacuous-pass.mjs +1 -1
- package/shared/verb-shim.mjs +1 -1
package/bin/disconnect.mjs
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
// is listening. The person most likely to believe "I turned it off" is the one who just stopped the
|
|
12
12
|
// service.
|
|
13
13
|
//
|
|
14
|
-
//
|
|
14
|
+
// Ruling, 2026-08-31: record key IDs never secrets; the id goes to the denylist; the record is
|
|
15
15
|
// struck. REVOCATION COMES FIRST in the apply order — if this dies halfway, the half that must already
|
|
16
16
|
// have happened is the credential being dead.
|
|
17
17
|
//
|
|
18
|
-
// ── WHAT THIS VERB STOPPED DOING ON 2026-09-03 (
|
|
18
|
+
// ── WHAT THIS VERB STOPPED DOING ON 2026-09-03 (ruling, Q3) ─────────────
|
|
19
19
|
//
|
|
20
20
|
// It used to stop and REMOVE the unit, and turn `CLIENT_MCP_ACCOUNT_ACCESS` back off. Both were right
|
|
21
21
|
// while the door existed only because a reader had asked for it. The door now comes up with the
|
|
@@ -257,7 +257,7 @@ async function cutEveryoneOff({ dryRun }) {
|
|
|
257
257
|
return 0;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
// THE DISPATCH RUNS ONLY WHEN THIS FILE IS THE COMMAND
|
|
260
|
+
// THE DISPATCH RUNS ONLY WHEN THIS FILE IS THE COMMAND. Importing a verb to reach
|
|
261
261
|
// something inside it must read a module, not start a command — `bin/connect.mjs` opened its interactive
|
|
262
262
|
// prompt and hung a suite when an arm imported it for one message helper.
|
|
263
263
|
//
|
package/bin/example.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { isFrozen, demoChildren, publishSource } from "../driver/demo-container.
|
|
|
50
50
|
|
|
51
51
|
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
52
52
|
|
|
53
|
-
import { usageBlock } from "../shared/usage-block.mjs";
|
|
53
|
+
import { usageBlock } from "../shared/usage-block.mjs";
|
|
54
54
|
const argv = process.argv.slice(2);
|
|
55
55
|
const flag = (n, d = null) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : d; };
|
|
56
56
|
const has = (n) => argv.includes(n);
|
|
@@ -65,11 +65,11 @@ if (has("--help") || has("-h")) {
|
|
|
65
65
|
//
|
|
66
66
|
// Until now the flag was discoverable only by getting it wrong: `--help` listed --run-dir, --base,
|
|
67
67
|
// --port and --no-open and stopped, so three of the four shipped demos were invisible from the
|
|
68
|
-
// command written to give somebody their first look
|
|
68
|
+
// command written to give somebody their first look.
|
|
69
69
|
const shipped = demoChildren(join(REPO, "demo"));
|
|
70
70
|
if (shipped.length) {
|
|
71
71
|
console.log(`\n --product takes one of:`);
|
|
72
|
-
// NO "DEFAULT" MARKER
|
|
72
|
+
// NO "DEFAULT" MARKER. This printed "(the default, when --product is not given)"
|
|
73
73
|
// beside the first id, and kept printing it after the default became ALL of them — so `--help` taught
|
|
74
74
|
// a reader the exact belief the change removed, in the command it was reported against.
|
|
75
75
|
for (const id of shipped) console.log(` ${id}`);
|
|
@@ -110,7 +110,7 @@ const DEMO_ROOT = join(REPO, "demo");
|
|
|
110
110
|
const wanted = flag("--product");
|
|
111
111
|
const children = demoChildren(DEMO_ROOT);
|
|
112
112
|
|
|
113
|
-
// ── ALL OF THEM, UNLESS THE CALLER NARROWED IT
|
|
113
|
+
// ── ALL OF THEM, UNLESS THE CALLER NARROWED IT ──────────────────────────────────────────────────────
|
|
114
114
|
//
|
|
115
115
|
// This used to replay `children[0]` and stop. The package ships one finished report per product, and a
|
|
116
116
|
// first-time reader met one of them with nothing on screen saying the other three existed — so three
|
|
@@ -129,8 +129,8 @@ const sampleDirs = flag("--run-dir")
|
|
|
129
129
|
// the container itself is what it looks at, which is what it always did when `demo/` was empty.
|
|
130
130
|
const sampleDir = sampleDirs[0] ?? resolve(DEMO_ROOT);
|
|
131
131
|
if (!sampleDirs.length || !isFrozen(sampleDir)) {
|
|
132
|
-
// AN ABSENCE IS A FINDING, AND IT NAMES WHAT IT LOOKED AT. This exits 1 and always has;
|
|
133
|
-
//
|
|
132
|
+
// AN ABSENCE IS A FINDING, AND IT NAMES WHAT IT LOOKED AT. This exits 1 and always has; a report of
|
|
133
|
+
// it exiting 0 did not reproduce at v0.1.0 or at main's tip. An arm pins it.
|
|
134
134
|
die(
|
|
135
135
|
`demo: no frozen demo at ${sampleDir}`,
|
|
136
136
|
"",
|
|
@@ -231,7 +231,7 @@ console.log(` reports folder: ${poolRoot}\n`);
|
|
|
231
231
|
mkdirSync(poolRoot, { recursive: true });
|
|
232
232
|
const { republishRun } = await import(pathToFileURL(join(REPO, "driver", "publish", "report-registry.mjs")).href);
|
|
233
233
|
|
|
234
|
-
// ── ONE FAILURE MUST NOT COST THE OTHERS, AND MUST NOT BE A QUIET COUNT
|
|
234
|
+
// ── ONE FAILURE MUST NOT COST THE OTHERS, AND MUST NOT BE A QUIET COUNT ─────────────────────────────
|
|
235
235
|
//
|
|
236
236
|
// Publishing stopped at the first error, which was right when there was one demo and is wrong now: a
|
|
237
237
|
// reader whose knockout capture is unreadable should still get the other three, and should be TOLD which
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
|
+
//
|
|
5
|
+
// clearotron framework — read a risk framework and its manifest, and say what they declare.
|
|
6
|
+
//
|
|
7
|
+
// The command a customer's counsel runs after writing the pair and before pointing a company at it.
|
|
8
|
+
// It creates nothing, rates nothing and calls nobody: it opens the two files exactly as a run would,
|
|
9
|
+
// reports the ladder in the framework's own order, and where the deck and the manifest disagree it
|
|
10
|
+
// names the band and says what the deck did not do.
|
|
11
|
+
//
|
|
12
|
+
// WHY IT IS ITS OWN COMMAND rather than a flag on the onboarding one. `brandowner add --dry-run`
|
|
13
|
+
// answers "would this create the company I meant", and it does check the framework loads — but a person
|
|
14
|
+
// writing a rubric has no company to create yet, and telling them to phrase their question as a
|
|
15
|
+
// half-finished onboarding is how a check goes unrun. The onboarding command prints this same report,
|
|
16
|
+
// from this same module, so the two can never disagree about what a valid framework is.
|
|
17
|
+
|
|
18
|
+
import { isEntrypoint } from "../shared/is-entrypoint.mjs";
|
|
19
|
+
import { invocationPrefix } from "../shared/invocation.mjs";
|
|
20
|
+
import { preflightFramework, formatPreflight } from "../driver/framework-preflight.mjs";
|
|
21
|
+
|
|
22
|
+
const USAGE = (cmd) => `
|
|
23
|
+
${cmd} framework <skills/prelim-search/your-framework.md>
|
|
24
|
+
|
|
25
|
+
Reads a risk framework deck and the manifest beside it, and reports what they declare —
|
|
26
|
+
the ladder, the company the deck names, the shape, and which file answered where.
|
|
27
|
+
Creates nothing. Rates nothing. Contacts nobody.
|
|
28
|
+
|
|
29
|
+
The path is the one a profile carries: it starts with skills/ and is resolved the way a
|
|
30
|
+
run resolves it, so this reads the file a matter would actually be rated under.
|
|
31
|
+
|
|
32
|
+
Exit codes: 0 the deck and the manifest agree · 1 not ready, with the reason · 2 usage
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
export function preflightMain(argv, { out = console.log, err = console.error } = {}) {
|
|
36
|
+
const cmd = invocationPrefix();
|
|
37
|
+
const args = argv.filter((a) => a !== "--");
|
|
38
|
+
if (args.includes("--help") || args.includes("-h")) { out(USAGE(cmd)); return 0; }
|
|
39
|
+
if (args.length !== 1 || args[0].startsWith("-")) { err(USAGE(cmd)); return 2; }
|
|
40
|
+
|
|
41
|
+
const report = preflightFramework(args[0]);
|
|
42
|
+
out(formatPreflight(report));
|
|
43
|
+
// A substitution is not a refusal — the pair may be perfectly valid — but it is the finding this
|
|
44
|
+
// command exists to surface, so it is never the difference between exit 0 and exit 1 and is never
|
|
45
|
+
// silent either. The line is printed above by the formatter.
|
|
46
|
+
return report.ok ? 0 : 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (isEntrypoint(import.meta.url)) process.exit(preflightMain(process.argv.slice(2)));
|
package/bin/grant.mjs
CHANGED
|
@@ -1,37 +1,49 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
4
|
-
// grant.mjs —
|
|
4
|
+
// grant.mjs — give someone access, take it away, or see who has it ( item 2).
|
|
5
5
|
//
|
|
6
6
|
// ── WHY THIS EXISTS ────────────────────────────────────────────────────────────────────────────────
|
|
7
7
|
//
|
|
8
|
-
// The guest list was a JSON file edited by hand. `bin/start.mjs` writes
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// The guest list was a JSON file edited by hand. `bin/start.mjs` writes one at install and nothing
|
|
9
|
+
// added a second person to it, so for anyone self-hosting the only way to let a colleague in was a text
|
|
10
|
+
// editor — and a mistake in that file does not error. `resolvePerson` simply matches nothing, and the
|
|
11
|
+
// person signs in to an empty world with nothing in any log to say why.
|
|
12
12
|
//
|
|
13
13
|
// `npm start` and `npm run setup` exist so that installing is not that. Enrolment is the very next thing
|
|
14
14
|
// the same reader does, and it dropped straight back to the editor.
|
|
15
15
|
//
|
|
16
16
|
// ── ONE OPINION ABOUT VALIDITY, NOT TWO ───────────────────────────────────────────────────────────
|
|
17
17
|
//
|
|
18
|
-
// The
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// them AFTER the mistake shipped.
|
|
18
|
+
// The change itself is made by `withPerson` (shared/grants-edit.mjs), the editor the portal's People
|
|
19
|
+
// page writes through, so the two writers cannot produce two shapes of one fact — and a company the
|
|
20
|
+
// organisation does not hold is refused there, by the same sentence the page would get.
|
|
22
21
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
22
|
+
// The product also knew the other typo direction: `accessView` (driver/portal-config-view.mjs) reports a
|
|
23
|
+
// company that no customer bundle matches. It reported it AFTER the mistake shipped. So this command runs
|
|
24
|
+
// `accessView` OVER THE PROPOSED STATE, seen from the top of the tree, and refuses if the change would
|
|
25
|
+
// introduce that finding. The screen's report and this refusal are the same code by construction.
|
|
27
26
|
//
|
|
28
27
|
// The email rule is borrowed the same way: `makePrincipal` refuses a multi-`@` identity outright, so a
|
|
29
28
|
// grant for one could never resolve. Rather than restate the rule, this asks `makePrincipal`.
|
|
30
29
|
//
|
|
30
|
+
// ── A PERSON IS ACCESS PLUS TWO PERMISSIONS ───────────────────────────────────────────────────────
|
|
31
|
+
//
|
|
32
|
+
// Access is where on the tree a person may look: a whole organisation (`--accounts '*'`) or companies
|
|
33
|
+
// it holds. What they may DO is two switches under `people`: `--run` (start and stop clearances) and
|
|
34
|
+
// `--manage` (add people and companies, change settings). Neither flag is the view-only person, and the
|
|
35
|
+
// output says so in a sentence — someone who can read a company's reports and start nothing is a real
|
|
36
|
+
// posture, not a mistake, but it must never be the surprise.
|
|
37
|
+
//
|
|
38
|
+
// Access to everything is not granted here. The person who installs holds it (`clearotron start`
|
|
39
|
+
// writes that entry), and it is CARRIED through every change this command makes to their entry, never
|
|
40
|
+
// dropped: `withPerson` replaces the entry it writes, and a person who held everything and was then
|
|
41
|
+
// given Run here would otherwise lose the whole install with nothing said.
|
|
42
|
+
//
|
|
31
43
|
// ── THE FILE STAYS AUTHORITATIVE ──────────────────────────────────────────────────────────────────
|
|
32
44
|
//
|
|
33
45
|
// This is an editor for that file and never a second store. It stays hand-editable, and anything this
|
|
34
|
-
// command writes a human can read and change back.
|
|
46
|
+
// command writes a human can read and change back.
|
|
35
47
|
//
|
|
36
48
|
// WRITTEN ATOMICALLY, because the portal reads this file PER REQUEST — that is what makes a grant land
|
|
37
49
|
// without a restart, and it is also what makes a non-atomic write dangerous: a reader catching a
|
|
@@ -52,6 +64,7 @@
|
|
|
52
64
|
import "../shared/env-local.mjs";
|
|
53
65
|
import { readFileSync, existsSync } from "node:fs";
|
|
54
66
|
import { assertGrantsShape } from "../shared/scope.mjs"; // — one shape check, not a second opinion
|
|
67
|
+
import { withPerson, withOrganisation, withCompany } from "../shared/grants-edit.mjs"; // — the People page's own editors
|
|
55
68
|
import { basename } from "node:path";
|
|
56
69
|
import { atomicWrite } from "../driver/progress.mjs";
|
|
57
70
|
import { accessView } from "../driver/portal-config-view.mjs";
|
|
@@ -74,7 +87,7 @@ const out = (msg) => console.log(msg);
|
|
|
74
87
|
/** The grants file, or a stated refusal. NEVER an invented empty one — a typo in the path would then
|
|
75
88
|
* silently create a second guest list nobody reads. */
|
|
76
89
|
function readGrants() {
|
|
77
|
-
if (!existsSync(FILE)) die(`No guest list at ${FILE}. \`clearotron start\` writes
|
|
90
|
+
if (!existsSync(FILE)) die(`No guest list at ${FILE}. \`clearotron start\` writes one the first time it runs — start the product once, or set CLEAROTRON_ACCESS_FILE if your roster lives elsewhere.`);
|
|
78
91
|
let g;
|
|
79
92
|
try { g = JSON.parse(readFileSync(FILE, "utf8")); }
|
|
80
93
|
catch (e) { die(`${basename(FILE)} is not valid JSON (${e.message}). Refusing to touch it — fix it by hand, or the portal will 500 on every request until you do.`); }
|
|
@@ -90,49 +103,66 @@ function readGrants() {
|
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
/**
|
|
93
|
-
* Every
|
|
106
|
+
* Every company a customer bundle actually holds, as `{ key: name }`, or NULL when that cannot be
|
|
107
|
+
* established.
|
|
94
108
|
*
|
|
95
|
-
* EMPTY IS NULL HERE, DELIBERATELY. `accessView` guards its unknown-
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* "
|
|
109
|
+
* EMPTY IS NULL HERE, DELIBERATELY. `accessView` guards its unknown-company check with `known.size` — so
|
|
110
|
+
* an empty map means the check DOES NOT RUN and reports nothing, which is indistinguishable from running
|
|
111
|
+
* and finding nothing. Collapsing empty into null makes the command say "not checked" instead of implying
|
|
112
|
+
* "checked and clean": not probed is not passed.
|
|
99
113
|
*/
|
|
100
|
-
async function
|
|
114
|
+
async function knownCompanies() {
|
|
101
115
|
try {
|
|
102
116
|
const { loadProfiles } = await import("../driver/profiles.mjs");
|
|
103
|
-
const
|
|
104
|
-
|
|
117
|
+
const named = [...loadProfiles({ force: true }).entries()]
|
|
118
|
+
.map(([key, p]) => [key, typeof p?.name === "string" && p.name.trim() ? p.name.trim() : key]);
|
|
119
|
+
return named.length ? Object.fromEntries(named) : null;
|
|
105
120
|
} catch { return null; }
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
/**
|
|
109
124
|
* Would this proposed state be a good one? Asked of `accessView`, so the answer is the screen's answer.
|
|
110
125
|
*
|
|
126
|
+
* SEEN FROM THE TOP OF THE TREE. The screen hides what its viewer cannot see; this command is run by
|
|
127
|
+
* whoever administers the box, and a finding hidden from it would be a check that did not run.
|
|
128
|
+
*
|
|
111
129
|
* Returns the findings ATTRIBUTABLE TO THIS CHANGE only. A guest list that already contains someone
|
|
112
130
|
* else's dangling grant is not this operator's problem to fix before they can add a colleague, and
|
|
113
131
|
* refusing on it would make the command unusable on exactly the messy file it exists to tidy.
|
|
114
132
|
*/
|
|
115
|
-
function faultsIntroduced(before, after,
|
|
116
|
-
const view = (g) => accessView({ grants: g,
|
|
133
|
+
function faultsIntroduced(before, after, companies, email) {
|
|
134
|
+
const view = (g) => accessView({ grants: g, viewer: { everything: true }, companies: companies ?? {} });
|
|
117
135
|
const b = view(before), a = view(after);
|
|
118
|
-
const
|
|
119
|
-
const dangling = rowFor(a).flatMap((p) => p.dangling);
|
|
136
|
+
const dangling = a.people.filter((p) => p.email === email).flatMap((p) => p.dangling);
|
|
120
137
|
const newUnknown = a.unknownAccounts.filter((x) => !b.unknownAccounts.includes(x));
|
|
121
138
|
return { dangling: [...new Set(dangling)], unknownAccounts: newUnknown };
|
|
122
139
|
}
|
|
123
140
|
|
|
141
|
+
/** What a person may do, in a sentence — the view-only case said as plainly as the others. */
|
|
142
|
+
function mayLine(email, entry) {
|
|
143
|
+
const run = entry?.run === true, manage = entry?.manage === true;
|
|
144
|
+
const whole = entry?.everything === true ? ", with access to everything on this install" : "";
|
|
145
|
+
if (!run && !manage)
|
|
146
|
+
return `${email} can view what this gives them and do nothing else: no clearance can be started, and nobody added${whole}. Add --run, --manage or both to change that.`;
|
|
147
|
+
return `${email} may ${[run && "run clearances", manage && "manage (add people and companies, change settings)"].filter(Boolean).join(" and ")}${whole}.`;
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
function usage(code = 1) {
|
|
125
151
|
// — HELP ASKED FOR GOES TO STDOUT; usage printed as a REFUSAL goes to stderr.
|
|
126
152
|
// This wrote to stderr either way, and it was the only verb of nine that did: `clearotron grant
|
|
127
153
|
// --help | less` showed nothing, and neither did any redirect a reader would try. The exit code
|
|
128
154
|
// already carries the distinction, so it decides the stream.
|
|
129
155
|
const out = code === 0 ? console.log : console.error;
|
|
130
|
-
out(`clearotron grant — edit
|
|
156
|
+
out(`clearotron grant — edit who may use this install
|
|
131
157
|
|
|
132
158
|
grant list
|
|
133
|
-
grant add <email> --tenant <
|
|
134
|
-
grant remove <email> [--tenant <
|
|
135
|
-
grant remove-tenant <
|
|
159
|
+
grant add <email> --tenant <organisation> --accounts <key,key|*> [--run] [--manage]
|
|
160
|
+
grant remove <email> [--tenant <organisation>]
|
|
161
|
+
grant remove-tenant <organisation>
|
|
162
|
+
|
|
163
|
+
--accounts * is the whole organisation, including companies filed under it later. --run lets the
|
|
164
|
+
person start and stop clearances; --manage lets them add people and companies and change settings.
|
|
165
|
+
With neither they can view what they have access to and nothing else.
|
|
136
166
|
|
|
137
167
|
The file (${FILE || "CLEAROTRON_ACCESS_FILE unset"}) stays authoritative and hand-editable; this is an
|
|
138
168
|
editor for it. The portal re-reads it per request, so a change lands with no restart.`);
|
|
@@ -145,18 +175,21 @@ const flag = (name) => { const i = argv.indexOf(`--${name}`); return i >= 0 ? ar
|
|
|
145
175
|
|
|
146
176
|
if (!cmd || cmd === "--help" || cmd === "-h") usage(0);
|
|
147
177
|
|
|
148
|
-
|
|
149
|
-
const tenants = grants.tenants;
|
|
178
|
+
let grants = readGrants();
|
|
150
179
|
|
|
151
180
|
if (cmd === "list") {
|
|
152
|
-
const
|
|
153
|
-
const v = accessView({ grants,
|
|
154
|
-
if (!v.people.length) out("
|
|
181
|
+
const companies = await knownCompanies();
|
|
182
|
+
const v = accessView({ grants, viewer: { everything: true }, companies: companies ?? {} });
|
|
183
|
+
if (!v.people.length) out("Nobody has access yet. `clearotron start` gives the person who installs access to everything; `grant add` gives anyone else theirs.");
|
|
155
184
|
for (const p of v.people) {
|
|
156
|
-
const reach = p.
|
|
157
|
-
|
|
185
|
+
const reach = p.access.map((pt) => pt.kind === "everything" ? "everything on this install"
|
|
186
|
+
: pt.kind === "organisation"
|
|
187
|
+
? `the whole of ${pt.name}${pt.name !== pt.key ? ` (${pt.key})` : ""} — every company it holds: ${(grants.tenants[pt.key]?.accounts ?? []).filter((a) => a !== "generic").join(", ") || "none yet"}`
|
|
188
|
+
: `${pt.key}, in ${pt.org}`).join("; ") || "nothing";
|
|
189
|
+
const may = [p.permissions.run && "run clearances", p.permissions.manage && "manage"].filter(Boolean).join(" and ") || "view only";
|
|
190
|
+
out(`${p.email}\n access ${reach}\n may ${may}${p.dangling.length ? `\n ⚠ DANGLING ${p.dangling.join(", ")} — the organisation that grants ${p.dangling.length > 1 ? "these does not hold them" : "this does not hold it"}, so it resolves to nothing` : ""}`);
|
|
158
191
|
}
|
|
159
|
-
if (
|
|
192
|
+
if (companies === null) out("\nNote: customer bundles could not be read, so companies were NOT checked against them.");
|
|
160
193
|
else if (v.unknownAccounts.length) out(`\n⚠ Named in grants but matching no customer bundle: ${v.unknownAccounts.join(", ")}`);
|
|
161
194
|
process.exit(0);
|
|
162
195
|
}
|
|
@@ -165,72 +198,88 @@ if (cmd === "add") {
|
|
|
165
198
|
const email = String(argv[1] ?? "").trim().toLowerCase();
|
|
166
199
|
const tenant = flag("tenant");
|
|
167
200
|
const accountsArg = flag("accounts");
|
|
201
|
+
const run = argv.includes("--run");
|
|
202
|
+
const manage = argv.includes("--manage");
|
|
168
203
|
if (!email || !tenant || !accountsArg) usage();
|
|
169
204
|
|
|
170
|
-
// THE EMAIL RULE IS makePrincipal's, asked rather than restated.
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
205
|
+
// THE EMAIL RULE IS makePrincipal's, asked rather than restated. Asked over a file in which this address
|
|
206
|
+
// would see everything, so the only way it resolves to nobody is that the resolver refused the address
|
|
207
|
+
// itself — which it does, outright, for a multi-@ identity. Writing that grant would be writing a line
|
|
208
|
+
// that cannot ever match, which is the silent-uselessness this command exists to prevent.
|
|
209
|
+
if (!makePrincipal({ email, grants: { tenants: {}, people: { [email]: { everything: true } } } })) {
|
|
210
|
+
if (email.indexOf("@") !== email.lastIndexOf("@"))
|
|
211
|
+
die(`"${email}" carries more than one @. The portal refuses those identities outright, so this grant could never match anyone.`);
|
|
212
|
+
die(`"${email}" is not an email address.`);
|
|
213
|
+
}
|
|
214
|
+
const list = accountsArg === "*" ? "*" : accountsArg.split(",").map((s) => s.trim()).filter(Boolean);
|
|
215
|
+
if (list !== "*" && !list.length) die("--accounts named nothing. Use a comma-separated list of account keys, or * for the whole organisation.");
|
|
176
216
|
|
|
177
217
|
// ── A REFUSAL THAT NAMES THE ROUTE ( — F38) ─────────────────────────────
|
|
178
218
|
//
|
|
179
219
|
// This used to end at "it does not create one", and NOTHING IN THE CLI CREATES ONE: `brandowner`
|
|
180
|
-
// never writes a tenant, this command
|
|
181
|
-
// install the file
|
|
220
|
+
// never writes a tenant, this command refused to, and `connect` sends the reader here. On a fresh
|
|
221
|
+
// install the file named no tenant, so the documented first run was connect → "run grant" → grant →
|
|
182
222
|
// "a tenant must already exist" → nothing. A dead end reached by following instructions.
|
|
183
223
|
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// on a fresh install ran out of road: the file is {"tenants":{}}, `brandowner` does not write tenants,
|
|
189
|
-
// and this command refused because none existed. 2176's F38 fix printed the JSON shape into the
|
|
190
|
-
// refusal, which is better than a bare stop and still asks a person to hand-edit the file the product
|
|
191
|
-
// is meant to manage.
|
|
224
|
+
// THE FIRST TENANT IS CREATED HERE — ONLY WHEN THE ROSTER HOLDS NONE, and that limit is the point.
|
|
225
|
+
// Creating on any unknown name would mean a typo mints an organisation nobody meant, silently, in the
|
|
226
|
+
// file the door reads — so once there is a roster to typo against, the refusal stays and lists what
|
|
227
|
+
// exists.
|
|
192
228
|
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
accounts: accountsArg === "*" ? "*" : accountsArg.split(",").map((x) => x.trim()).filter(Boolean),
|
|
208
|
-
users: {},
|
|
209
|
-
};
|
|
210
|
-
out(`Creating the first tenant "${tenant}" in ${FILE}, holding: ${accountsArg}`);
|
|
229
|
+
// THROUGH THE SHARED EDITORS. `withOrganisation` derives the key from the name, so the key is taken
|
|
230
|
+
// from what it returns rather than assumed equal to what was typed. SEEDED FROM --accounts, not
|
|
231
|
+
// created empty: for an organisation that does not exist yet, the companies this grant names ARE the
|
|
232
|
+
// companies it holds. `*` names none, so the organisation starts holding no company and the person gets
|
|
233
|
+
// the whole of it — a legitimate state, and the only one: an organisation's list is never "*".
|
|
234
|
+
let key = tenant;
|
|
235
|
+
if (!grants.tenants[tenant] && Object.keys(grants.tenants).length === 0) {
|
|
236
|
+
try {
|
|
237
|
+
const made = withOrganisation(grants, { name: tenant });
|
|
238
|
+
key = made.key;
|
|
239
|
+
grants = made.grants;
|
|
240
|
+
for (const account of list === "*" ? [] : list) grants = withCompany(grants, { tenant: key, account });
|
|
241
|
+
} catch (e) { die(`Refusing: ${e.message}. Nothing written.`); }
|
|
242
|
+
out(`Creating the first tenant "${key}" in ${FILE}, holding: ${list === "*" ? "no company yet — * gives the person the whole organisation, including companies filed under it later" : list.join(", ")}`);
|
|
211
243
|
}
|
|
212
|
-
if (!tenants[
|
|
213
|
-
Tenants: ${Object.keys(tenants).join(", ")}
|
|
244
|
+
if (!grants.tenants[key]) die(`No tenant "${tenant}". It must already exist with its companies — this command grants access to a tenant, and it creates one only when there are none at all, so that a typo cannot mint a tenant nobody meant.
|
|
245
|
+
Tenants: ${Object.keys(grants.tenants).join(", ")}
|
|
214
246
|
|
|
215
247
|
To add another, put it in ${FILE} — the shape is:
|
|
216
|
-
{ "tenants": { "${tenant}": { "accounts": ["<
|
|
217
|
-
"accounts" holds the
|
|
248
|
+
{ "tenants": { "${tenant}": { "name": "<its name>", "accounts": ["<company-key>"], "users": {} } } }
|
|
249
|
+
"accounts" holds the company keys this tenant holds, and a company belongs to one tenant only; \`clearotron doctor\` names the ones this install has, under "brand owner(s) resolve here". Then run this command again.`);
|
|
218
250
|
|
|
219
|
-
const before =
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
251
|
+
const before = structuredClone(grants);
|
|
252
|
+
const held = (Array.isArray(grants.tenants[key].accounts) ? grants.tenants[key].accounts : []).join(", ") || "(none)";
|
|
253
|
+
const points = list === "*" ? [{ tenant: key }] : list.map((account) => ({ tenant: key, account }));
|
|
254
|
+
const peopleKey = Object.keys(grants.people ?? {}).find((k) => k.toLowerCase() === email);
|
|
255
|
+
const had = peopleKey === undefined ? null : grants.people[peopleKey];
|
|
256
|
+
// THE SWITCHES ARE WRITTEN ONLY WHEN ONE IS ASKED FOR. A person with no entry is already view-only, so
|
|
257
|
+
// writing `{ run: false, manage: false }` for them adds a line that says nothing; and a person who
|
|
258
|
+
// already holds Run must not lose it because a company was added without repeating the flag.
|
|
259
|
+
const setSwitches = run || manage;
|
|
260
|
+
let after;
|
|
261
|
+
try {
|
|
262
|
+
after = withPerson(grants, { email, points, switches: { run, manage, everything: had?.everything === true }, setSwitches });
|
|
263
|
+
} catch (e) {
|
|
264
|
+
if (/does not hold/.test(e.message))
|
|
265
|
+
die(`Refusing: ${e.message}.\nThat grant would resolve to nothing and fail as a silent 404 for ${email}.\nAccounts "${key}" holds: ${held}. Nothing written.`);
|
|
266
|
+
die(`Refusing: ${e.message}. Nothing written.`);
|
|
267
|
+
}
|
|
223
268
|
|
|
224
|
-
const
|
|
225
|
-
const faults = faultsIntroduced(before,
|
|
269
|
+
const companies = await knownCompanies();
|
|
270
|
+
const faults = faultsIntroduced(before, after, companies, email);
|
|
226
271
|
if (faults.dangling.length)
|
|
227
|
-
die(`Refusing: tenant "${
|
|
272
|
+
die(`Refusing: tenant "${key}" does not hold ${faults.dangling.join(", ")}.\nThat grant would resolve to nothing and fail as a silent 404 for ${email}.\nAccounts "${key}" holds: ${held}`);
|
|
228
273
|
if (faults.unknownAccounts.length)
|
|
229
274
|
die(`Refusing: ${faults.unknownAccounts.join(", ")} ${faults.unknownAccounts.length > 1 ? "match" : "matches"} no customer bundle.\nThe grant would be written and reach nothing. Check the account key, or add the bundle first.`);
|
|
230
|
-
if (
|
|
275
|
+
if (companies === null) console.error("Note: customer bundles could not be read, so the account keys were NOT checked against them.");
|
|
231
276
|
|
|
232
|
-
atomicWrite(FILE, JSON.stringify(
|
|
233
|
-
out(`${email} → ${
|
|
277
|
+
atomicWrite(FILE, JSON.stringify(after, null, 2) + "\n");
|
|
278
|
+
out(`${email} → ${key} (${list === "*" ? "the whole organisation" : list.join(", ")})`);
|
|
279
|
+
if (setSwitches) out(mayLine(email, after.people?.[email]));
|
|
280
|
+
else if (had) out(`${mayLine(email, had)} Their permissions are unchanged; pass --run and --manage to set them.`);
|
|
281
|
+
else out(mayLine(email, null));
|
|
282
|
+
out(`Written to ${basename(FILE)}. The portal re-reads per request — no restart.`);
|
|
234
283
|
process.exit(0);
|
|
235
284
|
}
|
|
236
285
|
|
|
@@ -239,7 +288,7 @@ if (cmd === "remove") {
|
|
|
239
288
|
const only = flag("tenant");
|
|
240
289
|
if (!email) usage();
|
|
241
290
|
let removed = 0;
|
|
242
|
-
for (const [name, t] of Object.entries(tenants)) {
|
|
291
|
+
for (const [name, t] of Object.entries(grants.tenants)) {
|
|
243
292
|
if (only && name !== only) continue;
|
|
244
293
|
if (t?.users && email in t.users) { delete t.users[email]; removed++; }
|
|
245
294
|
// AN EMPTY `users` MAP IS NOT A DELETED TENANT, and this writes the former deliberately: the tenant
|
|
@@ -247,18 +296,27 @@ if (cmd === "remove") {
|
|
|
247
296
|
// tenant here would destroy configuration the operator never asked to remove, and it round-trips —
|
|
248
297
|
// `remove-tenant` is the other verb, and it is explicit.
|
|
249
298
|
}
|
|
250
|
-
|
|
299
|
+
// AND THE PERSON'S OWN ENTRY, when they are removed from everywhere. Their permissions and their access
|
|
300
|
+
// to everything live under `people`, not in any tenant, so a removal that left that entry would leave a
|
|
301
|
+
// person who sees everything still seeing everything. Removed from one tenant, they may still hold
|
|
302
|
+
// access elsewhere, and their entry stays.
|
|
303
|
+
const peopleKey = only ? undefined : Object.keys(grants.people ?? {}).find((k) => k.toLowerCase() === email);
|
|
304
|
+
if (peopleKey !== undefined) delete grants.people[peopleKey];
|
|
305
|
+
if (!removed && peopleKey === undefined) die(`${email} is not on the guest list${only ? ` for "${only}"` : ""}. Nothing written.`);
|
|
251
306
|
atomicWrite(FILE, JSON.stringify(grants, null, 2) + "\n");
|
|
252
|
-
out(`Removed ${email} from ${removed} tenant(s). Written to ${basename(FILE)}.`);
|
|
307
|
+
out(`Removed ${email} from ${removed} tenant(s)${peopleKey !== undefined ? ", and their permissions under people" : ""}. Written to ${basename(FILE)}.`);
|
|
308
|
+
const kept = Object.entries(grants.people ?? {}).find(([k]) => k.toLowerCase() === email)?.[1];
|
|
309
|
+
if (kept?.everything === true)
|
|
310
|
+
out(`${email} still has access to everything through their entry under people. \`grant remove ${email}\` without --tenant takes that away too.`);
|
|
253
311
|
process.exit(0);
|
|
254
312
|
}
|
|
255
313
|
|
|
256
314
|
if (cmd === "remove-tenant") {
|
|
257
315
|
const name = String(argv[1] ?? "").trim();
|
|
258
316
|
if (!name) usage();
|
|
259
|
-
if (!tenants[name]) die(`No tenant "${name}". Nothing written.`);
|
|
260
|
-
const people = Object.keys(tenants[name].users ?? {}).length;
|
|
261
|
-
delete tenants[name];
|
|
317
|
+
if (!grants.tenants[name]) die(`No tenant "${name}". Nothing written.`);
|
|
318
|
+
const people = Object.keys(grants.tenants[name].users ?? {}).length;
|
|
319
|
+
delete grants.tenants[name];
|
|
262
320
|
atomicWrite(FILE, JSON.stringify(grants, null, 2) + "\n");
|
|
263
321
|
out(`Removed tenant "${name}"${people ? ` and the ${people} grant(s) it held` : ""}. Written to ${basename(FILE)}.`);
|
|
264
322
|
process.exit(0);
|