create-lesa-app 0.0.1 → 0.0.3
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/CHANGELOG.md +46 -1
- package/README.ko.md +4 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/apply.ts +15 -1
- package/src/copy.ts +0 -3
- package/src/create.ts +14 -1
- package/src/derive.ts +19 -0
- package/src/fetch-template.ts +1 -1
- package/src/flow.test.mjs +24 -2
- package/src/identity.ts +35 -0
- package/src/repo-only.ts +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,49 @@
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [0.0.3] — 2026-09-15
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- 받아오는 템플릿을 `v0.0.3` 으로 올린다(`TEMPLATE_REF`). 프리로더 콜백 격리·딥링크
|
|
16
|
+
인코딩·Android 서명 앵커·auth 로그인 라우트가 새 프로젝트에 들어간다
|
|
17
|
+
|
|
18
|
+
## [0.0.2] — 2026-09-14
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- 받아오는 템플릿을 `v0.0.2` 로 올린다(`TEMPLATE_REF`)
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- 번역된 README(`README.ko.md`)가 생성된 앱에 그대로 복사됐다. 이름 목록이 아니라
|
|
27
|
+
패턴으로 걸러 `README.<lang>.md` 를 전부 제외한다
|
|
28
|
+
|
|
29
|
+
- Java 예약어를 slug 으로 받아들여 `com.class.development` 같은 패키지를 만들었다.
|
|
30
|
+
Android 가 그 이름으로 Java 를 생성하므로 Gradle 이 깨지고, 에러는 한참 뒤
|
|
31
|
+
빌드에서 난다. 이제 입력 시점에 거부한다 — 검사는 하이픈을 뺀 세그먼트 기준이라
|
|
32
|
+
`gym-class` 는 통과한다
|
|
33
|
+
|
|
34
|
+
- 표시 이름에 작은따옴표가 있으면(`Dev's App`) `env-candidates.ts` 가 문법적으로
|
|
35
|
+
깨진 채 커밋됐다. 역슬래시는 escape 로 먹혀 조용히 사라졌고, 줄바꿈은 파일을
|
|
36
|
+
깼다. 이제 리터럴에 맞게 escape 한다
|
|
37
|
+
|
|
38
|
+
- 완료 안내가 없어진 문서 절(`README "Make it yours" §3`)을 가리키고 있었다 —
|
|
39
|
+
생성된 앱의 README 를 짧은 것으로 바꾸면서 그 절이 사라졌다. 이제 교체할 파일
|
|
40
|
+
이름을 직접 말한다
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- 생성된 앱이 자기 정체를 갖는다 — `package.json` 의 `name` 을 slug 로, `version` 을
|
|
45
|
+
`1.0.0` 으로 두고 템플릿 `license` 를 지운다. 스토어 버전인
|
|
46
|
+
`env-candidates.ts` 의 `version.app` 도 같이 맞춘다
|
|
47
|
+
|
|
48
|
+
- 템플릿 레포 전용 파일(`CHANGELOG.md`·`LICENSE`·`README.md`)은 생성된 앱에 복사하지
|
|
49
|
+
않는다. 대신 앱 이름으로 짧은 `README.md` 를 새로 쓴다. `AGENTS.md`·`docs/` 는
|
|
50
|
+
앱과 같이 가는 게 목적이라 그대로 둔다
|
|
51
|
+
- `receipt.fileCount` 를 초기 커밋의 추적 파일 수로 센다 — 원격 경로는 복사 콜백이
|
|
52
|
+
없어서 0 으로 찍히고 있었다
|
|
53
|
+
|
|
11
54
|
## [0.0.1] — 2026-09-11
|
|
12
55
|
|
|
13
56
|
첫 공개 릴리즈. **PoC** 다 — `0.0.x` 는 안정성을 약속하지 않는다.
|
|
@@ -26,5 +69,7 @@
|
|
|
26
69
|
|
|
27
70
|
---
|
|
28
71
|
|
|
29
|
-
[unreleased]: https://github.com/LESANF/create-lesa-app/compare/v0.0.
|
|
72
|
+
[unreleased]: https://github.com/LESANF/create-lesa-app/compare/v0.0.3...HEAD
|
|
73
|
+
[0.0.3]: https://github.com/LESANF/create-lesa-app/compare/v0.0.2...v0.0.3
|
|
74
|
+
[0.0.2]: https://github.com/LESANF/create-lesa-app/compare/v0.0.1...v0.0.2
|
|
30
75
|
[0.0.1]: https://github.com/LESANF/create-lesa-app/releases/tag/v0.0.1
|
package/README.ko.md
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
lesa Expo 템플릿 발사대.<br/>
|
|
6
6
|
slug 하나만 주면 스킴·번들 ID·Android 패키지·첫 커밋까지 나온다.
|
|
7
7
|
|
|
8
|
+
[](https://www.npmjs.com/package/create-lesa-app)
|
|
9
|
+
[](https://www.npmjs.com/package/create-lesa-app)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
8
12
|
[](https://docs.expo.dev/versions/v57.0.0/)
|
|
9
13
|
[](https://reactnative.dev)
|
|
10
14
|
[](https://docs.expo.dev/router/introduction/)
|
package/README.md
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
Launcher for the lesa Expo template.<br/>
|
|
6
6
|
One slug in — schemes, bundle ids, Android packages and a first commit out.
|
|
7
7
|
|
|
8
|
+
[](https://www.npmjs.com/package/create-lesa-app)
|
|
9
|
+
[](https://www.npmjs.com/package/create-lesa-app)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
|
|
8
12
|
[](https://docs.expo.dev/versions/v57.0.0/)
|
|
9
13
|
[](https://reactnative.dev)
|
|
10
14
|
[](https://docs.expo.dev/router/introduction/)
|
package/package.json
CHANGED
package/src/apply.ts
CHANGED
|
@@ -33,6 +33,17 @@ const LEFTOVER = /write[-.]your/;
|
|
|
33
33
|
export class ApplyError extends Error {}
|
|
34
34
|
|
|
35
35
|
/** `env-candidates.ts` 치환. displayName 은 자리표시가 아니라 빈 문자열이라 따로 넣는다. */
|
|
36
|
+
/**
|
|
37
|
+
* 작은따옴표 문자열 리터럴에 넣을 수 있게 escape 한다. `Dev's App` 같은 이름이
|
|
38
|
+
* 그대로 들어가면 `env-candidates.ts` 가 문법적으로 깨진다.
|
|
39
|
+
*/
|
|
40
|
+
function escapeSingleQuoted(value: string): string {
|
|
41
|
+
return value
|
|
42
|
+
.replace(/\\/g, '\\\\')
|
|
43
|
+
.replace(/'/g, "\\'")
|
|
44
|
+
.replace(/\r\n?|\n/g, '\\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
export async function applyEnvCandidates(
|
|
37
48
|
projectDir: string,
|
|
38
49
|
fields: DerivedFields
|
|
@@ -51,7 +62,10 @@ export async function applyEnvCandidates(
|
|
|
51
62
|
|
|
52
63
|
if (fields.displayName) {
|
|
53
64
|
const before = source;
|
|
54
|
-
source = source.replace(
|
|
65
|
+
source = source.replace(
|
|
66
|
+
"displayName: '',",
|
|
67
|
+
`displayName: '${escapeSingleQuoted(fields.displayName)}',`
|
|
68
|
+
);
|
|
55
69
|
if (source === before) {
|
|
56
70
|
throw new ApplyError("Could not find the displayName slot (`displayName: '',`).");
|
|
57
71
|
}
|
package/src/copy.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 로컬 템플릿 폴더 → 대상 디렉터리 복사.
|
|
3
3
|
*
|
|
4
|
-
* 템플릿 레포가 private 이라 tarball 을 쓸 수 없다(사유: 템플릿 `docs/cli.md`).
|
|
5
|
-
* 배포 방식이 정해지면 이 파일만 교체한다 — 다른 모듈은 `copyTemplate` 시그니처만 안다.
|
|
6
|
-
*
|
|
7
4
|
* **복사 대상 = git 이 추적하는 파일.** 제외 목록을 손으로 들고 있으면 템플릿에 새 gitignore
|
|
8
5
|
* 항목이 생길 때마다 놓친다(실제로 `.pnpm-store`·`expo-env.d.ts`·`npmlogin.log` 를 놓쳤다).
|
|
9
6
|
* `git ls-files` 는 산출물·로컬 상태를 정의상 제외한다.
|
package/src/create.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { applyEnvCandidates, applyEnvFile } from './apply.ts';
|
|
|
12
12
|
import { assertEmptyTarget, copyTemplate } from './copy.ts';
|
|
13
13
|
import { derive } from './derive.ts';
|
|
14
14
|
import { fetchTemplate } from './fetch-template.ts';
|
|
15
|
+
import { applyIdentity } from './identity.ts';
|
|
16
|
+
import { pruneRepoOnly } from './repo-only.ts';
|
|
15
17
|
|
|
16
18
|
import type { AppInput } from './derive.ts';
|
|
17
19
|
|
|
@@ -53,6 +55,13 @@ export type CreateResult = {
|
|
|
53
55
|
receipt: { fileCount: number; wroteEnvFile: boolean; source: 'local' | 'github' };
|
|
54
56
|
};
|
|
55
57
|
|
|
58
|
+
async function countTracked(dir: string): Promise<number> {
|
|
59
|
+
const { stdout } = await run('git', ['-C', dir, 'ls-files', '-z'], {
|
|
60
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
61
|
+
});
|
|
62
|
+
return stdout.split('\0').filter(Boolean).length;
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
async function gitInit(dir: string): Promise<void> {
|
|
57
66
|
await run('git', ['-C', dir, 'init', '-q']);
|
|
58
67
|
await run('git', ['-C', dir, 'add', '-A']);
|
|
@@ -95,6 +104,8 @@ export async function createApp(options: CreateOptions): Promise<CreateResult> {
|
|
|
95
104
|
await fetchTemplate(targetDir, message => step({ index: 0, note: message }));
|
|
96
105
|
}
|
|
97
106
|
copied = true;
|
|
107
|
+
await pruneRepoOnly(targetDir, { displayName, slug });
|
|
108
|
+
await applyIdentity(targetDir, slug);
|
|
98
109
|
|
|
99
110
|
step({ index: 1 });
|
|
100
111
|
await applyEnvCandidates(targetDir, fields);
|
|
@@ -104,6 +115,8 @@ export async function createApp(options: CreateOptions): Promise<CreateResult> {
|
|
|
104
115
|
|
|
105
116
|
step({ index: 3 });
|
|
106
117
|
await gitInit(targetDir);
|
|
118
|
+
// 추적 파일 수가 유일한 정답이다 — 원격 경로는 복사 콜백이 없고, prune 이 3개를 뺀다.
|
|
119
|
+
fileCount = await countTracked(targetDir);
|
|
107
120
|
step({ index: CREATE_STEPS.length });
|
|
108
121
|
} catch (error) {
|
|
109
122
|
if (copied) {
|
|
@@ -123,7 +136,7 @@ export async function createApp(options: CreateOptions): Promise<CreateResult> {
|
|
|
123
136
|
nextSteps: [
|
|
124
137
|
`cd ${name} && pnpm install`,
|
|
125
138
|
'pnpm ios:development (or pnpm android:development)',
|
|
126
|
-
'Replace
|
|
139
|
+
'Replace assets/images/icon.png · adaptive-icon.png · splash-icon.png',
|
|
127
140
|
'Drop your config files into firebase/ to turn push on — docs/push.md',
|
|
128
141
|
'grep -rn "TODO(앱)" src — the spots your project fills in',
|
|
129
142
|
],
|
package/src/derive.ts
CHANGED
|
@@ -25,6 +25,20 @@ export type DerivedFields = {
|
|
|
25
25
|
|
|
26
26
|
export const SLUG_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Java 예약어는 패키지 세그먼트로 못 쓴다 — `com.class.development` 은 Gradle 에서
|
|
30
|
+
* 깨진다. Android 가 이 이름으로 Java 를 생성하기 때문이고, 에러는 한참 뒤 빌드에서 난다.
|
|
31
|
+
*/
|
|
32
|
+
const JAVA_RESERVED = new Set([
|
|
33
|
+
'abstract', 'assert', 'boolean', 'break', 'byte', 'case', 'catch', 'char',
|
|
34
|
+
'class', 'const', 'continue', 'default', 'do', 'double', 'else', 'enum',
|
|
35
|
+
'extends', 'false', 'final', 'finally', 'float', 'for', 'goto', 'if',
|
|
36
|
+
'implements', 'import', 'instanceof', 'int', 'interface', 'long', 'native',
|
|
37
|
+
'new', 'null', 'package', 'private', 'protected', 'public', 'return',
|
|
38
|
+
'short', 'static', 'strictfp', 'super', 'switch', 'synchronized', 'this',
|
|
39
|
+
'throw', 'throws', 'transient', 'true', 'try', 'void', 'volatile', 'while',
|
|
40
|
+
]);
|
|
41
|
+
|
|
28
42
|
/** 입력 검증 — 통과하지 못하면 이유를 돌려준다(호출부가 다시 묻는다). */
|
|
29
43
|
export function validateSlug(slug: string): string | null {
|
|
30
44
|
if (!slug) return 'Enter a slug.';
|
|
@@ -33,6 +47,11 @@ export function validateSlug(slug: string): string | null {
|
|
|
33
47
|
}
|
|
34
48
|
// reverse-domain 세그먼트로 들어가므로 하이픈으로 끝나면 `com.lesa-app-.development` 가 된다.
|
|
35
49
|
if (slug.endsWith('-')) return 'Cannot end with a hyphen.';
|
|
50
|
+
// 패키지 세그먼트는 하이픈을 뺀 형태다 — `gym-class` 는 `gymclass` 라 괜찮다.
|
|
51
|
+
const segment = slug.replaceAll('-', '');
|
|
52
|
+
if (JAVA_RESERVED.has(segment)) {
|
|
53
|
+
return `"${segment}" is a Java keyword and cannot be an Android package segment.`;
|
|
54
|
+
}
|
|
36
55
|
return null;
|
|
37
56
|
}
|
|
38
57
|
|
package/src/fetch-template.ts
CHANGED
|
@@ -17,7 +17,7 @@ const run = promisify(execFile);
|
|
|
17
17
|
|
|
18
18
|
export const TEMPLATE_REPO = 'LESANF/react-native-template-lesa';
|
|
19
19
|
/** 템플릿을 릴리즈하면 이 값을 올린다. */
|
|
20
|
-
export const TEMPLATE_REF = 'v0.0.
|
|
20
|
+
export const TEMPLATE_REF = 'v0.0.3';
|
|
21
21
|
|
|
22
22
|
export class FetchError extends Error {}
|
|
23
23
|
|
package/src/flow.test.mjs
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
|
|
3
|
-
import { derive } from './derive.ts';
|
|
4
|
-
import {
|
|
3
|
+
import { derive, validateSlug } from './derive.ts';
|
|
4
|
+
import {
|
|
5
|
+
advance,
|
|
6
|
+
editingField,
|
|
7
|
+
initialFlow,
|
|
8
|
+
isUsableAsSlug,
|
|
9
|
+
setField,
|
|
10
|
+
stepOrder,
|
|
11
|
+
toInput,
|
|
12
|
+
} from './flow.ts';
|
|
5
13
|
|
|
6
14
|
/** 타이핑 시뮬레이션: 문자열을 넣고 Enter. */
|
|
7
15
|
function type(state, text) {
|
|
@@ -96,3 +104,17 @@ console.log('flow: 7/7 groups pass');
|
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
console.log('derive: hyphen slug produces a valid Android package');
|
|
107
|
+
|
|
108
|
+
// 9) Java 예약어 — 패키지 세그먼트로 들어가면 Gradle 이 깬다
|
|
109
|
+
{
|
|
110
|
+
for (const slug of ['class', 'new', 'int', 'true', 'cl-ass']) {
|
|
111
|
+
assert.match(validateSlug(slug) ?? '', /Java keyword/, `${slug} must be rejected`);
|
|
112
|
+
}
|
|
113
|
+
for (const slug of ['gym-class', 'classroom', 'gym-log']) {
|
|
114
|
+
assert.equal(validateSlug(slug), null, `${slug} must be accepted`);
|
|
115
|
+
}
|
|
116
|
+
// 이름 스텝도 같은 검사를 탄다 — 통과하지 못하면 slug 을 따로 묻는다
|
|
117
|
+
assert.equal(isUsableAsSlug('class'), false);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log('derive: Java keywords are rejected as package segments');
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 생성된 앱이 자기 정체를 갖게 한다 — 템플릿의 이름·버전·라이선스를 물려받지 않도록.
|
|
3
|
+
*
|
|
4
|
+
* 앱의 스토어 버전은 `env-candidates.ts` 의 `version.app` 이고 `package.json` 의 것이
|
|
5
|
+
* 아니다. 둘이 어긋나면 읽는 사람이 헷갈리므로 같이 맞춘다.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** 새 앱의 첫 버전. 스토어 첫 배포 관례다. */
|
|
12
|
+
export const INITIAL_VERSION = '1.0.0';
|
|
13
|
+
|
|
14
|
+
export class IdentityError extends Error {}
|
|
15
|
+
|
|
16
|
+
export async function applyIdentity(projectDir: string, slug: string): Promise<void> {
|
|
17
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
18
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf8')) as Record<string, unknown>;
|
|
19
|
+
pkg.name = slug;
|
|
20
|
+
pkg.version = INITIAL_VERSION;
|
|
21
|
+
delete pkg.license; // 템플릿 라이선스다. 앱은 자기 것을 고른다
|
|
22
|
+
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
|
|
23
|
+
|
|
24
|
+
const envPath = path.join(projectDir, 'env-candidates.ts');
|
|
25
|
+
const source = await readFile(envPath, 'utf8');
|
|
26
|
+
const block = /(version:\s*\{\s*app:\s*\{)([^}]*)(\})/;
|
|
27
|
+
const match = source.match(block);
|
|
28
|
+
if (!match) {
|
|
29
|
+
throw new IdentityError(
|
|
30
|
+
`Could not find version.app in env-candidates.ts — the template shape changed.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const replaced = match[2].replace(/'\d+\.\d+\.\d+'/g, `'${INITIAL_VERSION}'`);
|
|
34
|
+
await writeFile(envPath, source.replace(block, `$1${replaced}$3`), 'utf8');
|
|
35
|
+
}
|
package/src/repo-only.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 템플릿 레포 전용 파일 — 생성된 앱에는 들어가면 안 된다.
|
|
3
|
+
* `AGENTS.md`·`docs/` 는 앱과 같이 가는 게 목적이라 여기 없다.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdir, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const REPO_ONLY = [
|
|
10
|
+
'CHANGELOG.md', // 템플릿의 릴리즈 이력
|
|
11
|
+
'LICENSE', // 템플릿 저작권 — 앱은 자기 것을 고른다
|
|
12
|
+
'README.md', // "MVP 라 사용을 권하지 않습니다" — 아래에서 새로 쓴다
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** 번역된 README — `README.ko.md` 처럼 언어 코드가 붙은 것들. */
|
|
16
|
+
const LOCALIZED_README = /^README\.[a-z]{2}(-[A-Z]{2})?\.md$/;
|
|
17
|
+
|
|
18
|
+
/** 레포 전용 파일을 지우고 앱 README 를 새로 쓴다. 복사·추출 어느 경로든 뒤에 한 번 돈다. */
|
|
19
|
+
export async function pruneRepoOnly(
|
|
20
|
+
targetDir: string,
|
|
21
|
+
app: { displayName: string; slug: string }
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const entries = await readdir(targetDir);
|
|
24
|
+
const remove = [...REPO_ONLY, ...entries.filter(name => LOCALIZED_README.test(name))];
|
|
25
|
+
await Promise.all(remove.map(name => rm(path.join(targetDir, name), { force: true })));
|
|
26
|
+
|
|
27
|
+
const title = app.displayName || app.slug;
|
|
28
|
+
await writeFile(
|
|
29
|
+
path.join(targetDir, 'README.md'),
|
|
30
|
+
`# ${title}\n\n` +
|
|
31
|
+
'```bash\n' +
|
|
32
|
+
'pnpm install\n' +
|
|
33
|
+
'pnpm ios:development # or pnpm android:development\n' +
|
|
34
|
+
'```\n\n' +
|
|
35
|
+
'아이콘·스플래시는 `assets/images/` 의 `icon.png`·`adaptive-icon.png`·`splash-icon.png` 를 교체합니다.\n\n' +
|
|
36
|
+
'작업 규칙과 메커니즘은 [`AGENTS.md`](./AGENTS.md), 영역별 문서는 [`docs/`](./docs) 에 있습니다.\n',
|
|
37
|
+
'utf8'
|
|
38
|
+
);
|
|
39
|
+
}
|