@topogram/cli 0.3.73 → 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 +1 -1
- package/src/agent-brief.js +18 -3
- package/src/cli/commands/check.js +15 -1
- package/src/cli/commands/release-rollout.js +191 -10
- package/src/cli/commands/release-shared.js +51 -2
- package/src/cli/commands/release.js +16 -3
- package/src/cli/help.js +1 -1
- package/src/generator/adapters.js +1 -1
- package/src/generator/runtime/app-bundle.js +3 -2
- package/src/generator/runtime/environment/index.js +15 -6
- package/src/generator/runtime/shared/index.js +1 -0
- package/src/generator/surfaces/databases/lifecycle-shared.js +175 -13
- package/src/import/extractors/cli/generic.js +107 -40
- package/src/project-config/index.js +97 -0
- package/src/project-config.js +1 -0
|
@@ -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
|
-
-
|
|
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
|
-
...(
|
|
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
|
-
: [
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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
|
-
|
|
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 ?
|
|
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 {
|
|
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
|
}
|