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
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// doctor — 워크스페이스 생성 이후의 환경·로그인 상태를 결정적으로 점검한다.
|
|
2
|
+
//
|
|
3
|
+
// 실행: 워크스페이스 루트에서 `pnpm doctor` (또는 `node scripts/doctor.mjs`).
|
|
4
|
+
// 의존성 0 — node 내장 모듈만 쓴다. 루트에는 node_modules가 없어도 돈다.
|
|
5
|
+
//
|
|
6
|
+
// 원칙:
|
|
7
|
+
// - 절대 중간에 죽지 않는다. 모든 검사를 감싸고, 실패해도 끝까지 점검해 전체 그림을 보여준다.
|
|
8
|
+
// - 각 ✗에는 "정확히 무엇을 하면 되는지"를 함께 출력한다. 셀프 페이스 강의에서 이 안내가 조교다.
|
|
9
|
+
// - 진단·안내까지만 한다. 자동 수리는 하지 않는다(수리는 안내문 또는 코딩 에이전트에 위임).
|
|
10
|
+
// - [앱] 섹션은 "앱으로 확장" 챕터 전까지 ✗여도 정상이며, 웹 준비 판정에 포함하지 않는다.
|
|
11
|
+
//
|
|
12
|
+
// 부트스트랩 단계의 점검(Node가 깔렸나 등)은 부트스트랩 자신의 책임이다. 이 파일의 책임은 생성 이후다.
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
|
+
const WIN = process.platform === "win32";
|
|
21
|
+
|
|
22
|
+
// ---------- 출력 ----------
|
|
23
|
+
|
|
24
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
25
|
+
const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
26
|
+
const green = (s) => c("32", s);
|
|
27
|
+
const red = (s) => c("31", s);
|
|
28
|
+
const yellow = (s) => c("33", s);
|
|
29
|
+
const dim = (s) => c("2", s);
|
|
30
|
+
const bold = (s) => c("1", s);
|
|
31
|
+
|
|
32
|
+
const MARK = { ok: green("✓"), fail: red("✗"), warn: yellow("⚠") };
|
|
33
|
+
|
|
34
|
+
// ---------- 명령 실행 (절대 throw하지 않는다) ----------
|
|
35
|
+
|
|
36
|
+
function run(cmd, { cwd = ROOT, timeout = 15000 } = {}) {
|
|
37
|
+
try {
|
|
38
|
+
// shell: true — PATH의 셸 셔임(.cmd 등)까지 OS 방식대로 해석하게 한다. cmd는 전부
|
|
39
|
+
// 이 파일이 소유한 고정 문자열이라 인젝션 표면이 없다.
|
|
40
|
+
const r = spawnSync(cmd, { shell: true, cwd, timeout, encoding: "utf8" });
|
|
41
|
+
const stdout = (r.stdout ?? "").trim();
|
|
42
|
+
const stderr = (r.stderr ?? "").trim();
|
|
43
|
+
return {
|
|
44
|
+
ok: r.status === 0,
|
|
45
|
+
timedOut: r.error?.code === "ETIMEDOUT",
|
|
46
|
+
// 오프라인·DNS 실패를 "로그인 안 됨"으로 오진하지 않기 위한 신호.
|
|
47
|
+
networkIssue: /ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|fetch failed|getaddrinfo|network/i.test(stdout + stderr),
|
|
48
|
+
stdout,
|
|
49
|
+
stderr,
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return { ok: false, timedOut: false, networkIssue: false, stdout: "", stderr: "" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const installed = (bin) => run(WIN ? `where ${bin}` : `command -v ${bin}`, { timeout: 8000 }).ok;
|
|
57
|
+
|
|
58
|
+
// ---------- 워크스페이스 메타 ----------
|
|
59
|
+
|
|
60
|
+
function readWorkspaceMeta() {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(path.join(ROOT, "workspace.json"), "utf8"));
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------- 개별 검사 ----------
|
|
69
|
+
// 각 검사는 { status: "ok"|"fail"|"warn", label, detail?, fix? }를 반환한다.
|
|
70
|
+
|
|
71
|
+
function checkNode() {
|
|
72
|
+
const major = Number(process.version.slice(1).split(".")[0]);
|
|
73
|
+
if (major >= 22) return { status: "ok", label: `Node.js ${process.version}` };
|
|
74
|
+
if (major >= 20)
|
|
75
|
+
return {
|
|
76
|
+
status: "warn",
|
|
77
|
+
label: `Node.js ${process.version}`,
|
|
78
|
+
detail: "동작은 하지만 22 이상을 권장합니다.",
|
|
79
|
+
fix: "강의의 설치 명령(setup)을 다시 실행하면 LTS로 올려 줍니다.",
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
status: "fail",
|
|
83
|
+
label: `Node.js ${process.version}`,
|
|
84
|
+
detail: "버전이 너무 낮습니다 (22 이상 필요).",
|
|
85
|
+
fix: "강의의 설치 명령(setup)을 다시 실행하세요.",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function checkPnpm() {
|
|
90
|
+
const r = run("pnpm --version", { timeout: 10000 });
|
|
91
|
+
if (!r.ok)
|
|
92
|
+
return { status: "fail", label: "pnpm", detail: "설치되어 있지 않습니다.", fix: "npm install -g pnpm@10" };
|
|
93
|
+
const major = Number(r.stdout.split(".")[0]);
|
|
94
|
+
if (major >= 10) return { status: "ok", label: `pnpm ${r.stdout}` };
|
|
95
|
+
return { status: "warn", label: `pnpm ${r.stdout}`, detail: "10 이상을 권장합니다.", fix: "npm install -g pnpm@10" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function checkGit() {
|
|
99
|
+
const r = run("git --version", { timeout: 10000 });
|
|
100
|
+
return r.ok
|
|
101
|
+
? { status: "ok", label: r.stdout.replace("git version ", "git ") }
|
|
102
|
+
: { status: "fail", label: "git", detail: "설치되어 있지 않습니다.", fix: "강의의 설치 명령(setup)을 다시 실행하세요." };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 코딩 에이전트 — workspace.json의 선택값을 우선 점검하고, 없으면 발견되는 것을 쓴다.
|
|
106
|
+
const AGENTS = {
|
|
107
|
+
claude: { bin: "claude", name: "Claude Code", install: "https://claude.com/claude-code 의 설치 안내", login: "터미널에 claude 를 입력하고 안내에 따라 로그인" },
|
|
108
|
+
codex: { bin: "codex", name: "Codex", install: "npm install -g @openai/codex", login: "codex login" },
|
|
109
|
+
opencode: { bin: "opencode", name: "OpenCode", install: "npm install -g opencode-ai", login: "opencode auth login" },
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function agentLoggedIn(key) {
|
|
113
|
+
// 로그인 상태는 에이전트마다 확인 수단이 달라 best-effort다. 판별 불가면 null을 돌려주고
|
|
114
|
+
// 호출자가 ⚠(직접 확인 안내)로 처리한다.
|
|
115
|
+
if (key === "codex") {
|
|
116
|
+
const r = run("codex login status", { timeout: 10000 });
|
|
117
|
+
return r.ok ? true : /not logged|로그인/i.test(r.stdout + r.stderr) ? false : null;
|
|
118
|
+
}
|
|
119
|
+
if (key === "opencode") {
|
|
120
|
+
// `opencode auth list`는 자격증명이 없어도 장식 출력(헤더 등)을 내므로,
|
|
121
|
+
// "출력이 있다 = 로그인됨"으로 단정하면 오탐이 난다. 명확할 때만 판정하고 아니면 null(⚠).
|
|
122
|
+
const r = run("opencode auth list", { timeout: 10000 });
|
|
123
|
+
if (!r.ok) return null;
|
|
124
|
+
if (/no credentials|not logged/i.test(r.stdout + r.stderr)) return false;
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (key === "claude") {
|
|
128
|
+
// Claude Code는 자격증명을 macOS 키체인 또는 ~/.claude/.credentials.json에 둔다.
|
|
129
|
+
if (process.platform === "darwin") {
|
|
130
|
+
const r = run(`security find-generic-password -s "Claude Code-credentials"`, { timeout: 8000 });
|
|
131
|
+
if (r.ok) return true;
|
|
132
|
+
}
|
|
133
|
+
if (existsSync(path.join(os.homedir(), ".claude", ".credentials.json"))) return true;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function checkAgent(meta) {
|
|
140
|
+
const selected = AGENTS[meta.agent] ? meta.agent : null;
|
|
141
|
+
const found = Object.keys(AGENTS).filter((k) => installed(AGENTS[k].bin));
|
|
142
|
+
|
|
143
|
+
const key = selected && found.includes(selected) ? selected : selected ? null : found[0] ?? null;
|
|
144
|
+
|
|
145
|
+
if (selected && key === null) {
|
|
146
|
+
const others = found.map((k) => AGENTS[k].name).join(", ");
|
|
147
|
+
return {
|
|
148
|
+
status: "fail",
|
|
149
|
+
label: `코딩 에이전트 (${AGENTS[selected].name})`,
|
|
150
|
+
detail: `선택한 ${AGENTS[selected].name}가 설치되어 있지 않습니다.` + (others ? ` (발견됨: ${others})` : ""),
|
|
151
|
+
fix: `설치: ${AGENTS[selected].install}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (!key) {
|
|
155
|
+
return {
|
|
156
|
+
status: "fail",
|
|
157
|
+
label: "코딩 에이전트",
|
|
158
|
+
detail: "Claude Code / Codex / OpenCode 중 어느 것도 찾지 못했습니다.",
|
|
159
|
+
fix: `Claude Code 설치: ${AGENTS.claude.install}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const auth = agentLoggedIn(key);
|
|
163
|
+
if (auth === true) return { status: "ok", label: `코딩 에이전트 (${AGENTS[key].name}) — 설치·로그인` };
|
|
164
|
+
if (auth === false)
|
|
165
|
+
return {
|
|
166
|
+
status: "fail",
|
|
167
|
+
label: `코딩 에이전트 (${AGENTS[key].name})`,
|
|
168
|
+
detail: "설치는 되어 있지만 로그인이 안 되어 있습니다.",
|
|
169
|
+
fix: AGENTS[key].login,
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
status: "warn",
|
|
173
|
+
label: `코딩 에이전트 (${AGENTS[key].name}) — 설치됨`,
|
|
174
|
+
detail: "로그인 상태는 여기서 판별할 수 없습니다.",
|
|
175
|
+
fix: `확인: ${AGENTS[key].login} (이미 로그인했다면 그대로 두면 됩니다)`,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function checkGh() {
|
|
180
|
+
if (!installed("gh"))
|
|
181
|
+
return { status: "fail", label: "GitHub CLI (gh)", detail: "설치되어 있지 않습니다.", fix: "강의의 설치 명령(setup)을 다시 실행하세요." };
|
|
182
|
+
const r = run("gh auth status", { timeout: 15000 });
|
|
183
|
+
return r.ok
|
|
184
|
+
? { status: "ok", label: "GitHub CLI (gh) — 로그인됨" }
|
|
185
|
+
: { status: "fail", label: "GitHub CLI (gh)", detail: "로그인이 안 되어 있습니다.", fix: "gh auth login --web --git-protocol https" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function checkVercel() {
|
|
189
|
+
if (!installed("vercel"))
|
|
190
|
+
return { status: "fail", label: "Vercel CLI", detail: "설치되어 있지 않습니다.", fix: "npm install -g vercel" };
|
|
191
|
+
const r = run("vercel whoami", { timeout: 20000 });
|
|
192
|
+
if (r.ok) return { status: "ok", label: `Vercel CLI — 로그인됨 (${r.stdout.split("\n").pop()})` };
|
|
193
|
+
if (r.timedOut || r.networkIssue)
|
|
194
|
+
return { status: "warn", label: "Vercel CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm doctor 재실행" };
|
|
195
|
+
return { status: "fail", label: "Vercel CLI", detail: "로그인이 안 되어 있습니다.", fix: "vercel login" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function checkSupabase() {
|
|
199
|
+
const bin = path.join(ROOT, "web", "node_modules", ".bin", WIN ? "supabase.CMD" : "supabase");
|
|
200
|
+
if (!existsSync(bin))
|
|
201
|
+
return {
|
|
202
|
+
status: "fail",
|
|
203
|
+
label: "Supabase CLI",
|
|
204
|
+
detail: "web/ 의존성에 포함된 CLI를 찾지 못했습니다 (설치 전?).",
|
|
205
|
+
fix: "cd web && pnpm install --frozen-lockfile",
|
|
206
|
+
};
|
|
207
|
+
const r = run(`"${bin}" projects list`, { cwd: path.join(ROOT, "web"), timeout: 25000 });
|
|
208
|
+
if (r.ok) return { status: "ok", label: "Supabase CLI — 로그인됨" };
|
|
209
|
+
if (r.timedOut || r.networkIssue)
|
|
210
|
+
return { status: "warn", label: "Supabase CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm doctor 재실행" };
|
|
211
|
+
return {
|
|
212
|
+
status: "fail",
|
|
213
|
+
label: "Supabase CLI",
|
|
214
|
+
detail: "로그인이 안 되어 있습니다.",
|
|
215
|
+
fix: "cd web && pnpm exec supabase login",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const checkDeps = (sub) =>
|
|
220
|
+
existsSync(path.join(ROOT, sub, "node_modules"))
|
|
221
|
+
? { status: "ok", label: `${sub}/ 의존성 설치됨` }
|
|
222
|
+
: { status: "fail", label: `${sub}/ 의존성`, detail: "node_modules가 없습니다.", fix: `cd ${sub} && pnpm install --frozen-lockfile` };
|
|
223
|
+
|
|
224
|
+
function checkEas() {
|
|
225
|
+
if (!installed("eas"))
|
|
226
|
+
return { status: "fail", label: "EAS CLI", detail: "설치되어 있지 않습니다.", fix: "npm install -g eas-cli" };
|
|
227
|
+
const r = run("eas whoami", { timeout: 20000 });
|
|
228
|
+
if (r.ok) return { status: "ok", label: `EAS CLI — 로그인됨 (${r.stdout.split("\n").pop()})` };
|
|
229
|
+
if (r.timedOut || r.networkIssue)
|
|
230
|
+
return { status: "warn", label: "EAS CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm doctor 재실행" };
|
|
231
|
+
return {
|
|
232
|
+
status: "fail",
|
|
233
|
+
label: "EAS CLI",
|
|
234
|
+
detail: "로그인이 안 되어 있습니다.",
|
|
235
|
+
fix: "eas login (Expo 계정이 없다면 expo.dev에서 가입 — '앱으로 확장' 챕터에서 안내)",
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------- 실행 ----------
|
|
240
|
+
|
|
241
|
+
const meta = readWorkspaceMeta();
|
|
242
|
+
|
|
243
|
+
console.log("");
|
|
244
|
+
console.log(bold(`🩺 doctor — ${meta.name ?? path.basename(ROOT)} 환경 점검`));
|
|
245
|
+
console.log(dim(" 각 항목을 점검합니다. 네트워크 확인이 포함되어 몇십 초 걸릴 수 있습니다."));
|
|
246
|
+
|
|
247
|
+
const sections = [
|
|
248
|
+
{ title: "공통", blocking: true, checks: [checkNode, checkPnpm, checkGit, () => checkAgent(meta), checkGh, checkVercel] },
|
|
249
|
+
{ title: "웹", blocking: true, checks: [() => checkDeps("web"), checkSupabase] },
|
|
250
|
+
{ title: "앱", blocking: false, note: "'앱으로 확장' 챕터 전까지는 ✗여도 정상입니다.", checks: [() => checkDeps("mobile"), checkEas] },
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
let blockingFails = 0;
|
|
254
|
+
let appFails = 0;
|
|
255
|
+
let warns = 0;
|
|
256
|
+
|
|
257
|
+
for (const section of sections) {
|
|
258
|
+
console.log("");
|
|
259
|
+
console.log(bold(`[${section.title}]`) + (section.note ? dim(` ${section.note}`) : ""));
|
|
260
|
+
for (const check of section.checks) {
|
|
261
|
+
let result;
|
|
262
|
+
try {
|
|
263
|
+
result = check();
|
|
264
|
+
} catch (e) {
|
|
265
|
+
// 어떤 검사도 doctor 전체를 멈추지 못한다.
|
|
266
|
+
result = { status: "warn", label: "내부 오류", detail: String(e?.message ?? e) };
|
|
267
|
+
}
|
|
268
|
+
console.log(` ${MARK[result.status]} ${result.label}`);
|
|
269
|
+
if (result.detail) console.log(` ${dim(result.detail)}`);
|
|
270
|
+
if (result.fix && result.status !== "ok") console.log(` ${yellow("→ " + result.fix)}`);
|
|
271
|
+
if (result.status === "fail") section.blocking ? blockingFails++ : appFails++;
|
|
272
|
+
if (result.status === "warn") warns++;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
console.log("");
|
|
277
|
+
if (blockingFails === 0) {
|
|
278
|
+
console.log(green(bold("✅ 웹 개발 준비 완료")) + (warns ? dim(` (⚠ ${warns}개는 안내를 참고해 직접 확인하세요)`) : ""));
|
|
279
|
+
} else {
|
|
280
|
+
console.log(red(bold(`✗ ${blockingFails}개 항목이 남았습니다.`)) + " 위의 → 안내를 따라 해결한 뒤 다시 실행하세요: " + bold("pnpm doctor"));
|
|
281
|
+
console.log("");
|
|
282
|
+
console.log("📋 직접 하기 어렵다면, 코딩 에이전트에 아래 문장을 그대로 붙여넣으세요:");
|
|
283
|
+
console.log(bold(' "pnpm doctor를 실행해서 ✗ 항목을 확인하고, 각 항목의 → 안내를 따라 해결해줘.'));
|
|
284
|
+
console.log(bold(' 브라우저 로그인이 필요한 단계는 멈추고 나에게 알려줘. 끝나면 pnpm doctor로 다시 검증해줘."'));
|
|
285
|
+
}
|
|
286
|
+
if (appFails > 0 && blockingFails === 0) {
|
|
287
|
+
console.log(dim(`📦 [앱] ✗ ${appFails}개는 '앱으로 확장' 챕터에서 해결합니다. 지금은 무시해도 됩니다.`));
|
|
288
|
+
}
|
|
289
|
+
console.log("");
|
|
290
|
+
|
|
291
|
+
process.exit(blockingFails === 0 ? 0 : 1);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# ssb — 앱↔웹 브릿지 계약 (SSOT)
|
|
2
|
+
|
|
3
|
+
이 폴더가 앱↔웹 통신 계약의 원본이다. 메시지는 `ns: "ssb"` 네임스페이스를 달아 페이지의 다른 postMessage 트래픽과 섞이지 않는다. `/kickoff`이 여기서 app(`src/bridge/`)과 web(`lib/bridge/`)으로 바이트 단위로 동일하게 복사하고, CI checksum이 드리프트를 막는다.
|
|
4
|
+
|
|
5
|
+
불변식 (상세는 `contract.ts` 헤더):
|
|
6
|
+
|
|
7
|
+
- 추가만 허용한다(append-only): 타입·옵셔널 필드만 추가하고, 제거·이름변경·필수화는 하지 않는다. 구버전 앱과 최신 웹이 서로 호환된다.
|
|
8
|
+
- reader는 tolerant하다: 모르는·깨진 메시지는 `UNKNOWN`이 되고 예외를 던지지 않는다.
|
|
9
|
+
- 토큰은 브릿지로 보내지 않는다. 세션 핸드오프는 1회용 nonce로 server가 Set-Cookie한다.
|
|
10
|
+
- 기능 분기는 HELLO의 `capabilities`(문자열 배열)로 하고, 버전 번호로 하지 않는다.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App ↔ Web bridge contract — SINGLE SOURCE OF TRUTH.
|
|
5
|
+
*
|
|
6
|
+
* This file is copied byte-for-byte into the web project (docs/web-adapter/contract.ts).
|
|
7
|
+
* The two MUST stay identical; the web adapter ships a checksum check to enforce it.
|
|
8
|
+
*
|
|
9
|
+
* Invariants (never break these):
|
|
10
|
+
* 1. Every message is namespaced (`ns: "ssb"`) so unrelated postMessage traffic on
|
|
11
|
+
* the page is ignored.
|
|
12
|
+
* 2. The contract is ADDITIVE-ONLY. Add new message `type`s or new OPTIONAL payload
|
|
13
|
+
* fields; never remove a type, rename one, or make an existing field required.
|
|
14
|
+
* Old apps must keep working against new webs and vice-versa.
|
|
15
|
+
* 3. Features are gated by HELLO `capabilities` (a string array), NOT by version
|
|
16
|
+
* number. The app advertises what it can do; the web feature-detects.
|
|
17
|
+
* 4. The reader (reader.ts) is tolerant: anything unrecognized becomes UNKNOWN and
|
|
18
|
+
* never throws.
|
|
19
|
+
* 5. Auth tokens NEVER travel over this channel. Session handoff is a server
|
|
20
|
+
* Set-Cookie via a one-time nonce (see src/session + docs/web-adapter).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const SSB_NS = "ssb" as const;
|
|
24
|
+
export const SSB_PROTOCOL_VERSION = 1 as const;
|
|
25
|
+
|
|
26
|
+
/** All message type names. Add here (append-only) when extending the contract. */
|
|
27
|
+
export const MSG = {
|
|
28
|
+
// ── app → web ──
|
|
29
|
+
/** App announces itself + capabilities as soon as the page is reachable. */
|
|
30
|
+
HELLO: "HELLO",
|
|
31
|
+
/** App confirms a web session cookie was installed (after native login handoff). */
|
|
32
|
+
SESSION_INSTALLED: "SESSION_INSTALLED",
|
|
33
|
+
/** App confirms it cleared its local session copy after a logout. */
|
|
34
|
+
LOGOUT_DONE: "LOGOUT_DONE",
|
|
35
|
+
/** Forward-compat only — push is a fast-follow; defined so the web can feature-detect later. */
|
|
36
|
+
PUSH_TOKEN_UPDATED: "PUSH_TOKEN_UPDATED",
|
|
37
|
+
|
|
38
|
+
// ── web → app ──
|
|
39
|
+
/** Web acknowledges the bridge is live (handshake completes). */
|
|
40
|
+
READY: "READY",
|
|
41
|
+
/** Web has no session and asks the app to run native login + install a session. */
|
|
42
|
+
REQUEST_SESSION_INSTALL: "REQUEST_SESSION_INSTALL",
|
|
43
|
+
/** Web tells the app its auth state changed (e.g. user logged in/out inside the webview). */
|
|
44
|
+
AUTH_STATE_CHANGED: "AUTH_STATE_CHANGED",
|
|
45
|
+
/** Web asks the app to forget the session (user logged out). */
|
|
46
|
+
LOGOUT: "LOGOUT",
|
|
47
|
+
/** Web asks the app to open a URL in the system browser instead of the webview. */
|
|
48
|
+
OPEN_EXTERNAL: "OPEN_EXTERNAL",
|
|
49
|
+
|
|
50
|
+
// ── reader fallback (not a wire message) ──
|
|
51
|
+
UNKNOWN: "UNKNOWN",
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
export type MessageType = (typeof MSG)[keyof typeof MSG];
|
|
55
|
+
|
|
56
|
+
/** Fields shared by every envelope. */
|
|
57
|
+
const envelopeBase = {
|
|
58
|
+
ns: z.literal(SSB_NS),
|
|
59
|
+
v: z.number().int(),
|
|
60
|
+
/** Correlation id, present on request/response pairs. */
|
|
61
|
+
id: z.string().optional(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ───────────────────────── web → app (inbound) ─────────────────────────
|
|
65
|
+
const ReadyMessage = z.object({
|
|
66
|
+
...envelopeBase,
|
|
67
|
+
type: z.literal(MSG.READY),
|
|
68
|
+
payload: z.object({}).optional(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const RequestSessionInstallMessage = z.object({
|
|
72
|
+
...envelopeBase,
|
|
73
|
+
type: z.literal(MSG.REQUEST_SESSION_INSTALL),
|
|
74
|
+
payload: z.object({ redirectPath: z.string().optional() }).optional(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const AuthStateChangedMessage = z.object({
|
|
78
|
+
...envelopeBase,
|
|
79
|
+
type: z.literal(MSG.AUTH_STATE_CHANGED),
|
|
80
|
+
payload: z.object({ authenticated: z.boolean() }),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const LogoutMessage = z.object({
|
|
84
|
+
...envelopeBase,
|
|
85
|
+
type: z.literal(MSG.LOGOUT),
|
|
86
|
+
payload: z.object({}).optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const OpenExternalMessage = z.object({
|
|
90
|
+
...envelopeBase,
|
|
91
|
+
type: z.literal(MSG.OPEN_EXTERNAL),
|
|
92
|
+
payload: z.object({ url: z.string() }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const InboundSchema = z.discriminatedUnion("type", [
|
|
96
|
+
ReadyMessage,
|
|
97
|
+
RequestSessionInstallMessage,
|
|
98
|
+
AuthStateChangedMessage,
|
|
99
|
+
LogoutMessage,
|
|
100
|
+
OpenExternalMessage,
|
|
101
|
+
]);
|
|
102
|
+
export type InboundMessage = z.infer<typeof InboundSchema>;
|
|
103
|
+
|
|
104
|
+
// ───────────────────────── app → web (outbound) ─────────────────────────
|
|
105
|
+
const HelloMessage = z.object({
|
|
106
|
+
...envelopeBase,
|
|
107
|
+
type: z.literal(MSG.HELLO),
|
|
108
|
+
payload: z.object({
|
|
109
|
+
protocolVersion: z.number().int(),
|
|
110
|
+
capabilities: z.array(z.string()),
|
|
111
|
+
appVersion: z.string(),
|
|
112
|
+
platform: z.enum(["ios", "android"]),
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const SessionInstalledMessage = z.object({
|
|
117
|
+
...envelopeBase,
|
|
118
|
+
type: z.literal(MSG.SESSION_INSTALLED),
|
|
119
|
+
payload: z.object({ ok: z.boolean() }),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const LogoutDoneMessage = z.object({
|
|
123
|
+
...envelopeBase,
|
|
124
|
+
type: z.literal(MSG.LOGOUT_DONE),
|
|
125
|
+
payload: z.object({}).optional(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const PushTokenUpdatedMessage = z.object({
|
|
129
|
+
...envelopeBase,
|
|
130
|
+
type: z.literal(MSG.PUSH_TOKEN_UPDATED),
|
|
131
|
+
payload: z.object({ token: z.string() }),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
export const OutboundSchema = z.discriminatedUnion("type", [
|
|
135
|
+
HelloMessage,
|
|
136
|
+
SessionInstalledMessage,
|
|
137
|
+
LogoutDoneMessage,
|
|
138
|
+
PushTokenUpdatedMessage,
|
|
139
|
+
]);
|
|
140
|
+
export type OutboundMessage = z.infer<typeof OutboundSchema>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The tolerant reader's fallback shape. Not a valid wire message — produced only
|
|
144
|
+
* when an inbound payload cannot be understood. See reader.ts.
|
|
145
|
+
*/
|
|
146
|
+
export type UnknownMessage = { type: typeof MSG.UNKNOWN; raw: unknown };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { InboundSchema, MSG, type InboundMessage, type UnknownMessage } from "./contract";
|
|
2
|
+
|
|
3
|
+
export type ReadResult = InboundMessage | UnknownMessage;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tolerant reader for messages arriving FROM the web (WebView `onMessage`).
|
|
7
|
+
*
|
|
8
|
+
* Hard contract: this function NEVER throws. Anything it cannot confidently
|
|
9
|
+
* understand — wrong namespace, non-JSON string, unknown `type`, missing/invalid
|
|
10
|
+
* fields, a number, null — collapses to `{ type: "UNKNOWN", raw }`. The native
|
|
11
|
+
* shell must survive hostile, outdated, or unrelated postMessage traffic.
|
|
12
|
+
*
|
|
13
|
+
* `raw` is whatever `event.nativeEvent.data` gave us (usually a JSON string, but
|
|
14
|
+
* a parsed object is accepted too).
|
|
15
|
+
*/
|
|
16
|
+
export function readInbound(raw: unknown): ReadResult {
|
|
17
|
+
let candidate: unknown = raw;
|
|
18
|
+
|
|
19
|
+
if (typeof raw === "string") {
|
|
20
|
+
try {
|
|
21
|
+
candidate = JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
return { type: MSG.UNKNOWN, raw };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parsed = InboundSchema.safeParse(candidate);
|
|
28
|
+
if (parsed.success) return parsed.data;
|
|
29
|
+
|
|
30
|
+
return { type: MSG.UNKNOWN, raw };
|
|
31
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# ─────────────────────────────────────────────────────────────
|
|
2
|
+
# my-space — 환경변수 명세
|
|
3
|
+
#
|
|
4
|
+
# 이 파일엔 *키 이름과 어느 환경에 들어가는지*만 있다.
|
|
5
|
+
# 실제 값은 /go-live가 Vercel에 박은 뒤 `vercel env pull` 로 동기화.
|
|
6
|
+
# ─────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
# ─── Supabase (dev/prod 별개 프로젝트) ──────────────────────────
|
|
9
|
+
# Vercel "Development" + "Preview" → dev 프로젝트 값
|
|
10
|
+
# Vercel "Production" → prod 프로젝트 값
|
|
11
|
+
NEXT_PUBLIC_SUPABASE_URL=
|
|
12
|
+
# 새 키 시스템 (2026): sb_publishable_xxx 형식. 레거시 anon JWT도 호환되나 신규 권장.
|
|
13
|
+
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
|
14
|
+
|
|
15
|
+
# 서버 전용. NEXT_PUBLIC_ 절대 금지. sb_secret_xxx 형식 (구 service_role 대체).
|
|
16
|
+
SUPABASE_SECRET_KEY=
|
|
17
|
+
|
|
18
|
+
# Supabase 프로젝트 ref (마이그레이션 CLI 인자)
|
|
19
|
+
SUPABASE_DEV_REF=
|
|
20
|
+
SUPABASE_PROD_REF=
|
|
21
|
+
|
|
22
|
+
# Supabase Management API Personal Access Token
|
|
23
|
+
# (Auth redirect URL 등 CLI 미커버 항목 자동화용)
|
|
24
|
+
# → 셸 rc에 export 권장. 이 파일에는 두지 말 것.
|
|
25
|
+
# SUPABASE_ACCESS_TOKEN=
|
|
26
|
+
|
|
27
|
+
# ─── Sentry ──────────────────────────────────────────────────
|
|
28
|
+
NEXT_PUBLIC_SENTRY_DSN=
|
|
29
|
+
SENTRY_AUTH_TOKEN=
|
|
30
|
+
SENTRY_ORG=
|
|
31
|
+
SENTRY_PROJECT=
|
|
32
|
+
|
|
33
|
+
# ─── 배포 환경 ─────────────────────────────────────────────────
|
|
34
|
+
# 코드 분기는 벤더 중립 APP_ENV로 한다(lib/app-env.ts).
|
|
35
|
+
# Vercel은 아래를 자동 주입하므로 설정 불필요(VERCEL_ENV가 APP_ENV로 매핑됨).
|
|
36
|
+
# VERCEL_ENV=production | preview | development
|
|
37
|
+
# VERCEL_URL=<deployment-url>
|
|
38
|
+
# 그 외 호스트에 배포할 때만 직접 설정:
|
|
39
|
+
# APP_ENV=production | preview | development
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
concurrency:
|
|
9
|
+
group: ci-${{ github.ref }}
|
|
10
|
+
cancel-in-progress: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
ci:
|
|
14
|
+
name: typecheck · lint · test · build
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v6
|
|
18
|
+
|
|
19
|
+
# pnpm 버전은 package.json의 packageManager 필드에서 읽는다.
|
|
20
|
+
# (version 입력을 함께 주면 두 값이 충돌해 action이 실패한다.)
|
|
21
|
+
- uses: pnpm/action-setup@v6
|
|
22
|
+
|
|
23
|
+
- uses: actions/setup-node@v6
|
|
24
|
+
with:
|
|
25
|
+
node-version: lts/* # 현재 LTS 자동 해석. 수동 갱신 불요.
|
|
26
|
+
cache: pnpm
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: pnpm install --frozen-lockfile
|
|
30
|
+
|
|
31
|
+
- name: Audit (high/critical only)
|
|
32
|
+
# 알려진 CVE·악성 advisory 발견 시 빌드 실패.
|
|
33
|
+
# cooldown(.npmrc minimum-release-age)은 미공개·신선한 위험을 막고,
|
|
34
|
+
# audit은 공개된 known vulnerability를 막는다.
|
|
35
|
+
run: pnpm audit --prod --audit-level=high
|
|
36
|
+
|
|
37
|
+
- name: Typecheck
|
|
38
|
+
run: pnpm typecheck
|
|
39
|
+
|
|
40
|
+
- name: Lint
|
|
41
|
+
# lint:ci = eslint --max-warnings 0
|
|
42
|
+
# `@typescript-eslint/no-deprecated`가 warn level로 들어오므로 여기서 차단.
|
|
43
|
+
run: pnpm lint:ci
|
|
44
|
+
|
|
45
|
+
- name: Test
|
|
46
|
+
run: pnpm test
|
|
47
|
+
env:
|
|
48
|
+
# env.ts가 모듈 로드 시 zod 파싱하므로 테스트 임포트만으로도 필요.
|
|
49
|
+
# 단위 테스트는 외부 호출 X (mock 사용) — 더미 또는 GH Secrets 둘 다 OK.
|
|
50
|
+
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
|
51
|
+
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
|
|
52
|
+
|
|
53
|
+
- name: Build
|
|
54
|
+
run: pnpm build
|
|
55
|
+
env:
|
|
56
|
+
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
|
57
|
+
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
|
|
58
|
+
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
|
|
59
|
+
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
60
|
+
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
|
61
|
+
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|