i-repo 2.13.0 → 2.15.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.
@@ -66,8 +66,8 @@ export function buildDeliverRun(meta, sinks) {
66
66
  status,
67
67
  delivered,
68
68
  ...(s.skippedReason ? { skippedReason: s.skippedReason } : {}),
69
- ...(s.dest !== undefined ? { dest: s.dest } : {}),
70
- ...(s.canonicalLandedUri !== undefined ? { canonicalLandedUri: s.canonicalLandedUri } : {}),
69
+ ...(s.dest !== undefined ? { dest: maskUserinfo(s.dest) } : {}),
70
+ ...(s.canonicalLandedUri !== undefined ? { canonicalLandedUri: maskUserinfo(s.canonicalLandedUri) } : {}),
71
71
  ...(s.failure ? { failure: s.failure } : {}),
72
72
  receipts: maskUserinfo(s.receipts),
73
73
  };
@@ -24,6 +24,7 @@ export const deliverCommand = new Command("deliver")
24
24
  format: globals.format,
25
25
  quiet: globals.quiet,
26
26
  timeout: globals.timeout,
27
+ flowName: spec.flowName, // archive の {flow} 展開(#89)
27
28
  };
28
29
  const run = await runDeliver(spec, ctx, { dryRun, archiveClean: !!opts.archiveClean });
29
30
  // run 集約は常に stdout へ(DC が結果に依らず永続化できる・§9.7)。
@@ -65,7 +65,31 @@ async function runSink(sink, archivePath, ctx, resolver) {
65
65
  const cap = await capturePlugin(sink.plugin, [sink.subcommand, archivePath, ...extraArgs], ctx);
66
66
  if (!cap)
67
67
  return { ...base, failure: `plugin "i-repo-${sink.plugin}" not found` };
68
- return { ...base, code: cap.code, receipts: cap.receipts, ...(sinkFailure(cap.code, cap.receipts)) };
68
+ // bundle sink receipt が焼いた canonical store location(dest)/ storeKind を
69
+ // sink へ載せる(#89)。DC が catalog の dataset identity・store.location に使うため、
70
+ // **delivered(verify 成功)した sink のみ**に載せる — 失敗 sink の receipt も
71
+ // dest を持つが、未着地の宛先を identity として広告すると DC が phantom dataset を作る。
72
+ const verifiedOk = receiptVerifiedOk(cap.receipts);
73
+ return {
74
+ ...base,
75
+ code: cap.code,
76
+ receipts: cap.receipts,
77
+ ...(verifiedOk ? sinkDestFromReceipts(cap.receipts) : {}),
78
+ ...(sinkFailure(cap.code, cap.receipts)),
79
+ };
80
+ }
81
+ /** push receipt が焼いた canonical `dest` / `storeKind` を拾う(#89・bundle sink)。 */
82
+ function sinkDestFromReceipts(receipts) {
83
+ const out = {};
84
+ for (const r of receipts) {
85
+ if (typeof r.dest === "string" && out.dest === undefined)
86
+ out.dest = r.dest;
87
+ if (typeof r.storeKind === "string" && out.storeKind === undefined)
88
+ out.storeKind = r.storeKind;
89
+ if (out.dest !== undefined && out.storeKind !== undefined)
90
+ break;
91
+ }
92
+ return out;
69
93
  }
70
94
  /** enrichLocator stream sink が依存する bundle sink id(明示 locatorFrom/単一 bundle なら自動)。 */
71
95
  function locatorSourceId(sink, bundleIds) {
@@ -86,6 +110,9 @@ export async function runDeliver(spec, ctx, opts) {
86
110
  const startedAt = localDash(new Date());
87
111
  const dryRun = opts.dryRun;
88
112
  const already = new Set(spec.alreadyDelivered ?? []);
113
+ // spec.flowName を IREPO_FLOW_NAME として全プラグインへ渡す(archive の {flow}
114
+ // 展開・#89)。caller(index.ts) に依らずオーケストレータが正本(直接呼び出しでも効く)。
115
+ ctx = { ...ctx, flowName: spec.flowName ?? ctx.flowName };
89
116
  // 1. archive create を 1 回(ハブ)。
90
117
  const archiveArgs = [
91
118
  spec.archive.subcommand,
@@ -8,6 +8,11 @@ export interface PluginContext {
8
8
  quiet?: boolean;
9
9
  /** Request timeout in seconds (--timeout); forwarded as IREPO_TIMEOUT. */
10
10
  timeout?: string;
11
+ /**
12
+ * フロー名。`deliver`(fan-out)が spec.flowName から渡す。IREPO_FLOW_NAME として
13
+ * プラグインへ転送し、archive の `--to`/`--prefix` の `{flow}` 展開に使う(#89)。
14
+ */
15
+ flowName?: string;
11
16
  /**
12
17
  * --save のファイルパス。プラグインは stdout を inherit で直接所有する
13
18
  * ため --save は適用できない — 呼び出し側 (run) で明示エラーにする
@@ -246,6 +246,12 @@ export function buildPluginEnv(ctx, opts = {}) {
246
246
  env.IREPO_FORMAT = String(format);
247
247
  if (timeout)
248
248
  env.IREPO_TIMEOUT = String(timeout);
249
+ // フロー名(deliver fan-out 由来)。archive の {flow} 展開に使う(#89)。host が
250
+ // 権威的に設定する: 継承値を先に scrub(PATH バイナリが IREPO_FLOW_NAME を spoof して
251
+ // 着地パス/canonical dest を捻じ曲げるのを防ぐ=fingerprint 系と同じ規律)。
252
+ delete env.IREPO_FLOW_NAME;
253
+ if (ctx.flowName)
254
+ env.IREPO_FLOW_NAME = String(ctx.flowName);
249
255
  env.IREPO_QUIET = quiet ? "1" : "";
250
256
  env.IREPO_PLUGIN_API = "1";
251
257
  // Provenance of the resolved i-Reporter endpoint, computed CLI-side — the CLI
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i-repo",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
4
4
  "description": "Modern CLI for ConMas i-Reporter - Built for humans and AI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,7 +44,7 @@ async function loadBuiltins() {
44
44
  }
45
45
 
46
46
  const PLUGIN = "i-repo-archive";
47
- const VERSION = "0.6.1";
47
+ const VERSION = "0.6.3";
48
48
  const PLUGIN_API = "1";
49
49
  const defaultArchiveRoot = () => join(homedir(), ".i-repo", "archives");
50
50
 
@@ -179,6 +179,19 @@ if (arg0 === "--plugin-schema") {
179
179
  { name: "--cleanup", type: "boolean", subcommand: "push-s3", destructive: true,
180
180
  label: "Clean up local archive",
181
181
  description: "Delete the local archive directory after the upload is verified" },
182
+ // push-s3 無人認証(#92・push-azure と同作法): 値は argv ではなく env で aws に渡す。
183
+ { name: "--access-key-id", type: "string", subcommand: "push-s3", envVar: "AWS_ACCESS_KEY_ID",
184
+ label: "AWS アクセスキーID(任意・無人用)",
185
+ description: "IAM アクセスキーID。値は環境変数 AWS_ACCESS_KEY_ID で aws に渡す(対話 aws sso login 不要で無人化)" },
186
+ { name: "--secret-access-key", type: "string", subcommand: "push-s3", secret: true, envVar: "AWS_SECRET_ACCESS_KEY",
187
+ label: "AWS シークレットアクセスキー(任意・無人用)",
188
+ description: "IAM シークレットアクセスキー。値は argv に出さず環境変数 AWS_SECRET_ACCESS_KEY で aws に渡す" },
189
+ { name: "--profile", type: "string", subcommand: "push-s3", envVar: "AWS_PROFILE",
190
+ label: "AWS プロファイル(任意)",
191
+ description: "named profile(~/.aws 構成)。値は環境変数 AWS_PROFILE で aws に渡す" },
192
+ { name: "--region", type: "string", subcommand: "push-s3", envVar: "AWS_REGION",
193
+ label: "AWS リージョン(任意)",
194
+ description: "リージョン。値は環境変数 AWS_REGION で aws に渡す" },
182
195
  // push-gcs
183
196
  { name: "--to", type: "string", required: true, subcommand: "push-gcs",
184
197
  label: "GCS destination", placeholder: "gs://bucket/prefix",
@@ -190,6 +203,10 @@ if (arg0 === "--plugin-schema") {
190
203
  { name: "--cleanup", type: "boolean", subcommand: "push-gcs", destructive: true,
191
204
  label: "Clean up local archive",
192
205
  description: "Delete the local archive directory after the upload is verified" },
206
+ // push-gcs 無人認証(#92): SA 鍵 JSON の**パス**を env(GOOGLE_APPLICATION_CREDENTIALS)で gcloud に渡す。
207
+ { name: "--gcp-credentials", type: "string", subcommand: "push-gcs", envVar: "GOOGLE_APPLICATION_CREDENTIALS",
208
+ label: "GCP サービスアカウント鍵のパス(任意・無人用)",
209
+ description: "サービスアカウント鍵 JSON のファイルパス(鍵そのものではない)。env GOOGLE_APPLICATION_CREDENTIALS で受け、プラグインが gcloud CLI 用に CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE へも複写して無人化(対話 gcloud auth login 不要)" },
193
210
  // push-azure
194
211
  { name: "--to", type: "string", required: true, subcommand: "push-azure",
195
212
  label: "Azure Blob destination", placeholder: "https://<account>.blob.core.windows.net/<container>/<prefix>",
@@ -222,8 +239,13 @@ if (arg0 === "--plugin-schema") {
222
239
  // pull-*(取り戻し・host 管理。GUI 配信フォームには出ない=mode:read。Rust が固定引数で渡す)
223
240
  { name: "--from", type: "string", subcommand: "pull-s3", description: "取得元 s3://bucket/prefix(着地した bundle のルート)" },
224
241
  { name: "--out", type: "string", subcommand: "pull-s3", description: "取得先ローカルディレクトリ" },
242
+ { name: "--access-key-id", type: "string", subcommand: "pull-s3", envVar: "AWS_ACCESS_KEY_ID", description: "無人取り戻し用 IAM アクセスキーID(env AWS_ACCESS_KEY_ID)" },
243
+ { name: "--secret-access-key", type: "string", subcommand: "pull-s3", secret: true, envVar: "AWS_SECRET_ACCESS_KEY", description: "無人取り戻し用 IAM シークレットキー(env のみ・argv 非掲載)" },
244
+ { name: "--profile", type: "string", subcommand: "pull-s3", envVar: "AWS_PROFILE", description: "AWS named profile(env AWS_PROFILE)" },
245
+ { name: "--region", type: "string", subcommand: "pull-s3", envVar: "AWS_REGION", description: "AWS リージョン(env AWS_REGION)" },
225
246
  { name: "--from", type: "string", subcommand: "pull-gcs", description: "取得元 gs://bucket/prefix" },
226
247
  { name: "--out", type: "string", subcommand: "pull-gcs", description: "取得先ローカルディレクトリ" },
248
+ { name: "--gcp-credentials", type: "string", subcommand: "pull-gcs", envVar: "GOOGLE_APPLICATION_CREDENTIALS", description: "無人取り戻し用 SA 鍵 JSON のパス(env GOOGLE_APPLICATION_CREDENTIALS)" },
227
249
  { name: "--from", type: "string", subcommand: "pull-azure", description: "取得元 https://<account>.blob.core.windows.net/<container>/<prefix>" },
228
250
  { name: "--out", type: "string", subcommand: "pull-azure", description: "取得先ローカルディレクトリ" },
229
251
  { name: "--sas", type: "string", subcommand: "pull-azure", secret: true, envVar: "AZURE_STORAGE_SAS_TOKEN",
@@ -350,15 +372,23 @@ function usage() {
350
372
  --history --regist-from --regist-to --update-from --update-to
351
373
  -k/--system-key <n=v> (repeatable)
352
374
  ${PLUGIN} push-s3 <archive-dir> --to s3://bucket/prefix [--cleanup] [--dry-run]
375
+ [--access-key-id <id> --secret-access-key <key> | --profile <name>] [--region <r>]
353
376
  ${PLUGIN} push-gcs <archive-dir> --to gs://bucket/prefix [--cleanup] [--dry-run]
377
+ [--gcp-credentials <service-account-key.json path>]
354
378
  ${PLUGIN} push-azure <archive-dir> --to https://<account>.blob.core.windows.net/<container>/<prefix>
355
379
  [--sas <token> | --connection-string <cs>] [--cleanup] [--dry-run]
356
380
  ${PLUGIN} push-local <archive-dir> --to <folder|network-share path> [--cleanup] [--dry-run]
357
381
  --cleanup Delete the local archive after the upload is verified
358
382
  (gcs requires gcloud; azure requires az CLI. push-local needs no external CLI
359
383
  (Node fs copy) and accepts a plain folder or mounted network-share path.
360
- secrets are read from --sas/--connection-string or AZURE_STORAGE_* env and
361
- passed to az via env, never argv)`);
384
+ Unattended creds are read from the flags above OR env
385
+ (s3: AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_PROFILE/AWS_REGION; gcs:
386
+ GOOGLE_APPLICATION_CREDENTIALS; azure: AZURE_STORAGE_*) and passed to the
387
+ cloud CLI via env, never argv — no interactive login needed.
388
+ Note: AWS keys are a pair (both required); --profile and IAM keys are mutually
389
+ exclusive. When run under \`i-repo\` (dispatch/deliver) the parent env is
390
+ allow-listed, so pass creds via these flags, host env-injection, or
391
+ \`i-repo config set pluginPassEnv "AWS_*,GOOGLE_APPLICATION_CREDENTIALS,AZURE_STORAGE_*"\`)`);
362
392
  }
363
393
 
364
394
  // ── Tiny argv parser (no dependencies) ─────────────────────────────────────
@@ -475,8 +505,13 @@ function normalizeLocalDest(to) {
475
505
  function parsePushArgs(argv, backend) {
476
506
  const label = `push-${backend.store}`;
477
507
  const spec = { "--to": "value", "--prefix": "value", "--dry-run": "flag", "--cleanup": "flag" };
478
- // Azure は認証 secret を受ける(値は argv に出さず env az に渡す。buildPushEnv 参照)。
508
+ // 無人認証フラグ(#92)。secret 値は argv に出さず env でクラウド CLI に渡す(buildPushEnv 参照)。
479
509
  if (backend.store === "azure") { spec["--sas"] = "value"; spec["--connection-string"] = "value"; }
510
+ if (backend.store === "s3") {
511
+ spec["--access-key-id"] = "value"; spec["--secret-access-key"] = "value";
512
+ spec["--profile"] = "value"; spec["--region"] = "value";
513
+ }
514
+ if (backend.store === "gcs") { spec["--gcp-credentials"] = "value"; }
480
515
  const o = parseArgv(argv, spec, label);
481
516
  if (o._.length !== 1) throw new Error(`${label} requires exactly one <archive-dir> argument.`);
482
517
  // local は scheme 付き URL ではなくファイルパス(フォルダ/ネットワーク共有)。scheme 検査を外し、
@@ -499,6 +534,10 @@ function parsePushArgs(argv, backend) {
499
534
  out.sas = o.sas || process.env.AZURE_STORAGE_SAS_TOKEN || "";
500
535
  out.connectionString = o["connection-string"] || process.env.AZURE_STORAGE_CONNECTION_STRING || "";
501
536
  }
537
+ // s3/gcs 無人認証(#92)。フラグ指定時のみ拾う(未指定なら ambient/host 注入の env が
538
+ // buildPushEnv の {...process.env} で素通り=envVar 宣言と整合)。
539
+ if (backend.store === "s3") Object.assign(out, s3CredsFromArgs(o, label));
540
+ if (backend.store === "gcs") out.gcpCredentials = o["gcp-credentials"] || "";
502
541
  return out;
503
542
  }
504
543
 
@@ -666,6 +705,26 @@ function joinUri(prefix, child) {
666
705
  }
667
706
 
668
707
  // secret(SAS / 接続文字列)は argv に載せず az が読む環境変数で渡す(AGENTS.md / 本番化方針)。
708
+ /**
709
+ * s3 無人認証フラグを検証して取り出す(#92)。IAM キーは**対**でのみ意味を持つ
710
+ * (AWS は片方だけだと "Partial credentials" で失敗し、明示キーは profile/ambient より
711
+ * 優先されるため)。キーと --profile の併用も aws では profile が黙殺されるので拒否する
712
+ * (usage は排他表記)。フラグ無指定時は ambient/host 注入の env が素通り。
713
+ */
714
+ function s3CredsFromArgs(o, label) {
715
+ const accessKeyId = o["access-key-id"] || "";
716
+ const secretAccessKey = o["secret-access-key"] || "";
717
+ const profile = o.profile || "";
718
+ const region = o.region || "";
719
+ if (Boolean(accessKeyId) !== Boolean(secretAccessKey)) {
720
+ throw new Error(`${label}: --access-key-id and --secret-access-key must be provided together.`);
721
+ }
722
+ if ((accessKeyId || secretAccessKey) && profile) {
723
+ throw new Error(`${label}: use either IAM keys (--access-key-id/--secret-access-key) or --profile, not both.`);
724
+ }
725
+ return { accessKeyId, secretAccessKey, profile, region };
726
+ }
727
+
669
728
  function buildPushEnv(options) {
670
729
  const env = { ...process.env };
671
730
  if (options.connectionString) env.AZURE_STORAGE_CONNECTION_STRING = options.connectionString;
@@ -674,6 +733,21 @@ function buildPushEnv(options) {
674
733
  // RBAC-only / listKeys 無効アカウントで失敗するため・Codex #76 P2)。s3/gcs は options.azure
675
734
  // 未設定なので素通り。
676
735
  else if (options.azure) env.AZURE_STORAGE_AUTH_MODE = "login";
736
+ // s3 無人認証(#92): フラグ指定時に env を上書き。未指定なら ambient/host 注入が素通り。
737
+ if (options.accessKeyId) env.AWS_ACCESS_KEY_ID = options.accessKeyId;
738
+ if (options.secretAccessKey) env.AWS_SECRET_ACCESS_KEY = options.secretAccessKey;
739
+ if (options.profile) env.AWS_PROFILE = options.profile;
740
+ // AWS_REGION(CLI v2)と AWS_DEFAULT_REGION(v1/botocore 系)の双方を立てる。
741
+ if (options.region) { env.AWS_REGION = options.region; env.AWS_DEFAULT_REGION = options.region; }
742
+ // gcs 無人認証(#92): SA 鍵パスを env で渡す(対話 login 不要)。
743
+ // GOOGLE_APPLICATION_CREDENTIALS は ADC/クライアントライブラリ用で、**gcloud CLI 自体は
744
+ // 読まない**(gcloud は CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE を読む・Codex #93 P1)。
745
+ // 両方を立てて gcloud storage と client-lib の双方をカバーする。フラグが無くても
746
+ // ambient/host 注入の GOOGLE_APPLICATION_CREDENTIALS があれば gcloud 用に複写する。
747
+ if (options.gcpCredentials) env.GOOGLE_APPLICATION_CREDENTIALS = options.gcpCredentials;
748
+ if (env.GOOGLE_APPLICATION_CREDENTIALS && !env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE) {
749
+ env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE = env.GOOGLE_APPLICATION_CREDENTIALS;
750
+ }
677
751
  return env;
678
752
  }
679
753
 
@@ -817,12 +891,17 @@ async function pushArchiveBundle(options, backend) {
817
891
  // 宛先サブパス。--prefix 指定時はテンプレ解決後の安全な相対パスが「実行ごとフォルダ」を置き換える。
818
892
  // 未指定なら従来どおり batchDir(UUID)。destSub は宛先専用で、ローカル dir 名(batchDir)とは分離する。
819
893
  const destSub = options.prefix ? resolvePrefixTemplate(options.prefix, { batchDir }) : batchDir;
894
+ // --to のテンプレ({flow} 等)を展開し、着地も dest も展開後の値で扱う(issue #89)。
895
+ const expandedTo = expandToTemplate(options.to);
896
+ // バッチ非依存の安定 store location(catalog 掲載用)。実行フォルダ destSub は含めない。
897
+ const dest = canonicalBundleDest(backend.store, expandedTo);
898
+ const storeKind = `${backend.store}-bundle`;
820
899
  const env = buildPushEnv(options);
821
900
 
822
901
  if (options.dryRun) {
823
902
  // local は OS パス結合(joinDest)、cloud は scheme ベースの joinUri。
824
- const previewDest = backend.joinDest ? backend.joinDest(options.to, destSub) : joinUri(options.to, destSub);
825
- emitReceipt("write", jobId, count, 0, false, { archivePath, [backend.prefixKey]: previewDest, dryRun: true });
903
+ const previewDest = backend.joinDest ? backend.joinDest(expandedTo, destSub) : joinUri(expandedTo, destSub);
904
+ emitReceipt("write", jobId, count, 0, false, { archivePath, [backend.prefixKey]: previewDest, dest, storeKind, dryRun: true });
826
905
  return;
827
906
  }
828
907
 
@@ -832,10 +911,10 @@ async function pushArchiveBundle(options, backend) {
832
911
  await fsp.rm(receiptPath, { force: true });
833
912
  // Upload the bundle first; only after the recursive copy succeeds do we
834
913
  // write the push receipt and upload it as a final, separate object.
835
- const destination = await backend.upload(archivePath, options.to, destSub, env);
836
- emitReceipt("write", jobId, count, 0, false, { archivePath, [backend.prefixKey]: destination });
914
+ const destination = await backend.upload(archivePath, expandedTo, destSub, env);
915
+ emitReceipt("write", jobId, count, 0, false, { archivePath, [backend.prefixKey]: destination, dest, storeKind });
837
916
 
838
- await writeJsonFile(receiptPath, { jobId, count, archivePath, [backend.prefixKey]: destination, pushedAt: new Date().toISOString() });
917
+ await writeJsonFile(receiptPath, { jobId, count, archivePath, [backend.prefixKey]: destination, dest, storeKind, pushedAt: new Date().toISOString() });
839
918
  await backend.uploadReceipt(receiptPath, destination, env);
840
919
 
841
920
  // ── verify phase: read the receipt object back from the destination ──
@@ -845,7 +924,7 @@ async function pushArchiveBundle(options, backend) {
845
924
  // (PLUGINS.md: 不可逆処理は exit 0 ではなく verified receipt を条件にする)。
846
925
  // 削除失敗は配送の失敗ではないので exit 0 のまま、ただし receipt の
847
926
  // cleanedUp:false と stderr で必ず報告する (沈黙させない)。
848
- const extras = { archivePath, [backend.prefixKey]: destination };
927
+ const extras = { archivePath, [backend.prefixKey]: destination, dest, storeKind };
849
928
  if (options.cleanup) {
850
929
  if (verified) {
851
930
  try {
@@ -871,7 +950,13 @@ async function pushArchiveBundle(options, backend) {
871
950
  function parsePullArgs(argv, backend) {
872
951
  const label = `pull-${backend.store}`;
873
952
  const spec = { "--from": "value", "--out": "value" };
953
+ // 無人認証(#92・push と対称): pull も対話ログイン無しで取り戻せる。
874
954
  if (backend.store === "azure") { spec["--sas"] = "value"; spec["--connection-string"] = "value"; }
955
+ if (backend.store === "s3") {
956
+ spec["--access-key-id"] = "value"; spec["--secret-access-key"] = "value";
957
+ spec["--profile"] = "value"; spec["--region"] = "value";
958
+ }
959
+ if (backend.store === "gcs") { spec["--gcp-credentials"] = "value"; }
875
960
  const o = parseArgv(argv, spec, label);
876
961
  if (!o.from) throw new Error(`${label} requires --from ${backend.scheme || "<source>"}…`);
877
962
  if (!o.out) throw new Error(`${label} requires --out <local-dir>.`);
@@ -890,6 +975,8 @@ function parsePullArgs(argv, backend) {
890
975
  out.sas = o.sas || process.env.AZURE_STORAGE_SAS_TOKEN || "";
891
976
  out.connectionString = o["connection-string"] || process.env.AZURE_STORAGE_CONNECTION_STRING || "";
892
977
  }
978
+ if (backend.store === "s3") Object.assign(out, s3CredsFromArgs(o, label));
979
+ if (backend.store === "gcs") out.gcpCredentials = o["gcp-credentials"] || "";
893
980
  return out;
894
981
  }
895
982
 
@@ -1512,3 +1599,56 @@ function resolvePrefixTemplate(template, { batchDir }) {
1512
1599
  function joinS3Uri(prefix, child) {
1513
1600
  return `${prefix.replace(/\/+$/, "")}/${child.replace(/^\/+/, "")}`;
1514
1601
  }
1602
+
1603
+ /**
1604
+ * --to トークンを URI パスセグメント安全な**単一トークン**へ無害化する(issue #89)。
1605
+ * `sanitizeUnicodeFileName` が `/\` 等を `_` に潰す(パス深さ注入防止)のに加え、URI 予約/
1606
+ * 問題文字(`#`・`?`・`@`・`%`・空白)も `_` にする — これで dest が round-trippable かつ
1607
+ * 全ストアで安全(azure の `[?#]` 拒否や `#` フラグメント切れを防ぐ)。Windows 予約名も無害化。
1608
+ */
1609
+ function sanitizeToToken(value) {
1610
+ let s = sanitizeUnicodeFileName(String(value).replace(/[\x00-\x1f\x7f]/g, ""));
1611
+ s = s.replace(/[#?@%\s]/g, "_");
1612
+ return WIN_RESERVED.test(s) ? `_${s}` : s;
1613
+ }
1614
+
1615
+ /**
1616
+ * --to のテンプレートを展開する(issue #89・§9.4a)。dest は**バッチ非依存**でなければ
1617
+ * ならないので、--to で許すのは**安定キー `{flow}` のみ**。run-varying トークン
1618
+ * ({batchId}/{datetime}/{date})は --prefix(実行フォルダ)専用で、--to にあれば拒否する。
1619
+ * {flow} は host が env(IREPO_FLOW_NAME) で渡す。scheme/bucket/区切りは保持。
1620
+ */
1621
+ function expandToTemplate(to) {
1622
+ const raw = String(to).replace(/[\x00-\x1f\x7f]/g, "");
1623
+ if (/\{(batchId|datetime|date)\}/.test(raw)) {
1624
+ throw new Error(
1625
+ "--to must not contain run-varying tokens ({batchId}/{datetime}/{date}) — dest must be batch-independent; " +
1626
+ "put those in --prefix. --to may use {flow} only.",
1627
+ );
1628
+ }
1629
+ const flow = sanitizeToToken(process.env.IREPO_FLOW_NAME || "");
1630
+ const expanded = raw.replace(/\{flow\}/g, flow);
1631
+ // 空 {flow} 等で生じる連続スラッシュを畳む(scheme の :// は保持)。
1632
+ return expanded.replace(/(?<!:)\/{2,}/g, "/");
1633
+ }
1634
+
1635
+ /**
1636
+ * 展開後 --to から、バッチに依らず安定した canonical な store location(dest)を作る(issue #89)。
1637
+ * DC(gemba-os) catalog.rs の canonical_dest と同形(単一コロン): s3:bucket/prefix・gcs:bucket/prefix・
1638
+ * azure:account/container[/prefix]・dir:absPath。実行フォルダ(--prefix/destSub)は dest に含めない。
1639
+ * secret(userinfo) は含めない(azure --to は query/SAS を parseAzureTo で既に拒否)。
1640
+ */
1641
+ function canonicalBundleDest(store, expandedTo) {
1642
+ if (store === "s3" || store === "gcs") {
1643
+ const scheme = store === "s3" ? "s3://" : "gs://";
1644
+ const rest = String(expandedTo).slice(scheme.length).replace(/\/+$/, ""); // bucket[/prefix...]
1645
+ return `${store === "s3" ? "s3" : "gcs"}:${rest}`;
1646
+ }
1647
+ if (store === "azure") {
1648
+ const { account, container, prefix } = parseAzureTo(expandedTo);
1649
+ return prefix ? `azure:${account}/${container}/${prefix}` : `azure:${account}/${container}`;
1650
+ }
1651
+ // local: 絶対パスへ正規化して dir: スキーム(dir-bundle と同表現)。
1652
+ const abs = resolve(normalizeLocalDest(expandedTo));
1653
+ return `dir:${abs}`;
1654
+ }
@@ -667,6 +667,18 @@ GUI 右側に claude / codex CLI を子プロセスで起動するパネル構
667
667
  - URI は round-trippable(§4.4.1)= objectKey のパスセグメントは予約文字(`#`・空白・`@` 等)を **percent-encode** してから連結する。
668
668
  - secret(userinfo 等)は URI に載せない(§6.2 マスク対象)。**enrich 経路(レコードへの注入)も必ず §6.2 マスクを通す**(receipt だけでなく注入値も対象)。
669
669
 
670
+ ### 9.4a per-sink の安定 `dest` / `storeKind`(catalog 掲載用・#89)
671
+
672
+ `canonicalLandedUri` が**レコード単位**の着地 URI なのに対し、`deliver-run.sinks[].dest` は**その配信先が読み返し対象として指す、バッチに依らず安定した store location**(sink 単位)。DC はこれを dataset の identity・`store.location` に使う。
673
+
674
+ - **算出はプラグイン(bundle backend)が責任を持つ**(store 固有の `--to`/`--prefix` 展開・URL 解析を host で二重化しない=脱料理)。push receipt に **canonical `dest` + `storeKind`** を焼き、`deliver` はそれを sink へコピーするだけ。
675
+ - **`dest` = canonical(展開後 `--to`)**。**`--to` で展開できる安定キーは `{flow}` のみ**(host が `IREPO_FLOW_NAME` で渡す)。**run-varying トークン(`{batchId}`/`{datetime}`/`{date}`)を `--to` に置くのは拒否**(dest がバッチ非依存でなくなる)=それらは `--prefix`(run-folder)専用。**実行フォルダ(`--prefix`/destSub)は dest に含めない**。
676
+ - トークン値は **URI パスセグメント安全な単一トークン**へ無害化(`/\` と `#`・`?`・`@`・`%`・空白 → `_`)=パス深さ注入・`#` フラグメント切れ・azure の `[?#]` 拒否を防ぎ、raw のまま round-trip できる(DC は受信 dest を verbatim 採用するので一致は構成的に保たれる)。空 `{flow}` の連続スラッシュは畳む。
677
+ - **canonical 形(DC `canonical_dest` と同形・単一コロン)**: `s3:<bucket>/<prefix>` / `gcs:<bucket>/<prefix>` / `azure:<account>/<container>[/<prefix>]` / `dir:<absPath>`(prefix 空なら `s3:<bucket>` 等)。secret(userinfo) 非掲載。
678
+ - **`storeKind`**: `s3-bundle` / `gcs-bundle` / `azure-bundle` / `local-bundle`。
679
+ - **delivered(verify 成功)した sink のみ `dest`/`storeKind` を出す**(失敗 sink が未着地の宛先を identity として広告すると DC が phantom dataset を作る)。
680
+ - stream sink(es/sqlite/mongo)は接続値が具体なので `dest`/`storeKind` は任意(現状 bundle のみ populated)。
681
+
670
682
  ### 9.5 per-sink `exported` 次元と再実行(skip-delivered)
671
683
 
672
684
  - sink の識別は **`id`**(プラグイン名ではない)。同一プラグインで 2 宛先(例: ES の 2 インデックス)を別 sink として許容する。
@@ -703,8 +715,8 @@ L3 カタログの単一書き手は GEMBA OS(§4.2)。`deliver` は **catal
703
715
  "sinks": [
704
716
  { "id": "es-main", "plugin": "elastic", "kind": "stream", "storeKind": "elasticsearch", "status": "success", "delivered": true,
705
717
  "dest": "elasticsearch:irepo-reports@https://es.local", "canonicalLandedUri": null, "receipts": [ /* write+verify・masked */ ] },
706
- { "id": "s3-archive", "plugin": "archive", "kind": "bundle", "storeKind": "s3-bundle", "status": "skipped", "delivered": true,
707
- "skippedReason": "already-delivered", "receipts": [] },
718
+ { "id": "s3-archive", "plugin": "archive", "kind": "bundle", "storeKind": "s3-bundle", "status": "success", "delivered": true,
719
+ "dest": "s3:my-bucket/reports/設備点検", "receipts": [ /* write+verify・masked */ ] },
708
720
  { "id": "mongo-x", "plugin": "mongo", "kind": "stream", "storeKind": "mongodb", "status": "failed", "delivered": false,
709
721
  "failure": "verify failed: count mismatch", "receipts": [ /* write のみ */ ] } ],
710
722
  "deliveredSinkIds": ["es-main","s3-archive"], "retrySinkIds": ["mongo-x"],
@@ -714,6 +726,7 @@ L3 カタログの単一書き手は GEMBA OS(§4.2)。`deliver` は **catal
714
726
  - **`batchId` / `finishedAtIso` / `watermarkAdvanced` / `deliverApi` は必須**(DC が `runs/<batchId>.json` 命名・前進判定に使う=省略を schema が拒否する。`catalog.schema.json` の `deliverRun` で強制)。
715
727
  - **per-sink `delivered`** = §9.2 の sink「成功」定義(今回 `verified:true && failedCount==0`、または `skipped`=前回配信済みを継承)。**`delivered:true` かつ非 `skipped` の sink は `receipts[]` に該当 phase の receipt(`verified` 等)を必ず持つ**(裏付けなしに `delivered:true` を主張させない=§0.1 原則1)。`skipped`(前回 verify 済みを継承)と、**未 delivered(失敗・あるいは locator source 未着地で実行を held した sink=receipt 皆無)** は `receipts:[]` を許す。`catalog.schema.json` の `deliverRun` も同条件で強制。
716
728
  - **run `delivered`** = **全 sink が delivered(fresh または skip)** かつ `!dryRun`。`status`: 全 sink delivered=`success`(全 skip も含む=前進)/一部 delivered+一部失敗=`partial`(停止)/archive create 失敗・配信ゼロ=`failed`(停止)。`watermarkAdvanced = (status=="success" && !dryRun)`。
729
+ - **bundle sink の `dest`/`storeKind`**(§9.4a・#89): プラグインが push receipt に焼いた canonical な store location(展開後 `--to`・実行フォルダ除外)と storeKind を、`deliver` が sink へコピーする。DC はこれを dataset identity・`store.location` に使い、テンプレート宛先 bundle も `datasets/` に掲載できる。
717
730
  - `pluginReceipts` は全 receipt 行を **§6.2 のマスクを通してから**載せる(elastic dest の userinfo 混入が実在・§8.1.1)。
718
731
  - 予約 recordType に `deliver-run` を追加(`catalog.schema.json`)。fail-open(§4.7): 未知 store の sink は `storeKind:"unknown"` として run にのみ現れ、datasets は作らない(`storeKind` は sink オブジェクトの宣言フィールド)。
719
732
  - exit code: `success`=0、`partial`/`failed`=非 0。ただし **JSON は常に stdout へ出す**(DC が結果に依らず run を永続化できる)。