@prisma-next/cli 0.3.0-dev.3 → 0.3.0-dev.30
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 +111 -27
- package/dist/{chunk-BZMBKEEQ.js → chunk-AGOTG4L3.js} +44 -76
- package/dist/chunk-AGOTG4L3.js.map +1 -0
- package/dist/chunk-HLLI4YL7.js +180 -0
- package/dist/chunk-HLLI4YL7.js.map +1 -0
- package/dist/chunk-VG2R7DGF.js +735 -0
- package/dist/chunk-VG2R7DGF.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1502 -968
- package/dist/cli.js.map +1 -1
- package/dist/commands/contract-emit.d.ts +2 -4
- package/dist/commands/contract-emit.d.ts.map +1 -0
- package/dist/commands/contract-emit.js +3 -2
- package/dist/commands/db-init.d.ts +2 -4
- package/dist/commands/db-init.d.ts.map +1 -0
- package/dist/commands/db-init.js +205 -289
- package/dist/commands/db-init.js.map +1 -1
- package/dist/commands/db-introspect.d.ts +2 -4
- package/dist/commands/db-introspect.d.ts.map +1 -0
- package/dist/commands/db-introspect.js +108 -143
- package/dist/commands/db-introspect.js.map +1 -1
- package/dist/commands/db-schema-verify.d.ts +2 -4
- package/dist/commands/db-schema-verify.d.ts.map +1 -0
- package/dist/commands/db-schema-verify.js +120 -113
- package/dist/commands/db-schema-verify.js.map +1 -1
- package/dist/commands/db-sign.d.ts +2 -4
- package/dist/commands/db-sign.d.ts.map +1 -0
- package/dist/commands/db-sign.js +152 -156
- package/dist/commands/db-sign.js.map +1 -1
- package/dist/commands/db-verify.d.ts +2 -4
- package/dist/commands/db-verify.d.ts.map +1 -0
- package/dist/commands/db-verify.js +142 -122
- package/dist/commands/db-verify.js.map +1 -1
- package/dist/config-loader.d.ts +3 -5
- package/dist/config-loader.d.ts.map +1 -0
- package/dist/control-api/client.d.ts +13 -0
- package/dist/control-api/client.d.ts.map +1 -0
- package/dist/control-api/operations/db-init.d.ts +29 -0
- package/dist/control-api/operations/db-init.d.ts.map +1 -0
- package/dist/control-api/types.d.ts +387 -0
- package/dist/control-api/types.d.ts.map +1 -0
- package/dist/exports/config-types.d.ts +3 -0
- package/dist/exports/config-types.d.ts.map +1 -0
- package/dist/exports/config-types.js.map +1 -0
- package/dist/exports/control-api.d.ts +13 -0
- package/dist/exports/control-api.d.ts.map +1 -0
- package/dist/exports/control-api.js +7 -0
- package/dist/exports/control-api.js.map +1 -0
- package/dist/exports/index.d.ts +4 -0
- package/dist/exports/index.d.ts.map +1 -0
- package/dist/{index.js → exports/index.js} +4 -3
- package/dist/exports/index.js.map +1 -0
- package/dist/{index.d.ts → load-ts-contract.d.ts} +4 -8
- package/dist/load-ts-contract.d.ts.map +1 -0
- package/dist/utils/cli-errors.d.ts +7 -0
- package/dist/utils/cli-errors.d.ts.map +1 -0
- package/dist/utils/command-helpers.d.ts +12 -0
- package/dist/utils/command-helpers.d.ts.map +1 -0
- package/dist/utils/framework-components.d.ts +70 -0
- package/dist/utils/framework-components.d.ts.map +1 -0
- package/dist/utils/global-flags.d.ts +25 -0
- package/dist/utils/global-flags.d.ts.map +1 -0
- package/dist/utils/output.d.ts +142 -0
- package/dist/utils/output.d.ts.map +1 -0
- package/dist/utils/progress-adapter.d.ts +26 -0
- package/dist/utils/progress-adapter.d.ts.map +1 -0
- package/dist/utils/result-handler.d.ts +15 -0
- package/dist/utils/result-handler.d.ts.map +1 -0
- package/package.json +30 -26
- package/src/cli.ts +260 -0
- package/src/commands/contract-emit.ts +259 -0
- package/src/commands/db-init.ts +360 -0
- package/src/commands/db-introspect.ts +227 -0
- package/src/commands/db-schema-verify.ts +238 -0
- package/src/commands/db-sign.ts +279 -0
- package/src/commands/db-verify.ts +258 -0
- package/src/config-loader.ts +76 -0
- package/src/control-api/client.ts +589 -0
- package/src/control-api/operations/db-init.ts +281 -0
- package/src/control-api/types.ts +461 -0
- package/src/exports/config-types.ts +6 -0
- package/src/exports/control-api.ts +46 -0
- package/src/exports/index.ts +4 -0
- package/src/load-ts-contract.ts +217 -0
- package/src/utils/cli-errors.ts +26 -0
- package/src/utils/command-helpers.ts +26 -0
- package/src/utils/framework-components.ts +177 -0
- package/src/utils/global-flags.ts +75 -0
- package/src/utils/output.ts +1471 -0
- package/src/utils/progress-adapter.ts +86 -0
- package/src/utils/result-handler.ts +44 -0
- package/dist/chunk-464LNZCE.js +0 -134
- package/dist/chunk-464LNZCE.js.map +0 -1
- package/dist/chunk-BZMBKEEQ.js.map +0 -1
- package/dist/chunk-ZKYEJROM.js +0 -94
- package/dist/chunk-ZKYEJROM.js.map +0 -1
- package/dist/config-types.d.ts +0 -1
- package/dist/config-types.js.map +0 -1
- package/dist/index.js.map +0 -1
- /package/dist/{config-types.js → exports/config-types.js} +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { ContractIR } from '@prisma-next/contract/ir';
|
|
5
|
+
import type { Plugin } from 'esbuild';
|
|
6
|
+
import { build } from 'esbuild';
|
|
7
|
+
|
|
8
|
+
export interface LoadTsContractOptions {
|
|
9
|
+
readonly allowlist?: ReadonlyArray<string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ALLOWLIST = ['@prisma-next/*'];
|
|
13
|
+
|
|
14
|
+
function isAllowedImport(importPath: string, allowlist: ReadonlyArray<string>): boolean {
|
|
15
|
+
for (const pattern of allowlist) {
|
|
16
|
+
if (pattern.endsWith('/*')) {
|
|
17
|
+
const prefix = pattern.slice(0, -2);
|
|
18
|
+
if (importPath === prefix || importPath.startsWith(`${prefix}/`)) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
} else if (importPath === pattern) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validatePurity(value: unknown): void {
|
|
29
|
+
if (typeof value !== 'object' || value === null) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const seen = new WeakSet();
|
|
34
|
+
function check(value: unknown): void {
|
|
35
|
+
if (value === null || typeof value !== 'object') {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (seen.has(value)) {
|
|
40
|
+
throw new Error('Contract export contains circular references');
|
|
41
|
+
}
|
|
42
|
+
seen.add(value);
|
|
43
|
+
|
|
44
|
+
for (const key in value) {
|
|
45
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
46
|
+
if (descriptor && (descriptor.get || descriptor.set)) {
|
|
47
|
+
throw new Error(`Contract export contains getter/setter at key "${key}"`);
|
|
48
|
+
}
|
|
49
|
+
if (descriptor && typeof descriptor.value === 'function') {
|
|
50
|
+
throw new Error(`Contract export contains function at key "${key}"`);
|
|
51
|
+
}
|
|
52
|
+
check((value as Record<string, unknown>)[key]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
check(value);
|
|
58
|
+
JSON.stringify(value);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof Error) {
|
|
61
|
+
if (error.message.includes('getter') || error.message.includes('circular')) {
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Contract export is not JSON-serializable: ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
throw new Error('Contract export is not JSON-serializable');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createImportAllowlistPlugin(allowlist: ReadonlyArray<string>, entryPath: string): Plugin {
|
|
71
|
+
return {
|
|
72
|
+
name: 'import-allowlist',
|
|
73
|
+
setup(build) {
|
|
74
|
+
build.onResolve({ filter: /.*/ }, (args) => {
|
|
75
|
+
if (args.kind === 'entry-point') {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
if (args.path.startsWith('.') || args.path.startsWith('/')) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const isFromEntryPoint = args.importer === entryPath || args.importer === '<stdin>';
|
|
82
|
+
if (isFromEntryPoint && !isAllowedImport(args.path, allowlist)) {
|
|
83
|
+
return {
|
|
84
|
+
path: args.path,
|
|
85
|
+
external: true,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Loads a contract from a TypeScript file and returns it as ContractIR.
|
|
96
|
+
*
|
|
97
|
+
* **Responsibility: Parsing Only**
|
|
98
|
+
* This function loads and parses a TypeScript contract file. It does NOT normalize the contract.
|
|
99
|
+
* The contract should already be normalized if it was built using the contract builder.
|
|
100
|
+
*
|
|
101
|
+
* Normalization must happen in the contract builder when the contract is created.
|
|
102
|
+
* This function only validates that the contract is JSON-serializable and returns it as-is.
|
|
103
|
+
*
|
|
104
|
+
* @param entryPath - Path to the TypeScript contract file
|
|
105
|
+
* @param options - Optional configuration (import allowlist)
|
|
106
|
+
* @returns The contract as ContractIR (should already be normalized)
|
|
107
|
+
* @throws Error if the contract cannot be loaded or is not JSON-serializable
|
|
108
|
+
*/
|
|
109
|
+
export async function loadContractFromTs(
|
|
110
|
+
entryPath: string,
|
|
111
|
+
options?: LoadTsContractOptions,
|
|
112
|
+
): Promise<ContractIR> {
|
|
113
|
+
const allowlist = options?.allowlist ?? DEFAULT_ALLOWLIST;
|
|
114
|
+
|
|
115
|
+
if (!existsSync(entryPath)) {
|
|
116
|
+
throw new Error(`Contract file not found: ${entryPath}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tempFile = join(
|
|
120
|
+
tmpdir(),
|
|
121
|
+
`prisma-next-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const result = await build({
|
|
126
|
+
entryPoints: [entryPath],
|
|
127
|
+
bundle: true,
|
|
128
|
+
format: 'esm',
|
|
129
|
+
platform: 'node',
|
|
130
|
+
target: 'es2022',
|
|
131
|
+
outfile: tempFile,
|
|
132
|
+
write: false,
|
|
133
|
+
metafile: true,
|
|
134
|
+
plugins: [createImportAllowlistPlugin(allowlist, entryPath)],
|
|
135
|
+
logLevel: 'error',
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (result.errors.length > 0) {
|
|
139
|
+
const errorMessages = result.errors.map((e: { text: string }) => e.text).join('\n');
|
|
140
|
+
throw new Error(`Failed to bundle contract file: ${errorMessages}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!result.outputFiles || result.outputFiles.length === 0) {
|
|
144
|
+
throw new Error('No output files generated from bundling');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const disallowedImports: string[] = [];
|
|
148
|
+
if (result.metafile) {
|
|
149
|
+
const inputs = result.metafile.inputs;
|
|
150
|
+
for (const [, inputData] of Object.entries(inputs)) {
|
|
151
|
+
const imports =
|
|
152
|
+
(inputData as { imports?: Array<{ path: string; external?: boolean }> }).imports || [];
|
|
153
|
+
for (const imp of imports) {
|
|
154
|
+
if (
|
|
155
|
+
imp.external &&
|
|
156
|
+
!imp.path.startsWith('.') &&
|
|
157
|
+
!imp.path.startsWith('/') &&
|
|
158
|
+
!isAllowedImport(imp.path, allowlist)
|
|
159
|
+
) {
|
|
160
|
+
disallowedImports.push(imp.path);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (disallowedImports.length > 0) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Disallowed imports detected. Only imports matching the allowlist are permitted:\n Allowlist: ${allowlist.join(', ')}\n Disallowed imports: ${disallowedImports.join(', ')}\n\nOnly @prisma-next/* packages are allowed in contract files.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const bundleContent = result.outputFiles[0]?.text;
|
|
173
|
+
if (bundleContent === undefined) {
|
|
174
|
+
throw new Error('Bundle content is undefined');
|
|
175
|
+
}
|
|
176
|
+
writeFileSync(tempFile, bundleContent, 'utf-8');
|
|
177
|
+
|
|
178
|
+
const module = (await import(`file://${tempFile}`)) as {
|
|
179
|
+
default?: unknown;
|
|
180
|
+
contract?: unknown;
|
|
181
|
+
};
|
|
182
|
+
unlinkSync(tempFile);
|
|
183
|
+
|
|
184
|
+
let contract: unknown;
|
|
185
|
+
|
|
186
|
+
if (module.default !== undefined) {
|
|
187
|
+
contract = module.default;
|
|
188
|
+
} else if (module.contract !== undefined) {
|
|
189
|
+
contract = module.contract;
|
|
190
|
+
} else {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Contract file must export a contract as default export or named export 'contract'. Found exports: ${Object.keys(module as Record<string, unknown>).join(', ') || 'none'}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (typeof contract !== 'object' || contract === null) {
|
|
197
|
+
throw new Error(`Contract export must be an object, got ${typeof contract}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
validatePurity(contract);
|
|
201
|
+
|
|
202
|
+
return contract as ContractIR;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
try {
|
|
205
|
+
if (tempFile) {
|
|
206
|
+
unlinkSync(tempFile);
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// Ignore cleanup errors
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (error instanceof Error) {
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`Failed to load contract from ${entryPath}: ${String(error)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export all domain error factories from core-control-plane for convenience.
|
|
3
|
+
* CLI-specific errors (e.g., Commander.js argument validation) can be added here if needed.
|
|
4
|
+
*/
|
|
5
|
+
export type { CliErrorConflict, CliErrorEnvelope } from '@prisma-next/core-control-plane/errors';
|
|
6
|
+
export {
|
|
7
|
+
CliStructuredError,
|
|
8
|
+
errorConfigFileNotFound,
|
|
9
|
+
errorConfigValidation,
|
|
10
|
+
errorContractConfigMissing,
|
|
11
|
+
errorContractMissingExtensionPacks,
|
|
12
|
+
errorContractValidationFailed,
|
|
13
|
+
errorDatabaseConnectionRequired,
|
|
14
|
+
errorDriverRequired,
|
|
15
|
+
errorFamilyReadMarkerSqlRequired,
|
|
16
|
+
errorFileNotFound,
|
|
17
|
+
errorHashMismatch,
|
|
18
|
+
errorJsonFormatNotSupported,
|
|
19
|
+
errorMarkerMissing,
|
|
20
|
+
errorMigrationPlanningFailed,
|
|
21
|
+
errorQueryRunnerFactoryRequired,
|
|
22
|
+
errorRuntime,
|
|
23
|
+
errorTargetMigrationNotSupported,
|
|
24
|
+
errorTargetMismatch,
|
|
25
|
+
errorUnexpected,
|
|
26
|
+
} from '@prisma-next/core-control-plane/errors';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sets both short and long descriptions for a command.
|
|
5
|
+
* The short description is used in command trees and headers.
|
|
6
|
+
* The long description is shown at the bottom of help output.
|
|
7
|
+
*/
|
|
8
|
+
export function setCommandDescriptions(
|
|
9
|
+
command: Command,
|
|
10
|
+
shortDescription: string,
|
|
11
|
+
longDescription?: string,
|
|
12
|
+
): Command {
|
|
13
|
+
command.description(shortDescription);
|
|
14
|
+
if (longDescription) {
|
|
15
|
+
// Store long description in a custom property for our formatters to access
|
|
16
|
+
(command as Command & { _longDescription?: string })._longDescription = longDescription;
|
|
17
|
+
}
|
|
18
|
+
return command;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Gets the long description from a command if it was set via setCommandDescriptions.
|
|
23
|
+
*/
|
|
24
|
+
export function getLongDescription(command: Command): string | undefined {
|
|
25
|
+
return (command as Command & { _longDescription?: string })._longDescription;
|
|
26
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkContractComponentRequirements,
|
|
3
|
+
type TargetBoundComponentDescriptor,
|
|
4
|
+
} from '@prisma-next/contract/framework-components';
|
|
5
|
+
import type { ContractIR } from '@prisma-next/contract/ir';
|
|
6
|
+
import type { ControlPlaneStack } from '@prisma-next/core-control-plane/types';
|
|
7
|
+
import { errorConfigValidation, errorContractMissingExtensionPacks } from './cli-errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Asserts that all framework components are compatible with the expected family and target.
|
|
11
|
+
*
|
|
12
|
+
* This function validates that each component in the framework components array:
|
|
13
|
+
* - Has kind 'target', 'adapter', 'extension', or 'driver'
|
|
14
|
+
* - Has familyId matching expectedFamilyId
|
|
15
|
+
* - Has targetId matching expectedTargetId
|
|
16
|
+
*
|
|
17
|
+
* This validation happens at the CLI composition boundary, before passing components
|
|
18
|
+
* to typed planner/runner instances. It fills the gap between runtime validation
|
|
19
|
+
* (via `validateConfig()`) and compile-time type enforcement.
|
|
20
|
+
*
|
|
21
|
+
* @param expectedFamilyId - The expected family ID (e.g., 'sql')
|
|
22
|
+
* @param expectedTargetId - The expected target ID (e.g., 'postgres')
|
|
23
|
+
* @param frameworkComponents - Array of framework components to validate
|
|
24
|
+
* @returns The same array typed as TargetBoundComponentDescriptor
|
|
25
|
+
* @throws CliStructuredError if any component is incompatible
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* const config = await loadConfig();
|
|
30
|
+
* const frameworkComponents = [config.target, config.adapter, ...(config.extensionPacks ?? [])];
|
|
31
|
+
*
|
|
32
|
+
* // Validate and type-narrow components before passing to planner
|
|
33
|
+
* const typedComponents = assertFrameworkComponentsCompatible(
|
|
34
|
+
* config.family.familyId,
|
|
35
|
+
* config.target.targetId,
|
|
36
|
+
* frameworkComponents
|
|
37
|
+
* );
|
|
38
|
+
*
|
|
39
|
+
* const planner = target.migrations.createPlanner(familyInstance);
|
|
40
|
+
* planner.plan({ contract, schema, policy, frameworkComponents: typedComponents });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function assertFrameworkComponentsCompatible<
|
|
44
|
+
TFamilyId extends string,
|
|
45
|
+
TTargetId extends string,
|
|
46
|
+
>(
|
|
47
|
+
expectedFamilyId: TFamilyId,
|
|
48
|
+
expectedTargetId: TTargetId,
|
|
49
|
+
frameworkComponents: ReadonlyArray<unknown>,
|
|
50
|
+
): ReadonlyArray<TargetBoundComponentDescriptor<TFamilyId, TTargetId>> {
|
|
51
|
+
for (let i = 0; i < frameworkComponents.length; i++) {
|
|
52
|
+
const component = frameworkComponents[i];
|
|
53
|
+
|
|
54
|
+
// Check that component is an object
|
|
55
|
+
if (typeof component !== 'object' || component === null) {
|
|
56
|
+
throw errorConfigValidation('frameworkComponents[]', {
|
|
57
|
+
why: `Framework component at index ${i} must be an object`,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const record = component as Record<string, unknown>;
|
|
62
|
+
|
|
63
|
+
// Check kind
|
|
64
|
+
if (!Object.hasOwn(record, 'kind')) {
|
|
65
|
+
throw errorConfigValidation('frameworkComponents[].kind', {
|
|
66
|
+
why: `Framework component at index ${i} must have 'kind' property`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const kind = record['kind'];
|
|
71
|
+
if (kind !== 'target' && kind !== 'adapter' && kind !== 'extension' && kind !== 'driver') {
|
|
72
|
+
throw errorConfigValidation('frameworkComponents[].kind', {
|
|
73
|
+
why: `Framework component at index ${i} has invalid kind '${String(kind)}' (must be 'target', 'adapter', 'extension', or 'driver')`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check familyId
|
|
78
|
+
if (!Object.hasOwn(record, 'familyId')) {
|
|
79
|
+
throw errorConfigValidation('frameworkComponents[].familyId', {
|
|
80
|
+
why: `Framework component at index ${i} (kind: ${String(kind)}) must have 'familyId' property`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const familyId = record['familyId'];
|
|
85
|
+
if (familyId !== expectedFamilyId) {
|
|
86
|
+
throw errorConfigValidation('frameworkComponents[].familyId', {
|
|
87
|
+
why: `Framework component at index ${i} (kind: ${String(kind)}) has familyId '${String(familyId)}' but expected '${expectedFamilyId}'`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check targetId
|
|
92
|
+
if (!Object.hasOwn(record, 'targetId')) {
|
|
93
|
+
throw errorConfigValidation('frameworkComponents[].targetId', {
|
|
94
|
+
why: `Framework component at index ${i} (kind: ${String(kind)}) must have 'targetId' property`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const targetId = record['targetId'];
|
|
99
|
+
if (targetId !== expectedTargetId) {
|
|
100
|
+
throw errorConfigValidation('frameworkComponents[].targetId', {
|
|
101
|
+
why: `Framework component at index ${i} (kind: ${String(kind)}) has targetId '${String(targetId)}' but expected '${expectedTargetId}'`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Type assertion is safe because we've validated all components above
|
|
107
|
+
return frameworkComponents as ReadonlyArray<TargetBoundComponentDescriptor<TFamilyId, TTargetId>>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validates that a contract is compatible with the configured target, adapter,
|
|
112
|
+
* and extension packs. Throws on family/target mismatches or missing extension packs.
|
|
113
|
+
*
|
|
114
|
+
* This check ensures the emitted contract matches the CLI config before running
|
|
115
|
+
* commands that depend on the contract (e.g., db verify, db sign).
|
|
116
|
+
*
|
|
117
|
+
* @param contract - The contract IR to validate (must include targetFamily, target, extensionPacks).
|
|
118
|
+
* @param stack - The control plane stack (target, adapter, driver, extensionPacks).
|
|
119
|
+
*
|
|
120
|
+
* @throws {CliStructuredError} errorConfigValidation when contract.targetFamily or contract.target
|
|
121
|
+
* doesn't match the configured family/target.
|
|
122
|
+
* @throws {CliStructuredError} errorContractMissingExtensionPacks when the contract requires
|
|
123
|
+
* extension packs that are not provided in the config (includes all missing packs in error.meta).
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* import { assertContractRequirementsSatisfied } from './framework-components';
|
|
128
|
+
*
|
|
129
|
+
* const config = await loadConfig();
|
|
130
|
+
* const contractIR = await loadContractJson(config.contract.output);
|
|
131
|
+
* const stack = createControlPlaneStack({ target: config.target, adapter: config.adapter, ... });
|
|
132
|
+
*
|
|
133
|
+
* // Throws if contract is incompatible with config
|
|
134
|
+
* assertContractRequirementsSatisfied({ contract: contractIR, stack });
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
export function assertContractRequirementsSatisfied<
|
|
138
|
+
TFamilyId extends string,
|
|
139
|
+
TTargetId extends string,
|
|
140
|
+
>({
|
|
141
|
+
contract,
|
|
142
|
+
stack,
|
|
143
|
+
}: {
|
|
144
|
+
readonly contract: Pick<ContractIR, 'targetFamily' | 'target' | 'extensionPacks'>;
|
|
145
|
+
readonly stack: ControlPlaneStack<TFamilyId, TTargetId>;
|
|
146
|
+
}): void {
|
|
147
|
+
const providedComponentIds = new Set<string>([stack.target.id, stack.adapter.id]);
|
|
148
|
+
for (const extension of stack.extensionPacks) {
|
|
149
|
+
providedComponentIds.add(extension.id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const result = checkContractComponentRequirements({
|
|
153
|
+
contract,
|
|
154
|
+
expectedTargetFamily: stack.target.familyId,
|
|
155
|
+
expectedTargetId: stack.target.targetId,
|
|
156
|
+
providedComponentIds,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (result.familyMismatch) {
|
|
160
|
+
throw errorConfigValidation('contract.targetFamily', {
|
|
161
|
+
why: `Contract was emitted for family '${result.familyMismatch.actual}' but CLI config is wired to '${result.familyMismatch.expected}'.`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (result.targetMismatch) {
|
|
166
|
+
throw errorConfigValidation('contract.target', {
|
|
167
|
+
why: `Contract target '${result.targetMismatch.actual}' does not match CLI target '${result.targetMismatch.expected}'.`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (result.missingExtensionPackIds.length > 0) {
|
|
172
|
+
throw errorContractMissingExtensionPacks({
|
|
173
|
+
missingExtensionPacks: result.missingExtensionPackIds,
|
|
174
|
+
providedComponentIds: [...providedComponentIds],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface GlobalFlags {
|
|
2
|
+
readonly json?: 'object' | 'ndjson';
|
|
3
|
+
readonly quiet?: boolean;
|
|
4
|
+
readonly verbose?: number; // 0, 1, or 2
|
|
5
|
+
readonly timestamps?: boolean;
|
|
6
|
+
readonly color?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CliOptions {
|
|
10
|
+
readonly json?: string | boolean;
|
|
11
|
+
readonly quiet?: boolean;
|
|
12
|
+
readonly q?: boolean;
|
|
13
|
+
readonly verbose?: boolean;
|
|
14
|
+
readonly v?: boolean;
|
|
15
|
+
readonly vv?: boolean;
|
|
16
|
+
readonly trace?: boolean;
|
|
17
|
+
readonly timestamps?: boolean;
|
|
18
|
+
readonly color?: boolean;
|
|
19
|
+
readonly 'no-color'?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parses global flags from CLI options.
|
|
24
|
+
* Handles verbosity flags (-v, -vv, --trace), JSON output, quiet mode, timestamps, and color.
|
|
25
|
+
*/
|
|
26
|
+
export function parseGlobalFlags(options: CliOptions): GlobalFlags {
|
|
27
|
+
const flags: {
|
|
28
|
+
json?: 'object' | 'ndjson';
|
|
29
|
+
quiet?: boolean;
|
|
30
|
+
verbose?: number;
|
|
31
|
+
timestamps?: boolean;
|
|
32
|
+
color?: boolean;
|
|
33
|
+
} = {};
|
|
34
|
+
|
|
35
|
+
// JSON output
|
|
36
|
+
if (options.json === true || options.json === 'object') {
|
|
37
|
+
flags.json = 'object';
|
|
38
|
+
} else if (options.json === 'ndjson') {
|
|
39
|
+
flags.json = 'ndjson';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Quiet mode
|
|
43
|
+
if (options.quiet || options.q) {
|
|
44
|
+
flags.quiet = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Verbosity: -v = 1, -vv or --trace = 2
|
|
48
|
+
if (options.vv || options.trace) {
|
|
49
|
+
flags.verbose = 2;
|
|
50
|
+
} else if (options.verbose || options.v) {
|
|
51
|
+
flags.verbose = 1;
|
|
52
|
+
} else {
|
|
53
|
+
flags.verbose = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Timestamps
|
|
57
|
+
if (options.timestamps) {
|
|
58
|
+
flags.timestamps = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Color: respect NO_COLOR env var, --color/--no-color flags
|
|
62
|
+
// When JSON output is enabled (any format), disable color to ensure clean JSON output
|
|
63
|
+
if (process.env['NO_COLOR'] || flags.json) {
|
|
64
|
+
flags.color = false;
|
|
65
|
+
} else if (options['no-color']) {
|
|
66
|
+
flags.color = false;
|
|
67
|
+
} else if (options.color !== undefined) {
|
|
68
|
+
flags.color = options.color;
|
|
69
|
+
} else {
|
|
70
|
+
// Default: enable color if TTY
|
|
71
|
+
flags.color = process.stdout.isTTY && !process.env['CI'];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return flags as GlobalFlags;
|
|
75
|
+
}
|