@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.
- package/LICENSE +20 -0
- package/README.md +50 -16
- package/bin/bridge.js +129 -0
- package/bin/cli.js +491 -0
- package/bin/cp.js +3542 -0
- package/bin/desktop.js +5 -0
- package/bin/lib/catalog.js +504 -0
- package/bin/lib/config.js +173 -0
- package/bin/lib/cpAscii.js +39 -0
- package/bin/lib/cpFormat.js +343 -0
- package/bin/lib/create.js +246 -0
- package/bin/lib/desktopConfig.js +48 -0
- package/bin/lib/generateTemplate.js +427 -0
- package/bin/lib/install.js +521 -0
- package/bin/lib/installWizardFlow.js +792 -0
- package/bin/lib/migrateModulePlayground.config.mjs +187 -0
- package/bin/lib/migrateModulePlayground.mjs +318 -0
- package/bin/lib/npmVersions.js +124 -0
- package/bin/lib/packageSources.js +375 -0
- package/bin/lib/playgroundContext.js +188 -0
- package/bin/lib/scaffold.js +205 -0
- package/bin/lib/status.js +319 -0
- package/bin/lib/validateModule.js +978 -0
- package/bin/lib/workspace.js +289 -0
- package/bin/owd.js +2 -118
- package/go/bridge/bridge.go +220 -0
- package/go/desktop-go +0 -0
- package/go/go.mod +33 -0
- package/go/go.sum +52 -0
- package/go/main.go +34 -0
- package/go/owd-cli +0 -0
- package/go/tui/engine.go +472 -0
- package/go/tui/engine_test.go +40 -0
- package/go/tui/handlers.go +604 -0
- package/go/tui/helpers.go +916 -0
- package/go/tui/helpers_test.go +97 -0
- package/go/tui/modals.go +423 -0
- package/go/tui/resolver.go +377 -0
- package/go/tui/resolver_test.go +86 -0
- package/go/tui/styles.go +153 -0
- package/go/tui/tasks.go +850 -0
- package/go/tui/tui.go +869 -0
- package/go/tui/tui_test.go +69 -0
- package/go/tui/types.go +230 -0
- package/go/tui/view.go +812 -0
- package/go/tui/workqueue.go +179 -0
- package/go/tui/workqueue_test.go +90 -0
- package/package.json +33 -22
- package/src/commands/create.js +0 -11
- package/src/commands/generate.js +0 -12
- package/src/commands/installApp.js +0 -9
- package/src/commands/installTheme.js +0 -9
- package/src/commands/start.js +0 -13
- package/src/utils/cloneRepo.js +0 -25
- package/src/utils/installPackage.js +0 -15
- package/src/utils/logging.js +0 -15
package/bin/desktop.js
ADDED
|
@@ -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
|
+
}
|