@unito/integration-cli 0.64.5 → 0.65.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.
@@ -866,37 +866,24 @@
866
866
  "typescript": ">=4.8.4 <6.0.0"
867
867
  }
868
868
  },
869
- "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
870
- "version": "4.0.4",
871
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
872
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
873
- "dev": true,
874
- "license": "MIT",
875
- "engines": {
876
- "node": "18 || 20 || >=22"
877
- }
878
- },
879
869
  "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
880
- "version": "5.0.3",
881
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
882
- "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
870
+ "version": "2.0.2",
871
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
872
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
883
873
  "dev": true,
884
874
  "license": "MIT",
885
875
  "dependencies": {
886
- "balanced-match": "^4.0.2"
887
- },
888
- "engines": {
889
- "node": "18 || 20 || >=22"
876
+ "balanced-match": "^1.0.0"
890
877
  }
891
878
  },
892
879
  "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
893
- "version": "9.0.6",
894
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz",
895
- "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==",
880
+ "version": "9.0.9",
881
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
882
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
896
883
  "dev": true,
897
884
  "license": "ISC",
898
885
  "dependencies": {
899
- "brace-expansion": "^5.0.2"
886
+ "brace-expansion": "^2.0.2"
900
887
  },
901
888
  "engines": {
902
889
  "node": ">=16 || 14 >=14.17"
@@ -2294,9 +2281,9 @@
2294
2281
  }
2295
2282
  },
2296
2283
  "node_modules/minimatch": {
2297
- "version": "3.1.3",
2298
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
2299
- "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
2284
+ "version": "3.1.5",
2285
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
2286
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
2300
2287
  "dev": true,
2301
2288
  "license": "ISC",
2302
2289
  "dependencies": {
@@ -0,0 +1,21 @@
1
+ import { BaseCommand } from '../baseCommand';
2
+ import * as GlobalConfiguration from '../resources/globalConfiguration';
3
+ export default class Graph extends BaseCommand<typeof Graph> {
4
+ static description: string;
5
+ static examples: string[];
6
+ static args: {
7
+ operation: import("@oclif/core/lib/interfaces").Arg<string, Record<string, unknown>>;
8
+ };
9
+ catch(error: Error): Promise<void>;
10
+ static flags: {
11
+ path: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
12
+ port: import("@oclif/core/lib/interfaces").OptionFlag<number, import("@oclif/core/lib/interfaces").CustomOptions>;
13
+ environment: import("@oclif/core/lib/interfaces").OptionFlag<GlobalConfiguration.Environment, import("@oclif/core/lib/interfaces").CustomOptions>;
14
+ 'test-account': import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
15
+ 'credential-payload': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
16
+ 'credential-id': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
17
+ 'config-path': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
18
+ output: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
19
+ };
20
+ run(): Promise<void>;
21
+ }
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const crypto_1 = tslib_1.__importDefault(require("crypto"));
5
+ const fs_1 = tslib_1.__importDefault(require("fs"));
6
+ const core_1 = require("@oclif/core");
7
+ const baseCommand_1 = require("../baseCommand");
8
+ const errors_1 = require("../errors");
9
+ const GlobalConfiguration = tslib_1.__importStar(require("../resources/globalConfiguration"));
10
+ const integrations_1 = require("../resources/integrations");
11
+ const configuration_1 = require("../resources/configuration");
12
+ const decryption_1 = require("../resources/decryption");
13
+ const credentials_1 = require("../resources/credentials");
14
+ class Graph extends baseCommand_1.BaseCommand {
15
+ static description = 'Query a running integration graph and print the response';
16
+ static examples = [
17
+ '<%= config.bin %> <%= command.id %> --path=/sobjects/Opportunity/records/abc123',
18
+ '<%= config.bin %> <%= command.id %> get --path=/sobjects/Opportunity --port=9201',
19
+ ];
20
+ static args = {
21
+ operation: core_1.Args.string({
22
+ description: 'Operation to perform on the graph path',
23
+ required: false,
24
+ default: 'get',
25
+ options: ['get', 'getItem', 'getCollection', 'createItem', 'updateItem', 'deleteItem'],
26
+ }),
27
+ };
28
+ async catch(error) {
29
+ /* istanbul ignore if */
30
+ if ((0, errors_1.handleError)(this, error)) {
31
+ this.exit(-1);
32
+ }
33
+ throw error;
34
+ }
35
+ static flags = {
36
+ path: core_1.Flags.string({
37
+ description: 'Graph path to fetch (must start with /), e.g. /sobjects/Opportunity/records/abc123',
38
+ required: true,
39
+ }),
40
+ port: core_1.Flags.integer({
41
+ description: 'Port the integration is running on',
42
+ default: 9200,
43
+ }),
44
+ environment: core_1.Flags.custom({
45
+ description: 'the environment of the platform',
46
+ options: Object.values(GlobalConfiguration.Environment),
47
+ default: GlobalConfiguration.Environment.Production,
48
+ })(),
49
+ 'test-account': core_1.Flags.string({
50
+ description: 'test account to use',
51
+ options: Object.values(configuration_1.CredentialScope),
52
+ default: configuration_1.CredentialScope.DEVELOPMENT,
53
+ }),
54
+ 'credential-payload': core_1.Flags.string({
55
+ description: '(advanced) credential payload to use.',
56
+ exclusive: ['credential-id'],
57
+ }),
58
+ 'credential-id': core_1.Flags.string({
59
+ description: '(advanced) credential to use.',
60
+ exclusive: ['credential-payload'],
61
+ }),
62
+ 'config-path': core_1.Flags.string({
63
+ summary: 'relative path to a custom ".unito.json" file',
64
+ description: `Use a custom configuration file instead of the default '.unito.json'.
65
+
66
+ If you want to force the CLI to use a specific configuration file, you can use this flag to specify the relative
67
+ path from your integration's root folder (with a leading '/').
68
+
69
+ Usage: <%= config.bin %> <%= command.id %> --config-path=/myCustomConfig.json`,
70
+ }),
71
+ output: core_1.Flags.string({
72
+ char: 'o',
73
+ description: 'Write response body to file instead of stdout',
74
+ }),
75
+ };
76
+ async run() {
77
+ (0, integrations_1.validateIsIntegrationDirectory)();
78
+ const { args, flags } = await this.parse(Graph);
79
+ const operation = args.operation ?? 'get';
80
+ if (operation === 'createItem' || operation === 'updateItem' || operation === 'deleteItem') {
81
+ this.error(`Operation "${operation}" is not yet implemented`, { exit: 1 });
82
+ }
83
+ const environment = flags.environment ?? GlobalConfiguration.Environment.Production;
84
+ const configuration = await (0, configuration_1.getConfiguration)(environment, flags['config-path']);
85
+ // Credential resolution — identical to dev.ts
86
+ let credentialPayload = '{}';
87
+ if (flags['credential-id']) {
88
+ const credential = await (0, credentials_1.fetchCredential)(environment, this.config.configDir, flags['credential-id']);
89
+ credentialPayload = JSON.stringify({
90
+ ...credential.payload,
91
+ unitoCredentialId: credential.id,
92
+ unitoUserId: credential.unitoUserId,
93
+ });
94
+ }
95
+ else if (flags['credential-payload']) {
96
+ credentialPayload = flags['credential-payload'];
97
+ }
98
+ else {
99
+ const credentials = configuration.testAccounts?.[flags['test-account']];
100
+ if (credentials) {
101
+ const decryptedEntries = await (0, decryption_1.decryptEntries)(configuration.name, environment, this.config.configDir, credentials);
102
+ if (decryptedEntries.failed.length) {
103
+ throw new errors_1.EntryDecryptionError(decryptedEntries.failed.at(0), environment);
104
+ }
105
+ credentialPayload = JSON.stringify({
106
+ ...decryptedEntries.successful,
107
+ unitoCredentialId: flags['test-account'],
108
+ unitoUserId: flags['test-account'],
109
+ });
110
+ }
111
+ }
112
+ // Load secrets — identical to dev.ts
113
+ const { successful: secrets, failed: failedSecrets } = await (0, decryption_1.decryptEntries)(configuration.name, environment, this.config.configDir, configuration.secrets ?? {});
114
+ if (failedSecrets.length) {
115
+ throw new errors_1.EntryDecryptionError(failedSecrets.at(0), environment);
116
+ }
117
+ // Build Unito request headers.
118
+ // Encoding: base64(JSON.stringify(payload)) — same as integrationDebugger/src/services/crawlerDriver.ts line 424.
119
+ const headers = {
120
+ 'X-Unito-Credentials': Buffer.from(credentialPayload).toString('base64'),
121
+ 'X-Unito-Secrets': Buffer.from(JSON.stringify(secrets)).toString('base64'),
122
+ 'X-Unito-Correlation-Id': crypto_1.default.randomUUID(),
123
+ 'Content-Type': 'application/json',
124
+ };
125
+ const url = `http://localhost:${flags.port}${flags.path}`;
126
+ const response = await fetch(url, { headers });
127
+ if (!response.ok) {
128
+ const body = await response.json().catch(() => undefined);
129
+ if (body !== undefined) {
130
+ this.logToStderr(JSON.stringify(body, null, 2));
131
+ }
132
+ this.error(`HTTP ${response.status} from ${url}`, { exit: response.status });
133
+ }
134
+ const body = (await response.json());
135
+ const json = JSON.stringify(body, null, 2);
136
+ if (flags.output) {
137
+ await fs_1.default.promises.writeFile(flags.output, json, 'utf8');
138
+ }
139
+ else {
140
+ this.log(json);
141
+ }
142
+ }
143
+ }
144
+ exports.default = Graph;
@@ -0,0 +1,9 @@
1
+ import { FlagInput } from '@oclif/core/lib/interfaces/parser';
2
+ import { BaseCommand } from '../baseCommand';
3
+ export default class SchemaSnapshot extends BaseCommand<typeof SchemaSnapshot> {
4
+ static description: string;
5
+ static examples: string[];
6
+ static flags: FlagInput;
7
+ catch(error: Error): Promise<void>;
8
+ run(): Promise<void>;
9
+ }
@@ -0,0 +1,425 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const child_process_1 = tslib_1.__importDefault(require("child_process"));
5
+ const fs_1 = tslib_1.__importDefault(require("fs"));
6
+ const os_1 = tslib_1.__importDefault(require("os"));
7
+ const path_1 = tslib_1.__importDefault(require("path"));
8
+ const core_1 = require("@oclif/core");
9
+ const chalk_1 = tslib_1.__importDefault(require("chalk"));
10
+ const baseCommand_1 = require("../baseCommand");
11
+ const errors_1 = require("../errors");
12
+ const configuration_1 = require("../resources/configuration");
13
+ const coverageCalculator_1 = require("../resources/coverageCalculator");
14
+ const decryption_1 = require("../resources/decryption");
15
+ const fieldExtractor_1 = require("../resources/fieldExtractor");
16
+ const GlobalConfiguration = tslib_1.__importStar(require("../resources/globalConfiguration"));
17
+ const integrations_1 = require("../resources/integrations");
18
+ const jsonlReader_1 = require("../resources/jsonlReader");
19
+ class SchemaSnapshot extends baseCommand_1.BaseCommand {
20
+ static description = 'Generate a schema snapshot by crawling the integration graph';
21
+ static examples = [
22
+ '<%= config.bin %> <%= command.id %> --output=./schema-snapshot.json',
23
+ '<%= config.bin %> <%= command.id %> --output=./schema-snapshot.json --diff=./previous-snapshot.json',
24
+ ];
25
+ static flags = {
26
+ output: core_1.Flags.string({
27
+ description: 'path to write the schema snapshot',
28
+ required: true,
29
+ }),
30
+ diff: core_1.Flags.string({
31
+ description: 'path to a previous snapshot for comparison',
32
+ }),
33
+ environment: core_1.Flags.custom({
34
+ description: 'the environment of the platform',
35
+ options: Object.values(GlobalConfiguration.Environment),
36
+ default: GlobalConfiguration.Environment.Production,
37
+ })(),
38
+ 'test-account': core_1.Flags.string({
39
+ description: 'test account to use',
40
+ default: configuration_1.CredentialScope.DEVELOPMENT,
41
+ }),
42
+ 'config-path': core_1.Flags.string({
43
+ description: 'relative path to a custom ".unito.json" file',
44
+ }),
45
+ 'skip-install': core_1.Flags.boolean({
46
+ description: 'skip npm install before starting',
47
+ default: false,
48
+ }),
49
+ strict: core_1.Flags.boolean({
50
+ description: 'fail if any fields are unmapped and not in the allowlist',
51
+ default: false,
52
+ }),
53
+ };
54
+ async catch(error) {
55
+ if ((0, errors_1.handleError)(this, error)) {
56
+ this.exit(-1);
57
+ }
58
+ throw error;
59
+ }
60
+ async run() {
61
+ (0, integrations_1.validateIsIntegrationDirectory)();
62
+ const { flags } = await this.parse(SchemaSnapshot);
63
+ const environment = flags.environment ?? GlobalConfiguration.Environment.Production;
64
+ const config = await (0, configuration_1.getConfiguration)(environment, flags['config-path']);
65
+ if (!flags['skip-install']) {
66
+ core_1.ux.action.start('Installing NPM dependencies', undefined, { stdout: true });
67
+ child_process_1.default.execSync('npm install', {
68
+ env: { ...process.env, NODE_ENV: 'development' },
69
+ stdio: ['ignore', 'ignore', 'inherit'],
70
+ });
71
+ core_1.ux.action.stop();
72
+ }
73
+ // Resolve credentials.
74
+ const testAccount = flags['test-account'];
75
+ const credentials = config.testAccounts?.[testAccount] || {};
76
+ const decryptedCreds = await (0, decryption_1.decryptEntries)(config.name, environment, this.config.configDir, credentials);
77
+ if (decryptedCreds.failed.length) {
78
+ throw new errors_1.EntryDecryptionError(decryptedCreds.failed.at(0), environment);
79
+ }
80
+ const credentialPayload = {
81
+ ...decryptedCreds.successful,
82
+ unitoCredentialId: flags['test-account'],
83
+ unitoUserId: flags['test-account'],
84
+ };
85
+ // Load secrets.
86
+ const { successful: secrets, failed: failedSecrets } = await (0, decryption_1.decryptEntries)(config.name, environment, this.config.configDir, config.secrets ?? {});
87
+ if (failedSecrets.length) {
88
+ throw new errors_1.EntryDecryptionError(failedSecrets.at(0), environment);
89
+ }
90
+ core_1.ux.action.start('Starting integration', undefined, { stdout: true });
91
+ const integrationPort = '9200';
92
+ let modernProcess;
93
+ const recordingPath = path_1.default.join(os_1.default.tmpdir(), `schema-snapshot-recording-${Date.now()}.jsonl`);
94
+ // Pre-create recording file with restrictive permissions (owner-only read/write).
95
+ fs_1.default.writeFileSync(recordingPath, '', { mode: 0o600 });
96
+ try {
97
+ modernProcess = child_process_1.default.spawn('npm', ['run', 'dev'], {
98
+ detached: false,
99
+ stdio: 'ignore',
100
+ env: {
101
+ ...process.env,
102
+ NODE_ENV: 'development',
103
+ PORT: integrationPort,
104
+ UNITO_SCHEMA_SNAPSHOT_RECORD_PATH: recordingPath,
105
+ },
106
+ });
107
+ await new Promise(resolve => setTimeout(resolve, 3000));
108
+ core_1.ux.action.stop();
109
+ // Load allowlist if present.
110
+ const allowlistPath = path_1.default.join(process.cwd(), '.schema-snapshot-ignore');
111
+ let allowlistConfig = {};
112
+ if (fs_1.default.existsSync(allowlistPath)) {
113
+ try {
114
+ allowlistConfig = JSON.parse(fs_1.default.readFileSync(allowlistPath, 'utf-8'));
115
+ }
116
+ catch (err) {
117
+ core_1.ux.log(chalk_1.default.yellow(`Warning: Failed to parse ${allowlistPath}, ignoring allowlist. ${err}`));
118
+ }
119
+ }
120
+ // Create crawler in sample mode (read-only, limited items).
121
+ const { createWithDirectCrawler, Operation } = await Promise.resolve().then(() => tslib_1.__importStar(require('@unito/integration-debugger/dist/src/services/crawlerDriver')));
122
+ const crawlerDriver = await createWithDirectCrawler(`http://localhost:${integrationPort}`, config.graphRelativeUrl ?? '/', config.credentialAccountRelativeUrl ?? '/me', config.webhookParsingRelativeUrl ?? undefined, config.webhookSubscriptionsRelativeUrl ?? undefined, config.webhookAcknowledgeRelativeUrl ?? undefined, credentialPayload, secrets, {
123
+ readOnly: true,
124
+ timeout: 20,
125
+ [Operation.GetCollection]: {
126
+ itemsPerPage: 1,
127
+ followNextPage: false,
128
+ },
129
+ });
130
+ // Only crawl graph structure, skip all step checks.
131
+ crawlerDriver.stepCheckKeys = [];
132
+ const credentialAccountPath = config.credentialAccountRelativeUrl ?? '/me';
133
+ crawlerDriver.startFrom(makeCrawlerStep(credentialAccountPath, Operation.GetCredentialAccount));
134
+ core_1.ux.action.start('Crawling credential account', undefined, { stdout: true });
135
+ await crawlerDriver.next();
136
+ core_1.ux.action.stop();
137
+ const startingPath = config.graphRelativeUrl ?? '/';
138
+ crawlerDriver.startFrom(makeCrawlerStep(startingPath, Operation.GetItem));
139
+ core_1.ux.action.start('Crawling graph for schemas', undefined, { stdout: true });
140
+ const resources = [];
141
+ let step = await crawlerDriver.next();
142
+ let lastSeq = 0;
143
+ // Raw API responses keyed by schemaPath (the relation collection path they belong to).
144
+ const rawResponsesBySchemaPath = new Map();
145
+ while (step) {
146
+ const payload = step.payloadOut;
147
+ if (step.operation === Operation.GetItem && payload?.relations) {
148
+ const relations = payload.relations;
149
+ const relationSnapshots = relations.map(rel => ({
150
+ name: rel.name,
151
+ path: rel.path,
152
+ label: rel.label,
153
+ semantic: rel.semantic,
154
+ canCreateItem: rel.schema.canCreateItem,
155
+ fields: normalizeFields(rel.schema.fields ?? []),
156
+ }));
157
+ relationSnapshots.sort((a, b) => a.name.localeCompare(b.name));
158
+ resources.push({
159
+ path: step.path,
160
+ operation: step.operation,
161
+ schemaPath: step.schemaPath,
162
+ relations: relationSnapshots,
163
+ });
164
+ }
165
+ // Read raw API responses recorded during this step.
166
+ const { entries: newEntries, lastSeq: newLastSeq } = (0, jsonlReader_1.readNewEntries)(recordingPath, lastSeq);
167
+ lastSeq = newLastSeq;
168
+ // Associate raw responses with their schemaPath (the relation's collection path).
169
+ // This links item-level API calls (e.g. /pokemons/bulbasaur) back to the relation
170
+ // schema defined on the parent resource (e.g. the "pokemons" relation at /).
171
+ if (step.operation === Operation.GetItem && step.schemaPath && newEntries.length > 0) {
172
+ const existing = rawResponsesBySchemaPath.get(step.schemaPath) ?? [];
173
+ existing.push(...newEntries.map(e => ({ url: e.url, body: e.body })));
174
+ rawResponsesBySchemaPath.set(step.schemaPath, existing);
175
+ }
176
+ step = await crawlerDriver.next();
177
+ }
178
+ core_1.ux.action.stop();
179
+ // Sort resources by path for deterministic output.
180
+ resources.sort((a, b) => a.path.localeCompare(b.path));
181
+ // Compute coverage for each relation using raw API responses.
182
+ // Raw responses are keyed by schemaPath — each relation's `path` is the schemaPath
183
+ // for items crawled under that relation.
184
+ for (const resource of resources) {
185
+ for (const relation of resource.relations) {
186
+ const rawResponses = rawResponsesBySchemaPath.get(relation.path) ?? [];
187
+ if (rawResponses.length === 0)
188
+ continue;
189
+ // Combine fields from all raw API responses for this relation.
190
+ const allRawFields = new Set();
191
+ const endpoints = [];
192
+ for (const resp of rawResponses) {
193
+ for (const field of (0, fieldExtractor_1.extractFields)(resp.body)) {
194
+ allRawFields.add(field);
195
+ }
196
+ endpoints.push(resp.url);
197
+ }
198
+ const key = relationKey(resource.path, relation.name);
199
+ const allowlist = allowlistConfig[key]?.unmapped ?? [];
200
+ const relFieldNames = relation.fields.map(f => f.name);
201
+ const rawFieldsArray = [...allRawFields];
202
+ const coverageResult = (0, coverageCalculator_1.computeCoverage)(rawFieldsArray, relFieldNames);
203
+ // Apply allowlist to filter intentionally unmapped fields.
204
+ const filteredUnmapped = (0, coverageCalculator_1.applyAllowlist)(coverageResult.unmappedFields, allowlist);
205
+ // Recalculate coverage after allowlist.
206
+ const effectiveMapped = rawFieldsArray.length - filteredUnmapped.length;
207
+ const effectiveCoveragePercent = rawFieldsArray.length > 0 ? Math.round((effectiveMapped / rawFieldsArray.length) * 100) : 100;
208
+ relation.coverage = {
209
+ rawApiFields: coverageResult.rawApiFields,
210
+ mappedFields: coverageResult.mappedFields,
211
+ unmappedFields: filteredUnmapped,
212
+ coveragePercent: effectiveCoveragePercent,
213
+ rawApiEndpoints: [...new Set(endpoints)],
214
+ };
215
+ }
216
+ }
217
+ const snapshot = {
218
+ generatedAt: new Date().toISOString(),
219
+ resources,
220
+ };
221
+ fs_1.default.writeFileSync(flags.output, JSON.stringify(snapshot, null, 2));
222
+ core_1.ux.log(chalk_1.default.green(`\nSnapshot written to ${flags.output}`));
223
+ core_1.ux.log(chalk_1.default.gray(` ${resources.length} resource(s), ${resources.reduce((sum, r) => sum + r.relations.length, 0)} relation(s)`));
224
+ // Display coverage summary.
225
+ const relationsWithCoverage = resources.flatMap(r => r.relations).filter(rel => rel.coverage);
226
+ if (relationsWithCoverage.length > 0) {
227
+ core_1.ux.log(chalk_1.default.bold('\nCoverage Summary'));
228
+ core_1.ux.log(chalk_1.default.bold('================\n'));
229
+ for (const rel of relationsWithCoverage) {
230
+ const cov = rel.coverage;
231
+ const color = coverageColor(cov.coveragePercent);
232
+ core_1.ux.log(` ${rel.name}: ${color(`${cov.coveragePercent}%`)} (${cov.mappedFields.length}/${cov.rawApiFields.length} fields)`);
233
+ if (cov.unmappedFields.length > 0) {
234
+ core_1.ux.log(chalk_1.default.gray(` unmapped: ${cov.unmappedFields.slice(0, 10).join(', ')}${cov.unmappedFields.length > 10 ? ` (+${cov.unmappedFields.length - 10} more)` : ''}`));
235
+ }
236
+ }
237
+ }
238
+ // Strict mode: fail if any unmapped fields remain after allowlist.
239
+ if (flags.strict) {
240
+ const failures = relationsWithCoverage.filter(rel => rel.coverage.unmappedFields.length > 0);
241
+ if (failures.length > 0) {
242
+ core_1.ux.log(chalk_1.default.red('\n--strict: unmapped fields detected\n'));
243
+ for (const rel of failures) {
244
+ core_1.ux.log(chalk_1.default.red(` ${rel.name}: ${rel.coverage.unmappedFields.length} unmapped fields`));
245
+ }
246
+ core_1.ux.log(chalk_1.default.yellow('\nAdd missing fields to the schema or justify them in .schema-snapshot-ignore'));
247
+ this.exit(1);
248
+ }
249
+ }
250
+ // Diff against previous snapshot if requested.
251
+ if (flags.diff) {
252
+ const diffPath = flags.diff;
253
+ if (!fs_1.default.existsSync(diffPath)) {
254
+ core_1.ux.log(chalk_1.default.yellow(`\nDiff file not found: ${diffPath}`));
255
+ }
256
+ else {
257
+ const previousRaw = fs_1.default.readFileSync(diffPath, 'utf-8');
258
+ const previous = JSON.parse(previousRaw);
259
+ const diff = diffSnapshots(previous, snapshot);
260
+ core_1.ux.log(chalk_1.default.bold('\nSchema Diff'));
261
+ core_1.ux.log(chalk_1.default.bold('===========\n'));
262
+ if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) {
263
+ core_1.ux.log(chalk_1.default.green('No differences found.'));
264
+ }
265
+ else {
266
+ for (const added of diff.added) {
267
+ core_1.ux.log(chalk_1.default.green(` + ${added}`));
268
+ }
269
+ for (const removed of diff.removed) {
270
+ core_1.ux.log(chalk_1.default.red(` - ${removed}`));
271
+ }
272
+ for (const changed of diff.changed) {
273
+ core_1.ux.log(chalk_1.default.yellow(` ~ ${changed.path}: ${changed.details}`));
274
+ }
275
+ core_1.ux.log(`\n ${chalk_1.default.green(`${diff.added.length} added`)}, ${chalk_1.default.red(`${diff.removed.length} removed`)}, ${chalk_1.default.yellow(`${diff.changed.length} changed`)}`);
276
+ }
277
+ if (diff.coverageChanges.length > 0) {
278
+ core_1.ux.log(chalk_1.default.bold('\nCoverage Changes'));
279
+ core_1.ux.log(chalk_1.default.bold('================\n'));
280
+ for (const change of diff.coverageChanges) {
281
+ const color = change.details.startsWith('↓') ? chalk_1.default.red : chalk_1.default.green;
282
+ core_1.ux.log(color(` ${change.path}: ${change.details}`));
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ finally {
289
+ modernProcess?.kill('SIGTERM');
290
+ try {
291
+ fs_1.default.unlinkSync(recordingPath);
292
+ }
293
+ catch {
294
+ /* Best-effort cleanup. */
295
+ }
296
+ }
297
+ }
298
+ }
299
+ exports.default = SchemaSnapshot;
300
+ function relationKey(resourcePath, relationName) {
301
+ return `${resourcePath} → ${relationName}`;
302
+ }
303
+ function makeCrawlerStep(stepPath, operation) {
304
+ return {
305
+ path: stepPath,
306
+ operation,
307
+ schemaPath: undefined,
308
+ parentOperation: undefined,
309
+ parentPath: undefined,
310
+ requestSchema: undefined,
311
+ warnings: [],
312
+ errors: [],
313
+ };
314
+ }
315
+ function coverageColor(percent) {
316
+ if (percent >= 80)
317
+ return chalk_1.default.green;
318
+ if (percent >= 50)
319
+ return chalk_1.default.yellow;
320
+ return chalk_1.default.red;
321
+ }
322
+ const OPTIONAL_FIELD_KEYS = [
323
+ 'semantic',
324
+ 'nullable',
325
+ 'isArray',
326
+ 'canSetOnCreate',
327
+ 'canSetOnUpdate',
328
+ 'readOnly',
329
+ ];
330
+ function normalizeFields(fields) {
331
+ return fields
332
+ .map(f => {
333
+ const snapshot = {
334
+ name: f.name,
335
+ type: f.type,
336
+ };
337
+ for (const key of OPTIONAL_FIELD_KEYS) {
338
+ if (f[key] !== undefined) {
339
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
340
+ snapshot[key] = f[key];
341
+ }
342
+ }
343
+ return snapshot;
344
+ })
345
+ .sort((a, b) => a.name.localeCompare(b.name));
346
+ }
347
+ function diffSnapshots(previous, current) {
348
+ const result = { added: [], removed: [], changed: [], coverageChanges: [] };
349
+ const prevMap = buildSchemaMap(previous);
350
+ const currMap = buildSchemaMap(current);
351
+ const allKeys = new Set([...prevMap.keys(), ...currMap.keys()]);
352
+ for (const key of allKeys) {
353
+ const prev = prevMap.get(key);
354
+ const curr = currMap.get(key);
355
+ if (!prev && curr) {
356
+ result.added.push(key);
357
+ }
358
+ else if (prev && !curr) {
359
+ result.removed.push(key);
360
+ }
361
+ else if (prev && curr) {
362
+ const prevFields = new Set(prev);
363
+ const currFields = new Set(curr);
364
+ const addedFields = [...currFields].filter(f => !prevFields.has(f));
365
+ const removedFields = [...prevFields].filter(f => !currFields.has(f));
366
+ if (addedFields.length > 0 || removedFields.length > 0) {
367
+ const details = [];
368
+ if (addedFields.length > 0)
369
+ details.push(`+fields: ${addedFields.join(', ')}`);
370
+ if (removedFields.length > 0)
371
+ details.push(`-fields: ${removedFields.join(', ')}`);
372
+ result.changed.push({ path: key, details: details.join('; ') });
373
+ }
374
+ }
375
+ }
376
+ // Coverage changes.
377
+ const prevCoverageMap = buildCoverageMap(previous);
378
+ const currCoverageMap = buildCoverageMap(current);
379
+ const allCoverageKeys = new Set([...prevCoverageMap.keys(), ...currCoverageMap.keys()]);
380
+ for (const key of allCoverageKeys) {
381
+ const prev = prevCoverageMap.get(key);
382
+ const curr = currCoverageMap.get(key);
383
+ if (prev && curr && prev.coveragePercent !== curr.coveragePercent) {
384
+ const direction = curr.coveragePercent > prev.coveragePercent ? '↑' : '↓';
385
+ const newUnmapped = curr.unmappedFields.filter(f => !prev.unmappedFields.includes(f));
386
+ const newlyMapped = prev.unmappedFields.filter(f => !curr.unmappedFields.includes(f));
387
+ let details = `${direction} coverage ${prev.coveragePercent}% → ${curr.coveragePercent}%`;
388
+ const annotations = [];
389
+ if (newUnmapped.length > 0)
390
+ annotations.push(`new unmapped: ${newUnmapped.join(', ')}`);
391
+ if (newlyMapped.length > 0)
392
+ annotations.push(`newly mapped: ${newlyMapped.join(', ')}`);
393
+ if (annotations.length > 0)
394
+ details += ` (${annotations.join(', ')})`;
395
+ result.coverageChanges.push({ path: key, details });
396
+ }
397
+ else if (!prev && curr) {
398
+ result.coverageChanges.push({
399
+ path: key,
400
+ details: `new coverage data: ${curr.coveragePercent}% (${curr.unmappedFields.length} unmapped)`,
401
+ });
402
+ }
403
+ }
404
+ return result;
405
+ }
406
+ function buildCoverageMap(snapshot) {
407
+ const map = new Map();
408
+ for (const resource of snapshot.resources) {
409
+ for (const relation of resource.relations) {
410
+ if (relation.coverage) {
411
+ map.set(relationKey(resource.path, relation.name), relation.coverage);
412
+ }
413
+ }
414
+ }
415
+ return map;
416
+ }
417
+ function buildSchemaMap(snapshot) {
418
+ const map = new Map();
419
+ for (const resource of snapshot.resources) {
420
+ for (const relation of resource.relations) {
421
+ map.set(relationKey(resource.path, relation.name), relation.fields.map(f => f.name));
422
+ }
423
+ }
424
+ return map;
425
+ }