@gaonjs/cli 0.34.0 → 0.36.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/dist/commands/check.d.ts +9 -0
- package/dist/commands/check.js +39 -8
- package/dist/commands/new.d.ts +7 -1
- package/dist/commands/new.js +8 -2
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.js +6 -1
- package/dist/generate.d.ts +13 -0
- package/dist/generate.js +50 -1
- package/dist/index.d.ts +17 -0
- package/dist/index.js +54 -23
- package/dist/templates/index.d.ts +17 -0
- package/dist/templates/index.js +18 -1
- package/dist/templates/index.ts +24 -0
- package/dist/templates/project/AGENTS.md.tpl +1 -1
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/data.md.tpl +23 -0
- package/dist/templates/project/agents/security.md.tpl +20 -3
- package/dist/templates/project/agents/testing.md.tpl +37 -0
- package/dist/templates/project/agents/web.md.tpl +22 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +20 -1
- package/dist/templates/project/package.json.tpl +1 -1
- package/package.json +6 -6
package/dist/commands/check.d.ts
CHANGED
|
@@ -49,6 +49,15 @@ export interface CheckReport {
|
|
|
49
49
|
readonly regen: CheckRegen;
|
|
50
50
|
readonly steps: readonly CheckStepResult[];
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* 결정 170: 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는
|
|
54
|
+
* package.json 의 `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을
|
|
55
|
+
* 기록하므로, `gaon check` 가 사용자 스크립트(예 build)를 돌릴 때 그 pm 으로
|
|
56
|
+
* 실행해야 한다 — pnpm 하드코딩은 npm/yarn 로 스캐폴드한 프로젝트에서 pnpm 이
|
|
57
|
+
* "This project is configured to use npm" 로 실행을 거부해 build 단계가 깨진다.
|
|
58
|
+
* 우선순위: packageManager 필드 → 락파일 → pnpm(기본).
|
|
59
|
+
*/
|
|
60
|
+
export declare function detectPackageManager(cwd: string): 'pnpm' | 'npm' | 'yarn';
|
|
52
61
|
/**
|
|
53
62
|
* `gaon check` 진입점. 검사 전에 .gaon 을 재생성(규칙 3)한 뒤 각 단계를
|
|
54
63
|
* 순서대로 실행하고, 하나라도 실패하면 exit 1. --only 지정 시 그 단계만
|
package/dist/commands/check.js
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
* @gaonjs/cli · `gaon check` — 통합 검증 (M9-G · v0.15 §13.5)
|
|
3
3
|
*
|
|
4
4
|
* 배포 · CI · AI 에이전트가 한 번에 신뢰할 수 있는 검사 묶음. 사용자의
|
|
5
|
-
* package.json
|
|
6
|
-
* ·
|
|
7
|
-
* (`node_modules/.bin/tsc --noEmit` ·
|
|
8
|
-
*
|
|
5
|
+
* package.json 스크립트를 그대로 재사용하되, **프로젝트 선언 pm** 으로 돌린다
|
|
6
|
+
* (결정 170 · `<pm> run <name>` — pnpm/npm/yarn). 스크립트가 없으면 폴백으로
|
|
7
|
+
* 로컬 바이너리를 직접 부른다(`node_modules/.bin/tsc --noEmit` ·
|
|
8
|
+
* `node_modules/.bin/vue-tsc --noEmit`).
|
|
9
9
|
*
|
|
10
10
|
* The One Way — 하나의 명령이 4 검사를 순서대로 돌린다(결정 157 · doctor 기본 포함):
|
|
11
|
-
* 1) typecheck (
|
|
12
|
-
* 2) vue-tsc (
|
|
13
|
-
* 3) build (
|
|
11
|
+
* 1) typecheck (<pm> run typecheck 또는 tsc --noEmit)
|
|
12
|
+
* 2) vue-tsc (<pm> run vue-tsc 또는 vue-tsc --noEmit)
|
|
13
|
+
* 3) build (<pm> run build)
|
|
14
14
|
* 4) doctor (기본 포함 · 규칙 5 등 doctor 규칙 · --no-doctor 로 뺌 · 코어 재사용)
|
|
15
15
|
*
|
|
16
16
|
* 옵션 최소:
|
|
@@ -54,6 +54,35 @@ function hasScript(cwd, name) {
|
|
|
54
54
|
function binExists(cwd, name) {
|
|
55
55
|
return existsSync(join(cwd, 'node_modules', '.bin', name));
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* 결정 170: 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는
|
|
59
|
+
* package.json 의 `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을
|
|
60
|
+
* 기록하므로, `gaon check` 가 사용자 스크립트(예 build)를 돌릴 때 그 pm 으로
|
|
61
|
+
* 실행해야 한다 — pnpm 하드코딩은 npm/yarn 로 스캐폴드한 프로젝트에서 pnpm 이
|
|
62
|
+
* "This project is configured to use npm" 로 실행을 거부해 build 단계가 깨진다.
|
|
63
|
+
* 우선순위: packageManager 필드 → 락파일 → pnpm(기본).
|
|
64
|
+
*/
|
|
65
|
+
export function detectPackageManager(cwd) {
|
|
66
|
+
const pkgPath = join(cwd, 'package.json');
|
|
67
|
+
if (existsSync(pkgPath)) {
|
|
68
|
+
try {
|
|
69
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
70
|
+
const pm = pkg.packageManager?.split('@')[0];
|
|
71
|
+
if (pm === 'pnpm' || pm === 'npm' || pm === 'yarn')
|
|
72
|
+
return pm;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// 파싱 실패는 락파일/기본으로 폴백
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (existsSync(join(cwd, 'pnpm-lock.yaml')))
|
|
79
|
+
return 'pnpm';
|
|
80
|
+
if (existsSync(join(cwd, 'yarn.lock')))
|
|
81
|
+
return 'yarn';
|
|
82
|
+
if (existsSync(join(cwd, 'package-lock.json')))
|
|
83
|
+
return 'npm';
|
|
84
|
+
return 'pnpm';
|
|
85
|
+
}
|
|
57
86
|
/**
|
|
58
87
|
* 단일 서브 프로세스를 spawn 해서 stdout+stderr 를 모으고 exit 코드를
|
|
59
88
|
* 돌려준다. 실행 실패(파일 없음)는 exit 127 로 매핑.
|
|
@@ -77,7 +106,9 @@ async function runStep(step, cwd) {
|
|
|
77
106
|
// 1) 사용자 스크립트가 정의돼 있으면 그걸 쓴다(한 곳에서 관리).
|
|
78
107
|
const scriptName = step;
|
|
79
108
|
if (hasScript(cwd, scriptName)) {
|
|
80
|
-
|
|
109
|
+
// 결정 170: pnpm 하드코딩 대신 프로젝트 선언 pm 으로 실행(npm/yarn 스캐폴드
|
|
110
|
+
// 대응). `<pm> run <name>` 는 pnpm·npm·yarn(classic) 셋 다 동일하게 동작한다.
|
|
111
|
+
const cmd = detectPackageManager(cwd);
|
|
81
112
|
const args = ['run', scriptName];
|
|
82
113
|
const { exitCode, output } = await runSubprocess(cwd, cmd, args);
|
|
83
114
|
// 결정 146(12차 W2): build 가 성공했으면 **등록된 앱마다** dist/<앱>/index.html 과
|
package/dist/commands/new.d.ts
CHANGED
|
@@ -8,7 +8,13 @@ export interface NewCommandOptions {
|
|
|
8
8
|
readonly skipInstall?: boolean;
|
|
9
9
|
/** git init · 첫 커밋 스킵(테스트·기존 git 저장소에 삽입). */
|
|
10
10
|
readonly skipGit?: boolean;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* 초기 `install` 을 돌릴 패키지 매니저(`--package-manager <pm>` · 별칭 `--pm`).
|
|
13
|
+
* 기본 pnpm(모노레포 관례 정합). blessed 툴체인(Dockerfile·pnpm-workspace)은
|
|
14
|
+
* The One Way 로 pnpm 을 유지하지만, package.json 의 `packageManager` 필드는
|
|
15
|
+
* 선택한 pm 에 맞춰 적는다 — 항상 pnpm 을 적으면 `--pm yarn` 시 yarn 1.22
|
|
16
|
+
* corepack enforcement 가 install 을 거부한다(결정 169 · 167 개정).
|
|
17
|
+
*/
|
|
12
18
|
readonly packageManager?: 'pnpm' | 'npm' | 'yarn';
|
|
13
19
|
/**
|
|
14
20
|
* (테스트 훅) 템플릿의 gaonjs 의존성 버전. 미지정 시 파사드(gaonjs)
|
package/dist/commands/new.js
CHANGED
|
@@ -22,7 +22,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
22
22
|
import { spawnSync } from 'node:child_process';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import { readFileSync } from 'node:fs';
|
|
25
|
-
import { renderProjectFiles } from '../templates/index.js';
|
|
25
|
+
import { renderProjectFiles, PACKAGE_MANAGER_PINS, } from '../templates/index.js';
|
|
26
26
|
import { writeUiKitScaffold } from '../uikit.js';
|
|
27
27
|
import { regenerateProjectGaon } from './gen.js';
|
|
28
28
|
/** 이름 유효성 — npm 패키지명 규칙(단순 부분)만 검사. */
|
|
@@ -172,7 +172,13 @@ export async function runNewCommand(name, opts = {}) {
|
|
|
172
172
|
// 파일 생성 — 실패 시 부분 생성물이 남지 않도록 폴더를 정리하지는 않는다
|
|
173
173
|
// (사용자가 원인 파악 후 rm -rf 로 지우도록). 정상 흐름에서는 문제 없음.
|
|
174
174
|
const gaonjsVersion = opts.gaonjsVersion ?? detectGaonjsVersion();
|
|
175
|
-
|
|
175
|
+
// 결정 169: packageManager 필드 = 선택한 pm 의 corepack 핀. 항상 pnpm 을
|
|
176
|
+
// 적던 옛 관례는 --pm yarn 시 yarn 1.22 corepack 이 install 을 거부시켰다.
|
|
177
|
+
const files = renderProjectFiles({
|
|
178
|
+
projectName: name,
|
|
179
|
+
gaonjsVersion,
|
|
180
|
+
packageManager: PACKAGE_MANAGER_PINS[pm],
|
|
181
|
+
});
|
|
176
182
|
let filesCreated = 0;
|
|
177
183
|
try {
|
|
178
184
|
mkdirSync(root, { recursive: true });
|
package/dist/doctor.d.ts
CHANGED
|
@@ -24,6 +24,15 @@ export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-
|
|
|
24
24
|
export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
|
|
25
25
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
26
26
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
27
|
+
/**
|
|
28
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 23개 모두.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* doctor 정적 검사 25종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
|
|
32
|
+
* 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
|
|
33
|
+
* 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
|
|
34
|
+
*/
|
|
35
|
+
export declare const ALL_RULES: readonly DoctorRule[];
|
|
27
36
|
export interface DoctorCommandOptions {
|
|
28
37
|
readonly cwd?: string;
|
|
29
38
|
readonly json?: boolean;
|
package/dist/doctor.js
CHANGED
|
@@ -94,7 +94,12 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
|
|
|
94
94
|
/**
|
|
95
95
|
* 실행할 검사 이름. 지정 없음(undefined) = 23개 모두.
|
|
96
96
|
*/
|
|
97
|
-
|
|
97
|
+
/**
|
|
98
|
+
* doctor 정적 검사 25종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
|
|
99
|
+
* 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
|
|
100
|
+
* 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
|
|
101
|
+
*/
|
|
102
|
+
export const ALL_RULES = [
|
|
98
103
|
'response-mixing',
|
|
99
104
|
'n-plus-one',
|
|
100
105
|
'dependency-direction',
|
package/dist/generate.d.ts
CHANGED
|
@@ -20,6 +20,19 @@ export interface ScaffoldResult {
|
|
|
20
20
|
/** 손 수리가 필요한 지점 — 수리 안내 문장(§7.5.3)을 그대로 담는다. */
|
|
21
21
|
readonly warnings: string[];
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* 결정 155·142: `g auth --app <앱>` 이 앱별 세션 secret 환경변수를 .env·.env.example
|
|
25
|
+
* 에도 시드한다. web 은 SESSION_SECRET 이 프로젝트 스캐폴드(.env.example.tpl)에 이미
|
|
26
|
+
* 있어 no-op 이지만, 비-web 앱(admin 등)은 이 키가 어디에도 없어 app.config 의
|
|
27
|
+
* `process.env.ADMIN_SESSION_SECRET ?? '공개 고정 dev secret'` 이 조용히 폴백으로
|
|
28
|
+
* 돌아 세션 위조 표면이 된다. env 파일에 키를 심어 사용자가 설정해야 함을 드러낸다
|
|
29
|
+
* (앱별 세션 완전 분리를 env 층까지 확장).
|
|
30
|
+
*
|
|
31
|
+
* 이미 그 키가 있으면 건드리지 않는다(멱등). 파일이 없으면 만들지 않는다 — 스캐폴드가
|
|
32
|
+
* .env 를 새로 만드는 관례가 없어(사용자가 cp .env.example .env), 존재하는 파일만 패치한다.
|
|
33
|
+
* 반환: 실제로 키를 추가한 상대 경로들.
|
|
34
|
+
*/
|
|
35
|
+
export declare function patchEnvFiles(root: string, app: string): string[];
|
|
23
36
|
/** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
|
|
24
37
|
* 결정 155: 공개 가입 여부(includePublic)로 registration·Signup·dashboard 변형을 가른다. */
|
|
25
38
|
export declare function authScaffoldFiles(opts?: AuthScaffoldOptions): ScaffoldFile[];
|
package/dist/generate.js
CHANGED
|
@@ -47,6 +47,41 @@ function sessionSecretEnvFor(app) {
|
|
|
47
47
|
function devSessionSecretFor(app) {
|
|
48
48
|
return `dev-only-session-secret-${app}-change-me-now!!`;
|
|
49
49
|
}
|
|
50
|
+
/** .env(.example)에 시드할 앱별 세션 secret 플레이스홀더 값(32자 이상 · 운영은 교체). */
|
|
51
|
+
function envSecretPlaceholderFor(app) {
|
|
52
|
+
return `change-me-to-a-32-char-${app}-session-secret!!`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* 결정 155·142: `g auth --app <앱>` 이 앱별 세션 secret 환경변수를 .env·.env.example
|
|
56
|
+
* 에도 시드한다. web 은 SESSION_SECRET 이 프로젝트 스캐폴드(.env.example.tpl)에 이미
|
|
57
|
+
* 있어 no-op 이지만, 비-web 앱(admin 등)은 이 키가 어디에도 없어 app.config 의
|
|
58
|
+
* `process.env.ADMIN_SESSION_SECRET ?? '공개 고정 dev secret'` 이 조용히 폴백으로
|
|
59
|
+
* 돌아 세션 위조 표면이 된다. env 파일에 키를 심어 사용자가 설정해야 함을 드러낸다
|
|
60
|
+
* (앱별 세션 완전 분리를 env 층까지 확장).
|
|
61
|
+
*
|
|
62
|
+
* 이미 그 키가 있으면 건드리지 않는다(멱등). 파일이 없으면 만들지 않는다 — 스캐폴드가
|
|
63
|
+
* .env 를 새로 만드는 관례가 없어(사용자가 cp .env.example .env), 존재하는 파일만 패치한다.
|
|
64
|
+
* 반환: 실제로 키를 추가한 상대 경로들.
|
|
65
|
+
*/
|
|
66
|
+
export function patchEnvFiles(root, app) {
|
|
67
|
+
const secretEnv = sessionSecretEnvFor(app);
|
|
68
|
+
const patched = [];
|
|
69
|
+
for (const rel of ['.env', '.env.example']) {
|
|
70
|
+
const abs = join(root, rel);
|
|
71
|
+
if (!existsSync(abs))
|
|
72
|
+
continue;
|
|
73
|
+
const existing = readFileSync(abs, 'utf8');
|
|
74
|
+
// 라인 시작에서 `KEY=` 를 찾는다(주석·부분 일치 회피).
|
|
75
|
+
if (new RegExp(`^${secretEnv}=`, 'm').test(existing))
|
|
76
|
+
continue;
|
|
77
|
+
const block = `\n# ${app} 앱 세션 secret (32자 이상 · 앱별 세션 완전 분리 · 운영은 반드시 교체).\n` +
|
|
78
|
+
`${secretEnv}=${envSecretPlaceholderFor(app)}\n`;
|
|
79
|
+
const sep = existing.endsWith('\n') ? '' : '\n';
|
|
80
|
+
writeFileSync(abs, existing + sep + block, 'utf8');
|
|
81
|
+
patched.push(rel);
|
|
82
|
+
}
|
|
83
|
+
return patched;
|
|
84
|
+
}
|
|
50
85
|
/** 결정 155: Login 페이지의 회원가입 링크 — 공개 가입 앱에만 넣는다(시큐어 앱은 뺀다).
|
|
51
86
|
* 템플릿의 `{{SIGNUP_LINK}}` 자리(8칸 들여쓰기)에 in-place 치환되므로 첫 줄은 들여쓰기 없이. */
|
|
52
87
|
function signupLinkMarkup(app) {
|
|
@@ -228,6 +263,9 @@ export function writeAuthScaffold(cwd, opts = {}) {
|
|
|
228
263
|
writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app, includePublic), 'utf8');
|
|
229
264
|
created.push(routesRel);
|
|
230
265
|
}
|
|
266
|
+
// 결정 155·142(W3): 앱별 세션 secret 을 .env·.env.example 에 시드한다. web 은 이미
|
|
267
|
+
// 있어 멱등, 비-web 앱은 여기서 키가 심어져 "공개 고정 dev secret 폴백" 표면을 막는다.
|
|
268
|
+
patched.push(...patchEnvFiles(root, app));
|
|
231
269
|
// 결정 155: 시큐어 앱(공개 가입 미생성)은 역할 게이트 대시보드를 깔았다 — role 컬럼을
|
|
232
270
|
// 두라고 안내한다(§7.5.3 · authorize 예시가 실제 역할 규칙이 되도록).
|
|
233
271
|
if (!includePublic) {
|
|
@@ -248,6 +286,7 @@ export function runGenerateAuthCommand(opts = {}) {
|
|
|
248
286
|
process.stdout.write(JSON.stringify({ command: 'g auth', app, public: opts.public ?? app === 'web', ...result }, null, 2) + '\n');
|
|
249
287
|
return 0;
|
|
250
288
|
}
|
|
289
|
+
const includePublic = includesPublicRegistration({ app, public: opts.public });
|
|
251
290
|
const lines = [''];
|
|
252
291
|
lines.push(` gaon g auth — 인증 스캐폴드 (${app} 앱)`);
|
|
253
292
|
lines.push('');
|
|
@@ -266,7 +305,17 @@ export function runGenerateAuthCommand(opts = {}) {
|
|
|
266
305
|
lines.push(` 1) .env 에 REDIS_URL·${secretEnv}(32자 이상)·COOKIE_SECRET 을 설정한다.`);
|
|
267
306
|
lines.push(' 2) gaon db diff && gaon db migrate 로 users 테이블을 만든다.');
|
|
268
307
|
lines.push(' 3) gaon dev 로 실행한다 (.gaon 타입 브리지 생성 + 세션·인증 자동 배선).');
|
|
269
|
-
|
|
308
|
+
// 결정 155(W4): 안내문을 실제 산출물과 정합시킨다. 시큐어 스캐폴드(비-web · 비-public)는
|
|
309
|
+
// 공개 가입을 안 깔았으므로 없는 /registration/new 경로를 광고하지 않는다 — 대신 역할
|
|
310
|
+
// 부여 경로(seed/DB)와 필요 시 --public 을 안내한다(생성물 ≠ 안내문 자기모순 제거).
|
|
311
|
+
if (includePublic) {
|
|
312
|
+
lines.push(` → ${prefix}/registration/new 회원가입 · ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
lines.push(` → ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
|
|
316
|
+
lines.push(` → 공개 회원가입 없음(시큐어 스캐폴드). 관리자 계정은 seed/DB 로 만들거나 승격하세요.`);
|
|
317
|
+
lines.push(` 공개 가입이 필요하면: gaon g auth --app ${app} --public`);
|
|
318
|
+
}
|
|
270
319
|
lines.push('');
|
|
271
320
|
process.stdout.write(lines.join('\n') + '\n');
|
|
272
321
|
return 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -53,5 +53,22 @@ export interface RunOptions {
|
|
|
53
53
|
* 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
|
|
54
54
|
*/
|
|
55
55
|
export declare function parseDoctorChecks(argv: readonly string[]): DoctorRule[] | undefined;
|
|
56
|
+
/** `gaon new` argv 파싱 결과. name 부재·pm 오타를 호출부가 분기 처리한다. */
|
|
57
|
+
export interface ParsedNewArgs {
|
|
58
|
+
/** 프로젝트 이름(첫 위치 인자). 부재 시 undefined. */
|
|
59
|
+
readonly name?: string;
|
|
60
|
+
/** 검증 통과한 패키지 매니저. 미지정 시 undefined(호출부가 pnpm 기본). */
|
|
61
|
+
readonly packageManager?: "pnpm" | "npm" | "yarn";
|
|
62
|
+
/** pm 값이 주어졌으나 pnpm·npm·yarn 이 아닐 때 그 원문(에러 안내용). */
|
|
63
|
+
readonly unknownPm?: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `gaon new <name>` 의 인자를 파싱한다.
|
|
67
|
+
* 패키지 매니저 플래그: `--package-manager <pm>`(정본) · `--pm <pm>`(별칭) ·
|
|
68
|
+
* 둘 다 `=` 형(`--package-manager=npm`)도 허용. 값을 취하는 플래그이므로
|
|
69
|
+
* 이름 위치 인자를 고를 때 그 값을 건너뛴다 — `gaon new --pm npm demo` 의
|
|
70
|
+
* npm 이 프로젝트 이름으로 오인되지 않도록(결정 167 · O-1 근본 fix).
|
|
71
|
+
*/
|
|
72
|
+
export declare function parseNewArgs(rest: readonly string[]): ParsedNewArgs;
|
|
56
73
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
57
74
|
export declare function runCli(argv: readonly string[], opts?: RunOptions): void;
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import { runServeCommand } from "./serve.js";
|
|
|
26
26
|
import { runWorkCommand } from "./work.js";
|
|
27
27
|
import { runJobsCommand } from "./jobs.js";
|
|
28
28
|
import { runDbCommand } from "./commands/db.js";
|
|
29
|
-
import { runDoctorCommand } from "./doctor.js";
|
|
29
|
+
import { runDoctorCommand, ALL_RULES } from "./doctor.js";
|
|
30
30
|
import { runMcpCommand } from "./commands/mcp.js";
|
|
31
31
|
export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
|
|
32
32
|
export { runDevCommand } from "./commands/dev.js";
|
|
@@ -95,7 +95,7 @@ function renderHelp(version = VERSION) {
|
|
|
95
95
|
"",
|
|
96
96
|
" 사용법:",
|
|
97
97
|
" gaon 로드맵과 개발 상태를 출력",
|
|
98
|
-
" gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --
|
|
98
|
+
" gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --package-manager <pnpm|npm|yarn>)",
|
|
99
99
|
" gaon dev 개발 스택 통합 (Docker · .gaon · serve · tsc/vue-tsc · 재시작 워처)",
|
|
100
100
|
" gaon dev --stop-docker Ctrl+C 시 Docker Compose 도 down",
|
|
101
101
|
" gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker 개별 debug 옵션",
|
|
@@ -146,18 +146,10 @@ function renderHelp(version = VERSION) {
|
|
|
146
146
|
* 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
|
|
147
147
|
*/
|
|
148
148
|
export function parseDoctorChecks(argv) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"connections",
|
|
154
|
-
"migration-diff",
|
|
155
|
-
"shared-composable-purity",
|
|
156
|
-
"no-auto-import",
|
|
157
|
-
"csrf-wiring",
|
|
158
|
-
"async-offload",
|
|
159
|
-
];
|
|
160
|
-
const isKnown = (s) => known.includes(s);
|
|
149
|
+
// 인정 집합은 doctor.ts 의 ALL_RULES(정본 25종)를 단일 출처로 쓴다 — 과거
|
|
150
|
+
// 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
|
|
151
|
+
// 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
|
|
152
|
+
const isKnown = (s) => ALL_RULES.includes(s);
|
|
161
153
|
const out = [];
|
|
162
154
|
for (const a of argv) {
|
|
163
155
|
if (a.startsWith("--check=")) {
|
|
@@ -169,6 +161,41 @@ export function parseDoctorChecks(argv) {
|
|
|
169
161
|
}
|
|
170
162
|
return out.length ? out : undefined;
|
|
171
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* `gaon new <name>` 의 인자를 파싱한다.
|
|
166
|
+
* 패키지 매니저 플래그: `--package-manager <pm>`(정본) · `--pm <pm>`(별칭) ·
|
|
167
|
+
* 둘 다 `=` 형(`--package-manager=npm`)도 허용. 값을 취하는 플래그이므로
|
|
168
|
+
* 이름 위치 인자를 고를 때 그 값을 건너뛴다 — `gaon new --pm npm demo` 의
|
|
169
|
+
* npm 이 프로젝트 이름으로 오인되지 않도록(결정 167 · O-1 근본 fix).
|
|
170
|
+
*/
|
|
171
|
+
export function parseNewArgs(rest) {
|
|
172
|
+
let name;
|
|
173
|
+
let pmRaw;
|
|
174
|
+
for (let i = 0; i < rest.length; i++) {
|
|
175
|
+
const a = rest[i];
|
|
176
|
+
if (a === "--package-manager" || a === "--pm") {
|
|
177
|
+
pmRaw = rest[++i];
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const eqPrefix = a.startsWith("--package-manager=")
|
|
181
|
+
? "--package-manager="
|
|
182
|
+
: a.startsWith("--pm=")
|
|
183
|
+
? "--pm="
|
|
184
|
+
: undefined;
|
|
185
|
+
if (eqPrefix !== undefined) {
|
|
186
|
+
pmRaw = a.slice(eqPrefix.length);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (a.startsWith("--"))
|
|
190
|
+
continue;
|
|
191
|
+
if (name === undefined)
|
|
192
|
+
name = a;
|
|
193
|
+
}
|
|
194
|
+
if (pmRaw === "pnpm" || pmRaw === "npm" || pmRaw === "yarn") {
|
|
195
|
+
return { name, packageManager: pmRaw };
|
|
196
|
+
}
|
|
197
|
+
return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
|
|
198
|
+
}
|
|
172
199
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
173
200
|
export function runCli(argv, opts = {}) {
|
|
174
201
|
const version = opts.version ?? VERSION;
|
|
@@ -460,19 +487,23 @@ export function runCli(argv, opts = {}) {
|
|
|
460
487
|
// `gaon new <name>` — 프로젝트 스캐폴드(M9-F). 파일 생성 → 의존성 설치 → git init.
|
|
461
488
|
if (argv[0] === "new") {
|
|
462
489
|
const rest = argv.slice(1);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (!a.startsWith("--") && name === undefined)
|
|
466
|
-
name = a;
|
|
467
|
-
}
|
|
468
|
-
if (!name) {
|
|
490
|
+
const parsed = parseNewArgs(rest);
|
|
491
|
+
if (!parsed.name) {
|
|
469
492
|
process.stderr.write(" ✗ gaon new: 프로젝트 이름이 없습니다.\n → 예: gaon new demo\n");
|
|
470
493
|
process.exitCode = 1;
|
|
471
494
|
return;
|
|
472
495
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
496
|
+
// 알 수 없는 pm 값은 조용히 pnpm 으로 되돌리지 않고 사용자에게 알린다
|
|
497
|
+
// (에러가 곧 수리 안내서 · §7.5.3). 이전엔 무시돼 --package-manager npm 이
|
|
498
|
+
// pnpm 으로 설치되던 O-1 표류를 근본 차단한다(결정 167).
|
|
499
|
+
if (parsed.unknownPm !== undefined) {
|
|
500
|
+
process.stderr.write(` ✗ gaon new: 알 수 없는 패키지 매니저 '${parsed.unknownPm}'.\n` +
|
|
501
|
+
` → --package-manager 는 pnpm · npm · yarn 만 지원합니다(기본 pnpm).\n`);
|
|
502
|
+
process.exitCode = 1;
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const name = parsed.name;
|
|
506
|
+
const packageManager = parsed.packageManager;
|
|
476
507
|
void runNewCommand(name, {
|
|
477
508
|
json: argv.includes("--json"),
|
|
478
509
|
skipInstall: argv.includes("--skip-install"),
|
|
@@ -7,7 +7,24 @@ export interface ProjectFile {
|
|
|
7
7
|
export interface ProjectTemplateTokens {
|
|
8
8
|
readonly projectName: string;
|
|
9
9
|
readonly gaonjsVersion: string;
|
|
10
|
+
/**
|
|
11
|
+
* package.json 의 `packageManager` 필드(corepack 핀 · 예 `pnpm@10.27.0`).
|
|
12
|
+
* 미지정 시 blessed 기본 pnpm. 선택한 pm 에 맞춰 채운다(결정 169).
|
|
13
|
+
*/
|
|
14
|
+
readonly packageManager?: string;
|
|
10
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
|
|
18
|
+
* 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
|
|
19
|
+
* 12차 실사용 yarn 파손). 정본 버전 근거:
|
|
20
|
+
* · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
|
|
21
|
+
* · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
|
|
22
|
+
* 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
|
|
23
|
+
* · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
|
|
24
|
+
*/
|
|
25
|
+
export declare const PACKAGE_MANAGER_PINS: Readonly<Record<'pnpm' | 'npm' | 'yarn', string>>;
|
|
26
|
+
/** blessed 기본(pm 미선택 시). */
|
|
27
|
+
export declare const DEFAULT_PACKAGE_MANAGER: string;
|
|
11
28
|
/** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
|
|
12
29
|
export declare function renderTemplate(raw: string, tokens: ProjectTemplateTokens): string;
|
|
13
30
|
/** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
|
package/dist/templates/index.js
CHANGED
|
@@ -12,11 +12,28 @@ import { readFileSync, readdirSync } from 'node:fs';
|
|
|
12
12
|
import { dirname, join, posix, relative, sep } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'project');
|
|
15
|
+
/**
|
|
16
|
+
* pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
|
|
17
|
+
* 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
|
|
18
|
+
* 12차 실사용 yarn 파손). 정본 버전 근거:
|
|
19
|
+
* · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
|
|
20
|
+
* · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
|
|
21
|
+
* 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
|
|
22
|
+
* · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
|
|
23
|
+
*/
|
|
24
|
+
export const PACKAGE_MANAGER_PINS = {
|
|
25
|
+
pnpm: 'pnpm@10.27.0',
|
|
26
|
+
npm: 'npm@10.9.9',
|
|
27
|
+
yarn: 'yarn@1.22.22',
|
|
28
|
+
};
|
|
29
|
+
/** blessed 기본(pm 미선택 시). */
|
|
30
|
+
export const DEFAULT_PACKAGE_MANAGER = PACKAGE_MANAGER_PINS.pnpm;
|
|
15
31
|
/** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
|
|
16
32
|
export function renderTemplate(raw, tokens) {
|
|
17
33
|
return raw
|
|
18
34
|
.replaceAll('{{PROJECT_NAME}}', tokens.projectName)
|
|
19
|
-
.replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
|
|
35
|
+
.replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
|
|
36
|
+
.replaceAll('{{PACKAGE_MANAGER}}', tokens.packageManager ?? DEFAULT_PACKAGE_MANAGER);
|
|
20
37
|
}
|
|
21
38
|
/** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
|
|
22
39
|
export function listTemplateFiles(root = TEMPLATE_DIR) {
|
package/dist/templates/index.ts
CHANGED
|
@@ -23,15 +23,39 @@ export interface ProjectFile {
|
|
|
23
23
|
export interface ProjectTemplateTokens {
|
|
24
24
|
readonly projectName: string
|
|
25
25
|
readonly gaonjsVersion: string
|
|
26
|
+
/**
|
|
27
|
+
* package.json 의 `packageManager` 필드(corepack 핀 · 예 `pnpm@10.27.0`).
|
|
28
|
+
* 미지정 시 blessed 기본 pnpm. 선택한 pm 에 맞춰 채운다(결정 169).
|
|
29
|
+
*/
|
|
30
|
+
readonly packageManager?: string
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'project')
|
|
29
34
|
|
|
35
|
+
/**
|
|
36
|
+
* pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
|
|
37
|
+
* 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
|
|
38
|
+
* 12차 실사용 yarn 파손). 정본 버전 근거:
|
|
39
|
+
* · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
|
|
40
|
+
* · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
|
|
41
|
+
* 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
|
|
42
|
+
* · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
|
|
43
|
+
*/
|
|
44
|
+
export const PACKAGE_MANAGER_PINS: Readonly<Record<'pnpm' | 'npm' | 'yarn', string>> = {
|
|
45
|
+
pnpm: 'pnpm@10.27.0',
|
|
46
|
+
npm: 'npm@10.9.9',
|
|
47
|
+
yarn: 'yarn@1.22.22',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** blessed 기본(pm 미선택 시). */
|
|
51
|
+
export const DEFAULT_PACKAGE_MANAGER = PACKAGE_MANAGER_PINS.pnpm
|
|
52
|
+
|
|
30
53
|
/** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
|
|
31
54
|
export function renderTemplate(raw: string, tokens: ProjectTemplateTokens): string {
|
|
32
55
|
return raw
|
|
33
56
|
.replaceAll('{{PROJECT_NAME}}', tokens.projectName)
|
|
34
57
|
.replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
|
|
58
|
+
.replaceAll('{{PACKAGE_MANAGER}}', tokens.packageManager ?? DEFAULT_PACKAGE_MANAGER)
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
/** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
|
|
@@ -195,7 +195,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
195
195
|
```bash
|
|
196
196
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
|
|
197
197
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
198
|
-
gaon doctor # 정적 검사
|
|
198
|
+
gaon doctor # 정적 검사 25종 (§2.2)
|
|
199
199
|
```
|
|
200
200
|
|
|
201
201
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -85,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
87
|
gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
|
|
88
|
-
gaon doctor # 정적 검사
|
|
88
|
+
gaon doctor # 정적 검사 25종 (상세 AGENTS §2.2)
|
|
89
89
|
npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -664,6 +664,29 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
664
664
|
미리 보여주고, migrate 는 건너뛴 테이블을 크게 알린다(조용한 무시 방지).
|
|
665
665
|
- 마이그레이션은 커넥션별로 돈다(§7 · `--db <키>`).
|
|
666
666
|
|
|
667
|
+
### 10.1 시드 — `domain/seed.ts` · `seed()` (§7)
|
|
668
|
+
|
|
669
|
+
개발·데모용 초기 데이터를 `domain/seed.ts` 한 파일에 선언하고 `gaon db seed` 로
|
|
670
|
+
실행한다. 파일이 존재하면 등록이고, **default export** 가 시드 정의여야 한다.
|
|
671
|
+
|
|
672
|
+
- **시그니처**: `seed(fn: () => Promise<void> | void): SeedDef` — `gaonjs/data`
|
|
673
|
+
에서 import 한다. 본문(`fn`)은 **모델을 그대로** 쓴다 — 모델이 커넥션을 자동
|
|
674
|
+
바인딩하므로(§4.5·§7) 시드는 커넥션을 몰라도 된다.
|
|
675
|
+
- **멱등하게 짠다** — 시드는 재적재에 자주 쓰이므로 여러 번 돌려도 안전해야
|
|
676
|
+
한다(예: `upsert`/존재 확인 후 생성).
|
|
677
|
+
- `gaon db seed` 는 모델 레이어를 거치므로 **프로젝트 로컬로 실행**하는 것이
|
|
678
|
+
정본이다(`npx gaon db seed` · 결정 156 · 위 dual package hazard 노트).
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
// domain/seed.ts
|
|
682
|
+
import { seed } from 'gaonjs/data'
|
|
683
|
+
import { User } from './models/User'
|
|
684
|
+
|
|
685
|
+
export default seed(async () => {
|
|
686
|
+
await User.create({ email: 'admin@example.com', name: 'Admin' })
|
|
687
|
+
})
|
|
688
|
+
```
|
|
689
|
+
|
|
667
690
|
## 정본 예시
|
|
668
691
|
|
|
669
692
|
```ts
|
|
@@ -58,15 +58,32 @@
|
|
|
58
58
|
· 결정 145)를 예시로 깔고, `domain/schema/users.ts` 에 `role` 컬럼을 두라고 안내한다.
|
|
59
59
|
관리자는 직접 만들거나 승격한다(공개 가입 라우트 없음). web 앱은 현행대로 공개 가입 O.
|
|
60
60
|
공개 비-web 앱이 필요하면 `--public` 로 공개 가입을 opt-in 한다.
|
|
61
|
-
- CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
- CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF 강제.
|
|
62
|
+
**토큰은 `<meta>` 태그가 아니라 data-page 공유 prop 으로 온다 (결정 116).**
|
|
63
|
+
프레임웍 Inertia 셸은 `<meta name="csrf-token">` 을 **넣지 않는다** — csrf 는
|
|
64
|
+
모든 렌더에 자동 주입되는 공유 prop 이라 페이지에서 `useShared().csrf` 로 읽는다
|
|
65
|
+
(`packages/vue/src/shared.ts`). 폼은 그 값을 실어 보낸다 — `useForm({ ..., _csrf:
|
|
66
|
+
shared.csrf })` 또는 HTML 폼이 못 보내는 메서드는 헤더로: `router.delete(url, {
|
|
67
|
+
headers: { 'x-csrf-token': shared.csrf } })` (스캐폴드 Login/Signup/Dashboard 가
|
|
68
|
+
이 관례를 그대로 깐다).
|
|
69
|
+
- `api()` 클라이언트도 **같은 data-page csrf 를 자동으로 붙인다 (결정 166).** 세션
|
|
70
|
+
앱에서 상태 변경 JSON 액션(`api('web:posts#tagAdd', ...)` 등)을 불러도 손수 토큰을
|
|
71
|
+
넘길 필요가 없다 — `api()` 가 data-page 의 `props.csrf`(`useShared().csrf` 와 같은
|
|
72
|
+
단일 출처)를 읽어 `X-CSRF-Token` 에 실어 준다(`packages/vue/src/api.ts`). data-page
|
|
73
|
+
가 없거나(비-Inertia) 봉인(seal)이면 레거시 `<meta name="csrf-token">` 로 폴백한다.
|
|
74
|
+
JWT/API 앱은 토큰 인증이라 CSRF 대상이 아니다.
|
|
64
75
|
- **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
|
|
65
76
|
세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
|
|
66
77
|
`app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
|
|
67
78
|
한다 — 폼(POST)을 추가하는 순간 CSRF 가 이미 켜져 있다. 앱에 비-GET
|
|
68
79
|
라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
|
|
69
80
|
`csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
|
|
81
|
+
- **CSRF/세션 실패는 코어가 Inertia-네이티브로 마감한다 (결정 165).** 세션 만료·
|
|
82
|
+
secret 로테이션·장시간 탭으로 CSRF 가 실패하면, Inertia 요청은 raw JSON 403 이 아니라
|
|
83
|
+
**409 + `X-Inertia-Location` 풀 리로드**(새 세션 쿠키+새 토큰) + `flash.error` 안내로
|
|
84
|
+
마감된다 — 앱이 안 깨지고 사용자는 재시도로 성공한다. 415(지원 안 되는 Content-Type)도
|
|
85
|
+
같은 핸들러가 Inertia-네이티브 에러+수리 안내로 마감한다. 비-Inertia(API/JWT)는 종전
|
|
86
|
+
JSON 유지(회귀 없음). 상세는 `agents/web.md` §4.1. 앱은 아무것도 안 한다.
|
|
70
87
|
- JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
|
|
71
88
|
- **인증·인가는 3층이다** (결정 145 · 149):
|
|
72
89
|
- **① 인증 `this.requireAuth()`** = **로그인 여부** — 비로그인이면 401(세션 앱은
|
|
@@ -87,6 +87,43 @@ describe('SendWelcomeMail (실 NATS JetStream)', () => {
|
|
|
87
87
|
- `configureJobs` 는 헬퍼가 대신 해 준다 — 테스트가 부팅 코드를 흉내낼
|
|
88
88
|
필요가 없다.
|
|
89
89
|
|
|
90
|
+
### 4.1 이벤트/리스너 헬퍼 — `expectEventProcessed`
|
|
91
|
+
|
|
92
|
+
이벤트 "emit → 리스너 실 처리" 는 잡의 대응 헬퍼 `expectEventProcessed`
|
|
93
|
+
한 호출로 확증한다 — 임시로 등록 리스너를 실 스트림에 붙여 이 이벤트가
|
|
94
|
+
**리스너 핸들러까지 실행**되는 것을 기다리고 정리한다(`expectJobProcessed`
|
|
95
|
+
대칭). ⚠️ 검증 대상 리스너(`domain/listeners/*.ts`)가 import 되어 레지스트리에
|
|
96
|
+
등록돼 있어야 한다(파일=등록).
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// test/integration/postPublished.integration.test.ts
|
|
100
|
+
import { describe, it } from 'vitest'
|
|
101
|
+
import { connectNats, expectEventProcessed } from 'gaonjs/testing'
|
|
102
|
+
import { PostPublished } from '../../domain/events/postPublished.js'
|
|
103
|
+
import '../../domain/listeners/notifyFollowers.js' // 파일=등록 — import 로 리스너 등록
|
|
104
|
+
|
|
105
|
+
describe('PostPublished (실 NATS JetStream)', () => {
|
|
106
|
+
it('emit 하면 리스너가 처리한다', async () => {
|
|
107
|
+
const nats = await connectNats()
|
|
108
|
+
try {
|
|
109
|
+
await expectEventProcessed(PostPublished, () => PostPublished.emit({ postId: 1n }), { nats })
|
|
110
|
+
} finally {
|
|
111
|
+
await nats.close()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **시그니처** — `expectEventProcessed(event, trigger, { nats, timeoutMs?, listenerId?, maxDeliver? })`.
|
|
118
|
+
`trigger` 는 emit 을 일으키는 임의 함수 — 이벤트 직접 `emit` 도, 컨트롤러·서비스
|
|
119
|
+
경유도 된다. 여러 리스너가 붙었을 때 `listenerId`(= `domain/listeners/` 파일명 ·
|
|
120
|
+
`on(..., { id })`)로 특정 리스너만 좁힌다.
|
|
121
|
+
- 리스너가 재전달을 소진하고 드롭되거나 `timeoutMs`(기본 10초) 안에 처리되지
|
|
122
|
+
않으면 수리 안내(§7.5.3)와 함께 실패한다. 실패 경로를 백오프 계단 없이 빨리
|
|
123
|
+
확증하려면 `maxDeliver: 1`(첫 실패에서 즉시 드롭).
|
|
124
|
+
- `configureEvents` 는 헬퍼가 대신 해 주고, 끝나면 하네스 배선을 복원한다
|
|
125
|
+
(`expectJobProcessed` 와 동형 · 자기 nats 를 `close()` 해도 다음 테스트 무영향).
|
|
126
|
+
|
|
90
127
|
### 5. DB 테스트 격리 — `gaon test` + `test/setup.ts` (결정 111)
|
|
91
128
|
|
|
92
129
|
DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test` 와 스캐폴드
|
|
@@ -39,6 +39,12 @@ export default controller({
|
|
|
39
39
|
`../../../domain/models/<Pascal>.js` 로 참조한다. `@gaonjs/*` (스코프
|
|
40
40
|
이름) 은 내부 패키지 이름 — 앱 코드에서 직접 import 하지 않는다.
|
|
41
41
|
`@inertiajs/vue3` 는 어댑터 내부 의존 — 앱에서 직접 안 쓴다.
|
|
42
|
+
- **직렬화는 자동이다** — JSON 액션 반환값·`this.json(...)`·`this.render` props 는
|
|
43
|
+
응답 경계에서 `serializeProps` 를 통과한다: `bigint`/`Date` → string, **hidden
|
|
44
|
+
컬럼 제외**(§4.2 · 결정 122), 함수 값 제거. 그래서 액션은 모델 Row 를 그대로
|
|
45
|
+
반환해도 안전하다(손 직렬화 불필요). 드물게 응답 경계 **밖**(예: 채널 broadcast
|
|
46
|
+
페이로드, 커스텀 문자열 응답)에서 같은 규칙으로 직렬화하고 싶으면 직접 부른다:
|
|
47
|
+
`import { serializeProps } from 'gaonjs/web'`.
|
|
42
48
|
- **`this.render` 인자** = `<PageFolder>/<Page>` (PascalCase 폴더 · Vue
|
|
43
49
|
파일명과 정합) — 예: `'Posts/Index'` → `apps/<app>/pages/Posts/Index.vue`.
|
|
44
50
|
- **리소스 부재 = `this.notFound()`** — 조회 결과가 없으면 404 를 손으로 만들지
|
|
@@ -233,6 +239,22 @@ async create() {
|
|
|
233
239
|
|
|
234
240
|
순수 JSON/API 앱(X-Inertia 아님·세션 없음)은 기존대로 **422 JSON** 을 받는다.
|
|
235
241
|
|
|
242
|
+
#### 세션/CSRF 실패·415 도 코어가 Inertia-네이티브로 마감한다 (결정 165)
|
|
243
|
+
|
|
244
|
+
폼 검증(결정 109)과 **대칭**으로, 컨트롤러 액션 밖(디스패처 try-catch 밖)에서 나는
|
|
245
|
+
인프라 실패도 앱이 손댈 필요 없이 코어가 마감한다 — 세션 만료·secret 로테이션·장시간
|
|
246
|
+
탭 등 운영 routine 에서 나던 raw JSON 403(앱이 깨지던 지점)을 없앤다:
|
|
247
|
+
|
|
248
|
+
- **세션/CSRF 실패**(세션 만료·CSRF 토큰 불일치) → **409 + `X-Inertia-Location` 풀
|
|
249
|
+
리로드**. Inertia 클라가 새 문서(=새 세션 쿠키 + 새 CSRF 토큰)를 받고, `flash.error`
|
|
250
|
+
로 "세션이 만료돼 다시 시도해 주세요." 안내가 뜬다(레이아웃이 `useShared().flash` 를
|
|
251
|
+
표시하면 자동 · 스캐폴드 기본 레이아웃에 배선됨). 사용자는 재시도로 성공한다.
|
|
252
|
+
- **415(지원 안 되는 Content-Type)** → **Inertia-네이티브 에러 + 수리 안내**. 지원 타입은
|
|
253
|
+
**`application/json` · `multipart/form-data`** 뿐이다 — 폼은 `useForm().post(...)`·
|
|
254
|
+
`router.<메서드>(...)`(gaonjs/vue)로 보내면 Content-Type 이 자동으로 맞는다. 수동
|
|
255
|
+
`fetch` 로 `application/x-www-form-urlencoded` 를 보내면 415 가 난다.
|
|
256
|
+
- **비-Inertia(API/JWT) 요청은 종전 JSON** 마감을 유지한다(회귀 없음).
|
|
257
|
+
|
|
236
258
|
### 4.2 공유 prop 자동 주입 — currentUser·csrf·flash (결정 116·117)
|
|
237
259
|
|
|
238
260
|
디스패처가 **모든** Inertia 렌더에 공유 prop 3종을 자동 주입한다 — 컨트롤러가
|
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
//
|
|
13
13
|
// 결정 96: 앱 내부 이동은 `Link`(선언적) — `<a href="/">` 는 전체 문서
|
|
14
14
|
// 리로드라 SPA 가 깨진다. 외부 URL 만 `<a>`(문서·GitHub).
|
|
15
|
-
import {
|
|
15
|
+
import { computed } from 'vue'
|
|
16
|
+
import { Link, useShared } from 'gaonjs/vue'
|
|
16
17
|
|
|
17
18
|
// package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
|
|
18
19
|
const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
|
|
20
|
+
|
|
21
|
+
// 공유 prop flash(결정 116) 를 앱 전역에서 한 번 표시한다 — 세션 만료 안내(결정 165 ·
|
|
22
|
+
// 코어가 심는 flash.error)나 this.flash('success', ...) 가 어느 페이지에서든 뜬다.
|
|
23
|
+
const shared = useShared()
|
|
24
|
+
const flashError = computed(() => shared.flash.error as string | undefined)
|
|
25
|
+
const flashSuccess = computed(() => shared.flash.success as string | undefined)
|
|
19
26
|
</script>
|
|
20
27
|
|
|
21
28
|
<template>
|
|
@@ -37,6 +44,18 @@ const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
|
|
|
37
44
|
</header>
|
|
38
45
|
|
|
39
46
|
<main class="w-full flex-1">
|
|
47
|
+
<div v-if="flashError || flashSuccess" class="px-6 pt-4">
|
|
48
|
+
<div
|
|
49
|
+
v-if="flashError"
|
|
50
|
+
role="alert"
|
|
51
|
+
class="mx-auto max-w-3xl rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2.5 text-sm text-destructive"
|
|
52
|
+
>{{ flashError }}</div>
|
|
53
|
+
<div
|
|
54
|
+
v-else-if="flashSuccess"
|
|
55
|
+
role="status"
|
|
56
|
+
class="mx-auto max-w-3xl rounded-md border border-primary/30 bg-primary/10 px-4 py-2.5 text-sm text-foreground"
|
|
57
|
+
>{{ flashSuccess }}</div>
|
|
58
|
+
</div>
|
|
40
59
|
<slot />
|
|
41
60
|
</main>
|
|
42
61
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/async": "0.
|
|
30
|
+
"@gaonjs/async": "0.11.0",
|
|
31
|
+
"@gaonjs/mail": "0.2.0",
|
|
31
32
|
"@gaonjs/core": "0.2.1",
|
|
32
|
-
"@gaonjs/config": "0.14.
|
|
33
|
+
"@gaonjs/config": "0.14.1",
|
|
33
34
|
"@gaonjs/i18n": "0.2.0",
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/
|
|
36
|
-
"@gaonjs/web": "0.16.0"
|
|
35
|
+
"@gaonjs/web": "0.17.0",
|
|
36
|
+
"@gaonjs/data": "0.16.0"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|