@cosmicdrift/kumiko-cli 0.368.0 → 0.370.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/package.json +8 -8
- package/src/changes.json +12 -0
- package/src/commands/agent.ts +6 -4
- package/src/commands/check-managed.ts +58 -0
- package/src/commands/check-scripts.ts +46 -0
- package/src/commands/check.ts +104 -25
- package/src/commands/index.ts +17 -1
- package/src/commands/load-app-config.ts +17 -3
- package/src/commands/ops/backfill-pii.ts +246 -0
- package/src/commands/ops/index.ts +32 -0
- package/src/commands/ops/waitlist-migrate.ts +70 -0
- package/src/commands/ports.ts +60 -0
- package/src/commands/sync-managed.ts +67 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.370.0",
|
|
4
4
|
"description": "CLI for Kumiko apps. `bunx @cosmicdrift/kumiko-cli new app <name>` scaffolds a runnable workspace; `add feature <name>` mounts a feature; `check` runs boot validation plus the guard suites with the step list derived from kumiko.json; `agent lint`, `project` and `consumer` operate on an existing app; `handbuch build` renders the HTML handbuch.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -36,10 +36,10 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@cosmicdrift/kumiko-dev-server": "0.
|
|
40
|
-
"@cosmicdrift/kumiko-guards": "0.
|
|
41
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
42
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
39
|
+
"@cosmicdrift/kumiko-dev-server": "0.370.0",
|
|
40
|
+
"@cosmicdrift/kumiko-guards": "0.370.0",
|
|
41
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.370.0",
|
|
42
|
+
"@cosmicdrift/kumiko-testing": "0.370.0",
|
|
43
43
|
"marked": "^18.0.3",
|
|
44
44
|
"zod": "^4.4.3"
|
|
45
45
|
},
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
68
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
69
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
67
|
+
"@cosmicdrift/kumiko-bundled-features": "0.370.0",
|
|
68
|
+
"@cosmicdrift/kumiko-framework": "0.370.0",
|
|
69
|
+
"@cosmicdrift/kumiko-headless": "0.370.0",
|
|
70
70
|
"sharp": "^0.35.5"
|
|
71
71
|
},
|
|
72
72
|
"publishConfig": {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.370.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "kumiko ops backfill-pii encrypts plaintext PII events of the app in kumiko.config.ts",
|
|
6
|
+
"detail": "kumiko ops backfill-pii [--dry-run] [--resolve-owner-from-projection] [--rekey-tenant-keyed-owner-fields] [--erase-unresolvable-subjects] runs the framework PII backfill over the features of kumiko.config.ts with DATABASE_URL, PLATFORM_KEK (and PLATFORM_KEK_PREVIOUS) and KUMIKO_BLIND_INDEX_KEY from the environment. It does not rebuild projections; run kumiko ops backfill-pii --rebuild afterwards, which rebuilds only the projections of entities with personal fields. A second backfill run changes nothing. Apps with their own backfill-pii script switch to this command and to the two-step runbook."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.370.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "kumiko check gains the managed and scripts steps; new kumiko sync-managed and kumiko ports",
|
|
12
|
+
"detail": "kumiko check managed compares framework-owned files with the installed templates and prints a diff; files with a local marker are skipped and listed. kumiko check scripts requires dev, build, start, typecheck, lint, check, test, test:unit, test:integration, test:integration:ci, test:dom (with uiRoots), test:real, e2e, e2e:real, screenshots and prepare, and lists extras. Both steps fail only once the app calls defineAppConfig. kumiko check accepts step ids as positional filters (kumiko check managed scripts). kumiko sync-managed [--write] [--adopt] writes the templates; it never overwrites a file without marker unless --adopt is passed, never touches local files and refuses symlinked paths. kumiko ports [workspace-root] lists the ports of every app below the root and exits 1 on a collision. kumiko agent lint also checks prodFeatures."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.365.0",
|
|
4
16
|
"type": "fix",
|
package/src/commands/agent.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { loadAppConfig } from "./load-app-config";
|
|
2
|
+
import { allConfiguredFeatures, loadAppConfig } from "./load-app-config";
|
|
3
3
|
import type { CliCommand } from "./types";
|
|
4
4
|
|
|
5
5
|
const LINT_SUBCOMMAND = "lint";
|
|
@@ -24,8 +24,9 @@ export const agentCommand: CliCommand = {
|
|
|
24
24
|
ctx.out.err("");
|
|
25
25
|
ctx.out.err(" Create a file that exports your features:");
|
|
26
26
|
ctx.out.err(" // kumiko.config.ts");
|
|
27
|
+
ctx.out.err(' import { defineAppConfig } from "@cosmicdrift/kumiko-framework/config";');
|
|
27
28
|
ctx.out.err(' import { myFeature } from "./src/features/my-feature";');
|
|
28
|
-
ctx.out.err(" export default { features: [myFeature] };");
|
|
29
|
+
ctx.out.err(" export default defineAppConfig({ features: [myFeature], client, ports });");
|
|
29
30
|
ctx.out.err("");
|
|
30
31
|
return 1;
|
|
31
32
|
}
|
|
@@ -36,8 +37,9 @@ export const agentCommand: CliCommand = {
|
|
|
36
37
|
"@cosmicdrift/kumiko-bundled-features/agent-tools"
|
|
37
38
|
);
|
|
38
39
|
|
|
39
|
-
const
|
|
40
|
-
const
|
|
40
|
+
const features = allConfiguredFeatures(config);
|
|
41
|
+
const gaps = findAgentDocGaps(features);
|
|
42
|
+
const translationGaps = findHandlerTranslationGaps(features);
|
|
41
43
|
|
|
42
44
|
if (gaps.length === 0 && translationGaps.length === 0) {
|
|
43
45
|
ctx.out.log("");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Output } from "../output";
|
|
2
|
+
|
|
3
|
+
type ManagedFilesModule = typeof import("@cosmicdrift/kumiko-dev-server/managed-files");
|
|
4
|
+
type ManagedFileReport = ReturnType<ManagedFilesModule["checkManagedFiles"]>["files"][number];
|
|
5
|
+
|
|
6
|
+
const NOT_ADOPTED_NOTE =
|
|
7
|
+
"kumiko.config.ts does not call defineAppConfig yet, so these findings are warnings; they fail the check once it does.";
|
|
8
|
+
|
|
9
|
+
function describeReport(report: ManagedFileReport): string {
|
|
10
|
+
switch (report.state) {
|
|
11
|
+
case "ok":
|
|
12
|
+
return "matches";
|
|
13
|
+
case "missing":
|
|
14
|
+
return report.kind === "seed" ? "missing (app-owned seed from `kumiko new app`)" : "missing";
|
|
15
|
+
case "differs":
|
|
16
|
+
return "differs from the template (- template, + repo)";
|
|
17
|
+
case "unmarked":
|
|
18
|
+
return report.diff === undefined
|
|
19
|
+
? "matches but has no kumiko-managed marker"
|
|
20
|
+
: "no kumiko-managed marker and differs from the template (- template, + repo)";
|
|
21
|
+
case "local":
|
|
22
|
+
return `local: ${report.reason}`;
|
|
23
|
+
case "invalid":
|
|
24
|
+
return report.message;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function printIndented(out: Output, text: string): void {
|
|
29
|
+
for (const line of text.split("\n")) out.log(` ${line}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runManagedCheckStep(
|
|
33
|
+
out: Output,
|
|
34
|
+
repoRoot: string,
|
|
35
|
+
uiRoots: readonly string[],
|
|
36
|
+
): Promise<number> {
|
|
37
|
+
const managed: ManagedFilesModule = await import("@cosmicdrift/kumiko-dev-server/managed-files");
|
|
38
|
+
const facts = managed.resolveManagedAppFacts({ appRoot: repoRoot, uiRoots });
|
|
39
|
+
const result = managed.checkManagedFiles(facts);
|
|
40
|
+
const drift = result.files.filter(managed.isManagedDrift);
|
|
41
|
+
const local = result.files.filter((report) => report.state === "local");
|
|
42
|
+
const okCount = result.files.length - drift.length - local.length;
|
|
43
|
+
out.log(` ${okCount}/${result.files.length} managed file(s) match the installed template`);
|
|
44
|
+
for (const report of local) out.log(` ~ ${report.path}: ${describeReport(report)}`);
|
|
45
|
+
for (const report of drift) {
|
|
46
|
+
out.log(` ${result.adopted ? "✗" : "!"} ${report.path}: ${describeReport(report)}`);
|
|
47
|
+
if ((report.state === "differs" || report.state === "unmarked") && report.diff !== undefined) {
|
|
48
|
+
printIndented(out, report.diff);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (drift.length === 0) return 0;
|
|
52
|
+
out.log(" Fix: kumiko sync-managed --write (files without a marker: add --adopt)");
|
|
53
|
+
if (!result.adopted) {
|
|
54
|
+
out.log(` ${NOT_ADOPTED_NOTE}`);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
|
+
import type { Output } from "../output";
|
|
5
|
+
|
|
6
|
+
type ManagedFilesModule = typeof import("@cosmicdrift/kumiko-dev-server/managed-files");
|
|
7
|
+
|
|
8
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
9
|
+
return (
|
|
10
|
+
typeof value === "object" &&
|
|
11
|
+
value !== null &&
|
|
12
|
+
Object.values(value).every((entry) => typeof entry === "string")
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readScripts(repoRoot: string): Record<string, string> {
|
|
17
|
+
const path = join(repoRoot, "package.json");
|
|
18
|
+
if (!existsSync(path)) throw new Error(`${path} not found`);
|
|
19
|
+
const raw = parseJsonOrThrow<unknown>(readFileSync(path, "utf-8"), path);
|
|
20
|
+
const scripts = typeof raw === "object" && raw !== null && "scripts" in raw ? raw.scripts : {};
|
|
21
|
+
if (!isStringRecord(scripts)) throw new Error(`${path}: "scripts" must map names to strings`);
|
|
22
|
+
return scripts;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runScriptsCheckStep(
|
|
26
|
+
out: Output,
|
|
27
|
+
repoRoot: string,
|
|
28
|
+
uiRoots: readonly string[],
|
|
29
|
+
): Promise<number> {
|
|
30
|
+
const managed: ManagedFilesModule = await import("@cosmicdrift/kumiko-dev-server/managed-files");
|
|
31
|
+
const report = managed.checkAppScripts(readScripts(repoRoot), { hasUi: uiRoots.length > 0 });
|
|
32
|
+
const adopted = managed.readStaticAppConfig(repoRoot).kind === "defined";
|
|
33
|
+
if (report.knownExtras.length > 0)
|
|
34
|
+
out.log(` extras (standard): ${report.knownExtras.join(", ")}`);
|
|
35
|
+
if (report.appExtras.length > 0) out.log(` extras (app): ${report.appExtras.join(", ")}`);
|
|
36
|
+
if (report.missing.length === 0) {
|
|
37
|
+
out.log(" all standard scripts present");
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
out.log(` ${adopted ? "✗" : "!"} missing standard scripts: ${report.missing.join(", ")}`);
|
|
41
|
+
if (!adopted) {
|
|
42
|
+
out.log(" kumiko.config.ts does not call defineAppConfig yet, so this is a warning.");
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
package/src/commands/check.ts
CHANGED
|
@@ -5,7 +5,16 @@ import type { CliCommand, CliCommandContext } from "./types";
|
|
|
5
5
|
|
|
6
6
|
const APP_SCHEMA_FILE = "kumiko/schema.ts";
|
|
7
7
|
|
|
8
|
-
export type CheckStepId = "boot" | "guards" | "ui" | "checks";
|
|
8
|
+
export type CheckStepId = "boot" | "guards" | "ui" | "checks" | "managed" | "scripts";
|
|
9
|
+
|
|
10
|
+
const CHECK_STEP_IDS: readonly CheckStepId[] = [
|
|
11
|
+
"boot",
|
|
12
|
+
"guards",
|
|
13
|
+
"ui",
|
|
14
|
+
"checks",
|
|
15
|
+
"managed",
|
|
16
|
+
"scripts",
|
|
17
|
+
];
|
|
9
18
|
|
|
10
19
|
export type CheckStep = {
|
|
11
20
|
readonly id: CheckStepId;
|
|
@@ -73,6 +82,20 @@ export function resolveCheckSteps(manifest: RepoManifest, facts: RepoFacts): Che
|
|
|
73
82
|
label: "Repo checks",
|
|
74
83
|
why: `kind "${manifest.kind}" — testGlobs ${manifest.testGlobs.join(", ")}`,
|
|
75
84
|
});
|
|
85
|
+
if (manifest.kind === "app") {
|
|
86
|
+
steps.push(
|
|
87
|
+
{
|
|
88
|
+
id: "managed",
|
|
89
|
+
label: "Managed files",
|
|
90
|
+
why: 'kind "app" — framework-owned files against the installed template',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "scripts",
|
|
94
|
+
label: "Standard scripts",
|
|
95
|
+
why: 'kind "app" — package.json scripts against the script standard',
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
}
|
|
76
99
|
return { kind: "steps", steps, waivers };
|
|
77
100
|
}
|
|
78
101
|
|
|
@@ -86,17 +109,49 @@ export type SchemaCli = Pick<
|
|
|
86
109
|
"runSchemaCli"
|
|
87
110
|
>;
|
|
88
111
|
|
|
112
|
+
export type AppCheckSteps = {
|
|
113
|
+
readonly runManagedCheckStep: typeof import("./check-managed").runManagedCheckStep;
|
|
114
|
+
readonly runScriptsCheckStep: typeof import("./check-scripts").runScriptsCheckStep;
|
|
115
|
+
};
|
|
116
|
+
|
|
89
117
|
export type CheckDeps = {
|
|
90
118
|
readonly loadGuards: () => Promise<GuardsCli>;
|
|
91
119
|
readonly loadSchemaCli: () => Promise<SchemaCli>;
|
|
120
|
+
readonly loadAppSteps: () => Promise<AppCheckSteps>;
|
|
92
121
|
};
|
|
93
122
|
|
|
94
123
|
// Lazy: `kumiko new app` must not pay for ts-morph or the framework runtime.
|
|
95
124
|
const DEFAULT_DEPS: CheckDeps = {
|
|
96
125
|
loadGuards: () => import("@cosmicdrift/kumiko-guards"),
|
|
97
126
|
loadSchemaCli: () => import("@cosmicdrift/kumiko-framework/schema-cli"),
|
|
127
|
+
loadAppSteps: async () => ({
|
|
128
|
+
runManagedCheckStep: (await import("./check-managed")).runManagedCheckStep,
|
|
129
|
+
runScriptsCheckStep: (await import("./check-scripts")).runScriptsCheckStep,
|
|
130
|
+
}),
|
|
98
131
|
};
|
|
99
132
|
|
|
133
|
+
function isCheckStepId(arg: string): arg is CheckStepId {
|
|
134
|
+
return CHECK_STEP_IDS.some((id) => id === arg);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Positional step ids narrow the run to those steps; an id outside the repo's
|
|
138
|
+
// plan is an error rather than a silent no-op.
|
|
139
|
+
function selectRequestedSteps(
|
|
140
|
+
ctx: CliCommandContext,
|
|
141
|
+
plannedSteps: readonly CheckStep[],
|
|
142
|
+
): readonly CheckStep[] | undefined {
|
|
143
|
+
const onlySteps = ctx.argv.filter(isCheckStepId);
|
|
144
|
+
const notApplicable = onlySteps.filter((id) => !plannedSteps.some((step) => step.id === id));
|
|
145
|
+
if (notApplicable.length > 0) {
|
|
146
|
+
ctx.out.err(` ✗ step(s) not part of this repo's plan: ${notApplicable.join(", ")}`);
|
|
147
|
+
ctx.out.err("");
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
return onlySteps.length === 0
|
|
151
|
+
? plannedSteps
|
|
152
|
+
: plannedSteps.filter((step) => onlySteps.includes(step.id));
|
|
153
|
+
}
|
|
154
|
+
|
|
100
155
|
export async function runCheck(
|
|
101
156
|
ctx: CliCommandContext,
|
|
102
157
|
deps: CheckDeps = DEFAULT_DEPS,
|
|
@@ -123,7 +178,7 @@ export async function runCheck(
|
|
|
123
178
|
return 1;
|
|
124
179
|
}
|
|
125
180
|
|
|
126
|
-
const flagsError = guards.cliFlagsError("check", ctx.argv, CHECK_FLAGS);
|
|
181
|
+
const flagsError = guards.cliFlagsError("check", ctx.argv, [...CHECK_FLAGS, ...CHECK_STEP_IDS]);
|
|
127
182
|
if (flagsError !== undefined) {
|
|
128
183
|
ctx.out.err("");
|
|
129
184
|
ctx.out.err(` ${flagsError}`);
|
|
@@ -131,24 +186,12 @@ export async function runCheck(
|
|
|
131
186
|
return 1;
|
|
132
187
|
}
|
|
133
188
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
repo = guards.findLocalRepo(ctx.cwd);
|
|
137
|
-
} catch (e) {
|
|
138
|
-
ctx.out.err("");
|
|
139
|
-
ctx.out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
140
|
-
ctx.out.err("");
|
|
141
|
-
return 1;
|
|
142
|
-
}
|
|
143
|
-
if (repo === undefined) {
|
|
144
|
-
ctx.out.err("");
|
|
145
|
-
ctx.out.err(` ✗ No Kumiko repo above ${ctx.cwd}.`);
|
|
146
|
-
ctx.out.err(` Add a ${REPO_MANIFEST_FILE} at the repo root (kind, sourceRoots, testGlobs).`);
|
|
147
|
-
ctx.out.err("");
|
|
148
|
-
return 1;
|
|
149
|
-
}
|
|
189
|
+
const repo = findCheckedRepo(ctx, guards);
|
|
190
|
+
if (repo === undefined) return 1;
|
|
150
191
|
|
|
151
|
-
const
|
|
192
|
+
const plannedSteps = printCheckPlan(ctx, repo);
|
|
193
|
+
if (plannedSteps === undefined) return 1;
|
|
194
|
+
const steps = selectRequestedSteps(ctx, plannedSteps);
|
|
152
195
|
if (steps === undefined) return 1;
|
|
153
196
|
|
|
154
197
|
if (steps.length === 0) {
|
|
@@ -165,13 +208,33 @@ export async function runCheck(
|
|
|
165
208
|
let failed = 0;
|
|
166
209
|
for (const step of steps) {
|
|
167
210
|
ctx.out.log(` ▸ ${step.label}`);
|
|
168
|
-
failed += await runStepReportingThrow(step, ctx, repo
|
|
211
|
+
failed += await runStepReportingThrow(step, ctx, repo, guards, deps);
|
|
169
212
|
}
|
|
170
213
|
return failed > 0 ? 1 : 0;
|
|
171
214
|
}
|
|
172
215
|
|
|
173
216
|
type LocalRepo = NonNullable<ReturnType<GuardsCli["findLocalRepo"]>>;
|
|
174
217
|
|
|
218
|
+
/** Returns undefined when no repo can be checked; the reason is already printed. */
|
|
219
|
+
function findCheckedRepo(ctx: CliCommandContext, guards: GuardsCli): LocalRepo | undefined {
|
|
220
|
+
let repo: ReturnType<GuardsCli["findLocalRepo"]>;
|
|
221
|
+
try {
|
|
222
|
+
repo = guards.findLocalRepo(ctx.cwd);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
ctx.out.err("");
|
|
225
|
+
ctx.out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
226
|
+
ctx.out.err("");
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
if (repo === undefined) {
|
|
230
|
+
ctx.out.err("");
|
|
231
|
+
ctx.out.err(` ✗ No Kumiko repo above ${ctx.cwd}.`);
|
|
232
|
+
ctx.out.err(` Add a ${REPO_MANIFEST_FILE} at the repo root (kind, sourceRoots, testGlobs).`);
|
|
233
|
+
ctx.out.err("");
|
|
234
|
+
}
|
|
235
|
+
return repo;
|
|
236
|
+
}
|
|
237
|
+
|
|
175
238
|
/** Returns undefined when the manifest cannot be checked; the reason is already printed. */
|
|
176
239
|
function printCheckPlan(ctx: CliCommandContext, repo: LocalRepo): readonly CheckStep[] | undefined {
|
|
177
240
|
const plan = resolveCheckSteps(repo.manifest, {
|
|
@@ -199,12 +262,12 @@ function printCheckPlan(ctx: CliCommandContext, repo: LocalRepo): readonly Check
|
|
|
199
262
|
async function runStepReportingThrow(
|
|
200
263
|
step: { readonly id: CheckStepId; readonly label: string },
|
|
201
264
|
ctx: CliCommandContext,
|
|
202
|
-
|
|
265
|
+
repo: LocalRepo,
|
|
203
266
|
guards: GuardsCli,
|
|
204
267
|
deps: CheckDeps,
|
|
205
268
|
): Promise<number> {
|
|
206
269
|
try {
|
|
207
|
-
return await runStep(step.id, ctx,
|
|
270
|
+
return await runStep(step.id, ctx, repo, guards, deps);
|
|
208
271
|
} catch (e) {
|
|
209
272
|
ctx.out.err(` ✗ ${step.label}: ${e instanceof Error ? e.message : String(e)}`);
|
|
210
273
|
return 1;
|
|
@@ -214,7 +277,7 @@ async function runStepReportingThrow(
|
|
|
214
277
|
async function runStep(
|
|
215
278
|
id: CheckStepId,
|
|
216
279
|
ctx: CliCommandContext,
|
|
217
|
-
|
|
280
|
+
repo: LocalRepo,
|
|
218
281
|
guards: GuardsCli,
|
|
219
282
|
deps: CheckDeps,
|
|
220
283
|
): Promise<number> {
|
|
@@ -222,8 +285,20 @@ async function runStep(
|
|
|
222
285
|
case "boot": {
|
|
223
286
|
const { runSchemaCli } = await deps.loadSchemaCli();
|
|
224
287
|
// findLocalRepo walks upwards, so ctx.cwd may be a subdirectory without kumiko/schema.ts.
|
|
225
|
-
return await runSchemaCli(["validate"],
|
|
288
|
+
return await runSchemaCli(["validate"], repo.absPath, ctx.out);
|
|
226
289
|
}
|
|
290
|
+
case "managed":
|
|
291
|
+
return (await deps.loadAppSteps()).runManagedCheckStep(
|
|
292
|
+
ctx.out,
|
|
293
|
+
repo.absPath,
|
|
294
|
+
repo.manifest.uiRoots ?? [],
|
|
295
|
+
);
|
|
296
|
+
case "scripts":
|
|
297
|
+
return (await deps.loadAppSteps()).runScriptsCheckStep(
|
|
298
|
+
ctx.out,
|
|
299
|
+
repo.absPath,
|
|
300
|
+
repo.manifest.uiRoots ?? [],
|
|
301
|
+
);
|
|
227
302
|
case "guards":
|
|
228
303
|
return guards.runGuardsCli([]);
|
|
229
304
|
case "ui":
|
|
@@ -235,7 +310,7 @@ async function runStep(
|
|
|
235
310
|
|
|
236
311
|
export const checkCommand: CliCommand = {
|
|
237
312
|
id: "check",
|
|
238
|
-
description: "Boot validation
|
|
313
|
+
description: "Boot validation, guard suites, managed files and scripts; steps from kumiko.json",
|
|
239
314
|
help: [
|
|
240
315
|
`Derives the step list from ${REPO_MANIFEST_FILE} instead of a hand-written command chain:`,
|
|
241
316
|
"",
|
|
@@ -244,6 +319,10 @@ export const checkCommand: CliCommand = {
|
|
|
244
319
|
" ui uiRoots declared UI guards (App-Mounting 2.0)",
|
|
245
320
|
' kind "app" must declare uiRoots; uiRoots: [] skips the UI guards on purpose',
|
|
246
321
|
' checks kind != "tooling" repo checks over sourceRoots + testGlobs',
|
|
322
|
+
' managed kind "app" framework-owned files against the installed template',
|
|
323
|
+
' scripts kind "app" package.json scripts against the script standard',
|
|
324
|
+
"",
|
|
325
|
+
"Pass step ids to run only those steps: `kumiko check managed scripts`.",
|
|
247
326
|
"",
|
|
248
327
|
"Flags:",
|
|
249
328
|
" --explain print the resolved step list and the guards' own scan explanation, run nothing",
|
package/src/commands/index.ts
CHANGED
|
@@ -2,15 +2,21 @@ import { agentCommand } from "./agent";
|
|
|
2
2
|
import { checkCommand } from "./check";
|
|
3
3
|
import { consumerCommand } from "./consumer";
|
|
4
4
|
import { handbuchCommand } from "./handbuch";
|
|
5
|
+
import { opsCommand } from "./ops";
|
|
6
|
+
import { portsCommand } from "./ports";
|
|
5
7
|
import { projectCommand } from "./project";
|
|
8
|
+
import { syncManagedCommand } from "./sync-managed";
|
|
6
9
|
import type { CliCommand } from "./types";
|
|
7
10
|
|
|
8
11
|
export const APP_COMMANDS: ReadonlyArray<CliCommand> = [
|
|
9
12
|
checkCommand,
|
|
13
|
+
syncManagedCommand,
|
|
14
|
+
portsCommand,
|
|
10
15
|
agentCommand,
|
|
11
16
|
projectCommand,
|
|
12
17
|
consumerCommand,
|
|
13
18
|
handbuchCommand,
|
|
19
|
+
opsCommand,
|
|
14
20
|
];
|
|
15
21
|
|
|
16
22
|
export function findAppCommand(id: string): CliCommand | undefined {
|
|
@@ -18,6 +24,7 @@ export function findAppCommand(id: string): CliCommand | undefined {
|
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
export {
|
|
27
|
+
type AppCheckSteps,
|
|
21
28
|
type CheckDeps,
|
|
22
29
|
type CheckStep,
|
|
23
30
|
type CheckStepId,
|
|
@@ -25,4 +32,13 @@ export {
|
|
|
25
32
|
runCheck,
|
|
26
33
|
} from "./check";
|
|
27
34
|
export type { CliCommand, CliCommandContext } from "./types";
|
|
28
|
-
export {
|
|
35
|
+
export {
|
|
36
|
+
agentCommand,
|
|
37
|
+
checkCommand,
|
|
38
|
+
consumerCommand,
|
|
39
|
+
handbuchCommand,
|
|
40
|
+
opsCommand,
|
|
41
|
+
portsCommand,
|
|
42
|
+
projectCommand,
|
|
43
|
+
syncManagedCommand,
|
|
44
|
+
};
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import type { CliCommandContext } from "./types";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/** What the CLI reads from kumiko.config.ts. A bare `{ features }` without
|
|
5
|
+
* defineAppConfig is still accepted for one release. */
|
|
6
|
+
export type AppConfig = {
|
|
7
|
+
readonly features: readonly FeatureDefinition[];
|
|
8
|
+
readonly prodFeatures?: readonly FeatureDefinition[];
|
|
9
|
+
};
|
|
5
10
|
|
|
6
11
|
function isAppConfig(value: unknown): value is AppConfig {
|
|
7
12
|
return (
|
|
8
13
|
typeof value === "object" &&
|
|
9
14
|
value !== null &&
|
|
10
15
|
"features" in value &&
|
|
11
|
-
Array.isArray(value.features)
|
|
16
|
+
Array.isArray(value.features) &&
|
|
17
|
+
(!("prodFeatures" in value) ||
|
|
18
|
+
value.prodFeatures === undefined ||
|
|
19
|
+
Array.isArray(value.prodFeatures))
|
|
12
20
|
);
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
/** Everything prod mounts: lint and docs checks must see the prod-only features too. */
|
|
24
|
+
export function allConfiguredFeatures(config: AppConfig): readonly FeatureDefinition[] {
|
|
25
|
+
return [...config.features, ...(config.prodFeatures ?? [])];
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
// kumiko.config.ts is user-authored: a missing default export or `features`
|
|
16
29
|
// array must produce a readable message, not a TypeError further down.
|
|
17
30
|
export async function loadAppConfig(
|
|
@@ -24,7 +37,8 @@ export async function loadAppConfig(
|
|
|
24
37
|
if (!isAppConfig(config)) {
|
|
25
38
|
ctx.out.err("");
|
|
26
39
|
ctx.out.err(` ${configPath} must default-export an object with a "features" array:`);
|
|
27
|
-
ctx.out.err(
|
|
40
|
+
ctx.out.err(' import { defineAppConfig } from "@cosmicdrift/kumiko-framework/config";');
|
|
41
|
+
ctx.out.err(" export default defineAppConfig({ features: [myFeature], client, ports });");
|
|
28
42
|
ctx.out.err("");
|
|
29
43
|
return null;
|
|
30
44
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { loadAppConfig } from "../load-app-config";
|
|
5
|
+
import type { CliCommandContext } from "../types";
|
|
6
|
+
|
|
7
|
+
const BACKFILL_PII_FLAGS = [
|
|
8
|
+
"--dry-run",
|
|
9
|
+
"--rebuild",
|
|
10
|
+
"--resolve-owner-from-projection",
|
|
11
|
+
"--rekey-tenant-keyed-owner-fields",
|
|
12
|
+
"--erase-unresolvable-subjects",
|
|
13
|
+
] as const;
|
|
14
|
+
type BackfillPiiFlag = (typeof BACKFILL_PII_FLAGS)[number];
|
|
15
|
+
|
|
16
|
+
function isBackfillPiiFlag(value: string): value is BackfillPiiFlag {
|
|
17
|
+
return BACKFILL_PII_FLAGS.some((flag) => flag === value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type BackfillPiiArgs =
|
|
21
|
+
| { readonly mode: "rebuild" }
|
|
22
|
+
| {
|
|
23
|
+
readonly mode: "backfill";
|
|
24
|
+
readonly dryRun: boolean;
|
|
25
|
+
readonly resolveOwnerFromProjection: boolean;
|
|
26
|
+
readonly rekeyTenantKeyedOwnerFields: boolean;
|
|
27
|
+
readonly eraseUnresolvableSubjects: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// A typo such as `--dryrun` must fail instead of silently running the real
|
|
31
|
+
// backfill. The rebuild is a separate invocation on purpose: it may only run
|
|
32
|
+
// once the backfill (and any forget-subject follow-up) has completed, and an
|
|
33
|
+
// erasing run must never propagate into the read side unchecked.
|
|
34
|
+
export function parseBackfillPiiArgs(argv: readonly string[]): BackfillPiiArgs {
|
|
35
|
+
const flags = new Set<BackfillPiiFlag>();
|
|
36
|
+
for (const arg of argv) {
|
|
37
|
+
if (!isBackfillPiiFlag(arg)) throw new Error(`unknown flag: ${arg}`);
|
|
38
|
+
flags.add(arg);
|
|
39
|
+
}
|
|
40
|
+
if (flags.has("--rebuild")) {
|
|
41
|
+
if (flags.size > 1) {
|
|
42
|
+
throw new Error("--rebuild runs the projection rebuild only and takes no other flag");
|
|
43
|
+
}
|
|
44
|
+
return { mode: "rebuild" };
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
mode: "backfill",
|
|
48
|
+
dryRun: flags.has("--dry-run"),
|
|
49
|
+
resolveOwnerFromProjection: flags.has("--resolve-owner-from-projection"),
|
|
50
|
+
rekeyTenantKeyedOwnerFields: flags.has("--rekey-tenant-keyed-owner-fields"),
|
|
51
|
+
eraseUnresolvableSubjects: flags.has("--erase-unresolvable-subjects"),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Must match backfillEventPiiEncryption's target set; rebuilding more would
|
|
56
|
+
// shadow-swap tables the backfill never touched.
|
|
57
|
+
export function selectPiiProjections<
|
|
58
|
+
P extends { readonly name: string; readonly sources: readonly string[] },
|
|
59
|
+
>(
|
|
60
|
+
registry: Registry,
|
|
61
|
+
projections: readonly P[],
|
|
62
|
+
piiDeps: {
|
|
63
|
+
readonly collectPiiSubjectFields: typeof import("@cosmicdrift/kumiko-framework/crypto").collectPiiSubjectFields;
|
|
64
|
+
readonly configuredEventPiiCatalog: typeof import("@cosmicdrift/kumiko-framework/crypto").configuredEventPiiCatalog;
|
|
65
|
+
},
|
|
66
|
+
): P[] {
|
|
67
|
+
const piiEntityNames = new Set<string>();
|
|
68
|
+
for (const [name, entity] of registry.getAllEntities()) {
|
|
69
|
+
if (piiDeps.collectPiiSubjectFields(entity).length > 0) piiEntityNames.add(name);
|
|
70
|
+
}
|
|
71
|
+
const piiEventTypes = new Set(piiDeps.configuredEventPiiCatalog().keys());
|
|
72
|
+
const sourceCarriesPii = (source: string): boolean => {
|
|
73
|
+
if (piiEntityNames.has(source) || piiEventTypes.has(source)) return true;
|
|
74
|
+
// "<entity>.<event>" sources; a dotless source must not be truncated.
|
|
75
|
+
const dot = source.lastIndexOf(".");
|
|
76
|
+
return dot > 0 && piiEntityNames.has(source.slice(0, dot));
|
|
77
|
+
};
|
|
78
|
+
// Event-sourced aggregates without an entity only show up through the
|
|
79
|
+
// catalog event types their projection applies.
|
|
80
|
+
const appliesPiiEvent = (projectionName: string): boolean =>
|
|
81
|
+
Object.keys(registry.getAllProjections().get(projectionName)?.apply ?? {}).some((eventType) =>
|
|
82
|
+
piiEventTypes.has(eventType),
|
|
83
|
+
);
|
|
84
|
+
return projections.filter((p) => p.sources.some(sourceCarriesPii) || appliesPiiEvent(p.name));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const LOG_PREFIX = "[backfill-pii]";
|
|
88
|
+
|
|
89
|
+
const KMS_SLOTS = ["PLATFORM_KEK", "PLATFORM_KEK_PREVIOUS", "KUMIKO_BLIND_INDEX_KEY"] as const;
|
|
90
|
+
|
|
91
|
+
const BIGINT_SAFE = (_key: string, value: unknown): unknown =>
|
|
92
|
+
typeof value === "bigint" ? String(value) : value;
|
|
93
|
+
|
|
94
|
+
export const BACKFILL_PII_HELP = [
|
|
95
|
+
"Re-encrypts pre-KMS plaintext PII in the event store, then (separately)",
|
|
96
|
+
"rebuilds the projections that read it. Reads kumiko.config.ts in cwd.",
|
|
97
|
+
"",
|
|
98
|
+
"Env: DATABASE_URL, SUBJECT_KEYS_DATABASE_URL, PLATFORM_KEK (or",
|
|
99
|
+
"PLATFORM_KEK_CIPHERTEXT + PLATFORM_KEK_KMS_KEY_ID + PLATFORM_KEK_KMS_TOKEN),",
|
|
100
|
+
"KUMIKO_BLIND_INDEX_KEY, optional PLATFORM_KEK_VERSION / _PREVIOUS / _PREVIOUS_VERSION.",
|
|
101
|
+
"",
|
|
102
|
+
"Flags:",
|
|
103
|
+
" --dry-run scan and count only",
|
|
104
|
+
" --resolve-owner-from-projection owner missing in old payloads: read it from the projection",
|
|
105
|
+
" --rekey-tenant-keyed-owner-fields move tenant-keyed owner fields under the resolved owner",
|
|
106
|
+
" --erase-unresolvable-subjects erase fields whose subject stays unresolvable (irreversible)",
|
|
107
|
+
" --rebuild rebuild the PII projections only; run after the backfill",
|
|
108
|
+
].join("\n");
|
|
109
|
+
|
|
110
|
+
type PiiDeps = Parameters<typeof selectPiiProjections>[2];
|
|
111
|
+
type BackfillArgs = Extract<BackfillPiiArgs, { readonly mode: "backfill" }>;
|
|
112
|
+
|
|
113
|
+
function errorMessage(e: unknown): string {
|
|
114
|
+
return e instanceof Error ? e.message : String(e);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function rebuildPiiProjections(
|
|
118
|
+
ctx: CliCommandContext,
|
|
119
|
+
db: DbConnection,
|
|
120
|
+
registry: Registry,
|
|
121
|
+
piiDeps: PiiDeps,
|
|
122
|
+
): Promise<number> {
|
|
123
|
+
const { listProjectionsWithState, rebuildProjection } = await import(
|
|
124
|
+
"@cosmicdrift/kumiko-framework/pipeline"
|
|
125
|
+
);
|
|
126
|
+
const all = await listProjectionsWithState(db, registry, { includeImplicit: true });
|
|
127
|
+
const rebuildFailures: string[] = [];
|
|
128
|
+
for (const projection of selectPiiProjections(registry, all, piiDeps)) {
|
|
129
|
+
try {
|
|
130
|
+
const result = await rebuildProjection(projection.name, { db, registry });
|
|
131
|
+
ctx.out.log(
|
|
132
|
+
`${LOG_PREFIX} rebuilt ${projection.name}: ${JSON.stringify(result, BIGINT_SAFE)}`,
|
|
133
|
+
);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
// One broken projection must not block the remaining rebuilds.
|
|
136
|
+
rebuildFailures.push(`${projection.name}: ${errorMessage(e)}`);
|
|
137
|
+
ctx.out.err(`${LOG_PREFIX} REBUILD FAILED ${projection.name}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (rebuildFailures.length === 0) return 0;
|
|
141
|
+
ctx.out.err(
|
|
142
|
+
`${LOG_PREFIX} ${rebuildFailures.length} rebuild(s) failed:\n${rebuildFailures.join("\n")}`,
|
|
143
|
+
);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function backfillPii(
|
|
148
|
+
ctx: CliCommandContext,
|
|
149
|
+
db: DbConnection,
|
|
150
|
+
registry: Registry,
|
|
151
|
+
args: BackfillArgs,
|
|
152
|
+
): Promise<number> {
|
|
153
|
+
const { backfillEventPiiEncryption } = await import("@cosmicdrift/kumiko-framework/event-store");
|
|
154
|
+
const result = await backfillEventPiiEncryption(db, registry, {
|
|
155
|
+
dryRun: args.dryRun,
|
|
156
|
+
resolveOwnerFromProjection: args.resolveOwnerFromProjection,
|
|
157
|
+
rekeyTenantKeyedOwnerFields: args.rekeyTenantKeyedOwnerFields,
|
|
158
|
+
eraseUnresolvableSubjects: args.eraseUnresolvableSubjects,
|
|
159
|
+
});
|
|
160
|
+
ctx.out.log(
|
|
161
|
+
`${LOG_PREFIX}${args.dryRun ? " DRY-RUN" : ""} ${JSON.stringify(result, BIGINT_SAFE, 2)}`,
|
|
162
|
+
);
|
|
163
|
+
if (result.erasedUnresolvable > 0) {
|
|
164
|
+
// Erasure is irreversible; a separate line so it cannot hide in the JSON above.
|
|
165
|
+
ctx.out.err(
|
|
166
|
+
`${LOG_PREFIX} WARNING: ${result.erasedUnresolvable} unresolvable subject(s) ${args.dryRun ? "would be" : "were"} erased — compare against the dry-run count before running --rebuild.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (result.failures.length > 0) {
|
|
170
|
+
ctx.out.err(
|
|
171
|
+
`${LOG_PREFIX} ${result.failures.length} events failed — nothing skipped silently, rerun after inspecting.`,
|
|
172
|
+
);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
if (!args.dryRun) ctx.out.log(`${LOG_PREFIX} next: kumiko ops backfill-pii --rebuild`);
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function runBackfillPii(ctx: CliCommandContext): Promise<number> {
|
|
180
|
+
let args: BackfillPiiArgs;
|
|
181
|
+
try {
|
|
182
|
+
args = parseBackfillPiiArgs(ctx.argv);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
ctx.out.err(`${LOG_PREFIX} ${errorMessage(e)}`);
|
|
185
|
+
ctx.out.err(BACKFILL_PII_HELP);
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const configPath = join(ctx.cwd, "kumiko.config.ts");
|
|
190
|
+
if (!(await Bun.file(configPath).exists())) {
|
|
191
|
+
ctx.out.err(`${LOG_PREFIX} kumiko.config.ts not found at: ${configPath}`);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
const databaseUrl = process.env["DATABASE_URL"];
|
|
195
|
+
if (!databaseUrl) {
|
|
196
|
+
ctx.out.err(`${LOG_PREFIX} DATABASE_URL not set. Run against the app env.`);
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
|
|
201
|
+
await ensureTemporalPolyfill();
|
|
202
|
+
|
|
203
|
+
const config = await loadAppConfig(ctx, configPath);
|
|
204
|
+
if (config === null) return 1;
|
|
205
|
+
|
|
206
|
+
const crypto = await import("@cosmicdrift/kumiko-framework/crypto");
|
|
207
|
+
const { createRegistry } = await import("@cosmicdrift/kumiko-framework/engine");
|
|
208
|
+
const { createDbConnection } = await import("@cosmicdrift/kumiko-framework/db");
|
|
209
|
+
|
|
210
|
+
// Without the full KMS wiring the backfill would have nothing to encrypt
|
|
211
|
+
// under, so there is deliberately no plaintext fallback here.
|
|
212
|
+
let wiring: Awaited<ReturnType<typeof crypto.requireKmsWiringAsync>>;
|
|
213
|
+
try {
|
|
214
|
+
wiring = await crypto.requireKmsWiringAsync(process.env, {
|
|
215
|
+
logPrefix: LOG_PREFIX,
|
|
216
|
+
slots: KMS_SLOTS,
|
|
217
|
+
log: (line) => ctx.out.log(line),
|
|
218
|
+
});
|
|
219
|
+
} catch (e) {
|
|
220
|
+
ctx.out.err(errorMessage(e));
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
crypto.configurePiiSubjectKms(wiring.kms);
|
|
224
|
+
crypto.configureBlindIndexKey(wiring.blindIndexKey);
|
|
225
|
+
|
|
226
|
+
const registry = createRegistry(config.features);
|
|
227
|
+
const { db, close } = createDbConnection({ url: databaseUrl, maxConnections: 4 });
|
|
228
|
+
try {
|
|
229
|
+
return args.mode === "rebuild"
|
|
230
|
+
? await rebuildPiiProjections(ctx, db, registry, crypto)
|
|
231
|
+
: await backfillPii(ctx, db, registry, args);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
ctx.out.err(`${LOG_PREFIX} ${errorMessage(e)}`);
|
|
234
|
+
return 1;
|
|
235
|
+
} finally {
|
|
236
|
+
await close();
|
|
237
|
+
// The KMS pool keeps the process alive otherwise.
|
|
238
|
+
await wiring.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const backfillPiiOp = {
|
|
243
|
+
id: "backfill-pii",
|
|
244
|
+
description: "Encrypt pre-KMS plaintext PII in the event store",
|
|
245
|
+
run: runBackfillPii,
|
|
246
|
+
} as const;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CliCommand, CliCommandContext } from "../types";
|
|
2
|
+
import { backfillPiiOp } from "./backfill-pii";
|
|
3
|
+
import { waitlistMigrateOp } from "./waitlist-migrate";
|
|
4
|
+
|
|
5
|
+
export type OpsSubcommand = {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly description: string;
|
|
8
|
+
readonly run: (ctx: CliCommandContext) => Promise<number>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const OPS_SUBCOMMANDS: ReadonlyArray<OpsSubcommand> = [backfillPiiOp, waitlistMigrateOp];
|
|
12
|
+
|
|
13
|
+
function opsUsage(): string {
|
|
14
|
+
return OPS_SUBCOMMANDS.map((op) => ` ${op.id.padEnd(20)} ${op.description}`).join("\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const opsCommand: CliCommand = {
|
|
18
|
+
id: "ops",
|
|
19
|
+
description: "One-off operations on an existing app (backfill-pii, waitlist-migrate)",
|
|
20
|
+
help: `Subcommands:\n${opsUsage()}`,
|
|
21
|
+
run: async (ctx) => {
|
|
22
|
+
const sub = OPS_SUBCOMMANDS.find((op) => op.id === ctx.argv[0]);
|
|
23
|
+
if (!sub) {
|
|
24
|
+
ctx.out.err("");
|
|
25
|
+
ctx.out.err(" Usage: kumiko ops <subcommand>");
|
|
26
|
+
ctx.out.err(opsUsage());
|
|
27
|
+
ctx.out.err("");
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
return await sub.run({ ...ctx, argv: ctx.argv.slice(1) });
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { loadAppConfig } from "../load-app-config";
|
|
3
|
+
import type { CliCommandContext } from "../types";
|
|
4
|
+
|
|
5
|
+
const USAGE =
|
|
6
|
+
"Usage: kumiko ops waitlist-migrate --from <offlot|publicstatus> [--dry-run] [--source-table <name>] [--locale <tag>]";
|
|
7
|
+
|
|
8
|
+
function flagValue(argv: readonly string[], flag: string): string | undefined {
|
|
9
|
+
const index = argv.indexOf(flag);
|
|
10
|
+
return index === -1 ? undefined : argv[index + 1];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fail(ctx: CliCommandContext, ...lines: readonly string[]): number {
|
|
14
|
+
ctx.out.err("");
|
|
15
|
+
for (const line of lines) ctx.out.err(` ${line}`);
|
|
16
|
+
ctx.out.err("");
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const waitlistMigrateOp = {
|
|
21
|
+
id: "waitlist-migrate",
|
|
22
|
+
description: "Copy a legacy app waitlist into the framework waitlist entity (idempotent)",
|
|
23
|
+
run: async (ctx: CliCommandContext): Promise<number> => {
|
|
24
|
+
const {
|
|
25
|
+
DEFAULT_IMPORT_LOCALE,
|
|
26
|
+
DEFAULT_LEGACY_SOURCE_TABLE,
|
|
27
|
+
isLegacyWaitlistSource,
|
|
28
|
+
migrateLegacyWaitlist,
|
|
29
|
+
} = await import("@cosmicdrift/kumiko-bundled-features/waitlist");
|
|
30
|
+
const from = flagValue(ctx.argv, "--from");
|
|
31
|
+
if (!isLegacyWaitlistSource(from)) return fail(ctx, USAGE);
|
|
32
|
+
|
|
33
|
+
const configPath = join(ctx.cwd, "kumiko.config.ts");
|
|
34
|
+
if (!(await Bun.file(configPath).exists())) {
|
|
35
|
+
return fail(ctx, `kumiko.config.ts not found at: ${configPath}`);
|
|
36
|
+
}
|
|
37
|
+
const databaseUrl = process.env["DATABASE_URL"];
|
|
38
|
+
if (!databaseUrl) return fail(ctx, "DATABASE_URL not set.");
|
|
39
|
+
const config = await loadAppConfig(ctx, configPath);
|
|
40
|
+
if (config === null) return 1;
|
|
41
|
+
if (!config.features.some((feature) => feature.name === "waitlist")) {
|
|
42
|
+
return fail(
|
|
43
|
+
ctx,
|
|
44
|
+
"The app config does not mount the framework `waitlist` feature; add createWaitlistFeature() first.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dryRun = ctx.argv.includes("--dry-run");
|
|
49
|
+
const { createDbConnection } = await import("@cosmicdrift/kumiko-framework/db");
|
|
50
|
+
const { db, close } = createDbConnection({ url: databaseUrl });
|
|
51
|
+
try {
|
|
52
|
+
const report = await migrateLegacyWaitlist(db, {
|
|
53
|
+
from,
|
|
54
|
+
dryRun,
|
|
55
|
+
sourceTable: flagValue(ctx.argv, "--source-table") ?? DEFAULT_LEGACY_SOURCE_TABLE[from],
|
|
56
|
+
defaultLocale: flagValue(ctx.argv, "--locale") ?? DEFAULT_IMPORT_LOCALE,
|
|
57
|
+
});
|
|
58
|
+
ctx.out.log("");
|
|
59
|
+
ctx.out.log(
|
|
60
|
+
` ${dryRun ? "[dry-run] " : ""}waitlist-migrate --from ${from}: read ${report.read}, ${dryRun ? "would create" : "created"} ${report.created}, already present ${report.skipped}, unreadable ${report.unreadable}`,
|
|
61
|
+
);
|
|
62
|
+
ctx.out.log("");
|
|
63
|
+
return report.unreadable > 0 ? 1 : 0;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return fail(ctx, `✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
66
|
+
} finally {
|
|
67
|
+
await close();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
} as const;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import type { CliCommand, CliCommandContext } from "./types";
|
|
3
|
+
|
|
4
|
+
// Fixed column order so the table reads the same in every run.
|
|
5
|
+
const PORT_COLUMNS = ["dev", "e2e", "postgres", "redis", "meilisearch", "s3"] as const;
|
|
6
|
+
|
|
7
|
+
export async function runPorts(ctx: CliCommandContext): Promise<number> {
|
|
8
|
+
if (ctx.argv.length > 1 || ctx.argv.some((arg) => arg.startsWith("-"))) {
|
|
9
|
+
ctx.out.err(" usage: kumiko ports [workspace-root]");
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
const root = resolve(ctx.cwd, ctx.argv[0] ?? ".");
|
|
13
|
+
const managed = await import("@cosmicdrift/kumiko-dev-server/managed-files");
|
|
14
|
+
const apps = managed.scanWorkspaceApps(root);
|
|
15
|
+
if (apps.length === 0) {
|
|
16
|
+
ctx.out.err(` No app with kumiko.config.ts directly below ${root}.`);
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
ctx.out.log("");
|
|
20
|
+
ctx.out.log(` ${"app".padEnd(20)}${PORT_COLUMNS.map((column) => column.padEnd(12)).join("")}`);
|
|
21
|
+
let undeclared = 0;
|
|
22
|
+
for (const { app, config: appConfig } of apps) {
|
|
23
|
+
if (appConfig.kind !== "defined") {
|
|
24
|
+
undeclared++;
|
|
25
|
+
const reason = appConfig.kind === "invalid" ? appConfig.message : "no defineAppConfig ports";
|
|
26
|
+
ctx.out.log(` ${app.padEnd(20)}(${reason})`);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const byRole = new Map(
|
|
30
|
+
managed.listAppPorts(appConfig.ports).map(({ role, port }) => [role, port]),
|
|
31
|
+
);
|
|
32
|
+
ctx.out.log(
|
|
33
|
+
` ${app.padEnd(20)}${PORT_COLUMNS.map((column) => String(byRole.get(column) ?? "-").padEnd(12)).join("")}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const collisions = managed.findPortCollisions(apps);
|
|
37
|
+
ctx.out.log("");
|
|
38
|
+
for (const { port, claims } of collisions) {
|
|
39
|
+
ctx.out.err(` ✗ port ${port}: ${claims.map(({ app, role }) => `${app} ${role}`).join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
if (undeclared > 0)
|
|
42
|
+
ctx.out.log(
|
|
43
|
+
` ${undeclared} app(s) without readable ports are not part of the collision check.`,
|
|
44
|
+
);
|
|
45
|
+
if (collisions.length === 0)
|
|
46
|
+
ctx.out.log(` No port collisions among ${apps.length - undeclared} of ${apps.length} app(s).`);
|
|
47
|
+
ctx.out.log("");
|
|
48
|
+
return collisions.length > 0 ? 1 : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const portsCommand: CliCommand = {
|
|
52
|
+
id: "ports",
|
|
53
|
+
description: "List the ports of every app in a workspace and report collisions",
|
|
54
|
+
help: [
|
|
55
|
+
"Scans the direct children of [workspace-root] (default: cwd) for kumiko.config.ts,",
|
|
56
|
+
"reads ports.dev, ports.e2e and ports.infra statically (no app code runs) and",
|
|
57
|
+
"exits 1 when two apps, or two roles of one app, claim the same port.",
|
|
58
|
+
].join("\n"),
|
|
59
|
+
run: runPorts,
|
|
60
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { loadRepoManifest, REPO_MANIFEST_FILE } from "../repo-manifest";
|
|
2
|
+
import type { CliCommand, CliCommandContext } from "./types";
|
|
3
|
+
|
|
4
|
+
const SYNC_FLAGS = ["--write", "--adopt"] as const;
|
|
5
|
+
|
|
6
|
+
export async function runSyncManaged(ctx: CliCommandContext): Promise<number> {
|
|
7
|
+
const unknown = ctx.argv.filter((arg) => !SYNC_FLAGS.some((flag) => flag === arg));
|
|
8
|
+
if (unknown.length > 0) {
|
|
9
|
+
ctx.out.err(
|
|
10
|
+
` Unknown argument(s) for "sync-managed": ${unknown.join(", ")}. Known flags: ${SYNC_FLAGS.join(", ")}`,
|
|
11
|
+
);
|
|
12
|
+
return 1;
|
|
13
|
+
}
|
|
14
|
+
const { manifest } = loadRepoManifest(ctx.cwd, {
|
|
15
|
+
warn: (message) => ctx.out.err(` ${message}`),
|
|
16
|
+
});
|
|
17
|
+
if (manifest.kind !== "app") {
|
|
18
|
+
ctx.out.err(
|
|
19
|
+
` sync-managed needs kind "app" in ${REPO_MANIFEST_FILE}, got "${manifest.kind}".`,
|
|
20
|
+
);
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
const write = ctx.argv.includes("--write");
|
|
24
|
+
const managed = await import("@cosmicdrift/kumiko-dev-server/managed-files");
|
|
25
|
+
const facts = managed.resolveManagedAppFacts({
|
|
26
|
+
appRoot: ctx.cwd,
|
|
27
|
+
uiRoots: manifest.uiRoots ?? [],
|
|
28
|
+
});
|
|
29
|
+
const actions = managed.syncManagedFiles(facts, { write, adopt: ctx.argv.includes("--adopt") });
|
|
30
|
+
ctx.out.log("");
|
|
31
|
+
for (const entry of actions) {
|
|
32
|
+
if (entry.action === "unchanged") continue;
|
|
33
|
+
const verb = write ? entry.action : `would ${entry.action}`;
|
|
34
|
+
ctx.out.log(
|
|
35
|
+
entry.action === "skip"
|
|
36
|
+
? ` skip ${entry.path}: ${entry.skippedBecause}`
|
|
37
|
+
: ` ${verb.padEnd(7)} ${entry.path}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const pending = actions.filter(
|
|
41
|
+
(entry) => entry.action !== "unchanged" && entry.action !== "skip",
|
|
42
|
+
);
|
|
43
|
+
ctx.out.log("");
|
|
44
|
+
if (!write && pending.length > 0)
|
|
45
|
+
ctx.out.log(" Dry run. Write the changes with: kumiko sync-managed --write");
|
|
46
|
+
if (pending.length === 0) ctx.out.log(" All writable managed files are up to date.");
|
|
47
|
+
ctx.out.log("");
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const syncManagedCommand: CliCommand = {
|
|
52
|
+
id: "sync-managed",
|
|
53
|
+
description: "Write the framework-owned files of an app from the installed templates",
|
|
54
|
+
help: [
|
|
55
|
+
"Renders the managed files (deploy/, .husky/pre-push, .github/workflows, bunfigs,",
|
|
56
|
+
"kumiko/schema.ts) from the installed @cosmicdrift/kumiko-dev-server and writes",
|
|
57
|
+
"the ones that differ. Without --write it only lists what would change.",
|
|
58
|
+
"",
|
|
59
|
+
"Only files that carry the kumiko-managed marker are overwritten. A file with",
|
|
60
|
+
"`kumiko-managed: local <reason>` on the line after the marker is left alone.",
|
|
61
|
+
"",
|
|
62
|
+
"Flags:",
|
|
63
|
+
" --write write the changes",
|
|
64
|
+
" --adopt also take over existing files without a marker (first adoption)",
|
|
65
|
+
].join("\n"),
|
|
66
|
+
run: runSyncManaged,
|
|
67
|
+
};
|