@intelligo-dev/cli 1.0.0-beta.1 → 1.0.0-beta.13

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 (103) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +63 -0
  3. package/dist/bin.js +85 -37
  4. package/dist/bin.js.map +1 -1
  5. package/dist/commands/add.d.ts +22 -1
  6. package/dist/commands/add.d.ts.map +1 -1
  7. package/dist/commands/add.js +61 -4
  8. package/dist/commands/add.js.map +1 -1
  9. package/dist/commands/create-flow.d.ts +34 -0
  10. package/dist/commands/create-flow.d.ts.map +1 -0
  11. package/dist/commands/create-flow.js +180 -0
  12. package/dist/commands/create-flow.js.map +1 -0
  13. package/dist/commands/create.d.ts +23 -6
  14. package/dist/commands/create.d.ts.map +1 -1
  15. package/dist/commands/create.js +45 -13
  16. package/dist/commands/create.js.map +1 -1
  17. package/dist/commands/doctor.d.ts +14 -3
  18. package/dist/commands/doctor.d.ts.map +1 -1
  19. package/dist/commands/doctor.js +293 -37
  20. package/dist/commands/doctor.js.map +1 -1
  21. package/dist/commands/migrate-check.d.ts +54 -1
  22. package/dist/commands/migrate-check.d.ts.map +1 -1
  23. package/dist/commands/migrate-check.js +101 -5
  24. package/dist/commands/migrate-check.js.map +1 -1
  25. package/dist/commands/migrate.d.ts +97 -0
  26. package/dist/commands/migrate.d.ts.map +1 -0
  27. package/dist/commands/migrate.js +147 -0
  28. package/dist/commands/migrate.js.map +1 -0
  29. package/dist/commands/upgrade-check.d.ts +5 -5
  30. package/dist/commands/upgrade-check.d.ts.map +1 -1
  31. package/dist/commands/upgrade-check.js +19 -6
  32. package/dist/commands/upgrade-check.js.map +1 -1
  33. package/dist/env-files.d.ts +12 -0
  34. package/dist/env-files.d.ts.map +1 -0
  35. package/dist/env-files.js +29 -0
  36. package/dist/env-files.js.map +1 -0
  37. package/dist/index.d.ts +9 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +9 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/manifest.d.ts +9 -15
  42. package/dist/manifest.d.ts.map +1 -1
  43. package/dist/manifest.js +5 -16
  44. package/dist/manifest.js.map +1 -1
  45. package/dist/migrations.d.ts +3 -6
  46. package/dist/migrations.d.ts.map +1 -1
  47. package/dist/migrations.js +3 -6
  48. package/dist/migrations.js.map +1 -1
  49. package/dist/model-catalogue.d.ts +14 -0
  50. package/dist/model-catalogue.d.ts.map +1 -0
  51. package/dist/model-catalogue.js +34 -0
  52. package/dist/model-catalogue.js.map +1 -0
  53. package/dist/registry-items.d.ts +56 -0
  54. package/dist/registry-items.d.ts.map +1 -0
  55. package/dist/registry-items.js +103 -0
  56. package/dist/registry-items.js.map +1 -0
  57. package/package.json +36 -10
  58. package/src/bin.ts +264 -0
  59. package/src/commands/add.ts +244 -0
  60. package/src/commands/create-flow.ts +228 -0
  61. package/src/commands/create.ts +139 -0
  62. package/src/commands/doctor.ts +487 -0
  63. package/src/commands/migrate-check.ts +257 -0
  64. package/src/commands/migrate.ts +234 -0
  65. package/src/commands/upgrade-check.ts +157 -0
  66. package/src/env-files.ts +31 -0
  67. package/src/index.ts +39 -0
  68. package/src/manifest.ts +93 -0
  69. package/src/migrations-dir.ts +25 -0
  70. package/src/migrations.ts +133 -0
  71. package/src/model-catalogue.ts +35 -0
  72. package/src/registry-items.ts +132 -0
  73. package/templates/admin-page/admin-page.tsx.tpl +2 -3
  74. package/templates/app-scaffold/assistant-route.ts.tpl +2 -2
  75. package/templates/app-scaffold/auth-route.ts.tpl +10 -0
  76. package/templates/app-scaffold/components.json.tpl +6 -2
  77. package/templates/app-scaffold/db-schema.ts.tpl +29 -0
  78. package/templates/app-scaffold/drizzle-journal.json.tpl +5 -0
  79. package/templates/app-scaffold/drizzle.config.ts.tpl +45 -0
  80. package/templates/app-scaffold/env.example.tpl +65 -1
  81. package/templates/app-scaffold/globals.css.tpl +172 -281
  82. package/templates/app-scaffold/i18n-request.ts.tpl +25 -0
  83. package/templates/app-scaffold/instrumentation.ts.tpl +21 -0
  84. package/templates/app-scaffold/intelligo.ts.tpl +104 -9
  85. package/templates/app-scaffold/layout.tsx.tpl +18 -1
  86. package/templates/app-scaffold/lib-utils.ts.tpl +1 -6
  87. package/templates/app-scaffold/next.config.mjs.tpl +2 -0
  88. package/templates/app-scaffold/package.json.tpl +31 -17
  89. package/templates/app-scaffold/page.tsx.tpl +16 -0
  90. package/templates/app-scaffold/plans.ts.tpl +32 -13
  91. package/templates/app-scaffold/{middleware.ts.tpl → proxy.ts.tpl} +6 -0
  92. package/templates/app-scaffold/sonner.tsx.tpl +42 -20
  93. package/templates/app-scaffold/stripe-webhook-route.ts.tpl +24 -0
  94. package/templates/app-scaffold/theme-provider.tsx.tpl +6 -4
  95. package/templates/app-scaffold/tsconfig.json.tpl +20 -4
  96. package/templates/app-scaffold/use-mobile.ts.tpl +19 -0
  97. package/templates/app-scaffold/workspace-bootstrap.ts.tpl +30 -0
  98. package/templates/maintenance/maintenance-route.ts.tpl +119 -0
  99. package/templates/manifest.json +57 -18
  100. package/templates/registry-items.json +116 -0
  101. package/templates/registry-requires.json +156 -0
  102. package/templates/usage-page/usage-page.tsx.tpl +23 -8
  103. package/templates/billing-page/billing-page.tsx.tpl +0 -72
@@ -0,0 +1,257 @@
1
+ /**
2
+ * `intelligo migrate --check`
3
+ *
4
+ * Answers "would deploying this code against that database work?"
5
+ * without changing anything. Two failure modes matter and neither is
6
+ * visible from the code alone:
7
+ *
8
+ * - migrations the database has not applied yet (deploying now runs
9
+ * code against an older schema);
10
+ * - migrations the database has applied that this checkout does not
11
+ * contain (the database is ahead — usually a rollback in progress).
12
+ *
13
+ * Drizzle records applied migrations in `drizzle.__drizzle_migrations`
14
+ * by content hash. A database provisioned with `db:push` has the
15
+ * schema but no rows there at all, which this reports distinctly:
16
+ * "unmanaged" is a different problem from "behind", and baselining is
17
+ * the fix (see the migrations README).
18
+ *
19
+ * The framework's chain is one baseline. A database that ran the pre-1.0
20
+ * chain holds its hashes, which `legacy-chain.json` (next to the
21
+ * journal) lists: they are reported as `legacy`, not as unknown, and a
22
+ * database holding all of them is `adoptable` — its schema is the
23
+ * baseline's, so `migrate` records the baseline without running it.
24
+ *
25
+ * The exit code is 1 whenever anything is pending or the database is
26
+ * ahead, which a brand-new database and a stale one share. A deploy
27
+ * gate that must tell them apart reads `migrate --check --json`: one
28
+ * JSON object on stdout, same exit code, whose `state` is
29
+ *
30
+ * - `up_to_date` — every migration is applied;
31
+ * - `pending` — a migrated database is behind this checkout;
32
+ * - `fresh` — no migration records and none of the framework's
33
+ * tables: an empty database, `migrate` applies the chain;
34
+ * - `unmanaged` — the tables exist with no records (`db:push`);
35
+ * - `ahead` — applied migrations this checkout does not contain;
36
+ * - `legacy` — the pre-1.0 chain; `adoptable` says whether
37
+ * `migrate` can take it over.
38
+ *
39
+ * Beside `state` it carries `exitCode`, `chain`, `applied`, `pending`,
40
+ * `unknown`, `legacy` and `adoptable`.
41
+ */
42
+
43
+ import { createHash } from "node:crypto";
44
+ import { existsSync, readFileSync } from "node:fs";
45
+ import path from "node:path";
46
+
47
+ import { readMigrationChain } from "../migrations.js";
48
+
49
+ export type MigrateCheckResult = {
50
+ /** Journal tags this checkout knows about, in order. */
51
+ chain: string[];
52
+ /** Applied according to the database. */
53
+ applied: string[];
54
+ /** In the chain but not applied — a deploy would run against an older schema. */
55
+ pending: string[];
56
+ /** Applied but absent from this checkout — the database is ahead. */
57
+ unknown: string[];
58
+ /**
59
+ * True when the migrations table is empty or missing while the
60
+ * schema exists — a push-provisioned database that needs baselining.
61
+ */
62
+ unmanaged: boolean;
63
+ /** Pre-1.0 migrations the database ran, by tag. */
64
+ legacy: string[];
65
+ /** Length of the pre-1.0 chain (0 when this checkout ships none). */
66
+ legacyChainLength: number;
67
+ /**
68
+ * The database ran the whole pre-1.0 chain and not the baseline:
69
+ * `migrate` records the baseline as applied without running it.
70
+ */
71
+ adoptable: boolean;
72
+ };
73
+
74
+ /** The pre-1.0 chain's tags and hashes, if this checkout ships them. */
75
+ export function readLegacyChain(
76
+ migrationsDir: string
77
+ ): Array<{ tag: string; hash: string }> {
78
+ const file = path.join(migrationsDir, "legacy-chain.json");
79
+ if (!existsSync(file)) return [];
80
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as {
81
+ entries?: Array<{ tag: string; hash: string }>;
82
+ };
83
+ return parsed.entries ?? [];
84
+ }
85
+
86
+ /** Drizzle hashes the file contents with sha256. */
87
+ export function hashMigration(sql: string): string {
88
+ return createHash("sha256").update(sql).digest("hex");
89
+ }
90
+
91
+ type QueryFn = (sql: string) => Promise<Array<{ hash: string }>>;
92
+
93
+ export async function migrateCheck(
94
+ migrationsDir: string,
95
+ query: QueryFn
96
+ ): Promise<MigrateCheckResult> {
97
+ const chain = readMigrationChain(migrationsDir);
98
+
99
+ const hashToTag = new Map<string, string>();
100
+ for (const tag of chain.journalTags) {
101
+ const file = path.join(migrationsDir, `${tag}.sql`);
102
+ try {
103
+ hashToTag.set(hashMigration(readFileSync(file, "utf8")), tag);
104
+ } catch {
105
+ // readMigrationChain already reports files the journal names but
106
+ // that are not on disk; nothing to hash here.
107
+ }
108
+ }
109
+
110
+ let rows: Array<{ hash: string }> = [];
111
+ let tableMissing = false;
112
+ try {
113
+ rows = await query(
114
+ `SELECT hash FROM drizzle.__drizzle_migrations ORDER BY created_at`
115
+ );
116
+ } catch {
117
+ tableMissing = true;
118
+ }
119
+
120
+ const legacyChain = readLegacyChain(migrationsDir);
121
+ const legacyByHash = new Map(legacyChain.map((e) => [e.hash, e.tag]));
122
+
123
+ const appliedHashes = new Set(rows.map((r) => r.hash));
124
+ const applied: string[] = [];
125
+ const unknown: string[] = [];
126
+ const legacy: string[] = [];
127
+ for (const hash of appliedHashes) {
128
+ const tag = hashToTag.get(hash);
129
+ const legacyTag = legacyByHash.get(hash);
130
+ if (tag) applied.push(tag);
131
+ else if (legacyTag) legacy.push(legacyTag);
132
+ else unknown.push(hash.slice(0, 12));
133
+ }
134
+
135
+ const appliedTags = new Set(applied);
136
+ const pending = chain.journalTags.filter((t) => !appliedTags.has(t));
137
+ const baseline = chain.journalTags[0];
138
+
139
+ return {
140
+ chain: chain.journalTags,
141
+ applied,
142
+ pending,
143
+ unknown,
144
+ unmanaged: tableMissing || appliedHashes.size === 0,
145
+ legacy,
146
+ legacyChainLength: legacyChain.length,
147
+ adoptable:
148
+ legacyChain.length > 0 &&
149
+ legacy.length === legacyChain.length &&
150
+ baseline !== undefined &&
151
+ !appliedTags.has(baseline),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * `schemaExists` is whether the framework's tables are in the database
157
+ * (`SCHEMA_PROBE_SQL`). When the caller did not probe, a database with
158
+ * no records gets the cautious `db:push` wording.
159
+ */
160
+ export function formatMigrateCheck(
161
+ r: MigrateCheckResult,
162
+ schemaExists?: boolean
163
+ ): string {
164
+ const lines: string[] = [];
165
+
166
+ if (r.unmanaged && schemaExists === false) {
167
+ lines.push(
168
+ `! This database is empty: no migration records and none of the ` +
169
+ `framework's tables. \`intelligo migrate\` applies all ` +
170
+ `${r.chain.length} migration(s).`
171
+ );
172
+ } else if (r.unmanaged) {
173
+ lines.push(
174
+ `! This database has no migration records. If it was provisioned with ` +
175
+ `db:push, baseline it before running migrate — otherwise migrate will ` +
176
+ `try to apply all ${r.chain.length} migrations. See README.md in ` +
177
+ `@intelligo-dev/core's src/db/migrations.`
178
+ );
179
+ }
180
+
181
+ if (r.adoptable) {
182
+ lines.push(
183
+ `! This database ran the framework's pre-1.0 migration chain. migrate ` +
184
+ `records ${r.chain[0]} as applied without running it (the schema is ` +
185
+ `already there), then applies what follows. Tables the old chain ` +
186
+ `created that the framework no longer owns are left untouched.`
187
+ );
188
+ } else if (r.legacy.length > 0 && r.legacy.length < r.legacyChainLength) {
189
+ lines.push(
190
+ `✗ This database ran ${r.legacy.length} of the ${r.legacyChainLength} ` +
191
+ `pre-1.0 migrations. Finish that chain with @intelligo-dev/core ` +
192
+ `1.0.0-beta.7 (\`intelligo migrate\`) before upgrading.`
193
+ );
194
+ }
195
+
196
+ if (r.unknown.length > 0) {
197
+ lines.push(
198
+ `✗ Database is ahead: ${r.unknown.length} applied migration(s) are not in ` +
199
+ `this checkout (${r.unknown.join(", ")})`
200
+ );
201
+ }
202
+
203
+ if (r.pending.length > 0) {
204
+ lines.push(
205
+ `✗ ${r.pending.length} migration(s) pending: ${r.pending.join(", ")}`
206
+ );
207
+ } else if (!r.unmanaged) {
208
+ lines.push(`✓ Up to date (${r.applied.length}/${r.chain.length} applied)`);
209
+ }
210
+
211
+ return lines.join("\n");
212
+ }
213
+
214
+ /** Non-zero when deploying this code would run against a stale schema. */
215
+ export function migrateCheckExitCode(r: MigrateCheckResult): number {
216
+ return r.pending.length > 0 || r.unknown.length > 0 ? 1 : 0;
217
+ }
218
+
219
+ /** A partial pre-1.0 chain: neither adoptable nor migratable from here. */
220
+ export function isPartialLegacy(r: MigrateCheckResult): boolean {
221
+ return r.legacy.length > 0 && r.legacy.length < r.legacyChainLength;
222
+ }
223
+
224
+ export type MigrateState =
225
+ "up_to_date" | "pending" | "fresh" | "ahead" | "unmanaged" | "legacy";
226
+
227
+ /**
228
+ * One word for what the check found, most urgent first: a database that
229
+ * is ahead or on the pre-1.0 chain is that before it is anything else.
230
+ * `schemaExists` separates an empty database from a push-provisioned one.
231
+ */
232
+ export function migrateState(
233
+ r: MigrateCheckResult,
234
+ schemaExists: boolean
235
+ ): MigrateState {
236
+ if (r.unknown.length > 0) return "ahead";
237
+ if (r.legacy.length > 0) return "legacy";
238
+ if (r.unmanaged) return schemaExists ? "unmanaged" : "fresh";
239
+ return r.pending.length > 0 ? "pending" : "up_to_date";
240
+ }
241
+
242
+ /** The `--json` report: `state` first, then the detail behind it. */
243
+ export function formatMigrateCheckJson(
244
+ r: MigrateCheckResult,
245
+ schemaExists: boolean
246
+ ): string {
247
+ return JSON.stringify({
248
+ state: migrateState(r, schemaExists),
249
+ exitCode: migrateCheckExitCode(r),
250
+ chain: r.chain,
251
+ applied: r.applied,
252
+ pending: r.pending,
253
+ unknown: r.unknown,
254
+ legacy: r.legacy,
255
+ adoptable: r.adoptable,
256
+ });
257
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * `intelligo migrate` — apply the framework's migration chain.
3
+ *
4
+ * A consumer application has two migration chains in one database:
5
+ * the framework's, shipped inside `@intelligo-dev/core` (its `.sql`
6
+ * files and journal are in the package's `files`), and its own, which
7
+ * drizzle-kit generates from the tables the application owns. They
8
+ * must not share a journal — drizzle-kit applies by timestamp, so a
9
+ * framework migration published after the consumer generated one of
10
+ * theirs would be silently skipped — and a consumer cannot write into
11
+ * `node_modules` anyway. So the framework chain is applied by this
12
+ * command, into drizzle's default `drizzle.__drizzle_migrations`
13
+ * table, and the consumer's chain by `drizzle-kit migrate` from their
14
+ * own `drizzle.config.ts`, into a table of its own (the scaffold sets
15
+ * `migrations.table` to `__app_migrations`).
16
+ *
17
+ * Migrations are selected by content hash (what `migrate --check`
18
+ * compares), not by drizzle's journal-timestamp rule, and are recorded
19
+ * in the same table drizzle's migrator writes — see
20
+ * `readPendingMigrations` for why.
21
+ *
22
+ * The one thing this refuses to do is guess. A database with the
23
+ * framework's tables but no migration records was provisioned with
24
+ * `db:push`; applying the whole chain to it would fail part-way (not
25
+ * every migration is `IF NOT EXISTS`-guarded) and leave the records
26
+ * half-written. Baselining is the fix, and it is deliberately a manual
27
+ * step — see `packages/core/src/db/migrations/README.md`.
28
+ */
29
+
30
+ import { readFileSync } from "node:fs";
31
+ import path from "node:path";
32
+
33
+ import {
34
+ hashMigration,
35
+ isPartialLegacy,
36
+ migrateCheck,
37
+ type MigrateCheckResult,
38
+ } from "./migrate-check.js";
39
+
40
+ export type ApplyDecision =
41
+ | {
42
+ action: "apply";
43
+ pending: string[];
44
+ /** Recorded as applied without running: the schema is already there. */
45
+ adopted: string[];
46
+ }
47
+ | { action: "noop" }
48
+ | { action: "refuse"; reason: string };
49
+
50
+ /**
51
+ * What to do with a database, given the check result and whether the
52
+ * framework's schema already exists in it. Pure, so the three states
53
+ * that matter — empty, push-provisioned, behind — are unit-testable
54
+ * without a database.
55
+ */
56
+ export function decideApply(
57
+ check: MigrateCheckResult,
58
+ schemaExists: boolean
59
+ ): ApplyDecision {
60
+ if (check.unknown.length > 0) {
61
+ return {
62
+ action: "refuse",
63
+ reason:
64
+ `Database is ahead: ${check.unknown.length} applied migration(s) are ` +
65
+ `not in this checkout (${check.unknown.join(", ")}). Upgrade ` +
66
+ `@intelligo-dev/core before migrating, or restore the database.`,
67
+ };
68
+ }
69
+
70
+ if (isPartialLegacy(check)) {
71
+ return {
72
+ action: "refuse",
73
+ reason:
74
+ `This database ran ${check.legacy.length} of the ` +
75
+ `${check.legacyChainLength} pre-1.0 migrations. Finish that chain ` +
76
+ `with @intelligo-dev/core 1.0.0-beta.7 (\`intelligo migrate\`) ` +
77
+ `before upgrading.`,
78
+ };
79
+ }
80
+
81
+ if (check.adoptable) {
82
+ const [baseline, ...rest] = check.pending;
83
+ return {
84
+ action: "apply",
85
+ adopted: baseline ? [baseline] : [],
86
+ pending: rest,
87
+ };
88
+ }
89
+
90
+ if (check.unmanaged && schemaExists) {
91
+ return {
92
+ action: "refuse",
93
+ reason:
94
+ `This database has the framework's tables but no migration records — ` +
95
+ `it was provisioned with db:push. Baseline it before running migrate, ` +
96
+ `otherwise all ${check.chain.length} migrations would be applied to ` +
97
+ `tables that already exist. See README.md in @intelligo-dev/core's ` +
98
+ `src/db/migrations.`,
99
+ };
100
+ }
101
+
102
+ if (check.pending.length === 0) return { action: "noop" };
103
+
104
+ return { action: "apply", pending: check.pending, adopted: [] };
105
+ }
106
+
107
+ type QueryFn = (sql: string) => Promise<Array<Record<string, unknown>>>;
108
+
109
+ /**
110
+ * One migration ready to run: its statements and the journal entry it
111
+ * will be recorded under. `createdAt` is the journal's `when`, which is
112
+ * what drizzle-kit writes to `created_at` too, so records made here and
113
+ * records made by drizzle's own migrator are indistinguishable.
114
+ */
115
+ export type PendingMigration = {
116
+ tag: string;
117
+ hash: string;
118
+ createdAt: number;
119
+ statements: string[];
120
+ };
121
+
122
+ /**
123
+ * Read the pending migrations off disk, in journal order.
124
+ *
125
+ * The chain is applied by content hash — the same key `migrate --check`
126
+ * compares — and not by drizzle's rule of "every entry whose journal
127
+ * timestamp is greater than the last applied row's". That rule depends
128
+ * on `when` values increasing monotonically, which a hand-numbered
129
+ * journal does not guarantee: an entry numbered below an applied one is
130
+ * skipped silently while the check reports it pending. Selecting by hash makes "pending" mean exactly what the check says.
131
+ */
132
+ export function readPendingMigrations(
133
+ migrationsDir: string,
134
+ pendingTags: string[]
135
+ ): PendingMigration[] {
136
+ const journal = JSON.parse(
137
+ readFileSync(path.join(migrationsDir, "meta", "_journal.json"), "utf8")
138
+ ) as {
139
+ entries?: Array<{ tag: string; when: number; breakpoints?: boolean }>;
140
+ };
141
+ const byTag = new Map((journal.entries ?? []).map((e) => [e.tag, e]));
142
+
143
+ return pendingTags.map((tag) => {
144
+ const entry = byTag.get(tag);
145
+ if (!entry) throw new Error(`${tag} is pending but not in the journal`);
146
+ const sql = readFileSync(path.join(migrationsDir, `${tag}.sql`), "utf8");
147
+ const statements = (
148
+ entry.breakpoints === false
149
+ ? [sql]
150
+ : sql.split("--> statement-breakpoint")
151
+ )
152
+ .map((s) => s.trim())
153
+ .filter((s) => s.length > 0);
154
+ return { tag, hash: hashMigration(sql), createdAt: entry.when, statements };
155
+ });
156
+ }
157
+
158
+ /** The records table drizzle's migrator uses, created the way it creates it. */
159
+ export const MIGRATIONS_TABLE_SQL = [
160
+ `CREATE SCHEMA IF NOT EXISTS "drizzle"`,
161
+ `CREATE TABLE IF NOT EXISTS "drizzle"."__drizzle_migrations" (id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint)`,
162
+ ];
163
+
164
+ export type ApplyMigrationsOptions = {
165
+ migrationsDir: string;
166
+ /** Runs a read-only query and returns its rows. */
167
+ query: QueryFn;
168
+ /**
169
+ * Applies the given migrations in order, all in one transaction,
170
+ * recording each in `drizzle.__drizzle_migrations` as it goes.
171
+ */
172
+ run: (pending: PendingMigration[]) => Promise<void>;
173
+ };
174
+
175
+ export type ApplyMigrationsResult = ApplyDecision & {
176
+ /** After the run, for the summary line and for `noop` detection. */
177
+ chainLength: number;
178
+ };
179
+
180
+ /**
181
+ * `users` is the first table the framework creates (auth), so its
182
+ * presence with no migration records is the push-provisioned signature.
183
+ */
184
+ export const SCHEMA_PROBE_SQL = `SELECT to_regclass('public.users') AS rel`;
185
+
186
+ export async function applyMigrations(
187
+ options: ApplyMigrationsOptions
188
+ ): Promise<ApplyMigrationsResult> {
189
+ const check = await migrateCheck(
190
+ options.migrationsDir,
191
+ async (sql) => (await options.query(sql)) as Array<{ hash: string }>
192
+ );
193
+
194
+ const probe = await options.query(SCHEMA_PROBE_SQL);
195
+ const schemaExists = probe.length > 0 && probe[0]!.rel != null;
196
+
197
+ const decision = decideApply(check, schemaExists);
198
+ if (decision.action === "apply") {
199
+ // An adopted migration is recorded with no statements: its schema is
200
+ // already in the database.
201
+ const adopted = readPendingMigrations(
202
+ options.migrationsDir,
203
+ decision.adopted
204
+ ).map((m) => ({ ...m, statements: [] }));
205
+ await options.run([
206
+ ...adopted,
207
+ ...readPendingMigrations(options.migrationsDir, decision.pending),
208
+ ]);
209
+ }
210
+
211
+ return { ...decision, chainLength: check.chain.length };
212
+ }
213
+
214
+ export function formatApplyResult(r: ApplyMigrationsResult): string {
215
+ switch (r.action) {
216
+ case "refuse":
217
+ return `✗ ${r.reason}`;
218
+ case "noop":
219
+ return `✓ Nothing to apply (${r.chainLength}/${r.chainLength} applied)`;
220
+ case "apply":
221
+ return [
222
+ ...r.adopted.map(
223
+ (tag) =>
224
+ `✓ Adopted ${tag}: this database ran the pre-1.0 chain, so its schema is already there`
225
+ ),
226
+ `✓ Applied ${r.pending.length} migration(s):`,
227
+ ...r.pending.map((tag) => ` ${tag}`),
228
+ ].join("\n");
229
+ }
230
+ }
231
+
232
+ export function applyExitCode(r: ApplyMigrationsResult): number {
233
+ return r.action === "refuse" ? 1 : 0;
234
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * `intelligo upgrade --check`
3
+ *
4
+ * Reports what a template upgrade would do, and does nothing. Upgrades
5
+ * never overwrite consumer source, so the interesting output is not
6
+ * "these templates changed" but "these changed AND you have edited
7
+ * them" — the set where the consumer has to make a decision.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import path from "node:path";
12
+
13
+ import { readCatalogue, substitute } from "./add.js";
14
+ import { hashContents, readManifest, type Manifest } from "../manifest.js";
15
+
16
+ export type UpgradeItem = {
17
+ feature: string;
18
+ path: string;
19
+ /**
20
+ * - `current` — generated file matches the current template
21
+ * - `outdated` — template changed; the local copy is untouched, so
22
+ * re-generating is safe
23
+ * - `conflict` — template changed AND the consumer edited the file
24
+ * - `customized` — consumer edited it; template unchanged
25
+ * - `deleted` — consumer removed it
26
+ * - `new` — the template has a file this app was never given
27
+ */
28
+ state: "current" | "outdated" | "conflict" | "customized" | "deleted" | "new";
29
+ };
30
+
31
+ export type UpgradeReport = {
32
+ installedVersion: Record<string, string>;
33
+ templateVersion: Record<string, string>;
34
+ items: UpgradeItem[];
35
+ };
36
+
37
+ export type UpgradeCheckOptions = {
38
+ appRoot: string;
39
+ templatesDir: string;
40
+ };
41
+
42
+ export function upgradeCheck(options: UpgradeCheckOptions): UpgradeReport {
43
+ const manifest: Manifest | null = readManifest(options.appRoot);
44
+ if (!manifest) {
45
+ return { installedVersion: {}, templateVersion: {}, items: [] };
46
+ }
47
+
48
+ const catalogue = readCatalogue(options.templatesDir);
49
+ const report: UpgradeReport = {
50
+ installedVersion: {},
51
+ templateVersion: {},
52
+ items: [],
53
+ };
54
+
55
+ for (const [feature, entry] of Object.entries(manifest.features)) {
56
+ const spec = catalogue[feature];
57
+ report.installedVersion[feature] = entry.templateVersion;
58
+ if (spec) report.templateVersion[feature] = spec.templateVersion;
59
+
60
+ const targetToTemplate = new Map(
61
+ (spec?.files ?? []).map((f) => [f.target, f.template])
62
+ );
63
+
64
+ for (const target of newFiles(spec, entry.files)) {
65
+ report.items.push({ feature, path: target, state: "new" });
66
+ }
67
+
68
+ for (const file of entry.files) {
69
+ const abs = path.join(options.appRoot, file.path);
70
+ if (!existsSync(abs)) {
71
+ report.items.push({ feature, path: file.path, state: "deleted" });
72
+ continue;
73
+ }
74
+
75
+ const localHash = hashContents(readFileSync(abs, "utf8"));
76
+ const customized = localHash !== file.hash;
77
+
78
+ // Compare against the template as it would be written for THIS
79
+ // app: the recorded hash is of substituted content, so the raw
80
+ // template never matches a file carrying a placeholder.
81
+ const templateRel = targetToTemplate.get(file.path);
82
+ let templateChanged = false;
83
+ if (templateRel) {
84
+ const templateHash = hashContents(
85
+ substitute(
86
+ readFileSync(path.join(options.templatesDir, templateRel), "utf8"),
87
+ entry.variables
88
+ )
89
+ );
90
+ templateChanged = templateHash !== file.hash;
91
+ }
92
+
93
+ report.items.push({
94
+ feature,
95
+ path: file.path,
96
+ state: templateChanged
97
+ ? customized
98
+ ? "conflict"
99
+ : "outdated"
100
+ : customized
101
+ ? "customized"
102
+ : "current",
103
+ });
104
+ }
105
+ }
106
+
107
+ return report;
108
+ }
109
+
110
+ /** The feature's files the manifest has no record of: added to the template since. */
111
+ function newFiles(
112
+ spec: { files: { target: string }[] } | undefined,
113
+ recorded: { path: string }[]
114
+ ): string[] {
115
+ const known = new Set(recorded.map((file) => file.path));
116
+ return (spec?.files ?? [])
117
+ .map((file) => file.target)
118
+ .filter((target) => !known.has(target));
119
+ }
120
+
121
+ export function formatUpgradeReport(r: UpgradeReport): string {
122
+ if (r.items.length === 0) {
123
+ return "No generated files recorded — nothing to upgrade.";
124
+ }
125
+
126
+ const label: Record<UpgradeItem["state"], string> = {
127
+ current: "✓ up to date",
128
+ outdated: "↑ template changed — safe to re-run `intelligo add`",
129
+ conflict: "! template changed AND you edited it — review the diff",
130
+ customized: "= yours (template unchanged)",
131
+ deleted: "✗ removed by you",
132
+ new: "+ new in the template — `intelligo add` writes it",
133
+ };
134
+
135
+ const lines: string[] = [];
136
+ for (const [feature, installed] of Object.entries(r.installedVersion)) {
137
+ const latest = r.templateVersion[feature];
138
+ lines.push(
139
+ latest && latest !== installed
140
+ ? `${feature}: ${installed} → ${latest}`
141
+ : `${feature}: ${installed}`
142
+ );
143
+ for (const item of r.items.filter((i) => i.feature === feature)) {
144
+ lines.push(` ${label[item.state]} ${item.path}`);
145
+ }
146
+ }
147
+ return lines.join("\n");
148
+ }
149
+
150
+ /**
151
+ * Non-zero only on conflicts. An outdated-but-untouched file is not a
152
+ * problem to fail a build over; a file that changed on both sides is a
153
+ * decision someone has to make.
154
+ */
155
+ export function upgradeCheckExitCode(r: UpgradeReport): number {
156
+ return r.items.some((i) => i.state === "conflict") ? 1 : 0;
157
+ }
@@ -0,0 +1,31 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parseEnv } from "node:util";
4
+
5
+ /**
6
+ * The env files a Next.js app reads, in the order it reads them: an
7
+ * earlier file wins, and a variable already set in the shell wins over
8
+ * every file.
9
+ *
10
+ * `migrate` and `doctor` run outside Next, so without this a fresh app
11
+ * whose `DATABASE_URL` lives in `.env.local` — exactly what the
12
+ * quickstart says to do — would fail with "DATABASE_URL is required".
13
+ */
14
+ export const ENV_FILES = [".env.local", ".env"] as const;
15
+
16
+ export function loadAppEnv(
17
+ root: string,
18
+ env: NodeJS.ProcessEnv = process.env
19
+ ): string[] {
20
+ const loaded: string[] = [];
21
+ for (const name of ENV_FILES) {
22
+ const file = path.join(root, name);
23
+ if (!existsSync(file)) continue;
24
+ const values = parseEnv(readFileSync(file, "utf8"));
25
+ for (const [key, value] of Object.entries(values)) {
26
+ if (env[key] === undefined) env[key] = value;
27
+ }
28
+ loaded.push(name);
29
+ }
30
+ return loaded;
31
+ }