@elytracms/cli 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 finaldream
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @elytracms/cli
2
+
3
+ The Elytra CLI — run an [Elytra](https://www.npmjs.com/package/@elytracms/core)
4
+ consuming repo's studio, delivery frontend, and Convex backend under one command.
5
+
6
+ Installed as a devDependency in projects scaffolded by
7
+ [`create-elytra`](https://www.npmjs.com/package/create-elytra); its `dev`/`build`
8
+ scripts call `elytra`.
9
+
10
+ ## Commands
11
+
12
+ ```
13
+ elytra dev Start the studio + frontend (and Convex in connected mode)
14
+ together as one supervised process group. One Ctrl-C stops
15
+ everything; if any process exits, the rest are torn down.
16
+
17
+ elytra build Build the studio + frontend (and deploy Convex in connected
18
+ mode), in order — failing as a whole if any step fails.
19
+
20
+ elytra help Show help
21
+ elytra version Show version
22
+ ```
23
+
24
+ Run from anywhere inside an Elytra project (a directory with `elytra.config.ts`).
25
+
26
+ ## Mode-aware
27
+
28
+ The CLI reads the studio's `.env` (the same backend signal the studio itself
29
+ uses):
30
+
31
+ - **Playground** (`VITE_ELYTRA_MODE=playground`) — runs the studio + frontend
32
+ only; no Convex.
33
+ - **Connected** (`VITE_CONVEX_URL` set) — also runs `convex dev` (or deploys on
34
+ build) once a deployment is provisioned (`CONVEX_DEPLOYMENT` present).
35
+
36
+ It orchestrates and owns the process lifecycle; it does not reimplement Vite,
37
+ Next, or Convex — each step shells out to the underlying tool and inherits its
38
+ watch/HMR/build behaviour.
package/index.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { argv, exit } from 'node:process'
3
+ import { run } from './src/cli.js'
4
+
5
+ run(argv.slice(2))
6
+ .then((code) => exit(code))
7
+ .catch((error) => {
8
+ console.error(`\nelytra failed: ${error?.message ?? error}`)
9
+ exit(1)
10
+ })
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@elytracms/cli",
3
+ "author": "finaldream <hi@finaldream.de>",
4
+ "version": "0.0.1",
5
+ "description": "The Elytra CLI — run a consuming repo's studio, frontend, and Convex backend under one command.",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "bin": {
9
+ "elytra": "./index.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "devDependencies": {
20
+ "vitest": "^3.0.0"
21
+ },
22
+ "scripts": {
23
+ "start": "node ./index.js",
24
+ "test": "vitest run"
25
+ }
26
+ }
package/src/cli.js ADDED
@@ -0,0 +1,133 @@
1
+ import { join } from 'node:path'
2
+ import { stdout } from 'node:process'
3
+ import { inspectProject } from './project.js'
4
+ import { runGroup, runSequential } from './run.js'
5
+
6
+ const HELP = `
7
+ elytra — run an Elytra project's studio, frontend, and backend under one command
8
+
9
+ Usage:
10
+ elytra <command>
11
+
12
+ Commands:
13
+ dev Start the studio + frontend (and Convex in connected mode) together.
14
+ One Ctrl-C stops everything.
15
+ build Build the studio + frontend (and deploy Convex in connected mode),
16
+ in order, failing as a whole if any step fails.
17
+ help Show this help
18
+ version Show version
19
+
20
+ Run from anywhere inside an Elytra project (a directory with elytra.config.ts).
21
+ `.trimStart()
22
+
23
+ function log(line = '') {
24
+ stdout.write(`${line}\n`)
25
+ }
26
+
27
+ function reportIssues(issues) {
28
+ log('Cannot continue:')
29
+ for (const issue of issues) log(` • ${issue}`)
30
+ }
31
+
32
+ /** Build the dev process group from a project inspection. */
33
+ export function planDev(project) {
34
+ const procs = []
35
+ const studioDir = join(project.root, 'studio')
36
+ const frontendDir = join(project.root, 'frontend')
37
+
38
+ if (project.apps.studio && project.mode === 'connected') {
39
+ if (project.convexProvisioned) {
40
+ procs.push({ name: 'convex', command: 'pnpm', args: ['run', 'convex:dev'], cwd: studioDir })
41
+ }
42
+ }
43
+ if (project.apps.studio) {
44
+ procs.push({ name: 'studio', command: 'pnpm', args: ['run', 'dev'], cwd: studioDir })
45
+ }
46
+ if (project.apps.frontend) {
47
+ procs.push({ name: 'frontend', command: 'pnpm', args: ['run', 'dev'], cwd: frontendDir })
48
+ }
49
+ return procs
50
+ }
51
+
52
+ /** Build the ordered build steps from a project inspection. */
53
+ export function planBuild(project) {
54
+ const steps = []
55
+ const studioDir = join(project.root, 'studio')
56
+ const frontendDir = join(project.root, 'frontend')
57
+
58
+ if (project.mode === 'connected' && project.convexProvisioned) {
59
+ steps.push({ name: 'convex deploy', command: 'pnpm', args: ['exec', 'convex', 'deploy'], cwd: studioDir })
60
+ }
61
+ if (project.apps.studio) {
62
+ steps.push({ name: 'studio build', command: 'pnpm', args: ['run', 'build'], cwd: studioDir })
63
+ }
64
+ if (project.apps.frontend) {
65
+ steps.push({ name: 'frontend build', command: 'pnpm', args: ['run', 'build'], cwd: frontendDir })
66
+ }
67
+ return steps
68
+ }
69
+
70
+ function noteMode(project) {
71
+ if (project.mode === 'playground') {
72
+ log('Mode: playground (in-memory, no backend). Convex is not started.')
73
+ } else if (project.mode === 'connected' && !project.convexProvisioned) {
74
+ log(
75
+ 'Mode: connected, but no Convex deployment is provisioned yet. Skipping the\n' +
76
+ 'backend — run `cd studio && npx convex dev` once to provision it. See CONNECT.md.',
77
+ )
78
+ } else if (project.mode === 'unconfigured') {
79
+ log(
80
+ 'Backend not configured. Defaulting to the frontends only. Set VITE_ELYTRA_MODE=\n' +
81
+ 'playground or VITE_CONVEX_URL in studio/.env. See CONNECT.md.',
82
+ )
83
+ }
84
+ }
85
+
86
+ export async function run(argv, { cwd = process.cwd(), env = process.env } = {}) {
87
+ const command = argv[0] ?? 'help'
88
+
89
+ if (command === 'help' || command === '-h' || command === '--help') {
90
+ log(HELP)
91
+ return 0
92
+ }
93
+ if (command === 'version' || command === '--version') {
94
+ const { readFileSync } = await import('node:fs')
95
+ const { fileURLToPath } = await import('node:url')
96
+ const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url))
97
+ log(JSON.parse(readFileSync(pkgPath, 'utf8')).version)
98
+ return 0
99
+ }
100
+
101
+ if (command !== 'dev' && command !== 'build') {
102
+ log(`Unknown command: ${command}\n`)
103
+ log(HELP)
104
+ return 1
105
+ }
106
+
107
+ const project = inspectProject(cwd, env)
108
+ if (project.issues.length > 0) {
109
+ reportIssues(project.issues)
110
+ return 1
111
+ }
112
+ noteMode(project)
113
+
114
+ if (command === 'dev') {
115
+ const procs = planDev(project)
116
+ if (procs.length === 0) {
117
+ log('Nothing to run — no studio/ or frontend/ app found.')
118
+ return 1
119
+ }
120
+ log('Starting (Ctrl-C to stop everything)…\n')
121
+ return runGroup(procs)
122
+ }
123
+
124
+ // build
125
+ const steps = planBuild(project)
126
+ if (steps.length === 0) {
127
+ log('Nothing to build — no studio/ or frontend/ app found.')
128
+ return 1
129
+ }
130
+ const code = await runSequential(steps)
131
+ log(code === 0 ? '\n✓ Build complete.' : '\n✗ Build failed.')
132
+ return code
133
+ }
package/src/project.js ADDED
@@ -0,0 +1,98 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ /**
5
+ * Project detection for the Elytra CLI (EC-235). Convention-aware, not config-
6
+ * executing: a consuming repo's layout (`studio/`, `frontend/`, root
7
+ * `elytra.config.ts`) and its `.env` are the source of truth — the same `.env`
8
+ * the studio itself reads to resolve its backend (`VITE_ELYTRA_MODE` /
9
+ * `VITE_CONVEX_URL`). We deliberately do NOT load the TS config (no TS runtime
10
+ * dependency); the paths are conventional and the operative backend signal lives
11
+ * in env regardless.
12
+ */
13
+
14
+ /** Walk up from `start` until a directory containing `elytra.config.ts` is found. */
15
+ export function findRepoRoot(start) {
16
+ let dir = start
17
+ for (;;) {
18
+ if (existsSync(join(dir, 'elytra.config.ts'))) return dir
19
+ const parent = dirname(dir)
20
+ if (parent === dir) return null
21
+ dir = parent
22
+ }
23
+ }
24
+
25
+ /** Parse a dotenv file into a flat record. Missing file → {}. */
26
+ function readEnvFile(path) {
27
+ if (!existsSync(path)) return {}
28
+ const out = {}
29
+ for (const rawLine of readFileSync(path, 'utf8').split('\n')) {
30
+ const line = rawLine.trim()
31
+ if (!line || line.startsWith('#')) continue
32
+ const eq = line.indexOf('=')
33
+ if (eq === -1) continue
34
+ const key = line.slice(0, eq).trim()
35
+ let value = line.slice(eq + 1).trim()
36
+ // Strip an inline comment and surrounding quotes.
37
+ value = value.replace(/\s+#.*$/, '').replace(/^['"]|['"]$/g, '')
38
+ out[key] = value
39
+ }
40
+ return out
41
+ }
42
+
43
+ /** Merge a package's `.env` then `.env.local` (local wins), then process.env. */
44
+ function resolveEnv(appDir, processEnv) {
45
+ return {
46
+ ...readEnvFile(join(appDir, '.env')),
47
+ ...readEnvFile(join(appDir, '.env.local')),
48
+ ...processEnv,
49
+ }
50
+ }
51
+
52
+ function isApp(root, name) {
53
+ return existsSync(join(root, name, 'package.json'))
54
+ }
55
+
56
+ /**
57
+ * Inspect a consuming repo. Returns:
58
+ * { root, apps: {studio, frontend}, mode, convexProvisioned, issues }
59
+ * `issues` are actionable, human-readable prerequisite problems (empty = ok).
60
+ */
61
+ export function inspectProject(cwd, processEnv = {}) {
62
+ const root = findRepoRoot(cwd)
63
+ if (!root) {
64
+ return {
65
+ root: null,
66
+ apps: { studio: false, frontend: false },
67
+ mode: 'unknown',
68
+ convexProvisioned: false,
69
+ issues: [
70
+ 'No elytra.config.ts found in this directory or any parent. Run `elytra` from inside an Elytra project (or scaffold one with `npm create elytra@latest`).',
71
+ ],
72
+ }
73
+ }
74
+
75
+ const apps = { studio: isApp(root, 'studio'), frontend: isApp(root, 'frontend') }
76
+ const issues = []
77
+ if (!apps.studio && !apps.frontend) {
78
+ issues.push('No `studio/` or `frontend/` app found next to elytra.config.ts.')
79
+ }
80
+ if (!existsSync(join(root, 'node_modules'))) {
81
+ issues.push('Dependencies are not installed. Run `pnpm install` first.')
82
+ }
83
+
84
+ // Backend mode is read from the studio app's env, mirroring the studio's own
85
+ // resolveBackendConfig: explicit playground flag wins; otherwise a Convex URL
86
+ // means connected; neither means it still needs configuring.
87
+ const studioEnv = apps.studio ? resolveEnv(join(root, 'studio'), processEnv) : processEnv
88
+ let mode
89
+ if (studioEnv.VITE_ELYTRA_MODE === 'playground') mode = 'playground'
90
+ else if (studioEnv.VITE_CONVEX_URL) mode = 'connected'
91
+ else mode = 'unconfigured'
92
+
93
+ // A Convex dev/deploy step only makes sense once a deployment is provisioned
94
+ // (`npx convex dev` writes CONVEX_DEPLOYMENT). Otherwise we skip it and say so.
95
+ const convexProvisioned = Boolean(studioEnv.CONVEX_DEPLOYMENT)
96
+
97
+ return { root, apps, mode, convexProvisioned, issues }
98
+ }
package/src/run.js ADDED
@@ -0,0 +1,132 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { stdout } from 'node:process'
3
+
4
+ /**
5
+ * Process orchestration for the Elytra CLI (EC-235). We own the lifecycle but
6
+ * never reimplement Vite/Next/Convex — each step shells out to the underlying
7
+ * tool and inherits its watch/HMR/build behaviour.
8
+ */
9
+
10
+ const COLORS = ['36', '35', '33', '32', '34'] // cyan, magenta, yellow, green, blue
11
+ const useColor = !process.env.NO_COLOR && stdout.isTTY
12
+
13
+ function paint(text, code) {
14
+ return useColor ? `[${code}m${text}` : text
15
+ }
16
+
17
+ /** Prefix every line of a child's output with its labeled, colored tag. */
18
+ function pipePrefixed(stream, label, color) {
19
+ let buffer = ''
20
+ const tag = paint(`[${label}]`, color)
21
+ stream.on('data', (chunk) => {
22
+ buffer += chunk.toString()
23
+ const lines = buffer.split('\n')
24
+ buffer = lines.pop() ?? ''
25
+ for (const line of lines) stdout.write(`${tag} ${line}\n`)
26
+ })
27
+ stream.on('end', () => {
28
+ if (buffer.length) stdout.write(`${tag} ${buffer}\n`)
29
+ })
30
+ }
31
+
32
+ /**
33
+ * Run a set of long-lived processes as one supervised group (`elytra dev`).
34
+ * Ctrl-C (SIGINT/SIGTERM) tears all of them down; if any child exits on its own,
35
+ * the rest are killed and the group resolves with that child's exit code.
36
+ *
37
+ * `procs`: [{ name, command, args, cwd }]. Returns the group's exit code.
38
+ */
39
+ export function runGroup(procs) {
40
+ return new Promise((resolve) => {
41
+ const children = []
42
+ let shuttingDown = false
43
+ let resultCode = 0
44
+
45
+ const killAll = (signal) => {
46
+ for (const child of children) {
47
+ if (child.exitCode === null && child.signalCode === null) {
48
+ try {
49
+ child.kill(signal)
50
+ } catch {
51
+ /* already gone */
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ const shutdown = (code) => {
58
+ if (shuttingDown) return
59
+ shuttingDown = true
60
+ resultCode = code
61
+ killAll('SIGTERM')
62
+ // Hard stop if a child ignores SIGTERM.
63
+ const hard = setTimeout(() => killAll('SIGKILL'), 4000)
64
+ hard.unref?.()
65
+ }
66
+
67
+ procs.forEach((proc, i) => {
68
+ const color = COLORS[i % COLORS.length]
69
+ stdout.write(`${paint(`[${proc.name}]`, color)} ${proc.command} ${proc.args.join(' ')}\n`)
70
+ const child = spawn(proc.command, proc.args, {
71
+ cwd: proc.cwd,
72
+ env: process.env,
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ })
75
+ children.push(child)
76
+ pipePrefixed(child.stdout, proc.name, color)
77
+ pipePrefixed(child.stderr, proc.name, color)
78
+
79
+ child.on('error', (err) => {
80
+ stdout.write(`${paint(`[${proc.name}]`, color)} failed to start: ${err.message}\n`)
81
+ shutdown(1)
82
+ })
83
+ child.on('exit', (code, signal) => {
84
+ if (!shuttingDown) {
85
+ // An unexpected exit takes the whole group down.
86
+ stdout.write(
87
+ `${paint(`[${proc.name}]`, color)} exited (${signal ?? code}); stopping the group.\n`,
88
+ )
89
+ shutdown(code ?? (signal ? 1 : 0))
90
+ }
91
+ if (children.every((c) => c.exitCode !== null || c.signalCode !== null)) {
92
+ resolve(resultCode)
93
+ }
94
+ })
95
+ })
96
+
97
+ const onSignal = () => shutdown(130)
98
+ process.once('SIGINT', onSignal)
99
+ process.once('SIGTERM', onSignal)
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Run steps in order, failing the whole command on the first non-zero exit
105
+ * (`elytra build`). No silent partial builds. Returns the failing step's exit
106
+ * code, or 0 if all pass.
107
+ */
108
+ export function runSequential(steps) {
109
+ return new Promise((resolve) => {
110
+ let i = 0
111
+ const next = () => {
112
+ if (i >= steps.length) return resolve(0)
113
+ const step = steps[i++]
114
+ stdout.write(`\n${paint(`▸ ${step.name}`, '36')}\n`)
115
+ const child = spawn(step.command, step.args, {
116
+ cwd: step.cwd,
117
+ env: process.env,
118
+ stdio: 'inherit',
119
+ })
120
+ child.on('error', (err) => {
121
+ stdout.write(`${step.name} failed to start: ${err.message}\n`)
122
+ resolve(1)
123
+ })
124
+ child.on('exit', (code, signal) => {
125
+ if (code === 0) return next()
126
+ stdout.write(`\n${step.name} failed (exit ${signal ?? code}). Stopping.\n`)
127
+ resolve(code ?? 1)
128
+ })
129
+ }
130
+ next()
131
+ })
132
+ }