@cosmicdrift/kumiko-dev-server 0.369.0 → 0.371.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.
Files changed (44) hide show
  1. package/dist/build.d.ts +1 -1
  2. package/dist/cli.d.ts +3 -3
  3. package/dist/compose-stacks.d.ts +63 -3
  4. package/dist/compose-stacks.js +94 -12
  5. package/dist/host-dispatch-adapter.d.ts +7 -0
  6. package/dist/host-dispatch-adapter.js +31 -0
  7. package/dist/index.d.ts +21 -20
  8. package/dist/index.js +2 -1
  9. package/dist/init-deploy-cli.js +6 -0
  10. package/dist/managed-files/app-scripts.d.ts +13 -0
  11. package/dist/managed-files/app-scripts.js +40 -0
  12. package/dist/managed-files/check.d.ts +34 -0
  13. package/dist/managed-files/check.js +109 -0
  14. package/dist/managed-files/index.d.ts +8 -0
  15. package/dist/managed-files/index.js +8 -0
  16. package/dist/managed-files/jsonc.d.ts +1 -0
  17. package/dist/managed-files/jsonc.js +12 -0
  18. package/dist/managed-files/line-diff.d.ts +2 -0
  19. package/dist/managed-files/line-diff.js +60 -0
  20. package/dist/managed-files/marker.d.ts +17 -0
  21. package/dist/managed-files/marker.js +49 -0
  22. package/dist/managed-files/port-registry.d.ts +17 -0
  23. package/dist/managed-files/port-registry.js +31 -0
  24. package/dist/managed-files/registry.d.ts +51 -0
  25. package/dist/managed-files/registry.js +115 -0
  26. package/dist/managed-files/static-app-config.d.ts +16 -0
  27. package/dist/managed-files/static-app-config.js +99 -0
  28. package/dist/managed-files/sync.d.ts +19 -0
  29. package/dist/managed-files/sync.js +101 -0
  30. package/dist/run-dev-app.d.ts +1 -1
  31. package/dist/run-dev-app.js +7 -1
  32. package/dist/scaffold-app.d.ts +3 -3
  33. package/dist/scaffold-app.js +125 -199
  34. package/dist/scaffold-deploy.d.ts +9 -0
  35. package/dist/scaffold-deploy.js +34 -6
  36. package/dist/scaffold-feature.js +1 -0
  37. package/package.json +18 -9
  38. package/src/changes.json +25 -0
  39. package/templates/deploy/migrate-step.sh.template +11 -6
  40. package/templates/managed/cache-cleanup.yml.template +30 -0
  41. package/templates/managed/ci.yml.template +65 -0
  42. package/templates/managed/husky-pre-push.template +40 -0
  43. package/templates/managed/kumiko-schema.ts.template +12 -0
  44. package/templates/managed/release.yml.template +55 -0
@@ -0,0 +1,8 @@
1
+ export { listAppPorts } from "@cosmicdrift/kumiko-framework/config";
2
+ export { type AppScriptsReport, checkAppScripts, KNOWN_EXTRA_APP_SCRIPTS, PREPARE_SCRIPT, STANDARD_APP_SCRIPTS, type StandardAppScript, } from "./app-scripts.ts";
3
+ export { checkManagedFiles, isManagedDrift, type ManagedCheckResult, type ManagedFileReport, type ManagedFileState, } from "./check.ts";
4
+ export { addManagedMarker, MANAGED_MARKER, readManagedMarker, stripManagedMarker, } from "./marker.ts";
5
+ export { findPortCollisions, type PortClaim, type PortCollision, type ScannedApp, scanWorkspaceApps, } from "./port-registry.ts";
6
+ export { BIOME_PRESET, listManagedFiles, type ManagedAppFacts, type ManagedAppInput, type ManagedFile, type ManagedTemplateFile, renderBiomePresetJson, renderManagedBunfig, renderManagedTemplates, renderTsconfigPresetJson, resolveManagedAppFacts, TSCONFIG_PRESET, } from "./registry.ts";
7
+ export { parseStaticAppConfig, readStaticAppConfig, type StaticAppConfig, } from "./static-app-config.ts";
8
+ export { type SyncAction, type SyncManagedOptions, syncManagedFiles } from "./sync.ts";
@@ -0,0 +1,8 @@
1
+ export { listAppPorts } from "@cosmicdrift/kumiko-framework/config";
2
+ export { checkAppScripts, KNOWN_EXTRA_APP_SCRIPTS, PREPARE_SCRIPT, STANDARD_APP_SCRIPTS, } from "./app-scripts.js";
3
+ export { checkManagedFiles, isManagedDrift, } from "./check.js";
4
+ export { addManagedMarker, MANAGED_MARKER, readManagedMarker, stripManagedMarker, } from "./marker.js";
5
+ export { findPortCollisions, scanWorkspaceApps, } from "./port-registry.js";
6
+ export { BIOME_PRESET, listManagedFiles, renderBiomePresetJson, renderManagedBunfig, renderManagedTemplates, renderTsconfigPresetJson, resolveManagedAppFacts, TSCONFIG_PRESET, } from "./registry.js";
7
+ export { parseStaticAppConfig, readStaticAppConfig, } from "./static-app-config.js";
8
+ export { syncManagedFiles } from "./sync.js";
@@ -0,0 +1 @@
1
+ export declare function parseJsonc(source: string, context: string): unknown;
@@ -0,0 +1,12 @@
1
+ import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
2
+ // Strings come first in each alternation, so comment and comma patterns
3
+ // inside string literals are matched as part of the string and kept.
4
+ const STRING_OR_COMMENT = /"(?:\\.|[^"\\])*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g;
5
+ const STRING_OR_TRAILING_COMMA = /"(?:\\.|[^"\\])*"|,(\s*[}\]])/g;
6
+ // biome.json and tsconfig.json accept comments and trailing commas; strip both
7
+ // (outside strings) so they parse as JSON.
8
+ export function parseJsonc(source, context) {
9
+ const withoutComments = source.replace(STRING_OR_COMMENT, (match) => match.startsWith('"') ? match : "");
10
+ const withoutTrailingCommas = withoutComments.replace(STRING_OR_TRAILING_COMMA, (match, afterComma) => afterComma ?? match);
11
+ return parseJsonOrThrow(withoutTrailingCommas, context);
12
+ }
@@ -0,0 +1,2 @@
1
+ /** `-` lines are what the template expects, `+` lines what the repo has. */
2
+ export declare function renderLineDiff(expected: string, actual: string): string;
@@ -0,0 +1,60 @@
1
+ // Unified-style line diff for drift reports. Managed files are a few hundred
2
+ // lines at most, so the quadratic LCS table stays small.
3
+ const CONTEXT_LINES = 2;
4
+ function buildLcsTable(expected, actual) {
5
+ const cols = actual.length + 1;
6
+ const table = new Uint32Array((expected.length + 1) * cols);
7
+ const lengthAt = (i, j) => table[i * cols + j] ?? 0;
8
+ for (let i = expected.length - 1; i >= 0; i--) {
9
+ for (let j = actual.length - 1; j >= 0; j--) {
10
+ table[i * cols + j] =
11
+ expected[i] === actual[j]
12
+ ? lengthAt(i + 1, j + 1) + 1
13
+ : Math.max(lengthAt(i + 1, j), lengthAt(i, j + 1));
14
+ }
15
+ }
16
+ return { lengthAt };
17
+ }
18
+ function nextOp(left, right, dropLeftKeepsLonger) {
19
+ if (left !== undefined && left === right)
20
+ return { kind: " ", line: left };
21
+ if (right === undefined || (left !== undefined && dropLeftKeepsLonger))
22
+ return { kind: "-", line: left ?? "" };
23
+ return { kind: "+", line: right };
24
+ }
25
+ function diffOps(expected, actual) {
26
+ const lcs = buildLcsTable(expected, actual);
27
+ const ops = [];
28
+ let i = 0;
29
+ let j = 0;
30
+ while (i < expected.length || j < actual.length) {
31
+ const op = nextOp(expected[i], actual[j], lcs.lengthAt(i + 1, j) >= lcs.lengthAt(i, j + 1));
32
+ ops.push(op);
33
+ if (op.kind !== "+")
34
+ i++;
35
+ if (op.kind !== "-")
36
+ j++;
37
+ }
38
+ return ops;
39
+ }
40
+ /** `-` lines are what the template expects, `+` lines what the repo has. */
41
+ export function renderLineDiff(expected, actual) {
42
+ const ops = diffOps(expected.split("\n"), actual.split("\n"));
43
+ const keep = ops.map((_op, index) => ops
44
+ .slice(Math.max(0, index - CONTEXT_LINES), index + CONTEXT_LINES + 1)
45
+ .some((near) => near.kind !== " "));
46
+ const out = [];
47
+ let skipped = false;
48
+ for (const [index, op] of ops.entries()) {
49
+ if (keep[index]) {
50
+ if (skipped && out.length > 0)
51
+ out.push("...");
52
+ skipped = false;
53
+ out.push(`${op.kind} ${op.line}`);
54
+ }
55
+ else {
56
+ skipped = true;
57
+ }
58
+ }
59
+ return out.join("\n");
60
+ }
@@ -0,0 +1,17 @@
1
+ export declare const MANAGED_MARKER = "kumiko-managed";
2
+ export type MarkerCommentStyle = "#" | "//";
3
+ export type ManagedMarker = {
4
+ readonly kind: "managed";
5
+ } | {
6
+ readonly kind: "local";
7
+ readonly reason: string;
8
+ } | {
9
+ readonly kind: "unmarked";
10
+ } | {
11
+ readonly kind: "invalid";
12
+ readonly message: string;
13
+ };
14
+ export declare function readManagedMarker(content: string): ManagedMarker;
15
+ /** Removes the marker line and a following local line; the bunfig header is content, not a marker. */
16
+ export declare function stripManagedMarker(content: string): string;
17
+ export declare function addManagedMarker(content: string, style: MarkerCommentStyle): string;
@@ -0,0 +1,49 @@
1
+ export const MANAGED_MARKER = "kumiko-managed";
2
+ // kumiko-testing has written this header into every bunfig since before the
3
+ // marker existed; it already means "generated, do not edit".
4
+ const BUNFIG_GENERATED_HEADER = "# Generated by kumiko-testing (";
5
+ const LOCAL_MARKER_RE = /^(?:#|\/\/) kumiko-managed: local(?:[ \t]+(.*))?$/;
6
+ // Docker requires `# syntax=` on line 1 and the kernel requires the shebang
7
+ // there, so the marker moves one line down in those files.
8
+ function isPreambleLine(line) {
9
+ return line !== undefined && (line.startsWith("#!") || line.startsWith("# syntax="));
10
+ }
11
+ function markerLineIndex(lines) {
12
+ return isPreambleLine(lines[0]) ? 1 : 0;
13
+ }
14
+ function isMarkerLine(line) {
15
+ return line === `# ${MANAGED_MARKER}` || line === `// ${MANAGED_MARKER}`;
16
+ }
17
+ export function readManagedMarker(content) {
18
+ const lines = content.split("\n");
19
+ const index = markerLineIndex(lines);
20
+ const line = lines[index];
21
+ if (!isMarkerLine(line) && !line?.startsWith(BUNFIG_GENERATED_HEADER))
22
+ return { kind: "unmarked" };
23
+ const local = LOCAL_MARKER_RE.exec(lines[index + 1] ?? "");
24
+ if (local === null)
25
+ return { kind: "managed" };
26
+ const reason = local[1]?.trim() ?? "";
27
+ if (reason === "") {
28
+ return {
29
+ kind: "invalid",
30
+ message: `line ${index + 2}: "kumiko-managed: local" needs a reason, e.g. "# kumiko-managed: local extra build step for pdf-parse"`,
31
+ };
32
+ }
33
+ return { kind: "local", reason };
34
+ }
35
+ /** Removes the marker line and a following local line; the bunfig header is content, not a marker. */
36
+ export function stripManagedMarker(content) {
37
+ const lines = content.split("\n");
38
+ const index = markerLineIndex(lines);
39
+ if (!isMarkerLine(lines[index]))
40
+ return content;
41
+ const removeCount = LOCAL_MARKER_RE.test(lines[index + 1] ?? "") ? 2 : 1;
42
+ lines.splice(index, removeCount);
43
+ return lines.join("\n");
44
+ }
45
+ export function addManagedMarker(content, style) {
46
+ const lines = content.split("\n");
47
+ lines.splice(markerLineIndex(lines), 0, `${style} ${MANAGED_MARKER}`);
48
+ return lines.join("\n");
49
+ }
@@ -0,0 +1,17 @@
1
+ import { type AppPortRole } from "@cosmicdrift/kumiko-framework/config";
2
+ import { type StaticAppConfig } from "./static-app-config.ts";
3
+ export type ScannedApp = {
4
+ readonly app: string;
5
+ readonly config: StaticAppConfig;
6
+ };
7
+ export type PortClaim = {
8
+ readonly app: string;
9
+ readonly role: AppPortRole;
10
+ };
11
+ export type PortCollision = {
12
+ readonly port: number;
13
+ readonly claims: readonly PortClaim[];
14
+ };
15
+ /** Direct children of `root` with a kumiko.config.ts; worktrees and dot-dirs are skipped. */
16
+ export declare function scanWorkspaceApps(root: string): readonly ScannedApp[];
17
+ export declare function findPortCollisions(apps: readonly ScannedApp[]): readonly PortCollision[];
@@ -0,0 +1,31 @@
1
+ // Port registry across the apps of a workspace: every app declares its ports
2
+ // in kumiko.config.ts, and two apps (or two roles of one app) must never claim
3
+ // the same port, otherwise their local dev and E2E runs block each other.
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { APP_CONFIG_FILE, listAppPorts, } from "@cosmicdrift/kumiko-framework/config";
7
+ import { readStaticAppConfig } from "./static-app-config.js";
8
+ /** Direct children of `root` with a kumiko.config.ts; worktrees and dot-dirs are skipped. */
9
+ export function scanWorkspaceApps(root) {
10
+ return readdirSync(root, { withFileTypes: true })
11
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules")
12
+ .filter((entry) => existsSync(join(root, entry.name, APP_CONFIG_FILE)))
13
+ .map((entry) => ({ app: entry.name, config: readStaticAppConfig(join(root, entry.name)) }))
14
+ .sort((a, b) => a.app.localeCompare(b.app));
15
+ }
16
+ export function findPortCollisions(apps) {
17
+ const claimsByPort = new Map();
18
+ for (const { app, config } of apps) {
19
+ if (config.kind !== "defined")
20
+ continue;
21
+ for (const { role, port } of listAppPorts(config.ports)) {
22
+ const claims = claimsByPort.get(port) ?? [];
23
+ claims.push({ app, role });
24
+ claimsByPort.set(port, claims);
25
+ }
26
+ }
27
+ return [...claimsByPort.entries()]
28
+ .filter(([, claims]) => claims.length > 1)
29
+ .map(([port, claims]) => ({ port, claims }))
30
+ .sort((a, b) => a.port - b.port);
31
+ }
@@ -0,0 +1,51 @@
1
+ import { type StaticAppConfig } from "./static-app-config.ts";
2
+ export declare const BIOME_PRESET = "@cosmicdrift/kumiko-framework/biome";
3
+ export declare const TSCONFIG_PRESET = "@cosmicdrift/kumiko-framework/tsconfig.app.json";
4
+ export type ManagedAppInput = {
5
+ readonly appRoot: string;
6
+ /** Default: package.json `name`. */
7
+ readonly appName?: string;
8
+ /** kumiko.json uiRoots; a non-empty list turns on UI guards and DOM tests. */
9
+ readonly uiRoots: readonly string[];
10
+ };
11
+ export type ManagedAppFacts = {
12
+ readonly appRoot: string;
13
+ readonly appName: string;
14
+ readonly hasUi: boolean;
15
+ readonly config: StaticAppConfig;
16
+ };
17
+ /** Byte-compared after the marker lines are stripped. */
18
+ export type ManagedTemplateFile = {
19
+ readonly kind: "template";
20
+ readonly path: string;
21
+ /** Rendered content including the marker. */
22
+ readonly content: string;
23
+ readonly executable: boolean;
24
+ };
25
+ /** Compared after kumiko-testing's merge, which keeps app extras such as [install.scopes]. */
26
+ export type ManagedBunfigFile = {
27
+ readonly kind: "bunfig";
28
+ readonly path: string;
29
+ readonly variant: "unit" | "integration" | "real" | "dom";
30
+ };
31
+ /** Must extend the framework preset; app overrides below `extends` are allowed. */
32
+ export type ManagedStructuralFile = {
33
+ readonly kind: "structural";
34
+ readonly path: "biome.json" | "tsconfig.json" | "kumiko.config.ts";
35
+ };
36
+ /** Written once by the scaffold, then owned by the app (wiring, env schema). */
37
+ export type ManagedSeedFile = {
38
+ readonly kind: "seed";
39
+ readonly path: string;
40
+ };
41
+ export type ManagedFile = ManagedTemplateFile | ManagedBunfigFile | ManagedStructuralFile | ManagedSeedFile;
42
+ export declare function resolveManagedAppFacts(input: ManagedAppInput): ManagedAppFacts;
43
+ export declare function renderManagedTemplates(facts: ManagedAppFacts): readonly ManagedTemplateFile[];
44
+ export declare function managedBunfigFiles(facts: ManagedAppFacts): readonly ManagedBunfigFile[];
45
+ /** Options are the app's choice; read them from the file so the check only flags real drift. */
46
+ export declare function renderManagedBunfig(file: ManagedBunfigFile, existing: string | undefined): string;
47
+ export declare const MANAGED_STRUCTURAL_FILES: readonly ManagedStructuralFile[];
48
+ export declare const MANAGED_SEED_FILES: readonly ManagedSeedFile[];
49
+ export declare function listManagedFiles(facts: ManagedAppFacts): readonly ManagedFile[];
50
+ export declare function renderBiomePresetJson(): string;
51
+ export declare function renderTsconfigPresetJson(): string;
@@ -0,0 +1,115 @@
1
+ // One source for every file the framework owns in an app: the scaffold writes
2
+ // these renders, `kumiko check managed` compares against them and
3
+ // `kumiko sync-managed` writes them back.
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
8
+ import { BUNFIG_FILES, renderBunfig } from "@cosmicdrift/kumiko-testing/bunfig";
9
+ import { isKebabSegment } from "../kebab.js";
10
+ import { readReleaseTarget, render, renderDeployFiles } from "../scaffold-deploy.js";
11
+ import { addManagedMarker } from "./marker.js";
12
+ import { readStaticAppConfig } from "./static-app-config.js";
13
+ export const BIOME_PRESET = "@cosmicdrift/kumiko-framework/biome";
14
+ export const TSCONFIG_PRESET = "@cosmicdrift/kumiko-framework/tsconfig.app.json";
15
+ function templatesDir() {
16
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates", "managed");
17
+ }
18
+ function readTemplate(name) {
19
+ return readFileSync(join(templatesDir(), name), "utf-8");
20
+ }
21
+ function readPackageName(appRoot) {
22
+ const path = join(appRoot, "package.json");
23
+ if (!existsSync(path))
24
+ throw new Error(`managed files: ${path} not found`);
25
+ const raw = parseJsonOrThrow(readFileSync(path, "utf-8"), path);
26
+ const name = typeof raw === "object" && raw !== null && "name" in raw ? raw.name : undefined;
27
+ if (typeof name !== "string")
28
+ throw new Error(`managed files: ${path} has no "name"`);
29
+ return name;
30
+ }
31
+ export function resolveManagedAppFacts(input) {
32
+ const appName = input.appName ?? readPackageName(input.appRoot);
33
+ // The name lands in workflow files, image names and shell scripts.
34
+ if (!isKebabSegment(appName)) {
35
+ throw new Error(`managed files: app name must be kebab-case (a-z, 0-9, -); got "${appName}"`);
36
+ }
37
+ return {
38
+ appRoot: input.appRoot,
39
+ appName,
40
+ hasUi: input.uiRoots.length > 0,
41
+ config: readStaticAppConfig(input.appRoot),
42
+ };
43
+ }
44
+ function marked(path, body, style, executable = false) {
45
+ return { kind: "template", path, content: addManagedMarker(body, style), executable };
46
+ }
47
+ function renderCiWorkflow(facts) {
48
+ const infra = facts.config.kind === "defined" ? facts.config.ports.infra : undefined;
49
+ const minio = infra?.s3 !== undefined;
50
+ const meilisearch = infra?.meilisearch !== undefined;
51
+ return render(readTemplate("ci.yml.template"), { appName: facts.appName, uiGuards: String(facts.hasUi) }, { minio, meilisearch, e2eInputs: minio || meilisearch });
52
+ }
53
+ export function renderManagedTemplates(facts) {
54
+ const deploy = renderDeployFiles({
55
+ appName: facts.appName,
56
+ destination: facts.appRoot,
57
+ ensuredRegistryConfigFiles: [BUNFIG_FILES.unit],
58
+ });
59
+ const target = readReleaseTarget(facts.appRoot, facts.appName);
60
+ return [
61
+ ...deploy.files.map((file) => marked(`deploy/${file.output}`, file.content, "#", file.output.endsWith(".sh"))),
62
+ marked(".husky/pre-push", readTemplate("husky-pre-push.template"), "#", true),
63
+ marked(".github/workflows/cache-cleanup.yml", readTemplate("cache-cleanup.yml.template"), "#"),
64
+ marked(".github/workflows/release.yml", render(readTemplate("release.yml.template"), { appName: facts.appName, ...target }, {}), "#"),
65
+ marked(".github/workflows/ci.yml", renderCiWorkflow(facts), "#"),
66
+ marked("kumiko/schema.ts", readTemplate("kumiko-schema.ts.template"), "//"),
67
+ ];
68
+ }
69
+ export function managedBunfigFiles(facts) {
70
+ return [
71
+ { kind: "bunfig", path: BUNFIG_FILES.unit, variant: "unit" },
72
+ { kind: "bunfig", path: BUNFIG_FILES.integration, variant: "integration" },
73
+ { kind: "bunfig", path: BUNFIG_FILES.real, variant: "real" },
74
+ ...(facts.hasUi ? [{ kind: "bunfig", path: BUNFIG_FILES.dom, variant: "dom" }] : []),
75
+ ];
76
+ }
77
+ /** Options are the app's choice; read them from the file so the check only flags real drift. */
78
+ export function renderManagedBunfig(file, existing) {
79
+ const options = {
80
+ ...(existing === undefined || /^linker = "hoisted"$/m.test(existing)
81
+ ? { install: { linker: "hoisted" } }
82
+ : {}),
83
+ ...(existing !== undefined && /^coverage = true$/m.test(existing) ? { coverage: true } : {}),
84
+ };
85
+ return file.variant === "dom"
86
+ ? renderBunfig("unit", { ...options, dom: true })
87
+ : renderBunfig(file.variant, options);
88
+ }
89
+ export const MANAGED_STRUCTURAL_FILES = [
90
+ { kind: "structural", path: "biome.json" },
91
+ { kind: "structural", path: "tsconfig.json" },
92
+ { kind: "structural", path: "kumiko.config.ts" },
93
+ ];
94
+ export const MANAGED_SEED_FILES = [
95
+ { kind: "seed", path: "bin/server.ts" },
96
+ { kind: "seed", path: "bin/env-schema.ts" },
97
+ ];
98
+ export function listManagedFiles(facts) {
99
+ return [
100
+ ...renderManagedTemplates(facts),
101
+ ...managedBunfigFiles(facts),
102
+ ...MANAGED_STRUCTURAL_FILES,
103
+ ...MANAGED_SEED_FILES,
104
+ ];
105
+ }
106
+ // Written the way Biome formats it, so the app's own `biome check` stays green.
107
+ export function renderBiomePresetJson() {
108
+ return `{\n "extends": [${JSON.stringify(BIOME_PRESET)}]\n}\n`;
109
+ }
110
+ export function renderTsconfigPresetJson() {
111
+ return `${JSON.stringify({
112
+ extends: TSCONFIG_PRESET,
113
+ include: ["bin", "src", "kumiko", "e2e", "kumiko.config.ts", "playwright.config.ts"],
114
+ }, null, 2)}\n`;
115
+ }
@@ -0,0 +1,16 @@
1
+ import { type AppPorts } from "@cosmicdrift/kumiko-framework/config";
2
+ export type StaticAppConfig = {
3
+ readonly kind: "missing";
4
+ }
5
+ /** A config without defineAppConfig: the app has not adopted the standard yet. */
6
+ | {
7
+ readonly kind: "legacy";
8
+ } | {
9
+ readonly kind: "invalid";
10
+ readonly message: string;
11
+ } | {
12
+ readonly kind: "defined";
13
+ readonly ports: AppPorts;
14
+ };
15
+ export declare function parseStaticAppConfig(source: string): StaticAppConfig;
16
+ export declare function readStaticAppConfig(appRoot: string): StaticAppConfig;
@@ -0,0 +1,99 @@
1
+ // Reads the ports out of kumiko.config.ts without importing it: importing
2
+ // would execute every feature module of the app, and the port registry scans
3
+ // sibling apps whose dependencies may not even be installed.
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { APP_CONFIG_FILE, findAppPortProblems, } from "@cosmicdrift/kumiko-framework/config";
7
+ import { Project, SyntaxKind } from "ts-morph";
8
+ const INFRA_KEYS = [
9
+ "postgres",
10
+ "redis",
11
+ "meilisearch",
12
+ "s3",
13
+ ];
14
+ function isInfraKey(name) {
15
+ return INFRA_KEYS.some((key) => key === name);
16
+ }
17
+ class StaticConfigError extends Error {
18
+ }
19
+ function propertyName(node) {
20
+ if (node.isKind(SyntaxKind.PropertyAssignment))
21
+ return node.getName().replace(/^["']|["']$/g, "");
22
+ return undefined;
23
+ }
24
+ function objectProperty(object, name) {
25
+ for (const property of object.getProperties()) {
26
+ if (property.isKind(SyntaxKind.SpreadAssignment) ||
27
+ property.isKind(SyntaxKind.ShorthandPropertyAssignment)) {
28
+ throw new StaticConfigError(`${name}: spread and shorthand properties cannot be read statically`);
29
+ }
30
+ if (propertyName(property) === name && property.isKind(SyntaxKind.PropertyAssignment)) {
31
+ return property.getInitializerOrThrow();
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+ function numericLiteral(node, path) {
37
+ if (node === undefined)
38
+ throw new StaticConfigError(`${path} is missing`);
39
+ if (!node.isKind(SyntaxKind.NumericLiteral)) {
40
+ throw new StaticConfigError(`${path} must be a number literal so tooling can read it, got "${node.getText()}"`);
41
+ }
42
+ return node.getLiteralValue();
43
+ }
44
+ function objectLiteral(node, path) {
45
+ if (node === undefined)
46
+ throw new StaticConfigError(`${path} is missing`);
47
+ if (!node.isKind(SyntaxKind.ObjectLiteralExpression)) {
48
+ throw new StaticConfigError(`${path} must be an object literal, got "${node.getText()}"`);
49
+ }
50
+ return node;
51
+ }
52
+ function readInfraPorts(node) {
53
+ if (node === undefined)
54
+ return undefined;
55
+ const object = objectLiteral(node, "ports.infra");
56
+ const infra = {};
57
+ for (const property of object.getProperties()) {
58
+ const name = propertyName(property);
59
+ if (name === undefined || !isInfraKey(name)) {
60
+ throw new StaticConfigError(`ports.infra: unknown entry "${property.getText()}", expected ${INFRA_KEYS.join(", ")}`);
61
+ }
62
+ infra[name] = numericLiteral(objectProperty(object, name), `ports.infra.${name}`);
63
+ }
64
+ return infra;
65
+ }
66
+ export function parseStaticAppConfig(source) {
67
+ const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
68
+ const file = project.createSourceFile(APP_CONFIG_FILE, source);
69
+ const call = file
70
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
71
+ .find((candidate) => candidate.getExpression().getText() === "defineAppConfig");
72
+ if (call === undefined)
73
+ return { kind: "legacy" };
74
+ try {
75
+ const config = objectLiteral(call.getArguments()[0], "defineAppConfig argument");
76
+ const ports = objectLiteral(objectProperty(config, "ports"), "ports");
77
+ const infra = readInfraPorts(objectProperty(ports, "infra"));
78
+ const parsed = {
79
+ dev: numericLiteral(objectProperty(ports, "dev"), "ports.dev"),
80
+ e2e: numericLiteral(objectProperty(ports, "e2e"), "ports.e2e"),
81
+ ...(infra === undefined ? {} : { infra }),
82
+ };
83
+ const problems = findAppPortProblems(parsed);
84
+ if (problems.length > 0)
85
+ return { kind: "invalid", message: problems.join("; ") };
86
+ return { kind: "defined", ports: parsed };
87
+ }
88
+ catch (error) {
89
+ if (error instanceof StaticConfigError)
90
+ return { kind: "invalid", message: error.message };
91
+ throw error;
92
+ }
93
+ }
94
+ export function readStaticAppConfig(appRoot) {
95
+ const path = join(appRoot, APP_CONFIG_FILE);
96
+ if (!existsSync(path))
97
+ return { kind: "missing" };
98
+ return parseStaticAppConfig(readFileSync(path, "utf-8"));
99
+ }
@@ -0,0 +1,19 @@
1
+ import { type ManagedAppFacts } from "./registry.ts";
2
+ export type SyncManagedOptions = {
3
+ /** Without it nothing is written; the result lists what would happen. */
4
+ readonly write: boolean;
5
+ /** Also take over existing files without a marker (first adoption). */
6
+ readonly adopt: boolean;
7
+ };
8
+ export type SyncAction = {
9
+ readonly action: "create" | "update" | "adopt";
10
+ readonly path: string;
11
+ } | {
12
+ readonly action: "unchanged";
13
+ readonly path: string;
14
+ } | {
15
+ readonly action: "skip";
16
+ readonly path: string;
17
+ readonly skippedBecause: string;
18
+ };
19
+ export declare function syncManagedFiles(facts: ManagedAppFacts, options: SyncManagedOptions): readonly SyncAction[];
@@ -0,0 +1,101 @@
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative, sep } from "node:path";
3
+ import { mergeBunfig } from "@cosmicdrift/kumiko-testing/bunfig";
4
+ import { readManagedMarker } from "./marker.js";
5
+ import { listManagedFiles, renderBiomePresetJson, renderManagedBunfig, renderTsconfigPresetJson, } from "./registry.js";
6
+ // A symlinked .github or deploy/ would let the sync write outside the app.
7
+ function symlinkOnPath(appRoot, path) {
8
+ const segments = relative(appRoot, path).split(sep);
9
+ let current = appRoot;
10
+ for (const segment of segments) {
11
+ current = join(current, segment);
12
+ if (!existsSync(current) && !isBrokenSymlink(current))
13
+ return undefined;
14
+ if (lstatSync(current).isSymbolicLink())
15
+ return relative(appRoot, current);
16
+ }
17
+ return undefined;
18
+ }
19
+ function isBrokenSymlink(path) {
20
+ try {
21
+ return lstatSync(path).isSymbolicLink();
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ function plannedContent(file, existing) {
28
+ switch (file.kind) {
29
+ case "template":
30
+ return { content: file.content, executable: file.executable };
31
+ case "bunfig": {
32
+ const generated = renderManagedBunfig(file, existing);
33
+ if (existing === undefined)
34
+ return { content: generated, executable: false };
35
+ const merged = mergeBunfig(generated, existing);
36
+ if (!merged.ok) {
37
+ const keys = merged.unknownKeys.map(({ section, key }) => `[${section}] ${key}`).join(", ");
38
+ return `keys the generator does not own: ${keys}`;
39
+ }
40
+ return { content: merged.content, executable: false };
41
+ }
42
+ case "structural":
43
+ if (existing !== undefined)
44
+ return "exists; extend the preset by hand, app overrides stay";
45
+ if (file.path === "biome.json")
46
+ return { content: renderBiomePresetJson(), executable: false };
47
+ if (file.path === "tsconfig.json")
48
+ return { content: renderTsconfigPresetJson(), executable: false };
49
+ return "write kumiko.config.ts with defineAppConfig by hand (see docs/reference/app-skeleton.md)";
50
+ case "seed":
51
+ return existing === undefined
52
+ ? "app-owned seed; copy it from a fresh `kumiko new app`"
53
+ : "app-owned";
54
+ }
55
+ }
56
+ function planFile(file, facts, options) {
57
+ const path = join(facts.appRoot, file.path);
58
+ const symlink = symlinkOnPath(facts.appRoot, path);
59
+ if (symlink !== undefined)
60
+ return { action: "skip", path: file.path, skippedBecause: `${symlink} is a symlink` };
61
+ const existing = existsSync(path) ? readFileSync(path, "utf-8") : undefined;
62
+ if (existing !== undefined && file.kind !== "structural" && file.kind !== "seed") {
63
+ const marker = readManagedMarker(existing);
64
+ if (marker.kind === "local")
65
+ return { action: "skip", path: file.path, skippedBecause: `local: ${marker.reason}` };
66
+ if (marker.kind === "invalid")
67
+ return { action: "skip", path: file.path, skippedBecause: marker.message };
68
+ if (marker.kind === "unmarked" && !options.adopt) {
69
+ return {
70
+ action: "skip",
71
+ path: file.path,
72
+ skippedBecause: "no kumiko-managed marker; pass --adopt to take it over",
73
+ };
74
+ }
75
+ }
76
+ const planned = plannedContent(file, existing);
77
+ if (typeof planned === "string")
78
+ return { action: "skip", path: file.path, skippedBecause: planned };
79
+ const { content } = planned;
80
+ if (existing === content)
81
+ return { action: "unchanged", path: file.path };
82
+ const action = existing === undefined
83
+ ? "create"
84
+ : readManagedMarker(existing).kind === "unmarked"
85
+ ? "adopt"
86
+ : "update";
87
+ return { action, path: file.path, write: { content, executable: planned.executable } };
88
+ }
89
+ export function syncManagedFiles(facts, options) {
90
+ return listManagedFiles(facts).map((file) => {
91
+ const { write, ...action } = planFile(file, facts, options);
92
+ if (options.write && write !== undefined) {
93
+ const path = join(facts.appRoot, file.path);
94
+ mkdirSync(dirname(path), { recursive: true });
95
+ writeFileSync(path, write.content);
96
+ if (write.executable)
97
+ chmodSync(path, 0o755);
98
+ }
99
+ return action;
100
+ });
101
+ }
@@ -6,7 +6,7 @@ import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import type { EnvelopeCipher, MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
7
7
  import type { PageHeadResolver } from "@cosmicdrift/kumiko-headless/apex";
8
8
  import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";
9
- import { type CreateKumikoServerOptions, type KumikoServerHandle } from "./create-kumiko-server.js";
9
+ import { type CreateKumikoServerOptions, type KumikoServerHandle } from "./create-kumiko-server.ts";
10
10
  export type { AccountLockoutSetup, AccountUnlockSetup, AuthMailOptions, EmailVerificationSetup, InviteSetup, PasswordResetSetup, SignupSetup, } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
11
11
  import type { AccountLockoutSetup, AccountUnlockSetup, AuthMailOptions, EmailVerificationSetup, InviteSetup, PasswordResetSetup, SignupSetup } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
12
12
  import { type UnsubscribeOption } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";