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