@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,205 @@
1
+ import { execSync, spawnSync } from 'node:child_process'
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { basename, dirname, join, resolve } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url))
16
+ const CLIENT_TEMPLATE = resolve(__dirname, '../../../../template')
17
+ const DEFAULT_CLIENT_REPO = 'https://github.com/owdproject/client.git'
18
+
19
+ /**
20
+ * @param {string} [startDir]
21
+ */
22
+ export function resolveLocalTemplatePath(startDir = process.cwd()) {
23
+ const fromEnv = process.env.OWD_TEMPLATE_PATH?.trim()
24
+ if (fromEnv && existsSync(join(fromEnv, 'package.json'))) {
25
+ return resolve(fromEnv)
26
+ }
27
+
28
+ if (existsSync(join(CLIENT_TEMPLATE, 'package.json'))) {
29
+ return CLIENT_TEMPLATE
30
+ }
31
+
32
+ let dir = startDir
33
+ for (;;) {
34
+ const candidate = join(dir, 'template')
35
+ if (
36
+ existsSync(join(candidate, 'package.json')) &&
37
+ existsSync(join(dir, 'pnpm-workspace.yaml'))
38
+ ) {
39
+ return candidate
40
+ }
41
+ const parent = dirname(dir)
42
+ if (parent === dir) break
43
+ dir = parent
44
+ }
45
+
46
+ return null
47
+ }
48
+
49
+ /**
50
+ * @param {string} templatePath
51
+ * @param {string} targetDir
52
+ */
53
+ export function copyTemplate(templatePath, targetDir) {
54
+ cpSync(templatePath, targetDir, { recursive: true, filter: (src) => !src.includes('node_modules') })
55
+ }
56
+
57
+ /**
58
+ * @param {string} targetDir
59
+ * @param {{ repo?: string, templatePath?: string | null, cwd?: string }} [options]
60
+ */
61
+ export async function cloneTemplate(targetDir, options = {}) {
62
+ const setupType = options.setupType ?? 'template'
63
+
64
+ if (setupType === 'clone') {
65
+ const repo = options.repo ?? DEFAULT_CLIENT_REPO
66
+ console.log()
67
+ execSync(`git clone --depth 1 ${JSON.stringify(repo)} ${JSON.stringify(targetDir)}`, {
68
+ stdio: 'inherit',
69
+ })
70
+ return { source: 'git', path: repo }
71
+ }
72
+
73
+ const localTemplate = options.templatePath ?? resolveLocalTemplatePath(options.cwd)
74
+
75
+ if (localTemplate) {
76
+ copyTemplate(localTemplate, targetDir)
77
+ return { source: 'local', path: localTemplate }
78
+ }
79
+
80
+ const repo = options.repo ?? DEFAULT_CLIENT_REPO
81
+ const tempParent = mkdtempSync(join(tmpdir(), 'owd-scaffold-'))
82
+ const tempClone = join(tempParent, 'client')
83
+
84
+ try {
85
+ console.log()
86
+ execSync(`git clone --depth 1 ${JSON.stringify(repo)} ${JSON.stringify(tempClone)}`, {
87
+ stdio: 'inherit',
88
+ })
89
+
90
+ const templatePath = join(tempClone, 'template')
91
+ if (!existsSync(templatePath)) {
92
+ throw new Error('Template directory does not exist in the cloned repository.')
93
+ }
94
+
95
+ copyTemplate(templatePath, targetDir)
96
+ return { source: 'git', path: repo }
97
+ } finally {
98
+ try {
99
+ rmSync(tempParent, { recursive: true, force: true })
100
+ } catch {
101
+ /* ignore */
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * @param {string} targetDir
108
+ */
109
+ export function runPostScaffold(targetDir) {
110
+ execSync('pnpm install', { cwd: targetDir, stdio: 'inherit' })
111
+ }
112
+
113
+ export function canLaunchUi() {
114
+ return Boolean(process.stdout.isTTY) && !process.env.CI
115
+ }
116
+
117
+ /**
118
+ * @param {string} targetDir
119
+ * @param {string} [commandName]
120
+ */
121
+ export function launchControlPanel(targetDir, commandName = 'desktop') {
122
+ const relDir = basename(targetDir)
123
+
124
+ if (!canLaunchUi()) {
125
+ console.log(`\nNext: cd ${relDir} && pnpm desktop\n`)
126
+ return false
127
+ }
128
+
129
+ console.log('\nOpening control panel…\n')
130
+ const result = spawnSync('pnpm', ['exec', commandName], {
131
+ cwd: targetDir,
132
+ stdio: 'inherit',
133
+ shell: false,
134
+ })
135
+
136
+ if (result.status !== 0 && result.status !== null) {
137
+ console.log(`\nControl panel exited. Run again with: cd ${relDir} && pnpm desktop\n`)
138
+ }
139
+
140
+ return true
141
+ }
142
+
143
+ /**
144
+ * @param {{ dir?: string, cwd?: string, commandName?: string, skipInstall?: boolean, skipUi?: boolean, repo?: string, setupType?: 'template' | 'clone' }} [options]
145
+ */
146
+ export async function scaffoldProject(options = {}) {
147
+ const cwd = options.cwd ?? process.cwd()
148
+ const dir = (options.dir ?? 'client').trim()
149
+ const commandName = options.commandName ?? 'desktop'
150
+ const setupType = options.setupType ?? 'template'
151
+
152
+ if (!dir) {
153
+ throw new Error('Project directory name is required.')
154
+ }
155
+
156
+ const targetDir = resolve(cwd, dir)
157
+
158
+ if (existsSync(targetDir)) {
159
+ if (existsSync(join(targetDir, 'package.json'))) {
160
+ throw new Error(`Directory "${dir}" already exists and contains a package.json.`)
161
+ }
162
+ throw new Error(`Directory "${dir}" already exists.`)
163
+ }
164
+
165
+ if (setupType !== 'clone') {
166
+ mkdirSync(targetDir, { recursive: true })
167
+ }
168
+
169
+ const cloneResult = await cloneTemplate(targetDir, {
170
+ repo: options.repo,
171
+ cwd,
172
+ setupType,
173
+ })
174
+
175
+ if (cloneResult.source === 'local') {
176
+ console.log(`Using local template: ${cloneResult.path}\n`)
177
+ }
178
+
179
+ if (!options.skipInstall) {
180
+ console.log('Installing dependencies (pnpm install)…\n')
181
+ runPostScaffold(targetDir)
182
+ }
183
+
184
+ console.log('\n✓ Project ready\n')
185
+
186
+ if (!options.skipUi) {
187
+ launchControlPanel(targetDir, commandName)
188
+ }
189
+
190
+ console.log(` cd ${dir}\n`)
191
+ console.log(' pnpm install')
192
+ console.log(' pnpm desktop\n')
193
+
194
+ return { targetDir, dir }
195
+ }
196
+
197
+ /** Copy scaffold.js into create-owd for npm publish (run from repo root). */
198
+ export function vendorScaffoldForCreateOwd(createOwdRoot) {
199
+ const src = join(__dirname, 'scaffold.js')
200
+ const destDir = join(createOwdRoot, 'src/lib')
201
+ const dest = join(destDir, 'scaffold.js')
202
+ mkdirSync(destDir, { recursive: true })
203
+ writeFileSync(dest, readFileSync(src, 'utf8'))
204
+ return dest
205
+ }
@@ -0,0 +1,317 @@
1
+ import { execSync, spawn } from 'node:child_process'
2
+ import {
3
+ existsSync,
4
+ readFileSync,
5
+ mkdirSync,
6
+ writeFileSync,
7
+ unlinkSync,
8
+ appendFileSync,
9
+ } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { desktopMetaDir } from './workspace.js'
12
+ import {
13
+ devSpawnCwd,
14
+ devTargetLogLabel,
15
+ normalizeDevTarget,
16
+ } from './playgroundContext.js'
17
+
18
+ function sleep(ms) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms))
20
+ }
21
+
22
+ export function devPidPath(workspaceRoot) {
23
+ return join(desktopMetaDir(workspaceRoot), 'dev.pid')
24
+ }
25
+
26
+ export function devLogPath(workspaceRoot) {
27
+ return join(desktopMetaDir(workspaceRoot), 'dev.log')
28
+ }
29
+
30
+ function readPidFile(workspaceRoot) {
31
+ const path = devPidPath(workspaceRoot)
32
+ if (!existsSync(path)) return null
33
+ const pid = Number.parseInt(readFileSync(path, 'utf8').trim(), 10)
34
+ if (!Number.isFinite(pid)) return null
35
+ try {
36
+ process.kill(pid, 0)
37
+ return pid
38
+ } catch {
39
+ try {
40
+ unlinkSync(path)
41
+ } catch {
42
+ /* ignore */
43
+ }
44
+ return null
45
+ }
46
+ }
47
+
48
+ export function findDevProcess() {
49
+ const patterns = [
50
+ 'nuxt dev',
51
+ 'nuxi dev',
52
+ 'nx run desktop:serve',
53
+ 'node.*\\.nuxt',
54
+ 'desktop.*nuxt',
55
+ ]
56
+
57
+ for (const pattern of patterns) {
58
+ try {
59
+ const out = execSync(`pgrep -af ${JSON.stringify(pattern)}`, {
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'ignore'],
62
+ })
63
+ for (const line of out.split('\n').filter(Boolean)) {
64
+ if (line.includes('pgrep')) continue
65
+ const pid = Number.parseInt(line.trim().split(/\s+/)[0], 10)
66
+ if (Number.isFinite(pid)) return { pid, cmdline: line.trim() }
67
+ }
68
+ } catch {
69
+ /* try next pattern */
70
+ }
71
+ }
72
+
73
+ return null
74
+ }
75
+
76
+ export function findProcessOnPort(port) {
77
+ try {
78
+ const out = execSync(`ss -tlnp 2>/dev/null | grep :${port} || lsof -i :${port} -t 2>/dev/null | head -1`, {
79
+ encoding: 'utf8',
80
+ stdio: ['ignore', 'pipe', 'ignore'],
81
+ })
82
+ const pidMatch = out.match(/pid=(\d+)/) ?? out.match(/^(\d+)/m)
83
+ if (pidMatch) {
84
+ const pid = Number.parseInt(pidMatch[1], 10)
85
+ if (Number.isFinite(pid)) return pid
86
+ }
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ return null
91
+ }
92
+
93
+ export function readProcessStats(pid) {
94
+ if (!pid || !existsSync(`/proc/${pid}/status`)) {
95
+ return { memMb: 0, threads: 0, rssKb: 0 }
96
+ }
97
+
98
+ try {
99
+ const status = readFileSync(`/proc/${pid}/status`, 'utf8')
100
+ const vmRss = status.match(/^VmRSS:\s+(\d+)/m)?.[1]
101
+ const threads = status.match(/^Threads:\s+(\d+)/m)?.[1]
102
+ const rssKb = vmRss ? Number(vmRss) : 0
103
+ return {
104
+ memMb: rssKb ? Math.round(rssKb / 1024) : 0,
105
+ rssKb,
106
+ threads: threads ? Number(threads) : 0,
107
+ }
108
+ } catch {
109
+ return { memMb: 0, threads: 0, rssKb: 0 }
110
+ }
111
+ }
112
+
113
+ const SPARK_CHARS = '▁▂▃▄▅▆▇█'
114
+
115
+ /** @param {number[]} samples RSS in MiB */
116
+ export function formatSparkline(samples) {
117
+ if (!samples.length) return '—'
118
+ const min = Math.min(...samples)
119
+ const max = Math.max(...samples)
120
+ const span = max - min || 1
121
+ return samples
122
+ .map((v) => {
123
+ const idx = Math.min(
124
+ SPARK_CHARS.length - 1,
125
+ Math.floor(((v - min) / span) * (SPARK_CHARS.length - 1)),
126
+ )
127
+ return SPARK_CHARS[idx]
128
+ })
129
+ .join('')
130
+ }
131
+
132
+ /** @param {number} value @param {number} max @param {number} [width] */
133
+ export function formatBar(value, max, width = 12) {
134
+ if (max <= 0) return '░'.repeat(width)
135
+ const filled = Math.round((value / max) * width)
136
+ return '█'.repeat(Math.min(width, filled)) + '░'.repeat(Math.max(0, width - filled))
137
+ }
138
+
139
+ export async function probeDevServer(port) {
140
+ const url = `http://127.0.0.1:${port}/`
141
+ try {
142
+ const controller = new AbortController()
143
+ const timer = setTimeout(() => controller.abort(), 1200)
144
+ const res = await fetch(url, { signal: controller.signal, redirect: 'manual' })
145
+ clearTimeout(timer)
146
+ return { up: true, status: res.status, url }
147
+ } catch {
148
+ return { up: false, status: 0, url }
149
+ }
150
+ }
151
+
152
+ export async function getClientStatus(workspaceRoot, port) {
153
+ const http = await probeDevServer(port)
154
+ const fromFile = readPidFile(workspaceRoot)
155
+ const fromPg = findDevProcess()
156
+ const portPid = findProcessOnPort(port)
157
+
158
+ const pid = fromPg?.pid ?? portPid ?? fromFile ?? null
159
+ const stats = pid ? readProcessStats(pid) : { memMb: 0, threads: 0 }
160
+
161
+ const isPidRunning = (p) => {
162
+ if (!p) return false
163
+ try {
164
+ process.kill(p, 0)
165
+ return true
166
+ } catch (err) {
167
+ return err.code === 'EPERM'
168
+ }
169
+ }
170
+
171
+ const running = http.up ||
172
+ (fromPg && isPidRunning(fromPg.pid)) ||
173
+ (portPid && isPidRunning(portPid)) ||
174
+ (fromFile && isPidRunning(fromFile))
175
+
176
+ return {
177
+ running: Boolean(running),
178
+ pid,
179
+ http,
180
+ stats,
181
+ port,
182
+ cmdline: fromPg?.cmdline ?? null,
183
+ }
184
+ }
185
+
186
+ let devChild = null
187
+
188
+ /**
189
+ * @param {import('./playgroundContext.js').DevTarget | string} target
190
+ */
191
+ export function startDev(target) {
192
+ const resolved = normalizeDevTarget(target)
193
+ if (!resolved) {
194
+ throw new Error('startDev: missing workspace root')
195
+ }
196
+
197
+ const { workspaceRoot } = resolved
198
+ const spawnCwd = devSpawnCwd(resolved)
199
+
200
+ if (devChild?.pid) {
201
+ try {
202
+ process.kill(devChild.pid, 0)
203
+ return devChild
204
+ } catch {
205
+ devChild = null
206
+ }
207
+ }
208
+
209
+ mkdirSync(desktopMetaDir(workspaceRoot), { recursive: true })
210
+ const logPath = devLogPath(workspaceRoot)
211
+
212
+ devChild = spawn('pnpm', ['run', 'dev'], {
213
+ cwd: spawnCwd,
214
+ detached: true,
215
+ stdio: ['ignore', 'pipe', 'pipe'],
216
+ })
217
+
218
+ const appendLog = (chunk) => {
219
+ try {
220
+ appendFileSync(logPath, chunk)
221
+ } catch {
222
+ /* ignore */
223
+ }
224
+ }
225
+ try {
226
+ appendFileSync(
227
+ logPath,
228
+ `[owd] dev target: ${devTargetLogLabel(resolved)} (cwd ${spawnCwd})\n`,
229
+ )
230
+ } catch {
231
+ /* ignore */
232
+ }
233
+ devChild.stdout?.on('data', appendLog)
234
+ devChild.stderr?.on('data', appendLog)
235
+
236
+ devChild.unref()
237
+ writeFileSync(devPidPath(workspaceRoot), String(devChild.pid))
238
+ return devChild
239
+ }
240
+
241
+ export function stopDev(workspaceRoot, pid) {
242
+ const fromPg = findDevProcess()
243
+ const targets = new Set([pid, fromPg?.pid, readPidFile(workspaceRoot)].filter(Boolean))
244
+
245
+ for (const p of targets) {
246
+ try {
247
+ process.kill(-p, 'SIGTERM')
248
+ } catch {
249
+ try {
250
+ process.kill(p, 'SIGTERM')
251
+ } catch {
252
+ /* ignore */
253
+ }
254
+ }
255
+ }
256
+
257
+ try {
258
+ unlinkSync(devPidPath(workspaceRoot))
259
+ } catch {
260
+ /* ignore */
261
+ }
262
+
263
+ devChild = null
264
+ return targets.size > 0
265
+ }
266
+
267
+ export async function waitForDevStop(workspaceRoot, port, timeoutMs = 10_000) {
268
+ const started = Date.now()
269
+ while (Date.now() - started < timeoutMs) {
270
+ const status = await getClientStatus(workspaceRoot, port)
271
+ if (!status.running && !status.http.up) return status
272
+ await sleep(500)
273
+ }
274
+ return getClientStatus(workspaceRoot, port)
275
+ }
276
+
277
+ export async function waitForDev(workspaceRoot, port, timeoutMs = 90_000) {
278
+ const started = Date.now()
279
+ while (Date.now() - started < timeoutMs) {
280
+ const status = await getClientStatus(workspaceRoot, port)
281
+ if (status.http.up) return status
282
+ if (Date.now() - started > 5000 && !status.running) {
283
+ return status
284
+ }
285
+ await sleep(1500)
286
+ }
287
+ return getClientStatus(workspaceRoot, port)
288
+ }
289
+
290
+ export function runScript(workspaceRoot, script) {
291
+ return spawn('pnpm', ['run', script], {
292
+ cwd: workspaceRoot,
293
+ stdio: 'inherit',
294
+ shell: false,
295
+ })
296
+ }
297
+
298
+ /** Run dev in the foreground (`desktop dev`). */
299
+ export function runDevForeground(target) {
300
+ const resolved = normalizeDevTarget(target)
301
+ if (!resolved) {
302
+ console.error('Not inside an OWD workspace.')
303
+ process.exit(1)
304
+ }
305
+
306
+ const spawnCwd = devSpawnCwd(resolved)
307
+ if (resolved.mode === 'playground' && resolved.packageName) {
308
+ console.log(`Starting playground: ${resolved.packageName}`)
309
+ }
310
+
311
+ const child = spawn('pnpm', ['run', 'dev'], {
312
+ cwd: spawnCwd,
313
+ stdio: 'inherit',
314
+ shell: false,
315
+ })
316
+ child.on('exit', (code) => process.exit(code ?? 0))
317
+ }