@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.
- package/dist/cli/flags-sync.d.ts +2 -0
- package/dist/cli/flags-sync.js +26 -0
- package/dist/cli/flags-sync.js.map +1 -0
- package/dist/cli/profile-sync.d.ts +2 -0
- package/dist/cli/profile-sync.js +26 -0
- package/dist/cli/profile-sync.js.map +1 -0
- package/dist/cli/sync-engine.d.ts +20 -0
- package/dist/cli/sync-engine.js +543 -0
- package/dist/cli/sync-engine.js.map +1 -0
- package/dist/cli/trace-sync.js +9 -359
- package/dist/cli/trace-sync.js.map +1 -1
- package/dist/flags/index.d.ts +49 -0
- package/dist/flags/index.js +49 -0
- package/dist/flags/index.js.map +1 -0
- package/dist/flags/plugin.d.ts +3 -0
- package/dist/flags/plugin.js +296 -0
- package/dist/flags/plugin.js.map +1 -0
- package/dist/flags/types.d.ts +159 -0
- package/dist/flags/types.js +11 -0
- package/dist/flags/types.js.map +1 -0
- package/dist/profile/index.d.ts +36 -0
- package/dist/profile/index.js +36 -0
- package/dist/profile/index.js.map +1 -0
- package/dist/profile/plugin.d.ts +3 -0
- package/dist/profile/plugin.js +111 -0
- package/dist/profile/plugin.js.map +1 -0
- package/dist/profile/types.d.ts +76 -0
- package/dist/profile/types.js +10 -0
- package/dist/profile/types.js.map +1 -0
- package/package.json +12 -2
- package/prisma/fragments/flags.meta.json +4 -0
- package/prisma/fragments/flags.prisma +52 -0
- package/prisma/fragments/profile.meta.json +4 -0
- package/prisma/fragments/profile.prisma +42 -0
- package/prisma/migrations/0002_app_kit_profile_init/migration.sql +15 -0
- package/prisma/migrations/0003_app_kit_flags_init/migration.sql +16 -0
- package/prisma/migrations/manifest.json +15 -0
|
@@ -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,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
|