sparkle-design-cli 2.5.0-beta.2 → 2.5.0-beta.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/README.md CHANGED
@@ -73,6 +73,9 @@ npx sparkle-design-cli check src --format json
73
73
 
74
74
  # 現在有効なアンチパターンルールを一覧表示(プラグイン由来のものも含む)
75
75
  npx sparkle-design-cli rules
76
+
77
+ # Artifact Registry の npm registry 用トークンを ~/.npmrc に書く(beta)
78
+ npx --yes sparkle-design-cli@beta auth
76
79
  ```
77
80
 
78
81
  ### generate: 基本的な使用方法
@@ -197,6 +200,23 @@ npx sparkle-design-cli rules --format json
197
200
 
198
201
  AI エージェントや CI からこの `lint:sparkle` を呼ぶ運用にすると、ガイドラインの注意書きだけに頼らず機械的に検査できます。
199
202
 
203
+ ### auth: Artifact Registry の認証(beta)
204
+
205
+ 社内パッケージの配信先を Google Artifact Registry(AR)に移行するための準備です(goodpatch/sparkle-design-internal#261)。Google の資格情報から AR 用のアクセストークンを取得し、ユーザー単位の `~/.npmrc` に書きます。
206
+
207
+ ```bash
208
+ gcloud auth login # 初回のみ(ADC を使う場合は gcloud auth application-default login)
209
+ npx --yes sparkle-design-cli@beta auth # プロジェクトの .npmrc の @goodpatch:registry が対象
210
+ pnpm install
211
+ ```
212
+
213
+ - 対象の registry は `--registry <url>`、無ければプロジェクトの `.npmrc`(親ディレクトリも辿る)の `@goodpatch:registry=` です。プロジェクトの `.npmrc` には registry 行だけを置けばよく、そのままコミットできます
214
+ - トークンの取得は ADC → `gcloud auth print-access-token` の順です。どちらも使えなければ `gcloud auth login` を案内して終了します
215
+ - **トークンは約 60 分で失効します。** `pnpm install` が 401 で落ちたら(npm は `npm adduser` を案内しますが、AR では解決しません)もう一度 `auth` を実行してください
216
+ - 同じ registry の行は置き換えるので、何度実行しても `~/.npmrc` に行は積み上がりません。`always-auth` は書きません
217
+ - トークンは Artifact Registry(`<location>-npm.pkg.dev`)以外には書きません。`cloud-platform` スコープの Google トークンなので、ほかのホストに渡すと資格情報の漏えいになるためです
218
+ - トークンを画面に出さず、refresh token などの長期クレデンシャルもコピーしません(gcloud の管理下に置いたまま)
219
+
200
220
  ### setup: プロジェクトのフルセットアップ
201
221
 
202
222
  `sparkle-design-cli setup` は、Sparkle Design の導入に必要な作業をまとめて行います:
@@ -7,8 +7,17 @@ import { runStopHook } from '../lib/stop-hook.js';
7
7
  import { PLUGIN_SPEC_MARKDOWN } from '../lib/plugin-api.js';
8
8
  import { loadAntiPatternPlugins } from '../lib/load-plugins.js';
9
9
  import { runRules } from '../lib/rules-report.js';
10
-
11
- const SUBCOMMANDS = new Set(['generate', 'check', 'rules', 'setup', 'stop-hook', 'plugin-spec']);
10
+ import { runAuth } from '../lib/auth.js';
11
+
12
+ const SUBCOMMANDS = new Set([
13
+ 'generate',
14
+ 'check',
15
+ 'rules',
16
+ 'setup',
17
+ 'auth',
18
+ 'stop-hook',
19
+ 'plugin-spec',
20
+ ]);
12
21
 
13
22
  function requireOptionValue(args, index, flags) {
14
23
  const value = args[index + 1];
@@ -170,6 +179,30 @@ function parseRulesOptions(args) {
170
179
  return options;
171
180
  }
172
181
 
182
+ /**
183
+ * `auth` のオプション。未知のフラグはその場で失敗させる(他のサブコマンドと同じ)。
184
+ * en: Unknown flags fail fast, same as the other subcommands.
185
+ */
186
+ function parseAuthOptions(args) {
187
+ const options = { registry: null, help: false };
188
+
189
+ for (let i = 0; i < args.length; i += 1) {
190
+ const arg = args[i];
191
+ if (arg === '-h' || arg === '--help') {
192
+ options.help = true;
193
+ continue;
194
+ }
195
+ if (arg === '--registry') {
196
+ options.registry = requireOptionValue(args, i, '--registry');
197
+ i += 1;
198
+ continue;
199
+ }
200
+ throw new Error(`Unknown option for auth: ${arg}`);
201
+ }
202
+
203
+ return options;
204
+ }
205
+
173
206
  function showHelp() {
174
207
  console.log(`
175
208
  Sparkle Design CLI
@@ -182,6 +215,7 @@ Commands:
182
215
  check Sparkle Design のアンチパターンを検査
183
216
  rules 現在有効なアンチパターンルールを一覧表示(プラグイン由来のものも含む)
184
217
  setup Sparkle Design プロジェクトをセットアップ(パッケージ導入 + 初期ファイル + AI ガード + generate)
218
+ auth Artifact Registry の npm registry 用アクセストークンを ~/.npmrc に書く(beta)
185
219
  stop-hook AI assistant の Stop hook 用 internal subcommand(severity=error の findings か、
186
220
  実行に失敗して未検査のルールがあるときに exit 2 で 1 度だけ停止をブロック / 再発火は自動回避。
187
221
  warning / info はブロックせず件数の要約を stderr に出す)
@@ -211,6 +245,10 @@ Plugin spec:
211
245
  sparkle-design-cli plugin-spec # アンチパターン拡張プラグインの契約仕様を出力
212
246
  sparkle-design-cli plugin-spec --list # 現在のプロジェクトで発見されたプラグインを表示
213
247
 
248
+ Auth (beta):
249
+ sparkle-design-cli auth # プロジェクトの .npmrc の @goodpatch:registry を対象に認証
250
+ sparkle-design-cli auth --registry https://asia-northeast1-npm.pkg.dev/<project>/<repository>/
251
+
214
252
  Setup:
215
253
  sparkle-design-cli setup # フルセットアップ(推奨・新規導入時)
216
254
  sparkle-design-cli setup --assistant claude # Claude 向けガードも同時にセットアップ
@@ -275,6 +313,19 @@ Check options:
275
313
  (warning / info は報告のみで exit code に影響しない)
276
314
  --format <text|json> 出力形式 (default: text)
277
315
 
316
+ Auth options:
317
+ -h, --help このヘルプメッセージを表示
318
+ --registry <url> 対象の registry(default: プロジェクトの .npmrc の @goodpatch:registry)
319
+
320
+ Auth の動作:
321
+ 1. Google の資格情報(ADC → gcloud auth print-access-token の順)からアクセストークンを取得
322
+ どちらも無い / 期限切れなら gcloud auth login を案内して終了
323
+ 2. ユーザー単位の ~/.npmrc(NPM_CONFIG_USERCONFIG があればそちら)に、同じ registry の行を
324
+ 置き換えて書く(プロジェクトの .npmrc は registry 行のみのまま、コミットできる状態を保つ)
325
+ 3. 有効期限(約 60 分)を表示する。切れたらもう一度 auth を実行する
326
+ トークンは Artifact Registry(<location>-npm.pkg.dev)にだけ書き、画面には出しません。
327
+ refresh token などの長期クレデンシャルは gcloud の管理下に置いたままで、コピーしません。
328
+
278
329
  Setup options:
279
330
  -h, --help このヘルプメッセージを表示
280
331
  --assistant <name> claude / codex / cursor / generic (default: generic)
@@ -410,6 +461,16 @@ async function main() {
410
461
  return;
411
462
  }
412
463
 
464
+ if (command === 'auth') {
465
+ const options = parseAuthOptions(args.slice(1));
466
+ if (options.help) {
467
+ showHelp();
468
+ process.exit(0);
469
+ }
470
+ await runAuth(options);
471
+ return;
472
+ }
473
+
413
474
  if (command === 'stop-hook') {
414
475
  // setup で各 agent の hook 設定ファイルから呼ばれる internal subcommand。
415
476
  // 以降の引数はすべて lint 対象 path。setup が書き出す hook は 1 つだが、
package/lib/auth.js ADDED
@@ -0,0 +1,302 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { spawnSync } from 'child_process';
5
+ import { URL, URLSearchParams } from 'url';
6
+
7
+ /**
8
+ * `sparkle-design-cli auth` — Google Artifact Registry(AR)の npm registry 用に、
9
+ * 利用者の Google 資格情報からアクセストークンを取り、ユーザー単位の `~/.npmrc` に書く。
10
+ *
11
+ * AR のトークンは約 60 分で失効するので、「翌日 `pnpm install` が 401 で落ちる」を
12
+ * 1 コマンドで復帰できるようにするのが目的(goodpatch/sparkle-design-cli#94 の Phase 1)。
13
+ *
14
+ * 失効を弱めないための制約(#94 の追加仕様):
15
+ * - 書き込むのはアクセストークン(60 分以内に失効)だけ。refresh token は gcloud / ADC の
16
+ * 管理下に置いたまま読むだけで、コピー・キャッシュしない
17
+ * - トークンを標準出力・エラー出力に出さない
18
+ * en: Writes a short-lived Google access token for an Artifact Registry npm registry into the
19
+ * user-level ~/.npmrc. Never persists or prints long-lived credentials or the token itself.
20
+ */
21
+
22
+ const SCOPE = '@goodpatch';
23
+ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
24
+ const GOOGLE_TOKENINFO_URL = 'https://oauth2.googleapis.com/tokeninfo';
25
+ /** アクセストークンとして受け付ける文字。改行などで .npmrc に別の行を差し込ませない */
26
+ const TOKEN_PATTERN = /^[A-Za-z0-9._\-~+/=]+$/;
27
+
28
+ export class AuthError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = 'AuthError';
32
+ }
33
+ }
34
+
35
+ /**
36
+ * registry URL を検証して、末尾スラッシュ付きの正規形にする。
37
+ *
38
+ * トークンは `cloud-platform` スコープの利用者アクセストークンで、60 分間は GCP 全体を
39
+ * その人として操作できる。AR 以外のホストに渡すと資格情報の漏えいになるので、
40
+ * https かつ AR の npm ホスト(`<location>-npm.pkg.dev`)に限る。
41
+ * en: Only https Artifact Registry npm hosts are accepted, to avoid leaking a broad Google token.
42
+ */
43
+ export function normalizeRegistry(raw) {
44
+ let url;
45
+ try {
46
+ url = new URL(String(raw).trim());
47
+ } catch {
48
+ throw new AuthError(`registry の URL として解釈できません: ${raw}`);
49
+ }
50
+ if (url.protocol !== 'https:') {
51
+ throw new AuthError(`registry は https である必要があります: ${url.href}`);
52
+ }
53
+ if (!/^[a-z0-9-]+-npm\.pkg\.dev$/.test(url.hostname)) {
54
+ throw new AuthError(
55
+ `Artifact Registry の npm registry(<location>-npm.pkg.dev)ではありません: ${url.host}\n` +
56
+ ' このコマンドは Google のアクセストークンを書き込むため、Artifact Registry 以外には使えません。'
57
+ );
58
+ }
59
+ if (url.username || url.password || url.search || url.hash) {
60
+ throw new AuthError(`registry の URL に余分な要素が含まれています: ${raw}`);
61
+ }
62
+ const pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`;
63
+ return `https://${url.host}${pathname}`;
64
+ }
65
+
66
+ /** `~/.npmrc` に書くキー(`//host/path/:_authToken`) */
67
+ export function registryAuthKey(registry) {
68
+ return `${registry.replace(/^https:/, '')}:_authToken`;
69
+ }
70
+
71
+ /** `.npmrc` のテキストから `@goodpatch:registry=` の値を取り出す。無ければ null */
72
+ export function readScopeRegistry(npmrcText, scope = SCOPE) {
73
+ for (const rawLine of npmrcText.split(/\r?\n/)) {
74
+ const line = rawLine.trim();
75
+ if (line.startsWith('#') || line.startsWith(';')) continue;
76
+ const match = /^([^=]+?)\s*=\s*(.+)$/.exec(line);
77
+ if (match && match[1] === `${scope}:registry`) {
78
+ return match[2].trim();
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * プロジェクトの `.npmrc` を cwd から親へ辿って探し、`@goodpatch:registry=` を返す。
86
+ * monorepo でサブディレクトリから実行しても、ルートの `.npmrc` を見つけられるようにする。
87
+ */
88
+ export function findProjectRegistry(cwd, readFile = (p) => fs.readFileSync(p, 'utf8')) {
89
+ let dir = path.resolve(cwd);
90
+ for (;;) {
91
+ const candidate = path.join(dir, '.npmrc');
92
+ let text = null;
93
+ try {
94
+ text = readFile(candidate);
95
+ } catch {
96
+ // 無ければ親へ
97
+ }
98
+ if (text !== null) {
99
+ const registry = readScopeRegistry(text);
100
+ if (registry) return { registry, source: candidate };
101
+ }
102
+ const parent = path.dirname(dir);
103
+ if (parent === dir) return null;
104
+ dir = parent;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * `.npmrc` のテキスト中の、同じ registry のトークン行を置き換える(無ければ末尾に足す)。
110
+ * `google-artifactregistry-auth` は追記するだけで、実行のたびに行が積み上がるため。
111
+ * 末尾スラッシュの有無が違う同じ registry の行もまとめて取り除く。
112
+ */
113
+ export function upsertAuthToken(npmrcText, registry, token) {
114
+ const key = registryAuthKey(registry);
115
+ const variants = new Set([key, key.replace(/\/:_authToken$/, ':_authToken')]);
116
+ const lines = npmrcText === '' ? [] : npmrcText.split(/\r?\n/);
117
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
118
+ const kept = lines.filter((line) => {
119
+ const match = /^\s*([^=\s]+)\s*=/.exec(line);
120
+ return !(match && variants.has(match[1]));
121
+ });
122
+ kept.push(`${key}=${token}`);
123
+ return `${kept.join('\n')}\n`;
124
+ }
125
+
126
+ /** npm が読むユーザー単位の設定ファイル */
127
+ export function resolveUserConfigPath(env = process.env, homedir = os.homedir()) {
128
+ return env.NPM_CONFIG_USERCONFIG || env.npm_config_userconfig || path.join(homedir, '.npmrc');
129
+ }
130
+
131
+ /** ADC ファイルの場所(gcloud と同じ探し方) */
132
+ export function resolveAdcPath(env = process.env, homedir = os.homedir()) {
133
+ if (env.GOOGLE_APPLICATION_CREDENTIALS) return env.GOOGLE_APPLICATION_CREDENTIALS;
134
+ const configDir =
135
+ env.CLOUDSDK_CONFIG ||
136
+ (process.platform === 'win32' && env.APPDATA
137
+ ? path.join(env.APPDATA, 'gcloud')
138
+ : path.join(homedir, '.config', 'gcloud'));
139
+ return path.join(configDir, 'application_default_credentials.json');
140
+ }
141
+
142
+ /** 失効時刻の表示(例: 15:52(あと 59 分)) */
143
+ export function formatExpiry(expiresInSeconds, now = new Date()) {
144
+ const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000);
145
+ const hh = String(expiresAt.getHours()).padStart(2, '0');
146
+ const mm = String(expiresAt.getMinutes()).padStart(2, '0');
147
+ return `${hh}:${mm}(あと ${Math.floor(expiresInSeconds / 60)} 分)`;
148
+ }
149
+
150
+ // ---- トークンの取得(テストでは差し替える) ----
151
+
152
+ /**
153
+ * ADC(`gcloud auth application-default login` で作られる authorized_user)の
154
+ * refresh token でアクセストークンを取り直す。refresh token は読むだけで保存しない。
155
+ * 使えなければ null(呼び出し側で gcloud にフォールバックする)。
156
+ */
157
+ async function tokenFromAdc({ env, homedir, fetchImpl }) {
158
+ let adc;
159
+ try {
160
+ adc = JSON.parse(fs.readFileSync(resolveAdcPath(env, homedir), 'utf8'));
161
+ } catch {
162
+ return null;
163
+ }
164
+ // サービスアカウントキー等は対象外(組織ポリシーでキー発行自体が禁止されている)
165
+ if (adc?.type !== 'authorized_user' || !adc.refresh_token || !adc.client_id) {
166
+ return null;
167
+ }
168
+ try {
169
+ const res = await fetchImpl(GOOGLE_TOKEN_URL, {
170
+ method: 'POST',
171
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
172
+ body: new URLSearchParams({
173
+ grant_type: 'refresh_token',
174
+ refresh_token: adc.refresh_token,
175
+ client_id: adc.client_id,
176
+ client_secret: adc.client_secret ?? '',
177
+ }).toString(),
178
+ });
179
+ if (!res.ok) return null;
180
+ const json = await res.json();
181
+ if (typeof json.access_token !== 'string') return null;
182
+ return { token: json.access_token, expiresIn: Number(json.expires_in) || null, via: 'ADC' };
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /** `gcloud auth print-access-token`。未導入・未ログインなら null */
189
+ function tokenFromGcloud({ spawn }) {
190
+ const result = spawn('gcloud', ['auth', 'print-access-token'], {
191
+ encoding: 'utf8',
192
+ shell: process.platform === 'win32',
193
+ stdio: ['ignore', 'pipe', 'pipe'],
194
+ });
195
+ if (result.error || result.status !== 0) return null;
196
+ const token = String(result.stdout ?? '').trim();
197
+ return token ? { token, expiresIn: null, via: 'gcloud' } : null;
198
+ }
199
+
200
+ /** 失効までの秒数を tokeninfo で調べる(取れなくても認証自体は成功扱い) */
201
+ async function lookupExpiresIn(token, fetchImpl) {
202
+ try {
203
+ const res = await fetchImpl(GOOGLE_TOKENINFO_URL, {
204
+ method: 'POST',
205
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
206
+ body: new URLSearchParams({ access_token: token }).toString(),
207
+ });
208
+ if (!res.ok) return null;
209
+ const json = await res.json();
210
+ return Number(json.expires_in) || null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ export const defaultDeps = {
217
+ env: process.env,
218
+ homedir: os.homedir(),
219
+ cwd: () => process.cwd(),
220
+ fetchImpl: (...args) => globalThis.fetch(...args),
221
+ spawn: spawnSync,
222
+ now: () => new Date(),
223
+ log: (...args) => console.log(...args),
224
+ };
225
+
226
+ /**
227
+ * `auth` 本体。成功したら書き込み先と失効時刻を返す。
228
+ * 失敗は AuthError(メッセージにトークンは含めない)。
229
+ */
230
+ export async function runAuth(options = {}, deps = {}) {
231
+ const d = { ...defaultDeps, ...deps };
232
+
233
+ let registrySource;
234
+ let rawRegistry;
235
+ if (options.registry) {
236
+ rawRegistry = options.registry;
237
+ registrySource = '--registry';
238
+ } else {
239
+ const found = findProjectRegistry(d.cwd());
240
+ if (!found) {
241
+ throw new AuthError(
242
+ `対象の registry が決められません。次のどちらかを行ってください:\n` +
243
+ ` - プロジェクトの .npmrc に ${SCOPE}:registry=https://<location>-npm.pkg.dev/<project>/<repository>/ を書く\n` +
244
+ ` - --registry <url> を指定する`
245
+ );
246
+ }
247
+ rawRegistry = found.registry;
248
+ registrySource = found.source;
249
+ }
250
+ const registry = normalizeRegistry(rawRegistry);
251
+
252
+ const acquired = (await tokenFromAdc(d)) ?? tokenFromGcloud(d);
253
+ if (!acquired) {
254
+ throw new AuthError(
255
+ 'Google の認証情報が見つからないか、期限切れです。次を実行してから、もう一度 auth を実行してください:\n' +
256
+ ' gcloud auth login\n' +
257
+ ' (ADC を使う場合は gcloud auth application-default login)'
258
+ );
259
+ }
260
+ if (!TOKEN_PATTERN.test(acquired.token)) {
261
+ throw new AuthError(
262
+ '取得したアクセストークンの形式が想定と異なるため、書き込みを中止しました。'
263
+ );
264
+ }
265
+ const expiresIn = acquired.expiresIn ?? (await lookupExpiresIn(acquired.token, d.fetchImpl));
266
+
267
+ const userConfigPath = resolveUserConfigPath(d.env, d.homedir);
268
+ let current = '';
269
+ let exists = false;
270
+ try {
271
+ current = fs.readFileSync(userConfigPath, 'utf8');
272
+ exists = true;
273
+ } catch {
274
+ // 無ければ新規作成
275
+ }
276
+ const next = upsertAuthToken(current, registry, acquired.token);
277
+ // 本人だけが読めるようにする。既存の ~/.npmrc は既定の 0644(他ユーザーも読める)のことが
278
+ // 多く、そのまま書くと共有マシンで他のアカウントにトークンを読まれうるので、
279
+ // 書き込む前に 0600 へ絞る(Windows は POSIX の権限が無いので対象外)
280
+ let tightened = false;
281
+ if (exists && process.platform !== 'win32') {
282
+ const mode = fs.statSync(userConfigPath).mode & 0o777;
283
+ if (mode !== 0o600) {
284
+ fs.chmodSync(userConfigPath, 0o600);
285
+ tightened = true;
286
+ }
287
+ }
288
+ fs.writeFileSync(userConfigPath, next, exists ? undefined : { mode: 0o600 });
289
+
290
+ d.log(`✅ Artifact Registry の認証情報を書き込みました(${acquired.via} の資格情報を使用)`);
291
+ d.log(` registry: ${registry}(${registrySource})`);
292
+ d.log(
293
+ ` 書き込み先: ${userConfigPath}${tightened ? '(他ユーザーから読めない権限 0600 に変更しました)' : ''}`
294
+ );
295
+ d.log(
296
+ expiresIn
297
+ ? ` 有効期限: ${formatExpiry(expiresIn, d.now())}。切れたらもう一度 auth を実行してください`
298
+ : ' 有効期限: 約 60 分。切れたらもう一度 auth を実行してください'
299
+ );
300
+
301
+ return { registry, userConfigPath, expiresIn, via: acquired.via };
302
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparkle-design-cli",
3
- "version": "2.5.0-beta.2",
3
+ "version": "2.5.0-beta.3",
4
4
  "description": "Sparkle Design CLI — プロジェクトセットアップ、CSS・フォント生成、アンチパターン検査、AI エージェント(Claude Code / Cursor / Codex)向けのガードと hook 設定まで一括で行う sparkle-design 公式 CLI。",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",