sparkle-design-cli 2.0.7-beta.9 → 2.0.7-rc.3

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/lib/constants.js CHANGED
@@ -33,6 +33,21 @@ export const GLOBALS_CSS_CANDIDATES = [
33
33
  'src/styles/globals.css',
34
34
  ];
35
35
 
36
+ // Vite プロジェクト判定に使う config ファイル候補。setup.js(scaffold
37
+ // 判定)と font-manager.js(resolveGlobalsPath / index.html 注入判定)
38
+ // で同じリストを参照する。片方だけ変えると scaffold と generate が別
39
+ // 判定をしてしまうので、唯一の source of truth としてここに置く。
40
+ // en: Single source of truth for Vite project detection. Reused by
41
+ // setup.js's scaffold logic and font-manager.js's resolver.
42
+ export const VITE_CONFIG_FILES = [
43
+ 'vite.config.ts',
44
+ 'vite.config.js',
45
+ 'vite.config.mjs',
46
+ 'vite.config.cjs',
47
+ 'vite.config.mts',
48
+ 'vite.config.cts',
49
+ ];
50
+
36
51
  // 正規表現パターン
37
52
  export const REGEX = {
38
53
  // フォント関連
@@ -44,16 +59,20 @@ export const REGEX = {
44
59
  /\/\*\s*フォントのインポート[^*]*\*\/\s*\n?(?:@import\s+(?:url\([^)]+fonts\.googleapis\.com[^)]+\)|['"][^"']*fonts\.googleapis\.com[^"']*['"]);?\s*\n?)*\n?/g,
45
60
 
46
61
  // Sparkle Design関連
47
- // `@import "sparkle-design.css"` / `@import "./sparkle-design.css"` だけでなく
48
- // `@import "./app/sparkle-design.css"` 等、任意の相対パスを含む variant も
49
- // 除去対象にする。Vite のように entry CSS (src/index.css) と sparkle-design.css
50
- // (src/app/sparkle-design.css) が別ディレクトリに置かれるレイアウトでは
51
- // 後者のような path になるので、同一 dir 前提の regex だと AI が手で直したもの
52
- // を除去できず二重 import が残ってしまう。
53
- // en: Match any relative path ending in `sparkle-design.css`, including layouts
54
- // like Vite where the entry CSS and sparkle-design.css live in different dirs.
62
+ // `@import "sparkle-design.css"` / `@import "./sparkle-design.css"` だけでなく、
63
+ // `@import "./app/sparkle-design.css"` のように entry CSS と sparkle-design.css
64
+ // が別ディレクトリにある Vite 系レイアウトの variant も除去対象にする。
65
+ // ただし **path prefix を `./` または `../` に限定**し、末尾の `sparkle-design.css`
66
+ // は path セパレータ直後であることを要求することで、ユーザーが独自命名した
67
+ // `custom-sparkle-design.css` / `my-sparkle-design.css` などを誤削除しないように
68
+ // している(beta.10 まではここが緩く、suffix match で user-authored ファイルを
69
+ // 巻き込む silent data loss リスクがあった)。
70
+ // en: Match paths ending in `sparkle-design.css` but require a `./` or `../`
71
+ // prefix and a path separator just before the filename. This preserves user-
72
+ // authored files like `custom-sparkle-design.css` that would otherwise be
73
+ // deleted by a loose suffix match.
55
74
  SPARKLE_IMPORT:
56
- /\/\*\s*Sparkle Design[^*]*\*\/\s*\n?|@import\s+['"][^"']*sparkle-design\.css['"];?\s*\n?/g,
75
+ /\/\*\s*Sparkle Design[^*]*\*\/\s*\n?|@import\s+['"](?:(?:\.{1,2}\/)[^"']*\/)?sparkle-design\.css['"];?\s*\n?/g,
57
76
  TAILWIND_IMPORT: /@import\s+['"]tailwindcss['"];?/,
58
77
 
59
78
  // @source ディレクティブ関連
@@ -70,8 +89,18 @@ export const REGEX = {
70
89
  /\/\*\s*npm パッケージのコンテンツスキャン[^*]*\*\/\s*\n?(?:@source\s+["'][^"']*["'];?\s*\n?)*/g,
71
90
 
72
91
  // カスタムCSS関連
92
+ // 除去対象は「CLI が書き込んだ custom CSS の managed block」のみ。以前は
93
+ // 文字列 "custom" を含む任意の import をすべて削っていたため、
94
+ // `customer-module.css` / `my-customer-ui.css` / `customizer.css` など
95
+ // ユーザー命名の import まで巻き込んで silent data loss していた。
96
+ // CLI が書くのは `/* プロジェクト固有のカスタムトークン */` コメント直後の
97
+ // 1 行 `@import` だけなので、そのペアだけをマッチする。
98
+ // en: Match only the managed block the CLI writes (comment + one `@import`
99
+ // line). Previously any `@import` containing the substring "custom" was
100
+ // deleted, which would silently remove user-authored files like
101
+ // `customer-module.css`.
73
102
  CUSTOM_CSS_IMPORT:
74
- /\/\*\s*プロジェクト固有のカスタムトークン[^*]*\*\/\s*\n?|@import\s+['"][^"']*custom[^"']*['"];?\s*\n?/g,
103
+ /\/\*\s*プロジェクト固有のカスタムトークン[^*]*\*\/\s*\n?(?:@import\s+['"][^"']+['"];?\s*\n?)?/g,
75
104
 
76
105
  // テンプレート関連
77
106
  COLOR_TOKENS_PLACEHOLDER: /[ \t]*\/\* \{\{COLOR_TOKENS\}\} \*\/[ \t]*\n?/,
@@ -46,11 +46,23 @@ function readFile(filePath, successMessage, errorMessage, additionalErrorHandler
46
46
  * @param {Function} additionalErrorHandler 追加のエラーハンドラ(オプション)
47
47
  * @returns {Object} パース済みのJSONオブジェクト
48
48
  */
49
+ // Prototype pollution 対策: `__proto__` / `constructor` / `prototype` を
50
+ // JSON から drop する reviver。本プロセス内の spread は安全だが、書き戻し
51
+ // 先(hook JSON / package.json 等)経由で下流 consumer に流れたときに
52
+ // プロトタイプ汚染が起きる古典パターンを断つ。
53
+ // en: Strip prototype-pollution keys at parse time so they cannot travel
54
+ // through serialized files to downstream consumers.
55
+ const POLLUTION_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
56
+ function jsonPollutionReviver(key, value) {
57
+ if (POLLUTION_KEYS.has(key)) return undefined;
58
+ return value;
59
+ }
60
+
49
61
  function readJsonFile(filePath, successMessage, errorMessage, additionalErrorHandler = null) {
50
62
  const content = readFile(filePath, successMessage, errorMessage, additionalErrorHandler);
51
63
 
52
64
  try {
53
- return JSON.parse(content);
65
+ return JSON.parse(content, jsonPollutionReviver);
54
66
  } catch (error) {
55
67
  const message =
56
68
  typeof errorMessage === 'function'
@@ -5,7 +5,15 @@
5
5
 
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
- import { REGEX, COMMENTS, IMPORTS, MESSAGES, GLOBALS_CSS_CANDIDATES } from './constants.js';
8
+ import {
9
+ REGEX,
10
+ COMMENTS,
11
+ IMPORTS,
12
+ MESSAGES,
13
+ GLOBALS_CSS_CANDIDATES,
14
+ VITE_CONFIG_FILES,
15
+ } from './constants.js';
16
+ import { assertSafeRelativePath } from './path-utils.js';
9
17
 
10
18
  /**
11
19
  * sparkle-design.css からフォントの@import文を抽出する
@@ -74,13 +82,17 @@ function createCustomCssImportBlock(customCssPath, globalsPath) {
74
82
  return '';
75
83
  }
76
84
 
77
- // custom-css パスは sparkle.config.json からの相対パス(= プロジェクトルートからの相対パス)
78
- // globals.css からの相対パスに変換
85
+ // `custom-css` は `sparkle.config.json` の `extend.custom-css` から来る
86
+ // ユーザー入力。globals-path と同様に path traversal / shell metacharacter
87
+ // の混入を弾く。検証を通過した上で entry CSS からの相対 path に変換する。
88
+ // en: Validate the user-supplied custom-css path before embedding it in
89
+ // the generated `@import` statement.
90
+ assertSafeRelativePath(customCssPath, 'custom-css');
91
+
79
92
  const globalsDir = path.dirname(globalsPath);
80
93
  const absoluteCustomPath = path.resolve(process.cwd(), customCssPath);
81
94
  let relativePath = path.relative(globalsDir, absoluteCustomPath).split(path.sep).join('/');
82
95
 
83
- // 相対パスが ./ で始まらない場合は付与
84
96
  if (!relativePath.startsWith('.')) {
85
97
  relativePath = `./${relativePath}`;
86
98
  }
@@ -294,15 +306,21 @@ export function updateGlobalsWithFonts(
294
306
  console.warn(
295
307
  `⚠️ ${displayPath} に \`@import "tailwindcss";\` が見つからなかったため先頭に追記します(canonical な Tailwind v4 entry point にするため)。`
296
308
  );
297
- globalsContent = `${IMPORTS.TAILWIND}\n${globalsContent.startsWith('\n') ? '' : ''}${globalsContent}`;
309
+ globalsContent = `${IMPORTS.TAILWIND}\n${globalsContent}`;
298
310
  tailwindInfo = findTailwindImport(globalsContent);
299
311
  if (!tailwindInfo) {
300
312
  // prepend したのに正規表現で拾えないのは `REGEX.TAILWIND_IMPORT` が
301
- // 想定と違う(= コードの不整合)。fail-fast する。
302
- // en: We just wrote the canonical import but the regex doesn't see it —
303
- // this is an internal inconsistency, not a user error. Fail loud.
313
+ // 想定と違う(= コードの不整合)。コメントどおり fail-loud にする。
314
+ // warn 止まりだと generate 完了扱いで exit 0 になり、ユーザーは
315
+ // patch が壊れていることに気付けない。strict 以外でも throw する。
316
+ // en: Coded as fail-loud per the comment. A warn-only return lets
317
+ // generate finish with exit 0 which hides the breakage.
304
318
  console.error(MESSAGES.TAILWIND_NOT_FOUND(displayPath));
305
- return { status: 'failed', reason: 'tailwind-import-missing-after-prepend' };
319
+ const err = new Error(
320
+ `tailwind import を prepend 後も検出できませんでした。REGEX.TAILWIND_IMPORT の不整合か globals.css の書き込み失敗の可能性があります: ${displayPath}`
321
+ );
322
+ err.code = 'E_TAILWIND_IMPORT_PREPEND_FAILED';
323
+ throw err;
306
324
  }
307
325
  }
308
326
 
@@ -326,26 +344,25 @@ export function updateGlobalsWithFonts(
326
344
  console.log(MESSAGES.GLOBALS_UPDATED(globalsPath));
327
345
  return { status: 'updated' };
328
346
  } catch (error) {
347
+ // `E_UNSAFE_RELATIVE_PATH`(custom-css の path traversal 等)や
348
+ // `E_TAILWIND_IMPORT_PREPEND_FAILED`(内部不整合)は silent fallback
349
+ // させず上流に伝播させる。これらは設定や状態の問題なので、write-error
350
+ // として status: failed で丸めてしまうと manageFontImports の上流
351
+ // catch では普通の write-error と区別できなくなる。
352
+ // en: Propagate codes that must never be silently downgraded. Generic
353
+ // write errors still go through the failed-return path.
354
+ if (
355
+ error.code === 'E_UNSAFE_RELATIVE_PATH' ||
356
+ error.code === 'E_TAILWIND_IMPORT_PREPEND_FAILED'
357
+ ) {
358
+ throw error;
359
+ }
329
360
  console.error(MESSAGES.GLOBALS_UPDATE_FAILED(error.message));
330
361
  return { status: 'failed', reason: `write-error: ${error.code ?? error.message}` };
331
362
  }
332
363
  }
333
364
 
334
- // Vite プロジェクト判定用の config 候補。setup.js 側の `VITE_CONFIG_FILES` と
335
- // 同じ対象を見る必要がある(判定ロジックが分かれると scaffold と generate で
336
- // 挙動が食い違う)。
337
- // en: Vite config candidates used for project detection. Must stay in sync with
338
- // setup.js's VITE_CONFIG_FILES so scaffold and generate agree on the layout.
339
- const VITE_CONFIG_FILES = [
340
- 'vite.config.ts',
341
- 'vite.config.js',
342
- 'vite.config.mjs',
343
- 'vite.config.cjs',
344
- 'vite.config.mts',
345
- 'vite.config.cts',
346
- ];
347
-
348
- function isViteProject(cwd) {
365
+ export function isViteProject(cwd) {
349
366
  return VITE_CONFIG_FILES.some((name) => fs.existsSync(path.join(cwd, name)));
350
367
  }
351
368
 
@@ -427,6 +444,15 @@ function resolveGlobalsPath(sparkleDesignPath, explicitGlobalsPath = null) {
427
444
  const dir = path.dirname(sparkleDesignPath);
428
445
 
429
446
  if (explicitGlobalsPath) {
447
+ // `--globals-path` CLI 引数 / `extend.globals-path` config 値はユーザー
448
+ // 由来のため、path traversal(`../../etc/passwd` 等)や shell メタ文字
449
+ // を弾く検証を通してから resolve する。共有リポジトリ経由で悪意ある
450
+ // config が入ったとき、teammate の fs 上で任意ファイルが書き換えられる
451
+ // supply-chain 型の attack を防ぐ。
452
+ // en: Validate user-supplied globals-path against traversal / shell
453
+ // metacharacters before resolving, to prevent config-injection abuse in
454
+ // shared repositories.
455
+ assertSafeRelativePath(explicitGlobalsPath, 'globals-path');
430
456
  const resolved = path.resolve(process.cwd(), explicitGlobalsPath);
431
457
  if (fs.existsSync(resolved)) {
432
458
  return { path: resolved, source: 'explicit' };
@@ -630,7 +656,17 @@ export function manageFontImports(
630
656
  // strict モード、または明示指定された globals path の not-found は
631
657
  // ユーザーが必ず気付くべきなので非 strict でも再 throw する。
632
658
  // en: Always re-throw explicit --globals-path typos; respect strict for the rest.
633
- if (strict || error.code === 'E_EXPLICIT_GLOBALS_PATH_NOT_FOUND') {
659
+ // 以下の code は strict 有無に関わらず常に throw:
660
+ // - E_EXPLICIT_GLOBALS_PATH_NOT_FOUND: 明示指定の typo
661
+ // - E_UNSAFE_RELATIVE_PATH: globals-path / custom-css の path traversal
662
+ // - E_TAILWIND_IMPORT_PREPEND_FAILED: 内部不整合(fail-loud)
663
+ // en: Always propagate these so silent misconfigurations never survive.
664
+ if (
665
+ strict ||
666
+ error.code === 'E_EXPLICIT_GLOBALS_PATH_NOT_FOUND' ||
667
+ error.code === 'E_UNSAFE_RELATIVE_PATH' ||
668
+ error.code === 'E_TAILWIND_IMPORT_PREPEND_FAILED'
669
+ ) {
634
670
  throw error;
635
671
  }
636
672
  return { status: 'failed', reason: `manage-error: ${error.code ?? error.message}` };
@@ -21,6 +21,7 @@ import {
21
21
  extractFontImports,
22
22
  removeFontImportsFromCSS,
23
23
  updateGlobalsWithFonts,
24
+ isViteProject,
24
25
  } from './font-manager.js';
25
26
 
26
27
  /**
@@ -42,15 +43,50 @@ function resolveExtend(config, configPath = null) {
42
43
  const baseDir = configPath ? path.dirname(path.resolve(configPath)) : process.cwd();
43
44
  const extendPath = path.resolve(baseDir, extend);
44
45
 
46
+ // 以前は try/catch で「読めなければ extend ごと無かったことにして続行」
47
+ // していたが、それだと `extend.globals-path` / `extend.source-packages`
48
+ // / `extend.fonts` のうち 1 つでも失敗すると全部が silent に無視される。
49
+ // 後段で Vite の entry CSS が想定と違う場所に patch される等の副作用に
50
+ // 繋がるため、必ず throw する。`bin/sparkle-design.js` の outer catch が
51
+ // 拾って exit 1 に昇格させる。
52
+ // en: Previously a failed extend file silently fell back to the base
53
+ // config, which hid typos / syntax errors and cascaded into wrong
54
+ // entry-CSS targets downstream. Always throw; the bin-level catch
55
+ // turns this into exit 1.
56
+ let content;
45
57
  try {
46
- const content = fs.readFileSync(extendPath, 'utf8');
47
- extend = JSON.parse(content);
48
- console.log(`✅ extend ファイルを読み込みました: ${extendPath}`);
58
+ content = fs.readFileSync(extendPath, 'utf8');
49
59
  } catch (error) {
50
- console.error(`❌ extend ファイルの読み込みに失敗しました: ${extendPath} (${error.message})`);
51
- const { extend: _, ...rest } = config;
52
- return rest;
60
+ const err = new Error(
61
+ `extend ファイルを読み込めませんでした: ${extendPath} (${error.code ?? error.message})`
62
+ );
63
+ err.code = 'E_EXTEND_FILE_READ_FAILED';
64
+ throw err;
65
+ }
66
+ try {
67
+ extend = JSON.parse(content, (key, value) => {
68
+ // Prototype pollution 対策: `__proto__` / `constructor` / `prototype` を drop
69
+ // en: Strip prototype-pollution keys.
70
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
71
+ return undefined;
72
+ }
73
+ return value;
74
+ });
75
+ } catch (error) {
76
+ const err = new Error(
77
+ `extend ファイルの JSON parse に失敗しました: ${extendPath} (${error.message})`
78
+ );
79
+ err.code = 'E_EXTEND_FILE_PARSE_FAILED';
80
+ throw err;
53
81
  }
82
+ if (typeof extend !== 'object' || extend === null || Array.isArray(extend)) {
83
+ const err = new Error(
84
+ `extend ファイルの中身は object である必要があります: ${extendPath}`
85
+ );
86
+ err.code = 'E_EXTEND_FILE_INVALID_SHAPE';
87
+ throw err;
88
+ }
89
+ console.log(`✅ extend ファイルを読み込みました: ${extendPath}`);
54
90
  }
55
91
 
56
92
  // extend の値を config にマージ(extend 内のフィールドが優先)
@@ -83,27 +119,81 @@ function dedupeFontImports(cssContent) {
83
119
  .join('\n');
84
120
  }
85
121
 
122
+ // Google Fonts に登録可能な family 名の安全な文字集合。英数字・スペース・
123
+ // ハイフン・アンダースコア(実際の Google Fonts ライブラリのほとんどを
124
+ // カバー)に限定。config 由来のため `'` / `;` / `}` / `*` / 改行が入ると
125
+ // 生成された CSS に `@import "http://attacker/"` のような injection が
126
+ // 通ってしまう。sparkle-variables で扱う Theme Settings プラグインの
127
+ // 選択肢集合を超える family 名はそもそも入らない想定なので、範囲を
128
+ // 狭く取ってよい(beyond-allowlist な family は後で緩和できる)。
129
+ // en: Restricted charset to prevent CSS injection via config-sourced font
130
+ // family names. Matches Google Fonts' actual naming conventions.
131
+ const FONT_FAMILY_SAFE = /^[A-Za-z0-9 _-]{1,64}$/;
132
+ const NPM_PACKAGE_SAFE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i;
133
+
134
+ function assertSafeFontFamily(family, label) {
135
+ if (typeof family !== 'string' || !FONT_FAMILY_SAFE.test(family)) {
136
+ const err = new Error(
137
+ `${label} に不正な文字が含まれています (${JSON.stringify(family)}). 英数字・スペース・ハイフン・アンダースコアのみ許可。`
138
+ );
139
+ err.code = 'E_UNSAFE_FONT_FAMILY';
140
+ throw err;
141
+ }
142
+ }
143
+
144
+ function assertSafeFontWeights(weights, label) {
145
+ if (!Array.isArray(weights)) {
146
+ const err = new Error(`${label} は配列である必要があります`);
147
+ err.code = 'E_UNSAFE_FONT_WEIGHTS';
148
+ throw err;
149
+ }
150
+ for (const w of weights) {
151
+ if (!Number.isInteger(w) || w < 1 || w > 1000) {
152
+ const err = new Error(
153
+ `${label} の値は 1..1000 の整数である必要があります (${JSON.stringify(w)})`
154
+ );
155
+ err.code = 'E_UNSAFE_FONT_WEIGHTS';
156
+ throw err;
157
+ }
158
+ }
159
+ }
160
+
161
+ function assertSafePackageName(name, label) {
162
+ if (typeof name !== 'string' || !NPM_PACKAGE_SAFE.test(name)) {
163
+ const err = new Error(`${label} は有効な npm パッケージ名ではありません (${JSON.stringify(name)})`);
164
+ err.code = 'E_UNSAFE_PACKAGE_NAME';
165
+ throw err;
166
+ }
167
+ }
168
+
86
169
  /**
87
- * fonts セクションのエントリを正規化する
170
+ * fonts セクションのエントリを正規化する。検証失敗は上流に throw する(silent
171
+ * にデフォルト値にフォールバックしない — 無効値はユーザーに気付かせる)。
88
172
  * @param {string|Array<string|{family:string, weights?:number[]}>} entry
173
+ * @param {string} label エラーメッセージ用ラベル(例: 'font-pro')
89
174
  * @returns {{family:string, weights:number[]}[]}
90
175
  */
91
- function normalizeFontsEntry(entry) {
176
+ function normalizeFontsEntry(entry, label = 'font') {
92
177
  if (!entry) {
93
178
  return [];
94
179
  }
180
+ const validate = (family, weights) => {
181
+ assertSafeFontFamily(family, `${label}.family`);
182
+ assertSafeFontWeights(weights, `${label}.weights`);
183
+ return { family, weights };
184
+ };
95
185
  if (typeof entry === 'string') {
96
- return [{ family: entry, weights: FONT_DEFAULTS.WEIGHTS }];
186
+ return [validate(entry, FONT_DEFAULTS.WEIGHTS)];
97
187
  }
98
188
  if (Array.isArray(entry)) {
99
189
  return entry.map((item) => {
100
190
  if (typeof item === 'string') {
101
- return { family: item, weights: FONT_DEFAULTS.WEIGHTS };
191
+ return validate(item, FONT_DEFAULTS.WEIGHTS);
102
192
  }
103
- return { family: item.family, weights: item.weights || FONT_DEFAULTS.WEIGHTS };
193
+ return validate(item.family, item.weights || FONT_DEFAULTS.WEIGHTS);
104
194
  });
105
195
  }
106
- return [{ family: entry.family, weights: entry.weights || FONT_DEFAULTS.WEIGHTS }];
196
+ return [validate(entry.family, entry.weights || FONT_DEFAULTS.WEIGHTS)];
107
197
  }
108
198
 
109
199
  /**
@@ -115,13 +205,13 @@ function normalizeFontsEntry(entry) {
115
205
  function resolveFontConfig(config) {
116
206
  if (config.fonts) {
117
207
  return {
118
- pro: normalizeFontsEntry(config.fonts.pro || config['font-pro']),
119
- mono: normalizeFontsEntry(config.fonts.mono || config['font-mono']),
208
+ pro: normalizeFontsEntry(config.fonts.pro || config['font-pro'], 'fonts.pro'),
209
+ mono: normalizeFontsEntry(config.fonts.mono || config['font-mono'], 'fonts.mono'),
120
210
  };
121
211
  }
122
212
  return {
123
- pro: normalizeFontsEntry(config['font-pro']),
124
- mono: normalizeFontsEntry(config['font-mono']),
213
+ pro: normalizeFontsEntry(config['font-pro'], 'font-pro'),
214
+ mono: normalizeFontsEntry(config['font-mono'], 'font-mono'),
125
215
  };
126
216
  }
127
217
 
@@ -374,6 +464,20 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
374
464
  processedCSS = processedCSS.replace(/\{\{FONT_FAMILY_MONO\}\}/g, monoFamilyValue);
375
465
 
376
466
  // 5. 基本的な設定値による置換(オブジェクト・配列・拡張フィールドはスキップ)
467
+ // 置換対象 key は想定される sparkle.config.json の一群(primary / radius /
468
+ // font-pro / font-mono 等)に限定しておくと、将来 config に追加された field
469
+ // が template に漏れ出すリスクと、`key.toUpperCase()` が regex メタ文字
470
+ // (`.` `*` `?` 等)を含んでいた場合に `new RegExp(placeholder, 'g')` が
471
+ // wildcard として広く置換してしまう L-2 型の regex injection 両方を防げる。
472
+ // en: Restrict replacement to a whitelist of known config keys so unknown /
473
+ // adversarial keys cannot smuggle regex metacharacters into the template.
474
+ const TEMPLATE_KEY_ALLOWLIST = new Set([
475
+ 'primary',
476
+ 'radius',
477
+ 'font-pro',
478
+ 'font-mono',
479
+ ]);
480
+ const ALPHANUMERIC_KEY = /^[a-z0-9-]+$/;
377
481
  Object.entries(config).forEach(([key, value]) => {
378
482
  // 配列・オブジェクト・拡張フィールドはスキップ
379
483
  if (
@@ -387,15 +491,27 @@ function processTemplate(template, config, grayMapping, radiusMapping, colors) {
387
491
  ) {
388
492
  return;
389
493
  }
390
- // 通常のプレースホルダー(CSS用 - スペースはそのまま)
494
+ // 未知 key は置換しない。また念のため英数 + ハイフン以外が含まれて
495
+ // いるキーも拒否(regex injection の 2 段目防御)。
496
+ // en: Reject unknown keys and keys with non-alphanumeric chars.
497
+ if (!TEMPLATE_KEY_ALLOWLIST.has(key) || !ALPHANUMERIC_KEY.test(key)) {
498
+ return;
499
+ }
500
+ // `String.prototype.replace` は第 2 引数が string のとき `$&` / `$'` /
501
+ // `$\`` / `$1` などを特別扱いして展開するため、ユーザー値が `$&` を
502
+ // 含んでいるとテンプレート内の別箇所が意図せず重複挿入される。callback
503
+ // 形式で渡すことで特別展開を無効化する(M-1 defensive fix)。
504
+ // en: Use the callback form of replace() to disable `$`-special
505
+ // replacements and treat the value as a literal.
506
+ const literalValue = String(value);
391
507
  const placeholder = `{{${key.toUpperCase().replace('-', '_')}}}`;
392
- processedCSS = processedCSS.replace(new RegExp(placeholder, 'g'), value);
508
+ processedCSS = processedCSS.replace(new RegExp(placeholder, 'g'), () => literalValue);
393
509
 
394
510
  // フォントファミリーの場合はURL用のプレースホルダーも生成(スペースを+に置き換え)
395
511
  if (key.toLowerCase().includes('font')) {
396
512
  const urlPlaceholder = `{{${key.toUpperCase().replace('-', '_')}_URL}}`;
397
- const urlValue = value.replace(/\s+/g, '+');
398
- processedCSS = processedCSS.replace(new RegExp(urlPlaceholder, 'g'), urlValue);
513
+ const urlValue = literalValue.replace(/\s+/g, '+');
514
+ processedCSS = processedCSS.replace(new RegExp(urlPlaceholder, 'g'), () => urlValue);
399
515
  }
400
516
  });
401
517
 
@@ -441,22 +557,49 @@ const KNOWN_DESIGN_SYSTEM_PACKAGES = ['@goodpatch/sparkle-design-internal'];
441
557
  * en: Scan package.json and surface source-packages when a design-system package is present.
442
558
  */
443
559
  function detectSourcePackagesFromPackageJson(cwd = process.cwd()) {
560
+ // package.json が存在しないプロジェクト(稀だが想定はする)は null を返して
561
+ // 「design system パッケージ検出なし」として扱う。一方で「ファイルはあるが
562
+ // parse エラー / 権限エラー」は silent null にせず throw に昇格させる。
563
+ // 以前は全エラーを `catch {}` で握り潰していたため、package.json の JSON
564
+ // 破損で `@source` が silent に未生成になり、Tailwind スキャンが空になって
565
+ // class が効かないという silent 回帰に繋がっていた(beta.7 の silent drop
566
+ // と同系統)。
567
+ // en: ENOENT → null (no package.json; no sparkle detection). Other errors
568
+ // → throw so they surface instead of turning into `@source` drops.
569
+ const pkgPath = path.join(cwd, 'package.json');
570
+ let raw;
444
571
  try {
445
- const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
446
- const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
447
-
448
- const hasDefault = Object.prototype.hasOwnProperty.call(deps, 'sparkle-design');
449
- const extras = KNOWN_DESIGN_SYSTEM_PACKAGES.filter((name) =>
450
- Object.prototype.hasOwnProperty.call(deps, name)
572
+ raw = fs.readFileSync(pkgPath, 'utf8');
573
+ } catch (error) {
574
+ if (error.code === 'ENOENT') return null;
575
+ throw new Error(
576
+ `package.json の読み込みに失敗しました: ${pkgPath} (${error.code ?? error.message})`
451
577
  );
578
+ }
579
+ let pkg;
580
+ try {
581
+ // Prototype pollution 対策(L-1): `__proto__` / `constructor` / `prototype` を drop
582
+ // en: Strip prototype-pollution keys at parse time.
583
+ pkg = JSON.parse(raw, (key, value) => {
584
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
585
+ return undefined;
586
+ }
587
+ return value;
588
+ });
589
+ } catch (error) {
590
+ throw new Error(`package.json の JSON parse に失敗しました: ${pkgPath} (${error.message})`);
591
+ }
592
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
452
593
 
453
- if (hasDefault || extras.length > 0) {
454
- return extras;
455
- }
456
- return null;
457
- } catch {
458
- return null;
594
+ const hasDefault = Object.prototype.hasOwnProperty.call(deps, 'sparkle-design');
595
+ const extras = KNOWN_DESIGN_SYSTEM_PACKAGES.filter((name) =>
596
+ Object.prototype.hasOwnProperty.call(deps, name)
597
+ );
598
+
599
+ if (hasDefault || extras.length > 0) {
600
+ return extras;
459
601
  }
602
+ return null;
460
603
  }
461
604
 
462
605
  function writeCSS(cssContent, outputPath = null) {
@@ -540,10 +683,14 @@ export function generateCSS(
540
683
  // する。Vite は <head> が index.html 側にあるため、React コンポーネントの
541
684
  // SparkleHead を挟めない。managed block でマーカー間だけを管理し、既存の
542
685
  // head 内容は非破壊に保つ。
543
- // en: Vite ships its <head> in index.html, outside React's reach, so mirror
544
- // the SparkleHead link tags into a managed block there.
686
+ // en: Inject only when confirmed Vite — other frameworks shipping an
687
+ // index.html (Astro / Remix / plain SPA) must not receive Sparkle's
688
+ // managed block.
545
689
  const cwdForHtml = process.cwd();
546
- if (fs.existsSync(path.resolve(cwdForHtml, 'index.html'))) {
690
+ if (
691
+ isViteProject(cwdForHtml) &&
692
+ fs.existsSync(path.resolve(cwdForHtml, 'index.html'))
693
+ ) {
547
694
  const htmlResult = upsertViteIndexHtmlFonts(cwdForHtml, resolvedFonts);
548
695
  if (htmlResult.status === 'created' || htmlResult.status === 'updated') {
549
696
  const displayPath = path.relative(cwdForHtml, htmlResult.path) || htmlResult.path;
@@ -551,9 +698,19 @@ export function generateCSS(
551
698
  `✅ index.html の Sparkle フォント <link> ブロックを${htmlResult.status === 'created' ? '挿入' : '更新'}しました: ${displayPath}`
552
699
  );
553
700
  } else if (htmlResult.status === 'failed') {
554
- console.warn(
555
- `⚠️ index.html への Sparkle フォント <link> 挿入に失敗しました (${htmlResult.reason})。手動で <head> 内に SparkleHead.tsx 相当の <link> タグを追加してください。`
701
+ // `no-head-close-tag` は index.html が壊れている事実上の user error。
702
+ // silent warn ではなく error として出し、strict / no-head-close-tag
703
+ // のときは throw に昇格させる。read-error も同様。
704
+ // en: A failed injection signals either a malformed index.html or a
705
+ // permission problem — surface it loudly rather than no-op silently.
706
+ console.error(
707
+ `❌ index.html への Sparkle フォント <link> 挿入に失敗しました (reason: ${htmlResult.reason})。index.html の <head>...</head> が正しいか確認してください。対象: ${path.resolve(cwdForHtml, 'index.html')}`
556
708
  );
709
+ if (Boolean(options.strict) || htmlResult.reason === 'no-head-close-tag') {
710
+ throw new Error(
711
+ `index.html への font link 挿入失敗 (${htmlResult.reason})。--strict または no-head-close-tag の場合は exit 1 で停止します。`
712
+ );
713
+ }
557
714
  }
558
715
  }
559
716
 
@@ -567,6 +724,17 @@ export function generateCSS(
567
724
  console.log(MESSAGES.FONT_MANAGEMENT_START);
568
725
  const detectedPackages = detectSourcePackagesFromPackageJson();
569
726
  const explicitPackages = 'source-packages' in config ? config['source-packages'] || [] : null;
727
+ // 明示指定側はユーザー由来なので、npm package name の安全な charset に
728
+ // 限定する。不正値(`a"; body{...}` のような CSS injection 等)は吐かない。
729
+ // 自動検出側は `package.json` の `dependencies` key(npm 自身が validate
730
+ // 済み)なので追加 validation は不要。
731
+ // en: Validate user-supplied source-packages entries; detected ones come
732
+ // from npm-validated package.json keys.
733
+ if (Array.isArray(explicitPackages)) {
734
+ for (const pkg of explicitPackages) {
735
+ assertSafePackageName(pkg, 'extend.source-packages');
736
+ }
737
+ }
570
738
  const sourcePackages =
571
739
  detectedPackages !== null || explicitPackages !== null
572
740
  ? [...new Set([...(detectedPackages ?? []), ...(explicitPackages ?? [])])]
@@ -0,0 +1,66 @@
1
+ import path from 'path';
2
+
3
+ // setup.js 側と共通の「unsafe な shell メタ文字や quote」を弾く pattern。
4
+ // config 由来や CLI 引数の相対 path に対して path traversal / shell injection
5
+ // 対策として使う。
6
+ // en: Shared unsafe-character pattern. Keeps in sync with setup.js's internal
7
+ // usage; any new call site should reuse this helper rather than re-implement.
8
+ const UNSAFE_CHARS = /[\s"'`;$&|<>()[\]{}\\]/;
9
+
10
+ /**
11
+ * path の区切りを POSIX (`/`) に揃える。Windows / POSIX 両環境で相対 path の
12
+ * 表示や `@import` path 生成に使う。
13
+ * en: Normalize OS path separators to POSIX `/`.
14
+ */
15
+ export function toPosixPath(p) {
16
+ return p.split(path.sep).join('/');
17
+ }
18
+
19
+ /**
20
+ * ユーザー入力の相対 path を検証し、検証済みの POSIX 相対 path を返す。
21
+ *
22
+ * 検証内容:
23
+ * - 空文字 / 空白のみ → throw
24
+ * - 絶対パス → throw(user config に絶対パスを書かせるとプロジェクト外書き込みが許される)
25
+ * - shell メタ文字を含む → throw(hook command 文字列や script に埋まると injection になる)
26
+ * - 正規化後に `../` から始まる = プロジェクト root を escape → throw
27
+ *
28
+ * 既存の `setup.js:normalizePath` から「相対 path validation 部分だけ」抜き
29
+ * 出した軽量版。`fs.existsSync` 等のファイル I/O は行わない(呼び出し側が
30
+ * それぞれの context で存在チェックする)。
31
+ *
32
+ * en: Validate a user-supplied relative path and return the normalized POSIX
33
+ * form. Fails loud on traversal / shell metacharacters / absolute paths.
34
+ * Unlike setup.js:normalizePath, this does not touch the filesystem.
35
+ *
36
+ * @param {string} inputPath
37
+ * @param {string} label エラーメッセージ用ラベル
38
+ * @returns {string} 正規化された POSIX 相対 path
39
+ */
40
+ export function assertSafeRelativePath(inputPath, label) {
41
+ const tagged = (message) => {
42
+ const err = new Error(message);
43
+ err.code = 'E_UNSAFE_RELATIVE_PATH';
44
+ return err;
45
+ };
46
+ if (typeof inputPath !== 'string') {
47
+ throw tagged(`${label} must be a string (got ${typeof inputPath})`);
48
+ }
49
+ const trimmed = inputPath.trim();
50
+ if (!trimmed) {
51
+ throw tagged(`${label} is empty`);
52
+ }
53
+ if (path.isAbsolute(trimmed)) {
54
+ throw tagged(`${label} must be relative to the project root: ${trimmed}`);
55
+ }
56
+ if (UNSAFE_CHARS.test(trimmed)) {
57
+ throw tagged(
58
+ `${label} contains unsupported characters: ${trimmed}. Use a simple relative path.`
59
+ );
60
+ }
61
+ const normalized = path.posix.normalize(trimmed.replace(/\\/g, '/'));
62
+ if (normalized.startsWith('../')) {
63
+ throw tagged(`${label} must stay inside the project root: ${trimmed}`);
64
+ }
65
+ return normalized;
66
+ }