i-repo 2.4.0 → 2.6.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.
@@ -91,13 +91,26 @@ pluginCommand
91
91
  const row = buildDescribeRow(PLUGIN_PREFIX + name, bin, declared);
92
92
  // 人間面 (table/csv) では params を読める1行に整形 (ndjson/json は宣言そのまま —
93
93
  // render と同じ「ラベル化は人間面のみ」の流儀)
94
- if ((format === "table" || format === "csv") && Array.isArray(row.params)) {
95
- row.params = row.params
96
- .map((p) => {
97
- const choices = Array.isArray(p.choices) ? `:${p.choices.join("|")}` : "";
98
- return `${String(p.name)} <${String(p.type)}${choices}>${p.required ? " (required)" : ""}`;
99
- })
100
- .join(", ");
94
+ if (format === "table" || format === "csv") {
95
+ if (Array.isArray(row.params)) {
96
+ // label は GUI の関心事 — CLI は実フラグ名を見せる
97
+ row.params = row.params
98
+ .map((p) => {
99
+ const choices = Array.isArray(p.choices) ? `:${p.choices.join("|")}` : "";
100
+ const markers = `${p.required ? " (required)" : ""}${p.secret ? " (secret)" : ""}` +
101
+ `${typeof p.subcommand === "string" ? ` [${p.subcommand}]` : ""}`;
102
+ return `${String(p.name)} <${String(p.type)}${choices}>${markers}`;
103
+ })
104
+ .join(", ");
105
+ }
106
+ if (Array.isArray(row.platforms)) {
107
+ row.platforms = row.platforms.join(", ");
108
+ }
109
+ if (Array.isArray(row.subcommands)) {
110
+ row.subcommands = row.subcommands
111
+ .map((s) => (s.description ? `${String(s.name)} (${String(s.description)})` : String(s.name)))
112
+ .join(", ");
113
+ }
101
114
  }
102
115
  formatOutput(row, {
103
116
  format,
@@ -1,5 +1,5 @@
1
- import { assertNumericId, handleError } from "../../core/errors.js";
2
- import { saveBinaryDownload, inferFilename } from "../../utils/file.js";
1
+ import { assertNumericId, handleError, ValidationError } from "../../core/errors.js";
2
+ import { saveBinaryDownload, inferFilename, sanitizeServerFilename } from "../../utils/file.js";
3
3
  import { withSpinner } from "../../ui/spinner.js";
4
4
  import { choiceOption, parsePageSpec } from "../shared-options.js";
5
5
  export function registerReportsDownload(parent, getClient) {
@@ -8,12 +8,19 @@ export function registerReportsDownload(parent, getClient) {
8
8
  .description("Download report file (PDF/Excel)")
9
9
  .argument("<reportId>", "Report ID (numeric)")
10
10
  .addOption(choiceOption("--file-type <type>", "File type", ["pdf", "pdfLayer", "excel"]).makeOptionMandatory())
11
- .option("-o, --output <path>", "Output file path")
11
+ .option("-o, --output <path>", "Output file path (default: server-provided filename, else report-<id>)")
12
12
  .option("--page <pages>", 'Pages (pdf/pdfLayer only; e.g. "1,3" or "2-4")', parsePageSpec)
13
- .option("--file-name <name>", "Output file name")
13
+ .option("--file-name <name>", "Output file name (also sent to the server's fileName parameter)")
14
14
  .action(async (reportId, options, cmd) => {
15
15
  try {
16
16
  assertNumericId(reportId, "reportId");
17
+ // --file-name は「ファイル名」専用 — パスは -o/--output の仕事。
18
+ // 空白のみ (→ 隠しファイル ".pdf") やパス区切り入りは黙って変形せず
19
+ // ここで明示的に拒否する (API 呼び出し前に早期失敗)。
20
+ const requestedName = options.fileName?.trim();
21
+ if (requestedName !== undefined && (requestedName === "" || /[/\\]/.test(requestedName))) {
22
+ throw new ValidationError(`Invalid --file-name "${options.fileName}": must be a plain file name without path separators (use --output for paths).`);
23
+ }
17
24
  const globalOpts = cmd.optsWithGlobals();
18
25
  const client = await getClient(cmd);
19
26
  const result = await withSpinner("downloading", async () => {
@@ -21,11 +28,29 @@ export function registerReportsDownload(parent, getClient) {
21
28
  fileType: options.fileType,
22
29
  report: reportId,
23
30
  pageNo: options.page,
24
- fileName: options.fileName,
31
+ fileName: requestedName,
25
32
  });
26
33
  });
27
34
  const ext = options.fileType === "excel" ? "xlsx" : "pdf";
28
- const defaultName = `report-${reportId}.${ext}`;
35
+ // 保存名の優先順位: -o/--output (パスごと明示) > --file-name (明示) >
36
+ // サーバ申告名 (Content-Disposition = ファイル自動出力設定の名称) >
37
+ // 従来の report-<ID>.<ext>。ユーザーの明示指定は、サーバが fileName
38
+ // パラメータを無視/正規化して別名を申告してきても負けない。
39
+ // --file-name / サーバ名とも、拡張子付きならそのまま・無ければ .<ext>
40
+ // を補う ("foo.pdf" を "foo.pdf.pdf" にしない。--file-type で拡張子は
41
+ // 確定しているので、octet-stream 応答でも拡張子なし保存にしない)。
42
+ // サーバ名は無害化してから使う。
43
+ // 末尾のドット/空白は Windows のファイルシステムが黙って剥がす — 先に
44
+ // 剥がしてから拡張子判定する ("foo." / "foo.pdf " を誤判定して、要求と
45
+ // 違う名前でディスクに載るのを防ぐ。"foo." → "foo.pdf")
46
+ const withExt = (name) => {
47
+ const base = name.replace(/[. ]+$/, "");
48
+ return base.lastIndexOf(".") > 0 ? base : `${base}.${ext}`;
49
+ };
50
+ const serverBase = sanitizeServerFilename(result.fileName);
51
+ const serverName = serverBase ? withExt(serverBase) : undefined;
52
+ const explicitName = requestedName ? withExt(requestedName) : undefined;
53
+ const defaultName = explicitName ?? serverName ?? `report-${reportId}.${ext}`;
29
54
  const filename = inferFilename(result.contentType, defaultName);
30
55
  await saveBinaryDownload(result, options.output, filename, globalOpts.quiet);
31
56
  }
@@ -13,6 +13,14 @@
13
13
  * すべて任意 (非破壊追加)。ただし宣言した以上は正しい形であること —
14
14
  * 壊れた宣言を黙って受理するとフォーム生成側が実行時に壊れる (fail loudly)。
15
15
  */
16
+ /**
17
+ * platforms の閉じた語彙。process.platform ではなく人間向けの名前で宣言する
18
+ * (シェルスクリプトのプラグイン作者が "darwin" を知っている必要はない)。
19
+ */
20
+ export declare const KNOWN_PLATFORMS: readonly ["macos", "linux", "windows"];
21
+ export type PluginPlatform = (typeof KNOWN_PLATFORMS)[number];
22
+ /** process.platform → platforms 語彙。未知の OS は null (= ゲート対象外) */
23
+ export declare function platformKeywordFor(p: NodeJS.Platform): PluginPlatform | null;
16
24
  /** params の1エントリ。GUI/ink がフォーム部品へ写像できる最小集合に絞る */
17
25
  export interface PluginParam {
18
26
  /** フラグ名 (例: "--bucket") */
@@ -23,6 +31,19 @@ export interface PluginParam {
23
31
  choices?: string[];
24
32
  default?: string | number | boolean;
25
33
  description?: string;
34
+ /** 機密値 (APIキー等)。GUI は伏字入力にし、値を表示・記録しない */
35
+ secret?: boolean;
36
+ /** フォーム表示名 (未設定 = name をそのまま表示) */
37
+ label?: string;
38
+ /** 入力欄のプレースホルダ */
39
+ placeholder?: string;
40
+ /** 属するサブコマンド (subcommands のいずれかの name)。未設定 = 全サブコマンド共通 */
41
+ subcommand?: string;
42
+ }
43
+ /** サブコマンドの宣言 (要望5)。params[].subcommand がここの name を参照する */
44
+ export interface PluginSubcommand {
45
+ name: string;
46
+ description?: string;
26
47
  }
27
48
  /** --plugin-schema 応答の全体形 (基本 + 拡張。未知フィールドは無視) */
28
49
  export interface PluginDescribe {
@@ -35,6 +56,9 @@ export interface PluginDescribe {
35
56
  /** 発行する receipt の phase (SCHEMA.md §6) */
36
57
  phases?: string[];
37
58
  params?: PluginParam[];
59
+ /** 対応 OS。未宣言 = 制限なし。宣言があり現在の OS が含まれない場合、dispatch は実行を拒否する */
60
+ platforms?: PluginPlatform[];
61
+ subcommands?: PluginSubcommand[];
38
62
  }
39
63
  /**
40
64
  * plugin describe の表示行を組む。plugin/path は CLI が解決した事実 —
@@ -50,6 +74,17 @@ export declare function buildDescribeRow(resolvedPluginName: string, resolvedPat
50
74
  * 壊れた宣言の明示診断は plugin verify / plugin describe の仕事)。
51
75
  */
52
76
  export declare function probeDescribe(bin: string, timeoutMs?: number): Promise<PluginDescribe | null>;
77
+ /**
78
+ * dispatch のプラットフォームゲート専用の寛容な platforms 読み取り。
79
+ * null = 「制限を確認できない」(schema 未対応・タイムアウト・壊れた応答・
80
+ * 語彙違反) — このとき呼び出し側は絶対にブロックしない (fail-open)。
81
+ * ゲートが信頼するのは完全に well-formed な宣言だけ: ["mac"] のような
82
+ * 語彙違反をそのまま返すと「現OSが含まれない」と誤読して typo した
83
+ * プラグインを遮断してしまう (codex P2)。意図的に validateDescribe は
84
+ * 通さない: 無関係な params の宣言ミスで日常の実行を止めない
85
+ * (壊れた宣言の明示診断は plugin verify / describe の仕事)。
86
+ */
87
+ export declare function probePlatforms(bin: string, timeoutMs?: number): Promise<PluginPlatform[] | null>;
53
88
  /** 拡張フィールドのいずれかを宣言しているか */
54
89
  export declare function hasDescribeFields(row: Record<string, unknown>): boolean;
55
90
  /**
@@ -16,6 +16,21 @@
16
16
  import { execFile } from "node:child_process";
17
17
  import { tmpdir } from "node:os";
18
18
  import { buildPluginEnv, buildWindowsShimArgv } from "./dispatch.js";
19
+ /**
20
+ * platforms の閉じた語彙。process.platform ではなく人間向けの名前で宣言する
21
+ * (シェルスクリプトのプラグイン作者が "darwin" を知っている必要はない)。
22
+ */
23
+ export const KNOWN_PLATFORMS = ["macos", "linux", "windows"];
24
+ /** process.platform → platforms 語彙。未知の OS は null (= ゲート対象外) */
25
+ export function platformKeywordFor(p) {
26
+ if (p === "darwin")
27
+ return "macos";
28
+ if (p === "win32")
29
+ return "windows";
30
+ if (p === "linux")
31
+ return "linux";
32
+ return null;
33
+ }
19
34
  const PARAM_TYPES = new Set(["string", "number", "boolean", "select"]);
20
35
  /**
21
36
  * plugin describe の表示行を組む。plugin/path は CLI が解決した事実 —
@@ -33,6 +48,16 @@ export function buildDescribeRow(resolvedPluginName, resolvedPath, declared) {
33
48
  * 壊れた宣言の明示診断は plugin verify / plugin describe の仕事)。
34
49
  */
35
50
  export function probeDescribe(bin, timeoutMs = 1500) {
51
+ return probeSchemaRow(bin, timeoutMs).then((row) => {
52
+ if (!row || !Array.isArray(row.pluginApi))
53
+ return null;
54
+ if (validateDescribe(row).length > 0)
55
+ return null;
56
+ return row;
57
+ });
58
+ }
59
+ /** --plugin-schema を1回だけ聞き、最初の JSON 行を返す (失敗/タイムアウト/非JSON = null) */
60
+ function probeSchemaRow(bin, timeoutMs) {
36
61
  return new Promise((resolve) => {
37
62
  const isShim = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin);
38
63
  const [cmd, args] = isShim
@@ -51,11 +76,9 @@ export function probeDescribe(bin, timeoutMs = 1500) {
51
76
  return resolve(null);
52
77
  try {
53
78
  const row = JSON.parse(stdout.trim().split("\n")[0]);
54
- if (!Array.isArray(row.pluginApi))
55
- return resolve(null);
56
- if (validateDescribe(row).length > 0)
57
- return resolve(null);
58
- resolve(row);
79
+ resolve(row !== null && typeof row === "object" && !Array.isArray(row)
80
+ ? row
81
+ : null);
59
82
  }
60
83
  catch {
61
84
  resolve(null);
@@ -63,9 +86,33 @@ export function probeDescribe(bin, timeoutMs = 1500) {
63
86
  });
64
87
  });
65
88
  }
89
+ /**
90
+ * dispatch のプラットフォームゲート専用の寛容な platforms 読み取り。
91
+ * null = 「制限を確認できない」(schema 未対応・タイムアウト・壊れた応答・
92
+ * 語彙違反) — このとき呼び出し側は絶対にブロックしない (fail-open)。
93
+ * ゲートが信頼するのは完全に well-formed な宣言だけ: ["mac"] のような
94
+ * 語彙違反をそのまま返すと「現OSが含まれない」と誤読して typo した
95
+ * プラグインを遮断してしまう (codex P2)。意図的に validateDescribe は
96
+ * 通さない: 無関係な params の宣言ミスで日常の実行を止めない
97
+ * (壊れた宣言の明示診断は plugin verify / describe の仕事)。
98
+ */
99
+ export async function probePlatforms(bin, timeoutMs = 1500) {
100
+ const row = await probeSchemaRow(bin, timeoutMs);
101
+ // schema 応答であることの確認 (copilot M1): pluginApi[] を欠く JSON は
102
+ // --plugin-schema への応答ではない — 偶然 platforms を含む別の出力で
103
+ // ゲートを作動させない
104
+ if (!row || !Array.isArray(row.pluginApi))
105
+ return null;
106
+ const p = row.platforms;
107
+ return Array.isArray(p) &&
108
+ p.length > 0 &&
109
+ p.every((v) => KNOWN_PLATFORMS.includes(v))
110
+ ? p
111
+ : null;
112
+ }
66
113
  /** 拡張フィールドのいずれかを宣言しているか */
67
114
  export function hasDescribeFields(row) {
68
- return ["name", "version", "description", "phases", "params"].some((k) => k in row);
115
+ return ["name", "version", "description", "phases", "params", "platforms", "subcommands"].some((k) => k in row);
69
116
  }
70
117
  /**
71
118
  * 宣言の形式検査。問題の説明文を返す (空配列 = 適合)。
@@ -84,6 +131,46 @@ export function validateDescribe(row) {
84
131
  problems.push("phases must be an array of strings");
85
132
  }
86
133
  }
134
+ if ("platforms" in row) {
135
+ // 閉じた語彙を強制する: "mac" や "darwin" のような typo を受理すると
136
+ // 実行ゲートに一度もマッチせず「宣言したのに効かない」沈黙の失敗になる。
137
+ // 空配列は「どこでも動かない」= 宣言ミス扱い。
138
+ const p = row.platforms;
139
+ if (!Array.isArray(p) ||
140
+ p.length === 0 ||
141
+ !p.every((v) => typeof v === "string" && KNOWN_PLATFORMS.includes(v))) {
142
+ problems.push(`platforms must be a non-empty array of ${KNOWN_PLATFORMS.map((k) => `"${k}"`).join(" | ")}`);
143
+ }
144
+ }
145
+ let declaredSubs = null;
146
+ if ("subcommands" in row) {
147
+ const subs = row.subcommands;
148
+ if (!Array.isArray(subs)) {
149
+ problems.push("subcommands must be an array");
150
+ }
151
+ else {
152
+ declaredSubs = new Set();
153
+ subs.forEach((entry, i) => {
154
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
155
+ problems.push(`subcommands[${i}] must be an object`);
156
+ return;
157
+ }
158
+ const s = entry;
159
+ if (typeof s.name !== "string" || s.name.length === 0) {
160
+ problems.push(`subcommands[${i}].name must be a non-empty string`);
161
+ }
162
+ else if (declaredSubs.has(s.name)) {
163
+ problems.push(`subcommands has duplicate name "${s.name}"`);
164
+ }
165
+ else {
166
+ declaredSubs.add(s.name);
167
+ }
168
+ if ("description" in s && typeof s.description !== "string") {
169
+ problems.push(`subcommands[${i}].description must be a string`);
170
+ }
171
+ });
172
+ }
173
+ }
87
174
  if ("params" in row) {
88
175
  const params = row.params;
89
176
  if (!Array.isArray(params)) {
@@ -137,6 +224,24 @@ export function validateDescribe(row) {
137
224
  if ("description" in p && typeof p.description !== "string") {
138
225
  problems.push(`params[${i}].description must be a string`);
139
226
  }
227
+ if ("secret" in p && typeof p.secret !== "boolean") {
228
+ problems.push(`params[${i}].secret must be a boolean`);
229
+ }
230
+ for (const key of ["label", "placeholder"]) {
231
+ if (key in p && typeof p[key] !== "string") {
232
+ problems.push(`params[${i}].${key} must be a string`);
233
+ }
234
+ }
235
+ if ("subcommand" in p) {
236
+ if (typeof p.subcommand !== "string" || p.subcommand.length === 0) {
237
+ problems.push(`params[${i}].subcommand must be a non-empty string`);
238
+ }
239
+ else if (declaredSubs !== null && !declaredSubs.has(p.subcommand)) {
240
+ // subcommands 一覧を宣言した以上、参照は必ず解決すること —
241
+ // 解決しない紐付けを通すと GUI がその項目をどの画面にも出せない
242
+ problems.push(`params[${i}].subcommand "${p.subcommand}" is not in declared subcommands`);
243
+ }
244
+ }
140
245
  });
141
246
  }
142
247
  }
@@ -256,6 +256,27 @@ export async function dispatchPlugin(name, args, ctx) {
256
256
  const bin = findPlugin(name);
257
257
  if (!bin)
258
258
  return null;
259
+ // プラットフォームゲート (PLUGINS.md §3): platforms を宣言したプラグインを
260
+ // 非対応 OS で実行しようとしたら、起動前に明確に拒否する (黙って壊れる
261
+ // より早い失敗)。宣言が「確認できた」ときのみ拒否し、schema 未対応・
262
+ // タイムアウト・壊れた応答では絶対にブロックしない (fail-open — 既存
263
+ // プラグインに退行経路を作らない)。メタデータ照会 (--plugin-schema /
264
+ // --plugin-healthcheck) はバイパス: GUI が platforms を知る手段そのもの
265
+ // であり、ここを塞ぐと非対応 OS から自己記述が読めなくなる。
266
+ const isMetadataProbe = args[0] === "--plugin-schema" || args[0] === "--plugin-healthcheck";
267
+ if (!isMetadataProbe) {
268
+ const { platformKeywordFor, probePlatforms } = await import("./describe.js");
269
+ const host = platformKeywordFor(process.platform);
270
+ if (host) {
271
+ const declared = await probePlatforms(bin);
272
+ if (declared && !declared.includes(host)) {
273
+ process.stderr.write(`Refusing to run plugin "${name}": it declares platforms ${JSON.stringify(declared)} ` +
274
+ `and does not support ${host}.\n` +
275
+ `See "i-repo plugin describe ${name}".\n`);
276
+ return 1;
277
+ }
278
+ }
279
+ }
259
280
  const env = buildPluginEnv(ctx);
260
281
  // Windows .cmd/.bat shims (e.g. npm-installed plugins) cannot be executed
261
282
  // directly by spawn() — Node requires cmd.exe for batch files. We invoke
@@ -20,11 +20,12 @@ set -eu
20
20
  case "\${1:-}" in
21
21
  --plugin-healthcheck)
22
22
  # TODO: check your sink's reachability here (no writes!)
23
- printf '{"ok":true,"checks":[{"name":"ready","ok":true}]}\\n'
23
+ # severity: required | optional | required-for:<phase|subcommand>。hint は失敗時の対処メッセージ
24
+ printf '{"ok":true,"checks":[{"name":"ready","ok":true,"severity":"required","hint":"TODO: how to fix when this fails"}]}\\n'
24
25
  exit 0 ;;
25
26
  --plugin-schema)
26
27
  # 自己記述 (PLUGINS.md §3)。params は GUI/ink のフォーム生成に使われる
27
- printf '{"pluginApi":["1"],"schemaVersions":["1.0"],"recordTypes":["*"],"name":"i-repo-${name}","version":"0.1.0","description":"TODO: what this plugin does","phases":["write","verify"],"params":[{"name":"--dry-run","type":"boolean","description":"Validate without writing"}]}\\n'
28
+ printf '{"pluginApi":["1"],"schemaVersions":["1.0"],"recordTypes":["*"],"name":"i-repo-${name}","version":"0.1.0","description":"TODO: what this plugin does","phases":["write","verify"],"platforms":["macos","linux"],"params":[{"name":"--dry-run","type":"boolean","label":"Dry run","description":"Validate without writing"}]}\\n'
28
29
  exit 0 ;;
29
30
  esac
30
31
 
@@ -99,7 +100,11 @@ const PLUGIN = "i-repo-${name}";
99
100
  const arg0 = process.argv[2];
100
101
  if (arg0 === "--plugin-healthcheck") {
101
102
  // TODO: check your sink's reachability here (no writes!)
102
- console.log(JSON.stringify({ ok: true, checks: [{ name: "ready", ok: true }] }));
103
+ // severity: required | optional | required-for:<phase|subcommand>。hint は失敗時の対処メッセージ
104
+ console.log(JSON.stringify({
105
+ ok: true,
106
+ checks: [{ name: "ready", ok: true, severity: "required", hint: "TODO: how to fix when this fails" }],
107
+ }));
103
108
  process.exit(0);
104
109
  }
105
110
  if (arg0 === "--plugin-schema") {
@@ -109,8 +114,9 @@ if (arg0 === "--plugin-schema") {
109
114
  name: PLUGIN, version: "0.1.0",
110
115
  description: "TODO: what this plugin does",
111
116
  phases: ["write", "verify"],
117
+ platforms: ["macos", "linux", "windows"],
112
118
  params: [
113
- { name: "--dry-run", type: "boolean", description: "Validate without writing" },
119
+ { name: "--dry-run", type: "boolean", label: "Dry run", description: "Validate without writing" },
114
120
  ],
115
121
  }));
116
122
  process.exit(0);
@@ -23,6 +23,13 @@ export declare function parseLines(stdout: string): {
23
23
  rows: Record<string, unknown>[];
24
24
  invalid: string[];
25
25
  };
26
+ /**
27
+ * healthcheck 応答の checks[] の形式検査 (PLUGINS.md §3)。
28
+ * 各 entry は {name: string, ok: boolean, severity?, hint?}。severity は
29
+ * "required" | "optional" | "required-for:<phase|subcommand>"、未設定の既定は
30
+ * "required" (現行挙動と同じ保守側)。未知の追加フィールドは無視する。
31
+ */
32
+ export declare function validateHealthChecks(value: unknown): string[];
26
33
  /**
27
34
  * Run all conformance checks against plugin `name`.
28
35
  * Returns null when the plugin is not installed.
@@ -16,7 +16,7 @@ import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { buildEnvelope, buildStreamEnd } from "../ui/formatters/envelope.js";
18
18
  import { buildPluginEnv, buildWindowsShimArgv, findPlugin } from "./dispatch.js";
19
- import { hasDescribeFields, validateDescribe } from "./describe.js";
19
+ import { KNOWN_PLATFORMS, hasDescribeFields, platformKeywordFor, validateDescribe, } from "./describe.js";
20
20
  /** Sample envelope stream (3 records + stream-end), generated from the real implementation. */
21
21
  export function sampleStream() {
22
22
  const spec = { recordType: "report", idField: "itemId", revField: "revNo" };
@@ -93,6 +93,40 @@ export function parseLines(stdout) {
93
93
  }
94
94
  return { rows, invalid };
95
95
  }
96
+ // required-for: の後は単一の非空白トークン (phase / subcommand 名)。
97
+ // 空白入りを許すと severity を単一トークンとしてパースする消費側が壊れる
98
+ const SEVERITY_RE = /^(required|optional|required-for:\S+)$/;
99
+ /**
100
+ * healthcheck 応答の checks[] の形式検査 (PLUGINS.md §3)。
101
+ * 各 entry は {name: string, ok: boolean, severity?, hint?}。severity は
102
+ * "required" | "optional" | "required-for:<phase|subcommand>"、未設定の既定は
103
+ * "required" (現行挙動と同じ保守側)。未知の追加フィールドは無視する。
104
+ */
105
+ export function validateHealthChecks(value) {
106
+ const problems = [];
107
+ if (!Array.isArray(value))
108
+ return ["checks must be an array"];
109
+ value.forEach((entry, i) => {
110
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
111
+ problems.push(`checks[${i}] must be an object`);
112
+ return;
113
+ }
114
+ const c = entry;
115
+ if (typeof c.name !== "string" || c.name.length === 0) {
116
+ problems.push(`checks[${i}].name must be a non-empty string`);
117
+ }
118
+ if (typeof c.ok !== "boolean") {
119
+ problems.push(`checks[${i}].ok must be a boolean`);
120
+ }
121
+ if ("severity" in c && (typeof c.severity !== "string" || !SEVERITY_RE.test(c.severity))) {
122
+ problems.push(`checks[${i}].severity must be "required" | "optional" | "required-for:<phase|subcommand>"`);
123
+ }
124
+ if ("hint" in c && typeof c.hint !== "string") {
125
+ problems.push(`checks[${i}].hint must be a string`);
126
+ }
127
+ });
128
+ return problems;
129
+ }
96
130
  const RECEIPT_REQUIRED = [
97
131
  "schemaVersion",
98
132
  "recordType",
@@ -140,6 +174,27 @@ export function verifyPlugin(name) {
140
174
  ? "self-description fields are well-formed"
141
175
  : problems[0],
142
176
  });
177
+ // platforms 宣言と現在の OS の照合は警告のみ — 他 OS 向けプラグインを
178
+ // ビルドマシンで verify するのは正当 (語彙の誤りは describe-shape が
179
+ // required 違反として捕捉済み)。実行ゲートは dispatch 側。
180
+ // well-formed な宣言のみ報告する (copilot M2): 語彙違反/空配列では
181
+ // dispatch は fail-open で拒否しないため、「dispatch will refuse」の
182
+ // note が嘘になる (壊れた宣言自体は上の describe-shape が required 違反)
183
+ const platforms = rows[0].platforms;
184
+ const host = platformKeywordFor(process.platform);
185
+ if (Array.isArray(platforms) &&
186
+ platforms.length > 0 &&
187
+ platforms.every((v) => KNOWN_PLATFORMS.includes(v))) {
188
+ const supported = host === null || platforms.includes(host);
189
+ checks.push({
190
+ check: "platform-support",
191
+ ok: supported,
192
+ required: false,
193
+ note: supported
194
+ ? `declares platforms ${JSON.stringify(platforms)}; current OS (${host ?? "unknown"}) is supported`
195
+ : `declares platforms ${JSON.stringify(platforms)}; current OS is ${host} — dispatch will refuse to run it here`,
196
+ });
197
+ }
143
198
  }
144
199
  }
145
200
  else {
@@ -155,14 +210,36 @@ export function verifyPlugin(name) {
155
210
  if (health.status === 0 || health.status === 1) {
156
211
  const { rows, invalid } = parseLines(health.stdout);
157
212
  const ok = invalid.length === 0 && rows.length === 1 && typeof rows[0]?.ok === "boolean";
213
+ // ok:false の応答に hint 付きの失敗 check があれば note に併記する —
214
+ // 「不健康」とだけ言われても直せない。hint が対処メッセージの契約面。
215
+ let detail = "";
216
+ if (ok && rows[0].ok === false && Array.isArray(rows[0].checks)) {
217
+ const failing = rows[0].checks.find((c) => c !== null && typeof c === "object" &&
218
+ c.ok === false &&
219
+ typeof c.hint === "string");
220
+ if (failing)
221
+ detail = ` — hint: ${String(failing.hint)}`;
222
+ }
158
223
  checks.push({
159
224
  check: "plugin-healthcheck",
160
225
  ok,
161
226
  required: false,
162
227
  note: ok
163
- ? `answered ok=${rows[0].ok} (exit ${health.status})`
228
+ ? `answered ok=${rows[0].ok} (exit ${health.status})${detail}`
164
229
  : "exited 0/1 but did not print a single {ok:boolean} JSON line",
165
230
  });
231
+ // checks[] は任意だが、宣言した以上は正しい形であること (describe-shape
232
+ // と同じ理屈): {name, ok, severity?, hint?}。壊れた checks を黙って通すと
233
+ // それを信じた GUI の充足判定が壊れる。
234
+ if (ok && "checks" in rows[0]) {
235
+ const problems = validateHealthChecks(rows[0].checks);
236
+ checks.push({
237
+ check: "healthcheck-shape",
238
+ ok: problems.length === 0,
239
+ required: true,
240
+ note: problems.length === 0 ? "healthcheck checks[] entries are well-formed" : problems[0],
241
+ });
242
+ }
166
243
  }
167
244
  else {
168
245
  checks.push({
@@ -53,11 +53,13 @@ export declare class ReportApi extends BaseApi {
53
53
  copyReport(params: CopyReportParams): Promise<CopyReportResponse>;
54
54
  /**
55
55
  * 帳票ファイル (PDF/Excel) をバイナリとして取得する
56
- * @returns バイナリデータとContentType
56
+ * @returns バイナリデータとContentType。fileName はサーバが Content-Disposition で
57
+ * 申告した出力名(ファイル自動出力設定の名称。無ければ undefined)
57
58
  */
58
59
  getReportFile(params: GetReportFileParams): Promise<{
59
60
  data: ArrayBuffer;
60
61
  contentType: string;
62
+ fileName?: string;
61
63
  }>;
62
64
  /**
63
65
  * クラスター値を取得する。
@@ -132,7 +132,8 @@ export class ReportApi extends BaseApi {
132
132
  }
133
133
  /**
134
134
  * 帳票ファイル (PDF/Excel) をバイナリとして取得する
135
- * @returns バイナリデータとContentType
135
+ * @returns バイナリデータとContentType。fileName はサーバが Content-Disposition で
136
+ * 申告した出力名(ファイル自動出力設定の名称。無ければ undefined)
136
137
  */
137
138
  async getReportFile(params) {
138
139
  const reqParams = buildParams({ command: "GetReportFile", fileType: params.fileType }, {
@@ -18,6 +18,7 @@ export declare abstract class BaseApi {
18
18
  protected executeForBinary(params: Record<string, string>): Promise<{
19
19
  data: ArrayBuffer;
20
20
  contentType: string;
21
+ fileName?: string;
21
22
  }>;
22
23
  /**
23
24
  * クラスタ値取得用。XMLレスポンスで `conmas.value` があればテキスト値を
@@ -58,7 +58,7 @@ export class BaseApi {
58
58
  }
59
59
  // Content-Type が xml でなくても、本文が XML エラーなら沈黙保存させない
60
60
  this.checkBinaryErrorEnvelope(result.data, params.command);
61
- return { data: result.data, contentType: result.contentType };
61
+ return { data: result.data, contentType: result.contentType, fileName: result.fileName };
62
62
  }
63
63
  /**
64
64
  * クラスタ値取得用。XMLレスポンスで `conmas.value` があればテキスト値を
@@ -1,4 +1,18 @@
1
1
  import type { IReporterConfig } from "../types/common.js";
2
+ /** バイナリ応答。fileName は Content-Disposition から得たサーバ提供名 (無ければ undefined) */
3
+ export interface BinaryResponse {
4
+ data: ArrayBuffer;
5
+ contentType: string;
6
+ fileName?: string;
7
+ }
8
+ /**
9
+ * Content-Disposition からファイル名を取り出す (RFC 6266)。
10
+ * `filename*=charset''pct-encoded` (RFC 5987) を優先し、無ければ
11
+ * `filename="..."` / `filename=token`。パースできない・壊れた値は undefined —
12
+ * 呼び出し側がフォールバック名を使う。値はサーバ申告のまま返す (パス区切り等の
13
+ * 無害化は保存する側の責務 — SDK はワイヤ忠実)。
14
+ */
15
+ export declare function parseContentDispositionFilename(header: string | null | undefined): string | undefined;
2
16
  /** HTTPクライアント: セッション(Cookie)管理 + POSTリクエスト */
3
17
  export declare class HttpClient {
4
18
  private readonly baseUrl;
@@ -41,19 +55,14 @@ export declare class HttpClient {
41
55
  filename: string;
42
56
  }): Promise<string>;
43
57
  /** バイナリレスポンスでPOSTリクエスト(ダウンロード用)。画像等を破損させないため arraybuffer で受信 */
44
- postForBinary(params: Record<string, string>): Promise<{
45
- data: ArrayBuffer;
46
- contentType: string;
47
- }>;
58
+ postForBinary(params: Record<string, string>): Promise<BinaryResponse>;
48
59
  /** レスポンスがXMLかどうかを判定してXML/バイナリを返す。画像等は arraybuffer で取得するため専用経路を使用 */
49
60
  postAutoDetect(params: Record<string, string>): Promise<{
50
61
  type: "xml";
51
62
  xml: string;
52
- } | {
63
+ } | ({
53
64
  type: "binary";
54
- data: ArrayBuffer;
55
- contentType: string;
56
- }>;
65
+ } & BinaryResponse)>;
57
66
  /** multipart/form-data でPOSTし、Content-Typeを判定してXML/バイナリを返す */
58
67
  postMultipartForBinary(params: Record<string, string>, file: {
59
68
  name: string;
@@ -2,6 +2,39 @@ import http from "node:http";
2
2
  import https from "node:https";
3
3
  import { URL } from "node:url";
4
4
  import { HttpError, NetworkError } from "./errors.js";
5
+ /**
6
+ * Content-Disposition からファイル名を取り出す (RFC 6266)。
7
+ * `filename*=charset''pct-encoded` (RFC 5987) を優先し、無ければ
8
+ * `filename="..."` / `filename=token`。パースできない・壊れた値は undefined —
9
+ * 呼び出し側がフォールバック名を使う。値はサーバ申告のまま返す (パス区切り等の
10
+ * 無害化は保存する側の責務 — SDK はワイヤ忠実)。
11
+ */
12
+ export function parseContentDispositionFilename(header) {
13
+ if (!header)
14
+ return undefined;
15
+ // パラメータ名と charset は大文字小文字を区別しない (RFC 6266/5987 — copilot 指摘)
16
+ // filename*=UTF-8''%E5%B8%B3%E7%A5%A8.pdf (charset は UTF-8 のみ対応)
17
+ const ext = /filename\*\s*=\s*utf-8''([^;]+)/i.exec(header);
18
+ if (ext) {
19
+ try {
20
+ const decoded = decodeURIComponent(ext[1].trim());
21
+ if (decoded.length > 0)
22
+ return decoded;
23
+ }
24
+ catch {
25
+ // 壊れた % エンコード → 素の filename= にフォールバック
26
+ }
27
+ }
28
+ const quoted = /filename\s*=\s*"((?:[^"\\]|\\.)*)"/i.exec(header);
29
+ if (quoted) {
30
+ const unescaped = quoted[1].replace(/\\(.)/g, "$1");
31
+ return unescaped.length > 0 ? unescaped : undefined;
32
+ }
33
+ const token = /filename\s*=\s*([^;\s]+)/i.exec(header);
34
+ if (token && token[1].length > 0)
35
+ return token[1];
36
+ return undefined;
37
+ }
5
38
  /** HTTPクライアント: セッション(Cookie)管理 + POSTリクエスト */
6
39
  export class HttpClient {
7
40
  baseUrl;
@@ -142,13 +175,13 @@ export class HttpClient {
142
175
  }
143
176
  /** レスポンスがXMLかどうかを判定してXML/バイナリを返す。画像等は arraybuffer で取得するため専用経路を使用 */
144
177
  async postAutoDetect(params) {
145
- const { data, contentType } = await this.postForBinary(params);
178
+ const { data, contentType, fileName } = await this.postForBinary(params);
146
179
  const rawContentType = contentType ?? "";
147
180
  if (rawContentType.includes("xml") && !isZipMagic(data)) {
148
181
  const xml = new TextDecoder("utf-8").decode(data);
149
182
  return { type: "xml", xml };
150
183
  }
151
- return { type: "binary", data, contentType: rawContentType };
184
+ return { type: "binary", data, contentType: rawContentType, fileName };
152
185
  }
153
186
  /** multipart/form-data でPOSTし、Content-Typeを判定してXML/バイナリを返す */
154
187
  async postMultipartForBinary(params, file) {
@@ -206,6 +239,7 @@ export class HttpClient {
206
239
  return {
207
240
  data: await this.readArrayBuffer(response),
208
241
  contentType: response.headers.get("content-type") ?? "application/octet-stream",
242
+ fileName: parseContentDispositionFilename(response.headers.get("content-disposition")),
209
243
  };
210
244
  }
211
245
  /** Node `http`/`https` で URL-encoded を送る(ASP.NET と相性が良い) */
@@ -228,6 +262,7 @@ export class HttpClient {
228
262
  return {
229
263
  data: u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength),
230
264
  contentType,
265
+ fileName: parseContentDispositionFilename(getIncomingHeader(headers, "content-disposition")),
231
266
  };
232
267
  }
233
268
  async nodePostUrlEncoded(startUrl, bodyString) {
@@ -24,6 +24,7 @@ export declare class ReportsResource {
24
24
  downloadFile(params: GetReportFileParams): Promise<{
25
25
  data: ArrayBuffer;
26
26
  contentType: string;
27
+ fileName?: string;
27
28
  }>;
28
29
  getClusterValue(params: GetClusterValueParams): Promise<{
29
30
  data: ArrayBuffer;
@@ -27,6 +27,15 @@ export declare function saveBinaryDownload(result: {
27
27
  data: ArrayBuffer;
28
28
  contentType: string;
29
29
  }, outputPath: string | undefined, defaultFilename: string, quiet?: boolean): Promise<DownloadResult>;
30
+ /**
31
+ * サーバ申告のダウンロードファイル名 (Content-Disposition) を保存に安全な
32
+ * 単一ファイル名へ無害化する。サーバ (または途中の何か) が "../../evil.pdf" や
33
+ * 制御文字入りの名前を申告しても、保存先ディレクトリの外へ書かせない。
34
+ * 日本語等の Unicode は保持する (帳票名がそのまま使えることが目的)。
35
+ * 無害化の結果ファイル名として成立しない場合は undefined — 呼び出し側が
36
+ * フォールバック名を使う。
37
+ */
38
+ export declare function sanitizeServerFilename(name: string | undefined): string | undefined;
30
39
  /**
31
40
  * Infer a filename from content-type header.
32
41
  * Falls back to the provided default.
@@ -109,6 +109,34 @@ export async function saveBinaryDownload(result, outputPath, defaultFilename, qu
109
109
  size: buffer.byteLength,
110
110
  };
111
111
  }
112
+ /**
113
+ * サーバ申告のダウンロードファイル名 (Content-Disposition) を保存に安全な
114
+ * 単一ファイル名へ無害化する。サーバ (または途中の何か) が "../../evil.pdf" や
115
+ * 制御文字入りの名前を申告しても、保存先ディレクトリの外へ書かせない。
116
+ * 日本語等の Unicode は保持する (帳票名がそのまま使えることが目的)。
117
+ * 無害化の結果ファイル名として成立しない場合は undefined — 呼び出し側が
118
+ * フォールバック名を使う。
119
+ */
120
+ export function sanitizeServerFilename(name) {
121
+ if (!name)
122
+ return undefined;
123
+ // パス区切りを越えさせない: 最後のセグメントだけを採用 (\ は Windows 区切り)
124
+ const lastSegment = name.split(/[/\\]/).pop() ?? "";
125
+ const cleaned = lastSegment
126
+ .replace(/[\u0000-\u001f\u007f]/g, "") // 制御文字 (端末・FS 両方に有害)
127
+ .replace(/[<>:"|?*]/g, "_") // Windows で使えない文字
128
+ .trim()
129
+ // Windows は末尾のドット/空白を黙って剥がす — 先にこちらで剥がして
130
+ // 「指定と違う名前で保存される」を起こさない (copilot 指摘)
131
+ .replace(/[. ]+$/, "");
132
+ if (cleaned === "")
133
+ return undefined;
134
+ // Windows 予約デバイス名 (NUL / CON.pdf 等) はどの OS でも採用しない —
135
+ // 保存物が後で Windows に渡ったとき開けない名前を作らない (可搬性)
136
+ if (isWindowsReservedName(cleaned))
137
+ return undefined;
138
+ return cleaned;
139
+ }
112
140
  /**
113
141
  * Infer a filename from content-type header.
114
142
  * Falls back to the provided default.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i-repo",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Modern CLI for ConMas i-Reporter - Built for humans and AI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,10 +61,10 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/gradient-string": "1.1.6",
64
- "@types/node": "24.13.0",
65
- "@types/react": "19.2.16",
64
+ "@types/node": "25.9.1",
65
+ "@types/react": "19.2.17",
66
66
  "typescript": "5.9.3",
67
- "vitest": "4.1.7"
67
+ "vitest": "4.1.8"
68
68
  },
69
69
  "overrides": {
70
70
  "ws": "8.21.0"