@gaonjs/cli 0.43.0 → 0.47.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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/db.js +29 -7
  3. package/dist/commands/new.d.ts +2 -0
  4. package/dist/commands/new.js +8 -3
  5. package/dist/db/resolve.d.ts +15 -0
  6. package/dist/db/resolve.js +24 -2
  7. package/dist/doctor/pageprops-destructure.d.ts +2 -2
  8. package/dist/doctor/pageprops-destructure.js +29 -23
  9. package/dist/doctor.js +1 -1
  10. package/dist/index.d.ts +6 -0
  11. package/dist/index.js +18 -2
  12. package/dist/scaffold/job.js +3 -1
  13. package/dist/templates/project/.dockerignore.tpl +3 -0
  14. package/dist/templates/project/.env.example.tpl +1 -1
  15. package/dist/templates/project/AGENTS.md.tpl +2 -1
  16. package/dist/templates/project/CLAUDE.md.tpl +4 -3
  17. package/dist/templates/project/Dockerfile.tpl +6 -1
  18. package/dist/templates/project/agents/async.md.tpl +44 -6
  19. package/dist/templates/project/agents/data.md.tpl +86 -13
  20. package/dist/templates/project/agents/frontend.md.tpl +36 -8
  21. package/dist/templates/project/agents/mail.md.tpl +2 -1
  22. package/dist/templates/project/agents/realtime.md.tpl +26 -2
  23. package/dist/templates/project/agents/seal.md.tpl +9 -1
  24. package/dist/templates/project/agents/security.md.tpl +18 -0
  25. package/dist/templates/project/agents/storage.md.tpl +15 -8
  26. package/dist/templates/project/agents/web.md.tpl +38 -2
  27. package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
  28. package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
  29. package/dist/templates/project/apps/web/main.ts.tpl +1 -1
  30. package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
  31. package/dist/templates/project/docker-compose.yaml.tpl +1 -1
  32. package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
  33. package/dist/templates/project/vite.config.ts.tpl +1 -1
  34. package/dist/work.d.ts +21 -0
  35. package/dist/work.js +45 -1
  36. package/package.json +12 -7
  37. package/dist/templates/index.ts +0 -109
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gaon CLI — `gaon` 명령. 프로젝트 스캐폴드(`gaon new`), 개발 스택 통합(`gaon dev`),
4
4
  제너레이터(`gaon g auth`·scaffold), 마이그레이션(`gaon db …`), 통합 검사
5
- (`gaon check`), 정적 검사(`gaon doctor` · 20종), 운영 부팅(`gaon serve`). 모든
5
+ (`gaon check`), 정적 검사(`gaon doctor` · `--fix`), 운영 부팅(`gaon serve`). 모든
6
6
  명령은 `--json` 출력을 지원합니다.
7
7
 
8
8
  이 패키지는 [`gaonjs`](https://www.npmjs.com/package/gaonjs) 파사드에 포함됩니다 —
@@ -11,7 +11,7 @@ import { runDbDiff } from '../db/diff.js';
11
11
  import { runDbMigrate } from '../db/migrate.js';
12
12
  import { runDbReset } from '../db/reset.js';
13
13
  import { runDbStatus } from '../db/status.js';
14
- import { listConnectionKeys } from '../db/resolve.js';
14
+ import { listConnectionKeysByKind } from '../db/resolve.js';
15
15
  import { runDbSeedCommand } from '../db.js';
16
16
  /** 전 커넥션 순회 대상 서브커맨드(결정 139 · reset 은 파괴적이라 제외 · 단일 유지). */
17
17
  const MULTI_CONN_SUBS = new Set(['diff', 'migrate', 'status', 'seed']);
@@ -57,14 +57,29 @@ export async function runDbCommand(subcommand, opts = {}) {
57
57
  // `--db <키>` 는 단일 좁힘. reset(파괴적)은 항상 단일(main 폴백).
58
58
  const wantsAll = subcommand !== 'reset' && (opts.db === undefined || opts.db === 'all');
59
59
  if (wantsAll && MULTI_CONN_SUBS.has(subcommand)) {
60
- const keys = await listConnectionKeys(cwd, configPath);
61
- // 단일 커넥션이면 종전 출력과 100% 동일(하위 호환).
62
- if (keys.length === 1) {
60
+ // 결정 292: 문서형(mongodb) 커넥션은 마이그레이션이 없어(§7.4) 순회에서 **건너뛴다** —
61
+ // 종전에는 mongo 키가 실패로 계상돼 mongo 커넥션이 하나라도 있으면 항상 exit 1 이었다.
62
+ // `--db <mongo키>` 명시 지정은 아래 단일 경로에서 여전히 fail-loud(resolveDbTarget).
63
+ const { sql: keys, mongo: mongoKeys } = await listConnectionKeysByKind(cwd, configPath);
64
+ if (keys.length === 0) {
65
+ // 문서형만 있는 프로젝트 — 마이그레이션 대상 없음 = 성공 no-op(수리 대상 아님).
66
+ const note = `문서형(mongodb) 커넥션 ${mongoKeys.join(', ')} 은 마이그레이션 대상이 아닙니다(§7.4) — SQL 커넥션 없음, ${subcommand} 할 것 없음.`;
67
+ emit(` ○ ${note}`, {
68
+ command: subcommand,
69
+ connections: [],
70
+ skipped: mongoKeys.map((db) => ({ db, adapter: 'mongodb', reason: 'documental-no-migration' })),
71
+ ok: true,
72
+ results: [],
73
+ });
74
+ return 0;
75
+ }
76
+ // 단일 SQL 커넥션 + 문서형 없음이면 종전 출력과 100% 동일(하위 호환).
77
+ if (keys.length === 1 && mongoKeys.length === 0) {
63
78
  const r = await runOne(keys[0]);
64
79
  emit(r.text, r.json);
65
80
  return r.exitCode;
66
81
  }
67
- return runAcrossConnections(subcommand, keys, runOne, emit, json);
82
+ return runAcrossConnections(subcommand, keys, runOne, emit, json, mongoKeys);
68
83
  }
69
84
  // 단일 커넥션 경로(--db <키> · reset · env-only 폴백) — 종전 동작 그대로.
70
85
  if (subcommand === 'reset' && opts.db === 'all') {
@@ -97,7 +112,7 @@ export async function runDbCommand(subcommand, opts = {}) {
97
112
  * 실패해도 나머지를 계속한다(부분 적용 방지가 아니라 부분 진행 후 전체 보고 — 어느
98
113
  * 커넥션이 성공/실패했는지가 사용자에게 필요). exitCode = 하나라도 실패면 1.
99
114
  */
100
- async function runAcrossConnections(subcommand, keys, runOne, emit, json) {
115
+ async function runAcrossConnections(subcommand, keys, runOne, emit, json, skippedMongo = []) {
101
116
  const results = [];
102
117
  for (const key of keys) {
103
118
  try {
@@ -121,12 +136,19 @@ async function runAcrossConnections(subcommand, keys, runOne, emit, json) {
121
136
  emit('', {
122
137
  command: subcommand,
123
138
  connections: keys,
139
+ // 결정 292: 문서형(mongodb) 커넥션은 순회 대상이 아니다 — skipped 로 명시 보고.
140
+ ...(skippedMongo.length > 0
141
+ ? { skipped: skippedMongo.map((db) => ({ db, adapter: 'mongodb', reason: 'documental-no-migration' })) }
142
+ : {}),
124
143
  ok: failed.length === 0,
125
144
  results: results.map((r) => ({ db: r.db, ok: r.ok, ...r.json })),
126
145
  });
127
146
  return exitCode;
128
147
  }
129
- const header = ` gaon db ${subcommand} · 커넥션 ${keys.length}개 순회: ${keys.join(', ')}`;
148
+ const skippedNote = skippedMongo.length > 0
149
+ ? `\n ○ 문서형(mongodb) 커넥션 건너뜀: ${skippedMongo.join(', ')} — 문서형은 마이그레이션이 없습니다(§7.4 · 인덱스는 Mongoose 스키마 선언).`
150
+ : '';
151
+ const header = ` gaon db ${subcommand} · 커넥션 ${keys.length}개 순회: ${keys.join(', ')}${skippedNote}`;
130
152
  const sections = results.map((r) => ` ── [${r.db}] ${'─'.repeat(Math.max(0, 40 - r.db.length))}\n${r.text}`);
131
153
  const footer = failed.length === 0
132
154
  ? ` ✓ 전 커넥션 완료(${keys.length}개).`
@@ -44,6 +44,8 @@ export interface NewCommandResult {
44
44
  readonly totalMs: number;
45
45
  readonly error?: string;
46
46
  }
47
+ /** 이름 유효성 — npm 패키지명 규칙(단순 부분)만 검사. */
48
+ export declare function validateProjectName(name: string): void;
47
49
  /**
48
50
  * `gaon new <name>` 실행. 파일 생성 → pnpm install → git init 순서.
49
51
  * 각 단계는 옵션으로 스킵 가능. 반환값은 프로세스 종료 코드(0=성공).
@@ -26,7 +26,7 @@ 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 패키지명 규칙(단순 부분)만 검사. */
29
- function validateProjectName(name) {
29
+ export function validateProjectName(name) {
30
30
  if (!name)
31
31
  throw new Error('프로젝트 이름이 비어 있습니다.');
32
32
  if (name.includes('/') || name.includes('\\')) {
@@ -35,8 +35,13 @@ function validateProjectName(name) {
35
35
  if (name.includes('..')) {
36
36
  throw new Error(`프로젝트 이름에 '..' 는 사용할 수 없습니다: ${name}`);
37
37
  }
38
- if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) {
39
- throw new Error(`프로젝트 이름은 영문/숫자/-/_ 시작해 같은 문자만 씁니다: ${name}\n` +
38
+ // 결정 315: 소문자·숫자·하이픈만 — 이름이 compose 프로젝트명(소문자만 유효)
39
+ // MinIO/S3 버킷명(`_`·대문자 불허)으로 그대로 치환되므로(docker-compose.yaml.tpl),
40
+ // 대문자·언더스코어를 받으면 gaon dev 인프라 기동·첫 업로드가 뒤늦게 깨진다.
41
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
42
+ throw new Error(`프로젝트 이름은 소문자 영문/숫자로 시작해 소문자/숫자/하이픈(-)만 씁니다: ${name}\n` +
43
+ ` → 이름은 Docker Compose 프로젝트명과 스토리지 버킷명으로 그대로 쓰여\n` +
44
+ ` 대문자·언더스코어(_)는 인프라 기동·버킷 생성에서 실패합니다.\n` +
40
45
  ` → 예: gaon new my-app · gaon new demo`);
41
46
  }
42
47
  }
@@ -33,4 +33,19 @@ export interface ResolvedDbTarget {
33
33
  * 폴백(GAON_DATABASE_URL 관례) — 단일 커넥션 프로젝트의 종전 동작과 정합.
34
34
  */
35
35
  export declare function listConnectionKeys(cwd: string, configPath?: string): Promise<string[]>;
36
+ /** SQL 키 / 문서형(mongodb) 키 분리 목록 — 전 커넥션 순회의 skip 판단용(결정 292). */
37
+ export interface ConnectionKeysByKind {
38
+ /** SQL(postgres·mysql) 커넥션 키 — diff/migrate/status/seed 순회 대상. */
39
+ readonly sql: string[];
40
+ /** 문서형(mongodb) 커넥션 키 — 마이그레이션 없음(§7.4) · 순회에서 건너뛴다. */
41
+ readonly mongo: string[];
42
+ }
43
+ /**
44
+ * 전 커넥션 순회(결정 139)용으로 커넥션 키를 SQL/문서형으로 갈라 돌려준다(결정 292).
45
+ * 문서형(mongodb)은 마이그레이션이 없어(§7.4) 순회에서 **건너뛰는** 대상이다 —
46
+ * 종전에는 순회가 mongo 키를 실패로 계상해 `gaon db migrate` 가 항상 exit 1 이었다.
47
+ * `--db <mongo키>` 명시 지정은 여전히 resolveDbTarget 이 fail-loud 로 안내한다.
48
+ * config 선언이 없으면(env-only) SQL ['main'] 폴백 — listConnectionKeys 와 정합.
49
+ */
50
+ export declare function listConnectionKeysByKind(cwd: string, configPath?: string): Promise<ConnectionKeysByKind>;
36
51
  export declare function resolveDbTarget(opts: ResolveDbOptions): Promise<ResolvedDbTarget>;
@@ -97,6 +97,28 @@ export async function listConnectionKeys(cwd, configPath) {
97
97
  const keys = Object.keys(config.db ?? {});
98
98
  return keys.length ? keys : ['main'];
99
99
  }
100
+ /**
101
+ * 전 커넥션 순회(결정 139)용으로 커넥션 키를 SQL/문서형으로 갈라 돌려준다(결정 292).
102
+ * 문서형(mongodb)은 마이그레이션이 없어(§7.4) 순회에서 **건너뛰는** 대상이다 —
103
+ * 종전에는 순회가 mongo 키를 실패로 계상해 `gaon db migrate` 가 항상 exit 1 이었다.
104
+ * `--db <mongo키>` 명시 지정은 여전히 resolveDbTarget 이 fail-loud 로 안내한다.
105
+ * config 선언이 없으면(env-only) SQL ['main'] 폴백 — listConnectionKeys 와 정합.
106
+ */
107
+ export async function listConnectionKeysByKind(cwd, configPath) {
108
+ const { config } = await loadDbConfig(resolvePath(cwd), configPath);
109
+ const entries = Object.entries(config.db ?? {});
110
+ if (entries.length === 0)
111
+ return { sql: ['main'], mongo: [] };
112
+ const sql = [];
113
+ const mongo = [];
114
+ for (const [key, cfg] of entries) {
115
+ if (cfg?.adapter === 'mongodb')
116
+ mongo.push(key);
117
+ else
118
+ sql.push(key);
119
+ }
120
+ return { sql, mongo };
121
+ }
100
122
  export async function resolveDbTarget(opts) {
101
123
  const cwd = resolvePath(opts.cwd);
102
124
  // 사용자가 --config <path> 를 준 경우 그 경로에서 default export 를 로드한다.
@@ -132,8 +154,8 @@ export async function resolveDbTarget(opts) {
132
154
  // introspect)로 흘리지 않고 명확히 안내한다. 몽고 인덱스는 Mongoose 스키마 선언으로 관리한다.
133
155
  if (connCfg.adapter === 'mongodb') {
134
156
  throw new Error(`[gaon db] 커넥션 '${dbKey}' 은 문서형(mongodb)입니다 — 문서형은 마이그레이션이 없습니다(§7.4).\n` +
135
- ` → 몽고 인덱스는 Mongoose 스키마 선언(index:true / schema.index())으로 관리합니다(dev 자동 ensureIndexes).\n` +
136
- ` → gaon db diff/migrate 는 SQL 커넥션(postgres·mysql)에만 실행하세요.`);
157
+ ` → 몽고 인덱스는 Mongoose 스키마 선언(index:true / schema.index())으로 관리합니다(mongoose autoIndex 기본값이 부팅 시 보장).\n` +
158
+ ` → gaon db diff/migrate 는 SQL 커넥션(postgres·mysql)에만 실행하세요(키 생략 시 순회가 문서형을 자동으로 건너뜁니다 · 결정 292).`);
137
159
  }
138
160
  const db = createDb(connCfg);
139
161
  registerConnection(dbKey, db, connCfg.adapter);
@@ -1,5 +1,5 @@
1
1
  import type { RuleReport } from './types.js';
2
- /** 소스에 pageProps() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
2
+ /** 소스에 pageProps()/useShared() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
3
3
  export declare function usesPagePropsDestructure(source: string): boolean;
4
- /** apps/ 의 .vue 를 훑어 pageProps() 구조분해를 경고로 낸다. */
4
+ /** apps/ 의 .vue + .ts(컴포저블 등)를 훑어 pageProps()/useShared() 구조분해를 경고로 낸다. */
5
5
  export declare function checkPagePropsDestructure(cwd: string): Promise<RuleReport>;
@@ -1,11 +1,16 @@
1
- // @gaonjs/cli · doctor · pageProps() 구조분해 검출 (결정 99 · 경고)
1
+ // @gaonjs/cli · doctor · pageProps()/useShared() 구조분해 검출 (결정 99 · 302 · 경고)
2
2
  //
3
- // `pageProps()` 는 반응형 프록시다 — 같은 페이지로의 리다이렉트/리로드로 서버가
4
- // 새 props 를 주면(댓글 작성·삭제 등) 재마운트 없이 화면이 즉시 갱신된다. 하지만
5
- // `const { posts } = pageProps<...>()` 처럼 **구조분해**하면 순간 값을 한 번
6
- // 읽어 반응성이 끊긴다(Vue defineProps 구조분해하면 되는 것과 같은 함정 ·
7
- // 실사용 블로그에서 preserveState:false 강제 리로드 우회로 나타난 근본 원인).
8
- // 검사가 구조분해를 경고로 잡는다. 판정은 소스 텍스트 기반(주석 제외).
3
+ // `pageProps()`·`useShared()` 는 반응형 프록시다 — 같은 페이지로의 리다이렉트/
4
+ // 리로드로 서버가 새 props 를 주면(댓글 작성·삭제·로그인 등) 재마운트 없이 화면이
5
+ // 즉시 갱신된다. 하지만 `const { posts } = pageProps<...>()`·`const { csrf } =
6
+ // useShared()` 처럼 **구조분해**하면 순간 값을 읽어 반응성이 끊긴다
7
+ // (Vue defineProps 구조분해하면 되는 것과 같은 함정 · 첫 실사용 블로그에서
8
+ // preserveState:false 강제 리로드 우회로 나타난 근본 원인). 검사가 구조분해를
9
+ // 경고로 잡는다. 판정은 소스 텍스트 기반(주석 제외).
10
+ //
11
+ // 결정 302: 문서(agents/frontend.md)는 useShared 구조분해도 같은 함정으로 경고해
12
+ // 왔는데 검사는 pageProps·`.vue` 만 봤다 — useShared 를 정규식에 더하고, pageProps/
13
+ // useShared 사용이 허용되는 앱 전용 컴포저블(`apps/**/*.ts`)까지 순회를 넓힌다.
9
14
  //
10
15
  // 오탐 방지: `const props = pageProps<...>()`(변수 바인딩)는 정상이므로 건드리지
11
16
  // 않는다 — 여는 중괄호 `{` 로 시작하는 구조분해 바인딩만 잡는다.
@@ -20,44 +25,45 @@ function stripCommentsKeepLines(source) {
20
25
  .replace(/<!--[\s\S]*?-->/g, blank)
21
26
  .replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
22
27
  }
23
- // `const|let|var { ... } = pageProps` — 구조분해 바인딩의 초기화가 pageProps 호출.
24
- // 제네릭 인자(`<'web:posts#index'>`)·공백·중첩 중괄호는 허용하되, `{` 로 시작하는
25
- // 구조분해만 잡는다(변수 바인딩 `const props =` 은 제외).
26
- const DESTRUCTURE_RE = /\b(?:const|let|var)\s*\{[^}]*\}\s*=\s*pageProps\b/g;
27
- /** 소스에 pageProps() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
28
+ // `const|let|var { ... } = pageProps|useShared` — 구조분해 바인딩의 초기화가 반응형
29
+ // 프록시 호출. 제네릭 인자(`<'web:posts#index'>`)·공백·중첩 중괄호는 허용하되,
30
+ // `{` 로 시작하는 구조분해만 잡는다(변수 바인딩 `const props =` 은 제외 · 결정 302).
31
+ const DESTRUCTURE_RE = /\b(?:const|let|var)\s*\{[^}]*\}\s*=\s*(pageProps|useShared)\b/g;
32
+ /** 소스에 pageProps()/useShared() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
28
33
  export function usesPagePropsDestructure(source) {
29
34
  DESTRUCTURE_RE.lastIndex = 0;
30
35
  return DESTRUCTURE_RE.test(stripCommentsKeepLines(source));
31
36
  }
32
- /** apps/ 의 .vue 를 훑어 pageProps() 구조분해를 경고로 낸다. */
37
+ /** apps/ 의 .vue + .ts(컴포저블 등)를 훑어 pageProps()/useShared() 구조분해를 경고로 낸다. */
33
38
  export async function checkPagePropsDestructure(cwd) {
34
39
  const appsDir = join(cwd, 'apps');
35
40
  const issues = [];
36
- for (const abs of await walkVue(appsDir)) {
41
+ for (const abs of await walkFrontendSources(appsDir)) {
37
42
  const source = await readFile(abs, 'utf8').catch(() => '');
38
43
  const stripped = stripCommentsKeepLines(source);
39
44
  const rel = relative(cwd, abs);
40
45
  for (const m of stripped.matchAll(DESTRUCTURE_RE)) {
46
+ const callee = m[1] ?? 'pageProps';
41
47
  const line = stripped.slice(0, m.index ?? 0).split('\n').length;
42
48
  issues.push({
43
49
  rule: 'pageprops-destructure',
44
50
  level: 'warning',
45
51
  file: rel,
46
52
  line,
47
- message: `pageProps() 구조분해 발견: ${rel}:${line} 이 \`const { … } = pageProps(…)\` 로 ` +
48
- `props 구조분해합니다. pageProps() 는 반응형 프록시라, 구조분해하면 그 순간 값을 ` +
49
- `한 번 읽어 반응성이 끊깁니다 — 같은 페이지로의 리다이렉트/리로드(댓글 작성·삭제 등) ` +
50
- `후 새 데이터가 화면에 반영되지 않습니다(결정 99).\n` +
51
- `→ 변수로 받아 속성으로 접근하세요: \`const props = pageProps<''>()\` 후 ` +
52
- `\`props.posts\`. (Vue defineProps 를 구조분해하면 안 되는 것과 같은 이유입니다.)`,
53
+ message: `${callee}() 구조분해 발견: ${rel}:${line} 이 \`const { … } = ${callee}(…)\` 로 ` +
54
+ `값을 구조분해합니다. ${callee}() 는 반응형 프록시라, 구조분해하면 그 순간 값을 ` +
55
+ `한 번 읽어 반응성이 끊깁니다 — 같은 페이지로의 리다이렉트/리로드(댓글 작성·삭제·로그인 등) ` +
56
+ `후 새 데이터가 화면에 반영되지 않습니다(결정 99 · 302).\n` +
57
+ `→ 변수로 받아 속성으로 접근하세요: \`const v = ${callee}(…)\` 후 ` +
58
+ `\`v.<필드>\`. (Vue defineProps 를 구조분해하면 안 되는 것과 같은 이유입니다.)`,
53
59
  detail: { file: rel, line },
54
60
  });
55
61
  }
56
62
  }
57
63
  return { rule: 'pageprops-destructure', issues };
58
64
  }
59
- /** apps/ 하위 .vue 절대경로. */
60
- async function walkVue(dir) {
65
+ /** apps/ 하위 .vue·.ts 절대경로(결정 302 — 컴포저블 .ts 포함 · 생성 파일 .gaon 제외). */
66
+ async function walkFrontendSources(dir) {
61
67
  const out = [];
62
68
  const walk = async (d) => {
63
69
  let entries;
@@ -74,7 +80,7 @@ async function walkVue(dir) {
74
80
  continue;
75
81
  await walk(abs);
76
82
  }
77
- else if (e.isFile() && e.name.endsWith('.vue')) {
83
+ else if (e.isFile() && (e.name.endsWith('.vue') || (e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')))) {
78
84
  out.push(abs);
79
85
  }
80
86
  }
package/dist/doctor.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * 17) method-override (결정 89 · _method HTTP 스푸핑 hack 경고)
22
22
  * 18) csrf-wiring (결정 93 · 비-GET 라우트 + session 미배선 = CSRF 무방비 경고)
23
23
  * 19) internal-anchor (결정 96 · 앱 내부 이동 일반 <a> = 풀 리로드 경고)
24
- * 20) pageprops-destructure (결정 99 · pageProps() 구조분해 = 반응성 끊김 경고)
24
+ * 20) pageprops-destructure (결정 99·302 · pageProps()/useShared() 구조분해 = 반응성 끊김 경고 · .vue+.ts)
25
25
  * 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
26
26
  * 22) page-layout-breakpoint (결정 107 · 페이지 레이아웃 브레이크포인트 직접 사용 = 안내 경고)
27
27
  * 23) link-button-nesting (결정 113 · Link 로 Button 감싸기 = <a><button> 중첩 경고)
package/dist/index.d.ts CHANGED
@@ -64,6 +64,12 @@ export interface ParsedNewArgs {
64
64
  readonly packageManager?: "pnpm" | "npm" | "yarn";
65
65
  /** pm 값이 주어졌으나 pnpm·npm·yarn 이 아닐 때 그 원문(에러 안내용). */
66
66
  readonly unknownPm?: string;
67
+ /**
68
+ * 결정 314: 파사드 버전 스펙(예 `^1.21.0`). create-gaon 이 자기 발행 메타에서
69
+ * 해석해 주입한다 — npx 설치 트리엔 gaonjs 패키지가 없어 상향 탐색(결정 242)이
70
+ * fail-loud 로 죽기 때문. 미지정이면 기존 자동 감지 경로 그대로.
71
+ */
72
+ readonly gaonjsVersion?: string;
67
73
  }
68
74
  /**
69
75
  * `gaon new <name>` 의 인자를 파싱한다.
package/dist/index.js CHANGED
@@ -172,12 +172,22 @@ export function parseDoctorChecks(argv) {
172
172
  export function parseNewArgs(rest) {
173
173
  let name;
174
174
  let pmRaw;
175
+ let gaonjsVersion;
175
176
  for (let i = 0; i < rest.length; i++) {
176
177
  const a = rest[i];
177
178
  if (a === "--package-manager" || a === "--pm") {
178
179
  pmRaw = rest[++i];
179
180
  continue;
180
181
  }
182
+ // 결정 314: 값을 취하는 플래그라 pm 과 같은 이유로 값을 건너뛴다(이름 오인 방지).
183
+ if (a === "--gaonjs-version") {
184
+ gaonjsVersion = rest[++i];
185
+ continue;
186
+ }
187
+ if (a.startsWith("--gaonjs-version=")) {
188
+ gaonjsVersion = a.slice("--gaonjs-version=".length);
189
+ continue;
190
+ }
181
191
  const eqPrefix = a.startsWith("--package-manager=")
182
192
  ? "--package-manager="
183
193
  : a.startsWith("--pm=")
@@ -192,10 +202,15 @@ export function parseNewArgs(rest) {
192
202
  if (name === undefined)
193
203
  name = a;
194
204
  }
205
+ const version = gaonjsVersion === undefined || gaonjsVersion === "" ? undefined : gaonjsVersion;
195
206
  if (pmRaw === "pnpm" || pmRaw === "npm" || pmRaw === "yarn") {
196
- return { name, packageManager: pmRaw };
207
+ return { name, packageManager: pmRaw, gaonjsVersion: version };
197
208
  }
198
- return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
209
+ return {
210
+ name,
211
+ unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw,
212
+ gaonjsVersion: version,
213
+ };
199
214
  }
200
215
  /**
201
216
  * `gaon db <sub>` 의 인자를 파싱한다(argv = 전체 · argv[0]='db', argv[1]=sub).
@@ -588,6 +603,7 @@ export function runCli(argv, opts = {}) {
588
603
  skipInstall: argv.includes("--skip-install"),
589
604
  skipGit: argv.includes("--skip-git"),
590
605
  packageManager,
606
+ gaonjsVersion: parsed.gaonjsVersion,
591
607
  })
592
608
  .then((code) => {
593
609
  process.exitCode = code;
@@ -67,7 +67,9 @@ export function jobTestScaffold(pascalName) {
67
67
  ``,
68
68
  `describe('${pascal} (실 NATS JetStream)', () => {`,
69
69
  ` it('발행한 잡이 워커에서 처리된다', async () => {`,
70
- ` const nats = await connectNats(process.env.NATS_URL ?? 'nats://localhost:4222')`,
70
+ // 결정 317: 무인자 connectNats 는 옵션 객체만 받는다(문자열은 조용히 무시됐다).
71
+ // 접속지는 NATS_URL env 폴백이 정본이다(agents/testing.md 와 동일).
72
+ ` const nats = await connectNats()`,
71
73
  ` try {`,
72
74
  ` await expectJobProcessed(${camel}, () => ${camel}.later({ id: 'test' }), { nats })`,
73
75
  ` } finally {`,
@@ -8,5 +8,8 @@ dist
8
8
  .git
9
9
  .env
10
10
  .env.*
11
+ # .env.example 은 예외로 포함한다 — gaon build 는 .gaon/env.d.ts 생성에 .env 가
12
+ # 필수(결정 198)라, 빌드 스테이지가 이를 복제해 임시 .env 를 만든다(결정 313).
13
+ !.env.example
11
14
  npm-debug.log*
12
15
  *.log
@@ -1,5 +1,5 @@
1
1
  # {{PROJECT_NAME}} 환경 변수 — gaon new 가 .env 로 자동 복제(결정 198). 값만 채워 쓰세요.
2
- # gaon.config.ts env('KEY')참조된다. gaon dev · gaon serve 가 자동 로드.
2
+ # gaon.config.ts process.env.KEY 로 참조한다. gaon dev · gaon serve 가 자동 로드.
3
3
 
4
4
  # DB — docker-compose.yaml 의 postgres 서비스와 정합.
5
5
  DATABASE_URL=postgres://{{PROJECT_NAME}}:{{PROJECT_NAME}}@127.0.0.1:5432/{{PROJECT_NAME}}_dev
@@ -132,7 +132,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
132
132
  17. `method-override` — `_method` HTTP 메서드 스푸핑 hack(Gaon 미지원 · router.delete 를 쓰라) (결정 89 · 경고)
133
133
  18. `csrf-wiring` — 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 `app.config.ts` 에 session 미배선 = CSRF 무방비 (결정 93 · 경고)
134
134
  19. `internal-anchor` — 앱 내부 경로 일반 `<a href="/...">`(풀 리로드로 SPA 파손 · `Link`/`router.visit` 를 쓰라 · 외부 URL·`target="_blank"` 는 제외) (결정 96 · 경고)
135
- 20. `pageprops-destructure` — `const { x } = pageProps(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라) (결정 99 · 경고)
135
+ 20. `pageprops-destructure` — `const { x } = pageProps(…)`/`useShared(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라 · `.vue`+앱 `.ts` 공통) (결정 99 · 302 · 경고)
136
136
  21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
137
137
  22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
138
138
  23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
@@ -252,6 +252,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
252
252
  | `@gaonjs/core` | 메타데이터 · 직렬화 프리미티브(Hidden) |
253
253
  | `@gaonjs/mail` / `storage` / `i18n` | 메일 · 파일 스토리지 · 다국어 |
254
254
  | `@gaonjs/seal` | 페이로드 봉인(선택 플러그인 · `agents/seal.md`) |
255
+ | `@gaonjs/adapter-mongo` | MongoDB 문서형 `collection()` 어댑터(선택 · mongoose optional peer · `agents/data.md`) |
255
256
 
256
257
  ## 6. 원칙 · 엄수
257
258
 
@@ -9,7 +9,7 @@
9
9
 
10
10
  Gaon 프레임웍 문서: https://gaonjs.dev
11
11
 
12
- - 설계 정본(v0.15) · errata E-1(파사드 = `gaonjs`) · E-3(JSON 액션 +
12
+ - 설계 정본(v0.17) · errata E-1(파사드 = `gaonjs`) · E-3(JSON 액션 +
13
13
  `api()`) · E-4(컬럼 확장) · E-5(컴포저블·레이아웃)
14
14
  - 앱 내 One Way(§1) — 선택지가 있는 것을 만들지 않는다. 하나로 정한다.
15
15
 
@@ -17,8 +17,9 @@ Gaon 프레임웍 문서: https://gaonjs.dev
17
17
 
18
18
  1. **TypeScript 전용.** JS 파일 추가 금지. 데코레이터 금지 — 함수·객체
19
19
  스타일(`model()`·`controller()`·`job()`) 만 쓴다.
20
- 2. **`.gaon/` 자동 생성 파일 편집 금지.** `routes.d.ts`·`tables.d.ts`
21
- 는 `gaon check` / `gaon dev` 가 재생성한다.
20
+ 2. **`.gaon/` 자동 생성 파일 편집 금지.** `routes.d.ts`·`routes.manifest.ts
21
+ `tables.d.ts`·`messages.d.ts`·`env.d.ts` 는 `gaon check` / `gaon dev` /
22
+ `gaon gen` 이 재생성한다.
22
23
  3. **의존 방향 4규칙**(doctor 강제): 앱→domain 허용 · domain→앱 금지 ·
23
24
  앱→앱 금지 · 앱→shared 허용(shared 는 앱 import 금지, domain 은
24
25
  타입 import 만).
@@ -18,7 +18,12 @@ RUN pnpm install --frozen-lockfile
18
18
  FROM base AS build
19
19
  COPY --from=deps /app/node_modules ./node_modules
20
20
  COPY . .
21
- RUN pnpm build
21
+ # gaon build 는 .gaon/env.d.ts 생성에 .env 가 필수(결정 198)인데 .env 는 이미지에
22
+ # 넣지 않는다(.dockerignore) — .env.example 을 임시 복제해 빌드하고 즉시 지운다.
23
+ # 지우는 이유: example 의 placeholder(SESSION_SECRET 등)가 런타임에 남으면 compose
24
+ # 가 env 를 안 넘겼을 때 fail-loud 검증을 조용히 통과시킨다(결정 313). 런타임 env
25
+ # 는 compose.prod.yaml 의 environment 가 단일 소스다.
26
+ RUN cp .env.example .env && pnpm build && rm -f .env
22
27
 
23
28
  # 3) 런타임 — 소스 + 번들 + 의존을 그대로 실행.
24
29
  FROM base AS runtime
@@ -175,6 +175,18 @@ await OrderPlaced.emit({ orderId: 1n })
175
175
 
176
176
  여러 리스너가 같은 이벤트를 durable 컨슈머로 구독하며, 각자 재시도된다.
177
177
 
178
+ **리스너 재시도·폐기 계약 (결정 308 명문화).** 리스너 실패는 잡보다 단순하게
179
+ 다룬다 — **DLQ 가 없다**:
180
+
181
+ - 실패하면 백오프(잡과 같은 곡선 `[1s, 5s, 30s, 5m, 1h]`)로 재전달되고, 최대
182
+ 재전달(기본 6 · 최초 포함) 소진 시 **영구 폐기**된다. 즉 계속 실패하는 이벤트는
183
+ 약 **1시간 36분** 뒤 사라진다 — `gaon work` 가 `✗ 이벤트 폐기` 로 신호한다
184
+ (결정 308 · 이전엔 human 모드 무신호). 놓치면 안 되는 처리는 리스너에서 잡을
185
+ 발행해(`.later()`) 잡의 재시도·DLQ 배터리로 넘긴다.
186
+ - **리스너별 순차 처리는 보장되지 않는다** — 재시도 대기 중 다음 이벤트가 먼저
187
+ 처리될 수 있고, 드물게 같은 리스너의 두 이벤트가 겹칠 수 있다. 순서·중복에
188
+ 기대지 말고 **멱등**하게 짠다(at-least-once 계약과 동일 축).
189
+
178
190
  ### 4. 아웃박스 (트랜잭션 정합)
179
191
 
180
192
  이벤트를 DB 트랜잭션과 **원자적으로** 발행하려면 아웃박스를 쓴다.
@@ -211,10 +223,17 @@ export const PlaceOrder = service(async (input: { name: string }) => {
211
223
  - 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
212
224
  기동 시 보장된다(결정 144 · nats 설정이 있을 때).
213
225
  - 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
214
- (결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `runWork()`
215
- 프로그래매틱 옵션 `outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(`gaon
216
- work` CLI 플래그가 아니다 · 운영 상세는 `docs/guides/operations.md`). 미발행 행은
217
- 절대 삭제되지 않는다.
226
+ (결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격·폴링 주기는 env
227
+ `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`
228
+ 조정한다(결정 312 · `GAON_WORKER_*` 대칭 · `gaon work` 가 읽는다). 프로그래매틱
229
+ 경로는 `runWork()` 의 `outboxRetentionMs`·`outboxPurgeIntervalMs`·`relayPollMs`.
230
+ 미발행 행은 절대 삭제되지 않는다.
231
+ - **발행 실패는 행 단위로 격리된다(결정 306).** 한 행의 publish 가 실패해도(예:
232
+ 페이로드가 NATS `max_payload` 1MiB 초과) 그 행만 미발행으로 남아 재시도되고,
233
+ 뒤 행들은 정상 발행된다 — 한 행이 아웃박스 전체를 조용히 세우지 않는다. 실패는
234
+ `gaon work` 에 `⚠ 아웃박스 릴레이 오류` 로 신호된다(행 id 별 60s 스로틀).
235
+ 이벤트 페이로드는 1MiB 미만으로 유지한다 — 큰 데이터는 본문 대신 id 를 실어
236
+ 리스너가 조회하게 한다.
218
237
 
219
238
  ### 5. 스케줄러
220
239
 
@@ -242,6 +261,18 @@ export default schedule((s) => {
242
261
  | `s.daily.at('HH:MM', Job)` | 매일 지정 시각 |
243
262
  | `s.cron('분 시 일 월 요일', Job)` | 5필드 크론 표현식 |
244
263
 
264
+ - **스케줄 대상 잡은 무인자여야 한다(결정 310).** 스케줄러는 인자 없이 발화하므로
265
+ 인자 필수 잡(`job(async (userId: bigint) => ...)`)을 등록하면 **컴파일 에러**로
266
+ 거부된다(이전엔 통과해 `undefined` 인자로 도는 무신호 버그였다). 인자가 필요하면
267
+ 인자를 안에서 결정하는 무인자 잡으로 감싼다(선택 인자 `tag?: string` 는 허용).
268
+ - **크론 부분집합 주의** — 스텝(`/n`)은 범위(`*`·`a-b`)에만 의미가 있다:
269
+ `*/2`·`1-9/2` 는 스텝이고, `5/2` 는 `{5}` 단일 값으로 해석된다(Vixie 계열의
270
+ `5/2`=5부터 스텝 확장은 지원하지 않음). 초 단위·`?`·`L`·`#` 도 범위 밖(§5 서두).
271
+ - **`s.every` 는 프로세스 정지를 따라잡는다** — 절전·일시정지 후 재개하면 밀린
272
+ 주기만큼 연속 발화(캐치업 버스트)할 수 있다. 틱당 정확히 1회가 필요하면
273
+ `s.cron`(분 경계 1회)을 쓰거나 잡을 멱등 + `lock(key, fn, { onBusy: 'skip' })`
274
+ 으로 지킨다(§중첩 방지).
275
+
245
276
  #### 리더 선출 계약 (정본)
246
277
 
247
278
  여러 `gaon work` 인스턴스를 HA 로 띄워도 스케줄이 중복 발행되지 않는 이유:
@@ -401,8 +432,11 @@ async create() {
401
432
  소속 · 어느 앱에서든 큐잉).
402
433
  - **`export const <Pascal> = job(...)`** — export 없이 정의만 하면
403
434
  컨트롤러가 import 해 `.later()` 를 부를 수 없다.
404
- - **스케줄 대상은 항상 잡** — `s.every('10m', async () => ...)` 인라인
405
- 함수 금지.
435
+ - **스케줄 대상은 항상 · 무인자** — `s.every('10m', async () => ...)` 인라인
436
+ 함수 금지. 인자 필수 잡은 컴파일 에러로 거부된다(결정 310 · §5).
437
+ - **계속 실패하는 리스너는 이벤트를 잃는다** — 리스너는 DLQ 가 없어 재전달
438
+ 소진(기본 6회 · 약 1h36m) 후 영구 폐기된다(§3 계약 · `gaon work` 가 `✗ 이벤트
439
+ 폐기` 로 신호). 유실 불가 처리는 리스너에서 잡으로 넘긴다.
406
440
  - **커밋 전 발행 주의** — 트랜잭션 안에서 DB 확정 후에만 나가야 하는
407
441
  발행은 `afterCommit()` 또는 아웃박스로.
408
442
  - **테스트에서 NATS 목업 금지** (§9) — 실 JetStream 에 접속한다
@@ -428,4 +462,8 @@ async create() {
428
462
  | 결정 233 | 크론 리더 페일오버 중복 발행 dedupe (결정론적 dedupe 키 + JetStream 중복 윈도우로 틱당 1회 발행 수렴 · §5) |
429
463
  | 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
430
464
  | 결정 258 | 워커 소비 루프 복원력(§1) — 재시도/DLQ 발행이 NATS 순단으로 실패해도 큐 소비가 멈추지 않음(nak 재전달 백스톱 · 잡 유실 방지 · 실패 로그) |
465
+ | 결정 306 | 아웃박스 발행 실패 행 격리(§4) — poison 행(페이로드 상한 초과 등)이 배치 전체를 세우지 않음 · 실패 행은 미발행 유지(유실 없음·재시도) · `relay-error` 이벤트로 관측 |
466
+ | 결정 308 | `gaon work` human 신호 확장(§3) — 리스너 폐기(`✗ 이벤트 폐기`)·워커 인프라 오류·재시도가 기본 모드에서 무신호이던 갭 봉합 + 리스너 재시도·폐기 계약(DLQ 없음·~1h36m) 명문화 |
467
+ | 결정 310 | 스케줄 대상 잡 무인자 가드(§5) — 인자 필수 잡 등록을 컴파일 타임 거부(메서드 bivariance 로 통과해 `undefined` 인자 발화하던 구멍 차단) |
468
+ | 결정 312 | 아웃박스 릴레이 env 튜닝(§4) — `GAON_OUTBOX_RETENTION_MS`·`GAON_OUTBOX_PURGE_INTERVAL_MS`·`GAON_OUTBOX_RELAY_POLL_MS`(`GAON_WORKER_*` 대칭) |
431
469
  | §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |