@zenstackhq/cli 3.0.0-alpha.8 → 3.0.0-beta.1

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.
@@ -0,0 +1,139 @@
1
+ import { init, type Mixpanel } from 'mixpanel';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import * as os from 'os';
5
+ import { TELEMETRY_TRACKING_TOKEN } from './constants';
6
+ import { isInCi } from './utils/is-ci';
7
+ import { isInContainer } from './utils/is-container';
8
+ import isDocker from './utils/is-docker';
9
+ import { isWsl } from './utils/is-wsl';
10
+ import { getMachineId } from './utils/machine-id-utils';
11
+ import { getVersion } from './utils/version-utils';
12
+
13
+ /**
14
+ * Telemetry events
15
+ */
16
+ export type TelemetryEvents =
17
+ | 'cli:start'
18
+ | 'cli:complete'
19
+ | 'cli:error'
20
+ | 'cli:command:start'
21
+ | 'cli:command:complete'
22
+ | 'cli:command:error'
23
+ | 'cli:plugin:start'
24
+ | 'cli:plugin:complete'
25
+ | 'cli:plugin:error';
26
+
27
+ /**
28
+ * Utility class for sending telemetry
29
+ */
30
+ export class Telemetry {
31
+ private readonly mixpanel: Mixpanel | undefined;
32
+ private readonly hostId = getMachineId();
33
+ private readonly sessionid = randomUUID();
34
+ private readonly _os_type = os.type();
35
+ private readonly _os_release = os.release();
36
+ private readonly _os_arch = os.arch();
37
+ private readonly _os_version = os.version();
38
+ private readonly _os_platform = os.platform();
39
+ private readonly version = getVersion();
40
+ private readonly prismaVersion = this.getPrismaVersion();
41
+ private readonly isDocker = isDocker();
42
+ private readonly isWsl = isWsl();
43
+ private readonly isContainer = isInContainer();
44
+ private readonly isCi = isInCi;
45
+
46
+ constructor() {
47
+ if (process.env['DO_NOT_TRACK'] !== '1' && TELEMETRY_TRACKING_TOKEN) {
48
+ this.mixpanel = init(TELEMETRY_TRACKING_TOKEN, {
49
+ geolocate: true,
50
+ });
51
+ }
52
+ }
53
+
54
+ get isTracking() {
55
+ return !!this.mixpanel;
56
+ }
57
+
58
+ track(event: TelemetryEvents, properties: Record<string, unknown> = {}) {
59
+ if (this.mixpanel) {
60
+ const payload = {
61
+ distinct_id: this.hostId,
62
+ session: this.sessionid,
63
+ time: new Date(),
64
+ $os: this._os_type,
65
+ osType: this._os_type,
66
+ osRelease: this._os_release,
67
+ osPlatform: this._os_platform,
68
+ osArch: this._os_arch,
69
+ osVersion: this._os_version,
70
+ nodeVersion: process.version,
71
+ version: this.version,
72
+ prismaVersion: this.prismaVersion,
73
+ isDocker: this.isDocker,
74
+ isWsl: this.isWsl,
75
+ isContainer: this.isContainer,
76
+ isCi: this.isCi,
77
+ ...properties,
78
+ };
79
+ this.mixpanel.track(event, payload);
80
+ }
81
+ }
82
+
83
+ trackError(err: Error) {
84
+ this.track('cli:error', {
85
+ message: err.message,
86
+ stack: err.stack,
87
+ });
88
+ }
89
+
90
+ async trackSpan<T>(
91
+ startEvent: TelemetryEvents,
92
+ completeEvent: TelemetryEvents,
93
+ errorEvent: TelemetryEvents,
94
+ properties: Record<string, unknown>,
95
+ action: () => Promise<T> | T,
96
+ ) {
97
+ this.track(startEvent, properties);
98
+ const start = Date.now();
99
+ let success = true;
100
+ try {
101
+ return await action();
102
+ } catch (err: any) {
103
+ this.track(errorEvent, {
104
+ message: err.message,
105
+ stack: err.stack,
106
+ ...properties,
107
+ });
108
+ success = false;
109
+ throw err;
110
+ } finally {
111
+ this.track(completeEvent, {
112
+ duration: Date.now() - start,
113
+ success,
114
+ ...properties,
115
+ });
116
+ }
117
+ }
118
+
119
+ async trackCommand(command: string, action: () => Promise<void> | void) {
120
+ await this.trackSpan('cli:command:start', 'cli:command:complete', 'cli:command:error', { command }, action);
121
+ }
122
+
123
+ async trackCli(action: () => Promise<void> | void) {
124
+ await this.trackSpan('cli:start', 'cli:complete', 'cli:error', {}, action);
125
+ }
126
+
127
+ getPrismaVersion() {
128
+ try {
129
+ const packageJsonPath = import.meta.resolve('prisma/package.json');
130
+ const packageJsonUrl = new URL(packageJsonPath);
131
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonUrl, 'utf8'));
132
+ return packageJson.version;
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+ }
138
+
139
+ export const telemetry = new Telemetry();
@@ -0,0 +1,5 @@
1
+ import { env } from 'node:process';
2
+ export const isInCi =
3
+ env['CI'] !== '0' &&
4
+ env['CI'] !== 'false' &&
5
+ ('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_')));
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs';
2
+ import isDocker from './is-docker';
3
+
4
+ let cachedResult: boolean | undefined;
5
+
6
+ // Podman detection
7
+ const hasContainerEnv = () => {
8
+ try {
9
+ fs.statSync('/run/.containerenv');
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+
16
+ export function isInContainer() {
17
+ // TODO: Use `??=` when targeting Node.js 16.
18
+ if (cachedResult === undefined) {
19
+ cachedResult = hasContainerEnv() || isDocker();
20
+ }
21
+
22
+ return cachedResult;
23
+ }
@@ -0,0 +1,31 @@
1
+ // Copied over from https://github.com/sindresorhus/is-docker for CJS compatibility
2
+
3
+ import fs from 'node:fs';
4
+
5
+ let isDockerCached: boolean | undefined;
6
+
7
+ function hasDockerEnv() {
8
+ try {
9
+ fs.statSync('/.dockerenv');
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ function hasDockerCGroup() {
17
+ try {
18
+ return fs.readFileSync('/proc/self/cgroup', 'utf8').includes('docker');
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ export default function isDocker() {
25
+ // TODO: Use `??=` when targeting Node.js 16.
26
+ if (isDockerCached === undefined) {
27
+ isDockerCached = hasDockerEnv() || hasDockerCGroup();
28
+ }
29
+
30
+ return isDockerCached;
31
+ }
@@ -0,0 +1,18 @@
1
+ import process from 'node:process';
2
+ import os from 'node:os';
3
+ import fs from 'node:fs';
4
+ export const isWsl = () => {
5
+ if (process.platform !== 'linux') {
6
+ return false;
7
+ }
8
+
9
+ if (os.release().toLowerCase().includes('microsoft')) {
10
+ return true;
11
+ }
12
+
13
+ try {
14
+ return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
15
+ } catch {
16
+ return false;
17
+ }
18
+ };
@@ -0,0 +1,76 @@
1
+ // modified from https://github.com/automation-stack/node-machine-id
2
+
3
+ import { execSync } from 'child_process';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+
6
+ const { platform } = process;
7
+ const win32RegBinPath = {
8
+ native: '%windir%\\System32',
9
+ mixed: '%windir%\\sysnative\\cmd.exe /c %windir%\\System32',
10
+ };
11
+ const guid = {
12
+ darwin: 'ioreg -rd1 -c IOPlatformExpertDevice',
13
+ win32:
14
+ `${win32RegBinPath[isWindowsProcessMixedOrNativeArchitecture()]}\\REG.exe ` +
15
+ 'QUERY HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography ' +
16
+ '/v MachineGuid',
17
+ linux: '( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname 2> /dev/null) | head -n 1 || :',
18
+ freebsd: 'kenv -q smbios.system.uuid || sysctl -n kern.hostuuid',
19
+ };
20
+
21
+ function isWindowsProcessMixedOrNativeArchitecture() {
22
+ // eslint-disable-next-line no-prototype-builtins
23
+ if (process.arch === 'ia32' && process.env.hasOwnProperty('PROCESSOR_ARCHITEW6432')) {
24
+ return 'mixed';
25
+ }
26
+ return 'native';
27
+ }
28
+
29
+ function hash(guid: string): string {
30
+ return createHash('sha256').update(guid).digest('hex');
31
+ }
32
+
33
+ function expose(result: string): string | undefined {
34
+ switch (platform) {
35
+ case 'darwin':
36
+ return result
37
+ .split('IOPlatformUUID')[1]
38
+ ?.split('\n')[0]
39
+ ?.replace(/=|\s+|"/gi, '')
40
+ .toLowerCase();
41
+ case 'win32':
42
+ return result
43
+ .toString()
44
+ .split('REG_SZ')[1]
45
+ ?.replace(/\r+|\n+|\s+/gi, '')
46
+ .toLowerCase();
47
+ case 'linux':
48
+ return result
49
+ .toString()
50
+ .replace(/\r+|\n+|\s+/gi, '')
51
+ .toLowerCase();
52
+ case 'freebsd':
53
+ return result
54
+ .toString()
55
+ .replace(/\r+|\n+|\s+/gi, '')
56
+ .toLowerCase();
57
+ default:
58
+ throw new Error(`Unsupported platform: ${process.platform}`);
59
+ }
60
+ }
61
+
62
+ export function getMachineId() {
63
+ if (!(platform in guid)) {
64
+ return randomUUID();
65
+ }
66
+ try {
67
+ const value = execSync(guid[platform as keyof typeof guid]);
68
+ const id = expose(value.toString());
69
+ if (!id) {
70
+ return randomUUID();
71
+ }
72
+ return hash(id);
73
+ } catch {
74
+ return randomUUID();
75
+ }
76
+ }
@@ -1,6 +1,11 @@
1
+ import colors from 'colors';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
5
+ import semver from 'semver';
6
+
7
+ const CHECK_VERSION_TIMEOUT = 2000;
8
+ const VERSION_CHECK_TAG = 'next';
4
9
 
5
10
  export function getVersion() {
6
11
  try {
@@ -11,3 +16,35 @@ export function getVersion() {
11
16
  return undefined;
12
17
  }
13
18
  }
19
+
20
+ export async function checkNewVersion() {
21
+ const currVersion = getVersion();
22
+ let latestVersion: string;
23
+ try {
24
+ latestVersion = await getLatestVersion();
25
+ } catch {
26
+ // noop
27
+ return;
28
+ }
29
+
30
+ if (latestVersion && currVersion && semver.gt(latestVersion, currVersion)) {
31
+ console.log(`A newer version ${colors.cyan(latestVersion)} is available.`);
32
+ }
33
+ }
34
+
35
+ export async function getLatestVersion() {
36
+ const fetchResult = await fetch(`https://registry.npmjs.org/@zenstackhq/cli/${VERSION_CHECK_TAG}`, {
37
+ headers: { accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*' },
38
+ signal: AbortSignal.timeout(CHECK_VERSION_TIMEOUT),
39
+ });
40
+
41
+ if (fetchResult.ok) {
42
+ const data: any = await fetchResult.json();
43
+ const latestVersion = data?.version;
44
+ if (typeof latestVersion === 'string' && semver.valid(latestVersion)) {
45
+ return latestVersion;
46
+ }
47
+ }
48
+
49
+ throw new Error('invalid npm registry response');
50
+ }
@@ -0,0 +1,101 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { createProject, runCli } from './utils';
5
+
6
+ const validModel = `
7
+ model User {
8
+ id String @id @default(cuid())
9
+ email String @unique
10
+ name String?
11
+ posts Post[]
12
+ }
13
+
14
+ model Post {
15
+ id String @id @default(cuid())
16
+ title String
17
+ content String?
18
+ author User @relation(fields: [authorId], references: [id])
19
+ authorId String
20
+ }
21
+ `;
22
+
23
+ const invalidModel = `
24
+ model User {
25
+ id String @id @default(cuid())
26
+ email String @unique
27
+ posts Post[]
28
+ }
29
+
30
+ model Post {
31
+ id String @id @default(cuid())
32
+ title String
33
+ author User @relation(fields: [authorId], references: [id])
34
+ // Missing authorId field - should cause validation error
35
+ }
36
+ `;
37
+
38
+ describe('CLI validate command test', () => {
39
+ it('should validate a valid schema successfully', () => {
40
+ const workDir = createProject(validModel);
41
+
42
+ // Should not throw an error
43
+ expect(() => runCli('check', workDir)).not.toThrow();
44
+ });
45
+
46
+ it('should fail validation for invalid schema', () => {
47
+ const workDir = createProject(invalidModel);
48
+
49
+ // Should throw an error due to validation failure
50
+ expect(() => runCli('check', workDir)).toThrow();
51
+ });
52
+
53
+ it('should respect custom schema location', () => {
54
+ const workDir = createProject(validModel);
55
+ fs.renameSync(path.join(workDir, 'zenstack/schema.zmodel'), path.join(workDir, 'zenstack/custom.zmodel'));
56
+
57
+ // Should not throw an error when using custom schema path
58
+ expect(() => runCli('check --schema ./zenstack/custom.zmodel', workDir)).not.toThrow();
59
+ });
60
+
61
+ it('should fail when schema file does not exist', () => {
62
+ const workDir = createProject(validModel);
63
+
64
+ // Should throw an error when schema file doesn't exist
65
+ expect(() => runCli('check --schema ./nonexistent.zmodel', workDir)).toThrow();
66
+ });
67
+
68
+ it('should respect package.json config', () => {
69
+ const workDir = createProject(validModel);
70
+ fs.mkdirSync(path.join(workDir, 'foo'));
71
+ fs.renameSync(path.join(workDir, 'zenstack/schema.zmodel'), path.join(workDir, 'foo/schema.zmodel'));
72
+ fs.rmdirSync(path.join(workDir, 'zenstack'));
73
+
74
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(workDir, 'package.json'), 'utf8'));
75
+ pkgJson.zenstack = {
76
+ schema: './foo/schema.zmodel',
77
+ };
78
+ fs.writeFileSync(path.join(workDir, 'package.json'), JSON.stringify(pkgJson, null, 2));
79
+
80
+ // Should not throw an error when using package.json config
81
+ expect(() => runCli('check', workDir)).not.toThrow();
82
+ });
83
+
84
+ it('should validate schema with syntax errors', () => {
85
+ const modelWithSyntaxError = `
86
+ datasource db {
87
+ provider = "sqlite"
88
+ url = "file:./dev.db"
89
+ }
90
+
91
+ model User {
92
+ id String @id @default(cuid())
93
+ email String @unique
94
+ // Missing closing brace - syntax error
95
+ `;
96
+ const workDir = createProject(modelWithSyntaxError, false);
97
+
98
+ // Should throw an error due to syntax error
99
+ expect(() => runCli('check', workDir)).toThrow();
100
+ });
101
+ });
@@ -30,15 +30,18 @@ describe('CLI generate command test', () => {
30
30
  expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
31
31
  });
32
32
 
33
- it('should respect save prisma schema option', () => {
33
+ it('should respect package.json config', () => {
34
34
  const workDir = createProject(model);
35
- runCli('generate --save-prisma-schema', workDir);
36
- expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(true);
37
- });
38
-
39
- it('should respect save prisma schema custom path option', () => {
40
- const workDir = createProject(model);
41
- runCli('generate --save-prisma-schema "../prisma/schema.prisma"', workDir);
42
- expect(fs.existsSync(path.join(workDir, 'prisma/schema.prisma'))).toBe(true);
35
+ fs.mkdirSync(path.join(workDir, 'foo'));
36
+ fs.renameSync(path.join(workDir, 'zenstack/schema.zmodel'), path.join(workDir, 'foo/schema.zmodel'));
37
+ fs.rmdirSync(path.join(workDir, 'zenstack'));
38
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(workDir, 'package.json'), 'utf8'));
39
+ pkgJson.zenstack = {
40
+ schema: './foo/schema.zmodel',
41
+ output: './bar',
42
+ };
43
+ fs.writeFileSync(path.join(workDir, 'package.json'), JSON.stringify(pkgJson, null, 2));
44
+ runCli('generate', workDir);
45
+ expect(fs.existsSync(path.join(workDir, 'bar/schema.ts'))).toBe(true);
43
46
  });
44
47
  });
@@ -38,4 +38,35 @@ describe('CLI migrate commands test', () => {
38
38
  runCli('migrate dev --name init', workDir);
39
39
  runCli('migrate status', workDir);
40
40
  });
41
+
42
+ it('supports migrate resolve', () => {
43
+ const workDir = createProject(model);
44
+ runCli('migrate dev --name init', workDir);
45
+
46
+ // find the migration record "timestamp_init"
47
+ const migrationRecords = fs.readdirSync(path.join(workDir, 'zenstack/migrations'));
48
+ const migration = migrationRecords.find((f) => f.endsWith('_init'));
49
+
50
+ // force a migration failure
51
+ fs.writeFileSync(path.join(workDir, 'zenstack/migrations', migration!, 'migration.sql'), 'invalid content');
52
+
53
+ // redeploy the migration, which will fail
54
+ fs.rmSync(path.join(workDir, 'zenstack/dev.db'), { force: true });
55
+ try {
56
+ runCli('migrate deploy', workDir);
57
+ } catch {
58
+ // noop
59
+ }
60
+
61
+ // --rolled-back
62
+ runCli(`migrate resolve --rolled-back ${migration}`, workDir);
63
+
64
+ // --applied
65
+ runCli(`migrate resolve --applied ${migration}`, workDir);
66
+ });
67
+
68
+ it('should throw error when neither applied nor rolled-back is provided', () => {
69
+ const workDir = createProject(model);
70
+ expect(() => runCli('migrate resolve', workDir)).toThrow();
71
+ });
41
72
  });
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { createProject, runCli } from '../utils';
5
+ import { execSync } from 'node:child_process';
6
+
7
+ describe('Custom plugins tests', () => {
8
+ it('runs custom plugin generator', () => {
9
+ const workDir = createProject(`
10
+ plugin custom {
11
+ provider = '../my-plugin.js'
12
+ output = '../custom-output'
13
+ }
14
+
15
+ model User {
16
+ id String @id @default(cuid())
17
+ }
18
+ `);
19
+
20
+ fs.writeFileSync(
21
+ path.join(workDir, 'my-plugin.ts'),
22
+ `
23
+ import type { CliPlugin } from '@zenstackhq/sdk';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+
27
+ const plugin: CliPlugin = {
28
+ name: 'Custom Generator',
29
+ statusText: 'Generating foo.txt',
30
+ async generate({ model, defaultOutputPath, pluginOptions }) {
31
+ let outDir = defaultOutputPath;
32
+ if (typeof pluginOptions['output'] === 'string') {
33
+ outDir = path.resolve(defaultOutputPath, pluginOptions['output']);
34
+ if (!fs.existsSync(outDir)) {
35
+ fs.mkdirSync(outDir, { recursive: true });
36
+ }
37
+ }
38
+ fs.writeFileSync(path.join(outDir, 'foo.txt'), 'from my plugin');
39
+ },
40
+ };
41
+
42
+ export default plugin;
43
+ `,
44
+ );
45
+
46
+ execSync('npx tsc', { cwd: workDir });
47
+ runCli('generate', workDir);
48
+ expect(fs.existsSync(path.join(workDir, 'custom-output/foo.txt'))).toBe(true);
49
+ });
50
+ });
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { createProject, runCli } from '../utils';
5
+
6
+ describe('Core plugins tests', () => {
7
+ it('can automatically generate a TypeScript schema with default output', () => {
8
+ const workDir = createProject(`
9
+ model User {
10
+ id String @id @default(cuid())
11
+ }
12
+ `);
13
+ runCli('generate', workDir);
14
+ expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
15
+ });
16
+
17
+ it('can automatically generate a TypeScript schema with custom output', () => {
18
+ const workDir = createProject(`
19
+ plugin typescript {
20
+ provider = '@core/typescript'
21
+ output = '../generated-schema'
22
+ }
23
+
24
+ model User {
25
+ id String @id @default(cuid())
26
+ }
27
+ `);
28
+ runCli('generate', workDir);
29
+ expect(fs.existsSync(path.join(workDir, 'generated-schema/schema.ts'))).toBe(true);
30
+ });
31
+
32
+ it('can generate a Prisma schema with default output', () => {
33
+ const workDir = createProject(`
34
+ plugin prisma {
35
+ provider = '@core/prisma'
36
+ }
37
+
38
+ model User {
39
+ id String @id @default(cuid())
40
+ }
41
+ `);
42
+ runCli('generate', workDir);
43
+ expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(true);
44
+ });
45
+
46
+ it('can generate a Prisma schema with custom output', () => {
47
+ const workDir = createProject(`
48
+ plugin prisma {
49
+ provider = '@core/prisma'
50
+ output = '../prisma/schema.prisma'
51
+ }
52
+
53
+ model User {
54
+ id String @id @default(cuid())
55
+ }
56
+ `);
57
+ runCli('generate', workDir);
58
+ expect(fs.existsSync(path.join(workDir, 'prisma/schema.prisma'))).toBe(true);
59
+ });
60
+ });