diff --git a/packages/cli/package.json b/packages/cli/package.json index 3b59ac456..93e1c5374 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,9 +37,9 @@ }, "dependencies": { "@zenstackhq/common-helpers": "workspace:*", - "@zenstackhq/schema": "workspace:*", "@zenstackhq/language": "workspace:*", "@zenstackhq/orm": "workspace:*", + "@zenstackhq/schema": "workspace:*", "@zenstackhq/sdk": "workspace:*", "@zenstackhq/server": "workspace:*", "chokidar": "^5.0.0", @@ -56,7 +56,9 @@ "package-manager-detector": "^1.3.0", "prisma": "catalog:", "semver": "^7.7.2", - "ts-pattern": "catalog:" + "terminal-link": "^5.0.0", + "ts-pattern": "catalog:", + "zod": "catalog:" }, "devDependencies": { "@types/better-sqlite3": "catalog:", @@ -72,9 +74,9 @@ "tmp": "catalog:" }, "peerDependencies": { - "pg": "catalog:", "better-sqlite3": "catalog:", - "mysql2": "catalog:" + "mysql2": "catalog:", + "pg": "catalog:" }, "peerDependenciesMeta": { "pg": { diff --git a/packages/cli/src/actions/action-utils.ts b/packages/cli/src/actions/action-utils.ts index c33a81d64..7539c4ca0 100644 --- a/packages/cli/src/actions/action-utils.ts +++ b/packages/cli/src/actions/action-utils.ts @@ -6,6 +6,8 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { CliError } from '../cli-error'; +import terminalLink from 'terminal-link'; +import { z } from 'zod'; export function getSchemaFile(file?: string) { if (file) { @@ -216,3 +218,69 @@ export async function getZenStackPackages( return result.filter((p) => !!p); } + +const FETCH_CLI_MAX_TIME = 1000; +const CLI_CONFIG_ENDPOINT = 'https://zenstack.dev/config/cli-v3.json'; + +const usageTipsSchema = z.object({ + notifications: z.array(z.object({ title: z.string(), url: z.url().optional(), active: z.boolean() })), +}); + +/** + * Starts the usage tips fetch in the background. Returns a callback that, when invoked check if the fetch + * is complete. If not complete, it will wait until the max time is reached. After that, if fetch is still + * not complete, just return. + */ +export function startUsageTipsFetch() { + let fetchedData: z.infer | undefined = undefined; + let fetchComplete = false; + + const start = Date.now(); + const controller = new AbortController(); + + fetch(CLI_CONFIG_ENDPOINT, { + headers: { accept: 'application/json' }, + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) return; + const data = await res.json(); + const parseResult = usageTipsSchema.safeParse(data); + if (parseResult.success) { + fetchedData = parseResult.data; + } + }) + .catch(() => { + // noop + }) + .finally(() => { + fetchComplete = true; + }); + + return async () => { + const elapsed = Date.now() - start; + + if (!fetchComplete && elapsed < FETCH_CLI_MAX_TIME) { + // wait for the timeout + await new Promise((resolve) => setTimeout(resolve, FETCH_CLI_MAX_TIME - elapsed)); + } + + if (!fetchComplete) { + controller.abort(); + return; + } + + if (!fetchedData) return; + + const activeItems = fetchedData.notifications.filter((item) => item.active); + // show a random active item + if (activeItems.length > 0) { + const item = activeItems[Math.floor(Math.random() * activeItems.length)]!; + if (item.url) { + console.log(terminalLink(item.title, item.url)); + } else { + console.log(item.title); + } + } + }; +} diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index 351ecceb0..fe31688b8 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -1,19 +1,25 @@ import { invariant, singleDebounce } from '@zenstackhq/common-helpers'; import { ZModelLanguageMetaData } from '@zenstackhq/language'; -import { type AbstractDeclaration, isPlugin, LiteralExpr, Plugin, type Model } from '@zenstackhq/language/ast'; +import { isPlugin, LiteralExpr, Plugin, type AbstractDeclaration, type Model } from '@zenstackhq/language/ast'; import { getLiteral, getLiteralArray } from '@zenstackhq/language/utils'; import { type CliPlugin } from '@zenstackhq/sdk'; +import { watch } from 'chokidar'; import colors from 'colors'; import { createJiti } from 'jiti'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { watch } from 'chokidar'; import ora, { type Ora } from 'ora'; +import semver from 'semver'; import { CliError } from '../cli-error'; import * as corePlugins from '../plugins'; -import { getOutputPath, getSchemaFile, getZenStackPackages, loadSchemaDocument } from './action-utils'; -import semver from 'semver'; +import { + getOutputPath, + getSchemaFile, + getZenStackPackages, + loadSchemaDocument, + startUsageTipsFetch, +} from './action-utils'; type Options = { schema?: string; @@ -24,6 +30,7 @@ type Options = { liteOnly?: boolean; generateModels?: boolean; generateInput?: boolean; + tips?: boolean; }; /** @@ -35,8 +42,13 @@ export async function run(options: Options) { } catch (err) { console.warn(colors.yellow(`Failed to check for mismatched ZenStack packages: ${err}`)); } + + const maybeShowUsageTips = options.tips && !options.silent && !options.watch ? startUsageTipsFetch() : undefined; + const model = await pureGenerate(options, false); + await maybeShowUsageTips?.(); + if (options.watch) { const logsEnabled = !options.silent; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fcc4685c3..bdeca9b5d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -74,12 +74,14 @@ function createProgram() { ); const noVersionCheckOption = new Option('--no-version-check', 'do not check for new version'); + const noTipsOption = new Option('--no-tips', 'do not show usage tips'); program .command('generate') .description('Run code generation plugins') .addOption(schemaOption) .addOption(noVersionCheckOption) + .addOption(noTipsOption) .addOption(new Option('-o, --output ', 'default output directory for code generation')) .addOption(new Option('-w, --watch', 'enable watch mode').default(false)) .addOption( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95980553f..c5ac83d9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -266,9 +266,15 @@ importers: semver: specifier: ^7.7.2 version: 7.7.2 + terminal-link: + specifier: ^5.0.0 + version: 5.0.0 ts-pattern: specifier: 'catalog:' version: 5.7.1 + zod: + specifier: 'catalog:' + version: 4.1.12 devDependencies: '@types/better-sqlite3': specifier: 'catalog:' @@ -4213,6 +4219,10 @@ packages: alien-signals@3.0.3: resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -5236,6 +5246,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -5790,6 +5804,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -8057,6 +8075,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -8119,6 +8141,10 @@ packages: resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} engines: {node: '>=18'} + terminal-link@5.0.0: + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} + engines: {node: '>=20'} + terser@5.44.0: resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} engines: {node: '>=10'} @@ -12065,6 +12091,10 @@ snapshots: alien-signals@3.0.3: {} + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -12987,6 +13017,8 @@ snapshots: entities@7.0.1: {} + environment@1.1.0: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -13841,6 +13873,8 @@ snapshots: has-flag@4.0.0: {} + has-flag@5.0.1: {} + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -16403,6 +16437,11 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-hyperlinks@4.4.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + supports-preserve-symlinks-flag@1.0.0: {} svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.53.5)(typescript@5.9.3): @@ -16498,6 +16537,11 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + terminal-link@5.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 4.4.0 + terser@5.44.0: dependencies: '@jridgewell/source-map': 0.3.11 diff --git a/scripts/test-generate.ts b/scripts/test-generate.ts index 0af24290e..890d26b8e 100644 --- a/scripts/test-generate.ts +++ b/scripts/test-generate.ts @@ -22,7 +22,7 @@ async function generate(schemaPath: string, options: string[]) { const cliPath = path.join(_dirname, '../packages/cli/dist/index.js'); const RUNTIME = process.env.RUNTIME ?? 'node'; execSync( - `${RUNTIME} ${cliPath} generate --schema ${schemaPath} ${options.join(' ')} --generate-models=false --generate-input=false`, + `${RUNTIME} ${cliPath} generate --schema ${schemaPath} ${options.join(' ')} --generate-models=false --generate-input=false --no-version-check --no-tips`, { cwd: path.dirname(schemaPath), },