@gaonjs/cli 0.41.7 → 0.42.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/check.js +6 -4
- package/dist/commands/test.d.ts +8 -0
- package/dist/commands/test.js +60 -17
- package/dist/db/journal.d.ts +8 -1
- package/dist/db/journal.js +9 -2
- package/dist/index.d.ts +11 -0
- package/dist/index.js +49 -11
- package/dist/mcp/tools.d.ts +9 -5
- package/dist/mcp/tools.js +18 -35
- package/dist/templates/project/AGENTS.md.tpl +8 -5
- package/dist/templates/project/agents/async.md.tpl +12 -5
- package/dist/templates/project/agents/data.md.tpl +6 -6
- package/dist/templates/project/agents/frontend.md.tpl +13 -0
- package/dist/templates/project/agents/i18n.md.tpl +28 -10
- package/dist/templates/project/agents/realtime.md.tpl +14 -7
- package/dist/templates/project/agents/seal.md.tpl +2 -2
- package/dist/templates/project/agents/security.md.tpl +7 -0
- package/dist/templates/project/agents/testing.md.tpl +3 -0
- package/dist/templates/project/agents/web.md.tpl +11 -3
- package/dist/templates/project/package.json.tpl +1 -1
- package/package.json +7 -7
package/dist/commands/check.js
CHANGED
|
@@ -30,7 +30,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
30
30
|
import { join } from 'node:path';
|
|
31
31
|
import { generateTablesDts } from '@gaonjs/data';
|
|
32
32
|
import { generateRoutesDts } from '@gaonjs/web';
|
|
33
|
-
import {
|
|
33
|
+
import { computeDoctorResult } from '../doctor.js';
|
|
34
34
|
import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
|
|
35
35
|
import { registerTsResolve } from '../tsResolve.js';
|
|
36
36
|
import { listFrontendApps, verifyAppDist } from '../dev/build.js';
|
|
@@ -209,9 +209,11 @@ async function verifyBuildOutput(cwd) {
|
|
|
209
209
|
/** doctor 는 이미 있는 명령을 재사용 — cwd 만 넘긴다. json 은 상위에서. */
|
|
210
210
|
async function runDoctorStep(cwd) {
|
|
211
211
|
try {
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
212
|
+
// computeDoctorResult 는 stdout 에 아무것도 쓰지 않고 결과만 돌려준다.
|
|
213
|
+
// runDoctorCommand 는 사람용 리포트를 stdout 에 직접 찍어 `gaon check --json`
|
|
214
|
+
// 의 순수 JSON 출력을 오염시켰다(mcp 의 parseJsonTail 우회가 방증). check 는
|
|
215
|
+
// 결과 객체만 필요하므로 무출력 경로를 쓴다(결정 269 · "--json = 파싱 안전").
|
|
216
|
+
const result = await computeDoctorResult({ cwd });
|
|
215
217
|
const failed = !!result.fatal || result.errors.length > 0;
|
|
216
218
|
return {
|
|
217
219
|
step: 'doctor',
|
package/dist/commands/test.d.ts
CHANGED
|
@@ -3,6 +3,14 @@ export interface TestCommandOptions {
|
|
|
3
3
|
readonly cwd?: string;
|
|
4
4
|
readonly json?: boolean;
|
|
5
5
|
readonly scope?: TestScope;
|
|
6
|
+
/**
|
|
7
|
+
* 프로그램 호출(MCP run_tests)용 출력 싱크. 지정되면 자식(vitest·test 스크립트)
|
|
8
|
+
* stdout/stderr 를 상속(inherit) 대신 파이프로 이 콜백에 흘린다 — 결과를 도구
|
|
9
|
+
* 응답에 담기 위함. CLI 경로(미지정)는 종전대로 `stdio:'inherit'`. 이 옵션으로
|
|
10
|
+
* MCP 가 `gaon test` 하네스(테스트 DB 프로비저닝·GAON_STREAM_PREFIX 격리·사용자
|
|
11
|
+
* test 스크립트 우선)를 그대로 거치게 한다(결정 270 · vitest 직접 spawn 우회 제거).
|
|
12
|
+
*/
|
|
13
|
+
readonly onOutput?: (chunk: string) => void;
|
|
6
14
|
}
|
|
7
15
|
/**
|
|
8
16
|
* `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
|
package/dist/commands/test.js
CHANGED
|
@@ -67,7 +67,21 @@ function scopeArgs(scope) {
|
|
|
67
67
|
* scope='unit' 은 실 인프라가 필요 없으므로 건너뛴다. config 가 없거나 db 커넥션이
|
|
68
68
|
* 없으면 조용히 스킵(순수 vitest 위임). DB 접속 실패는 수리 안내와 함께 실패한다.
|
|
69
69
|
*/
|
|
70
|
-
async function provisionTestDatabases(cwd, json) {
|
|
70
|
+
async function provisionTestDatabases(cwd, json, onOutput) {
|
|
71
|
+
// capture(MCP) 모드는 stdio JSON-RPC 를 오염시키지 않도록 모든 출력을 싱크로
|
|
72
|
+
// 돌린다 — process.stdout 직접 쓰기 0(결정 270).
|
|
73
|
+
const writeOut = (s) => {
|
|
74
|
+
if (onOutput)
|
|
75
|
+
onOutput(s);
|
|
76
|
+
else
|
|
77
|
+
process.stdout.write(s);
|
|
78
|
+
};
|
|
79
|
+
const writeErr = (s) => {
|
|
80
|
+
if (onOutput)
|
|
81
|
+
onOutput(s);
|
|
82
|
+
else
|
|
83
|
+
process.stderr.write(s);
|
|
84
|
+
};
|
|
71
85
|
registerTsResolve();
|
|
72
86
|
let config;
|
|
73
87
|
try {
|
|
@@ -93,7 +107,7 @@ async function provisionTestDatabases(cwd, json) {
|
|
|
93
107
|
});
|
|
94
108
|
if (res.exitCode !== 0) {
|
|
95
109
|
if (!json)
|
|
96
|
-
|
|
110
|
+
writeErr(` ✗ 테스트 DB '${key}' 마이그레이션 실패\n${res.text}\n`);
|
|
97
111
|
return false;
|
|
98
112
|
}
|
|
99
113
|
prepared.push(key);
|
|
@@ -104,18 +118,18 @@ async function provisionTestDatabases(cwd, json) {
|
|
|
104
118
|
const hint = ` ✗ 테스트 DB 준비 실패: ${msg}\n` +
|
|
105
119
|
` → DB 가 떠 있는지 확인하세요(docker compose up -d db). 테스트는 실 인프라가 필요합니다(§9).\n`;
|
|
106
120
|
if (json)
|
|
107
|
-
|
|
121
|
+
writeOut(JSON.stringify({ ok: false, kind: 'provision', error: msg }) + '\n');
|
|
108
122
|
else
|
|
109
|
-
|
|
123
|
+
writeErr(hint);
|
|
110
124
|
return false;
|
|
111
125
|
}
|
|
112
126
|
finally {
|
|
113
127
|
await destroyAllConnections();
|
|
114
128
|
}
|
|
115
129
|
if (json)
|
|
116
|
-
|
|
130
|
+
writeOut(JSON.stringify({ kind: 'provisioned', dbs: prepared }) + '\n');
|
|
117
131
|
else
|
|
118
|
-
|
|
132
|
+
writeOut(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
|
|
119
133
|
return true;
|
|
120
134
|
}
|
|
121
135
|
/**
|
|
@@ -127,9 +141,23 @@ export async function runTestCommand(args = [], opts = {}) {
|
|
|
127
141
|
const cwd = opts.cwd ?? process.cwd();
|
|
128
142
|
const scope = opts.scope ?? 'all';
|
|
129
143
|
const json = opts.json ?? false;
|
|
144
|
+
const capture = typeof opts.onOutput === 'function';
|
|
145
|
+
// capture(MCP) 모드: 모든 출력을 싱크로. 미지정: 종전대로 process.std*.
|
|
146
|
+
const writeOut = (s) => {
|
|
147
|
+
if (opts.onOutput)
|
|
148
|
+
opts.onOutput(s);
|
|
149
|
+
else
|
|
150
|
+
process.stdout.write(s);
|
|
151
|
+
};
|
|
152
|
+
const writeErr = (s) => {
|
|
153
|
+
if (opts.onOutput)
|
|
154
|
+
opts.onOutput(s);
|
|
155
|
+
else
|
|
156
|
+
process.stderr.write(s);
|
|
157
|
+
};
|
|
130
158
|
// 결정 111: unit 이 아니면 실행 전 테스트 DB 를 준비한다(생성 + 마이그레이션).
|
|
131
159
|
if (scope !== 'unit') {
|
|
132
|
-
const ok = await provisionTestDatabases(cwd, json);
|
|
160
|
+
const ok = await provisionTestDatabases(cwd, json, opts.onOutput);
|
|
133
161
|
if (!ok)
|
|
134
162
|
return 1;
|
|
135
163
|
}
|
|
@@ -137,7 +165,11 @@ export async function runTestCommand(args = [], opts = {}) {
|
|
|
137
165
|
const passthrough = [...scopeExtras, ...args];
|
|
138
166
|
let cmd;
|
|
139
167
|
let spawnArgs;
|
|
140
|
-
|
|
168
|
+
// 결정 271(F5): 스캐폴드 `"test": "gaon test"` 가 정본이라, gaon test 가 그 스크립트를
|
|
169
|
+
// 다시 실행하면 무한 재귀다. 재진입(GAON_TEST_ACTIVE) 이면 사용자 스크립트를 건너뛰고
|
|
170
|
+
// 내장 vitest 경로로 폴백해 재귀를 끊는다 — 프로비저닝·프리픽스는 위/아래에서 유지된다.
|
|
171
|
+
const reentrant = process.env.GAON_TEST_ACTIVE === '1';
|
|
172
|
+
if (hasTestScript(cwd) && !reentrant) {
|
|
141
173
|
// 결정 170 W1: pnpm 하드코딩 대신 프로젝트 선언 pm 으로 test 스크립트 실행
|
|
142
174
|
// (npm/yarn 스캐폴드 대응 · 공유 `pm.ts` 단일 소스). passthrough(필터 등)는
|
|
143
175
|
// pnpm·npm 은 `--` 로, yarn(classic)은 `--` 없이 전달 — scriptRunArgs 가 처리.
|
|
@@ -151,10 +183,10 @@ export async function runTestCommand(args = [], opts = {}) {
|
|
|
151
183
|
const msg = `vitest 가 설치돼 있지 않고 package.json 에 "test" 스크립트도 없습니다.\n` +
|
|
152
184
|
`→ pnpm add -D vitest 후 다시 실행하거나, package.json 에 "test": "vitest run" 을 추가하세요.`;
|
|
153
185
|
if (json) {
|
|
154
|
-
|
|
186
|
+
writeOut(JSON.stringify({ ok: false, error: msg }) + '\n');
|
|
155
187
|
}
|
|
156
188
|
else {
|
|
157
|
-
|
|
189
|
+
writeErr(` ✗ ${msg}\n`);
|
|
158
190
|
}
|
|
159
191
|
return 127;
|
|
160
192
|
}
|
|
@@ -166,22 +198,33 @@ export async function runTestCommand(args = [], opts = {}) {
|
|
|
166
198
|
// 붙으면 테스트가 쓰는 스트림/subject 가 `GAON_TEST_JOBS`·`test.gaon.jobs.>` 로
|
|
167
199
|
// 갈려, 개발용 `gaon work` 가 떠 있어도 서로 잡을 훔치지 않는다. 사용자가 이미
|
|
168
200
|
// 값을 세팅했으면 존중한다(고급 · 다중 테스트 컨텍스트 분리).
|
|
169
|
-
|
|
201
|
+
// GAON_TEST_ACTIVE: 자식(사용자 test 스크립트가 `gaon test` 여도)이 재진입을 감지해
|
|
202
|
+
// 사용자 스크립트를 재실행하지 않고 내장 vitest 로 가도록 하는 재귀 차단 센티넬(결정 271).
|
|
203
|
+
const testEnv = {
|
|
204
|
+
...process.env,
|
|
205
|
+
GAON_STREAM_PREFIX: process.env.GAON_STREAM_PREFIX ?? 'test',
|
|
206
|
+
GAON_TEST_ACTIVE: '1',
|
|
207
|
+
};
|
|
170
208
|
if (json) {
|
|
171
|
-
|
|
209
|
+
writeOut(JSON.stringify({ kind: 'starting', cmd, args: spawnArgs, scope, streamPrefix: testEnv.GAON_STREAM_PREFIX }) + '\n');
|
|
172
210
|
}
|
|
173
211
|
else {
|
|
174
|
-
|
|
212
|
+
writeOut(` gaon test · ${cmd} ${spawnArgs.join(' ')} (scope=${scope})\n`);
|
|
175
213
|
}
|
|
176
214
|
const exitCode = await new Promise((resolvePromise) => {
|
|
215
|
+
// capture(MCP) 모드는 자식 출력을 파이프로 모아 싱크로 흘린다(도구 응답에 담기).
|
|
216
|
+
// CLI 경로(미지정)는 vitest 컬러 출력·리포터를 그대로 보이게 stdio 를 상속한다.
|
|
177
217
|
const child = spawn(cmd, spawnArgs, {
|
|
178
218
|
cwd,
|
|
179
219
|
env: testEnv,
|
|
180
|
-
|
|
181
|
-
stdio: 'inherit',
|
|
220
|
+
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
182
221
|
});
|
|
222
|
+
if (capture) {
|
|
223
|
+
child.stdout?.on('data', (d) => opts.onOutput(d.toString('utf8')));
|
|
224
|
+
child.stderr?.on('data', (d) => opts.onOutput(d.toString('utf8')));
|
|
225
|
+
}
|
|
183
226
|
child.on('error', (err) => {
|
|
184
|
-
|
|
227
|
+
writeErr(` ✗ gaon test spawn 실패: ${String(err)}\n`);
|
|
185
228
|
resolvePromise(127);
|
|
186
229
|
});
|
|
187
230
|
child.on('close', (code, signal) => {
|
|
@@ -195,7 +238,7 @@ export async function runTestCommand(args = [], opts = {}) {
|
|
|
195
238
|
});
|
|
196
239
|
});
|
|
197
240
|
if (json) {
|
|
198
|
-
|
|
241
|
+
writeOut(JSON.stringify({ kind: 'exited', exitCode }) + '\n');
|
|
199
242
|
}
|
|
200
243
|
return exitCode;
|
|
201
244
|
}
|
package/dist/db/journal.d.ts
CHANGED
|
@@ -9,7 +9,14 @@ export interface JournalEntry {
|
|
|
9
9
|
readonly down_sql: string | null;
|
|
10
10
|
readonly applied_at: Date;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* _gaon_migrations 존재 여부. information_schema 조회로 방언 무관.
|
|
14
|
+
*
|
|
15
|
+
* count 는 캐스트 없이 뽑는다 — 이전 `count(*)::text` 는 postgres 전용 문법이라
|
|
16
|
+
* mysql/mariadb 에서 문법 에러를 내며 `gaon db migrate/status/down/reset` 을 전멸시켰다.
|
|
17
|
+
* pg 는 count 를 bigint(문자열)로, mysql 은 number 로 돌려주므로 `Number()` 로 통일한다
|
|
18
|
+
* (양 방언 공통 · 결정 268).
|
|
19
|
+
*/
|
|
13
20
|
export declare function journalExists(db: Kysely<any>): Promise<boolean>;
|
|
14
21
|
/**
|
|
15
22
|
* _gaon_migrations 를 만든다(존재하면 no-op). id/kind/applied_at/db_key/
|
package/dist/db/journal.js
CHANGED
|
@@ -9,10 +9,17 @@
|
|
|
9
9
|
// 마이그레이션은 커넥션별로 돈다(§4.5) — 원장도 커넥션 DB 마다 따로 존재한다.
|
|
10
10
|
import { sql } from 'kysely';
|
|
11
11
|
import { MIGRATIONS_TABLE } from '@gaonjs/data';
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* _gaon_migrations 존재 여부. information_schema 조회로 방언 무관.
|
|
14
|
+
*
|
|
15
|
+
* count 는 캐스트 없이 뽑는다 — 이전 `count(*)::text` 는 postgres 전용 문법이라
|
|
16
|
+
* mysql/mariadb 에서 문법 에러를 내며 `gaon db migrate/status/down/reset` 을 전멸시켰다.
|
|
17
|
+
* pg 는 count 를 bigint(문자열)로, mysql 은 number 로 돌려주므로 `Number()` 로 통일한다
|
|
18
|
+
* (양 방언 공통 · 결정 268).
|
|
19
|
+
*/
|
|
13
20
|
export async function journalExists(db) {
|
|
14
21
|
const rows = await sql `
|
|
15
|
-
select count(*)
|
|
22
|
+
select count(*) as n
|
|
16
23
|
from information_schema.tables
|
|
17
24
|
where table_name = ${MIGRATIONS_TABLE}
|
|
18
25
|
`.execute(db);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type DevCommandOptions } from "./commands/dev.js";
|
|
2
2
|
import { type ServeCommandOptions } from "./serve.js";
|
|
3
|
+
import { type DbSubcommand, type DbCommandOptions } from "./commands/db.js";
|
|
3
4
|
import { type DoctorRule } from "./doctor.js";
|
|
4
5
|
export { startDev, resolveDevLayout, regenerateGaonOnce, type DevDeps, type DevLayout, type DevApp, type DevEvent, type DevHandle, type RegenDeps, type RegenResult, } from "./dev.js";
|
|
5
6
|
export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
|
|
@@ -72,6 +73,16 @@ export interface ParsedNewArgs {
|
|
|
72
73
|
* npm 이 프로젝트 이름으로 오인되지 않도록(결정 167 · O-1 근본 fix).
|
|
73
74
|
*/
|
|
74
75
|
export declare function parseNewArgs(rest: readonly string[]): ParsedNewArgs;
|
|
76
|
+
/**
|
|
77
|
+
* `gaon db <sub>` 의 인자를 파싱한다(argv = 전체 · argv[0]='db', argv[1]=sub).
|
|
78
|
+
*
|
|
79
|
+
* 방향 토큰 `down` 은 **위치 인자**다 — 값 플래그(`--db`/`--config <값>`)와 불리언
|
|
80
|
+
* 플래그를 건너뛰고 판별한다. 이전엔 `argv[2] === 'down'` 고정 위치로만 봐서
|
|
81
|
+
* `gaon db migrate --db X down` 처럼 플래그가 앞서면 down 을 놓치고 정방향 migrate 로
|
|
82
|
+
* 조용히 반전됐다(파괴 방향 오동작 · 결정 267). 위치로 훑어 순서와 무관하게
|
|
83
|
+
* 정확히 롤백으로 인식한다.
|
|
84
|
+
*/
|
|
85
|
+
export declare function parseDbArgs(sub: DbSubcommand, argv: readonly string[]): DbCommandOptions;
|
|
75
86
|
/**
|
|
76
87
|
* `--port <값>` 플래그를 읽어 검증한다(serve·dev 공용). 플래그가 없으면
|
|
77
88
|
* undefined(기본 포트 폴백). 플래그는 있는데 값이 없거나(마지막 토큰) 다른
|
package/dist/index.js
CHANGED
|
@@ -197,6 +197,42 @@ export function parseNewArgs(rest) {
|
|
|
197
197
|
}
|
|
198
198
|
return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
|
|
199
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* `gaon db <sub>` 의 인자를 파싱한다(argv = 전체 · argv[0]='db', argv[1]=sub).
|
|
202
|
+
*
|
|
203
|
+
* 방향 토큰 `down` 은 **위치 인자**다 — 값 플래그(`--db`/`--config <값>`)와 불리언
|
|
204
|
+
* 플래그를 건너뛰고 판별한다. 이전엔 `argv[2] === 'down'` 고정 위치로만 봐서
|
|
205
|
+
* `gaon db migrate --db X down` 처럼 플래그가 앞서면 down 을 놓치고 정방향 migrate 로
|
|
206
|
+
* 조용히 반전됐다(파괴 방향 오동작 · 결정 267). 위치로 훑어 순서와 무관하게
|
|
207
|
+
* 정확히 롤백으로 인식한다.
|
|
208
|
+
*/
|
|
209
|
+
export function parseDbArgs(sub, argv) {
|
|
210
|
+
const dbIdx = argv.indexOf("--db");
|
|
211
|
+
const cfgIdx = argv.indexOf("--config");
|
|
212
|
+
const valueFlags = new Set(["--db", "--config"]);
|
|
213
|
+
const positionals = [];
|
|
214
|
+
for (let i = 2; i < argv.length; i++) {
|
|
215
|
+
const a = argv[i];
|
|
216
|
+
if (a === undefined)
|
|
217
|
+
continue;
|
|
218
|
+
if (valueFlags.has(a)) {
|
|
219
|
+
i++; // 플래그 값 스킵
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (a.startsWith("--"))
|
|
223
|
+
continue; // 불리언 플래그
|
|
224
|
+
positionals.push(a);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
json: argv.includes("--json"),
|
|
228
|
+
db: dbIdx >= 0 ? argv[dbIdx + 1] : undefined,
|
|
229
|
+
config: cfgIdx >= 0 ? argv[cfgIdx + 1] : undefined,
|
|
230
|
+
yes: argv.includes("--yes"),
|
|
231
|
+
dryRun: argv.includes("--dry-run"),
|
|
232
|
+
// `gaon db migrate down` — 위치 인자로 롤백 지시(순서 무관).
|
|
233
|
+
down: sub === "migrate" && positionals.includes("down"),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
200
236
|
/**
|
|
201
237
|
* `--port <값>` 플래그를 읽어 검증한다(serve·dev 공용). 플래그가 없으면
|
|
202
238
|
* undefined(기본 포트 폴백). 플래그는 있는데 값이 없거나(마지막 토큰) 다른
|
|
@@ -447,17 +483,7 @@ export function runCli(argv, opts = {}) {
|
|
|
447
483
|
process.exitCode = 1;
|
|
448
484
|
return;
|
|
449
485
|
}
|
|
450
|
-
const
|
|
451
|
-
const cfgIdx = argv.indexOf("--config");
|
|
452
|
-
const dbOpts = {
|
|
453
|
-
json: argv.includes("--json"),
|
|
454
|
-
db: dbIdx >= 0 ? argv[dbIdx + 1] : undefined,
|
|
455
|
-
config: cfgIdx >= 0 ? argv[cfgIdx + 1] : undefined,
|
|
456
|
-
yes: argv.includes("--yes"),
|
|
457
|
-
dryRun: argv.includes("--dry-run"),
|
|
458
|
-
// `gaon db migrate down` — 위치 인자로 롤백 지시.
|
|
459
|
-
down: sub === "migrate" && argv[2] === "down",
|
|
460
|
-
};
|
|
486
|
+
const dbOpts = parseDbArgs(sub, argv);
|
|
461
487
|
void runDbCommand(sub, dbOpts)
|
|
462
488
|
.then((code) => {
|
|
463
489
|
process.exitCode = code;
|
|
@@ -616,6 +642,18 @@ export function runCli(argv, opts = {}) {
|
|
|
616
642
|
});
|
|
617
643
|
return;
|
|
618
644
|
}
|
|
645
|
+
// 결정 266: 미지 명령은 로드맵 배너로 조용히 성공(exit 0)하지 않는다 — `gaon serv`·
|
|
646
|
+
// `gaon migrate` 같은 오타가 성공 종료로 오판되면 CI·AI 가 실패를 못 본다.
|
|
647
|
+
// argv[0] 가 있으면서 어떤 명령·플래그와도 안 맞으면(플래그는 `-` 접두라
|
|
648
|
+
// help/version/json 폴백이 처리) 여기서 fail-loud(§7.5.3). 인자 없는 `gaon`
|
|
649
|
+
// (argv[0] 미존재)은 종전대로 로드맵 배너를 낸다.
|
|
650
|
+
if (argv[0] !== undefined && !argv[0].startsWith("-")) {
|
|
651
|
+
process.stderr.write(` ✗ 알 수 없는 명령: ${argv[0]}\n` +
|
|
652
|
+
` → 지원 명령: dev · serve · check · gen · build · doctor · mcp · hub · work · jobs · db · g · new · console · test\n` +
|
|
653
|
+
` → 전체 사용법: gaon --help\n`);
|
|
654
|
+
process.exitCode = 1;
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
619
657
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
620
658
|
process.stdout.write(renderHelp(version) + "\n");
|
|
621
659
|
return;
|
package/dist/mcp/tools.d.ts
CHANGED
|
@@ -54,10 +54,14 @@ export declare function getSchemaTool(args: ToolArgs, cwd: string): Promise<Tool
|
|
|
54
54
|
*/
|
|
55
55
|
export declare function runMigrationTool(args: ToolArgs, cwd: string): Promise<ToolResult>;
|
|
56
56
|
/**
|
|
57
|
-
* `run_tests` — 프로젝트 테스트를
|
|
57
|
+
* `run_tests` — 프로젝트 테스트를 정본 `gaon test` 하네스로 실행한다(§9).
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
59
|
+
* 결정 270: 이전엔 여기서 `vitest run` 을 **직접 spawn** 해 `gaon test`(runTestCommand)의
|
|
60
|
+
* 세 가지 필수 거동을 전부 우회했다 — ① `<db>_test` 프로비저닝(결정 111) ② NATS
|
|
61
|
+
* `GAON_STREAM_PREFIX` 격리(결정 130) ③ 사용자 `test` 스크립트 우선(결정 170). 주석은
|
|
62
|
+
* "같은 경로" 라 주장했으나 거짓이었다. 이제 runTestCommand 에 위임하고, MCP(stdio
|
|
63
|
+
* JSON-RPC) 컨텍스트라 자식·프로비저닝 출력을 `onOutput` 싱크로 모아 도구 응답에
|
|
64
|
+
* 담는다(process.stdout 오염 0 — 트랜스포트 보호).
|
|
61
65
|
*
|
|
62
66
|
* 인자:
|
|
63
67
|
* { scope?: 'unit'|'integration'|'all', filter?: string }
|
|
@@ -65,8 +69,8 @@ export declare function runMigrationTool(args: ToolArgs, cwd: string): Promise<T
|
|
|
65
69
|
* filter — vitest 위치 인자(파일 패턴 substring)
|
|
66
70
|
*
|
|
67
71
|
* 반환:
|
|
68
|
-
* data: { exitCode, scope, filter, output }
|
|
69
|
-
* text:
|
|
72
|
+
* data: { ok, exitCode, scope, filter, output }
|
|
73
|
+
* text: gaon test 출력(프로비저닝 + vitest · 사람 UI 그대로)
|
|
70
74
|
*/
|
|
71
75
|
export declare function runTestsTool(args: ToolArgs, cwd: string): Promise<ToolResult>;
|
|
72
76
|
/**
|
package/dist/mcp/tools.js
CHANGED
|
@@ -32,6 +32,7 @@ import { join, resolve as resolvePath } from 'node:path';
|
|
|
32
32
|
import { pathToFileURL } from 'node:url';
|
|
33
33
|
import { registerTsResolve } from '../tsResolve.js';
|
|
34
34
|
import { runDbMigrate } from '../db/migrate.js';
|
|
35
|
+
import { runTestCommand } from '../commands/test.js';
|
|
35
36
|
import { scanSchemaDir, columns } from '@gaonjs/data';
|
|
36
37
|
// ── 유틸 ────────────────────────────────────────────────────
|
|
37
38
|
/** 문자열 옵션 안전 추출. */
|
|
@@ -276,10 +277,14 @@ export async function runMigrationTool(args, cwd) {
|
|
|
276
277
|
}
|
|
277
278
|
// ── 도구 4: run_tests ────────────────────────────────────────
|
|
278
279
|
/**
|
|
279
|
-
* `run_tests` — 프로젝트 테스트를
|
|
280
|
+
* `run_tests` — 프로젝트 테스트를 정본 `gaon test` 하네스로 실행한다(§9).
|
|
280
281
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
282
|
+
* 결정 270: 이전엔 여기서 `vitest run` 을 **직접 spawn** 해 `gaon test`(runTestCommand)의
|
|
283
|
+
* 세 가지 필수 거동을 전부 우회했다 — ① `<db>_test` 프로비저닝(결정 111) ② NATS
|
|
284
|
+
* `GAON_STREAM_PREFIX` 격리(결정 130) ③ 사용자 `test` 스크립트 우선(결정 170). 주석은
|
|
285
|
+
* "같은 경로" 라 주장했으나 거짓이었다. 이제 runTestCommand 에 위임하고, MCP(stdio
|
|
286
|
+
* JSON-RPC) 컨텍스트라 자식·프로비저닝 출력을 `onOutput` 싱크로 모아 도구 응답에
|
|
287
|
+
* 담는다(process.stdout 오염 0 — 트랜스포트 보호).
|
|
283
288
|
*
|
|
284
289
|
* 인자:
|
|
285
290
|
* { scope?: 'unit'|'integration'|'all', filter?: string }
|
|
@@ -287,8 +292,8 @@ export async function runMigrationTool(args, cwd) {
|
|
|
287
292
|
* filter — vitest 위치 인자(파일 패턴 substring)
|
|
288
293
|
*
|
|
289
294
|
* 반환:
|
|
290
|
-
* data: { exitCode, scope, filter, output }
|
|
291
|
-
* text:
|
|
295
|
+
* data: { ok, exitCode, scope, filter, output }
|
|
296
|
+
* text: gaon test 출력(프로비저닝 + vitest · 사람 UI 그대로)
|
|
292
297
|
*/
|
|
293
298
|
export async function runTestsTool(args, cwd) {
|
|
294
299
|
const scope = (stringOpt(args, 'scope') ?? 'all');
|
|
@@ -296,39 +301,17 @@ export async function runTestsTool(args, cwd) {
|
|
|
296
301
|
if (!['unit', 'integration', 'all'].includes(scope)) {
|
|
297
302
|
return errorResult(`scope 는 'unit' | 'integration' | 'all' 중 하나여야 합니다. 입력값: '${scope}'`);
|
|
298
303
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const scopeExtras = scope === 'unit'
|
|
302
|
-
? ['--exclude', '**/*.integration.test.ts']
|
|
303
|
-
: scope === 'integration'
|
|
304
|
-
? ['integration.test']
|
|
305
|
-
: [];
|
|
306
|
-
const passthrough = [...scopeExtras, ...(filter ? [filter] : [])];
|
|
307
|
-
// 실행 경로: 로컬 vitest 바이너리 우선. 없으면 에러 안내.
|
|
308
|
-
const vitestBin = join(resolvePath(cwd), 'node_modules', '.bin', 'vitest');
|
|
309
|
-
if (!existsSync(vitestBin)) {
|
|
310
|
-
return errorResult(`vitest 가 설치돼 있지 않습니다: ${vitestBin}\n` +
|
|
311
|
-
`→ 프로젝트에서 \`pnpm add -D vitest\` 후 다시 시도하세요.`);
|
|
312
|
-
}
|
|
313
|
-
const child = spawn(vitestBin, ['run', ...passthrough], {
|
|
304
|
+
let output = '';
|
|
305
|
+
const exitCode = await runTestCommand(filter ? [filter] : [], {
|
|
314
306
|
cwd,
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
child.stdout.on('data', (d) => {
|
|
321
|
-
stdout += d.toString('utf8');
|
|
322
|
-
});
|
|
323
|
-
child.stderr.on('data', (d) => {
|
|
324
|
-
stderr += d.toString('utf8');
|
|
325
|
-
});
|
|
326
|
-
const exitCode = await new Promise((resolveExit) => {
|
|
327
|
-
child.on('close', (code) => resolveExit(code ?? 1));
|
|
307
|
+
scope,
|
|
308
|
+
json: false,
|
|
309
|
+
onOutput: (chunk) => {
|
|
310
|
+
output += chunk;
|
|
311
|
+
},
|
|
328
312
|
});
|
|
329
|
-
const output = stdout + (stderr ? `\n[stderr]\n${stderr}` : '');
|
|
330
313
|
return {
|
|
331
|
-
text: output || `(
|
|
314
|
+
text: output || `(gaon test 출력 없음 · exit ${exitCode})`,
|
|
332
315
|
data: {
|
|
333
316
|
ok: exitCode === 0,
|
|
334
317
|
exitCode,
|
|
@@ -76,9 +76,11 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
76
76
|
`router.delete(...)` 등). REST + `fetch()` 는 **API 앱(JWT) 전용**.
|
|
77
77
|
7. **한 액션은 한 종류 응답만** (render 또는 JSON 또는 redirect —
|
|
78
78
|
혼용 금지 · doctor response-mixing).
|
|
79
|
-
8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`
|
|
80
|
-
|
|
81
|
-
`
|
|
79
|
+
8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`routes.manifest.ts`
|
|
80
|
+
(routes 축 = 타입 브리지 + api() 런타임 매니페스트 2파일 · 결정 127)·
|
|
81
|
+
`tables.d.ts`·`messages.d.ts`(3축 · `locales/` 있을 때 · 결정 158)·`env.d.ts`
|
|
82
|
+
(`.env` 스캔 · 프론트 앱 · 결정 198) 는 `gaon check`/`gaon dev`/`gaon gen` 이
|
|
83
|
+
재생성한다.
|
|
82
84
|
|
|
83
85
|
### 2.1 파일 네이밍 표 (2026-07-24 승인 · 벤치마크 R1 실측 고정)
|
|
84
86
|
|
|
@@ -134,7 +136,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
134
136
|
21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
|
|
135
137
|
22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
|
|
136
138
|
23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
|
|
137
|
-
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon
|
|
139
|
+
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon doctor --fix --yes` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
138
140
|
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
|
|
139
141
|
26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
|
|
140
142
|
27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
|
|
@@ -208,7 +210,8 @@ gaon doctor # 정적 검사 27종 (§2.2)
|
|
|
208
210
|
| `gaon new <name>` | 프로젝트 스캐폴드 |
|
|
209
211
|
| `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**serve·work·hub 자동 기동**·**코드 변경 감시·재시작** · 결정 211) |
|
|
210
212
|
| `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
|
|
211
|
-
| `gaon g <type> <name>` | 스캐폴드: `auth`·`controller`·`model`·`page`·`job` |
|
|
213
|
+
| `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app` |
|
|
214
|
+
| `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
|
|
212
215
|
| `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
|
|
213
216
|
| `gaon check` / `test` / `doctor` | 검증 루프 |
|
|
214
217
|
| `gaon console` | 프로젝트 컨텍스트 REPL |
|
|
@@ -82,6 +82,10 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
|
|
|
82
82
|
- **이름** — `options.name` 으로 명시하거나, 생략하면 `domain/jobs/`
|
|
83
83
|
파일 로더가 **파일명**으로 채운다(`assignName`). 이름을 얻기 전까지
|
|
84
84
|
`.later()` 등을 호출하면 에러 — 파일로 두거나 `name` 을 직접 준다.
|
|
85
|
+
- **파일당 잡 하나(결정 271)** — 같은 파일에 `job()` 을 둘 이상 두면 파일명
|
|
86
|
+
유추가 충돌한다. 이제 **등록 시 throw**(조용한 덮어쓰기 = 발행이 엉뚱한
|
|
87
|
+
핸들러로 가던 무신호 버그 봉합) — 각각 `name` 을 다르게 주거나 파일을 나눈다.
|
|
88
|
+
리스너(`on()`)도 동형 — 같은 파일에 여럿 두면 `id` 를 다르게 준다(durable 충돌).
|
|
85
89
|
- **옵션** — `queue`(기본 `'default'`) · `retries`(기본 3) ·
|
|
86
90
|
`curve`(백오프 곡선 ms) · `jitter` · `concurrency`.
|
|
87
91
|
- **실패** — 재시도를 소진하면 DLQ 로 간다. `gaon jobs list --failed` ·
|
|
@@ -108,7 +112,7 @@ await SendWelcomeMail.at(someDate, user.id) // 특정 시각 실행
|
|
|
108
112
|
|
|
109
113
|
```ts
|
|
110
114
|
async create() {
|
|
111
|
-
const user = await User.create(this.params(User.
|
|
115
|
+
const user = await User.create(this.params(User.form))
|
|
112
116
|
await SendWelcomeMail.later(user.id) // 컨트롤러에서 발행
|
|
113
117
|
return this.redirect('/dashboard')
|
|
114
118
|
}
|
|
@@ -204,9 +208,10 @@ export const PlaceOrder = service(async (input: { name: string }) => {
|
|
|
204
208
|
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 `gaon serve`·`gaon work`
|
|
205
209
|
기동 시 보장된다(결정 144 · nats 설정이 있을 때).
|
|
206
210
|
- 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
|
|
207
|
-
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `
|
|
208
|
-
`outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(
|
|
209
|
-
`docs/guides/operations.md`). 미발행 행은
|
|
211
|
+
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `runWork()` 의
|
|
212
|
+
프로그래매틱 옵션 `outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(`gaon
|
|
213
|
+
work` CLI 플래그가 아니다 · 운영 상세는 `docs/guides/operations.md`). 미발행 행은
|
|
214
|
+
절대 삭제되지 않는다.
|
|
210
215
|
|
|
211
216
|
### 5. 스케줄러
|
|
212
217
|
|
|
@@ -368,7 +373,9 @@ export const SendWelcomeMail = job(async (userId: bigint) => {
|
|
|
368
373
|
```ts
|
|
369
374
|
// apps/web/controllers/registration.ts — 컨트롤러는 잡 발행만 (직접 발송 금지)
|
|
370
375
|
async create() {
|
|
371
|
-
|
|
376
|
+
// 서비스는 모델 폼이 없다 — 컨트롤러가 모델 스키마 폼(`User.form.pick(...)`)으로
|
|
377
|
+
// 검증한 입력을 서비스에 넘긴다(폼 표면 = 모델 소속 · 결정 104).
|
|
378
|
+
const user = await RegisterUser.call(this.params(User.form.pick('name', 'email')))
|
|
372
379
|
await SendWelcomeMail.later(user.id)
|
|
373
380
|
return this.redirect('/dashboard')
|
|
374
381
|
}
|
|
@@ -168,7 +168,7 @@ export const posts = table('posts', {
|
|
|
168
168
|
### 4. 체이닝 전체 (`packages/data/src/model.ts`)
|
|
169
169
|
|
|
170
170
|
체이닝 표면은 아래 표가 **전부**다. 표에 없는 메서드
|
|
171
|
-
(`destroy`·`findBy`·`
|
|
171
|
+
(`destroy`·`findBy`·`order`·해시 인자
|
|
172
172
|
`where({...})` 같은 다른 ORM 관습)를 추측해서 쓰지 말 것 — 표
|
|
173
173
|
바깥의 쿼리는 §5 `Post.query()` 탈출구로 내려간다.
|
|
174
174
|
|
|
@@ -216,7 +216,7 @@ export const posts = table('posts', {
|
|
|
216
216
|
| `having` | `('count', op, val)` · `('sum'\|'avg'\|'min'\|'max', col, op, val)` | `GroupChain` | groupBy 뒤 **집계값** 필터 (그룹 키 필터는 `where`). `.having('count', '>', 2)` · `.having('sum', 'price', '>=', 1000)` |
|
|
217
217
|
| `distinct` | `()` · `(col \| col[])` | `Chain` · `SelectChain` | 인자 없으면 `SELECT DISTINCT` 전체 행(집계·벌크 쓰기 이어짐), 컬럼을 주면 그 컬럼만 뽑는 `SelectChain`. `distinct().count()` 는 `count(distinct id)` |
|
|
218
218
|
| `withCount` | `(...rels)` | `IncludedChain` | 관계별 개수를 **상관 서브쿼리**로 얹는다 — `withCount('comments')` → 각 Rec 에 `commentsCount: bigint`. 조인이 아니라 행이 안 늘어 `limit` 과 함께 써도 개수가 정확. **hasMany·hasOne·belongsToMany 만**(belongsTo 는 항상 0/1 이라 throw). `include` 와 같은 체인에 실린다(`include('author').withCount('comments')`) |
|
|
219
|
-
| `join` | `(table, 'table.col', 'self.col')` | `JoinChain` | INNER JOIN — **필터·정렬 수단**이고 반환은 **자기 테이블의 Rec**(조인 테이블 컬럼은 안 실림 → 뽑아야 하면 §5 `Post.query()`).
|
|
219
|
+
| `join` | `(table, 'table.col', 'self.col')` | `JoinChain` | INNER JOIN — **필터·정렬 수단**이고 반환은 **자기 테이블의 Rec**(조인 테이블 컬럼은 안 실림 → 뽑아야 하면 §5 `Post.query()`). **자기 테이블 컬럼은 한정 없이 그대로** 쓴다 — `t.timestamps()`·`t.id()` 로 양 테이블이 `createdAt`·`id` 를 공유해도 조인 시 자기 테이블로 자동 한정돼 `where('createdAt', ..)` 가 안전하다(ambiguous column 방지). **조인 테이블** 조건만 한정 이름(`where('users.name', '=', ...)`)으로 쓴다. 1:N 부풀림은 `distinct()` 로 접는다. `join`/`leftJoin`·`where`·`orderBy`·`distinct`·`select`·`pluck`·`count`·`exists`·`first`·`all` 이어짐 |
|
|
220
220
|
| `leftJoin` | `(table, 'table.col', 'self.col')` | `JoinChain` | LEFT OUTER JOIN — 짝 없는 자기 행도 남는다. "짝 없는 것만" = `.where('posts.id', 'is null')` |
|
|
221
221
|
|
|
222
222
|
> 조인 노출은 정본 결정 28 게이트 (f)(원안 = 기각·`Post.query()` 로만)를
|
|
@@ -725,7 +725,7 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
725
725
|
```ts
|
|
726
726
|
// domain/seed.ts
|
|
727
727
|
import { seed } from 'gaonjs/data'
|
|
728
|
-
import { User } from './models/User'
|
|
728
|
+
import { User } from './models/User.js'
|
|
729
729
|
|
|
730
730
|
export default seed(async () => {
|
|
731
731
|
await User.create({ email: 'admin@example.com', name: 'Admin' })
|
|
@@ -815,9 +815,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
|
|
|
815
815
|
|
|
816
816
|
## 알려진 함정
|
|
817
817
|
|
|
818
|
-
- **체이닝 표 밖 메서드 추측 금지** — `destroy`·`findBy
|
|
819
|
-
`order`·해시 인자 `where({...})` 는
|
|
820
|
-
벌크는 `deleteAll()` (결정 31).
|
|
818
|
+
- **체이닝 표 밖 메서드 추측 금지** — `destroy`·`findBy`·
|
|
819
|
+
`order`·해시 인자 `where({...})` 는 없다(페이지네이션은 표 안 `paginate`
|
|
820
|
+
· 결정 119). 단건 삭제는 `rec.delete()`, 벌크는 `deleteAll()` (결정 31).
|
|
821
821
|
- **`find(id)` 는 없으면 throw** — undefined 를 원하면
|
|
822
822
|
`where('id', '=', id).first()`.
|
|
823
823
|
- **관계 대상은 문자열 테이블명** — 모델 객체를 넘기면 순환 참조.
|
|
@@ -70,6 +70,10 @@ const props = pageProps<'web:posts#index'>()
|
|
|
70
70
|
의 `Link`(`<Link href="/posts">글 목록</Link>`), 코드에서의 이동은
|
|
71
71
|
`router.visit(url)`. **외부 URL(`https://…`)·`target="_blank"` 만 `<a>`** 를
|
|
72
72
|
유지한다. 내부 경로 일반 앵커는 doctor **internal-anchor** 가 잡는다(결정 96).
|
|
73
|
+
- **페이지 제목 = `Head`(결정 271)** — 문서 `<title>` 은 `gaonjs/vue` 의 `Head` 로
|
|
74
|
+
설정한다: `<Head title="글 목록" />`. `createGaonApp({ title })` 조합자가 이 값을
|
|
75
|
+
받아 `글 목록 · 사이트명` 처럼 꾸민다. `document.title` 수동 조작·`@inertiajs`
|
|
76
|
+
직접 import 금지(결정 64) — 정본 표면은 `Head` 뿐이다.
|
|
73
77
|
- **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
|
|
74
78
|
금지 (라우트를 모른다는 순수 규칙).
|
|
75
79
|
|
|
@@ -398,6 +402,10 @@ async function runSearch(q: string) {
|
|
|
398
402
|
|
|
399
403
|
- **`usePage()`·`defineProps<T>()` 로 pageProps 대체 금지** — Serialized
|
|
400
404
|
경계 우회로 타입 안전 붕괴.
|
|
405
|
+
- **`pageProps()`·`useShared()` 는 setup 컨텍스트 전용** — `<script setup>` 최상위
|
|
406
|
+
(또는 컴포저블)에서 호출해야 `usePage()` inject 가 성립한다(`pageProps.ts`). 이벤트
|
|
407
|
+
핸들러·`await` 뒤·setup 밖에서 부르면 inject 가 없어 깨진다. setup 에서 한 번 잡아
|
|
408
|
+
(반응형 프록시라 그대로) 쓴다.
|
|
401
409
|
- **`api()` 에 제네릭 인자 직접 붙이지 않는다** — key 리터럴이 타입을
|
|
402
410
|
결정한다.
|
|
403
411
|
- **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
|
|
@@ -448,6 +456,8 @@ async function runSearch(q: string) {
|
|
|
448
456
|
| 결정 25 (E-5) | 컴포저블·레이아웃 관례 · 프론트 로직 배치 3규칙 · 자동 import 금지 |
|
|
449
457
|
| 결정 37 | bigint PK 컨트롤러 `String()` 정규화 |
|
|
450
458
|
| 결정 46 | doctor page-filename(페이지 PascalCase)·model/column 검사 3종 |
|
|
459
|
+
| 결정 64 | 폼 API = `gaonjs/vue` `useForm`·`router`(@inertiajs 직접 import 금지) · HTML 폼 불가 메서드(DELETE 등)는 `router.delete()` · Inertia=SPA+서버 라우팅(`agents/web.md` §4) |
|
|
460
|
+
| 결정 99 | `pageProps()` 반환 = usePage().props 얇은 반응형 프록시(매 접근 최신) · 구조분해=스냅샷이라 변수로 받아 `props.x` 로 접근 |
|
|
451
461
|
| 결정 69 | 랜딩 정본(라이브 헬스 카드 · 다크 헤더 레이아웃 · 실 상태 · 코드 블록) |
|
|
452
462
|
| 결정 70 | auth 통합 = 수동(`gaon g auth` 는 랜딩·nav 를 안 건드림 · Rails 관례) |
|
|
453
463
|
| 결정 71 | HUB 카드 실 introspection(jobs=스트림 pending · channels=프레즌스 실 접속자 · 과대 약속 금지) |
|
|
@@ -464,9 +474,12 @@ async function runSearch(q: string) {
|
|
|
464
474
|
| 결정 166 | `api()` CSRF 자동 부착 = data-page `props.csrf`(결정 116 과 같은 단일 출처) · `<meta name="csrf-token">` 은 레거시 폴백(§2 · `packages/vue/src/api.ts`) |
|
|
465
475
|
| 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
|
|
466
476
|
| 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
|
|
477
|
+
| 결정 128 | `useChannel` 자동 재연결(지수 백오프 1s·2s·5s·10s·지터 · `onReconnect` 로 놓친 데이터 따라잡기 · 미인가 4401 은 재연결 안 함 · `agents/realtime.md` §4) |
|
|
467
478
|
| 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
|
|
468
479
|
| 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
|
|
469
480
|
| 결정 213 | i18n Vue 소비 = 서버 주도 render props/sharedProps 만 · `t()`·`useT()` 클라 미노출(`agents/i18n.md` §5) |
|
|
481
|
+
| 결정 217 | doctor `shared-purity`(구 shared-composable-purity 개명) — `shared/` 의 .ts 컴포저블 + .vue 컴포넌트 순수성(pageProps/api 호출·domain 값 import 금지 · §7) |
|
|
482
|
+
| 결정 271 | W4 표면 정합 — `Head` 재수출(`gaonjs/vue` · `<Head title>` 제목 조합자 발화) 외 표면/최적화 4건(§12 결정 271) |
|
|
470
483
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
471
484
|
|
|
472
485
|
## `@gaonjs/seal` 켠 앱의 프론트
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
### 1. 카탈로그와 `t()`
|
|
10
10
|
|
|
11
11
|
번역 문자열은 프로젝트 루트 `locales/<로케일>.json` 에 둔다(중첩 JSON = 점 표기
|
|
12
|
-
키). `t('key')`
|
|
13
|
-
|
|
12
|
+
키). `t('key')` 는 **현재 요청 로케일**의 문자열을 얻는다 — 요청 컨텍스트 안
|
|
13
|
+
(컨트롤러·서비스)에서 자동으로 따라간다. **요청 밖(잡·크론·스크립트)에는 요청
|
|
14
|
+
로케일이 없어 항상 fallback** 이므로, 로케일을 명시적으로 실어 `runWithLanguage`
|
|
15
|
+
로 감싼다(메일은 `deliver(data, { locale })` 로 대칭 · §정본 예시).
|
|
14
16
|
|
|
15
17
|
```json
|
|
16
18
|
// locales/ko.json
|
|
@@ -81,9 +83,11 @@ export default defineConfig({
|
|
|
81
83
|
없는 앱(랜딩·API)에서도·앱 간에도 유지된다.
|
|
82
84
|
|
|
83
85
|
```ts
|
|
84
|
-
// 컨트롤러 — 언어 전환 라우트
|
|
85
|
-
async
|
|
86
|
-
this.
|
|
86
|
+
// 컨트롤러 — 언어 전환 라우트 (액션명은 컨텍스트 메서드 this.setLocale 과 겹치지 않게)
|
|
87
|
+
async switchLocale() {
|
|
88
|
+
// 입력은 정본 this.params — raw this.request.params + cast 대신 통합 입력 접근자.
|
|
89
|
+
const { lng } = this.params({ _row: {} as { lng: string } })
|
|
90
|
+
this.setLocale(lng)
|
|
87
91
|
return this.redirect(this.request.headers.referer ?? '/')
|
|
88
92
|
}
|
|
89
93
|
```
|
|
@@ -205,16 +209,28 @@ CSS 가 올바른 언어를 안다). 비-i18n 프로젝트는 템플릿 정적
|
|
|
205
209
|
## 정본 예시
|
|
206
210
|
|
|
207
211
|
```ts
|
|
208
|
-
// domain/services/greet.ts —
|
|
212
|
+
// domain/services/greet.ts — 서비스는 요청 컨텍스트 안이라 t() 가 요청 로케일을 쓴다.
|
|
209
213
|
import { t } from 'gaonjs/i18n'
|
|
210
214
|
export function greetLine(name: string): string {
|
|
211
215
|
return t('greeting', { name })
|
|
212
216
|
}
|
|
213
217
|
```
|
|
214
218
|
|
|
219
|
+
```ts
|
|
220
|
+
// domain/jobs/sendDigest.ts — 잡은 `gaon work`(별도 프로세스)라 요청 컨텍스트가 없다.
|
|
221
|
+
// 로케일을 페이로드에 실어 runWithLanguage 로 감싼다(안 그러면 t() 는 fallback).
|
|
222
|
+
import { job } from 'gaonjs/async'
|
|
223
|
+
import { runWithLanguage, t } from 'gaonjs/i18n'
|
|
224
|
+
|
|
225
|
+
export const SendDigest = job(async ({ userId, locale }: { userId: bigint; locale: string }) => {
|
|
226
|
+
const line = runWithLanguage(locale, () => t('greeting', { name: '가온' }))
|
|
227
|
+
// …line 으로 메일/알림 조립
|
|
228
|
+
})
|
|
229
|
+
```
|
|
230
|
+
|
|
215
231
|
`t()` 는 요청 컨텍스트(ALS)의 로케일을 자동으로 따라간다 — 로케일을 인자로
|
|
216
|
-
넘기고 다니지 않는다.
|
|
217
|
-
`runWithLanguage(lng, () => t('key'))
|
|
232
|
+
넘기고 다니지 않는다. **요청 밖(잡·크론·스크립트)** 이나 특정 로케일로 강제하려면
|
|
233
|
+
`runWithLanguage(lng, () => t('key'))` — 잡은 로케일을 페이로드에 담아 넘긴다(위 예).
|
|
218
234
|
|
|
219
235
|
## 알려진 함정
|
|
220
236
|
|
|
@@ -222,8 +238,10 @@ export function greetLine(name: string): string {
|
|
|
222
238
|
`gaon.config.ts` 에 `i18n` 이 있어야 배선된다(결정 159). 설정만 하면 자동.
|
|
223
239
|
- **키를 손으로 `string` 으로 넓히지 말 것** — `.gaon/messages.d.ts`(결정 158)가
|
|
224
240
|
키를 타입으로 좁혀 준다. `gaon check` 가 없는 키를 잡는다.
|
|
225
|
-
-
|
|
226
|
-
전환은 `this.setLocale`, 특정 로케일 강제는 `runWithLanguage`.
|
|
241
|
+
- **요청 코드에선 로케일을 함수 인자로 실어 나르지 말 것** — `t()` 는 ALS 로 요청
|
|
242
|
+
로케일을 안다. 전환은 `this.setLocale`, 특정 로케일 강제는 `runWithLanguage`. **예외:
|
|
243
|
+
잡·크론(요청 밖)은 요청 로케일이 없으므로** 로케일을 페이로드에 담아 `runWithLanguage`
|
|
244
|
+
로 감싼다(§정본 예시 · 메일 `deliver(data,{locale})` 와 동형).
|
|
227
245
|
- **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })` 로
|
|
228
246
|
명시한다(`agents/mail.md` · 결정 160).
|
|
229
247
|
- **검증 실패 문안도 로케일화된다** — 예약 namespace `validation.<code>`(예 `validation.required`)
|
|
@@ -190,21 +190,25 @@ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{
|
|
|
190
190
|
import { useChannel } from 'gaonjs/vue'
|
|
191
191
|
|
|
192
192
|
export function useRoom(roomId: number) {
|
|
193
|
-
// messages(반응형)·status·send·connect·close 를 돌려준다. 마운트에 접속.
|
|
194
|
-
const { messages, status, send } = useChannel('room', {
|
|
193
|
+
// messages·members(반응형)·status·send·connect·close 를 돌려준다. 마운트에 접속.
|
|
194
|
+
const { messages, members, status, send } = useChannel('room', {
|
|
195
195
|
params: { room: roomId },
|
|
196
196
|
onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
|
|
197
|
-
|
|
197
|
+
// members = 현재 접속자 **전체 명단**. 스냅샷·들어옴·나감을 하나로 반영하므로
|
|
198
|
+
// 델타를 손으로 병합하지 않는다. 반응형이라 template 에서 그대로 렌더해도 된다.
|
|
199
|
+
onPresence: (members) => { /* 접속자 명단이 바뀔 때마다 전체 명단으로 호출 */ },
|
|
198
200
|
onReconnect: () => { /* 재연결됨 — 놓친 데이터를 Inertia partial reload 로 따라잡기 */ },
|
|
199
201
|
})
|
|
200
|
-
return { messages, status, send }
|
|
202
|
+
return { messages, members, status, send }
|
|
201
203
|
}
|
|
202
204
|
```
|
|
203
205
|
|
|
204
206
|
- **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
|
|
205
207
|
정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
|
|
206
|
-
- **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage`
|
|
207
|
-
|
|
208
|
+
- **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출. 접속자 프레임(초기
|
|
209
|
+
스냅샷 + 이후 들어옴/나감)은 하나의 **명단**으로 합쳐져 반응형 `members` 에 반영되고
|
|
210
|
+
`onPresence(members)` 로도 통지된다(결정 272 · 콜백만으로 항상 최신 명단 · 델타 병합
|
|
211
|
+
불요). 그 외 종류는 `onFrame`(저수준 탈출구).
|
|
208
212
|
- **자동 재연결(결정 128 · 기본 켬)** — 소켓이 끊기면(서버 재시작·네트워크 blip)
|
|
209
213
|
useChannel 이 **지수 백오프**(1s·2s·5s·10s · 이후 10s 반복 · 지터)로 자동 재접속한다.
|
|
210
214
|
새 소켓은 서버가 다시 인가하고 프레즌스 스냅샷을 다시 밀어주므로 접속자 목록이
|
|
@@ -311,7 +315,9 @@ export default channel({
|
|
|
311
315
|
연결에만 전달 · 결정 227). `sendToUsers` 는 그 **채널에 접속한** 유저만
|
|
312
316
|
대상이다 — 접속 안 한(오프라인) 유저는 `0` 도달로 반환된다.
|
|
313
317
|
- **자기 자신의 join 델타** — 클라이언트는 자기 `presence:join` 델타도
|
|
314
|
-
스냅샷과 별개로
|
|
318
|
+
스냅샷과 별개로 받는다. `useChannel` 은 `members` 를 `id` 로 키잉해 이 중복을
|
|
319
|
+
**멱등**으로 흡수하므로 명단이 불어나지 않는다(결정 272). 직접 `new WebSocket`
|
|
320
|
+
을 쓰는 탈출구에서는 스스로 `id` 로 dedup 한다.
|
|
315
321
|
- **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
|
|
316
322
|
(`agents/testing.md`).
|
|
317
323
|
|
|
@@ -329,6 +335,7 @@ export default channel({
|
|
|
329
335
|
| 결정 257 | 채널 `onMessage`/`onLeave` throw 는 그 연결만 마감(§2.4) — 사용자 핸들러 예외가 unhandledRejection 으로 serve 를 죽이지 않는다(HTTP 500 대칭 · 에러 프레임 + 연결 종료) |
|
|
330
336
|
| 결정 259 | 허브 디스커버리 endpoint 는 소유 리더만 삭제(§5) — addr 일치 + revision CAS · standby 종료·리더 교대가 활성 endpoint 를 지우지 않음(재접속 서버 허브 발견 보존) |
|
|
331
337
|
| 결정 260 | 리스 TTL 역할별 독립(§5) — 허브·스케줄러가 `gaon_lease_<역할>` 별도 버킷 · 공유 버킷 MaxAge 플래핑 제거 |
|
|
338
|
+
| 결정 272 | `useChannel` 접속자 명단 조립(§4) — `onPresence(members)` 가 스냅샷+join+leave 를 하나의 전체 명단으로 반영 · 반응형 `members` Ref 추가(`messages` 대칭) · id 키 멱등 · 종전엔 스냅샷만 `onPresence`(`data`=undefined)·델타는 `onFrame` 으로만 흘러 문서대로 짠 접속자 목록이 조용히 빈 채 남던 결함 |
|
|
332
339
|
|
|
333
340
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
334
341
|
|
|
@@ -82,7 +82,7 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
|
82
82
|
vite** 가 번들하도록 `main.ts` 에서 **app-side 정적 import** 로 주입한다. (과거 `@gaonjs/vue` 안의
|
|
83
83
|
**변수-specifier 동적 import** 는 vite 가 번들하지 못해 실 브라우저 마운트가 blank 로 깨졌다 — **폐기**.)
|
|
84
84
|
- **doctor 가 지킨다**: `seal-security` 가 `seal: true` 인데 main.ts 배선이 없으면 **에러 + 수리 안내**를 내고,
|
|
85
|
-
`gaon
|
|
85
|
+
`gaon doctor --fix --yes` 의 `seal-client-wiring` fixer 가 배선을 자동 생성한다. 개발자·AI 멘탈모델은 여전히
|
|
86
86
|
"seal: true 한 줄" 이고, 배선 누락은 doctor 가 즉시 잡는다(§7.5.3 에러=수리 안내서). 런타임도 봉인 문서를
|
|
87
87
|
배선 없이 받으면 정확한 수리 메시지로 throw 한다(blank 대신).
|
|
88
88
|
- 미설치로 `seal: true` 를 켜면 **부팅 에러**(수리 안내). `masterSecret` 설정 표면은 없다 — 미끼 literal
|
|
@@ -170,7 +170,7 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
170
170
|
|
|
171
171
|
## 알려진 함정
|
|
172
172
|
|
|
173
|
-
1. **main.ts 배선 누락** → 봉인 문서를 브라우저가 못 열어 화면 blank. → `gaon check`(`seal-security`)가 잡고
|
|
173
|
+
1. **main.ts 배선 누락** → 봉인 문서를 브라우저가 못 열어 화면 blank. → `gaon check`(`seal-security`)가 잡고 `gaon doctor --fix --yes` 가 배선. 런타임도 수리 안내 throw.
|
|
174
174
|
2. **변수-specifier 동적 import** (`const s='@gaonjs/seal/client'; import(s)`) — vite 가 번들 못 해 프로덕션 blank. **폐기됨** — app-side 정적 주입만.
|
|
175
175
|
3. **전역 CSP 완화 금지** — `'wasm-unsafe-eval'` 은 seal 앱 응답에만. 비-seal 앱 strict CSP 유지.
|
|
176
176
|
4. **"seal 켰으니 검증 느슨" = 가짜 안심** — 서버 방어층(§0) 전부 유지. seal 은 이유가 못 된다.
|
|
@@ -60,6 +60,13 @@
|
|
|
60
60
|
- **로그인은 세션 ID 를 재생성하고 로그아웃은 세션을 파기한다 (결정 254)**:
|
|
61
61
|
`this.auth.login`/`logout` 이 자동 처리한다(session fixation 방어). 스캐폴드는
|
|
62
62
|
`await this.auth.login(user)` 형태다 — 손으로 세션을 조작하지 않는다.
|
|
63
|
+
- **가입 이메일은 유니크다 (결정 256)**: `gaon g auth` 스키마의 `email` 은
|
|
64
|
+
`t.string().max(255).unique()` — DDL UNIQUE 로 물성화돼 중복 가입을 DB 층에서
|
|
65
|
+
막는다(무결성 backstop). 가입 컨트롤러는 그 위에 **pre-check**(`if (await
|
|
66
|
+
User.where('email','=',email).first()) …`)를 깔아 중복 시 raw DB 500 대신
|
|
67
|
+
친절한 폼 에러(`props.error` → Alert)로 마감한다(유니크 제약이 TOCTOU 레이스의
|
|
68
|
+
backstop). 유니크 없이 두면 중복 가입이 조용히 성공하고 로그인 `.first()` 가
|
|
69
|
+
비결정이 된다.
|
|
63
70
|
- **비-web 앱은 시큐어 기본이다** (결정 155): `gaon g auth --app admin` 은
|
|
64
71
|
**공개 회원가입(registration)을 깔지 않는다** — 관리 앱에 공개 가입이 열리고
|
|
65
72
|
로그인한 일반 고객이 관리 화면을 보던 위험 기본을 구조적으로 막는다. 대신 보호
|
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
안 생기며, 테스트가 남긴 잡을 개발 워커가 처리하지도 않는다 — **테스트 전에
|
|
32
32
|
워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이 격리가 없어 개발 스택과
|
|
33
33
|
섞이니 `gaon test` 를 쓴다.)
|
|
34
|
+
- **스캐폴드 `package.json` 의 `"test"` 스크립트가 곧 `gaon test`(결정 271)** — 그래서
|
|
35
|
+
`pnpm test`(또는 `npm test`)도 정본 하네스를 그대로 탄다. `"vitest run"` 으로 바꾸면
|
|
36
|
+
프로비저닝·스트림 격리가 사라지니 두지 말 것.
|
|
34
37
|
- SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
|
|
35
38
|
아니다.
|
|
36
39
|
|
|
@@ -257,9 +257,12 @@ async create() {
|
|
|
257
257
|
| `required` | — | 필수 컬럼 누락 |
|
|
258
258
|
| `too_long` | `{ max, len }` | `.max(n)` 초과 |
|
|
259
259
|
| `not_allowed` | `{ value, allowed }` | enum 밖 값 |
|
|
260
|
-
| `
|
|
260
|
+
| `not_number` | `{ value }` | number·numeric(decimal) 변환 실패 |
|
|
261
|
+
| `not_integer` | `{ value }` | 정수(integer·smallint·bigint) 변환 실패 |
|
|
261
262
|
| `not_boolean` | `{ value }` | boolean 변환 실패 |
|
|
262
263
|
| `not_date` | `{ value }` | 날짜 변환 실패 |
|
|
264
|
+
| `not_uuid` | `{ value }` | uuid 형식 아님 |
|
|
265
|
+
| `invalid` | — | 그 외 coerce 예외(비-CoerceError) |
|
|
263
266
|
|
|
264
267
|
```json
|
|
265
268
|
// locales/en.json — 앱이 검증 문안을 로케일별로 준다(i18next {{max}} 보간).
|
|
@@ -337,7 +340,10 @@ declare module 'gaonjs/vue' {
|
|
|
337
340
|
interface GaonSharedProps { locale: string; theme: string }
|
|
338
341
|
}
|
|
339
342
|
// 페이지에서
|
|
340
|
-
const
|
|
343
|
+
const shared = useShared() // 객체로 잡으면 반응형 — 매 접근이 최신 props 를 읽는다
|
|
344
|
+
shared.locale // 로그인/로그아웃·플래시로 서버가 새 값을 주면 즉시 갱신
|
|
345
|
+
// 구조분해 const { locale } = useShared() 는 그 시점 값을 한 번 읽는 **스냅샷**(반응성 없음).
|
|
346
|
+
// 반응성이 필요하면 객체(shared.locale)로 접근한다(pageProps 결정 99 와 같은 계약).
|
|
341
347
|
```
|
|
342
348
|
|
|
343
349
|
- **코어 3종(currentUser·csrf·flash)은 예약** — `sharedProps` 가 이 이름을 반환하면
|
|
@@ -411,7 +417,9 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
411
417
|
|
|
412
418
|
- 세션 쿠키가 기본, JWT 는 **API 앱 전용 옵션** (v0.11 확정).
|
|
413
419
|
- 세션은 앱별 완전 분리 (Fastify 캡슐화 스코프): 쿠키 이름(`<app>_sid`) ·
|
|
414
|
-
서명 secret · Redis 키 prefix
|
|
420
|
+
서명 secret · Redis 키 prefix 가 앱 단위로 갇힌다. (쿠키 path 는 `/` 고정 —
|
|
421
|
+
프리픽스·서브도메인 양쪽 접근에 쿠키가 실리려면 정적 path 가 `/` 여야 한다 ·
|
|
422
|
+
결정 142. 분리는 위 세 축으로 완성된다.)
|
|
415
423
|
- 스캐폴드는 `gaon g auth` — 로그인/회원가입 컨트롤러·페이지·라우트 일습.
|
|
416
424
|
- **로그인 필요 액션의 정답 = `this.requireAuth()`** (결정 57). notFound 처럼
|
|
417
425
|
예외로 마감하지만, **`if` 가드 자체가 없다**는 게 핵심:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.2",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
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.15.
|
|
31
|
-
"@gaonjs/config": "0.
|
|
30
|
+
"@gaonjs/async": "0.15.3",
|
|
31
|
+
"@gaonjs/config": "0.18.0",
|
|
32
32
|
"@gaonjs/core": "0.2.4",
|
|
33
|
-
"@gaonjs/
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/mail": "0.3.
|
|
36
|
-
"@gaonjs/web": "0.20.
|
|
33
|
+
"@gaonjs/i18n": "0.2.4",
|
|
34
|
+
"@gaonjs/data": "0.17.3",
|
|
35
|
+
"@gaonjs/mail": "0.3.3",
|
|
36
|
+
"@gaonjs/web": "0.20.4"
|
|
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})\""
|