@owdproject/core 3.1.4 → 3.2.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.
Files changed (57) hide show
  1. package/README.md +6 -6
  2. package/bin/cli.js +395 -0
  3. package/bin/desktop.js +5 -0
  4. package/bin/lib/catalog.js +293 -0
  5. package/bin/lib/config.js +109 -0
  6. package/bin/lib/desktopConfig.js +56 -0
  7. package/bin/lib/generateTemplate.js +389 -0
  8. package/bin/lib/install.js +408 -0
  9. package/bin/lib/migrateModulePlayground.config.mjs +164 -0
  10. package/bin/lib/migrateModulePlayground.mjs +313 -0
  11. package/bin/lib/npmVersions.js +124 -0
  12. package/bin/lib/playgroundContext.js +188 -0
  13. package/bin/lib/scaffold.js +197 -0
  14. package/bin/lib/status.js +289 -0
  15. package/bin/lib/validateModule.js +683 -0
  16. package/bin/lib/workspace.js +182 -0
  17. package/bin/owd.js +2 -25
  18. package/bin/tui.js +1379 -0
  19. package/dist/module.d.mts +13 -0
  20. package/dist/module.json +9 -0
  21. package/dist/module.mjs +219 -0
  22. package/dist/types.d.mts +7 -0
  23. package/module.ts +38 -12
  24. package/package.json +78 -22
  25. package/runtime/components/Core/Application/CoreApplicationRender.vue +3 -1
  26. package/runtime/components/Core/Application/CoreApplicationWindowsRender.vue +28 -7
  27. package/runtime/components/Core/Window/CoreWindow.vue +42 -8
  28. package/runtime/components/Core/Window/CoreWindowNav.vue +26 -8
  29. package/runtime/composables/useApplicationManager.ts +18 -13
  30. package/runtime/composables/useDesktopManager.ts +31 -7
  31. package/runtime/composables/useOwdDialogs.ts +13 -0
  32. package/runtime/composables/useWorkspaceOverview.ts +41 -0
  33. package/runtime/core/controllers/ApplicationController.ts +114 -24
  34. package/runtime/core/controllers/WindowController.ts +4 -9
  35. package/runtime/dialogs/owdDialogProvider.ts +81 -0
  36. package/runtime/plugins/01.owd-dialogs-fallback.ts +15 -0
  37. package/runtime/stores/storeApplicationMeta.ts +56 -12
  38. package/runtime/stores/storeDesktop.ts +79 -2
  39. package/runtime/stores/storeDesktopDefaultApps.ts +2 -2
  40. package/runtime/stores/storeDesktopExplorer.ts +74 -0
  41. package/runtime/stores/storeDesktopWorkspace.ts +14 -2
  42. package/runtime/utils/captureElementToCanvas.ts +21 -0
  43. package/runtime/utils/explorerEntryPath.ts +10 -0
  44. package/runtime/utils/resolveDesktopConfigPath.ts +52 -0
  45. package/runtime/utils/validateOwdUserConfig.ts +44 -0
  46. package/types/index.d.ts +64 -12
  47. package/CHANGELOG.md +0 -25
  48. package/project.json +0 -4
  49. package/runtime/stores/storeApplication.ts +0 -236
  50. package/test/basic.test.ts +0 -15
  51. package/test/fixtures/basic/app.vue +0 -5
  52. package/test/fixtures/basic/nuxt.config.ts +0 -5
  53. package/test/fixtures/basic/package.json +0 -5
  54. package/tsconfig.app.json +0 -31
  55. package/tsconfig.json +0 -12
  56. package/tsconfig.spec.json +0 -37
  57. package/vitest.config.ts +0 -24
@@ -0,0 +1,683 @@
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
+
10
+ const REQUIRED_SCRIPTS = ['dev', 'dev:prepare', 'dev:generate', 'prepack']
11
+
12
+ /**
13
+ * @param {string} pkgName
14
+ */
15
+ export function isDesktopCorePackage(pkgName) {
16
+ return pkgName === DESKTOP_CORE_PACKAGE
17
+ }
18
+
19
+ /**
20
+ * @param {string} pkgName
21
+ * @returns {'app' | 'theme' | 'module'}
22
+ */
23
+ export function inferModuleKind(pkgName) {
24
+ const short = pkgName.startsWith(SCOPE) ? pkgName.slice(SCOPE.length) : pkgName
25
+ if (short.startsWith('app-')) return 'app'
26
+ if (short.startsWith('theme-')) return 'theme'
27
+ return 'module'
28
+ }
29
+
30
+ /**
31
+ * Walk up from cwd until a package.json with @owdproject/ name is found.
32
+ *
33
+ * @param {string} [startDir]
34
+ * @returns {string | null}
35
+ */
36
+ export function findModulePackageRoot(startDir = process.cwd()) {
37
+ let dir = resolve(startDir)
38
+ for (;;) {
39
+ const pkgPath = join(dir, 'package.json')
40
+ if (existsSync(pkgPath) && existsSync(join(dir, 'src', 'module.ts'))) {
41
+ try {
42
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
43
+ if (typeof pkg.name === 'string' && pkg.name.startsWith(SCOPE)) {
44
+ return dir
45
+ }
46
+ } catch {
47
+ /* ignore */
48
+ }
49
+ }
50
+ const parent = dirname(dir)
51
+ if (parent === dir) return null
52
+ dir = parent
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {string} workspaceRoot
58
+ * @returns {string[]}
59
+ */
60
+ export function discoverOwdModulePackages(workspaceRoot) {
61
+ const roots = []
62
+ const scanDirs = [
63
+ join(workspaceRoot, 'apps'),
64
+ join(workspaceRoot, 'themes'),
65
+ join(workspaceRoot, 'packages'),
66
+ ]
67
+
68
+ for (const base of scanDirs) {
69
+ if (!existsSync(base)) continue
70
+ for (const entry of readdirSync(base)) {
71
+ const dir = join(base, entry)
72
+ try {
73
+ if (!statSync(dir).isDirectory()) continue
74
+ } catch {
75
+ continue
76
+ }
77
+ const pkgPath = join(dir, 'package.json')
78
+ if (!existsSync(pkgPath)) continue
79
+ try {
80
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
81
+ if (!pkg.name?.startsWith(SCOPE)) continue
82
+ if (isDesktopCorePackage(pkg.name)) continue
83
+ if (!existsSync(join(dir, 'src', 'module.ts'))) continue
84
+ roots.push(dir)
85
+ } catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ }
90
+
91
+ return roots.sort()
92
+ }
93
+
94
+ /**
95
+ * @param {string} content
96
+ * @param {string} pkgName
97
+ */
98
+ function desktopConfigReferencesPackage(content, pkgName, kind) {
99
+ if (kind === 'app') {
100
+ return (
101
+ new RegExp(`apps\\s*:\\s*\\[[^\\]]*['"]${escapeRegExp(pkgName)}['"]`, 's').test(
102
+ content,
103
+ ) || new RegExp(`apps\\s*:\\s*\\[[^\\]]*${escapeRegExp(pkgName)}`, 's').test(content)
104
+ )
105
+ }
106
+ if (kind === 'theme') {
107
+ return (
108
+ new RegExp(`theme\\s*:\\s*['"]${escapeRegExp(pkgName)}['"]`).test(content) ||
109
+ new RegExp(`theme\\s*:\\s*${escapeRegExp(pkgName)}`).test(content)
110
+ )
111
+ }
112
+ return (
113
+ new RegExp(`modules\\s*:\\s*\\[[^\\]]*['"]${escapeRegExp(pkgName)}['"]`, 's').test(
114
+ content,
115
+ ) || new RegExp(`modules\\s*:\\s*\\[[^\\]]*${escapeRegExp(pkgName)}`, 's').test(content)
116
+ )
117
+ }
118
+
119
+ /**
120
+ * @param {string} s
121
+ */
122
+ function escapeRegExp(s) {
123
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
124
+ }
125
+
126
+ /**
127
+ * @param {string} workspaceYaml
128
+ * @param {string} playgroundRel
129
+ */
130
+ function workspaceListsPlayground(workspaceYaml, playgroundRel) {
131
+ const normalized = playgroundRel.replace(/\\/g, '/')
132
+ const lines = workspaceYaml.split('\n')
133
+ for (const line of lines) {
134
+ const trimmed = line.trim().replace(/^-\s*/, '')
135
+ if (!trimmed) continue
136
+ if (trimmed === normalized) return true
137
+ if (trimmed.endsWith('/*')) {
138
+ const prefix = trimmed.slice(0, -2)
139
+ if (normalized.startsWith(`${prefix}/`)) return true
140
+ }
141
+ }
142
+ return false
143
+ }
144
+
145
+ /**
146
+ * @typedef {object} ValidationIssue
147
+ * @property {'error' | 'warning'} level
148
+ * @property {string} code
149
+ * @property {string} message
150
+ */
151
+
152
+ /**
153
+ * @typedef {object} ValidationResult
154
+ * @property {boolean} ok
155
+ * @property {string} packageDir
156
+ * @property {string} pkgName
157
+ * @property {'app' | 'theme' | 'module'} kind
158
+ * @property {ValidationIssue[]} errors
159
+ * @property {ValidationIssue[]} warnings
160
+ */
161
+
162
+ /**
163
+ * @param {string} packageDir
164
+ * @param {{ workspaceRoot?: string | null, requireDist?: boolean, strict?: boolean }} [options]
165
+ * @returns {ValidationResult}
166
+ */
167
+ export function validateOwdModule(packageDir, options = {}) {
168
+ const dir = resolve(packageDir)
169
+ const pkgPath = join(dir, 'package.json')
170
+ /** @type {ValidationIssue[]} */
171
+ const errors = []
172
+ /** @type {ValidationIssue[]} */
173
+ const warnings = []
174
+
175
+ const issue = (level, code, message) => {
176
+ ;(level === 'error' ? errors : warnings).push({ level, code, message })
177
+ }
178
+
179
+ if (!existsSync(pkgPath)) {
180
+ return {
181
+ ok: false,
182
+ packageDir: dir,
183
+ pkgName: '',
184
+ kind: 'module',
185
+ errors: [{ level: 'error', code: 'missing-package-json', message: 'package.json not found' }],
186
+ warnings: [],
187
+ }
188
+ }
189
+
190
+ /** @type {Record<string, unknown>} */
191
+ let pkg
192
+ try {
193
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
194
+ } catch {
195
+ return {
196
+ ok: false,
197
+ packageDir: dir,
198
+ pkgName: '',
199
+ kind: 'module',
200
+ errors: [
201
+ {
202
+ level: 'error',
203
+ code: 'invalid-package-json',
204
+ message: 'package.json is not valid JSON',
205
+ },
206
+ ],
207
+ warnings: [],
208
+ }
209
+ }
210
+
211
+ const pkgName = typeof pkg.name === 'string' ? pkg.name : ''
212
+ if (!pkgName.startsWith(SCOPE)) {
213
+ issue('error', 'invalid-scope', `package name must start with ${SCOPE}`)
214
+ }
215
+
216
+ const kind = inferModuleKind(pkgName)
217
+ const isCore = isDesktopCorePackage(pkgName)
218
+
219
+ if (pkg.type !== 'module') {
220
+ issue('error', 'type-module', 'package.json must set "type": "module"')
221
+ }
222
+
223
+ if (isCore) {
224
+ const scripts = /** @type {Record<string, string>} */ (pkg.scripts ?? {})
225
+ if (!scripts['dev:prepare']?.includes('nuxt-module-build')) {
226
+ issue(
227
+ 'error',
228
+ 'dev-prepare-builder',
229
+ 'dev:prepare must run nuxt-module-build build --stub',
230
+ )
231
+ }
232
+ if (!scripts['prepack']?.includes('nuxt-module-build')) {
233
+ issue('error', 'prepack-builder', 'prepack must run nuxt-module-build build')
234
+ }
235
+ const devDeps = /** @type {Record<string, string>} */ (pkg.devDependencies ?? {})
236
+ if (!devDeps['@nuxt/module-builder']) {
237
+ issue(
238
+ 'error',
239
+ 'module-builder-dep',
240
+ 'devDependencies must include @nuxt/module-builder',
241
+ )
242
+ }
243
+ const distModule = join(dir, 'dist', 'module.mjs')
244
+ if (!existsSync(distModule)) {
245
+ issue(
246
+ options.requireDist ? 'error' : 'error',
247
+ 'dist-module',
248
+ 'dist/module.mjs missing — run: pnpm run dev:prepare',
249
+ )
250
+ }
251
+ const ok =
252
+ errors.length === 0 && (!options.strict || warnings.length === 0)
253
+ return {
254
+ ok,
255
+ packageDir: dir,
256
+ pkgName,
257
+ kind,
258
+ errors,
259
+ warnings,
260
+ }
261
+ }
262
+
263
+ const exports = /** @type {Record<string, unknown>} */ (pkg.exports ?? {})
264
+ const rootExport = /** @type {Record<string, string> | undefined} */ (
265
+ typeof exports['.'] === 'object' ? exports['.'] : undefined
266
+ )
267
+ if (rootExport?.import !== './dist/module.mjs') {
268
+ issue(
269
+ 'error',
270
+ 'exports-import',
271
+ 'exports["."].import must be "./dist/module.mjs"',
272
+ )
273
+ }
274
+ if (pkg.main !== './dist/module.mjs') {
275
+ issue('error', 'main', 'main must be "./dist/module.mjs"')
276
+ }
277
+ const files = /** @type {string[] | undefined} */ (pkg.files)
278
+ if (!Array.isArray(files) || !files.includes('dist')) {
279
+ issue('error', 'files-dist', 'files must include "dist"')
280
+ }
281
+
282
+ const scripts = /** @type {Record<string, string>} */ (pkg.scripts ?? {})
283
+ for (const script of REQUIRED_SCRIPTS) {
284
+ if (!scripts[script]) {
285
+ issue('error', `script-${script}`, `package.json scripts must include "${script}"`)
286
+ }
287
+ }
288
+ if (scripts['dev:prepare'] && !scripts['dev:prepare'].includes('nuxt-module-build')) {
289
+ issue(
290
+ 'error',
291
+ 'dev-prepare-builder',
292
+ 'dev:prepare must run nuxt-module-build (stub + prepare)',
293
+ )
294
+ }
295
+ if (scripts['prepack'] && !scripts['prepack'].includes('nuxt-module-build')) {
296
+ issue('error', 'prepack-builder', 'prepack must run nuxt-module-build build')
297
+ }
298
+ if (!scripts['dev:generate']?.includes('NUXT_APP_BASE_URL')) {
299
+ issue(
300
+ 'warning',
301
+ 'dev-generate-base-url',
302
+ 'dev:generate should set NUXT_APP_BASE_URL=/<repo-slug>/ for GitHub Pages',
303
+ )
304
+ }
305
+
306
+ const devDeps = /** @type {Record<string, string>} */ (pkg.devDependencies ?? {})
307
+ if (!devDeps['@nuxt/module-builder']) {
308
+ issue(
309
+ 'error',
310
+ 'module-builder-dep',
311
+ 'devDependencies must include @nuxt/module-builder',
312
+ )
313
+ }
314
+
315
+ const peerDeps = /** @type {Record<string, string>} */ (pkg.peerDependencies ?? {})
316
+ if (!peerDeps['@owdproject/core']) {
317
+ issue(
318
+ 'warning',
319
+ 'peer-core',
320
+ 'peerDependencies should declare @owdproject/core',
321
+ )
322
+ }
323
+
324
+ if (existsSync(join(dir, 'module.ts'))) {
325
+ issue(
326
+ 'error',
327
+ 'legacy-module-root',
328
+ 'legacy layout: move module.ts to src/module.ts',
329
+ )
330
+ }
331
+ if (existsSync(join(dir, 'runtime')) && !existsSync(join(dir, 'src', 'runtime'))) {
332
+ issue(
333
+ 'error',
334
+ 'legacy-runtime-root',
335
+ 'legacy layout: move runtime/ under src/runtime/',
336
+ )
337
+ }
338
+
339
+ const moduleTs = join(dir, 'src', 'module.ts')
340
+ if (!existsSync(moduleTs)) {
341
+ issue('error', 'src-module', 'src/module.ts is required')
342
+ } else {
343
+ const moduleSrc = readFileSync(moduleTs, 'utf8')
344
+ if (!moduleSrc.includes('defineNuxtModule')) {
345
+ issue('error', 'define-nuxt-module', 'src/module.ts must use defineNuxtModule')
346
+ }
347
+ if (!moduleSrc.includes('registerTailwindPath')) {
348
+ issue(
349
+ 'warning',
350
+ 'tailwind-path',
351
+ 'src/module.ts should call registerTailwindPath for Vue components',
352
+ )
353
+ }
354
+ }
355
+
356
+ const distModule = join(dir, 'dist', 'module.mjs')
357
+ if (!existsSync(distModule)) {
358
+ issue(
359
+ options.requireDist ? 'error' : 'error',
360
+ 'dist-module',
361
+ 'dist/module.mjs missing — run: pnpm run dev:prepare',
362
+ )
363
+ }
364
+
365
+ const playgroundDir = join(dir, 'playground')
366
+ if (!existsSync(playgroundDir)) {
367
+ issue('error', 'playground-dir', 'playground/ directory is required')
368
+ } else {
369
+ const pgPkgPath = join(playgroundDir, 'package.json')
370
+ if (!existsSync(pgPkgPath)) {
371
+ issue('error', 'playground-package-json', 'playground/package.json is required')
372
+ } else {
373
+ try {
374
+ const pgPkg = JSON.parse(readFileSync(pgPkgPath, 'utf8'))
375
+ const deps = {
376
+ .../** @type {Record<string, string>} */ (pgPkg.dependencies ?? {}),
377
+ .../** @type {Record<string, string>} */ (pgPkg.devDependencies ?? {}),
378
+ }
379
+ if (!deps[pkgName] && !deps[pkgName.replace(SCOPE, '')]) {
380
+ issue(
381
+ 'error',
382
+ 'playground-self-dep',
383
+ `playground/package.json must depend on ${pkgName}`,
384
+ )
385
+ }
386
+ if (!deps['@owdproject/core']) {
387
+ issue(
388
+ 'error',
389
+ 'playground-core-dep',
390
+ 'playground/package.json must depend on @owdproject/core',
391
+ )
392
+ }
393
+ if (kind === 'app' && !deps['@owdproject/theme-nova']) {
394
+ issue(
395
+ 'warning',
396
+ 'playground-theme-nova',
397
+ 'app playgrounds should use @owdproject/theme-nova in dependencies',
398
+ )
399
+ }
400
+ } catch {
401
+ issue('error', 'playground-package-json', 'playground/package.json is invalid JSON')
402
+ }
403
+ }
404
+
405
+ const nuxtConfigPath = join(playgroundDir, 'nuxt.config.ts')
406
+ if (!existsSync(nuxtConfigPath)) {
407
+ issue('error', 'playground-nuxt-config', 'playground/nuxt.config.ts is required')
408
+ } else {
409
+ const nuxtCfg = readFileSync(nuxtConfigPath, 'utf8')
410
+ if (!nuxtCfg.includes('@owdproject/core')) {
411
+ issue(
412
+ 'error',
413
+ 'playground-nuxt-core',
414
+ 'playground/nuxt.config.ts must register @owdproject/core',
415
+ )
416
+ }
417
+ if (!/\bssr\s*:\s*false\b/.test(nuxtCfg)) {
418
+ issue('error', 'playground-ssr-false', 'playground/nuxt.config.ts must set ssr: false')
419
+ }
420
+ }
421
+
422
+ const desktopConfigPath = join(playgroundDir, 'desktop.config.ts')
423
+ if (!existsSync(desktopConfigPath)) {
424
+ issue('error', 'playground-desktop-config', 'playground/desktop.config.ts is required')
425
+ } else if (pkgName) {
426
+ const desktopCfg = readFileSync(desktopConfigPath, 'utf8')
427
+ if (!desktopConfigReferencesPackage(desktopCfg, pkgName, kind)) {
428
+ const field = kind === 'app' ? 'apps' : kind === 'theme' ? 'theme' : 'modules'
429
+ issue(
430
+ 'error',
431
+ 'playground-desktop-ref',
432
+ `playground/desktop.config.ts must reference ${pkgName} in \`${field}\``,
433
+ )
434
+ }
435
+ if (!desktopCfg.includes('defineDesktopConfig')) {
436
+ issue(
437
+ 'error',
438
+ 'playground-define-config',
439
+ 'playground/desktop.config.ts must use defineDesktopConfig',
440
+ )
441
+ }
442
+ }
443
+
444
+ const appVuePath = join(playgroundDir, 'app', 'app.vue')
445
+ if (!existsSync(appVuePath)) {
446
+ issue('error', 'playground-app-vue', 'playground/app/app.vue is required')
447
+ } else {
448
+ const appVue = readFileSync(appVuePath, 'utf8')
449
+ if (!/<Desktop\s*\/?>/.test(appVue) && !/<Desktop[\s>]/.test(appVue)) {
450
+ issue(
451
+ 'error',
452
+ 'playground-desktop-component',
453
+ 'playground/app/app.vue must render <Desktop />',
454
+ )
455
+ }
456
+ }
457
+
458
+ const workspaceRoot = options.workspaceRoot ?? findWorkspaceRoot(dir)
459
+ const relPkg =
460
+ workspaceRoot != null ? relative(workspaceRoot, dir).replace(/\\/g, '/') : ''
461
+ const isMonorepoModule =
462
+ workspaceRoot != null &&
463
+ /^(apps|themes|packages)\/[^/]+$/.test(relPkg) &&
464
+ options.checkWorkspace !== false
465
+
466
+ if (isMonorepoModule) {
467
+ const wsPath = join(workspaceRoot, 'pnpm-workspace.yaml')
468
+ if (existsSync(wsPath)) {
469
+ const relPlayground = relative(workspaceRoot, playgroundDir)
470
+ const wsYaml = readFileSync(wsPath, 'utf8')
471
+ if (!workspaceListsPlayground(wsYaml, relPlayground)) {
472
+ issue(
473
+ 'error',
474
+ 'workspace-playground',
475
+ `add "${relPlayground}" to pnpm-workspace.yaml packages`,
476
+ )
477
+ }
478
+ }
479
+ }
480
+ }
481
+
482
+ if (kind === 'app') {
483
+ const appConfigPath = join(dir, 'src', 'runtime', 'app.config.ts')
484
+ if (!existsSync(appConfigPath)) {
485
+ issue('error', 'app-config', 'src/runtime/app.config.ts is required for apps')
486
+ }
487
+
488
+ const pluginPath = join(dir, 'src', 'runtime', 'plugin.ts')
489
+ if (!existsSync(pluginPath)) {
490
+ issue('error', 'app-plugin', 'src/runtime/plugin.ts is required for apps')
491
+ } else {
492
+ const pluginSrc = readFileSync(pluginPath, 'utf8')
493
+ if (!pluginSrc.includes('defineDesktopApp')) {
494
+ issue('error', 'define-desktop-app', 'plugin.ts must call defineDesktopApp')
495
+ }
496
+ if (!/name\s*:\s*['"]owd-[^'"]+-register['"]/.test(pluginSrc)) {
497
+ issue(
498
+ 'error',
499
+ 'plugin-name',
500
+ 'plugin.ts must set name: "owd-<slug>-register" for dependsOn ordering',
501
+ )
502
+ }
503
+ if (!pluginSrc.includes('import.meta.server')) {
504
+ issue(
505
+ 'warning',
506
+ 'plugin-server-guard',
507
+ 'plugin.ts should guard with if (import.meta.server) return',
508
+ )
509
+ }
510
+ }
511
+
512
+ const launchPlugins = existsSync(join(playgroundDir, 'app', 'plugins'))
513
+ ? readdirSync(join(playgroundDir, 'app', 'plugins')).filter((f) =>
514
+ /^launch-.*\.client\.ts$/.test(f),
515
+ )
516
+ : []
517
+ if (launchPlugins.length === 0) {
518
+ issue(
519
+ 'warning',
520
+ 'launch-plugin',
521
+ 'optional: add playground/app/plugins/launch-*.client.ts for dev auto-open',
522
+ )
523
+ }
524
+ }
525
+
526
+ if (!existsSync(join(dir, '.github', 'workflows', 'pages.yml'))) {
527
+ issue(
528
+ 'warning',
529
+ 'pages-workflow',
530
+ 'optional for standalone repos: .github/workflows/pages.yml',
531
+ )
532
+ }
533
+
534
+ const ok =
535
+ errors.length === 0 && (!options.strict || warnings.length === 0)
536
+
537
+ return {
538
+ ok,
539
+ packageDir: dir,
540
+ pkgName,
541
+ kind,
542
+ errors,
543
+ warnings,
544
+ }
545
+ }
546
+
547
+ /**
548
+ * @param {ValidationResult} result
549
+ * @param {{ json?: boolean }} [options]
550
+ */
551
+ export function formatValidationReport(result, options = {}) {
552
+ if (options.json) {
553
+ return JSON.stringify(result, null, 2)
554
+ }
555
+
556
+ const lines = []
557
+ const status = result.ok ? 'PASS' : 'FAIL'
558
+ lines.push(`${status} ${result.pkgName || result.packageDir} (${result.kind})`)
559
+ lines.push(` ${result.packageDir}`)
560
+ for (const e of result.errors) {
561
+ lines.push(` ✗ [${e.code}] ${e.message}`)
562
+ }
563
+ for (const w of result.warnings) {
564
+ lines.push(` ⚠ [${w.code}] ${w.message}`)
565
+ }
566
+ if (result.ok && result.warnings.length === 0) {
567
+ lines.push(' All checks passed.')
568
+ }
569
+ return lines.join('\n')
570
+ }
571
+
572
+ /**
573
+ * @param {string} packageDir
574
+ * @param {{ smoke?: boolean }} [options]
575
+ */
576
+ export function runSmokeBuild(packageDir, options = {}) {
577
+ if (!options.smoke) return { ok: true, message: '' }
578
+ const dir = resolve(packageDir)
579
+ try {
580
+ execSync('pnpm run dev:prepare', { cwd: dir, stdio: 'pipe', encoding: 'utf8' })
581
+ execSync('pnpm exec nuxt build playground', {
582
+ cwd: dir,
583
+ stdio: 'pipe',
584
+ encoding: 'utf8',
585
+ timeout: 300_000,
586
+ })
587
+ return { ok: true, message: 'smoke build succeeded' }
588
+ } catch (error) {
589
+ const msg =
590
+ error instanceof Error
591
+ ? error.message
592
+ : String(error)
593
+ return { ok: false, message: `smoke build failed: ${msg}` }
594
+ }
595
+ }
596
+
597
+ /**
598
+ * @param {string[]} paths
599
+ * @param {{ workspaceRoot?: string | null, json?: boolean, strict?: boolean, smoke?: boolean }} [options]
600
+ * @returns {{ exitCode: number, results: ValidationResult[] }}
601
+ */
602
+ export function runValidateCli(paths, options = {}) {
603
+ const workspaceRoot = options.workspaceRoot ?? findWorkspaceRoot() ?? null
604
+ /** @type {string[]} */
605
+ let targets = []
606
+
607
+ if (paths.length === 0) {
608
+ const fromCwd = findModulePackageRoot()
609
+ if (fromCwd) {
610
+ targets = [fromCwd]
611
+ } else if (workspaceRoot) {
612
+ targets = discoverOwdModulePackages(workspaceRoot)
613
+ }
614
+ } else {
615
+ for (const p of paths) {
616
+ const resolved = resolve(p)
617
+ if (existsSync(join(resolved, 'package.json'))) {
618
+ targets.push(resolved)
619
+ } else if (workspaceRoot && (p === 'apps' || p === 'themes' || p === 'packages')) {
620
+ const base = join(workspaceRoot, p)
621
+ if (existsSync(base)) {
622
+ targets.push(...discoverOwdModulePackages(workspaceRoot).filter((t) =>
623
+ t.startsWith(base),
624
+ ))
625
+ }
626
+ } else {
627
+ targets.push(resolved)
628
+ }
629
+ }
630
+ }
631
+
632
+ if (targets.length === 0) {
633
+ return {
634
+ exitCode: 1,
635
+ results: [
636
+ {
637
+ ok: false,
638
+ packageDir: process.cwd(),
639
+ pkgName: '',
640
+ kind: 'module',
641
+ errors: [
642
+ {
643
+ level: 'error',
644
+ code: 'no-target',
645
+ message:
646
+ 'No package to validate. Run from an @owdproject/* package or pass a path.',
647
+ },
648
+ ],
649
+ warnings: [],
650
+ },
651
+ ],
652
+ }
653
+ }
654
+
655
+ /** @type {ValidationResult[]} */
656
+ const results = []
657
+ let exitCode = 0
658
+
659
+ for (const dir of [...new Set(targets)]) {
660
+ const result = validateOwdModule(dir, {
661
+ workspaceRoot,
662
+ requireDist: true,
663
+ strict: options.strict,
664
+ })
665
+
666
+ if (options.smoke && result.pkgName && !isDesktopCorePackage(result.pkgName)) {
667
+ const smoke = runSmokeBuild(dir, { smoke: true })
668
+ if (!smoke.ok) {
669
+ result.errors.push({
670
+ level: 'error',
671
+ code: 'smoke-build',
672
+ message: smoke.message,
673
+ })
674
+ result.ok = false
675
+ }
676
+ }
677
+
678
+ results.push(result)
679
+ if (!result.ok) exitCode = 1
680
+ }
681
+
682
+ return { exitCode, results }
683
+ }