sparkle-design-cli 2.5.0-beta.2 → 2.5.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/bin/sparkle-design.js +136 -2
- package/docs/anti-patterns.md +21 -0
- package/lib/anti-pattern-rules.js +344 -3
- package/lib/auth.js +302 -0
- package/lib/check.js +2 -0
- package/lib/migrate.js +460 -0
- package/lib/token-migration.js +3 -1
- package/package.json +1 -1
- package/templates/sparkle-variables/README.md +6 -2
- package/templates/sparkle-variables/scripts/validate-tokens.mjs +161 -0
- package/templates/sparkle-variables/sparkle-design.template.css +3 -25
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/lib/check.js
CHANGED
|
@@ -696,9 +696,11 @@ export {
|
|
|
696
696
|
BUILTIN_RULES as RULES,
|
|
697
697
|
blockingFindings,
|
|
698
698
|
coerceSeverity,
|
|
699
|
+
collectFiles,
|
|
699
700
|
collectFindings,
|
|
700
701
|
countBySeverity,
|
|
701
702
|
createCheckReport,
|
|
702
703
|
hasBlockingIssues,
|
|
704
|
+
isSuppressed,
|
|
703
705
|
BUILTIN_MANUAL_REVIEW_REMINDERS as MANUAL_REVIEW_REMINDERS,
|
|
704
706
|
};
|