|
| 1 | +import { invariant } from '@zenstackhq/common-helpers'; |
1 | 2 | import { type ZModelServices, loadDocument } from '@zenstackhq/language'; |
2 | | -import { type Model, isDataSource } from '@zenstackhq/language/ast'; |
3 | | -import { PrismaSchemaGenerator } from '@zenstackhq/sdk'; |
| 3 | +import { type Model, type Plugin, isDataSource, type LiteralExpr } from '@zenstackhq/language/ast'; |
| 4 | +import { type CliPlugin, PrismaSchemaGenerator } from '@zenstackhq/sdk'; |
4 | 5 | import colors from 'colors'; |
| 6 | +import { createJiti } from 'jiti'; |
5 | 7 | import fs from 'node:fs'; |
6 | 8 | import { createRequire } from 'node:module'; |
7 | 9 | import path from 'node:path'; |
| 10 | +import { pathToFileURL } from 'node:url'; |
| 11 | +import terminalLink from 'terminal-link'; |
| 12 | +import { z } from 'zod'; |
8 | 13 | import { CliError } from '../cli-error'; |
9 | 14 |
|
10 | 15 | export function getSchemaFile(file?: string) { |
@@ -216,3 +221,152 @@ export async function getZenStackPackages( |
216 | 221 |
|
217 | 222 | return result.filter((p) => !!p); |
218 | 223 | } |
| 224 | + |
| 225 | +export function getPluginProvider(plugin: Plugin) { |
| 226 | + const providerField = plugin.fields.find((f) => f.name === 'provider'); |
| 227 | + invariant(providerField, `Plugin ${plugin.name} does not have a provider field`); |
| 228 | + const provider = (providerField.value as LiteralExpr).value as string; |
| 229 | + return provider; |
| 230 | +} |
| 231 | + |
| 232 | +export async function loadPluginModule(provider: string, basePath: string) { |
| 233 | + if (provider.toLowerCase().endsWith('.zmodel')) { |
| 234 | + // provider is a zmodel file, no plugin code module to load |
| 235 | + return undefined; |
| 236 | + } |
| 237 | + |
| 238 | + let moduleSpec = provider; |
| 239 | + if (moduleSpec.startsWith('.')) { |
| 240 | + // relative to schema's path |
| 241 | + moduleSpec = path.resolve(basePath, moduleSpec); |
| 242 | + } |
| 243 | + |
| 244 | + const importAsEsm = async (spec: string) => { |
| 245 | + try { |
| 246 | + const result = (await import(spec)).default as CliPlugin; |
| 247 | + return result; |
| 248 | + } catch (err) { |
| 249 | + throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`); |
| 250 | + } |
| 251 | + }; |
| 252 | + |
| 253 | + const jiti = createJiti(pathToFileURL(basePath).toString()); |
| 254 | + const importAsTs = async (spec: string) => { |
| 255 | + try { |
| 256 | + const result = (await jiti.import(spec, { default: true })) as CliPlugin; |
| 257 | + return result; |
| 258 | + } catch (err) { |
| 259 | + throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`); |
| 260 | + } |
| 261 | + }; |
| 262 | + |
| 263 | + const esmSuffixes = ['.js', '.mjs']; |
| 264 | + const tsSuffixes = ['.ts', '.mts']; |
| 265 | + |
| 266 | + if (fs.existsSync(moduleSpec) && fs.statSync(moduleSpec).isFile()) { |
| 267 | + // try provider as ESM file |
| 268 | + if (esmSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) { |
| 269 | + return await importAsEsm(pathToFileURL(moduleSpec).toString()); |
| 270 | + } |
| 271 | + |
| 272 | + // try provider as TS file |
| 273 | + if (tsSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) { |
| 274 | + return await importAsTs(moduleSpec); |
| 275 | + } |
| 276 | + } |
| 277 | + |
| 278 | + // try ESM index files in provider directory |
| 279 | + for (const suffix of esmSuffixes) { |
| 280 | + const indexPath = path.join(moduleSpec, `index${suffix}`); |
| 281 | + if (fs.existsSync(indexPath)) { |
| 282 | + return await importAsEsm(pathToFileURL(indexPath).toString()); |
| 283 | + } |
| 284 | + } |
| 285 | + |
| 286 | + // try TS index files in provider directory |
| 287 | + for (const suffix of tsSuffixes) { |
| 288 | + const indexPath = path.join(moduleSpec, `index${suffix}`); |
| 289 | + if (fs.existsSync(indexPath)) { |
| 290 | + return await importAsTs(indexPath); |
| 291 | + } |
| 292 | + } |
| 293 | + |
| 294 | + // last resort, try to import as esm directly |
| 295 | + try { |
| 296 | + const mod = await import(moduleSpec); |
| 297 | + // plugin may not export a generator, return undefined in that case |
| 298 | + return mod.default as CliPlugin | undefined; |
| 299 | + } catch (err) { |
| 300 | + const errorCode = (err as NodeJS.ErrnoException)?.code; |
| 301 | + if (errorCode === 'ERR_MODULE_NOT_FOUND' || errorCode === 'MODULE_NOT_FOUND') { |
| 302 | + throw new CliError(`Cannot find plugin module "${provider}". Please make sure the package exists.`); |
| 303 | + } |
| 304 | + throw new CliError(`Failed to load plugin module "${provider}": ${(err as Error).message}`); |
| 305 | + } |
| 306 | +} |
| 307 | + |
| 308 | +const FETCH_CLI_MAX_TIME = 1000; |
| 309 | +const CLI_CONFIG_ENDPOINT = 'https://zenstack.dev/config/cli-v3.json'; |
| 310 | + |
| 311 | +const usageTipsSchema = z.object({ |
| 312 | + notifications: z.array(z.object({ title: z.string(), url: z.url().optional(), active: z.boolean() })), |
| 313 | +}); |
| 314 | + |
| 315 | +/** |
| 316 | + * Starts the usage tips fetch in the background. Returns a callback that, when invoked check if the fetch |
| 317 | + * is complete. If not complete, it will wait until the max time is reached. After that, if fetch is still |
| 318 | + * not complete, just return. |
| 319 | + */ |
| 320 | +export function startUsageTipsFetch() { |
| 321 | + let fetchedData: z.infer<typeof usageTipsSchema> | undefined = undefined; |
| 322 | + let fetchComplete = false; |
| 323 | + |
| 324 | + const start = Date.now(); |
| 325 | + const controller = new AbortController(); |
| 326 | + |
| 327 | + fetch(CLI_CONFIG_ENDPOINT, { |
| 328 | + headers: { accept: 'application/json' }, |
| 329 | + signal: controller.signal, |
| 330 | + }) |
| 331 | + .then(async (res) => { |
| 332 | + if (!res.ok) return; |
| 333 | + const data = await res.json(); |
| 334 | + const parseResult = usageTipsSchema.safeParse(data); |
| 335 | + if (parseResult.success) { |
| 336 | + fetchedData = parseResult.data; |
| 337 | + } |
| 338 | + }) |
| 339 | + .catch(() => { |
| 340 | + // noop |
| 341 | + }) |
| 342 | + .finally(() => { |
| 343 | + fetchComplete = true; |
| 344 | + }); |
| 345 | + |
| 346 | + return async () => { |
| 347 | + const elapsed = Date.now() - start; |
| 348 | + |
| 349 | + if (!fetchComplete && elapsed < FETCH_CLI_MAX_TIME) { |
| 350 | + // wait for the timeout |
| 351 | + await new Promise((resolve) => setTimeout(resolve, FETCH_CLI_MAX_TIME - elapsed)); |
| 352 | + } |
| 353 | + |
| 354 | + if (!fetchComplete) { |
| 355 | + controller.abort(); |
| 356 | + return; |
| 357 | + } |
| 358 | + |
| 359 | + if (!fetchedData) return; |
| 360 | + |
| 361 | + const activeItems = fetchedData.notifications.filter((item) => item.active); |
| 362 | + // show a random active item |
| 363 | + if (activeItems.length > 0) { |
| 364 | + const item = activeItems[Math.floor(Math.random() * activeItems.length)]!; |
| 365 | + if (item.url) { |
| 366 | + console.log(terminalLink(item.title, item.url)); |
| 367 | + } else { |
| 368 | + console.log(item.title); |
| 369 | + } |
| 370 | + } |
| 371 | + }; |
| 372 | +} |
0 commit comments