@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-cli",
3
- "version": "0.368.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.368.0",
40
- "@cosmicdrift/kumiko-guards": "0.368.0",
41
- "@cosmicdrift/kumiko-repo-manifest": "0.368.0",
42
- "@cosmicdrift/kumiko-testing": "0.368.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.368.0",
68
- "@cosmicdrift/kumiko-framework": "0.368.0",
69
- "@cosmicdrift/kumiko-headless": "0.368.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",
@@ -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 gaps = findAgentDocGaps(config.features);
40
- const translationGaps = findHandlerTranslationGaps(config.features);
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
+ }
@@ -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
- let repo: ReturnType<GuardsCli["findLocalRepo"]>;
135
- try {
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 steps = printCheckPlan(ctx, repo);
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.absPath, guards, deps);
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
- repoRoot: string,
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, repoRoot, guards, deps);
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
- repoRoot: string,
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"], repoRoot, ctx.out);
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 + guard suites, step list derived from kumiko.json",
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",
@@ -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 { agentCommand, checkCommand, consumerCommand, handbuchCommand, projectCommand };
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
- export type AppConfig = { readonly features: readonly FeatureDefinition[] };
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(" export default { features: [myFeature] };");
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
+ };