@owdproject/cli 0.0.2 → 0.1.0

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,868 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
3
+ import { dirname, join, relative, resolve } from 'node:path'
4
+ import { findWorkspaceRoot } from './workspace.js'
5
+
6
+ const SCOPE = '@owdproject/'
7
+
8
+ export const DESKTOP_CORE_PACKAGE = '@owdproject/core'
9
+ export const DESKTOP_KIT_PRIMEVUE_PACKAGE = '@owdproject/kit-primevue'
10
+
11
+ const REQUIRED_SCRIPTS = ['dev', 'dev:prepare', 'dev:generate', 'prepack']
12
+ const INFRASTRUCTURE_PLAYBOOK_SCRIPTS = ['dev:prepare', 'prepack']
13
+
14
+ /**
15
+ * @param {string} pkgName
16
+ */
17
+ export function isDesktopCorePackage(pkgName) {
18
+ return pkgName === DESKTOP_CORE_PACKAGE
19
+ }
20
+
21
+ /**
22
+ * Published Nuxt modules without an app/theme playground (core, kit-primevue).
23
+ *
24
+ * @param {string} pkgName
25
+ */
26
+ export function isDesktopInfrastructurePackage(pkgName) {
27
+ return (
28
+ pkgName === DESKTOP_CORE_PACKAGE || pkgName === DESKTOP_KIT_PRIMEVUE_PACKAGE
29
+ )
30
+ }
31
+
32
+ /**
33
+ * @param {string} pkgName
34
+ * @returns {'app' | 'theme' | 'module'}
35
+ */
36
+ export function inferModuleKind(pkgName) {
37
+ const short = pkgName.startsWith(SCOPE) ? pkgName.slice(SCOPE.length) : pkgName
38
+ if (short.startsWith('app-')) return 'app'
39
+ if (short.startsWith('theme-')) return 'theme'
40
+ return 'module'
41
+ }
42
+
43
+ /**
44
+ * Walk up from cwd until a package.json with @owdproject/ name is found.
45
+ *
46
+ * @param {string} [startDir]
47
+ * @returns {string | null}
48
+ */
49
+ export function findModulePackageRoot(startDir = process.cwd()) {
50
+ let dir = resolve(startDir)
51
+ for (;;) {
52
+ const pkgPath = join(dir, 'package.json')
53
+ if (existsSync(pkgPath) && existsSync(join(dir, 'src', 'module.ts'))) {
54
+ try {
55
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
56
+ if (typeof pkg.name === 'string' && pkg.name.startsWith(SCOPE)) {
57
+ return dir
58
+ }
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ }
63
+ const parent = dirname(dir)
64
+ if (parent === dir) return null
65
+ dir = parent
66
+ }
67
+ }
68
+
69
+ /**
70
+ * @param {string} workspaceRoot
71
+ * @returns {string[]}
72
+ */
73
+ export function discoverOwdModulePackages(workspaceRoot) {
74
+ const roots = []
75
+ const scanDirs = [
76
+ join(workspaceRoot, 'apps'),
77
+ join(workspaceRoot, 'themes'),
78
+ join(workspaceRoot, 'packages'),
79
+ ]
80
+
81
+ for (const base of scanDirs) {
82
+ if (!existsSync(base)) continue
83
+ for (const entry of readdirSync(base)) {
84
+ const dir = join(base, entry)
85
+ try {
86
+ if (!statSync(dir).isDirectory()) continue
87
+ } catch {
88
+ continue
89
+ }
90
+ const pkgPath = join(dir, 'package.json')
91
+ if (!existsSync(pkgPath)) continue
92
+ try {
93
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
94
+ if (!pkg.name?.startsWith(SCOPE)) continue
95
+ if (isDesktopCorePackage(pkg.name)) continue
96
+ if (!existsSync(join(dir, 'src', 'module.ts'))) continue
97
+ roots.push(dir)
98
+ } catch {
99
+ /* ignore */
100
+ }
101
+ }
102
+ }
103
+
104
+ return roots.sort()
105
+ }
106
+
107
+ /**
108
+ * @param {string} content
109
+ * @param {string} pkgName
110
+ */
111
+ function desktopConfigReferencesPackage(content, pkgName, kind) {
112
+ if (kind === 'app') {
113
+ return (
114
+ new RegExp(`apps\\s*:\\s*\\[[^\\]]*['"]${escapeRegExp(pkgName)}['"]`, 's').test(
115
+ content,
116
+ ) || new RegExp(`apps\\s*:\\s*\\[[^\\]]*${escapeRegExp(pkgName)}`, 's').test(content)
117
+ )
118
+ }
119
+ if (kind === 'theme') {
120
+ return (
121
+ new RegExp(`theme\\s*:\\s*['"]${escapeRegExp(pkgName)}['"]`).test(content) ||
122
+ new RegExp(`theme\\s*:\\s*${escapeRegExp(pkgName)}`).test(content)
123
+ )
124
+ }
125
+ return (
126
+ new RegExp(`modules\\s*:\\s*\\[[^\\]]*['"]${escapeRegExp(pkgName)}['"]`, 's').test(
127
+ content,
128
+ ) || new RegExp(`modules\\s*:\\s*\\[[^\\]]*${escapeRegExp(pkgName)}`, 's').test(content)
129
+ )
130
+ }
131
+
132
+ /**
133
+ * @param {string} s
134
+ */
135
+ function escapeRegExp(s) {
136
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
137
+ }
138
+
139
+ /** @param {string} moduleSrc */
140
+ export function findLegacyModuleMetaName(moduleSrc) {
141
+ const match = moduleSrc.match(
142
+ /meta\s*:\s*\{[\s\S]*?\bname\s*:\s*['"](owd-[^'"]+)['"]/,
143
+ )
144
+ return match?.[1] ?? null
145
+ }
146
+
147
+ /**
148
+ * @param {string} content
149
+ * @returns {string[]}
150
+ */
151
+ export function findLegacyPiniaStoreIdSnippets(content) {
152
+ /** @type {string[]} */
153
+ const hits = []
154
+ const patterns = [
155
+ /defineStore\s*\(\s*['"]owd\/[^'"]+['"]/g,
156
+ /defineStore\s*\(\s*`owd\/[^`]+`/g,
157
+ /(?:const|let)\s+\w*id\w*\s*=\s*['"]owd\/[^'"]+['"]/gi,
158
+ /(?:const|let)\s+\w*id\w*\s*=\s*`owd\/[^`]+`/gi,
159
+ ]
160
+
161
+ for (const pattern of patterns) {
162
+ for (const match of content.matchAll(pattern)) {
163
+ const snippet = match[0].replace(/\s+/g, ' ').trim()
164
+ if (!hits.includes(snippet)) {
165
+ hits.push(snippet)
166
+ }
167
+ }
168
+ }
169
+
170
+ return hits
171
+ }
172
+
173
+ /**
174
+ * @param {string} packageDir
175
+ * @returns {{ file: string, snippets: string[] }[]}
176
+ */
177
+ export function scanLegacyPiniaStoreIds(packageDir) {
178
+ const srcDir = join(packageDir, 'src')
179
+ if (!existsSync(srcDir)) return []
180
+
181
+ /** @type {{ file: string, snippets: string[] }[]} */
182
+ const findings = []
183
+
184
+ /** @param {string} current */
185
+ const walk = (current) => {
186
+ for (const entry of readdirSync(current)) {
187
+ const full = join(current, entry)
188
+ let stat
189
+ try {
190
+ stat = statSync(full)
191
+ } catch {
192
+ continue
193
+ }
194
+ if (stat.isDirectory()) {
195
+ walk(full)
196
+ continue
197
+ }
198
+ if (!/\.(ts|js|mjs|vue)$/i.test(entry)) continue
199
+ if (
200
+ /storeIds\.ts$/i.test(entry) ||
201
+ /migrate[A-Za-z]*\.ts$/i.test(entry) ||
202
+ /migrate[A-Za-z]*\.js$/i.test(entry)
203
+ ) {
204
+ continue
205
+ }
206
+
207
+ const content = readFileSync(full, 'utf8')
208
+ const snippets = findLegacyPiniaStoreIdSnippets(content)
209
+ if (snippets.length > 0) {
210
+ findings.push({
211
+ file: relative(packageDir, full).replace(/\\/g, '/'),
212
+ snippets,
213
+ })
214
+ }
215
+ }
216
+ }
217
+
218
+ walk(srcDir)
219
+ return findings
220
+ }
221
+
222
+ /**
223
+ * @param {string} workspaceYaml
224
+ * @param {string} playgroundRel
225
+ */
226
+ function workspaceListsPlayground(workspaceYaml, playgroundRel) {
227
+ const normalized = playgroundRel.replace(/\\/g, '/')
228
+ const lines = workspaceYaml.split('\n')
229
+ for (const line of lines) {
230
+ const trimmed = line.trim().replace(/^-\s*/, '')
231
+ if (!trimmed) continue
232
+ if (trimmed === normalized) return true
233
+ if (trimmed.endsWith('/*')) {
234
+ const prefix = trimmed.slice(0, -2)
235
+ if (normalized.startsWith(`${prefix}/`)) return true
236
+ }
237
+ }
238
+ return false
239
+ }
240
+
241
+ /**
242
+ * @typedef {object} ValidationIssue
243
+ * @property {'error' | 'warning'} level
244
+ * @property {string} code
245
+ * @property {string} message
246
+ */
247
+
248
+ /**
249
+ * @typedef {object} ValidationResult
250
+ * @property {boolean} ok
251
+ * @property {string} packageDir
252
+ * @property {string} pkgName
253
+ * @property {'app' | 'theme' | 'module'} kind
254
+ * @property {ValidationIssue[]} errors
255
+ * @property {ValidationIssue[]} warnings
256
+ */
257
+
258
+ /**
259
+ * @param {string} packageDir
260
+ * @param {{ workspaceRoot?: string | null, requireDist?: boolean, strict?: boolean }} [options]
261
+ * @returns {ValidationResult}
262
+ */
263
+ export function validateOwdModule(packageDir, options = {}) {
264
+ const dir = resolve(packageDir)
265
+ const pkgPath = join(dir, 'package.json')
266
+ /** @type {ValidationIssue[]} */
267
+ const errors = []
268
+ /** @type {ValidationIssue[]} */
269
+ const warnings = []
270
+
271
+ const issue = (level, code, message) => {
272
+ ;(level === 'error' ? errors : warnings).push({ level, code, message })
273
+ }
274
+
275
+ if (!existsSync(pkgPath)) {
276
+ return {
277
+ ok: false,
278
+ packageDir: dir,
279
+ pkgName: '',
280
+ kind: 'module',
281
+ errors: [{ level: 'error', code: 'missing-package-json', message: 'package.json not found' }],
282
+ warnings: [],
283
+ }
284
+ }
285
+
286
+ /** @type {Record<string, unknown>} */
287
+ let pkg
288
+ try {
289
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
290
+ } catch {
291
+ return {
292
+ ok: false,
293
+ packageDir: dir,
294
+ pkgName: '',
295
+ kind: 'module',
296
+ errors: [
297
+ {
298
+ level: 'error',
299
+ code: 'invalid-package-json',
300
+ message: 'package.json is not valid JSON',
301
+ },
302
+ ],
303
+ warnings: [],
304
+ }
305
+ }
306
+
307
+ const pkgName = typeof pkg.name === 'string' ? pkg.name : ''
308
+ if (!pkgName.startsWith(SCOPE)) {
309
+ issue('error', 'invalid-scope', `package name must start with ${SCOPE}`)
310
+ }
311
+
312
+ const kind = inferModuleKind(pkgName)
313
+ const isInfrastructure = isDesktopInfrastructurePackage(pkgName)
314
+
315
+ if (pkg.type !== 'module') {
316
+ issue('error', 'type-module', 'package.json must set "type": "module"')
317
+ }
318
+
319
+ if (isInfrastructure) {
320
+ const scripts = /** @type {Record<string, string>} */ (pkg.scripts ?? {})
321
+ for (const script of INFRASTRUCTURE_PLAYBOOK_SCRIPTS) {
322
+ if (!scripts[script]) {
323
+ issue(
324
+ 'error',
325
+ `script-${script}`,
326
+ `package.json scripts must include "${script}"`,
327
+ )
328
+ }
329
+ }
330
+ if (!scripts['dev:prepare']?.includes('nuxt-module-build')) {
331
+ issue(
332
+ 'error',
333
+ 'dev-prepare-builder',
334
+ 'dev:prepare must run nuxt-module-build build --stub',
335
+ )
336
+ }
337
+ if (!scripts['prepack']?.includes('nuxt-module-build')) {
338
+ issue('error', 'prepack-builder', 'prepack must run nuxt-module-build build')
339
+ }
340
+ const devDeps = /** @type {Record<string, string>} */ (pkg.devDependencies ?? {})
341
+ if (!devDeps['@nuxt/module-builder']) {
342
+ issue(
343
+ 'error',
344
+ 'module-builder-dep',
345
+ 'devDependencies must include @nuxt/module-builder',
346
+ )
347
+ }
348
+ const distModule = join(dir, 'dist', 'module.mjs')
349
+ if (!existsSync(distModule)) {
350
+ issue(
351
+ options.requireDist ? 'error' : 'error',
352
+ 'dist-module',
353
+ 'dist/module.mjs missing — run: pnpm run dev:prepare',
354
+ )
355
+ }
356
+ const ok =
357
+ errors.length === 0 && (!options.strict || warnings.length === 0)
358
+ return {
359
+ ok,
360
+ packageDir: dir,
361
+ pkgName,
362
+ kind,
363
+ errors,
364
+ warnings,
365
+ }
366
+ }
367
+
368
+ const exports = /** @type {Record<string, unknown>} */ (pkg.exports ?? {})
369
+ const rootExport = /** @type {Record<string, string> | undefined} */ (
370
+ typeof exports['.'] === 'object' ? exports['.'] : undefined
371
+ )
372
+ if (rootExport?.import !== './dist/module.mjs') {
373
+ issue(
374
+ 'error',
375
+ 'exports-import',
376
+ 'exports["."].import must be "./dist/module.mjs"',
377
+ )
378
+ }
379
+ if (pkg.main !== './dist/module.mjs') {
380
+ issue('error', 'main', 'main must be "./dist/module.mjs"')
381
+ }
382
+ const files = /** @type {string[] | undefined} */ (pkg.files)
383
+ if (!Array.isArray(files) || !files.includes('dist')) {
384
+ issue('error', 'files-dist', 'files must include "dist"')
385
+ }
386
+
387
+ const scripts = /** @type {Record<string, string>} */ (pkg.scripts ?? {})
388
+ for (const script of REQUIRED_SCRIPTS) {
389
+ if (!scripts[script]) {
390
+ issue('error', `script-${script}`, `package.json scripts must include "${script}"`)
391
+ }
392
+ }
393
+ if (scripts['dev:prepare'] && !scripts['dev:prepare'].includes('nuxt-module-build')) {
394
+ issue(
395
+ 'error',
396
+ 'dev-prepare-builder',
397
+ 'dev:prepare must run nuxt-module-build (stub + prepare)',
398
+ )
399
+ }
400
+ if (scripts['prepack'] && !scripts['prepack'].includes('nuxt-module-build')) {
401
+ issue('error', 'prepack-builder', 'prepack must run nuxt-module-build build')
402
+ }
403
+ if (scripts.prepare) {
404
+ issue(
405
+ 'error',
406
+ 'no-prepare-script',
407
+ 'Do not add a "prepare" script on publishable packages — it runs on npm publish/install and breaks release. Use "prepack" (full build) and "dev:prepare" (local stub + playground) only.',
408
+ )
409
+ }
410
+ if (!rootExport?.development?.includes('src/module')) {
411
+ issue(
412
+ 'warning',
413
+ 'exports-development',
414
+ 'exports["."].development should point to ./src/module.ts (monorepo dev without manual stub after every edit)',
415
+ )
416
+ }
417
+ if (!scripts['dev:generate']?.includes('NUXT_APP_BASE_URL')) {
418
+ issue(
419
+ 'warning',
420
+ 'dev-generate-base-url',
421
+ 'dev:generate should set NUXT_APP_BASE_URL=/<repo-slug>/ for GitHub Pages',
422
+ )
423
+ }
424
+
425
+ const devDeps = /** @type {Record<string, string>} */ (pkg.devDependencies ?? {})
426
+ if (!devDeps['@nuxt/module-builder']) {
427
+ issue(
428
+ 'error',
429
+ 'module-builder-dep',
430
+ 'devDependencies must include @nuxt/module-builder',
431
+ )
432
+ }
433
+
434
+ const peerDeps = /** @type {Record<string, string>} */ (pkg.peerDependencies ?? {})
435
+ if (!peerDeps['@owdproject/core']) {
436
+ issue(
437
+ 'warning',
438
+ 'peer-core',
439
+ 'peerDependencies should declare @owdproject/core',
440
+ )
441
+ }
442
+
443
+ if (existsSync(join(dir, 'module.ts'))) {
444
+ issue(
445
+ 'error',
446
+ 'legacy-module-root',
447
+ 'legacy layout: move module.ts to src/module.ts',
448
+ )
449
+ }
450
+ if (existsSync(join(dir, 'runtime')) && !existsSync(join(dir, 'src', 'runtime'))) {
451
+ issue(
452
+ 'error',
453
+ 'legacy-runtime-root',
454
+ 'legacy layout: move runtime/ under src/runtime/',
455
+ )
456
+ }
457
+
458
+ const moduleTs = join(dir, 'src', 'module.ts')
459
+ if (!existsSync(moduleTs)) {
460
+ issue('error', 'src-module', 'src/module.ts is required')
461
+ } else {
462
+ const moduleSrc = readFileSync(moduleTs, 'utf8')
463
+ const hasModuleWrapper =
464
+ moduleSrc.includes('defineNuxtModule') ||
465
+ (kind === 'module' && moduleSrc.includes('defineDesktopModule')) ||
466
+ (kind === 'theme' && moduleSrc.includes('defineDesktopTheme'))
467
+ if (!hasModuleWrapper) {
468
+ const expected =
469
+ kind === 'module'
470
+ ? 'defineDesktopModule (or defineNuxtModule)'
471
+ : kind === 'theme'
472
+ ? 'defineDesktopTheme (or defineNuxtModule)'
473
+ : 'defineNuxtModule'
474
+ issue(
475
+ 'error',
476
+ 'define-nuxt-module',
477
+ `src/module.ts must use ${expected}`,
478
+ )
479
+ }
480
+ const hasKitTailwind =
481
+ moduleSrc.includes('registerTailwindPath') ||
482
+ moduleSrc.includes('registerThemeTailwindPath')
483
+ const hasKitPrimevue =
484
+ moduleSrc.includes("installModule('@owdproject/kit-primevue'") ||
485
+ moduleSrc.includes('installModule("@owdproject/kit-primevue"')
486
+ if (kind === 'theme') {
487
+ if (!hasKitPrimevue) {
488
+ issue(
489
+ 'warning',
490
+ 'kit-primevue',
491
+ 'theme src/module.ts should install @owdproject/kit-primevue for PrimeVue + Tailwind',
492
+ )
493
+ }
494
+ if (!hasKitTailwind) {
495
+ issue(
496
+ 'warning',
497
+ 'tailwind-path',
498
+ 'theme src/module.ts should call registerThemeTailwindPath from @owdproject/kit-primevue/kit/registerTailwindPath',
499
+ )
500
+ }
501
+ } else if (kind === 'app' && !hasKitTailwind) {
502
+ issue(
503
+ 'warning',
504
+ 'tailwind-path',
505
+ 'src/module.ts should call registerTailwindPath from @owdproject/kit-primevue/kit/registerTailwindPath for Vue components',
506
+ )
507
+ }
508
+
509
+ const legacyMetaName = findLegacyModuleMetaName(moduleSrc)
510
+ if (legacyMetaName) {
511
+ issue(
512
+ 'warning',
513
+ 'module-meta-name-legacy',
514
+ `src/module.ts meta.name is "${legacyMetaName}"; migrate to desktop-<kind>-<slug> (see MIGRATION_3.4.md)`,
515
+ )
516
+ }
517
+ }
518
+
519
+ if (!isInfrastructure) {
520
+ for (const finding of scanLegacyPiniaStoreIds(dir)) {
521
+ issue(
522
+ 'warning',
523
+ 'pinia-store-id-legacy',
524
+ `${finding.file}: Pinia store id still uses legacy "owd/" (${finding.snippets.join('; ')}); migrate to "desktop/" ids`,
525
+ )
526
+ }
527
+ }
528
+
529
+ const distModule = join(dir, 'dist', 'module.mjs')
530
+ if (!existsSync(distModule)) {
531
+ issue(
532
+ options.requireDist ? 'error' : 'error',
533
+ 'dist-module',
534
+ 'dist/module.mjs missing — run: pnpm run dev:prepare',
535
+ )
536
+ }
537
+
538
+ const playgroundDir = join(dir, 'playground')
539
+ if (!existsSync(playgroundDir)) {
540
+ issue('error', 'playground-dir', 'playground/ directory is required')
541
+ } else {
542
+ const pgPkgPath = join(playgroundDir, 'package.json')
543
+ if (!existsSync(pgPkgPath)) {
544
+ issue('error', 'playground-package-json', 'playground/package.json is required')
545
+ } else {
546
+ try {
547
+ const pgPkg = JSON.parse(readFileSync(pgPkgPath, 'utf8'))
548
+ const deps = {
549
+ .../** @type {Record<string, string>} */ (pgPkg.dependencies ?? {}),
550
+ .../** @type {Record<string, string>} */ (pgPkg.devDependencies ?? {}),
551
+ }
552
+ if (!deps[pkgName] && !deps[pkgName.replace(SCOPE, '')]) {
553
+ issue(
554
+ 'error',
555
+ 'playground-self-dep',
556
+ `playground/package.json must depend on ${pkgName}`,
557
+ )
558
+ }
559
+ if (!deps['@owdproject/core']) {
560
+ issue(
561
+ 'error',
562
+ 'playground-core-dep',
563
+ 'playground/package.json must depend on @owdproject/core',
564
+ )
565
+ }
566
+ if (kind === 'app' && !deps['@owdproject/theme-nova']) {
567
+ issue(
568
+ 'warning',
569
+ 'playground-theme-nova',
570
+ 'app playgrounds should use @owdproject/theme-nova in dependencies',
571
+ )
572
+ }
573
+ } catch {
574
+ issue('error', 'playground-package-json', 'playground/package.json is invalid JSON')
575
+ }
576
+ }
577
+
578
+ const nuxtConfigPath = join(playgroundDir, 'nuxt.config.ts')
579
+ if (!existsSync(nuxtConfigPath)) {
580
+ issue('error', 'playground-nuxt-config', 'playground/nuxt.config.ts is required')
581
+ } else {
582
+ const nuxtCfg = readFileSync(nuxtConfigPath, 'utf8')
583
+ if (!nuxtCfg.includes('@owdproject/core')) {
584
+ issue(
585
+ 'error',
586
+ 'playground-nuxt-core',
587
+ 'playground/nuxt.config.ts must register @owdproject/core',
588
+ )
589
+ }
590
+ if (!/\bssr\s*:\s*false\b/.test(nuxtCfg)) {
591
+ issue('error', 'playground-ssr-false', 'playground/nuxt.config.ts must set ssr: false')
592
+ }
593
+ }
594
+
595
+ const desktopConfigPath = join(playgroundDir, 'desktop.config.ts')
596
+ if (!existsSync(desktopConfigPath)) {
597
+ issue('error', 'playground-desktop-config', 'playground/desktop.config.ts is required')
598
+ } else if (pkgName) {
599
+ const desktopCfg = readFileSync(desktopConfigPath, 'utf8')
600
+ if (!desktopConfigReferencesPackage(desktopCfg, pkgName, kind)) {
601
+ const field = kind === 'app' ? 'apps' : kind === 'theme' ? 'theme' : 'modules'
602
+ issue(
603
+ 'error',
604
+ 'playground-desktop-ref',
605
+ `playground/desktop.config.ts must reference ${pkgName} in \`${field}\``,
606
+ )
607
+ }
608
+ if (!desktopCfg.includes('defineDesktopConfig')) {
609
+ issue(
610
+ 'error',
611
+ 'playground-define-config',
612
+ 'playground/desktop.config.ts must use defineDesktopConfig',
613
+ )
614
+ }
615
+ }
616
+
617
+ const appVuePath = join(playgroundDir, 'app', 'app.vue')
618
+ if (!existsSync(appVuePath)) {
619
+ issue('error', 'playground-app-vue', 'playground/app/app.vue is required')
620
+ } else {
621
+ const appVue = readFileSync(appVuePath, 'utf8')
622
+ if (!/<Desktop\s*\/?>/.test(appVue) && !/<Desktop[\s>]/.test(appVue)) {
623
+ issue(
624
+ 'error',
625
+ 'playground-desktop-component',
626
+ 'playground/app/app.vue must render <Desktop />',
627
+ )
628
+ }
629
+ }
630
+
631
+ const workspaceRoot = options.workspaceRoot ?? findWorkspaceRoot(dir)
632
+ const relPkg =
633
+ workspaceRoot != null ? relative(workspaceRoot, dir).replace(/\\/g, '/') : ''
634
+ const isMonorepoModule =
635
+ workspaceRoot != null &&
636
+ /^(apps|themes|packages)\/[^/]+$/.test(relPkg) &&
637
+ options.checkWorkspace !== false
638
+
639
+ if (isMonorepoModule) {
640
+ const wsPath = join(workspaceRoot, 'pnpm-workspace.yaml')
641
+ if (existsSync(wsPath)) {
642
+ const relPlayground = relative(workspaceRoot, playgroundDir)
643
+ const wsYaml = readFileSync(wsPath, 'utf8')
644
+ if (!workspaceListsPlayground(wsYaml, relPlayground)) {
645
+ issue(
646
+ 'error',
647
+ 'workspace-playground',
648
+ `add "${relPlayground}" to pnpm-workspace.yaml packages`,
649
+ )
650
+ }
651
+ }
652
+ }
653
+ }
654
+
655
+ if (kind === 'app') {
656
+ const appConfigPath = join(dir, 'src', 'runtime', 'app.config.ts')
657
+ if (!existsSync(appConfigPath)) {
658
+ issue('error', 'app-config', 'src/runtime/app.config.ts is required for apps')
659
+ }
660
+
661
+ const pluginPath = join(dir, 'src', 'runtime', 'plugin.ts')
662
+ if (!existsSync(pluginPath)) {
663
+ issue('error', 'app-plugin', 'src/runtime/plugin.ts is required for apps')
664
+ } else {
665
+ const pluginSrc = readFileSync(pluginPath, 'utf8')
666
+ if (!pluginSrc.includes('defineDesktopApp')) {
667
+ issue('error', 'define-desktop-app', 'plugin.ts must call defineDesktopApp')
668
+ }
669
+ const hasDesktopRegister = /name\s*:\s*['"]desktop-[^'"]+-register['"]/.test(
670
+ pluginSrc,
671
+ )
672
+ const hasLegacyRegister = /name\s*:\s*['"]owd-[^'"]+-register['"]/.test(
673
+ pluginSrc,
674
+ )
675
+ if (!hasDesktopRegister && !hasLegacyRegister) {
676
+ issue(
677
+ 'error',
678
+ 'plugin-name',
679
+ 'plugin.ts must set name: "desktop-<slug>-register" for dependsOn ordering',
680
+ )
681
+ } else if (!hasDesktopRegister && hasLegacyRegister) {
682
+ issue(
683
+ 'error',
684
+ 'plugin-name-legacy',
685
+ 'plugin.ts uses deprecated name "owd-<slug>-register"; rename to "desktop-<slug>-register"',
686
+ )
687
+ }
688
+ if (!pluginSrc.includes('import.meta.server')) {
689
+ issue(
690
+ 'warning',
691
+ 'plugin-server-guard',
692
+ 'plugin.ts should guard with if (import.meta.server) return',
693
+ )
694
+ }
695
+ }
696
+
697
+ const launchPlugins = existsSync(join(playgroundDir, 'app', 'plugins'))
698
+ ? readdirSync(join(playgroundDir, 'app', 'plugins')).filter((f) =>
699
+ /^launch-.*\.client\.ts$/.test(f),
700
+ )
701
+ : []
702
+ if (launchPlugins.length === 0) {
703
+ issue(
704
+ 'warning',
705
+ 'launch-plugin',
706
+ 'optional: add playground/app/plugins/launch-*.client.ts for dev auto-open',
707
+ )
708
+ }
709
+ }
710
+
711
+ if (!existsSync(join(dir, '.github', 'workflows', 'pages.yml'))) {
712
+ issue(
713
+ 'warning',
714
+ 'pages-workflow',
715
+ 'optional for standalone repos: .github/workflows/pages.yml',
716
+ )
717
+ }
718
+
719
+ const ok =
720
+ errors.length === 0 && (!options.strict || warnings.length === 0)
721
+
722
+ return {
723
+ ok,
724
+ packageDir: dir,
725
+ pkgName,
726
+ kind,
727
+ errors,
728
+ warnings,
729
+ }
730
+ }
731
+
732
+ /**
733
+ * @param {ValidationResult} result
734
+ * @param {{ json?: boolean }} [options]
735
+ */
736
+ export function formatValidationReport(result, options = {}) {
737
+ if (options.json) {
738
+ return JSON.stringify(result, null, 2)
739
+ }
740
+
741
+ const lines = []
742
+ const status = result.ok ? 'PASS' : 'FAIL'
743
+ lines.push(`${status} ${result.pkgName || result.packageDir} (${result.kind})`)
744
+ lines.push(` ${result.packageDir}`)
745
+ for (const e of result.errors) {
746
+ lines.push(` ✗ [${e.code}] ${e.message}`)
747
+ }
748
+ for (const w of result.warnings) {
749
+ lines.push(` ⚠ [${w.code}] ${w.message}`)
750
+ }
751
+ if (result.ok && result.warnings.length === 0) {
752
+ lines.push(' All checks passed.')
753
+ }
754
+ return lines.join('\n')
755
+ }
756
+
757
+ /**
758
+ * @param {string} packageDir
759
+ * @param {{ smoke?: boolean }} [options]
760
+ */
761
+ export function runSmokeBuild(packageDir, options = {}) {
762
+ if (!options.smoke) return { ok: true, message: '' }
763
+ const dir = resolve(packageDir)
764
+ try {
765
+ execSync('pnpm run dev:prepare', { cwd: dir, stdio: 'pipe', encoding: 'utf8' })
766
+ execSync('pnpm exec nuxt build playground', {
767
+ cwd: dir,
768
+ stdio: 'pipe',
769
+ encoding: 'utf8',
770
+ timeout: 300_000,
771
+ })
772
+ return { ok: true, message: 'smoke build succeeded' }
773
+ } catch (error) {
774
+ const msg =
775
+ error instanceof Error
776
+ ? error.message
777
+ : String(error)
778
+ return { ok: false, message: `smoke build failed: ${msg}` }
779
+ }
780
+ }
781
+
782
+ /**
783
+ * @param {string[]} paths
784
+ * @param {{ workspaceRoot?: string | null, json?: boolean, strict?: boolean, smoke?: boolean }} [options]
785
+ * @returns {{ exitCode: number, results: ValidationResult[] }}
786
+ */
787
+ export function runValidateCli(paths, options = {}) {
788
+ const workspaceRoot = options.workspaceRoot ?? findWorkspaceRoot() ?? null
789
+ /** @type {string[]} */
790
+ let targets = []
791
+
792
+ if (paths.length === 0) {
793
+ const fromCwd = findModulePackageRoot()
794
+ if (fromCwd) {
795
+ targets = [fromCwd]
796
+ } else if (workspaceRoot) {
797
+ targets = discoverOwdModulePackages(workspaceRoot)
798
+ }
799
+ } else {
800
+ for (const p of paths) {
801
+ const resolved = resolve(p)
802
+ if (existsSync(join(resolved, 'package.json'))) {
803
+ targets.push(resolved)
804
+ } else if (workspaceRoot && (p === 'apps' || p === 'themes' || p === 'packages')) {
805
+ const base = join(workspaceRoot, p)
806
+ if (existsSync(base)) {
807
+ targets.push(...discoverOwdModulePackages(workspaceRoot).filter((t) =>
808
+ t.startsWith(base),
809
+ ))
810
+ }
811
+ } else {
812
+ targets.push(resolved)
813
+ }
814
+ }
815
+ }
816
+
817
+ if (targets.length === 0) {
818
+ return {
819
+ exitCode: 1,
820
+ results: [
821
+ {
822
+ ok: false,
823
+ packageDir: process.cwd(),
824
+ pkgName: '',
825
+ kind: 'module',
826
+ errors: [
827
+ {
828
+ level: 'error',
829
+ code: 'no-target',
830
+ message:
831
+ 'No package to validate. Run from an @owdproject/* package or pass a path.',
832
+ },
833
+ ],
834
+ warnings: [],
835
+ },
836
+ ],
837
+ }
838
+ }
839
+
840
+ /** @type {ValidationResult[]} */
841
+ const results = []
842
+ let exitCode = 0
843
+
844
+ for (const dir of [...new Set(targets)]) {
845
+ const result = validateOwdModule(dir, {
846
+ workspaceRoot,
847
+ requireDist: true,
848
+ strict: options.strict,
849
+ })
850
+
851
+ if (options.smoke && result.pkgName && !isDesktopCorePackage(result.pkgName)) {
852
+ const smoke = runSmokeBuild(dir, { smoke: true })
853
+ if (!smoke.ok) {
854
+ result.errors.push({
855
+ level: 'error',
856
+ code: 'smoke-build',
857
+ message: smoke.message,
858
+ })
859
+ result.ok = false
860
+ }
861
+ }
862
+
863
+ results.push(result)
864
+ if (!result.ok) exitCode = 1
865
+ }
866
+
867
+ return { exitCode, results }
868
+ }