@owdproject/cli 0.1.0 → 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/README.md CHANGED
@@ -31,7 +31,7 @@ pnpm add -g @owdproject/cli
31
31
  Once installed, run the `desktop` command to open the control panel:
32
32
 
33
33
  ```bash
34
- desktop
34
+ pnpm desktop
35
35
  ```
36
36
 
37
37
  ### CLI Reference
@@ -39,27 +39,27 @@ desktop
39
39
  #### Dev Server
40
40
  Start the development server for the monorepo or automatically detect a module's playground:
41
41
  ```bash
42
- desktop dev [--playground]
42
+ pnpm desktop dev [--playground]
43
43
  ```
44
44
 
45
45
  #### Add Packages
46
46
  Install apps, modules, or themes from npm, local directories, or custom repositories:
47
47
  ```bash
48
- desktop add app-todo --npm
49
- desktop add theme-nova --dev
50
- desktop add module-fs --from <github-user>
48
+ pnpm desktop add app-todo --npm
49
+ pnpm desktop add theme-nova --dev
50
+ pnpm desktop add module-fs --from <github-user>
51
51
  ```
52
52
 
53
53
  #### Scaffold a Project
54
54
  Initialize a fresh OWD workspace:
55
55
  ```bash
56
- desktop init [project-name]
56
+ pnpm desktop init [project-name]
57
57
  ```
58
58
 
59
59
  #### Validation
60
60
  Check Nuxt module configuration and playground directory structures:
61
61
  ```bash
62
- desktop validate
62
+ pnpm desktop validate
63
63
  ```
64
64
 
65
65
  ## License
package/bin/bridge.js CHANGED
@@ -12,7 +12,8 @@ import {
12
12
  readDesktopConfig,
13
13
  writeDesktopConfig,
14
14
  readDesktopDependencies,
15
- writeDesktopDependencies
15
+ writeDesktopDependencies,
16
+ resolveConfigPathForWrite
16
17
  } from './lib/config.js'
17
18
  import { loadCatalog, mergeInstalled } from './lib/catalog.js'
18
19
  import { resolveDevTarget } from './lib/playgroundContext.js'
@@ -98,7 +99,8 @@ async function main() {
98
99
  // payload = { config: { theme, apps, modules }, depsToAdd: { name: version }, depsToRemove: [name], settings?: { ... } }
99
100
 
100
101
  if (payload.config) {
101
- writeDesktopConfig(ctx.paths.config, ctx.workspaceRoot, payload.config)
102
+ const configPath = resolveConfigPathForWrite(ctx.paths)
103
+ writeDesktopConfig(configPath, ctx.workspaceRoot, payload.config)
102
104
  }
103
105
 
104
106
  if (payload.depsToAdd || payload.depsToRemove) {
package/bin/cli.js CHANGED
@@ -68,8 +68,11 @@ CONTROL PANEL (TUI)
68
68
  g Settings (GitHub user, SSH, trusted orgs)
69
69
  r Refresh package list from GitHub (detects new modules)
70
70
  b Run pnpm run generate
71
+ n Create a new app or module (interactive wizard)
71
72
  q / Esc Quit
72
73
  ${name} init [dir] Create a new OWD project (then opens the control panel)
74
+ ${name} create Create a new app or module (interactive wizard)
75
+ ${name} create <kind> <name> Scaffold a new app or module (e.g. desktop create app todo)
73
76
  ${name} add <package> [options]
74
77
  ${name} add <kind> <name> [options]
75
78
  ${name} validate [path...] Check Nuxt module + playground layout
@@ -136,6 +139,7 @@ async function openControlPanel(name) {
136
139
  const cliDir = join(fileURLToPath(import.meta.url), '..')
137
140
  const goDir = join(cliDir, '../go')
138
141
  const binaryPath = join(goDir, 'desktop-go')
142
+ const readline = await import('node:readline/promises')
139
143
 
140
144
  // Compile the Go Control Panel if binary doesn't exist
141
145
  if (!existsSync(binaryPath)) {
@@ -145,16 +149,52 @@ async function openControlPanel(name) {
145
149
  console.log('Go Control Panel compiled successfully!')
146
150
  } catch (err) {
147
151
  console.warn('Failed to compile Go Control Panel, falling back to legacy CP:', err.message)
148
- const { runCp } = await import('./cp.js')
149
- await runCp(name)
152
+ const child = spawn(process.execPath, [
153
+ '-e',
154
+ `import('./cp.js').then(m => m.runCp(${JSON.stringify(name)}))`
155
+ ], {
156
+ cwd: cliDir,
157
+ stdio: 'inherit',
158
+ })
159
+ child.on('exit', async (code) => {
160
+ if (code === 10) {
161
+ const { runCreateCli } = await import('./lib/create.js')
162
+ const workspaceRoot = findWorkspaceRoot()
163
+ try {
164
+ await runCreateCli({ workspaceRoot })
165
+ } catch (err) {
166
+ console.error('\n✗ Error creating package:', err.message)
167
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
168
+ await rl.question('\nPress Enter to return to the control panel...')
169
+ rl.close()
170
+ }
171
+ await openControlPanel(name)
172
+ } else {
173
+ process.exit(code ?? 0)
174
+ }
175
+ })
150
176
  return
151
177
  }
152
178
  }
153
179
 
154
180
  // Execute Go TUI
155
181
  const child = spawn(binaryPath, [], { stdio: 'inherit' })
156
- child.on('exit', (code) => {
157
- process.exit(code ?? 0)
182
+ child.on('exit', async (code) => {
183
+ if (code === 10) {
184
+ const { runCreateCli } = await import('./lib/create.js')
185
+ const workspaceRoot = findWorkspaceRoot()
186
+ try {
187
+ await runCreateCli({ workspaceRoot })
188
+ } catch (err) {
189
+ console.error('\n✗ Error creating package:', err.message)
190
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
191
+ await rl.question('\nPress Enter to return to the control panel...')
192
+ rl.close()
193
+ }
194
+ await openControlPanel(name)
195
+ } else {
196
+ process.exit(code ?? 0)
197
+ }
158
198
  })
159
199
  }
160
200
 
@@ -351,6 +391,20 @@ export async function runCli(name, argv, options = {}) {
351
391
  return
352
392
  }
353
393
 
394
+ if (cmd === 'create' || cmd === 'new') {
395
+ if (!workspaceRoot) {
396
+ fail(
397
+ 'Not inside an OWD workspace.',
398
+ `Run from inside an OWD workspace directory to scaffold a new package.`,
399
+ )
400
+ }
401
+ const { runCreateCli } = await import('./lib/create.js')
402
+ const kind = _[1]
403
+ const name = _[2]
404
+ await runCreateCli({ kind, name, workspaceRoot })
405
+ return
406
+ }
407
+
354
408
  if (!cmd) {
355
409
  if (!workspaceRoot) {
356
410
  fail(
@@ -414,7 +468,11 @@ export async function runCli(name, argv, options = {}) {
414
468
  })
415
469
  process.exit(0)
416
470
  }
417
- runNpmInstall(kind, pkgName, workspaceRoot)
471
+ try {
472
+ await runNpmInstall(kind, pkgName, workspaceRoot)
473
+ } catch (error) {
474
+ fail(error.message ?? String(error))
475
+ }
418
476
  } else {
419
477
  try {
420
478
  await runWorkspaceInstall({
package/bin/cp.js CHANGED
@@ -489,6 +489,7 @@ export async function runCp(commandName = 'desktop') {
489
489
  { id: 'docs', label: 'Open documentation', key: 'i' },
490
490
  { id: 'settings', label: 'Settings', key: 'g' },
491
491
  { id: 'build', label: 'Build (pnpm generate)', key: 'b' },
492
+ { id: 'create', label: 'Create new app or module', key: 'n' },
492
493
  { id: 'quit', label: 'Quit control panel', key: 'q' },
493
494
  ]
494
495
 
@@ -1385,11 +1386,16 @@ export async function runCp(commandName = 'desktop') {
1385
1386
  ? `{${C.accent}-fg}${keyHint('s')} save (${queue}){/}`
1386
1387
  : `${keyHint('s')} save`
1387
1388
 
1389
+ const serverRunning = isDevServerUp()
1390
+ const serverShortcut = serverRunning
1391
+ ? `{${C.accent}-fg}${keyHint('x')} stop server{/}`
1392
+ : `${keyHint('d')} start server`
1393
+
1388
1394
  helpBar.setContent(
1389
1395
  [
1390
1396
  msgLine,
1391
1397
  ` Legend: {${C.add}-fg}[+]{/} add {${C.remove}-fg}[-]{/} remove {${C.accent}-fg}[*]{/} on desktop | {${C.npm}-fg}NPM{/} registry {${C.git}-fg}GIT{/} repo {${C.local}-fg}LOC{/} workspace | {${C.warn}-fg}WRN{/} untrusted`,
1392
- ` Shortcuts: ↑↓ Space toggle ${keyHint('1')}${keyHint('2')}${keyHint('3')} tabs ${saveShortcut} ${keyHint('d')} server ${keyHint('O')} sort ${keyHint('m')} menu ${keyHint('u')} updates ${keyHint('r')} refresh ${keyHint('q')} quit`,
1398
+ ` Shortcuts: ↑↓ Space toggle ${keyHint('1')}${keyHint('2')}${keyHint('3')} tabs ${saveShortcut} ${serverShortcut} ${keyHint('g')} settings ${keyHint('O')} sort ${keyHint('m')} menu ${keyHint('u')} updates ${keyHint('r')} refresh ${keyHint('n')} new ${keyHint('q')} quit`,
1393
1399
  ].join('\n'),
1394
1400
  )
1395
1401
  }
@@ -1410,6 +1416,7 @@ export async function runCp(commandName = 'desktop') {
1410
1416
  const newConfig = readDesktopConfig(paths.config, workspaceRoot)
1411
1417
  configError = null
1412
1418
  if (!areConfigsEqual(config, newConfig)) {
1419
+ const oldTheme = config ? config.theme : null
1413
1420
  config = newConfig
1414
1421
  deps = readDesktopDependencies(paths.packageJson)
1415
1422
 
@@ -1420,7 +1427,7 @@ export async function runCp(commandName = 'desktop') {
1420
1427
  pendingPackages.delete(name)
1421
1428
  }
1422
1429
  }
1423
- if (pendingTheme === config.theme || !pendingTheme) {
1430
+ if (pendingTheme === oldTheme || !pendingTheme) {
1424
1431
  pendingTheme = config.theme
1425
1432
  }
1426
1433
 
@@ -2036,6 +2043,9 @@ export async function runCp(commandName = 'desktop') {
2036
2043
  refreshTerminalUi()
2037
2044
  }
2038
2045
  break
2046
+ case 'create':
2047
+ process.exit(10)
2048
+ break
2039
2049
  case 'quit':
2040
2050
  process.exit(0)
2041
2051
  break
@@ -2723,30 +2733,35 @@ export async function runCp(commandName = 'desktop') {
2723
2733
  installWizardList.setItems(
2724
2734
  installWizardOptions.map((opt) => {
2725
2735
  const prefix =
2726
- opt.kind === 'npm' ? `{${C.npm}-fg}NPM{/}` : `{${C.git}-fg}GIT{/}`
2736
+ opt.kind === 'npm' ? `{${C.npm}-fg}NPM{/}` : opt.kind === 'local' ? `{${C.local}-fg}LOC{/}` : `{${C.git}-fg}GIT{/}`
2727
2737
  return `${prefix} ${opt.label}`
2728
2738
  }),
2729
2739
  )
2730
2740
  let defaultIdx = 0
2731
- const lastId = settings.lastInstallChoiceId
2732
- if (lastId) {
2733
- let found = installWizardOptions.findIndex((opt) => opt.id === lastId)
2734
- if (found >= 0) {
2735
- defaultIdx = found
2736
- } else {
2737
- const isSsh = lastId.includes('ssh')
2738
- const isHttps = lastId.includes('https')
2739
- const isNpm = lastId === 'npm'
2740
- if (isNpm) {
2741
- found = installWizardOptions.findIndex((opt) => opt.id === 'npm')
2742
- if (found >= 0) defaultIdx = found
2741
+ const isLocalAvailable = installWizardOptions.some((opt) => opt.id === 'local')
2742
+ if (isLocalAvailable) {
2743
+ defaultIdx = installWizardOptions.findIndex((opt) => opt.id === 'local')
2744
+ } else {
2745
+ const lastId = settings.lastInstallChoiceId
2746
+ if (lastId) {
2747
+ let found = installWizardOptions.findIndex((opt) => opt.id === lastId)
2748
+ if (found >= 0) {
2749
+ defaultIdx = found
2743
2750
  } else {
2744
- found = installWizardOptions.findIndex((opt) => {
2745
- if (isSsh && opt.protocol === 'ssh') return true
2746
- if (isHttps && opt.protocol === 'https') return true
2747
- return false
2748
- })
2749
- if (found >= 0) defaultIdx = found
2751
+ const isSsh = lastId.includes('ssh')
2752
+ const isHttps = lastId.includes('https')
2753
+ const isNpm = lastId === 'npm'
2754
+ if (isNpm) {
2755
+ found = installWizardOptions.findIndex((opt) => opt.id === 'npm')
2756
+ if (found >= 0) defaultIdx = found
2757
+ } else {
2758
+ found = installWizardOptions.findIndex((opt) => {
2759
+ if (isSsh && opt.protocol === 'ssh') return true
2760
+ if (isHttps && opt.protocol === 'https') return true
2761
+ return false
2762
+ })
2763
+ if (found >= 0) defaultIdx = found
2764
+ }
2750
2765
  }
2751
2766
  }
2752
2767
  }
@@ -3216,6 +3231,10 @@ export async function runCp(commandName = 'desktop') {
3216
3231
  if (menuOpen) closeMenu()
3217
3232
  else openMenu()
3218
3233
  })
3234
+ screen.key(['n', 'N'], () => {
3235
+ if (settingsOpen || installWizardOpen || sortPickerOpen || menuOpen || confirmDialogOpen) return
3236
+ process.exit(10)
3237
+ })
3219
3238
  screen.key(['d', 'D'], () => {
3220
3239
  if (!overlayBlocksKeys()) {
3221
3240
  if (isDevServerUp()) stopDevServer()
@@ -3481,7 +3500,7 @@ export async function runCp(commandName = 'desktop') {
3481
3500
  startConfigWatcher()
3482
3501
 
3483
3502
  setStatus(
3484
- `Select packages · ${keyHint('s')} opens install wizard · ${keyHint('u')} updates · ${keyHint('g')} settings · ${keyHint('d')} server`,
3503
+ `Select packages · ${keyHint('s')} opens install wizard · ${keyHint('u')} updates · ${keyHint('d')} start server · ${keyHint('g')} settings`,
3485
3504
  'info',
3486
3505
  )
3487
3506
 
@@ -3490,7 +3509,7 @@ export async function runCp(commandName = 'desktop') {
3490
3509
 
3491
3510
  setInterval(async () => {
3492
3511
  const now = Date.now()
3493
- if (now - lastShowcaseTime >= 5000) {
3512
+ if (now - lastShowcaseTime >= 15000) {
3494
3513
  showcaseIndex++
3495
3514
  lastShowcaseTime = now
3496
3515
  }
@@ -312,6 +312,7 @@ export async function loadCatalog(workspaceRoot, settings, options = {}) {
312
312
  byName.set(item.shortName, {
313
313
  ...prev,
314
314
  ...item,
315
+ stars: prev.stars ?? 0, // preserve GitHub stars — scanLocal always sets 0
315
316
  localPath: item.localPath,
316
317
  remote: item.remote ?? prev.remote,
317
318
  updatedAt: item.updatedAt ?? prev.updatedAt,
@@ -336,6 +337,8 @@ export async function loadCatalog(workspaceRoot, settings, options = {}) {
336
337
  trusted: isTrustedPublisher(entry, settings),
337
338
  }))
338
339
 
340
+ entries = entries.filter((e) => !e.name.includes('-template') && !e.shortName.includes('-template'))
341
+
339
342
  entries = await enrichCatalogEntries(entries, workspaceRoot, settings)
340
343
 
341
344
  return {
package/bin/lib/config.js CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, copyFileSync } from 'node:fs'
1
+ import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs'
2
2
  import { createRequire } from 'node:module'
3
3
  import { join } from 'node:path'
4
4
  import { SCOPE } from './workspace.js'
@@ -7,13 +7,6 @@ import { LEGACY_CONFIG_DEPRECATION } from './desktopConfig.js'
7
7
  const require = createRequire(import.meta.url)
8
8
 
9
9
  function loadTsMorph(workspaceRoot) {
10
- const utilPath = join(
11
- workspaceRoot,
12
- 'node_modules/@owdproject/nx/dist/utils/utilConfig.js',
13
- )
14
- if (!existsSync(utilPath)) {
15
- throw new Error('Missing @owdproject/nx — run pnpm install at the repo root.')
16
- }
17
10
  const { Project, SyntaxKind } = require('ts-morph')
18
11
  return { Project, SyntaxKind }
19
12
  }
@@ -128,3 +121,53 @@ export function writeDesktopDependencies(packageJsonPath, toAdd, toRemove) {
128
121
 
129
122
  writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n')
130
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
+ }
@@ -160,7 +160,7 @@ export function formatCatalogRowPlain(item, ctx = {}) {
160
160
 
161
161
  // 3.5 DIR Column (4 chars)
162
162
  let dirTag = ''
163
- const isMissing = item.installed && !item.localSource
163
+ const isMissing = item.installed && !item.localSource && !item.inPackageJson
164
164
  if (isMissing) {
165
165
  dirTag = `{red-fg}MISS{/}`
166
166
  } else if (item.localSource) {
@@ -321,7 +321,7 @@ export function formatDetailPanel(item, targetDir, colors = DEFAULT_COLORS, widt
321
321
  lines.push(` {${c.muted}-fg}${item.description.slice(0, maxDescLen)}{/}`)
322
322
  }
323
323
 
324
- const isMissing = item.installed && !item.localSource
324
+ const isMissing = item.installed && !item.localSource && !item.inPackageJson
325
325
  if (isMissing) {
326
326
  lines.push(` {red-fg}{bold}⚠ WARNING: Local workspace folder is missing!{/}{/}`)
327
327
  }
@@ -0,0 +1,246 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, mkdtempSync, rmSync } from 'node:fs'
2
+ import { join, dirname, resolve } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import readline from 'node:readline/promises'
5
+ import { stdin as input, stdout as output } from 'node:process'
6
+ import { KINDS } from './workspace.js'
7
+ import {
8
+ resolveDesktopConfigPath,
9
+ desktopConfigWritePath,
10
+ } from './desktopConfig.js'
11
+ import { addToDesktopConfig } from './config.js'
12
+ import {
13
+ linkWorkspacePackage,
14
+ spawnAsync,
15
+ } from './install.js'
16
+ import { createRequire } from 'node:module'
17
+
18
+ const require = createRequire(import.meta.url)
19
+ const __dirname = dirname(fileURLToPath(import.meta.url))
20
+ const CLI_ROOT = resolve(__dirname, '../..')
21
+
22
+ function pascalCase(str) {
23
+ return str
24
+ .replace(/(?:^|-|_)(\w)/g, (_, c) => c.toUpperCase())
25
+ .replace(/[-_]/g, '')
26
+ }
27
+
28
+ function camelCase(str) {
29
+ const p = pascalCase(str)
30
+ return p.charAt(0).toLowerCase() + p.slice(1)
31
+ }
32
+
33
+ async function cloneTemplate(gitUrl, destDir) {
34
+ try {
35
+ await spawnAsync('git', ['clone', '--depth', '1', gitUrl, destDir])
36
+ } catch (err) {
37
+ if (gitUrl.startsWith('git@')) {
38
+ const httpsUrl = gitUrl
39
+ .replace('git@github.com:', 'https://github.com/')
40
+ .replace(/\.git$/, '') + '.git'
41
+ console.log(`SSH clone failed, trying HTTPS fallback: ${httpsUrl}`)
42
+ await spawnAsync('git', ['clone', '--depth', '1', httpsUrl, destDir])
43
+ } else {
44
+ throw err
45
+ }
46
+ }
47
+ }
48
+
49
+ function copyAndScaffold(srcDir, destDir, replacements) {
50
+ if (!existsSync(destDir)) {
51
+ mkdirSync(destDir, { recursive: true })
52
+ }
53
+
54
+ const entries = readdirSync(srcDir)
55
+ for (const entry of entries) {
56
+ if (
57
+ entry === 'node_modules' ||
58
+ entry === '.nuxt' ||
59
+ entry === 'dist' ||
60
+ entry === '.output' ||
61
+ entry === '.git'
62
+ ) {
63
+ continue
64
+ }
65
+
66
+ const srcPath = join(srcDir, entry)
67
+ let destEntry = entry
68
+ for (const [placeholder, val] of Object.entries(replacements)) {
69
+ destEntry = destEntry.replaceAll(placeholder, val)
70
+ }
71
+ const destPath = join(destDir, destEntry)
72
+
73
+ const stat = statSync(srcPath)
74
+ if (stat.isDirectory()) {
75
+ copyAndScaffold(srcPath, destPath, replacements)
76
+ } else {
77
+ const fileContent = readFileSync(srcPath)
78
+ const isText = !fileContent.slice(0, 512).includes(0)
79
+ if (isText) {
80
+ let textContent = fileContent.toString('utf8')
81
+ for (const [placeholder, val] of Object.entries(replacements)) {
82
+ textContent = textContent.replaceAll(placeholder, val)
83
+ }
84
+ writeFileSync(destPath, textContent, 'utf8')
85
+ } else {
86
+ writeFileSync(destPath, fileContent)
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ export async function runCreateCli(options = {}) {
93
+ const workspaceRoot = options.workspaceRoot
94
+ if (!workspaceRoot) {
95
+ throw new Error('Not inside an OWD workspace.')
96
+ }
97
+
98
+ let { kind, name } = options
99
+
100
+ const rl = readline.createInterface({ input, output })
101
+
102
+ try {
103
+ // 1. Prompt for Kind if missing or invalid
104
+ while (!kind || (kind !== 'app' && kind !== 'module')) {
105
+ const answer = await rl.question('Select package kind (app / module) [app]: ')
106
+ const cleaned = answer.trim().toLowerCase()
107
+ if (cleaned === '') {
108
+ kind = 'app'
109
+ } else if (cleaned === 'app' || cleaned === 'module') {
110
+ kind = cleaned
111
+ } else {
112
+ console.log('Invalid kind. Please select "app" or "module".')
113
+ }
114
+ }
115
+
116
+ // 2. Prompt for Name if missing
117
+ while (!name || name.trim() === '') {
118
+ const answer = await rl.question(`Enter ${kind} name: `)
119
+ name = answer.trim().toLowerCase().replace(/[^a-z0-9-_]/g, '')
120
+ if (name === '') {
121
+ console.log('Name is required and should contain only alphanumeric characters, dashes, or underscores.')
122
+ }
123
+ }
124
+ } finally {
125
+ rl.close()
126
+ }
127
+
128
+ // Normalize prefix from name (e.g. if user entered "app-todo" instead of "todo")
129
+ if (kind === 'app' && name.startsWith('app-')) {
130
+ name = name.slice(4)
131
+ } else if (kind === 'module' && name.startsWith('module-')) {
132
+ name = name.slice(7)
133
+ }
134
+
135
+ const pkgShortName = `${kind}-${name}`
136
+ const pkgFullName = `@owdproject/${pkgShortName}`
137
+ const workspaceDir = KINDS[kind].workspaceDir
138
+ const targetDir = join(workspaceRoot, workspaceDir, pkgShortName)
139
+
140
+ if (existsSync(targetDir)) {
141
+ throw new Error(`Directory already exists: ${targetDir}`)
142
+ }
143
+
144
+ console.log(`\nCreating new ${kind} in ${targetDir}...\n`)
145
+
146
+ let templateSrc = null
147
+ let tempCloneDir = null
148
+
149
+ if (kind === 'app') {
150
+ const monorepoPath = join(workspaceRoot, 'apps/app-template')
151
+ if (existsSync(join(monorepoPath, 'package.json'))) {
152
+ templateSrc = monorepoPath
153
+ }
154
+ } else if (kind === 'module') {
155
+ const monorepoPath = join(workspaceRoot, 'packages/module-template')
156
+ if (existsSync(join(monorepoPath, 'package.json'))) {
157
+ templateSrc = monorepoPath
158
+ }
159
+ }
160
+
161
+ if (!templateSrc) {
162
+ const gitUrl = kind === 'app'
163
+ ? 'git@github.com:owdproject/app-template.git'
164
+ : 'git@github.com:owdproject/module-template.git'
165
+
166
+ console.log(`Cloning template from ${gitUrl}...`)
167
+ try {
168
+ const tmpPrefix = join(workspaceRoot, '.owd-template-')
169
+ tempCloneDir = mkdtempSync(tmpPrefix)
170
+ await cloneTemplate(gitUrl, tempCloneDir)
171
+ templateSrc = tempCloneDir
172
+ } catch (err) {
173
+ if (tempCloneDir && existsSync(tempCloneDir)) {
174
+ try {
175
+ rmSync(tempCloneDir, { recursive: true, force: true })
176
+ } catch {}
177
+ }
178
+ throw new Error(`Failed to clone template from GitHub: ${err.message}`)
179
+ }
180
+ }
181
+
182
+ // Prepare replacements
183
+ const replacements = {}
184
+ if (kind === 'app') {
185
+ replacements['@owdproject/app-template'] = pkgFullName
186
+ replacements['app-template'] = pkgShortName
187
+ replacements['desktop-app-template'] = `desktop-app-${name}`
188
+ replacements['desktop-app-template-register'] = `desktop-app-${name}-register`
189
+ replacements['appTemplate'] = `app${pascalCase(name)}`
190
+ replacements['AppTemplate'] = `App${pascalCase(name)}`
191
+ replacements['WindowTemplate'] = `Window${pascalCase(name)}`
192
+ replacements['myApp'] = `app${pascalCase(name)}`
193
+ replacements['my-app'] = `app-${name}`
194
+ replacements['ext.domain.app'] = `ext.domain.${name}`
195
+ replacements['Template'] = pascalCase(name)
196
+ } else {
197
+ replacements['@owdproject/module-template'] = pkgFullName
198
+ replacements['module-template'] = pkgShortName
199
+ replacements['desktop-module-template'] = `desktop-module-${name}`
200
+ replacements['desktop-module-template-register'] = `desktop-module-${name}-register`
201
+ replacements['moduleTemplate'] = `module${pascalCase(name)}`
202
+ replacements['ModuleTemplate'] = `Module${pascalCase(name)}`
203
+ replacements['ext.domain.module'] = `ext.domain.${name}`
204
+ replacements['owd-module-template'] = `owd-module-${name}`
205
+ replacements['owd-plugin-template'] = `owd-plugin-${name}`
206
+ replacements['Template'] = pascalCase(name)
207
+ }
208
+
209
+ // Copy and replace placeholders
210
+ try {
211
+ copyAndScaffold(templateSrc, targetDir, replacements)
212
+ console.log(`✓ Files scaffolded from template.`)
213
+ } finally {
214
+ if (tempCloneDir && existsSync(tempCloneDir)) {
215
+ try {
216
+ rmSync(tempCloneDir, { recursive: true, force: true })
217
+ } catch {}
218
+ }
219
+ }
220
+
221
+ // Run pnpm install in root
222
+ console.log('Running pnpm install in workspace root...')
223
+ await spawnAsync('pnpm', ['install'], { cwd: workspaceRoot })
224
+
225
+ // Link package in desktop
226
+ console.log(`Linking ${pkgFullName} in desktop project...`)
227
+ const desktopPath = join(workspaceRoot, 'desktop')
228
+ await linkWorkspacePackage(desktopPath, pkgFullName)
229
+
230
+ // Update desktop/desktop.config.ts
231
+ console.log('Registering package in desktop.config.ts...')
232
+ const resolved = resolveDesktopConfigPath(desktopPath)
233
+ const configPath = resolved?.path ?? desktopConfigWritePath(desktopPath)
234
+
235
+ addToDesktopConfig(configPath, workspaceRoot, KINDS[kind].configKey, pkgFullName)
236
+
237
+ // Run dev:prepare
238
+ console.log(`Running dev:prepare on ${pkgFullName}...`)
239
+ try {
240
+ await spawnAsync('pnpm', ['--filter', pkgFullName, 'run', 'dev:prepare'], { cwd: workspaceRoot })
241
+ } catch (err) {
242
+ console.warn(`(warning: dev:prepare failed — it's normal if package does not have the script)`)
243
+ }
244
+
245
+ console.log(`\n✓ Scaffolded and registered new ${kind}: ${pkgFullName}\n`)
246
+ }