@geastack/cli 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,430 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { flag, option, optionList, parseArgs } from './args.mjs'
5
+ import { createContext } from './context.mjs'
6
+ import { ExitCode, fail } from './errors.mjs'
7
+ import { exists, readJson, writeJson } from './fs-utils.mjs'
8
+ import { canPrompt, choose, createPrompt } from './prompts.mjs'
9
+ import { runExternal } from './run.mjs'
10
+ import {
11
+ copyStarterFiles,
12
+ discoverBundledStarters,
13
+ discoverGithubExamples,
14
+ fetchGithubExample,
15
+ formatStarterChoice
16
+ } from './starter-catalog.mjs'
17
+
18
+ export async function runCreateGeastack(argv, io = {}) {
19
+ const parsed = parseArgs(argv)
20
+ const stdout = io.stdout || console.log
21
+ const env = io.env || process.env
22
+ const cwd = io.cwd || process.cwd()
23
+
24
+ if (flag(parsed, 'help') || parsed.positionals[0] === 'help') {
25
+ stdout(usage())
26
+ return 0
27
+ }
28
+
29
+ const rawName = parsed.positionals[0]
30
+ if (!rawName) fail('create-geastack requires an app name.', ExitCode.usage)
31
+
32
+ const appId = slugify(option(parsed, 'id', rawName))
33
+ if (!appId) fail(`Could not derive a valid app id from '${rawName}'.`, ExitCode.usage)
34
+
35
+ const targetDir = path.resolve(cwd, option(parsed, 'dir', rawName))
36
+ ensureWritableTarget(targetDir, flag(parsed, 'force'))
37
+
38
+ const ctx = createContext(parsed, env, cwd)
39
+ const displayName = option(parsed, 'name', titleFromId(appId))
40
+ const coreDependency = option(parsed, 'core-dependency') || '^0.1.2'
41
+ const cliDependency = option(parsed, 'cli-dependency') || '^0.1.0'
42
+ const starter = await resolveStarter(ctx, parsed, io)
43
+
44
+ fs.mkdirSync(targetDir, { recursive: true })
45
+ if (starter.kind === 'empty') {
46
+ fs.writeFileSync(path.join(targetDir, 'index.tsx'), indexTsx(displayName))
47
+ fs.writeFileSync(path.join(targetDir, 'styles.css'), stylesCss())
48
+ } else if (starter.kind === 'bundled') {
49
+ copyStarterFiles(starter.starter.root, targetDir)
50
+ } else if (starter.kind === 'example') {
51
+ const repo = option(parsed, 'examples-repo') || env.GEA_EXAMPLES_REPO || starter.example.repo
52
+ const ref = option(parsed, 'examples-ref') || env.GEA_EXAMPLES_REF || starter.example.ref
53
+ fetchGithubExample(starter.example, targetDir, {
54
+ repo,
55
+ ref,
56
+ env,
57
+ cwd,
58
+ dryRun: flag(parsed, 'dry-run'),
59
+ stdout
60
+ })
61
+ }
62
+
63
+ const sourcePackage = readPackageJson(targetDir)
64
+ const sourceManifest = sourcePackage.gea || starter.starter?.manifest || starter.example?.manifest || {}
65
+ const targets = optionList(parsed, 'targets').length === 0
66
+ ? targetManifestFromObject(sourceManifest.targets)
67
+ : targetManifest(optionList(parsed, 'targets'))
68
+ writeJson(path.join(targetDir, 'package.json'), packageJson({
69
+ appId,
70
+ displayName,
71
+ targets,
72
+ coreDependency,
73
+ cliDependency,
74
+ sourcePackage,
75
+ sourceManifest
76
+ }))
77
+ ensureFile(path.join(targetDir, 'index.html'), () => indexHtml(displayName))
78
+ fs.mkdirSync(path.join(targetDir, '.gea'), { recursive: true })
79
+ writeJson(path.join(targetDir, '.gea', 'boards.json'), {})
80
+ ensureJson(path.join(targetDir, 'tsconfig.json'), tsconfigJson)
81
+ ensureFile(path.join(targetDir, 'vite.config.ts'), viteConfigTs)
82
+ fs.writeFileSync(path.join(targetDir, 'README.md'), readme({ appId, displayName, starter }))
83
+
84
+ const installDependencies = shouldInstallDependencies(parsed, io)
85
+ if (installDependencies) {
86
+ stdout('Installing npm dependencies...')
87
+ runExternal('npm', ['install'], {
88
+ cwd: targetDir,
89
+ env,
90
+ dryRun: flag(parsed, 'dry-run'),
91
+ failureCode: ExitCode.missingDependency,
92
+ stdout
93
+ })
94
+ }
95
+
96
+ stdout(`Created ${displayName} at ${targetDir}`)
97
+ if (starter.kind === 'bundled') stdout(`Starter: ${starter.starter.name}`)
98
+ else if (starter.kind === 'example') stdout(`Example: fetched ${starter.example.name}`)
99
+ else stdout('Starter: empty app')
100
+ if (installDependencies) stdout(`Next: cd ${targetDir} && npx gea setup`)
101
+ else stdout(`Next: cd ${targetDir} && npm install && npx gea setup`)
102
+ return 0
103
+ }
104
+
105
+ async function resolveStarter(ctx, parsed, io) {
106
+ const interactive = canPrompt(io) && !flag(parsed, 'yes')
107
+ const bundled = discoverBundledStarters(ctx)
108
+ const examples = discoverGithubExamples(ctx, io.env || process.env)
109
+ const requested = normalizeStarter(option(parsed, 'starter') || option(parsed, 'template') || '')
110
+ const prompt = interactive ? createPrompt(io) : null
111
+ try {
112
+ const mode = requested || await chooseStarterMode({ interactive, prompt, bundled, examples })
113
+
114
+ if (mode === 'empty') return { kind: 'empty' }
115
+ if (mode === 'bundled' || mode === 'counter') {
116
+ const requestedBundled = mode === 'counter' ? 'counter' : option(parsed, 'bundled') || option(parsed, 'starter-id') || ''
117
+ const starter = requestedBundled
118
+ ? bundled.find((candidate) => candidate.id === requestedBundled)
119
+ : bundled[0]
120
+ if (!starter) {
121
+ const available = bundled.map((candidate) => candidate.id).join(', ')
122
+ fail(`Unknown bundled starter '${requestedBundled}'. Available starters: ${available}`, ExitCode.usage)
123
+ }
124
+ return { kind: 'bundled', starter }
125
+ }
126
+ if (mode !== 'example') {
127
+ fail(`Unknown starter '${mode}'. Expected counter, empty, or example.`, ExitCode.usage)
128
+ }
129
+ if (examples.length === 0) {
130
+ fail('No GitHub examples are available. Use --starter counter or --starter empty.', ExitCode.usage)
131
+ }
132
+
133
+ const requestedExample = option(parsed, 'example') || option(parsed, 'from-example') || ''
134
+ const example = requestedExample
135
+ ? examples.find((candidate) => candidate.id === requestedExample)
136
+ : await chooseExample({ interactive, prompt, examples })
137
+ if (!example) {
138
+ const available = examples.map((candidate) => candidate.id).join(', ')
139
+ fail(`Unknown starter example '${requestedExample}'. Available examples: ${available}`, ExitCode.usage)
140
+ }
141
+ return { kind: 'example', example }
142
+ } finally {
143
+ if (prompt) await prompt.close()
144
+ }
145
+ }
146
+
147
+ async function chooseStarterMode({ interactive, prompt, bundled, examples }) {
148
+ if (!interactive) return bundled.length > 0 ? 'counter' : 'empty'
149
+ return choose(prompt, {
150
+ message: 'Starter app',
151
+ choices: [
152
+ ...(bundled.length > 0 ? [{ value: 'counter', label: 'Counter starter - bundled minimal JSX app' }] : []),
153
+ { value: 'empty', label: 'Empty app - minimal blank Gea app' },
154
+ ...(examples.length > 0 ? [{ value: 'example', label: 'Rich example - fetch from GitHub examples repo' }] : [])
155
+ ],
156
+ defaultValue: bundled.length > 0 ? 'counter' : 'empty'
157
+ })
158
+ }
159
+
160
+ async function chooseExample({ interactive, prompt, examples }) {
161
+ if (!interactive) {
162
+ fail('Choosing --starter example in a non-interactive shell also requires --example <id>.', ExitCode.usage)
163
+ }
164
+ const id = await choose(prompt, {
165
+ message: 'Example to copy',
166
+ choices: examples.map((example) => ({ value: example.id, label: formatStarterChoice(example) })),
167
+ defaultValue: examples[0].id
168
+ })
169
+ return examples.find((example) => example.id === id) || null
170
+ }
171
+
172
+ function ensureWritableTarget(targetDir, force) {
173
+ if (!exists(targetDir)) return
174
+ const entries = fs.readdirSync(targetDir).filter((entry) => entry !== '.DS_Store')
175
+ if (entries.length > 0 && !force) {
176
+ fail(`Target directory is not empty: ${targetDir}. Pass --force to write into it.`, ExitCode.usage)
177
+ }
178
+ }
179
+
180
+ function targetManifest(requestedTargets) {
181
+ const enabled = requestedTargets.length > 0 ? new Set(requestedTargets) : new Set(['web', 'esp32', 'rp2350', 'geaos'])
182
+ return {
183
+ web: enabled.has('web'),
184
+ esp32: enabled.has('esp32'),
185
+ rp2350: enabled.has('rp2350'),
186
+ geaos: enabled.has('geaos'),
187
+ macos: enabled.has('macos'),
188
+ ios: enabled.has('ios'),
189
+ android: enabled.has('android')
190
+ }
191
+ }
192
+
193
+ function targetManifestFromObject(targets = {}) {
194
+ if (!targets || typeof targets !== 'object') return targetManifest([])
195
+ return {
196
+ web: targets.web === true,
197
+ esp32: targets.esp32 === true,
198
+ rp2350: targets.rp2350 === true,
199
+ geaos: targets.geaos === true,
200
+ macos: targets.macos === true,
201
+ ios: targets.ios === true,
202
+ android: targets.android === true
203
+ }
204
+ }
205
+
206
+ function packageJson({ appId, displayName, targets, coreDependency, cliDependency, sourcePackage = {}, sourceManifest = {} }) {
207
+ return {
208
+ name: `gea-${appId}`,
209
+ version: '0.1.0',
210
+ private: true,
211
+ type: 'module',
212
+ scripts: {
213
+ ...(sourcePackage.scripts || {}),
214
+ dev: 'gea dev',
215
+ build: 'gea build --target web',
216
+ check: 'tsc --noEmit'
217
+ },
218
+ dependencies: {
219
+ ...(sourcePackage.dependencies || {}),
220
+ '@geajs/core': sourcePackage.dependencies?.['@geajs/core'] || '^1.3.0',
221
+ '@geastack/core': coreDependency
222
+ },
223
+ devDependencies: {
224
+ ...(sourcePackage.devDependencies || {}),
225
+ '@geastack/cli': cliDependency,
226
+ typescript: sourcePackage.devDependencies?.typescript || 'latest',
227
+ vite: sourcePackage.devDependencies?.vite || 'latest'
228
+ },
229
+ gea: {
230
+ ...sourceManifest,
231
+ id: appId,
232
+ name: displayName,
233
+ entry: sourceManifest.entry || 'index.tsx',
234
+ runtime: sourceManifest.runtime || 'gea',
235
+ targets
236
+ },
237
+ license: 'MIT'
238
+ }
239
+ }
240
+
241
+ function indexTsx(displayName) {
242
+ return `import { mount } from '@geastack/core'
243
+ import './styles.css'
244
+
245
+ function App() {
246
+ return (
247
+ <body class="app">
248
+ <main class="panel">
249
+ <p class="eyebrow">GeaStack</p>
250
+ <h1>${escapeText(displayName)}</h1>
251
+ <p class="copy">One TypeScript app, ready for simulator and native targets.</p>
252
+ </main>
253
+ </body>
254
+ )
255
+ }
256
+
257
+ mount(App)
258
+ `
259
+ }
260
+
261
+ function stylesCss() {
262
+ return `.app {
263
+ width: 100vw;
264
+ height: 100vh;
265
+ margin: 0;
266
+ display: flex;
267
+ align-items: center;
268
+ justify-content: center;
269
+ background: #101418;
270
+ color: #f8fafc;
271
+ font-family: Inter, system-ui, sans-serif;
272
+ }
273
+
274
+ .panel {
275
+ width: min(320px, calc(100vw - 32px));
276
+ padding: 24px;
277
+ border: 1px solid #2dd4bf;
278
+ background: #182026;
279
+ }
280
+
281
+ .eyebrow {
282
+ margin: 0 0 8px;
283
+ color: #2dd4bf;
284
+ font-size: 12px;
285
+ text-transform: uppercase;
286
+ }
287
+
288
+ h1 {
289
+ margin: 0;
290
+ font-size: 28px;
291
+ }
292
+
293
+ .copy {
294
+ margin: 12px 0 0;
295
+ color: #cbd5e1;
296
+ }
297
+ `
298
+ }
299
+
300
+ function indexHtml(displayName) {
301
+ return `<!doctype html>
302
+ <html lang="en">
303
+ <head>
304
+ <meta charset="UTF-8" />
305
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
306
+ <title>${escapeText(displayName)}</title>
307
+ </head>
308
+ <body>
309
+ <div id="app"></div>
310
+ <script type="module" src="/index.tsx"></script>
311
+ </body>
312
+ </html>
313
+ `
314
+ }
315
+
316
+ function tsconfigJson() {
317
+ return {
318
+ compilerOptions: {
319
+ target: 'ES2022',
320
+ module: 'ESNext',
321
+ moduleResolution: 'Bundler',
322
+ lib: ['ES2022', 'DOM'],
323
+ strict: true,
324
+ noEmit: true,
325
+ skipLibCheck: true,
326
+ jsx: 'preserve',
327
+ jsxImportSource: '@geajs/core',
328
+ allowImportingTsExtensions: true
329
+ },
330
+ include: ['**/*.tsx', '**/*.ts', '**/*.d.ts'],
331
+ exclude: ['vite.config.ts', 'dist']
332
+ }
333
+ }
334
+
335
+ function viteConfigTs() {
336
+ return `import { defineConfig } from 'vite'
337
+
338
+ export default defineConfig({
339
+ root: __dirname,
340
+ build: {
341
+ outDir: 'dist',
342
+ emptyOutDir: true
343
+ }
344
+ })
345
+ `
346
+ }
347
+
348
+ function readme({ appId, displayName, starter }) {
349
+ const starterLine = starter.kind === 'example'
350
+ ? `Started from GitHub example: \`${starter.example.name}\` (\`${starter.example.id}\`).`
351
+ : starter.kind === 'bundled'
352
+ ? `Started from bundled starter: \`${starter.starter.name}\` (\`${starter.starter.id}\`).`
353
+ : 'Started from an empty app.'
354
+ return `# ${displayName}
355
+
356
+ GeaStack app id: \`${appId}\`.
357
+
358
+ ${starterLine}
359
+
360
+ \`\`\`sh
361
+ npx gea setup
362
+ npx gea dev
363
+ npx gea build --target web
364
+ \`\`\`
365
+ `
366
+ }
367
+
368
+ function shouldInstallDependencies(parsed, io) {
369
+ const explicit = option(parsed, 'install')
370
+ if (explicit !== undefined) return explicit === true || explicit === 'true'
371
+ return canPrompt(io)
372
+ }
373
+
374
+ function slugify(value) {
375
+ return String(value)
376
+ .trim()
377
+ .toLowerCase()
378
+ .replace(/[^a-z0-9]+/g, '-')
379
+ .replace(/^-+|-+$/g, '')
380
+ }
381
+
382
+ function titleFromId(id) {
383
+ return id.split('-').filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join(' ')
384
+ }
385
+
386
+ function escapeText(value) {
387
+ return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
388
+ }
389
+
390
+ function normalizeStarter(value) {
391
+ const normalized = String(value || '').trim().toLowerCase()
392
+ if (!normalized) return ''
393
+ if (['empty', 'blank', 'minimal'].includes(normalized)) return 'empty'
394
+ if (['counter', 'starter', 'bundled', 'hello', 'hello-world'].includes(normalized)) return normalized === 'counter' ? 'counter' : 'bundled'
395
+ if (['example', 'examples', 'from-example', 'from_example'].includes(normalized)) return 'example'
396
+ return normalized
397
+ }
398
+
399
+ function readPackageJson(targetDir) {
400
+ const packagePath = path.join(targetDir, 'package.json')
401
+ if (!exists(packagePath)) return {}
402
+ return readJson(packagePath)
403
+ }
404
+
405
+ function ensureFile(filePath, create) {
406
+ if (!exists(filePath)) fs.writeFileSync(filePath, create())
407
+ }
408
+
409
+ function ensureJson(filePath, create) {
410
+ if (!exists(filePath)) writeJson(filePath, create())
411
+ }
412
+
413
+ function usage() {
414
+ return `Usage:
415
+ create-geastack <name> [--dir <path>] [--id <app-id>] [--name <display-name>]
416
+
417
+ Options:
418
+ --starter counter|empty|example
419
+ --example <example-id>
420
+ --examples-repo <git-url-or-local-path>
421
+ --examples-ref <git-ref>
422
+ --targets web,esp32,rp2350,geaos,macos,ios,android
423
+ --core-dependency <specifier>
424
+ --cli-dependency <specifier>
425
+ --install / --no-install
426
+ --dry-run
427
+ --yes
428
+ --force
429
+ `
430
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,20 @@
1
+ export const ExitCode = Object.freeze({
2
+ generic: 1,
3
+ usage: 2,
4
+ missingDependency: 3,
5
+ targetUnavailable: 4,
6
+ buildFailed: 5,
7
+ deployFailed: 6
8
+ })
9
+
10
+ export class CliError extends Error {
11
+ constructor(message, exitCode = ExitCode.generic) {
12
+ super(message)
13
+ this.name = 'CliError'
14
+ this.exitCode = exitCode
15
+ }
16
+ }
17
+
18
+ export function fail(message, exitCode = ExitCode.generic) {
19
+ throw new CliError(`ERROR: ${message}`, exitCode)
20
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ export function exists(filePath) {
5
+ try {
6
+ fs.accessSync(filePath)
7
+ return true
8
+ } catch {
9
+ return false
10
+ }
11
+ }
12
+
13
+ export function isDirectory(filePath) {
14
+ try {
15
+ return fs.statSync(filePath).isDirectory()
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ export function readJson(filePath) {
22
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
23
+ }
24
+
25
+ export function writeJson(filePath, value) {
26
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
27
+ }
28
+
29
+ export function listDirectories(root) {
30
+ try {
31
+ return fs.readdirSync(root, { withFileTypes: true })
32
+ .filter((entry) => entry.isDirectory())
33
+ .map((entry) => path.join(root, entry.name))
34
+ } catch {
35
+ return []
36
+ }
37
+ }
38
+
39
+ export function findUp(startDir, predicate) {
40
+ let current = path.resolve(startDir)
41
+ while (true) {
42
+ if (predicate(current)) return current
43
+ const parent = path.dirname(current)
44
+ if (parent === current) return ''
45
+ current = parent
46
+ }
47
+ }