diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c48a1a5..8d77895 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,8 @@ updates: interval: "weekly" day: "monday" time: "09:00" + cooldown: + default-days: 7 target-branch: "main" open-pull-requests-limit: 10 commit-message: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..5a6b735 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,20 @@ +name: dependency-review + +on: + pull_request: + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + with: + fail-on-severity: moderate + fail-on-scopes: runtime,development,unknown diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md index 40960e5..04273e2 100644 --- a/CHANGELOG.ja.md +++ b/CHANGELOG.ja.md @@ -7,6 +7,22 @@ ## [Unreleased] +## [0.7.4] - 2026-05-17 + +このリリースでは、direct content write の dry-run を本実行の予測として信頼しやすくすることを主眼にした。 +従来は `content create/update --dry-run` が `.ok == true` を返しても、validation・select 正規化・API error details の扱いが +本実行と揃っていないため、後続の本実行で 400 になるケースがあった。 + +### 変更 +- `content create/update --dry-run` で、取得できる場合は API schema による payload 検証を行うようにした。 +- direct write の dry-run で、ユーザーが渡した元の `payload` と、本実行時に microCMS へ送る `normalizedPayload` の両方を返すようにした。 +- schema metadata に string 型ヒントが含まれる single select でも、単一要素の配列 payload を受け付けるようにした。 +- JSON mode の API error で、取得できる場合は microCMS の response body を `--verbose` なしでも `error.details` に含めるようにした。 +- direct write dry-run の契約と JSON API error details の挙動を README / README.ja に追記した。 + +### テスト +- dry-run 成功と本実行がズレるフィードバックケースに対する回帰テストとして、direct content dry-run validation、dry-run の normalized payload 出力、string 型ヒント付き single select 配列、JSON API error details を追加した。 + ## [0.7.3] - 2026-03-13 ### 変更 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca86f6..c791c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.4] - 2026-05-17 + +This release makes direct content write dry-runs a better predictor of the actual write request. +Previously, `content create/update --dry-run` could return `.ok == true` while execution later failed +because validation, select normalization, and API error details were not aligned across the flow. + +### Changed +- `content create/update --dry-run` now performs schema-based payload validation when schema metadata is available. +- Direct write dry-runs now return both the user-provided `payload` and the `normalizedPayload` that execution would send to microCMS. +- Single select validation now accepts single-item array payloads even when schema metadata also describes the field as a string. +- JSON API errors now include the microCMS response body in `error.details` when available, without requiring `--verbose`. +- Updated README / README.ja to document the direct write dry-run contract and JSON API error details. + +### Tests +- Added regression coverage for the feedback case where dry-run success could diverge from write execution: direct content dry-run validation, normalized dry-run payload output, single select arrays with string type hints, and JSON API error details. + ## [0.7.3] - 2026-03-13 ### Changed diff --git a/README.ja.md b/README.ja.md index 0f34b9f..9ba96db 100644 --- a/README.ja.md +++ b/README.ja.md @@ -144,6 +144,7 @@ microcms validate --file payload.json --json - managed-json v1 は list API のみ対応です。object API、media 同期、hooks/revalidate、自動マージは対象外です。 - `content push` はデフォルトで verify のみ行い、remote 書き込みは `--execute` 指定時だけ行います。 - managed-json の delete は明示 tombstone 方式です。record file を消しただけでは remote delete されません。 +- `content create/update --dry-run` は取得できる場合に API schema で検証し、元の `payload` と書き込み時の `normalizedPayload` を出力します。 - `content bulk --dry-run` は API 呼び出しなしで操作定義ファイルの構造のみ検証します。 - スキーマに基づく payload 検証が必要な場合は `--validate-payload`(または `--strict-warnings`)を指定してください。 - single select フィールドは CLI payload では `"value"` と `["value"]` の両方を受け付けます。managed-json は string で保存し、書き込み実行時に API 互換の配列 shape へ正規化します。 @@ -266,6 +267,7 @@ Error: `error.details` は `--verbose` 指定時に含まれます。 ただし、payload 検証失敗(`validate`、payload 検証付きの `content import` / `content bulk`)では、 原因特定のため `error.details` を常時返します。 +JSON mode の microCMS API 失敗では、取得できる場合に API response body を `error.details` に含めます。 ## 終了コード diff --git a/README.md b/README.md index 46380d0..125beee 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ microcms validate --file payload.json --json - managed-json v1 supports list APIs only. Object APIs, media sync, hooks/revalidate, and auto-merge are out of scope. - `content push` is verify-first by default and only performs remote writes when `--execute` is set. - delete is explicit-only in managed-json workflows. Removing a record file does not delete remote content; use `deletions/.json` tombstones. +- `content create/update --dry-run` validates against the API schema when it can be fetched and includes both the original `payload` and the write-time `normalizedPayload`. - `content bulk --dry-run` checks operation-file structure without API calls. - Add `--validate-payload` (or `--strict-warnings`) when you want schema-based payload checks. - Single select fields are accepted as either `"value"` or `["value"]` in CLI payloads. `managed-json` stores single select as a string, and write execution normalizes it to the array shape expected by the API. @@ -266,6 +267,7 @@ Error: `error.details` is included when `--verbose` is enabled. For payload validation failures (`validate`, `content import`/`content bulk` with payload checks), `error.details` is always included to make CI diagnostics easier. +For microCMS API failures in JSON mode, `error.details` includes the API response body when available. ## Exit codes diff --git a/package-lock.json b/package-lock.json index cd9a0c5..77a6234 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mrmtsu/mcms-cli", - "version": "0.7.3", + "version": "0.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mrmtsu/mcms-cli", - "version": "0.7.3", + "version": "0.7.4", "license": "MIT", "dependencies": { "@mrmtsu/microcms-schema-adapter": "^0.1.0", diff --git a/package.json b/package.json index 828c8ce..dd39c70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mrmtsu/mcms-cli", - "version": "0.7.3", + "version": "0.7.4", "description": "Unofficial microCMS CLI for Content API and Management API automation (AI/CI friendly)", "license": "MIT", "homepage": "https://github.com/mrmtsu/mcms-cli#readme", diff --git a/src/commands/content.ts b/src/commands/content.ts index 37021bf..35f8ad7 100644 --- a/src/commands/content.ts +++ b/src/commands/content.ts @@ -789,6 +789,32 @@ export function registerContentCommands(program: Command): void { const payload = assertObjectPayload(await readJsonFile(options.file)); if (options.dryRun) { + const requestState = { requestId: null as string | null }; + const schema = await loadEndpointSchema( + ctx, + endpoint, + new Map(), + requestState, + ); + const validation = validatePayload(payload, schema); + if (!validation.valid) { + const summary = summarizeValidationFailure(validation); + throw new CliError({ + code: "INVALID_INPUT", + message: summary + ? `Content create dry-run validation failed: ${summary}` + : "Content create dry-run validation failed", + exitCode: EXIT_CODE.INVALID_INPUT, + detailsVisibility: "always", + details: { + endpoint, + file: options.file, + ...validation, + }, + }); + } + + const normalizedPayload = normalizePayloadForWrite(payload, schema); printSuccess( ctx, withOperationConfirmation("content.create", { @@ -796,7 +822,10 @@ export function registerContentCommands(program: Command): void { operation: "content.create", endpoint, payload, + normalizedPayload, + validation, }), + requestState.requestId, ); return; } @@ -824,6 +853,33 @@ export function registerContentCommands(program: Command): void { const payload = assertObjectPayload(await readJsonFile(options.file)); if (options.dryRun) { + const requestState = { requestId: null as string | null }; + const schema = await loadEndpointSchema( + ctx, + endpoint, + new Map(), + requestState, + ); + const validation = validatePayload(payload, schema); + if (!validation.valid) { + const summary = summarizeValidationFailure(validation); + throw new CliError({ + code: "INVALID_INPUT", + message: summary + ? `Content update dry-run validation failed: ${summary}` + : "Content update dry-run validation failed", + exitCode: EXIT_CODE.INVALID_INPUT, + detailsVisibility: "always", + details: { + endpoint, + id, + file: options.file, + ...validation, + }, + }); + } + + const normalizedPayload = normalizePayloadForWrite(payload, schema); printSuccess( ctx, withOperationConfirmation("content.update", { @@ -832,7 +888,10 @@ export function registerContentCommands(program: Command): void { endpoint, id, payload, + normalizedPayload, + validation, }), + requestState.requestId, ); return; } @@ -2812,6 +2871,20 @@ function summarizeImportValidationFailure( return `item #${first.index}${idLabel}: ${reason}`; } +function summarizeValidationFailure( + result: { errors: string[]; warnings: string[] }, + limit = 3, +): string { + const reasons = result.errors.length > 0 ? result.errors : result.warnings; + if (reasons.length === 0) { + return ""; + } + + const head = reasons.slice(0, limit).join("; "); + const rest = reasons.length - Math.min(limit, reasons.length); + return rest > 0 ? `${head} (+${rest} more)` : head; +} + function summarizeBulkValidationFailure( invalidItems: BulkValidationCheck[], includeWarnings: boolean, diff --git a/src/core/output.ts b/src/core/output.ts index d2771d2..b60db33 100644 --- a/src/core/output.ts +++ b/src/core/output.ts @@ -68,7 +68,7 @@ export function printError( ): void { const payload: ErrorEnvelope = { ok: false, - error: error.toJson({ includeDetails: ctx.verbose }), + error: error.toJson({ includeDetails: shouldIncludeErrorDetails(ctx, error) }), meta: { requestId, version: OUTPUT_VERSION, @@ -86,6 +86,10 @@ export function printError( } } +function shouldIncludeErrorDetails(ctx: RuntimeContext, error: CliError): boolean { + return ctx.verbose || (ctx.json && error.code === "API_ERROR"); +} + function renderPlain(data: unknown, selectFields?: string[]): string { if (typeof data === "string") { return data; diff --git a/src/validation/payload.ts b/src/validation/payload.ts index a7d85d8..fe5f19c 100644 --- a/src/validation/payload.ts +++ b/src/validation/payload.ts @@ -147,7 +147,8 @@ export function validatePayload(payload: unknown, apiSchema?: unknown): Validati } } - const expectedType = inferExpectedType(field); + const expectedType = + kind === "select" && !isFieldMultiple(field) ? null : inferExpectedType(field); if (expectedType && !matchesExpectedType(value, expectedType)) { const actualType = describeValueType(value); const message = `Field type mismatch: ${key} expected ${expectedType} (actual ${actualType})`; diff --git a/tests/contract/content-crud.test.ts b/tests/contract/content-crud.test.ts index 6dcc067..025913a 100644 --- a/tests/contract/content-crud.test.ts +++ b/tests/contract/content-crud.test.ts @@ -171,4 +171,130 @@ describe("content CRUD contract", () => { }; expect(store.endpoints.tech_articles.article_1?.intent).toEqual(["guide"]); }); + + it("validates and shows normalized payloads during direct create dry-runs", () => { + const workDir = mkdtempSync(join(tmpdir(), "microcms-cli-contract-dry-run-select-")); + const createPath = join(workDir, "create.json"); + const mockStorePath = join(workDir, "mock-content-store.json"); + + writeFileSync(createPath, JSON.stringify({ intent: "guide" }, null, 2), "utf8"); + writeFileSync( + mockStorePath, + JSON.stringify( + { + nextId: 10, + endpoints: {}, + schemas: { + tech_articles: { + endpoint: "tech_articles", + apiType: "list", + apiFields: [ + { + fieldId: "intent", + kind: "select", + type: "string", + multipleSelect: false, + selectItems: ["comparison", "guide"], + }, + ], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const env = { + MICROCMS_SERVICE_DOMAIN: "mock", + MICROCMS_API_KEY: "mock-key", + MICROCMS_CONTENT_MOCK_FILE: mockStorePath, + }; + + const dryRunResult = runCli( + ["content", "create", "tech_articles", "--file", createPath, "--dry-run", "--json"], + env, + ); + expect(dryRunResult.code).toBe(0); + + const body = JSON.parse(dryRunResult.stdout); + expect(body.data.payload).toEqual({ intent: "guide" }); + expect(body.data.normalizedPayload).toEqual({ intent: ["guide"] }); + expect(body.data.validation.valid).toBe(true); + }); + + it("fails direct update dry-runs when schema validation fails", () => { + const workDir = mkdtempSync(join(tmpdir(), "microcms-cli-contract-dry-run-invalid-")); + const updatePath = join(workDir, "update.json"); + const mockStorePath = join(workDir, "mock-content-store.json"); + + writeFileSync(updatePath, JSON.stringify({ intent: "invalid" }, null, 2), "utf8"); + writeFileSync( + mockStorePath, + JSON.stringify( + { + nextId: 10, + endpoints: { + tech_articles: { + article_1: { + title: "Article 1", + intent: ["comparison"], + }, + }, + }, + schemas: { + tech_articles: { + endpoint: "tech_articles", + apiType: "list", + apiFields: [ + { + fieldId: "intent", + kind: "select", + multipleSelect: false, + selectItems: ["comparison", "guide"], + }, + ], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + const env = { + MICROCMS_SERVICE_DOMAIN: "mock", + MICROCMS_API_KEY: "mock-key", + MICROCMS_CONTENT_MOCK_FILE: mockStorePath, + }; + + const dryRunResult = runCli( + [ + "content", + "update", + "tech_articles", + "article_1", + "--file", + updatePath, + "--dry-run", + "--json", + ], + env, + ); + expect(dryRunResult.code).toBe(2); + + const body = JSON.parse(dryRunResult.stderr); + expect(body.error.code).toBe("INVALID_INPUT"); + expect(body.error.message).toContain("Content update dry-run validation failed"); + expect(body.error.details.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "FIELD_VALUE_OUT_OF_RANGE", + field: "intent", + }), + ]), + ); + }); }); diff --git a/tests/unit/error-mapping.test.ts b/tests/unit/error-mapping.test.ts index 648255f..87cefa6 100644 --- a/tests/unit/error-mapping.test.ts +++ b/tests/unit/error-mapping.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { fromHttpStatus } from "../../src/core/errors.js"; +import { printError } from "../../src/core/output.js"; describe("http status mapping", () => { it("maps 403 to permission exit code", () => { @@ -20,4 +21,45 @@ describe("http status mapping", () => { expect(error.exitCode).toBe(5); expect(error.retryable).toBe(true); }); + + it("includes API error details in JSON mode without verbose", () => { + const error = fromHttpStatus(400, "bad request", { + message: "invalid field", + status: 400, + }); + const originalWrite = process.stderr.write; + let stderr = ""; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += chunk.toString(); + return true; + }) as typeof process.stderr.write; + + try { + printError( + { + json: true, + verbose: false, + color: false, + timeoutMs: 10_000, + retry: 2, + retryMaxDelayMs: 3_000, + outputMode: "inspect", + profileSource: "none", + serviceDomainSource: "none", + apiKeySource: "none", + apiKeySourceDetail: "none", + }, + error, + ); + } finally { + process.stderr.write = originalWrite; + } + + const body = JSON.parse(stderr); + expect(body.error.code).toBe("API_ERROR"); + expect(body.error.details).toMatchObject({ + message: "invalid field", + status: 400, + }); + }); }); diff --git a/tests/unit/validation-v2.test.ts b/tests/unit/validation-v2.test.ts index 1eee2ec..b9d7ca8 100644 --- a/tests/unit/validation-v2.test.ts +++ b/tests/unit/validation-v2.test.ts @@ -73,6 +73,22 @@ describe("payload validation v2", () => { expect(invalid.errors.join("\n")).toContain("single-item array"); }); + it("does not reject single select arrays because of string type hints", () => { + const schema = { + apiFields: [ + { + fieldId: "domain", + kind: "select", + type: "string", + multipleSelect: false, + selectItems: [{ value: "coffee" }], + }, + ], + }; + + expect(validatePayload({ domain: ["coffee"] }, schema).valid).toBe(true); + }); + it("avoids type errors for unknown field kinds", () => { const schema = { apiFields: [