@owdproject/cli 0.0.3 → 0.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 (56) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +50 -16
  3. package/bin/bridge.js +129 -0
  4. package/bin/cli.js +491 -0
  5. package/bin/cp.js +3542 -0
  6. package/bin/desktop.js +5 -0
  7. package/bin/lib/catalog.js +504 -0
  8. package/bin/lib/config.js +173 -0
  9. package/bin/lib/cpAscii.js +39 -0
  10. package/bin/lib/cpFormat.js +343 -0
  11. package/bin/lib/create.js +246 -0
  12. package/bin/lib/desktopConfig.js +48 -0
  13. package/bin/lib/generateTemplate.js +427 -0
  14. package/bin/lib/install.js +521 -0
  15. package/bin/lib/installWizardFlow.js +792 -0
  16. package/bin/lib/migrateModulePlayground.config.mjs +187 -0
  17. package/bin/lib/migrateModulePlayground.mjs +318 -0
  18. package/bin/lib/npmVersions.js +124 -0
  19. package/bin/lib/packageSources.js +375 -0
  20. package/bin/lib/playgroundContext.js +188 -0
  21. package/bin/lib/scaffold.js +205 -0
  22. package/bin/lib/status.js +319 -0
  23. package/bin/lib/validateModule.js +978 -0
  24. package/bin/lib/workspace.js +289 -0
  25. package/bin/owd.js +2 -118
  26. package/go/bridge/bridge.go +220 -0
  27. package/go/desktop-go +0 -0
  28. package/go/go.mod +33 -0
  29. package/go/go.sum +52 -0
  30. package/go/main.go +34 -0
  31. package/go/owd-cli +0 -0
  32. package/go/tui/engine.go +472 -0
  33. package/go/tui/engine_test.go +40 -0
  34. package/go/tui/handlers.go +604 -0
  35. package/go/tui/helpers.go +916 -0
  36. package/go/tui/helpers_test.go +97 -0
  37. package/go/tui/modals.go +423 -0
  38. package/go/tui/resolver.go +377 -0
  39. package/go/tui/resolver_test.go +86 -0
  40. package/go/tui/styles.go +153 -0
  41. package/go/tui/tasks.go +850 -0
  42. package/go/tui/tui.go +869 -0
  43. package/go/tui/tui_test.go +69 -0
  44. package/go/tui/types.go +230 -0
  45. package/go/tui/view.go +812 -0
  46. package/go/tui/workqueue.go +179 -0
  47. package/go/tui/workqueue_test.go +90 -0
  48. package/package.json +33 -22
  49. package/src/commands/create.js +0 -11
  50. package/src/commands/generate.js +0 -12
  51. package/src/commands/installApp.js +0 -9
  52. package/src/commands/installTheme.js +0 -9
  53. package/src/commands/start.js +0 -13
  54. package/src/utils/cloneRepo.js +0 -25
  55. package/src/utils/installPackage.js +0 -15
  56. package/src/utils/logging.js +0 -15
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,504 @@
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
+ stars: prev.stars ?? 0, // preserve GitHub stars — scanLocal always sets 0
316
+ localPath: item.localPath,
317
+ remote: item.remote ?? prev.remote,
318
+ updatedAt: item.updatedAt ?? prev.updatedAt,
319
+ pushedAt: item.pushedAt ?? prev.pushedAt,
320
+ sources: [...new Set([...(prev.sources ?? []), 'workspace'])],
321
+ })
322
+ } else {
323
+ byName.set(item.shortName, {
324
+ ...item,
325
+ sources: ['workspace'],
326
+ isNew: false,
327
+ isRecent: false,
328
+ })
329
+ }
330
+ }
331
+ }
332
+
333
+ let entries = [...byName.values()].map((entry) => ({
334
+ ...entry,
335
+ name: fullName(entry.shortName),
336
+ kind: entry.kind ?? inferKind(entry.shortName),
337
+ trusted: isTrustedPublisher(entry, settings),
338
+ }))
339
+
340
+ entries = entries.filter((e) => !e.name.includes('-template') && !e.shortName.includes('-template'))
341
+
342
+ entries = await enrichCatalogEntries(entries, workspaceRoot, settings)
343
+
344
+ return {
345
+ entries,
346
+ cacheAge: cacheAge ?? 'live',
347
+ }
348
+ }
349
+
350
+ function updatedTimestamp(entry) {
351
+ const raw = entry.updatedAt ?? entry.pushedAt
352
+ if (!raw) return 0
353
+ const t = new Date(raw).getTime()
354
+ return Number.isFinite(t) ? t : 0
355
+ }
356
+
357
+ /**
358
+ * @param {object[]} entries
359
+ * @param {CatalogSortMode} mode
360
+ * @param {{ apps?: string[], modules?: string[], theme?: string } | null} [config]
361
+ */
362
+ export function sortCatalog(entries, mode, config = null) {
363
+ const installedApps = new Set(config?.apps ?? [])
364
+ const installedModules = new Set(config?.modules ?? [])
365
+ const activeTheme = config?.theme
366
+
367
+ const isInstalled = (entry) => {
368
+ if (entry.kind === 'app') return installedApps.has(entry.name)
369
+ if (entry.kind === 'module') return installedModules.has(entry.name)
370
+ if (entry.kind === 'theme') return activeTheme === entry.name
371
+ return false
372
+ }
373
+
374
+ const sorted = [...entries]
375
+
376
+ sorted.sort((a, b) => {
377
+ if (mode === 'installed') {
378
+ const ai = isInstalled(a) ? 1 : 0
379
+ const bi = isInstalled(b) ? 1 : 0
380
+ if (bi !== ai) return bi - ai
381
+ const du = updatedTimestamp(b) - updatedTimestamp(a)
382
+ if (du !== 0) return du
383
+ return a.shortName.localeCompare(b.shortName)
384
+ }
385
+
386
+ if (mode === 'name') {
387
+ return a.shortName.localeCompare(b.shortName)
388
+ }
389
+
390
+ if (mode === 'stars') {
391
+ return (b.stars ?? 0) - (a.stars ?? 0) || a.shortName.localeCompare(b.shortName)
392
+ }
393
+
394
+ // updated (default)
395
+ const du = updatedTimestamp(b) - updatedTimestamp(a)
396
+ if (du !== 0) return du
397
+ return a.shortName.localeCompare(b.shortName)
398
+ })
399
+
400
+ return sorted
401
+ }
402
+
403
+ /**
404
+ * @param {CatalogSortMode} mode
405
+ */
406
+ export function normalizeCatalogSort(mode) {
407
+ return CATALOG_SORT_MODES.includes(mode) ? mode : 'updated'
408
+ }
409
+
410
+ /**
411
+ * @param {object[]} catalog
412
+ * @param {'app' | 'module' | 'theme'} kind
413
+ * @param {{ sortMode?: CatalogSortMode, config?: { apps?: string[], modules?: string[], theme?: string } }} [options]
414
+ */
415
+ export function filterCatalog(catalog, kind, options = {}) {
416
+ const sortMode = normalizeCatalogSort(options.sortMode ?? 'updated')
417
+ const filtered = catalog
418
+ .filter((e) => e.kind === kind)
419
+ .filter((e) => kind !== 'module' || (isInstallableDesktopModule(e.name) && e.shortName.startsWith('module-')))
420
+
421
+ return sortCatalog(filtered, sortMode, options.config ?? null)
422
+ }
423
+
424
+ export function mergeInstalled(catalog, config, deps, workspaceRoot) {
425
+ const installedApps = new Set(config.apps ?? [])
426
+ const installedModules = new Set(config.modules ?? [])
427
+ const activeTheme = config.theme
428
+
429
+ return catalog.map((entry) => {
430
+ let installed = false
431
+ if (entry.kind === 'app') installed = installedApps.has(entry.name)
432
+ else if (entry.kind === 'module') installed = installedModules.has(entry.name)
433
+ else if (entry.kind === 'theme') installed = activeTheme === entry.name
434
+
435
+ const localSource = workspaceRoot
436
+ ? hasLocalWorkspaceSource(workspaceRoot, entry.name)
437
+ : entry.org === 'workspace'
438
+
439
+ return {
440
+ ...entry,
441
+ installed,
442
+ localSource,
443
+ inPackageJson: deps.includes(entry.name),
444
+ activeTheme: entry.kind === 'theme' && activeTheme === entry.name,
445
+ }
446
+ })
447
+ }
448
+
449
+ /** @param {string | null | undefined} iso */
450
+ export function formatCatalogAge(iso) {
451
+ if (!iso) return ''
452
+ const ms = Date.now() - new Date(iso).getTime()
453
+ if (!Number.isFinite(ms) || ms < 0) return ''
454
+ const days = Math.floor(ms / 86_400_000)
455
+ if (days < 1) return '<1d'
456
+ if (days < 7) return `${days}d`
457
+ if (days < 30) return `${Math.floor(days / 7)}w`
458
+ return `${Math.floor(days / 30)}mo`
459
+ }
460
+
461
+ export function readThemeMeta(workspaceRoot, themePkgName) {
462
+ const short = shortName(themePkgName)
463
+ const localDir = join(workspaceRoot, 'themes', short)
464
+ const pkgPath = join(localDir, 'package.json')
465
+
466
+ if (!existsSync(pkgPath)) {
467
+ return {
468
+ name: themePkgName,
469
+ description: 'Theme package not in workspace',
470
+ version: '—',
471
+ author: '—',
472
+ remote: null,
473
+ license: '—',
474
+ }
475
+ }
476
+
477
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
478
+ let remote = null
479
+ try {
480
+ remote = execSync(`git -C ${localDir} remote get-url origin`, {
481
+ encoding: 'utf8',
482
+ stdio: ['ignore', 'pipe', 'ignore'],
483
+ }).trim()
484
+ } catch {
485
+ remote = pkg.repository?.url ?? pkg.homepage ?? null
486
+ }
487
+
488
+ const author =
489
+ typeof pkg.author === 'string'
490
+ ? pkg.author
491
+ : pkg.author?.name
492
+ ? `${pkg.author.name}${pkg.author.url ? ` · ${pkg.author.url}` : ''}`
493
+ : '—'
494
+
495
+ return {
496
+ name: pkg.name,
497
+ description: pkg.description ?? '',
498
+ version: pkg.version ?? '—',
499
+ author,
500
+ license: pkg.license ?? 'MIT',
501
+ remote,
502
+ homepage: pkg.homepage ?? null,
503
+ }
504
+ }
@@ -0,0 +1,173 @@
1
+ import { readFileSync, writeFileSync, 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 { Project, SyntaxKind } = require('ts-morph')
11
+ return { Project, SyntaxKind }
12
+ }
13
+
14
+ function getConfigObject(sourceFile, SyntaxKind) {
15
+ const exportDefault = sourceFile.getExportAssignmentOrThrow((exp) => !exp.isExportEquals())
16
+ const callExpr = exportDefault.getExpressionIfKindOrThrow(SyntaxKind.CallExpression)
17
+ const args = callExpr.getArguments()
18
+ if (args.length === 0) throw new Error('defineDesktopConfig({}) is empty')
19
+ return args[0].asKindOrThrow(SyntaxKind.ObjectLiteralExpression)
20
+ }
21
+
22
+ function readArrayProperty(configObj, key, SyntaxKind) {
23
+ const prop = configObj.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment)
24
+ if (!prop) return []
25
+ const init = prop.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression)
26
+ if (!init) return []
27
+ return init
28
+ .getElements()
29
+ .map((el) => el.getText().trim().replace(/['"`]/g, ''))
30
+ .filter(Boolean)
31
+ }
32
+
33
+ function readTheme(configObj, SyntaxKind) {
34
+ const prop = configObj.getProperty('theme')?.asKind(SyntaxKind.PropertyAssignment)
35
+ if (!prop) return null
36
+ return prop.getInitializer()?.getText().trim().replace(/['"`]/g, '') ?? null
37
+ }
38
+
39
+ function setArrayProperty(configObj, key, values, SyntaxKind) {
40
+ const quoted = values.map((v) => `'${v}'`).join(', ')
41
+ const initializer = `[ ${quoted} ]`
42
+ const existing = configObj.getProperty(key)?.asKind(SyntaxKind.PropertyAssignment)
43
+ if (existing) {
44
+ existing.setInitializer(initializer)
45
+ } else {
46
+ configObj.addPropertyAssignment({ name: key, initializer })
47
+ }
48
+ }
49
+
50
+ export function readDesktopConfig(configPath, workspaceRoot) {
51
+ const { Project, SyntaxKind } = loadTsMorph(workspaceRoot)
52
+ const project = new Project()
53
+ const sourceFile = project.addSourceFileAtPath(configPath)
54
+ const configObj = getConfigObject(sourceFile, SyntaxKind)
55
+
56
+ return {
57
+ theme: readTheme(configObj, SyntaxKind),
58
+ apps: readArrayProperty(configObj, 'apps', SyntaxKind),
59
+ modules: readArrayProperty(configObj, 'modules', SyntaxKind),
60
+ }
61
+ }
62
+
63
+ /**
64
+ * When saving from the TUI, always target desktop.config.ts (migrate from legacy on first save).
65
+ *
66
+ * @param {{ config: string, configWrite: string }} paths
67
+ */
68
+ export function resolveConfigPathForWrite(paths) {
69
+ if (paths.config === paths.configWrite) {
70
+ return paths.configWrite
71
+ }
72
+ if (!existsSync(paths.configWrite) && existsSync(paths.config)) {
73
+ copyFileSync(paths.config, paths.configWrite)
74
+ console.warn(LEGACY_CONFIG_DEPRECATION)
75
+ }
76
+ return paths.configWrite
77
+ }
78
+
79
+ export function writeDesktopConfig(configPath, workspaceRoot, state) {
80
+ const { Project, SyntaxKind } = loadTsMorph(workspaceRoot)
81
+ const project = new Project()
82
+ const sourceFile = project.addSourceFileAtPath(configPath)
83
+ const configObj = getConfigObject(sourceFile, SyntaxKind)
84
+
85
+ if (state.theme) {
86
+ const themeProp = configObj.getProperty('theme')?.asKind(SyntaxKind.PropertyAssignment)
87
+ if (themeProp) themeProp.setInitializer(`'${state.theme}'`)
88
+ else configObj.addPropertyAssignment({ name: 'theme', initializer: `'${state.theme}'` })
89
+ }
90
+
91
+ if (state.apps) setArrayProperty(configObj, 'apps', state.apps, SyntaxKind)
92
+ if (state.modules) setArrayProperty(configObj, 'modules', state.modules, SyntaxKind)
93
+
94
+ sourceFile.saveSync()
95
+ }
96
+
97
+ export function readDesktopDependencies(packageJsonPath) {
98
+ if (!existsSync(packageJsonPath)) return []
99
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
100
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies }
101
+ return Object.keys(deps).filter((name) => name.startsWith(SCOPE))
102
+ }
103
+
104
+ export function writeDesktopDependencies(packageJsonPath, toAdd, toRemove) {
105
+ if (!existsSync(packageJsonPath)) return
106
+ const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
107
+ if (!pkg.dependencies) pkg.dependencies = {}
108
+
109
+ // Add dependencies
110
+ for (const [name, version] of Object.entries(toAdd)) {
111
+ pkg.dependencies[name] = version
112
+ }
113
+
114
+ // Remove dependencies
115
+ for (const name of toRemove) {
116
+ delete pkg.dependencies[name]
117
+ if (pkg.devDependencies) {
118
+ delete pkg.devDependencies[name]
119
+ }
120
+ }
121
+
122
+ writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n')
123
+ }
124
+
125
+ export function addToDesktopConfig(configPath, workspaceRoot, key, value) {
126
+ const { Project, SyntaxKind } = loadTsMorph(workspaceRoot)
127
+ const project = new Project()
128
+ const sourceFile = project.addSourceFileAtPath(configPath)
129
+ const configObj = getConfigObject(sourceFile, SyntaxKind)
130
+
131
+ const rawProp = configObj.getProperty(key)
132
+ const targetProp = rawProp?.asKind(SyntaxKind.PropertyAssignment)
133
+
134
+ if (!targetProp) {
135
+ // if property doesn't exist, create it
136
+ configObj.addPropertyAssignment({
137
+ name: key,
138
+ initializer: key === 'theme' ? `'${value}'` : `[ '${value}' ]`,
139
+ })
140
+
141
+ sourceFile.saveSync()
142
+ console.log(`🛠️ Created '${key}' and added '${value}'`)
143
+ return
144
+ }
145
+
146
+ // handle 'theme' key — just set string literal
147
+ if (key === 'theme') {
148
+ targetProp.setInitializer(`'${value}'`)
149
+ sourceFile.saveSync()
150
+ console.log(`🎨 Theme set to '${value}'`)
151
+ return
152
+ }
153
+
154
+ // ensure initializer is an array
155
+ const initializer = targetProp.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression)
156
+ if (!initializer) {
157
+ throw new Error(`Expected '${key}' to be an array`)
158
+ }
159
+
160
+ const exists = initializer.getElements().some((el) => {
161
+ const text = el.getText().trim().replace(/['"`]/g, '')
162
+ return text === value
163
+ })
164
+
165
+ if (exists) {
166
+ console.log(`⚠️ '${value}' already exists in '${key}'`)
167
+ return
168
+ }
169
+
170
+ initializer.addElement(`'${value}'`)
171
+ sourceFile.saveSync()
172
+ console.log(`✅ Added '${value}' to '${key}'`)
173
+ }