modscape 2.0.2 → 2.0.3

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": "modscape",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "Modscape: A YAML-driven data modeling visualizer CLI",
5
5
  "repository": {
6
6
  "type": "git",
package/src/column.js ADDED
@@ -0,0 +1,100 @@
1
+ import { Command } from 'commander';
2
+ import { readYaml, writeYaml, findTableById, outputError, outputWarn, outputOk } from './model-utils.js';
3
+
4
+ function findColumnById(table, id) {
5
+ return (table.columns || []).find(c => c.id === id) || null;
6
+ }
7
+
8
+ export function columnCommand() {
9
+ const cmd = new Command('column').description('Manage columns in a table');
10
+
11
+ // add
12
+ cmd
13
+ .command('add <file>')
14
+ .description('Add a column to a table')
15
+ .requiredOption('--table <tableId>', 'target table ID')
16
+ .requiredOption('--id <id>', 'column ID (snake_case)')
17
+ .requiredOption('--name <name>', 'logical name')
18
+ .option('--type <type>', 'logical type (Int|String|Decimal|Date|Timestamp|Boolean|...)')
19
+ .option('--primary-key', 'mark as primary key')
20
+ .option('--foreign-key', 'mark as foreign key')
21
+ .option('--physical-name <name>', 'physical column name')
22
+ .option('--physical-type <type>', 'physical column type (e.g. BIGINT)')
23
+ .option('--json', 'output as JSON')
24
+ .action((file, opts) => {
25
+ const data = readYaml(file);
26
+ const table = findTableById(data, opts.table);
27
+ if (!table) return outputError(opts.json, `Table "${opts.table}" not found`);
28
+ if (findColumnById(table, opts.id)) {
29
+ return outputError(opts.json, `Column "${opts.id}" already exists in table "${opts.table}"`, 'Use `column update` instead');
30
+ }
31
+ const column = { id: opts.id, logical: { name: opts.name } };
32
+ if (opts.type) column.logical.type = opts.type;
33
+ if (opts.primaryKey) column.logical.isPrimaryKey = true;
34
+ if (opts.foreignKey) column.logical.isForeignKey = true;
35
+ if (opts.physicalName || opts.physicalType) {
36
+ column.physical = {};
37
+ if (opts.physicalName) column.physical.name = opts.physicalName;
38
+ if (opts.physicalType) column.physical.type = opts.physicalType;
39
+ }
40
+ if (!table.columns) table.columns = [];
41
+ table.columns.push(column);
42
+ writeYaml(file, data);
43
+ outputOk(opts.json, 'add', 'column', `${opts.table}.${opts.id}`);
44
+ });
45
+
46
+ // update
47
+ cmd
48
+ .command('update <file>')
49
+ .description('Update a column in a table')
50
+ .requiredOption('--table <tableId>', 'target table ID')
51
+ .requiredOption('--id <id>', 'column ID to update')
52
+ .option('--name <name>', 'logical name')
53
+ .option('--type <type>', 'logical type')
54
+ .option('--primary-key <bool>', 'set isPrimaryKey (true|false)')
55
+ .option('--foreign-key <bool>', 'set isForeignKey (true|false)')
56
+ .option('--physical-name <name>', 'physical column name')
57
+ .option('--physical-type <type>', 'physical column type')
58
+ .option('--json', 'output as JSON')
59
+ .action((file, opts) => {
60
+ const data = readYaml(file);
61
+ const table = findTableById(data, opts.table);
62
+ if (!table) return outputError(opts.json, `Table "${opts.table}" not found`);
63
+ const column = findColumnById(table, opts.id);
64
+ if (!column) return outputError(opts.json, `Column "${opts.id}" not found in table "${opts.table}"`, 'Use `column add` instead');
65
+ column.logical = column.logical || {};
66
+ if (opts.name) column.logical.name = opts.name;
67
+ if (opts.type) column.logical.type = opts.type;
68
+ if (opts.primaryKey !== undefined) column.logical.isPrimaryKey = opts.primaryKey === 'true';
69
+ if (opts.foreignKey !== undefined) column.logical.isForeignKey = opts.foreignKey === 'true';
70
+ if (opts.physicalName || opts.physicalType) {
71
+ column.physical = column.physical || {};
72
+ if (opts.physicalName) column.physical.name = opts.physicalName;
73
+ if (opts.physicalType) column.physical.type = opts.physicalType;
74
+ }
75
+ writeYaml(file, data);
76
+ outputOk(opts.json, 'update', 'column', `${opts.table}.${opts.id}`);
77
+ });
78
+
79
+ // remove
80
+ cmd
81
+ .command('remove <file>')
82
+ .description('Remove a column from a table')
83
+ .requiredOption('--table <tableId>', 'target table ID')
84
+ .requiredOption('--id <id>', 'column ID to remove')
85
+ .option('--json', 'output as JSON')
86
+ .action((file, opts) => {
87
+ const data = readYaml(file);
88
+ const table = findTableById(data, opts.table);
89
+ if (!table) return outputError(opts.json, `Table "${opts.table}" not found`);
90
+ const before = (table.columns || []).length;
91
+ table.columns = (table.columns || []).filter(c => c.id !== opts.id);
92
+ if (table.columns.length === before) {
93
+ return outputWarn(opts.json, `Column "${opts.id}" not found in table "${opts.table}", nothing removed`);
94
+ }
95
+ writeYaml(file, data);
96
+ outputOk(opts.json, 'remove', 'column', `${opts.table}.${opts.id}`);
97
+ });
98
+
99
+ return cmd;
100
+ }
package/src/domain.js ADDED
@@ -0,0 +1,149 @@
1
+ import { Command } from 'commander';
2
+ import { readYaml, writeYaml, findTableById, findDomainById, outputError, outputWarn, outputOk } from './model-utils.js';
3
+
4
+ export function domainCommand() {
5
+ const cmd = new Command('domain').description('Manage domains in a YAML model');
6
+
7
+ // list
8
+ cmd
9
+ .command('list <file>')
10
+ .description('List all domains')
11
+ .option('--json', 'output as JSON')
12
+ .action((file, opts) => {
13
+ const data = readYaml(file);
14
+ const domains = (data.domains || []).map(d => ({ id: d.id, name: d.name }));
15
+ if (opts.json) {
16
+ console.log(JSON.stringify(domains));
17
+ } else {
18
+ if (domains.length === 0) {
19
+ console.log(' (no domains)');
20
+ } else {
21
+ domains.forEach(d => console.log(` ${d.id} ${d.name || ''}`));
22
+ }
23
+ }
24
+ });
25
+
26
+ // get
27
+ cmd
28
+ .command('get <file>')
29
+ .description('Get a domain definition by ID')
30
+ .requiredOption('--id <id>', 'domain ID')
31
+ .option('--json', 'output as JSON')
32
+ .action((file, opts) => {
33
+ const data = readYaml(file);
34
+ const domain = findDomainById(data, opts.id);
35
+ if (!domain) return outputError(opts.json, `Domain "${opts.id}" not found`);
36
+ if (opts.json) {
37
+ console.log(JSON.stringify(domain));
38
+ } else {
39
+ console.log(JSON.stringify(domain, null, 2));
40
+ }
41
+ });
42
+
43
+ // add
44
+ cmd
45
+ .command('add <file>')
46
+ .description('Add a new domain')
47
+ .requiredOption('--id <id>', 'domain ID (snake_case)')
48
+ .requiredOption('--name <name>', 'display name')
49
+ .option('--description <text>', 'description')
50
+ .option('--color <color>', 'background color (e.g. rgba(59,130,246,0.1))')
51
+ .option('--json', 'output as JSON')
52
+ .action((file, opts) => {
53
+ const data = readYaml(file);
54
+ if (findDomainById(data, opts.id)) {
55
+ return outputError(opts.json, `Domain "${opts.id}" already exists`, 'Use `domain update` instead');
56
+ }
57
+ const domain = { id: opts.id, name: opts.name, tables: [] };
58
+ if (opts.description) domain.description = opts.description;
59
+ if (opts.color) domain.color = opts.color;
60
+ if (!data.domains) data.domains = [];
61
+ data.domains.push(domain);
62
+ writeYaml(file, data);
63
+ outputOk(opts.json, 'add', 'domain', opts.id);
64
+ });
65
+
66
+ // update
67
+ cmd
68
+ .command('update <file>')
69
+ .description('Update fields of an existing domain')
70
+ .requiredOption('--id <id>', 'domain ID to update')
71
+ .option('--name <name>', 'display name')
72
+ .option('--description <text>', 'description')
73
+ .option('--color <color>', 'background color')
74
+ .option('--json', 'output as JSON')
75
+ .action((file, opts) => {
76
+ const data = readYaml(file);
77
+ const domain = findDomainById(data, opts.id);
78
+ if (!domain) return outputError(opts.json, `Domain "${opts.id}" not found`, 'Use `domain add` instead');
79
+ if (opts.name) domain.name = opts.name;
80
+ if (opts.description) domain.description = opts.description;
81
+ if (opts.color) domain.color = opts.color;
82
+ writeYaml(file, data);
83
+ outputOk(opts.json, 'update', 'domain', opts.id);
84
+ });
85
+
86
+ // remove
87
+ cmd
88
+ .command('remove <file>')
89
+ .description('Remove a domain from the model')
90
+ .requiredOption('--id <id>', 'domain ID to remove')
91
+ .option('--json', 'output as JSON')
92
+ .action((file, opts) => {
93
+ const data = readYaml(file);
94
+ const before = (data.domains || []).length;
95
+ data.domains = (data.domains || []).filter(d => d.id !== opts.id);
96
+ if (data.domains.length === before) {
97
+ return outputWarn(opts.json, `Domain "${opts.id}" not found, nothing removed`);
98
+ }
99
+ writeYaml(file, data);
100
+ outputOk(opts.json, 'remove', 'domain', opts.id);
101
+ });
102
+
103
+ // member subcommand
104
+ const member = new Command('member').description('Manage table membership in a domain');
105
+
106
+ member
107
+ .command('add <file>')
108
+ .description('Add a table to a domain')
109
+ .requiredOption('--domain <domainId>', 'domain ID')
110
+ .requiredOption('--table <tableId>', 'table ID to add')
111
+ .option('--json', 'output as JSON')
112
+ .action((file, opts) => {
113
+ const data = readYaml(file);
114
+ const domain = findDomainById(data, opts.domain);
115
+ if (!domain) return outputError(opts.json, `Domain "${opts.domain}" not found`);
116
+ if (!findTableById(data, opts.table)) {
117
+ return outputError(opts.json, `Table "${opts.table}" not found`);
118
+ }
119
+ if (!domain.tables) domain.tables = [];
120
+ if (domain.tables.includes(opts.table)) {
121
+ return outputWarn(opts.json, `Table "${opts.table}" is already in domain "${opts.domain}", skipped`);
122
+ }
123
+ domain.tables.push(opts.table);
124
+ writeYaml(file, data);
125
+ outputOk(opts.json, 'member_add', 'domain', `${opts.domain} ← ${opts.table}`);
126
+ });
127
+
128
+ member
129
+ .command('remove <file>')
130
+ .description('Remove a table from a domain')
131
+ .requiredOption('--domain <domainId>', 'domain ID')
132
+ .requiredOption('--table <tableId>', 'table ID to remove')
133
+ .option('--json', 'output as JSON')
134
+ .action((file, opts) => {
135
+ const data = readYaml(file);
136
+ const domain = findDomainById(data, opts.domain);
137
+ if (!domain) return outputError(opts.json, `Domain "${opts.domain}" not found`);
138
+ const before = (domain.tables || []).length;
139
+ domain.tables = (domain.tables || []).filter(t => t !== opts.table);
140
+ if (domain.tables.length === before) {
141
+ return outputWarn(opts.json, `Table "${opts.table}" not found in domain "${opts.domain}", nothing removed`);
142
+ }
143
+ writeYaml(file, data);
144
+ outputOk(opts.json, 'member_remove', 'domain', `${opts.domain} ✕ ${opts.table}`);
145
+ });
146
+
147
+ cmd.addCommand(member);
148
+ return cmd;
149
+ }
package/src/extract.js ADDED
@@ -0,0 +1,72 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+
5
+ const collectYamlFiles = (inputPath) => {
6
+ const stat = fs.statSync(inputPath);
7
+ if (stat.isDirectory()) {
8
+ return fs.readdirSync(inputPath)
9
+ .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
10
+ .map(f => path.join(inputPath, f));
11
+ }
12
+ return [inputPath];
13
+ };
14
+
15
+ export function extractModels(inputs, options) {
16
+ const outputPath = options.output || 'extracted.yaml';
17
+ const tableIds = options.tables
18
+ ? options.tables.split(',').map(id => id.trim()).filter(Boolean)
19
+ : [];
20
+
21
+ if (tableIds.length === 0) {
22
+ console.error(' ❌ --tables option is required. Specify comma-separated table IDs.');
23
+ return;
24
+ }
25
+
26
+ // 後勝ちマージ用 Map
27
+ const tableMap = new Map();
28
+
29
+ const allFiles = [];
30
+ for (const input of inputs) {
31
+ allFiles.push(...collectYamlFiles(input));
32
+ }
33
+
34
+ if (allFiles.length === 0) {
35
+ console.error(' ❌ No YAML files found');
36
+ return;
37
+ }
38
+
39
+ for (const filePath of allFiles) {
40
+ try {
41
+ const data = yaml.load(fs.readFileSync(filePath, 'utf8'));
42
+ if (!data) continue;
43
+
44
+ let matched = 0;
45
+ for (const table of data.tables || []) {
46
+ if (tableIds.includes(table.id)) {
47
+ tableMap.set(table.id, table); // 後勝ち上書き
48
+ matched++;
49
+ }
50
+ }
51
+
52
+ console.log(` 📄 ${filePath} (${matched} matched)`);
53
+ } catch (e) {
54
+ console.error(` ❌ Failed to read ${filePath}: ${e.message}`);
55
+ }
56
+ }
57
+
58
+ // マッチしなかった ID を警告
59
+ for (const id of tableIds) {
60
+ if (!tableMap.has(id)) {
61
+ console.warn(` ⚠️ Table ID not found: "${id}"`);
62
+ }
63
+ }
64
+
65
+ const outputModel = {
66
+ tables: [...tableMap.values()],
67
+ };
68
+
69
+ fs.writeFileSync(outputPath, yaml.dump(outputModel), 'utf8');
70
+ console.log(`\n ✅ Extracted ${tableMap.size} tables → ${outputPath}`);
71
+ console.log(` 🚀 Run 'modscape dev ${outputPath}' to visualize.`);
72
+ }
package/src/index.js CHANGED
@@ -12,8 +12,14 @@ import { importDbt } from './import-dbt.js';
12
12
  import { syncDbt } from './sync-dbt.js';
13
13
  import { applyLayout } from './layout.js';
14
14
  import { createRequire } from 'module';
15
- import { mergeModels } from './merge.js';
16
-
15
+ import { mergeModels } from './merge.js';
16
+ import { extractModels } from './extract.js';
17
+ import { tableCommand } from './table.js';
18
+ import { columnCommand } from './column.js';
19
+ import { relationshipCommand } from './relationship.js';
20
+ import { lineageCommand } from './lineage.js';
21
+ import { domainCommand } from './domain.js';
22
+
17
23
  const require = createRequire(import.meta.url);
18
24
  const pkg = require('../package.json');
19
25
 
@@ -105,6 +111,22 @@ program
105
111
  mergeModels(paths, options);
106
112
  });
107
113
 
114
+ program
115
+ .command('extract')
116
+ .description('Extract specific tables from YAML models by ID')
117
+ .argument('<paths...>', 'YAML files or directories to extract from')
118
+ .option('-t, --tables <ids>', 'comma-separated table IDs to extract')
119
+ .option('-o, --output <path>', 'output file path', 'extracted.yaml')
120
+ .action((paths, options) => {
121
+ extractModels(paths, options);
122
+ });
123
+
124
+ program.addCommand(tableCommand());
125
+ program.addCommand(columnCommand());
126
+ program.addCommand(relationshipCommand());
127
+ program.addCommand(lineageCommand());
128
+ program.addCommand(domainCommand());
129
+
108
130
  program
109
131
  .command('layout')
110
132
  .description('Perform automatic layout calculation and update the YAML file')
package/src/lineage.js ADDED
@@ -0,0 +1,69 @@
1
+ import { Command } from 'commander';
2
+ import { readYaml, writeYaml, findTableById, outputError, outputWarn, outputOk } from './model-utils.js';
3
+
4
+ export function lineageCommand() {
5
+ const cmd = new Command('lineage').description('Manage data lineage in a YAML model');
6
+
7
+ // list
8
+ cmd
9
+ .command('list <file>')
10
+ .description('List all lineage entries')
11
+ .option('--json', 'output as JSON')
12
+ .action((file, opts) => {
13
+ const data = readYaml(file);
14
+ const entries = data.lineage || [];
15
+ if (opts.json) {
16
+ console.log(JSON.stringify(entries));
17
+ } else {
18
+ if (entries.length === 0) {
19
+ console.log(' (no lineage)');
20
+ } else {
21
+ entries.forEach(e => console.log(` ${e.from} --> ${e.to}`));
22
+ }
23
+ }
24
+ });
25
+
26
+ // add
27
+ cmd
28
+ .command('add <file>')
29
+ .description('Add a lineage entry (data flow from → to)')
30
+ .requiredOption('--from <tableId>', 'upstream table ID')
31
+ .requiredOption('--to <tableId>', 'downstream table ID')
32
+ .option('--json', 'output as JSON')
33
+ .action((file, opts) => {
34
+ const data = readYaml(file);
35
+ if (!findTableById(data, opts.from)) {
36
+ return outputError(opts.json, `Table "${opts.from}" not found`);
37
+ }
38
+ if (!findTableById(data, opts.to)) {
39
+ return outputError(opts.json, `Table "${opts.to}" not found`);
40
+ }
41
+ const entries = data.lineage || [];
42
+ if (entries.some(e => e.from === opts.from && e.to === opts.to)) {
43
+ return outputWarn(opts.json, `Lineage ${opts.from} → ${opts.to} already exists, skipped`);
44
+ }
45
+ data.lineage = [...entries, { from: opts.from, to: opts.to }];
46
+ writeYaml(file, data);
47
+ outputOk(opts.json, 'add', 'lineage', `${opts.from} → ${opts.to}`);
48
+ });
49
+
50
+ // remove
51
+ cmd
52
+ .command('remove <file>')
53
+ .description('Remove a lineage entry')
54
+ .requiredOption('--from <tableId>', 'upstream table ID')
55
+ .requiredOption('--to <tableId>', 'downstream table ID')
56
+ .option('--json', 'output as JSON')
57
+ .action((file, opts) => {
58
+ const data = readYaml(file);
59
+ const before = (data.lineage || []).length;
60
+ data.lineage = (data.lineage || []).filter(e => !(e.from === opts.from && e.to === opts.to));
61
+ if (data.lineage.length === before) {
62
+ return outputWarn(opts.json, `Lineage ${opts.from} → ${opts.to} not found, nothing removed`);
63
+ }
64
+ writeYaml(file, data);
65
+ outputOk(opts.json, 'remove', 'lineage', `${opts.from} → ${opts.to}`);
66
+ });
67
+
68
+ return cmd;
69
+ }
@@ -0,0 +1,53 @@
1
+ import fs from 'fs';
2
+ import yaml from 'js-yaml';
3
+
4
+ export function readYaml(filePath) {
5
+ const data = yaml.load(fs.readFileSync(filePath, 'utf8'));
6
+ return data || {};
7
+ }
8
+
9
+ export function writeYaml(filePath, data) {
10
+ fs.writeFileSync(filePath, yaml.dump(data, { lineWidth: -1 }), 'utf8');
11
+ }
12
+
13
+ export function findTableById(data, id) {
14
+ return (data.tables || []).find(t => t.id === id) || null;
15
+ }
16
+
17
+ export function findDomainById(data, id) {
18
+ return (data.domains || []).find(d => d.id === id) || null;
19
+ }
20
+
21
+ export function output(json, message, opts = {}) {
22
+ if (opts.json) {
23
+ console.log(JSON.stringify(message));
24
+ } else {
25
+ console.log(message);
26
+ }
27
+ }
28
+
29
+ export function outputError(json, message, hint) {
30
+ if (json) {
31
+ console.error(JSON.stringify({ ok: false, error: message, hint: hint || '' }));
32
+ } else {
33
+ console.error(` ❌ ${message}${hint ? '\n 💡 ' + hint : ''}`);
34
+ }
35
+ process.exit(1);
36
+ }
37
+
38
+ export function outputWarn(json, message) {
39
+ if (json) {
40
+ console.error(JSON.stringify({ ok: false, warning: message }));
41
+ } else {
42
+ console.warn(` ⚠️ ${message}`);
43
+ }
44
+ }
45
+
46
+ export function outputOk(json, action, resource, id, extra = {}) {
47
+ if (json) {
48
+ console.log(JSON.stringify({ ok: true, action, resource, id, ...extra }));
49
+ } else {
50
+ const icons = { add: '✅', update: '✏️', remove: '🗑️', list: '📋', get: '🔍', member_add: '✅', member_remove: '🗑️' };
51
+ console.log(` ${icons[action] || '✅'} ${action} ${resource}: ${id}`);
52
+ }
53
+ }
@@ -0,0 +1,93 @@
1
+ import { Command } from 'commander';
2
+ import { readYaml, writeYaml, findTableById, outputError, outputWarn, outputOk } from './model-utils.js';
3
+
4
+ // Parse "table.column" or "table" into { tableId, columnId }
5
+ function parseRef(ref) {
6
+ const parts = ref.split('.');
7
+ return { tableId: parts[0], columnId: parts[1] || null };
8
+ }
9
+
10
+ function sameRel(r, fromTableId, toTableId) {
11
+ return r.from?.table === fromTableId && r.to?.table === toTableId;
12
+ }
13
+
14
+ export function relationshipCommand() {
15
+ const cmd = new Command('relationship').description('Manage relationships in a YAML model');
16
+
17
+ // list
18
+ cmd
19
+ .command('list <file>')
20
+ .description('List all relationships')
21
+ .option('--json', 'output as JSON')
22
+ .action((file, opts) => {
23
+ const data = readYaml(file);
24
+ const rels = data.relationships || [];
25
+ if (opts.json) {
26
+ console.log(JSON.stringify(rels));
27
+ } else {
28
+ if (rels.length === 0) {
29
+ console.log(' (no relationships)');
30
+ } else {
31
+ rels.forEach(r => {
32
+ const from = r.from?.column ? `${r.from.table}.${r.from.column}` : r.from?.table;
33
+ const to = r.to?.column ? `${r.to.table}.${r.to.column}` : r.to?.table;
34
+ console.log(` ${from} --[${r.type}]--> ${to}`);
35
+ });
36
+ }
37
+ }
38
+ });
39
+
40
+ // add
41
+ cmd
42
+ .command('add <file>')
43
+ .description('Add a relationship between two tables')
44
+ .requiredOption('--from <ref>', 'source table (table.column or table)')
45
+ .requiredOption('--to <ref>', 'target table (table.column or table)')
46
+ .requiredOption('--type <type>', 'cardinality (one-to-one|one-to-many|many-to-one|many-to-many)')
47
+ .option('--json', 'output as JSON')
48
+ .action((file, opts) => {
49
+ const data = readYaml(file);
50
+ const from = parseRef(opts.from);
51
+ const to = parseRef(opts.to);
52
+ if (!findTableById(data, from.tableId)) {
53
+ return outputError(opts.json, `Table "${from.tableId}" not found`);
54
+ }
55
+ if (!findTableById(data, to.tableId)) {
56
+ return outputError(opts.json, `Table "${to.tableId}" not found`);
57
+ }
58
+ const rels = data.relationships || [];
59
+ if (rels.some(r => sameRel(r, from.tableId, to.tableId))) {
60
+ return outputWarn(opts.json, `Relationship ${from.tableId} → ${to.tableId} already exists, skipped`);
61
+ }
62
+ const rel = {
63
+ from: { table: from.tableId, ...(from.columnId ? { column: from.columnId } : {}) },
64
+ to: { table: to.tableId, ...(to.columnId ? { column: to.columnId } : {}) },
65
+ type: opts.type,
66
+ };
67
+ data.relationships = [...rels, rel];
68
+ writeYaml(file, data);
69
+ outputOk(opts.json, 'add', 'relationship', `${opts.from} → ${opts.to}`);
70
+ });
71
+
72
+ // remove
73
+ cmd
74
+ .command('remove <file>')
75
+ .description('Remove a relationship')
76
+ .requiredOption('--from <ref>', 'source table (table.column or table)')
77
+ .requiredOption('--to <ref>', 'target table (table.column or table)')
78
+ .option('--json', 'output as JSON')
79
+ .action((file, opts) => {
80
+ const data = readYaml(file);
81
+ const from = parseRef(opts.from);
82
+ const to = parseRef(opts.to);
83
+ const before = (data.relationships || []).length;
84
+ data.relationships = (data.relationships || []).filter(r => !sameRel(r, from.tableId, to.tableId));
85
+ if (data.relationships.length === before) {
86
+ return outputWarn(opts.json, `Relationship ${from.tableId} → ${to.tableId} not found, nothing removed`);
87
+ }
88
+ writeYaml(file, data);
89
+ outputOk(opts.json, 'remove', 'relationship', `${opts.from} → ${opts.to}`);
90
+ });
91
+
92
+ return cmd;
93
+ }