@tbsten/mir-core 0.0.1-alpha02

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +25 -0
  2. package/src/__tests__/errors.test.ts +71 -0
  3. package/src/__tests__/hooks.test.ts +145 -0
  4. package/src/__tests__/i18n.test.ts +60 -0
  5. package/src/__tests__/remote-registry.test.ts +265 -0
  6. package/src/__tests__/safe-yaml-parser.test.ts +187 -0
  7. package/src/__tests__/snapshots/__snapshots__/error-messages.snapshot.test.ts.snap +27 -0
  8. package/src/__tests__/snapshots/__snapshots__/snippet-schema-output.snapshot.test.ts.snap +78 -0
  9. package/src/__tests__/snapshots/__snapshots__/template-output.snapshot.test.ts.snap +45 -0
  10. package/src/__tests__/snapshots/error-messages.snapshot.test.ts +76 -0
  11. package/src/__tests__/snapshots/snippet-schema-output.snapshot.test.ts +98 -0
  12. package/src/__tests__/snapshots/template-output.snapshot.test.ts +73 -0
  13. package/src/__tests__/snippet-schema.test.ts +180 -0
  14. package/src/__tests__/symlink-checker.test.ts +95 -0
  15. package/src/__tests__/template-engine.test.ts +137 -0
  16. package/src/__tests__/validate-name.test.ts +61 -0
  17. package/src/errors.ts +82 -0
  18. package/src/hooks.ts +63 -0
  19. package/src/i18n/index.ts +32 -0
  20. package/src/i18n/locales/en.ts +91 -0
  21. package/src/i18n/locales/ja.ts +91 -0
  22. package/src/i18n/types.ts +91 -0
  23. package/src/index.ts +84 -0
  24. package/src/lib/symlink-checker.ts +62 -0
  25. package/src/registry.ts +117 -0
  26. package/src/remote-registry.ts +260 -0
  27. package/src/safe-yaml-parser.ts +71 -0
  28. package/src/snippet-schema.ts +117 -0
  29. package/src/template-engine.ts +114 -0
  30. package/src/validate-name.ts +12 -0
  31. package/tsconfig.json +17 -0
@@ -0,0 +1,260 @@
1
+ import { RemoteRegistryFetchError, InvalidManifestError, MirError } from "./errors.js";
2
+ import { parseSnippetYaml } from "./snippet-schema.js";
3
+ import { expandTemplate, expandPath } from "./template-engine.js";
4
+ import { t } from "./i18n/index.js";
5
+ import type { SnippetDefinition } from "./snippet-schema.js";
6
+
7
+ /**
8
+ * リモート registry のマニフェスト (index.json) の型
9
+ */
10
+ export interface RegistryManifest {
11
+ snippets: Record<string, { files: string[] }>;
12
+ }
13
+
14
+ /**
15
+ * fetch オプション (タイムアウト等)
16
+ */
17
+ export interface FetchOptions {
18
+ /** タイムアウト時間(ミリ秒)。未指定の場合はタイムアウトなし */
19
+ timeoutMs?: number;
20
+ }
21
+
22
+ /**
23
+ * リモートから取得した snippet の情報
24
+ */
25
+ export interface RemoteSnippet {
26
+ definition: SnippetDefinition;
27
+ files: Map<string, string>;
28
+ }
29
+
30
+ /**
31
+ * キャッシュエントリの型
32
+ */
33
+ interface CacheEntry<T> {
34
+ data: T;
35
+ timestamp: number;
36
+ }
37
+
38
+ const CACHE_TTL_MS = 60000; // 60秒
39
+ const manifestCache = new Map<string, CacheEntry<RegistryManifest>>();
40
+ const snippetListCache = new Map<string, CacheEntry<string[]>>();
41
+
42
+ /**
43
+ * キャッシュが有効か確認
44
+ */
45
+ function isCacheValid<T>(entry: CacheEntry<T>): boolean {
46
+ return Date.now() - entry.timestamp < CACHE_TTL_MS;
47
+ }
48
+
49
+ /**
50
+ * すべてのリモート registry キャッシュをクリア
51
+ */
52
+ export function clearAllRemoteRegistryCaches(): void {
53
+ manifestCache.clear();
54
+ snippetListCache.clear();
55
+ }
56
+
57
+ function normalizeBaseUrl(baseUrl: string): string {
58
+ return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
59
+ }
60
+
61
+ /**
62
+ * AbortController を使ったタイムアウト付き fetch
63
+ */
64
+ async function fetchWithTimeout(
65
+ url: string,
66
+ options: FetchOptions = {},
67
+ ): Promise<Response> {
68
+ if (!options.timeoutMs) {
69
+ return fetch(url);
70
+ }
71
+
72
+ const controller = new AbortController();
73
+ const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
74
+
75
+ try {
76
+ return await fetch(url, { signal: controller.signal });
77
+ } catch (err) {
78
+ if (err instanceof Error && err.name === "AbortError") {
79
+ const timeoutSec = Math.round(options.timeoutMs / 1000);
80
+ throw new MirError(t("error.fetch-timeout", { url, timeout: timeoutSec }));
81
+ }
82
+ throw err;
83
+ } finally {
84
+ clearTimeout(timeoutId);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * マニフェスト (index.json) を取得する(キャッシュ付き)
90
+ */
91
+ export async function fetchRegistryManifest(
92
+ baseUrl: string,
93
+ options: FetchOptions = {},
94
+ ): Promise<RegistryManifest> {
95
+ const normalizedUrl = normalizeBaseUrl(baseUrl);
96
+ const cached = manifestCache.get(normalizedUrl);
97
+
98
+ if (cached && isCacheValid(cached)) {
99
+ return cached.data;
100
+ }
101
+
102
+ const url = `${normalizedUrl}/index.json`;
103
+ let res: Response;
104
+ try {
105
+ res = await fetchWithTimeout(url, options);
106
+ } catch (err) {
107
+ if (err instanceof MirError) throw err;
108
+ throw new RemoteRegistryFetchError(url);
109
+ }
110
+ if (!res.ok) {
111
+ throw new RemoteRegistryFetchError(url, res.status);
112
+ }
113
+ let data: unknown;
114
+ try {
115
+ data = await res.json();
116
+ } catch {
117
+ throw new InvalidManifestError(url);
118
+ }
119
+ if (
120
+ typeof data !== "object" ||
121
+ data === null ||
122
+ !("snippets" in data) ||
123
+ typeof (data as RegistryManifest).snippets !== "object"
124
+ ) {
125
+ throw new InvalidManifestError(url);
126
+ }
127
+
128
+ const manifest = data as RegistryManifest;
129
+ manifestCache.set(normalizedUrl, {
130
+ data: manifest,
131
+ timestamp: Date.now(),
132
+ });
133
+
134
+ return manifest;
135
+ }
136
+
137
+ /**
138
+ * リモート registry の snippet 名一覧を返す(キャッシュ付き)
139
+ */
140
+ export async function listRemoteSnippets(
141
+ baseUrl: string,
142
+ options: FetchOptions = {},
143
+ ): Promise<string[]> {
144
+ const normalizedUrl = normalizeBaseUrl(baseUrl);
145
+ const cached = snippetListCache.get(normalizedUrl);
146
+
147
+ if (cached && isCacheValid(cached)) {
148
+ return cached.data;
149
+ }
150
+
151
+ const manifest = await fetchRegistryManifest(baseUrl, options);
152
+ const snippetList = Object.keys(manifest.snippets);
153
+
154
+ snippetListCache.set(normalizedUrl, {
155
+ data: snippetList,
156
+ timestamp: Date.now(),
157
+ });
158
+
159
+ return snippetList;
160
+ }
161
+
162
+ /**
163
+ * snippet 定義 (YAML) を取得する
164
+ */
165
+ export async function fetchSnippetDefinition(
166
+ baseUrl: string,
167
+ name: string,
168
+ options: FetchOptions = {},
169
+ ): Promise<SnippetDefinition> {
170
+ const url = `${normalizeBaseUrl(baseUrl)}/${name}.yaml`;
171
+ let res: Response;
172
+ try {
173
+ res = await fetchWithTimeout(url, options);
174
+ } catch (err) {
175
+ if (err instanceof MirError) throw err;
176
+ throw new RemoteRegistryFetchError(url);
177
+ }
178
+ if (!res.ok) {
179
+ throw new RemoteRegistryFetchError(url, res.status);
180
+ }
181
+ const text = await res.text();
182
+ return parseSnippetYaml(text);
183
+ }
184
+
185
+ /**
186
+ * テンプレートファイル群を並列で取得する
187
+ */
188
+ export async function fetchRemoteFiles(
189
+ baseUrl: string,
190
+ name: string,
191
+ files: string[],
192
+ options: FetchOptions = {},
193
+ ): Promise<Map<string, string>> {
194
+ const base = normalizeBaseUrl(baseUrl);
195
+ const result = new Map<string, string>();
196
+
197
+ const entries = await Promise.all(
198
+ files.map(async (filePath) => {
199
+ const url = `${base}/${name}/${encodeURIComponent(filePath)}`;
200
+ let res: Response;
201
+ try {
202
+ res = await fetchWithTimeout(url, options);
203
+ } catch (err) {
204
+ if (err instanceof MirError) throw err;
205
+ throw new RemoteRegistryFetchError(url);
206
+ }
207
+ if (!res.ok) {
208
+ throw new RemoteRegistryFetchError(url, res.status);
209
+ }
210
+ const content = await res.text();
211
+ return [filePath, content] as const;
212
+ }),
213
+ );
214
+
215
+ for (const [filePath, content] of entries) {
216
+ result.set(filePath, content);
217
+ }
218
+ return result;
219
+ }
220
+
221
+ /**
222
+ * snippet 定義とテンプレートファイルを統合して取得する
223
+ */
224
+ export async function fetchRemoteSnippet(
225
+ baseUrl: string,
226
+ name: string,
227
+ options: FetchOptions = {},
228
+ ): Promise<RemoteSnippet> {
229
+ const manifest = await fetchRegistryManifest(baseUrl, options);
230
+ const snippetEntry = manifest.snippets[name];
231
+ if (!snippetEntry) {
232
+ throw new RemoteRegistryFetchError(
233
+ `${normalizeBaseUrl(baseUrl)}/${name}.yaml`,
234
+ 404,
235
+ );
236
+ }
237
+
238
+ const [definition, files] = await Promise.all([
239
+ fetchSnippetDefinition(baseUrl, name, options),
240
+ fetchRemoteFiles(baseUrl, name, snippetEntry.files, options),
241
+ ]);
242
+
243
+ return { definition, files };
244
+ }
245
+
246
+ /**
247
+ * リモートから取得したテンプレートファイルの変数を展開する
248
+ */
249
+ export function expandRemoteTemplateFiles(
250
+ files: Map<string, string>,
251
+ variables: Record<string, unknown>,
252
+ ): Map<string, string> {
253
+ const result = new Map<string, string>();
254
+ for (const [filePath, content] of files) {
255
+ const expandedPath = expandPath(filePath, variables);
256
+ const expandedContent = expandTemplate(content, variables);
257
+ result.set(expandedPath, expandedContent);
258
+ }
259
+ return result;
260
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 安全な YAML パーサー
3
+ *
4
+ * チケット 008-yaml-injection の対策として以下を実装:
5
+ * - 入力サイズ上限チェック(YAML Bomb / DoS 対策)
6
+ * - CORE_SCHEMA 使用によりカスタムタグ攻撃(!!js/function 等)を防止
7
+ * - JSON Schema の $ref 禁止チェック(外部リソース読み込み攻撃対策)
8
+ */
9
+ import yaml from "js-yaml";
10
+ import { ValidationError } from "./errors.js";
11
+
12
+ /**
13
+ * YAML ドキュメントの最大許容サイズ(バイト)。
14
+ * 64 KB を上限とする。通常の snippet 定義には十分な値。
15
+ */
16
+ export const YAML_MAX_SIZE_BYTES = 64 * 1024; // 64 KB
17
+
18
+ /**
19
+ * 安全な設定で YAML をパースする。
20
+ *
21
+ * - 入力サイズが YAML_MAX_SIZE_BYTES を超える場合は ValidationError を投げる
22
+ * - CORE_SCHEMA を使用し、JS 固有のカスタムタグ(!!js/function 等)を禁止する
23
+ *
24
+ * @param content パースする YAML 文字列
25
+ * @returns パース結果
26
+ * @throws ValidationError 入力サイズ超過、または無効な YAML の場合
27
+ */
28
+ export function safeParseYaml(content: string): unknown {
29
+ // 入力サイズチェック(YAML Bomb 対策)
30
+ const sizeBytes = Buffer.byteLength(content, "utf-8");
31
+ if (sizeBytes > YAML_MAX_SIZE_BYTES) {
32
+ throw new ValidationError(
33
+ `YAML ドキュメントのサイズが上限 (${YAML_MAX_SIZE_BYTES} バイト) を超えています: ${sizeBytes} バイト`,
34
+ );
35
+ }
36
+
37
+ try {
38
+ // CORE_SCHEMA: bool/int/float/null のみを認識し、JS 固有タグ(!!js/function 等)を拒否する
39
+ return yaml.load(content, {
40
+ schema: yaml.CORE_SCHEMA,
41
+ });
42
+ } catch (err) {
43
+ if (err instanceof ValidationError) {
44
+ throw err;
45
+ }
46
+ const message = err instanceof Error ? err.message : String(err);
47
+ throw new ValidationError(`YAML パースエラー: ${message}`);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * JSON Schema オブジェクト内に $ref キーが存在しないか検証する。
53
+ *
54
+ * $ref を使った外部スキーマ読み込み攻撃を防ぐため、
55
+ * snippet.yaml 内の schema フィールドには $ref を禁止する。
56
+ *
57
+ * @param schema 検証対象の schema 値(any)
58
+ * @throws ValidationError $ref が見つかった場合
59
+ */
60
+ export function checkNoRefInSchema(schema: unknown): void {
61
+ if (schema === null || schema === undefined) {
62
+ return;
63
+ }
64
+ // JSON.stringify 経由で深いネストも含めて $ref キーを検出する
65
+ const serialized = JSON.stringify(schema);
66
+ if (serialized.includes('"$ref"')) {
67
+ throw new ValidationError(
68
+ 'snippet の schema フィールドに "$ref" を使用することはセキュリティ上禁止されています',
69
+ );
70
+ }
71
+ }
@@ -0,0 +1,117 @@
1
+ import yaml from "js-yaml";
2
+ import { ValidationError } from "./errors.js";
3
+ import { validateSnippetName } from "./validate-name.js";
4
+ import { safeParseYaml, checkNoRefInSchema } from "./safe-yaml-parser.js";
5
+
6
+ export interface VariableSchema {
7
+ type?: "string" | "number" | "boolean";
8
+ default?: unknown;
9
+ enum?: unknown[];
10
+ }
11
+
12
+ export interface VariableDefinition {
13
+ name?: string;
14
+ description?: string;
15
+ suggests?: string[];
16
+ schema?: VariableSchema;
17
+ }
18
+
19
+ export interface Action {
20
+ echo?: string;
21
+ exit?: boolean;
22
+ if?: string;
23
+ input?: Record<
24
+ string,
25
+ {
26
+ name?: string;
27
+ description?: string;
28
+ schema?: VariableSchema;
29
+ "answer-to"?: string;
30
+ }
31
+ >;
32
+ }
33
+
34
+ export interface SnippetDefinition {
35
+ name: string;
36
+ /** semver 形式のバージョン文字列 (例: "1.0.0")。省略時は未バージョン管理扱い */
37
+ version?: string;
38
+ description?: string;
39
+ tags?: string[];
40
+ dependencies?: string[];
41
+ variables?: Record<string, VariableDefinition>;
42
+ hooks?: {
43
+ "before-install"?: Action[];
44
+ "after-install"?: Action[];
45
+ };
46
+ }
47
+
48
+ export function parseSnippetYaml(content: string): SnippetDefinition {
49
+ // 安全なパーサー使用(サイズ制限・カスタムタグ禁止)
50
+ const parsed = safeParseYaml(content);
51
+ if (typeof parsed !== "object" || parsed === null) {
52
+ throw new ValidationError("snippet YAML のパースに失敗しました");
53
+ }
54
+ const def = parsed as SnippetDefinition;
55
+ validateSnippetDefinition(def);
56
+ return def;
57
+ }
58
+
59
+ export function serializeSnippetYaml(def: SnippetDefinition): string {
60
+ return yaml.dump(def, { noRefs: true, lineWidth: -1 });
61
+ }
62
+
63
+ export function validateSnippetDefinition(def: SnippetDefinition): void {
64
+ if (!def.name || typeof def.name !== "string") {
65
+ throw new ValidationError("snippet 定義に name フィールドが必要です");
66
+ }
67
+ validateSnippetName(def.name);
68
+ if (def.dependencies !== undefined) {
69
+ if (!Array.isArray(def.dependencies)) {
70
+ throw new ValidationError("dependencies は配列でなければなりません");
71
+ }
72
+ for (const dep of def.dependencies) {
73
+ if (typeof dep !== "string") {
74
+ throw new ValidationError(
75
+ `dependencies の各要素は文字列でなければなりません。受け取った値: ${typeof dep}`,
76
+ );
77
+ }
78
+ validateSnippetName(dep);
79
+ }
80
+ }
81
+ if (def.variables !== undefined) {
82
+ if (typeof def.variables !== "object" || def.variables === null) {
83
+ throw new ValidationError("variables はオブジェクトでなければなりません");
84
+ }
85
+ for (const [key, varDef] of Object.entries(def.variables)) {
86
+ if (typeof varDef !== "object" || varDef === null) {
87
+ throw new ValidationError(
88
+ `変数 "${key}" の定義はオブジェクトでなければなりません`,
89
+ );
90
+ }
91
+ if (varDef.suggests !== undefined) {
92
+ if (!Array.isArray(varDef.suggests)) {
93
+ throw new ValidationError(
94
+ `変数 "${key}" の suggests は配列でなければなりません`,
95
+ );
96
+ }
97
+ for (const item of varDef.suggests) {
98
+ if (typeof item !== "string") {
99
+ throw new ValidationError(
100
+ `変数 "${key}" の suggests の各要素は文字列でなければなりません`,
101
+ );
102
+ }
103
+ }
104
+ }
105
+ // $ref によるJSON Schema外部参照攻撃を禁止
106
+ checkNoRefInSchema(varDef.schema);
107
+ if (varDef.schema?.type !== undefined) {
108
+ const validTypes = ["string", "number", "boolean"];
109
+ if (!validTypes.includes(varDef.schema.type)) {
110
+ throw new ValidationError(
111
+ `変数 "${key}" の type "${varDef.schema.type}" は無効です。string, number, boolean のいずれかを指定してください`,
112
+ );
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,114 @@
1
+ import Handlebars from "handlebars";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ listTemplateFiles,
6
+ readTemplateFile,
7
+ } from "./registry.js";
8
+
9
+ export function expandTemplate(
10
+ template: string,
11
+ variables: Record<string, unknown>,
12
+ ): string {
13
+ const compiled = Handlebars.compile(template, { noEscape: true });
14
+ return compiled(variables);
15
+ }
16
+
17
+ export function expandPath(
18
+ pathTemplate: string,
19
+ variables: Record<string, unknown>,
20
+ ): string {
21
+ const expanded = expandTemplate(pathTemplate, variables);
22
+ // 展開後のパスを正規化(空セグメント除去、区切り文字統一)
23
+ return path.normalize(expanded);
24
+ }
25
+
26
+ export function extractVariables(template: string): string[] {
27
+ const ast = Handlebars.parse(template);
28
+ const vars = new Set<string>();
29
+
30
+ function visitExpression(expr: hbs.AST.Expression): void {
31
+ if (expr.type === "PathExpression") {
32
+ const pathExpr = expr as hbs.AST.PathExpression;
33
+ vars.add(pathExpr.parts[0]);
34
+ }
35
+ }
36
+
37
+ function visit(node: hbs.AST.Node): void {
38
+ if (node.type === "MustacheStatement") {
39
+ const stmt = node as hbs.AST.MustacheStatement;
40
+ if (stmt.path) visitExpression(stmt.path);
41
+ if (stmt.params) {
42
+ for (const param of stmt.params) visitExpression(param);
43
+ }
44
+ }
45
+ if (node.type === "BlockStatement") {
46
+ const block = node as hbs.AST.BlockStatement;
47
+ // #if, #unless, #each 等のパラメータから変数を抽出
48
+ if (block.params) {
49
+ for (const param of block.params) visitExpression(param);
50
+ }
51
+ if (block.program) visit(block.program);
52
+ if (block.inverse) visit(block.inverse);
53
+ }
54
+ if ("body" in node && Array.isArray((node as hbs.AST.Program).body)) {
55
+ for (const child of (node as hbs.AST.Program).body) {
56
+ visit(child);
57
+ }
58
+ }
59
+ }
60
+
61
+ visit(ast);
62
+ return [...vars];
63
+ }
64
+
65
+ export function extractVariablesFromDirectory(dirPath: string): string[] {
66
+ const allVars = new Set<string>();
67
+
68
+ function walkDir(currentPath: string): void {
69
+ const entries = fs.readdirSync(currentPath, { withFileTypes: true });
70
+ for (const entry of entries) {
71
+ const fullPath = path.join(currentPath, entry.name);
72
+ if (entry.isDirectory()) {
73
+ // ディレクトリ名からも変数を抽出
74
+ for (const v of extractVariables(entry.name)) {
75
+ allVars.add(v);
76
+ }
77
+ walkDir(fullPath);
78
+ } else {
79
+ // ファイル名から変数を抽出
80
+ for (const v of extractVariables(entry.name)) {
81
+ allVars.add(v);
82
+ }
83
+ // ファイル内容から変数を抽出
84
+ const content = fs.readFileSync(fullPath, "utf-8");
85
+ for (const v of extractVariables(content)) {
86
+ allVars.add(v);
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ if (fs.existsSync(dirPath)) {
93
+ walkDir(dirPath);
94
+ }
95
+ return [...allVars];
96
+ }
97
+
98
+ export function expandTemplateDirectory(
99
+ registryPath: string,
100
+ snippetName: string,
101
+ variables: Record<string, unknown>,
102
+ ): Map<string, string> {
103
+ const files = listTemplateFiles(registryPath, snippetName);
104
+ const result = new Map<string, string>();
105
+
106
+ for (const filePath of files) {
107
+ const expandedPath = expandPath(filePath, variables);
108
+ const content = readTemplateFile(registryPath, snippetName, filePath);
109
+ const expandedContent = expandTemplate(content, variables);
110
+ result.set(expandedPath, expandedContent);
111
+ }
112
+
113
+ return result;
114
+ }
@@ -0,0 +1,12 @@
1
+ import { ValidationError } from "./errors.js";
2
+ import { t } from "./i18n/index.js";
3
+
4
+ const SNIPPET_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
5
+
6
+ export function validateSnippetName(name: string): void {
7
+ if (!SNIPPET_NAME_PATTERN.test(name)) {
8
+ throw new ValidationError(
9
+ t("error.invalid-snippet-name", { name }),
10
+ );
11
+ }
12
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true,
12
+ "sourceMap": true,
13
+ "composite": true
14
+ },
15
+ "include": ["src"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }