@gaonjs/cli 0.41.2 → 0.41.4
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/console.js +8 -7
- package/dist/serve.js +51 -23
- package/dist/templates/project/agents/testing.md.tpl +6 -0
- package/dist/work.js +43 -30
- package/package.json +5 -5
package/dist/commands/console.js
CHANGED
|
@@ -33,9 +33,11 @@ function humanEvent(e) {
|
|
|
33
33
|
return ` ▶ 로드됨 — ${cfg} · ${apps} · ${d}`;
|
|
34
34
|
}
|
|
35
35
|
case 'ready':
|
|
36
|
+
// 결정 251: 컨텍스트 문구는 실제 주입된 키(contextKeys)에서 파생한다 — 배너가
|
|
37
|
+
// 현실과 어긋날 수 없게(과거엔 빈 스텁 models·services 를 약속해 오도).
|
|
36
38
|
return e.noConfig
|
|
37
39
|
? ' ▶ REPL 준비 (--no-config · 프로젝트 컨텍스트 없음). .exit 로 종료.'
|
|
38
|
-
:
|
|
40
|
+
: ` ▶ REPL 준비. 컨텍스트: ${e.contextKeys.join(', ')}. .exit 로 종료.`;
|
|
39
41
|
case 'exited':
|
|
40
42
|
return ' gaon console · 종료';
|
|
41
43
|
}
|
|
@@ -76,11 +78,10 @@ export async function runConsoleCommand(opts = {}) {
|
|
|
76
78
|
context.config = config;
|
|
77
79
|
context.apps = wired.apps;
|
|
78
80
|
context.domain = domain;
|
|
79
|
-
// 모델·서비스 자동 노출은 사용자 코드 스캔이 필요해 v1
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
context.services = {};
|
|
81
|
+
// 결정 251: 모델·서비스 자동 노출은 사용자 코드 스캔이 필요해 v1 M9-G 최소 스코프
|
|
82
|
+
// 밖이다(§13.5). 빈 스텁(context.models={} 등)을 심어 배너로 약속하면 오도이므로
|
|
83
|
+
// 심지 않는다 — 배너도 실제 주입된 키만 나열한다(위 humanEvent). 사용자는 컨텍스트에서
|
|
84
|
+
// 필요 파일을 `await import(...)` 로 가져온다(REPL 은 top-level await 허용).
|
|
84
85
|
emit({
|
|
85
86
|
kind: 'loaded',
|
|
86
87
|
configPath,
|
|
@@ -92,7 +93,7 @@ export async function runConsoleCommand(opts = {}) {
|
|
|
92
93
|
},
|
|
93
94
|
});
|
|
94
95
|
}
|
|
95
|
-
emit({ kind: 'ready', noConfig });
|
|
96
|
+
emit({ kind: 'ready', noConfig, contextKeys: Object.keys(context) });
|
|
96
97
|
// REPL 시작 — 테스트는 replFactory 로 stdin/stdout 을 갈아 끼운다.
|
|
97
98
|
const repl = opts.replFactory
|
|
98
99
|
? opts.replFactory()
|
package/dist/serve.js
CHANGED
|
@@ -133,30 +133,58 @@ export async function runServeCommand(opts = {}) {
|
|
|
133
133
|
};
|
|
134
134
|
const configPath = findConfigPath(cwd);
|
|
135
135
|
const config = await loadGaonConfig(cwd);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// opts.dev 가 없어 등록되지 않으므로 /_gaon/health 는 production 에 없다.
|
|
145
|
-
// wired.app 은 여기서 FastifyInstance 로 해상되므로(cli 는 fastify 타입을
|
|
146
|
-
// 직접 의존하지 않는다) 라우트 등록은 serve 층에서 인라인으로 한다.
|
|
147
|
-
if (opts.dev) {
|
|
148
|
-
const healthCtx = { cwd, config, apps: wired.apps, nats: wired.nats, port, host };
|
|
149
|
-
wired.app.get(DEV_HEALTH_PATH, async () => computeHealth(healthCtx));
|
|
136
|
+
// 결정 252(결정 250 확장): wireGaon 이 부팅 도중(mid-wire · 예: 잘못된 DB 자격증명으로
|
|
137
|
+
// ensureOutboxTable 의 첫 DB 접근이 throw) 실패하면, wireGaon 이 자기가 연 부분 핸들(NATS·
|
|
138
|
+
// Redis·DB)을 정리하고 rethrow 한다. 이전엔 이 호출이 try 밖이라, 상위(index.ts)가 exitCode 만
|
|
139
|
+
// 세팅하고 열린 소켓이 이벤트 루프를 붙잡아 프로세스가 종료하지 못하고 매달렸다(25s 미종료 실측).
|
|
140
|
+
// 여기서 fail-loud + 명시 종료로 확정 종료를 보장한다(post-wire 실패[아래]와 대칭 · exit 는 실패 시만).
|
|
141
|
+
let wired;
|
|
142
|
+
try {
|
|
143
|
+
wired = await wireGaon(config, cwd);
|
|
150
144
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
145
|
+
catch (err) {
|
|
146
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
147
|
+
process.stderr.write(` ✗ gaon serve 부팅 실패: ${msg}\n`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
// 결정 250(샘플 C · P2): wireGaon 이 NATS·Redis 소켓을 연 뒤의 부팅 단계(포트 파싱·listen
|
|
151
|
+
// 등)에서 실패하면, 상위 catch(index.ts)가 exitCode=1 만 세팅하고 끝나 열린 소켓이 이벤트
|
|
152
|
+
// 루프를 붙잡아 프로세스가 종료하지 못하고 매달린다(정확한 fail-loud 메시지는 찍히지만
|
|
153
|
+
// 행업). 부팅 실패는 자기가 연 핸들을 정리하고 명시 종료한다(이 파일 헤더 "예외는 stderr +
|
|
154
|
+
// exit 1" 을 실제로 구현). 정상 부팅·정상 종료 경로엔 영향이 없다(process.exit 는 실패 시만).
|
|
155
|
+
try {
|
|
156
|
+
emit({ kind: 'starting', configPath, apps: wired.apps.map((a) => a.name) });
|
|
157
|
+
const host = opts.host ?? config.web?.host ?? '0.0.0.0';
|
|
158
|
+
// opts.port(프로그램적)는 검증하지 않는다 — port:0(임의 포트) 같은 정당한 값이 있다.
|
|
159
|
+
// env PORT 는 사용자 입력이라 비숫자면 NaN 대신 fail-loud(결정 240 · §7.5.3).
|
|
160
|
+
const envPort = process.env.PORT != null && process.env.PORT !== '' ? parsePort(process.env.PORT, 'env PORT') : undefined;
|
|
161
|
+
const port = opts.port ?? config.web?.port ?? envPort ?? 3000;
|
|
162
|
+
// dev 전용 진단 라우트(결정 69). listen 전에 등록한다 — 운영 serve 는
|
|
163
|
+
// opts.dev 가 없어 등록되지 않으므로 /_gaon/health 는 production 에 없다.
|
|
164
|
+
// wired.app 은 여기서 FastifyInstance 로 해상되므로(cli 는 fastify 타입을
|
|
165
|
+
// 직접 의존하지 않는다) 라우트 등록은 serve 층에서 인라인으로 한다.
|
|
166
|
+
if (opts.dev) {
|
|
167
|
+
const healthCtx = { cwd, config, apps: wired.apps, nats: wired.nats, port, host };
|
|
168
|
+
wired.app.get(DEV_HEALTH_PATH, async () => computeHealth(healthCtx));
|
|
169
|
+
}
|
|
170
|
+
await wired.app.listen({ host, port });
|
|
171
|
+
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
|
172
|
+
emit({ kind: 'listening', host, port, url: `http://${displayHost}:${port}` });
|
|
173
|
+
// 결정 98: serve 는 빌드된 번들을 서빙하며 코드 변경을 감시하지 않는다 —
|
|
174
|
+
// 수정이 조용히 반영 안 되는 혼란(첫 실사용 관측)을 막으려 한 줄 안내한다.
|
|
175
|
+
// dev 자식(gaon dev · opts.dev)은 이미 감시하므로 그때는 안내하지 않고,
|
|
176
|
+
// production 은 운영 로그 소음을 막으려 출력하지 않는다. json 은 파싱 안전상 제외.
|
|
177
|
+
if (!json && !opts.dev && process.env.NODE_ENV !== 'production') {
|
|
178
|
+
process.stdout.write(' ℹ serve 는 빌드된 번들을 서빙하며 코드 변경을 감시하지 않습니다 → 개발 중이면 `gaon dev` 를 쓰세요.\n');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
183
|
+
process.stderr.write(` ✗ gaon serve 부팅 실패: ${msg}\n`);
|
|
184
|
+
// 열린 핸들(NATS·Redis·DB) 정리 — 이게 없으면 아래 명시 종료 없이도, 있어도 소켓이
|
|
185
|
+
// 남아 다음 부팅/셧다운을 오염시킨다. close 실패는 삼켜 종료를 막지 않는다(best-effort).
|
|
186
|
+
await wired.close().catch(() => { });
|
|
187
|
+
process.exit(1);
|
|
160
188
|
}
|
|
161
189
|
await new Promise((resolvePromise) => {
|
|
162
190
|
const stop = () => {
|
|
@@ -169,6 +169,12 @@ DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test`
|
|
|
169
169
|
의 "롤백=미발행" 보장이 테스트에서도 참). 이 배선이 없던 때는 테스트의 emit 이 즉시발행
|
|
170
170
|
경로로 새 그 보장이 **공허하게 통과**했다 — 아웃박스 롤백을 단언하는 테스트는 표준
|
|
171
171
|
하네스로 그대로 돈다(수동 `setServiceTxWrapper` 불필요).
|
|
172
|
+
- `connectTestDatabase` 는 **락·캐시 백엔드도 배선**한다(결정 249) — serve·work 의
|
|
173
|
+
`wireDomain`(결정 202)과 대칭으로. 그래서 `lock()`·`cache()` 를 쓰는 잡·서비스가
|
|
174
|
+
테스트에서도 그대로 돈다(이전엔 serve/work 에선 정상인데 `gaon test` 에서만
|
|
175
|
+
`LockBackendUnconfiguredError` 로 깨졌다). 단일 프로세스 테스트라 분산 락이 무의미하고
|
|
176
|
+
락 키에 테스트 격리 prefix 가 없어, Redis 유무와 무관하게 in-memory 백엔드로 격리한다
|
|
177
|
+
(수동 `setLockBackend` 불필요).
|
|
172
178
|
|
|
173
179
|
스캐폴드가 심어 주는 `test/setup.ts`(수정 불필요):
|
|
174
180
|
|
package/dist/work.js
CHANGED
|
@@ -70,35 +70,45 @@ export async function runWorkCommand(opts = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
72
|
const nats = await connectNats({ servers: opts.natsUrl, name: `work@${id}` });
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
// 스캐폴드 .env 관례(DATABASE_URL)를 정본으로 · GAON_DATABASE_URL 은 하위호환.
|
|
84
|
-
const dbUrl = opts.databaseUrl ?? process.env.DATABASE_URL ?? process.env.GAON_DATABASE_URL;
|
|
85
|
-
if (!hasConnection('main') && dbUrl) {
|
|
86
|
-
const cfg = dbConfigFromUrl(dbUrl);
|
|
87
|
-
registerConnection('main', createDb(cfg), cfg.adapter);
|
|
88
|
-
}
|
|
89
|
-
const db = hasConnection('main') ? getConnection('main') : undefined;
|
|
90
|
-
const domain = await loadDomain(root);
|
|
91
|
-
// 로드된 도메인 자산 요약(파일=등록 관측용). 잡·리스너·메일 수를 노출한다.
|
|
92
|
-
if (json) {
|
|
93
|
-
process.stdout.write(JSON.stringify({
|
|
94
|
-
kind: 'domain',
|
|
95
|
-
jobs: domain.jobs,
|
|
96
|
-
listeners: domain.listeners,
|
|
97
|
-
mails: domain.mails,
|
|
98
|
-
}) + '\n');
|
|
99
|
-
}
|
|
73
|
+
// 결정 250(샘플 C · P2): connectNats 로 소켓을 연 뒤의 부팅 단계(config 로드·wireDomain·
|
|
74
|
+
// loadDomain·runWork) 중 어디서 실패해도 열린 NATS·Redis 소켓이 이벤트 루프를 붙잡아 상위
|
|
75
|
+
// catch(index.ts)가 exitCode=1 만 세팅한 채 프로세스가 매달린다. 부팅 실패는 그때까지 연
|
|
76
|
+
// 핸들을 정리하고 명시 종료한다(serve 와 대칭). 정상 부팅·정상 종료엔 영향 없다.
|
|
77
|
+
// (이전엔 runWork 실패만 정리했고, 그 앞의 wireDomain·loadDomain throw 는 nats 를 누출했다.)
|
|
78
|
+
// 결정 252: wireDomain 이 **mid-wire** 로 throw 하면(예: Redis 배선 후 다음 단계 실패)
|
|
79
|
+
// domainWiring 이 undefined 라 아래 catch 의 closeDomain 이 안 돌지만, wireDomain 이 자기
|
|
80
|
+
// 부분 핸들(Redis·DB)을 스스로 정리하고 rethrow 하므로(opener 소유) 누출이 없다 — nats 는
|
|
81
|
+
// 이 catch 가 닫는다. 이로써 serve(직접 exit)와 work(nats close + exit)가 mid-wire 대칭.
|
|
82
|
+
let domainWiring;
|
|
100
83
|
let work;
|
|
101
84
|
try {
|
|
85
|
+
// 도메인 배터리 배선(결정 129) — 잡 핸들러가 모델을 읽고(registerConnection)
|
|
86
|
+
// 메일을 보내려면(configureMailer) work 도 serve 와 같은 도메인 배선을 태워야
|
|
87
|
+
// 한다. serve 는 wireGaon → wireDomain, work 은 여기서 직접 wireDomain 을
|
|
88
|
+
// 호출한다. 이 배선이 없으면 잡에서 모델 조회·메일 발송이 운영 워커에서만
|
|
89
|
+
// 조용히 깨진다(gaon dev 는 serve 프로세스가 대신 배선해 가려짐).
|
|
90
|
+
const config = await loadGaonConfig(root);
|
|
91
|
+
domainWiring = await wireDomain(config, root);
|
|
92
|
+
// 아웃박스 릴레이·모델 읽기용 main 커넥션. gaon.config 에 db.main 이 있으면
|
|
93
|
+
// wireDomain 이 이미 등록했다. config 없이 env(DATABASE_URL)만 있는 프로젝트는
|
|
94
|
+
// 여기서 관례로 보강 등록해 잡의 모델 읽기와 릴레이가 함께 동작하게 한다.
|
|
95
|
+
// 스캐폴드 .env 관례(DATABASE_URL)를 정본으로 · GAON_DATABASE_URL 은 하위호환.
|
|
96
|
+
const dbUrl = opts.databaseUrl ?? process.env.DATABASE_URL ?? process.env.GAON_DATABASE_URL;
|
|
97
|
+
if (!hasConnection('main') && dbUrl) {
|
|
98
|
+
const cfg = dbConfigFromUrl(dbUrl);
|
|
99
|
+
registerConnection('main', createDb(cfg), cfg.adapter);
|
|
100
|
+
}
|
|
101
|
+
const db = hasConnection('main') ? getConnection('main') : undefined;
|
|
102
|
+
const domain = await loadDomain(root);
|
|
103
|
+
// 로드된 도메인 자산 요약(파일=등록 관측용). 잡·리스너·메일 수를 노출한다.
|
|
104
|
+
if (json) {
|
|
105
|
+
process.stdout.write(JSON.stringify({
|
|
106
|
+
kind: 'domain',
|
|
107
|
+
jobs: domain.jobs,
|
|
108
|
+
listeners: domain.listeners,
|
|
109
|
+
mails: domain.mails,
|
|
110
|
+
}) + '\n');
|
|
111
|
+
}
|
|
102
112
|
work = await runWork({
|
|
103
113
|
nats,
|
|
104
114
|
id,
|
|
@@ -114,9 +124,12 @@ export async function runWorkCommand(opts = {}) {
|
|
|
114
124
|
});
|
|
115
125
|
}
|
|
116
126
|
catch (err) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
127
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
128
|
+
process.stderr.write(` ✗ gaon work 부팅 실패: ${msg}\n`);
|
|
129
|
+
if (domainWiring)
|
|
130
|
+
await domainWiring.closeDomain().catch(() => { });
|
|
131
|
+
await nats.close().catch(() => { });
|
|
132
|
+
process.exit(1);
|
|
120
133
|
}
|
|
121
134
|
await new Promise((resolve) => {
|
|
122
135
|
const stop = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.4",
|
|
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/config": "0.17.2",
|
|
31
|
-
"@gaonjs/core": "0.2.3",
|
|
32
30
|
"@gaonjs/async": "0.15.0",
|
|
31
|
+
"@gaonjs/config": "0.17.4",
|
|
32
|
+
"@gaonjs/core": "0.2.3",
|
|
33
|
+
"@gaonjs/mail": "0.3.0",
|
|
33
34
|
"@gaonjs/data": "0.17.0",
|
|
34
35
|
"@gaonjs/i18n": "0.2.2",
|
|
35
|
-
"@gaonjs/web": "0.19.4"
|
|
36
|
-
"@gaonjs/mail": "0.3.0"
|
|
36
|
+
"@gaonjs/web": "0.19.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})\""
|