@ultimat3/cli 1.0.0
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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- package/src/workspace-checks.ts +288 -0
package/src/parse.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Argument parsing for the whole `x` binary — one parser, so every command accepts flags the
|
|
2
|
+
// same way and `--json` / `--help` behave identically everywhere. Pure: no I/O, no process
|
|
3
|
+
// access, so the parser is unit-testable and the dispatcher owns all side effects.
|
|
4
|
+
|
|
5
|
+
import { BadFlagError, UnknownCommandError } from './errors';
|
|
6
|
+
|
|
7
|
+
export type FlagValue = string | boolean;
|
|
8
|
+
|
|
9
|
+
export interface FlagSpec {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly type: 'boolean' | 'string';
|
|
12
|
+
readonly summary: string;
|
|
13
|
+
readonly short?: string;
|
|
14
|
+
readonly default?: FlagValue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CommandSpec {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly summary: string;
|
|
20
|
+
readonly usage: string;
|
|
21
|
+
readonly aliases?: readonly string[];
|
|
22
|
+
readonly subcommands?: readonly string[];
|
|
23
|
+
readonly flags?: readonly FlagSpec[];
|
|
24
|
+
/** Command needs an app root (`app.config.ts`) — the dispatcher enforces it. */
|
|
25
|
+
readonly requiresApp?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ParsedArgs {
|
|
29
|
+
readonly command: string;
|
|
30
|
+
readonly subcommand: string | undefined;
|
|
31
|
+
readonly positionals: readonly string[];
|
|
32
|
+
readonly flags: ReadonlyMap<string, FlagValue>;
|
|
33
|
+
readonly json: boolean;
|
|
34
|
+
readonly help: boolean;
|
|
35
|
+
/** Everything after a bare `--`, handed to the underlying tool verbatim. */
|
|
36
|
+
readonly passthrough: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Accepted by every command. `--json` is axiom 4: the feedback loop is machine-readable. */
|
|
40
|
+
export const GLOBAL_FLAGS: readonly FlagSpec[] = [
|
|
41
|
+
{ name: 'json', type: 'boolean', summary: 'machine-readable output', short: 'j' },
|
|
42
|
+
{ name: 'help', type: 'boolean', summary: 'usage for this command', short: 'h' },
|
|
43
|
+
{ name: 'cwd', type: 'string', summary: 'run as if started in this directory' },
|
|
44
|
+
{ name: 'verbose', type: 'boolean', summary: 'include step output on success' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const HELP_ALIASES = new Set(['--help', '-h', 'help']);
|
|
48
|
+
const VERSION_ALIASES = new Set(['--version', '-v', '-V']);
|
|
49
|
+
|
|
50
|
+
function distance(a: string, b: string): number {
|
|
51
|
+
const rows = a.length + 1;
|
|
52
|
+
const cols = b.length + 1;
|
|
53
|
+
const grid: number[] = new Array<number>(rows * cols).fill(0);
|
|
54
|
+
const at = (r: number, c: number): number => grid[r * cols + c] ?? 0;
|
|
55
|
+
for (let r = 0; r < rows; r += 1) grid[r * cols] = r;
|
|
56
|
+
for (let c = 0; c < cols; c += 1) grid[c] = c;
|
|
57
|
+
for (let r = 1; r < rows; r += 1) {
|
|
58
|
+
for (let c = 1; c < cols; c += 1) {
|
|
59
|
+
const cost = a[r - 1] === b[c - 1] ? 0 : 1;
|
|
60
|
+
grid[r * cols + c] = Math.min(at(r - 1, c) + 1, at(r, c - 1) + 1, at(r - 1, c - 1) + cost);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return at(rows - 1, cols - 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Nearest known name within an edit distance of 3, so the error can suggest a retry. */
|
|
67
|
+
export function nearest(input: string, candidates: readonly string[]): string | undefined {
|
|
68
|
+
let best: string | undefined;
|
|
69
|
+
let bestScore = 4;
|
|
70
|
+
for (const candidate of candidates) {
|
|
71
|
+
const score = distance(input, candidate);
|
|
72
|
+
if (score < bestScore) {
|
|
73
|
+
best = candidate;
|
|
74
|
+
bestScore = score;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveCommand(token: string, specs: readonly CommandSpec[]): CommandSpec {
|
|
81
|
+
const found = specs.find((spec) => spec.name === token || (spec.aliases ?? []).includes(token));
|
|
82
|
+
if (found !== undefined) return found;
|
|
83
|
+
const names = specs.map((spec) => spec.name);
|
|
84
|
+
const suggestion = nearest(token, names);
|
|
85
|
+
throw new UnknownCommandError(
|
|
86
|
+
suggestion === undefined
|
|
87
|
+
? { path: token, known: names }
|
|
88
|
+
: { path: token, known: names, suggestion },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findFlag(name: string, spec: CommandSpec): FlagSpec | undefined {
|
|
93
|
+
const all = [...GLOBAL_FLAGS, ...(spec.flags ?? [])];
|
|
94
|
+
return all.find((flag) => flag.name === name || flag.short === name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function defaults(spec: CommandSpec): Map<string, FlagValue> {
|
|
98
|
+
const out = new Map<string, FlagValue>();
|
|
99
|
+
for (const flag of [...GLOBAL_FLAGS, ...(spec.flags ?? [])]) {
|
|
100
|
+
if (flag.default !== undefined) out.set(flag.name, flag.default);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse `x` arguments against the command registry. Throws `X_CLI_UNKNOWN_COMMAND` or
|
|
107
|
+
* `X_CLI_BAD_FLAG` — never returns a partially-valid result, because a command that guesses
|
|
108
|
+
* what you meant is a command an agent cannot reason about.
|
|
109
|
+
*/
|
|
110
|
+
export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]): ParsedArgs {
|
|
111
|
+
const tokens = [...argv];
|
|
112
|
+
const cut = tokens.indexOf('--');
|
|
113
|
+
const passthrough = cut === -1 ? [] : tokens.splice(cut + 1);
|
|
114
|
+
if (cut !== -1) tokens.pop();
|
|
115
|
+
|
|
116
|
+
if (tokens.length === 0) return blank('help', specs, false);
|
|
117
|
+
const first = tokens[0] ?? '';
|
|
118
|
+
// `help` and `version` short-circuit the flag loop below, so `--json` has to be read here or the
|
|
119
|
+
// two commands silently print prose to an agent that asked for JSON — and every `fix:` naming
|
|
120
|
+
// `x help --json` would be a command that does not do what it says.
|
|
121
|
+
const json = tokens.some((token) => token === '--json' || token === '-j');
|
|
122
|
+
if (VERSION_ALIASES.has(first)) return blank('version', specs, json);
|
|
123
|
+
if (HELP_ALIASES.has(first)) {
|
|
124
|
+
const rest = tokens.slice(1).filter((token) => !token.startsWith('-'));
|
|
125
|
+
return { ...blank('help', specs, json), positionals: rest };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const spec = resolveCommand(first, specs);
|
|
129
|
+
const flags = defaults(spec);
|
|
130
|
+
const positionals: string[] = [];
|
|
131
|
+
let index = 1;
|
|
132
|
+
|
|
133
|
+
while (index < tokens.length) {
|
|
134
|
+
const token = tokens[index] ?? '';
|
|
135
|
+
index += 1;
|
|
136
|
+
if (!token.startsWith('-') || token === '-') {
|
|
137
|
+
positionals.push(token);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const negated = token.startsWith('--no-');
|
|
141
|
+
const raw = negated ? token.slice(5) : token.replace(/^--?/, '');
|
|
142
|
+
const [name, inlineValue] = splitInline(raw);
|
|
143
|
+
const flag = findFlag(name, spec);
|
|
144
|
+
if (flag === undefined) {
|
|
145
|
+
const known = [...GLOBAL_FLAGS, ...(spec.flags ?? [])].map((entry) => entry.name);
|
|
146
|
+
const suggestion = nearest(name, known);
|
|
147
|
+
throw new BadFlagError({
|
|
148
|
+
flag: name,
|
|
149
|
+
command: spec.name,
|
|
150
|
+
reason:
|
|
151
|
+
suggestion === undefined
|
|
152
|
+
? `unknown flag (known: ${known.join(', ')})`
|
|
153
|
+
: `unknown flag — did you mean --${suggestion}?`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (flag.type === 'boolean') {
|
|
157
|
+
if (inlineValue !== undefined) {
|
|
158
|
+
throw new BadFlagError({
|
|
159
|
+
flag: flag.name,
|
|
160
|
+
command: spec.name,
|
|
161
|
+
reason: 'boolean flag takes no value',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
flags.set(flag.name, !negated);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const value = inlineValue ?? tokens[index];
|
|
168
|
+
if (value === undefined || value.startsWith('--')) {
|
|
169
|
+
throw new BadFlagError({
|
|
170
|
+
flag: flag.name,
|
|
171
|
+
command: spec.name,
|
|
172
|
+
reason: 'expects a value',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (inlineValue === undefined) index += 1;
|
|
176
|
+
flags.set(flag.name, value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const subcommand = readSubcommand(spec, positionals);
|
|
180
|
+
return {
|
|
181
|
+
command: spec.name,
|
|
182
|
+
subcommand,
|
|
183
|
+
positionals: subcommand === undefined ? positionals : positionals.slice(1),
|
|
184
|
+
flags,
|
|
185
|
+
json: flags.get('json') === true,
|
|
186
|
+
help: flags.get('help') === true,
|
|
187
|
+
passthrough,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function splitInline(raw: string): [string, string | undefined] {
|
|
192
|
+
const eq = raw.indexOf('=');
|
|
193
|
+
if (eq === -1) return [raw, undefined];
|
|
194
|
+
return [raw.slice(0, eq), raw.slice(eq + 1)];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readSubcommand(spec: CommandSpec, positionals: readonly string[]): string | undefined {
|
|
198
|
+
const allowed = spec.subcommands;
|
|
199
|
+
if (allowed === undefined || allowed.length === 0) return undefined;
|
|
200
|
+
const token = positionals[0];
|
|
201
|
+
if (token === undefined) return allowed[0];
|
|
202
|
+
if (allowed.includes(token)) return token;
|
|
203
|
+
const suggestion = nearest(token, allowed);
|
|
204
|
+
throw new UnknownCommandError(
|
|
205
|
+
suggestion === undefined
|
|
206
|
+
? { path: `${spec.name} ${token}`, known: allowed }
|
|
207
|
+
: { path: `${spec.name} ${token}`, known: allowed, suggestion: `${spec.name} ${suggestion}` },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function blank(command: string, specs: readonly CommandSpec[], json: boolean): ParsedArgs {
|
|
212
|
+
const spec = specs.find((entry) => entry.name === command);
|
|
213
|
+
const flags = spec === undefined ? new Map<string, FlagValue>() : defaults(spec);
|
|
214
|
+
// Set on both, because `flagBool(args, 'json')` and `args.json` are the same fact read two ways.
|
|
215
|
+
flags.set('json', json);
|
|
216
|
+
return {
|
|
217
|
+
command,
|
|
218
|
+
subcommand: undefined,
|
|
219
|
+
positionals: [],
|
|
220
|
+
flags,
|
|
221
|
+
json,
|
|
222
|
+
help: false,
|
|
223
|
+
passthrough: [],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function flagString(args: ParsedArgs, name: string): string | undefined {
|
|
228
|
+
const value = args.flags.get(name);
|
|
229
|
+
return typeof value === 'string' ? value : undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function flagBool(args: ParsedArgs, name: string): boolean {
|
|
233
|
+
return args.flags.get(name) === true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function flagList(args: ParsedArgs, name: string): readonly string[] {
|
|
237
|
+
const value = flagString(args, name);
|
|
238
|
+
if (value === undefined || value.length === 0) return [];
|
|
239
|
+
return value
|
|
240
|
+
.split(',')
|
|
241
|
+
.map((part) => part.trim())
|
|
242
|
+
.filter((part) => part.length > 0);
|
|
243
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Pure fact-gathering behind `x policy`: every declared permission projected against the roles
|
|
2
|
+
// that grant it and the declarations that enforce it, plus the per-role allow/deny matrix behind
|
|
3
|
+
// `explain`. No CLI shapes, no `msg()` — testable with real registries and no app to load.
|
|
4
|
+
|
|
5
|
+
import type { AnyAction } from '@ultimat3/action';
|
|
6
|
+
import { describeActions, getAction } from '@ultimat3/action';
|
|
7
|
+
import type { MatrixRow, Policy } from '@ultimat3/policy';
|
|
8
|
+
import { knownPermissions, policyMatrix, roleDefinitions, rolesGranting } from '@ultimat3/policy';
|
|
9
|
+
import type { AnyQuery } from '@ultimat3/query';
|
|
10
|
+
import { describeQueries, getQuery } from '@ultimat3/query';
|
|
11
|
+
import { devActors } from './dev-policy';
|
|
12
|
+
|
|
13
|
+
const isDefined = <T>(value: T | undefined): value is T => value !== undefined;
|
|
14
|
+
|
|
15
|
+
/** Descriptor names whose `capability` is exactly this permission — shared by list and explain. */
|
|
16
|
+
const namesEnforcing = <D extends { readonly name: string; readonly capability: string }>(
|
|
17
|
+
descriptors: readonly D[],
|
|
18
|
+
permission: string,
|
|
19
|
+
): readonly string[] =>
|
|
20
|
+
descriptors.filter((descriptor) => descriptor.capability === permission).map((d) => d.name);
|
|
21
|
+
|
|
22
|
+
// ── list ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface PermissionRow {
|
|
25
|
+
readonly permission: string;
|
|
26
|
+
readonly roles: readonly string[];
|
|
27
|
+
readonly actions: readonly string[];
|
|
28
|
+
readonly queries: readonly string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PolicyListFacts {
|
|
32
|
+
readonly rows: readonly PermissionRow[];
|
|
33
|
+
readonly roleCount: number;
|
|
34
|
+
readonly enforcedCount: number;
|
|
35
|
+
/** Permissions no action or query enforces — a grant that does nothing. */
|
|
36
|
+
readonly unenforced: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function listPolicy(): PolicyListFacts {
|
|
40
|
+
const actionDescriptors = describeActions();
|
|
41
|
+
const queryDescriptors = describeQueries();
|
|
42
|
+
const rows = knownPermissions().map(
|
|
43
|
+
(permission): PermissionRow => ({
|
|
44
|
+
permission,
|
|
45
|
+
roles: rolesGranting(permission),
|
|
46
|
+
actions: namesEnforcing(actionDescriptors, permission),
|
|
47
|
+
queries: namesEnforcing(queryDescriptors, permission),
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
const unenforced = rows
|
|
51
|
+
.filter((row) => row.actions.length === 0 && row.queries.length === 0)
|
|
52
|
+
.map((row) => row.permission);
|
|
53
|
+
return {
|
|
54
|
+
rows,
|
|
55
|
+
roleCount: Object.keys(roleDefinitions()).length,
|
|
56
|
+
enforcedCount: rows.length - unenforced.length,
|
|
57
|
+
unenforced,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── explain ───────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export type DeclarationKind = 'action' | 'query';
|
|
64
|
+
export type SubjectKind = 'permission' | DeclarationKind;
|
|
65
|
+
|
|
66
|
+
export interface DeclarationExplanation {
|
|
67
|
+
readonly name: string;
|
|
68
|
+
readonly kind: DeclarationKind;
|
|
69
|
+
readonly capability: string;
|
|
70
|
+
readonly label: string;
|
|
71
|
+
/**
|
|
72
|
+
* Whether this policy can be decided at all outside a request. `false` when evaluating it
|
|
73
|
+
* with no request input threw — a predicate dereferencing `input.post.id` has nothing to
|
|
74
|
+
* dereference here — and `rows` is then empty, because a partial matrix reads as a verdict.
|
|
75
|
+
*/
|
|
76
|
+
readonly decidable: boolean;
|
|
77
|
+
readonly rows: readonly MatrixRow[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SubjectExplanation {
|
|
81
|
+
readonly subject: string;
|
|
82
|
+
readonly kind: SubjectKind;
|
|
83
|
+
readonly grantingRoles: readonly string[];
|
|
84
|
+
readonly declarations: readonly DeclarationExplanation[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The matrix half of a declaration: the rows, and whether they mean anything at all. */
|
|
88
|
+
type DeclarationMatrix = Pick<DeclarationExplanation, 'decidable' | 'rows'>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One `testActor` per declared role plus the anonymous caller — the same actor set the `/_x`
|
|
92
|
+
* policy panel asks about (`devActors`, `dev-policy.ts`). Reusing it is the point: a second
|
|
93
|
+
* "every role plus anonymous" builder here is the duplicate axiom 1 bans, and it would drift
|
|
94
|
+
* from the panel's own set the first time a role is renamed.
|
|
95
|
+
*
|
|
96
|
+
* Actor by actor inside a `try`, because there is no request input outside a request and
|
|
97
|
+
* `policyMatrix` does not catch: a predicate reading `input.post.id` threw a bare `TypeError`
|
|
98
|
+
* straight out of `x policy explain`. One throw makes the whole declaration undecidable rather
|
|
99
|
+
* than half-reported — the rows a synthetic `{}` did produce are not the request's verdicts.
|
|
100
|
+
* `policyMatrix` stays the only decider; nothing here re-derives one.
|
|
101
|
+
*/
|
|
102
|
+
const matrixFor = (policy: Policy): DeclarationMatrix => {
|
|
103
|
+
const rows: MatrixRow[] = [];
|
|
104
|
+
for (const actor of devActors()) {
|
|
105
|
+
try {
|
|
106
|
+
rows.push(...policyMatrix(policy, { actors: [actor], input: {} }).rows);
|
|
107
|
+
} catch {
|
|
108
|
+
return { decidable: false, rows: [] };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { decidable: true, rows };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const explainAction = (action: AnyAction): DeclarationExplanation => {
|
|
115
|
+
const descriptor = action.describe();
|
|
116
|
+
return {
|
|
117
|
+
name: descriptor.name,
|
|
118
|
+
kind: 'action',
|
|
119
|
+
capability: descriptor.capability,
|
|
120
|
+
label: action.policy.label,
|
|
121
|
+
...matrixFor(action.policy),
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const explainQuery = (query: AnyQuery): DeclarationExplanation => {
|
|
126
|
+
const descriptor = query.describe();
|
|
127
|
+
return {
|
|
128
|
+
name: descriptor.name,
|
|
129
|
+
kind: 'query',
|
|
130
|
+
capability: descriptor.capability,
|
|
131
|
+
label: query.policy.label,
|
|
132
|
+
...matrixFor(query.policy),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
type Resolved =
|
|
137
|
+
| { readonly kind: 'permission'; readonly permission: string }
|
|
138
|
+
| { readonly kind: 'action'; readonly action: AnyAction }
|
|
139
|
+
| { readonly kind: 'query'; readonly query: AnyQuery };
|
|
140
|
+
|
|
141
|
+
/** Priority order from the brief: a known permission, then an action, a query, an action path. */
|
|
142
|
+
function resolveSubject(name: string): Resolved | undefined {
|
|
143
|
+
if (knownPermissions().includes(name)) return { kind: 'permission', permission: name };
|
|
144
|
+
const action = getAction(name);
|
|
145
|
+
if (action !== undefined) return { kind: 'action', action };
|
|
146
|
+
const query = getQuery(name);
|
|
147
|
+
if (query !== undefined) return { kind: 'query', query };
|
|
148
|
+
const byPath = describeActions().find((descriptor) => descriptor.path === name);
|
|
149
|
+
const pathAction = byPath === undefined ? undefined : getAction(byPath.name);
|
|
150
|
+
return pathAction === undefined ? undefined : { kind: 'action', action: pathAction };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Every string `x policy explain` accepts — permissions, then action/query names, then paths. */
|
|
154
|
+
export function knownPolicySubjects(): readonly string[] {
|
|
155
|
+
const actions = describeActions();
|
|
156
|
+
return [
|
|
157
|
+
...knownPermissions(),
|
|
158
|
+
...actions.map((descriptor) => descriptor.name),
|
|
159
|
+
...describeQueries().map((descriptor) => descriptor.name),
|
|
160
|
+
...actions.map((descriptor) => descriptor.path),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function explainPolicy(name: string): SubjectExplanation | undefined {
|
|
165
|
+
const resolved = resolveSubject(name);
|
|
166
|
+
if (resolved === undefined) return undefined;
|
|
167
|
+
if (resolved.kind === 'permission') {
|
|
168
|
+
const { permission } = resolved;
|
|
169
|
+
const declarations = [
|
|
170
|
+
...describeActions()
|
|
171
|
+
.filter((descriptor) => descriptor.capability === permission)
|
|
172
|
+
.map((descriptor) => getAction(descriptor.name))
|
|
173
|
+
.filter(isDefined)
|
|
174
|
+
.map(explainAction),
|
|
175
|
+
...describeQueries()
|
|
176
|
+
.filter((descriptor) => descriptor.capability === permission)
|
|
177
|
+
.map((descriptor) => getQuery(descriptor.name))
|
|
178
|
+
.filter(isDefined)
|
|
179
|
+
.map(explainQuery),
|
|
180
|
+
];
|
|
181
|
+
return {
|
|
182
|
+
subject: name,
|
|
183
|
+
kind: 'permission',
|
|
184
|
+
grantingRoles: rolesGranting(permission),
|
|
185
|
+
declarations,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const declaration =
|
|
189
|
+
resolved.kind === 'action' ? explainAction(resolved.action) : explainQuery(resolved.query);
|
|
190
|
+
return {
|
|
191
|
+
subject: name,
|
|
192
|
+
kind: resolved.kind,
|
|
193
|
+
grantingRoles: rolesGranting(declaration.capability),
|
|
194
|
+
declarations: [declaration],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The one policy declaration set both `x policy` test files run against. Shared rather than
|
|
2
|
+
// copied, for the reason `thrown-by.ts` gives: every count in both files' assertions is read off
|
|
3
|
+
// THIS set, so a second copy drifts and each file keeps passing while they stop agreeing.
|
|
4
|
+
|
|
5
|
+
import { action, registerActions, t } from '@ultimat3/action';
|
|
6
|
+
import { and, can, definePermissions, defineRoles } from '@ultimat3/policy';
|
|
7
|
+
import { from, query, registerQuery } from '@ultimat3/query';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The row the fixture's two reads return, over an empty source: `x policy` describes a query and
|
|
11
|
+
* evaluates its policy, it never executes one, so the rows are the one fact that may be missing.
|
|
12
|
+
*/
|
|
13
|
+
interface PostRow {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Three permissions, three roles, two actions and two queries — each fact earning its place.
|
|
19
|
+
*
|
|
20
|
+
* `post:read` is declared but enforced by nothing: `archivePost`'s policy grants that permission
|
|
21
|
+
* to its second clause, but the compound policy's own capability is `and(post:publish, post:read)`
|
|
22
|
+
* — a different string — so `post:read` alone stays unenforced. `post:publish` is enforced by an
|
|
23
|
+
* action AND a query at once, so the aggregation across declarations has something to aggregate.
|
|
24
|
+
*
|
|
25
|
+
* Registers only; the registries are process-global, so the caller clears them first.
|
|
26
|
+
*/
|
|
27
|
+
export function registerPolicyFixture(): void {
|
|
28
|
+
definePermissions(['post:publish', 'post:read', 'feed:read'] as const);
|
|
29
|
+
defineRoles({
|
|
30
|
+
admin: { grants: ['post:publish', 'post:read', 'feed:read'] },
|
|
31
|
+
editor: { grants: ['post:publish', 'post:read'] },
|
|
32
|
+
reader: { grants: ['post:read', 'feed:read'] },
|
|
33
|
+
});
|
|
34
|
+
registerActions({
|
|
35
|
+
publishPost: action({
|
|
36
|
+
input: t.object({}),
|
|
37
|
+
output: t.object({}),
|
|
38
|
+
policy: can('post:publish'),
|
|
39
|
+
async handle() {
|
|
40
|
+
return {};
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
archivePost: action({
|
|
44
|
+
input: t.object({}),
|
|
45
|
+
output: t.object({}),
|
|
46
|
+
policy: and(
|
|
47
|
+
can('post:publish'),
|
|
48
|
+
can('post:read', ({ actor }) => actor?.id === 'admin'),
|
|
49
|
+
),
|
|
50
|
+
async handle() {
|
|
51
|
+
return {};
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
registerQuery(
|
|
56
|
+
'postFeed',
|
|
57
|
+
query({
|
|
58
|
+
input: t.object({}),
|
|
59
|
+
policy: can('feed:read'),
|
|
60
|
+
sql: () => from<PostRow>('posts', []).orderBy('id').limit(10),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
registerQuery(
|
|
64
|
+
'publishedPosts',
|
|
65
|
+
query({
|
|
66
|
+
input: t.object({}),
|
|
67
|
+
policy: can('post:publish'),
|
|
68
|
+
sql: () => from<PostRow>('posts', []).orderBy('id').limit(10),
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The command registry: the one list the parser, the help catalogue and the dispatcher all read.
|
|
2
|
+
// A command that is not here does not exist — there is no second place to register one.
|
|
3
|
+
|
|
4
|
+
import { buildCommand } from './cmd-build';
|
|
5
|
+
import { dbCommand } from './cmd-db';
|
|
6
|
+
import { deployCommand } from './cmd-deploy';
|
|
7
|
+
import { devCommand } from './cmd-dev';
|
|
8
|
+
import { doctorCommand } from './cmd-doctor';
|
|
9
|
+
import { errorsCommand } from './cmd-errors';
|
|
10
|
+
import { fixCommand } from './cmd-fix';
|
|
11
|
+
import { generateCommand } from './cmd-generate';
|
|
12
|
+
import { createHelpCommand, createVersionCommand } from './cmd-help';
|
|
13
|
+
import { i18nCommand } from './cmd-i18n';
|
|
14
|
+
import { jobsCommand } from './cmd-jobs';
|
|
15
|
+
import { manifestCommand } from './cmd-manifest';
|
|
16
|
+
import { mcpCommand } from './cmd-mcp';
|
|
17
|
+
import { newCommand } from './cmd-new';
|
|
18
|
+
import { plannedCommands } from './cmd-planned';
|
|
19
|
+
import { policyCommand } from './cmd-policy';
|
|
20
|
+
import { actionsCommand, entitiesCommand, queriesCommand } from './cmd-registries';
|
|
21
|
+
import { routesCommand } from './cmd-routes';
|
|
22
|
+
import { tasksCommand } from './cmd-tasks';
|
|
23
|
+
import { testCommand } from './cmd-test';
|
|
24
|
+
import { verifyCommand } from './cmd-verify';
|
|
25
|
+
import type { CliCommand } from './command';
|
|
26
|
+
import type { CommandSpec } from './parse';
|
|
27
|
+
import { loadVersion } from './version-loader';
|
|
28
|
+
|
|
29
|
+
/** Single source of truth for the framework version — loaded from root package.json. */
|
|
30
|
+
export const CLI_VERSION = loadVersion();
|
|
31
|
+
|
|
32
|
+
const CORE: readonly CliCommand[] = [
|
|
33
|
+
newCommand,
|
|
34
|
+
devCommand,
|
|
35
|
+
buildCommand,
|
|
36
|
+
testCommand,
|
|
37
|
+
verifyCommand,
|
|
38
|
+
generateCommand,
|
|
39
|
+
dbCommand,
|
|
40
|
+
mcpCommand,
|
|
41
|
+
doctorCommand,
|
|
42
|
+
deployCommand,
|
|
43
|
+
manifestCommand,
|
|
44
|
+
routesCommand,
|
|
45
|
+
actionsCommand,
|
|
46
|
+
queriesCommand,
|
|
47
|
+
entitiesCommand,
|
|
48
|
+
jobsCommand,
|
|
49
|
+
tasksCommand,
|
|
50
|
+
policyCommand,
|
|
51
|
+
i18nCommand,
|
|
52
|
+
errorsCommand,
|
|
53
|
+
fixCommand,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Planned last, so `x help` reads shipped-first and the tail is honestly labelled. They are in the
|
|
58
|
+
* registry rather than absent from it because "not built yet" and "not a command" are different
|
|
59
|
+
* facts, and only one of them is true — see `cmd-planned.ts`.
|
|
60
|
+
*/
|
|
61
|
+
export const COMMANDS: readonly CliCommand[] = [
|
|
62
|
+
...CORE,
|
|
63
|
+
...plannedCommands(),
|
|
64
|
+
createHelpCommand(() => SPECS),
|
|
65
|
+
createVersionCommand(CLI_VERSION),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const SPECS: readonly CommandSpec[] = COMMANDS.map((command) => command.spec);
|
|
69
|
+
|
|
70
|
+
export const commandFor = (name: string): CliCommand | undefined =>
|
|
71
|
+
COMMANDS.find(
|
|
72
|
+
(command) => command.spec.name === name || (command.spec.aliases ?? []).includes(name),
|
|
73
|
+
);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What the scaffold drift gate compiles: every documented `x new` invocation, with every `x g`
|
|
2
|
+
// generator run on top of the one that carries an app. Separate from the compiler harness next to
|
|
3
|
+
// it, because "which scaffolds exist" is a fact about the CLI's surface, not about running `tsc`.
|
|
4
|
+
|
|
5
|
+
import type { GenerateOptions } from './cmd-generate';
|
|
6
|
+
import { dedupe, generate } from './cmd-generate';
|
|
7
|
+
import { planNewApp } from './cmd-new';
|
|
8
|
+
import type { GeneratedFile } from './templates';
|
|
9
|
+
|
|
10
|
+
/** The app the fixture scaffolds. Kebab, multi-word: single-word names hide casing bugs. */
|
|
11
|
+
export const FIXTURE_APP = 'ledger-demo';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One realistic invocation of every generator, on top of `x new --example`. Names differ from
|
|
15
|
+
* their feature on purpose: `x g query invoice --feature invoice` would collide with the entity
|
|
16
|
+
* import, and a fixture that trips over its own naming stops testing the templates.
|
|
17
|
+
*
|
|
18
|
+
* `admin: true` on the resource is not decoration: `apps/web/app/<name>/admin/resource.ts` and its
|
|
19
|
+
* test are templates no other invocation emits, and they cost nothing extra here because they land
|
|
20
|
+
* in a sandbox that is compiled anyway.
|
|
21
|
+
*/
|
|
22
|
+
export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
|
|
23
|
+
{ kind: 'resource', name: 'invoice', admin: true },
|
|
24
|
+
{ kind: 'entity', name: 'credit-note', feature: 'credit-note' },
|
|
25
|
+
{ kind: 'policy', name: 'credit-note', feature: 'credit-note' },
|
|
26
|
+
{ kind: 'action', name: 'send-invoice', feature: 'invoice' },
|
|
27
|
+
{ kind: 'mutator', name: 'rename-invoice', feature: 'invoice' },
|
|
28
|
+
{ kind: 'query', name: 'invoice-search', feature: 'invoice' },
|
|
29
|
+
{ kind: 'query', name: 'invoice-feed', feature: 'invoice', live: true },
|
|
30
|
+
{ kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
|
|
31
|
+
{ kind: 'task', name: 'nightly-sweep', feature: 'invoice' },
|
|
32
|
+
{ kind: 'route', name: 'pricing', surface: 'site' },
|
|
33
|
+
{ kind: 'route', name: 'billing', surface: 'app' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** The whole scaffolded surface: a new app, then every generator run inside it. */
|
|
37
|
+
export function scaffoldFixture(): readonly GeneratedFile[] {
|
|
38
|
+
return dedupe([
|
|
39
|
+
...planNewApp({ name: FIXTURE_APP, example: true }),
|
|
40
|
+
...FIXTURE_GENERATORS.flatMap((options) => generate(options)),
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ScaffoldVariant {
|
|
45
|
+
/** Names the invocation in a failure, so a red gate says which `x new` broke. */
|
|
46
|
+
readonly name: string;
|
|
47
|
+
/** What this variant emits that no other one does — why it earns its own compile. */
|
|
48
|
+
readonly why: string;
|
|
49
|
+
readonly files: readonly GeneratedFile[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every scaffold a user can ask for, each compiled on its own. One variant per *file set*, not per
|
|
54
|
+
* flag: `--no-example` writes a different `packages/db` than `--example` does, and compiling only
|
|
55
|
+
* the example app is what let `x new --no-example` ship a `schema.ts` importing a slice that
|
|
56
|
+
* invocation never writes.
|
|
57
|
+
*/
|
|
58
|
+
export const scaffoldVariants = (): readonly ScaffoldVariant[] => [
|
|
59
|
+
{
|
|
60
|
+
name: 'x new',
|
|
61
|
+
why: 'the example slice, plus one run of every generator on top of it',
|
|
62
|
+
files: scaffoldFixture(),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'x new --no-example',
|
|
66
|
+
why: 'an empty app/: no entity, so schema, seed and the initial migration have nothing to name',
|
|
67
|
+
files: planNewApp({ name: FIXTURE_APP, example: false }),
|
|
68
|
+
},
|
|
69
|
+
];
|