@owdproject/cli 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,525 @@
1
+ import { execSync, spawn } from 'node:child_process'
2
+ import { existsSync, appendFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import {
7
+ KINDS,
8
+ shortName as toShortName,
9
+ isWorkspaceInstallMode,
10
+ inferKind,
11
+ } from './workspace.js'
12
+ import {
13
+ githubCloneUrl,
14
+ resolveForkUser,
15
+ } from './packageSources.js'
16
+ import {
17
+ resolveDesktopConfigPath,
18
+ desktopConfigWritePath,
19
+ } from './desktopConfig.js'
20
+
21
+ const require = createRequire(import.meta.url)
22
+ const __dirname = dirname(fileURLToPath(import.meta.url))
23
+ const desktopBin = join(__dirname, '..', 'desktop.js')
24
+
25
+ /**
26
+ * Runs a shell command asynchronously without blocking the event loop.
27
+ * Resolves when the process exits with code 0, rejects otherwise.
28
+ * @param {string} cmd
29
+ * @param {string[]} args
30
+ * @param {{ cwd?: string, stdio?: 'pipe' | 'inherit' | 'ignore' }} [options]
31
+ */
32
+ export function spawnAsync(cmd, args, options = {}) {
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawn(cmd, args, {
35
+ cwd: options.cwd,
36
+ stdio: options.stdio ?? 'pipe',
37
+ shell: process.platform === 'win32',
38
+ })
39
+
40
+ let logFile = null
41
+ if (options.cwd) {
42
+ const candidate1 = join(options.cwd, '.desktop', 'dev.log')
43
+ const candidate2 = join(dirname(options.cwd), '.desktop', 'dev.log')
44
+ if (existsSync(dirname(candidate1))) {
45
+ logFile = candidate1
46
+ } else if (existsSync(dirname(candidate2))) {
47
+ logFile = candidate2
48
+ }
49
+ }
50
+
51
+ if (logFile) {
52
+ try {
53
+ appendFileSync(logFile, `\n[installer] $ ${cmd} ${args.join(' ')}\n`)
54
+ } catch {
55
+ /* ignore */
56
+ }
57
+ }
58
+
59
+ let out = ''
60
+ let err = ''
61
+ if (child.stdout) {
62
+ child.stdout.on('data', (d) => {
63
+ const text = d.toString()
64
+ out += text
65
+ if (logFile) {
66
+ try {
67
+ appendFileSync(logFile, text)
68
+ } catch {
69
+ /* ignore */
70
+ }
71
+ }
72
+ })
73
+ }
74
+ if (child.stderr) {
75
+ child.stderr.on('data', (d) => {
76
+ const text = d.toString()
77
+ err += text
78
+ if (logFile) {
79
+ try {
80
+ appendFileSync(logFile, text)
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ }
85
+ })
86
+ }
87
+ child.on('error', reject)
88
+ child.on('close', (code) => {
89
+ if (code === 0) resolve()
90
+ else {
91
+ const combined = [err.trim(), out.trim()].filter(Boolean).join('\n')
92
+ reject(new Error(combined || `Process exited with code ${code}`))
93
+ }
94
+ })
95
+ })
96
+ }
97
+
98
+ const SCOPE = '@owdproject/'
99
+
100
+ /**
101
+ * @param {string | undefined} from
102
+ * @param {string} pkgShortName
103
+ * @param {boolean} inMonorepo
104
+ * @param {{ npm?: boolean, dev?: boolean }} [options]
105
+ */
106
+ export function resolveSource(from, pkgShortName, inMonorepo, options = {}) {
107
+ const { npm = false, dev = false } = options
108
+
109
+ if (npm || from === 'npm' || from === 'registry') {
110
+ return { mode: 'npm', label: 'npm registry' }
111
+ }
112
+
113
+ if (dev && inMonorepo) {
114
+ return {
115
+ mode: 'workspace',
116
+ gitUrl: `https://github.com/owdproject/${pkgShortName}.git`,
117
+ label: `github.com/owdproject/${pkgShortName}`,
118
+ }
119
+ }
120
+
121
+ if (!from) {
122
+ return { mode: 'npm', label: 'npm registry' }
123
+ }
124
+
125
+ if (from === 'owdproject' || from === 'official') {
126
+ if (!inMonorepo) {
127
+ return { mode: 'npm', label: 'npm registry' }
128
+ }
129
+ return {
130
+ mode: 'workspace',
131
+ gitUrl: `https://github.com/owdproject/${pkgShortName}.git`,
132
+ label: `github.com/owdproject/${pkgShortName}`,
133
+ }
134
+ }
135
+
136
+ const trimmed = from.trim()
137
+
138
+ if (
139
+ trimmed.startsWith('https://') ||
140
+ trimmed.startsWith('http://') ||
141
+ trimmed.startsWith('git@') ||
142
+ trimmed.startsWith('ssh://')
143
+ ) {
144
+ return {
145
+ mode: 'workspace',
146
+ gitUrl: normalizeGitUrl(trimmed),
147
+ label: trimmed,
148
+ }
149
+ }
150
+
151
+ const slug = trimmed
152
+ .replace(/^github:/, '')
153
+ .replace(/^fork:/, '')
154
+ .replace(/^https?:\/\/github\.com\//, '')
155
+ .replace(/\.git$/, '')
156
+ .replace(/\/$/, '')
157
+
158
+ if (slug.includes('/')) {
159
+ const gitUrl = normalizeGitUrl(`https://github.com/${slug}`)
160
+ return { mode: 'workspace', gitUrl, label: slug }
161
+ }
162
+
163
+ if (!inMonorepo) {
164
+ return { mode: 'npm', label: 'npm registry' }
165
+ }
166
+
167
+ return {
168
+ mode: 'workspace',
169
+ gitUrl: `https://github.com/${slug}/${pkgShortName}.git`,
170
+ label: `github.com/${slug}/${pkgShortName}`,
171
+ }
172
+ }
173
+
174
+ export function normalizeGitUrl(input) {
175
+ const trimmed = input.trim().replace(/\/$/, '')
176
+ if (trimmed.startsWith('git@') || trimmed.startsWith('ssh://')) return trimmed
177
+ if (/^https?:\/\//.test(trimmed)) {
178
+ return trimmed.endsWith('.git') ? trimmed : `${trimmed}.git`
179
+ }
180
+ const path = trimmed
181
+ .replace(/^github\.com\//, '')
182
+ .replace(/^https?:\/\/github\.com\//, '')
183
+ if (/^[\w.-]+\/[\w.-]+/.test(path)) {
184
+ return `https://github.com/${path.split('/').slice(0, 2).join('/')}.git`
185
+ }
186
+ throw new Error(`Could not parse git source: "${input}"`)
187
+ }
188
+
189
+ /**
190
+ * @param {string} workspaceRoot
191
+ * @param {string} pkgName full @owdproject/name
192
+ */
193
+ export function hasLocalWorkspaceSource(workspaceRoot, pkgName) {
194
+ const pkgShort = toShortName(pkgName)
195
+ const kind = inferKind(pkgShort)
196
+ return existsSync(join(workspaceRoot, KINDS[kind].workspaceDir, pkgShort, 'package.json'))
197
+ }
198
+
199
+ export { resolveForkUser } from './packageSources.js'
200
+
201
+ /**
202
+ * @typedef {import('./packageSources.js').InstallSourceChoice} InstallSourceChoice
203
+ */
204
+
205
+ /**
206
+ * @param {InstallSourceChoice} sourceChoice
207
+ * @param {string} pkgShort
208
+ */
209
+ export function sourceChoiceToGitUrl(sourceChoice, pkgShort) {
210
+ if (sourceChoice.type !== 'git') return null
211
+ return githubCloneUrl(sourceChoice.owner, pkgShort, sourceChoice.protocol)
212
+ }
213
+
214
+ /**
215
+ * @param {string} pkgName full @owdproject/name
216
+ * @param {{ installMode?: string, githubUser?: string | null }} settings
217
+ * @param {string | null} workspaceRoot
218
+ * @param {InstallSourceChoice} [sourceChoice]
219
+ */
220
+ export async function resolveInstallPlan(pkgName, settings, workspaceRoot, sourceChoice) {
221
+ const pkgShort = toShortName(pkgName)
222
+ const kind = inferKind(pkgShort)
223
+ const inMonorepo = Boolean(workspaceRoot)
224
+
225
+ if (sourceChoice) {
226
+ if (sourceChoice.type === 'npm') {
227
+ return {
228
+ mode: 'npm',
229
+ pkgName,
230
+ shortName: pkgShort,
231
+ kind,
232
+ label: 'npm registry',
233
+ sourceChoice,
234
+ }
235
+ }
236
+
237
+ if (!inMonorepo) {
238
+ return {
239
+ error: 'Git clone requires an OWD monorepo (apps/, packages/, themes/).',
240
+ }
241
+ }
242
+
243
+ const gitUrl = sourceChoiceToGitUrl(sourceChoice, pkgShort)
244
+ const owner = sourceChoice.owner
245
+ return {
246
+ mode: 'workspace',
247
+ pkgName,
248
+ shortName: pkgShort,
249
+ kind,
250
+ from: owner,
251
+ source: {
252
+ mode: 'workspace',
253
+ gitUrl,
254
+ label: `${sourceChoice.protocol === 'ssh' ? 'SSH' : 'HTTPS'} github.com/${owner}/${pkgShort}`,
255
+ },
256
+ targetDir: join(KINDS[kind].workspaceDir, pkgShort),
257
+ label: `github.com/${owner}/${pkgShort}`,
258
+ sourceChoice,
259
+ }
260
+ }
261
+
262
+ if (!isWorkspaceInstallMode(settings)) {
263
+ return {
264
+ mode: 'npm',
265
+ pkgName,
266
+ shortName: pkgShort,
267
+ kind,
268
+ label: 'npm registry',
269
+ }
270
+ }
271
+
272
+ if (!inMonorepo) {
273
+ return {
274
+ error:
275
+ 'Dev install mode requires an OWD monorepo (apps/, packages/, themes/). Switch to User mode or use --npm.',
276
+ }
277
+ }
278
+
279
+ const fromUser = settings.githubUser
280
+ ? await resolveForkUser(settings.githubUser, pkgShort)
281
+ : 'owdproject'
282
+
283
+ const source = resolveSource(fromUser, pkgShort, true)
284
+ return {
285
+ mode: 'workspace',
286
+ pkgName,
287
+ shortName: pkgShort,
288
+ kind,
289
+ from: fromUser,
290
+ source,
291
+ targetDir: join(KINDS[kind].workspaceDir, pkgShort),
292
+ label: source.label,
293
+ }
294
+ }
295
+
296
+ /**
297
+ * @param {string} pkgName
298
+ * @param {{ installMode?: string, githubUser?: string | null }} settings
299
+ * @param {string} workspaceRoot
300
+ * @param {{ stdio?: 'inherit' | 'pipe' }} [options]
301
+ */
302
+ export async function installPackage(pkgName, settings, workspaceRoot, options = {}) {
303
+ const plan = await resolveInstallPlan(
304
+ pkgName,
305
+ settings,
306
+ workspaceRoot,
307
+ options.sourceChoice,
308
+ )
309
+ if (plan.error) throw new Error(plan.error)
310
+
311
+ const args = [desktopBin, 'add', plan.shortName]
312
+
313
+ if (plan.mode === 'npm') {
314
+ args.push('--npm')
315
+ } else if (plan.sourceChoice?.type === 'git') {
316
+ const { owner, protocol } = plan.sourceChoice
317
+ if (protocol === 'ssh') {
318
+ args.push('--from', githubCloneUrl(owner, plan.shortName, 'ssh'))
319
+ } else if (owner !== 'owdproject') {
320
+ args.push('--from', owner)
321
+ } else {
322
+ args.push('--dev')
323
+ }
324
+ } else if (plan.from && plan.from !== 'owdproject') {
325
+ args.push('--from', plan.from)
326
+ } else {
327
+ args.push('--dev')
328
+ }
329
+
330
+ await spawnAsync(process.execPath, args, { cwd: workspaceRoot })
331
+
332
+ return plan
333
+ }
334
+
335
+ export async function runPrepareModules(workspaceRoot, _stdio = 'inherit') {
336
+ try {
337
+ await spawnAsync('pnpm', ['run', 'prepare:modules'], { cwd: workspaceRoot })
338
+ } catch {
339
+ /* optional in minimal workspaces */
340
+ }
341
+ }
342
+
343
+ function loadConfigUtil(workspaceRoot) {
344
+ const utilPath = join(
345
+ workspaceRoot,
346
+ 'node_modules/@owdproject/nx/dist/utils/utilConfig.js',
347
+ )
348
+ if (!existsSync(utilPath)) {
349
+ throw new Error('Workspace install needs @owdproject/nx. Run `pnpm install` at the repo root.')
350
+ }
351
+ return require(utilPath)
352
+ }
353
+
354
+ export function printPlan({ pkgName, kind, targetDir, source, branch, dryRun }) {
355
+ const kindMeta = KINDS[kind]
356
+ console.log(`
357
+ ${dryRun ? 'Plan (dry run)' : 'Adding to desktop'}
358
+ ────────────────────────────────────────
359
+ Package ${pkgName}
360
+ Type ${kindMeta.label}
361
+ Folder ${targetDir}/
362
+ Source ${source.label}${branch ? ` (branch: ${branch})` : ''}
363
+ Config desktop/desktop.config.ts → ${kindMeta.configKey}
364
+ ────────────────────────────────────────
365
+ `)
366
+ }
367
+
368
+ export function printNextSteps({ targetDir, dryRun }) {
369
+ if (dryRun) {
370
+ console.log('Dry run complete. Re-run without --dry-run to apply.\n')
371
+ return
372
+ }
373
+ console.log(`Next steps:
374
+ pnpm run dev Start the desktop
375
+ pnpm run prepare:modules Rebuild module stubs if needed
376
+
377
+ Package lives in ${targetDir}/
378
+ `)
379
+ }
380
+
381
+ /**
382
+ * @param {string} targetDir
383
+ * @param {string} gitUrl
384
+ * @param {string | undefined} branch
385
+ * @param {string} workspaceRoot
386
+ * @param {{ stdio?: 'inherit' | 'pipe' | 'ignore', quiet?: boolean }} [options]
387
+ */
388
+ export async function cloneRepo(targetDir, gitUrl, branch, workspaceRoot, options = {}) {
389
+ const quiet = options.quiet ?? true
390
+ const absoluteTarget = join(workspaceRoot, targetDir)
391
+
392
+ if (existsSync(join(absoluteTarget, 'package.json'))) {
393
+ if (!quiet) console.log(`Folder ${targetDir}/ already exists — skipping clone.\n`)
394
+ return
395
+ }
396
+
397
+ if (existsSync(absoluteTarget)) {
398
+ throw new Error(
399
+ `${targetDir}/ exists but has no package.json. Remove the folder or clone your fork there manually.`,
400
+ )
401
+ }
402
+
403
+ const args = ['clone']
404
+ if (branch) args.push('-b', branch)
405
+ args.push(gitUrl, absoluteTarget)
406
+
407
+ if (!quiet) console.log(`Cloning into ${targetDir}/ …\n`)
408
+ await spawnAsync('git', args, { cwd: workspaceRoot })
409
+ }
410
+
411
+ export async function linkWorkspacePackage(desktopPath, pkgName) {
412
+ await spawnAsync('pnpm', ['add', `${pkgName}@workspace:*`], { cwd: desktopPath })
413
+ }
414
+
415
+ async function runDevPrepare(workspaceRoot, pkgName, options = {}) {
416
+ const quiet = options.quiet ?? true
417
+ try {
418
+ await spawnAsync('pnpm', ['--filter', pkgName, 'run', 'dev:prepare'], { cwd: workspaceRoot })
419
+ } catch {
420
+ if (!quiet) console.log('(no dev:prepare script — ok for some packages)\n')
421
+ }
422
+ }
423
+
424
+ export async function runWorkspaceInstall({
425
+ pkgName,
426
+ shortName: pkgShort,
427
+ kind,
428
+ workspaceRoot,
429
+ source,
430
+ branch,
431
+ dryRun,
432
+ }) {
433
+ const { workspaceDir } = KINDS[kind]
434
+ const targetDir = join(workspaceDir, pkgShort)
435
+ const desktopPath = join(workspaceRoot, 'desktop')
436
+ const resolved = resolveDesktopConfigPath(desktopPath)
437
+ const configPath = resolved?.path ?? desktopConfigWritePath(desktopPath)
438
+
439
+ printPlan({
440
+ pkgName,
441
+ kind,
442
+ targetDir,
443
+ source,
444
+ branch,
445
+ dryRun,
446
+ })
447
+
448
+ if (dryRun) {
449
+ printNextSteps({ targetDir, dryRun: true })
450
+ return
451
+ }
452
+
453
+ await cloneRepo(targetDir, source.gitUrl, branch, workspaceRoot)
454
+ await linkWorkspacePackage(desktopPath, pkgName)
455
+
456
+ const { addToDesktopConfig } = loadConfigUtil(workspaceRoot)
457
+ addToDesktopConfig(configPath, KINDS[kind].configKey, pkgName)
458
+
459
+ await runDevPrepare(workspaceRoot, pkgName)
460
+
461
+ console.log(`✓ ${pkgName} is ready.\n`)
462
+ printNextSteps({ targetDir, dryRun: false })
463
+ }
464
+
465
+ export function runNpmInstall(kind, pkgName, workspaceRoot, stdio = 'inherit') {
466
+ const nxTarget = KINDS[kind].nx
467
+ const child = spawn('pnpm', ['nx', 'run', nxTarget, `--name=${pkgName}`], {
468
+ stdio,
469
+ shell: true,
470
+ cwd: workspaceRoot,
471
+ })
472
+ child.on('exit', (code) => {
473
+ if (stdio === 'inherit') process.exit(code ?? 1)
474
+ })
475
+ return child
476
+ }
477
+
478
+ /** Preview label for TUI list rows */
479
+ /**
480
+ * @param {string} pkgName
481
+ * @param {{ installMode?: string, githubUser?: string | null }} settings
482
+ * @param {string} workspaceRoot
483
+ * @param {{ stdio?: 'inherit' | 'pipe' }} [options]
484
+ */
485
+ export async function materializeToWorkspace(pkgName, settings, workspaceRoot, options = {}) {
486
+ const plan = await resolveInstallPlan(
487
+ pkgName,
488
+ settings,
489
+ workspaceRoot,
490
+ options.sourceChoice,
491
+ )
492
+ if (plan.error) throw new Error(plan.error)
493
+ if (plan.mode !== 'workspace') {
494
+ throw new Error(`Cannot materialize ${pkgName}: source is not a git workspace clone.`)
495
+ }
496
+
497
+ if (hasLocalWorkspaceSource(workspaceRoot, pkgName)) {
498
+ return { skipped: true, plan }
499
+ }
500
+
501
+ const desktopPath = join(workspaceRoot, 'desktop')
502
+
503
+ await cloneRepo(plan.targetDir, plan.source.gitUrl, undefined, workspaceRoot, { quiet: true })
504
+ await linkWorkspacePackage(desktopPath, pkgName)
505
+ await runDevPrepare(workspaceRoot, pkgName, { quiet: true })
506
+
507
+ return { skipped: false, plan }
508
+ }
509
+
510
+ /** @param {import('./catalog.js').CatalogEntry} item */
511
+ export function catalogListTag(item) {
512
+ if (item.localSource) return 'local'
513
+ if (item.installed && !item.localSource) return 'npm'
514
+ if (item.org === 'workspace') return 'local'
515
+ return 'catalog'
516
+ }
517
+
518
+ export function installTag(_settings, item) {
519
+ const tag = catalogListTag(item)
520
+ if (tag === 'catalog') {
521
+ if (item.sourcesMeta?.npm?.version) return 'npm+git'
522
+ return 'git'
523
+ }
524
+ return tag
525
+ }