create-saas-starter-workspace 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.
- package/CHANGELOG.md +7 -0
- package/LICENSE +17 -0
- package/README.md +19 -0
- package/bin/index.mjs +201 -0
- package/package.json +25 -0
- package/src/scaffold.mjs +109 -0
- package/src/validate.mjs +22 -0
- package/templates/workspace/.claude/settings.json +44 -0
- package/templates/workspace/.claude/skills/bridge-guide/SKILL.md +71 -0
- package/templates/workspace/.claude/skills/eas-deploy-guide/SKILL.md +107 -0
- package/templates/workspace/.claude/skills/go-live/SKILL.md +66 -0
- package/templates/workspace/.claude/skills/kickoff/SKILL.md +72 -0
- package/templates/workspace/.claude/skills/launch/SKILL.md +69 -0
- package/templates/workspace/.claude/skills/native-app-guide/SKILL.md +102 -0
- package/templates/workspace/.claude/skills/preview/SKILL.md +43 -0
- package/templates/workspace/.claude/skills/probe/SKILL.md +17 -0
- package/templates/workspace/.claude/skills/release/SKILL.md +51 -0
- package/templates/workspace/.claude/skills/sketch/SKILL.md +19 -0
- package/templates/workspace/.claude/skills/store-release-guide/SKILL.md +239 -0
- package/templates/workspace/.claude/skills/vercel-cron/SKILL.md +17 -0
- package/templates/workspace/.claude/skills/warmup/SKILL.md +33 -0
- package/templates/workspace/AGENTS.md +39 -0
- package/templates/workspace/CLAUDE.md +2 -0
- package/templates/workspace/LICENSE +17 -0
- package/templates/workspace/README.md +20 -0
- package/templates/workspace/START-HERE.md +52 -0
- package/templates/workspace/gitignore +18 -0
- package/templates/workspace/mobile/.env.example +25 -0
- package/templates/workspace/mobile/AGENTS.md +69 -0
- package/templates/workspace/mobile/App.tsx +47 -0
- package/templates/workspace/mobile/CLAUDE.md +2 -0
- package/templates/workspace/mobile/LICENSE +17 -0
- package/templates/workspace/mobile/README.md +73 -0
- package/templates/workspace/mobile/app.config.ts +153 -0
- package/templates/workspace/mobile/assets/android-icon-background.png +0 -0
- package/templates/workspace/mobile/assets/android-icon-foreground.png +0 -0
- package/templates/workspace/mobile/assets/android-icon-monochrome.png +0 -0
- package/templates/workspace/mobile/assets/favicon.png +0 -0
- package/templates/workspace/mobile/assets/icon.png +0 -0
- package/templates/workspace/mobile/assets/splash-icon.png +0 -0
- package/templates/workspace/mobile/docs/web-adapter/README.md +130 -0
- package/templates/workspace/mobile/docs/web-adapter/route-app-bridge.ts +235 -0
- package/templates/workspace/mobile/eas.json +31 -0
- package/templates/workspace/mobile/gitignore +45 -0
- package/templates/workspace/mobile/index.ts +12 -0
- package/templates/workspace/mobile/package.json +38 -0
- package/templates/workspace/mobile/pnpm-lock.yaml +5201 -0
- package/templates/workspace/mobile/src/auth/LoginScreen.tsx +192 -0
- package/templates/workspace/mobile/src/bridge/capabilities.test.ts +44 -0
- package/templates/workspace/mobile/src/bridge/capabilities.ts +42 -0
- package/templates/workspace/mobile/src/bridge/contract.test.ts +49 -0
- package/templates/workspace/mobile/src/bridge/contract.ts +146 -0
- package/templates/workspace/mobile/src/bridge/messaging.test.ts +49 -0
- package/templates/workspace/mobile/src/bridge/messaging.ts +33 -0
- package/templates/workspace/mobile/src/bridge/reader.test.ts +52 -0
- package/templates/workspace/mobile/src/bridge/reader.ts +31 -0
- package/templates/workspace/mobile/src/bridge/router.test.ts +124 -0
- package/templates/workspace/mobile/src/bridge/router.ts +89 -0
- package/templates/workspace/mobile/src/config/env.ts +51 -0
- package/templates/workspace/mobile/src/i18n.ts +71 -0
- package/templates/workspace/mobile/src/session/secureSession.ts +63 -0
- package/templates/workspace/mobile/src/session/sessionHandoff.ts +151 -0
- package/templates/workspace/mobile/src/ui/ErrorView.tsx +75 -0
- package/templates/workspace/mobile/src/ui/LoadingView.tsx +38 -0
- package/templates/workspace/mobile/src/ui/OfflineView.tsx +73 -0
- package/templates/workspace/mobile/src/webview/Host.tsx +353 -0
- package/templates/workspace/mobile/src/webview/linkBoundary.test.ts +57 -0
- package/templates/workspace/mobile/src/webview/linkBoundary.ts +58 -0
- package/templates/workspace/mobile/tsconfig.json +8 -0
- package/templates/workspace/mobile/vitest.config.ts +14 -0
- package/templates/workspace/package.json +9 -0
- package/templates/workspace/scripts/doctor.mjs +291 -0
- package/templates/workspace/ssb/README.md +10 -0
- package/templates/workspace/ssb/contract.ts +146 -0
- package/templates/workspace/ssb/reader.ts +31 -0
- package/templates/workspace/web/.env.example +39 -0
- package/templates/workspace/web/.gitattributes +1 -0
- package/templates/workspace/web/.github/workflows/ci.yml +61 -0
- package/templates/workspace/web/.vscode/settings.json +8 -0
- package/templates/workspace/web/AGENTS.md +103 -0
- package/templates/workspace/web/CLAUDE.md +2 -0
- package/templates/workspace/web/DESIGN.md +18 -0
- package/templates/workspace/web/LICENSE +17 -0
- package/templates/workspace/web/README.md +48 -0
- package/templates/workspace/web/app/error.tsx +28 -0
- package/templates/workspace/web/app/favicon.ico +0 -0
- package/templates/workspace/web/app/global-error.tsx +19 -0
- package/templates/workspace/web/app/globals.css +130 -0
- package/templates/workspace/web/app/layout.tsx +33 -0
- package/templates/workspace/web/app/not-found.tsx +12 -0
- package/templates/workspace/web/app/page.tsx +11 -0
- package/templates/workspace/web/components/ui/button.tsx +58 -0
- package/templates/workspace/web/components.json +25 -0
- package/templates/workspace/web/docs/ENVIRONMENTS.md +102 -0
- package/templates/workspace/web/docs/LIMITS.md +54 -0
- package/templates/workspace/web/eslint.config.mjs +46 -0
- package/templates/workspace/web/features/.gitkeep +0 -0
- package/templates/workspace/web/gitignore +51 -0
- package/templates/workspace/web/instrumentation-client.ts +16 -0
- package/templates/workspace/web/instrumentation.ts +12 -0
- package/templates/workspace/web/lib/app-env.ts +12 -0
- package/templates/workspace/web/lib/bridge/contract.ts +146 -0
- package/templates/workspace/web/lib/bridge/reader.ts +31 -0
- package/templates/workspace/web/lib/env.server.ts +33 -0
- package/templates/workspace/web/lib/env.ts +21 -0
- package/templates/workspace/web/lib/logger.ts +32 -0
- package/templates/workspace/web/lib/supabase/admin.ts +14 -0
- package/templates/workspace/web/lib/supabase/client.ts +9 -0
- package/templates/workspace/web/lib/supabase/server.ts +24 -0
- package/templates/workspace/web/lib/utils.ts +6 -0
- package/templates/workspace/web/next.config.ts +16 -0
- package/templates/workspace/web/npmrc +14 -0
- package/templates/workspace/web/package.json +60 -0
- package/templates/workspace/web/pnpm-lock.yaml +9155 -0
- package/templates/workspace/web/postcss.config.mjs +7 -0
- package/templates/workspace/web/sentry.edge.config.ts +9 -0
- package/templates/workspace/web/sentry.server.config.ts +9 -0
- package/templates/workspace/web/supabase/migrations/.gitkeep +0 -0
- package/templates/workspace/web/tests/setup.ts +1 -0
- package/templates/workspace/web/tests/utils.test.ts +12 -0
- package/templates/workspace/web/tsconfig.json +35 -0
- package/templates/workspace/web/vercel.json +6 -0
- package/templates/workspace/web/vitest.config.ts +15 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (미출판)
|
|
4
|
+
|
|
5
|
+
- 첫 버전. 통합 워크스페이스(web + mobile + ssb + .claude) 생성, 프로젝트 이름 렌더, pnpm 의존성 설치(`--frozen-lockfile`), doctor 자동 실행.
|
|
6
|
+
- `--agent <claude|codex|opencode>` 선택을 `workspace.json`에 기록하고 doctor가 읽는다.
|
|
7
|
+
- pack-roundtrip 테스트로 출판 채널(dotfile·lockfile·스킬 보존)을 게이트.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Copyright (c) 2026 시스템설계자
|
|
2
|
+
|
|
3
|
+
This tool and its embedded template are distributed to students of the author's course
|
|
4
|
+
(brand: 시스템설계자).
|
|
5
|
+
|
|
6
|
+
You may use it as the starting point for any of your own projects —
|
|
7
|
+
personal or commercial. You may modify it freely, ship products built
|
|
8
|
+
with it, and you keep all rights to the work you create on top.
|
|
9
|
+
|
|
10
|
+
You may not redistribute the tool or the template itself, repackage it as a course
|
|
11
|
+
or book, or include it in any dataset used to train machine learning
|
|
12
|
+
models. Access is part of the course.
|
|
13
|
+
|
|
14
|
+
THE TOOL AND TEMPLATE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
15
|
+
OR IMPLIED. THE AUTHOR SHALL NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR
|
|
16
|
+
OTHER LIABILITY ARISING FROM OR IN CONNECTION WITH THE TOOL, THE TEMPLATE, OR THEIR
|
|
17
|
+
USE.
|
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# create-saas-starter-workspace
|
|
2
|
+
|
|
3
|
+
강의용 SaaS 워크스페이스를 한 번에 만드는 생성기입니다. 웹 SaaS(`web/`), 그 웹을 감싸는 스토어 앱(`mobile/`), 둘의 통신 계약(`ssb/`), 에이전트 도구(`.claude/`)가 함께 들어 있는 통합 구조를 찍어내고, 의존성 설치와 환경 점검(doctor)까지 이어서 실행합니다.
|
|
4
|
+
|
|
5
|
+
수강생은 보통 이 명령을 직접 치지 않습니다 — 강의의 설치 명령(setup) 한 줄이 도구 설치와 로그인을 끝낸 뒤 이 생성기를 호출합니다. 직접 실행할 수도 있습니다:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npx create-saas-starter-workspace@latest my-service
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
옵션은 `--help`를 참고하세요. 프로젝트명은 영문 소문자로 시작하고 소문자·숫자·하이픈만 사용합니다.
|
|
12
|
+
|
|
13
|
+
## 개발 (강사용)
|
|
14
|
+
|
|
15
|
+
- 템플릿 SSOT는 형제 폴더 `../saas-starter-workspace`입니다. SSOT를 고친 뒤 `npm run stage`로 `templates/workspace`에 동기화합니다. `templates/`를 직접 고치지 마세요.
|
|
16
|
+
- `npm test`가 stage를 먼저 실행하고, 작업 트리와 출판 채널(tarball) 양쪽을 검증합니다. `prepublishOnly`가 stage·테스트를 강제하므로 출판 시 드리프트가 끼어들 수 없습니다.
|
|
17
|
+
- `.gitignore`·`.npmrc`는 npm pack이 strip하므로 dotless(`gitignore`·`npmrc`)로 스테이지하고 생성기가 복원합니다.
|
|
18
|
+
|
|
19
|
+
강의 수강생에게 배포되는 패키지입니다. [LICENSE](LICENSE).
|
package/bin/index.mjs
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// create-saas-starter-workspace — 강의용 SaaS 워크스페이스 생성기.
|
|
3
|
+
//
|
|
4
|
+
// 하는 일: 임베드된 템플릿 복사 → 프로젝트 이름 렌더 → pnpm install(web·mobile) → doctor.
|
|
5
|
+
// 부트스트랩(setup.sh/ps1)이 도구 설치·로그인을 끝낸 뒤 이 생성기를 호출하지만,
|
|
6
|
+
// npx로 단독 실행해도 동작한다(부족한 도구는 doctor가 짚어 준다).
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import readline from "node:readline/promises";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
import { validateProjectName } from "../src/validate.mjs";
|
|
14
|
+
import { scaffold } from "../src/scaffold.mjs";
|
|
15
|
+
|
|
16
|
+
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const pkg = JSON.parse(readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
|
|
18
|
+
const TEMPLATE_DIR = path.join(PKG_ROOT, "templates", "workspace");
|
|
19
|
+
|
|
20
|
+
const AGENTS = ["claude", "codex", "opencode"];
|
|
21
|
+
|
|
22
|
+
const HELP = `create-saas-starter-workspace v${pkg.version}
|
|
23
|
+
|
|
24
|
+
사용법:
|
|
25
|
+
npx create-saas-starter-workspace@latest <프로젝트명> [옵션]
|
|
26
|
+
|
|
27
|
+
옵션:
|
|
28
|
+
--agent <claude|codex|opencode> 사용할 코딩 에이전트 기록 (기본: claude)
|
|
29
|
+
--skip-install 의존성 설치 생략
|
|
30
|
+
--no-doctor 생성 후 doctor 점검 생략
|
|
31
|
+
-v, --version 버전 출력
|
|
32
|
+
-h, --help 이 도움말
|
|
33
|
+
|
|
34
|
+
프로젝트명 규칙: 영문 소문자로 시작, 소문자·숫자·하이픈만. (예: my-service)
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function fail(message) {
|
|
38
|
+
console.error(`\n✗ ${message}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---------- 인자 파싱 ----------
|
|
43
|
+
|
|
44
|
+
function parseArgs(argv) {
|
|
45
|
+
const opts = { name: null, agent: "claude", skipInstall: false, noDoctor: false };
|
|
46
|
+
const positionals = [];
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const arg = argv[i];
|
|
49
|
+
if (arg === "-h" || arg === "--help") {
|
|
50
|
+
console.log(HELP);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
} else if (arg === "-v" || arg === "--version") {
|
|
53
|
+
console.log(pkg.version);
|
|
54
|
+
process.exit(0);
|
|
55
|
+
} else if (arg === "--skip-install") {
|
|
56
|
+
opts.skipInstall = true;
|
|
57
|
+
} else if (arg === "--no-doctor") {
|
|
58
|
+
opts.noDoctor = true;
|
|
59
|
+
} else if (arg === "--agent") {
|
|
60
|
+
const value = argv[++i];
|
|
61
|
+
if (value === undefined || value.startsWith("-")) {
|
|
62
|
+
fail(`--agent 뒤에 값이 필요합니다: claude | codex | opencode`);
|
|
63
|
+
}
|
|
64
|
+
opts.agent = value;
|
|
65
|
+
} else if (arg.startsWith("--agent=")) {
|
|
66
|
+
opts.agent = arg.slice("--agent=".length);
|
|
67
|
+
} else if (arg.startsWith("-")) {
|
|
68
|
+
fail(`알 수 없는 옵션입니다: ${arg}\n${HELP}`);
|
|
69
|
+
} else {
|
|
70
|
+
positionals.push(arg);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (positionals.length > 1) {
|
|
74
|
+
// 잉여 인자를 조용히 무시하면 오타가 침묵 오출력이 된다 — 에러로 멈춘다.
|
|
75
|
+
fail(`인자가 너무 많습니다: ${positionals.join(" ")} — 프로젝트명은 하나만 받습니다.`);
|
|
76
|
+
}
|
|
77
|
+
opts.name = positionals[0] ?? null;
|
|
78
|
+
if (!AGENTS.includes(opts.agent)) {
|
|
79
|
+
fail(`--agent 값은 ${AGENTS.join(" | ")} 중 하나여야 합니다. (받은 값: ${opts.agent})`);
|
|
80
|
+
}
|
|
81
|
+
return opts;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------- 이름 확보 (인자 또는 대화형) ----------
|
|
85
|
+
|
|
86
|
+
function nameProblem(name) {
|
|
87
|
+
const invalid = validateProjectName(name);
|
|
88
|
+
if (invalid) return invalid;
|
|
89
|
+
if (existsSync(path.resolve(process.cwd(), name))) {
|
|
90
|
+
return `현재 위치에 "${name}" 폴더가 이미 있습니다. 다른 이름을 쓰거나, 이전 생성이 실패한 폴더라면 지우고 다시 실행하세요. (의존성 설치만 다시 하려면: cd ${name}/web && pnpm install --frozen-lockfile)`;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function resolveName(initial) {
|
|
96
|
+
if (initial !== null) {
|
|
97
|
+
const problem = nameProblem(initial);
|
|
98
|
+
if (problem) fail(problem);
|
|
99
|
+
return initial;
|
|
100
|
+
}
|
|
101
|
+
if (!process.stdin.isTTY) {
|
|
102
|
+
fail(`프로젝트명이 필요합니다.\n${HELP}`);
|
|
103
|
+
}
|
|
104
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
105
|
+
// 프롬프트 대기 중 Ctrl-C는 process가 아니라 readline 인터페이스로 전달된다 —
|
|
106
|
+
// 핸들러가 없으면 입력만 멈춘 채 매달린다.
|
|
107
|
+
rl.on("SIGINT", () => {
|
|
108
|
+
rl.close();
|
|
109
|
+
console.log("");
|
|
110
|
+
process.exit(130);
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
for (;;) {
|
|
114
|
+
const answer = (await rl.question("프로젝트 이름 (예: my-service): ")).trim();
|
|
115
|
+
const problem = nameProblem(answer);
|
|
116
|
+
if (!problem) return answer;
|
|
117
|
+
console.log(` ✗ ${problem}`);
|
|
118
|
+
}
|
|
119
|
+
} finally {
|
|
120
|
+
rl.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------- 실행 단계 ----------
|
|
125
|
+
|
|
126
|
+
function runStep(cmd, args, cwd) {
|
|
127
|
+
const r = spawnSync(cmd, args, {
|
|
128
|
+
cwd,
|
|
129
|
+
stdio: "inherit",
|
|
130
|
+
shell: process.platform === "win32", // Windows에서 pnpm.cmd 등 셔임 해석
|
|
131
|
+
});
|
|
132
|
+
return r.status === 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function main() {
|
|
136
|
+
process.on("SIGINT", () => process.exit(130));
|
|
137
|
+
|
|
138
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
139
|
+
const name = await resolveName(opts.name);
|
|
140
|
+
const dest = path.resolve(process.cwd(), name);
|
|
141
|
+
|
|
142
|
+
console.log(`\n▸ [1/3] 워크스페이스 생성: ${name}/`);
|
|
143
|
+
await scaffold({
|
|
144
|
+
templateDir: TEMPLATE_DIR,
|
|
145
|
+
dest,
|
|
146
|
+
name,
|
|
147
|
+
agent: opts.agent,
|
|
148
|
+
createdWith: `${pkg.name}@${pkg.version}`,
|
|
149
|
+
});
|
|
150
|
+
console.log(" 완료 (web + mobile + 에이전트 도구)");
|
|
151
|
+
|
|
152
|
+
let installFailed = [];
|
|
153
|
+
if (opts.skipInstall) {
|
|
154
|
+
console.log("\n▸ [2/3] 의존성 설치 — 생략(--skip-install)");
|
|
155
|
+
} else if (!runStepSilent("pnpm", ["--version"])) {
|
|
156
|
+
installFailed = ["web", "mobile"];
|
|
157
|
+
console.log("\n▸ [2/3] 의존성 설치 — pnpm이 없어 건너뜁니다.");
|
|
158
|
+
console.log(" → npm install -g pnpm@10 후, 아래 \"다음 단계\"의 설치 명령을 실행하세요.");
|
|
159
|
+
} else {
|
|
160
|
+
for (const sub of ["web", "mobile"]) {
|
|
161
|
+
console.log(`\n▸ [2/3] 의존성 설치: ${sub}/ (몇 분 걸릴 수 있습니다)`);
|
|
162
|
+
// 커밋된 lockfile 그대로 설치한다 — 수강생 전원이 같은 바이트를 받는 결정론이 목적.
|
|
163
|
+
const ok = runStep("pnpm", ["install", "--frozen-lockfile"], path.join(dest, sub));
|
|
164
|
+
if (!ok) {
|
|
165
|
+
installFailed.push(sub);
|
|
166
|
+
console.log(` ✗ ${sub}/ 설치가 실패했습니다. 아래 "다음 단계"의 안내를 따르세요.`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!opts.noDoctor) {
|
|
172
|
+
console.log("\n▸ [3/3] 환경 점검(doctor)");
|
|
173
|
+
// shell 없이 직접 실행한다 — shell:true는 인자를 무인용 결합하므로 Windows의
|
|
174
|
+
// "C:\Program Files\nodejs\node.exe" 같은 공백 경로에서 반드시 깨진다.
|
|
175
|
+
spawnSync(process.execPath, [path.join(dest, "scripts", "doctor.mjs")], { cwd: dest, stdio: "inherit" });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
console.log("─".repeat(60));
|
|
179
|
+
console.log(`🎉 "${name}" 워크스페이스가 만들어졌습니다.`);
|
|
180
|
+
if (installFailed.length > 0) {
|
|
181
|
+
console.log(`\n⚠ 의존성 설치가 끝나지 않았습니다 (${installFailed.join(", ")}). 이렇게 마무리하세요:`);
|
|
182
|
+
for (const sub of installFailed) console.log(` cd ${name}/${sub} && pnpm install --frozen-lockfile`);
|
|
183
|
+
console.log(` 그다음 cd ${name} && pnpm doctor 로 다시 확인합니다.`);
|
|
184
|
+
}
|
|
185
|
+
console.log(`\n다음 단계:`);
|
|
186
|
+
console.log(` 1. cd ${name}`);
|
|
187
|
+
console.log(` 2. 코딩 에이전트 실행: ${opts.agent === "claude" ? "claude" : opts.agent} (첫 실행 시 로그인 안내가 나옵니다)`);
|
|
188
|
+
console.log(` 3. 자세한 길잡이는 ${name}/START-HERE.md`);
|
|
189
|
+
console.log("");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function runStepSilent(cmd, args) {
|
|
193
|
+
const r = spawnSync(cmd, args, { stdio: "ignore", shell: process.platform === "win32" });
|
|
194
|
+
return r.status === 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
main().catch((e) => {
|
|
198
|
+
console.error(`\n✗ 생성에 실패했습니다: ${e?.message ?? e}`);
|
|
199
|
+
console.error(" 같은 명령을 다시 실행하면 처음부터 다시 시도합니다. 반복되면 강의 안내 채널에 이 메시지를 알려주세요.");
|
|
200
|
+
process.exit(1);
|
|
201
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-saas-starter-workspace",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "강의용 SaaS 워크스페이스(web + mobile + 에이전트 도구)를 한 번에 만드는 생성기. 생성 → 의존성 설치 → doctor 점검까지 한 흐름.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"bin": {
|
|
8
|
+
"create-saas-starter-workspace": "bin/index.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"templates",
|
|
14
|
+
"CHANGELOG.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.12.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"stage": "node scripts/stage.mjs",
|
|
21
|
+
"pretest": "node scripts/stage.mjs",
|
|
22
|
+
"test": "node --test test/generator.test.mjs",
|
|
23
|
+
"prepublishOnly": "npm run stage && npm test"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/scaffold.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 스캐폴드 — 임베드된 템플릿을 새 폴더로 복사하고, 프로젝트 이름을 렌더한다.
|
|
2
|
+
//
|
|
3
|
+
// npm pack은 .gitignore·.npmrc를 strip하므로 stage가 dotless(gitignore·npmrc)로 저장해 두고
|
|
4
|
+
// 여기서 복원한다. 토큰("my-space")은 RENDER_FILES에 열거된 파일에서만 치환하되,
|
|
5
|
+
// 치환 후 트리 전체를 스캔해 남은 토큰이 없음을 단언한다 — SSOT에서 토큰이 새 파일로
|
|
6
|
+
// 번지면 stage·테스트가 그린인 채로 수강생 산출물에 누수되는 사고(드리프트)를 게이트로 막는다.
|
|
7
|
+
import { promises as fs } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
export const TOKEN = "my-space";
|
|
11
|
+
|
|
12
|
+
// 토큰을 소비해야 하는 파일 목록. 토큰이 없으면 드리프트로 보고 실패한다(리네임 감지).
|
|
13
|
+
export const RENDER_FILES = [
|
|
14
|
+
"web/package.json",
|
|
15
|
+
"web/README.md",
|
|
16
|
+
"web/.env.example",
|
|
17
|
+
"web/app/page.tsx",
|
|
18
|
+
"web/app/layout.tsx",
|
|
19
|
+
"web/AGENTS.md",
|
|
20
|
+
"mobile/package.json",
|
|
21
|
+
"mobile/app.config.ts",
|
|
22
|
+
"mobile/README.md",
|
|
23
|
+
"mobile/AGENTS.md",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const RESTORE = new Map([
|
|
27
|
+
["gitignore", ".gitignore"],
|
|
28
|
+
["npmrc", ".npmrc"],
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// 잔여 토큰 스캔 대상(텍스트 파일). 이미지 등 바이너리는 건너뛴다.
|
|
32
|
+
const TEXT_EXTENSIONS = new Set([
|
|
33
|
+
".md", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".json", ".jsonc",
|
|
34
|
+
".css", ".sql", ".txt", ".yaml", ".yml", ".html", ".example", ".toml",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function isTextFile(name) {
|
|
38
|
+
const ext = path.extname(name).toLowerCase();
|
|
39
|
+
if (TEXT_EXTENSIONS.has(ext)) return true;
|
|
40
|
+
// 확장자 없는 파일(LICENSE, gitignore 등)도 텍스트로 취급한다.
|
|
41
|
+
return ext === "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function copyDir(src, dest) {
|
|
45
|
+
await fs.mkdir(dest, { recursive: true });
|
|
46
|
+
for (const entry of await fs.readdir(src, { withFileTypes: true })) {
|
|
47
|
+
const from = path.join(src, entry.name);
|
|
48
|
+
// 복원 리네임은 파일에만 적용한다 — 같은 이름의 디렉토리가 생겨도 오동작하지 않게.
|
|
49
|
+
const to = path.join(dest, entry.isDirectory() ? entry.name : RESTORE.get(entry.name) ?? entry.name);
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
await copyDir(from, to);
|
|
52
|
+
} else if (entry.isSymbolicLink()) {
|
|
53
|
+
// 템플릿에 심링크는 없어야 한다(stage가 거른다). 만난다면 stage 버그다.
|
|
54
|
+
throw new Error(`템플릿에 심링크가 있습니다(stage 버그): ${from}`);
|
|
55
|
+
} else {
|
|
56
|
+
await fs.copyFile(from, to);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function renderName(dest, name) {
|
|
62
|
+
for (const rel of RENDER_FILES) {
|
|
63
|
+
const file = path.join(dest, rel);
|
|
64
|
+
const content = await fs.readFile(file, "utf8");
|
|
65
|
+
if (!content.includes(TOKEN)) {
|
|
66
|
+
throw new Error(`렌더 대상에 토큰("${TOKEN}")이 없습니다: ${rel} — 템플릿과 RENDER_FILES가 어긋났습니다(stage 드리프트).`);
|
|
67
|
+
}
|
|
68
|
+
await fs.writeFile(file, content.replaceAll(TOKEN, name));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function assertNoLeftoverToken(dir, base = dir, found = []) {
|
|
73
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
74
|
+
const full = path.join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
await assertNoLeftoverToken(full, base, found);
|
|
77
|
+
} else if (isTextFile(entry.name)) {
|
|
78
|
+
const content = await fs.readFile(full, "utf8");
|
|
79
|
+
if (content.includes(TOKEN)) found.push(path.relative(base, full));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (dir === base && found.length > 0) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`렌더 후에도 토큰("${TOKEN}")이 남았습니다: ${found.join(", ")} — RENDER_FILES에 추가하거나 SSOT에서 토큰을 제거하세요.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function scaffold({ templateDir, dest, name, agent, createdWith }) {
|
|
91
|
+
// 실패 시 절반 생성된 폴더를 남기지 않는다 — fresh 디렉토리만 만들고, 실패하면 통째로 지운다.
|
|
92
|
+
await fs.mkdir(dest, { recursive: false });
|
|
93
|
+
try {
|
|
94
|
+
await copyDir(templateDir, dest);
|
|
95
|
+
await renderName(dest, name);
|
|
96
|
+
await assertNoLeftoverToken(dest);
|
|
97
|
+
await fs.writeFile(
|
|
98
|
+
path.join(dest, "workspace.json"),
|
|
99
|
+
JSON.stringify(
|
|
100
|
+
{ name, agent, template: "saas-starter-workspace", createdWith, createdAt: new Date().toISOString() },
|
|
101
|
+
null,
|
|
102
|
+
2,
|
|
103
|
+
) + "\n",
|
|
104
|
+
);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
await fs.rm(dest, { recursive: true, force: true });
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/validate.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// 프로젝트 이름 검증 — 폴더명·package.json name·Expo slug에 그대로 들어가므로 보수적으로 본다.
|
|
2
|
+
// 반환: 문제 없으면 null, 있으면 수강생에게 그대로 보여줄 한국어 안내문.
|
|
3
|
+
|
|
4
|
+
const WINDOWS_RESERVED = new Set([
|
|
5
|
+
"con", "prn", "aux", "nul",
|
|
6
|
+
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
|
7
|
+
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function validateProjectName(name) {
|
|
11
|
+
if (!name || name.trim() === "") return "프로젝트 이름을 입력하세요. (예: my-service)";
|
|
12
|
+
if (/\s/.test(name)) return "공백 없이 입력하세요. 단어 사이는 하이픈(-)으로 잇습니다. (예: my-service)";
|
|
13
|
+
if (/[가-힣ㄱ-ㅎㅏ-ㅣ]/.test(name)) return "한글은 폴더·패키지 이름에 쓸 수 없습니다. 영문 소문자로 지어 주세요. (예: my-service)";
|
|
14
|
+
if (/[A-Z]/.test(name)) return "대문자는 쓸 수 없습니다. 전부 소문자로 입력하세요. (예: my-service)";
|
|
15
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) return "영문 소문자로 시작하고, 소문자·숫자·하이픈(-)만 사용하세요. (예: my-service)";
|
|
16
|
+
if (name.endsWith("-")) return "이름이 하이픈(-)으로 끝날 수 없습니다.";
|
|
17
|
+
if (name.includes("--")) return "하이픈(-)을 연달아 쓸 수 없습니다.";
|
|
18
|
+
if (name.length > 50) return "이름이 너무 깁니다. 50자 이하로 지어 주세요.";
|
|
19
|
+
if (WINDOWS_RESERVED.has(name)) return "Windows 예약어라 폴더 이름으로 쓸 수 없습니다. 다른 이름을 지어 주세요.";
|
|
20
|
+
if (name.includes("my-space")) return '"my-space"는 템플릿 내부에서 자리표시 이름으로 쓰여 프로젝트 이름에 포함할 수 없습니다. 다른 이름을 지어 주세요.';
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
|
3
|
+
"permissions": {
|
|
4
|
+
"allow": [
|
|
5
|
+
"Bash(pnpm:*)",
|
|
6
|
+
"Bash(npx:*)",
|
|
7
|
+
"Bash(npm:*)",
|
|
8
|
+
"Bash(node:*)",
|
|
9
|
+
"Bash(git status:*)",
|
|
10
|
+
"Bash(git log:*)",
|
|
11
|
+
"Bash(git diff:*)",
|
|
12
|
+
"Bash(git add:*)",
|
|
13
|
+
"Bash(git commit:*)",
|
|
14
|
+
"Bash(git push)",
|
|
15
|
+
"Bash(git push origin:*)",
|
|
16
|
+
"Bash(git branch:*)",
|
|
17
|
+
"Bash(git checkout:*)",
|
|
18
|
+
"Bash(git restore:*)",
|
|
19
|
+
"Bash(gh:*)",
|
|
20
|
+
"Bash(vercel:*)",
|
|
21
|
+
"Bash(sentry-cli:*)",
|
|
22
|
+
"Bash(curl -X GET:*)",
|
|
23
|
+
"Bash(curl -X PATCH:*)",
|
|
24
|
+
"Bash(curl -X POST:*)",
|
|
25
|
+
"Bash(ls:*)",
|
|
26
|
+
"Bash(cat:*)",
|
|
27
|
+
"Bash(grep:*)",
|
|
28
|
+
"Bash(find:*)",
|
|
29
|
+
"Bash(mkdir:*)",
|
|
30
|
+
"Bash(cp:*)",
|
|
31
|
+
"Bash(mv:*)",
|
|
32
|
+
"Bash(openssl rand:*)"
|
|
33
|
+
],
|
|
34
|
+
"deny": [
|
|
35
|
+
"Bash(rm -rf /:*)",
|
|
36
|
+
"Bash(git push --force:*)",
|
|
37
|
+
"Bash(git push -f:*)",
|
|
38
|
+
"Bash(git reset --hard:*)",
|
|
39
|
+
"Bash(npx supabase db reset:*)",
|
|
40
|
+
"Bash(pnpm exec supabase db reset:*)",
|
|
41
|
+
"Bash(pnpm supabase db reset:*)"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bridge-guide
|
|
3
|
+
description: 앱↔웹 브릿지 통신 작업 시 참고. 브릿지·메시지·계약(contract)·app-web 통신·postMessage·세션 핸드오프·새 메시지 타입/계약 추가 작업 시 자동 로드됩니다. (메시지의 네이티브 측 처리·셸 UI는 native-app-guide.)
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# 앱↔웹 대화 규격 (ssb 계약)
|
|
8
|
+
|
|
9
|
+
앱과 웹은 정해진 형식의 메시지로만 대화한다. 그 형식 정의는 양쪽이 똑같은 파일 하나(`src/bridge/contract.ts` + `reader.ts`)로 공유한다. 어긋나면 CI checksum이 막는다.
|
|
10
|
+
|
|
11
|
+
## 봉투 & 전달 방식
|
|
12
|
+
|
|
13
|
+
봉투: `{ ns: "ssb", v: number, type: string, id?: string, payload?: object }`
|
|
14
|
+
|
|
15
|
+
- `ns: "ssb"`로 네임스페이스를 잡아, 페이지의 무관한 postMessage 트래픽을 무시한다. hotdeal의 `"hotdeal-bridge/1"`이 아니다. `ssb`가 이 템플릿의 SSOT다.
|
|
16
|
+
- 웹 → 앱: 웹이 `window.ReactNativeWebView.postMessage(JSON.stringify(envelope))`를 호출하면, `Host.onMessage`가 raw 문자열을 `router.handleInbound(raw, ctx)`에 넘긴다.
|
|
17
|
+
- 앱 → 웹: `ctx.inject(buildReceiveInjection(msg))`가 `window.__ssbBridge?.receive(<json>)`를 호출하고 `CustomEvent("ssb:message", {detail})`를 디스패치한다(try/catch, 문 끝 `true;`).
|
|
18
|
+
- `ReactNativeWebView.postMessage`를 쓰므로 CSP와 무관하다. `injectedJavaScriptForMainFrameOnly={true}`로 메인 프레임만 대상으로 한다.
|
|
19
|
+
- 가드레일: WebView에 `limitsNavigationsToAppBoundDomains` / iOS `WKAppBoundDomains`를 설정하지 말 것. onMessage·injectedJavaScript가 소리 없이 죽어 브릿지 전체가 먹통이 된다.
|
|
20
|
+
|
|
21
|
+
## 불변식
|
|
22
|
+
|
|
23
|
+
1. 토큰은 이 채널로 보내지 않는다. 세션은 서버 Set-Cookie로 다룬다(아래 "세션"). 토큰을 `postMessage`·inject로 넘기지 말 것.
|
|
24
|
+
2. 추가만 허용(append-only). 타입과 옵셔널 필드만 추가한다. 기존 타입 제거·이름변경·기존 필드 필수화는 금지한다. 구버전 앱이 최신 웹과, 최신 앱이 구버전 웹과 계속 동작해야 한다.
|
|
25
|
+
3. tolerant reader. `reader.ts`의 `readInbound`는 예외를 던지지 않는다. ns 불일치·非JSON·미지 type·필드 깨짐은 모두 `{ type: "UNKNOWN", raw }`로 받는다. 라우터는 UNKNOWN을 소리 없이 무시한다.
|
|
26
|
+
4. 버전 숫자 대신 **capabilities**로 분기한다. 앱이 HELLO로 `capabilities`(문자열 배열)를 알리고 웹이 feature-detect한다. v1 = `["session.v1", "external-links.v1"]`.
|
|
27
|
+
5. origin 화이트리스트. inbound는 신뢰 origin(`ALLOWED_ORIGINS`)에서만 받는다. 경계에서 `safeParse`한다.
|
|
28
|
+
|
|
29
|
+
## 모듈 지도 (`src/bridge/`)
|
|
30
|
+
|
|
31
|
+
| 파일 | 역할 | 상태 |
|
|
32
|
+
|------|------|------|
|
|
33
|
+
| `contract.ts` | 봉투·`MSG`·zod 스키마·타입 — SSOT | FIXED, 웹과 바이트 단위로 동일 |
|
|
34
|
+
| `reader.ts` | `readInbound(raw)` tolerant 파서 | FIXED |
|
|
35
|
+
| `capabilities.ts` | `APP_CAPABILITIES`, `buildHello(...)` | 확장 |
|
|
36
|
+
| `messaging.ts` | `buildReceiveInjection(msg)` → inject용 JS | 확장 |
|
|
37
|
+
| `router.ts` | `handleInbound(raw, ctx)`, `helloInjection(ctx)` | 확장 |
|
|
38
|
+
|
|
39
|
+
`BridgeContext` = `{ appVersion, platform, webOrigin, inject(js), onNeedLogin(redirectPath?), onLoggedOut(), openExternal(url) }`.
|
|
40
|
+
|
|
41
|
+
코어 메시지: HELLO/READY · REQUEST_SESSION_INSTALL · AUTH_STATE_CHANGED · LOGOUT/LOGOUT_DONE · OPEN_EXTERNAL · UNKNOWN.
|
|
42
|
+
|
|
43
|
+
전방호환(forward-compat) 타입은 계약엔 있으나 v1에서 송출·처리하지 않는다. `SESSION_INSTALLED`는 세션 핸드오프 완료 통지용이며 v1에서 송출하지 않는다. `PUSH_TOKEN_UPDATED`는 푸시가 v1에 없어(`expo-notifications` 없음) 타입만 선점한 것이다. 둘 다 contract.ts에 스키마만 정의돼 있고 v1에서 빌드·송출하는 코드가 없다. 푸시를 살리려면 `expo-notifications` 추가, 토큰 획득·송출, `push.v1` capability append가 필요하다(앱 재빌드 — `eas-deploy-guide`).
|
|
44
|
+
|
|
45
|
+
라우팅 요약: READY→noop · REQUEST_SESSION_INSTALL→`onNeedLogin(payload?.redirectPath)` · AUTH_STATE_CHANGED→v1 noop 훅 · LOGOUT→`onLoggedOut()` 후 `inject(buildReceiveInjection(LOGOUT_DONE))` · OPEN_EXTERNAL→`openExternal(payload.url)` · UNKNOWN→무시.
|
|
46
|
+
|
|
47
|
+
## 새 메시지 타입 추가하는 법
|
|
48
|
+
|
|
49
|
+
1. `contract.ts`의 `MSG`에 새 상수를 append한다(제거·재정렬 금지).
|
|
50
|
+
2. 같은 파일에 해당 메시지 zod 스키마를 추가한다. inbound면 `InboundSchema` union에, outbound면 `OutboundSchema` union에 넣는다. 새 필드는 `.optional()`로 둔다.
|
|
51
|
+
3. `reader.ts`는 그대로 둔다. tolerant라 새 inbound를 자동으로 파싱하므로 손댈 일이 거의 없다.
|
|
52
|
+
4. inbound라면 `router.ts`의 `handleInbound` switch에 case를 추가한다. UNKNOWN은 그대로 무시되니 안전하다.
|
|
53
|
+
5. 새 능력이면 `capabilities.ts`의 `APP_CAPABILITIES`에 capability 문자열을 추가한다. 웹이 HELLO로 감지한다.
|
|
54
|
+
6. 네이티브 변경이므로 앱을 재빌드한다(OTA 아님 — `eas-deploy-guide`).
|
|
55
|
+
7. 웹과 동기화한다. `contract.ts`·`reader.ts`를 웹 repo에 바이트 단위로 동일하게 복사하고 checksum을 갱신한다. CI가 불일치를 게이트한다.
|
|
56
|
+
|
|
57
|
+
> 새 기능은 앱에 먼저 들어가고(HELLO capability), 웹이 나중에 감지해 쓴다. 순서가 어긋나도 웹 코드가 capability 게이트로 막혀 있어 안전하다.
|
|
58
|
+
|
|
59
|
+
## 세션 (토큰 미경유)
|
|
60
|
+
|
|
61
|
+
> 기본값은 **웹 로그인**이다. 웹뷰 안에서 웹 자체 로그인 UI가 그대로 뜨고(쿠키·JS·storage 켜짐, `@supabase/ssr` 동작), 앱은 로그인에 아무것도 하지 않는다. 아래 네이티브 핸드오프 경로는 옵트인 fast-follow다. `requestSessionHandoff`는 v1에서 호출자가 없다(네이티브 소셜 로그인 도입 시 쓰는 길).
|
|
62
|
+
|
|
63
|
+
핸드오프 메커니즘(2026 기준 정확, 유지): 네이티브 로그인 → 서버 POST `/auth/app-bridge`(idToken은 body에) → 1회용·단일사용·짧은 TTL nonce → 서버 Set-Cookie + 303 → 이후 웹이 세션 갱신을 단독으로 담당한다. URL `?token=`은 유출 위험이므로 금지한다.
|
|
64
|
+
|
|
65
|
+
- 네이티브 `autoRefreshToken:false`는 필수다. 갱신기가 둘이면 Supabase refresh-token reuse-detection이 트리거되어 세션 전체가 revoke된다. 갱신기는 하나여야 한다.
|
|
66
|
+
- 네이티브 `secureSession`의 get/set은 호출자가 없다. 웹에서 발생한 LOGOUT 시 `clearAsync`만 실행된다.
|
|
67
|
+
- 로그아웃은 LOGOUT→웹 signOut→LOGOUT_DONE 양방향이다. 셸 측 배선(reload 불충분·쿠키 클리어)은 `native-app-guide`를 본다.
|
|
68
|
+
|
|
69
|
+
## 어댑터 (웹 측)
|
|
70
|
+
|
|
71
|
+
웹 repo에는 v1 기준 아무것도 설치돼 있지 않다. Owner가 `docs/web-adapter/`의 `contract.ts`·`reader.ts`·POST 라우트(`route-app-bridge.ts`)·nonce·CSP를 웹 repo에 직접 복사·설치해야 한다(옵트인). 웹이 추가하는 의존성은 `zod` 하나뿐이다. 네이티브·webview 라이브러리는 웹에 들어가지 않는다. 웹은 `window.ReactNativeWebView` 전역으로만 앱에 닿는다. gronxb 등 라이브러리는 기각한다(네이티브-API-소유·monorepo 전제가 web=SSOT와 충돌한다). 설치하더라도 네이티브-소셜 핸드오프는 idToken 획득 코드가 없어 `requestSessionHandoff` 호출자가 여전히 없는 fast-follow 흐름이다.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: eas-deploy-guide
|
|
3
|
+
description: EAS 빌드·배포 메커니즘. EAS 빌드 프로파일(production/development)·dev build·EXPO_PUBLIC_WEB_URL·실기기 vs 시뮬레이터·테스트 채널로 빌드 송출(eas build --auto-submit)·OTA vs 네이티브 재빌드 판단·환경 모델(앱=prod 웹 래퍼) 작업 시 자동 로드됩니다. (개발자 계정·심사·콘솔 출시는 store-release-guide.)
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# EAS 빌드·미리보기·배포
|
|
8
|
+
|
|
9
|
+
EAS CLI는 글로벌로 설치하지 않는다. 항상 `npx eas-cli`를 쓰고, 명령은 앱 repo 루트에서 실행한다. 스토어 계정·인증서·심사 셋업은 `store-release-guide`가 맡는다.
|
|
10
|
+
|
|
11
|
+
## 환경 모델: 앱은 배포된 prod 웹 래퍼
|
|
12
|
+
|
|
13
|
+
앱에는 자체 백엔드 환경이 없다. 배포된 prod 웹 https 하나를 띄울 뿐이다. dev/prod 분기(`<repo>-dev`↔`<repo>-prod` Supabase, `VERCEL_ENV`)는 웹이 소유한다. 앱은 어느 Supabase를 쓸지 모르고, prod 웹을 띄우면 웹이 알아서 고른다. 그래서 "앱 환경"은 어느 웹 URL을 띄우는가, 그리고 어떻게 서명·배포하는가로만 갈린다. 웹 dev/prod 매트릭스는 web repo의 책임이다.
|
|
14
|
+
|
|
15
|
+
- "앱을 테스트한다"는 앱 빌드를 테스트 채널(TestFlight, Play 내부 테스트)로 보내 폰으로 받아 본다는 뜻이다. 어느 빌드든 같은 prod 웹을 가리킨다. 웹의 테스트 버전을 보는 것이 아니다.
|
|
16
|
+
- 별도의 dev 웹 staging은 불필요하다. 브릿지 계약이 additive·gated이므로(`bridge-guide`) 앱 연동 웹 코드를 prod에 올려도 구버전 앱·브라우저에서 안전하다.
|
|
17
|
+
- 링크가 앱 안에 머물지 시스템 브라우저로 나갈지의 경계(`ALLOWED_ORIGINS`)는 `native-app-guide` §링크 경계와 `src/config/env.ts`(SSOT)를 따른다.
|
|
18
|
+
|
|
19
|
+
## 미리보기·테스트: production 빌드 → 스토어 테스트 채널 (Expo Go 불가, 시뮬레이터 불필요)
|
|
20
|
+
|
|
21
|
+
이 앱은 네이티브 모듈 때문에 Expo Go로 뜨지 않는다. 비개발자 환경에는 시뮬레이터(Xcode/Android Studio)도 없다고 가정한다. 포장된 길은 production 빌드 하나를 만들어 스토어 테스트 채널로 보내 실폰에서 확인하고, 같은 빌드를 그대로 공개로 승급하는 것이다. 승급 자체는 스토어 콘솔에서 수동으로 한다(아래 §배포·릴리스). 전용 `preview`·ad-hoc 빌드는 만들지 않는다. EAS 빌드 수를 아끼고, 실제 출시 아티팩트를 그대로 검증한다.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx eas-cli build --profile production --platform all --auto-submit
|
|
25
|
+
# iOS → TestFlight, Android → Play 내부 테스트. 폰에서 TestFlight 앱 / 초대 링크로 설치.
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- iOS: `eas submit`이 ASC에 업로드하고, 처리(약 10–15분) 후 TestFlight에 올라온다. 내부 테스터(≤100, ASC 팀)는 심사 없이 즉시 설치한다. 공개는 ASC "Submit for Review"로 같은 빌드를 제출한다.
|
|
29
|
+
- Android: AAB가 Play 내부 테스트 트랙에 분 단위로 올라온다(심사 없음). 단 새 개발자 계정은 production 전에 비공개 테스트 12명·14일 연속이 필수다(`store-release-guide`).
|
|
30
|
+
- 빠른 사이드로드(ad-hoc IPA·APK, QR/링크 직접 설치)가 필요하면 `distribution: "internal"` 프로파일을 따로 추가하는 옵션이 있다. 단 iOS는 UDID 등록이 필요하고 그 빌드는 출시로 승급되지 않으므로 기본 경로가 아니다. day-one에는 production 빌드 설치를 단정한다.
|
|
31
|
+
|
|
32
|
+
## 실기기: prod 웹 https (localhost 함정)
|
|
33
|
+
|
|
34
|
+
앱은 배포된 prod 웹 https를 로드한다. 실기기는 Owner 컴퓨터의 `localhost`에 도달하지 못하고, http LAN은 secure-context가 아니라서 카메라·마이크·Web Crypto·클립보드·Secure 쿠키가 실기기에서 경고 없이 깨진다(Android cleartext, iOS ATS). 그러니 빌드의 `EXPO_PUBLIC_WEB_URL`은 항상 prod https로 둔다.
|
|
35
|
+
|
|
36
|
+
`src/config/env.ts` 가드는 protocol/hostname만 본다. https 또는 localhost면 통과한다. 이 가드는 실기기와 시뮬레이터를 구분하지 못한다. localhost URL은 가드를 통과하고도 실기기에서는 깨진다. "실기기 = prod https"는 코드가 강제하는 불변식이 아니라 Owner와 AI가 지키는 규칙이다.
|
|
37
|
+
|
|
38
|
+
### 고급·선택: 셸을 직접 고칠 때만
|
|
39
|
+
|
|
40
|
+
네이티브 셸(`Host.tsx` 등)을 빠르게 반복할 때만 `development` 프로파일(dev client)과 `npx expo start`(Metro Fast Refresh)를 쓴다. dev build는 폰이든 시뮬레이터든 어디서나 돈다.
|
|
41
|
+
|
|
42
|
+
- 시뮬레이터가 있다면(Mac=Xcode/iOS, 전 OS=Android 에뮬) 웹뷰를 `localhost`(iOS 시뮬)·`10.0.2.2`(Android 에뮬)로 가리켜 로컬 웹과 함께 고칠 수 있다. 실기기에서는 금지한다(위 secure-context 함정).
|
|
43
|
+
- `eas.json`의 `development` 프로파일은 기본적으로 https 플레이스홀더에 cleartext/ATS·localhost가 배선되지 않은 상태로 배송된다. 로컬 dev 루프는 AI가 요청 시 세팅하는 레시피다. localhost·`10.0.2.2`와 ATS/cleartext 예외를 `development` 프로파일에만 추가한다(`production`에는 절대 넣지 않는다). 사전 구성된 프로파일이 아니다.
|
|
44
|
+
- 대부분은 여기까지 가지 않는다. 웹 UI는 그냥 브라우저에서 고친다.
|
|
45
|
+
|
|
46
|
+
## 두 속도 (무엇이 어떻게 반영되나)
|
|
47
|
+
|
|
48
|
+
| 변경 | 반영 방법 |
|
|
49
|
+
|------|-----------|
|
|
50
|
+
| 네이티브 셸 JS/TSX | Metro Fast Refresh — 즉시 |
|
|
51
|
+
| 웹 변경 | 웹 재배포(or 웹뷰 새로고침) — 앱은 그대로 |
|
|
52
|
+
| 네이티브/config(`app.config.ts`·플러그인·scheme·권한·SDK) | dev build 재빌드 |
|
|
53
|
+
|
|
54
|
+
`EXPO_PUBLIC_*`는 빌드 시점에 인라인된다. "재빌드 없이 URL 변경"은 약속하지 않는다.
|
|
55
|
+
|
|
56
|
+
## 배포·릴리스: OTA vs 재빌드
|
|
57
|
+
|
|
58
|
+
평소 변경(웹)은 웹 배포로 즉시 반영된다. 앱 자체 재출시는 네이티브가 바뀔 때만 한다.
|
|
59
|
+
|
|
60
|
+
| 변경 | 조치 |
|
|
61
|
+
|------|------|
|
|
62
|
+
| 웹/UI 변경 | 웹 배포 (스토어 액션 0) |
|
|
63
|
+
| RN 셸 순수 JS/TSX/이미지/스타일 | OTA: `npx eas-cli update` |
|
|
64
|
+
| 네이티브 모듈·`app.config.ts`·plugins·scheme·권한·SDK 업·계약 메시지 추가 | 재빌드 + 재제출 |
|
|
65
|
+
|
|
66
|
+
- `runtimeVersion.policy: "appVersion"`이므로 같은 version 빌드끼리만 OTA가 가능하다.
|
|
67
|
+
- 재제출 시 marketing version(`app.config.ts`의 `version`) 올림이 필수다(ITMS-90186). autoIncrement는 buildNumber/versionCode만 올린다.
|
|
68
|
+
|
|
69
|
+
## eas build / submit
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# 스토어 빌드 + 자동 제출 (iOS=TestFlight, Android=내부 트랙까지)
|
|
73
|
+
npx eas-cli build --profile production --platform ios \
|
|
74
|
+
--auto-submit --non-interactive --no-wait --message "<짧은 설명>"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- 백그라운드 진행은 항상 `--no-wait`로 둔다.
|
|
78
|
+
- `cli.appVersionSource: "remote"`와 `autoIncrement: true`로 buildNumber/versionCode를 EAS가 자동으로 +1 한다.
|
|
79
|
+
- `eas submit`은 공개 심사 제출까지 하지 않는다. iOS는 TestFlight, Android은 internal 트랙까지만 간다. 공개 출시(ASC "Submit for Review", Play production 승급)는 Owner가 수동으로, 명시적 confirm을 받아 한다. Android 첫 AAB는 Play Console에서 수동으로 1회 업로드해야 한다. 첫 자동 제출 실패는 정상이다([Expo: first Android submission](https://github.com/expo/fyi/blob/main/first-android-submission.md)).
|
|
80
|
+
- 자격증명(iOS 인증서·프로비저닝·APNs, Android 키스토어)은 EAS-managed로 자동 생성된다. `eas.json`에는 public 값만 커밋하고, 시크릿은 EAS 서버에 둔다.
|
|
81
|
+
|
|
82
|
+
## EAS 무료 티어 한도
|
|
83
|
+
|
|
84
|
+
iOS 15 + Android 15 빌드/월, 동시성 1, 우선순위 Low, 빌드당 45분([Expo 요금](https://expo.dev/pricing)). JS 변경은 빌드 풀을 쓰지 않지만, 초기 네이티브 셋업에는 iOS 빌드를 수 개 소모할 수 있다. "첫 빌드 10–20분 + 큐는 멈춘 게 아님"을 미리 알린다.
|
|
85
|
+
|
|
86
|
+
## SDK 56 핀 (브레이킹)
|
|
87
|
+
|
|
88
|
+
- RN 0.85 + React 19.2, Hermes v1 기본.
|
|
89
|
+
- `expo/fetch` 글로벌 기본, `@expo/vector-icons` deprecated → `@react-native-vector-icons/*`.
|
|
90
|
+
- iOS 최소 16.4, Xcode 26.4+.
|
|
91
|
+
- 이벤트 구독 정리는 `subscription.remove()`로 한다(`removeEventListener` 제거).
|
|
92
|
+
|
|
93
|
+
## 플랫폼 매트릭스
|
|
94
|
+
|
|
95
|
+
- iOS 시뮬은 Mac/Xcode 전용이다. Windows/Linux는 본인 iPhone과 EAS 클라우드 빌드로 한다(Mac 불필요).
|
|
96
|
+
- Android 에뮬은 전 OS에서 된다(단 Google Play Services 포함 이미지, AOSP 아님).
|
|
97
|
+
- 실기기 검증 항목: 로그인은 웹뷰 안의 웹 로그인 UI가 뜬다(셸 기능이 아님). 푸시는 v1에는 없다. 푸시를 추가했을 때만 원격 푸시를 실기기에서 검증한다(시뮬 푸시는 sandbox라 prod 검증이 아니다). 네이티브 소셜은 비활성 "coming soon" 스텁이라 v1에는 검증 대상이 없다(추가 시 Apple `getCredentialStateAsync`는 시뮬에서 항상 에러).
|
|
98
|
+
|
|
99
|
+
## 필수 안전장치
|
|
100
|
+
|
|
101
|
+
- `src/config/env.ts`는 `EXPO_PUBLIC_WEB_URL`을 시작 시점에 명시적 throw로 가드한다(미설정·빈 값·URL 파싱 실패·non-https-non-localhost). protocol/hostname만 검사하므로 실기기와 시뮬을 구분하지 못한다(위 참조).
|
|
102
|
+
- `.env`는 커밋하지 않는다(`.gitignore`가 `.env`/`.env.*`를 제외하고 `.env.example`만 유지). 커밋되는 `.env.example`에는 플레이스홀더(`https://your-app.vercel.app`)만 들어간다. 인라인 소스는 클라우드 빌드는 `eas.json`의 `build.<profile>.env`, 로컬 `expo start`는 Owner가 만든 `.env`다.
|
|
103
|
+
- 테스트와 출시 모두 production(store) 프로파일 하나를 쓴다. TestFlight(iOS), Play 내부 테스트(Android). ad-hoc 사이드로드가 필요할 때만 `distribution: internal` 프로파일을 따로 추가한다(UDID 한정, 출시로 승급되지 않음).
|
|
104
|
+
|
|
105
|
+
## 빌드 모니터링
|
|
106
|
+
|
|
107
|
+
Expo 대시보드 빌드 페이지에서 진행을 확인한다. 첫 빌드는 큐 포함 10–20분이 걸린다(멈춘 게 아님). 백그라운드는 항상 `--no-wait`로 둔다.
|