@gaonjs/cli 0.21.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/dist/commands/db.js +2 -2
- package/dist/db.d.ts +7 -2
- package/dist/db.js +30 -17
- package/dist/doctor/async-offload.d.ts +23 -0
- package/dist/doctor/async-offload.js +236 -0
- package/dist/doctor/csrf-wiring.d.ts +5 -0
- package/dist/doctor/csrf-wiring.js +72 -0
- package/dist/doctor/internal-anchor.d.ts +6 -0
- package/dist/doctor/internal-anchor.js +122 -0
- package/dist/doctor/pageprops-destructure.d.ts +5 -0
- package/dist/doctor/pageprops-destructure.js +84 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +2 -4
- package/dist/doctor.d.ts +4 -0
- package/dist/doctor.js +22 -2
- package/dist/generate.d.ts +8 -0
- package/dist/generate.js +51 -7
- package/dist/index.d.ts +6 -2
- package/dist/index.js +27 -21
- package/dist/serve.js +7 -0
- package/dist/templates/auth/Dashboard.vue.tpl +5 -4
- package/dist/templates/auth/Login.vue.tpl +9 -7
- package/dist/templates/auth/Signup.vue.tpl +7 -6
- package/dist/templates/project/AGENTS.md.tpl +33 -4
- package/dist/templates/project/agents/async.md.tpl +55 -0
- package/dist/templates/project/agents/frontend.md.tpl +21 -1
- package/dist/templates/project/agents/security.md.tpl +7 -0
- package/dist/templates/project/agents/web.md.tpl +47 -4
- package/dist/templates/project/apps/web/app.config.ts.tpl +14 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +6 -2
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# @gaonjs/cli
|
|
2
2
|
|
|
3
|
-
Gaon CLI
|
|
3
|
+
Gaon CLI — `gaon` 명령. 프로젝트 스캐폴드(`gaon new`), 개발 스택 통합(`gaon dev`),
|
|
4
|
+
제너레이터(`gaon g auth`·scaffold), 마이그레이션(`gaon db …`), 통합 검사
|
|
5
|
+
(`gaon check`), 정적 검사(`gaon doctor` · 20종), 운영 부팅(`gaon serve`). 모든
|
|
6
|
+
명령은 `--json` 출력을 지원합니다.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
이 패키지는 [`gaonjs`](https://www.npmjs.com/package/gaonjs) 파사드에 포함됩니다 —
|
|
9
|
+
`gaonjs` 를 설치하면 `gaon` 명령을 쓸 수 있습니다.
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
```bash
|
|
12
|
+
gaon new my-app && cd my-app && gaon dev
|
|
13
|
+
```
|
|
8
14
|
|
|
9
15
|
- 홈페이지 / 문서: https://gaonjs.dev
|
|
16
|
+
- 라이선스: MIT
|
package/dist/commands/db.js
CHANGED
|
@@ -57,8 +57,8 @@ export async function runDbCommand(subcommand, opts = {}) {
|
|
|
57
57
|
return r.exitCode;
|
|
58
58
|
}
|
|
59
59
|
if (subcommand === 'seed') {
|
|
60
|
-
//
|
|
61
|
-
const r = await runDbSeedCommand({ root: cwd, json });
|
|
60
|
+
// 결정 101: seed 도 diff/migrate 와 같은 설정 해석 경로(config → env)를 쓴다.
|
|
61
|
+
const r = await runDbSeedCommand({ root: cwd, json, configPath, dbKey });
|
|
62
62
|
emit(r.text, r.json);
|
|
63
63
|
return r.exitCode;
|
|
64
64
|
}
|
package/dist/db.d.ts
CHANGED
|
@@ -3,8 +3,12 @@ export interface DbSeedOptions {
|
|
|
3
3
|
readonly json?: boolean;
|
|
4
4
|
/** 도메인 루트(domain/ 의 부모). 기본 cwd. */
|
|
5
5
|
readonly root?: string;
|
|
6
|
-
/** DB URL
|
|
6
|
+
/** DB URL 오버라이드(reset 내부 호출 등). 생략 시 config → GAON_DATABASE_URL. */
|
|
7
7
|
readonly databaseUrl?: string;
|
|
8
|
+
/** --config <path> 오버라이드. 없으면 root/gaon.config.ts 관례. */
|
|
9
|
+
readonly configPath?: string;
|
|
10
|
+
/** 커넥션 키(§4.5). 기본 'main'. */
|
|
11
|
+
readonly dbKey?: string;
|
|
8
12
|
}
|
|
9
13
|
/** domain/seed.ts 의 default export 를 읽어 SeedDef 를 돌려준다(없으면 안내 에러). */
|
|
10
14
|
export declare function loadSeed(root: string): Promise<SeedDef>;
|
|
@@ -15,6 +19,7 @@ export interface DbSeedResult {
|
|
|
15
19
|
}
|
|
16
20
|
/**
|
|
17
21
|
* `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
|
|
18
|
-
* 커넥션
|
|
22
|
+
* 커넥션 해석은 diff·migrate·status·reset 과 같은 경로(resolveDbTarget)를
|
|
23
|
+
* 쓴다(결정 101). 커넥션 정리는 항상 수행한다.
|
|
19
24
|
*/
|
|
20
25
|
export declare function runDbSeedCommand(opts?: DbSeedOptions): Promise<DbSeedResult>;
|
package/dist/db.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// @gaonjs/cli · `gaon db seed` (§7 M8 — domain/seed.ts 실행)
|
|
2
2
|
//
|
|
3
3
|
// 관례: `domain/seed.ts` 의 default export(seed 정의)를 실행한다. 커넥션은
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// diff·migrate·status·reset 과 **같은 해석 경로**(resolveDbTarget)로 연결한다
|
|
5
|
+
// (결정 101). 우선순위: databaseUrl 오버라이드 > gaon.config.ts db.<키> >
|
|
6
|
+
// GAON_DATABASE_URL. 과거엔 seed 만 GAON_DATABASE_URL 만 봐서, config 에
|
|
7
|
+
// db.main 만 선언한 프로젝트는 migrate 는 되는데 seed 만 실패했다(.env 진입점
|
|
8
|
+
// 통일 wave 동형). 모든 명령은 --json 을 함께 낸다(CLAUDE.md §4).
|
|
7
9
|
import { existsSync } from 'node:fs';
|
|
8
10
|
import { join } from 'node:path';
|
|
9
11
|
import { pathToFileURL } from 'node:url';
|
|
10
|
-
import { env, EnvError } from '@gaonjs/core';
|
|
11
12
|
import { createDb, registerConnection, destroyAllConnections, isSeedDef, } from '@gaonjs/data';
|
|
12
13
|
import { registerTsResolve } from './tsResolve.js';
|
|
14
|
+
import { resolveDbTarget } from './db/resolve.js';
|
|
13
15
|
/** DB URL 에서 어댑터를 추정한다(gaon work 와 동일 규칙). */
|
|
14
16
|
function dbConfigFromUrl(url) {
|
|
15
17
|
if (url.startsWith('mysql://') || url.startsWith('mariadb://')) {
|
|
@@ -34,29 +36,40 @@ export async function loadSeed(root) {
|
|
|
34
36
|
}
|
|
35
37
|
return mod.default;
|
|
36
38
|
}
|
|
39
|
+
const SEED_OK = {
|
|
40
|
+
text: ` gaon db seed · 완료 — domain/seed.ts 실행됨`,
|
|
41
|
+
json: { command: 'seed', ok: true },
|
|
42
|
+
};
|
|
37
43
|
/**
|
|
38
44
|
* `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
|
|
39
|
-
* 커넥션
|
|
45
|
+
* 커넥션 해석은 diff·migrate·status·reset 과 같은 경로(resolveDbTarget)를
|
|
46
|
+
* 쓴다(결정 101). 커넥션 정리는 항상 수행한다.
|
|
40
47
|
*/
|
|
41
48
|
export async function runDbSeedCommand(opts = {}) {
|
|
42
49
|
const root = opts.root ?? process.cwd();
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
const dbKey = opts.dbKey ?? 'main';
|
|
51
|
+
// databaseUrl 명시 오버라이드(reset 내부 호출) — URL 로 직접 커넥션을 세운다.
|
|
52
|
+
if (opts.databaseUrl) {
|
|
53
|
+
const cfg = dbConfigFromUrl(opts.databaseUrl);
|
|
54
|
+
registerConnection(dbKey, createDb(cfg), cfg.adapter);
|
|
55
|
+
try {
|
|
56
|
+
const def = await loadSeed(root);
|
|
57
|
+
await def.run();
|
|
58
|
+
return { exitCode: 0, ...SEED_OK };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await destroyAllConnections();
|
|
62
|
+
}
|
|
48
63
|
}
|
|
49
|
-
|
|
64
|
+
// 그 외 — migrate 와 같은 해석 경로: gaon.config.ts db.<키> → GAON_DATABASE_URL.
|
|
65
|
+
// config 도 env 도 없으면 resolveDbTarget 이 수리 안내 에러를 던진다(§7.5.3).
|
|
66
|
+
const target = await resolveDbTarget({ cwd: root, dbKey, configPath: opts.configPath });
|
|
50
67
|
try {
|
|
51
68
|
const def = await loadSeed(root);
|
|
52
69
|
await def.run();
|
|
53
|
-
return {
|
|
54
|
-
exitCode: 0,
|
|
55
|
-
text: ` gaon db seed · 완료 — domain/seed.ts 실행됨`,
|
|
56
|
-
json: { command: 'seed', ok: true },
|
|
57
|
-
};
|
|
70
|
+
return { exitCode: 0, ...SEED_OK };
|
|
58
71
|
}
|
|
59
72
|
finally {
|
|
60
|
-
await
|
|
73
|
+
await target.close();
|
|
61
74
|
}
|
|
62
75
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정). */
|
|
3
|
+
export declare function isApiApp(appConfigSource: string): boolean;
|
|
4
|
+
/** 파일이 메일 SDK 를 직접 import 하는가(단위 테스트 진입점). */
|
|
5
|
+
export declare function importsMailSdk(source: string): boolean;
|
|
6
|
+
/** 파일이 이미지 처리 라이브러리를 직접 import 하는가(단위 테스트 진입점). */
|
|
7
|
+
export declare function importsImageLib(source: string): boolean;
|
|
8
|
+
/** 액션 본문 텍스트에 리터럴 외부 URL 로의 fetch/axios 호출이 있는가(단위 테스트 진입점). */
|
|
9
|
+
export declare function callsExternalHttp(actionBody: string): boolean;
|
|
10
|
+
/** apps/ 의 컨트롤러를 훑어 요청 경로 인라인 무거운/외부 작업을 경고로 낸다. */
|
|
11
|
+
export declare function checkAsyncOffload(cwd: string): Promise<RuleReport>;
|
|
12
|
+
interface PageAction {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly line: number;
|
|
15
|
+
readonly body: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* controller({ … }) 안에서 **페이지 액션**(return this.render/redirect 을 담는
|
|
19
|
+
* 액션)만 골라 이름·라인·본문 텍스트를 낸다. JSON 액션(반환값=응답 · render/
|
|
20
|
+
* redirect 없음)은 제외한다 — 외부 프록시/집계가 정당한 자리라 C arm 대상 밖.
|
|
21
|
+
*/
|
|
22
|
+
export declare function pageActions(file: string, source: string): PageAction[];
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 요청 경로 인라인 무거운/외부 작업 검출 (결정 102·103 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// 비동기 배치 판단표(결정 102 · AGENTS §3.5 · agents/async.md 서두)의 강제 장치다.
|
|
4
|
+
// 컨트롤러 액션 안에서 "응답에 필요 없는데 무겁거나 느린 일" 을 동기로 하면 웹
|
|
5
|
+
// 응답이 그 무게를 진다 — Node 는 싱글 스레드라 이웃 요청까지 전부 세운다. 정답은
|
|
6
|
+
// `domain/jobs/` 잡으로 빼고 `.later()` 로 발행하는 것(§3.5 · async.md §1).
|
|
7
|
+
//
|
|
8
|
+
// 검출 3종(대표 인라인 패턴):
|
|
9
|
+
// A) 메일 SDK 직접 import (nodemailer·resend·@sendgrid/mail·mailgun) — 컨트롤러는
|
|
10
|
+
// 잡 발행만 (async.md 함정 · async-flow forbidden 과 동일 집합). 앱 종류 무관.
|
|
11
|
+
// B) 이미지/미디어 처리 라이브러리 import (sharp·jimp·gm) — CPU 무거운 작업이
|
|
12
|
+
// 요청 경로에 있다. 앱 종류 무관.
|
|
13
|
+
// C) 외부 HTTP 호출 (fetch/axios 에 **리터럴 `http(s)://` URL**) — 페이지 액션
|
|
14
|
+
// 안에서. 느린/불안정 외부 호출은 응답 시간을 보호하려면 잡으로.
|
|
15
|
+
//
|
|
16
|
+
// 오탐 설계(결정 103 · response-mixing 교훈 "확신 없으면 잡지 않는다"):
|
|
17
|
+
// · **API 앱 제외** — app.config 에 `strategy: 'jwt'` 면 외부 API 호출이 정당
|
|
18
|
+
// (§3 데이터 경로 4종 · 외부 공개 API 앱). 해당 앱은 C(외부 HTTP)를 끈다.
|
|
19
|
+
// · **JSON 액션 제외** — 반환값=응답 액션(E-3)은 프록시/집계일 수 있어 C 제외.
|
|
20
|
+
// C 는 페이지 액션(render/redirect)에만 적용한다.
|
|
21
|
+
// · **리터럴 외부 URL 만** — 변수 URL·상대/내부 경로(`/...`)·`localhost`·
|
|
22
|
+
// `127.0.0.1` 은 정적 판별 불가/내부 호출이라 **잡지 않는다**(빠른 내부 호출
|
|
23
|
+
// 오탐 방지). internal-anchor 가 루트-상대/템플릿리터럴만 잡은 것과 동형.
|
|
24
|
+
// · 주석은 라인 보존 스트립으로 제외 — "이렇게 쓰지 말라" 설명 주석 오탐 방지.
|
|
25
|
+
//
|
|
26
|
+
// 판정은 소스 텍스트 + TS AST(액션 경계)만 본다 — 파일을 실행하지 않는다.
|
|
27
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
29
|
+
import { join, relative } from 'node:path';
|
|
30
|
+
import ts from 'typescript';
|
|
31
|
+
// ── 검출 시그니처 ─────────────────────────────────────────────────────
|
|
32
|
+
/** 메일 SDK 모듈 import (컨트롤러 = 잡 발행만 · async.md 함정 · async-flow forbidden 동일 집합). */
|
|
33
|
+
const MAIL_SDK_IMPORT = /import\s+[\s\S]*?\bfrom\s+['"](?:nodemailer|resend|@sendgrid\/mail|mailgun[.\-/][^'"]*|mailgun)['"]/;
|
|
34
|
+
/** 이미지/미디어 처리 라이브러리 import (CPU 무거운 작업). */
|
|
35
|
+
const IMAGE_LIB_IMPORT = /import\s+[\s\S]*?\bfrom\s+['"](?:sharp|jimp|gm)['"]/;
|
|
36
|
+
/**
|
|
37
|
+
* 페이지 액션 본문 안 외부 HTTP 호출 — 리터럴 `http(s)://` URL 만.
|
|
38
|
+
* localhost·127.0.0.1(내부)은 음성 룩어헤드로 제외한다. 변수 URL·상대 경로는
|
|
39
|
+
* 매치하지 않는다(정적 판별 불가 = 잡지 않는다).
|
|
40
|
+
*/
|
|
41
|
+
const EXTERNAL_HTTP = [
|
|
42
|
+
// fetch('https://...') · fetch(`https://...`)
|
|
43
|
+
/\bfetch\s*\(\s*[`'"]https?:\/\/(?!localhost|127\.0\.0\.1)/i,
|
|
44
|
+
// axios('https://...') · axios.get('https://...') · axios({ url: 'https://...' })
|
|
45
|
+
/\baxios\s*(?:\.\s*(?:get|post|put|patch|delete|request|head)\s*)?\(\s*(?:\{[\s\S]*?\burl\s*:\s*)?[`'"]https?:\/\/(?!localhost|127\.0\.0\.1)/i,
|
|
46
|
+
];
|
|
47
|
+
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정). */
|
|
48
|
+
export function isApiApp(appConfigSource) {
|
|
49
|
+
return /strategy\s*:\s*['"]jwt['"]/.test(appConfigSource);
|
|
50
|
+
}
|
|
51
|
+
/** 파일이 메일 SDK 를 직접 import 하는가(단위 테스트 진입점). */
|
|
52
|
+
export function importsMailSdk(source) {
|
|
53
|
+
return MAIL_SDK_IMPORT.test(stripComments(source));
|
|
54
|
+
}
|
|
55
|
+
/** 파일이 이미지 처리 라이브러리를 직접 import 하는가(단위 테스트 진입점). */
|
|
56
|
+
export function importsImageLib(source) {
|
|
57
|
+
return IMAGE_LIB_IMPORT.test(stripComments(source));
|
|
58
|
+
}
|
|
59
|
+
/** 액션 본문 텍스트에 리터럴 외부 URL 로의 fetch/axios 호출이 있는가(단위 테스트 진입점). */
|
|
60
|
+
export function callsExternalHttp(actionBody) {
|
|
61
|
+
const stripped = stripComments(actionBody);
|
|
62
|
+
return EXTERNAL_HTTP.some((re) => re.test(stripped));
|
|
63
|
+
}
|
|
64
|
+
// ── 검사 본체 ─────────────────────────────────────────────────────────
|
|
65
|
+
/** apps/ 의 컨트롤러를 훑어 요청 경로 인라인 무거운/외부 작업을 경고로 낸다. */
|
|
66
|
+
export async function checkAsyncOffload(cwd) {
|
|
67
|
+
const appsDir = join(cwd, 'apps');
|
|
68
|
+
const issues = [];
|
|
69
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
70
|
+
// 앱 종류 판정 — API 앱이면 외부 HTTP arm 을 끈다(오탐 방지 · 결정 103).
|
|
71
|
+
const acPath = join(appsDir, app, 'app.config.ts');
|
|
72
|
+
const acSource = existsSync(acPath) ? await readFile(acPath, 'utf8') : '';
|
|
73
|
+
const apiApp = isApiApp(acSource);
|
|
74
|
+
const ctrlDir = join(appsDir, app, 'controllers');
|
|
75
|
+
for (const file of await safeListFiles(ctrlDir)) {
|
|
76
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
|
|
77
|
+
continue;
|
|
78
|
+
const full = join(ctrlDir, file);
|
|
79
|
+
const rel = relative(cwd, full);
|
|
80
|
+
const source = await readFile(full, 'utf8');
|
|
81
|
+
const clean = stripComments(source);
|
|
82
|
+
// A) 메일 SDK import — 앱 종류 무관 경고(컨트롤러는 잡 발행만).
|
|
83
|
+
const mailImport = firstMatch(clean, MAIL_SDK_IMPORT);
|
|
84
|
+
if (mailImport) {
|
|
85
|
+
issues.push(offloadIssue(rel, lineOf(source, mailImport.index), 'mail', app, `메일 SDK 직접 import (${mailImport.text.trim().slice(0, 60)}…)`, '메일 발송은 응답에 필요 없고 실패 시 재시도가 필요한 일'));
|
|
86
|
+
}
|
|
87
|
+
// B) 이미지 처리 라이브러리 import — 앱 종류 무관 경고(CPU 무거운 작업).
|
|
88
|
+
const imgImport = firstMatch(clean, IMAGE_LIB_IMPORT);
|
|
89
|
+
if (imgImport) {
|
|
90
|
+
issues.push(offloadIssue(rel, lineOf(source, imgImport.index), 'image', app, `이미지 처리 라이브러리 import (${imgImport.text.trim().slice(0, 60)}…)`, '이미지 리사이즈/변환은 CPU 무거운 작업 — 루프를 막아 이웃 요청을 세운다'));
|
|
91
|
+
}
|
|
92
|
+
// C) 외부 HTTP — 페이지 액션(render/redirect)에서만 · API 앱 제외.
|
|
93
|
+
if (apiApp)
|
|
94
|
+
continue;
|
|
95
|
+
for (const action of pageActions(full, source)) {
|
|
96
|
+
if (!callsExternalHttp(action.body))
|
|
97
|
+
continue;
|
|
98
|
+
issues.push(offloadIssue(rel, action.line, 'http', app, `페이지 액션 '${action.name}' 안에서 외부 HTTP 호출(리터럴 URL)`, '느리거나 불안정한 외부 호출이 응답 시간에 그대로 실린다'));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { rule: 'async-offload', issues };
|
|
103
|
+
}
|
|
104
|
+
/** 공통 경고 메시지 조립 — §7.5.3(에러 = 수리 안내서)대로 "→ 어떻게 고치라" 까지. */
|
|
105
|
+
function offloadIssue(rel, line, kind, app, what, why) {
|
|
106
|
+
const apiHint = kind === 'http'
|
|
107
|
+
? `\n→ 외부 공개 API 앱(모바일·서드파티)이라 외부 호출이 정당하면 apps/${app}/app.config.ts 에 auth: { strategy: 'jwt', ... } 를 선언하세요(이 경고 제외).`
|
|
108
|
+
: '';
|
|
109
|
+
return {
|
|
110
|
+
rule: 'async-offload',
|
|
111
|
+
level: 'warning',
|
|
112
|
+
file: rel,
|
|
113
|
+
line,
|
|
114
|
+
message: `요청 경로 인라인 작업: ${rel}:${line} · ${what}. ${why} — ` +
|
|
115
|
+
`Node 는 싱글 스레드라 무거운/느린 작업 하나가 이웃 요청을 전부 세웁니다(결정 102 판단표).\n` +
|
|
116
|
+
`→ domain/jobs/ 아래 잡으로 빼고 컨트롤러에서는 .later() 로 발행하세요:\n` +
|
|
117
|
+
` // domain/jobs/<camel>.ts — export const <Pascal> = job(async (…) => { …무거운 일… }, { retries: 3 })\n` +
|
|
118
|
+
` // 컨트롤러: await <Pascal>.later(…) (응답은 즉시 반환 · 처리는 gaon work 프로세스)\n` +
|
|
119
|
+
`→ 커밋 뒤에만 나가야 하면 서비스 afterCommit() 또는 아웃박스(agents/async.md §4).` +
|
|
120
|
+
apiHint,
|
|
121
|
+
detail: { app, kind },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* controller({ … }) 안에서 **페이지 액션**(return this.render/redirect 을 담는
|
|
126
|
+
* 액션)만 골라 이름·라인·본문 텍스트를 낸다. JSON 액션(반환값=응답 · render/
|
|
127
|
+
* redirect 없음)은 제외한다 — 외부 프록시/집계가 정당한 자리라 C arm 대상 밖.
|
|
128
|
+
*/
|
|
129
|
+
export function pageActions(file, source) {
|
|
130
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
131
|
+
const out = [];
|
|
132
|
+
const visit = (node) => {
|
|
133
|
+
if (ts.isCallExpression(node) && isControllerCall(node)) {
|
|
134
|
+
const arg = node.arguments[0];
|
|
135
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
136
|
+
for (const prop of arg.properties) {
|
|
137
|
+
const name = actionName(prop);
|
|
138
|
+
const body = actionBodyNode(prop);
|
|
139
|
+
if (!name || !body)
|
|
140
|
+
continue;
|
|
141
|
+
if (!rendersPage(body))
|
|
142
|
+
continue; // JSON/void 액션 제외
|
|
143
|
+
const { line } = sf.getLineAndCharacterOfPosition(prop.getStart(sf));
|
|
144
|
+
out.push({ name, line: line + 1, body: body.getText(sf) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
ts.forEachChild(node, visit);
|
|
149
|
+
};
|
|
150
|
+
visit(sf);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
function isControllerCall(node) {
|
|
154
|
+
const e = node.expression;
|
|
155
|
+
if (ts.isIdentifier(e) && e.text === 'controller')
|
|
156
|
+
return true;
|
|
157
|
+
if (ts.isPropertyAccessExpression(e) && e.name.text === 'controller')
|
|
158
|
+
return true;
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
function actionName(prop) {
|
|
162
|
+
if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name))
|
|
163
|
+
return prop.name.text;
|
|
164
|
+
if (ts.isPropertyAssignment(prop) &&
|
|
165
|
+
ts.isIdentifier(prop.name) &&
|
|
166
|
+
(ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
|
|
167
|
+
return prop.name.text;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
function actionBodyNode(prop) {
|
|
172
|
+
if (ts.isMethodDeclaration(prop))
|
|
173
|
+
return prop.body;
|
|
174
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
175
|
+
if (ts.isArrowFunction(prop.initializer))
|
|
176
|
+
return prop.initializer.body;
|
|
177
|
+
if (ts.isFunctionExpression(prop.initializer))
|
|
178
|
+
return prop.initializer.body;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
/** 액션 본문이 this.render/this.redirect 를 하나라도 담는가(= 페이지 액션). */
|
|
183
|
+
function rendersPage(body) {
|
|
184
|
+
let renders = false;
|
|
185
|
+
const visit = (node) => {
|
|
186
|
+
if (renders)
|
|
187
|
+
return;
|
|
188
|
+
// 중첩 함수(콜백)로 내려가지 않는다 — 액션 자신의 응답만 본다.
|
|
189
|
+
if (node !== body &&
|
|
190
|
+
(ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node))) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (ts.isPropertyAccessExpression(node) &&
|
|
194
|
+
node.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
195
|
+
(node.name.text === 'render' || node.name.text === 'redirect')) {
|
|
196
|
+
renders = true;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
ts.forEachChild(node, visit);
|
|
200
|
+
};
|
|
201
|
+
visit(body);
|
|
202
|
+
return renders;
|
|
203
|
+
}
|
|
204
|
+
// ── 텍스트 유틸 ───────────────────────────────────────────────────────
|
|
205
|
+
/** 라인 보존 주석 스트립 — 안티패턴을 설명하는 주석이 오탐을 내지 않도록. */
|
|
206
|
+
function stripComments(source) {
|
|
207
|
+
return source
|
|
208
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
209
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + ' '.repeat(m.length - p1.length));
|
|
210
|
+
}
|
|
211
|
+
function firstMatch(source, re) {
|
|
212
|
+
const m = re.exec(source);
|
|
213
|
+
return m ? { index: m.index, text: m[0] } : undefined;
|
|
214
|
+
}
|
|
215
|
+
/** 문자 오프셋 → 1-기반 라인 번호. */
|
|
216
|
+
function lineOf(source, index) {
|
|
217
|
+
return source.slice(0, index).split('\n').length;
|
|
218
|
+
}
|
|
219
|
+
async function safeListDirs(dir) {
|
|
220
|
+
try {
|
|
221
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
222
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function safeListFiles(dir) {
|
|
229
|
+
try {
|
|
230
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
231
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** routes.ts 소스에 상태 변경(비-GET) 라우트가 있는지 판정한다(단위 테스트 진입점). */
|
|
3
|
+
export declare function hasStateChangingRoutes(routesSource: string): boolean;
|
|
4
|
+
/** apps/ 를 훑어 CSRF/세션 미배선(비-GET 라우트 + session 없음)을 경고로 낸다. */
|
|
5
|
+
export declare function checkCsrfWiring(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · CSRF/세션 배선 검사 (결정 93)
|
|
2
|
+
//
|
|
3
|
+
// 규칙 8(§2.5.1): 보안 기본값(CORS·rate limit·CSRF)은 코어에서 기본 켬이다.
|
|
4
|
+
// CSRF 는 세션 위에 얹힌다(session.ts) — 세션이 없으면 CSRF 토큰을 저장·검증할
|
|
5
|
+
// 곳이 없어 상태 변경 요청(POST/PUT/PATCH/DELETE)이 **무방비**로 통과한다.
|
|
6
|
+
//
|
|
7
|
+
// 이 검사는 앱 routes.ts 에 비-GET 라우트가 있는데 그 앱 app.config.ts 에
|
|
8
|
+
// session 배선이 없으면 경고한다 — 컴파일은 통과하지만 CSRF 가 실질적으로 꺼진
|
|
9
|
+
// 상태다. JWT/API 앱(strategy:'jwt')은 토큰 인증이라 CSRF 대상이 아니므로 제외한다.
|
|
10
|
+
//
|
|
11
|
+
// 판정은 소스 텍스트 기반(가벼운 정적 검사) — routes.ts·app.config.ts 를 실행하지
|
|
12
|
+
// 않는다. gaon new 스캐폴드는 web 앱에 세션을 기본 배선하므로(결정 93) 관례를
|
|
13
|
+
// 따르면 이 경고는 나오지 않는다.
|
|
14
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { join, relative } from 'node:path';
|
|
17
|
+
import { hasSessionWiring } from './auth-wiring.js';
|
|
18
|
+
/** routes.ts 소스에 상태 변경(비-GET) 라우트가 있는지 판정한다(단위 테스트 진입점). */
|
|
19
|
+
export function hasStateChangingRoutes(routesSource) {
|
|
20
|
+
// r.post('/x', ...) · r.put/patch/delete — 메서드 + 경로 문자열.
|
|
21
|
+
if (/\.\s*(post|put|patch|delete)\s*\(\s*['"]/.test(routesSource))
|
|
22
|
+
return true;
|
|
23
|
+
// resource(s)('posts') — 리소스 라우트는 create/update/destroy(POST/PUT/DELETE)를 만든다.
|
|
24
|
+
if (/\.\s*resources?\s*\(\s*['"]/.test(routesSource))
|
|
25
|
+
return true;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/** apps/ 를 훑어 CSRF/세션 미배선(비-GET 라우트 + session 없음)을 경고로 낸다. */
|
|
29
|
+
export async function checkCsrfWiring(cwd) {
|
|
30
|
+
const appsDir = join(cwd, 'apps');
|
|
31
|
+
const issues = [];
|
|
32
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
33
|
+
const routesPath = join(appsDir, app, 'routes.ts');
|
|
34
|
+
const routesSource = await readFile(routesPath, 'utf8').catch(() => undefined);
|
|
35
|
+
if (routesSource === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
if (!hasStateChangingRoutes(routesSource))
|
|
38
|
+
continue;
|
|
39
|
+
const acPath = join(appsDir, app, 'app.config.ts');
|
|
40
|
+
const acRel = relative(cwd, acPath);
|
|
41
|
+
const acSource = existsSync(acPath) ? await readFile(acPath, 'utf8') : undefined;
|
|
42
|
+
// JWT/API 앱은 토큰 인증 — CSRF 대상 아님(제외).
|
|
43
|
+
const jwt = acSource != null && /strategy\s*:\s*['"]jwt['"]/.test(acSource);
|
|
44
|
+
if (jwt)
|
|
45
|
+
continue;
|
|
46
|
+
if (acSource != null && hasSessionWiring(acSource))
|
|
47
|
+
continue;
|
|
48
|
+
issues.push({
|
|
49
|
+
rule: 'csrf-wiring',
|
|
50
|
+
level: 'warning',
|
|
51
|
+
file: `apps/${app}/routes.ts`,
|
|
52
|
+
message: `CSRF 미배선: apps/${app} 에 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 ` +
|
|
53
|
+
`${acRel} 에 session 배선이 없습니다 — CSRF 는 세션 위에 얹히므로(규칙 8 · §2.5.1) ` +
|
|
54
|
+
`이대로면 상태 변경 요청이 CSRF 검증 없이 통과합니다(결정 93).\n` +
|
|
55
|
+
`→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
|
|
56
|
+
` session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' }\n` +
|
|
57
|
+
`→ 루트 gaon.config.ts 에 redis: { url: process.env.REDIS_URL } 이 켜져 있는지 확인하세요(세션 스토어).\n` +
|
|
58
|
+
`→ 토큰 인증 API 앱이라면 app.config 에 auth: { strategy: 'jwt', ... } 를 선언하세요(CSRF 대상 제외).`,
|
|
59
|
+
detail: { app },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return { rule: 'csrf-wiring', issues };
|
|
63
|
+
}
|
|
64
|
+
async function safeListDirs(dir) {
|
|
65
|
+
try {
|
|
66
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
67
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
export declare function internalAnchorHref(openTag: string): string | null;
|
|
3
|
+
/** 소스에 내부 이동 일반 앵커가 있는지(단위 테스트 진입점 · 주석은 제외). */
|
|
4
|
+
export declare function usesInternalAnchor(source: string): boolean;
|
|
5
|
+
/** apps/ 의 .vue 를 훑어 내부 이동 일반 앵커를 경고로 낸다. */
|
|
6
|
+
export declare function checkInternalAnchor(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 앱 내부 이동 일반 <a> 앵커 검출 (결정 96 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// 앱 내부 경로로 이동하는 일반 `<a href="/...">` 는 클릭마다 전체 문서를
|
|
4
|
+
// 다시 로드한다 — Inertia SPA 상태(스크롤·폼·구독)가 초기화되고, "메인
|
|
5
|
+
// 경로" 로 이동할 때마다 풀 리로드가 난다(첫 실사용 블로그에서 관측된 결함).
|
|
6
|
+
// 앱 내부 이동은 `gaonjs/vue` 의 `Link`(선언적) 또는 `router.visit`(프로그램적)
|
|
7
|
+
// 를 쓴다. 이 검사가 내부 경로 일반 앵커를 경고로 잡는다. 판정은 소스 텍스트 기반.
|
|
8
|
+
//
|
|
9
|
+
// 오탐 방지(결정 96): 외부 URL(http/https·//·mailto·tel)·프래그먼트(#)·
|
|
10
|
+
// `target="_blank"`·정적 판별 불가한 바인딩(`:href="expr"` 중 템플릿 리터럴이
|
|
11
|
+
// 아닌 것)은 건드리지 않는다. 내부로 확정 가능한 경우만 — 정적 `href="/..."`
|
|
12
|
+
// 와 `/` 로 시작하는 템플릿 리터럴 `:href="`/...`"` — 잡는다.
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
14
|
+
import { join, relative } from 'node:path';
|
|
15
|
+
// 주석을 공백으로 치환하되 줄바꿈은 보존한다(라인 번호 유지) — 안티패턴을
|
|
16
|
+
// "쓰지 말라"고 설명하는 주석(줄/블록/HTML-Vue)이 오탐을 내지 않도록.
|
|
17
|
+
function stripCommentsKeepLines(source) {
|
|
18
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
|
19
|
+
return source
|
|
20
|
+
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
21
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
22
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
|
|
23
|
+
}
|
|
24
|
+
// 값이 앱 내부 경로(루트-상대)인지. 외부·프래그먼트·스킴은 제외.
|
|
25
|
+
function isInternalStaticHref(value) {
|
|
26
|
+
const v = value.trim();
|
|
27
|
+
if (v === '')
|
|
28
|
+
return false;
|
|
29
|
+
if (v.startsWith('//'))
|
|
30
|
+
return false; // 프로토콜-상대 → 외부
|
|
31
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(v))
|
|
32
|
+
return false; // http:·mailto:·tel:·data: 등 스킴
|
|
33
|
+
if (v.startsWith('#'))
|
|
34
|
+
return false; // 페이지 내 프래그먼트
|
|
35
|
+
return v.startsWith('/'); // 루트-상대만 내부로 확정
|
|
36
|
+
}
|
|
37
|
+
// 여는 `<a ...>` 태그 하나가 "내부 이동 일반 앵커" 인지 판정해, 그렇다면
|
|
38
|
+
// 문제의 href 문자열을 돌려준다(아니면 null). 정적 판별 불가는 null(오탐 방지).
|
|
39
|
+
export function internalAnchorHref(openTag) {
|
|
40
|
+
// target="_blank" = 외부 의도 → 건드리지 않는다.
|
|
41
|
+
if (/\btarget\s*=\s*['"]_blank['"]/i.test(openTag))
|
|
42
|
+
return null;
|
|
43
|
+
// 정적 href="..."
|
|
44
|
+
const staticM = openTag.match(/\shref\s*=\s*(['"])([^'"]*)\1/i);
|
|
45
|
+
if (staticM)
|
|
46
|
+
return isInternalStaticHref(staticM[2]) ? staticM[2].trim() : null;
|
|
47
|
+
// 바인딩 :href / v-bind:href — 템플릿 리터럴 `/...` 만 내부로 확정.
|
|
48
|
+
const boundM = openTag.match(/(?::|v-bind:)href\s*=\s*(['"])([^'"]*)\1/i);
|
|
49
|
+
if (boundM) {
|
|
50
|
+
const expr = boundM[2].trim();
|
|
51
|
+
const tl = expr.match(/^`([^`]*)`$/); // `...` 전체가 템플릿 리터럴
|
|
52
|
+
if (tl && tl[1].startsWith('/') && !tl[1].startsWith('//'))
|
|
53
|
+
return expr;
|
|
54
|
+
return null; // 바인딩된 식(prop·식별자)은 정적 판별 불가 → 건드리지 않는다
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/** 소스에 내부 이동 일반 앵커가 있는지(단위 테스트 진입점 · 주석은 제외). */
|
|
59
|
+
export function usesInternalAnchor(source) {
|
|
60
|
+
const stripped = stripCommentsKeepLines(source);
|
|
61
|
+
for (const m of stripped.matchAll(/<a\b[^>]*>/gi)) {
|
|
62
|
+
if (internalAnchorHref(m[0]) !== null)
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/** apps/ 의 .vue 를 훑어 내부 이동 일반 앵커를 경고로 낸다. */
|
|
68
|
+
export async function checkInternalAnchor(cwd) {
|
|
69
|
+
const appsDir = join(cwd, 'apps');
|
|
70
|
+
const issues = [];
|
|
71
|
+
for (const abs of await walkVue(appsDir)) {
|
|
72
|
+
const source = await readFile(abs, 'utf8').catch(() => '');
|
|
73
|
+
const stripped = stripCommentsKeepLines(source);
|
|
74
|
+
const rel = relative(cwd, abs);
|
|
75
|
+
for (const m of stripped.matchAll(/<a\b[^>]*>/gi)) {
|
|
76
|
+
const href = internalAnchorHref(m[0]);
|
|
77
|
+
if (href === null)
|
|
78
|
+
continue;
|
|
79
|
+
const line = stripped.slice(0, m.index ?? 0).split('\n').length;
|
|
80
|
+
issues.push({
|
|
81
|
+
rule: 'internal-anchor',
|
|
82
|
+
level: 'warning',
|
|
83
|
+
file: rel,
|
|
84
|
+
line,
|
|
85
|
+
message: `내부 이동 일반 앵커 발견: ${rel}:${line} 이 \`<a href="${href}">\` 로 앱 내부 경로를 ` +
|
|
86
|
+
`가리킵니다. 일반 \`<a>\` 는 클릭마다 전체 문서를 다시 로드해 Inertia SPA 상태가 ` +
|
|
87
|
+
`초기화됩니다(첫 실사용에서 관측된 결함 · 결정 96).\n` +
|
|
88
|
+
`→ \`gaonjs/vue\` 의 \`Link\` 를 쓰세요: \`import { Link } from 'gaonjs/vue'\` 후 ` +
|
|
89
|
+
`\`<Link href="${href}">…</Link>\`. 프로그램적 이동은 \`router.visit(...)\`. ` +
|
|
90
|
+
`외부 URL·\`target="_blank"\` 만 \`<a>\` 를 유지합니다.`,
|
|
91
|
+
detail: { file: rel, line, href },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { rule: 'internal-anchor', issues };
|
|
96
|
+
}
|
|
97
|
+
/** apps/ 하위 .vue(선언·테스트 제외) 절대경로. */
|
|
98
|
+
async function walkVue(dir) {
|
|
99
|
+
const out = [];
|
|
100
|
+
const walk = async (d) => {
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const e of entries) {
|
|
109
|
+
const abs = join(d, e.name);
|
|
110
|
+
if (e.isDirectory()) {
|
|
111
|
+
if (e.name === 'node_modules' || e.name === '.gaon')
|
|
112
|
+
continue;
|
|
113
|
+
await walk(abs);
|
|
114
|
+
}
|
|
115
|
+
else if (e.isFile() && e.name.endsWith('.vue')) {
|
|
116
|
+
out.push(abs);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
await walk(dir);
|
|
121
|
+
return out.sort();
|
|
122
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** 소스에 pageProps() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
|
|
3
|
+
export declare function usesPagePropsDestructure(source: string): boolean;
|
|
4
|
+
/** apps/ 의 .vue 를 훑어 pageProps() 구조분해를 경고로 낸다. */
|
|
5
|
+
export declare function checkPagePropsDestructure(cwd: string): Promise<RuleReport>;
|