create-saas-starter-workspace 0.1.0 → 0.1.2
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 +14 -1
- package/README.md +3 -2
- package/bin/index.mjs +6 -2
- package/{templates → dist}/workspace/.claude/skills/bridge-guide/SKILL.md +3 -3
- package/{templates → dist}/workspace/.claude/skills/go-live/SKILL.md +3 -1
- package/{templates → dist}/workspace/.claude/skills/kickoff/SKILL.md +1 -1
- package/{templates → dist}/workspace/.claude/skills/warmup/SKILL.md +4 -4
- package/{templates → dist}/workspace/AGENTS.md +2 -2
- package/{templates → dist}/workspace/README.md +3 -3
- package/{templates → dist}/workspace/START-HERE.md +2 -2
- package/{templates → dist}/workspace/mobile/README.md +1 -1
- package/{templates → dist}/workspace/mobile/docs/web-adapter/README.md +1 -1
- package/{templates/workspace/ssb → dist/workspace/mobile/src/bridge}/contract.ts +3 -2
- package/{templates → dist}/workspace/package.json +1 -1
- package/{templates → dist}/workspace/scripts/doctor.mjs +56 -10
- package/{templates → dist}/workspace/ssb/README.md +1 -1
- package/{templates/workspace/mobile/src/bridge → dist/workspace/ssb}/contract.ts +3 -2
- package/{templates → dist}/workspace/web/lib/bridge/contract.ts +3 -2
- package/package.json +2 -2
- package/src/scaffold.mjs +3 -0
- /package/{templates → dist}/workspace/.claude/settings.json +0 -0
- /package/{templates → dist}/workspace/.claude/skills/eas-deploy-guide/SKILL.md +0 -0
- /package/{templates → dist}/workspace/.claude/skills/launch/SKILL.md +0 -0
- /package/{templates → dist}/workspace/.claude/skills/native-app-guide/SKILL.md +0 -0
- /package/{templates → dist}/workspace/.claude/skills/preview/SKILL.md +0 -0
- /package/{templates → dist}/workspace/.claude/skills/probe/SKILL.md +0 -0
- /package/{templates → dist}/workspace/.claude/skills/release/SKILL.md +0 -0
- /package/{templates → dist}/workspace/.claude/skills/sketch/SKILL.md +0 -0
- /package/{templates → dist}/workspace/.claude/skills/store-release-guide/SKILL.md +0 -0
- /package/{templates → dist}/workspace/.claude/skills/vercel-cron/SKILL.md +0 -0
- /package/{templates → dist}/workspace/CLAUDE.md +0 -0
- /package/{templates → dist}/workspace/LICENSE +0 -0
- /package/{templates → dist}/workspace/gitignore +0 -0
- /package/{templates → dist}/workspace/mobile/.env.example +0 -0
- /package/{templates → dist}/workspace/mobile/AGENTS.md +0 -0
- /package/{templates → dist}/workspace/mobile/App.tsx +0 -0
- /package/{templates → dist}/workspace/mobile/CLAUDE.md +0 -0
- /package/{templates → dist}/workspace/mobile/LICENSE +0 -0
- /package/{templates → dist}/workspace/mobile/app.config.ts +0 -0
- /package/{templates → dist}/workspace/mobile/assets/android-icon-background.png +0 -0
- /package/{templates → dist}/workspace/mobile/assets/android-icon-foreground.png +0 -0
- /package/{templates → dist}/workspace/mobile/assets/android-icon-monochrome.png +0 -0
- /package/{templates → dist}/workspace/mobile/assets/favicon.png +0 -0
- /package/{templates → dist}/workspace/mobile/assets/icon.png +0 -0
- /package/{templates → dist}/workspace/mobile/assets/splash-icon.png +0 -0
- /package/{templates → dist}/workspace/mobile/docs/web-adapter/route-app-bridge.ts +0 -0
- /package/{templates → dist}/workspace/mobile/eas.json +0 -0
- /package/{templates → dist}/workspace/mobile/gitignore +0 -0
- /package/{templates → dist}/workspace/mobile/index.ts +0 -0
- /package/{templates → dist}/workspace/mobile/package.json +0 -0
- /package/{templates → dist}/workspace/mobile/pnpm-lock.yaml +0 -0
- /package/{templates → dist}/workspace/mobile/src/auth/LoginScreen.tsx +0 -0
- /package/{templates → dist}/workspace/mobile/src/bridge/capabilities.test.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/bridge/capabilities.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/bridge/contract.test.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/bridge/messaging.test.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/bridge/messaging.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/bridge/reader.test.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/bridge/reader.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/bridge/router.test.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/bridge/router.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/config/env.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/i18n.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/session/secureSession.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/session/sessionHandoff.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/ui/ErrorView.tsx +0 -0
- /package/{templates → dist}/workspace/mobile/src/ui/LoadingView.tsx +0 -0
- /package/{templates → dist}/workspace/mobile/src/ui/OfflineView.tsx +0 -0
- /package/{templates → dist}/workspace/mobile/src/webview/Host.tsx +0 -0
- /package/{templates → dist}/workspace/mobile/src/webview/linkBoundary.test.ts +0 -0
- /package/{templates → dist}/workspace/mobile/src/webview/linkBoundary.ts +0 -0
- /package/{templates → dist}/workspace/mobile/tsconfig.json +0 -0
- /package/{templates → dist}/workspace/mobile/vitest.config.ts +0 -0
- /package/{templates → dist}/workspace/ssb/reader.ts +0 -0
- /package/{templates → dist}/workspace/web/.env.example +0 -0
- /package/{templates → dist}/workspace/web/.gitattributes +0 -0
- /package/{templates → dist}/workspace/web/.github/workflows/ci.yml +0 -0
- /package/{templates → dist}/workspace/web/.vscode/settings.json +0 -0
- /package/{templates → dist}/workspace/web/AGENTS.md +0 -0
- /package/{templates → dist}/workspace/web/CLAUDE.md +0 -0
- /package/{templates → dist}/workspace/web/DESIGN.md +0 -0
- /package/{templates → dist}/workspace/web/LICENSE +0 -0
- /package/{templates → dist}/workspace/web/README.md +0 -0
- /package/{templates → dist}/workspace/web/app/error.tsx +0 -0
- /package/{templates → dist}/workspace/web/app/favicon.ico +0 -0
- /package/{templates → dist}/workspace/web/app/global-error.tsx +0 -0
- /package/{templates → dist}/workspace/web/app/globals.css +0 -0
- /package/{templates → dist}/workspace/web/app/layout.tsx +0 -0
- /package/{templates → dist}/workspace/web/app/not-found.tsx +0 -0
- /package/{templates → dist}/workspace/web/app/page.tsx +0 -0
- /package/{templates → dist}/workspace/web/components/ui/button.tsx +0 -0
- /package/{templates → dist}/workspace/web/components.json +0 -0
- /package/{templates → dist}/workspace/web/docs/ENVIRONMENTS.md +0 -0
- /package/{templates → dist}/workspace/web/docs/LIMITS.md +0 -0
- /package/{templates → dist}/workspace/web/eslint.config.mjs +0 -0
- /package/{templates → dist}/workspace/web/features/.gitkeep +0 -0
- /package/{templates → dist}/workspace/web/gitignore +0 -0
- /package/{templates → dist}/workspace/web/instrumentation-client.ts +0 -0
- /package/{templates → dist}/workspace/web/instrumentation.ts +0 -0
- /package/{templates → dist}/workspace/web/lib/app-env.ts +0 -0
- /package/{templates → dist}/workspace/web/lib/bridge/reader.ts +0 -0
- /package/{templates → dist}/workspace/web/lib/env.server.ts +0 -0
- /package/{templates → dist}/workspace/web/lib/env.ts +0 -0
- /package/{templates → dist}/workspace/web/lib/logger.ts +0 -0
- /package/{templates → dist}/workspace/web/lib/supabase/admin.ts +0 -0
- /package/{templates → dist}/workspace/web/lib/supabase/client.ts +0 -0
- /package/{templates → dist}/workspace/web/lib/supabase/server.ts +0 -0
- /package/{templates → dist}/workspace/web/lib/utils.ts +0 -0
- /package/{templates → dist}/workspace/web/next.config.ts +0 -0
- /package/{templates → dist}/workspace/web/npmrc +0 -0
- /package/{templates → dist}/workspace/web/package.json +0 -0
- /package/{templates → dist}/workspace/web/pnpm-lock.yaml +0 -0
- /package/{templates → dist}/workspace/web/postcss.config.mjs +0 -0
- /package/{templates → dist}/workspace/web/sentry.edge.config.ts +0 -0
- /package/{templates → dist}/workspace/web/sentry.server.config.ts +0 -0
- /package/{templates → dist}/workspace/web/supabase/migrations/.gitkeep +0 -0
- /package/{templates → dist}/workspace/web/tests/setup.ts +0 -0
- /package/{templates → dist}/workspace/web/tests/utils.test.ts +0 -0
- /package/{templates → dist}/workspace/web/tsconfig.json +0 -0
- /package/{templates → dist}/workspace/web/vercel.json +0 -0
- /package/{templates → dist}/workspace/web/vitest.config.ts +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.2 (미출판)
|
|
4
|
+
|
|
5
|
+
- 문서·doctor 안내의 점검 명령을 `pnpm run doctor`로 정정 — `doctor`는 pnpm 내장 명령이라 `pnpm doctor`는 우리 스크립트 대신 내장 명령(무출력)을 실행했다.
|
|
6
|
+
|
|
7
|
+
- doctor [웹]에 Sentry CLI 점검 추가(`sentry-cli info`의 Unauthorized 파싱 — exit 0이어도 미로그인 판별). setup이 Sentry 로그인까지 처리하게 된 변경과 짝.
|
|
8
|
+
|
|
9
|
+
## 0.1.1 — 2026-06-12
|
|
10
|
+
|
|
11
|
+
- doctor [공통]에 브릿지 계약 점검 추가: `ssb/`(원본)와 `web/lib/bridge/`·`mobile/src/bridge/` 사본의 byte-동일성을 단언한다. 템플릿 문서들이 약속하던 "CI checksum"(실재하지 않던 게이트)을 doctor 기준으로 전부 정정.
|
|
12
|
+
- 워크스페이스 루트의 자기명칭도 프로젝트 이름으로 렌더: 루트 `package.json`(`<이름>-workspace`)과 `README.md`·`AGENTS.md` 제목. `pnpm run doctor` 등 루트 명령의 출력에 학생 프로젝트명이 보인다.
|
|
13
|
+
- bin이 `dist/` 부재 시(개발 트리 직접 실행) `npm run stage` 안내와 함께 명확히 실패한다.
|
|
14
|
+
- 패키지 내부 레이아웃 정리: 템플릿 SSOT를 패키지 내 `template/`(소스)로 이동, stage 산출물은 `templates/` → `dist/`(표준 src/dist 관례). 소비자(npx) 영향 없음.
|
|
15
|
+
|
|
16
|
+
## 0.1.0
|
|
4
17
|
|
|
5
18
|
- 첫 버전. 통합 워크스페이스(web + mobile + ssb + .claude) 생성, 프로젝트 이름 렌더, pnpm 의존성 설치(`--frozen-lockfile`), doctor 자동 실행.
|
|
6
19
|
- `--agent <claude|codex|opencode>` 선택을 `workspace.json`에 기록하고 doctor가 읽는다.
|
package/README.md
CHANGED
|
@@ -12,8 +12,9 @@ npx create-saas-starter-workspace@latest my-service
|
|
|
12
12
|
|
|
13
13
|
## 개발 (강사용)
|
|
14
14
|
|
|
15
|
-
- 템플릿 SSOT는
|
|
15
|
+
- 템플릿 SSOT는 `template/`(편집용 원본)입니다. 고친 뒤 `npm run stage`로 `dist/workspace`(출판용 산출물)에 동기화합니다. `dist/`를 직접 고치지 마세요. `template/`는 `files` 허용목록에 없어 출판되지 않습니다.
|
|
16
16
|
- `npm test`가 stage를 먼저 실행하고, 작업 트리와 출판 채널(tarball) 양쪽을 검증합니다. `prepublishOnly`가 stage·테스트를 강제하므로 출판 시 드리프트가 끼어들 수 없습니다.
|
|
17
|
-
-
|
|
17
|
+
- `engines`는 `>=20.12`로 둡니다 — npx로 직접 실행하는 사용자를 막지 않기 위한 하한이고, 부트스트랩 경로는 24 LTS를 설치합니다(doctor는 22 미만에 ⚠).
|
|
18
|
+
- `.gitignore`·`.npmrc`는 npm pack이 strip하므로 dotless(`gitignore`·`npmrc`)로 스테이지하고 생성기가 복원합니다. 단 템플릿 루트의 것은 SSOT에서부터 dotless(`template/gitignore`)입니다 — 화이트리스트 방식이라 .gitignore 그대로 두면 이 레포의 git이 템플릿 소스(web/·mobile/)를 추적하지 못하기 때문입니다.
|
|
18
19
|
|
|
19
20
|
강의 수강생에게 배포되는 패키지입니다. [LICENSE](LICENSE).
|
package/bin/index.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { scaffold } from "../src/scaffold.mjs";
|
|
|
15
15
|
|
|
16
16
|
const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
17
|
const pkg = JSON.parse(readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
|
|
18
|
-
const TEMPLATE_DIR = path.join(PKG_ROOT, "
|
|
18
|
+
const TEMPLATE_DIR = path.join(PKG_ROOT, "dist", "workspace");
|
|
19
19
|
|
|
20
20
|
const AGENTS = ["claude", "codex", "opencode"];
|
|
21
21
|
|
|
@@ -136,6 +136,10 @@ async function main() {
|
|
|
136
136
|
process.on("SIGINT", () => process.exit(130));
|
|
137
137
|
|
|
138
138
|
const opts = parseArgs(process.argv.slice(2));
|
|
139
|
+
if (!existsSync(TEMPLATE_DIR)) {
|
|
140
|
+
// 출판본·npm test 경로에서는 항상 존재한다. 개발 트리에서 bin을 직접 실행한 경우뿐.
|
|
141
|
+
fail("템플릿 산출물(dist/)이 없습니다. 개발 트리에서 직접 실행했다면 먼저 npm run stage 를 실행하세요.");
|
|
142
|
+
}
|
|
139
143
|
const name = await resolveName(opts.name);
|
|
140
144
|
const dest = path.resolve(process.cwd(), name);
|
|
141
145
|
|
|
@@ -180,7 +184,7 @@ async function main() {
|
|
|
180
184
|
if (installFailed.length > 0) {
|
|
181
185
|
console.log(`\n⚠ 의존성 설치가 끝나지 않았습니다 (${installFailed.join(", ")}). 이렇게 마무리하세요:`);
|
|
182
186
|
for (const sub of installFailed) console.log(` cd ${name}/${sub} && pnpm install --frozen-lockfile`);
|
|
183
|
-
console.log(` 그다음 cd ${name} && pnpm doctor 로 다시 확인합니다.`);
|
|
187
|
+
console.log(` 그다음 cd ${name} && pnpm run doctor 로 다시 확인합니다.`);
|
|
184
188
|
}
|
|
185
189
|
console.log(`\n다음 단계:`);
|
|
186
190
|
console.log(` 1. cd ${name}`);
|
|
@@ -6,7 +6,7 @@ user-invocable: false
|
|
|
6
6
|
|
|
7
7
|
# 앱↔웹 대화 규격 (ssb 계약)
|
|
8
8
|
|
|
9
|
-
앱과 웹은 정해진 형식의 메시지로만 대화한다. 그 형식 정의는 양쪽이 똑같은 파일 하나(`src/bridge/contract.ts` + `reader.ts`)로 공유한다. 어긋나면
|
|
9
|
+
앱과 웹은 정해진 형식의 메시지로만 대화한다. 그 형식 정의는 양쪽이 똑같은 파일 하나(`src/bridge/contract.ts` + `reader.ts`)로 공유한다. 어긋나면 워크스페이스 루트의 `pnpm run doctor`가 잡아낸다.
|
|
10
10
|
|
|
11
11
|
## 봉투 & 전달 방식
|
|
12
12
|
|
|
@@ -52,7 +52,7 @@ user-invocable: false
|
|
|
52
52
|
4. inbound라면 `router.ts`의 `handleInbound` switch에 case를 추가한다. UNKNOWN은 그대로 무시되니 안전하다.
|
|
53
53
|
5. 새 능력이면 `capabilities.ts`의 `APP_CAPABILITIES`에 capability 문자열을 추가한다. 웹이 HELLO로 감지한다.
|
|
54
54
|
6. 네이티브 변경이므로 앱을 재빌드한다(OTA 아님 — `eas-deploy-guide`).
|
|
55
|
-
7.
|
|
55
|
+
7. 동기화한다. 같은 변경을 `ssb/`(원본)와 `web/lib/bridge/`·`mobile/src/bridge/` 세 곳에 바이트 단위로 동일하게 반영하고, 워크스페이스 루트에서 `pnpm run doctor`로 동일성을 확인한다.
|
|
56
56
|
|
|
57
57
|
> 새 기능은 앱에 먼저 들어가고(HELLO capability), 웹이 나중에 감지해 쓴다. 순서가 어긋나도 웹 코드가 capability 게이트로 막혀 있어 안전하다.
|
|
58
58
|
|
|
@@ -68,4 +68,4 @@ user-invocable: false
|
|
|
68
68
|
|
|
69
69
|
## 어댑터 (웹 측)
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
계약(`contract.ts`·`reader.ts`)은 이미 `web/lib/bridge/`에 설치돼 있다. 옵트인은 세션 핸드오프 레이어뿐이다 — 네이티브 소셜 로그인을 켤 때만 `docs/web-adapter/`의 POST 라우트(`route-app-bridge.ts`)·nonce·CSP를 웹 repo에 추가한다. 웹이 추가하는 의존성은 `zod` 하나뿐이다. 네이티브·webview 라이브러리는 웹에 들어가지 않는다. 웹은 `window.ReactNativeWebView` 전역으로만 앱에 닿는다. gronxb 등 라이브러리는 기각한다(네이티브-API-소유·monorepo 전제가 web=SSOT와 충돌한다). 설치하더라도 네이티브-소셜 핸드오프는 idToken 획득 코드가 없어 `requestSessionHandoff` 호출자가 여전히 없는 fast-follow 흐름이다.
|
|
@@ -11,7 +11,9 @@ dev/prod 클라우드 환경을 뚫어 첫 배포를 진행한다. 최신 외부
|
|
|
11
11
|
|
|
12
12
|
## 1. 이름·GitHub repo
|
|
13
13
|
|
|
14
|
-
이름을 먼저 결정한다. 이후 모든 리소스가 이 이름을 기반으로
|
|
14
|
+
이름을 먼저 결정한다. 기본값 = 워크스페이스 루트 `workspace.json`의 `name`(생성 시 입력한 프로젝트명). 이후 모든 리소스가 이 이름을 기반으로 한다: GitHub repo = `<name>`, Vercel 프로젝트 = `<name>`, Supabase = `<name>-dev`/`-prod`, Sentry 프로젝트 = `<name>`. **폴더명(`web`)을 리소스 이름으로 쓰지 않는다** — 특히 `vercel link`는 폴더명 `web`을 기본 프로젝트명으로 제안하므로 그대로 수락하지 말고 `<name>`으로 바꾼다. (앱 단계의 mobile repo는 `<name>-app` — package.json·Expo slug와 일치.)
|
|
15
|
+
|
|
16
|
+
private repo를 생성한다. secret scanning push protection은 private free에서 미지원이고 GitHub Advanced Security 유료 기능이다. 9-6에서 점검만 skip한다.
|
|
15
17
|
|
|
16
18
|
## 2. Supabase dev + prod
|
|
17
19
|
|
|
@@ -44,7 +44,7 @@ Owner confirm. 비용을 알린 뒤 계속할지 확정받고서야 다음 단
|
|
|
44
44
|
|
|
45
45
|
## 4. 웹 어댑터 (web/)
|
|
46
46
|
|
|
47
|
-
계약(`contract.ts`·`reader.ts`)은 이미 `web/lib/bridge/`에 바이트 단위로 동일하게 설치돼 있고(
|
|
47
|
+
계약(`contract.ts`·`reader.ts`)은 이미 `web/lib/bridge/`에 바이트 단위로 동일하게 설치돼 있고(`pnpm run doctor`가 동일성을 점검한다) 다시 복사하지 않는다. 웹이 앱과 실제로 대화하게 만드는 얇은 옵트인 레이어만 필요할 때 추가한다. 규약·보안 불변식은 인라인하지 않고 `bridge-guide`를 따른다.
|
|
48
48
|
|
|
49
49
|
세션 핸드오프는 fast-follow다. v1은 호출자가 없다. 기본 로그인은 웹뷰 안의 웹 로그인이 그대로 동작하고, 핸드오프 라우트(POST `/auth/app-bridge` + nonce)는 네이티브 소셜 로그인을 켤 때만 살아난다. 토큰은 브릿지·URL로 보내지 않는다(1회용 nonce만 보낸다).
|
|
50
50
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: warmup
|
|
3
|
-
description: Verify the AI agent's tools and environment (CLI installs, logins) and install/audit dependencies in web/
|
|
3
|
+
description: Verify the AI agent's tools and environment (CLI installs, logins) and install/audit dependencies in web/ and mobile/, fixing issues where possible. Use when the Owner runs /warmup, just opened the workspace, or before tasks that depend on external CLIs working.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# /warmup
|
|
@@ -14,7 +14,7 @@ Owner의 행동이 필요한 부분은 AskUserQuestion으로 해결한다.
|
|
|
14
14
|
## CLI
|
|
15
15
|
|
|
16
16
|
- `node` — 현 LTS
|
|
17
|
-
- `pnpm` —
|
|
17
|
+
- `pnpm` — 10 이상 (각 repo `package.json`의 `packageManager`로 버전 고정)
|
|
18
18
|
- `git`
|
|
19
19
|
- `gh` — 로그인
|
|
20
20
|
- `vercel` — 로그인
|
|
@@ -25,9 +25,9 @@ Owner의 행동이 필요한 부분은 AskUserQuestion으로 해결한다.
|
|
|
25
25
|
|
|
26
26
|
## 의존성
|
|
27
27
|
|
|
28
|
-
워크스페이스 루트의 `package.json`은 `pnpm doctor`(환경 점검) 진입점만 가진다 — 루트에 의존성을 설치하지 않는다. `web/`과 `mobile/`은 각자 독립 프로젝트이므로 의존성도 각자 설치한다.
|
|
28
|
+
워크스페이스 루트의 `package.json`은 `pnpm run doctor`(환경 점검) 진입점만 가진다 — 루트에 의존성을 설치하지 않는다. `web/`과 `mobile/`은 각자 독립 프로젝트이므로 의존성도 각자 설치한다.
|
|
29
29
|
|
|
30
30
|
- `web/`에서 `pnpm install` 후 `pnpm audit`.
|
|
31
|
-
- `mobile
|
|
31
|
+
- `mobile/`에서도 동일하게 한다.
|
|
32
32
|
|
|
33
33
|
세부 정책은 각 repo의 `.npmrc`와 CI 설정을 따른다. 보안 게이트(cooldown, audit level 등)는 우회하거나 수정하지 않는다.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# my-space — 워크스페이스
|
|
2
2
|
|
|
3
3
|
서비스들 위의 컨텍스트 레이어. 이 repo의 git은 공유 맥락(에이전트 도구·계약·문서)만 추적한다. `web/`와 `mobile/`은 각자 자기 git·배포를 가진 독립 repo다.
|
|
4
4
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
- `mobile/` — 배포된 web을 WebView로 감싸는 네이티브 셸 (Expo). 자기 git → EAS → 스토어로 배포한다. 웹뷰가 못 메우는 네이티브 공백(푸시·홈 화면 아이콘·오프라인 화면)만 채운다.
|
|
11
11
|
- `ssb/` — web ↔ mobile postMessage 계약의 SSOT. `web/lib/bridge/`와 `mobile/src/bridge/`에 바이트 단위로 동일하게 복사된다.
|
|
12
12
|
- `.claude/` — 에이전트 도구(스킬·설정). 워크스페이스 루트에만 둔다.
|
|
13
|
-
- `scripts/doctor.mjs` — 환경·로그인 점검(`pnpm doctor`). 진단과 안내까지만 하고 자동 수리는 하지 않는다. Owner가 ✗ 해결을 부탁하면 doctor의 → 안내를 따라 수행하고 다시 doctor로 검증한다.
|
|
13
|
+
- `scripts/doctor.mjs` — 환경·로그인 점검(`pnpm run doctor`). 진단과 안내까지만 하고 자동 수리는 하지 않는다. Owner가 ✗ 해결을 부탁하면 doctor의 → 안내를 따라 수행하고 다시 doctor로 검증한다.
|
|
14
14
|
- `workspace.json` — 생성기가 기록한 메타(프로젝트명·선택한 코딩 에이전트). doctor가 읽는다.
|
|
15
15
|
|
|
16
16
|
## 불변식
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# my-space
|
|
2
2
|
|
|
3
3
|
웹 SaaS를 만들고, 원하면 그대로 스토어 앱으로 감싸는 스타터입니다. 화면·코드·배포는 AI 코딩 에이전트가 맡고, 당신은 무엇을 만들지 정합니다.
|
|
4
4
|
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
├── web/ 웹 SaaS (Next.js · Supabase · Vercel). 화면·기능·데이터의 본체.
|
|
12
12
|
├── mobile/ 그 웹을 감싸는 스토어 앱 (Expo · EAS).
|
|
13
13
|
├── ssb/ web ↔ mobile 통신 계약. 손댈 일은 거의 없습니다.
|
|
14
|
-
├── scripts/ 환경 점검 도구(`pnpm doctor`).
|
|
14
|
+
├── scripts/ 환경 점검 도구(`pnpm run doctor`).
|
|
15
15
|
└── .claude/ 에이전트 도구(스킬·설정).
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
`web/`과 `mobile/`은 각자 배포되는 독립 프로젝트입니다. 이 워크스페이스는 둘을 잇는 공유 맥락(에이전트 도구·계약)만 둡니다. 에이전트 운영 규칙은 [AGENTS.md](AGENTS.md)에 있습니다.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
이 워크스페이스는 강의용 템플릿에서 생성되었습니다. [LICENSE](LICENSE).
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
2. 처음 열면 에이전트가 로그인(구독 계정)을 안내합니다. 한 번만 하면 됩니다.
|
|
11
11
|
3. 그다음부터는 아래 명령을 입력하거나, 평소 말로 부탁하면 됩니다.
|
|
12
12
|
|
|
13
|
-
> 환경이 멀쩡한지 궁금할 땐 언제든 터미널에서 `pnpm doctor`를 실행하세요. 도구·로그인 상태를 ✓/✗로 보여주고, ✗마다 해결 방법을 알려줍니다.
|
|
13
|
+
> 환경이 멀쩡한지 궁금할 땐 언제든 터미널에서 `pnpm run doctor`를 실행하세요. 도구·로그인 상태를 ✓/✗로 보여주고, ✗마다 해결 방법을 알려줍니다.
|
|
14
14
|
|
|
15
15
|
## 2. 명령 순서
|
|
16
16
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
├── web/ 당신의 웹 서비스 (Next.js → Vercel). 모든 화면·기능·데이터의 본체.
|
|
38
38
|
├── mobile/ 그 웹을 감싸는 스토어 앱 (Expo → 앱·플레이스토어). 푸시·홈 화면 아이콘처럼 앱만의 것.
|
|
39
39
|
├── ssb/ web ↔ mobile이 주고받는 통신 계약. 손대지 않아도 됩니다.
|
|
40
|
-
├── scripts/ 환경 점검 도구(`pnpm doctor`).
|
|
40
|
+
├── scripts/ 환경 점검 도구(`pnpm run doctor`).
|
|
41
41
|
└── .claude/ 위 명령(스킬)이 있는 곳.
|
|
42
42
|
```
|
|
43
43
|
|
|
@@ -8,7 +8,7 @@ Owner가 만든 웹을 감싸는 앱(웹뷰 래퍼)입니다. 화면과 기능
|
|
|
8
8
|
|
|
9
9
|
- 웹을 고치면 앱도 같이 바뀝니다. 대부분의 변경은 웹만 다시 배포하면 끝이고, 앱을 다시 만들거나 제출할 필요가 없습니다.
|
|
10
10
|
- 셸이 채우는 것: 외부 링크를 모바일 브라우저로 열기, Android 뒤로가기, 카메라·마이크·파일 올리기 권한, 화면 안전 여백, 오프라인 화면, 첫 로딩·크래시 복구 화면. 웹뷰만으로 막히는 네이티브 빈틈만 메웁니다.
|
|
11
|
-
- 소셜 로그인(구글·애플): 웹뷰
|
|
11
|
+
- 소셜 로그인(구글·애플): 구글 등은 웹뷰 안 로그인 창을 보안 정책으로 차단할 수 있어, 필요하면 외부 브라우저로 열거나 네이티브 로그인 화면(옵트인)을 켭니다.
|
|
12
12
|
|
|
13
13
|
## 시작 전에 (한 번만)
|
|
14
14
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
| `src/bridge/contract.ts` | `lib/bridge/contract.ts` |
|
|
22
22
|
| `src/bridge/reader.ts` | `lib/bridge/reader.ts` |
|
|
23
23
|
|
|
24
|
-
두 사본은 바이트 단위로 동일해야 한다. 어긋나면 양쪽이 메시지 형태를 다르게 해석해 브릿지가 오작동하므로,
|
|
24
|
+
두 사본은 바이트 단위로 동일해야 한다. 어긋나면 양쪽이 메시지 형태를 다르게 해석해 브릿지가 오작동하므로, 워크스페이스 루트의 `pnpm run doctor`가 동일성을 점검한다.
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
cmp -s mobile/src/bridge/contract.ts web/lib/bridge/contract.ts || exit 1
|
|
@@ -3,8 +3,9 @@ import { z } from "zod";
|
|
|
3
3
|
/**
|
|
4
4
|
* App ↔ Web bridge contract — SINGLE SOURCE OF TRUTH.
|
|
5
5
|
*
|
|
6
|
-
* This file is copied byte-for-byte into the web
|
|
7
|
-
*
|
|
6
|
+
* This file is copied byte-for-byte into the web (web/lib/bridge/) and the app
|
|
7
|
+
* (mobile/src/bridge/). All copies MUST stay identical; `pnpm run doctor` at the
|
|
8
|
+
* workspace root checks this.
|
|
8
9
|
*
|
|
9
10
|
* Invariants (never break these):
|
|
10
11
|
* 1. Every message is namespaced (`ns: "ssb"`) so unrelated postMessage traffic on
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// doctor — 워크스페이스 생성 이후의 환경·로그인 상태를 결정적으로 점검한다.
|
|
2
2
|
//
|
|
3
|
-
// 실행: 워크스페이스 루트에서 `pnpm doctor` (또는 `node scripts/doctor.mjs`).
|
|
3
|
+
// 실행: 워크스페이스 루트에서 `pnpm run doctor` (또는 `node scripts/doctor.mjs`).
|
|
4
4
|
// 의존성 0 — node 내장 모듈만 쓴다. 루트에는 node_modules가 없어도 돈다.
|
|
5
5
|
//
|
|
6
6
|
// 원칙:
|
|
@@ -191,7 +191,7 @@ function checkVercel() {
|
|
|
191
191
|
const r = run("vercel whoami", { timeout: 20000 });
|
|
192
192
|
if (r.ok) return { status: "ok", label: `Vercel CLI — 로그인됨 (${r.stdout.split("\n").pop()})` };
|
|
193
193
|
if (r.timedOut || r.networkIssue)
|
|
194
|
-
return { status: "warn", label: "Vercel CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm doctor 재실행" };
|
|
194
|
+
return { status: "warn", label: "Vercel CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm run doctor 재실행" };
|
|
195
195
|
return { status: "fail", label: "Vercel CLI", detail: "로그인이 안 되어 있습니다.", fix: "vercel login" };
|
|
196
196
|
}
|
|
197
197
|
|
|
@@ -207,7 +207,7 @@ function checkSupabase() {
|
|
|
207
207
|
const r = run(`"${bin}" projects list`, { cwd: path.join(ROOT, "web"), timeout: 25000 });
|
|
208
208
|
if (r.ok) return { status: "ok", label: "Supabase CLI — 로그인됨" };
|
|
209
209
|
if (r.timedOut || r.networkIssue)
|
|
210
|
-
return { status: "warn", label: "Supabase CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm doctor 재실행" };
|
|
210
|
+
return { status: "warn", label: "Supabase CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm run doctor 재실행" };
|
|
211
211
|
return {
|
|
212
212
|
status: "fail",
|
|
213
213
|
label: "Supabase CLI",
|
|
@@ -227,7 +227,7 @@ function checkEas() {
|
|
|
227
227
|
const r = run("eas whoami", { timeout: 20000 });
|
|
228
228
|
if (r.ok) return { status: "ok", label: `EAS CLI — 로그인됨 (${r.stdout.split("\n").pop()})` };
|
|
229
229
|
if (r.timedOut || r.networkIssue)
|
|
230
|
-
return { status: "warn", label: "EAS CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm doctor 재실행" };
|
|
230
|
+
return { status: "warn", label: "EAS CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm run doctor 재실행" };
|
|
231
231
|
return {
|
|
232
232
|
status: "fail",
|
|
233
233
|
label: "EAS CLI",
|
|
@@ -236,6 +236,50 @@ function checkEas() {
|
|
|
236
236
|
};
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
function checkSentry() {
|
|
240
|
+
if (!installed("sentry-cli"))
|
|
241
|
+
return { status: "fail", label: "Sentry CLI", detail: "설치되어 있지 않습니다.", fix: "npm install -g @sentry/cli" };
|
|
242
|
+
// sentry-cli info는 미로그인이어도 exit 0 — 출력의 Unauthorized로 판별한다.
|
|
243
|
+
const r = run("sentry-cli info", { timeout: 15000 });
|
|
244
|
+
if (r.ok && !/unauthorized/i.test(r.stdout + r.stderr)) return { status: "ok", label: "Sentry CLI — 로그인됨" };
|
|
245
|
+
if (r.timedOut || r.networkIssue)
|
|
246
|
+
return { status: "warn", label: "Sentry CLI", detail: "네트워크 문제로 확인하지 못했습니다.", fix: "인터넷 연결 후 pnpm run doctor 재실행" };
|
|
247
|
+
return {
|
|
248
|
+
status: "fail",
|
|
249
|
+
label: "Sentry CLI",
|
|
250
|
+
detail: "로그인이 안 되어 있습니다.",
|
|
251
|
+
fix: "sentry-cli login --global → 열린 페이지에서 [Create New Token] → 토큰 복사 → 터미널의 Enter your token:에 붙여넣기",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 브릿지 계약 동기 — ssb/(원본)와 web/mobile 사본의 byte-동일성.
|
|
256
|
+
// 어긋난 채 배포되면 앱·웹이 메시지를 다르게 해석해 조용히 오작동한다.
|
|
257
|
+
function checkBridgeSync() {
|
|
258
|
+
const diverged = [];
|
|
259
|
+
for (const file of ["contract.ts", "reader.ts"]) {
|
|
260
|
+
let original;
|
|
261
|
+
try {
|
|
262
|
+
original = readFileSync(path.join(ROOT, "ssb", file));
|
|
263
|
+
} catch {
|
|
264
|
+
return { status: "warn", label: "브릿지 계약 (ssb)", detail: `ssb/${file}를 읽지 못했습니다.` };
|
|
265
|
+
}
|
|
266
|
+
for (const dir of ["web/lib/bridge", "mobile/src/bridge"]) {
|
|
267
|
+
try {
|
|
268
|
+
if (!original.equals(readFileSync(path.join(ROOT, dir, file)))) diverged.push(`${dir}/${file}`);
|
|
269
|
+
} catch {
|
|
270
|
+
diverged.push(`${dir}/${file} (없음)`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (diverged.length === 0) return { status: "ok", label: "브릿지 계약 — 3곳 동일 (ssb · web · mobile)" };
|
|
275
|
+
return {
|
|
276
|
+
status: "fail",
|
|
277
|
+
label: "브릿지 계약",
|
|
278
|
+
detail: `ssb/ 원본과 어긋났습니다: ${diverged.join(", ")}`,
|
|
279
|
+
fix: '코딩 에이전트에게 부탁: "ssb/의 contract.ts·reader.ts를 web/lib/bridge/와 mobile/src/bridge/에 바이트 단위로 동일하게 복사해줘"',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
239
283
|
// ---------- 실행 ----------
|
|
240
284
|
|
|
241
285
|
const meta = readWorkspaceMeta();
|
|
@@ -245,8 +289,8 @@ console.log(bold(`🩺 doctor — ${meta.name ?? path.basename(ROOT)} 환경 점
|
|
|
245
289
|
console.log(dim(" 각 항목을 점검합니다. 네트워크 확인이 포함되어 몇십 초 걸릴 수 있습니다."));
|
|
246
290
|
|
|
247
291
|
const sections = [
|
|
248
|
-
{ title: "공통", blocking: true, checks: [checkNode, checkPnpm, checkGit, () => checkAgent(meta), checkGh, checkVercel] },
|
|
249
|
-
{ title: "웹", blocking: true, checks: [() => checkDeps("web"), checkSupabase] },
|
|
292
|
+
{ title: "공통", blocking: true, checks: [checkNode, checkPnpm, checkGit, () => checkAgent(meta), checkGh, checkVercel, checkBridgeSync] },
|
|
293
|
+
{ title: "웹", blocking: true, checks: [() => checkDeps("web"), checkSupabase, checkSentry] },
|
|
250
294
|
{ title: "앱", blocking: false, note: "'앱으로 확장' 챕터 전까지는 ✗여도 정상입니다.", checks: [() => checkDeps("mobile"), checkEas] },
|
|
251
295
|
];
|
|
252
296
|
|
|
@@ -275,13 +319,15 @@ for (const section of sections) {
|
|
|
275
319
|
|
|
276
320
|
console.log("");
|
|
277
321
|
if (blockingFails === 0) {
|
|
278
|
-
|
|
322
|
+
// [앱]까지 전부 ✓면 그 사실도 인정한다 — "웹만 완료"로 읽히지 않게.
|
|
323
|
+
const verdict = appFails === 0 ? "✅ 웹·앱 개발 준비 모두 완료" : "✅ 웹 개발 준비 완료";
|
|
324
|
+
console.log(green(bold(verdict)) + (warns ? dim(` (⚠ ${warns}개는 안내를 참고해 직접 확인하세요)`) : ""));
|
|
279
325
|
} else {
|
|
280
|
-
console.log(red(bold(`✗ ${blockingFails}개 항목이 남았습니다.`)) + " 위의 → 안내를 따라 해결한 뒤 다시 실행하세요: " + bold("pnpm doctor"));
|
|
326
|
+
console.log(red(bold(`✗ ${blockingFails}개 항목이 남았습니다.`)) + " 위의 → 안내를 따라 해결한 뒤 다시 실행하세요: " + bold("pnpm run doctor"));
|
|
281
327
|
console.log("");
|
|
282
328
|
console.log("📋 직접 하기 어렵다면, 코딩 에이전트에 아래 문장을 그대로 붙여넣으세요:");
|
|
283
|
-
console.log(bold(' "pnpm doctor를 실행해서 ✗ 항목을 확인하고, 각 항목의 → 안내를 따라 해결해줘.'));
|
|
284
|
-
console.log(bold(' 브라우저 로그인이 필요한 단계는 멈추고 나에게 알려줘. 끝나면 pnpm doctor로 다시 검증해줘."'));
|
|
329
|
+
console.log(bold(' "pnpm run doctor를 실행해서 ✗ 항목을 확인하고, 각 항목의 → 안내를 따라 해결해줘.'));
|
|
330
|
+
console.log(bold(' 브라우저 로그인이 필요한 단계는 멈추고 나에게 알려줘. 끝나면 pnpm run doctor로 다시 검증해줘."'));
|
|
285
331
|
}
|
|
286
332
|
if (appFails > 0 && blockingFails === 0) {
|
|
287
333
|
console.log(dim(`📦 [앱] ✗ ${appFails}개는 '앱으로 확장' 챕터에서 해결합니다. 지금은 무시해도 됩니다.`));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ssb — 앱↔웹 브릿지 계약 (SSOT)
|
|
2
2
|
|
|
3
|
-
이 폴더가 앱↔웹 통신 계약의 원본이다. 메시지는 `ns: "ssb"` 네임스페이스를 달아 페이지의 다른 postMessage 트래픽과 섞이지 않는다.
|
|
3
|
+
이 폴더가 앱↔웹 통신 계약의 원본이다. 메시지는 `ns: "ssb"` 네임스페이스를 달아 페이지의 다른 postMessage 트래픽과 섞이지 않는다. 이 원본은 app(`mobile/src/bridge/`)과 web(`web/lib/bridge/`)에 바이트 단위로 동일하게 복사되어 있고, 워크스페이스 루트의 `pnpm run doctor`가 세 사본의 동일성을 점검한다.
|
|
4
4
|
|
|
5
5
|
불변식 (상세는 `contract.ts` 헤더):
|
|
6
6
|
|
|
@@ -3,8 +3,9 @@ import { z } from "zod";
|
|
|
3
3
|
/**
|
|
4
4
|
* App ↔ Web bridge contract — SINGLE SOURCE OF TRUTH.
|
|
5
5
|
*
|
|
6
|
-
* This file is copied byte-for-byte into the web
|
|
7
|
-
*
|
|
6
|
+
* This file is copied byte-for-byte into the web (web/lib/bridge/) and the app
|
|
7
|
+
* (mobile/src/bridge/). All copies MUST stay identical; `pnpm run doctor` at the
|
|
8
|
+
* workspace root checks this.
|
|
8
9
|
*
|
|
9
10
|
* Invariants (never break these):
|
|
10
11
|
* 1. Every message is namespaced (`ns: "ssb"`) so unrelated postMessage traffic on
|
|
@@ -3,8 +3,9 @@ import { z } from "zod";
|
|
|
3
3
|
/**
|
|
4
4
|
* App ↔ Web bridge contract — SINGLE SOURCE OF TRUTH.
|
|
5
5
|
*
|
|
6
|
-
* This file is copied byte-for-byte into the web
|
|
7
|
-
*
|
|
6
|
+
* This file is copied byte-for-byte into the web (web/lib/bridge/) and the app
|
|
7
|
+
* (mobile/src/bridge/). All copies MUST stay identical; `pnpm run doctor` at the
|
|
8
|
+
* workspace root checks this.
|
|
8
9
|
*
|
|
9
10
|
* Invariants (never break these):
|
|
10
11
|
* 1. Every message is namespaced (`ns: "ssb"`) so unrelated postMessage traffic on
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-saas-starter-workspace",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "강의용 SaaS 워크스페이스(web + mobile + 에이전트 도구)를 한 번에 만드는 생성기. 생성 → 의존성 설치 → doctor 점검까지 한 흐름.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"src",
|
|
13
|
-
"
|
|
13
|
+
"dist",
|
|
14
14
|
"CHANGELOG.md"
|
|
15
15
|
],
|
|
16
16
|
"engines": {
|
package/src/scaffold.mjs
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|