@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,39 @@
1
+ import spinners from 'cli-spinners'
2
+ import logSymbols from 'log-symbols'
3
+
4
+ /** Same spinner family as ora (dots). */
5
+ const DOTS = spinners.dots.frames
6
+
7
+ export const spinnerFrameCount = DOTS.length
8
+
9
+ /**
10
+ * @param {'info' | 'ok' | 'error'} [tone]
11
+ */
12
+ export function statusPrefix(tone = 'info') {
13
+ if (tone === 'ok') return `${logSymbols.success} `
14
+ if (tone === 'error') return `${logSymbols.error} `
15
+ return ''
16
+ }
17
+
18
+ /**
19
+ * @param {number} frame
20
+ */
21
+ export function radarSpinner(frame) {
22
+ return DOTS[frame % DOTS.length]
23
+ }
24
+
25
+ /**
26
+ * @param {number} step
27
+ * @param {number} total
28
+ * @param {number} _frame
29
+ * @param {number} [width]
30
+ */
31
+ export function progressTrack(step, total, _frame, width = 16) {
32
+ if (total <= 0) return '░'.repeat(width)
33
+ const ratio = Math.min(1, step / total)
34
+ const filled = Math.round(ratio * width)
35
+ if (filled >= width) return '█'.repeat(width)
36
+ const before = '█'.repeat(filled)
37
+ const after = '░'.repeat(width - filled)
38
+ return `${before}${after}`
39
+ }
@@ -0,0 +1,343 @@
1
+ import { formatCatalogAge } from './catalog.js'
2
+ import { githubHtmlUrl } from './packageSources.js'
3
+
4
+ const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
5
+ function stripTags(str) {
6
+ const cleanAnsi = String(str).replace(ansiRegex, '')
7
+ return cleanAnsi.replace(/\{[^{}]+\}/g, '')
8
+ }
9
+
10
+ /** @typedef {{ pending?: boolean | undefined, colors?: Record<string, string>, columns?: Record<string, number> }} RowContext */
11
+
12
+ const COL = {
13
+ sel: 3,
14
+ name: 28,
15
+ sources: 7,
16
+ dir: 4,
17
+ publisher: 20,
18
+ meta: 14,
19
+ }
20
+
21
+ export function getColumnWidths(targetWidth) {
22
+ const showChanges = targetWidth >= 110
23
+ const changesWidth = showChanges ? 10 : 0
24
+ const spaces = showChanges ? 6 : 5
25
+ const fixed = 3 + 7 + 4 + 14 + spaces + changesWidth
26
+ const remaining = targetWidth - fixed
27
+
28
+ let nameWidth = 28
29
+ let pubWidth = 20
30
+
31
+ if (remaining < 48) {
32
+ const minName = 15
33
+ const minPub = 10
34
+ const minTotal = minName + minPub // 25
35
+ if (remaining <= minTotal) {
36
+ nameWidth = minName
37
+ pubWidth = minPub
38
+ } else {
39
+ const ratio = (remaining - minTotal) / (48 - minTotal)
40
+ nameWidth = Math.floor(minName + ratio * (28 - minName))
41
+ pubWidth = remaining - nameWidth
42
+ }
43
+ } else {
44
+ const extra = remaining - 48
45
+ const extraName = Math.floor(extra * 0.6)
46
+ const extraPub = extra - extraName
47
+ nameWidth = 28 + extraName
48
+ pubWidth = 20 + extraPub
49
+ }
50
+
51
+ return {
52
+ sel: 3,
53
+ name: nameWidth,
54
+ sources: 7,
55
+ dir: 4,
56
+ changes: changesWidth,
57
+ publisher: pubWidth,
58
+ meta: 14,
59
+ }
60
+ }
61
+
62
+
63
+ const DEFAULT_COLORS = {
64
+ npm: '#7ee787',
65
+ git: 'cyan',
66
+ local: '#ff79c6',
67
+ warn: 'yellow',
68
+ add: '#7ee787',
69
+ remove: 'red',
70
+ accent: '#7ee787',
71
+ muted: '#6c7086',
72
+ }
73
+
74
+ /**
75
+ * @param {string} text
76
+ * @param {number} width
77
+ */
78
+ export function padCell(text, width) {
79
+ const raw = String(text)
80
+ if (raw.length >= width) return raw.slice(0, width)
81
+ return raw + ' '.repeat(width - raw.length)
82
+ }
83
+
84
+ /**
85
+ * @param {object} item
86
+ */
87
+ export function selectionMarker(item) {
88
+ const pending = item.pending
89
+ const installed = item.installed
90
+ if (pending === true) return '[+]'
91
+ if (pending === false) return '[-]'
92
+ if (installed) return '[*]'
93
+ return '[ ]'
94
+ }
95
+
96
+ /**
97
+ * @param {object} item
98
+ */
99
+ export function sourceSlots(item) {
100
+ const hasNpm = Boolean(item.sourcesMeta?.npm?.version)
101
+ const hasGit = Boolean(item.htmlUrl || item.sourcesMeta?.github)
102
+ const hasLoc = Boolean(item.localSource)
103
+ return {
104
+ npm: hasNpm ? 'NPM' : '---',
105
+ git: hasGit ? 'GIT' : '---',
106
+ loc: hasLoc ? 'LOC' : '---',
107
+ }
108
+ }
109
+
110
+ /**
111
+ * @param {object} item
112
+ * @param {RowContext} [ctx]
113
+ */
114
+ export function formatSelectionMarker(item, colors = DEFAULT_COLORS) {
115
+ const pending = item.pending
116
+ const installed = item.installed
117
+ if (pending === true) return `{${colors.muted}-fg}[{/}{${colors.add}-fg}+{/}{${colors.muted}-fg}]{/}`
118
+ if (pending === false) return `{${colors.muted}-fg}[{/}{${colors.remove}-fg}-{/}{${colors.muted}-fg}]{/}`
119
+ if (installed) return `{${colors.muted}-fg}[{/}{${colors.accent}-fg}*{/}{${colors.muted}-fg}]{/}`
120
+ return `{${colors.muted}-fg}[ ]{/}`
121
+ }
122
+
123
+ /**
124
+ * @param {object} item
125
+ * @param {RowContext} [ctx]
126
+ */
127
+ export function formatCatalogRowPlain(item, ctx = {}) {
128
+ const colors = { ...DEFAULT_COLORS, ...ctx.colors }
129
+ const columns = { ...COL, ...ctx.columns }
130
+ const slots = sourceSlots(item)
131
+ const publisher = item.org && item.org !== 'workspace' ? item.org : 'owdproject'
132
+ const stars = item.stars > 0 ? `*${item.stars}` : ''
133
+ const age = formatCatalogAge(item.updatedAt ?? item.pushedAt)
134
+
135
+ // 1. Status / Selection Marker (3 chars)
136
+ const selTag = formatSelectionMarker(item, colors)
137
+
138
+ // 2. Name Column (columns.name chars)
139
+ let badgeText = (item.isNew || item.isRecent) ? ' new' : ''
140
+ const maxNameLen = columns.name - badgeText.length
141
+ let namePart = item.shortName
142
+ if (namePart.length > maxNameLen) {
143
+ namePart = namePart.slice(0, maxNameLen)
144
+ }
145
+ let nameTag = `{bold}{${colors.accent}-fg}${namePart}{/}{/}`
146
+ if (badgeText) {
147
+ nameTag += `{${colors.warn}-fg}${badgeText}{/}`
148
+ }
149
+ const namePadLen = columns.name - namePart.length - badgeText.length
150
+ if (namePadLen > 0) {
151
+ nameTag += ' '.repeat(namePadLen)
152
+ }
153
+
154
+ // 3. Sources Column (7 chars)
155
+ const srcNpm =
156
+ slots.npm === 'NPM' ? `{${colors.npm}-fg}${slots.npm}{/}` : `{${colors.muted}-fg}${slots.npm}{/}`
157
+ const srcGit =
158
+ slots.git === 'GIT' ? `{${colors.git}-fg}${slots.git}{/}` : `{${colors.muted}-fg}${slots.git}{/}`
159
+ const sources = `${srcNpm} ${srcGit}`
160
+
161
+ // 3.5 DIR Column (4 chars)
162
+ let dirTag = ''
163
+ const isMissing = item.installed && !item.localSource && !item.inPackageJson
164
+ if (isMissing) {
165
+ dirTag = `{red-fg}MISS{/}`
166
+ } else if (item.localSource) {
167
+ dirTag = `{${colors.npm}-fg}OK{/} `
168
+ } else {
169
+ dirTag = `{${colors.muted}-fg}---{/} `
170
+ }
171
+
172
+ // 4. Publisher Column (columns.publisher chars)
173
+ let pubPart = publisher
174
+ if (pubPart.length > columns.publisher) {
175
+ pubPart = pubPart.slice(0, columns.publisher)
176
+ }
177
+ const pubColor = item.trusted === false ? colors.warn : colors.muted
178
+ let pubTag = `{${pubColor}-fg}${pubPart}{/}`
179
+ const pubPadLen = columns.publisher - pubPart.length
180
+ if (pubPadLen > 0) {
181
+ pubTag += ' '.repeat(pubPadLen)
182
+ }
183
+
184
+ // 5. Meta Column (columns.meta chars)
185
+ let finalStars = stars
186
+ let finalAge = age
187
+ const metaText = [stars, age].filter(Boolean).join(' ')
188
+ if (metaText.length > columns.meta) {
189
+ if (stars && age) {
190
+ const allowedAgeLen = columns.meta - stars.length - 1
191
+ if (allowedAgeLen > 0) {
192
+ finalAge = age.slice(0, allowedAgeLen)
193
+ } else {
194
+ finalAge = ''
195
+ finalStars = stars.slice(0, columns.meta)
196
+ }
197
+ } else if (stars) {
198
+ finalStars = stars.slice(0, columns.meta)
199
+ } else if (age) {
200
+ finalAge = age.slice(0, columns.meta)
201
+ }
202
+ }
203
+ const finalMetaText = [finalStars, finalAge].filter(Boolean).join(' ')
204
+ const metaPadLen = Math.max(0, columns.meta - finalMetaText.length)
205
+ let metaTag = ' '.repeat(metaPadLen)
206
+ if (finalStars && finalAge) {
207
+ metaTag += `{yellow-fg}${finalStars}{/} {${colors.muted}-fg}${finalAge}{/}`
208
+ } else if (finalStars) {
209
+ metaTag += `{yellow-fg}${finalStars}{/}`
210
+ } else if (finalAge) {
211
+ metaTag += `{${colors.muted}-fg}${finalAge}{/}`
212
+ }
213
+
214
+ let changesTag = ''
215
+ if (columns.changes > 0) {
216
+ const parts = []
217
+
218
+ // Local Git changes
219
+ if (item.localSource && ctx.localGitChanges && ctx.localGitChanges.has(item.shortName)) {
220
+ const ch = ctx.localGitChanges.get(item.shortName)
221
+ if (ch.added > 0) parts.push(`{${colors.add}-fg}+${ch.added}{/}`)
222
+ if (ch.modified > 0) parts.push(`{${colors.warn}-fg}~${ch.modified}{/}`)
223
+ if (ch.deleted > 0) parts.push(`{${colors.remove}-fg}-${ch.deleted}{/}`)
224
+ }
225
+
226
+ // Upstream Updates
227
+ if (ctx.packageUpdates && ctx.packageUpdates.has(item.shortName)) {
228
+ const updateInfo = ctx.packageUpdates.get(item.shortName)
229
+ if (updateInfo && updateInfo.hasUpdate) {
230
+ if (updateInfo.localGit) {
231
+ const count = updateInfo.behindCount ? `${updateInfo.behindCount}` : ''
232
+ parts.push(`{cyan-fg}↓${count}{/}`)
233
+ } else if (updateInfo.npm) {
234
+ parts.push(`{${colors.npm}-fg}↑{/}`)
235
+ }
236
+ }
237
+ }
238
+
239
+ const txt = parts.join(' ')
240
+ const rawLen = stripTags(txt).length
241
+ changesTag = txt + ' '.repeat(Math.max(0, columns.changes - rawLen))
242
+ }
243
+
244
+ // Join columns with exactly one space (matching header format)
245
+ const cols = [selTag, nameTag, sources, dirTag]
246
+ if (columns.changes > 0) {
247
+ cols.push(changesTag)
248
+ }
249
+ cols.push(pubTag, metaTag)
250
+ return cols.join(' ')
251
+ }
252
+
253
+ /**
254
+ * @param {Record<string, string>} [colors]
255
+ */
256
+ export function formatLegendLine(colors = DEFAULT_COLORS) {
257
+ const c = { ...DEFAULT_COLORS, ...colors }
258
+ return [
259
+ `{${c.add}-fg}[+]{/} add`,
260
+ `{${c.remove}-fg}[-]{/} remove`,
261
+ `{${c.accent}-fg}[*]{/} on desktop`,
262
+ '|',
263
+ `{${c.npm}-fg}NPM{/} registry`,
264
+ `{${c.git}-fg}GIT{/} repo`,
265
+ `{${c.local}-fg}LOC{/} workspace`,
266
+ '|',
267
+ `{${c.npm}-fg}OK{/} folder exists`,
268
+ `{red-fg}MISS{/} folder missing`,
269
+ '|',
270
+ `{cyan-fg}↓{/} git pull`,
271
+ `{${c.npm}-fg}↑{/} npm update`,
272
+ ].join(' ')
273
+ }
274
+
275
+ /**
276
+ * @param {object} item
277
+ * @param {string} targetDir
278
+ * @param {Record<string, string>} [colors]
279
+ * @param {number} [width]
280
+ */
281
+ export function formatDetailPanel(item, targetDir, colors = DEFAULT_COLORS, width = 76) {
282
+ const c = { ...DEFAULT_COLORS, ...colors }
283
+ const owner = item.org && item.org !== 'workspace' ? item.org : 'owdproject'
284
+ const lines = [`{bold}{${c.accent}-fg}${item.shortName}{/}{/}`]
285
+
286
+ const sourceParts = []
287
+ if (item.sourcesMeta?.npm?.version) {
288
+ sourceParts.push(`{${c.npm}-fg}NPM{/} ${item.sourcesMeta.npm.version}`)
289
+ }
290
+ const gitUrl = item.htmlUrl ?? githubHtmlUrl(owner, item.shortName)
291
+ sourceParts.push(`{${c.git}-fg}GIT{/} ${gitUrl}`)
292
+ if (item.localSource) {
293
+ sourceParts.push(`{${c.local}-fg}LOC{/} ${targetDir}/`)
294
+ }
295
+ lines.push(` Sources ..... ${sourceParts.join(' ')}`)
296
+
297
+ const pub =
298
+ item.trusted === false
299
+ ? `{${c.warn}-fg}${owner} (untrusted){/}`
300
+ : owner
301
+ lines.push(` Publisher ... ${pub}`)
302
+
303
+ const starsStr = item.stars > 0 ? `{yellow-fg}★${item.stars}{/}` : '0'
304
+ const dateStr = item.updatedAt ? new Date(item.updatedAt).toLocaleDateString() : '—'
305
+ const kindStr = item.kind ? item.kind.toUpperCase() : '—'
306
+ lines.push(` Stats ....... Stars: ${starsStr} · Updated: ${dateStr} · Type: ${kindStr}`)
307
+
308
+ if (item.localSource) {
309
+ // Show update/change status in details if available
310
+ }
311
+
312
+ if (!item.localSource) {
313
+ const cloneText = ` Clone ....... ${targetDir}/`
314
+ const maxCloneLen = Math.max(15, width - 2)
315
+ const truncatedCloneText = cloneText.length > maxCloneLen ? cloneText.slice(0, maxCloneLen - 3) + '...' : cloneText
316
+ lines.push(` {${c.muted}-fg}${truncatedCloneText}{/}`)
317
+ }
318
+
319
+ if (item.description) {
320
+ const maxDescLen = Math.max(15, width - 4)
321
+ lines.push(` {${c.muted}-fg}${item.description.slice(0, maxDescLen)}{/}`)
322
+ }
323
+
324
+ const isMissing = item.installed && !item.localSource && !item.inPackageJson
325
+ if (isMissing) {
326
+ lines.push(` {red-fg}{bold}⚠ WARNING: Local workspace folder is missing!{/}{/}`)
327
+ }
328
+
329
+ return lines.join('\n')
330
+ }
331
+
332
+ export function formatHeaderLine(colors = DEFAULT_COLORS, columns = COL) {
333
+ const status = ' '.repeat(columns.sel || 3) + ' '
334
+ const name = 'NAME'.padEnd(columns.name || 28, ' ') + ' '
335
+ const sources = 'SOURCES'.padEnd(columns.sources || 7, ' ') + ' '
336
+ const dir = 'DIR'.padEnd(columns.dir || 4, ' ') + ' '
337
+ const changes = columns.changes > 0 ? 'SYNC'.padEnd(columns.changes, ' ') + ' ' : ''
338
+ const publisher = 'PUBLISHER'.padEnd(columns.publisher || 20, ' ') + ' '
339
+ const meta = 'STARS/AGE'.padStart(columns.meta || 14, ' ')
340
+ return `{bold}${status}${name}${sources}${dir}${changes}${publisher}${meta}{/}`
341
+ }
342
+
343
+ export { COL as TUI_COLUMN_WIDTHS }
@@ -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
+ }
@@ -0,0 +1,48 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export const DESKTOP_CONFIG_FILENAME = 'desktop.config.ts'
5
+ export const LEGACY_DESKTOP_CONFIG_FILENAME = 'owd.config.ts'
6
+
7
+ export const LEGACY_CONFIG_DEPRECATION =
8
+ '[@owdproject/core] owd.config.ts is deprecated and kept only for backward compatibility; rename to desktop.config.ts (required from @owdproject/core 3.2).'
9
+
10
+ export function resolveDesktopConfigPath(rootDir) {
11
+ const desktopPath = join(rootDir, DESKTOP_CONFIG_FILENAME)
12
+ const legacyPath = join(rootDir, LEGACY_DESKTOP_CONFIG_FILENAME)
13
+ const hasDesktop = existsSync(desktopPath)
14
+ const hasLegacy = existsSync(legacyPath)
15
+
16
+ if (hasDesktop && hasLegacy) {
17
+ console.warn(
18
+ '[@owdproject/core] Both desktop.config.ts and owd.config.ts exist; using desktop.config.ts. Remove owd.config.ts.',
19
+ )
20
+ return { path: desktopPath, file: DESKTOP_CONFIG_FILENAME, legacy: false }
21
+ }
22
+
23
+ if (hasDesktop) {
24
+ return { path: desktopPath, file: DESKTOP_CONFIG_FILENAME, legacy: false }
25
+ }
26
+
27
+ if (hasLegacy) {
28
+ return {
29
+ path: legacyPath,
30
+ file: LEGACY_DESKTOP_CONFIG_FILENAME,
31
+ legacy: true,
32
+ }
33
+ }
34
+
35
+ return null
36
+ }
37
+
38
+ export function warnLegacyDesktopConfig(resolved) {
39
+ if (resolved?.legacy) {
40
+ console.warn(LEGACY_CONFIG_DEPRECATION)
41
+ }
42
+ }
43
+
44
+ /** Path used when creating or updating config from the CLI (always the new filename). */
45
+ export function desktopConfigWritePath(desktopDir) {
46
+ return join(desktopDir, DESKTOP_CONFIG_FILENAME)
47
+ }
48
+