@xubylele/schema-forge 1.5.2 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -171,12 +171,25 @@ Creates the necessary directory structure and configuration files.
171
171
  Generate SQL migration from schema changes.
172
172
 
173
173
  ```bash
174
- schema-forge generate [--name "migration description"]
174
+ schema-forge generate [--name "migration description"] [--safe] [--force]
175
175
  ```
176
176
 
177
177
  **Options:**
178
178
 
179
179
  - `--name` - Optional name for the migration (default: "migration")
180
+ - `--safe` - Block execution if destructive operations are detected (exits with error)
181
+ - `--force` - Bypass safety checks and proceed with destructive operations (shows warning)
182
+
183
+ **Safety Behavior:**
184
+
185
+ When destructive or risky operations are detected (like dropping columns or tables), SchemaForge will:
186
+
187
+ 1. **Without flags** - Display an interactive prompt showing the risky operations and ask for confirmation (yes/no)
188
+ 2. **With `--safe`** - Block execution immediately and exit with error code 1, listing all destructive operations
189
+ 3. **With `--force`** - Bypass safety checks, show a warning message, and proceed with generating the migration
190
+ 4. **In CI environment** (`CI=true`) - Skip the interactive prompt, fail with exit code 3 for destructive operations unless `--force` is used
191
+
192
+ See [CI Behavior](#ci-behavior) for more details.
180
193
 
181
194
  Compares your current schema with the tracked state, generates SQL for any changes, and updates the state file.
182
195
 
@@ -185,10 +198,15 @@ Compares your current schema with the tracked state, generates SQL for any chang
185
198
  Compare your schema with the current state without generating files.
186
199
 
187
200
  ```bash
188
- schema-forge diff
201
+ schema-forge diff [--safe] [--force]
189
202
  ```
190
203
 
191
- Shows what SQL would be generated if you ran `generate`. Useful for previewing changes.
204
+ **Options:**
205
+
206
+ - `--safe` - Block execution if destructive operations are detected (exits with error)
207
+ - `--force` - Bypass safety checks and proceed with displaying destructive SQL (shows warning)
208
+
209
+ Shows what SQL would be generated if you ran `generate`. Useful for previewing changes. Safety behavior is the same as `generate` command. In CI environments, exits with code 3 if destructive operations are detected unless `--force` is used. See [CI Behavior](#ci-behavior) for more details.
192
210
 
193
211
  ### `schema-forge import`
194
212
 
@@ -229,11 +247,82 @@ Use JSON mode for CI and automation:
229
247
  schema-forge validate --json
230
248
  ```
231
249
 
250
+ Exit codes (also see [CI Behavior](#ci-behavior)):
251
+
252
+ - `3` in CI environment if destructive findings detected
253
+ - `1` if one or more `error` findings are detected
254
+ - `0` if no `error` findings are detected (warnings alone do not fail)
255
+
232
256
  Exit codes:
233
257
 
234
258
  - `1` when one or more `error` findings are detected
235
259
  - `0` when no `error` findings are detected (warnings alone do not fail)
236
260
 
261
+ ## CI Behavior
262
+
263
+ SchemaForge ensures deterministic behavior in Continuous Integration (CI) environments to prevent accidental destructive operations.
264
+
265
+ ### Detecting CI Environment
266
+
267
+ CI mode is automatically activated when either environment variable is set:
268
+
269
+ - `CI=true`
270
+ - `CONTINUOUS_INTEGRATION=true`
271
+
272
+ ### Exit Codes
273
+
274
+ SchemaForge uses specific exit codes for different scenarios:
275
+
276
+ | Exit Code | Meaning |
277
+ | --------- | ------- |
278
+ | `0` | Success - no changes or no destructive operations detected |
279
+ | `1` | General error - validation failed, operation declined, missing files, etc. |
280
+ | `2` | Schema validation error - invalid DSL syntax or structure |
281
+ | `3` | **CI Destructive** - destructive operations detected in CI environment without `--force` |
282
+
283
+ ### Destructive Operations in CI
284
+
285
+ When running in a CI environment, destructive operations (those flagged as `error` or `warning` level findings) trigger exit code 3:
286
+
287
+ **Operations classified as destructive:**
288
+
289
+ - Dropping tables (`DROP_TABLE`)
290
+ - Dropping columns (`DROP_COLUMN`)
291
+ - Changing column types in incompatible ways
292
+ - Making columns NOT NULL when they allow NULL
293
+
294
+ ### Overriding in CI
295
+
296
+ To proceed with destructive operations in CI, use the `--force` flag:
297
+
298
+ ```bash
299
+ # This will fail with exit code 3 if destructive changes detected
300
+ schema-forge generate
301
+
302
+ # This will proceed despite destructive changes (requires explicit acknowledgment)
303
+ schema-forge generate --force
304
+ ```
305
+
306
+ ### No Interactive Prompts in CI
307
+
308
+ When `CI=true`, SchemaForge will:
309
+
310
+ - ✅ Never show interactive prompts, preventing script hangs
311
+ - ✅ Fail deterministically (exit code 3) for destructive operations
312
+ - ✅ Allow explicit override with `--force` flag
313
+ - ❌ Not accept user input for confirmation
314
+
315
+ ### Using `--safe` in CI
316
+
317
+ The `--safe` flag is compatible with CI and blocks execution of destructive operations:
318
+
319
+ ```bash
320
+ # Blocks execution if destructive operations detected, exits with code 1
321
+ schema-forge generate --safe
322
+ ```
323
+
324
+ This is useful for strict CI pipelines where all destructive changes must be reviewed and merged separately.
325
+
237
326
  ## Constraint Change Detection
238
327
 
239
328
  SchemaForge detects and generates migrations for:
package/dist/cli.d.ts CHANGED
@@ -1 +1,2 @@
1
- #!/usr/bin/env node
1
+
2
+ export { }
package/dist/cli.js CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  "use strict";
3
2
  var __create = Object.create;
4
3
  var __defProp = Object.defineProperty;
@@ -46,11 +45,11 @@ function parseSchema(source) {
46
45
  "date"
47
46
  ]);
48
47
  const validIdentifierPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
49
- function normalizeColumnType3(type) {
48
+ function normalizeColumnType4(type) {
50
49
  return type.toLowerCase().trim().replace(/\s+/g, " ").replace(/\s*\(\s*/g, "(").replace(/\s*,\s*/g, ",").replace(/\s*\)\s*/g, ")");
51
50
  }
52
51
  function isValidColumnType2(type) {
53
- const normalizedType = normalizeColumnType3(type);
52
+ const normalizedType = normalizeColumnType4(type);
54
53
  if (validBaseColumnTypes.has(normalizedType)) {
55
54
  return true;
56
55
  }
@@ -174,7 +173,7 @@ function parseSchema(source) {
174
173
  throw new Error(`Line ${lineNum}: Invalid column definition. Expected: <name> <type> [modifiers...]`);
175
174
  }
176
175
  const colName = tokens[0];
177
- const colType = normalizeColumnType3(tokens[1]);
176
+ const colType = normalizeColumnType4(tokens[1]);
178
177
  validateIdentifier(colName, lineNum, "column");
179
178
  if (!isValidColumnType2(colType)) {
180
179
  throw new Error(`Line ${lineNum}: Invalid column type '${tokens[1]}'. Valid types: ${Array.from(validBaseColumnTypes).join(", ")}, varchar(n), numeric(p,s)`);
@@ -777,7 +776,7 @@ var init_validator = __esm({
777
776
  }
778
777
  });
779
778
 
780
- // node_modules/@xubylele/schema-forge-core/dist/core/validate.js
779
+ // node_modules/@xubylele/schema-forge-core/dist/core/safety/operation-classifier.js
781
780
  function normalizeColumnType2(type) {
782
781
  return type.toLowerCase().trim().replace(/\s+/g, " ").replace(/\s*\(\s*/g, "(").replace(/\s*,\s*/g, ",").replace(/\s*\)\s*/g, ")");
783
782
  }
@@ -800,119 +799,246 @@ function classifyTypeChange(from, to) {
800
799
  const toType = normalizeColumnType2(to);
801
800
  const uuidInvolved = fromType === "uuid" || toType === "uuid";
802
801
  if (uuidInvolved && fromType !== toType) {
803
- return {
804
- severity: "error",
805
- message: `Type changed from ${fromType} to ${toType} (likely incompatible cast)`
806
- };
802
+ return "DESTRUCTIVE";
807
803
  }
808
804
  if (fromType === "int" && toType === "bigint") {
809
- return {
810
- severity: "warning",
811
- message: "Type widened from int to bigint"
812
- };
805
+ return "WARNING";
813
806
  }
814
807
  if (fromType === "bigint" && toType === "int") {
815
- return {
816
- severity: "error",
817
- message: "Type narrowed from bigint to int (likely incompatible cast)"
818
- };
808
+ return "DESTRUCTIVE";
819
809
  }
820
810
  if (fromType === "text" && parseVarcharLength(toType) !== null) {
821
- return {
822
- severity: "error",
823
- message: `Type changed from text to ${toType} (may truncate existing values)`
824
- };
811
+ return "DESTRUCTIVE";
825
812
  }
826
813
  if (parseVarcharLength(fromType) !== null && toType === "text") {
827
- return {
828
- severity: "warning",
829
- message: "Type widened from varchar(n) to text"
830
- };
814
+ return "WARNING";
831
815
  }
832
816
  const fromVarcharLength = parseVarcharLength(fromType);
833
817
  const toVarcharLength = parseVarcharLength(toType);
834
818
  if (fromVarcharLength !== null && toVarcharLength !== null) {
835
819
  if (toVarcharLength >= fromVarcharLength) {
836
- return {
837
- severity: "warning",
838
- message: `Type widened from varchar(${fromVarcharLength}) to varchar(${toVarcharLength})`
839
- };
820
+ return "WARNING";
840
821
  }
841
- return {
842
- severity: "error",
843
- message: `Type narrowed from varchar(${fromVarcharLength}) to varchar(${toVarcharLength})`
844
- };
822
+ return "DESTRUCTIVE";
845
823
  }
846
824
  const fromNumeric = parseNumericType(fromType);
847
825
  const toNumeric = parseNumericType(toType);
848
826
  if (fromNumeric && toNumeric && fromNumeric.scale === toNumeric.scale) {
849
827
  if (toNumeric.precision >= fromNumeric.precision) {
850
- return {
851
- severity: "warning",
852
- message: `Type widened from numeric(${fromNumeric.precision},${fromNumeric.scale}) to numeric(${toNumeric.precision},${toNumeric.scale})`
853
- };
828
+ return "WARNING";
854
829
  }
855
- return {
856
- severity: "error",
857
- message: `Type narrowed from numeric(${fromNumeric.precision},${fromNumeric.scale}) to numeric(${toNumeric.precision},${toNumeric.scale})`
858
- };
830
+ return "DESTRUCTIVE";
831
+ }
832
+ return "WARNING";
833
+ }
834
+ function classifyOperation(operation) {
835
+ switch (operation.kind) {
836
+ case "create_table":
837
+ return "SAFE";
838
+ case "add_column":
839
+ return "SAFE";
840
+ case "drop_table":
841
+ return "DESTRUCTIVE";
842
+ case "drop_column":
843
+ return "DESTRUCTIVE";
844
+ case "column_type_changed":
845
+ return classifyTypeChange(operation.fromType, operation.toType);
846
+ case "column_nullability_changed":
847
+ if (operation.from && !operation.to) {
848
+ return "WARNING";
849
+ }
850
+ return "SAFE";
851
+ case "column_default_changed":
852
+ return "SAFE";
853
+ case "column_unique_changed":
854
+ return "SAFE";
855
+ case "drop_primary_key_constraint":
856
+ return "DESTRUCTIVE";
857
+ case "add_primary_key_constraint":
858
+ return "SAFE";
859
+ default:
860
+ const _exhaustive = operation;
861
+ return _exhaustive;
862
+ }
863
+ }
864
+ var init_operation_classifier = __esm({
865
+ "node_modules/@xubylele/schema-forge-core/dist/core/safety/operation-classifier.js"() {
866
+ "use strict";
867
+ }
868
+ });
869
+
870
+ // node_modules/@xubylele/schema-forge-core/dist/core/safety/safety-checker.js
871
+ function normalizeColumnType3(type) {
872
+ return type.toLowerCase().trim().replace(/\s+/g, " ").replace(/\s*\(\s*/g, "(").replace(/\s*,\s*/g, ",").replace(/\s*\)\s*/g, ")");
873
+ }
874
+ function parseVarcharLength2(type) {
875
+ const match = normalizeColumnType3(type).match(/^varchar\((\d+)\)$/);
876
+ return match ? Number(match[1]) : null;
877
+ }
878
+ function parseNumericType2(type) {
879
+ const match = normalizeColumnType3(type).match(/^numeric\((\d+),(\d+)\)$/);
880
+ if (!match) {
881
+ return null;
859
882
  }
860
883
  return {
861
- severity: "warning",
862
- message: `Type changed from ${fromType} to ${toType} (compatibility unknown)`
884
+ precision: Number(match[1]),
885
+ scale: Number(match[2])
863
886
  };
864
887
  }
865
- function validateSchemaChanges(previousState, currentSchema) {
866
- const findings = [];
867
- const diff = diffSchemas(previousState, currentSchema);
868
- for (const operation of diff.operations) {
869
- switch (operation.kind) {
870
- case "drop_table":
871
- findings.push({
872
- severity: "error",
873
- code: "DROP_TABLE",
874
- table: operation.tableName,
875
- message: "Table removed"
876
- });
877
- break;
878
- case "drop_column":
879
- findings.push({
880
- severity: "error",
881
- code: "DROP_COLUMN",
882
- table: operation.tableName,
883
- column: operation.columnName,
884
- message: "Column removed"
885
- });
886
- break;
887
- case "column_type_changed": {
888
- const classification = classifyTypeChange(operation.fromType, operation.toType);
889
- findings.push({
890
- severity: classification.severity,
891
- code: "ALTER_COLUMN_TYPE",
888
+ function generateTypeChangeMessage(from, to) {
889
+ const fromType = normalizeColumnType3(from);
890
+ const toType = normalizeColumnType3(to);
891
+ const uuidInvolved = fromType === "uuid" || toType === "uuid";
892
+ if (uuidInvolved && fromType !== toType) {
893
+ return `Type changed from ${fromType} to ${toType} (likely incompatible cast)`;
894
+ }
895
+ if (fromType === "int" && toType === "bigint") {
896
+ return "Type widened from int to bigint";
897
+ }
898
+ if (fromType === "bigint" && toType === "int") {
899
+ return "Type narrowed from bigint to int (likely incompatible cast)";
900
+ }
901
+ if (fromType === "text" && parseVarcharLength2(toType) !== null) {
902
+ return `Type changed from text to ${toType} (may truncate existing values)`;
903
+ }
904
+ if (parseVarcharLength2(fromType) !== null && toType === "text") {
905
+ const length = parseVarcharLength2(fromType);
906
+ return `Type widened from varchar(${length}) to text`;
907
+ }
908
+ const fromVarcharLength = parseVarcharLength2(fromType);
909
+ const toVarcharLength = parseVarcharLength2(toType);
910
+ if (fromVarcharLength !== null && toVarcharLength !== null) {
911
+ if (toVarcharLength >= fromVarcharLength) {
912
+ return `Type widened from varchar(${fromVarcharLength}) to varchar(${toVarcharLength})`;
913
+ }
914
+ return `Type narrowed from varchar(${fromVarcharLength}) to varchar(${toVarcharLength})`;
915
+ }
916
+ const fromNumeric = parseNumericType2(fromType);
917
+ const toNumeric = parseNumericType2(toType);
918
+ if (fromNumeric && toNumeric && fromNumeric.scale === toNumeric.scale) {
919
+ if (toNumeric.precision >= fromNumeric.precision) {
920
+ return `Type widened from numeric(${fromNumeric.precision},${fromNumeric.scale}) to numeric(${toNumeric.precision},${toNumeric.scale})`;
921
+ }
922
+ return `Type narrowed from numeric(${fromNumeric.precision},${fromNumeric.scale}) to numeric(${toNumeric.precision},${toNumeric.scale})`;
923
+ }
924
+ return `Type changed from ${fromType} to ${toType} (compatibility unknown)`;
925
+ }
926
+ function checkOperationSafety(operation) {
927
+ const safetyLevel = classifyOperation(operation);
928
+ if (safetyLevel === "SAFE") {
929
+ return null;
930
+ }
931
+ switch (operation.kind) {
932
+ case "drop_table":
933
+ return {
934
+ safetyLevel,
935
+ code: "DROP_TABLE",
936
+ table: operation.tableName,
937
+ message: "Table removed",
938
+ operationKind: operation.kind
939
+ };
940
+ case "drop_column":
941
+ return {
942
+ safetyLevel,
943
+ code: "DROP_COLUMN",
944
+ table: operation.tableName,
945
+ column: operation.columnName,
946
+ message: "Column removed",
947
+ operationKind: operation.kind
948
+ };
949
+ case "column_type_changed":
950
+ return {
951
+ safetyLevel,
952
+ code: "ALTER_COLUMN_TYPE",
953
+ table: operation.tableName,
954
+ column: operation.columnName,
955
+ from: normalizeColumnType3(operation.fromType),
956
+ to: normalizeColumnType3(operation.toType),
957
+ message: generateTypeChangeMessage(operation.fromType, operation.toType),
958
+ operationKind: operation.kind
959
+ };
960
+ case "column_nullability_changed":
961
+ if (operation.from && !operation.to) {
962
+ return {
963
+ safetyLevel,
964
+ code: "SET_NOT_NULL",
892
965
  table: operation.tableName,
893
966
  column: operation.columnName,
894
- from: normalizeColumnType2(operation.fromType),
895
- to: normalizeColumnType2(operation.toType),
896
- message: classification.message
897
- });
898
- break;
967
+ message: "Column changed to NOT NULL (may fail if data contains NULLs)",
968
+ operationKind: operation.kind
969
+ };
899
970
  }
900
- case "column_nullability_changed":
901
- if (operation.from && !operation.to) {
902
- findings.push({
903
- severity: "warning",
904
- code: "SET_NOT_NULL",
905
- table: operation.tableName,
906
- column: operation.columnName,
907
- message: "Column changed to NOT NULL (may fail if data contains NULLs)"
908
- });
909
- }
910
- break;
911
- default:
912
- break;
971
+ return null;
972
+ case "drop_primary_key_constraint":
973
+ return {
974
+ safetyLevel,
975
+ code: "DROP_TABLE",
976
+ // Reuse code for primary key drops
977
+ table: operation.tableName,
978
+ message: "Primary key constraint removed",
979
+ operationKind: operation.kind
980
+ };
981
+ default:
982
+ return null;
983
+ }
984
+ }
985
+ function checkSchemaSafety(previousState, currentSchema) {
986
+ const findings = [];
987
+ const diff = diffSchemas(previousState, currentSchema);
988
+ for (const operation of diff.operations) {
989
+ const finding = checkOperationSafety(operation);
990
+ if (finding) {
991
+ findings.push(finding);
913
992
  }
914
993
  }
915
- return findings;
994
+ const hasWarnings = findings.some((f) => f.safetyLevel === "WARNING");
995
+ const hasDestructiveOps = findings.some((f) => f.safetyLevel === "DESTRUCTIVE");
996
+ return {
997
+ findings,
998
+ hasSafeIssues: false,
999
+ // All findings are non-safe by definition
1000
+ hasWarnings,
1001
+ hasDestructiveOps
1002
+ };
1003
+ }
1004
+ var init_safety_checker = __esm({
1005
+ "node_modules/@xubylele/schema-forge-core/dist/core/safety/safety-checker.js"() {
1006
+ "use strict";
1007
+ init_diff();
1008
+ init_operation_classifier();
1009
+ }
1010
+ });
1011
+
1012
+ // node_modules/@xubylele/schema-forge-core/dist/core/safety/index.js
1013
+ var init_safety = __esm({
1014
+ "node_modules/@xubylele/schema-forge-core/dist/core/safety/index.js"() {
1015
+ "use strict";
1016
+ init_operation_classifier();
1017
+ init_safety_checker();
1018
+ }
1019
+ });
1020
+
1021
+ // node_modules/@xubylele/schema-forge-core/dist/core/validate.js
1022
+ function safetyLevelToSeverity(level) {
1023
+ if (level === "DESTRUCTIVE") {
1024
+ return "error";
1025
+ }
1026
+ return "warning";
1027
+ }
1028
+ function adaptSafetyFinding(finding) {
1029
+ return {
1030
+ severity: safetyLevelToSeverity(finding.safetyLevel),
1031
+ code: finding.code,
1032
+ table: finding.table,
1033
+ column: finding.column,
1034
+ from: finding.from,
1035
+ to: finding.to,
1036
+ message: finding.message
1037
+ };
1038
+ }
1039
+ function validateSchemaChanges(previousState, currentSchema) {
1040
+ const safetyReport = checkSchemaSafety(previousState, currentSchema);
1041
+ return safetyReport.findings.map(adaptSafetyFinding);
916
1042
  }
917
1043
  function toValidationReport(findings) {
918
1044
  const errors = findings.filter((finding) => finding.severity === "error");
@@ -927,7 +1053,7 @@ function toValidationReport(findings) {
927
1053
  var init_validate = __esm({
928
1054
  "node_modules/@xubylele/schema-forge-core/dist/core/validate.js"() {
929
1055
  "use strict";
930
- init_diff();
1056
+ init_safety();
931
1057
  }
932
1058
  });
933
1059
 
@@ -2093,6 +2219,9 @@ var dist_exports = {};
2093
2219
  __export(dist_exports, {
2094
2220
  SchemaValidationError: () => SchemaValidationError,
2095
2221
  applySqlOps: () => applySqlOps,
2222
+ checkOperationSafety: () => checkOperationSafety,
2223
+ checkSchemaSafety: () => checkSchemaSafety,
2224
+ classifyOperation: () => classifyOperation,
2096
2225
  diffSchemas: () => diffSchemas,
2097
2226
  ensureDir: () => ensureDir2,
2098
2227
  fileExists: () => fileExists2,
@@ -2146,6 +2275,7 @@ var init_dist = __esm({
2146
2275
  init_diff();
2147
2276
  init_validator();
2148
2277
  init_validate();
2278
+ init_safety();
2149
2279
  init_state_manager();
2150
2280
  init_sql_generator();
2151
2281
  init_parse_migration();
@@ -2167,7 +2297,7 @@ var import_commander6 = require("commander");
2167
2297
  // package.json
2168
2298
  var package_default = {
2169
2299
  name: "@xubylele/schema-forge",
2170
- version: "1.5.2",
2300
+ version: "1.6.0",
2171
2301
  description: "Universal migration generator from schema DSL",
2172
2302
  main: "dist/cli.js",
2173
2303
  type: "commonjs",
@@ -2214,7 +2344,7 @@ var package_default = {
2214
2344
  devDependencies: {
2215
2345
  "@changesets/cli": "^2.29.8",
2216
2346
  "@types/node": "^25.2.3",
2217
- "@xubylele/schema-forge-core": "^1.1.0",
2347
+ "@xubylele/schema-forge-core": "^1.2.0",
2218
2348
  "ts-node": "^10.9.2",
2219
2349
  tsup: "^8.5.1",
2220
2350
  typescript: "^5.9.3",
@@ -2382,6 +2512,18 @@ async function isSchemaValidationError(error2) {
2382
2512
  return error2 instanceof core.SchemaValidationError;
2383
2513
  }
2384
2514
 
2515
+ // src/utils/exitCodes.ts
2516
+ var EXIT_CODES = {
2517
+ /** Successful operation */
2518
+ SUCCESS: 0,
2519
+ /** Validation error (invalid DSL syntax, config errors, missing files, etc.) */
2520
+ VALIDATION_ERROR: 1,
2521
+ /** Drift detected - Reserved for future use when comparing actual DB state vs schema */
2522
+ DRIFT_DETECTED: 2,
2523
+ /** Destructive operation detected in CI environment without --force */
2524
+ CI_DESTRUCTIVE: 3
2525
+ };
2526
+
2385
2527
  // src/utils/output.ts
2386
2528
  var import_boxen = __toESM(require("boxen"));
2387
2529
  var import_chalk = require("chalk");
@@ -2422,13 +2564,96 @@ function warning(message) {
2422
2564
  function error(message) {
2423
2565
  console.error(theme.error(`[ERROR] ${message}`));
2424
2566
  }
2567
+ function forceWarning(message) {
2568
+ console.error(theme.warning(`[FORCE] ${message}`));
2569
+ }
2570
+
2571
+ // src/utils/prompt.ts
2572
+ var import_node_readline = __toESM(require("readline"));
2573
+ function isCI() {
2574
+ return process.env.CI === "true" || process.env.CONTINUOUS_INTEGRATION === "true";
2575
+ }
2576
+ function formatFindingsSummary(findings) {
2577
+ const errors = findings.filter((f) => f.severity === "error");
2578
+ const warnings = findings.filter((f) => f.severity === "warning");
2579
+ const lines = [];
2580
+ if (errors.length > 0) {
2581
+ lines.push(theme.error("DESTRUCTIVE OPERATIONS:"));
2582
+ for (const finding of errors) {
2583
+ const columnPart = finding.column ? `.${finding.column}` : "";
2584
+ const fromTo = finding.from && finding.to ? ` (${finding.from} \u2192 ${finding.to})` : "";
2585
+ lines.push(theme.error(` \u2022 ${finding.code}: ${finding.table}${columnPart}${fromTo}`));
2586
+ }
2587
+ }
2588
+ if (warnings.length > 0) {
2589
+ if (lines.length > 0) lines.push("");
2590
+ lines.push(theme.warning("WARNING OPERATIONS:"));
2591
+ for (const finding of warnings) {
2592
+ const columnPart = finding.column ? `.${finding.column}` : "";
2593
+ const fromTo = finding.from && finding.to ? ` (${finding.from} \u2192 ${finding.to})` : "";
2594
+ lines.push(theme.warning(` \u2022 ${finding.code}: ${finding.table}${columnPart}${fromTo}`));
2595
+ }
2596
+ }
2597
+ return lines.join("\n");
2598
+ }
2599
+ async function readConfirmation(input = process.stdin, output = process.stdout) {
2600
+ const rl = import_node_readline.default.createInterface({
2601
+ input,
2602
+ output
2603
+ });
2604
+ return new Promise((resolve) => {
2605
+ const askQuestion = () => {
2606
+ rl.question(theme.primary("Proceed with these changes? (yes/no): "), (answer) => {
2607
+ const normalized = answer.trim().toLowerCase();
2608
+ if (normalized === "yes" || normalized === "y") {
2609
+ rl.close();
2610
+ resolve(true);
2611
+ } else if (normalized === "no" || normalized === "n") {
2612
+ rl.close();
2613
+ resolve(false);
2614
+ } else {
2615
+ console.log(theme.warning('Please answer "yes" or "no".'));
2616
+ askQuestion();
2617
+ }
2618
+ });
2619
+ };
2620
+ askQuestion();
2621
+ });
2622
+ }
2623
+ async function confirmDestructiveOps(findings, input, output) {
2624
+ const riskyFindings = findings.filter(
2625
+ (f) => f.severity === "error" || f.severity === "warning"
2626
+ );
2627
+ if (riskyFindings.length === 0) {
2628
+ return true;
2629
+ }
2630
+ if (isCI()) {
2631
+ error("Cannot run interactive prompts in CI environment. Use --force flag to bypass safety checks.");
2632
+ process.exitCode = EXIT_CODES.CI_DESTRUCTIVE;
2633
+ return false;
2634
+ }
2635
+ console.log("");
2636
+ console.log(formatFindingsSummary(riskyFindings));
2637
+ console.log("");
2638
+ const confirmed = await readConfirmation(input, output);
2639
+ if (!confirmed) {
2640
+ warning("Operation cancelled by user.");
2641
+ }
2642
+ return confirmed;
2643
+ }
2644
+ function hasDestructiveFindings(findings) {
2645
+ return findings.some((f) => f.severity === "error" || f.severity === "warning");
2646
+ }
2425
2647
 
2426
2648
  // src/commands/diff.ts
2427
2649
  var REQUIRED_CONFIG_FIELDS = ["schemaFile", "stateFile"];
2428
2650
  function resolveConfigPath(root, targetPath) {
2429
2651
  return import_path7.default.isAbsolute(targetPath) ? targetPath : import_path7.default.join(root, targetPath);
2430
2652
  }
2431
- async function runDiff() {
2653
+ async function runDiff(options = {}) {
2654
+ if (options.safe && options.force) {
2655
+ throw new Error("Cannot use --safe and --force flags together. Choose one:\n --safe: Block destructive operations\n --force: Bypass safety checks");
2656
+ }
2432
2657
  const root = getProjectRoot();
2433
2658
  const configPath = getConfigPath(root);
2434
2659
  if (!await fileExists(configPath)) {
@@ -2456,12 +2681,47 @@ async function runDiff() {
2456
2681
  }
2457
2682
  const previousState = await loadState2(statePath);
2458
2683
  const diff = await diffSchemas2(previousState, schema);
2684
+ if (options.force) {
2685
+ forceWarning("Are you sure to use --force? This option will bypass safety checks for destructive operations.");
2686
+ }
2687
+ if (options.safe && !options.force && diff.operations.length > 0) {
2688
+ const findings = await validateSchemaChanges2(previousState, schema);
2689
+ const destructiveFindings = findings.filter((f) => f.severity === "error");
2690
+ if (destructiveFindings.length > 0) {
2691
+ const errorMessages = destructiveFindings.map((f) => {
2692
+ const target = f.column ? `${f.table}.${f.column}` : f.table;
2693
+ const typeRange = f.from && f.to ? ` (${f.from} -> ${f.to})` : "";
2694
+ return ` - ${f.code}: ${target}${typeRange}`;
2695
+ }).join("\n");
2696
+ throw await createSchemaValidationError(
2697
+ `Cannot proceed with --safe flag: Found ${destructiveFindings.length} destructive operation(s):
2698
+ ${errorMessages}
2699
+
2700
+ Remove --safe flag or modify schema to avoid destructive changes.`
2701
+ );
2702
+ }
2703
+ }
2704
+ if (!options.safe && !options.force && diff.operations.length > 0) {
2705
+ const findings = await validateSchemaChanges2(previousState, schema);
2706
+ const riskyFindings = findings.filter((f) => f.severity === "error" || f.severity === "warning");
2707
+ if (riskyFindings.length > 0) {
2708
+ const confirmed = await confirmDestructiveOps(findings);
2709
+ if (!confirmed) {
2710
+ if (process.exitCode !== EXIT_CODES.CI_DESTRUCTIVE) {
2711
+ process.exitCode = EXIT_CODES.VALIDATION_ERROR;
2712
+ }
2713
+ return;
2714
+ }
2715
+ }
2716
+ }
2459
2717
  if (diff.operations.length === 0) {
2460
2718
  success("No changes detected");
2719
+ process.exitCode = EXIT_CODES.SUCCESS;
2461
2720
  return;
2462
2721
  }
2463
2722
  const sql = await generateSql2(diff, provider, config.sql);
2464
2723
  console.log(sql);
2724
+ process.exitCode = EXIT_CODES.SUCCESS;
2465
2725
  }
2466
2726
 
2467
2727
  // src/commands/generate.ts
@@ -2488,6 +2748,9 @@ function resolveConfigPath2(root, targetPath) {
2488
2748
  return import_path8.default.isAbsolute(targetPath) ? targetPath : import_path8.default.join(root, targetPath);
2489
2749
  }
2490
2750
  async function runGenerate(options) {
2751
+ if (options.safe && options.force) {
2752
+ throw new Error("Cannot use --safe and --force flags together. Choose one:\n --safe: Block destructive operations\n --force: Bypass safety checks");
2753
+ }
2491
2754
  const root = getProjectRoot();
2492
2755
  const configPath = getConfigPath(root);
2493
2756
  if (!await fileExists(configPath)) {
@@ -2520,8 +2783,42 @@ async function runGenerate(options) {
2520
2783
  }
2521
2784
  const previousState = await loadState2(statePath);
2522
2785
  const diff = await diffSchemas2(previousState, schema);
2786
+ if (options.force) {
2787
+ forceWarning("Are you sure to use --force? This option will bypass safety checks for destructive operations.");
2788
+ }
2789
+ if (options.safe && !options.force && diff.operations.length > 0) {
2790
+ const findings = await validateSchemaChanges2(previousState, schema);
2791
+ const destructiveFindings = findings.filter((f) => f.severity === "error");
2792
+ if (destructiveFindings.length > 0) {
2793
+ const errorMessages = destructiveFindings.map((f) => {
2794
+ const target = f.column ? `${f.table}.${f.column}` : f.table;
2795
+ const typeRange = f.from && f.to ? ` (${f.from} -> ${f.to})` : "";
2796
+ return ` - ${f.code}: ${target}${typeRange}`;
2797
+ }).join("\n");
2798
+ throw await createSchemaValidationError(
2799
+ `Cannot proceed with --safe flag: Found ${destructiveFindings.length} destructive operation(s):
2800
+ ${errorMessages}
2801
+
2802
+ Remove --safe flag or modify schema to avoid destructive changes.`
2803
+ );
2804
+ }
2805
+ }
2806
+ if (!options.safe && !options.force && diff.operations.length > 0) {
2807
+ const findings = await validateSchemaChanges2(previousState, schema);
2808
+ const riskyFindings = findings.filter((f) => f.severity === "error" || f.severity === "warning");
2809
+ if (riskyFindings.length > 0) {
2810
+ const confirmed = await confirmDestructiveOps(findings);
2811
+ if (!confirmed) {
2812
+ if (process.exitCode !== EXIT_CODES.CI_DESTRUCTIVE) {
2813
+ process.exitCode = EXIT_CODES.VALIDATION_ERROR;
2814
+ }
2815
+ return;
2816
+ }
2817
+ }
2818
+ }
2523
2819
  if (diff.operations.length === 0) {
2524
2820
  info("No changes detected");
2821
+ process.exitCode = EXIT_CODES.SUCCESS;
2525
2822
  return;
2526
2823
  }
2527
2824
  const sql = await generateSql2(diff, provider, config.sql);
@@ -2534,6 +2831,7 @@ async function runGenerate(options) {
2534
2831
  const nextState = await schemaToState2(schema);
2535
2832
  await saveState2(statePath, nextState);
2536
2833
  success(`SQL generated successfully: ${migrationPath}`);
2834
+ process.exitCode = EXIT_CODES.SUCCESS;
2537
2835
  }
2538
2836
 
2539
2837
  // src/commands/import.ts
@@ -2585,6 +2883,7 @@ async function runImport(inputPath, options = {}) {
2585
2883
  warning(`...and ${warnings.length - 10} more`);
2586
2884
  }
2587
2885
  }
2886
+ process.exitCode = EXIT_CODES.SUCCESS;
2588
2887
  }
2589
2888
 
2590
2889
  // src/commands/init.ts
@@ -2593,24 +2892,19 @@ async function runInit() {
2593
2892
  const root = getProjectRoot();
2594
2893
  const schemaForgeDir = getSchemaForgeDir(root);
2595
2894
  if (await fileExists(schemaForgeDir)) {
2596
- error("schemaforge/ directory already exists");
2597
- error("Please remove it or run init in a different directory");
2598
- process.exit(1);
2895
+ throw new Error("schemaforge/ directory already exists. Please remove it or run init in a different directory.");
2599
2896
  }
2600
2897
  const schemaFilePath = getSchemaFilePath(root);
2601
2898
  const configPath = getConfigPath(root);
2602
2899
  const statePath = getStatePath(root);
2603
2900
  if (await fileExists(schemaFilePath)) {
2604
- error(`${schemaFilePath} already exists`);
2605
- process.exit(1);
2901
+ throw new Error(`${schemaFilePath} already exists`);
2606
2902
  }
2607
2903
  if (await fileExists(configPath)) {
2608
- error(`${configPath} already exists`);
2609
- process.exit(1);
2904
+ throw new Error(`${configPath} already exists`);
2610
2905
  }
2611
2906
  if (await fileExists(statePath)) {
2612
- error(`${statePath} already exists`);
2613
- process.exit(1);
2907
+ throw new Error(`${statePath} already exists`);
2614
2908
  }
2615
2909
  info("Initializing schema project...");
2616
2910
  await ensureDir(schemaForgeDir);
@@ -2649,6 +2943,7 @@ table users {
2649
2943
  info("Next steps:");
2650
2944
  info(" 1. Edit schemaforge/schema.sf to define your schema");
2651
2945
  info(" 2. Run: schema-forge generate");
2946
+ process.exitCode = EXIT_CODES.SUCCESS;
2652
2947
  }
2653
2948
 
2654
2949
  // src/commands/validate.ts
@@ -2686,14 +2981,17 @@ async function runValidate(options = {}) {
2686
2981
  const previousState = await loadState2(statePath);
2687
2982
  const findings = await validateSchemaChanges2(previousState, schema);
2688
2983
  const report = await toValidationReport2(findings);
2984
+ if (isCI() && hasDestructiveFindings(findings)) {
2985
+ process.exitCode = EXIT_CODES.CI_DESTRUCTIVE;
2986
+ } else {
2987
+ process.exitCode = report.hasErrors ? EXIT_CODES.VALIDATION_ERROR : EXIT_CODES.SUCCESS;
2988
+ }
2689
2989
  if (options.json) {
2690
2990
  console.log(JSON.stringify(report, null, 2));
2691
- process.exitCode = report.hasErrors ? 1 : 0;
2692
2991
  return;
2693
2992
  }
2694
2993
  if (findings.length === 0) {
2695
2994
  success("No destructive changes detected");
2696
- process.exitCode = 0;
2697
2995
  return;
2698
2996
  }
2699
2997
  console.log(
@@ -2710,16 +3008,61 @@ async function runValidate(options = {}) {
2710
3008
  );
2711
3009
  }
2712
3010
  }
2713
- process.exitCode = report.hasErrors ? 1 : 0;
3011
+ }
3012
+
3013
+ // src/utils/whatsNew.ts
3014
+ var import_node_os = __toESM(require("os"));
3015
+ var import_node_path = __toESM(require("path"));
3016
+ function getCliMetaPath() {
3017
+ return import_node_path.default.join(import_node_os.default.homedir(), ".schema-forge", "cli-meta.json");
3018
+ }
3019
+ function getReleaseUrl(version) {
3020
+ return `https://github.com/xubylele/schema-forge/releases/tag/v${version}`;
3021
+ }
3022
+ function shouldShowWhatsNew(argv) {
3023
+ if (argv.length === 0) {
3024
+ return false;
3025
+ }
3026
+ if (argv.includes("--help") || argv.includes("-h") || argv.includes("--version") || argv.includes("-V")) {
3027
+ return false;
3028
+ }
3029
+ return true;
3030
+ }
3031
+ async function showWhatsNewIfUpdated(currentVersion, argv) {
3032
+ if (!shouldShowWhatsNew(argv)) {
3033
+ return;
3034
+ }
3035
+ try {
3036
+ const metaPath = getCliMetaPath();
3037
+ const meta = await readJsonFile(metaPath, {});
3038
+ if (meta.lastSeenVersion === currentVersion) {
3039
+ return;
3040
+ }
3041
+ info(`What's new in schema-forge v${currentVersion}: ${getReleaseUrl(currentVersion)}`);
3042
+ await writeJsonFile(metaPath, { lastSeenVersion: currentVersion });
3043
+ } catch {
3044
+ }
3045
+ }
3046
+ async function seedLastSeenVersion(version) {
3047
+ const metaPath = getCliMetaPath();
3048
+ const exists = await fileExists(metaPath);
3049
+ if (!exists) {
3050
+ await writeJsonFile(metaPath, { lastSeenVersion: version });
3051
+ }
2714
3052
  }
2715
3053
 
2716
3054
  // src/cli.ts
2717
3055
  var program = new import_commander6.Command();
2718
- program.name("schema-forge").description("CLI tool for schema management and SQL generation").version(package_default.version);
3056
+ program.name("schema-forge").description("CLI tool for schema management and SQL generation").version(package_default.version).option("--safe", "Prevent execution of destructive operations").option("--force", "Force execution by bypassing safety checks and CI detection");
3057
+ function validateFlagExclusivity(options) {
3058
+ if (options.safe && options.force) {
3059
+ throw new Error("Cannot use --safe and --force flags together. Choose one:\n --safe: Block destructive operations\n --force: Bypass safety checks");
3060
+ }
3061
+ }
2719
3062
  async function handleError(error2) {
2720
3063
  if (await isSchemaValidationError(error2) && error2 instanceof Error) {
2721
3064
  error(error2.message);
2722
- process.exitCode = 2;
3065
+ process.exitCode = EXIT_CODES.VALIDATION_ERROR;
2723
3066
  return;
2724
3067
  }
2725
3068
  if (error2 instanceof Error) {
@@ -2727,7 +3070,7 @@ async function handleError(error2) {
2727
3070
  } else {
2728
3071
  error("Unexpected error");
2729
3072
  }
2730
- process.exitCode = 1;
3073
+ process.exitCode = EXIT_CODES.VALIDATION_ERROR;
2731
3074
  }
2732
3075
  program.command("init").description("Initialize a new schema project").action(async () => {
2733
3076
  try {
@@ -2736,16 +3079,20 @@ program.command("init").description("Initialize a new schema project").action(as
2736
3079
  await handleError(error2);
2737
3080
  }
2738
3081
  });
2739
- program.command("generate").description("Generate SQL from schema files").option("--name <string>", "Schema name to generate").action(async (options) => {
3082
+ program.command("generate").description("Generate SQL from schema files. In CI environments (CI=true), exits with code 3 if destructive operations are detected unless --force is used.").option("--name <string>", "Schema name to generate").action(async (options) => {
2740
3083
  try {
2741
- await runGenerate(options);
3084
+ const globalOptions = program.opts();
3085
+ validateFlagExclusivity(globalOptions);
3086
+ await runGenerate({ ...options, ...globalOptions });
2742
3087
  } catch (error2) {
2743
3088
  await handleError(error2);
2744
3089
  }
2745
3090
  });
2746
- program.command("diff").description("Compare two schema versions and generate migration SQL").action(async () => {
3091
+ program.command("diff").description("Compare two schema versions and generate migration SQL. In CI environments (CI=true), exits with code 3 if destructive operations are detected unless --force is used.").action(async () => {
2747
3092
  try {
2748
- await runDiff();
3093
+ const globalOptions = program.opts();
3094
+ validateFlagExclusivity(globalOptions);
3095
+ await runDiff(globalOptions);
2749
3096
  } catch (error2) {
2750
3097
  await handleError(error2);
2751
3098
  }
@@ -2757,14 +3104,20 @@ program.command("import").description("Import schema from SQL migrations").argum
2757
3104
  await handleError(error2);
2758
3105
  }
2759
3106
  });
2760
- program.command("validate").description("Detect destructive or risky schema changes").option("--json", "Output structured JSON").action(async (options) => {
3107
+ program.command("validate").description("Detect destructive or risky schema changes. In CI environments (CI=true), exits with code 3 if destructive operations are detected.").option("--json", "Output structured JSON").action(async (options) => {
2761
3108
  try {
2762
3109
  await runValidate(options);
2763
3110
  } catch (error2) {
2764
3111
  await handleError(error2);
2765
3112
  }
2766
3113
  });
2767
- program.parse(process.argv);
2768
- if (!process.argv.slice(2).length) {
2769
- program.outputHelp();
3114
+ async function main() {
3115
+ const argv = process.argv.slice(2);
3116
+ await seedLastSeenVersion(package_default.version);
3117
+ await showWhatsNewIfUpdated(package_default.version, argv);
3118
+ program.parse(process.argv);
3119
+ if (!argv.length) {
3120
+ program.outputHelp();
3121
+ }
2770
3122
  }
3123
+ void main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xubylele/schema-forge",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "Universal migration generator from schema DSL",
5
5
  "main": "dist/cli.js",
6
6
  "type": "commonjs",
@@ -47,10 +47,10 @@
47
47
  "devDependencies": {
48
48
  "@changesets/cli": "^2.29.8",
49
49
  "@types/node": "^25.2.3",
50
- "@xubylele/schema-forge-core": "^1.1.0",
50
+ "@xubylele/schema-forge-core": "^1.2.0",
51
51
  "ts-node": "^10.9.2",
52
52
  "tsup": "^8.5.1",
53
53
  "typescript": "^5.9.3",
54
54
  "vitest": "^4.0.18"
55
55
  }
56
- }
56
+ }