@gaonjs/cli 0.4.0 → 0.5.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/dist/commands/check.d.ts +31 -0
- package/dist/commands/check.js +223 -0
- package/dist/commands/console.d.ts +46 -0
- package/dist/commands/console.js +129 -0
- package/dist/commands/new.d.ts +45 -0
- package/dist/commands/new.js +274 -0
- package/dist/commands/test.d.ts +11 -0
- package/dist/commands/test.js +119 -0
- package/dist/doctor/no-auto-import.d.ts +10 -0
- package/dist/doctor/no-auto-import.js +158 -0
- package/dist/doctor/shared-composable-purity.d.ts +8 -0
- package/dist/doctor/shared-composable-purity.js +164 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +6 -5
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +13 -3
- package/dist/index.d.ts +4 -1
- package/dist/index.js +104 -6
- package/dist/templates/index.d.ts +23 -0
- package/dist/templates/index.js +66 -0
- package/dist/templates/index.ts +85 -0
- package/dist/templates/project/.env.example.tpl +18 -0
- package/dist/templates/project/.gitignore.tpl +24 -0
- package/dist/templates/project/.npmrc.tpl +4 -0
- package/dist/templates/project/CLAUDE.md.tpl +119 -0
- package/dist/templates/project/apps/web/channels/.gitkeep.tpl +1 -0
- package/dist/templates/project/apps/web/components/.gitkeep.tpl +1 -0
- package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +25 -0
- package/dist/templates/project/apps/web/controllers/home.ts.tpl +19 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +43 -0
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +36 -0
- package/dist/templates/project/apps/web/routes.ts.tpl +8 -0
- package/dist/templates/project/docker-compose.yaml.tpl +73 -0
- package/dist/templates/project/domain/events/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/jobs/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/listeners/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/mails/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/models/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/schema/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/services/.gitkeep.tpl +1 -0
- package/dist/templates/project/gaon.config.ts.tpl +27 -0
- package/dist/templates/project/package.json.tpl +27 -0
- package/dist/templates/project/pnpm-workspace.yaml.tpl +11 -0
- package/dist/templates/project/shared/components/.gitkeep.tpl +1 -0
- package/dist/templates/project/shared/composables/useDebounce.ts.tpl +21 -0
- package/dist/templates/project/tsconfig.json.tpl +25 -0
- package/package.json +3 -3
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · shared 컴포저블 순수성 검사 (M9-E 확장 · errata E-5 §2.2)
|
|
2
|
+
//
|
|
3
|
+
// shared/composables/ 는 shared 컴포넌트의 "props 로만" 규칙과 정확히
|
|
4
|
+
// 같은 구도를 따른다. 라우트를 몰라야 하고(api·pageProps 금지) · domain
|
|
5
|
+
// 은 타입으로만 참조해야 한다. 필요한 데이터·호출 함수는 인자로 받는다.
|
|
6
|
+
// 정본 근거: errata E-5 §2.2 (컴포저블·레이아웃 관례 · 결정 25).
|
|
7
|
+
//
|
|
8
|
+
// 검사 대상:
|
|
9
|
+
// 1) 프레임웍 모듈(gaonjs · gaonjs/vue · @gaonjs/vue · @gaonjs/web) 에서
|
|
10
|
+
// 'api' · 'pageProps' 를 value import 하면 error.
|
|
11
|
+
// - type-only 는 무해(구조 참조뿐 · 라우트 지식 필요 없음).
|
|
12
|
+
// 2) domain 을 value import 하면 error.
|
|
13
|
+
// - type-only 는 허용(모델 Row 타입 등 · 순수 타입 참조).
|
|
14
|
+
//
|
|
15
|
+
// 방법: shared/composables/*.ts 를 TS AST 로 파싱해 import 선언만 훑는다.
|
|
16
|
+
// 상대 import 는 실 파일까지 해석하지 않고 경로 접두사(domain/) 로 판정 —
|
|
17
|
+
// 이 검사는 순수성 게이트라 정확도보다 재현성이 우선(false positive 는
|
|
18
|
+
// 오히려 안전).
|
|
19
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
20
|
+
import { join, relative, resolve, dirname } from 'node:path';
|
|
21
|
+
import ts from 'typescript';
|
|
22
|
+
/** 라우트 지식을 담은 심볼 — shared 는 참조할 수 없다. */
|
|
23
|
+
const FORBIDDEN_FRAMEWORK_NAMES = new Set(['api', 'pageProps']);
|
|
24
|
+
/**
|
|
25
|
+
* 프레임웍 모듈 패턴. 문서 표기(gaon/vue)는 실 패키지 이름의 짧은
|
|
26
|
+
* 별칭이며, 실 배포본은 gaonjs 파사드 subpath 와 @gaonjs 스코프를 쓴다.
|
|
27
|
+
* 하나만 잡으면 우회가 쉬우므로 알려진 표기 3종을 모두 매치한다.
|
|
28
|
+
*/
|
|
29
|
+
const FRAMEWORK_MODULE_PATTERNS = [
|
|
30
|
+
/^gaonjs$/,
|
|
31
|
+
/^gaonjs\/vue$/,
|
|
32
|
+
/^@gaonjs\/vue$/,
|
|
33
|
+
/^@gaonjs\/web$/,
|
|
34
|
+
];
|
|
35
|
+
/** import 선언에서 이름과 type-only 플래그를 추출한다. */
|
|
36
|
+
function collectImportedNames(node) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const clause = node.importClause;
|
|
39
|
+
if (!clause)
|
|
40
|
+
return out;
|
|
41
|
+
const clauseTypeOnly = clause.isTypeOnly;
|
|
42
|
+
if (clause.name)
|
|
43
|
+
out.push({ name: clause.name.text, typeOnly: clauseTypeOnly });
|
|
44
|
+
const bindings = clause.namedBindings;
|
|
45
|
+
if (bindings) {
|
|
46
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
47
|
+
out.push({ name: bindings.name.text, typeOnly: clauseTypeOnly });
|
|
48
|
+
}
|
|
49
|
+
else if (ts.isNamedImports(bindings)) {
|
|
50
|
+
for (const el of bindings.elements) {
|
|
51
|
+
out.push({ name: el.name.text, typeOnly: clauseTypeOnly || el.isTypeOnly });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
function isRelativeSpecifier(s) {
|
|
58
|
+
return s.startsWith('./') || s.startsWith('../');
|
|
59
|
+
}
|
|
60
|
+
function matchesFrameworkModule(spec) {
|
|
61
|
+
return FRAMEWORK_MODULE_PATTERNS.some((re) => re.test(spec));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 단일 shared 컴포저블 소스를 검사해 순수성 위반 목록을 낸다
|
|
65
|
+
* (단위 테스트 진입점).
|
|
66
|
+
*/
|
|
67
|
+
export function inspectSharedComposable(file, source, cwd) {
|
|
68
|
+
const issues = [];
|
|
69
|
+
const rel = relative(cwd, file);
|
|
70
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
71
|
+
const baseDir = dirname(file);
|
|
72
|
+
const visit = (node) => {
|
|
73
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
74
|
+
const spec = node.moduleSpecifier.text;
|
|
75
|
+
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
76
|
+
const names = collectImportedNames(node);
|
|
77
|
+
// 1) 프레임웍의 api/pageProps 를 value import → error
|
|
78
|
+
if (matchesFrameworkModule(spec)) {
|
|
79
|
+
for (const n of names) {
|
|
80
|
+
if (n.typeOnly)
|
|
81
|
+
continue; // type-only 는 라우트 실행 지식이 아님
|
|
82
|
+
if (!FORBIDDEN_FRAMEWORK_NAMES.has(n.name))
|
|
83
|
+
continue;
|
|
84
|
+
issues.push({
|
|
85
|
+
rule: 'shared-composable-purity',
|
|
86
|
+
level: 'error',
|
|
87
|
+
file: rel,
|
|
88
|
+
line: line + 1,
|
|
89
|
+
message: `${rel} (line ${line + 1})\n` +
|
|
90
|
+
` '${spec}' 의 '${n.name}' import 발견 · shared 는 라우트를 몰라야 함\n` +
|
|
91
|
+
`→ 옵션: apps/<앱>/composables/ 로 옮기거나 · ${n.name} 호출을 인자로 받도록 바꾸라`,
|
|
92
|
+
detail: {
|
|
93
|
+
kind: 'framework-api',
|
|
94
|
+
module: spec,
|
|
95
|
+
name: n.name,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// 2) domain value import → error (type-only 는 허용)
|
|
101
|
+
if (isRelativeSpecifier(spec)) {
|
|
102
|
+
const abs = resolve(baseDir, spec);
|
|
103
|
+
const parts = relative(cwd, abs).split(/[\\/]/).filter(Boolean);
|
|
104
|
+
if (parts[0] === 'domain') {
|
|
105
|
+
const valueNames = names.filter((n) => !n.typeOnly);
|
|
106
|
+
if (valueNames.length > 0) {
|
|
107
|
+
issues.push({
|
|
108
|
+
rule: 'shared-composable-purity',
|
|
109
|
+
level: 'error',
|
|
110
|
+
file: rel,
|
|
111
|
+
line: line + 1,
|
|
112
|
+
message: `${rel} (line ${line + 1})\n` +
|
|
113
|
+
` domain 값 import 발견 · shared 컴포저블은 domain 을 타입으로만 참조해야 함\n` +
|
|
114
|
+
`→ 'import type { ... } from ...' 형태로 바꾸거나 · 필요한 값은 인자로 받도록 바꾸라`,
|
|
115
|
+
detail: {
|
|
116
|
+
kind: 'domain-value',
|
|
117
|
+
module: spec,
|
|
118
|
+
names: valueNames.map((n) => n.name),
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
ts.forEachChild(node, visit);
|
|
126
|
+
};
|
|
127
|
+
visit(sf);
|
|
128
|
+
return issues;
|
|
129
|
+
}
|
|
130
|
+
/** shared/composables/ 를 훑어 순수성 위반을 모두 낸다. */
|
|
131
|
+
export async function checkSharedComposablePurity(cwd) {
|
|
132
|
+
const composablesDir = join(cwd, 'shared', 'composables');
|
|
133
|
+
const files = [];
|
|
134
|
+
await collectTsFiles(composablesDir, files);
|
|
135
|
+
const issues = [];
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const src = await readFile(file, 'utf8');
|
|
138
|
+
issues.push(...inspectSharedComposable(file, src, cwd));
|
|
139
|
+
}
|
|
140
|
+
return { rule: 'shared-composable-purity', issues };
|
|
141
|
+
}
|
|
142
|
+
async function collectTsFiles(root, out) {
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = (await readdir(root, { withFileTypes: true }));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const e of entries) {
|
|
151
|
+
const name = e.name;
|
|
152
|
+
if (name === 'node_modules' || name === 'dist' || name === '.gaon')
|
|
153
|
+
continue;
|
|
154
|
+
const full = join(root, name);
|
|
155
|
+
if (e.isDirectory())
|
|
156
|
+
await collectTsFiles(full, out);
|
|
157
|
+
else if (e.isFile() &&
|
|
158
|
+
name.endsWith('.ts') &&
|
|
159
|
+
!name.endsWith('.d.ts') &&
|
|
160
|
+
!name.endsWith('.test.ts')) {
|
|
161
|
+
out.push(full);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff';
|
|
1
|
+
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import';
|
|
2
2
|
export type DoctorLevel = 'passed' | 'warning' | 'error';
|
|
3
3
|
export interface DoctorCheck {
|
|
4
4
|
readonly rule: DoctorRule;
|
package/dist/doctor/types.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix)
|
|
1
|
+
// @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// · migration-diff
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// 7 검사(response-mixing · n-plus-one · dependency-direction · connections
|
|
4
|
+
// · migration-diff · shared-composable-purity · no-auto-import)가 모두
|
|
5
|
+
// 이 DoctorCheck 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
|
|
6
|
+
// warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
|
|
7
|
+
// errors.length > 0 이면 fail 로 판단한다.
|
|
7
8
|
//
|
|
8
9
|
// 사전 검사 실패(프로젝트 마커 없음 · TS API 미노출)는 규칙 실행 자체가
|
|
9
10
|
// 불가능한 상황이므로 별도 `fatal` 필드로 표현한다 — runCli 는 이 경우
|
package/dist/doctor.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one
|
|
|
6
6
|
export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
7
7
|
export { extractConfigDbKeys, extractKeyUses, checkConnections } from './doctor/connections.js';
|
|
8
8
|
export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
|
|
9
|
+
export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
|
|
10
|
+
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
|
9
11
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
10
12
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
11
13
|
export interface DoctorCommandOptions {
|
package/dist/doctor.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성)
|
|
2
|
+
* @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 7 검사를 조립한다:
|
|
5
5
|
* 1) response-mixing (errata E-3 §C · 라이브)
|
|
6
6
|
* 2) n-plus-one (errata E-4 (e))
|
|
7
7
|
* 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
|
|
8
8
|
* 4) connections (v0.15 §4.5)
|
|
9
9
|
* 5) migration-diff (최소 감지 · 상세는 M9-D)
|
|
10
|
+
* 6) shared-composable-purity (errata E-5 §2.2 · 결정 25)
|
|
11
|
+
* 7) no-auto-import (errata E-5 §2.4 · v0.15 §1.2)
|
|
10
12
|
*
|
|
11
13
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
12
14
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -22,6 +24,8 @@ import { checkNPlusOne } from './doctor/n-plus-one.js';
|
|
|
22
24
|
import { checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
23
25
|
import { checkConnections } from './doctor/connections.js';
|
|
24
26
|
import { checkMigrationDiff } from './doctor/migration-diff.js';
|
|
27
|
+
import { checkSharedComposablePurity } from './doctor/shared-composable-purity.js';
|
|
28
|
+
import { checkNoAutoImport } from './doctor/no-auto-import.js';
|
|
25
29
|
import { renderHuman, renderJson } from './doctor/reporter.js';
|
|
26
30
|
import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
27
31
|
import { makeResult, } from './doctor/types.js';
|
|
@@ -30,10 +34,12 @@ export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one
|
|
|
30
34
|
export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
|
|
31
35
|
export { extractConfigDbKeys, extractKeyUses, checkConnections } from './doctor/connections.js';
|
|
32
36
|
export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
|
|
37
|
+
export { inspectSharedComposable, checkSharedComposablePurity, } from './doctor/shared-composable-purity.js';
|
|
38
|
+
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
|
33
39
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
34
40
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
35
41
|
/**
|
|
36
|
-
* 실행할 검사 이름. 지정 없음(undefined) =
|
|
42
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 7개 모두.
|
|
37
43
|
*/
|
|
38
44
|
const ALL_RULES = [
|
|
39
45
|
'response-mixing',
|
|
@@ -41,6 +47,8 @@ const ALL_RULES = [
|
|
|
41
47
|
'dependency-direction',
|
|
42
48
|
'connections',
|
|
43
49
|
'migration-diff',
|
|
50
|
+
'shared-composable-purity',
|
|
51
|
+
'no-auto-import',
|
|
44
52
|
];
|
|
45
53
|
const CHECKERS = {
|
|
46
54
|
'response-mixing': checkResponseMixing,
|
|
@@ -48,6 +56,8 @@ const CHECKERS = {
|
|
|
48
56
|
'dependency-direction': checkDependencyDirection,
|
|
49
57
|
connections: checkConnections,
|
|
50
58
|
'migration-diff': checkMigrationDiff,
|
|
59
|
+
'shared-composable-purity': checkSharedComposablePurity,
|
|
60
|
+
'no-auto-import': checkNoAutoImport,
|
|
51
61
|
};
|
|
52
62
|
/**
|
|
53
63
|
* `gaon doctor` 진입점 (M9-E · 확장 · M9-E-Fix 하드닝).
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { type DoctorRule } from "./doctor.js";
|
|
|
2
2
|
export { startDev, resolveDevLayout, type DevDeps, type DevLayout, type DevApp, type DevEvent, type DevHandle, } from "./dev.js";
|
|
3
3
|
export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
|
|
4
4
|
export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, type DevConsole, type DevConsoleOptions, type DevSource, type DevLevel, type ComposeStatus, type ComposeUpOptions, type EnsureInfraResult, type DockerLocateOptions, type TscWatcherOptions, type TscWatcherHandle, type RestartWatcherOptions, type RestartWatcherHandle, } from "./dev/index.js";
|
|
5
|
-
export {
|
|
5
|
+
export { runCheckCommand, type CheckCommandOptions, type CheckStep, type CheckStepStatus, type CheckStepResult, } from "./commands/check.js";
|
|
6
|
+
export { runNewCommand, type NewCommandOptions, type NewCommandResult } from "./commands/new.js";
|
|
7
|
+
export { runConsoleCommand, type ConsoleCommandOptions } from "./commands/console.js";
|
|
8
|
+
export { runTestCommand, type TestCommandOptions, type TestScope } from "./commands/test.js";
|
|
6
9
|
export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, type AuthScaffoldOptions, type ScaffoldFile, type ScaffoldResult, type GenerateAuthOptions, } from "./generate.js";
|
|
7
10
|
export { runGenerateCommand, planScaffold, parseGenerateArgs, type GenerateType, type GenerateOptions, type GenerateResult, } from "./commands/g.js";
|
|
8
11
|
export { runHubCommand, type HubCommandOptions } from "./hub.js";
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { MILESTONES, VERSION, HOMEPAGE } from "@gaonjs/core";
|
|
14
14
|
import { runDevCommand } from "./commands/dev.js";
|
|
15
|
-
import { runCheckCommand } from "./check.js";
|
|
15
|
+
import { runCheckCommand } from "./commands/check.js";
|
|
16
|
+
import { runNewCommand } from "./commands/new.js";
|
|
17
|
+
import { runConsoleCommand } from "./commands/console.js";
|
|
18
|
+
import { runTestCommand } from "./commands/test.js";
|
|
16
19
|
import { runGenerateAuthCommand } from "./generate.js";
|
|
17
20
|
import { runGenerateCommand } from "./commands/g.js";
|
|
18
21
|
import { runHubCommand } from "./hub.js";
|
|
@@ -24,7 +27,10 @@ import { runDoctorCommand } from "./doctor.js";
|
|
|
24
27
|
export { startDev, resolveDevLayout, } from "./dev.js";
|
|
25
28
|
export { runDevCommand } from "./commands/dev.js";
|
|
26
29
|
export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, } from "./dev/index.js";
|
|
27
|
-
export {
|
|
30
|
+
export { runCheckCommand, } from "./commands/check.js";
|
|
31
|
+
export { runNewCommand } from "./commands/new.js";
|
|
32
|
+
export { runConsoleCommand } from "./commands/console.js";
|
|
33
|
+
export { runTestCommand } from "./commands/test.js";
|
|
28
34
|
export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, } from "./generate.js";
|
|
29
35
|
export { runGenerateCommand, planScaffold, parseGenerateArgs, } from "./commands/g.js";
|
|
30
36
|
export { runHubCommand } from "./hub.js";
|
|
@@ -79,6 +85,7 @@ function renderHelp(version = VERSION) {
|
|
|
79
85
|
"",
|
|
80
86
|
" 사용법:",
|
|
81
87
|
" gaon 로드맵과 개발 상태를 출력",
|
|
88
|
+
" gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --pm <이름>)",
|
|
82
89
|
" gaon dev 개발 스택 통합 (Docker · .gaon · serve · tsc/vue-tsc · 재시작 워처)",
|
|
83
90
|
" gaon dev --stop-docker Ctrl+C 시 Docker Compose 도 down",
|
|
84
91
|
" gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker 개별 debug 옵션",
|
|
@@ -86,8 +93,10 @@ function renderHelp(version = VERSION) {
|
|
|
86
93
|
" gaon dev --json 통합 콘솔을 JSON 라인으로 출력(자동화)",
|
|
87
94
|
" gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
|
|
88
95
|
" gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
|
|
89
|
-
" gaon check
|
|
90
|
-
" gaon
|
|
96
|
+
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
97
|
+
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
98
|
+
" gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
|
|
99
|
+
" gaon doctor 정적 검사 (7 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import)",
|
|
91
100
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
92
101
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
93
102
|
" gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트)",
|
|
@@ -124,6 +133,8 @@ export function parseDoctorChecks(argv) {
|
|
|
124
133
|
"dependency-direction",
|
|
125
134
|
"connections",
|
|
126
135
|
"migration-diff",
|
|
136
|
+
"shared-composable-purity",
|
|
137
|
+
"no-auto-import",
|
|
127
138
|
];
|
|
128
139
|
const isKnown = (s) => known.includes(s);
|
|
129
140
|
const out = [];
|
|
@@ -179,9 +190,21 @@ export function runCli(argv, opts = {}) {
|
|
|
179
190
|
});
|
|
180
191
|
return;
|
|
181
192
|
}
|
|
182
|
-
// `gaon check` —
|
|
193
|
+
// `gaon check` — typecheck · vue-tsc · build (·doctor) 통합 검사(M9-G).
|
|
194
|
+
// package.json 스크립트 관례 재사용. --only <step> 로 단일 단계, --include-doctor
|
|
195
|
+
// 로 doctor 포함. 종료 코드로 결과 전달.
|
|
183
196
|
if (argv[0] === "check") {
|
|
184
|
-
|
|
197
|
+
const knownSteps = ["typecheck", "vue-tsc", "build", "doctor"];
|
|
198
|
+
const onlyIdx = argv.indexOf("--only");
|
|
199
|
+
const onlyRaw = onlyIdx >= 0 ? argv[onlyIdx + 1] : undefined;
|
|
200
|
+
const only = onlyRaw && knownSteps.includes(onlyRaw)
|
|
201
|
+
? onlyRaw
|
|
202
|
+
: undefined;
|
|
203
|
+
void runCheckCommand({
|
|
204
|
+
json: argv.includes("--json"),
|
|
205
|
+
only,
|
|
206
|
+
includeDoctor: argv.includes("--include-doctor"),
|
|
207
|
+
})
|
|
185
208
|
.then((code) => {
|
|
186
209
|
process.exitCode = code;
|
|
187
210
|
})
|
|
@@ -331,6 +354,81 @@ export function runCli(argv, opts = {}) {
|
|
|
331
354
|
process.exitCode = 1;
|
|
332
355
|
return;
|
|
333
356
|
}
|
|
357
|
+
// `gaon new <name>` — 프로젝트 스캐폴드(M9-F). 파일 생성 → 의존성 설치 → git init.
|
|
358
|
+
if (argv[0] === "new") {
|
|
359
|
+
const rest = argv.slice(1);
|
|
360
|
+
let name;
|
|
361
|
+
for (const a of rest) {
|
|
362
|
+
if (!a.startsWith("--") && name === undefined)
|
|
363
|
+
name = a;
|
|
364
|
+
}
|
|
365
|
+
if (!name) {
|
|
366
|
+
process.stderr.write(" ✗ gaon new: 프로젝트 이름이 없습니다.\n → 예: gaon new demo\n");
|
|
367
|
+
process.exitCode = 1;
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const pmIdx = rest.indexOf("--pm");
|
|
371
|
+
const pmRaw = pmIdx >= 0 ? rest[pmIdx + 1] : undefined;
|
|
372
|
+
const packageManager = pmRaw === "pnpm" || pmRaw === "npm" || pmRaw === "yarn" ? pmRaw : undefined;
|
|
373
|
+
void runNewCommand(name, {
|
|
374
|
+
json: argv.includes("--json"),
|
|
375
|
+
skipInstall: argv.includes("--skip-install"),
|
|
376
|
+
skipGit: argv.includes("--skip-git"),
|
|
377
|
+
packageManager,
|
|
378
|
+
})
|
|
379
|
+
.then((code) => {
|
|
380
|
+
process.exitCode = code;
|
|
381
|
+
})
|
|
382
|
+
.catch((err) => {
|
|
383
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
384
|
+
process.stderr.write(` ✗ gaon new 실패: ${msg}\n`);
|
|
385
|
+
process.exitCode = 1;
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// `gaon console` — 프로젝트 컨텍스트 REPL(M9-G). --no-config 로 배선 없이 기동.
|
|
390
|
+
if (argv[0] === "console") {
|
|
391
|
+
void runConsoleCommand({
|
|
392
|
+
json: argv.includes("--json"),
|
|
393
|
+
noConfig: argv.includes("--no-config"),
|
|
394
|
+
}).catch((err) => {
|
|
395
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
396
|
+
process.stderr.write(` ✗ gaon console 실패: ${msg}\n`);
|
|
397
|
+
process.exitCode = 1;
|
|
398
|
+
});
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// `gaon test [--scope unit|integration|all] [-- vitest 인자]` — 테스트 러너(M9-G).
|
|
402
|
+
if (argv[0] === "test") {
|
|
403
|
+
const rest = argv.slice(1);
|
|
404
|
+
const scopeIdx = rest.indexOf("--scope");
|
|
405
|
+
const scopeRaw = scopeIdx >= 0 ? rest[scopeIdx + 1] : undefined;
|
|
406
|
+
const scope = scopeRaw === "unit" || scopeRaw === "integration" || scopeRaw === "all"
|
|
407
|
+
? scopeRaw
|
|
408
|
+
: undefined;
|
|
409
|
+
const passthrough = [];
|
|
410
|
+
for (let i = 0; i < rest.length; i++) {
|
|
411
|
+
const a = rest[i];
|
|
412
|
+
if (a === "--scope") {
|
|
413
|
+
i++;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (a === "--json")
|
|
417
|
+
continue;
|
|
418
|
+
if (a !== undefined)
|
|
419
|
+
passthrough.push(a);
|
|
420
|
+
}
|
|
421
|
+
void runTestCommand(passthrough, { json: argv.includes("--json"), scope })
|
|
422
|
+
.then((code) => {
|
|
423
|
+
process.exitCode = code;
|
|
424
|
+
})
|
|
425
|
+
.catch((err) => {
|
|
426
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
427
|
+
process.stderr.write(` ✗ gaon test 실패: ${msg}\n`);
|
|
428
|
+
process.exitCode = 1;
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
334
432
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
335
433
|
process.stdout.write(renderHelp(version) + "\n");
|
|
336
434
|
return;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** 생성할 파일 하나 — path 는 프로젝트 루트 기준 상대 경로(POSIX). */
|
|
2
|
+
export interface ProjectFile {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
readonly contents: string;
|
|
5
|
+
}
|
|
6
|
+
/** 템플릿 렌더 시 치환되는 토큰 값. */
|
|
7
|
+
export interface ProjectTemplateTokens {
|
|
8
|
+
readonly projectName: string;
|
|
9
|
+
readonly gaonjsVersion: string;
|
|
10
|
+
}
|
|
11
|
+
/** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
|
|
12
|
+
export declare function renderTemplate(raw: string, tokens: ProjectTemplateTokens): string;
|
|
13
|
+
/** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
|
|
14
|
+
export declare function listTemplateFiles(root?: string): string[];
|
|
15
|
+
/**
|
|
16
|
+
* 스캐폴드 파일 전체를 렌더링해 반환한다.
|
|
17
|
+
* `.tpl` 접미사는 벗기고, `{{PROJECT_NAME}}`·`{{GAONJS_VERSION}}` 을 치환한다.
|
|
18
|
+
*/
|
|
19
|
+
export declare function renderProjectFiles(tokens: ProjectTemplateTokens): ProjectFile[];
|
|
20
|
+
/** 테스트·검증용 — 템플릿 폴더의 절대 경로. */
|
|
21
|
+
export declare function templateDir(): string;
|
|
22
|
+
/** 렌더링된 내용에 미치환 토큰이 남아 있는지 검사(회귀 방지). */
|
|
23
|
+
export declare function findUnresolvedTokens(contents: string): string[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @gaonjs/cli · templates/index.ts — 프로젝트 스캐폴드 템플릿 로더 (M9-F).
|
|
2
|
+
//
|
|
3
|
+
// `gaon new <name>` 이 소비하는 파일 트리. 소스 트리(src/templates/project/*)
|
|
4
|
+
// 를 재귀 스캔해 각 `.tpl` 파일의 최종 경로와 렌더된 내용을 반환한다.
|
|
5
|
+
// 렌더는 `{{TOKEN}}` 리터럴 replaceAll — Vue 의 `{{ }}` 보간과 겹치지 않는
|
|
6
|
+
// 고정 리터럴이라 정규식 없이 안전하다(generate.ts 와 동일 관례).
|
|
7
|
+
//
|
|
8
|
+
// 폴더는 관례상 유지되어야 하지만 git 이 빈 폴더를 추적하지 않으므로
|
|
9
|
+
// `.gitkeep.tpl` 을 두어 실 파일 `.gitkeep` 으로 렌더한다. 최종 경로는
|
|
10
|
+
// `.tpl` 접미사를 벗긴 값이다.
|
|
11
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { dirname, join, posix, relative, sep } from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'project');
|
|
15
|
+
/** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
|
|
16
|
+
export function renderTemplate(raw, tokens) {
|
|
17
|
+
return raw
|
|
18
|
+
.replaceAll('{{PROJECT_NAME}}', tokens.projectName)
|
|
19
|
+
.replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion);
|
|
20
|
+
}
|
|
21
|
+
/** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
|
|
22
|
+
export function listTemplateFiles(root = TEMPLATE_DIR) {
|
|
23
|
+
const out = [];
|
|
24
|
+
const walk = (dir) => {
|
|
25
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
26
|
+
const abs = join(dir, entry.name);
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
walk(abs);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (!entry.isFile())
|
|
32
|
+
continue;
|
|
33
|
+
out.push(abs);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
walk(root);
|
|
37
|
+
return out.sort();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 스캐폴드 파일 전체를 렌더링해 반환한다.
|
|
41
|
+
* `.tpl` 접미사는 벗기고, `{{PROJECT_NAME}}`·`{{GAONJS_VERSION}}` 을 치환한다.
|
|
42
|
+
*/
|
|
43
|
+
export function renderProjectFiles(tokens) {
|
|
44
|
+
const paths = listTemplateFiles();
|
|
45
|
+
return paths.map((abs) => {
|
|
46
|
+
const rel = relative(TEMPLATE_DIR, abs).split(sep).join(posix.sep);
|
|
47
|
+
const outPath = rel.endsWith('.tpl') ? rel.slice(0, -'.tpl'.length) : rel;
|
|
48
|
+
const raw = readFileSync(abs, 'utf8');
|
|
49
|
+
return { path: outPath, contents: renderTemplate(raw, tokens) };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/** 테스트·검증용 — 템플릿 폴더의 절대 경로. */
|
|
53
|
+
export function templateDir() {
|
|
54
|
+
return TEMPLATE_DIR;
|
|
55
|
+
}
|
|
56
|
+
/** 렌더링된 내용에 미치환 토큰이 남아 있는지 검사(회귀 방지). */
|
|
57
|
+
export function findUnresolvedTokens(contents) {
|
|
58
|
+
const re = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g;
|
|
59
|
+
const found = new Set();
|
|
60
|
+
for (const m of contents.matchAll(re)) {
|
|
61
|
+
// Vue 템플릿의 {{ prop }} (소문자·공백 시작) 은 제외 — 위 정규식이 대문자만
|
|
62
|
+
// 잡으므로 자연스럽게 걸러진다. 남으면 진짜 미치환.
|
|
63
|
+
found.add(m[1]);
|
|
64
|
+
}
|
|
65
|
+
return [...found].sort();
|
|
66
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @gaonjs/cli · templates/index.ts — 프로젝트 스캐폴드 템플릿 로더 (M9-F).
|
|
2
|
+
//
|
|
3
|
+
// `gaon new <name>` 이 소비하는 파일 트리. 소스 트리(src/templates/project/*)
|
|
4
|
+
// 를 재귀 스캔해 각 `.tpl` 파일의 최종 경로와 렌더된 내용을 반환한다.
|
|
5
|
+
// 렌더는 `{{TOKEN}}` 리터럴 replaceAll — Vue 의 `{{ }}` 보간과 겹치지 않는
|
|
6
|
+
// 고정 리터럴이라 정규식 없이 안전하다(generate.ts 와 동일 관례).
|
|
7
|
+
//
|
|
8
|
+
// 폴더는 관례상 유지되어야 하지만 git 이 빈 폴더를 추적하지 않으므로
|
|
9
|
+
// `.gitkeep.tpl` 을 두어 실 파일 `.gitkeep` 으로 렌더한다. 최종 경로는
|
|
10
|
+
// `.tpl` 접미사를 벗긴 값이다.
|
|
11
|
+
|
|
12
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
13
|
+
import { dirname, join, posix, relative, sep } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
|
|
16
|
+
/** 생성할 파일 하나 — path 는 프로젝트 루트 기준 상대 경로(POSIX). */
|
|
17
|
+
export interface ProjectFile {
|
|
18
|
+
readonly path: string
|
|
19
|
+
readonly contents: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 템플릿 렌더 시 치환되는 토큰 값. */
|
|
23
|
+
export interface ProjectTemplateTokens {
|
|
24
|
+
readonly projectName: string
|
|
25
|
+
readonly gaonjsVersion: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'project')
|
|
29
|
+
|
|
30
|
+
/** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
|
|
31
|
+
export function renderTemplate(raw: string, tokens: ProjectTemplateTokens): string {
|
|
32
|
+
return raw
|
|
33
|
+
.replaceAll('{{PROJECT_NAME}}', tokens.projectName)
|
|
34
|
+
.replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
|
|
38
|
+
export function listTemplateFiles(root: string = TEMPLATE_DIR): string[] {
|
|
39
|
+
const out: string[] = []
|
|
40
|
+
const walk = (dir: string): void => {
|
|
41
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
42
|
+
const abs = join(dir, entry.name)
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
walk(abs)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (!entry.isFile()) continue
|
|
48
|
+
out.push(abs)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
walk(root)
|
|
52
|
+
return out.sort()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 스캐폴드 파일 전체를 렌더링해 반환한다.
|
|
57
|
+
* `.tpl` 접미사는 벗기고, `{{PROJECT_NAME}}`·`{{GAONJS_VERSION}}` 을 치환한다.
|
|
58
|
+
*/
|
|
59
|
+
export function renderProjectFiles(tokens: ProjectTemplateTokens): ProjectFile[] {
|
|
60
|
+
const paths = listTemplateFiles()
|
|
61
|
+
return paths.map((abs) => {
|
|
62
|
+
const rel = relative(TEMPLATE_DIR, abs).split(sep).join(posix.sep)
|
|
63
|
+
const outPath = rel.endsWith('.tpl') ? rel.slice(0, -'.tpl'.length) : rel
|
|
64
|
+
const raw = readFileSync(abs, 'utf8')
|
|
65
|
+
return { path: outPath, contents: renderTemplate(raw, tokens) }
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 테스트·검증용 — 템플릿 폴더의 절대 경로. */
|
|
70
|
+
export function templateDir(): string {
|
|
71
|
+
return TEMPLATE_DIR
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 렌더링된 내용에 미치환 토큰이 남아 있는지 검사(회귀 방지). */
|
|
75
|
+
export function findUnresolvedTokens(contents: string): string[] {
|
|
76
|
+
const re = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g
|
|
77
|
+
const found = new Set<string>()
|
|
78
|
+
for (const m of contents.matchAll(re)) {
|
|
79
|
+
// Vue 템플릿의 {{ prop }} (소문자·공백 시작) 은 제외 — 위 정규식이 대문자만
|
|
80
|
+
// 잡으므로 자연스럽게 걸러진다. 남으면 진짜 미치환.
|
|
81
|
+
found.add(m[1]!)
|
|
82
|
+
}
|
|
83
|
+
return [...found].sort()
|
|
84
|
+
}
|
|
85
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} 환경 변수 — cp .env.example .env 후 편집.
|
|
2
|
+
# gaon.config.ts 의 env('KEY') 로 참조된다. gaon dev · gaon serve 가 자동 로드.
|
|
3
|
+
|
|
4
|
+
# DB — docker-compose.yaml 의 postgres 서비스와 정합.
|
|
5
|
+
DATABASE_URL=postgres://{{PROJECT_NAME}}:{{PROJECT_NAME}}@127.0.0.1:5432/{{PROJECT_NAME}}_dev
|
|
6
|
+
|
|
7
|
+
# Redis — 세션 스토어.
|
|
8
|
+
REDIS_URL=redis://127.0.0.1:6379
|
|
9
|
+
|
|
10
|
+
# NATS — 실시간·비동기 백본(§7).
|
|
11
|
+
NATS_URL=nats://127.0.0.1:4222
|
|
12
|
+
|
|
13
|
+
# 세션 · 쿠키 서명 비밀 (32자 이상, 운영은 반드시 교체).
|
|
14
|
+
SESSION_SECRET=change-me-to-a-32-char-random-secret!!
|
|
15
|
+
COOKIE_SECRET=change-me-too-32-char-random-secret!!
|
|
16
|
+
|
|
17
|
+
# 리슨 포트 (gaon serve --port 로 덮음).
|
|
18
|
+
PORT=3000
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# 의존성 · 빌드 산출물
|
|
2
|
+
node_modules/
|
|
3
|
+
dist/
|
|
4
|
+
*.tsbuildinfo
|
|
5
|
+
|
|
6
|
+
# 자동 생성 타입 브리지(§6.3) — gaon check / gaon dev 가 재생성한다.
|
|
7
|
+
.gaon/
|
|
8
|
+
|
|
9
|
+
# 환경 변수
|
|
10
|
+
.env
|
|
11
|
+
.env.local
|
|
12
|
+
.env.*.local
|
|
13
|
+
|
|
14
|
+
# 로그 · 캐시
|
|
15
|
+
*.log
|
|
16
|
+
.DS_Store
|
|
17
|
+
.cache/
|
|
18
|
+
coverage/
|
|
19
|
+
|
|
20
|
+
# 에디터
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/*
|
|
23
|
+
!.vscode/settings.json
|
|
24
|
+
!.vscode/launch.json
|