@flytedesk/app-kit 0.2.0 → 0.3.1

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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(argv: string[]): Promise<void>;
@@ -0,0 +1,23 @@
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 { isRunningAsMain, runSyncCli, SyncCliError } from "./sync-engine.js";
12
+ export async function main(argv) {
13
+ return runSyncCli(argv, { name: "flags", binName: "flags-sync" });
14
+ }
15
+ if (isRunningAsMain(import.meta.url)) {
16
+ main(process.argv.slice(2)).catch((err) => {
17
+ if (!(err instanceof SyncCliError)) {
18
+ console.error(err);
19
+ }
20
+ process.exitCode = process.exitCode ?? 1;
21
+ });
22
+ }
23
+ //# 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,eAAe,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE7E,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,IAAI,eAAe,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IACrC,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"}
@@ -8,14 +8,11 @@
8
8
  * fragment name and the "profile-sync" bin name; trace-sync.ts is the same wrapper
9
9
  * for the trace fragment.
10
10
  */
11
- import { pathToFileURL } from "node:url";
12
- import { runSyncCli, SyncCliError } from "./sync-engine.js";
11
+ import { isRunningAsMain, runSyncCli, SyncCliError } from "./sync-engine.js";
13
12
  export async function main(argv) {
14
13
  return runSyncCli(argv, { name: "profile", binName: "profile-sync" });
15
14
  }
16
- const isMainModule = process.argv[1] !== undefined &&
17
- import.meta.url === pathToFileURL(process.argv[1]).href;
18
- if (isMainModule) {
15
+ if (isRunningAsMain(import.meta.url)) {
19
16
  main(process.argv.slice(2)).catch((err) => {
20
17
  if (!(err instanceof SyncCliError)) {
21
18
  console.error(err);
@@ -1 +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"}
1
+ {"version":3,"file":"profile-sync.js","sourceRoot":"","sources":["../../src/cli/profile-sync.ts"],"names":[],"mappings":";AACA;;;;;;;;GAQG;AACH,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAE7E,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,IAAI,eAAe,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;IACrC,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"}
@@ -1,3 +1,35 @@
1
+ /**
2
+ * Whether the module at `moduleUrl` (pass `import.meta.url`) is the one Node was
3
+ * invoked to run directly — the ESM successor to `require.main === module`, shared
4
+ * by every bin wrapper (trace-sync.ts/profile-sync.ts/flags-sync.ts) so there's one
5
+ * fix point instead of three copies of the same check.
6
+ *
7
+ * A raw `import.meta.url === pathToFileURL(process.argv[1]).href` string comparison
8
+ * (the previous check) is NOT reliable: a package manager's bin shim can leave
9
+ * `argv[1]` pointing at a *symlink* while Node resolves the ESM module id through
10
+ * that symlink to the real target path for `import.meta.url` — so the two name the
11
+ * same file but compare unequal. This is not hypothetical: pnpm's
12
+ * `node_modules/<pkg>` is exactly such a symlink into its content-addressable
13
+ * store, and it reproduces on Windows for every real invocation path (`pnpm exec
14
+ * flags-sync init`, an npm-script `"flags-sync init"` run via `pnpm run`, etc.) —
15
+ * confirmed by instrumenting a real 0.3.0 install: `argv[1]` resolved to
16
+ * `...\node_modules\@flytedesk\app-kit\dist\cli\flags-sync.js` (the symlinked
17
+ * path) while `import.meta.url` resolved to
18
+ * `...\node_modules\.pnpm\@flytedesk+app-kit@0.3.0_.../node_modules\@flytedesk\app-kit\dist\cli\flags-sync.js`
19
+ * (the real store path). The mismatch meant `main()` was never called at all —
20
+ * every CLI entrypoint silently no-op'd: exit code 0, no output, nothing written.
21
+ *
22
+ * `realpathSync.native` (the real OS `realpath` syscall, not Node's pure-JS
23
+ * fallback — verified directly: the JS fallback leaves an on-disk casing mismatch
24
+ * unresolved, e.g. `RealFile.js` vs `realfile.js` compare unequal even though NTFS
25
+ * treats them as the same file, while `.native` correctly normalizes both to the
26
+ * same canonical path) resolves both sides to the same canonical filesystem path
27
+ * first — following symlinks AND normalizing casing — so the comparison is correct
28
+ * regardless of symlinks, path-separator style, or casing differences between how
29
+ * a shell/bin-shim sets `argv[1]` and how Node constructs `import.meta.url` for
30
+ * the same file.
31
+ */
32
+ export declare function isRunningAsMain(moduleUrl: string): boolean;
1
33
  /**
2
34
  * Everything that differs between `trace-sync` and `profile-sync`. `name` picks out
3
35
  * prisma/fragments/<name>.prisma, prisma/fragments/<name>.meta.json, and every
@@ -3,12 +3,26 @@
3
3
  * (DEC-39, generalized to a second fragment for the profile module).
4
4
  *
5
5
  * This package never generates its own Prisma Client for any of its fragments.
6
- * Instead, this engine copies a package fragment (prisma/fragments/<fragment>.prisma)
7
- * into the *consumer's* own multi-file Prisma schema folder, applies that fragment's
8
- * raw SQL migrations directly against the consumer's configured datasource, and
9
- * tracks what's been applied in a lock file (`.app-kit-<fragment>.lock.json`) written
10
- * at the consumer project root. The consumer's own `prisma generate` then produces
11
- * one client that includes these models alongside their own.
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).
12
26
  *
13
27
  * Both bin commands are thin wrappers around `runSyncCli` (see src/cli/trace-sync.ts
14
28
  * and src/cli/profile-sync.ts) — everything fragment-specific is passed in as a
@@ -39,9 +53,53 @@
39
53
  */
40
54
  import { createHash } from "node:crypto";
41
55
  import spawn from "cross-spawn";
42
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
56
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync, } from "node:fs";
43
57
  import path from "node:path";
44
58
  import { fileURLToPath, pathToFileURL } from "node:url";
59
+ /**
60
+ * Whether the module at `moduleUrl` (pass `import.meta.url`) is the one Node was
61
+ * invoked to run directly — the ESM successor to `require.main === module`, shared
62
+ * by every bin wrapper (trace-sync.ts/profile-sync.ts/flags-sync.ts) so there's one
63
+ * fix point instead of three copies of the same check.
64
+ *
65
+ * A raw `import.meta.url === pathToFileURL(process.argv[1]).href` string comparison
66
+ * (the previous check) is NOT reliable: a package manager's bin shim can leave
67
+ * `argv[1]` pointing at a *symlink* while Node resolves the ESM module id through
68
+ * that symlink to the real target path for `import.meta.url` — so the two name the
69
+ * same file but compare unequal. This is not hypothetical: pnpm's
70
+ * `node_modules/<pkg>` is exactly such a symlink into its content-addressable
71
+ * store, and it reproduces on Windows for every real invocation path (`pnpm exec
72
+ * flags-sync init`, an npm-script `"flags-sync init"` run via `pnpm run`, etc.) —
73
+ * confirmed by instrumenting a real 0.3.0 install: `argv[1]` resolved to
74
+ * `...\node_modules\@flytedesk\app-kit\dist\cli\flags-sync.js` (the symlinked
75
+ * path) while `import.meta.url` resolved to
76
+ * `...\node_modules\.pnpm\@flytedesk+app-kit@0.3.0_.../node_modules\@flytedesk\app-kit\dist\cli\flags-sync.js`
77
+ * (the real store path). The mismatch meant `main()` was never called at all —
78
+ * every CLI entrypoint silently no-op'd: exit code 0, no output, nothing written.
79
+ *
80
+ * `realpathSync.native` (the real OS `realpath` syscall, not Node's pure-JS
81
+ * fallback — verified directly: the JS fallback leaves an on-disk casing mismatch
82
+ * unresolved, e.g. `RealFile.js` vs `realfile.js` compare unequal even though NTFS
83
+ * treats them as the same file, while `.native` correctly normalizes both to the
84
+ * same canonical path) resolves both sides to the same canonical filesystem path
85
+ * first — following symlinks AND normalizing casing — so the comparison is correct
86
+ * regardless of symlinks, path-separator style, or casing differences between how
87
+ * a shell/bin-shim sets `argv[1]` and how Node constructs `import.meta.url` for
88
+ * the same file.
89
+ */
90
+ export function isRunningAsMain(moduleUrl) {
91
+ if (process.argv[1] === undefined)
92
+ return false;
93
+ try {
94
+ return (realpathSync.native(fileURLToPath(moduleUrl)) ===
95
+ realpathSync.native(process.argv[1]));
96
+ }
97
+ catch {
98
+ // Either path doesn't exist / isn't resolvable (e.g. a REPL or a bundler that
99
+ // gives import.meta.url a non-file URL) — definitely not "run directly" then.
100
+ return false;
101
+ }
102
+ }
45
103
  export class SyncCliError extends Error {
46
104
  }
47
105
  function readJson(filePath) {
@@ -53,6 +111,9 @@ function sha256(content) {
53
111
  function sha256File(filePath) {
54
112
  return sha256(readFileSync(filePath));
55
113
  }
114
+ function escapeRegExp(value) {
115
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
116
+ }
56
117
  // ---------------------------------------------------------------------------
57
118
  // Engine: bound to one SyncFragment + the consumer root it's operating on
58
119
  // ---------------------------------------------------------------------------
@@ -155,19 +216,31 @@ class SyncEngine {
155
216
  }
156
217
  return path.resolve(path.dirname(configFile), schemaPath);
157
218
  }
158
- /** Resolve the folder <fragment>.prisma gets copied into, from the config's
159
- * schema path (which may point at the folder itself, or at a single schema file
160
- * inside it, e.g. "prisma/schema.prisma"). */
161
- resolveSchemaFolder(schemaPath) {
219
+ /**
220
+ * Classifies the consumer's schema layout from their prisma.config.ts `schema`
221
+ * path: a path ending in `.prisma` names one specific file — Prisma reads only
222
+ * that file, so this is the flat single-file layout, and the fragment must be
223
+ * merged into it directly (mergeIntoFlatSchema). Anything else names a folder —
224
+ * the multi-file layout — and the fragment is copied in as a sibling file
225
+ * (copyFragment), same as before.
226
+ */
227
+ classifySchemaLayout(schemaPath) {
162
228
  if (schemaPath.endsWith(".prisma")) {
163
- return path.dirname(schemaPath);
229
+ return {
230
+ kind: "flat-file",
231
+ file: schemaPath,
232
+ prismaRoot: path.dirname(schemaPath),
233
+ };
164
234
  }
165
- return schemaPath;
235
+ return {
236
+ kind: "folder",
237
+ folder: schemaPath,
238
+ prismaRoot: this.resolvePrismaRoot(schemaPath),
239
+ };
166
240
  }
167
241
  /** The conventional "prisma root" a schema folder sits under, used to locate (or
168
242
  * create) the sibling `migrations/` directory. For the common multi-file layout
169
- * `prisma/schema/*.prisma`, that's `prisma/`; for a flat `prisma/schema.prisma`
170
- * layout, `resolveSchemaFolder` already returned `prisma/`, so this is a no-op. */
243
+ * `prisma/schema/*.prisma`, that's `prisma/`. */
171
244
  resolvePrismaRoot(schemaFolder) {
172
245
  return path.basename(schemaFolder) === "schema"
173
246
  ? path.dirname(schemaFolder)
@@ -182,7 +255,10 @@ class SyncEngine {
182
255
  // with an argument array is an unescaped-concatenation footgun (path arguments
183
256
  // here can contain spaces). cross-spawn resolves and correctly quotes .cmd/.bat
184
257
  // executables on Windows without going through a shell at all.
185
- const result = spawn.sync("npx", ["prisma", ...args], { cwd, stdio: "inherit" });
258
+ const result = spawn.sync("npx", ["prisma", ...args], {
259
+ cwd,
260
+ stdio: "inherit",
261
+ });
186
262
  if (result.error) {
187
263
  this.fail(`failed to run "npx prisma ${args.join(" ")}": ${result.error.message}`);
188
264
  }
@@ -204,16 +280,221 @@ class SyncEngine {
204
280
  cpSync(srcDir, destDir, { recursive: true });
205
281
  }
206
282
  // -------------------------------------------------------------------------
283
+ // Flat single-file schema.prisma support
284
+ // -------------------------------------------------------------------------
285
+ /**
286
+ * The portion of a fragment's .prisma source that gets merged into a consumer's
287
+ * flat schema.prisma — everything from the first `model` declaration onward,
288
+ * trimmed. Deliberately drops the fragment file's own leading design-rationale
289
+ * doc comment (see prisma/fragments/profile.prisma / trace.prisma's file
290
+ * headers): that comment describes the *fragment file itself* ("this file is
291
+ * copied by <bin> init...", "matching the raw SQL in prisma/migrations/...")
292
+ * which reads oddly once inlined into someone else's schema.prisma, whereas the
293
+ * marker region's own header (markerStart) already identifies the block as
294
+ * app-kit-owned and names which command manages it.
295
+ */
296
+ extractFragmentBody(fragmentSource) {
297
+ const lines = fragmentSource.split(/\r?\n/);
298
+ const firstModelLine = lines.findIndex((line) => /^model\s+\w+\s*\{/.test(line));
299
+ const body = firstModelLine === -1
300
+ ? fragmentSource
301
+ : lines.slice(firstModelLine).join("\n");
302
+ return body.trim();
303
+ }
304
+ static DATASOURCE_SCHEMAS_RE = /datasource\s+\w+\s*\{[^}]*\bschemas\s*=\s*\[([^\]]*)\]/;
305
+ /**
306
+ * The Postgres schema name(s) declared on the consumer's own `datasource` block
307
+ * (`schemas = [...]`) in a flat schema.prisma's full text. Prisma's multiSchema
308
+ * feature requires `@@schema(...)` on every single model once this array is
309
+ * non-empty (P1012 otherwise) — see resolveFragmentBody, which uses this to
310
+ * decide whether merged-in fragment models need one. Returns `[]` when the
311
+ * datasource has no `schemas` setting at all (a non-multiSchema consumer).
312
+ */
313
+ readConsumerSchemaNames(schemaContent) {
314
+ const match = schemaContent.match(SyncEngine.DATASOURCE_SCHEMAS_RE);
315
+ if (!match)
316
+ return [];
317
+ return match[1]
318
+ .split(",")
319
+ .map((entry) => entry.trim())
320
+ .filter((entry) => entry.length > 0)
321
+ .map((entry) => entry.replace(/^["']|["']$/g, ""));
322
+ }
323
+ /**
324
+ * Inserts `@@schema("<name>")` as the last block attribute of every top-level
325
+ * `model { ... }` in `body`, immediately before its closing brace — the same
326
+ * position a hand-written model in a multiSchema consumer's own schema.prisma
327
+ * uses (see FLAT_SCHEMA_FIXTURE in sync-engine.test.ts).
328
+ *
329
+ * Relies on this package's own fragment files' formatting convention (verified
330
+ * against every prisma/fragments/*.prisma file): `model Foo {` always starts at
331
+ * column 0, and its matching `}` is always alone on its own line at column 0 —
332
+ * the same convention extractFragmentBody's firstModelLine scan already depends
333
+ * on. This is deliberately not a general-purpose Prisma parser (it would, for
334
+ * instance, mis-handle a `{`/`}` inside a string default like flags.prisma's
335
+ * `@default("{}")` if it tried to brace-count) — it only ever needs to run
336
+ * against this package's own fragment files, never a consumer's.
337
+ */
338
+ injectSchemaAttribute(body, schemaName) {
339
+ const lines = body.split(/\r?\n/);
340
+ const out = [];
341
+ let inModel = false;
342
+ for (const line of lines) {
343
+ if (!inModel && /^model\s+\w+\s*\{$/.test(line)) {
344
+ inModel = true;
345
+ out.push(line);
346
+ continue;
347
+ }
348
+ if (inModel && line === "}") {
349
+ out.push(` @@schema(${JSON.stringify(schemaName)})`);
350
+ out.push(line);
351
+ inModel = false;
352
+ continue;
353
+ }
354
+ out.push(line);
355
+ }
356
+ return out.join("\n");
357
+ }
358
+ /**
359
+ * Resolves this fragment's exact body text as it should be merged into a
360
+ * consumer's flat schema.prisma: extractFragmentBody's output, with
361
+ * `@@schema(...)` injected into every model when the consumer's datasource
362
+ * declares `schemas = [...]` (multiSchema) — fixing the bug where a merged
363
+ * fragment had no `@@schema` at all and broke `prisma validate` (P1012:
364
+ * "This model is missing an @@schema attribute") for every multiSchema consumer,
365
+ * which is both real consumers (flytedesk-id, media-planner).
366
+ *
367
+ * `schemaNameOverride` is the CLI's `--schema-name` flag. When the consumer
368
+ * declares exactly one schema name, that name is used automatically and the
369
+ * override (if given) is only validated, not required. When more than one is
370
+ * declared, this engine has no way to guess which one a given fragment's models
371
+ * belong to — guessing silently is exactly the class of bug this fixes — so
372
+ * `--schema-name` becomes required and this fails loudly (via `fail()`,
373
+ * surfaced to the consumer with the exact flag to pass) rather than picking one.
374
+ */
375
+ resolveFragmentBody(schemaFileContent, schemaNameOverride) {
376
+ const rawBody = this.extractFragmentBody(readFileSync(this.fragmentSrcPath, "utf8"));
377
+ const declaredSchemas = this.readConsumerSchemaNames(schemaFileContent);
378
+ if (declaredSchemas.length === 0) {
379
+ return rawBody;
380
+ }
381
+ let schemaName;
382
+ if (declaredSchemas.length === 1) {
383
+ schemaName = schemaNameOverride ?? declaredSchemas[0];
384
+ }
385
+ else if (schemaNameOverride) {
386
+ schemaName = schemaNameOverride;
387
+ }
388
+ else {
389
+ this.fail(`the consumer's datasource declares multiple schemas (${declaredSchemas.join(", ")}) via ` +
390
+ `\`schemas = [...]\`. ${this.fragment.binName} can't guess which one this fragment's model(s) ` +
391
+ `belong to — pass --schema-name=<name> to disambiguate, e.g. ` +
392
+ `"${this.fragment.binName} init --schema-name=${declaredSchemas[0]}".`);
393
+ }
394
+ if (!declaredSchemas.includes(schemaName)) {
395
+ this.fail(`--schema-name=${schemaName} is not one of the consumer's declared schemas (${declaredSchemas.join(", ")}).`);
396
+ }
397
+ return this.injectSchemaAttribute(rawBody, schemaName);
398
+ }
399
+ markerStart() {
400
+ return `// --- @flytedesk/app-kit:${this.fragment.name} start (managed by ${this.fragment.binName} — do not hand-edit this region) ---`;
401
+ }
402
+ markerEnd() {
403
+ return `// --- @flytedesk/app-kit:${this.fragment.name} end ---`;
404
+ }
405
+ /**
406
+ * Extracts the current content of THIS fragment's own marker region from a flat
407
+ * schema.prisma's full text, or null if the region isn't present yet. Matching on
408
+ * this fragment's own name means two different fragments merged into the same
409
+ * flat file never see or touch each other's regions.
410
+ *
411
+ * Exact inverse of the `${start}\n${body}\n${end}` block mergeIntoFlatSchema
412
+ * writes, so a round trip (write, then extract) returns the same body
413
+ * byte-for-byte — this is what makes both mergeIntoFlatSchema's own
414
+ * no-op-if-unchanged check and cmdStatus's checksum comparison correct.
415
+ */
416
+ extractInstalledRegion(schemaContent) {
417
+ const re = new RegExp(`${escapeRegExp(this.markerStart())}\\n([\\s\\S]*?)\\n${escapeRegExp(this.markerEnd())}`);
418
+ const match = schemaContent.match(re);
419
+ return match ? match[1] : null;
420
+ }
421
+ /**
422
+ * Merges this fragment's model(s) into a consumer's flat single-file
423
+ * prisma/schema.prisma, inside a delimited, app-kit-owned marker region — the
424
+ * flat-file counterpart to copyFragment's sibling-file copy for the multi-file
425
+ * folder layout.
426
+ *
427
+ * Idempotent: running this twice with the same fragment content is a no-op (the
428
+ * installed region already matches, so the file isn't rewritten at all). Multiple
429
+ * fragments merged into the same file coexist as separate regions — this only
430
+ * ever reads or replaces text between ITS OWN start/end marker pair; everything
431
+ * else in the file (the consumer's own models, datasource/generator blocks,
432
+ * `schemas = [...]`, other fragments' regions) is left byte-for-byte untouched.
433
+ */
434
+ mergeIntoFlatSchema(schemaFilePath, schemaNameOverride) {
435
+ if (!existsSync(schemaFilePath)) {
436
+ this.fail(`schema file not found at ${schemaFilePath} (expected a flat single-file prisma/schema.prisma)`);
437
+ }
438
+ const original = readFileSync(schemaFilePath, "utf8");
439
+ const body = this.resolveFragmentBody(original, schemaNameOverride);
440
+ const existingRegion = this.extractInstalledRegion(original);
441
+ if (existingRegion === body) {
442
+ return;
443
+ }
444
+ let updated;
445
+ if (existingRegion !== null) {
446
+ const re = new RegExp(`${escapeRegExp(this.markerStart())}\\n[\\s\\S]*?\\n${escapeRegExp(this.markerEnd())}`);
447
+ updated = original.replace(re, `${this.markerStart()}\n${body}\n${this.markerEnd()}`);
448
+ }
449
+ else {
450
+ updated = `${original.replace(/\s*$/, "")}\n\n${this.markerStart()}\n${body}\n${this.markerEnd()}\n`;
451
+ }
452
+ writeFileSync(schemaFilePath, updated, "utf8");
453
+ }
454
+ /** Installs the fragment for either layout — the one call site cmdInit/cmdApply
455
+ * both use, so they can never disagree about which path a given layout takes. */
456
+ installFragment(layout, schemaNameOverride) {
457
+ const bin = this.fragment.binName;
458
+ if (layout.kind === "folder") {
459
+ console.log(`${bin}: copying ${this.fragment.name}.prisma into ${layout.folder}`);
460
+ this.copyFragment(layout.folder);
461
+ }
462
+ else {
463
+ console.log(`${bin}: merging ${this.fragment.name} model(s) into ${layout.file} (managed region)`);
464
+ this.mergeIntoFlatSchema(layout.file, schemaNameOverride);
465
+ }
466
+ }
467
+ /**
468
+ * What gets recorded as `fragmentChecksum` in the lock file, and what cmdStatus
469
+ * recomputes from the consumer's tree to detect hand-edits. Must be called AFTER
470
+ * installFragment has already written to the consumer's tree (both call sites,
471
+ * cmdInit/cmdApply, satisfy this).
472
+ *
473
+ * For the flat-file/merge case this reads the marker region back out of the file
474
+ * that was just written, rather than re-deriving it from the fragment source —
475
+ * that keeps this in lockstep with whatever mergeIntoFlatSchema actually wrote
476
+ * (including any `@@schema(...)` injected by resolveFragmentBody) without
477
+ * duplicating that resolution logic a second time here.
478
+ */
479
+ computeFragmentChecksum(layout) {
480
+ if (layout.kind === "folder") {
481
+ return sha256File(this.fragmentSrcPath);
482
+ }
483
+ const installed = this.extractInstalledRegion(readFileSync(layout.file, "utf8"));
484
+ if (installed === null) {
485
+ this.fail(`internal error: no managed region found in ${layout.file} immediately after installing it`);
486
+ }
487
+ return sha256(installed);
488
+ }
489
+ // -------------------------------------------------------------------------
207
490
  // Subcommands
208
491
  // -------------------------------------------------------------------------
209
- async cmdInit(consumerRoot) {
492
+ async cmdInit(consumerRoot, schemaNameOverride) {
210
493
  const bin = this.fragment.binName;
211
494
  const schemaPath = await this.loadConsumerSchemaPath(consumerRoot);
212
- const schemaFolder = this.resolveSchemaFolder(schemaPath);
213
- const prismaRoot = this.resolvePrismaRoot(schemaFolder);
214
- const migrationsDestDir = path.join(prismaRoot, "migrations");
215
- console.log(`${bin}: copying ${this.fragment.name}.prisma into ${schemaFolder}`);
216
- this.copyFragment(schemaFolder);
495
+ const layout = this.classifySchemaLayout(schemaPath);
496
+ const migrationsDestDir = path.join(layout.prismaRoot, "migrations");
497
+ this.installFragment(layout, schemaNameOverride);
217
498
  console.log(`${bin}: running "npx prisma validate" against ${consumerRoot}`);
218
499
  this.runPrisma(["validate"], consumerRoot);
219
500
  const manifest = this.readManifest();
@@ -236,7 +517,7 @@ class SyncEngine {
236
517
  const meta = this.readFragmentMeta();
237
518
  this.writeLockFile(consumerRoot, {
238
519
  fragmentVersion: meta.fragmentVersion,
239
- fragmentChecksum: sha256File(this.fragmentSrcPath),
520
+ fragmentChecksum: this.computeFragmentChecksum(layout),
240
521
  appliedMigrations,
241
522
  lastSyncedAt: new Date().toISOString(),
242
523
  });
@@ -277,15 +558,13 @@ class SyncEngine {
277
558
  }
278
559
  console.log(`${this.fragment.binName} diff is read-only. Run "${this.fragment.binName} apply" to apply these.`);
279
560
  }
280
- async cmdApply(consumerRoot) {
561
+ async cmdApply(consumerRoot, schemaNameOverride) {
281
562
  const bin = this.fragment.binName;
282
563
  const report = this.computePending(consumerRoot);
283
564
  const schemaPath = await this.loadConsumerSchemaPath(consumerRoot);
284
- const schemaFolder = this.resolveSchemaFolder(schemaPath);
285
- const prismaRoot = this.resolvePrismaRoot(schemaFolder);
286
- const migrationsDestDir = path.join(prismaRoot, "migrations");
287
- console.log(`${bin}: refreshing ${this.fragment.name}.prisma in ${schemaFolder}`);
288
- this.copyFragment(schemaFolder);
565
+ const layout = this.classifySchemaLayout(schemaPath);
566
+ const migrationsDestDir = path.join(layout.prismaRoot, "migrations");
567
+ this.installFragment(layout, schemaNameOverride);
289
568
  const lock = this.readLockFile(consumerRoot);
290
569
  if (!lock) {
291
570
  this.fail(`no ${this.lockFileName} found at ${consumerRoot}. Run "${bin} init" first.`);
@@ -308,7 +587,7 @@ class SyncEngine {
308
587
  const meta = this.readFragmentMeta();
309
588
  this.writeLockFile(consumerRoot, {
310
589
  fragmentVersion: meta.fragmentVersion,
311
- fragmentChecksum: sha256File(this.fragmentSrcPath),
590
+ fragmentChecksum: this.computeFragmentChecksum(layout),
312
591
  appliedMigrations,
313
592
  lastSyncedAt: new Date().toISOString(),
314
593
  });
@@ -330,18 +609,36 @@ class SyncEngine {
330
609
  }
331
610
  try {
332
611
  const schemaPath = await this.loadConsumerSchemaPath(consumerRoot);
333
- const schemaFolder = this.resolveSchemaFolder(schemaPath);
334
- const copiedFragmentPath = path.join(schemaFolder, `${this.fragment.name}.prisma`);
335
- if (!existsSync(copiedFragmentPath)) {
336
- console.log(`fragment copy: MISSING at ${copiedFragmentPath}`);
337
- return;
612
+ const layout = this.classifySchemaLayout(schemaPath);
613
+ let installedChecksum;
614
+ let location;
615
+ if (layout.kind === "folder") {
616
+ const copiedFragmentPath = path.join(layout.folder, `${this.fragment.name}.prisma`);
617
+ if (!existsSync(copiedFragmentPath)) {
618
+ console.log(`fragment copy: MISSING at ${copiedFragmentPath}`);
619
+ return;
620
+ }
621
+ installedChecksum = sha256File(copiedFragmentPath);
622
+ location = copiedFragmentPath;
623
+ }
624
+ else {
625
+ if (!existsSync(layout.file)) {
626
+ console.log(`fragment copy: MISSING — ${layout.file} does not exist`);
627
+ return;
628
+ }
629
+ const region = this.extractInstalledRegion(readFileSync(layout.file, "utf8"));
630
+ if (region === null) {
631
+ console.log(`fragment copy: MISSING — no managed region found in ${layout.file}`);
632
+ return;
633
+ }
634
+ installedChecksum = sha256(region);
635
+ location = `${layout.file} (managed region)`;
338
636
  }
339
- const installedChecksum = sha256File(copiedFragmentPath);
340
637
  if (installedChecksum === lock.fragmentChecksum) {
341
638
  console.log("fragment copy: unmodified");
342
639
  }
343
640
  else {
344
- console.log(`fragment copy: CHECKSUM MISMATCH — ${copiedFragmentPath} has been hand-edited ` +
641
+ console.log(`fragment copy: CHECKSUM MISMATCH — ${location} has been hand-edited ` +
345
642
  `(or is out of sync with the lock file)`);
346
643
  }
347
644
  }
@@ -357,6 +654,30 @@ const SUBCOMMANDS = ["init", "diff", "apply", "status"];
357
654
  function isSubcommand(value) {
358
655
  return SUBCOMMANDS.includes(value);
359
656
  }
657
+ /**
658
+ * Parses `--schema-name=<name>` / `--schema-name <name>` out of a subcommand's
659
+ * trailing args — the flag `resolveFragmentBody` requires when a consumer's flat
660
+ * schema.prisma declares more than one entry in `schemas = [...]` and this engine
661
+ * can't guess which one a fragment's models belong to. Only `init`/`apply` use the
662
+ * result (they're the only subcommands that install the fragment); `diff`/`status`
663
+ * ignore it since they never write anything.
664
+ */
665
+ function parseSchemaNameFlag(rest) {
666
+ for (let i = 0; i < rest.length; i++) {
667
+ const arg = rest[i];
668
+ if (arg === "--schema-name") {
669
+ const value = rest[i + 1];
670
+ if (value === undefined) {
671
+ throw new SyncCliError("--schema-name requires a value, e.g. --schema-name=my_schema");
672
+ }
673
+ return value;
674
+ }
675
+ if (arg.startsWith("--schema-name=")) {
676
+ return arg.slice("--schema-name=".length);
677
+ }
678
+ }
679
+ return undefined;
680
+ }
360
681
  /**
361
682
  * Runs one CLI invocation for the given fragment. Called by each bin wrapper
362
683
  * (src/cli/trace-sync.ts, src/cli/profile-sync.ts) with `process.argv.slice(2)`.
@@ -364,25 +685,22 @@ function isSubcommand(value) {
364
685
  export async function runSyncCli(argv, fragment) {
365
686
  const [subcommand, ...rest] = argv;
366
687
  if (!isSubcommand(subcommand)) {
367
- console.error(`usage: ${fragment.binName} <${SUBCOMMANDS.join("|")}>`);
688
+ console.error(`usage: ${fragment.binName} <${SUBCOMMANDS.join("|")}> [--schema-name=<name>]`);
368
689
  process.exitCode = 1;
369
690
  return;
370
691
  }
371
- // Reserved for a future --cwd override; today every sync CLI always operates on
372
- // the directory it's invoked from, which is how `npx <bin> ...` / an npm script in
373
- // the consumer's own package.json will run it.
374
- void rest;
692
+ const schemaNameOverride = parseSchemaNameFlag(rest);
375
693
  const consumerRoot = process.cwd();
376
694
  const engine = new SyncEngine(fragment);
377
695
  switch (subcommand) {
378
696
  case "init":
379
- await engine.cmdInit(consumerRoot);
697
+ await engine.cmdInit(consumerRoot, schemaNameOverride);
380
698
  break;
381
699
  case "diff":
382
700
  engine.cmdDiff(consumerRoot);
383
701
  break;
384
702
  case "apply":
385
- await engine.cmdApply(consumerRoot);
703
+ await engine.cmdApply(consumerRoot, schemaNameOverride);
386
704
  break;
387
705
  case "status":
388
706
  await engine.cmdStatus(consumerRoot);