@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.
@@ -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 지정 시 그 단계만
@@ -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 관례를 그대로 재사용한다 `pnpm typecheck` · `pnpm vue-tsc`
6
- * · `pnpm build`. 스크립트가 없으면 폴백으로 로컬 바이너리를 직접 부른다
7
- * (`node_modules/.bin/tsc --noEmit` · `node_modules/.bin/vue-tsc --noEmit`
8
- * · `pnpm -r build` → `npm run build`).
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 (pnpm typecheck 또는 tsc --noEmit)
12
- * 2) vue-tsc (pnpm vue-tsc 또는 vue-tsc --noEmit)
13
- * 3) build (pnpm 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
- const cmd = 'pnpm';
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 과
@@ -8,7 +8,13 @@ export interface NewCommandOptions {
8
8
  readonly skipInstall?: boolean;
9
9
  /** git init · 첫 커밋 스킵(테스트·기존 git 저장소에 삽입). */
10
10
  readonly skipGit?: boolean;
11
- /** 패키지 매니저. 기본 pnpm(모노레포 관례 정합). */
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)
@@ -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
- const files = renderProjectFiles({ projectName: name, gaonjsVersion });
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
- const ALL_RULES = [
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',
@@ -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
- lines.push(` → ${prefix}/registration/new 회원가입 · ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
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 · --pm <이름>)",
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
- const known = [
150
- "response-mixing",
151
- "n-plus-one",
152
- "dependency-direction",
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
- let name;
464
- for (const a of rest) {
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
- const pmIdx = rest.indexOf("--pm");
474
- const pmRaw = pmIdx >= 0 ? rest[pmIdx + 1] : undefined;
475
- const packageManager = pmRaw === "pnpm" || pmRaw === "npm" || pmRaw === "yarn" ? pmRaw : undefined;
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 경로 · 정렬). */
@@ -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) {
@@ -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 # 정적 검사 24종 (§2.2)
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 # 정적 검사 24종 (상세 AGENTS §2.2)
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
- 강제. `api()` 클라이언트는 `<meta name="csrf-token">` 자동으로
63
- 읽어 `X-CSRF-Token` 헤더에 붙인다 (`packages/vue/src/api.ts:184`).
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 { Link } from 'gaonjs/vue'
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
 
@@ -3,7 +3,7 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
- "packageManager": "pnpm@10.27.0",
6
+ "packageManager": "{{PACKAGE_MANAGER}}",
7
7
  "engines": {
8
8
  "node": ">=22"
9
9
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.34.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.10.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.0",
33
+ "@gaonjs/config": "0.14.1",
33
34
  "@gaonjs/i18n": "0.2.0",
34
- "@gaonjs/data": "0.16.0",
35
- "@gaonjs/mail": "0.2.0",
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})\""