@owdproject/cli 0.0.3 → 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,365 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { fetchLatestVersion } from './npmVersions.js'
5
+ import { desktopMetaDir, fullName, SCOPE, KINDS, inferKind } from './workspace.js'
6
+
7
+ /**
8
+ * @param {string} githubUser
9
+ * @param {string} pkgShortName
10
+ */
11
+ export async function resolveForkUser(githubUser, pkgShortName) {
12
+ if (!githubUser || githubUser === 'owdproject') return { exists: false, isFork: false }
13
+
14
+ try {
15
+ const res = await fetch(`https://api.github.com/repos/${githubUser}/${pkgShortName}`, {
16
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'owd-desktop-cli' },
17
+ })
18
+ if (res.ok) {
19
+ const data = await res.json()
20
+ // data.fork === true only if this repo was forked FROM another repo
21
+ return { exists: true, isFork: data.fork === true }
22
+ }
23
+ } catch {
24
+ /* ignore */
25
+ }
26
+
27
+ return { exists: false, isFork: false }
28
+ }
29
+
30
+ /**
31
+ * @param {string} workspaceRoot
32
+ * @param {string} pkgName
33
+ */
34
+ function hasLocalWorkspaceSource(workspaceRoot, pkgName) {
35
+ const pkgShort = pkgName.startsWith(SCOPE) ? pkgName.slice(SCOPE.length) : pkgName
36
+ const kind = inferKind(pkgShort)
37
+ return existsSync(join(workspaceRoot, KINDS[kind].workspaceDir, pkgShort, 'package.json'))
38
+ }
39
+
40
+ const CACHE_TTL_MS = 60 * 60 * 1000
41
+ const SSH_CACHE_TTL_MS = 15 * 60 * 1000
42
+
43
+ /**
44
+ * @typedef {{ type: 'npm' } | { type: 'git', protocol: 'https' | 'ssh', owner: string }} InstallSourceChoice
45
+ */
46
+
47
+ /**
48
+ * @typedef {{
49
+ * id: string
50
+ * kind: 'npm' | 'git'
51
+ * protocol?: 'https' | 'ssh'
52
+ * owner?: string
53
+ * label: string
54
+ * url: string
55
+ * choice: InstallSourceChoice
56
+ * }} SourceOption
57
+ */
58
+
59
+ function cachePath(workspaceRoot) {
60
+ return join(desktopMetaDir(workspaceRoot), 'package-sources.json')
61
+ }
62
+
63
+ function readCacheFile(workspaceRoot) {
64
+ const path = cachePath(workspaceRoot)
65
+ if (!existsSync(path)) return null
66
+ try {
67
+ return JSON.parse(readFileSync(path, 'utf8'))
68
+ } catch {
69
+ return null
70
+ }
71
+ }
72
+
73
+ function writeCacheFile(workspaceRoot, data) {
74
+ mkdirSync(desktopMetaDir(workspaceRoot), { recursive: true })
75
+ writeFileSync(cachePath(workspaceRoot), JSON.stringify(data, null, 2) + '\n')
76
+ }
77
+
78
+ /**
79
+ * @param {string[] | undefined} githubOrgs
80
+ * @param {string | null | undefined} githubUser
81
+ */
82
+ export function trustedPublishers(githubOrgs, githubUser) {
83
+ return [...new Set([...(githubOrgs ?? ['owdproject']), githubUser].filter(Boolean))]
84
+ }
85
+
86
+ /**
87
+ * @param {{ org?: string }} entry
88
+ * @param {{ githubOrgs?: string[], githubUser?: string | null }} settings
89
+ */
90
+ export function isTrustedPublisher(entry, settings) {
91
+ if (!entry.org || entry.org === 'workspace') return true
92
+ return trustedPublishers(settings.githubOrgs, settings.githubUser).includes(entry.org)
93
+ }
94
+
95
+ /**
96
+ * @param {string} owner
97
+ * @param {string} shortName
98
+ * @param {'https' | 'ssh'} protocol
99
+ */
100
+ export function githubCloneUrl(owner, shortName, protocol) {
101
+ return protocol === 'ssh'
102
+ ? `git@github.com:${owner}/${shortName}.git`
103
+ : `https://github.com/${owner}/${shortName}.git`
104
+ }
105
+
106
+ export function githubHtmlUrl(owner, shortName) {
107
+ return `https://github.com/${owner}/${shortName}`
108
+ }
109
+
110
+ export function npmPackageUrl(pkgName) {
111
+ const short = pkgName.startsWith(SCOPE) ? pkgName.slice(SCOPE.length) : pkgName
112
+ return `https://www.npmjs.com/package/${SCOPE}${short}`
113
+ }
114
+
115
+ /**
116
+ * @param {string} workspaceRoot
117
+ * @param {{ force?: boolean }} [options]
118
+ */
119
+ export async function detectGithubSshAuth(workspaceRoot, options = {}) {
120
+ const cache = readCacheFile(workspaceRoot)
121
+ const cached = cache?.sshAuth
122
+ if (!options.force && cached?.checkedAt && Date.now() - cached.checkedAt < SSH_CACHE_TTL_MS) {
123
+ return cached
124
+ }
125
+
126
+ /** @type {{ checkedAt: number, available: boolean, githubLogin: string | null, message: string }} */
127
+ let result = {
128
+ checkedAt: Date.now(),
129
+ available: false,
130
+ githubLogin: null,
131
+ message: 'SSH not configured',
132
+ }
133
+
134
+ try {
135
+ const out = execSync(
136
+ 'ssh -T -o BatchMode=yes -o StrictHostKeyChecking=accept-new git@github.com 2>&1',
137
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 8000 },
138
+ )
139
+ const text = out || ''
140
+ const login = text.match(/Hi ([^!]+)!/)?.[1]?.trim() ?? null
141
+ if (login) {
142
+ result = {
143
+ checkedAt: Date.now(),
144
+ available: true,
145
+ githubLogin: login,
146
+ message: `authenticated as ${login}`,
147
+ }
148
+ }
149
+ } catch (error) {
150
+ const text = `${error.stdout ?? ''}${error.stderr ?? ''}${error.message ?? ''}`
151
+ const login = text.match(/Hi ([^!]+)!/)?.[1]?.trim() ?? null
152
+ if (login) {
153
+ result = {
154
+ checkedAt: Date.now(),
155
+ available: true,
156
+ githubLogin: login,
157
+ message: `authenticated as ${login}`,
158
+ }
159
+ } else if (/Permission denied|publickey/i.test(text)) {
160
+ result = {
161
+ checkedAt: Date.now(),
162
+ available: false,
163
+ githubLogin: null,
164
+ message: 'SSH keys not accepted by GitHub',
165
+ }
166
+ } else {
167
+ result = {
168
+ checkedAt: Date.now(),
169
+ available: false,
170
+ githubLogin: null,
171
+ message: 'SSH probe failed',
172
+ }
173
+ }
174
+ }
175
+
176
+ const next = { ...(cache ?? { fetchedAt: 0, packages: {} }), sshAuth: result }
177
+ writeCacheFile(workspaceRoot, next)
178
+ return result
179
+ }
180
+
181
+ /**
182
+ * @param {string} workspaceRoot
183
+ */
184
+ export function getCachedSshAuth(workspaceRoot) {
185
+ const cache = readCacheFile(workspaceRoot)
186
+ return cache?.sshAuth ?? null
187
+ }
188
+
189
+ /**
190
+ * @param {string} shortName
191
+ * @param {{ htmlUrl?: string | null, org?: string }} [catalogEntry]
192
+ * @param {{ githubUser?: string | null }} settings
193
+ */
194
+ export async function resolvePackageSources(shortName, settings, workspaceRoot, catalogEntry = {}) {
195
+ const pkgName = fullName(shortName)
196
+ const officialOwner = catalogEntry.org && catalogEntry.org !== 'workspace' ? catalogEntry.org : 'owdproject'
197
+ const officialHtml =
198
+ catalogEntry.htmlUrl ?? githubHtmlUrl(officialOwner, shortName)
199
+
200
+ const cache = readCacheFile(workspaceRoot)
201
+ const cachedPkg = cache?.packages?.[shortName]
202
+ const cacheFresh = cachedPkg && cache?.fetchedAt && Date.now() - cache.fetchedAt < CACHE_TTL_MS
203
+
204
+ let npm = cachedPkg?.npm ?? null
205
+ if (!cacheFresh || npm === undefined) {
206
+ const version = fetchLatestVersion(pkgName, { optional: true })
207
+ npm = version ? { version } : null
208
+ }
209
+
210
+ let forkOwner = settings.githubUser && settings.githubUser !== 'owdproject' ? settings.githubUser : null
211
+ let forkExists = false
212
+ let forkIsActualFork = false
213
+ if (forkOwner) {
214
+ const result = await resolveForkUser(forkOwner, shortName)
215
+ forkExists = result.exists
216
+ forkIsActualFork = result.isFork
217
+ }
218
+
219
+ const local = workspaceRoot && hasLocalWorkspaceSource(workspaceRoot, pkgName)
220
+
221
+ const metadata = {
222
+ shortName,
223
+ pkgName,
224
+ npm,
225
+ github: {
226
+ official: {
227
+ owner: officialOwner,
228
+ htmlUrl: officialHtml,
229
+ exists: Boolean(catalogEntry.htmlUrl || officialOwner),
230
+ },
231
+ fork: forkOwner && forkExists
232
+ ? { owner: forkOwner, exists: forkExists, isFork: forkIsActualFork, htmlUrl: githubHtmlUrl(forkOwner, shortName) }
233
+ : null,
234
+ },
235
+ local: local ? true : false,
236
+ }
237
+
238
+ const packages = { ...(cache?.packages ?? {}), [shortName]: { npm, github: metadata.github } }
239
+ writeCacheFile(workspaceRoot, {
240
+ fetchedAt: Date.now(),
241
+ sshAuth: cache?.sshAuth ?? null,
242
+ packages,
243
+ })
244
+
245
+ return metadata
246
+ }
247
+
248
+ /**
249
+ * @param {Awaited<ReturnType<typeof resolvePackageSources>>} metadata
250
+ * @param {{ githubUser?: string | null }} settings
251
+ * @param {{ available?: boolean }} sshAuth
252
+ * @returns {SourceOption[]}
253
+ */
254
+ export function buildSourceOptions(metadata, settings, sshAuth = {}) {
255
+ /** @type {SourceOption[]} */
256
+ const options = []
257
+
258
+ const official = metadata.github.official
259
+ if (official?.owner) {
260
+ options.push({
261
+ id: 'git-https-official',
262
+ kind: 'git',
263
+ protocol: 'https',
264
+ owner: official.owner,
265
+ label: `GitHub HTTPS — ${official.owner}/${metadata.shortName}`,
266
+ url: githubCloneUrl(official.owner, metadata.shortName, 'https'),
267
+ choice: { type: 'git', protocol: 'https', owner: official.owner },
268
+ })
269
+ if (sshAuth.available) {
270
+ options.push({
271
+ id: 'git-ssh-official',
272
+ kind: 'git',
273
+ protocol: 'ssh',
274
+ owner: official.owner,
275
+ label: `GitHub SSH — ${official.owner}/${metadata.shortName}`,
276
+ url: githubCloneUrl(official.owner, metadata.shortName, 'ssh'),
277
+ choice: { type: 'git', protocol: 'ssh', owner: official.owner },
278
+ })
279
+ }
280
+ }
281
+
282
+ const fork = metadata.github.fork
283
+ const officialOwnerLower = official?.owner?.toLowerCase()
284
+ const forkOwnerLower = fork?.owner?.toLowerCase()
285
+ const userLower = settings.githubUser?.toLowerCase()
286
+ // Skip fork options when fork owner is the same as the official repo owner
287
+ // (the official options already cover that user)
288
+ const forkIsSameAsOfficial = forkOwnerLower && forkOwnerLower === officialOwnerLower
289
+ const forkMissingIsSameAsOfficial = userLower && userLower === officialOwnerLower
290
+
291
+ if (fork?.exists && fork.owner && !forkIsSameAsOfficial) {
292
+ options.push({
293
+ id: 'git-https-fork',
294
+ kind: 'git',
295
+ protocol: 'https',
296
+ owner: fork.owner,
297
+ label: `GitHub HTTPS — ${fork.owner}/${metadata.shortName} (your fork)`,
298
+ url: githubCloneUrl(fork.owner, metadata.shortName, 'https'),
299
+ choice: { type: 'git', protocol: 'https', owner: fork.owner },
300
+ })
301
+ if (sshAuth.available) {
302
+ options.push({
303
+ id: 'git-ssh-fork',
304
+ kind: 'git',
305
+ protocol: 'ssh',
306
+ owner: fork.owner,
307
+ label: `GitHub SSH — ${fork.owner}/${metadata.shortName} (your fork)`,
308
+ url: githubCloneUrl(fork.owner, metadata.shortName, 'ssh'),
309
+ choice: { type: 'git', protocol: 'ssh', owner: fork.owner },
310
+ })
311
+ }
312
+ }
313
+
314
+ if (metadata.npm?.version) {
315
+ options.push({
316
+ id: 'npm',
317
+ kind: 'npm',
318
+ label: `npm — ${metadata.pkgName}@${metadata.npm.version}`,
319
+ url: npmPackageUrl(metadata.pkgName),
320
+ choice: { type: 'npm' },
321
+ })
322
+ }
323
+
324
+ return options
325
+ }
326
+
327
+ /**
328
+ * @param {object[]} entries
329
+ * @param {string} workspaceRoot
330
+ * @param {{ githubOrgs?: string[], githubUser?: string | null }} settings
331
+ */
332
+ export async function enrichCatalogEntries(entries, workspaceRoot, settings) {
333
+ const cache = readCacheFile(workspaceRoot)
334
+ const sshAuth = getCachedSshAuth(workspaceRoot) ?? { available: false }
335
+
336
+ return entries.map((entry) => {
337
+ const pkgMeta = cache?.packages?.[entry.shortName]
338
+ return {
339
+ ...entry,
340
+ trusted: isTrustedPublisher(entry, settings),
341
+ sourcesMeta: {
342
+ npm: pkgMeta?.npm ?? null,
343
+ github: {
344
+ official: {
345
+ owner: entry.org && entry.org !== 'workspace' ? entry.org : 'owdproject',
346
+ htmlUrl: entry.htmlUrl ?? githubHtmlUrl(entry.org ?? 'owdproject', entry.shortName),
347
+ },
348
+ fork: pkgMeta?.github?.fork ?? null,
349
+ },
350
+ },
351
+ }
352
+ })
353
+ }
354
+
355
+ /**
356
+ * @param {{ sourcesMeta?: { npm?: { version?: string } | null }, localSource?: boolean, htmlUrl?: string, trusted?: boolean }} item
357
+ */
358
+ export function formatSourceBadges(item) {
359
+ const parts = []
360
+ if (item.localSource) parts.push(`{cyan-fg}l{/}`)
361
+ if (item.sourcesMeta?.npm?.version) parts.push(`{green-fg}n{/}`)
362
+ if (item.htmlUrl || item.sourcesMeta?.github) parts.push(`{blue-fg}g{/}`)
363
+ if (item.trusted === false) parts.push(`{yellow-fg}!{/}`)
364
+ return parts.length ? ` ${parts.join('')}` : ''
365
+ }
@@ -0,0 +1,188 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, resolve, sep } from 'node:path'
3
+ import { SCOPE } from './workspace.js'
4
+
5
+ const PLAYGROUND_CONFIG = join('playground', 'desktop.config.ts')
6
+
7
+ /**
8
+ * @param {string} dir
9
+ * @returns {boolean}
10
+ */
11
+ export function hasPlaygroundDesktopConfig(dir) {
12
+ return (
13
+ existsSync(join(dir, 'playground')) &&
14
+ existsSync(join(dir, PLAYGROUND_CONFIG))
15
+ )
16
+ }
17
+
18
+ /**
19
+ * @param {string} pkgPath
20
+ * @returns {{ name: string } | null}
21
+ */
22
+ function readOwdPackageJson(pkgPath) {
23
+ if (!existsSync(pkgPath)) return null
24
+ try {
25
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
26
+ if (typeof pkg.name === 'string' && pkg.name.startsWith(SCOPE)) {
27
+ return { name: pkg.name }
28
+ }
29
+ } catch {
30
+ /* ignore */
31
+ }
32
+ return null
33
+ }
34
+
35
+ /**
36
+ * Walk up from cwd until a package.json with @owdproject/* name and a playground
37
+ * with desktop.config.ts is found.
38
+ *
39
+ * @param {string} [startDir]
40
+ * @returns {string | null} Absolute package directory
41
+ */
42
+ export function findOwdModulePackageRoot(startDir = process.cwd()) {
43
+ let dir = resolve(startDir)
44
+ for (;;) {
45
+ const meta = readOwdPackageJson(join(dir, 'package.json'))
46
+ if (meta && hasPlaygroundDesktopConfig(dir)) {
47
+ return dir
48
+ }
49
+ const parent = dirname(dir)
50
+ if (parent === dir) return null
51
+ dir = parent
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param {string} path
57
+ * @param {string} root
58
+ */
59
+ function isPathInside(path, root) {
60
+ const normalized = resolve(path)
61
+ const base = resolve(root)
62
+ return normalized === base || normalized.startsWith(base + sep)
63
+ }
64
+
65
+ /**
66
+ * @typedef {'workspace' | 'playground'} DevTargetMode
67
+ */
68
+
69
+ /**
70
+ * @typedef {object} DevTarget
71
+ * @property {DevTargetMode} mode
72
+ * @property {string} workspaceRoot
73
+ * @property {string} packageDir Directory where `pnpm run dev` runs
74
+ * @property {string | null} packageName Set when mode is playground
75
+ * @property {string | null} playgroundDir Absolute path to playground/
76
+ */
77
+
78
+ /**
79
+ * @param {string} packageDir
80
+ * @param {string} workspaceRoot
81
+ * @param {string} packageName
82
+ * @returns {DevTarget}
83
+ */
84
+ function playgroundTarget(packageDir, workspaceRoot, packageName) {
85
+ return {
86
+ mode: 'playground',
87
+ workspaceRoot,
88
+ packageDir: resolve(packageDir),
89
+ packageName,
90
+ playgroundDir: join(resolve(packageDir), 'playground'),
91
+ }
92
+ }
93
+
94
+ /**
95
+ * @param {string} workspaceRoot
96
+ * @returns {DevTarget}
97
+ */
98
+ function workspaceTarget(workspaceRoot) {
99
+ return {
100
+ mode: 'workspace',
101
+ workspaceRoot: resolve(workspaceRoot),
102
+ packageDir: resolve(workspaceRoot),
103
+ packageName: null,
104
+ playgroundDir: null,
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Resolve whether the CLI/TUI should start the monorepo desktop shell or a
110
+ * module package playground dev server.
111
+ *
112
+ * @param {string} [cwd]
113
+ * @param {string | null} workspaceRoot
114
+ * @param {{ forcePlayground?: boolean }} [options]
115
+ * @returns {DevTarget | null} null when workspaceRoot is missing
116
+ */
117
+ export function resolveDevTarget(cwd, workspaceRoot, options = {}) {
118
+ if (!workspaceRoot) return null
119
+
120
+ const normalizedCwd = resolve(cwd ?? process.cwd())
121
+ const root = resolve(workspaceRoot)
122
+ const desktopDir = join(root, 'desktop')
123
+ const packageRoot = findOwdModulePackageRoot(normalizedCwd)
124
+ const forcePlayground = options.forcePlayground === true
125
+
126
+ if (packageRoot) {
127
+ const meta = readOwdPackageJson(join(packageRoot, 'package.json'))
128
+ const packageName = meta?.name ?? null
129
+
130
+ if (forcePlayground) {
131
+ return playgroundTarget(packageRoot, root, packageName)
132
+ }
133
+
134
+ const atWorkspaceRoot = normalizedCwd === root
135
+ const underDesktopOnly =
136
+ isPathInside(normalizedCwd, desktopDir) && !isPathInside(normalizedCwd, packageRoot)
137
+
138
+ if (!atWorkspaceRoot && !underDesktopOnly && isPathInside(normalizedCwd, packageRoot)) {
139
+ return playgroundTarget(packageRoot, root, packageName)
140
+ }
141
+ }
142
+
143
+ if (forcePlayground) {
144
+ return null
145
+ }
146
+
147
+ return workspaceTarget(root)
148
+ }
149
+
150
+ /**
151
+ * @param {DevTarget | string | null | undefined} target
152
+ * @param {string} [workspaceRoot]
153
+ * @returns {DevTarget | null}
154
+ */
155
+ export function normalizeDevTarget(target, workspaceRoot) {
156
+ if (!target) return workspaceRoot ? workspaceTarget(workspaceRoot) : null
157
+ if (typeof target === 'string') return workspaceTarget(target)
158
+ if (target.mode && target.workspaceRoot) return target
159
+ if (workspaceRoot) {
160
+ return {
161
+ ...workspaceTarget(workspaceRoot),
162
+ ...target,
163
+ workspaceRoot: resolve(workspaceRoot),
164
+ }
165
+ }
166
+ return null
167
+ }
168
+
169
+ /**
170
+ * @param {DevTarget | string} target
171
+ * @returns {string}
172
+ */
173
+ export function devSpawnCwd(target) {
174
+ const resolved = typeof target === 'string' ? workspaceTarget(target) : target
175
+ return resolved.packageDir
176
+ }
177
+
178
+ /**
179
+ * @param {DevTarget | string} target
180
+ * @returns {string}
181
+ */
182
+ export function devTargetLogLabel(target) {
183
+ const resolved = typeof target === 'string' ? workspaceTarget(target) : target
184
+ if (resolved.mode === 'playground' && resolved.packageName) {
185
+ return `playground ${resolved.packageName}`
186
+ }
187
+ return 'workspace desktop'
188
+ }