Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/framework/tooling/cli/src/load-ts-contract.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { unlinkSync, writeFileSync } from 'node:fs';
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ContractIR } from '@prisma-next/contract/ir';
Expand Down Expand Up @@ -112,6 +112,10 @@ export async function loadContractFromTs(
): Promise<ContractIR> {
const allowlist = options?.allowlist ?? DEFAULT_ALLOWLIST;

if (!existsSync(entryPath)) {
throw new Error(`Contract file not found: ${entryPath}`);
}

const tempFile = join(
tmpdir(),
`prisma-next-contract-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`,
Expand Down
30 changes: 22 additions & 8 deletions packages/framework/tooling/cli/src/utils/output.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { relative } from 'node:path';
import { blue, bold, cyan, dim, green, magenta, red } from 'colorette';
import { bgGreen, blue, bold, cyan, dim, green, magenta, red } from 'colorette';
import type { Command } from 'commander';
import stringWidth from 'string-width';
import stripAnsi from 'strip-ansi';
Expand Down Expand Up @@ -249,10 +249,23 @@ function calculateRightColumnWidth(): number {
}

/**
* Creates a simple arrow marker.
* Creates an arrow segment badge with green background and white text.
* Body: green background with white "prisma-next" text
* Tip: dark grey arrow pointing right (Powerline separator)
*/
function createPrismaNextBadge(useColor: boolean): string {
return useColor ? bold(green('prisma-next')) : 'prisma-next';
if (!useColor) {
return 'prisma-next';
}
// Body: green background with white text
const text = ' prisma-next ';
const body = bgGreen(bold(text));

// Use Powerline separator (U+E0B0) which creates the arrow transition effect
const separator = '\u{E0B0}';
const tip = green(separator); // Dark grey arrow tip

return `${body}${tip}`;
}

/**
Expand All @@ -271,9 +284,9 @@ function formatHeaderLine(options: {
readonly intent: string;
}): string {
if (options.operation) {
return `${options.brand} ${options.operation} ${options.intent}`;
return `${options.brand} ${options.operation} ${options.intent}`;
}
return `${options.brand} ${options.intent}`;
return `${options.brand} ${options.intent}`;
}

/**
Expand Down Expand Up @@ -449,8 +462,8 @@ export function formatStyledHeader(options: {

// Header: arrow + operation badge + intent
const brand = createPrismaNextBadge(useColor);
const opName = options.command.split(' ').slice(-1)[0] || 'emit';
const operation = useColor ? bold(opName) : opName;
// Use full command path (e.g., "contract emit" not just "emit")
const operation = useColor ? bold(options.command) : options.command;
const intent = formatDimText(options.description);
lines.push(formatHeaderLine({ brand, operation, intent }));
lines.push(formatDimText('│')); // Vertical line separator between command and params
Expand Down Expand Up @@ -536,7 +549,7 @@ export function formatCommandHelp(options: {
const shortDescription = command.description() || '';
const longDescription = getLongDescription(command);

// Header: "prisma-next <full-command-path> <short-description>"
// Header: "prisma-next <full-command-path> <short-description>"
const brand = createPrismaNextBadge(useColor);
const operation = useColor ? bold(commandPath) : commandPath;
const intent = formatDimText(shortDescription);
Expand Down Expand Up @@ -649,6 +662,7 @@ export function formatRootHelp(options: {
const shortDescription = 'Manage your data layer';
const intent = formatDimText(shortDescription);
lines.push(formatHeaderLine({ brand, operation: '', intent }));
lines.push(formatDimText('│')); // Vertical line separator after header

// Extract top-level commands (exclude hidden commands starting with '_' and the 'help' command)
const topLevelCommands = program.commands.filter(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`help text snapshots > formats contract emit help 1`] = `
"prisma-next emit Emit signed contract artifacts
"prisma-next emit Emit signed contract artifacts
│ --config <path> Path to prisma-next.config.ts
│ --json [format] Output as JSON (object or ndjson)
Expand All @@ -23,7 +23,7 @@ exports[`help text snapshots > formats contract emit help 1`] = `
`;

exports[`help text snapshots > formats contract emit help with no color 1`] = `
"prisma-next emit Emit signed contract artifacts
"prisma-next emit Emit signed contract artifacts
│ --config <path> Path to prisma-next.config.ts
│ --json [format] Output as JSON (object or ndjson)
Expand All @@ -45,7 +45,7 @@ exports[`help text snapshots > formats contract emit help with no color 1`] = `
`;

exports[`help text snapshots > formats db verify help 1`] = `
"prisma-next verify Check the database satisfies your contract
"prisma-next verify Check the database satisfies your contract
│ --db <url> Database connection string
│ --config <path> Path to prisma-next.config.ts
Expand All @@ -67,7 +67,8 @@ exports[`help text snapshots > formats db verify help 1`] = `
`;

exports[`help text snapshots > formats root help 1`] = `
"prisma-next ➜ Manage your data layer
"prisma-next Manage your data layer
│ ├─ emit Emit signed contract artifacts
│ └─ db
│ └─ verify Check the database satisfies your contract
Expand All @@ -80,7 +81,8 @@ exports[`help text snapshots > formats root help 1`] = `
`;

exports[`help text snapshots > formats root help with no color 1`] = `
"prisma-next ➜ Manage your data layer
"prisma-next Manage your data layer
│ ├─ emit Emit signed contract artifacts
│ └─ db
│ └─ verify Check the database satisfies your contract
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"dependencies": {
"@prisma-next/adapter-postgres": "workspace:*",
"@prisma-next/cli": "workspace:*",
"@prisma-next/control-plane": "workspace:*",
"@prisma-next/driver-postgres": "workspace:*",
"@prisma-next/family-sql": "workspace:*",
"@prisma-next/sql-contract-ts": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion packages/framework/tooling/cli/test/db-verify.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ describe('db verify command (e2e)', () => {
cleanupDir();
}
},
{ acceleratePort: 54190, databasePort: 54191, shadowDatabasePort: 54192 },
// Use random ports to avoid conflicts in CI (no options = random ports)
{},
);
},
timeouts.spinUpPpgDev,
Expand Down
177 changes: 177 additions & 0 deletions packages/framework/tooling/cli/test/emit-cli-process.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { execFile } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { loadContractFromTs } from '@prisma-next/cli';
import type { SqlContract, SqlMappings } from '@prisma-next/sql-contract/types';
import { validateContract } from '@prisma-next/sql-contract-ts/contract';
import { timeouts } from '@prisma-next/test-utils';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setupIntegrationTestDirectoryFromFixtures } from './utils/test-helpers';

const __dirname = dirname(fileURLToPath(import.meta.url));
const execFileAsync = promisify(execFile);

// Fixture subdirectory for emit-command tests
const fixtureSubdir = 'emit-command';

type EmittedContract = SqlContract<
{
readonly tables: {
readonly user: {
readonly columns: {
readonly id: { readonly type: 'pg/int4@1'; readonly nullable: false };
readonly email: { readonly type: 'pg/text@1'; readonly nullable: false };
};
readonly primaryKey: { readonly columns: readonly ['id'] };
readonly uniques: readonly [];
readonly indexes: readonly [];
readonly foreignKeys: readonly [];
};
};
},
{
readonly User: {
readonly storage: { readonly table: 'user' };
readonly fields: {
readonly id: { readonly column: 'id' };
readonly email: { readonly column: 'email' };
};
readonly relations: Record<string, never>;
};
},
Record<string, never>,
SqlMappings
>;

describe('contract emit command (CLI process e2e)', () => {
let testDir: string;
let contractPath: string;
let outputDir: string;
let cleanupDir: () => void;

beforeEach(() => {
// Set up test directory from fixtures
const testSetup = setupIntegrationTestDirectoryFromFixtures(fixtureSubdir);
testDir = testSetup.testDir;
contractPath = testSetup.contractPath;
outputDir = testSetup.outputDir;
cleanupDir = testSetup.cleanup;
});

afterEach(() => {
cleanupDir();
});

it(
'executes CLI as separate process to emit contract and verifies artifacts',
async () => {
const cliPath = resolve(__dirname, '../dist/cli.js');

try {
// Set cwd for spawned process so relative paths in config resolve correctly
await execFileAsync(
'node',
[cliPath, 'contract', 'emit', '--config', 'prisma-next.config.ts'],
{
cwd: testDir, // Set working directory for spawned process
},
);
} catch (error: unknown) {
// Only log output on errors for debugging
if (error && typeof error === 'object' && 'stderr' in error) {
console.error('CLI stderr:', error.stderr);
}
if (error && typeof error === 'object' && 'stdout' in error) {
console.log('CLI stdout:', error.stdout);
}
throw error;
}

const contractJsonPath = join(outputDir, 'contract.json');
const contractDtsPath = join(outputDir, 'contract.d.ts');

expect(existsSync(contractJsonPath)).toBe(true);
expect(existsSync(contractDtsPath)).toBe(true);

const contractJsonContent = readFileSync(contractJsonPath, 'utf-8');
const contractDtsContent = readFileSync(contractDtsPath, 'utf-8');

const contractJson = JSON.parse(contractJsonContent);
expect(contractJson).toMatchObject({
targetFamily: 'sql',
target: 'postgres',
storage: {
tables: {
user: expect.anything(),
},
},
});

expect(contractDtsContent).toContain('export type Contract');
expect(contractDtsContent).toContain('CodecTypes');

const validatedContract = validateContract<EmittedContract>(contractJson);
expect(validatedContract.targetFamily).toBe('sql');
expect(validatedContract.target).toBe('postgres');
},
timeouts.typeScriptCompilation,
);

it(
'round-trip test: TS contract → CLI emit → parse JSON → compare with loaded TS contract',
async () => {
// loadContractFromTs can resolve packages because testDir is within the fixture app
const originalContract = await loadContractFromTs(contractPath);

const cliPath = resolve(__dirname, '../dist/cli.js');

try {
// Set cwd for spawned process so relative paths in config resolve correctly
await execFileAsync(
'node',
[cliPath, 'contract', 'emit', '--config', 'prisma-next.config.ts'],
{
cwd: testDir, // Set working directory for spawned process
},
);
} catch (error: unknown) {
// Only log output on errors for debugging
if (error && typeof error === 'object' && 'stderr' in error) {
console.error('CLI stderr:', error.stderr);
}
if (error && typeof error === 'object' && 'stdout' in error) {
console.log('CLI stdout:', error.stdout);
}
throw error;
}

const contractJsonPath = join(outputDir, 'contract.json');
const contractJsonContent = readFileSync(contractJsonPath, 'utf-8');
const contractJson = JSON.parse(contractJsonContent) as Record<string, unknown>;

const validatedContract = validateContract<EmittedContract>(contractJson);

expect(validatedContract.targetFamily).toBe(originalContract.targetFamily);
expect(validatedContract.target).toBe(originalContract.target);
const tables = validatedContract.storage['tables'] as Record<string, unknown> | undefined;
const originalTables = originalContract.storage?.['tables'] as
| Record<string, unknown>
| undefined;
const userTable = tables?.['user'] as Record<string, unknown> | undefined;
const originalUserTable = originalTables?.['user'] as Record<string, unknown> | undefined;
if (userTable && originalUserTable) {
const columns = userTable['columns'] as Record<string, { type?: string }> | undefined;
const originalColumns = originalUserTable['columns'] as
| Record<string, { type?: string }>
| undefined;
if (columns && originalColumns) {
expect(columns['id']?.type).toBe(originalColumns['id']?.type);
expect(columns['email']?.type).toBe(originalColumns['email']?.type);
}
}
},
timeouts.typeScriptCompilation,
);
});
9 changes: 6 additions & 3 deletions packages/framework/tooling/cli/test/help.snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,26 @@ describe('help text snapshots', () => {
program.addCommand(contractEmit);
program.addCommand(db);

const flags = parseGlobalFlags({});
// Explicitly disable colors for consistent snapshots
const flags = parseGlobalFlags({ 'no-color': true });
const helpText = formatRootHelp({ program, flags });

expect(helpText).toMatchSnapshot();
});

it('formats contract emit help', () => {
const command = createContractEmitCommand();
const flags = parseGlobalFlags({});
// Explicitly disable colors for consistent snapshots
const flags = parseGlobalFlags({ 'no-color': true });
const helpText = formatCommandHelp({ command, flags });

expect(helpText).toMatchSnapshot();
});

it('formats db verify help', () => {
const command = createDbVerifyCommand();
const flags = parseGlobalFlags({});
// Explicitly disable colors for consistent snapshots
const flags = parseGlobalFlags({ 'no-color': true });
const helpText = formatCommandHelp({ command, flags });

expect(helpText).toMatchSnapshot();
Expand Down
3 changes: 3 additions & 0 deletions packages/framework/tooling/cli/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export default defineConfig({
environment: 'node',
testTimeout: timeouts.default,
hookTimeout: timeouts.default,
env: {
CI: 'true',
},
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.