diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a4e9f..32d4e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added +- Native Sheets structure commands create/rename tabs, insert bounded rows/columns and format + at most 10,000 cells. Formatting masks only supplied leaf properties and preserves cell values. + Equivalent raw/generated batch requests check the same profile permission keys. + - Live Sheets commands inspect tabs, read ranges or formulas, update finite ranges, append rows and clear values. Writes require the explicit Drive write grant and reuse profile policy guards. Values default to literals; formula parsing is opt-in. Enable Sheets API for these commands. @@ -13,6 +17,9 @@ ### Changed +- Uses exact `@wirecat/cli-base` 0.40.1 with the existing compatible Core pin. Six Sheets naming + exceptions are removed; the command lint baseline does not grow. + - Uses `@wirecat/cli-core` 0.307.0. ## 0.15.1 — 2026-10-17 diff --git a/docs/commands.md b/docs/commands.md index c6b6e99..f309af0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2774,6 +2774,75 @@ drive api sheets-spreadsheets-values-update [options] read spreadsheet tabs and ranges, or change cells live in Google Sheets +### `drive sheets create` + +create an empty tab inside an existing spreadsheet + +This command can change Google data or send data to the configured model. + +```sh +drive sheets create +``` + +| Argument | | What it is | +|---|---|---| +| `spreadsheet` | required | spreadsheet ID or Google Sheets URL. | +| `title` | required | name of the new tab. | + +### `drive sheets rename` + +rename one tab while preserving its ID and other properties + +This command can change Google data or send data to the configured model. + +```sh +drive <profile> sheets rename <spreadsheet> <sheet-id> <title> +``` + +| Argument | | What it is | +|---|---|---| +| `spreadsheet` | required | spreadsheet ID or Google Sheets URL. | +| `sheet-id` | required | numeric tab ID from sheets info. | +| `title` | required | new tab name. | + +### `drive sheets insert` + +insert bounded rows or columns before a zero-based index + +This command can change Google data or send data to the configured model. + +```sh +drive <profile> sheets insert <spreadsheet> <sheet-id> <axis> <index> <count> +``` + +| Argument | | What it is | +|---|---|---| +| `spreadsheet` | required | spreadsheet ID or Google Sheets URL. | +| `sheet-id` | required | numeric tab ID from sheets info. | +| `axis` | required | rows or columns. | +| `index` | required | zero-based index to insert before. | +| `count` | required | number to insert, from 1 to 1000. | + +### `drive sheets format` + +change only supplied formatting fields in at most 10000 cells + +This command can change Google data or send data to the configured model. + +```sh +drive <profile> sheets format <spreadsheet> <sheet-id> <range> [options] +``` + +| Argument | | What it is | +|---|---|---| +| `spreadsheet` | required | spreadsheet ID or Google Sheets URL. | +| `sheet-id` | required | numeric tab ID from sheets info. | +| `range` | required | finite cells within that tab, such as A1:C4; no tab prefix. | + +| Option | What it does | +|---|---| +| `--body <json>` | supported CellFormat fields as JSON; values and formulas are refused. | + ### `drive sheets info` list spreadsheet properties and tabs without downloading grid data diff --git a/docs/dev/CLI_ALIGNMENT.md b/docs/dev/CLI_ALIGNMENT.md index 294a4b0..1cc0830 100644 --- a/docs/dev/CLI_ALIGNMENT.md +++ b/docs/dev/CLI_ALIGNMENT.md @@ -148,10 +148,16 @@ work and does not certify complete command-standard conformance. ## Native Sheets vocabulary -Sheets uses the canonical `info`, `show`, `update`, `add` and `clear` verbs. `add` appends rows; -`info` returns spreadsheet properties and tab metadata. Generated operations retain Google's names. -The pinned cli-base vocabulary predates this domain. Six dated exceptions register `sheets` and -its `--values` (value), `--input` (mode) and `--render` (output) roles until the shared vocabulary -adopts them ([central vocabulary](https://github.com/WireCatLabs/cli-base/pull/178)). They do not -introduce query filters or baseline entries. Remove these exceptions at -the next compatible shared-vocabulary adoption; the existing baseline remains unchanged. +Sheets uses `info`, `show`, `update`, `add`, `clear`, `create` and `rename` from the shared +vocabulary. `add` appends rows; `create` adds a tab within an existing spreadsheet. Generated +operations retain Google's names. Base 0.40.1 registers `sheets`, `--values` (value), `--input` +(mode) and `--render` (output), so those six dated exceptions are removed. Two dated verb +exceptions remain for `insert` and `format` until a published shared version includes +[their vocabulary registration](https://github.com/WireCatLabs/cli-base/pull/182). The +existing baseline files are unchanged. + +Friendly structure commands submit one `spreadsheets.batchUpdate` request. Rename masks only +`title`; format constructs finite GridRange bounds and leaf masks under `userEnteredFormat`. +Raw/generated batches check the corresponding friendly permission keys after body validation, +before preview/credentials/provider dispatch. Broad cell batches consult both format/update +keys when they can affect both. This retains the same guards across command paths. diff --git a/docs/index.md b/docs/index.md index 203545b..25a39ad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -82,7 +82,8 @@ Replace `FOLDER_ID` and the profile name. Continue sync while pending work remai Local indexing supports native Google Docs and Sheets, and uploaded Markdown/plain text, PDF with a text layer, Word `.docx`, Excel `.xlsx`, CSV and TSV. Sheets are imported for reading and search; live [Sheets commands](recipes.md#read-and-change-spreadsheet-cells) -read ranges, update values/formulas, append rows and clear selected cells. Scanned PDFs, Slides, audio and shortcuts +read ranges, update values/formulas, append rows and clear selected cells. +They also [organize tabs and format bounded ranges](recipes.md#organize-tabs-and-format-selected-cells). Scanned PDFs, Slides, audio and shortcuts have no indexed text. Images are searchable by name, or by what they show when you enable [picture search](search.md#search-images-by-what-they-show). Native Docs indexing includes body text, tables and footnotes across tabs; images, headers, footers, comments and suggested edits are not diff --git a/docs/recipes.md b/docs/recipes.md index 0db912d..2f0b9c4 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -19,6 +19,7 @@ file. [Import and storage](usage.md) explains what the saved copy contains. | Inspect comments or history | Yes | No automatic import | No | | Create or edit a Doc | Yes | Imported on the next sync | Yes | | Read or edit spreadsheet cells | Yes | Imported on the next sync | Only when explicitly changing cells | +| Organize spreadsheet tabs and formatting | Yes | Imported on the next sync | Yes | ## First searchable folder, without extra exports @@ -376,3 +377,55 @@ or 10,000-cell checks. Raw `api call` writes also require `--allow-writes`. Gene comma-separated; use a JSON array in raw `api call --params` when a tab name itself contains a comma. Some POST-based lookup methods require the broader Drive grant even though their effect is a read; inspect the method contract and Google authorization requirements before reconnecting. + +## Organize tabs and format selected cells + +Use tab IDs from `sheets info` to change a specific tab inside an existing spreadsheet. These +commands use the same explicit write connection as cell updates. They change Google data; +refresh the imported copy with `sync` when local search needs the updated source. + +```text prompt +Use drive CLI with profile personal. Inspect the tabs in my selected spreadsheet. Create a +Quarter 2 tab, rename the tab I identify, and make only its A1:C1 header bold. Show the returned +tab IDs and changed range. Preserve existing cell values and other formatting. +``` + +```sh +drive personal sheets info SHEET_ID --json +drive personal sheets create SHEET_ID 'Quarter 2' --json +drive personal sheets rename SHEET_ID 101 'Quarter 1' --json +drive personal sheets insert SHEET_ID 101 rows 1 3 --json +drive personal sheets insert SHEET_ID 101 columns 0 1 --json +drive personal sheets format SHEET_ID 101 A1:C1 --body '{"textFormat":{"bold":true}}' --json +drive personal sheets format SHEET_ID 101 B2:B10 --body '{"numberFormat":{"type":"NUMBER","pattern":"0.00"}}' --json +``` + +`create` adds an empty tab to the existing spreadsheet, rather than creating a new spreadsheet +file. Replace `101` with the numeric tab ID Google returned. The ID remains stable after rename; +rename changes only the title. Tab names must contain 1–100 characters; Google also checks name +validity and uniqueness. + +Insert takes `rows` or `columns`, a **zero-based index to insert before**, and a count of 1–1,000. +Index `0` means the first row/column; row index `1` inserts before the second row. Later entries +shift. Google validates the current grid bounds and spreadsheet limits; use the generated API +for appending grid dimensions at the end. The command requests formatting inheritance from the +dimension after the insertion position. + +Format uses an explicit tab ID and a finite range **without a tab prefix**, such as `A1:C4`. +At most 10,000 cells can be selected. Its JSON body supports text bold/italic/underline/ +strikethrough, font family/size, foreground/background colors, horizontal/vertical alignment, +wrap strategy and number format. Field names follow Google's `CellFormat` JSON. Unknown fields, +empty objects, cell values and formulas are refused. + +Only the formatting properties you provide are included in the request's field mask. Setting +`textFormat.bold` preserves italic text, number formatting and cell values; use `false` to remove +bold. Both color channels (`red`, `green`, `blue`, `alpha`) and theme-color styles are supported. +For other formatting fields or multiple operations in one request, use the generated +`api sheets-spreadsheets-batch-update` command. A generic batch has broader effects and none of +the friendly formatter's 10,000-cell/allowed-field limits. + +All paths retain profile policy, OAuth write, offline/read-only, dry-run and uncertain-write +handling. A profile denial such as `sheets.rename` also applies to an equivalent raw/generated +batch request. Inspect current cells before repeating an uncertain structure change; otherwise +you could create duplicate tabs or insert more rows. Separate commands can observe collaborator +changes between requests. diff --git a/lint/overrides.json b/lint/overrides.json index de96f15..256404d 100644 --- a/lint/overrides.json +++ b/lint/overrides.json @@ -7,44 +7,16 @@ "review_after": "2026-11-16" }, { - "rule": "unknown-resource", - "target": "sheets", - "reason": "Spreadsheet-specific resource; shared cli-base 0.40.0 predates the native Sheets commands. Register the noun centrally before removing this exception. Track in docs/dev/CLI_ALIGNMENT.md; no baseline entries added.", - "owner": "@WireCatLabs", - "review_after": "2026-11-17" - }, - { - "rule": "option-role", - "target": "sheets show --render", - "reason": "Output role: select Google formatted values, unformatted values or formulas, not a list filter. Track in docs/dev/CLI_ALIGNMENT.md; no baseline entries added.", - "owner": "@WireCatLabs", - "review_after": "2026-11-17" - }, - { - "rule": "option-role", - "target": "sheets update --values", - "reason": "Value role: bounded JSON cell rows for this explicit remote write. Track in docs/dev/CLI_ALIGNMENT.md; no baseline entries added.", - "owner": "@WireCatLabs", - "review_after": "2026-11-17" - }, - { - "rule": "option-role", - "target": "sheets add --values", - "reason": "Value role: bounded JSON cell rows appended to the selected table. Track in docs/dev/CLI_ALIGNMENT.md; no baseline entries added.", - "owner": "@WireCatLabs", - "review_after": "2026-11-17" - }, - { - "rule": "option-role", - "target": "sheets update --input", - "reason": "Mode role: literal RAW input by default; explicit USER_ENTERED formula/locale parsing. Track in docs/dev/CLI_ALIGNMENT.md; no baseline entries added.", + "rule": "unknown-verb", + "target": "sheets insert", + "reason": "Insert bounded source rows or columns before an explicit index. Shared vocabulary registration is tracked in docs/dev/CLI_ALIGNMENT.md; no baseline entries added.", "owner": "@WireCatLabs", "review_after": "2026-11-17" }, { - "rule": "option-role", - "target": "sheets add --input", - "reason": "Mode role: literal RAW input by default; explicit USER_ENTERED formula/locale parsing. Track in docs/dev/CLI_ALIGNMENT.md; no baseline entries added.", + "rule": "unknown-verb", + "target": "sheets format", + "reason": "Change only selected cell-format fields, not source values/formulas. Shared vocabulary registration is tracked in docs/dev/CLI_ALIGNMENT.md; no baseline entries added.", "owner": "@WireCatLabs", "review_after": "2026-11-17" } diff --git a/package.json b/package.json index ff8973b..18ff059 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@googleapis/driveactivity": "11.0.1", "@googleapis/sheets": "18.1.1", "@modelcontextprotocol/server": "2.1.0", - "@wirecat/cli-base": "0.40.0", + "@wirecat/cli-base": "0.40.1", "@wirecat/cli-core": "0.307.0", "commander": "15.0.0", "google-auth-library": "11.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c35818..63f5085 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,11 +31,11 @@ importers: specifier: 2.1.0 version: 2.1.0 '@wirecat/cli-base': - specifier: 0.40.0 - version: 0.40.0(commander@15.0.0)(typescript@7.0.2) + specifier: 0.40.1 + version: 0.40.1(commander@15.0.0)(typescript@7.0.2) '@wirecat/cli-core': specifier: 0.307.0 - version: 0.307.0(@wirecat/cli-base@0.40.0(commander@15.0.0)(typescript@7.0.2))(commander@15.0.0)(typescript@7.0.2)(zod@4.6.5) + version: 0.307.0(@wirecat/cli-base@0.40.1(commander@15.0.0)(typescript@7.0.2))(commander@15.0.0)(typescript@7.0.2)(zod@4.6.5) commander: specifier: 15.0.0 version: 15.0.0 @@ -1043,8 +1043,8 @@ packages: '@wasm-audio-decoders/opus-ml@0.0.3': resolution: {integrity: sha512-SEeTmk0eb3q5etJA/NHCRWW6BivTx9tqvNTmG0kg2ynuATEQRUZnwQE6Ibvl1BBvqIGHqI57+qyJMWmJBwb5XQ==} - '@wirecat/cli-base@0.40.0': - resolution: {integrity: sha512-jv+n/hyKzASfS9Sz8Q+pljE8qefsqn4H+jDS5/tsr2vIfEDvFXw+inME+mM2kzaSV70s+eSH8kyEUPmq7UGd5A==} + '@wirecat/cli-base@0.40.1': + resolution: {integrity: sha512-ClhbXBWTDc8JIcBJaFWyDMfGPYLFznESuj06k6CYwGyvCX5c53tgGfLdnpVaq/5vFJ3kK1uHswJSxa04ynpJcQ==} engines: {node: '>=22'} hasBin: true peerDependencies: @@ -3264,7 +3264,7 @@ snapshots: dependencies: '@wasm-audio-decoders/common': 9.0.7 - '@wirecat/cli-base@0.40.0(commander@15.0.0)(typescript@7.0.2)': + '@wirecat/cli-base@0.40.1(commander@15.0.0)(typescript@7.0.2)': dependencies: '@valibot/to-json-schema': 1.8.0(valibot@1.5.0(typescript@7.0.2)) cli-table3: 0.6.5 @@ -3283,7 +3283,7 @@ snapshots: '@wirecat/cli-core-sqlite@1.0.1': {} - '@wirecat/cli-core@0.307.0(@wirecat/cli-base@0.40.0(commander@15.0.0)(typescript@7.0.2))(commander@15.0.0)(typescript@7.0.2)(zod@4.6.5)': + '@wirecat/cli-core@0.307.0(@wirecat/cli-base@0.40.1(commander@15.0.0)(typescript@7.0.2))(commander@15.0.0)(typescript@7.0.2)(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.131.0(zod@4.6.5) '@bomb.sh/tab': 0.0.22(commander@15.0.0) @@ -3291,7 +3291,7 @@ snapshots: '@modelcontextprotocol/server': 2.1.0 '@resvg/resvg-js': 2.6.2 '@valibot/to-json-schema': 1.8.0(valibot@1.5.0(typescript@7.0.2)) - '@wirecat/cli-base': 0.40.0(commander@15.0.0)(typescript@7.0.2) + '@wirecat/cli-base': 0.40.1(commander@15.0.0)(typescript@7.0.2) '@wirecat/cli-core-onnx': 1.0.1 '@wirecat/cli-core-sqlite': 1.0.1 '@xmldom/xmldom': 0.9.12 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09e2811..96fa1fe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,7 +2,7 @@ allowBuilds: esbuild: true lefthook: true minimumReleaseAgeExclude: - - '@wirecat/cli-base@0.21.0 || 0.22.0 || 0.24.0 || 0.26.0 || 0.27.1 || 0.27.2 || 0.28.1 || 0.28.2 || 0.31.0 || 0.31.1 || 0.32.0 || 0.33.0 || 0.35.0 || 0.40.0' + - '@wirecat/cli-base@0.21.0 || 0.22.0 || 0.24.0 || 0.26.0 || 0.27.1 || 0.27.2 || 0.28.1 || 0.28.2 || 0.31.0 || 0.31.1 || 0.32.0 || 0.33.0 || 0.35.0 || 0.40.0 || 0.40.1' - '@wirecat/cli-meetings@0.2.0 || 0.2.1 || 0.2.3 || 0.2.7 || 0.3.0' - '@wirecat/cli-messaging@0.226.0 || 0.231.0 || 0.253.0 || 0.256.0 || 0.258.0' - '@wirecat/cli-core@0.262.0 || 0.264.0 || 0.265.0 || 0.268.0 || 0.272.0 || 0.273.0 || 0.275.0 || 0.277.0 || 0.278.0 || 0.281.0 || 0.283.0 || 0.285.0 || 0.287.0 || 0.288.0 || 0.289.0 || 0.292.0 || 0.294.0 || 0.295.0 || 0.299.0 || 0.301.0 || 0.307.0' diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 424cf93..f02f634 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -104,6 +104,22 @@ try { ) assert.equal(sheets.item.method, "sheets.spreadsheets.values.update") assert.equal(sheets.item.mutates, true) + const format = JSON.parse( + run([ + "synthetic", + "--dry-run", + "sheets", + "format", + "synthetic-sheet", + "0", + "A1", + "--body", + '{"textFormat":{"bold":true}}', + "--json", + ]), + ) + assert.equal(format.item.method, "sheets.spreadsheets.batchUpdate") + assert.equal(format.item.mutates, true) console.log( `Installed ${JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name}: offline help/version/registry/error checks passed (${temp})`, ) diff --git a/src/api-contract.ts b/src/api-contract.ts index 6357791..0487bdc 100644 --- a/src/api-contract.ts +++ b/src/api-contract.ts @@ -1,6 +1,6 @@ import { CliError } from "@wirecat/cli-base" import type { ManifestOperation } from "@wirecat/cli-base/codegen" -import { apiJson, checkApiBody, checkApiParameter } from "@wirecat/cli-core/cli" +import { apiJson, apiPlainJson, checkApiBody, checkApiParameter, parseApiJson } from "@wirecat/cli-core/cli" import { operations } from "./generated/manifest.js" import { schemas } from "./generated/schemas.js" @@ -45,3 +45,10 @@ export const validateRequest = (name: string, params: Record<string, unknown>, b if (params.alt === "media") throw new CliError("validation_error", "this API surface supports JSON; use document export for content") } + +export const jsonObject = (value: string): Record<string, unknown> => { + const parsed = apiPlainJson(parseApiJson(value)) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw new CliError("validation_error", "use a JSON object") + return parsed as Record<string, unknown> +} diff --git a/src/commands/remote.ts b/src/commands/remote.ts index 88ff182..a7d74bc 100644 --- a/src/commands/remote.ts +++ b/src/commands/remote.ts @@ -1,8 +1,6 @@ -import { CliError } from "@wirecat/cli-base" import { annotate } from "@wirecat/cli-base/commands" import type { QueryRegistry } from "@wirecat/cli-base/query" import { - apiPlainJson, type Body, checkApiBody, checkApiParameter, @@ -12,14 +10,13 @@ import { LOCAL_READ, listOf, NO_EFFECTS, - parseApiJson, queryTerms, readApiBody, termField, withQuery, } from "@wirecat/cli-core/cli" import type { Command } from "commander" -import { operationOf, operations, schemas } from "../api-contract.js" +import { jsonObject, operationOf, operations, schemas } from "../api-contract.js" import { fileId } from "../config.js" import { apiMethods, validateApiParams } from "../google-api.js" import { allOf } from "../output.js" @@ -33,12 +30,8 @@ const API_METHOD_QUERY = { fields: [termField("service", "enum", "service:docs", { values: ["drive", "docs", "driveactivity", "sheets"] })], } as const satisfies QueryRegistry -export const jsonObject = (value: string): Record<string, unknown> => { - const parsed = apiPlainJson(parseApiJson(value)) - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) - throw new CliError("validation_error", "use a JSON object") - return parsed as Record<string, unknown> -} +export { jsonObject } from "../api-contract.js" + /** One page of a Google list, which names its rows `key` and the next page `nextPageToken`. */ const pageOf = ( command: string, diff --git a/src/commands/sheets-structure.ts b/src/commands/sheets-structure.ts new file mode 100644 index 0000000..1a49637 --- /dev/null +++ b/src/commands/sheets-structure.ts @@ -0,0 +1,132 @@ +import { CliError } from "@wirecat/cli-base" +import { annotate } from "@wirecat/cli-base/commands" +import { itemOf } from "@wirecat/cli-core/cli" +import type { Command } from "commander" +import { jsonObject } from "../api-contract.js" +import { fileId } from "../config.js" +import { examples, GOOGLE_WRITE } from "../registry.js" +import { cellRectangle } from "../sheets-input.js" +import type { RemoteCommands } from "./remote.js" + +const integer = (name: string, raw: string, maximum: number, minimum = 0) => { + if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw)) || Number(raw) < minimum || Number(raw) > maximum) + throw new CliError("validation_error", `${name} must be an integer from ${minimum} to ${maximum}`) + return Number(raw) +} +const sheetId = (raw: string) => integer("sheet-id", raw, 2147483647) +const title = (raw: string) => { + if (!raw.trim() || raw.length > 100) throw new CliError("validation_error", "title must contain 1 to 100 characters") + return raw +} +const color = (prefix: string) => ["red", "green", "blue", "alpha"].map((key) => `${prefix}.${key}`) +const FORMAT_PATHS = new Set([ + ...color("backgroundColor"), + ...color("backgroundColorStyle.rgbColor"), + "backgroundColorStyle.themeColor", + "horizontalAlignment", + "verticalAlignment", + "wrapStrategy", + "numberFormat.type", + "numberFormat.pattern", + "textFormat.bold", + "textFormat.italic", + "textFormat.underline", + "textFormat.strikethrough", + "textFormat.fontFamily", + "textFormat.fontSize", + ...color("textFormat.foregroundColor"), + ...color("textFormat.foregroundColorStyle.rgbColor"), + "textFormat.foregroundColorStyle.themeColor", +]) +const formatFields = (body: Record<string, unknown>, prefix = ""): string[] => { + const paths: string[] = [] + if (!Object.keys(body).length) throw new CliError("validation_error", "--body must set at least one formatting field") + for (const [key, value] of Object.entries(body)) { + const path = prefix ? `${prefix}.${key}` : key + if (FORMAT_PATHS.has(path)) { + paths.push(`userEnteredFormat.${path}`) + } else if ( + [...FORMAT_PATHS].some((candidate) => candidate.startsWith(`${path}.`)) && + value && + typeof value === "object" && + !Array.isArray(value) + ) { + for (const field of formatFields(value as Record<string, unknown>, path)) paths.push(field) + } else throw new CliError("validation_error", "--body contains an unsupported formatting field or shape") + } + return paths +} +export const installSheetsStructureCommands = ( + group: Command, + { call, output }: Pick<RemoteCommands, "call" | "output">, +) => { + const batch = async (spreadsheet: string, request: Record<string, unknown>) => + output( + itemOf( + await call("sheets.spreadsheets.batchUpdate", { spreadsheetId: fileId(spreadsheet) }, { requests: [request] }), + ), + ) + examples( + annotate(group.command("create"), { effects: GOOGLE_WRITE }) + .description("create an empty tab inside an existing spreadsheet") + .argument("<spreadsheet>", "spreadsheet ID or Google Sheets URL") + .argument("<title>", "name of the new tab", title), + ['drive personal sheets create SHEET_ID "Quarter 2"'], + ).action(async (spreadsheet: string, name: string) => + batch(spreadsheet, { addSheet: { properties: { title: name } } }), + ) + examples( + annotate(group.command("rename"), { effects: GOOGLE_WRITE }) + .description("rename one tab while preserving its ID and other properties") + .argument("<spreadsheet>", "spreadsheet ID or Google Sheets URL") + .argument("<sheet-id>", "numeric tab ID from sheets info", sheetId) + .argument("<title>", "new tab name", title), + ['drive personal sheets rename SHEET_ID 0 "Quarter 2"'], + ).action(async (spreadsheet: string, id: number, name: string) => + batch(spreadsheet, { + updateSheetProperties: { properties: { sheetId: id, title: name }, fields: "title" }, + }), + ) + examples( + annotate(group.command("insert"), { effects: GOOGLE_WRITE }) + .description("insert bounded rows or columns before a zero-based index") + .argument("<spreadsheet>", "spreadsheet ID or Google Sheets URL") + .argument("<sheet-id>", "numeric tab ID from sheets info", sheetId) + .argument("<axis>", "rows or columns", (raw: string) => { + if (raw !== "rows" && raw !== "columns") throw new CliError("validation_error", "axis must be rows or columns") + return raw + }) + .argument("<index>", "zero-based index to insert before", (raw: string) => integer("index", raw, 9999999)) + .argument("<count>", "number to insert, from 1 to 1000", (raw: string) => integer("count", raw, 1000, 1)), + ["drive personal sheets insert SHEET_ID 0 rows 1 3"], + ).action(async (spreadsheet: string, id: number, axis: string, index: number, count: number) => { + if (index + count > (axis === "rows" ? 10000000 : 18278)) + throw new CliError("validation_error", "index and count exceed the dimension bound") + return batch(spreadsheet, { + insertDimension: { + range: { sheetId: id, dimension: axis.toUpperCase(), startIndex: index, endIndex: index + count }, + inheritFromBefore: false, + }, + }) + }) + examples( + annotate(group.command("format"), { effects: GOOGLE_WRITE }) + .description("change only supplied formatting fields in at most 10000 cells") + .argument("<spreadsheet>", "spreadsheet ID or Google Sheets URL") + .argument("<sheet-id>", "numeric tab ID from sheets info", sheetId) + .argument("<range>", "finite cells within that tab, such as A1:C4; no tab prefix"), + [`drive personal sheets format SHEET_ID 0 A1:C1 --body '{"textFormat":{"bold":true}}'`], + ) + .requiredOption("--body <json>", "supported CellFormat fields as JSON; values and formulas are refused", jsonObject) + .action(async (spreadsheet: string, id: number, range: string, args: { body: Record<string, unknown> }) => { + const { rows, columns, ...bounds } = cellRectangle(range) + if (rows * columns > 10000) throw new CliError("validation_error", "format range is limited to 10000 cells") + return batch(spreadsheet, { + repeatCell: { + range: { sheetId: id, ...bounds }, + cell: { userEnteredFormat: args.body }, + fields: formatFields(args.body).join(","), + }, + }) + }) +} diff --git a/src/commands/sheets.ts b/src/commands/sheets.ts index fd4fdd1..3a8e7ad 100644 --- a/src/commands/sheets.ts +++ b/src/commands/sheets.ts @@ -4,28 +4,10 @@ import { apiPlainJson, itemOf, parseApiJson } from "@wirecat/cli-core/cli" import { type Command, Option } from "commander" import { fileId } from "../config.js" import { examples, GOOGLE_READ, GOOGLE_WRITE } from "../registry.js" +import { namedRectangle } from "../sheets-input.js" import type { RemoteCommands } from "./remote.js" +import { installSheetsStructureCommands } from "./sheets-structure.js" -// Friendly writes require a concrete tab and finite cell rectangle. Named/unbounded ranges -// remain available through the generated API, where their broader semantics are explicit. -const rectangle = (range: string) => { - const match = /^(?:'[^']*(?:''[^']*)*'|[^'!]+)!([A-Za-z]{1,3})([1-9]\d*)(?::([A-Za-z]{1,3})([1-9]\d*))?$/.exec(range) - const column = (text: string) => [...text.toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - if (!match) throw new CliError("validation_error", "range must name a tab and finite cells, such as 'Budget!A1:C4'") - const firstColumn = column(match[1] as string) - const firstRow = Number(match[2]) - const lastColumn = column(match[3] ?? (match[1] as string)) - const lastRow = Number(match[4] ?? match[2]) - if ( - !Number.isSafeInteger(lastRow) || - lastRow > 10000000 || - lastColumn > 18278 || - lastRow < firstRow || - lastColumn < firstColumn - ) - throw new CliError("validation_error", "range has invalid or reversed cell bounds") - return { rows: lastRow - firstRow + 1, columns: lastColumn - firstColumn + 1 } -} const values = (text: string): unknown[][] => { const parsed = parseApiJson(text) const native: unknown = JSON.parse(text) @@ -86,6 +68,7 @@ export const installSheetsCommands = (program: Command, { call, output }: Remote const group = annotate(program.command("sheets"), { effects: GOOGLE_READ }).description( "read spreadsheet tabs and ranges, or change cells live in Google Sheets", ) + installSheetsStructureCommands(group, { call, output }) const sheetArg = "spreadsheet ID or Google Sheets URL" examples( annotate(group.command("info"), { effects: GOOGLE_READ }) @@ -151,7 +134,7 @@ export const installSheetsCommands = (program: Command, { call, output }: Remote .default("raw"), ) .action(async (id: string, range: string, args: { values: unknown[][]; input: string }) => { - const bounds = rectangle(range) + const bounds = namedRectangle(range) if ( args.values.some((row) => row.length > bounds.columns) || (name === "update" && args.values.length > bounds.rows) @@ -182,7 +165,7 @@ export const installSheetsCommands = (program: Command, { call, output }: Remote .argument("<range>", "tab and finite A1 rectangle, such as Budget!A1:C4"), ["drive personal sheets clear SHEET_ID 'Budget!A2:C4'"], ).action(async (id: string, range: string) => { - rectangle(range) + namedRectangle(range) output(itemOf(await call("sheets.spreadsheets.values.clear", { spreadsheetId: fileId(id), range }, {}))) }) } diff --git a/src/policy.ts b/src/policy.ts index 040f63c..ae0d367 100644 --- a/src/policy.ts +++ b/src/policy.ts @@ -36,7 +36,7 @@ export const policySettings = (env: NodeJS.ProcessEnv, profile: string, flags: P const defaults = Object.fromEntries( operations.filter((op) => op.effect === "destructive").map((op) => [`api.${op.id}`, "ask"]), ) as Record<string, Level> -export const methodPolicyKeys = (method: string) => { +export const methodPolicyKeys = (method: string, body?: Record<string, unknown>) => { const parts = method.split(".") const verb = parts.at(-1) const alias = method.startsWith("drive.files.") @@ -50,7 +50,60 @@ export const methodPolicyKeys = (method: string) => { : method.startsWith("drive.") ? `${parts[1]}.${verb}` : undefined - return [`api.${method}`, ...(alias ? [alias] : [])] + const keys = [`api.${method}`, ...(alias ? [alias] : [])] + if (method === "sheets.spreadsheets.batchUpdate" && Array.isArray(body?.requests)) { + const object = (value: unknown): value is Record<string, unknown> => + !!value && typeof value === "object" && !Array.isArray(value) + for (const request of body.requests) { + if (!object(request)) continue + if (request.addSheet || request.duplicateSheet) keys.push("sheets.create") + if (object(request.updateSheetProperties)) { + const update = request.updateSheetProperties + if ( + (object(update.properties) && update.properties.title !== undefined) || + String(update.fields) + .split(",") + .some((field) => field.trim() === "title" || field.trim() === "*") + ) + keys.push("sheets.rename") + if (object(update.properties) && update.properties.gridProperties) keys.push("sheets.insert") + } + if (request.insertDimension || request.appendDimension || request.insertRange) keys.push("sheets.insert") + if ( + request.repeatCell || + request.updateCells || + request.appendCells || + request.updateBorders || + request.addConditionalFormatRule || + request.updateConditionalFormatRule || + request.deleteConditionalFormatRule || + request.addBanding || + request.updateBanding || + request.deleteBanding + ) + keys.push("sheets.format") + if ( + request.updateCells || + request.appendCells || + request.findReplace || + request.pasteData || + request.copyPaste || + request.cutPaste || + request.autoFill + ) + keys.push("sheets.update") + if (request.pasteData || request.copyPaste || request.cutPaste || request.autoFill) keys.push("sheets.format") + if ( + object(request.repeatCell) && + (String(request.repeatCell.fields) + .split(",") + .some((field) => field.trim() === "*" || field.includes("userEnteredValue")) || + (object(request.repeatCell.cell) && request.repeatCell.cell.userEnteredValue !== undefined)) + ) + keys.push("sheets.update") + } + } + return [...new Set(keys)] } export const checkPolicy = async ( env: NodeJS.ProcessEnv, diff --git a/src/program.ts b/src/program.ts index 526cc51..3a2a50b 100644 --- a/src/program.ts +++ b/src/program.ts @@ -243,10 +243,9 @@ export const createProgram = (options: ProgramOptions = {}): Command => { const descriptor = apiMethod(method) validateRequest(method, params, body) const flags = policyFlags() - // An api command's own method was checked before its parameters were read; checking again changes nothing. - if (method !== checkedMethod) - for (const key of methodPolicyKeys(method)) - await checkPolicy(env, selected(), key, descriptor.mutates, flags, askOnce) + const alreadyChecked = new Set(method === checkedMethod ? methodPolicyKeys(method) : []) + for (const key of methodPolicyKeys(method, body)) + if (!alreadyChecked.has(key)) await checkPolicy(env, selected(), key, descriptor.mutates, flags, askOnce) if (flags.dryRun) { output( itemOf({ diff --git a/src/sheets-input.ts b/src/sheets-input.ts new file mode 100644 index 0000000..1badf17 --- /dev/null +++ b/src/sheets-input.ts @@ -0,0 +1,33 @@ +import { CliError } from "@wirecat/cli-base" + +export const cellRectangle = (range: string) => { + const match = /^([A-Za-z]{1,3})([1-9]\d*)(?::([A-Za-z]{1,3})([1-9]\d*))?$/.exec(range) + const column = (text: string) => [...text.toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) + if (!match) throw new CliError("validation_error", "range must be finite cells, such as A1:C4") + const firstColumn = column(match[1] as string) + const firstRow = Number(match[2]) + const lastColumn = column(match[3] ?? (match[1] as string)) + const lastRow = Number(match[4] ?? match[2]) + if ( + !Number.isSafeInteger(lastRow) || + lastRow > 10000000 || + lastColumn > 18278 || + lastRow < firstRow || + lastColumn < firstColumn + ) + throw new CliError("validation_error", "range has invalid or reversed cell bounds") + return { + rows: lastRow - firstRow + 1, + columns: lastColumn - firstColumn + 1, + startRowIndex: firstRow - 1, + endRowIndex: lastRow, + startColumnIndex: firstColumn - 1, + endColumnIndex: lastColumn, + } +} + +export const namedRectangle = (range: string) => { + const match = /^(?:'[^']*(?:''[^']*)*'|[^'!]+)!(.+)$/.exec(range) + if (!match) throw new CliError("validation_error", "range must name a tab and finite cells, such as 'Budget!A1:C4'") + return cellRectangle(match[1] as string) +} diff --git a/src/sheets-structure-roundtrip.test.ts b/src/sheets-structure-roundtrip.test.ts new file mode 100644 index 0000000..deb13f4 --- /dev/null +++ b/src/sheets-structure-roundtrip.test.ts @@ -0,0 +1,189 @@ +import { Socket } from "node:net" +import { captureStreams } from "@wirecat/cli-base" +import { OAuth2Client } from "google-auth-library" +import { afterEach, describe, expect, it, vi } from "vitest" +import { googleApi } from "./google-api.js" +import { createProgram } from "./program.js" +import { commandResult, fixture } from "./testing.js" + +type Cell = { value: string | number; format: Record<string, unknown> } +const setup = () => { + const f = fixture() + const original: Cell = { + value: "=B2*2", + format: { textFormat: { bold: false, italic: true }, numberFormat: { type: "NUMBER", pattern: "0.00" } }, + } + const tabs = new Map<number, { title: string; rows: Cell[][] }>([ + [0, { title: "Budget", rows: [[structuredClone(original)], [{ value: 12, format: {} }]] }], + [42, { title: "Other", rows: [[{ value: "untouched", format: {} }]] }], + ]) + let writes = 0 + let failAfterApply = false + const network = vi.spyOn(Socket.prototype, "connect").mockImplementation(() => { + throw new Error("fixture forbids network sockets") + }) + const auth = new OAuth2Client({ + transporterOptions: { + retryConfig: { retryBackoff: async () => {} }, + fetchImplementation: async (input, options) => { + const url = new URL(String(input)) + expect(url.origin).toBe("https://sheets.googleapis.com") + if (options?.method === "GET") + return Response.json({ + spreadsheetId: "synthetic-sheet", + sheets: [...tabs.entries()].map(([id, tab]) => ({ + properties: { sheetId: id, title: tab.title, gridProperties: { rowCount: tab.rows.length } }, + ...(url.searchParams.get("includeGridData") === "true" + ? { + data: [ + { + rowData: tab.rows.map((row) => ({ + values: row.map((cell) => ({ + userEnteredValue: + typeof cell.value === "number" + ? { numberValue: cell.value } + : cell.value.startsWith("=") + ? { formulaValue: cell.value } + : { stringValue: cell.value }, + userEnteredFormat: cell.format, + })), + })), + }, + ], + } + : {}), + })), + }) + writes++ + expect(url.pathname).toBe("/v4/spreadsheets/synthetic-sheet:batchUpdate") + const body = JSON.parse(String(options?.body)) + const replies: unknown[] = [] + for (const request of body.requests) { + if (request.addSheet) { + const id = 100 + tabs.size + tabs.set(id, { title: request.addSheet.properties.title, rows: [[]] }) + replies.push({ addSheet: { properties: { sheetId: id, title: request.addSheet.properties.title } } }) + } else if (request.updateSheetProperties) { + const { properties, fields } = request.updateSheetProperties + expect(fields).toBe("title") + const tab = tabs.get(properties.sheetId) + if (!tab) throw new Error("unknown synthetic tab") + tab.title = properties.title + replies.push({}) + } else if (request.insertDimension) { + const { range } = request.insertDimension + const tab = tabs.get(range.sheetId) + if (!tab) throw new Error("unknown synthetic tab") + const count = range.endIndex - range.startIndex + if (range.dimension === "ROWS") + for (let index = 0; index < count; index++) tab.rows.splice(range.startIndex + index, 0, []) + else + for (const row of tab.rows) + for (let index = 0; index < count; index++) + row.splice(range.startIndex + index, 0, { value: "", format: {} }) + replies.push({}) + } else if (request.repeatCell) { + const { range, cell, fields } = request.repeatCell + expect(Object.keys(cell)).toEqual(["userEnteredFormat"]) + const tab = tabs.get(range.sheetId) + if (!tab) throw new Error("unknown synthetic tab") + for (let row = range.startRowIndex; row < range.endRowIndex; row++) + for (let col = range.startColumnIndex; col < range.endColumnIndex; col++) { + const target = tab.rows[row]?.[col] + if (!target) throw new Error("unexpected synthetic grid cell") + for (const field of fields.split(",")) { + expect(field.startsWith("userEnteredFormat.")).toBe(true) + const parts = field.split(".").slice(1) + let source: Record<string, unknown> = cell.userEnteredFormat + let destination = target.format + for (const part of parts.slice(0, -1)) { + source = source[part] as Record<string, unknown> + destination[part] ??= {} + destination = destination[part] as Record<string, unknown> + } + const leaf = parts.at(-1) + if (!leaf) throw new Error("empty synthetic format path") + destination[leaf] = source[leaf] + } + } + replies.push({}) + } else throw new Error("unexpected synthetic batch operation") + } + if (failAfterApply) { + failAfterApply = false + return Response.json({ error: { message: "SYNTHETIC-PRIVATE-BODY" } }, { status: 503 }) + } + return Response.json({ spreadsheetId: "synthetic-sheet", replies }) + }, + }, + }) + auth.setCredentials({ access_token: "SYNTHETIC-STRUCTURE-BEARER", expiry_date: Date.now() + 3600000 }) + const api = googleApi(auth) + const run = async (...args: string[]) => { + const streams = captureStreams() + await createProgram({ env: f.env, streams, api }).parseAsync(["default", ...args, "--json"], { from: "user" }) + return commandResult(streams.stdout.join("")) + } + return { + run, + tabs, + original, + network, + writes: () => writes, + failNextWrite: () => { + failAfterApply = true + }, + } +} +afterEach(() => vi.restoreAllMocks()) + +describe("stateful Sheets structure through the pinned SDK", () => { + it("creates, renames and inserts, then reads back stable IDs and preserved data", async () => { + const f = setup() + const other = structuredClone(f.tabs.get(42)) + const created = await f.run("sheets", "create", "synthetic-sheet", "Quarter 2") + expect(created.replies[0].add_sheet.properties.title).toBe("Quarter 2") + await f.run("sheets", "rename", "synthetic-sheet", "0", "Quarter 1") + await f.run("sheets", "insert", "synthetic-sheet", "0", "rows", "1", "2") + const after = await f.run("sheets", "info", "synthetic-sheet") + expect( + after.sheets.find((sheet: { properties: { sheetId: number } }) => sheet.properties.sheetId === 0).properties, + ).toMatchObject({ title: "Quarter 1", gridProperties: { rowCount: 4 } }) + expect(f.tabs.get(0)?.rows[0]?.[0]?.value).toBe("=B2*2") + expect(f.tabs.get(0)?.rows[3]?.[0]?.value).toBe(12) + expect(f.tabs.get(42)).toEqual(other) + expect(f.network).not.toHaveBeenCalled() + }) + it("formats one leaf then independently reads unchanged values and other formatting", async () => { + const f = setup() + await f.run("sheets", "format", "synthetic-sheet", "0", "A1", "--body", '{"textFormat":{"bold":true}}') + const after = await f.run( + "api", + "sheets-spreadsheets-get", + "--spreadsheet-id", + "synthetic-sheet", + "--include-grid-data", + "true", + ) + const cell = after.sheets[0].data[0].row_data[0].values[0] + expect(cell.user_entered_value.formula_value).toBe("=B2*2") + expect(cell.user_entered_format).toEqual({ + text_format: { bold: true, italic: true }, + number_format: { type: "NUMBER", pattern: "0.00" }, + }) + expect(f.tabs.get(42)?.rows[0]?.[0]?.value).toBe("untouched") + expect(f.network).not.toHaveBeenCalled() + }) + it("does not duplicate row insertion after an apply-then-error response", async () => { + const f = setup() + f.failNextWrite() + const error: unknown = await f + .run("sheets", "insert", "synthetic-sheet", "0", "rows", "0", "1") + .catch((error: unknown) => error) + expect(error).toMatchObject({ code: "outcome_unknown" }) + expect(f.writes()).toBe(1) + expect(f.tabs.get(0)?.rows).toHaveLength(3) + expect(JSON.stringify(error)).not.toContain("SYNTHETIC-PRIVATE-BODY") + expect(f.network).not.toHaveBeenCalled() + }) +}) diff --git a/src/sheets-structure.test.ts b/src/sheets-structure.test.ts new file mode 100644 index 0000000..a3271e1 --- /dev/null +++ b/src/sheets-structure.test.ts @@ -0,0 +1,167 @@ +import { captureStreams } from "@wirecat/cli-base" +import { describe, expect, it, vi } from "vitest" +import { readConfig, writeConfig } from "./config.js" +import { createProgram } from "./program.js" +import { commandResult, fixture } from "./testing.js" + +const setup = () => { + const f = fixture() + const call = vi.fn(async () => ({ replies: [{ synthetic: true }] })) + const run = async (...args: string[]) => { + const streams = captureStreams() + await createProgram({ env: f.env, streams, api: { call } }).parseAsync(["default", ...args, "--json"], { + from: "user", + }) + return commandResult(streams.stdout.join("")) + } + return { ...f, run, call } +} + +describe("bounded native Sheets structure and formatting", () => { + it("creates and renames only the selected tab fields", async () => { + const f = setup() + await f.run("sheets", "create", "synthetic-sheet", "Quarter 2") + expect(f.call).toHaveBeenLastCalledWith( + "sheets.spreadsheets.batchUpdate", + { spreadsheetId: "synthetic-sheet" }, + { + requests: [{ addSheet: { properties: { title: "Quarter 2" } } }], + }, + ) + await f.run("sheets", "rename", "https://docs.google.com/spreadsheets/d/synthetic-sheet/edit", "0", "Q2") + expect(f.call).toHaveBeenLastCalledWith( + "sheets.spreadsheets.batchUpdate", + { spreadsheetId: "synthetic-sheet" }, + { + requests: [{ updateSheetProperties: { properties: { sheetId: 0, title: "Q2" }, fields: "title" } }], + }, + ) + }) + it.each(["rows", "columns"])("inserts a bounded %s range using explicit zero-based indexes", async (axis) => { + const f = setup() + await f.run("sheets", "insert", "synthetic-sheet", "101", axis, "1", "3") + expect(f.call).toHaveBeenCalledWith( + "sheets.spreadsheets.batchUpdate", + { spreadsheetId: "synthetic-sheet" }, + { + requests: [ + { + insertDimension: { + range: { sheetId: 101, dimension: axis.toUpperCase(), startIndex: 1, endIndex: 4 }, + inheritFromBefore: false, + }, + }, + ], + }, + ) + }) + it("derives leaf field masks and a finite GridRange without supplying cell values", async () => { + const f = setup() + const format = { textFormat: { bold: true, italic: false }, numberFormat: { type: "NUMBER", pattern: "0.00" } } + await f.run("sheets", "format", "synthetic-sheet", "101", "B2:C4", "--body", JSON.stringify(format)) + expect(f.call).toHaveBeenCalledWith( + "sheets.spreadsheets.batchUpdate", + { spreadsheetId: "synthetic-sheet" }, + { + requests: [ + { + repeatCell: { + range: { sheetId: 101, startRowIndex: 1, endRowIndex: 4, startColumnIndex: 1, endColumnIndex: 3 }, + cell: { userEnteredFormat: format }, + fields: + "userEnteredFormat.textFormat.bold,userEnteredFormat.textFormat.italic,userEnteredFormat.numberFormat.type,userEnteredFormat.numberFormat.pattern", + }, + }, + ], + }, + ) + }) + it.each([ + ["create", "synthetic-sheet", " "], + ["create", "synthetic-sheet", "x".repeat(101)], + ["rename", "synthetic-sheet", "-1", "Q2"], + ["rename", "synthetic-sheet", "2147483648", "Q2"], + ["insert", "synthetic-sheet", "0", "diagonal", "1", "1"], + ["insert", "synthetic-sheet", "0", "rows", "-1", "1"], + ["insert", "synthetic-sheet", "0", "rows", "0", "0"], + ["insert", "synthetic-sheet", "0", "rows", "0", "1001"], + ["insert", "synthetic-sheet", "0", "columns", "18278", "1"], + ["format", "synthetic-sheet", "0", "A:C", "--body", '{"textFormat":{"bold":true}}'], + ["format", "synthetic-sheet", "0", "Budget!A1:C4", "--body", '{"textFormat":{"bold":true}}'], + ["format", "synthetic-sheet", "0", "A1:Z1000", "--body", '{"textFormat":{"bold":true}}'], + ["format", "synthetic-sheet", "0", "A1", "--body", "{}"], + ["format", "synthetic-sheet", "0", "A1", "--body", '{"textFormat":{}}'], + ["format", "synthetic-sheet", "0", "A1", "--body", '{"userEnteredValue":{"stringValue":"DO_NOT_LOG"}}'], + ["format", "synthetic-sheet", "0", "A1", "--body", '{"textFormat":{"invented":"DO_NOT_LOG"}}'], + ["format", "synthetic-sheet", "0", "A1", "--body", '{"textFormat":{"bold":"DO_NOT_LOG"}}'], + ])("refuses invalid structure/formatting before providers: %j", async (...args: string[]) => { + const f = setup() + const error: unknown = await f.run("sheets", ...args).catch((error: unknown) => error) + expect(error).toMatchObject({ code: "validation_error" }) + expect(JSON.stringify(error)).not.toContain("DO_NOT_LOG") + expect(f.call).not.toHaveBeenCalled() + }) + it("retains dry-run, offline and read-only behavior", async () => { + const f = setup() + expect(await f.run("--dry-run", "sheets", "create", "synthetic-sheet", "Q2")).toMatchObject({ + dry_run: true, + method: "sheets.spreadsheets.batchUpdate", + }) + await expect(f.run("--offline", "sheets", "rename", "synthetic-sheet", "0", "Q2")).rejects.toMatchObject({ + code: "permission_error", + }) + await expect( + f.run("--read-only", "sheets", "format", "synthetic-sheet", "0", "A1", "--body", '{"textFormat":{"bold":true}}'), + ).rejects.toMatchObject({ code: "permission_error" }) + expect(f.call).not.toHaveBeenCalled() + }) + it.each([ + [ + "rename", + { updateSheetProperties: { properties: { sheetId: 0, title: "Q2" }, fields: "title" } }, + ["rename", "synthetic-sheet", "0", "Q2"], + ], + ["create", { addSheet: { properties: { title: "Q2" } } }, ["create", "synthetic-sheet", "Q2"]], + [ + "insert", + { insertDimension: { range: { sheetId: 0, dimension: "ROWS", startIndex: 0, endIndex: 1 } } }, + ["insert", "synthetic-sheet", "0", "rows", "0", "1"], + ], + [ + "format", + { + repeatCell: { + range: { sheetId: 0 }, + cell: { userEnteredFormat: { textFormat: { bold: true } } }, + fields: "userEnteredFormat.textFormat.bold", + }, + }, + ["format", "synthetic-sheet", "0", "A1", "--body", '{"textFormat":{"bold":true}}'], + ], + ] as const)( + "applies sheets.%s policy to friendly, raw and generated batch requests", + async (permission, request, friendly) => { + const f = setup() + const config = readConfig(f.env) + config.defaults = { permissions: { [`sheets.${permission}`]: "deny" } } + writeConfig(f.env, config) + const body = JSON.stringify({ requests: [request] }) + for (const args of [ + ["sheets", ...friendly], + [ + "api", + "call", + "sheets.spreadsheets.batchUpdate", + "--params", + '{"spreadsheetId":"synthetic-sheet"}', + "--body", + body, + "--allow-writes", + ], + ["api", "sheets-spreadsheets-batch-update", "--spreadsheet-id", "synthetic-sheet", "--body", body], + ]) + await expect(f.run(...args)).rejects.toMatchObject({ code: "permission_error" }) + expect(f.call).not.toHaveBeenCalled() + }, + ) +})