@topogram/cli 0.3.74 → 0.3.75

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.74",
3
+ "version": "0.3.75",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -31,7 +31,8 @@ import { DEFAULT_TOPO_FOLDER_NAME, resolveTopoRoot, resolveWorkspaceContext } fr
31
31
  * @typedef {{ command: string, reason: string, phase: string }} AgentBriefCommand
32
32
  * @typedef {{ path: string, ownership: string, rule: string }} AgentBriefOutputBoundary
33
33
  * @typedef {{ id: string, title: string, commands: string[], rule: string }} AgentBriefWorkflow
34
- * @typedef {{ id: string, kind: string, projection: string|null, generator: string|null, uses_api: string|null, uses_database: string|null }} AgentBriefRuntime
34
+ * @typedef {{ ownership: string|null, tool: string|null, apply: string|null, statePath: string|null, snapshotPath: string|null, schemaPath: string|null, migrationsPath: string|null }} AgentBriefMigration
35
+ * @typedef {{ id: string, kind: string, projection: string|null, generator: string|null, uses_api: string|null, uses_database: string|null, migration: AgentBriefMigration|null }} AgentBriefRuntime
35
36
  * @typedef {{ path: string, workspaceRoot: string, source: string|null, tracks: string[], candidateCounts: Record<string, any>, ownership: string|null }} AgentBriefImport
36
37
  * @typedef {{ ok: true, payload: Record<string, any> } | { ok: false, kind: "topogram", validation: any } | { ok: false, kind: "project", validation: any, configPath: string }} AgentBriefResult
37
38
  */
@@ -103,7 +104,18 @@ function summarizeRuntimes(config) {
103
104
  projection: typeof runtime.projection === "string" ? runtime.projection : null,
104
105
  generator: typeof runtime.generator?.id === "string" ? runtime.generator.id : null,
105
106
  uses_api: typeof runtime.uses_api === "string" ? runtime.uses_api : null,
106
- uses_database: typeof runtime.uses_database === "string" ? runtime.uses_database : null
107
+ uses_database: typeof runtime.uses_database === "string" ? runtime.uses_database : null,
108
+ migration: runtime.kind === "database" && runtime.migration
109
+ ? {
110
+ ownership: typeof runtime.migration.ownership === "string" ? runtime.migration.ownership : null,
111
+ tool: typeof runtime.migration.tool === "string" ? runtime.migration.tool : null,
112
+ apply: typeof runtime.migration.apply === "string" ? runtime.migration.apply : null,
113
+ statePath: typeof runtime.migration.statePath === "string" ? runtime.migration.statePath : null,
114
+ snapshotPath: typeof runtime.migration.snapshotPath === "string" ? runtime.migration.snapshotPath : null,
115
+ schemaPath: typeof runtime.migration.schemaPath === "string" ? runtime.migration.schemaPath : null,
116
+ migrationsPath: typeof runtime.migration.migrationsPath === "string" ? runtime.migration.migrationsPath : null
117
+ }
118
+ : null
107
119
  }));
108
120
  }
109
121
 
@@ -495,7 +507,10 @@ export function formatAgentBrief(brief) {
495
507
  runtime.uses_api ? `uses_api=${runtime.uses_api}` : null,
496
508
  runtime.uses_database ? `uses_database=${runtime.uses_database}` : null
497
509
  ].filter(Boolean).join(", ");
498
- lines.push(` - ${runtime.id}: ${runtime.kind}${runtime.projection ? ` -> ${runtime.projection}` : ""}${runtime.generator ? ` via ${runtime.generator}` : ""}${edges ? ` (${edges})` : ""}`);
510
+ const migration = runtime.migration
511
+ ? ` migration=${runtime.migration.ownership}/${runtime.migration.tool} apply=${runtime.migration.apply}`
512
+ : "";
513
+ lines.push(` - ${runtime.id}: ${runtime.kind}${runtime.projection ? ` -> ${runtime.projection}` : ""}${runtime.generator ? ` via ${runtime.generator}` : ""}${edges ? ` (${edges})` : ""}${migration}`);
499
514
  }
500
515
  if ((brief.topology?.runtimes || []).length === 0) {
501
516
  lines.push(" - No topology runtimes configured.");
@@ -80,6 +80,17 @@ function summarizeProjectTopology(config) {
80
80
  version: component.generator?.version || null
81
81
  },
82
82
  port: topologyComponentPort(component),
83
+ migration: component.kind === "database" && component.migration
84
+ ? {
85
+ ownership: component.migration.ownership || null,
86
+ tool: component.migration.tool || null,
87
+ apply: component.migration.apply || null,
88
+ statePath: component.migration.statePath || null,
89
+ snapshotPath: component.migration.snapshotPath || null,
90
+ schemaPath: component.migration.schemaPath || null,
91
+ migrationsPath: component.migration.migrationsPath || null
92
+ }
93
+ : null,
83
94
  references: topologyComponentReferences(component)
84
95
  }))
85
96
  .sort((left, right) => left.id.localeCompare(right.id));
@@ -136,7 +147,10 @@ function formatTopologyComponent(component) {
136
147
  .filter(([, value]) => Boolean(value))
137
148
  .map(([key, value]) => `${key} ${value}`);
138
149
  const suffix = refs.length ? ` -> ${refs.join(", ")}` : "";
139
- return ` - ${component.id}: ${component.kind} ${component.projection} via ${generator} (${port})${suffix}`;
150
+ const migration = component.migration
151
+ ? ` [migration ${component.migration.ownership}/${component.migration.tool}, apply=${component.migration.apply}]`
152
+ : "";
153
+ return ` - ${component.id}: ${component.kind} ${component.projection} via ${generator} (${port})${suffix}${migration}`;
140
154
  }
141
155
 
142
156
  /**
@@ -177,7 +177,7 @@ function buildContractsForContext(context) {
177
177
  if (surface === "database") {
178
178
  return {
179
179
  db: generateDbContractGraph(context.graph, { ...(context.options || {}), projectionId }),
180
- lifecyclePlan: generateDbLifecyclePlan(context.graph, { ...(context.options || {}), projectionId })
180
+ lifecyclePlan: generateDbLifecyclePlan(context.graph, { ...(context.options || {}), projectionId, runtime, component: runtime, topology: context.topology })
181
181
  };
182
182
  }
183
183
  if (surface === "native" || surface === "ios_surface" || surface === "android_surface") {
@@ -48,7 +48,7 @@ function buildAppBundlePlan(graph, options = {}) {
48
48
  const runtimeReference = runtimeReferenceFor(graph, options);
49
49
  const topology = resolveRuntimeTopology(graph, options);
50
50
  const { apiProjection, uiProjection, dbProjection } = getDefaultEnvironmentProjections(graph, options);
51
- const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id }) : null;
51
+ const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id, runtime: topology.primaryDb || undefined }) : null;
52
52
  const environmentProfile = options.profileId || "local_process";
53
53
  const deployProfile = options.deployProfileId || "fly_io";
54
54
  const smokeVerification = buildVerificationSummary(graph, ["smoke", "journey"]);
@@ -77,7 +77,8 @@ function buildAppBundlePlan(graph, options = {}) {
77
77
  generator: runtime.generator,
78
78
  port: runtime.port ?? null,
79
79
  uses_api: runtime.api || null,
80
- uses_database: runtime.database || null
80
+ uses_database: runtime.database || null,
81
+ ...(runtime.kind === "database" && runtime.migration ? { migration: runtime.migration } : {})
81
82
  }))
82
83
  },
83
84
  runtimeReference,
@@ -43,7 +43,7 @@ function buildEnvironmentPlan(graph, options = {}) {
43
43
  const runtimeReference = runtimeReferenceFor(graph, options);
44
44
  const topology = resolveRuntimeTopology(graph, options);
45
45
  const { apiProjection, uiProjection, dbProjection } = getDefaultEnvironmentProjections(graph, options);
46
- const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id }) : null;
46
+ const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id, runtime: topology.primaryDb || undefined }) : null;
47
47
  const dbEngine = dbLifecycle?.engine || null;
48
48
  const profile = options.profileId || (dbEngine === "sqlite" || !dbProjection ? "local_process" : "local_docker");
49
49
  const usesDocker = profile === "local_docker";
@@ -67,7 +67,8 @@ function buildEnvironmentPlan(graph, options = {}) {
67
67
  generator: runtime.generator,
68
68
  port: runtime.port ?? null,
69
69
  uses_api: runtime.api || null,
70
- uses_database: runtime.database || null
70
+ uses_database: runtime.database || null,
71
+ ...(runtime.kind === "database" && runtime.migration ? { migration: runtime.migration } : {})
71
72
  }))
72
73
  },
73
74
  projections: {
@@ -145,7 +146,8 @@ function buildEnvironmentPlan(graph, options = {}) {
145
146
  engine: component.id === topology.primaryDb?.id ? dbEngine : null,
146
147
  port: component.port,
147
148
  dir: topology.dbDir(component),
148
- env: dbEnvVarsForComponent(component, { primary: component.id === topology.primaryDb?.id })
149
+ env: dbEnvVarsForComponent(component, { primary: component.id === topology.primaryDb?.id }),
150
+ ...(component.migration ? { migration: component.migration } : {})
149
151
  }))
150
152
  },
151
153
  ports: {
@@ -329,7 +331,8 @@ ${dockerSection}
329
331
  - ${hasWeb && hasApi ? "The generated web app talks to `PUBLIC_TOPOGRAM_API_BASE_URL`." : hasWeb ? "The generated web app is standalone." : "No web surface is generated."}
330
332
  - ${hasNative ? "Native app scaffolds use the same UI surface contracts as web surfaces." : "No native surface is generated."}
331
333
  - If \`.env\` is missing, generated scripts fall back to \`.env.example\`.
332
- - The DB lifecycle scripts remain the source of truth for greenfield bootstrap and brownfield migration.
334
+ - Generated-owned DB lifecycle scripts remain the source of truth for greenfield bootstrap and generated migrations.
335
+ - Maintained DB lifecycle scripts emit proposals only and never apply migrations.
333
336
  `;
334
337
  }
335
338
 
@@ -349,6 +352,10 @@ function renderEnvironmentBootstrapDbScript(plan) {
349
352
  return `(cd "$ROOT_DIR/${component.dir}" && TOPOGRAM_ENV_FILE=/dev/null ${assignments} bash ./scripts/db-bootstrap-or-migrate.sh)`;
350
353
  });
351
354
  const primaryApi = plan.runtimes.apis[0];
355
+ const primaryApiDatabase = primaryApi?.uses_database
356
+ ? plan.runtimes.databases.find((component) => component.id === primaryApi.uses_database)
357
+ : null;
358
+ const seedAllowed = Boolean(primaryApi?.uses_database) && primaryApiDatabase?.migration?.ownership !== "maintained";
352
359
  if (plan.runtimes.databases.length === 0) {
353
360
  return renderEnvAwareShellScript([
354
361
  'echo "No database runtimes are configured; skipping DB bootstrap."'
@@ -366,9 +373,11 @@ function renderEnvironmentBootstrapDbScript(plan) {
366
373
  ...dbBootstrapLines,
367
374
  'if [[ "${TOPOGRAM_SEED_DEMO:-true}" != "false" ]]; then',
368
375
  ...apiDatabaseExportLines(primaryApi),
369
- ...(primaryApi?.uses_database
376
+ ...(seedAllowed
370
377
  ? [`(cd "$ROOT_DIR/${primaryApi.dir}" && npm install && npm exec -- prisma generate --schema prisma/schema.prisma && npm exec -- prisma db push --schema prisma/schema.prisma --skip-generate && npm run seed:demo)`]
371
- : ['echo "No DB-backed API component is configured; skipping demo seed."']),
378
+ : [primaryApi?.uses_database
379
+ ? 'echo "Primary API uses a maintained database runtime; skipping generated demo seed."'
380
+ : 'echo "No DB-backed API component is configured; skipping demo seed."']),
372
381
  "fi"
373
382
  ]);
374
383
  }
@@ -27,6 +27,7 @@ import { defaultProjectConfigForGraph, validateProjectConfig } from "../../../pr
27
27
  * @property {string|null} [api]
28
28
  * @property {string|null} [database]
29
29
  * @property {Record<string, string>} [env]
30
+ * @property {import("../../../project-config.js").RuntimeMigrationStrategy} [migration]
30
31
  * @property {RuntimeComponent|null} [apiRuntime]
31
32
  * @property {RuntimeComponent|null} [databaseRuntime]
32
33
  * @property {RuntimeComponent|null} [apiComponent] Legacy adapter alias for apiRuntime.
@@ -17,11 +17,65 @@ function defaultInputPathForGraph(graph, options = {}) {
17
17
  return ".";
18
18
  }
19
19
 
20
+ function runtimeMigrationStrategyForProjection(projection, options = {}) {
21
+ const projectionId = projection?.id || options.projectionId || null;
22
+ const runtime = options.runtime || options.component || null;
23
+ if (runtime?.kind === "database" && runtime?.migration && (!projectionId || runtime.projection?.id === projectionId || runtime.projection === projectionId)) {
24
+ return runtime.migration;
25
+ }
26
+
27
+ const topologyRuntime = (options.topology?.dbRuntimes || options.topology?.runtimes || [])
28
+ .find((entry) => entry?.kind === "database" && entry?.migration && (entry.projection?.id === projectionId || entry.projection === projectionId));
29
+ if (topologyRuntime?.migration) {
30
+ return topologyRuntime.migration;
31
+ }
32
+
33
+ const configRuntime = (options.projectConfig?.topology?.runtimes || [])
34
+ .find((entry) => entry?.kind === "database" && entry?.migration && entry.projection === projectionId);
35
+ return configRuntime?.migration || null;
36
+ }
37
+
38
+ function normalizeMigrationStrategy(strategy) {
39
+ if (!strategy) {
40
+ return {
41
+ ownership: "generated",
42
+ tool: "sql",
43
+ apply: "script",
44
+ statePath: null,
45
+ snapshotPath: null,
46
+ schemaPath: null,
47
+ migrationsPath: null,
48
+ defaulted: true
49
+ };
50
+ }
51
+ return {
52
+ ownership: strategy.ownership || "generated",
53
+ tool: strategy.tool || "sql",
54
+ apply: strategy.apply || (strategy.ownership === "maintained" ? "never" : "script"),
55
+ statePath: strategy.statePath || null,
56
+ snapshotPath: strategy.snapshotPath || null,
57
+ schemaPath: strategy.schemaPath || null,
58
+ migrationsPath: strategy.migrationsPath || null,
59
+ defaulted: false
60
+ };
61
+ }
62
+
63
+ function pathJoinPosix(...parts) {
64
+ return parts.filter(Boolean).join("/").replace(/\/+/g, "/");
65
+ }
66
+
20
67
  function dbLifecyclePlan(graph, projection, options = {}) {
21
68
  const contract = buildDbProjectionContract(graph, projection);
22
69
  const snapshot = normalizeDbSchemaSnapshot(contract);
23
70
  const engine = snapshot.engine;
24
71
  const ormProfiles = engine === "postgres" ? ["prisma", "drizzle"] : ["prisma"];
72
+ const migrationStrategy = normalizeMigrationStrategy(runtimeMigrationStrategyForProjection(projection, options));
73
+ const generatedStateRoot = migrationStrategy.statePath || "state";
74
+ const proposalStateRoot = "state";
75
+ const stateRoot = migrationStrategy.ownership === "generated" ? generatedStateRoot : proposalStateRoot;
76
+ const currentSnapshot = migrationStrategy.ownership === "maintained" && migrationStrategy.snapshotPath
77
+ ? migrationStrategy.snapshotPath
78
+ : pathJoinPosix(stateRoot, "current.snapshot.json");
25
79
 
26
80
  return {
27
81
  type: "db_lifecycle_plan",
@@ -31,19 +85,26 @@ function dbLifecyclePlan(graph, projection, options = {}) {
31
85
  ormProfiles,
32
86
  runtimeProfile: ormProfiles.includes("prisma") ? "prisma" : null,
33
87
  inputPath: defaultInputPathForGraph(graph, options),
88
+ migrationStrategy,
34
89
  state: {
35
- currentSnapshot: "state/current.snapshot.json",
36
- desiredSnapshot: "state/desired.snapshot.json",
37
- migrationPlan: "state/migration.plan.json",
38
- migrationSql: "state/migration.sql"
90
+ root: stateRoot,
91
+ currentSnapshot,
92
+ desiredSnapshot: pathJoinPosix(stateRoot, "desired.snapshot.json"),
93
+ migrationPlan: pathJoinPosix(stateRoot, "migration.plan.json"),
94
+ migrationSql: pathJoinPosix(stateRoot, "migration.sql")
39
95
  },
40
96
  bundle: {
41
97
  emptySnapshot: "snapshots/empty.snapshot.json",
42
98
  prismaSchema: ormProfiles.includes("prisma") ? "prisma/schema.prisma" : null,
43
99
  drizzleSchema: engine === "postgres" ? "drizzle/schema.ts" : null
44
100
  },
101
+ proposals: {
102
+ schemaPath: migrationStrategy.schemaPath,
103
+ migrationsPath: migrationStrategy.migrationsPath,
104
+ snapshotPath: migrationStrategy.snapshotPath
105
+ },
45
106
  environment: {
46
- required: ["DATABASE_URL"],
107
+ required: migrationStrategy.ownership === "maintained" ? [] : ["DATABASE_URL"],
47
108
  optional:
48
109
  engine === "postgres"
49
110
  ? ["DATABASE_ADMIN_URL", "TOPOGRAM_BIN", "TOPOGRAM_INPUT_PATH", "TOPOGRAM_DB_STATE_DIR"]
@@ -75,6 +136,14 @@ function dbLifecyclePlan(graph, projection, options = {}) {
75
136
  "persist desired snapshot as current"
76
137
  ],
77
138
  safety: "manual migration required plans are never auto-applied"
139
+ },
140
+ behavior: {
141
+ ownership: migrationStrategy.ownership,
142
+ tool: migrationStrategy.tool,
143
+ apply: migrationStrategy.apply,
144
+ appliesMigrations: migrationStrategy.ownership === "generated" && migrationStrategy.apply === "script",
145
+ writesGeneratedState: migrationStrategy.ownership === "generated",
146
+ proposalOnly: migrationStrategy.ownership === "maintained"
78
147
  }
79
148
  };
80
149
  }
@@ -108,10 +177,28 @@ function renderDbLifecycleEnvExample(projection, plan) {
108
177
  }
109
178
 
110
179
  function renderDbLifecycleReadme(plan) {
180
+ const proposalLines = [
181
+ plan.proposals.snapshotPath ? `- Current snapshot source: \`${plan.proposals.snapshotPath}\`` : null,
182
+ plan.proposals.schemaPath ? `- Maintained schema path: \`${plan.proposals.schemaPath}\`` : null,
183
+ plan.proposals.migrationsPath ? `- Maintained migrations path: \`${plan.proposals.migrationsPath}\`` : null
184
+ ].filter(Boolean).join("\n");
185
+ const modeText = plan.behavior.proposalOnly
186
+ ? `Maintained proposal mode. Topogram emits desired snapshots, migration plans, SQL proposals, and schema proposals, but these scripts do not apply migrations to the database. A human or agent must adapt accepted proposals into the maintained migration system.`
187
+ : `Generated apply mode. Topogram owns this lifecycle bundle and scripts may apply supported generated SQL migrations. Unsupported or destructive migration plans stop for manual review.`;
111
188
  return `# ${plan.projection.name} Lifecycle
112
189
 
113
190
  This bundle gives agents a repeatable database workflow for projection \`${plan.projection.id}\`.
114
191
 
192
+ ## Migration Strategy
193
+
194
+ - Ownership: \`${plan.migrationStrategy.ownership}\`
195
+ - Tool: \`${plan.migrationStrategy.tool}\`
196
+ - Apply: \`${plan.migrationStrategy.apply}\`
197
+ - Defaulted: \`${plan.migrationStrategy.defaulted ? "yes" : "no"}\`
198
+
199
+ ${modeText}
200
+ ${proposalLines ? `\n${proposalLines}\n` : ""}
201
+
115
202
  ## Modes
116
203
 
117
204
  - Greenfield: run \`./scripts/db-bootstrap-or-migrate.sh\` with no current snapshot
@@ -143,6 +230,12 @@ ${plan.environment.optional.map((name) => `- \`${name}\``).join("\n")}
143
230
 
144
231
  function renderDbLifecycleCommonScript(plan) {
145
232
  const engine = plan.engine;
233
+ const configuredStatePath = plan.migrationStrategy.ownership === "generated" && plan.migrationStrategy.statePath
234
+ ? plan.migrationStrategy.statePath
235
+ : "";
236
+ const configuredSnapshotPath = plan.migrationStrategy.ownership === "maintained" && plan.migrationStrategy.snapshotPath
237
+ ? plan.migrationStrategy.snapshotPath
238
+ : "";
146
239
  return `#!/usr/bin/env bash
147
240
  set -euo pipefail
148
241
 
@@ -219,9 +312,24 @@ discover_input_path() {
219
312
  }
220
313
  TOPOGRAM_BIN="$(find_topogram_bin)"
221
314
  INPUT_PATH="$(discover_input_path)"
315
+ if [[ -f "$INPUT_PATH/topogram.project.json" ]]; then
316
+ PROJECT_ROOT="$INPUT_PATH"
317
+ else
318
+ PROJECT_ROOT="$(cd "$INPUT_PATH/.." && pwd)"
319
+ fi
222
320
  PROJECTION_ID="${plan.projection.id}"
223
- STATE_DIR="\${TOPOGRAM_DB_STATE_DIR:-$BUNDLE_DIR/state}"
224
- CURRENT_SNAPSHOT="$STATE_DIR/current.snapshot.json"
321
+ CONFIGURED_STATE_PATH=${JSON.stringify(configuredStatePath)}
322
+ CONFIGURED_SNAPSHOT_PATH=${JSON.stringify(configuredSnapshotPath)}
323
+ if [[ -n "$CONFIGURED_STATE_PATH" ]]; then
324
+ STATE_DIR="\${TOPOGRAM_DB_STATE_DIR:-$PROJECT_ROOT/$CONFIGURED_STATE_PATH}"
325
+ else
326
+ STATE_DIR="\${TOPOGRAM_DB_STATE_DIR:-$BUNDLE_DIR/state}"
327
+ fi
328
+ if [[ -n "$CONFIGURED_SNAPSHOT_PATH" ]]; then
329
+ CURRENT_SNAPSHOT="$PROJECT_ROOT/$CONFIGURED_SNAPSHOT_PATH"
330
+ else
331
+ CURRENT_SNAPSHOT="$STATE_DIR/current.snapshot.json"
332
+ fi
225
333
  DESIRED_SNAPSHOT="$STATE_DIR/desired.snapshot.json"
226
334
  PLAN_JSON="$STATE_DIR/migration.plan.json"
227
335
  MIGRATION_SQL="$STATE_DIR/migration.sql"
@@ -429,7 +537,7 @@ ${engine === "sqlite" ? ` normalize_sqlite_database_url
429
537
  `;
430
538
  }
431
539
 
432
- function renderDbStatusScript() {
540
+ function renderDbStatusScript(plan) {
433
541
  return `#!/usr/bin/env bash
434
542
  set -euo pipefail
435
543
  . "$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)/db-common.sh"
@@ -440,11 +548,26 @@ if [[ -f "$CURRENT_SNAPSHOT" ]]; then
440
548
  generate_migration_plan "$CURRENT_SNAPSHOT"
441
549
  cat "$PLAN_JSON"
442
550
  else
443
- echo '{"mode":"greenfield","currentSnapshot":null}'
551
+ echo ${JSON.stringify(plan.behavior.proposalOnly ? '{"mode":"maintained_proposal","currentSnapshot":null}' : '{"mode":"greenfield","currentSnapshot":null}')}
444
552
  fi
445
553
  `;
446
554
  }
447
555
 
556
+ function renderMaintainedDbBootstrapScript() {
557
+ return `#!/usr/bin/env bash
558
+ set -euo pipefail
559
+ . "$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)/db-common.sh"
560
+
561
+ generate_desired_snapshot
562
+ generate_sql_migration "$EMPTY_SNAPSHOT"
563
+
564
+ echo "Maintained database runtime: bootstrap is proposal-only."
565
+ echo "Desired snapshot: $DESIRED_SNAPSHOT"
566
+ echo "SQL proposal: $MIGRATION_SQL"
567
+ echo "No migration was applied. Review and adapt the proposal into the maintained database migration system."
568
+ `;
569
+ }
570
+
448
571
  function renderDbBootstrapScript() {
449
572
  return `#!/usr/bin/env bash
450
573
  set -euo pipefail
@@ -489,6 +612,30 @@ echo "Greenfield bootstrap complete."
489
612
  `;
490
613
  }
491
614
 
615
+ function renderMaintainedDbMigrateScript() {
616
+ return `#!/usr/bin/env bash
617
+ set -euo pipefail
618
+ . "$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)/db-common.sh"
619
+
620
+ generate_desired_snapshot
621
+
622
+ if [[ ! -f "$CURRENT_SNAPSHOT" ]]; then
623
+ echo "Maintained database runtime: current snapshot not found at $CURRENT_SNAPSHOT." >&2
624
+ echo "Create the reviewed snapshot first, then rerun this proposal command." >&2
625
+ exit 1
626
+ fi
627
+
628
+ generate_migration_plan "$CURRENT_SNAPSHOT"
629
+ ensure_supported_plan
630
+ generate_sql_migration "$CURRENT_SNAPSHOT"
631
+
632
+ echo "Maintained database runtime: migration proposal generated."
633
+ echo "Migration plan: $PLAN_JSON"
634
+ echo "SQL proposal: $MIGRATION_SQL"
635
+ echo "No migration was applied. Review and adapt the proposal into the maintained database migration system."
636
+ `;
637
+ }
638
+
492
639
  function renderDbMigrateScript() {
493
640
  return `#!/usr/bin/env bash
494
641
  set -euo pipefail
@@ -524,6 +671,21 @@ echo "Brownfield migration complete."
524
671
  `;
525
672
  }
526
673
 
674
+ function renderMaintainedDbBootstrapOrMigrateScript() {
675
+ return `#!/usr/bin/env bash
676
+ set -euo pipefail
677
+
678
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
679
+ . "$SCRIPT_DIR/db-common.sh"
680
+
681
+ if [[ -f "$CURRENT_SNAPSHOT" ]]; then
682
+ exec bash "$SCRIPT_DIR/db-migrate.sh"
683
+ fi
684
+
685
+ exec bash "$SCRIPT_DIR/db-bootstrap.sh"
686
+ `;
687
+ }
688
+
527
689
  function renderDbBootstrapOrMigrateScript() {
528
690
  return `#!/usr/bin/env bash
529
691
  set -euo pipefail
@@ -571,10 +733,10 @@ function generateDbLifecycleBundle(graph, projection, options = {}) {
571
733
  "README.md": renderDbLifecycleReadme(plan),
572
734
  ".env.example": renderDbLifecycleEnvExample(projection, plan),
573
735
  "scripts/db-common.sh": renderDbLifecycleCommonScript(plan),
574
- "scripts/db-status.sh": renderDbStatusScript(),
575
- "scripts/db-bootstrap.sh": renderDbBootstrapScript(),
576
- "scripts/db-migrate.sh": renderDbMigrateScript(),
577
- "scripts/db-bootstrap-or-migrate.sh": renderDbBootstrapOrMigrateScript(),
736
+ "scripts/db-status.sh": renderDbStatusScript(plan),
737
+ "scripts/db-bootstrap.sh": plan.behavior.proposalOnly ? renderMaintainedDbBootstrapScript() : renderDbBootstrapScript(),
738
+ "scripts/db-migrate.sh": plan.behavior.proposalOnly ? renderMaintainedDbMigrateScript() : renderDbMigrateScript(),
739
+ "scripts/db-bootstrap-or-migrate.sh": plan.behavior.proposalOnly ? renderMaintainedDbBootstrapOrMigrateScript() : renderDbBootstrapOrMigrateScript(),
578
740
  "snapshots/empty.snapshot.json": `${JSON.stringify(renderEmptySnapshotForProjection(projection), null, 2)}\n`,
579
741
  "state/.gitkeep": ""
580
742
  };
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import path from "node:path";
4
+
3
5
  import {
4
6
  findImportFiles,
5
7
  idHintify,
@@ -12,6 +14,7 @@ import {
12
14
 
13
15
  const CLI_SOURCE_PATTERN = /(^|\/)(bin|cli|command|commands|parser|help)(\/|[-_.A-Za-z0-9]*\.(?:js|mjs|cjs|ts))$/i;
14
16
  const JS_SOURCE_PATTERN = /\.(?:js|mjs|cjs|ts)$/i;
17
+ const NON_AUTHORITATIVE_CLI_PATH_PATTERN = /(^|\/)(test|tests|__tests__|fixtures|fixture|expected|snapshots|snapshot|mock|mocks|candidates|docs-generated)(\/|$)|\.(?:test|spec)\.(?:js|mjs|cjs|ts)$/i;
15
18
 
16
19
  /**
17
20
  * @param {string} value
@@ -21,6 +24,18 @@ function normalizePath(value) {
21
24
  return value.replaceAll("\\", "/");
22
25
  }
23
26
 
27
+ /**
28
+ * CLI import should prefer public command surfaces, not tests or fixture strings
29
+ * that happen to look like command help.
30
+ * @param {any} paths
31
+ * @param {string} filePath
32
+ * @returns {boolean}
33
+ */
34
+ function isAuthoritativeCliSource(paths, filePath) {
35
+ const normalized = normalizePath(normalizeImportRelativePath(paths, filePath));
36
+ return !NON_AUTHORITATIVE_CLI_PATH_PATTERN.test(normalized);
37
+ }
38
+
24
39
  /**
25
40
  * @param {string} commandId
26
41
  * @returns {string}
@@ -29,24 +44,44 @@ function capabilityIdForCommand(commandId) {
29
44
  return `cap_${idHintify(commandId)}`;
30
45
  }
31
46
 
47
+ /**
48
+ * @param {string} rawLine
49
+ * @returns {{ text: string, terminalOutput: boolean }}
50
+ */
51
+ function normalizePotentialHelpLine(rawLine) {
52
+ const trimmed = rawLine.trim();
53
+ const terminalOutput = /^(?:console\.(?:log|error|warn)|print)\(\s*["'`]/.test(trimmed) || /^echo\s+["'`]/.test(trimmed);
54
+ return {
55
+ terminalOutput,
56
+ text: trimmed
57
+ .replace(/^\s*(?:console\.(?:log|error|warn)\(|print\(|echo\s+)?["'`]*/, "")
58
+ .replace(/["'`),;]*\s*$/, "")
59
+ .trim()
60
+ };
61
+ }
62
+
32
63
  /**
33
64
  * @param {string} text
65
+ * @param {Set<string>} binNames
34
66
  * @returns {string[]}
35
67
  */
36
- function extractUsageLines(text) {
68
+ function extractUsageLines(text, binNames) {
37
69
  const lines = [];
38
70
  for (const rawLine of text.split(/\r?\n/)) {
39
- const line = rawLine
40
- .replace(/^\s*(?:console\.log\(|print\(|echo\s+)?["'`]*/, "")
41
- .replace(/["'`),;]*\s*$/, "")
42
- .trim();
71
+ const { text: line, terminalOutput } = normalizePotentialHelpLine(rawLine);
43
72
  if (!line) continue;
44
73
  const usageMatch = line.match(/(?:^|\b)Usage:\s*(.+)$/i);
45
74
  if (usageMatch?.[1]) {
46
75
  lines.push(usageMatch[1].trim());
47
76
  continue;
48
77
  }
49
- if (/^[a-zA-Z][\w.-]+(?:\s+[a-zA-Z][\w:-]+)+(?:\s|$)/.test(line) && /(?:--[a-zA-Z][\w:-]*|<[^>]+>|\[[^\]]+\])/.test(line)) {
78
+ const firstToken = line.split(/\s+/)[0];
79
+ if (
80
+ terminalOutput &&
81
+ (binNames.size === 0 || binNames.has(firstToken)) &&
82
+ /^[a-zA-Z][\w.-]+(?:\s+[a-zA-Z][\w:-]+)+(?:\s|$)/.test(line) &&
83
+ /(?:--[a-zA-Z][\w:-]*|<[^>]+>|\[[^\]]+\])/.test(line)
84
+ ) {
50
85
  lines.push(line);
51
86
  }
52
87
  }
@@ -167,19 +202,79 @@ function dedupeRecords(records, keyFn) {
167
202
 
168
203
  /**
169
204
  * @param {any} context
170
- * @returns {{ packageFiles: string[], sourceFiles: string[] }}
205
+ * @param {string[]} packageFiles
206
+ * @returns {{ binNames: Set<string>, binTargets: Set<string>, findings: any[], provenance: string[] }}
207
+ */
208
+ function inspectPackageCliMetadata(context, packageFiles) {
209
+ const binNames = new Set();
210
+ const binTargets = new Set();
211
+ const findings = [];
212
+ const provenance = [];
213
+
214
+ for (const packagePath of packageFiles) {
215
+ const pkg = readJsonIfExists(packagePath);
216
+ if (!pkg) continue;
217
+ const relPath = normalizeImportRelativePath(context.paths, packagePath);
218
+ const bin = pkg.bin;
219
+ if (typeof bin === "string") {
220
+ const binName = pkg.name ? String(pkg.name).split("/").pop() : "cli";
221
+ binNames.add(binName);
222
+ binTargets.add(path.resolve(path.dirname(packagePath), bin));
223
+ provenance.push(`${relPath}#bin`);
224
+ } else if (bin && typeof bin === "object") {
225
+ for (const [name, target] of Object.entries(bin)) {
226
+ binNames.add(name);
227
+ if (typeof target === "string") {
228
+ binTargets.add(path.resolve(path.dirname(packagePath), target));
229
+ }
230
+ }
231
+ provenance.push(`${relPath}#bin`);
232
+ }
233
+ for (const [name, command] of Object.entries(pkg.scripts || {})) {
234
+ if (/^(cli|bin|start|check|test|verify)(:|$)/.test(name) || /\b(node|tsx|ts-node)\b.+\b(cli|bin)\b/i.test(String(command))) {
235
+ findings.push({
236
+ kind: "cli_script",
237
+ name,
238
+ command,
239
+ source: relPath
240
+ });
241
+ }
242
+ }
243
+ }
244
+
245
+ return { binNames, binTargets, findings, provenance };
246
+ }
247
+
248
+ /**
249
+ * @param {any} context
250
+ * @returns {{ packageFiles: string[], sourceFiles: string[], binNames: Set<string>, findings: any[], provenance: string[] }}
171
251
  */
172
252
  function discoverCliSources(context) {
173
253
  const packageFiles = findImportFiles(context.paths, (/** @type {string} */ filePath) => /package\.json$/i.test(filePath));
254
+ const packageMetadata = inspectPackageCliMetadata(context, packageFiles);
174
255
  const sourceFiles = findImportFiles(context.paths, (/** @type {string} */ filePath) => {
175
256
  const normalized = normalizePath(filePath);
257
+ if (!isAuthoritativeCliSource(context.paths, filePath)) {
258
+ return false;
259
+ }
176
260
  return JS_SOURCE_PATTERN.test(normalized) && (
177
261
  CLI_SOURCE_PATTERN.test(normalized) ||
178
262
  normalized.includes("/src/cli/") ||
179
263
  normalized.includes("/commands/")
180
264
  );
181
265
  });
182
- return { packageFiles, sourceFiles };
266
+ for (const binTarget of packageMetadata.binTargets) {
267
+ if (JS_SOURCE_PATTERN.test(binTarget)) {
268
+ sourceFiles.push(binTarget);
269
+ }
270
+ }
271
+ return {
272
+ packageFiles,
273
+ sourceFiles: [...new Set(sourceFiles)].sort(),
274
+ binNames: packageMetadata.binNames,
275
+ findings: packageMetadata.findings,
276
+ provenance: packageMetadata.provenance
277
+ };
183
278
  }
184
279
 
185
280
  export const genericCliExtractor = {
@@ -187,7 +282,7 @@ export const genericCliExtractor = {
187
282
  track: "cli",
188
283
  /** @param {any} context */
189
284
  detect(context) {
190
- const { packageFiles, sourceFiles } = discoverCliSources(context);
285
+ const { packageFiles, sourceFiles, binNames } = discoverCliSources(context);
191
286
  const hasBin = packageFiles.some((filePath) => {
192
287
  const pkg = readJsonIfExists(filePath);
193
288
  return Boolean(pkg?.bin);
@@ -196,53 +291,25 @@ export const genericCliExtractor = {
196
291
  return {
197
292
  score,
198
293
  reasons: [
199
- hasBin ? "package.json declares a CLI bin" : null,
294
+ hasBin ? `package.json declares ${binNames.size || 1} CLI bin${binNames.size === 1 ? "" : "s"}` : null,
200
295
  sourceFiles.length ? `${sourceFiles.length} CLI-like source files found` : null
201
296
  ].filter(Boolean)
202
297
  };
203
298
  },
204
299
  /** @param {any} context */
205
300
  extract(context) {
206
- const { packageFiles, sourceFiles } = discoverCliSources(context);
207
- const findings = [];
301
+ const { sourceFiles, binNames, findings, provenance } = discoverCliSources(context);
208
302
  const commands = [];
209
303
  const options = [];
210
304
  const outputs = [];
211
305
  const effects = [];
212
306
  const examples = [];
213
307
  const capabilities = [];
214
- const binNames = new Set();
215
- const provenance = [];
216
-
217
- for (const packagePath of packageFiles) {
218
- const pkg = readJsonIfExists(packagePath);
219
- if (!pkg) continue;
220
- const relPath = normalizeImportRelativePath(context.paths, packagePath);
221
- const bin = pkg.bin;
222
- if (typeof bin === "string") {
223
- binNames.add(pkg.name ? String(pkg.name).split("/").pop() : "cli");
224
- } else if (bin && typeof bin === "object") {
225
- for (const name of Object.keys(bin)) {
226
- binNames.add(name);
227
- }
228
- }
229
- for (const [name, command] of Object.entries(pkg.scripts || {})) {
230
- if (/^(cli|bin|start|check|test|verify)(:|$)/.test(name) || /\b(node|tsx|ts-node)\b.+\b(cli|bin)\b/i.test(String(command))) {
231
- findings.push({
232
- kind: "cli_script",
233
- name,
234
- command,
235
- source: relPath
236
- });
237
- }
238
- }
239
- provenance.push(`${relPath}#bin`);
240
- }
241
308
 
242
309
  for (const sourcePath of sourceFiles) {
243
310
  const sourceText = readTextIfExists(sourcePath) || "";
244
311
  const relPath = normalizeImportRelativePath(context.paths, sourcePath);
245
- const usageLines = extractUsageLines(sourceText);
312
+ const usageLines = extractUsageLines(sourceText, binNames);
246
313
  if (usageLines.length === 0) {
247
314
  continue;
248
315
  }
@@ -18,6 +18,17 @@ import { DEFAULT_WORKSPACE_PATH, normalizeWorkspaceConfigPath } from "../workspa
18
18
  * @property {string} [package]
19
19
  */
20
20
 
21
+ /**
22
+ * @typedef {Object} RuntimeMigrationStrategy
23
+ * @property {"generated"|"maintained"} ownership
24
+ * @property {"sql"|"prisma"|"drizzle"} tool
25
+ * @property {"never"|"script"} apply
26
+ * @property {string} [statePath]
27
+ * @property {string} [snapshotPath]
28
+ * @property {string} [schemaPath]
29
+ * @property {string} [migrationsPath]
30
+ */
31
+
21
32
  /**
22
33
  * @typedef {Object} RuntimeTopologyRuntime
23
34
  * @property {string} id
@@ -28,6 +39,7 @@ import { DEFAULT_WORKSPACE_PATH, normalizeWorkspaceConfigPath } from "../workspa
28
39
  * @property {string} [uses_api]
29
40
  * @property {string} [uses_database]
30
41
  * @property {Record<string, string>} [env]
42
+ * @property {RuntimeMigrationStrategy} [migration]
31
43
  */
32
44
 
33
45
  /**
@@ -58,6 +70,10 @@ const PROJECT_CONFIG_FILE = "topogram.project.json";
58
70
  const LEGACY_IMPLEMENTATION_FILE = "topogram.implementation.json";
59
71
  const GENERATED_OUTPUT_SENTINEL = ".topogram-generated.json";
60
72
  const IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/;
73
+ const MIGRATION_OWNERSHIPS = new Set(["generated", "maintained"]);
74
+ const MIGRATION_TOOLS = new Set(["sql", "prisma", "drizzle"]);
75
+ const MIGRATION_APPLY_MODES = new Set(["never", "script"]);
76
+ const MIGRATION_PATH_FIELDS = ["statePath", "snapshotPath", "schemaPath", "migrationsPath"];
61
77
 
62
78
  /**
63
79
  * @param {string|null|undefined} root
@@ -249,6 +265,21 @@ function validateWorkspaceConfig(errors, config) {
249
265
  }
250
266
  }
251
267
 
268
+ /**
269
+ * @param {string} value
270
+ * @returns {boolean}
271
+ */
272
+ function isProjectRelativePath(value) {
273
+ if (typeof value !== "string" || value.trim().length === 0) {
274
+ return false;
275
+ }
276
+ if (path.isAbsolute(value)) {
277
+ return false;
278
+ }
279
+ const normalized = path.posix.normalize(value.replace(/\\/g, "/"));
280
+ return normalized !== ".." && !normalized.startsWith("../");
281
+ }
282
+
252
283
  /**
253
284
  * @param {string} root
254
285
  * @returns {ProjectConfigInfo|null}
@@ -388,9 +419,75 @@ function validateRuntimeShape(errors, runtime, seenIds) {
388
419
  if (runtime.port != null && (!Number.isInteger(runtime.port) || runtime.port <= 0 || runtime.port > 65535)) {
389
420
  pushError(errors, `${runtimeLabel(runtime)} port must be an integer from 1 to 65535`);
390
421
  }
422
+ validateRuntimeMigrationStrategy(errors, runtime);
391
423
  return true;
392
424
  }
393
425
 
426
+ /**
427
+ * @param {ValidationError[]} errors
428
+ * @param {any} runtime
429
+ * @returns {void}
430
+ */
431
+ function validateRuntimeMigrationStrategy(errors, runtime) {
432
+ if (runtime.migration == null) {
433
+ return;
434
+ }
435
+ if (runtime.kind !== "database") {
436
+ pushError(errors, `${runtimeLabel(runtime)} migration is only supported on database runtimes`);
437
+ return;
438
+ }
439
+ const migration = runtime.migration;
440
+ if (!migration || typeof migration !== "object" || Array.isArray(migration)) {
441
+ pushError(errors, `${runtimeLabel(runtime)} migration must be an object`);
442
+ return;
443
+ }
444
+ if (!MIGRATION_OWNERSHIPS.has(migration.ownership)) {
445
+ pushError(errors, `${runtimeLabel(runtime)} migration.ownership must be generated or maintained`);
446
+ }
447
+ if (!MIGRATION_TOOLS.has(migration.tool)) {
448
+ pushError(errors, `${runtimeLabel(runtime)} migration.tool must be sql, prisma, or drizzle`);
449
+ }
450
+ if (!MIGRATION_APPLY_MODES.has(migration.apply)) {
451
+ pushError(errors, `${runtimeLabel(runtime)} migration.apply must be never or script`);
452
+ }
453
+ if (migration.ownership === "maintained" && migration.apply !== "never") {
454
+ pushError(errors, `${runtimeLabel(runtime)} maintained migration.apply must be never`);
455
+ }
456
+ if (migration.ownership === "generated" && migration.apply !== "script") {
457
+ pushError(errors, `${runtimeLabel(runtime)} generated migration.apply must be script`);
458
+ }
459
+ for (const field of MIGRATION_PATH_FIELDS) {
460
+ if (migration[field] != null && !isProjectRelativePath(migration[field])) {
461
+ pushError(errors, `${runtimeLabel(runtime)} migration.${field} must be a non-empty relative path that stays inside the project root`);
462
+ }
463
+ }
464
+ if (migration.ownership === "generated" && typeof migration.statePath !== "string") {
465
+ pushError(errors, `${runtimeLabel(runtime)} generated migration requires statePath`);
466
+ }
467
+ if (migration.ownership === "maintained" && typeof migration.snapshotPath !== "string") {
468
+ pushError(errors, `${runtimeLabel(runtime)} maintained migration requires snapshotPath`);
469
+ }
470
+ if (migration.ownership === "maintained" && migration.tool === "prisma") {
471
+ if (typeof migration.schemaPath !== "string") {
472
+ pushError(errors, `${runtimeLabel(runtime)} maintained prisma migration requires schemaPath`);
473
+ }
474
+ if (typeof migration.migrationsPath !== "string") {
475
+ pushError(errors, `${runtimeLabel(runtime)} maintained prisma migration requires migrationsPath`);
476
+ }
477
+ }
478
+ if (migration.ownership === "maintained" && migration.tool === "drizzle") {
479
+ if (typeof migration.schemaPath !== "string") {
480
+ pushError(errors, `${runtimeLabel(runtime)} maintained drizzle migration requires schemaPath`);
481
+ }
482
+ if (typeof migration.migrationsPath !== "string") {
483
+ pushError(errors, `${runtimeLabel(runtime)} maintained drizzle migration requires migrationsPath`);
484
+ }
485
+ }
486
+ if (migration.ownership === "maintained" && migration.tool === "sql" && typeof migration.migrationsPath !== "string") {
487
+ pushError(errors, `${runtimeLabel(runtime)} maintained sql migration requires migrationsPath`);
488
+ }
489
+ }
490
+
394
491
  /**
395
492
  * @param {ValidationError[]} errors
396
493
  * @param {RuntimeTopologyRuntime} runtime
@@ -2,6 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @typedef {import("./project-config/index.js").GeneratorBinding} GeneratorBinding
5
+ * @typedef {import("./project-config/index.js").RuntimeMigrationStrategy} RuntimeMigrationStrategy
5
6
  * @typedef {import("./project-config/index.js").RuntimeTopologyRuntime} RuntimeTopologyRuntime
6
7
  * @typedef {import("./project-config/index.js").ProjectConfig} ProjectConfig
7
8
  * @typedef {import("./project-config/index.js").ProjectConfigInfo} ProjectConfigInfo