i-repo 2.2.0 → 2.3.0

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/dist/bin/irepo.js CHANGED
@@ -285,9 +285,12 @@ if (isMain) {
285
285
  // パイプ下流が早く閉じると stdout への書き込みが EPIPE を投げる
286
286
  // (`i-repo rep list | head` など)。握りつぶさないと Node が生の
287
287
  // スタックトレースを吐く — ndjson パイプを売りにする CLI で最も目立つ
288
- // 行儀の悪さなので、EPIPE のみ静かに終了する (他のエラーは握りつぶさない)。
288
+ // 行儀の悪さなので、パイプ切断系のみ静かに終了する。
289
+ // Windows の閉じパイプは EPIPE とは限らず EOF / ERR_STREAM_DESTROYED を
290
+ // 出すため、それらも同じ「下流が消えた」扱いにする (他のエラーは握りつぶさない)。
291
+ const PIPE_CLOSED = new Set(["EPIPE", "EOF", "ERR_STREAM_DESTROYED"]);
289
292
  const onStreamError = (err) => {
290
- if (err.code === "EPIPE")
293
+ if (PIPE_CLOSED.has(err.code ?? ""))
291
294
  process.exit(0);
292
295
  throw err;
293
296
  };
@@ -312,6 +315,16 @@ if (isMain) {
312
315
  // ループが終わらずハングしうる。ハンドラを登録した時点で Node 既定の
313
316
  // 即時終了を奪っているので、整形後に明示的に終了させて非ゼロを保証する。
314
317
  const onFatal = (error) => {
318
+ // Ink 描画中の致命例外でもここに来て process.exit するため、Ink の
319
+ // クリーンアップ (raw mode 解除) を経ずに即死しうる。Windows conhost は
320
+ // raw mode のまま終了するとエコー不能になるので、落とす前に端末を素に戻す。
321
+ try {
322
+ if (process.stdin.isTTY)
323
+ process.stdin.setRawMode(false);
324
+ }
325
+ catch {
326
+ /* 既に detach 済み等は無視 */
327
+ }
315
328
  process.exit(printFatal(error));
316
329
  };
317
330
  process.on("uncaughtException", onFatal);
@@ -9,6 +9,7 @@ import { readFile, writeFile } from "node:fs/promises";
9
9
  import { basename } from "node:path";
10
10
  import { createClient } from "../config/client.js";
11
11
  import { handleError, ValidationError } from "../core/errors.js";
12
+ import { assertSafeOutputPath } from "../utils/file.js";
12
13
  import { withSpinner, out } from "../ui/index.js";
13
14
  export const rawCommand = new Command("raw")
14
15
  .description("Execute raw API command (returns XML)")
@@ -29,6 +30,11 @@ export const rawCommand = new Command("raw")
29
30
  }
30
31
  parsedParams[param.substring(0, eqIdx)] = param.substring(eqIdx + 1);
31
32
  }
33
+ // 出力先の検証は API 送信より前に行う。raw は状態変更コマンド
34
+ // (作成/更新/削除) も実行しうるため、送信後に保存パスで弾くと
35
+ // 「実行済みなのに検証エラー → 直して再実行で二重実行」になる。
36
+ if (opts.output)
37
+ assertSafeOutputPath(opts.output);
32
38
  const client = await createClient(globalOpts);
33
39
  let xml;
34
40
  if (opts.file) {
@@ -49,6 +55,7 @@ export const rawCommand = new Command("raw")
49
55
  });
50
56
  }
51
57
  if (opts.output) {
58
+ // 検証は送信前に済ませてある (上記)
52
59
  await writeFile(opts.output, xml, "utf-8");
53
60
  if (!globalOpts.quiet) {
54
61
  console.error(`Saved to ${opts.output}`);
@@ -101,6 +101,28 @@ function readJsonFile(filePath) {
101
101
  }
102
102
  return data;
103
103
  }
104
+ /**
105
+ * renameSync を EPERM/EBUSY/EACCES に短い同期バックオフでリトライする。
106
+ * Windows ではアンチウイルス/インデクサが書込直後のファイルを一瞬開くため
107
+ * temp+rename の rename が散発的に共有違反で失敗する (config set がたまにコケる)。
108
+ * 同期コンテキストなので Atomics.wait で待つ。POSIX では通常1回で成功。
109
+ */
110
+ function renameSyncWithRetry(from, to) {
111
+ const transient = new Set(["EPERM", "EBUSY", "EACCES"]);
112
+ const sab = new Int32Array(new SharedArrayBuffer(4));
113
+ for (let attempt = 0;; attempt++) {
114
+ try {
115
+ renameSync(from, to);
116
+ return;
117
+ }
118
+ catch (err) {
119
+ const code = err.code ?? "";
120
+ if (attempt >= 9 || !transient.has(code))
121
+ throw err;
122
+ Atomics.wait(sab, 0, 0, 10 + attempt * 15);
123
+ }
124
+ }
125
+ }
104
126
  function writeJsonFile(filePath, data) {
105
127
  const dir = dirname(filePath);
106
128
  if (!existsSync(dir)) {
@@ -124,7 +146,7 @@ function writeJsonFile(filePath, data) {
124
146
  catch {
125
147
  /* 権限是正の失敗で書込自体を失敗扱いにはしない */
126
148
  }
127
- renameSync(tmpPath, filePath);
149
+ renameSyncWithRetry(tmpPath, filePath);
128
150
  }
129
151
  catch (err) {
130
152
  // rename 前に失敗したら中途半端な tmp を残さない
@@ -16,6 +16,7 @@
16
16
  import { writeFileSync, appendFileSync } from "node:fs";
17
17
  import { resolve } from "node:path";
18
18
  import { stripAnsi } from "../width.js";
19
+ import { assertSafeOutputPath } from "../../utils/file.js";
19
20
  const BOM = "\uFEFF";
20
21
  let outputFile;
21
22
  let wroteAnything = false;
@@ -32,6 +33,7 @@ export function setOutputFile(path, opts) {
32
33
  outputFile = path ? resolve(path) : undefined;
33
34
  wroteAnything = false;
34
35
  if (outputFile) {
36
+ assertSafeOutputPath(outputFile); // Windows 予約デバイス名への沈黙保存を防ぐ
35
37
  writeFileSync(outputFile, opts?.bom ? BOM : "", "utf-8");
36
38
  }
37
39
  }
@@ -1,3 +1,9 @@
1
+ /**
2
+ * ローカル保存パスが Windows 予約デバイス名でないか検証する (送信前/書込前に呼ぶ)。
3
+ * Windows は予約名を**末尾ファイル名だけでなく親ディレクトリ成分としても**拒否する
4
+ * (CON\response.xml も無効) ため、`/` `\` どちらの区切りも分割して全成分を検査する。
5
+ */
6
+ export declare function assertSafeOutputPath(outputPath: string): void;
1
7
  export interface DownloadResult {
2
8
  /** Saved file path (absolute) */
3
9
  path: string;
@@ -6,6 +6,70 @@
6
6
  import { writeFile, mkdir, rename, unlink } from "node:fs/promises";
7
7
  import { dirname, resolve, basename } from "node:path";
8
8
  import { t, icons } from "../ui/theme.js";
9
+ import { ValidationError } from "../core/errors.js";
10
+ /**
11
+ * Windows の予約 DOS デバイス名。これらに書き込むとファイルにならずデバイスへ
12
+ * 消える/ハングする — 「保存した」表示なのに実体が無い沈黙の失敗になる。
13
+ * MS の RtlIsDosDeviceName_U が認識する名前に揃える: CON/PRN/AUX/NUL、
14
+ * COM1-9/LPT1-9、CONIN$/CONOUT$、CLOCK$。
15
+ */
16
+ const WIN_RESERVED_NAMES = new Set([
17
+ "CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$",
18
+ ]);
19
+ for (let i = 1; i <= 9; i++) {
20
+ WIN_RESERVED_NAMES.add(`COM${i}`);
21
+ WIN_RESERVED_NAMES.add(`LPT${i}`);
22
+ }
23
+ /**
24
+ * basename が Windows 予約デバイス名に解決するか。Windows は以下を同じデバイスと
25
+ * 見なす: 拡張子付き (NUL.txt)・末尾コロン (NUL:)・末尾のドット/空白 ("NUL " /
26
+ * "NUL.") は無視される。大文字小文字も無視。
27
+ */
28
+ function isWindowsReservedName(base) {
29
+ // 末尾の空白・ドット、先頭の空白を除去 (Windows のパス正規化が落とす)
30
+ let s = base.replace(/^[ ]+/, "").replace(/[ .]+$/, "");
31
+ // ドライブ相対パスの接頭辞を剥がす: `C:NUL` は drive C のカレント相対 `NUL`
32
+ // (= デバイス) を指す。これを残すと先頭の `C` を device 候補と誤判定する。
33
+ s = s.replace(/^[A-Za-z]:/, "");
34
+ // 拡張子・(ADS等の)コロンより前の device 名候補を取り出す (NUL.txt / NUL: → NUL)
35
+ const candidate = s.split(/[.:]/, 1)[0].toUpperCase();
36
+ return WIN_RESERVED_NAMES.has(candidate);
37
+ }
38
+ /**
39
+ * ローカル保存パスが Windows 予約デバイス名でないか検証する (送信前/書込前に呼ぶ)。
40
+ * Windows は予約名を**末尾ファイル名だけでなく親ディレクトリ成分としても**拒否する
41
+ * (CON\response.xml も無効) ため、`/` `\` どちらの区切りも分割して全成分を検査する。
42
+ */
43
+ export function assertSafeOutputPath(outputPath) {
44
+ if (process.platform !== "win32")
45
+ return;
46
+ for (const seg of outputPath.split(/[\\/]/)) {
47
+ if (seg && isWindowsReservedName(seg)) {
48
+ throw new ValidationError(`Invalid output path: "${outputPath}" — "${seg}" is a reserved Windows device name (the write would fail or vanish). Choose another name.`);
49
+ }
50
+ }
51
+ }
52
+ /**
53
+ * rename を EPERM/EBUSY/EACCES に対して短い指数バックオフでリトライする。
54
+ * Windows では Defender 等のアンチウイルス/インデクサが書込直後の tmp や
55
+ * ターゲットを一瞬開くため、temp+rename の rename が散発的に共有違反で失敗する
56
+ * (npm/esbuild 等も同種のリトライを実装)。POSIX では通常1回で成功する。
57
+ */
58
+ async function renameWithRetry(from, to) {
59
+ const transient = new Set(["EPERM", "EBUSY", "EACCES"]);
60
+ for (let attempt = 0;; attempt++) {
61
+ try {
62
+ await rename(from, to);
63
+ return;
64
+ }
65
+ catch (err) {
66
+ const code = err.code ?? "";
67
+ if (attempt >= 9 || !transient.has(code))
68
+ throw err;
69
+ await new Promise((r) => setTimeout(r, 10 + attempt * 15));
70
+ }
71
+ }
72
+ }
9
73
  /**
10
74
  * Save binary data from an SDK download to a local file.
11
75
  *
@@ -21,6 +85,7 @@ import { t, icons } from "../ui/theme.js";
21
85
  */
22
86
  export async function saveBinaryDownload(result, outputPath, defaultFilename, quiet = false) {
23
87
  const resolvedPath = resolve(outputPath ?? defaultFilename);
88
+ assertSafeOutputPath(resolvedPath); // Windows 予約デバイス名への沈黙保存を防ぐ
24
89
  // Ensure parent directory exists
25
90
  await mkdir(dirname(resolvedPath), { recursive: true });
26
91
  const buffer = Buffer.from(result.data);
@@ -30,7 +95,7 @@ export async function saveBinaryDownload(result, outputPath, defaultFilename, qu
30
95
  const tmpPath = `${resolvedPath}.${process.pid}.part`;
31
96
  try {
32
97
  await writeFile(tmpPath, buffer);
33
- await rename(tmpPath, resolvedPath);
98
+ await renameWithRetry(tmpPath, resolvedPath);
34
99
  }
35
100
  catch (err) {
36
101
  await unlink(tmpPath).catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i-repo",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Modern CLI for ConMas i-Reporter - Built for humans and AI",
5
5
  "type": "module",
6
6
  "bin": {