@nuwel-dev/setup 1.0.0 → 1.0.2

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
@@ -10,13 +10,50 @@ npx @nuwel-dev/setup
10
10
  > この橋自身は公開 npm 側に在るため scope registry を明示して実行する:
11
11
  > `npx --@nuwel-dev:registry=https://registry.npmjs.org @nuwel-dev/setup`
12
12
 
13
+ ## 動作環境(**始める前に**)
14
+
15
+ | | 要件 | 満たさない時 |
16
+ |---|---|---|
17
+ | OS | **macOS / Linux**(Windows は WSL2 の Linux 側で) | トークンを尋ねる**前**に停止します |
18
+ | Node | **22.18 以上 / 24.11 以上 / 26 以上**(23・25 は不可) | 同上(`nvm` / `Volta` で切り替えてから再実行) |
19
+
20
+ どちらもウィザードの起動直後に検査します。**発行済みのトークンが宙に浮かないよう、
21
+ 秘密を尋ねる前に落とす**設計です(旧版は `.npmrc` を書き終えた最後の 1 手で英語のエラーになりました)。
22
+
13
23
  ## これは何をするか
14
24
 
15
- 1. GitHub のトークン作成ページ URL を表示する(`read:packages` にチェック済みの URL)
16
- 2. 貼り付けられたトークンを受け取る(**画面には表示しない**)
17
- 3. `~/.npmrc` にスコープ行と authToken を書く(権限 **600**・他の行は壊さない)
18
- 4. 接続確認して、401 / 403 / 404 を**平語**で説明する(「招待をまだ Accept していない」等)
19
- 5. `npx @nuwel-dev/figma-kit init` へ委譲する
25
+ 1. 前提(OS / Node 版・`~/.npmrc` に安全に置けるか)を検査する — 満たさなければここで停止
26
+ 2. GitHub のトークン作成ページ URL を表示する(`read:packages` にチェック済みの URL)
27
+ 3. 貼り付けられたトークンを受け取る(**画面には表示しない**)
28
+ 4. **先に接続確認する**(`~/.npmrc` はまだ触りません)。401 / 403 / 404 を**平語**で説明します(「招待をまだ Accept していない」等)
29
+ 5. **通った時だけ** `~/.npmrc` にスコープ行と authToken を書く(権限 **600**・他の行は壊さない・原子的に置き換え)
30
+ 6. `@nuwel-dev/figma-kit@^1 init` へ委譲する(**メジャー版を固定**して実行します)
31
+
32
+ > **失敗しても `~/.npmrc` は 1 バイトも変わりません。** 順序が 4 → 5 なのはそのためです。
33
+ > 誤入力・期限切れ・権限不足のトークンを 1 回貼っただけで、それまで動いていた**別のトークン**
34
+ > (`//npm.pkg.github.com/:_authToken`)が消えては困るからです。書き込みは新しいファイルを作って
35
+ > `rename` で置き換えるので、「途中まで書けた壊れた `.npmrc`」も残りません。
36
+
37
+ ### 接続確認の経路(企業 proxy 下でも止まらないように)
38
+
39
+ 第一手は **`npm` そのもの**(`npm view @nuwel-dev/figma-kit version --registry=https://npm.pkg.github.com`)です。
40
+ キットを実際に落とすのも `npm` なので、`~/.npmrc` の `proxy` / `https-proxy` / `cafile` / `strict-ssl` が
41
+ そのまま効きます。トークンは **`npm` の引数には渡さず環境変数だけ**で渡します(引数は同じマシンの
42
+ 他のユーザーから `ps` で読めるため)。問い合わせ先はコマンドラインで固定するので、`~/.npmrc` に
43
+ 古い `@nuwel-dev:registry=` が残っていても、そちらへ問い合わせて「接続できました」と誤表示することは
44
+ ありません。
45
+
46
+ `npm` が無い・時間内に終わらない・HTTP として判定できない場合だけ、直接 HTTPS で確かめます(その旨を表示)。
47
+ **どちらか一方でも通れば合格**にします — proxy 下は `npm` だけが通り、配布物が prerelease だけの時は
48
+ 直接接続だけが通るので、片方の失敗で買い手を止めないためです。
49
+
50
+ ### 残っているリスク(正直に)
51
+
52
+ 版は固定しますが、**受け取った tarball が発行元の物かの検証はローカルでは行っていません**
53
+ (`npm` が lockfile の integrity で tarball のハッシュは検証しますが、それは「レジストリが返した物と
54
+ lockfile が一致する」ことであって「発行元が出した物である」ことではありません)。
55
+ 発行元の同一性は、`figma-kit diagnose` の「配布物同一性」が出す **pkg-merkle** を、
56
+ リリース告知に載る値と照合して確認してください。
20
57
 
21
58
  ## なぜ公開 npm にあるのか
22
59
 
@@ -31,12 +68,18 @@ publish は provenance 付き(公開レジストリでのみ効く)。
31
68
  - 書き込み先は `~/.npmrc` だけ。案件ディレクトリの `.npmrc` には**スコープ行しか書かない**(`figma-kit init` が書く・`.gitignore` 済み)
32
69
  - stdout・ログ・エラーメッセージにトークンを出す経路を持たない(表示は `redact()` のマスクのみ)
33
70
  - 例外は種別(`e.code`)だけを表示する(例外メッセージにトークンが載る事故を構造で防ぐ)
71
+ - 子プロセス(接続確認の `npm`)へは**環境変数だけ**で渡す。コマンドライン引数には載せません
72
+ - **接続確認に失敗したら `~/.npmrc` は一切変更しません**(成功した時だけ、新しいファイルを作って `rename` で原子的に置き換えます)
73
+
74
+ **マスク表示の仕様**: 登録の成功行に `abcd…yz(40 文字)` の形で **先頭 4 文字・末尾 2 文字・全体の文字数**だけを
75
+ 表示します(それ以外は 1 文字も出しません)。これは「貼り付けが途中で切れていないか」「別の値を貼っていないか」を
76
+ 買い手が自分で確かめられるようにするためで、意図的な仕様です。全部を隠すと、失敗時に何が起きたか誰も分かりません。
34
77
 
35
78
  ## 非対話フラグ(テスト・CI 用)
36
79
 
37
80
  | flag | 意味 |
38
81
  |---|---|
39
- | `--no-verify` | 接続確認を行わない |
82
+ | `--no-verify` | 接続確認を行わない(**確認せずに書く**唯一の経路。ネットワークが無い環境での明示指定) |
40
83
  | `--no-init` | `figma-kit init` へ委譲しない |
41
84
  | `--home=<dir>` | `~/.npmrc` の代わりに `<dir>/.npmrc` を書く |
42
85
  | `--dir=<dir>` | init を実行する案件ディレクトリ(既定は cwd) |
package/bin/setup.js CHANGED
@@ -8,10 +8,16 @@
8
8
  * なぜ公開 npm に置くのか: キット本体 `@nuwel-dev/figma-kit` は GitHub Packages(private)にあり、
9
9
  * **トークンを設定しないと落とせない**。その設定手順自体を private 側に置くと鶏と卵になる。
10
10
  * ここは秘密を 1 バイトも含まず(依存もゼロ)、やることは 4 つだけ:
11
- * ① トークン作成ページの URL を出す → ② 貼ってもらう → ③ ~/.npmrc へ 0600 で書く →
12
- * ④ 接続確認(401/404 を平語で説明)→ ⑤ `npx @nuwel-dev/figma-kit init` へ委譲
11
+ * ① トークン作成ページの URL を出す → ② 貼ってもらう → ③ **接続確認**(401/404 を平語で説明)→
12
+ * ④ **通った時だけ** ~/.npmrc へ 0600 で原子的に書く → ⑤ `npx @nuwel-dev/figma-kit init` へ委譲
13
+ *
14
+ * **③ が ④ より先なのは仕様**(2026-08-26・codex R2 Q7 block): 旧版は貼られた直後に `~/.npmrc` の
15
+ * スコープ行と authToken 行を置換し、その後で接続確認していた。誤入力 1 回で、それまで動いていた
16
+ * 別トークンが不可逆に消える(=買い手の既存の私有パッケージ取得を購入初日に壊す)。
17
+ * 接続確認は**買い手が実際に取得に使う npm CLI 経由**を第一手にする(企業 proxy・社内 CA を通す)。
13
18
  *
14
19
  * **トークンは stdout・ログ・エラー文へ 1 度も出さない**(表示は lib/wizard.js の redact のみ)。
20
+ * npm へ渡す時も **argv には載せず env だけ**(argv は同一ホストの他ユーザーから `ps` で読める)。
15
21
  *
16
22
  * flags(テスト・非対話用):
17
23
  * --no-verify 接続確認を行わない(ネットワークが無い環境)
@@ -57,8 +63,9 @@ function prompt(question) {
57
63
  });
58
64
  }
59
65
 
60
- /** GitHub Packages GET して権限を確かめる(応答本文は読み捨て・トークンはヘッダのみ) */
61
- function probe(token) {
66
+ /** GitHub Packages に直接 GET して権限を確かめる(応答本文は読み捨て・トークンはヘッダのみ)。
67
+ * **npm 経路で判定が付かなかった時だけ**使うフォールバック(npm の proxy / 証明書設定を通らないため) */
68
+ function probeDirect(token) {
62
69
  return new Promise((resolve) => {
63
70
  // eslint-disable-next-line global-require
64
71
  const https = require('https');
@@ -72,20 +79,39 @@ function probe(token) {
72
79
  });
73
80
  }
74
81
 
75
- function writeNpmrc(token) {
76
- // 秘密を書く時の順序(codex R1〜R3 block の封鎖):
77
- // ① 親ディレクトリが自分の所有で他者書込み不可(差し替え主体を排除)。O_NOFOLLOW / getuid が無い OS は**書かない**
78
- // ② 既存 .npmrc O_NOFOLLOW で開き **fd に対して** fstat(通常ファイル・自分の所有・nlink 1)してから内容を読む
79
- // ③ **既存 inode には書かない**(他者が先に read fd を握っていれば chmod 後も読める・codex R3):
80
- // 同ディレクトリに O_EXCL|0600 で新 inode を作り → 書く → fsync → rename で置き換える(atomic)
82
+ /**
83
+ * npm CLI 経由の接続確認(**買い手が実際に取得に使う経路そのもの**=proxy・cafile・strict-ssl が効く)。
84
+ * トークンは argv 1 度も載せず env だけで渡す(`W.npmProbeEnv` が唯一の口)。stdin は閉じる
85
+ * (ここまでに読み終えた stdin npm に食わせない)。
86
+ */
87
+ function probeNpm(token) {
88
+ const r = spawnSync('npm', W.npmProbeArgs(), {
89
+ encoding: 'utf8', env: W.npmProbeEnv(process.env, token),
90
+ stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000,
91
+ });
92
+ return W.classifyNpmProbe({
93
+ status: r.status,
94
+ output: `${r.stdout || ''}${r.stderr || ''}`,
95
+ spawnError: r.error ? (r.error.code || 'ESPAWN') : null,
96
+ });
97
+ }
98
+
99
+ /**
100
+ * `~/.npmrc` に**安全に書けるか**を検査し、既存の中身を返す。**1 バイトも書かない**ので
101
+ * 「トークンを尋ねる前の前提確認」と「書く直前の再検査(TOCTOU)」の両方から呼べる。
102
+ *
103
+ * 秘密を書く時の順序(codex R1〜R3 block の封鎖):
104
+ * ① 親ディレクトリが自分の所有で他者書込み不可(差し替え主体を排除)。O_NOFOLLOW / getuid が無い OS は**書かない**
105
+ * ② 既存 .npmrc は O_NOFOLLOW で開き **fd に対して** fstat(通常ファイル・自分の所有・nlink 1)してから内容を読む
106
+ */
107
+ function inspectNpmrc() {
81
108
  const dir = path.dirname(NPMRC);
82
109
  fs.mkdirSync(dir, { recursive: true });
83
110
  if (typeof process.getuid !== 'function' || !fs.constants.O_NOFOLLOW) throw Object.assign(new Error('unsafe-platform'), { code: 'EPLATFORM' });
84
111
  const uid = process.getuid();
85
112
  const dst = fs.lstatSync(dir);
86
113
  if (!dst.isDirectory() || dst.uid !== uid || (dst.mode & 0o022)) throw Object.assign(new Error('parent-dir'), { code: 'EPARENT' });
87
- const { O_RDONLY, O_WRONLY, O_CREAT, O_EXCL, O_NOFOLLOW } = fs.constants;
88
- // ② 既存内容(あれば)
114
+ const { O_RDONLY, O_NOFOLLOW } = fs.constants;
89
115
  let existing = '';
90
116
  let rfd = null;
91
117
  try { rfd = fs.openSync(NPMRC, O_RDONLY | O_NOFOLLOW); }
@@ -101,7 +127,19 @@ function writeNpmrc(token) {
101
127
  existing = fs.readFileSync(rfd, 'utf8');
102
128
  } finally { fs.closeSync(rfd); }
103
129
  }
104
- // ③ 新 inode に書いて rename
130
+ return existing;
131
+ }
132
+
133
+ /**
134
+ * `~/.npmrc` を置き換える。**`W.mayWriteNpmrc` が ok を返した後にだけ呼ぶ**(codex R2 Q7 block)。
135
+ * ③ **既存 inode には書かない**(他者が先に read fd を握っていれば chmod 後も読める・codex R3):
136
+ * 同ディレクトリに O_EXCL|0600 で新 inode を作り → 書く → fsync → rename で置き換える(atomic)。
137
+ * rename まで到達しなければ既存の `.npmrc` は無傷=「途中まで書けた壊れた .npmrc」を作らない。
138
+ */
139
+ function writeNpmrc(token) {
140
+ const existing = inspectNpmrc(); // 書く直前にもう一度(前提確認からここまでに差し替えられていないか)
141
+ const dir = path.dirname(NPMRC);
142
+ const { O_WRONLY, O_CREAT, O_EXCL, O_NOFOLLOW } = fs.constants;
105
143
  const tmp = path.join(dir, `.npmrc.${process.pid}.${Date.now()}.tmp`);
106
144
  const wfd = fs.openSync(tmp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0o600);
107
145
  try {
@@ -115,10 +153,37 @@ function writeNpmrc(token) {
115
153
  try { fs.renameSync(tmp, NPMRC); } catch (e) { try { fs.unlinkSync(tmp); } catch (_) { /* noop */ } throw e; }
116
154
  }
117
155
 
156
+ /** `.npmrc` 系の例外を買い手向けの 1 行にする。**例外の message は使わない**(トークンが載る経路を作らない) */
157
+ function npmrcErrorMessage(e) {
158
+ const code = e && e.code;
159
+ if (code === 'ESYMLINK') return `${NPMRC} がシンボリックリンクです。トークンをリンク先へ書くのは危険なので中止しました。リンクを外してから再実行してください`;
160
+ if (code === 'ENOTFILE') return `${NPMRC} が通常ファイルではありません。確認してから再実行してください`;
161
+ if (code === 'EOWNER') return `${NPMRC} があなたの所有でないか、ハードリンクされています。確認してから再実行してください`;
162
+ if (code === 'EPARENT') return `${path.dirname(NPMRC)} があなたの所有でないか、他のユーザーが書き込めます(安全にトークンを置けません)。権限を直してから再実行してください`;
163
+ if (code === 'EPLATFORM') return 'この OS では安全にトークンを保存できません(macOS / Linux で実行してください)';
164
+ return `${NPMRC} に書けませんでした(${code || 'エラー'})。ホームディレクトリの権限を確認してください`;
165
+ }
166
+
118
167
  (async () => {
119
168
  say('');
120
169
  say('=== figma-kit セットアップ ===');
121
170
  say('');
171
+ // **秘密を尋ねる前**に前提を検査する(L6#1 / L1#15)。旧版は PAT を貼らせ切ってから
172
+ // EBADENGINE(Node 版)・EPLATFORM(Windows)で落ちており、発行済みトークンが宙に浮いた。
173
+ {
174
+ // eslint-disable-next-line global-require
175
+ const engines = (require('../package.json').engines || {}).node || '';
176
+ const nv = W.nodeSupported(process.versions.node, engines);
177
+ if (!nv.ok) { say(`❌ ${nv.message}`); process.exit(1); }
178
+ const pv = W.platformSupported({ getuid: process.getuid, hasNoFollow: !!fs.constants.O_NOFOLLOW });
179
+ if (!pv.ok) { say(`❌ ${pv.message}`); process.exit(1); }
180
+ // `~/.npmrc` に**安全に置けるか**もここで見る(1 バイトも書かない検査)。旧版は書き込み時に初めて
181
+ // symlink / 所有者を判定していたため、買い手は PAT を発行して貼った**後**に「中止しました」と言われ、
182
+ // 発行済みトークンが宙に浮いた(L1#15 と同型)。書く直前の再検査は writeNpmrc 側に残す(TOCTOU)。
183
+ try { inspectNpmrc(); } catch (e) { say(`❌ ${npmrcErrorMessage(e)}`); process.exit(1); }
184
+ say(`· 前提の確認 OK(Node v${process.versions.node} / ${process.platform})`);
185
+ say('');
186
+ }
122
187
  say('キット本体は GitHub の限定配布(あなたのアカウント=1 席)です。');
123
188
  say('取得用のトークンを 1 回だけ登録します。所要 2 分。');
124
189
  say('');
@@ -132,29 +197,31 @@ function writeNpmrc(token) {
132
197
  if (!shape.ok) { say(`\n❌ ${shape.message}`); process.exit(1); }
133
198
  const token = shape.token;
134
199
 
135
- try { writeNpmrc(token); } catch (e) {
136
- // エラー文にトークンが載る経路を作らない(メッセージは自前で組む)
137
- if (e.code === 'ESYMLINK') say(`\n❌ ${NPMRC} がシンボリックリンクです。トークンをリンク先へ書くのは危険なので中止しました。リンクを外してから再実行してください`);
138
- else if (e.code === 'ENOTFILE') say(`\n❌ ${NPMRC} が通常ファイルではありません。確認してから再実行してください`);
139
- else if (e.code === 'EOWNER') say(`\n❌ ${NPMRC} があなたの所有でないか、ハードリンクされています。確認してから再実行してください`);
140
- else if (e.code === 'EPARENT') say(`\n❌ ${path.dirname(NPMRC)} があなたの所有でないか、他のユーザーが書き込めます(安全にトークンを置けません)。権限を直してから再実行してください`);
141
- else if (e.code === 'EPLATFORM') say('\n❌ この OS では安全にトークンを保存できません(macOS / Linux で実行してください)');
142
- else say(`\n❌ ${NPMRC} に書けませんでした(${e.code || 'エラー'})。ホームディレクトリの権限を確認してください`);
143
- process.exit(1);
144
- }
145
- say('');
146
- say(`✅ ${NPMRC} に登録しました(権限 600・${W.redact(token)})`);
147
-
148
- if (!flag('--no-verify')) {
149
- say('③ 接続を確認しています…');
150
- const status = await probe(token);
200
+ // **確かめてから書く**(2026-08-26・codex R2 Q7 block の封鎖)。
201
+ // 旧版はここで先に `~/.npmrc` を置換し、その後で接続確認していた。誤入力・期限切れ・権限不足の
202
+ // トークン 1 つで、それまで動いていた別トークンとスコープ行が不可逆に消え、買い手の既存の私有
203
+ // パッケージ取得が購入初日に壊れていた。**probe 200 を返すまで `~/.npmrc` 1 バイトも触らない**。
204
+ const verified = !flag('--no-verify');
205
+ let status = null;
206
+ if (verified) {
207
+ say(`③ 接続を確認しています(${NPMRC} はまだ変更しません)…`);
208
+ // 第一手は npm CLI=買い手が実際に取得に使う経路(proxy・cafile・strict-ssl が効く)
209
+ const n = probeNpm(token);
210
+ status = n.status;
211
+ if (status !== 200) {
212
+ // npm が 200 を返さなかった=「本当に落とせない」か「npm 経路が使えない」かの区別が付かないので、
213
+ // **必ず**直接接続でも確かめて合成する(W.resolveProbe: どちらかが 200 なら合格)
214
+ if (n.note) say(` · ${n.note}。直接接続でも確かめます(npm の proxy 設定は通りません)`);
215
+ status = W.resolveProbe(n.status, await probeDirect(token));
216
+ }
151
217
  const r = W.explainStatus(status);
152
218
  if (!r.ok) {
153
219
  say(`\n❌ ${r.message}`);
154
220
  if (r.next) say(` → ${r.next}`);
155
- // 再実行の案内: wizard が書いた `@nuwel-dev:registry=npm.pkg.github.com` のせいで素の `npx @nuwel-dev/setup` は
156
- // GitHub Packages を見に行って 404 になる(codex R1 major)→ scope registry を npmjs に明示して再実行する
157
- say(` (~/.npmrc には登録済みなので、直したら次で再実行してください)`);
221
+ // **これが Q7 の要**: 書く前に止めたので、買い手の既存設定は無傷。そう明示しないと
222
+ // 「もう壊れたのでは」と疑わせるし、実際に壊していた旧版との違いが伝わらない
223
+ say(` ${NPMRC} は 1 バイトも変更していません(これまでの設定はそのまま残っています)`);
224
+ say(' (直したら次で再実行してください — scope registry の明示は 1・2 回目のどちらでも効きます)');
158
225
  say(` npx --@nuwel-dev:registry=${W.NPMJS} @nuwel-dev/setup`);
159
226
  process.exit(1);
160
227
  }
@@ -163,19 +230,54 @@ function writeNpmrc(token) {
163
230
  say('· 接続確認は省略しました(--no-verify)');
164
231
  }
165
232
 
166
- if (flag('--no-init')) { say('\n· ここまで(--no-init)。次は `npx @nuwel-dev/figma-kit init` を実行してください'); return; }
233
+ // 書く前に必ず通る関門。上の `explainStatus` で既に弾いているので通常は素通りする**backstop** で、
234
+ // 判定基準はわざと独立させてある(explainStatus は説明のための合否・こちらは status===200 ちょうど)。
235
+ // 片方だけを将来緩めても、もう片方が書き込みを止める。不明な形は fail-closed で拒否する。
236
+ const gate = W.mayWriteNpmrc({ verified, status });
237
+ if (!gate.ok) { say(`\n❌ 接続確認に通らなかったので中止しました(${gate.reason})。${NPMRC} は変更していません`); process.exit(1); }
238
+
239
+ try { writeNpmrc(token); } catch (e) {
240
+ // エラー文にトークンが載る経路を作らない(メッセージは自前で組む)
241
+ say(`\n❌ ${npmrcErrorMessage(e)}`);
242
+ process.exit(1);
243
+ }
244
+ say('');
245
+ say(`✅ ${NPMRC} に登録しました(権限 600・${W.redact(token)})`);
246
+ // **今書いた設定が、このウィザード自身の 2 回目を壊す**(codex R1 major の恒久案内)。
247
+ // `@nuwel-dev:registry=<GitHub Packages>` を ~/.npmrc に置いた以上、素の
248
+ // `npx @nuwel-dev/setup` は公開 npm ではなく GitHub Packages を見に行って 404 になる。
249
+ // 失敗してから案内するのでは遅いので、**成功した今**、次回の打ち方を渡しておく。
250
+ say(` (このウィザードをもう一度実行する時は: npx --@nuwel-dev:registry=${W.NPMJS} @nuwel-dev/setup)`);
251
+
252
+ if (flag('--no-init')) { say('\n· ここまで(--no-init)。次は `npx @nuwel-dev/figma-kit@^1 init` を実行してください'); return; }
167
253
 
168
254
  const dir = path.resolve(val('--dir') || process.cwd());
169
255
  say('');
170
256
  say(`④ 案件を配線します: ${dir}`);
171
- const r = spawnSync('npx', ['--yes', '@nuwel-dev/figma-kit', 'init', dir], { stdio: 'inherit', env: process.env });
257
+ say(' (依存の取得と chromium で数分かかります。前提が合わない環境なら開始 1 秒で止まります)');
258
+ // **版を固定する**(codex/L3#6): 素の `npx --yes @nuwel-dev/figma-kit` はレジストリが返した物を
259
+ // 無確認で install+実行する。メジャーを固定すれば、少なくとも「別メジャーの別物が初日に降ってくる」
260
+ // 経路は閉じる(integrity 検証まではローカルでは行えない — README の残存リスクに明記)。
261
+ // `npm exec` を使うのは、`--package=<名前>@<レンジ>` で**取得する物**と**実行する名前**を
262
+ // 別々に明示できるため(npx の位置引数だと版付き指定が name と混ざる)。
263
+ const r = spawnSync('npm', ['exec', '--yes', `--package=${W.SCOPE}/figma-kit@^1`, '--', 'figma-kit', 'init', dir], {
264
+ stdio: 'inherit', env: process.env,
265
+ });
172
266
  if (r.status !== 0) {
173
- say('\n❌ init が失敗しました。上の出力を確認してください(やり直しは `npx @nuwel-dev/figma-kit init`)');
267
+ say('\n❌ init が失敗しました。上の出力の 行を見てください(直したら同じコマンドで再開できます)');
268
+ say(` やり直し: npx --yes ${W.SCOPE}/figma-kit@^1 init ${dir}`);
174
269
  process.exit(r.status === null ? 1 : r.status);
175
270
  }
176
271
  say('');
177
272
  say('=== 完了 ===');
178
- say('最初に打つ 1 行: /figma-kit:intake --figma');
273
+ // **実在する導線だけを案内する**(L1#3)。`/implement` 等は init が案件の
274
+ // `.claude/commands/` へ配線したファイルで、Claude Code がそこから発見する。
275
+ say('① 設置の確認(必須がすべて緑になれば完了):');
276
+ say(` cd ${dir} && npx figma-kit diagnose`);
277
+ say('② Claude Code を開いて、最初に打つ 1 行:');
278
+ say(' /orchestrate … 全体を状態機械で進める');
279
+ say(' /implement <セクション名> … 1 セクションだけ実装する');
280
+ say(' (使えるコマンドの一覧は .claude/commands/ ・手順の説明は .claude/USAGE.md)');
179
281
  })().catch((e) => {
180
282
  // 例外の message にトークンが混ざる可能性を排除するため、種別だけを出す
181
283
  say(`\n❌ セットアップが異常終了しました(${(e && e.code) || 'エラー'})`);
package/lib/wizard.js CHANGED
@@ -56,6 +56,129 @@ function mergeNpmrc(existing, token) {
56
56
  return `${lines.join('\n')}\n`;
57
57
  }
58
58
 
59
+ /**
60
+ * 接続確認を **npm CLI 経由**で行うための引数(`npm view @nuwel-dev/figma-kit version`)。
61
+ *
62
+ * なぜ生 https ではなく npm なのか(2026-08-26・codex R2 Q7): 買い手が実際にキットを落とすのは
63
+ * `npm`(`npm exec … figma-kit init`)で、npm は proxy・cafile・strict-ssl・NODE_EXTRA_CA_CERTS を
64
+ * **自分の設定から**読む。生 `https.request` はそのどれも通らないので、企業 proxy 下では
65
+ * 「npm なら落とせるのに、接続確認だけが落ちる」=**買い手を誤って止める**。
66
+ *
67
+ * **トークンは argv に載せない**(argv は同一ホストの他ユーザーから `ps` で読める)。渡し口は
68
+ * `npmProbeEnv` の env 1 つだけ。`--prefer-online` は「前回の 200 がキャッシュに残っていて、
69
+ * 期限切れトークンが通ってしまう」経路を閉じるため(再検証を強制する)。
70
+ *
71
+ * **`--@nuwel-dev:registry=` を必ず併記する**(2026-08-26 実測で判明した hollow green の封鎖):
72
+ * scope 付きパッケージの取得先を決めるのは `--registry` ではなく `@scope:registry` の方で、
73
+ * 買い手の `~/.npmrc` に古い/誤った `@nuwel-dev:registry=` が残っていると `--registry` だけでは
74
+ * **そちらへ問い合わせに行く**。相手が 200 を返せば「接続できました」と出てしまい、
75
+ * 実際には GitHub Packages を 1 度も見ていない(=空回りの緑)。cli の config は npmrc より強いので、
76
+ * 両方を argv に置いて取得先を確定させる。
77
+ * @returns {string[]}
78
+ */
79
+ function npmProbeArgs() {
80
+ return [
81
+ 'view', `${SCOPE}/figma-kit`, 'version',
82
+ `--${SCOPE}:registry=${REGISTRY}`,
83
+ `--registry=${REGISTRY}`,
84
+ '--prefer-online', '--loglevel=error',
85
+ ];
86
+ }
87
+
88
+ /**
89
+ * 上の `npm` に**トークンだけ**を足した env を作る(`process.env` は書き換えない)。
90
+ *
91
+ * npm は `npm_config_` 接頭の env を config として読み、**`//` で始まるキーは正規化しない**(nerf dart は
92
+ * そのまま)ので、`npm_config_//npm.pkg.github.com/:_authToken` が `.npmrc` の同名行と同じ意味になる。
93
+ * env は cli の次に強く、買い手の `~/.npmrc` に残っている**古いトークン行より優先される**(=古い値で
94
+ * 401 になって新しい正しいトークンを弾く事故が起きない)。一方 proxy・cafile 等は `~/.npmrc` 側が
95
+ * そのまま効く(userconfig を差し替えていないため)=この関数が proxy 対応の肝。
96
+ *
97
+ * `logs-max=0` は npm のデバッグログファイルを 1 つも作らせないため(npm は秘匿値を redact するが、
98
+ * **作らせない**方が確実)。
99
+ * @param {NodeJS.ProcessEnv} base process.env
100
+ * @param {string} token
101
+ * @returns {NodeJS.ProcessEnv}
102
+ */
103
+ function npmProbeEnv(base, token) {
104
+ const env = { ...(base || {}) };
105
+ env[`npm_config_${REGISTRY_KEY}`] = String(token);
106
+ env.npm_config_logs_max = '0';
107
+ env.npm_config_update_notifier = 'false';
108
+ env.npm_config_fund = 'false';
109
+ env.npm_config_audit = 'false';
110
+ return env;
111
+ }
112
+
113
+ /**
114
+ * `npm view` の終了状態と出力から HTTP ステータス相当を導く。
115
+ *
116
+ * **接頭辞やコードを列挙しない**(列挙は必ずその外側を穴にする): npm は失敗を必ず `code E<数字>` の
117
+ * 形で出す(E401 / E403 / E404 / E500 …)ので、3 桁の数字が付いた物だけを「HTTP の判定が付いた」と見なし、
118
+ * それ以外(ENOTFOUND / ECONNREFUSED / ERR_TLS_* / npm 不在 / timeout)はすべて **conclusive=false**=
119
+ * 「判定できなかった」に倒して生 https のフォールバックへ回す。
120
+ * @param {{status:number|null, output:string, spawnError:string|null}} r
121
+ * @returns {{status:number|null, conclusive:boolean, reason:string, note:string}}
122
+ */
123
+ function classifyNpmProbe(input) {
124
+ // 引数欠落は「判定できなかった」=フォールバックへ倒す(例外で落として probe ごと消さない)
125
+ const r = input && typeof input === 'object' ? input : {};
126
+ const spawnError = r.spawnError ? String(r.spawnError) : null;
127
+ if (spawnError === 'ENOENT') return { status: null, conclusive: false, reason: 'npm-not-found', note: 'npm が見つかりませんでした' };
128
+ if (spawnError === 'ETIMEDOUT') return { status: null, conclusive: false, reason: 'npm-timeout', note: 'npm が時間内に終わりませんでした' };
129
+ if (spawnError) return { status: null, conclusive: false, reason: `npm-spawn-${spawnError}`, note: `npm を起動できませんでした(${spawnError})` };
130
+ if (r.status === 0) return { status: 200, conclusive: true, reason: 'npm-ok', note: '' };
131
+ const m = /(?:^|\s)code\s+E(\d{3})(?:\s|$)/m.exec(String(r.output || ''));
132
+ if (m) return { status: Number(m[1]), conclusive: true, reason: `npm-E${m[1]}`, note: `npm が HTTP ${m[1]} を受け取りました` };
133
+ if (r.status === null) return { status: null, conclusive: false, reason: 'npm-killed', note: 'npm が最後まで実行されませんでした' };
134
+ return { status: null, conclusive: false, reason: 'npm-unclassified', note: 'npm の応答を HTTP として判定できませんでした' };
135
+ }
136
+
137
+ /**
138
+ * npm 経路と直結経路の結果を合成する。
139
+ *
140
+ * **どちらか一方でも 200 なら合格**にするのが要。片方だけが通る環境が両向きに実在する:
141
+ * ① 企業 proxy 下 — npm は通り、生 https は通らない
142
+ * ② 未公開/prerelease だけの状態 — `npm view … version` は E404 でも packument の GET は 200
143
+ * どちらでも買い手は本当に取得できるので、**止めてはいけない**。合格でない時は「HTTP の判定が付いた方」を
144
+ * 採る(「ネットワークに出られません」より「401 でトークンが違います」の方が買い手が直せる)。
145
+ * @param {number|null} npmStatus
146
+ * @param {number|null} httpStatus
147
+ * @returns {number|null}
148
+ */
149
+ function resolveProbe(npmStatus, httpStatus) {
150
+ if (npmStatus === 200 || httpStatus === 200) return 200;
151
+ if (typeof npmStatus === 'number') return npmStatus;
152
+ if (typeof httpStatus === 'number') return httpStatus;
153
+ return null;
154
+ }
155
+
156
+ /**
157
+ * **`~/.npmrc` を書いてよいか**を接続確認の結果だけから決める、唯一の口(2026-08-26・codex R2 Q7 block)。
158
+ *
159
+ * 旧版はトークンを受け取った直後に `~/.npmrc` を置換し、**その後で**接続確認していた。誤入力・期限切れ・
160
+ * 権限不足のトークン 1 つで、それまで動いていた別トークン(`//npm.pkg.github.com/:_authToken`)と
161
+ * スコープ行が不可逆に消え、買い手の既存の私有パッケージ取得が購入初日に壊れた。
162
+ *
163
+ * 判定を bin 側の `if` に散らすと同じ事故がいつでも戻せるので、**書く前に必ず通る関門**をここに置く。
164
+ * bin 側は `explainStatus(status).ok` が偽なら先に exit するので、通常この関門は素通りする(=backstop)。
165
+ * 判定基準は独立している: `explainStatus` は買い手への説明が目的で「合否」を返し、こちらは
166
+ * **`status === 200` ちょうど**でしか書かせない。片方だけを緩めても、もう片方が書き込みを止める。
167
+ *
168
+ * **不明な形は書かせない**(fail-closed): `--no-verify` は `verified === false` の明示だけを指し、
169
+ * 引数欠落・`verified` が真偽でない・呼び出し形の変更はすべて「判定が無い」=拒否に倒す。
170
+ * 「唯一の書き込み関門」が既定で通す作りだと、将来の呼び出し追加で静かに穴になる(codex/change-review)。
171
+ * @param {{verified:boolean, status:number|null}} a verified=false は `--no-verify`(明示的に確認しない指定)
172
+ * @returns {{ok:boolean, reason:string}}
173
+ */
174
+ function mayWriteNpmrc(a) {
175
+ const o = a && typeof a === 'object' ? a : null;
176
+ if (!o || typeof o.verified !== 'boolean') return { ok: false, reason: 'no-verdict' };
177
+ if (o.verified === false) return { ok: true, reason: 'skip-verify' };
178
+ if (o.status === 200) return { ok: true, reason: 'probe-200' };
179
+ return { ok: false, reason: `probe-${o.status === null || o.status === undefined ? 'unreachable' : o.status}` };
180
+ }
181
+
59
182
  /**
60
183
  * 接続確認の HTTP ステータスを**平語**にする。
61
184
  * 買い手が最も踏むのは 401(トークンが違う/期限切れ)と 404(招待を受けていない・
@@ -94,4 +217,88 @@ function explainStatus(status) {
94
217
  return { ok: false, message: `想定外の応答でした(HTTP ${status})`, next: 'この行をそのまま販売元へ連絡してください' };
95
218
  }
96
219
 
97
- module.exports = { SCOPE, REGISTRY, NPMJS, REGISTRY_KEY, PAT_PAGE, PROBE_PATH, redact, validateTokenShape, mergeNpmrc, explainStatus };
220
+ /**
221
+ * 実行中の Node がキットの床(この package.json の `engines.node`)を満たすか。
222
+ *
223
+ * なぜ入口で見るのか(2026-08-26・L6#1): 旧版は `engines: ">=20"` を宣言していて、Node 20/21/23/25 の
224
+ * 買い手が**成功して進み**、PAT を発行して貼り、`.npmrc` を書き終えた**最後の 1 手**(figma-kit init)で
225
+ * 初めて英語の EBADENGINE を踏んでいた。発行済みのトークンが宙に浮く最悪の落ち方なので、
226
+ * **秘密を求める前**に日本語で止める。
227
+ *
228
+ * 判定は `^a.b.c || >=d.e.f` の形(キットの engines が実際に使う記法)だけを解釈する。
229
+ * **解釈できないトークンは「満たす」に倒す** — レンジは自分の package.json 由来で
230
+ * 外部入力ではなく、ここで買い手を誤って弾く方が害が大きい(記法の妥当性は
231
+ * `tools/__tests__/setup-wizard.test.js` が実レンジで固定する)。
232
+ * @param {string} version process.versions.node("22.18.0")
233
+ * @param {string} range engines.node
234
+ * @returns {{ok:boolean, message:string}}
235
+ */
236
+ function nodeSupported(version, range) {
237
+ const r = String(range == null ? '' : range).trim();
238
+ const m = /^v?(\d+)\.(\d+)/.exec(String(version == null ? '' : version));
239
+ // **前提が読めない時に先へ進めない**(codex R1 major)。ここはこの直後に PAT(秘密)を尋ねる門で、
240
+ // レンジはこのパッケージ自身の package.json 由来=読めないなら配布物が壊れている。
241
+ // 「読めないので通す」は、対応外の Node に PAT を発行させてから落とす旧挙動に戻ることを意味する。
242
+ if (!r || !m) {
243
+ return {
244
+ ok: false,
245
+ message: `対応 Node 版を判定できませんでした(宣言: ${r || '(なし)'} / 実行中: ${version})。`
246
+ + '\n 配布物が壊れている可能性があります。販売元へこの行をそのまま連絡してください。',
247
+ };
248
+ }
249
+ const major = Number(m[1]);
250
+ const minor = Number(m[2]);
251
+ const unknown = [];
252
+ const satisfiesAlt = (alt) => alt.trim().split(/\s+/).filter(Boolean).every((tok) => {
253
+ const t = /^(>=|<=|>|<|\^|~|=)?v?(\d+)(?:\.(\d+))?/.exec(tok.replace(/([<>]=?|\^|~|=)\s+/, '$1'));
254
+ if (!t) { unknown.push(tok); return false; } // 未知の記法は**満たさない**側へ(読めない前提で秘密を尋ねない)
255
+ const op = t[1] || '=';
256
+ const maj = Number(t[2]);
257
+ const min = t[3] === undefined ? 0 : Number(t[3]);
258
+ switch (op) {
259
+ case '>=': return major > maj || (major === maj && minor >= min);
260
+ case '>': return major > maj || (major === maj && minor > min);
261
+ case '<=': return major < maj || (major === maj && minor <= min);
262
+ case '<': return major < maj || (major === maj && minor < min);
263
+ case '^': case '~': case '=': return major === maj && minor >= min;
264
+ default: return true;
265
+ }
266
+ });
267
+ if (r.split('||').some(satisfiesAlt)) return { ok: true, message: '' };
268
+ if (unknown.length) {
269
+ return {
270
+ ok: false,
271
+ message: `対応 Node 版の宣言を解釈できませんでした(読めなかった: ${unknown.slice(0, 3).join(', ')} / 宣言: ${r})。`
272
+ + '\n 配布物が壊れている可能性があります。販売元へこの行をそのまま連絡してください。',
273
+ };
274
+ }
275
+ return {
276
+ ok: false,
277
+ message: `Node v${version} はこのキットの対応外です(対応: ${r})。`
278
+ + '\n nvm / Volta で対応版に切り替えてから、もう一度 `npx @nuwel-dev/setup` を実行してください。'
279
+ + '\n (トークンはまだ作らないでください — 対応版に切り替えてからで大丈夫です)',
280
+ };
281
+ }
282
+
283
+ /**
284
+ * 秘密を安全に保存できる OS か。**トークンを尋ねる前**に判定する(L1#15)。
285
+ * 旧版は writeNpmrc の中で判定していたため、Windows の買い手は「所要 2 分」を読み、PAT を発行して
286
+ * 貼り付けた**後**で「この OS では安全にトークンを保存できません」と言われ、発行済み PAT が宙に浮いた。
287
+ * @returns {{ok:boolean, message:string}}
288
+ */
289
+ function platformSupported({ getuid, hasNoFollow }) {
290
+ if (typeof getuid !== 'function' || !hasNoFollow) {
291
+ return {
292
+ ok: false,
293
+ message: 'この OS では安全にトークンを保存できません(macOS / Linux、または WSL2 の Linux 側で実行してください)。'
294
+ + '\n (トークンはまだ作らないでください — 対応 OS で実行すれば 1 回で終わります)',
295
+ };
296
+ }
297
+ return { ok: true, message: '' };
298
+ }
299
+
300
+ module.exports = {
301
+ SCOPE, REGISTRY, NPMJS, REGISTRY_KEY, PAT_PAGE, PROBE_PATH,
302
+ redact, validateTokenShape, mergeNpmrc, explainStatus, nodeSupported, platformSupported,
303
+ npmProbeArgs, npmProbeEnv, classifyNpmProbe, resolveProbe, mayWriteNpmrc,
304
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuwel-dev/setup",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "figma-kit のセットアップウィザード(公開 npm・秘密ゼロ)。GitHub Packages のトークンを ~/.npmrc に置き、@nuwel-dev/figma-kit の init へ橋渡しする。",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -18,7 +18,7 @@
18
18
  "nuwel-setup": "bin/setup.js"
19
19
  },
20
20
  "engines": {
21
- "node": ">=20"
21
+ "node": "^22.18.0 || ^24.11.0 || >=26.0.0"
22
22
  },
23
23
  "files": [
24
24
  "bin/",