@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,187 @@
1
+ /** @typedef {import('./migrateModulePlayground.mjs').MigrateConfig} MigrateConfig */
2
+
3
+ /** @type {MigrateConfig[]} */
4
+ export const configs = [
5
+ {
6
+ path: 'apps/app-todo',
7
+ slug: 'app-todo',
8
+ name: '@owdproject/app-todo',
9
+ moduleName: 'desktop-app-todo',
10
+ version: '0.1.3',
11
+ keywords: ['owd-client', 'owd-apps', 'owd-modules'],
12
+ launch: {
13
+ file: 'launch-todo.client.ts',
14
+ pluginName: 'app-todo-playground-launch',
15
+ dependsOn: 'desktop-app-todo-register',
16
+ appId: 'org.owdproject.todo',
17
+ command: 'todo',
18
+ },
19
+ pagesYml: true,
20
+ },
21
+ {
22
+ path: 'apps/app-terminal',
23
+ slug: 'app-terminal',
24
+ name: '@owdproject/app-terminal',
25
+ moduleName: 'desktop-app-terminal',
26
+ version: '0.1.3',
27
+ launch: {
28
+ file: 'launch-terminal.client.ts',
29
+ pluginName: 'app-terminal-playground-launch',
30
+ dependsOn: 'desktop-app-terminal-register',
31
+ appId: 'org.owdproject.terminal',
32
+ command: 'terminal',
33
+ },
34
+ pagesYml: true,
35
+ },
36
+ {
37
+ path: 'apps/app-debug',
38
+ slug: 'app-debug',
39
+ name: '@owdproject/app-debug',
40
+ moduleName: 'desktop-app-debug',
41
+ version: '0.1.0',
42
+ launch: {
43
+ file: 'launch-debug.client.ts',
44
+ pluginName: 'app-debug-playground-launch',
45
+ dependsOn: 'desktop-app-debug-register',
46
+ appId: 'org.owdproject.debug',
47
+ command: 'debug',
48
+ },
49
+ pagesYml: true,
50
+ },
51
+ {
52
+ path: 'apps/app-dino',
53
+ slug: 'app-dino',
54
+ name: '@owdproject/app-dino',
55
+ moduleName: 'desktop-app-dino',
56
+ version: '0.0.1',
57
+ private: true,
58
+ launch: {
59
+ file: 'launch-dino.client.ts',
60
+ pluginName: 'app-dino-playground-launch',
61
+ dependsOn: 'desktop-app-dino-register',
62
+ appId: 'org.owdproject.dino',
63
+ command: 'dino',
64
+ },
65
+ },
66
+ {
67
+ path: 'apps/app-soundcloud',
68
+ slug: 'app-soundcloud',
69
+ name: '@owdproject/app-soundcloud',
70
+ moduleName: 'desktop-app-soundcloud',
71
+ version: '0.1.0',
72
+ launch: {
73
+ file: 'launch-soundcloud.client.ts',
74
+ pluginName: 'app-soundcloud-playground-launch',
75
+ dependsOn: 'desktop-app-soundcloud-register',
76
+ appId: 'org.owdproject.soundcloud',
77
+ command: 'soundcloud --new --no-check',
78
+ },
79
+ pagesYml: true,
80
+ },
81
+ {
82
+ path: 'apps/app-youtube',
83
+ slug: 'app-youtube',
84
+ name: '@owdproject/app-youtube',
85
+ moduleName: 'desktop-app-youtube',
86
+ version: '0.1.0',
87
+ launch: {
88
+ file: 'launch-youtube.client.ts',
89
+ pluginName: 'app-youtube-playground-launch',
90
+ dependsOn: 'desktop-app-youtube-register',
91
+ appId: 'org.owdproject.youtube',
92
+ command: 'youtube --new --no-check',
93
+ },
94
+ pagesYml: true,
95
+ },
96
+ {
97
+ path: 'apps/app-classic-audioplayer',
98
+ slug: 'app-classic-audioplayer',
99
+ name: '@owdproject/app-classic-audioplayer',
100
+ moduleName: 'desktop-app-classic-audioplayer',
101
+ version: '0.0.4',
102
+ playgroundModules: ['@owdproject/module-fs', '@owdproject/module-persistence'],
103
+ playgroundApps: ['@owdproject/app-classic-audioplayer'],
104
+ fsMounts: { '/mnt/test': '/test.zip' },
105
+ copyTestZip: true,
106
+ playgroundExtraDevDeps: {
107
+ '@owdproject/module-fs': 'workspace:*',
108
+ '@owdproject/module-persistence': 'workspace:*',
109
+ },
110
+ launch: {
111
+ file: 'launch-audioplayer.client.ts',
112
+ pluginName: 'app-classic-audioplayer-playground-launch',
113
+ dependsOn: 'desktop-app-classic-audioplayer-register',
114
+ appId: 'org.owdproject.classic-audioplayer',
115
+ command: 'classic-audioplayer',
116
+ fsDemo: true,
117
+ },
118
+ pagesYml: true,
119
+ },
120
+ {
121
+ path: 'apps/app-classic-videoplayer',
122
+ slug: 'app-classic-videoplayer',
123
+ name: '@owdproject/app-classic-videoplayer',
124
+ moduleName: 'desktop-app-classic-videoplayer',
125
+ version: '0.0.4',
126
+ launch: {
127
+ file: 'launch-videoplayer.client.ts',
128
+ pluginName: 'app-classic-videoplayer-playground-launch',
129
+ dependsOn: 'desktop-app-classic-videoplayer-register',
130
+ appId: 'org.owdproject.classic-videoplayer',
131
+ command: 'classic-videoplayer',
132
+ },
133
+ pagesYml: true,
134
+ },
135
+ {
136
+ path: 'apps/app-explorer',
137
+ slug: 'app-explorer',
138
+ name: '@owdproject/app-explorer',
139
+ moduleName: 'desktop-app-explorer',
140
+ version: '0.0.4',
141
+ playgroundModules: ['@owdproject/module-fs', '@owdproject/module-persistence'],
142
+ playgroundApps: ['@owdproject/app-explorer'],
143
+ fsMounts: { '/mnt/test': '/test.zip' },
144
+ copyTestZip: true,
145
+ playgroundExtraDevDeps: {
146
+ '@owdproject/module-fs': 'workspace:*',
147
+ '@owdproject/module-persistence': 'workspace:*',
148
+ },
149
+ dependencies: {
150
+ },
151
+ launch: {
152
+ file: 'launch-explorer.client.ts',
153
+ pluginName: 'app-explorer-playground-launch',
154
+ dependsOn: 'desktop-app-explorer-register',
155
+ appId: 'org.owdproject.explorer',
156
+ command: 'explorer /mnt/test',
157
+ },
158
+ pagesYml: true,
159
+ },
160
+ {
161
+ path: 'apps/app-atproto',
162
+ slug: 'app-atproto',
163
+ name: '@owdproject/app-atproto',
164
+ moduleName: 'desktop-app-atproto',
165
+ version: '0.0.3',
166
+ playgroundTheme: '@owdproject/theme-win95',
167
+ playgroundModules: ['@owdproject/module-atproto'],
168
+ playgroundApps: ['@owdproject/app-terminal', '@owdproject/app-atproto'],
169
+ dependencies: {
170
+ '@owdproject/kit-tailwind': 'workspace:*',
171
+ '@owdproject/module-atproto': 'workspace:*',
172
+ },
173
+ playgroundExtraDevDeps: {
174
+ '@owdproject/module-atproto': 'workspace:*',
175
+ '@owdproject/theme-win95': 'workspace:*',
176
+ '@owdproject/app-terminal': 'workspace:*',
177
+ },
178
+ launch: {
179
+ file: 'launch-atproto.client.ts',
180
+ pluginName: 'app-atproto-playground-launch',
181
+ dependsOn: 'desktop-app-atproto-register',
182
+ appId: 'org.owdproject.atproto',
183
+ command: 'atproto',
184
+ },
185
+ pagesYml: true,
186
+ },
187
+ ]
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
10
+ import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { configs } from './migrateModulePlayground.config.mjs'
13
+
14
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../../')
15
+
16
+ function fixAppPlugin(pluginPath, registerName) {
17
+ const raw = readFileSync(pluginPath, 'utf8')
18
+ const configVar = raw.match(/import (config\w+) from/)?.[1] || 'configApp'
19
+ writeFileSync(
20
+ pluginPath,
21
+ `import { defineNuxtPlugin } from 'nuxt/app'
22
+ import { defineDesktopApp } from '@owdproject/core/kit/defineDesktopApp'
23
+ import ${configVar} from './app.config'
24
+
25
+ export default defineNuxtPlugin({
26
+ name: '${registerName}',
27
+ async setup() {
28
+ if (import.meta.server) return
29
+ await defineDesktopApp(${configVar})
30
+ },
31
+ })
32
+ `,
33
+ )
34
+ }
35
+
36
+ function buildLaunchPlugin(cfg) {
37
+ const l = cfg.launch
38
+ if (l.fsDemo) {
39
+ return `import { nextTick } from 'vue'
40
+ import { defineNuxtPlugin } from 'nuxt/app'
41
+ import { useApplicationManager } from '@owdproject/core/runtime/composables/useApplicationManager'
42
+ import { openVfsFile } from '@owdproject/module-fs/runtime/utils/utilFsOpenFile'
43
+
44
+ const APP_ID = '${l.appId}'
45
+
46
+ export default defineNuxtPlugin({
47
+ name: '${l.pluginName}',
48
+ dependsOn: ['${l.dependsOn}'],
49
+ async setup(nuxtApp) {
50
+ if (!import.meta.dev) return
51
+
52
+ const applicationManager = useApplicationManager()
53
+
54
+ async function runDemo() {
55
+ if (!applicationManager.isAppDefined('org.owdproject.explorer')) return false
56
+ await applicationManager.execAppCommand('org.owdproject.explorer', 'explorer /mnt/test')
57
+ const opened = await openVfsFile('/mnt/test/demo.mp3')
58
+ if (!opened && applicationManager.isAppDefined(APP_ID)) {
59
+ await applicationManager.execAppCommand(APP_ID, ${JSON.stringify(l.command)})
60
+ }
61
+ return true
62
+ }
63
+
64
+ nuxtApp.hook('app:mounted', async () => {
65
+ await nextTick()
66
+ for (let i = 0; i < 80; i++) {
67
+ if (await runDemo()) return
68
+ await new Promise((r) => setTimeout(r, 50))
69
+ }
70
+ })
71
+ },
72
+ })
73
+ `
74
+ }
75
+
76
+ return `import { nextTick } from 'vue'
77
+ import { defineNuxtPlugin } from 'nuxt/app'
78
+ import { useApplicationManager } from '@owdproject/core/runtime/composables/useApplicationManager'
79
+
80
+ const APP_ID = '${l.appId}'
81
+ const WINDOW_MODEL = 'main'
82
+
83
+ export default defineNuxtPlugin({
84
+ name: '${l.pluginName}',
85
+ dependsOn: ['${l.dependsOn}'],
86
+ async setup(nuxtApp) {
87
+ if (!import.meta.dev) return
88
+
89
+ const applicationManager = useApplicationManager()
90
+
91
+ async function surfaceWindow() {
92
+ if (!applicationManager.isAppDefined(APP_ID)) return false
93
+ const app = applicationManager.getAppById(APP_ID)!
94
+ if (app.storeWindows.$persistedState) {
95
+ await app.storeWindows.$persistedState.isReady()
96
+ }
97
+ app.closeAllWindows()
98
+ app.storeWindows.windows = {}
99
+ await applicationManager.execAppCommand(APP_ID, ${JSON.stringify(l.command)})
100
+ const window = app.getFirstWindowByModel(WINDOW_MODEL)
101
+ if (window) {
102
+ window.actions.setActive(true)
103
+ window.actions.bringToFront()
104
+ }
105
+ return Boolean(window)
106
+ }
107
+
108
+ nuxtApp.hook('app:mounted', async () => {
109
+ await nextTick()
110
+ await surfaceWindow()
111
+ })
112
+
113
+ for (let attempt = 0; attempt < 80; attempt++) {
114
+ if (await surfaceWindow()) return
115
+ await new Promise((resolve) => setTimeout(resolve, 50))
116
+ }
117
+ },
118
+ })
119
+ `
120
+ }
121
+
122
+ function buildPackageJson(cfg, old) {
123
+ const slug = cfg.slug
124
+ const deps = { '@nuxt/kit': '^4.3.0', ...(cfg.dependencies || {}) }
125
+ const devDeps = {
126
+ '@nuxt/module-builder': '^1.0.2',
127
+ '@nuxt/schema': '^4.3.0',
128
+ '@owdproject/core': 'workspace:*',
129
+ '@owdproject/theme-nova': 'workspace:*',
130
+ nuxt: '^4.3.0',
131
+ typescript: '~5.9.3',
132
+ ...(cfg.devDependencies || {}),
133
+ ...(cfg.playgroundExtraDevDeps || {}),
134
+ }
135
+
136
+ const pkg = {
137
+ name: cfg.name,
138
+ version: old.version || cfg.version,
139
+ description: old.description,
140
+ keywords: old.keywords || cfg.keywords,
141
+ license: old.license || 'MIT',
142
+ homepage: old.homepage || `https://github.com/owdproject/${slug}`,
143
+ author: old.author,
144
+ type: 'module',
145
+ exports: { '.': { types: './dist/types.d.mts', import: './dist/module.mjs' } },
146
+ main: './dist/module.mjs',
147
+ typesVersions: { '*': { '.': ['./dist/types.d.mts'] } },
148
+ files: ['dist'],
149
+ nx: { name: slug },
150
+ scripts: {
151
+ prepack: 'nuxt-module-build build',
152
+ dev: 'pnpm run dev:prepare && cd playground && pnpm exec nuxt dev',
153
+ 'dev:build': 'nuxt build playground',
154
+ 'dev:generate': `NUXT_APP_BASE_URL=\${NUXT_APP_BASE_URL:-/${slug}/} nuxt generate playground`,
155
+ 'dev:prepare':
156
+ 'nuxt-module-build build --stub && nuxt-module-build prepare && cd playground && pnpm exec nuxt prepare',
157
+ validate: 'desktop validate .',
158
+ },
159
+ dependencies: deps,
160
+ devDependencies: devDeps,
161
+ peerDependencies: old.peerDependencies || { '@owdproject/core': '^3.2.0' },
162
+ }
163
+ if (cfg.private || old.private) pkg.private = true
164
+ if (old.peerDependenciesMeta) pkg.peerDependenciesMeta = old.peerDependenciesMeta
165
+ return pkg
166
+ }
167
+
168
+ function buildPlaygroundPkg(cfg) {
169
+ const deps = {
170
+ '@owdproject/core': 'workspace:*',
171
+ [cfg.name]: 'workspace:*',
172
+ nuxt: '^4.4.4',
173
+ ...(cfg.playgroundExtraDevDeps || {}),
174
+ }
175
+ if (!cfg.playgroundTheme) deps['@owdproject/theme-nova'] = 'workspace:*'
176
+ if (cfg.playgroundTheme) deps[cfg.playgroundTheme] = 'workspace:*'
177
+ return {
178
+ name: `${cfg.name}-playground`,
179
+ private: true,
180
+ type: 'module',
181
+ scripts: { dev: 'nuxt dev', build: 'nuxt build', generate: 'nuxt generate' },
182
+ dependencies: deps,
183
+ }
184
+ }
185
+
186
+ function buildDesktopConfig(cfg) {
187
+ const theme = cfg.playgroundTheme || '@owdproject/theme-nova'
188
+ const apps = cfg.playgroundApps || [cfg.name]
189
+ const modules = cfg.playgroundModules || []
190
+ let s = `import { defineDesktopConfig } from '@owdproject/core'\n\nexport default defineDesktopConfig({\n theme: '${theme}',\n`
191
+ if (apps.length) s += ` apps: [${apps.map((a) => `'${a}'`).join(', ')}],\n`
192
+ if (modules.length) s += ` modules: [${modules.map((m) => `'${m}'`).join(', ')}],\n`
193
+ s += ' systemBar: { enabled: true, startButton: true },\n'
194
+ if (cfg.fsMounts) {
195
+ s += ' fs: {\n mounts: {\n'
196
+ for (const [k, v] of Object.entries(cfg.fsMounts)) s += ` '${k}': '${v}',\n`
197
+ s += ' },\n },\n'
198
+ }
199
+ s += '})\n'
200
+ return s
201
+ }
202
+
203
+ const NUXT = `export default defineNuxtConfig({
204
+ modules: ['@owdproject/core'],
205
+ app: { baseURL: process.env.NUXT_APP_BASE_URL || '/' },
206
+ i18n: { strategy: 'no_prefix' },
207
+ devtools: { enabled: true },
208
+ compatibilityDate: 'latest',
209
+ ssr: false,
210
+ })
211
+ `
212
+
213
+ function pagesYml(slug) {
214
+ return `name: Deploy playground to GitHub Pages
215
+ on:
216
+ push:
217
+ branches: [main]
218
+ workflow_dispatch:
219
+ permissions:
220
+ contents: read
221
+ pages: write
222
+ id-token: write
223
+ concurrency:
224
+ group: pages
225
+ cancel-in-progress: true
226
+ jobs:
227
+ build:
228
+ runs-on: ubuntu-latest
229
+ steps:
230
+ - uses: actions/checkout@v6
231
+ - uses: actions/setup-node@v6
232
+ with:
233
+ node-version: 22
234
+ - run: npm i -g --force corepack@latest && corepack enable
235
+ - run: npx nypm@latest i
236
+ - run: npm run dev:prepare
237
+ - run: npm run dev:generate
238
+ env:
239
+ NUXT_APP_BASE_URL: /${slug}/
240
+ - uses: actions/upload-pages-artifact@v3
241
+ with:
242
+ path: playground/.output/public
243
+ deploy:
244
+ needs: build
245
+ runs-on: ubuntu-latest
246
+ environment:
247
+ name: github-pages
248
+ url: \${{ steps.deployment.outputs.page_url }}
249
+ steps:
250
+ - uses: actions/deploy-pages@v4
251
+ `
252
+ }
253
+
254
+ function migrate(cfg) {
255
+ const dir = join(ROOT, cfg.path)
256
+ const pkgPath = join(dir, 'package.json')
257
+ if (!existsSync(pkgPath)) {
258
+ console.warn('Skipping path without package.json:', cfg.path)
259
+ return
260
+ }
261
+ const src = join(dir, 'src')
262
+ mkdirSync(src, { recursive: true })
263
+ if (existsSync(join(dir, 'module.ts')) && !existsSync(join(src, 'module.ts'))) renameSync(join(dir, 'module.ts'), join(src, 'module.ts'))
264
+ if (existsSync(join(dir, 'runtime')) && !existsSync(join(src, 'runtime'))) renameSync(join(dir, 'runtime'), join(src, 'runtime'))
265
+
266
+ const registerName = `${cfg.moduleName}-register`
267
+ const pluginPath = join(src, 'runtime/plugin.ts')
268
+ if (existsSync(pluginPath)) fixAppPlugin(pluginPath, registerName)
269
+
270
+ const old = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
271
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(buildPackageJson(cfg, old), null, 2) + '\n')
272
+
273
+ writeFileSync(
274
+ join(dir, 'tsconfig.json'),
275
+ JSON.stringify(
276
+ {
277
+ extends: '../../tsconfig.base.json',
278
+ include: ['src/runtime', 'src/module.ts', '../../packages/core/types/index.d.ts'],
279
+ exclude: ['dist', 'node_modules', 'playground'],
280
+ compilerOptions: { outDir: 'dist' },
281
+ },
282
+ null,
283
+ 2,
284
+ ) + '\n',
285
+ )
286
+
287
+ writeFileSync(
288
+ join(dir, '.gitignore'),
289
+ 'node_modules\n.nuxt\ndist\n.output\nplayground/.nuxt\nplayground/node_modules\n._*\n',
290
+ )
291
+
292
+ const pg = join(dir, 'playground')
293
+ mkdirSync(join(pg, 'app/plugins'), { recursive: true })
294
+ writeFileSync(join(pg, 'nuxt.config.ts'), NUXT)
295
+ writeFileSync(join(pg, 'tsconfig.json'), '{ "extends": "./.nuxt/tsconfig.json" }\n')
296
+ writeFileSync(join(pg, 'package.json'), JSON.stringify(buildPlaygroundPkg(cfg), null, 2) + '\n')
297
+ writeFileSync(join(pg, 'desktop.config.ts'), buildDesktopConfig(cfg))
298
+ writeFileSync(join(pg, 'app/app.vue'), '<template>\n <Desktop />\n</template>\n')
299
+ if (cfg.launch) {
300
+ writeFileSync(join(pg, 'app/plugins', cfg.launch.file), buildLaunchPlugin(cfg))
301
+ }
302
+
303
+ if (cfg.copyTestZip) {
304
+ mkdirSync(join(pg, 'public'), { recursive: true })
305
+ const srcZip = join(ROOT, 'desktop/public/test.zip')
306
+ if (existsSync(srcZip)) copyFileSync(srcZip, join(pg, 'public/test.zip'))
307
+ }
308
+
309
+ if (cfg.pagesYml) {
310
+ const wf = join(dir, '.github/workflows')
311
+ mkdirSync(wf, { recursive: true })
312
+ writeFileSync(join(wf, 'pages.yml'), pagesYml(cfg.slug))
313
+ }
314
+
315
+ console.log('OK', cfg.name)
316
+ }
317
+
318
+ for (const cfg of configs) migrate(cfg)
@@ -0,0 +1,124 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { desktopMetaDir } from './workspace.js'
5
+
6
+ const CACHE_TTL_MS = 60 * 60 * 1000
7
+ const SCOPE = '@owdproject/'
8
+
9
+ /**
10
+ * @param {string} version
11
+ */
12
+ export function formatCaretVersion(version) {
13
+ const trimmed = String(version).trim()
14
+ if (!trimmed) throw new Error('Invalid npm version (empty)')
15
+ if (trimmed.startsWith('^') || trimmed.startsWith('~') || trimmed.startsWith('>=')) {
16
+ return trimmed
17
+ }
18
+ return `^${trimmed}`
19
+ }
20
+
21
+ /**
22
+ * @param {unknown} error
23
+ */
24
+ function isNpmLookupSkippableError(error) {
25
+ const msg = error?.stderr?.toString?.() || error?.message || String(error)
26
+ return (
27
+ /E404|404 Not Found/.test(msg) ||
28
+ /EAI_AGAIN|ENOTFOUND|ETIMEDOUT|ECONNREFUSED|network/i.test(msg)
29
+ )
30
+ }
31
+
32
+ /**
33
+ * @param {string} pkgName
34
+ * @param {{ optional?: boolean }} [options]
35
+ * @returns {string | null}
36
+ */
37
+ export function fetchLatestVersion(pkgName, options = {}) {
38
+ let out
39
+ try {
40
+ out = execSync(`npm view ${JSON.stringify(pkgName)} version`, {
41
+ encoding: 'utf8',
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ }).trim()
44
+ } catch (error) {
45
+ if (options.optional || isNpmLookupSkippableError(error)) return null
46
+ const msg = error.stderr?.toString?.() || error.message || String(error)
47
+ throw new Error(`npm view failed for ${pkgName}: ${msg}`)
48
+ }
49
+ return formatCaretVersion(out)
50
+ }
51
+
52
+ /**
53
+ * @param {string[]} pkgNames
54
+ * @returns {Record<string, string>}
55
+ */
56
+ export function fetchLatestVersions(pkgNames, options = {}) {
57
+ const unique = [...new Set(pkgNames.filter(Boolean))]
58
+ if (unique.length === 0) return {}
59
+
60
+ /** @type {Record<string, string>} */
61
+ const result = {}
62
+ for (const name of unique) {
63
+ const version = fetchLatestVersion(name, options)
64
+ if (version) result[name] = version
65
+ }
66
+ return result
67
+ }
68
+
69
+ function cachePath(workspaceRoot) {
70
+ return join(desktopMetaDir(workspaceRoot), 'template-versions.json')
71
+ }
72
+
73
+ /**
74
+ * @param {string} workspaceRoot
75
+ * @param {string[]} pkgNames
76
+ * @param {{ useCache?: boolean }} [options]
77
+ */
78
+ export function resolveLatestVersions(workspaceRoot, pkgNames, options = {}) {
79
+ const useCache = options.useCache !== false
80
+ const unique = [...new Set(pkgNames.filter(Boolean))]
81
+ const path = cachePath(workspaceRoot)
82
+
83
+ if (useCache && existsSync(path)) {
84
+ try {
85
+ const data = JSON.parse(readFileSync(path, 'utf8'))
86
+ if (Date.now() - data.fetchedAt < CACHE_TTL_MS && data.versions) {
87
+ const cached = data.versions
88
+ const missing = unique.filter((name) => !cached[name])
89
+ if (missing.length === 0) {
90
+ return Object.fromEntries(unique.map((name) => [name, cached[name]]))
91
+ }
92
+ const fresh = fetchLatestVersions(missing)
93
+ const merged = { ...cached, ...fresh }
94
+ writeVersionCache(workspaceRoot, merged)
95
+ return Object.fromEntries(unique.map((name) => [name, merged[name]]))
96
+ }
97
+ } catch {
98
+ /* ignore corrupt cache */
99
+ }
100
+ }
101
+
102
+ const versions = fetchLatestVersions(unique)
103
+ if (useCache) writeVersionCache(workspaceRoot, versions)
104
+ return versions
105
+ }
106
+
107
+ /**
108
+ * @param {string} workspaceRoot
109
+ * @param {Record<string, string>} versions
110
+ */
111
+ export function writeVersionCache(workspaceRoot, versions) {
112
+ mkdirSync(desktopMetaDir(workspaceRoot), { recursive: true })
113
+ writeFileSync(
114
+ cachePath(workspaceRoot),
115
+ JSON.stringify({ fetchedAt: Date.now(), versions }, null, 2) + '\n',
116
+ )
117
+ }
118
+
119
+ /**
120
+ * @param {string} pkgName
121
+ */
122
+ export function isOwdScopedPackage(pkgName) {
123
+ return pkgName.startsWith(SCOPE)
124
+ }