@flytedesk/app-kit 0.1.3 → 0.3.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 (37) hide show
  1. package/dist/cli/flags-sync.d.ts +2 -0
  2. package/dist/cli/flags-sync.js +26 -0
  3. package/dist/cli/flags-sync.js.map +1 -0
  4. package/dist/cli/profile-sync.d.ts +2 -0
  5. package/dist/cli/profile-sync.js +26 -0
  6. package/dist/cli/profile-sync.js.map +1 -0
  7. package/dist/cli/sync-engine.d.ts +20 -0
  8. package/dist/cli/sync-engine.js +543 -0
  9. package/dist/cli/sync-engine.js.map +1 -0
  10. package/dist/cli/trace-sync.js +9 -359
  11. package/dist/cli/trace-sync.js.map +1 -1
  12. package/dist/flags/index.d.ts +49 -0
  13. package/dist/flags/index.js +49 -0
  14. package/dist/flags/index.js.map +1 -0
  15. package/dist/flags/plugin.d.ts +3 -0
  16. package/dist/flags/plugin.js +296 -0
  17. package/dist/flags/plugin.js.map +1 -0
  18. package/dist/flags/types.d.ts +159 -0
  19. package/dist/flags/types.js +11 -0
  20. package/dist/flags/types.js.map +1 -0
  21. package/dist/profile/index.d.ts +36 -0
  22. package/dist/profile/index.js +36 -0
  23. package/dist/profile/index.js.map +1 -0
  24. package/dist/profile/plugin.d.ts +3 -0
  25. package/dist/profile/plugin.js +111 -0
  26. package/dist/profile/plugin.js.map +1 -0
  27. package/dist/profile/types.d.ts +76 -0
  28. package/dist/profile/types.js +10 -0
  29. package/dist/profile/types.js.map +1 -0
  30. package/package.json +12 -2
  31. package/prisma/fragments/flags.meta.json +4 -0
  32. package/prisma/fragments/flags.prisma +52 -0
  33. package/prisma/fragments/profile.meta.json +4 -0
  34. package/prisma/fragments/profile.prisma +42 -0
  35. package/prisma/migrations/0002_app_kit_profile_init/migration.sql +15 -0
  36. package/prisma/migrations/0003_app_kit_flags_init/migration.sql +16 -0
  37. package/prisma/migrations/manifest.json +15 -0
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(argv: string[]): Promise<void>;
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * flags-sync — the CLI half of @flytedesk/app-kit/flags (DEC-39).
4
+ *
5
+ * A thin wrapper around the generalized sync engine in ./sync-engine.js — see that
6
+ * file's doc comment for the full subcommand reference (init/diff/apply/status) and
7
+ * how fragments/migrations/lock files work. This file only supplies the "flags"
8
+ * fragment name and the "flags-sync" bin name; trace-sync.ts/profile-sync.ts are the
9
+ * same wrapper for the trace/profile fragments.
10
+ */
11
+ import { pathToFileURL } from "node:url";
12
+ import { runSyncCli, SyncCliError } from "./sync-engine.js";
13
+ export async function main(argv) {
14
+ return runSyncCli(argv, { name: "flags", binName: "flags-sync" });
15
+ }
16
+ const isMainModule = process.argv[1] !== undefined &&
17
+ import.meta.url === pathToFileURL(process.argv[1]).href;
18
+ if (isMainModule) {
19
+ main(process.argv.slice(2)).catch((err) => {
20
+ if (!(err instanceof SyncCliError)) {
21
+ console.error(err);
22
+ }
23
+ process.exitCode = process.exitCode ?? 1;
24
+ });
25
+ }
26
+ //# sourceMappingURL=flags-sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flags-sync.js","sourceRoot":"","sources":["../../src/cli/flags-sync.ts"],"names":[],"mappings":";AACA;;;;;;;;GAQG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE5D,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,IAAc;IACvC,OAAO,UAAU,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,CAAC,CAAC;AACpE,CAAC;AAED,MAAM,YAAY,GAChB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,SAAS;IAC7B,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAE1D,IAAI,YAAY,EAAE,CAAC;IACjB,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QACjD,IAAI,CAAC,CAAC,GAAG,YAAY,YAAY,CAAC,EAAE,CAAC;YACnC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACrB,CAAC;QACD,OAAO,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(argv: string[]): Promise<void>;
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * profile-sync — the CLI half of @flytedesk/app-kit/profile (DEC-39, DEC-52).
4
+ *
5
+ * A thin wrapper around the generalized sync engine in ./sync-engine.js — see that
6
+ * file's doc comment for the full subcommand reference (init/diff/apply/status) and
7
+ * how fragments/migrations/lock files work. This file only supplies the "profile"
8
+ * fragment name and the "profile-sync" bin name; trace-sync.ts is the same wrapper
9
+ * for the trace fragment.
10
+ */
11
+ import { pathToFileURL } from "node:url";
12
+ import { runSyncCli, SyncCliError } from "./sync-engine.js";
13
+ export async function main(argv) {
14
+ return runSyncCli(argv, { name: "profile", binName: "profile-sync" });
15
+ }
16
+ const isMainModule = process.argv[1] !== undefined &&
17
+ import.meta.url === pathToFileURL(process.argv[1]).href;
18
+ if (isMainModule) {
19
+ main(process.argv.slice(2)).catch((err) => {
20
+ if (!(err instanceof SyncCliError)) {
21
+ console.error(err);
22
+ }
23
+ process.exitCode = process.exitCode ?? 1;
24
+ });
25
+ }
26
+ //# sourceMappingURL=profile-sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"profile-sync.js","sourceRoot":"","sources":["../../src/cli/profile-sync.ts"],"names":[],"mappings":";AACA;;;;;;;;GAQG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE5D,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,IAAc;IACvC,OAAO,UAAU,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,CAAC,CAAC;AACxE,CAAC;AAED,MAAM,YAAY,GAChB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,SAAS;IAC7B,MAAM,CAAC,IAAI,CAAC,GAAG,KAAK,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAE1D,IAAI,YAAY,EAAE,CAAC;IACjB,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;QACjD,IAAI,CAAC,CAAC,GAAG,YAAY,YAAY,CAAC,EAAE,CAAC;YACnC,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACrB,CAAC;QACD,OAAO,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,CAAC,CAAC;IAC3C,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Everything that differs between `trace-sync` and `profile-sync`. `name` picks out
3
+ * prisma/fragments/<name>.prisma, prisma/fragments/<name>.meta.json, and every
4
+ * manifest entry with `fragment: name`; `binName` is only used for log/error message
5
+ * prefixes and the CLI's own usage string.
6
+ */
7
+ export interface SyncFragment {
8
+ /** e.g. "trace" or "profile" — must match the fragment's file basenames and its
9
+ * manifest entries' `fragment` field. */
10
+ name: string;
11
+ /** e.g. "trace-sync" or "profile-sync" — the bin name this is invoked as. */
12
+ binName: string;
13
+ }
14
+ export declare class SyncCliError extends Error {
15
+ }
16
+ /**
17
+ * Runs one CLI invocation for the given fragment. Called by each bin wrapper
18
+ * (src/cli/trace-sync.ts, src/cli/profile-sync.ts) with `process.argv.slice(2)`.
19
+ */
20
+ export declare function runSyncCli(argv: string[], fragment: SyncFragment): Promise<void>;
@@ -0,0 +1,543 @@
1
+ /**
2
+ * sync-engine — the shared internals behind `trace-sync` and `profile-sync`
3
+ * (DEC-39, generalized to a second fragment for the profile module).
4
+ *
5
+ * This package never generates its own Prisma Client for any of its fragments.
6
+ * Instead, this engine installs a package fragment (prisma/fragments/<fragment>.prisma)
7
+ * into the *consumer's* own Prisma schema, applies that fragment's raw SQL migrations
8
+ * directly against the consumer's configured datasource, and tracks what's been
9
+ * applied in a lock file (`.app-kit-<fragment>.lock.json`) written at the consumer
10
+ * project root. The consumer's own `prisma generate` then produces one client that
11
+ * includes these models alongside their own.
12
+ *
13
+ * A consumer's `prisma.config.ts` `schema` setting points at one of two real layouts
14
+ * (see classifySchemaLayout below), and this engine handles both:
15
+ * - A multi-file schema FOLDER (`schema: "prisma/schema"`, no `.prisma` suffix):
16
+ * the fragment is copied as a sibling file, `prisma/schema/<fragment>.prisma`.
17
+ * - A flat single-file schema.prisma (`schema: "prisma/schema.prisma"`), which is
18
+ * what both real consumers (flytedesk-id, media-planner) actually use: the
19
+ * fragment's model(s) are merged directly into that one file, inside a
20
+ * delimited, app-kit-owned marker region (see mergeIntoFlatSchema) rather than
21
+ * copied as a sibling file — a sibling file next to a flat schema.prisma is never
22
+ * read by Prisma, since `schema` names that one file, not a folder. An earlier
23
+ * version of this engine always did the sibling-file copy regardless of layout,
24
+ * which is why the CLI had never successfully run against a real consumer
25
+ * (flytedesk-id's /profile integration had to be pasted in by hand).
26
+ *
27
+ * Both bin commands are thin wrappers around `runSyncCli` (see src/cli/trace-sync.ts
28
+ * and src/cli/profile-sync.ts) — everything fragment-specific is passed in as a
29
+ * `SyncFragment` rather than hardcoded, so a third fragment only ever needs its own
30
+ * ~15-line wrapper file, not a second copy of this logic.
31
+ *
32
+ * Migrations for every fragment live together under prisma/migrations/ and are
33
+ * tracked in one shared prisma/migrations/manifest.json, disambiguated by each
34
+ * entry's `fragment` field — `computePending`/`cmdInit`/`cmdApply` below all filter
35
+ * the manifest down to `entry.fragment === fragment.name` before doing anything, so
36
+ * e.g. `trace-sync init` only ever touches trace's own migrations, never profile's.
37
+ *
38
+ * Subcommands (identical across every fragment):
39
+ * <bin> init copy fragment + migrations in, run `prisma validate` and
40
+ * `prisma db execute`, write the lock file.
41
+ * <bin> diff read-only: compare this package's bundled fragment version /
42
+ * migration manifest against the consumer's lock file and report
43
+ * what's pending. Never applies anything.
44
+ * <bin> apply apply pending migrations + refresh the fragment copy, update the
45
+ * lock file. Does not re-run `prisma validate` against a fresh
46
+ * init — use `init` for first install.
47
+ * <bin> status read-only report: fragment version, applied migrations, and
48
+ * whether the consumer's copy of the fragment has been hand-edited
49
+ * (checksum mismatch).
50
+ *
51
+ * This is a real, runnable CLI — it shells out to the consumer's local `prisma` via
52
+ * `npx` and touches the consumer's real filesystem — not a stub.
53
+ */
54
+ import { createHash } from "node:crypto";
55
+ import spawn from "cross-spawn";
56
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
57
+ import path from "node:path";
58
+ import { fileURLToPath, pathToFileURL } from "node:url";
59
+ export class SyncCliError extends Error {
60
+ }
61
+ function readJson(filePath) {
62
+ return JSON.parse(readFileSync(filePath, "utf8"));
63
+ }
64
+ function sha256(content) {
65
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
66
+ }
67
+ function sha256File(filePath) {
68
+ return sha256(readFileSync(filePath));
69
+ }
70
+ function escapeRegExp(value) {
71
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Engine: bound to one SyncFragment + the consumer root it's operating on
75
+ // ---------------------------------------------------------------------------
76
+ class SyncEngine {
77
+ fragment;
78
+ packageRoot;
79
+ fragmentSrcPath;
80
+ fragmentMetaPath;
81
+ migrationsSrcDir;
82
+ manifestSrcPath;
83
+ lockFileName;
84
+ constructor(fragment) {
85
+ this.fragment = fragment;
86
+ // Root of the installed @flytedesk/app-kit package (two levels up from
87
+ // dist/cli/<bin>.js — this file, once built).
88
+ this.packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
89
+ this.fragmentSrcPath = path.join(this.packageRoot, "prisma", "fragments", `${fragment.name}.prisma`);
90
+ this.fragmentMetaPath = path.join(this.packageRoot, "prisma", "fragments", `${fragment.name}.meta.json`);
91
+ this.migrationsSrcDir = path.join(this.packageRoot, "prisma", "migrations");
92
+ this.manifestSrcPath = path.join(this.migrationsSrcDir, "manifest.json");
93
+ this.lockFileName = `.app-kit-${fragment.name}.lock.json`;
94
+ }
95
+ fail(message) {
96
+ console.error(`${this.fragment.binName}: ${message}`);
97
+ process.exitCode = 1;
98
+ throw new SyncCliError(message);
99
+ }
100
+ readFragmentMeta() {
101
+ return readJson(this.fragmentMetaPath);
102
+ }
103
+ /** Every manifest entry for THIS fragment only — see the module doc comment on
104
+ * why the manifest is shared across fragments and how entries are disambiguated. */
105
+ readManifest() {
106
+ return readJson(this.manifestSrcPath).filter((entry) => entry.fragment === this.fragment.name);
107
+ }
108
+ lockFilePath(consumerRoot) {
109
+ return path.join(consumerRoot, this.lockFileName);
110
+ }
111
+ readLockFile(consumerRoot) {
112
+ const p = this.lockFilePath(consumerRoot);
113
+ if (!existsSync(p))
114
+ return null;
115
+ return readJson(p);
116
+ }
117
+ writeLockFile(consumerRoot, lock) {
118
+ writeFileSync(this.lockFilePath(consumerRoot), `${JSON.stringify(lock, null, 2)}\n`, "utf8");
119
+ }
120
+ // -------------------------------------------------------------------------
121
+ // Locating the consumer's Prisma schema folder via their prisma.config.ts
122
+ // -------------------------------------------------------------------------
123
+ static CONSUMER_CONFIG_CANDIDATES = [
124
+ "prisma.config.ts",
125
+ "prisma.config.mts",
126
+ "prisma.config.js",
127
+ "prisma.config.mjs",
128
+ ];
129
+ findConsumerConfigFile(consumerRoot) {
130
+ for (const candidate of SyncEngine.CONSUMER_CONFIG_CANDIDATES) {
131
+ const p = path.join(consumerRoot, candidate);
132
+ if (existsSync(p))
133
+ return p;
134
+ }
135
+ this.fail(`could not find a prisma.config.{ts,mts,js,mjs} in ${consumerRoot}. ` +
136
+ `${this.fragment.binName} reads this file to find your Prisma schema folder — it does not assume a path.`);
137
+ }
138
+ /**
139
+ * Best-effort read of the consumer's `schema` setting out of their
140
+ * prisma.config.ts default export. Supports the shapes Prisma's config API has
141
+ * used: a bare string path (file or folder), or `{ path: string }`. Relies on
142
+ * Node's native TypeScript type-stripping (unflagged as of the Node 24 baseline
143
+ * this package requires) to import a .ts file directly — if the consumer's config
144
+ * uses syntax that isn't erasable (enums, namespaces, parameter properties, etc.)
145
+ * this import will fail, and that failure is surfaced as-is rather than swallowed.
146
+ */
147
+ async loadConsumerSchemaPath(consumerRoot) {
148
+ const configFile = this.findConsumerConfigFile(consumerRoot);
149
+ let mod;
150
+ try {
151
+ mod = await import(pathToFileURL(configFile).href);
152
+ }
153
+ catch (err) {
154
+ this.fail(`failed to load ${configFile}: ${err.message}\n` +
155
+ `If this config file uses TypeScript syntax that Node can't type-strip natively ` +
156
+ `(enums, namespaces, decorators, ...), ${this.fragment.binName} can't import it directly.`);
157
+ }
158
+ const config = mod.default ?? mod;
159
+ const schema = config?.schema;
160
+ let schemaPath;
161
+ if (typeof schema === "string") {
162
+ schemaPath = schema;
163
+ }
164
+ else if (schema &&
165
+ typeof schema === "object" &&
166
+ typeof schema.path === "string") {
167
+ schemaPath = schema.path;
168
+ }
169
+ if (!schemaPath) {
170
+ this.fail(`could not find a "schema" path in ${configFile}'s default export. ` +
171
+ `Expected \`defineConfig({ schema: "prisma/schema" })\` (or a { path } object).`);
172
+ }
173
+ return path.resolve(path.dirname(configFile), schemaPath);
174
+ }
175
+ /**
176
+ * Classifies the consumer's schema layout from their prisma.config.ts `schema`
177
+ * path: a path ending in `.prisma` names one specific file — Prisma reads only
178
+ * that file, so this is the flat single-file layout, and the fragment must be
179
+ * merged into it directly (mergeIntoFlatSchema). Anything else names a folder —
180
+ * the multi-file layout — and the fragment is copied in as a sibling file
181
+ * (copyFragment), same as before.
182
+ */
183
+ classifySchemaLayout(schemaPath) {
184
+ if (schemaPath.endsWith(".prisma")) {
185
+ return {
186
+ kind: "flat-file",
187
+ file: schemaPath,
188
+ prismaRoot: path.dirname(schemaPath),
189
+ };
190
+ }
191
+ return {
192
+ kind: "folder",
193
+ folder: schemaPath,
194
+ prismaRoot: this.resolvePrismaRoot(schemaPath),
195
+ };
196
+ }
197
+ /** The conventional "prisma root" a schema folder sits under, used to locate (or
198
+ * create) the sibling `migrations/` directory. For the common multi-file layout
199
+ * `prisma/schema/*.prisma`, that's `prisma/`. */
200
+ resolvePrismaRoot(schemaFolder) {
201
+ return path.basename(schemaFolder) === "schema"
202
+ ? path.dirname(schemaFolder)
203
+ : schemaFolder;
204
+ }
205
+ // -------------------------------------------------------------------------
206
+ // Shelling out to the consumer's local `prisma`
207
+ // -------------------------------------------------------------------------
208
+ runPrisma(args, cwd) {
209
+ // On Windows, npx resolves to npx.cmd, a shell script — node:child_process's
210
+ // spawnSync can't exec it directly (EINVAL) without shell:true, and shell:true
211
+ // with an argument array is an unescaped-concatenation footgun (path arguments
212
+ // here can contain spaces). cross-spawn resolves and correctly quotes .cmd/.bat
213
+ // executables on Windows without going through a shell at all.
214
+ const result = spawn.sync("npx", ["prisma", ...args], {
215
+ cwd,
216
+ stdio: "inherit",
217
+ });
218
+ if (result.error) {
219
+ this.fail(`failed to run "npx prisma ${args.join(" ")}": ${result.error.message}`);
220
+ }
221
+ if (result.status !== 0) {
222
+ this.fail(`"npx prisma ${args.join(" ")}" exited with code ${result.status}`);
223
+ }
224
+ }
225
+ // -------------------------------------------------------------------------
226
+ // File copying
227
+ // -------------------------------------------------------------------------
228
+ copyFragment(schemaFolder) {
229
+ mkdirSync(schemaFolder, { recursive: true });
230
+ copyFileSync(this.fragmentSrcPath, path.join(schemaFolder, `${this.fragment.name}.prisma`));
231
+ }
232
+ copyMigration(migrationId, migrationsDestDir) {
233
+ const srcDir = path.join(this.migrationsSrcDir, migrationId);
234
+ const destDir = path.join(migrationsDestDir, migrationId);
235
+ mkdirSync(destDir, { recursive: true });
236
+ cpSync(srcDir, destDir, { recursive: true });
237
+ }
238
+ // -------------------------------------------------------------------------
239
+ // Flat single-file schema.prisma support
240
+ // -------------------------------------------------------------------------
241
+ /**
242
+ * The portion of a fragment's .prisma source that gets merged into a consumer's
243
+ * flat schema.prisma — everything from the first `model` declaration onward,
244
+ * trimmed. Deliberately drops the fragment file's own leading design-rationale
245
+ * doc comment (see prisma/fragments/profile.prisma / trace.prisma's file
246
+ * headers): that comment describes the *fragment file itself* ("this file is
247
+ * copied by <bin> init...", "matching the raw SQL in prisma/migrations/...")
248
+ * which reads oddly once inlined into someone else's schema.prisma, whereas the
249
+ * marker region's own header (markerStart) already identifies the block as
250
+ * app-kit-owned and names which command manages it.
251
+ */
252
+ extractFragmentBody(fragmentSource) {
253
+ const lines = fragmentSource.split(/\r?\n/);
254
+ const firstModelLine = lines.findIndex((line) => /^model\s+\w+\s*\{/.test(line));
255
+ const body = firstModelLine === -1
256
+ ? fragmentSource
257
+ : lines.slice(firstModelLine).join("\n");
258
+ return body.trim();
259
+ }
260
+ markerStart() {
261
+ return `// --- @flytedesk/app-kit:${this.fragment.name} start (managed by ${this.fragment.binName} — do not hand-edit this region) ---`;
262
+ }
263
+ markerEnd() {
264
+ return `// --- @flytedesk/app-kit:${this.fragment.name} end ---`;
265
+ }
266
+ /**
267
+ * Extracts the current content of THIS fragment's own marker region from a flat
268
+ * schema.prisma's full text, or null if the region isn't present yet. Matching on
269
+ * this fragment's own name means two different fragments merged into the same
270
+ * flat file never see or touch each other's regions.
271
+ *
272
+ * Exact inverse of the `${start}\n${body}\n${end}` block mergeIntoFlatSchema
273
+ * writes, so a round trip (write, then extract) returns the same body
274
+ * byte-for-byte — this is what makes both mergeIntoFlatSchema's own
275
+ * no-op-if-unchanged check and cmdStatus's checksum comparison correct.
276
+ */
277
+ extractInstalledRegion(schemaContent) {
278
+ const re = new RegExp(`${escapeRegExp(this.markerStart())}\\n([\\s\\S]*?)\\n${escapeRegExp(this.markerEnd())}`);
279
+ const match = schemaContent.match(re);
280
+ return match ? match[1] : null;
281
+ }
282
+ /**
283
+ * Merges this fragment's model(s) into a consumer's flat single-file
284
+ * prisma/schema.prisma, inside a delimited, app-kit-owned marker region — the
285
+ * flat-file counterpart to copyFragment's sibling-file copy for the multi-file
286
+ * folder layout.
287
+ *
288
+ * Idempotent: running this twice with the same fragment content is a no-op (the
289
+ * installed region already matches, so the file isn't rewritten at all). Multiple
290
+ * fragments merged into the same file coexist as separate regions — this only
291
+ * ever reads or replaces text between ITS OWN start/end marker pair; everything
292
+ * else in the file (the consumer's own models, datasource/generator blocks,
293
+ * `schemas = [...]`, other fragments' regions) is left byte-for-byte untouched.
294
+ */
295
+ mergeIntoFlatSchema(schemaFilePath) {
296
+ if (!existsSync(schemaFilePath)) {
297
+ this.fail(`schema file not found at ${schemaFilePath} (expected a flat single-file prisma/schema.prisma)`);
298
+ }
299
+ const original = readFileSync(schemaFilePath, "utf8");
300
+ const body = this.extractFragmentBody(readFileSync(this.fragmentSrcPath, "utf8"));
301
+ const existingRegion = this.extractInstalledRegion(original);
302
+ if (existingRegion === body) {
303
+ return;
304
+ }
305
+ let updated;
306
+ if (existingRegion !== null) {
307
+ const re = new RegExp(`${escapeRegExp(this.markerStart())}\\n[\\s\\S]*?\\n${escapeRegExp(this.markerEnd())}`);
308
+ updated = original.replace(re, `${this.markerStart()}\n${body}\n${this.markerEnd()}`);
309
+ }
310
+ else {
311
+ updated = `${original.replace(/\s*$/, "")}\n\n${this.markerStart()}\n${body}\n${this.markerEnd()}\n`;
312
+ }
313
+ writeFileSync(schemaFilePath, updated, "utf8");
314
+ }
315
+ /** Installs the fragment for either layout — the one call site cmdInit/cmdApply
316
+ * both use, so they can never disagree about which path a given layout takes. */
317
+ installFragment(layout) {
318
+ const bin = this.fragment.binName;
319
+ if (layout.kind === "folder") {
320
+ console.log(`${bin}: copying ${this.fragment.name}.prisma into ${layout.folder}`);
321
+ this.copyFragment(layout.folder);
322
+ }
323
+ else {
324
+ console.log(`${bin}: merging ${this.fragment.name} model(s) into ${layout.file} (managed region)`);
325
+ this.mergeIntoFlatSchema(layout.file);
326
+ }
327
+ }
328
+ /**
329
+ * What gets recorded as `fragmentChecksum` in the lock file, and what cmdStatus
330
+ * recomputes from the consumer's tree to detect hand-edits — computed the same
331
+ * way installFragment actually wrote it, per layout: the exact fragment file
332
+ * bytes for the folder/sibling-copy case, or the extracted marker-region body for
333
+ * the flat-file/merge case (NOT the whole fragment source file, since the
334
+ * installed region deliberately excludes the fragment file's leading doc
335
+ * comment — see extractFragmentBody).
336
+ */
337
+ computeFragmentChecksum(layout) {
338
+ if (layout.kind === "folder") {
339
+ return sha256File(this.fragmentSrcPath);
340
+ }
341
+ return sha256(this.extractFragmentBody(readFileSync(this.fragmentSrcPath, "utf8")));
342
+ }
343
+ // -------------------------------------------------------------------------
344
+ // Subcommands
345
+ // -------------------------------------------------------------------------
346
+ async cmdInit(consumerRoot) {
347
+ const bin = this.fragment.binName;
348
+ const schemaPath = await this.loadConsumerSchemaPath(consumerRoot);
349
+ const layout = this.classifySchemaLayout(schemaPath);
350
+ const migrationsDestDir = path.join(layout.prismaRoot, "migrations");
351
+ this.installFragment(layout);
352
+ console.log(`${bin}: running "npx prisma validate" against ${consumerRoot}`);
353
+ this.runPrisma(["validate"], consumerRoot);
354
+ const manifest = this.readManifest();
355
+ const appliedMigrations = [];
356
+ for (const entry of manifest) {
357
+ console.log(`${bin}: copying migration ${entry.id} into ${migrationsDestDir}`);
358
+ this.copyMigration(entry.id, migrationsDestDir);
359
+ const migrationSqlPath = path.join(migrationsDestDir, entry.id, "migration.sql");
360
+ console.log(`${bin}: applying ${entry.id} via "npx prisma db execute"`);
361
+ this.runPrisma(
362
+ // Prisma ORM v7's `db execute` no longer accepts `--schema` at all — the
363
+ // datasource URL comes only from the consumer's prisma.config.ts now (same
364
+ // "config file, not CLI flags" migration documented on flytedesk-id's own
365
+ // prisma.config.ts). Passing --schema here is a hard CLI usage error
366
+ // ("unknown or unexpected option: --schema") against a real Prisma 7 install,
367
+ // confirmed while building examples/hello-world.
368
+ ["db", "execute", "--file", migrationSqlPath], consumerRoot);
369
+ appliedMigrations.push(entry.id);
370
+ }
371
+ const meta = this.readFragmentMeta();
372
+ this.writeLockFile(consumerRoot, {
373
+ fragmentVersion: meta.fragmentVersion,
374
+ fragmentChecksum: this.computeFragmentChecksum(layout),
375
+ appliedMigrations,
376
+ lastSyncedAt: new Date().toISOString(),
377
+ });
378
+ console.log(`${bin}: wrote ${this.lockFilePath(consumerRoot)}`);
379
+ console.log(`${bin}: init complete.`);
380
+ }
381
+ computePending(consumerRoot) {
382
+ const lock = this.readLockFile(consumerRoot);
383
+ if (!lock) {
384
+ this.fail(`no ${this.lockFileName} found at ${consumerRoot}. Run "${this.fragment.binName} init" first.`);
385
+ }
386
+ const meta = this.readFragmentMeta();
387
+ const manifest = this.readManifest();
388
+ const applied = new Set(lock.appliedMigrations);
389
+ const pendingMigrations = manifest.filter((entry) => !applied.has(entry.id));
390
+ return {
391
+ fragmentVersionCurrent: meta.fragmentVersion,
392
+ fragmentVersionInstalled: lock.fragmentVersion,
393
+ fragmentVersionChanged: meta.fragmentVersion !== lock.fragmentVersion,
394
+ pendingMigrations,
395
+ };
396
+ }
397
+ cmdDiff(consumerRoot) {
398
+ const report = this.computePending(consumerRoot);
399
+ if (report.fragmentVersionChanged) {
400
+ console.log(`fragment version: ${report.fragmentVersionInstalled} -> ${report.fragmentVersionCurrent} (changed)`);
401
+ }
402
+ else {
403
+ console.log(`fragment version: ${report.fragmentVersionCurrent} (up to date)`);
404
+ }
405
+ if (report.pendingMigrations.length === 0) {
406
+ console.log("migrations: up to date, nothing pending.");
407
+ return;
408
+ }
409
+ console.log(`migrations: ${report.pendingMigrations.length} pending:`);
410
+ for (const entry of report.pendingMigrations) {
411
+ console.log(` - ${entry.id} (introduced in fragment ${entry.introducedInFragmentVersion})`);
412
+ }
413
+ console.log(`${this.fragment.binName} diff is read-only. Run "${this.fragment.binName} apply" to apply these.`);
414
+ }
415
+ async cmdApply(consumerRoot) {
416
+ const bin = this.fragment.binName;
417
+ const report = this.computePending(consumerRoot);
418
+ const schemaPath = await this.loadConsumerSchemaPath(consumerRoot);
419
+ const layout = this.classifySchemaLayout(schemaPath);
420
+ const migrationsDestDir = path.join(layout.prismaRoot, "migrations");
421
+ this.installFragment(layout);
422
+ const lock = this.readLockFile(consumerRoot);
423
+ if (!lock) {
424
+ this.fail(`no ${this.lockFileName} found at ${consumerRoot}. Run "${bin} init" first.`);
425
+ }
426
+ const appliedMigrations = [...lock.appliedMigrations];
427
+ if (report.pendingMigrations.length === 0) {
428
+ console.log(`${bin}: no pending migrations to apply.`);
429
+ }
430
+ for (const entry of report.pendingMigrations) {
431
+ console.log(`${bin}: copying migration ${entry.id} into ${migrationsDestDir}`);
432
+ this.copyMigration(entry.id, migrationsDestDir);
433
+ const migrationSqlPath = path.join(migrationsDestDir, entry.id, "migration.sql");
434
+ console.log(`${bin}: applying ${entry.id} via "npx prisma db execute"`);
435
+ this.runPrisma(
436
+ // See the matching comment in cmdInit above — Prisma 7's `db execute` has no
437
+ // --schema flag; the datasource comes from the consumer's prisma.config.ts.
438
+ ["db", "execute", "--file", migrationSqlPath], consumerRoot);
439
+ appliedMigrations.push(entry.id);
440
+ }
441
+ const meta = this.readFragmentMeta();
442
+ this.writeLockFile(consumerRoot, {
443
+ fragmentVersion: meta.fragmentVersion,
444
+ fragmentChecksum: this.computeFragmentChecksum(layout),
445
+ appliedMigrations,
446
+ lastSyncedAt: new Date().toISOString(),
447
+ });
448
+ console.log(`${bin}: wrote ${this.lockFilePath(consumerRoot)}`);
449
+ console.log(`${bin}: apply complete.`);
450
+ }
451
+ async cmdStatus(consumerRoot) {
452
+ const lock = this.readLockFile(consumerRoot);
453
+ if (!lock) {
454
+ console.log(`no ${this.lockFileName} found at ${consumerRoot}. Not yet initialized — run "${this.fragment.binName} init".`);
455
+ return;
456
+ }
457
+ console.log(`fragment version (installed): ${lock.fragmentVersion}`);
458
+ console.log(`fragment version (package): ${this.readFragmentMeta().fragmentVersion}`);
459
+ console.log(`last synced: ${lock.lastSyncedAt}`);
460
+ console.log(`applied migrations (${lock.appliedMigrations.length}):`);
461
+ for (const id of lock.appliedMigrations) {
462
+ console.log(` - ${id}`);
463
+ }
464
+ try {
465
+ const schemaPath = await this.loadConsumerSchemaPath(consumerRoot);
466
+ const layout = this.classifySchemaLayout(schemaPath);
467
+ let installedChecksum;
468
+ let location;
469
+ if (layout.kind === "folder") {
470
+ const copiedFragmentPath = path.join(layout.folder, `${this.fragment.name}.prisma`);
471
+ if (!existsSync(copiedFragmentPath)) {
472
+ console.log(`fragment copy: MISSING at ${copiedFragmentPath}`);
473
+ return;
474
+ }
475
+ installedChecksum = sha256File(copiedFragmentPath);
476
+ location = copiedFragmentPath;
477
+ }
478
+ else {
479
+ if (!existsSync(layout.file)) {
480
+ console.log(`fragment copy: MISSING — ${layout.file} does not exist`);
481
+ return;
482
+ }
483
+ const region = this.extractInstalledRegion(readFileSync(layout.file, "utf8"));
484
+ if (region === null) {
485
+ console.log(`fragment copy: MISSING — no managed region found in ${layout.file}`);
486
+ return;
487
+ }
488
+ installedChecksum = sha256(region);
489
+ location = `${layout.file} (managed region)`;
490
+ }
491
+ if (installedChecksum === lock.fragmentChecksum) {
492
+ console.log("fragment copy: unmodified");
493
+ }
494
+ else {
495
+ console.log(`fragment copy: CHECKSUM MISMATCH — ${location} has been hand-edited ` +
496
+ `(or is out of sync with the lock file)`);
497
+ }
498
+ }
499
+ catch (err) {
500
+ console.log(`fragment copy: could not verify (${err.message})`);
501
+ }
502
+ }
503
+ }
504
+ // ---------------------------------------------------------------------------
505
+ // Entry point shared by every fragment's bin wrapper
506
+ // ---------------------------------------------------------------------------
507
+ const SUBCOMMANDS = ["init", "diff", "apply", "status"];
508
+ function isSubcommand(value) {
509
+ return SUBCOMMANDS.includes(value);
510
+ }
511
+ /**
512
+ * Runs one CLI invocation for the given fragment. Called by each bin wrapper
513
+ * (src/cli/trace-sync.ts, src/cli/profile-sync.ts) with `process.argv.slice(2)`.
514
+ */
515
+ export async function runSyncCli(argv, fragment) {
516
+ const [subcommand, ...rest] = argv;
517
+ if (!isSubcommand(subcommand)) {
518
+ console.error(`usage: ${fragment.binName} <${SUBCOMMANDS.join("|")}>`);
519
+ process.exitCode = 1;
520
+ return;
521
+ }
522
+ // Reserved for a future --cwd override; today every sync CLI always operates on
523
+ // the directory it's invoked from, which is how `npx <bin> ...` / an npm script in
524
+ // the consumer's own package.json will run it.
525
+ void rest;
526
+ const consumerRoot = process.cwd();
527
+ const engine = new SyncEngine(fragment);
528
+ switch (subcommand) {
529
+ case "init":
530
+ await engine.cmdInit(consumerRoot);
531
+ break;
532
+ case "diff":
533
+ engine.cmdDiff(consumerRoot);
534
+ break;
535
+ case "apply":
536
+ await engine.cmdApply(consumerRoot);
537
+ break;
538
+ case "status":
539
+ await engine.cmdStatus(consumerRoot);
540
+ break;
541
+ }
542
+ }
543
+ //# sourceMappingURL=sync-engine.js.map