create-lesa-app 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.
@@ -0,0 +1,98 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { derive } from './derive.ts';
4
+ import { advance, editingField, initialFlow, setField, stepOrder, toInput } from './flow.ts';
5
+
6
+ /** 타이핑 시뮬레이션: 문자열을 넣고 Enter. */
7
+ function type(state, text) {
8
+ return advance(setField(state, text));
9
+ }
10
+
11
+ // 1) 영문 이름 → slug 질문을 건너뛰고 홈 화면 이름을 묻는다
12
+ {
13
+ let s = type(initialFlow, 'lesaapp');
14
+ assert.equal(s.step, 'display', 'ASCII name should skip the slug step');
15
+ assert.equal(s.slug, 'lesaapp');
16
+ assert.deepEqual(stepOrder(s), ['name', 'display', 'team', 'ready']);
17
+ s = advance(s); // 홈 화면 이름 생략
18
+ assert.equal(s.step, 'team');
19
+ s = type(s, 'ABCDE12345');
20
+ assert.equal(s.step, 'ready');
21
+ // 생략하면 비워둔다 — 템플릿이 `name` 으로 폴백한다
22
+ assert.deepEqual(toInput(s), { appleTeamId: 'ABCDE12345', displayName: '', slug: 'lesaapp' });
23
+ }
24
+
25
+ // 2) 영문 이름 + 홈 화면 이름을 다듬는 경우 (lesa-app → "lesa-app")
26
+ {
27
+ let s = type(initialFlow, 'lesa-app');
28
+ assert.equal(s.step, 'display');
29
+ s = type(s, 'lesa-app');
30
+ assert.equal(s.step, 'team');
31
+ s = advance(s);
32
+ assert.deepEqual(toInput(s), { appleTeamId: '', displayName: 'lesa-app', slug: 'lesa-app' });
33
+ }
34
+
35
+ // 3) 한글 이름 → slug 을 반드시 묻고, 이름이 곧 홈 화면 이름이다
36
+ {
37
+ let s = type(initialFlow, '레사앱');
38
+ assert.equal(s.step, 'slug', 'non-ASCII name must ask for a slug');
39
+ assert.equal(editingField(s.step), 'slug');
40
+ assert.deepEqual(stepOrder(s), ['name', 'slug', 'team', 'ready']);
41
+ s = type(s, 'lesaapp');
42
+ assert.equal(s.step, 'team', 'non-ASCII path must not ask for a display name');
43
+ s = advance(s);
44
+ assert.deepEqual(toInput(s), { appleTeamId: '', displayName: '레사앱', slug: 'lesaapp' });
45
+ }
46
+
47
+ // 4) 이름 비어 있거나 공백만이면 전진하지 않는다
48
+ {
49
+ for (const bad of ['', ' ']) {
50
+ const s = advance(setField(initialFlow, bad));
51
+ assert.equal(s.step, 'name');
52
+ assert.ok(s.error);
53
+ }
54
+ }
55
+
56
+ // 5) 잘못된 slug 은 slug 스텝에 머문다
57
+ {
58
+ const base = type(initialFlow, '레사앱');
59
+ for (const bad of ['LesaApp', '1lesaapp', 'lesa_app', '-lesa', 'lesa app', '']) {
60
+ const r = type(base, bad);
61
+ assert.equal(r.step, 'slug', `"${bad}" should be rejected`);
62
+ assert.ok(r.error, `"${bad}" should set an error`);
63
+ }
64
+ assert.equal(type(base, 'lesa-app').step, 'team');
65
+ }
66
+
67
+ // 6) 이름을 ASCII → 비ASCII 로 고치면 slug 을 다시 묻는다
68
+ {
69
+ const s = type(initialFlow, 'lesaapp'); // display
70
+ assert.equal(s.slug, 'lesaapp');
71
+ assert.equal(advance({ ...s, name: '레사앱', step: 'name' }).step, 'slug');
72
+ }
73
+
74
+ // 7) 앞뒤 공백은 잘린다
75
+ {
76
+ const s = type(initialFlow, ' lesaapp ');
77
+ assert.equal(s.name, 'lesaapp');
78
+ assert.equal(s.slug, 'lesaapp');
79
+ const d = type(s, ' Lesa App ');
80
+ assert.equal(d.display, 'Lesa App');
81
+ }
82
+
83
+ console.log('flow: 7/7 groups pass');
84
+
85
+ // 8) 하이픈 slug — Android package 는 하이픈을 못 쓴다
86
+ {
87
+ const f = derive({ displayName: '', slug: 'lesa-app' });
88
+ assert.equal(f.slug, 'lesa-app');
89
+ assert.equal(f.scheme.development, 'lesa-app-dev', 'scheme allows hyphens');
90
+ for (const value of [...Object.values(f.bundleId), ...Object.values(f.package)]) {
91
+ assert.match(value, /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/, `invalid identifier: ${value}`);
92
+ }
93
+ assert.equal(f.package.development, 'com.lesaapp.development');
94
+ assert.equal(f.bundleId.production, 'com.lesaapp');
95
+ assert.deepEqual(f.package, f.bundleId, 'package must equal bundleId');
96
+ }
97
+
98
+ console.log('derive: hyphen slug produces a valid Android package');
package/src/flow.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 프롬프트 스텝 머신 — 순수 함수. UI(ink) 밖에 두어 TTY 없이 테스트한다.
3
+ *
4
+ * 언어를 고르게 하지 않는다. 이름을 받고 ASCII 인지 검사한다:
5
+ * ASCII → 그 값이 곧 slug. displayName 은 비운다(템플릿이 name 을 그대로 쓴다)
6
+ * 비 ASCII → displayName 으로 쓰고 slug 을 따로 묻는다
7
+ */
8
+
9
+ import { validateSlug } from './derive.ts';
10
+
11
+ import type { AppInput } from './derive.ts';
12
+
13
+ export type StepId = 'name' | 'slug' | 'display' | 'team' | 'ready';
14
+
15
+ export type FlowState = {
16
+ step: StepId;
17
+ /** 사용자가 처음 입력한 이름. ASCII 면 slug 과 같다. */
18
+ name: string;
19
+ slug: string;
20
+ /** 홈 화면 이름. 비우면 템플릿이 `name` 을 그대로 쓴다. */
21
+ display: string;
22
+ appleTeamId: string;
23
+ error: string | null;
24
+ };
25
+
26
+ export const initialFlow: FlowState = {
27
+ appleTeamId: '',
28
+ display: '',
29
+ error: null,
30
+ name: '',
31
+ slug: '',
32
+ step: 'name',
33
+ };
34
+
35
+ /** slug 으로 바로 쓸 수 있는 이름인지 — 통과하면 slug 질문을 건너뛴다. */
36
+ export function isUsableAsSlug(name: string): boolean {
37
+ return validateSlug(name) === null;
38
+ }
39
+
40
+ /** 텍스트를 담는 필드만 — `error` 가 섞이면 호출부에서 string 으로 못 쓴다. */
41
+ export type TextField = 'name' | 'slug' | 'display' | 'appleTeamId';
42
+
43
+ /** 지금 스텝이 편집 중인 필드. null 이면 입력 스텝이 아니다. */
44
+ export function editingField(step: StepId): TextField | null {
45
+ if (step === 'name') return 'name';
46
+ if (step === 'slug') return 'slug';
47
+ if (step === 'display') return 'display';
48
+ if (step === 'team') return 'appleTeamId';
49
+ return null;
50
+ }
51
+
52
+ export function setField(state: FlowState, value: string): FlowState {
53
+ const field = editingField(state.step);
54
+ if (!field) return state;
55
+ return { ...state, [field]: value, error: null };
56
+ }
57
+
58
+ /** Enter. 검증 실패면 error 를 채운 같은 스텝을 돌려준다. */
59
+ export function advance(state: FlowState): FlowState {
60
+ if (state.step === 'name') {
61
+ const name = state.name.trim();
62
+ if (!name) return { ...state, error: 'Enter an app name.' };
63
+ // ASCII 면 그게 slug 다. 대신 홈 화면 이름을 다듬을 기회를 준다
64
+ // (비우면 `name` 이 그대로 홈 화면에 뜬다 — 하이픈이 그대로 보인다).
65
+ if (isUsableAsSlug(name)) {
66
+ return { ...state, error: null, name, slug: name, step: 'display' };
67
+ }
68
+ // 비ASCII 는 이 값이 홈 화면 이름이고, slug 을 따로 받는다.
69
+ return { ...state, error: null, name, step: 'slug' };
70
+ }
71
+
72
+ if (state.step === 'slug') {
73
+ const problem = validateSlug(state.slug.trim());
74
+ if (problem) return { ...state, error: problem };
75
+ return { ...state, error: null, slug: state.slug.trim(), step: 'team' };
76
+ }
77
+
78
+ if (state.step === 'display') {
79
+ return { ...state, display: state.display.trim(), error: null, step: 'team' };
80
+ }
81
+
82
+ if (state.step === 'team') {
83
+ return { ...state, appleTeamId: state.appleTeamId.trim(), error: null, step: 'ready' };
84
+ }
85
+
86
+ return state;
87
+ }
88
+
89
+ /**
90
+ * 'ready' 에서 결과로 변환.
91
+ * 비ASCII 이름은 그 자체가 홈 화면 이름이다. ASCII 면 따로 받은 값을 쓰고, 안 받았으면
92
+ * 비워둔다(템플릿이 `name` 으로 폴백한다 — `app.config.ts` 의 `CFBundleDisplayName`).
93
+ */
94
+ export function toInput(state: FlowState): AppInput {
95
+ return {
96
+ appleTeamId: state.appleTeamId,
97
+ displayName: isUsableAsSlug(state.name) ? state.display : state.name,
98
+ slug: state.slug,
99
+ };
100
+ }
101
+
102
+ export const STEP_LABELS: Record<StepId, string> = {
103
+ display: 'DISPLAY',
104
+ name: 'NAME',
105
+ ready: 'READY',
106
+ slug: 'SLUG',
107
+ team: 'TEAM',
108
+ };
109
+
110
+ /** 진행 표시용 4칸. 2번 칸은 경로에 따라 SLUG 또는 LABEL 이다. */
111
+ export function stepOrder(state: FlowState): StepId[] {
112
+ const second: StepId = state.step === 'slug' || !isUsableAsSlug(state.name) ? 'slug' : 'display';
113
+ return ['name', second, 'team', 'ready'];
114
+ }
115
+
116
+ /** 이미 답한 스텝들 — 레일에 남겨 무엇을 입력했는지 보이게 한다. */
117
+ export function answered(state: FlowState): { label: string; value: string }[] {
118
+ const order = stepOrder(state);
119
+ const current = order.indexOf(state.step);
120
+ const shown: { label: string; value: string }[] = [];
121
+
122
+ for (const id of order.slice(0, current === -1 ? order.length : current)) {
123
+ if (id === 'name') shown.push({ label: 'App name', value: state.name });
124
+ if (id === 'slug') shown.push({ label: 'Slug', value: state.slug });
125
+ if (id === 'display')
126
+ shown.push({ label: 'Display name', value: state.display || `${state.name} (kept)` });
127
+ if (id === 'team')
128
+ shown.push({ label: 'Apple Team ID', value: state.appleTeamId || 'skipped' });
129
+ }
130
+ return shown;
131
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * create-lesa-app 진입점.
3
+ *
4
+ * create-lesa-app <dir> [--template <path>]
5
+ *
6
+ * 템플릿 경로는 `--template` → `LESA_TEMPLATE_DIR` → 형제 폴더 순으로 찾는다
7
+ * (레포가 private 이라 tarball 이 아니라 로컬 복사다 — `docs/cli.md`).
8
+ */
9
+
10
+ import { Box, render, Text, useApp, useStdout } from 'ink';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import process from 'node:process';
14
+ import { fileURLToPath } from 'node:url';
15
+ import React, { useEffect, useState } from 'react';
16
+
17
+ import { createApp, CREATE_STEPS } from './create.ts';
18
+ import { Intro } from './intro.tsx';
19
+ import { Frame, Prompt, railColors as colors, Section, Steps } from './ui.tsx';
20
+
21
+ import type { CreateProgress, CreateResult } from './create.ts';
22
+ import type { PromptResult } from './ui.tsx';
23
+
24
+ const USAGE = `create-lesa-app — create a project from the lesa Expo template
25
+
26
+ create-lesa-app <dir> [options]
27
+
28
+ Options
29
+ --template <path> template directory (else $LESA_TEMPLATE_DIR, else a
30
+ sibling "lesa-expo-template" folder)
31
+ -h, --help show this
32
+ -v, --version print the version
33
+
34
+ It asks for an app name, a slug when the name is not lowercase ASCII, and an
35
+ optional Apple Team ID, then copies the template's git-tracked files, fills in
36
+ env-candidates.ts and makes the first commit.`;
37
+
38
+ class ArgError extends Error {}
39
+
40
+ function parseArgs(argv: string[]) {
41
+ const rest = argv.slice(2);
42
+ let templateDir = process.env.LESA_TEMPLATE_DIR ?? '';
43
+ const positional: string[] = [];
44
+
45
+ for (let index = 0; index < rest.length; index++) {
46
+ const arg = rest[index];
47
+ if (arg === '--help' || arg === '-h') return { help: true };
48
+ if (arg === '--version' || arg === '-v') return { version: true };
49
+ if (arg === '--template') {
50
+ templateDir = rest[++index] ?? '';
51
+ if (!templateDir) throw new ArgError('--template needs a path');
52
+ continue;
53
+ }
54
+ // 알 수 없는 플래그를 위치 인자로 삼으면 오타가 디렉터리 이름이 된다.
55
+ if (arg.startsWith('-')) throw new ArgError(`unknown option: ${arg}`);
56
+ positional.push(arg);
57
+ }
58
+
59
+ if (positional.length > 1) throw new ArgError(`unexpected argument: ${positional[1]}`);
60
+ return { targetDir: positional[0] ?? '', templateDir };
61
+ }
62
+
63
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
64
+
65
+ /** 템플릿 폴더로 보이는지 — 형제 폴더를 기본값으로 쓸지 판단한다. */
66
+ function looksLikeTemplate(dir: string): boolean {
67
+ return existsSync(path.join(dir, 'env-candidates.ts')) && existsSync(path.join(dir, 'app.config.ts'));
68
+ }
69
+
70
+ /**
71
+ * `--template` → `$LESA_TEMPLATE_DIR` → 형제 `lesa-expo-template` → 빈 문자열(= GitHub).
72
+ * 빈 문자열은 실패가 아니다 — `create.ts` 가 tarball 을 받는다.
73
+ */
74
+ function resolveTemplateDir(explicit: string): string {
75
+ if (explicit) return path.resolve(explicit);
76
+ const sibling = path.resolve(packageRoot, '../lesa-expo-template');
77
+ return looksLikeTemplate(sibling) ? sibling : '';
78
+ }
79
+
80
+ type Stage =
81
+ | { kind: 'input' }
82
+ | { kind: 'working'; progress: CreateProgress; skipped: number[] }
83
+ | { kind: 'done'; result: CreateResult };
84
+
85
+ /** 워드마크(20줄) + 가장 긴 화면이 들어갈 높이. 모자라면 애니메이션을 멈춘다. */
86
+ const ANIMATE_MIN_ROWS = 46;
87
+
88
+ function App({ targetDir, templateDir }: { targetDir: string; templateDir: string }) {
89
+ const { exit } = useApp();
90
+ const { stdout } = useStdout();
91
+ const [stage, setStage] = useState<Stage>({ kind: 'input' });
92
+ const [error, setError] = useState<string | null>(null);
93
+
94
+ const settled = error !== null || stage.kind === 'done';
95
+
96
+ // 끝나면 스스로 빠져나온다 — 인트로 타이머와 raw mode 가 이벤트 루프를 붙잡고 있어서
97
+ // exit() 를 부르지 않으면 프로세스가 안 죽는다.
98
+ useEffect(() => {
99
+ if (settled) exit();
100
+ }, [exit, settled]);
101
+
102
+ const onDone = (input: PromptResult) => {
103
+ setStage({ kind: 'working', progress: { index: 0 }, skipped: [] });
104
+ void createApp({
105
+ ...input,
106
+ onStep: progress =>
107
+ setStage(previous => ({
108
+ kind: 'working',
109
+ progress,
110
+ // 건너뛴 단계는 누적한다 — 다음 단계로 넘어가도 표시가 남아야 한다.
111
+ skipped:
112
+ previous.kind === 'working' && progress.skipped
113
+ ? [...new Set([...previous.skipped, progress.index])]
114
+ : previous.kind === 'working'
115
+ ? previous.skipped
116
+ : [],
117
+ })),
118
+ targetDir,
119
+ templateDir,
120
+ })
121
+ .then(result => setStage({ kind: 'done', result }))
122
+ .catch((cause: unknown) => {
123
+ setError(cause instanceof Error ? cause.message : String(cause));
124
+ process.exitCode = 1;
125
+ });
126
+ };
127
+
128
+ return (
129
+ <Box flexDirection="column">
130
+ {/* 출력이 터미널 높이를 넘으면 ink 가 제자리 갱신을 못 해 매 프레임이 쌓인다.
131
+ 완료·실패 화면은 가장 길므로 그때도 멈춘다. */}
132
+ <Intro loop={!settled && (stdout?.rows ?? 0) >= ANIMATE_MIN_ROWS} />
133
+ {error ? (
134
+ <Frame footer="Failed — nothing was left behind">
135
+ <Section color={colors.error} marker="■" title="Failed to create the project">
136
+ <Text color={colors.softText}>{` ${error}`}</Text>
137
+ </Section>
138
+ </Frame>
139
+ ) : stage.kind === 'working' ? (
140
+ <Frame footer={`Creating ${path.basename(targetDir)}…`}>
141
+ <Steps
142
+ current={stage.progress.index}
143
+ labels={CREATE_STEPS}
144
+ note={stage.progress.note}
145
+ progress={
146
+ stage.progress.total
147
+ ? { done: stage.progress.done ?? 0, total: stage.progress.total }
148
+ : undefined
149
+ }
150
+ skipped={new Set(stage.skipped)}
151
+ />
152
+ </Frame>
153
+ ) : stage.kind === 'done' ? (
154
+ <Frame footer="Done">
155
+ <Section
156
+ color={colors.done}
157
+ marker="◆"
158
+ title={`Created ${path.basename(stage.result.targetDir)}`}
159
+ />
160
+ <Steps
161
+ current={CREATE_STEPS.length}
162
+ details={{
163
+ 0:
164
+ stage.result.receipt.source === 'github'
165
+ ? 'from GitHub'
166
+ : `${stage.result.receipt.fileCount} files`,
167
+ 1: 'identity substituted',
168
+ 3: '1 commit',
169
+ }}
170
+ labels={CREATE_STEPS}
171
+ skipped={new Set(stage.result.receipt.wroteEnvFile ? [] : [2])}
172
+ />
173
+ <Section color={colors.done} marker="◇" title="Next steps">
174
+ {stage.result.nextSteps.map(step => (
175
+ <Text color={colors.softText} key={step}>{` ${step}`}</Text>
176
+ ))}
177
+ </Section>
178
+ </Frame>
179
+ ) : (
180
+ <Prompt onDone={onDone} />
181
+ )}
182
+ </Box>
183
+ );
184
+ }
185
+
186
+ let args: ReturnType<typeof parseArgs>;
187
+ try {
188
+ args = parseArgs(process.argv);
189
+ } catch (error) {
190
+ console.error(`${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
191
+ process.exit(1);
192
+ }
193
+
194
+ if (args.help) {
195
+ console.log(USAGE);
196
+ process.exit(0);
197
+ }
198
+ if (args.version) {
199
+ const pkg = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8')) as {
200
+ version: string;
201
+ };
202
+ console.log(pkg.version);
203
+ process.exit(0);
204
+ }
205
+ if (!args.targetDir) {
206
+ console.error(USAGE);
207
+ process.exit(1);
208
+ }
209
+
210
+ const templateDir = resolveTemplateDir(args.templateDir);
211
+
212
+ render(<App targetDir={path.resolve(args.targetDir)} templateDir={templateDir} />);
package/src/intro.tsx ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ASCII 워드마크 — `assets/lesa-appkit.asciimtn` 를 `scripts/bake-intro.mjs` 로 접은 것.
3
+ * 글리프는 10프레임 모두 같고 색만 흐른다. 무한 루프로 돈다 — 프레임 state 를 이 컴포넌트가
4
+ * 들고 있어서 형제인 프롬프트는 리렌더되지 않는다(입력 커서가 영향받지 않는다).
5
+ */
6
+
7
+ import { Box, Text } from 'ink';
8
+ import fs from 'node:fs';
9
+ import React, { useEffect, useState } from 'react';
10
+
11
+ type Run = [text: string, color: string | null];
12
+ type Frame = { ms: number; rows: Run[][] };
13
+
14
+ const { frames } = JSON.parse(
15
+ fs.readFileSync(new URL('./assets/intro.json', import.meta.url), 'utf8'),
16
+ ) as { frames: Frame[] };
17
+
18
+ export function Intro({ loop = true }: { loop?: boolean }) {
19
+ const [index, setIndex] = useState(0);
20
+
21
+ useEffect(() => {
22
+ const last = frames.length - 1;
23
+ // loop 이 꺼지면 정착 프레임으로 붙이고 타이머를 놓는다(호출부가 높이를 보고 끈다).
24
+ if (!loop) {
25
+ setIndex(last);
26
+ return;
27
+ }
28
+ const timer = setTimeout(() => setIndex(index >= last ? 0 : index + 1), frames[index].ms);
29
+ return () => clearTimeout(timer);
30
+ }, [index, loop]);
31
+
32
+ return (
33
+ <Box flexDirection="column" paddingLeft={2}>
34
+ {frames[index].rows.map((runs, y) => (
35
+ <Text key={y}>
36
+ {runs.map(([text, color], run) => (
37
+ <Text color={color ?? undefined} key={run}>
38
+ {text}
39
+ </Text>
40
+ ))}
41
+ </Text>
42
+ ))}
43
+ </Box>
44
+ );
45
+ }