@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.
package/bin/desktop.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from './cli.js'
4
+
5
+ await runCli('desktop', process.argv.slice(2))
@@ -0,0 +1,501 @@
1
+ import {
2
+ existsSync,
3
+ readFileSync,
4
+ readdirSync,
5
+ mkdirSync,
6
+ writeFileSync,
7
+ statSync,
8
+ } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import { execSync } from 'node:child_process'
11
+ import {
12
+ KINDS,
13
+ SCOPE,
14
+ fullName,
15
+ shortName,
16
+ inferKind,
17
+ isInstallableDesktopModule,
18
+ desktopMetaDir,
19
+ } from './workspace.js'
20
+ import { hasLocalWorkspaceSource } from './install.js'
21
+ import { enrichCatalogEntries, isTrustedPublisher } from './packageSources.js'
22
+
23
+ const CACHE_TTL_MS = 60 * 60 * 1000
24
+
25
+ /** @typedef {'updated' | 'name' | 'stars' | 'installed'} CatalogSortMode */
26
+
27
+ export const CATALOG_SORT_MODES = /** @type {const} */ ([
28
+ 'updated',
29
+ 'name',
30
+ 'stars',
31
+ 'installed',
32
+ ])
33
+
34
+ /** @type {Record<CatalogSortMode, string>} */
35
+ export const CATALOG_SORT_LABELS = {
36
+ updated: 'Updated',
37
+ name: 'Name',
38
+ stars: 'Stars',
39
+ installed: 'Installed',
40
+ }
41
+
42
+ /** @type {Record<CatalogSortMode, string>} */
43
+ export const CATALOG_SORT_DESCRIPTIONS = {
44
+ updated: 'Recent GitHub activity',
45
+ name: 'A-Z package name',
46
+ stars: 'GitHub star count',
47
+ installed: 'On desktop first',
48
+ }
49
+
50
+ /**
51
+ * @param {{ apps?: string[], modules?: string[], theme?: string }} config
52
+ * @param {Map<string, boolean>} [pendingPackages]
53
+ * @param {string | null | undefined} [pendingTheme]
54
+ */
55
+ export function effectiveInstalledSets(config, pendingPackages = new Map(), pendingTheme) {
56
+ const apps = new Set(config?.apps ?? [])
57
+ const modules = new Set(config?.modules ?? [])
58
+ let theme = pendingTheme ?? config?.theme ?? null
59
+
60
+ for (const [name, on] of pendingPackages) {
61
+ const kind = inferKind(shortName(name))
62
+ if (kind === 'app') {
63
+ if (on) apps.add(name)
64
+ else apps.delete(name)
65
+ } else if (kind === 'module') {
66
+ if (on) modules.add(name)
67
+ else modules.delete(name)
68
+ } else if (kind === 'theme') {
69
+ if (on) theme = name
70
+ else if (theme === name) theme = config?.theme ?? null
71
+ }
72
+ }
73
+
74
+ return {
75
+ apps: [...apps],
76
+ modules: [...modules],
77
+ theme,
78
+ }
79
+ }
80
+
81
+ function cachePath(workspaceRoot) {
82
+ return join(desktopMetaDir(workspaceRoot), 'catalog-cache.json')
83
+ }
84
+
85
+ function readCacheFile(workspaceRoot) {
86
+ const path = cachePath(workspaceRoot)
87
+ if (!existsSync(path)) return null
88
+ try {
89
+ return JSON.parse(readFileSync(path, 'utf8'))
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ function readCache(workspaceRoot) {
96
+ const data = readCacheFile(workspaceRoot)
97
+ if (!data?.entries) return null
98
+ if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null
99
+ const ageMin = Math.round((Date.now() - data.fetchedAt) / 60_000)
100
+ return { entries: data.entries, cacheAge: `${ageMin}m` }
101
+ }
102
+
103
+ /** Names from last cache file (even expired) for isNew detection. */
104
+ function readPreviousCatalogNames(workspaceRoot) {
105
+ const data = readCacheFile(workspaceRoot)
106
+ if (!data?.entries?.length) return new Set()
107
+ return new Set(data.entries.map((e) => e.shortName))
108
+ }
109
+
110
+ function formatCacheAge(fetchedAt) {
111
+ const ageMin = Math.round((Date.now() - fetchedAt) / 60_000)
112
+ if (ageMin < 1) return '<1m'
113
+ if (ageMin < 60) return `${ageMin}m`
114
+ return `${Math.round(ageMin / 60)}h`
115
+ }
116
+
117
+ function writeCache(workspaceRoot, entries) {
118
+ mkdirSync(desktopMetaDir(workspaceRoot), { recursive: true })
119
+ writeFileSync(
120
+ cachePath(workspaceRoot),
121
+ JSON.stringify({ fetchedAt: Date.now(), entries }, null, 2),
122
+ )
123
+ }
124
+
125
+ function mapGithubRepo(repo, org) {
126
+ return {
127
+ shortName: repo.name,
128
+ description: repo.description ?? '',
129
+ stars: repo.stargazers_count ?? 0,
130
+ htmlUrl: repo.html_url,
131
+ pushedAt: repo.pushed_at ?? null,
132
+ updatedAt: repo.updated_at ?? repo.pushed_at ?? null,
133
+ org: repo.owner?.login ?? org,
134
+ kind: inferKind(repo.name),
135
+ }
136
+ }
137
+
138
+ function localPackageUpdatedAt(packageDir) {
139
+ try {
140
+ return execSync(`git -C ${packageDir} log -1 --format=%cI`, {
141
+ encoding: 'utf8',
142
+ stdio: ['ignore', 'pipe', 'ignore'],
143
+ }).trim()
144
+ } catch {
145
+ const pkgPath = join(packageDir, 'package.json')
146
+ if (existsSync(pkgPath)) {
147
+ return new Date(statSync(pkgPath).mtime).toISOString()
148
+ }
149
+ }
150
+ return null
151
+ }
152
+
153
+ /**
154
+ * @param {object[]} entries
155
+ * @param {Set<string>} previousNames
156
+ * @param {number} newDays
157
+ */
158
+ function annotateDiscoveryFlags(entries, previousNames, newDays) {
159
+ const cutoff = Date.now() - newDays * 24 * 60 * 60 * 1000
160
+ const now = new Date().toISOString()
161
+
162
+ return entries.map((entry) => {
163
+ const isNew = !previousNames.has(entry.shortName)
164
+ const pushedMs = entry.pushedAt ? new Date(entry.pushedAt).getTime() : 0
165
+ const isRecent = pushedMs > 0 && pushedMs >= cutoff
166
+ return {
167
+ ...entry,
168
+ firstSeenAt: isNew ? now : entry.firstSeenAt ?? now,
169
+ isNew,
170
+ isRecent: isNew || isRecent,
171
+ }
172
+ })
173
+ }
174
+
175
+ async function fetchTopic(topic, org) {
176
+ const q = encodeURIComponent(`topic:${topic} user:${org}`)
177
+ const url = `https://api.github.com/search/repositories?q=${q}&sort=stars&per_page=100`
178
+ const headers = {
179
+ Accept: 'application/vnd.github+json',
180
+ 'User-Agent': 'owd-desktop-cli',
181
+ }
182
+ if (process.env.GITHUB_TOKEN) {
183
+ headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`
184
+ }
185
+
186
+ const res = await fetch(url, { headers })
187
+ if (!res.ok) throw new Error(`GitHub API ${res.status} for ${org}/${topic}`)
188
+ const json = await res.json()
189
+ return (json.items ?? []).map((repo) => mapGithubRepo(repo, org))
190
+ }
191
+
192
+ async function fetchUserRepos(user) {
193
+ const url = `https://api.github.com/users/${encodeURIComponent(user)}/repos?per_page=100&sort=updated`
194
+ const headers = {
195
+ Accept: 'application/vnd.github+json',
196
+ 'User-Agent': 'owd-desktop-cli',
197
+ }
198
+ if (process.env.GITHUB_TOKEN) {
199
+ headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`
200
+ }
201
+
202
+ const res = await fetch(url, { headers })
203
+ if (!res.ok) return []
204
+ const repos = await res.json()
205
+ return repos
206
+ .filter((repo) => /^(app|theme|module|kit)-/.test(repo.name))
207
+ .map((repo) => mapGithubRepo(repo, user))
208
+ }
209
+
210
+ function scanLocal(workspaceRoot, kind) {
211
+ const dir = join(workspaceRoot, KINDS[kind].workspaceDir)
212
+ if (!existsSync(dir)) return []
213
+
214
+ return readdirSync(dir, { withFileTypes: true })
215
+ .filter((d) => d.isDirectory())
216
+ .map((d) => {
217
+ const packageDir = join(dir, d.name)
218
+ const pkgPath = join(packageDir, 'package.json')
219
+ if (!existsSync(pkgPath)) return null
220
+ try {
221
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
222
+ if (!pkg.name?.startsWith(SCOPE)) return null
223
+ let remote = null
224
+ try {
225
+ remote = execSync(`git -C ${packageDir} remote get-url origin`, {
226
+ encoding: 'utf8',
227
+ stdio: ['ignore', 'pipe', 'ignore'],
228
+ }).trim()
229
+ } catch {
230
+ /* no git */
231
+ }
232
+ const updatedAt = localPackageUpdatedAt(packageDir)
233
+ return {
234
+ shortName: shortName(pkg.name),
235
+ description: pkg.description ?? '',
236
+ stars: 0,
237
+ htmlUrl: pkg.homepage ?? pkg.repository?.url ?? null,
238
+ pushedAt: updatedAt,
239
+ updatedAt,
240
+ org: 'workspace',
241
+ kind,
242
+ localPath: join(KINDS[kind].workspaceDir, d.name),
243
+ remote,
244
+ version: pkg.version,
245
+ }
246
+ } catch {
247
+ return null
248
+ }
249
+ })
250
+ .filter(Boolean)
251
+ }
252
+
253
+ export async function loadCatalog(workspaceRoot, settings, options = {}) {
254
+ const previousNames = readPreviousCatalogNames(workspaceRoot)
255
+ const newDays = settings.catalogNewDays ?? 14
256
+
257
+ const cached = options.force ? null : readCacheFile(workspaceRoot)
258
+ let remote = cached?.entries ?? null
259
+ let cacheAge = cached?.fetchedAt ? formatCacheAge(cached.fetchedAt) : null
260
+
261
+ if (!remote) {
262
+ remote = []
263
+ const orgs = [...new Set([...(settings.githubOrgs ?? ['owdproject']), settings.githubUser].filter(Boolean))]
264
+
265
+ const promises = []
266
+
267
+ for (const org of orgs) {
268
+ for (const kind of Object.keys(KINDS)) {
269
+ promises.push(
270
+ fetchTopic(KINDS[kind].topic, org)
271
+ .then((items) => {
272
+ remote.push(...items)
273
+ })
274
+ .catch(() => {
275
+ /* offline — use local only */
276
+ })
277
+ )
278
+ }
279
+ }
280
+
281
+ if (settings.githubUser && !orgs.includes(settings.githubUser)) {
282
+ promises.push(
283
+ fetchUserRepos(settings.githubUser)
284
+ .then((items) => {
285
+ remote.push(...items)
286
+ })
287
+ .catch(() => {
288
+ /* ignore */
289
+ })
290
+ )
291
+ }
292
+
293
+ await Promise.all(promises)
294
+
295
+ if (remote.length) {
296
+ remote = annotateDiscoveryFlags(remote, previousNames, newDays)
297
+ writeCache(workspaceRoot, remote)
298
+ cacheAge = formatCacheAge(Date.now())
299
+ }
300
+ }
301
+
302
+ const byName = new Map()
303
+
304
+ for (const item of remote ?? []) {
305
+ byName.set(item.shortName, { ...item, sources: [item.org] })
306
+ }
307
+
308
+ for (const kind of Object.keys(KINDS)) {
309
+ for (const item of scanLocal(workspaceRoot, kind)) {
310
+ const prev = byName.get(item.shortName)
311
+ if (prev) {
312
+ byName.set(item.shortName, {
313
+ ...prev,
314
+ ...item,
315
+ localPath: item.localPath,
316
+ remote: item.remote ?? prev.remote,
317
+ updatedAt: item.updatedAt ?? prev.updatedAt,
318
+ pushedAt: item.pushedAt ?? prev.pushedAt,
319
+ sources: [...new Set([...(prev.sources ?? []), 'workspace'])],
320
+ })
321
+ } else {
322
+ byName.set(item.shortName, {
323
+ ...item,
324
+ sources: ['workspace'],
325
+ isNew: false,
326
+ isRecent: false,
327
+ })
328
+ }
329
+ }
330
+ }
331
+
332
+ let entries = [...byName.values()].map((entry) => ({
333
+ ...entry,
334
+ name: fullName(entry.shortName),
335
+ kind: entry.kind ?? inferKind(entry.shortName),
336
+ trusted: isTrustedPublisher(entry, settings),
337
+ }))
338
+
339
+ entries = await enrichCatalogEntries(entries, workspaceRoot, settings)
340
+
341
+ return {
342
+ entries,
343
+ cacheAge: cacheAge ?? 'live',
344
+ }
345
+ }
346
+
347
+ function updatedTimestamp(entry) {
348
+ const raw = entry.updatedAt ?? entry.pushedAt
349
+ if (!raw) return 0
350
+ const t = new Date(raw).getTime()
351
+ return Number.isFinite(t) ? t : 0
352
+ }
353
+
354
+ /**
355
+ * @param {object[]} entries
356
+ * @param {CatalogSortMode} mode
357
+ * @param {{ apps?: string[], modules?: string[], theme?: string } | null} [config]
358
+ */
359
+ export function sortCatalog(entries, mode, config = null) {
360
+ const installedApps = new Set(config?.apps ?? [])
361
+ const installedModules = new Set(config?.modules ?? [])
362
+ const activeTheme = config?.theme
363
+
364
+ const isInstalled = (entry) => {
365
+ if (entry.kind === 'app') return installedApps.has(entry.name)
366
+ if (entry.kind === 'module') return installedModules.has(entry.name)
367
+ if (entry.kind === 'theme') return activeTheme === entry.name
368
+ return false
369
+ }
370
+
371
+ const sorted = [...entries]
372
+
373
+ sorted.sort((a, b) => {
374
+ if (mode === 'installed') {
375
+ const ai = isInstalled(a) ? 1 : 0
376
+ const bi = isInstalled(b) ? 1 : 0
377
+ if (bi !== ai) return bi - ai
378
+ const du = updatedTimestamp(b) - updatedTimestamp(a)
379
+ if (du !== 0) return du
380
+ return a.shortName.localeCompare(b.shortName)
381
+ }
382
+
383
+ if (mode === 'name') {
384
+ return a.shortName.localeCompare(b.shortName)
385
+ }
386
+
387
+ if (mode === 'stars') {
388
+ return (b.stars ?? 0) - (a.stars ?? 0) || a.shortName.localeCompare(b.shortName)
389
+ }
390
+
391
+ // updated (default)
392
+ const du = updatedTimestamp(b) - updatedTimestamp(a)
393
+ if (du !== 0) return du
394
+ return a.shortName.localeCompare(b.shortName)
395
+ })
396
+
397
+ return sorted
398
+ }
399
+
400
+ /**
401
+ * @param {CatalogSortMode} mode
402
+ */
403
+ export function normalizeCatalogSort(mode) {
404
+ return CATALOG_SORT_MODES.includes(mode) ? mode : 'updated'
405
+ }
406
+
407
+ /**
408
+ * @param {object[]} catalog
409
+ * @param {'app' | 'module' | 'theme'} kind
410
+ * @param {{ sortMode?: CatalogSortMode, config?: { apps?: string[], modules?: string[], theme?: string } }} [options]
411
+ */
412
+ export function filterCatalog(catalog, kind, options = {}) {
413
+ const sortMode = normalizeCatalogSort(options.sortMode ?? 'updated')
414
+ const filtered = catalog
415
+ .filter((e) => e.kind === kind)
416
+ .filter((e) => kind !== 'module' || (isInstallableDesktopModule(e.name) && e.shortName.startsWith('module-')))
417
+
418
+ return sortCatalog(filtered, sortMode, options.config ?? null)
419
+ }
420
+
421
+ export function mergeInstalled(catalog, config, deps, workspaceRoot) {
422
+ const installedApps = new Set(config.apps ?? [])
423
+ const installedModules = new Set(config.modules ?? [])
424
+ const activeTheme = config.theme
425
+
426
+ return catalog.map((entry) => {
427
+ let installed = false
428
+ if (entry.kind === 'app') installed = installedApps.has(entry.name)
429
+ else if (entry.kind === 'module') installed = installedModules.has(entry.name)
430
+ else if (entry.kind === 'theme') installed = activeTheme === entry.name
431
+
432
+ const localSource = workspaceRoot
433
+ ? hasLocalWorkspaceSource(workspaceRoot, entry.name)
434
+ : entry.org === 'workspace'
435
+
436
+ return {
437
+ ...entry,
438
+ installed,
439
+ localSource,
440
+ inPackageJson: deps.includes(entry.name),
441
+ activeTheme: entry.kind === 'theme' && activeTheme === entry.name,
442
+ }
443
+ })
444
+ }
445
+
446
+ /** @param {string | null | undefined} iso */
447
+ export function formatCatalogAge(iso) {
448
+ if (!iso) return ''
449
+ const ms = Date.now() - new Date(iso).getTime()
450
+ if (!Number.isFinite(ms) || ms < 0) return ''
451
+ const days = Math.floor(ms / 86_400_000)
452
+ if (days < 1) return '<1d'
453
+ if (days < 7) return `${days}d`
454
+ if (days < 30) return `${Math.floor(days / 7)}w`
455
+ return `${Math.floor(days / 30)}mo`
456
+ }
457
+
458
+ export function readThemeMeta(workspaceRoot, themePkgName) {
459
+ const short = shortName(themePkgName)
460
+ const localDir = join(workspaceRoot, 'themes', short)
461
+ const pkgPath = join(localDir, 'package.json')
462
+
463
+ if (!existsSync(pkgPath)) {
464
+ return {
465
+ name: themePkgName,
466
+ description: 'Theme package not in workspace',
467
+ version: '—',
468
+ author: '—',
469
+ remote: null,
470
+ license: '—',
471
+ }
472
+ }
473
+
474
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
475
+ let remote = null
476
+ try {
477
+ remote = execSync(`git -C ${localDir} remote get-url origin`, {
478
+ encoding: 'utf8',
479
+ stdio: ['ignore', 'pipe', 'ignore'],
480
+ }).trim()
481
+ } catch {
482
+ remote = pkg.repository?.url ?? pkg.homepage ?? null
483
+ }
484
+
485
+ const author =
486
+ typeof pkg.author === 'string'
487
+ ? pkg.author
488
+ : pkg.author?.name
489
+ ? `${pkg.author.name}${pkg.author.url ? ` · ${pkg.author.url}` : ''}`
490
+ : '—'
491
+
492
+ return {
493
+ name: pkg.name,
494
+ description: pkg.description ?? '',
495
+ version: pkg.version ?? '—',
496
+ author,
497
+ license: pkg.license ?? 'MIT',
498
+ remote,
499
+ homepage: pkg.homepage ?? null,
500
+ }
501
+ }
@@ -0,0 +1,130 @@
1
+ import { readFileSync, existsSync, copyFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { join } from 'node:path'
4
+ import { SCOPE } from './workspace.js'
5
+ import { LEGACY_CONFIG_DEPRECATION } from './desktopConfig.js'
6
+
7
+ const require = createRequire(import.meta.url)
8
+
9
+ function loadTsMorph(workspaceRoot) {
10
+ const utilPath = join(
11
+ workspaceRoot,
12
+ 'node_modules/@owdproject/nx/dist/utils/utilConfig.js',
13
+ )
14
+ if (!existsSync(utilPath)) {
15
+ throw new Error('Missing @owdproject/nx — run pnpm install at the repo root.')
16
+ }
17
+ const { Project, SyntaxKind } = require('ts-morph')
18
+ return { Project, SyntaxKind }
19
+ }
20
+
21
+ function getConfigObject(sourceFile, SyntaxKind) {
22
+ const exportDefault = sourceFile.getExportAssignmentOrThrow((exp) => !exp.isExportEquals())
23
+ const callExpr = exportDefault.getExpressionIfKindOrThrow(SyntaxKind.CallExpression)
24
+ const args = callExpr.getArguments()
25
+ if (args.length === 0) throw new Error('defineDesktopConfig({}) is empty')
26
+ return args[0].asKindOrThrow(SyntaxKind.ObjectLiteralExpression)
27
+ }
28
+
29
+ function readArrayProperty(configObj, key, SyntaxKind) {
30
+ const prop = configObj.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment)
31
+ if (!prop) return []
32
+ const init = prop.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression)
33
+ if (!init) return []
34
+ return init
35
+ .getElements()
36
+ .map((el) => el.getText().trim().replace(/['"`]/g, ''))
37
+ .filter(Boolean)
38
+ }
39
+
40
+ function readTheme(configObj, SyntaxKind) {
41
+ const prop = configObj.getProperty('theme')?.asKind(SyntaxKind.PropertyAssignment)
42
+ if (!prop) return null
43
+ return prop.getInitializer()?.getText().trim().replace(/['"`]/g, '') ?? null
44
+ }
45
+
46
+ function setArrayProperty(configObj, key, values, SyntaxKind) {
47
+ const quoted = values.map((v) => `'${v}'`).join(', ')
48
+ const initializer = `[ ${quoted} ]`
49
+ const existing = configObj.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment)
50
+ if (existing) {
51
+ existing.setInitializer(initializer)
52
+ } else {
53
+ configObj.addPropertyAssignment({ name: key, initializer })
54
+ }
55
+ }
56
+
57
+ export function readDesktopConfig(configPath, workspaceRoot) {
58
+ const { Project, SyntaxKind } = loadTsMorph(workspaceRoot)
59
+ const project = new Project()
60
+ const sourceFile = project.addSourceFileAtPath(configPath)
61
+ const configObj = getConfigObject(sourceFile, SyntaxKind)
62
+
63
+ return {
64
+ theme: readTheme(configObj, SyntaxKind),
65
+ apps: readArrayProperty(configObj, 'apps', SyntaxKind),
66
+ modules: readArrayProperty(configObj, 'modules', SyntaxKind),
67
+ }
68
+ }
69
+
70
+ /**
71
+ * When saving from the TUI, always target desktop.config.ts (migrate from legacy on first save).
72
+ *
73
+ * @param {{ config: string, configWrite: string }} paths
74
+ */
75
+ export function resolveConfigPathForWrite(paths) {
76
+ if (paths.config === paths.configWrite) {
77
+ return paths.configWrite
78
+ }
79
+ if (!existsSync(paths.configWrite) && existsSync(paths.config)) {
80
+ copyFileSync(paths.config, paths.configWrite)
81
+ console.warn(LEGACY_CONFIG_DEPRECATION)
82
+ }
83
+ return paths.configWrite
84
+ }
85
+
86
+ export function writeDesktopConfig(configPath, workspaceRoot, state) {
87
+ const { Project, SyntaxKind } = loadTsMorph(workspaceRoot)
88
+ const project = new Project()
89
+ const sourceFile = project.addSourceFileAtPath(configPath)
90
+ const configObj = getConfigObject(sourceFile, SyntaxKind)
91
+
92
+ if (state.theme) {
93
+ const themeProp = configObj.getProperty('theme')?.asKind(SyntaxKind.PropertyAssignment)
94
+ if (themeProp) themeProp.setInitializer(`'${state.theme}'`)
95
+ else configObj.addPropertyAssignment({ name: 'theme', initializer: `'${state.theme}'` })
96
+ }
97
+
98
+ if (state.apps) setArrayProperty(configObj, 'apps', state.apps, SyntaxKind)
99
+ if (state.modules) setArrayProperty(configObj, 'modules', state.modules, SyntaxKind)
100
+
101
+ sourceFile.saveSync()
102
+ }
103
+
104
+ export function readDesktopDependencies(packageJsonPath) {
105
+ if (!existsSync(packageJsonPath)) return []
106
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
107
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
108
+ return Object.keys(deps).filter((name) => name.startsWith(SCOPE))
109
+ }
110
+
111
+ export function writeDesktopDependencies(packageJsonPath, toAdd, toRemove) {
112
+ if (!existsSync(packageJsonPath)) return
113
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
114
+ if (!pkg.dependencies) pkg.dependencies = {}
115
+
116
+ // Add dependencies
117
+ for (const [name, version] of Object.entries(toAdd)) {
118
+ pkg.dependencies[name] = version
119
+ }
120
+
121
+ // Remove dependencies
122
+ for (const name of toRemove) {
123
+ delete pkg.dependencies[name]
124
+ if (pkg.devDependencies) {
125
+ delete pkg.devDependencies[name]
126
+ }
127
+ }
128
+
129
+ writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n')
130
+ }
@@ -0,0 +1,39 @@
1
+ import spinners from 'cli-spinners'
2
+ import logSymbols from 'log-symbols'
3
+
4
+ /** Same spinner family as ora (dots). */
5
+ const DOTS = spinners.dots.frames
6
+
7
+ export const spinnerFrameCount = DOTS.length
8
+
9
+ /**
10
+ * @param {'info' | 'ok' | 'error'} [tone]
11
+ */
12
+ export function statusPrefix(tone = 'info') {
13
+ if (tone === 'ok') return `${logSymbols.success} `
14
+ if (tone === 'error') return `${logSymbols.error} `
15
+ return ''
16
+ }
17
+
18
+ /**
19
+ * @param {number} frame
20
+ */
21
+ export function radarSpinner(frame) {
22
+ return DOTS[frame % DOTS.length]
23
+ }
24
+
25
+ /**
26
+ * @param {number} step
27
+ * @param {number} total
28
+ * @param {number} _frame
29
+ * @param {number} [width]
30
+ */
31
+ export function progressTrack(step, total, _frame, width = 16) {
32
+ if (total <= 0) return '░'.repeat(width)
33
+ const ratio = Math.min(1, step / total)
34
+ const filled = Math.round(ratio * width)
35
+ if (filled >= width) return '█'.repeat(width)
36
+ const before = '█'.repeat(filled)
37
+ const after = '░'.repeat(width - filled)
38
+ return `${before}${after}`
39
+ }