From 4000c574ee1a4e4296d675f7afbc6aa0affe3dd0 Mon Sep 17 00:00:00 2001 From: Josh Dzielak <174777+joshed-io@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:13:49 +0100 Subject: [PATCH] Upgrade notion-cli to Notion API 2025-09-03 Migrate the CLI from the legacy Notion API (2022-02-22) to the current 2025-09-03 collection. This is a significant expansion that adds full coverage of the Notion API. Key changes: - Upgrade Notion-Version header from 2022-02-22 to 2025-09-03 - Add new commands: block, comment, datasource, file, user, docs, auth-internal, auth-public, integration - Add generated API clients for all new endpoints (blocks CRUD, comments, data sources, file uploads, OAuth, pages CRUD, users) - Expand existing page/database commands with create, update, archive, move, and property retrieval - Add CI workflow (.github/workflows/notion-cli.yml) - Add comprehensive tests for all commands - Update README with full command reference and documentation - Add UPGRADE.md tracking the API migration details --- .github/workflows/notion-cli.yml | 37 + notion-cli/AGENTS.md | 7 + notion-cli/KNOWN-ISSUES.md | 44 + notion-cli/LICENSE | 21 + notion-cli/README.md | 1020 ++++++++++++++++- notion-cli/TASKS.md | 71 ++ notion-cli/UPGRADE.md | 450 ++++++++ notion-cli/cli.ts | 69 +- notion-cli/commands/auth-internal.ts | 171 +++ notion-cli/commands/auth-public.ts | 479 ++++++++ notion-cli/commands/block.ts | 316 +++++ notion-cli/commands/comment.ts | 220 ++++ notion-cli/commands/database.ts | 158 ++- notion-cli/commands/datasource.ts | 333 ++++++ notion-cli/commands/docs.ts | 79 ++ notion-cli/commands/file.ts | 196 ++++ notion-cli/commands/integration.ts | 113 ++ notion-cli/commands/page.ts | 281 ++++- notion-cli/commands/search.ts | 95 +- notion-cli/commands/set-token.ts | 3 +- notion-cli/commands/user.ts | 205 ++++ notion-cli/helpers.ts | 95 +- notion-cli/package.json | 14 +- .../blocks/append-block-children/client.ts | 53 + .../notion-api/blocks/delete-block/client.ts | 49 + .../blocks/retrieve-block-children/client.ts | 10 +- .../blocks/retrieve-block/client.ts | 46 + .../notion-api/blocks/update-block/client.ts | 56 + .../comments/create-comment/client.ts | 67 ++ .../comments/retrieve-comment/client.ts | 46 + .../comments/retrieve-comments/client.ts | 61 + .../data-sources/create-data-source/client.ts | 54 + .../list-data-source-templates/client.ts | 78 ++ .../data-sources/query-data-source/client.ts | 61 + .../retrieve-data-source/client.ts | 50 + .../data-sources/update-data-source/client.ts | 56 + .../databases/create-database/client.ts | 49 + .../databases/query-database/client.ts | 10 +- .../databases/retrieve-database/client.ts | 10 +- .../databases/update-database/client.ts | 53 + .../complete-file-upload/client.ts | 46 + .../file-uploads/create-file-upload/client.ts | 80 ++ .../file-uploads/list-file-uploads/client.ts | 68 ++ .../retrieve-file-upload/client.ts | 45 + .../file-uploads/send-file-upload/client.ts | 65 ++ notion-cli/src/postman/notion-api/index.ts | 193 +++- .../notion-api/oauth/introspect/client.ts | 61 + .../postman/notion-api/oauth/revoke/client.ts | 45 + .../postman/notion-api/oauth/token/client.ts | 71 ++ .../notion-api/pages/archive-page/client.ts | 52 + .../notion-api/pages/create-page/client.ts | 49 + .../notion-api/pages/move-page/client.ts | 54 + .../pages/retrieve-page-property/client.ts | 63 + .../notion-api/pages/retrieve-page/client.ts | 10 +- .../pages/update-page-properties/client.ts | 54 + .../notion-api/search/search/client.ts | 12 +- .../src/postman/notion-api/shared/types.ts | 232 +++- .../postman/notion-api/shared/variables.ts | 7 +- .../notion-api/users/list-users/client.ts | 53 + .../users/retrieve-bot-user/client.ts | 48 + .../notion-api/users/retrieve-user/client.ts | 48 + notion-cli/test/auth.test.ts | 269 +++++ notion-cli/test/block.test.ts | 70 ++ notion-cli/test/comment.test.ts | 91 ++ notion-cli/test/database.test.ts | 74 ++ notion-cli/test/datasource.test.ts | 127 ++ notion-cli/test/docs.test.ts | 21 + notion-cli/test/file.test.ts | 90 ++ notion-cli/test/helpers.ts | 170 +++ notion-cli/test/integration-cmd.test.ts | 22 + notion-cli/test/page.test.ts | 101 ++ notion-cli/test/search.test.ts | 47 + notion-cli/test/user.test.ts | 42 + notion-cli/tsconfig.json | 2 +- 74 files changed, 7525 insertions(+), 243 deletions(-) create mode 100644 .github/workflows/notion-cli.yml create mode 100644 notion-cli/AGENTS.md create mode 100644 notion-cli/KNOWN-ISSUES.md create mode 100644 notion-cli/LICENSE create mode 100644 notion-cli/TASKS.md create mode 100644 notion-cli/UPGRADE.md create mode 100644 notion-cli/commands/auth-internal.ts create mode 100644 notion-cli/commands/auth-public.ts create mode 100644 notion-cli/commands/block.ts create mode 100644 notion-cli/commands/comment.ts create mode 100644 notion-cli/commands/datasource.ts create mode 100644 notion-cli/commands/docs.ts create mode 100644 notion-cli/commands/file.ts create mode 100644 notion-cli/commands/integration.ts create mode 100644 notion-cli/commands/user.ts create mode 100644 notion-cli/src/postman/notion-api/blocks/append-block-children/client.ts create mode 100644 notion-cli/src/postman/notion-api/blocks/delete-block/client.ts create mode 100644 notion-cli/src/postman/notion-api/blocks/retrieve-block/client.ts create mode 100644 notion-cli/src/postman/notion-api/blocks/update-block/client.ts create mode 100644 notion-cli/src/postman/notion-api/comments/create-comment/client.ts create mode 100644 notion-cli/src/postman/notion-api/comments/retrieve-comment/client.ts create mode 100644 notion-cli/src/postman/notion-api/comments/retrieve-comments/client.ts create mode 100644 notion-cli/src/postman/notion-api/data-sources/create-data-source/client.ts create mode 100644 notion-cli/src/postman/notion-api/data-sources/list-data-source-templates/client.ts create mode 100644 notion-cli/src/postman/notion-api/data-sources/query-data-source/client.ts create mode 100644 notion-cli/src/postman/notion-api/data-sources/retrieve-data-source/client.ts create mode 100644 notion-cli/src/postman/notion-api/data-sources/update-data-source/client.ts create mode 100644 notion-cli/src/postman/notion-api/databases/create-database/client.ts create mode 100644 notion-cli/src/postman/notion-api/databases/update-database/client.ts create mode 100644 notion-cli/src/postman/notion-api/file-uploads/complete-file-upload/client.ts create mode 100644 notion-cli/src/postman/notion-api/file-uploads/create-file-upload/client.ts create mode 100644 notion-cli/src/postman/notion-api/file-uploads/list-file-uploads/client.ts create mode 100644 notion-cli/src/postman/notion-api/file-uploads/retrieve-file-upload/client.ts create mode 100644 notion-cli/src/postman/notion-api/file-uploads/send-file-upload/client.ts create mode 100644 notion-cli/src/postman/notion-api/oauth/introspect/client.ts create mode 100644 notion-cli/src/postman/notion-api/oauth/revoke/client.ts create mode 100644 notion-cli/src/postman/notion-api/oauth/token/client.ts create mode 100644 notion-cli/src/postman/notion-api/pages/archive-page/client.ts create mode 100644 notion-cli/src/postman/notion-api/pages/create-page/client.ts create mode 100644 notion-cli/src/postman/notion-api/pages/move-page/client.ts create mode 100644 notion-cli/src/postman/notion-api/pages/retrieve-page-property/client.ts create mode 100644 notion-cli/src/postman/notion-api/pages/update-page-properties/client.ts create mode 100644 notion-cli/src/postman/notion-api/users/list-users/client.ts create mode 100644 notion-cli/src/postman/notion-api/users/retrieve-bot-user/client.ts create mode 100644 notion-cli/src/postman/notion-api/users/retrieve-user/client.ts create mode 100644 notion-cli/test/auth.test.ts create mode 100644 notion-cli/test/block.test.ts create mode 100644 notion-cli/test/comment.test.ts create mode 100644 notion-cli/test/database.test.ts create mode 100644 notion-cli/test/datasource.test.ts create mode 100644 notion-cli/test/docs.test.ts create mode 100644 notion-cli/test/file.test.ts create mode 100644 notion-cli/test/helpers.ts create mode 100644 notion-cli/test/integration-cmd.test.ts create mode 100644 notion-cli/test/page.test.ts create mode 100644 notion-cli/test/search.test.ts create mode 100644 notion-cli/test/user.test.ts diff --git a/.github/workflows/notion-cli.yml b/.github/workflows/notion-cli.yml new file mode 100644 index 0000000..aa0a243 --- /dev/null +++ b/.github/workflows/notion-cli.yml @@ -0,0 +1,37 @@ +name: notion-cli + +on: + push: + paths: + - "notion-cli/**" + pull_request: + paths: + - "notion-cli/**" + workflow_dispatch: # allow manual runs + +defaults: + run: + working-directory: notion-cli + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: notion-cli/package-lock.json + + - run: npm ci + + - name: Build + run: npm run build + + - name: Integration tests + env: + NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }} + run: npm test diff --git a/notion-cli/AGENTS.md b/notion-cli/AGENTS.md new file mode 100644 index 0000000..fc8fde2 --- /dev/null +++ b/notion-cli/AGENTS.md @@ -0,0 +1,7 @@ +# Agent Guidelines for notion-cli + +## Notion API Version + +Always use `2025-09-03` when making direct Notion API calls. This is the version the CLI uses (defined in `src/postman/notion-api/shared/variables.ts`). When writing scripts, tests, or debugging with `fetch()`, use `Notion-Version: 2025-09-03` — not the older `2022-06-28`. + +Before making changes, verify the version in `src/postman/notion-api/shared/variables.ts` matches `2025-09-03`. diff --git a/notion-cli/KNOWN-ISSUES.md b/notion-cli/KNOWN-ISSUES.md new file mode 100644 index 0000000..7d9b406 --- /dev/null +++ b/notion-cli/KNOWN-ISSUES.md @@ -0,0 +1,44 @@ +# Known Issues + +## OAuth tokens cannot read comments + +**Status:** Unresolved — reported 2026-02-13 + +**Summary:** Comment read operations fail when using an OAuth access token, despite the token having `read_comment` scope and the integration having "Read comments" capability enabled. The same operations work correctly with an internal integration token on the same pages. + +### Symptoms + +| Endpoint | OAuth token | Internal token | +|----------|------------|----------------| +| `POST /v1/comments` (create) | 200, but partial response: `{ object, id, request_id }` | 200, full response with `discussion_id`, `rich_text`, etc. | +| `GET /v1/comments/:id` (retrieve) | 200, but partial response: `{ object, id, request_id }` | 200, full response | +| `GET /v1/comments?block_id=:id` (list) | 404 "Could not find block" | 200, full list | + +The partial response matches Notion's documented [Option 1 / `partialCommentObjectResponse`](https://developers.notion.com/reference/retrieve-comment) — the shape returned when the token lacks read comment permissions. + +### What we verified + +- **Token scope:** `read_comment` is present (confirmed via `POST /v1/oauth/introspect`) +- **Integration capabilities:** "Read comments" and "Insert comments" both enabled in the [integration dashboard](https://www.notion.so/profile/integrations) +- **Page-level permissions:** Page shows "Can read comments" for the OAuth integration in the Notion UI (Connections panel) +- **Page accessibility:** The same page returns 200 for `GET /v1/pages/:id`, `GET /v1/blocks/:id`, and `GET /v1/blocks/:id/children` with the OAuth token +- **Comment creation works:** `POST /v1/comments` succeeds (comments appear in the Notion UI), but the response is partial +- **API version:** Same behavior with both `2022-06-28` and `2025-09-03` +- **Fresh token:** Revoked and re-authorized — same behavior with a new token +- **Internal token on same page:** All comment operations work correctly with an internal integration token (`owner.type: "workspace"`) on the exact same page + +### Workaround + +Use an internal integration token for comment operations. The CLI supports both auth methods: + +```bash +# Switch to internal auth +notion-cli auth-internal set + +# Switch back to OAuth +notion-cli auth-public login +``` + +### Impact on tests + +The `comment` test suite requires an internal integration token to pass. When authenticated via OAuth, all 5 comment tests that read comment data will fail. Other test suites (block, page, database, search, user, etc.) are unaffected. diff --git a/notion-cli/LICENSE b/notion-cli/LICENSE new file mode 100644 index 0000000..a4e2c4f --- /dev/null +++ b/notion-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Postman, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/notion-cli/README.md b/notion-cli/README.md index b6528da..1bd3102 100644 --- a/notion-cli/README.md +++ b/notion-cli/README.md @@ -1,21 +1,41 @@ # Notion CLI -A human and agent-friendly CLI for exploring Notion workspaces. Search for pages, read their content, and query databases. +[![Build](https://github.com/postmanlabs/postman-code-examples/actions/workflows/notion-cli.yml/badge.svg)](https://github.com/postmanlabs/postman-code-examples/actions/workflows/notion-cli.yml) -### What you can do with it +A command-line tool for reading and writing to Notion workspaces — built for humans and AI agents alike. Built on the latest Notion API (`2025-09-03`), including data sources, file uploads, page moves, and OAuth. Search pages, navigate database schemas, read and update content, upload files, and manage comments, all from the terminal. -- **Search** — Find pages by keyword across everything the integration can access -- **Read pages** — Fetch page metadata, properties, and content blocks (including nested blocks) -- **Query databases** — View a database's schema and page through its entries -- **Map a workspace** — Find root pages and traverse the tree to build a map of all workspace content -- **Feed an AI agent** — Give an agent the CLI and let it autonomously explore a workspace to answer questions, summarize content, or extract data -- **Audit or export** — Walk every page to check for stale content, missing properties, or generate a plaintext export +### What you can do + +- **Search** across your workspace by keyword, filtered to pages, databases, or both +- **Read pages** with full content — blocks are fetched recursively and rendered with structure intact (headings, lists, toggles, callouts, code blocks, and more) +- **Navigate databases** — view the schema, data sources, and entries with all their property values +- **Work with data sources directly** — retrieve, query, update, and list templates for the data source layer underneath databases +- **Write back** — create pages, update titles, move pages between parents, append content blocks, edit block text, archive what you don't need +- **Manage comments** — list threads, retrieve individual comments, add comments, reply to discussions +- **Upload files** — upload local files to Notion, list uploads, and retrieve upload status +- **Two auth methods** — internal integration tokens for simplicity, or full OAuth browser flow for public integrations, with token introspection and revocation +- **Map a workspace** — `integration pages` finds the roots, `page get` reveals children — compose them to walk the full content tree +- **Great for humans and agents** — default output is human-readable and token-efficient; `--raw` gives agents the full JSON when they need it. A built-in `docs` command and navigation hints between related commands let an AI agent explore a workspace without prior knowledge + +## How it was made + +This project was built by a human and an AI coding agent working together 🤝. The agent used [Postman Code](https://www.postman.com/explore/code)'s MCP tools to search for the Notion API on Postman's API Network, pull request definitions from the [Notion API Collection](https://www.postman.com/notionhq/notion-s-api-workspace/collection/a1b2c3d/notion-api), and generate a typed TypeScript function for each endpoint. Every API call is a direct API integration — no HTTP code was written by hand and no third-party dependencies were introduced. + +The **command layer** was designed collaboratively. The human defined the command hierarchy, output format, and the behaviors that make the CLI useful beyond raw API access — the agent implemented them. Commands aren't thin wrappers around the generated clients; they add real workflow logic: + +- `page get` recursively fetches nested blocks in parallel and renders 20+ block types with depth-aware indentation +- `integration pages` derives the concept of "root pages" (which doesn't exist in the Notion API) by paginating all visible pages and filtering by parent visibility +- `block update` fetches the block first to determine its type, validates that it supports text updates, then sends the correctly-keyed payload +- Property formatting handles 15+ Notion property types (select, multi_select, date, formula, rollup, relation, etc.) +- Every command includes detailed `--help` text with usage examples and navigation hints between related commands + +The generated clients handle all the HTTP details. The commands handle workflow — composing API calls, adding derived concepts, and formatting output for readability. ## How it works -The API clients under `src/postman/notion-api/` were generated by [Postman Code](https://www.postman.com/explore/code). The [Notion API Collection](https://www.postman.com/notionhq/notion-s-api-workspace/collection/a1b2c3d/notion-api) is the source of truth — Postman Code reads a request from the collection and produces an on-demand, type-safe TypeScript function with typed parameters, response types, and error handling. Each request becomes its own `client.ts` file. +The API clients under `src/postman/notion-api/` were generated by Postman Code. The [Notion API (2025-09-03)](https://www.postman.com/notionhq/notion-s-api-workspace/collection/a1b2c3d/notion-api) collection is the source of truth — Postman Code reads a request from the collection and produces a type-safe TypeScript function. Each request becomes its own `client.ts` file. -Those generated clients are re-exported through an `index.ts` file that namespaces them by resource. You create a client with your token, then call methods with just resource IDs and parameters: +Those generated clients are re-exported through an `index.ts` file that namespaces them by resource. ```typescript import { createNotionClient } from "./src/postman/notion-api/index.js"; @@ -28,9 +48,10 @@ const results = await notion.search({ filter: { value: "page", property: "object" }, }); -// Retrieve a database schema and query its entries +// Retrieve a database schema and list its entries via data sources const schema = await notion.databases.retrieve(databaseId); -const entries = await notion.databases.query(databaseId, { +const dataSourceId = schema.data_sources?.[0]?.id; +const entries = await notion.dataSources.query(dataSourceId, { page_size: 50, }); @@ -45,7 +66,64 @@ All types are also exported from the same entry point: import { createNotionClient, type NotionPage, type NotionDatabase } from "./src/postman/notion-api/index.js"; ``` -The CLI commands in `commands/` are thin wrappers around these clients — they handle argument parsing and output formatting, while the generated code handles all the HTTP details. +The CLI commands in `commands/` use these clients and add the workflow logic described above — recursive traversal, derived concepts, multi-step orchestration, and rich output formatting. + +Each command uses one or more Postman Collection requests — the same requests Postman Code used to generate the client code: + +| Command | Collection Requests Used | Generated Clients | +|---------|------------------------|-----------------| +| `search` | [Search](https://go.postman.co/request/52041987-0e8a4f2d-d453-4bc1-b4c6-286905b87f4a) | [search](src/postman/notion-api/search/search/client.ts) | +| `integration pages` | [Search](https://go.postman.co/request/52041987-0e8a4f2d-d453-4bc1-b4c6-286905b87f4a) (paginated) | [search](src/postman/notion-api/search/search/client.ts) | +| `page get` | [Retrieve a page](https://go.postman.co/request/52041987-d7e520f6-0c75-4fe0-9b23-990f742d496e)
[Retrieve block children](https://go.postman.co/request/52041987-039ea5be-709a-4539-b021-170a63eba771) | [retrieve-page](src/postman/notion-api/pages/retrieve-page/client.ts)
[retrieve-block-children](src/postman/notion-api/blocks/retrieve-block-children/client.ts) | +| `page create` | [Create a page](https://go.postman.co/request/52041987-a2ef9963-62e0-4e87-a12b-f899f695280c) | [create-page](src/postman/notion-api/pages/create-page/client.ts) | +| `page update` | [Update page properties](https://go.postman.co/request/52041987-de2726f0-1465-4fdc-81d5-bd35415848b4) | [update-page-properties](src/postman/notion-api/pages/update-page-properties/client.ts) | +| `page archive` | [Update page properties](https://go.postman.co/request/52041987-de2726f0-1465-4fdc-81d5-bd35415848b4) | [archive-page](src/postman/notion-api/pages/archive-page/client.ts) | +| `page property` | [Retrieve a page property item](https://go.postman.co/request/52041987-e3c019ad-9c8b-4975-a142-5549ef771028) | [retrieve-page-property](src/postman/notion-api/pages/retrieve-page-property/client.ts) | +| `page move` | [Move page](https://go.postman.co/request/52041987-9686de7b-77c0-4d53-b800-bf6c748bc668) | [move-page](src/postman/notion-api/pages/move-page/client.ts) | +| `database get` | [Retrieve a database](https://go.postman.co/request/52041987-73359528-2278-415f-98f2-4d20274cc69e) | [retrieve-database](src/postman/notion-api/databases/retrieve-database/client.ts) | +| `database list` | [Retrieve a database](https://go.postman.co/request/52041987-73359528-2278-415f-98f2-4d20274cc69e)
[Query a data source](https://go.postman.co/request/52041987-aa498c21-f7e7-4839-bbe7-78957fb7379d) | [retrieve-database](src/postman/notion-api/databases/retrieve-database/client.ts)
[query-data-source](src/postman/notion-api/data-sources/query-data-source/client.ts) | +| `database create` | [Create a database](https://go.postman.co/request/52041987-85ab373b-2fc1-4b9a-a8a6-ee6b9e72728c) | [create-database](src/postman/notion-api/databases/create-database/client.ts) | +| `database update` | [Update a database](https://go.postman.co/request/52041987-5febd2f6-c9ff-486d-8a4b-71e5c58f82ef) | [update-database](src/postman/notion-api/databases/update-database/client.ts) | +| `datasource get` | [Retrieve a data source](https://go.postman.co/request/52041987-dfeeac14-f85e-4527-ad2e-d85f79284dd9) | [retrieve-data-source](src/postman/notion-api/data-sources/retrieve-data-source/client.ts) | +| `datasource query` | [Query a data source](https://go.postman.co/request/52041987-aa498c21-f7e7-4839-bbe7-78957fb7379d) | [query-data-source](src/postman/notion-api/data-sources/query-data-source/client.ts) | +| `datasource create` | [Create a data source](https://go.postman.co/request/52041987-9c41977a-1606-4c76-a4e0-d094a3d0b4c7) | [create-data-source](src/postman/notion-api/data-sources/create-data-source/client.ts) | +| `datasource update` | [Update a data source](https://go.postman.co/request/52041987-29f06253-bd7e-4c3c-b0d8-a36b285c4e0e) | [update-data-source](src/postman/notion-api/data-sources/update-data-source/client.ts) | +| `datasource templates` | [List data source templates](https://go.postman.co/request/52041987-f38c907f-36d5-40c7-b057-e4811b4b5cde) | [list-data-source-templates](src/postman/notion-api/data-sources/list-data-source-templates/client.ts) | +| `block get` | [Retrieve a block](https://go.postman.co/request/52041987-30ea7fcd-b8b4-441f-935a-c9d143d59d66) | [retrieve-block](src/postman/notion-api/blocks/retrieve-block/client.ts) | +| `block children` | [Retrieve block children](https://go.postman.co/request/52041987-039ea5be-709a-4539-b021-170a63eba771) | [retrieve-block-children](src/postman/notion-api/blocks/retrieve-block-children/client.ts) | +| `block append` | [Append block children](https://go.postman.co/request/52041987-a9376866-eb97-4cfa-b08d-5fc49f09ef26) | [append-block-children](src/postman/notion-api/blocks/append-block-children/client.ts) | +| `block update` | [Retrieve a block](https://go.postman.co/request/52041987-30ea7fcd-b8b4-441f-935a-c9d143d59d66)
[Update a block](https://go.postman.co/request/52041987-1a96de40-de2c-49c8-9697-fc91b077d06d) | [retrieve-block](src/postman/notion-api/blocks/retrieve-block/client.ts)
[update-block](src/postman/notion-api/blocks/update-block/client.ts) | +| `block delete` | [Delete a block](https://go.postman.co/request/52041987-95e3f732-e993-42b4-8451-70178b3d2ac9) | [delete-block](src/postman/notion-api/blocks/delete-block/client.ts) | +| `comment list` | [Retrieve comments](https://go.postman.co/request/52041987-4def4425-319e-418c-9d96-56a4807a8ce7) | [retrieve-comments](src/postman/notion-api/comments/retrieve-comments/client.ts) | +| `comment get` | [Retrieve a comment](https://go.postman.co/request/52041987-2f312153-d16c-459b-9d51-88358a96fe03) | [retrieve-comment](src/postman/notion-api/comments/retrieve-comment/client.ts) | +| `comment add` | [Create a comment](https://go.postman.co/request/52041987-9261f5ec-6b04-4d13-83a0-d12fe1b188c7) | [add-comment-to-page](src/postman/notion-api/comments/add-comment-to-page/client.ts) | +| `comment reply` | [Create a comment](https://go.postman.co/request/52041987-9261f5ec-6b04-4d13-83a0-d12fe1b188c7) | [add-comment-to-discussion](src/postman/notion-api/comments/add-comment-to-discussion/client.ts) | +| `file upload` | [Create a file upload](https://go.postman.co/request/52041987-1548ae35-ac12-4fc3-a337-416ed2a92088)
[Send file upload](https://go.postman.co/request/52041987-aaf29c9a-7236-432d-97e8-beebee88b3cd)
[Complete file upload](https://go.postman.co/request/52041987-80549896-30b1-43a4-add0-a73958b602e1) | [create-file-upload](src/postman/notion-api/file-uploads/create-file-upload/client.ts)
[send-file-upload](src/postman/notion-api/file-uploads/send-file-upload/client.ts)
[complete-file-upload](src/postman/notion-api/file-uploads/complete-file-upload/client.ts) | +| `file list` | [List file uploads](https://go.postman.co/request/52041987-d6b82f81-aaf2-4cc0-b92c-c8cdf709e67d) | [list-file-uploads](src/postman/notion-api/file-uploads/list-file-uploads/client.ts) | +| `file get` | [Retrieve a file upload](https://go.postman.co/request/52041987-2f30fd9c-c12b-40fc-bb98-03d153c3e353) | [retrieve-file-upload](src/postman/notion-api/file-uploads/retrieve-file-upload/client.ts) | +| `user me` | [Retrieve your token's bot user](https://go.postman.co/request/52041987-30ad8b7b-5eb0-4bbe-bfbf-509d7f961cae) | [retrieve-bot-user](src/postman/notion-api/users/retrieve-bot-user/client.ts) | +| `user get` | [Retrieve a user](https://go.postman.co/request/52041987-e23bf76f-cb3a-4bbc-9e5a-0a39cd59e909) | [retrieve-user](src/postman/notion-api/users/retrieve-user/client.ts) | +| `user list` | [List all users](https://go.postman.co/request/52041987-1f6b9bec-dc7d-4412-9e80-46a33b8b11c6) | [list-users](src/postman/notion-api/users/list-users/client.ts) | +| `auth-public login` | [Token](https://go.postman.co/request/52041987-ced3cc2e-170e-40fa-8f39-fee0f6a464d7) | [token](src/postman/notion-api/oauth/token/client.ts) | +| `auth-public introspect` | [Introspect](https://go.postman.co/request/52041987-3070c020-0b6d-402f-bd18-88e9e1348521) | [introspect](src/postman/notion-api/oauth/introspect/client.ts) | +| `auth-public revoke` | [Revoke](https://go.postman.co/request/52041987-2e4a8940-5ef0-42b2-9c3c-d56c1752e9ac) | [revoke](src/postman/notion-api/oauth/revoke/client.ts) | + +Each generated client file embeds the collection and request IDs in its header, along with the last-modified timestamp: + +```typescript +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Search > Search + * Request UID: 52041987-0e8a4f2d-d453-4bc1-b4c6-286905b87f4a + * Request modified at: 2022-02-24T23:01:58.000Z + */ +``` + +This means that if the Postman Collection request changes, the change can be detected by Postman Code and client code re-regenerated. ## Getting Started @@ -67,18 +145,23 @@ npm link This builds the project and creates a symlink so you can run `notion-cli` from anywhere. To remove it later, run `npm unlink -g notion-cli`. -### 3. Create a Notion Integration +### 3. Set Up a Notion Integration + +Notion supports two types of integrations. Choose one: + +- **Internal** (recommended) — simpler setup, uses a static token. Good for personal use or accessing your own workspace. +- **Public** (OAuth) — uses a browser-based authorization flow. Needed when other Notion users need to install your integration into their own workspaces. + +#### Internal integration 1. Go to [notion.so/my-integrations](https://www.notion.so/my-integrations) -2. Click "New integration" +2. Click "New integration" and choose **Internal** 3. Give it a name (e.g., "notion-cli") 4. Select the workspace you want to access 5. Under "Capabilities", enable "Read content" 6. Click "Submit" and copy the "Internal Integration Secret" -### 4. Share Pages with Your Integration - -Your integration can only access pages that have been explicitly shared with it: +Share pages with your integration — internal integrations can only access pages that have been explicitly shared with them: 1. Open a page or database in Notion 2. Click the `•••` menu in the top right @@ -87,21 +170,52 @@ Your integration can only access pages that have been explicitly shared with it: > **Tip**: Share a top-level page to give access to all its children. -### 5. Set Your API Key +Save your integration token: + +```bash +notion-cli auth-internal set +``` + +This prompts for your token with masked input and stores it in `~/.notion-cli/config.json`. You can also pass the token directly (`notion-cli auth-internal set `) or set the `NOTION_TOKEN` environment variable, which takes precedence over the stored token. -Save your Notion integration token: +Run `notion-cli auth-internal status` to check your current authentication state. + +#### Public integration (OAuth) + +1. Go to [notion.so/my-integrations](https://www.notion.so/my-integrations) +2. Click "New integration" and choose **Public** +3. Give it a name and configure the required fields +4. Set the redirect URI to `http://localhost:8787/callback` +5. Copy the **OAuth client ID** and **OAuth client secret** from the integration settings + +Save your OAuth client credentials: ```bash -notion-cli set-token +notion-cli auth-public setup ``` -This prompts for your token with masked input and stores it in `~/.notion-cli/config.json` so it's available from any directory. You can also pass the token directly (`notion-cli set-token `) or set the `NOTION_API_KEY` environment variable, which takes precedence over the stored token. +This prompts for your client ID and client secret (masked) and stores them in `~/.notion-cli/config.json`. + +Then run the OAuth login flow: + +```bash +notion-cli auth-public login +``` + +This opens your browser for Notion authorization. During authorization, the user selects which pages to share with the integration — no manual sharing step is needed. The CLI runs a local server to handle the callback, exchanges the authorization code for an access token, and stores it automatically. + +Run `notion-cli auth-public status` to check your current authentication state. ## Usage +Every command can produce two kinds of output: + +- **Default (formatted)** — a human-readable, token-efficient summary. Property values are labeled, blocks are indented by depth, and only the most useful fields are shown. This is what you see in the examples below. +- **Raw (`--raw` / `-r`)** — the full API response as JSON, exactly as Notion returned it. Use this when piping into other tools, when an AI agent needs complete data, or when the formatted view doesn't include a field you need. + ### search -Search for pages or find workspace roots: +Search for pages and databases: ```bash # List pages the integration can access (via the Search API) @@ -113,39 +227,261 @@ notion-cli search "meeting notes" # Search for databases instead of pages notion-cli search --filter database -# Find only top-level workspace pages (roots) -notion-cli search --workspace +# Show both pages and databases +notion-cli search --filter all + +# Limit results +notion-cli search -n 5 +``` + +By default, search returns pages only. Use `--filter database` to find databases, or `--filter all` to show both. Results are paginated — use `--cursor` with the cursor from the previous response to fetch the next page. + +Example: + +``` +$ notion-cli search "AI Pilot" -n 2 +🔍 Searching pages for "AI Pilot"... + +Found 2 result(s): + + 📄 AI Pilot Retrospective + ID: d4e5f6a7-b8c9-0123-defa-234567890123 + Parent: page (ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890) + Created: 2/12/2026 + Last edited: 2/12/2026 + Archived: false + URL: https://www.notion.so/AI-Pilot-Retrospective-d4e5f6a7b8c90123defa234567890123 + + 📄 Emergency Rollback Procedures + ID: e4f5a6b7-c8d9-0123-efab-345678901234 + Parent: page (ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890) + Created: 1/15/2026 + Last edited: 2/10/2026 + Archived: false + URL: https://www.notion.so/Emergency-Rollback-Procedures-e4f5a6b7c8d90123efab345678901234 +``` + +### integration pages + +List the root pages the integration can access: + +```bash +notion-cli integration pages +``` + +Returns the entry points for navigating workspace content. A page is considered a root if its parent is the workspace itself, or if its parent page isn't visible to the integration (meaning the integration was shared with a nested page directly). This is the recommended starting point for mapping a workspace — the command paginates through all pages internally and does the filtering, so agents don't need to figure out which pages are roots themselves. + +Example: + +``` +$ notion-cli integration pages +🔍 Finding root pages... + +Found 3 root page(s) (scanned 42 total): + + 📄 AI Adoption Plan + ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890 + Created: 6/15/2025 + Last edited: 2/12/2026 + URL: https://www.notion.so/AI-Adoption-Plan-a1b2c3d45e6f7890abcdef1234567890 + + 📄 Prompt Engineering for Managers + ID: b2c3d4e5-f6a7-8901-bcde-f12345678901 + Created: 8/1/2025 + Last edited: 2/10/2026 + URL: https://www.notion.so/Prompt-Engineering-for-Managers-b2c3d4e5f6a78901bcdef12345678901 + + 📄 Things the AI Broke + ID: c3d4e5f6-a7b8-9012-cdef-123456789012 + Created: 3/20/2025 + Last edited: 1/28/2026 + URL: https://www.notion.so/Things-the-AI-Broke-c3d4e5f6a7b89012cdef123456789012 ``` -By default, search returns pages only. Use `--filter database` to find databases, or `--filter all` to show both. The `--workspace` flag paginates through results internally and returns only pages whose parent is the workspace — these are your starting points for building a sitemap. +### page -### page get +Read and manage Notion pages. + +#### page get Read a page's content: ```bash notion-cli page get - -# Output raw JSON -notion-cli page get --raw ``` Shows page metadata (title, ID, parent, dates, URL), all properties, and content blocks with formatting. Child pages and databases are listed with their IDs for further navigation. The command recursively fetches nested blocks (toggles, callouts, etc.) but stops at child pages and databases. -### database get +Example: + +``` +$ notion-cli page get d4e5f6a7-b8c9-0123-defa-234567890123 +📄 Fetching page... + +Title: AI Pilot Retrospective +ID: d4e5f6a7-b8c9-0123-defa-234567890123 +Parent: page (ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890) +Created: 2/12/2026 +Last edited: 2/12/2026 +Archived: false +URL: https://www.notion.so/AI-Pilot-Retrospective-d4e5f6a7b8c90123defa234567890123 + +Properties (1): + title: AI Pilot Retrospective + +Content (2 blocks): + +──────────────────────────────────────────────────────────── +The AI pilot went mostly well. Marketing loved the generated content. Legal has questions. The chatbot told three customers we offer free shipping to Mars. +Action item: add guardrails before the next demo with the board. Seriously. +──────────────────────────────────────────────────────────── +``` + +#### page create + +Create a new page under a parent page: + +```bash +notion-cli page create --title "My New Page" + +# Create under a database parent +notion-cli page create --title "New Entry" --database +``` + +Example: + +``` +$ notion-cli page create d4e5f6a7-b8c9-0123-defa-234567890123 --title "Lessons Learned" +Page created. + Title: Lessons Learned + ID: d9e0f1a2-b3c4-5678-defa-890123456789 + URL: https://www.notion.so/Lessons-Learned-d9e0f1a2b3c45678defa890123456789 +``` + +#### page update + +Update a page's properties: + +```bash +notion-cli page update --title "New Title" +``` + +Currently supports setting the title via `--title`. For more complex property updates, use the generated client directly. + +Example: + +``` +$ notion-cli page update d4e5f6a7-b8c9-0123-defa-234567890123 --title "AI Pilot Retrospective" +Page updated. + Title: AI Pilot Retrospective + ID: d4e5f6a7-b8c9-0123-defa-234567890123 + URL: https://www.notion.so/AI-Pilot-Retrospective-d4e5f6a7b8c90123defa234567890123 +``` + +#### page archive + +Archive (soft-delete) a page: + +```bash +notion-cli page archive +``` + +The page can be restored later from the Notion UI. + +Example: + +``` +$ notion-cli page archive d9e0f1a2-b3c4-5678-defa-890123456789 +Page archived. + ID: d9e0f1a2-b3c4-5678-defa-890123456789 + Archived: true +``` + +#### page property + +Retrieve a single property value from a page: + +```bash +notion-cli page property + +# Use the property name "title" as a shorthand +notion-cli page property title +``` + +Useful for paginated properties (rollups, relations with many entries, long rich text) where the full value isn't returned by `page get`. Property IDs can be found in `page get --raw` or `database get --raw` output. + +Example: + +``` +$ notion-cli page property d4e5f6a7-b8c9-0123-defa-234567890123 title +Type: property_item +Items: 1 + AI Pilot Retrospective +``` + +#### page move + +Move a page to a new parent: + +```bash +notion-cli page move --parent +``` + +The page keeps its content and properties — only the parent changes. + +Example: + +``` +$ notion-cli page move d9e0f1a2-b3c4-5678-defa-890123456789 --parent a1b2c3d4-5e6f-7890-abcd-ef1234567890 +Page moved. + Title: Lessons Learned + ID: d9e0f1a2-b3c4-5678-defa-890123456789 + New parent: a1b2c3d4-5e6f-7890-abcd-ef1234567890 + URL: https://www.notion.so/Lessons-Learned-d9e0f1a2b3c45678defa890123456789 +``` + +### database + +View and query Notion databases. + +#### database get View a database's metadata and schema: ```bash notion-cli database get - -# Output raw JSON -notion-cli database get --raw ``` -Shows the database title, ID, parent, dates, URL, and schema (property names and types). Use `database list` to see the entries. +Shows the database title, ID, parent, dates, URL, data sources, and schema (property names and types). Use `database list` to see the entries. + +Example: + +``` +$ notion-cli database get a6b7c8d9-e0f1-2345-abcd-567890123456 +🗃️ Fetching database... + +Title: AI Incident Log +ID: a6b7c8d9-e0f1-2345-abcd-567890123456 +Parent: page (ID: a1b2c3d4-5e6f-7890-abcd-ef1234567890) +Created: 9/5/2025 +Last edited: 2/10/2026 +URL: https://www.notion.so/a6b7c8d9e0f12345abcd567890123456 + +Data sources (1): + AI Incident Log — ID: b7c8d9e0-f1a2-3456-bcde-aaaaaaaaaaaa + +Schema (6 properties): + Date: date + Severity: select + Status: select + Blamed On: rich_text + Resolution: rich_text + Name: title + +To list entries: notion-cli database list a6b7c8d9-e0f1-2345-abcd-567890123456 +``` -### database list +#### database list List entries in a database: @@ -159,17 +495,546 @@ notion-cli database list --limit 50 notion-cli database list --cursor ``` -Shows entries with titles, IDs, property values, and URLs. Each entry is a Notion page — use `page get ` to read its full content. +Retrieves the database to find its data source ID, then queries that data source. Shows entries with titles, IDs, property values, and URLs. Each entry is a Notion page — use `page get ` to read its full content. Falls back to the legacy `databases.query()` if no data sources are found. -## Example workflow: mapping a workspace +Example: + +``` +$ notion-cli database list a6b7c8d9-e0f1-2345-abcd-567890123456 --limit 2 +🗃️ Listing database entries... + +Entries (2+): + +──────────────────────────────────────────────────────────── + 📄 Chatbot Told Customer We Sell Jetpacks + ID: b7c8d9e0-f1a2-3456-bcde-678901234567 + Last edited: 1/20/2026 + URL: https://www.notion.so/Chatbot-Told-Customer-We-Sell-Jetpacks-b7c8d9e0f1a23456bcde678901234567 + Date: 2026-01-20 + Severity: High + Status: Resolved + Blamed On: temperature set to 1.0 + Resolution: Added product catalog to system prompt + + 📄 Auto-Reply Sent CEO's Email to Spam + ID: c8d9e0f1-a2b3-4567-cdef-789012345678 + Last edited: 2/3/2026 + URL: https://www.notion.so/Auto-Reply-Sent-CEOs-Email-to-Spam-c8d9e0f1a2b34567cdef789012345678 + Date: 2026-02-03 + Severity: Critical + Status: Investigating + Blamed On: overzealous email filter agent + Resolution: (empty) +``` + +#### database create + +Create a new inline database as a child of a page: + +```bash +notion-cli database create --title "Task Tracker" +``` + +The database is created with a single "Name" title property. Use `database update` to modify properties after creation. + +Example: + +``` +$ notion-cli database create d4e5f6a7-b8c9-0123-defa-234567890123 --title "AI Approved Use Cases" +Database created. + Title: AI Approved Use Cases + ID: e0f1a2b3-c4d5-6789-efab-901234567890 + URL: https://www.notion.so/e0f1a2b3c4d56789efab901234567890 +``` + +#### database update + +Update a database's title or description: + +```bash +notion-cli database update --title "New Title" + +# Update description +notion-cli database update --description "Updated description" + +# Update both +notion-cli database update --title "New" --description "Desc" +``` + +Only the specified fields are updated; others remain unchanged. + +Example: + +``` +$ notion-cli database update e0f1a2b3-c4d5-6789-efab-901234567890 --title "AI Approved Use Cases — Revised" +Database updated. + Title: AI Approved Use Cases — Revised + ID: e0f1a2b3-c4d5-6789-efab-901234567890 + URL: https://www.notion.so/e0f1a2b3c4d56789efab901234567890 +``` + +### datasource + +Work with data sources directly. In Notion's data model, databases contain one or more data sources, and data sources contain pages (entries). The `database` commands work at the database level; `datasource` commands give direct access to the data source layer underneath. + +Data source IDs are shown in the output of `database get`. + +#### datasource get + +Retrieve a data source by ID: + +```bash +notion-cli datasource get +``` + +Shows the data source title, ID, parent database, schema, and a hint to query its entries. + +#### datasource query + +Query entries from a data source: + +```bash +notion-cli datasource query + +# Limit results +notion-cli datasource query --limit 50 + +# Paginate +notion-cli datasource query --cursor +``` + +Returns entries with titles, IDs, property values, and URLs — the same output format as `database list`, but operating directly on a data source ID. + +#### datasource create + +Create a new data source in a database: + +```bash +notion-cli datasource create --title "Q1 Data" +``` + +#### datasource update + +Update a data source's title: + +```bash +notion-cli datasource update --title "New Title" +``` + +#### datasource templates + +List available page templates for a data source: + +```bash +notion-cli datasource templates +``` + +Templates are pages that serve as blueprints for new entries in the data source. + +### block + +Read and manage individual content blocks. + +#### block get + +Retrieve a single block by ID: + +```bash +notion-cli block get +``` + +Shows the block type, content, parent, dates, and whether it has children. Pages are also blocks, so you can pass a page ID. + +Example: + +``` +$ notion-cli block get f1a2b3c4-d5e6-7890-fabc-012345678901 +ID: f1a2b3c4-d5e6-7890-fabc-012345678901 +Type: paragraph +Parent: page (ID: d4e5f6a7-b8c9-0123-defa-234567890123) +Has children: false +Archived: false +Created: 2/12/2026 +Last edited: 2/12/2026 + +Content: + The AI pilot went mostly well. Marketing loved the generated content. Legal has questions. The chatbot told three customers we offer free shipping to Mars. +``` + +#### block children + +List the immediate children of a block or page: + +```bash +notion-cli block children + +# Also works with page IDs +notion-cli block children + +# Limit results +notion-cli block children -n 10 +``` + +Unlike `page get`, this does not recurse into nested blocks — it returns only the direct children. + +Example: + +``` +$ notion-cli block children d4e5f6a7-b8c9-0123-defa-234567890123 +Found 2 block(s): + + The AI pilot went mostly well. Marketing loved the generated content. Legal has questions. The chatbot told three customers we offer free shipping to Mars. + Action item: add guardrails before the next demo with the board. Seriously. +``` + +#### block append + +Append a paragraph block to a page or block: + +```bash +notion-cli block append "Hello, world!" +``` + +Appends a paragraph block containing the given text. For more complex block structures, use the generated client directly. + +Example: + +``` +$ notion-cli block append d4e5f6a7-b8c9-0123-defa-234567890123 "Update the FAQ before the chatbot invents new company policies." +Appended 1 block(s). + Update the FAQ before the chatbot invents new company policies. +``` + +#### block update + +Update a block's text content: + +```bash +notion-cli block update "Updated text" +``` + +Works with paragraph, heading, bulleted list item, numbered list item, to-do, toggle, callout, and quote blocks. The command retrieves the block first to determine its type, then sends the update with the correct type key. + +Example: + +``` +$ notion-cli block update a2b3c4d5-e6f7-8901-abcd-123456789012 "The chatbot no longer makes promises about interplanetary logistics." +Block updated. + ID: a2b3c4d5-e6f7-8901-abcd-123456789012 + Type: paragraph + Content: The chatbot no longer makes promises about interplanetary logistics. +``` + +#### block delete + +Delete (archive) a block: + +```bash +notion-cli block delete +``` + +The block can be restored via the Notion UI or by updating it with `archived: false`. + +Example: + +``` +$ notion-cli block delete a2b3c4d5-e6f7-8901-abcd-123456789012 +Block deleted. + ID: a2b3c4d5-e6f7-8901-abcd-123456789012 + Type: paragraph + Archived: true +``` + +### comment + +Read and manage comments on pages and blocks. + +> **Limitations:** The Notion API only returns **unresolved** comments — if a comment is resolved in the Notion UI, it won't appear in `comment list`. The API also cannot start new inline discussion threads on blocks; `comment add` creates top-level page comments only, and `comment reply` can respond to existing inline threads. +> +> **Requirements:** Both "Read comments" and "Insert comments" capabilities must be enabled in the [Notion integration dashboard](https://www.notion.so/profile/integrations) — they are off by default. + +#### comment list + +List comments on a page or block: + +```bash +notion-cli comment list +``` + +Comments show their text, ID, discussion thread ID, author, and creation date. Comments with the same discussion ID are part of the same thread. + +Example: + +``` +$ notion-cli comment list d4e5f6a7-b8c9-0123-defa-234567890123 +Found 1 comment(s): + + 💬 Can we not demo this to the board until the Mars shipping issue is fixed? + ID: b3c4d5e6-f7a8-9012-bcde-234567890123 + Discussion: c4d5e6f7-a8b9-0123-cdef-345678901234 + Author: a8b9c0d1-e2f3-4567-abcd-789012345678 + Created: 2/12/2026 +``` + +#### comment get + +Retrieve a single comment by ID: + +```bash +notion-cli comment get +``` + +Shows the comment text, ID, discussion thread ID, author, and creation date. Comment IDs can be found in the output of `comment list`. + +Example: + +``` +$ notion-cli comment get b3c4d5e6-f7a8-9012-bcde-234567890123 +💬 Can we not demo this to the board until the Mars shipping issue is fixed? + ID: b3c4d5e6-f7a8-9012-bcde-234567890123 + Discussion: c4d5e6f7-a8b9-0123-cdef-345678901234 + Author: a8b9c0d1-e2f3-4567-abcd-789012345678 + Created: 2/12/2026 +``` + +#### comment add + +Add a comment to a page: + +```bash +notion-cli comment add "This looks great!" +``` + +Creates a new top-level comment thread on the page. + +Example: + +``` +$ notion-cli comment add d4e5f6a7-b8c9-0123-defa-234567890123 "Fixed. It now only promises free shipping to the Moon." +Comment added. + ID: e6f7a8b9-c0d1-2345-efab-567890123456 + Discussion: c4d5e6f7-a8b9-0123-cdef-345678901234 + Created: 2/12/2026 +``` + +#### comment reply + +Reply to an existing comment thread: + +```bash +notion-cli comment reply "I agree!" +``` + +The discussion ID can be found in the output of `comment list`, or from the Notion UI by clicking "Copy link to discussion" on a comment thread — the `d` query parameter in the copied URL is the discussion ID. + +Example: + +``` +$ notion-cli comment reply c4d5e6f7-a8b9-0123-cdef-345678901234 "Good enough. Ship it." +Reply added. + ID: d5e6f7a8-b9c0-1234-defa-456789012345 + Discussion: c4d5e6f7-a8b9-0123-cdef-345678901234 + Created: 2/12/2026 +``` + +### file + +Upload and manage files. + +#### file upload + +Upload a local file to Notion: + +```bash +notion-cli file upload ./report.pdf +``` + +Handles the full upload workflow in one command: creates an upload slot, sends the file data as multipart/form-data, and reports the result. The resulting file upload ID can be used to attach the file to a page or block via the API. + +Example: -The Notion API doesn't have a "list all pages" endpoint, so mapping a workspace typically starts by finding pages whose parent is the workspace, then recursively reading child pages. +``` +$ notion-cli file upload ./report.pdf +📎 Uploading report.pdf (2048576 bytes)... + +File uploaded. + Filename: report.pdf + ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + Content type: application/pdf + Size: 2048576 bytes + Status: uploaded + Created: 2/13/2026 +``` + +#### file list + +List file uploads: + +```bash +notion-cli file list + +# Limit results +notion-cli file list --limit 50 +``` + +Shows each file's name, ID, content type, size, status, and creation date. + +#### file get + +Retrieve a file upload by ID: + +```bash +notion-cli file get +``` + +### auth-internal + +Manage internal integration tokens. + +```bash +# Save your integration token (prompts with masked input) +notion-cli auth-internal set + +# Pass the token directly +notion-cli auth-internal set + +# Check if a token is configured +notion-cli auth-internal status + +# Remove the stored token +notion-cli auth-internal clear +``` + +### auth-public -If you want a workspace sitemap, the high-level loop looks like this: +Authenticate with a public integration using OAuth. + +```bash +# Save your OAuth client ID and secret (prompted, masked) +notion-cli auth-public setup + +# Run the OAuth browser flow +notion-cli auth-public login + +# Print the authorization URL instead of opening the browser +notion-cli auth-public login --no-browser + +# Check OAuth authentication state +notion-cli auth-public status +``` + +#### auth-public introspect + +Inspect a token's metadata: + +```bash +notion-cli auth-public introspect +``` + +Shows whether the token is active, along with bot ID, workspace ID, and expiry information. + +#### auth-public revoke + +Revoke an access token: + +```bash +notion-cli auth-public revoke +``` + +### user + +View Notion users and bot info. + +#### user me + +Show the bot user for your integration token: + +```bash +notion-cli user me +``` + +Example: + +``` +$ notion-cli user me +Name: my-notion-bot +ID: a8b9c0d1-e2f3-4567-abcd-789012345678 +Type: bot +Workspace: Acme Corp +Owner: workspace +``` + +#### user get + +Retrieve a user by ID: + +```bash +notion-cli user get +``` + +Shows the user's name, ID, type (person or bot), and type-specific details (email for people, workspace and owner info for bots). + +Example: + +``` +$ notion-cli user get f7a8b9c0-d1e2-3456-fabc-678901234567 +Name: Alice Chen +ID: f7a8b9c0-d1e2-3456-fabc-678901234567 +Type: person +Email: alice@example.com +``` + +#### user list + +List all users in the workspace: + +```bash +notion-cli user list + +# Limit results +notion-cli user list -n 10 + +# Paginate +notion-cli user list --cursor +``` + +Returns all people and bots in the workspace with their names, IDs, types, emails, and workspace/owner details. + +Example: + +``` +$ notion-cli user list +Found 2 user(s): + + 👤 Alice Chen + ID: f7a8b9c0-d1e2-3456-fabc-678901234567 + Type: person + Email: alice@example.com + + 🤖 my-notion-bot + ID: a8b9c0d1-e2f3-4567-abcd-789012345678 + Type: bot + Workspace: Acme Corp + Owner: workspace +``` + +### docs + +Show guides and workflows for using the CLI: + +```bash +notion-cli docs +``` + +Covers setup, workspace mapping, output/performance notes, and agent usage tips. This is the content that would clutter `--help` — run it once to understand the high-level flows. + +## Example workflow: mapping a workspace + +The Notion API doesn't have a "list all pages" endpoint, so mapping a workspace starts by finding the root pages the integration can access, then recursively reading child pages. ```bash # 1. Find root pages -notion-cli search --workspace +notion-cli integration pages # 2. Read a page to discover its children notion-cli page get @@ -184,44 +1049,77 @@ notion-cli database list notion-cli page get ``` -For AI agents: run `notion-cli --help` — it includes the full workspace mapping workflow, command details, and examples. Each subcommand also has its own `--help` with detailed usage. +For AI agents: run `notion-cli docs` for the full workspace mapping workflow and usage guides. Each subcommand also has its own `--help` with detailed usage. ## Development If you want to modify the CLI, use `npm run cli` during development to run from source without rebuilding: ```bash -npm run cli -- search --workspace +npm run cli -- integration pages npm run cli -- page get npm run cli -- database list ``` After making changes, rebuild with `npm run build` to update the linked `notion-cli` command. -## Project Structure +## Testing +Integration tests call each CLI command against a real Notion workspace to verify end-to-end behavior: argument parsing, API calls, and output formatting. + +```bash +# Run all test suites (concurrent — ~20s) +npm test + +# Run a single suite +npm run test:page +npm run test:database +npm run test:datasource +npm run test:block +npm run test:comment +npm run test:file +npm run test:search +npm run test:user +npm run test:docs +npm run test:integration + +# Auth tests (config management + OAuth API tests when credentials are set) +npm run test:auth ``` -notion-cli/ -├── cli.ts # Main CLI application -├── commands/ # CLI command handlers -├── helpers.ts # Shared formatting utilities -├── src/postman/notion-api/ # Generated API clients -│ ├── index.ts # notion object with client functions -│ ├── blocks/retrieve-block-children/client.ts -│ ├── databases/ -│ │ ├── retrieve-database/client.ts -│ │ └── query-database/client.ts -│ ├── pages/retrieve-page/client.ts -│ ├── search/search/client.ts -│ └── shared/types.ts -└── package.json -``` + +The tests pick up the token stored by `notion-cli auth-internal set` or `notion-cli auth-public login` (in `~/.notion-cli/config.json`), so if you've already authenticated there's nothing extra to configure. You can also set the `NOTION_TOKEN` environment variable, which takes precedence. If no token is found, each suite skips cleanly. + +Tests are split into independent files — each suite creates its own test resources and cleans up afterward. Suites run concurrently when using `npm test`, or individually for faster iteration during development. + +**What's covered:** + +| Suite | Commands tested | Variations | +|-------|----------------|------------| +| `test:docs` | `docs` | prints guide content | +| `test:user` | `user me`, `user list`, `user get` | `--raw`, ID chaining | +| `test:search` | `search` | default pages, `--filter database`, `--filter all`, with query, `--limit` | +| `test:page` | `page get`, `page property`, `page update`, `page create`, `page archive`, `page move` | formatted + `--raw`, create/archive lifecycle, move between parents | +| `test:block` | `block get`, `block children`, `block append`, `block update`, `block delete` | formatted + `--raw`, full CRUD lifecycle | +| `test:comment` | `comment add`, `comment list`, `comment get`, `comment reply` | formatted + `--raw`, thread verification | +| `test:database` | `database create`, `database update`, `database get`, `database list` | `--raw`, data sources, full CRUD lifecycle | +| `test:datasource` | `datasource get`, `datasource query`, `datasource update`, `datasource templates` | formatted + `--raw`, pagination | +| `test:file` | `file upload`, `file list`, `file get` | formatted + `--raw`, full upload lifecycle | +| `test:integration` | `integration pages` | formatted output | +| `test:auth` | `auth-internal` set/status/clear, `auth-public` status/login/introspect/revoke | config management (isolated HOME), OAuth API tests (env var gated) | + +**Requirements:** +- A valid Notion integration token (via `auth-internal set`, `auth-public login`, or env var) +- The integration must have access to at least one workspace page +- The integration needs "Read content" and "Insert content" capabilities +- For OAuth tests: `NOTION_OAUTH_CLIENT_ID` and `NOTION_OAUTH_CLIENT_SECRET` env vars + +Each suite is self-contained — it creates a test page during setup (if needed), runs tests against it, and archives it during teardown. No pre-existing workspace content is required beyond the integration being connected to at least one page. ## Troubleshooting ### "No Notion API key found" -Run `notion-cli set-token ` or set the `NOTION_API_KEY` environment variable. +Run `notion-cli auth-internal set` or `notion-cli auth-public login` to authenticate. You can also set the `NOTION_TOKEN` environment variable. ### "Unauthorized" or "Forbidden" errors @@ -234,4 +1132,4 @@ Your integration can only see pages explicitly shared with it. Share a top-level ## License -MIT +[MIT](LICENSE) diff --git a/notion-cli/TASKS.md b/notion-cli/TASKS.md new file mode 100644 index 0000000..5444bfc --- /dev/null +++ b/notion-cli/TASKS.md @@ -0,0 +1,71 @@ +# Notion CLI — Task Tracker + +Worklist for expanding the CLI to cover the full Notion API collection. + +Collection: **Notion API** (`15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2`) + +## What's done + +| Command | Client | Request ID | +|---------|--------|------------| +| `search` | `search/search/client.ts` | `15568543-816435ec-1d78-4c55-a85e-a1a4a8a24564` | +| `page get` | `pages/retrieve-page/client.ts` | `15568543-8a70cbd1-7dbe-4699-a04e-108084a9c31b` | +| `page create` | `pages/create-page/client.ts` | `15568543-d23f8f9c-e220-46d8-9ac2-aab80b909a42` | +| `page update` | `pages/update-page-properties/client.ts` | `15568543-3f95deb8-805c-4c1c-a487-a090f06da32f` | +| `page archive` | `pages/archive-page/client.ts` | `15568543-5911c414-b183-49d7-8c22-72f60a2d0a98` | +| `page property` | `pages/retrieve-page-property/client.ts` | `15568543-d1bc0b09-7043-4c5d-823f-186fa9110c0f` | +| `database get` | `databases/retrieve-database/client.ts` | `15568543-095c448d-2373-4aee-9b9e-ed3cf58afbe3` | +| `database list` | `databases/query-database/client.ts` | `15568543-cddcc0aa-d534-4744-b37a-ddf36dee7d8f` | +| `database create` | `databases/create-database/client.ts` | `15568543-a027e2d4-11f2-4068-a82c-cb9fb8562036` | +| `database update` | `databases/update-database/client.ts` | `15568543-4ed21a83-c1aa-4e3b-b35e-2133faf2ea51` | +| `block get` | `blocks/retrieve-block/client.ts` | `15568543-9142406e-2a57-4861-9f3e-86fd5c847813` | +| `block children` | `blocks/retrieve-block-children/client.ts` | `15568543-228caaa0-074b-4487-a515-efcea60e5906` | +| `block append` | `blocks/append-block-children/client.ts` | `15568543-dcab1db7-5157-406b-b4ad-65699dc32900` | +| `block update` | `blocks/update-block/client.ts` | `15568543-730dedda-9c55-41e8-9a06-7393f7c8b1c4` | +| `block delete` | `blocks/delete-block/client.ts` | `15568543-b13eff7b-ac0a-4cd8-bce2-080d4211be86` | +| `comment list` | `comments/retrieve-comments/client.ts` | `15568543-a578d9ef-3e9e-4303-b83e-a1ce660ec699` | +| `comment add` | `comments/add-comment-to-page/client.ts` | `15568543-262fbf44-6031-44b5-b91a-a919fea9b311` | +| `comment reply` | `comments/add-comment-to-discussion/client.ts` | `15568543-d78e06cd-6fd2-45b1-af60-1e0b703455df` | +| `user me` | `users/retrieve-bot-user/client.ts` | `15568543-e3aad8e1-2357-4174-92c2-d8ae56637d60` | +| `user get` | `users/retrieve-user/client.ts` | `15568543-c8944457-5f3a-4eb0-add2-8aeb15d9765b` | +| `user list` | `users/list-users/client.ts` | `15568543-f7bf2b64-2739-4539-aee0-326f61b9bfbf` | + +## What's left + +All Notion API collection requests are covered. No remaining tasks. + +> "Sort a database" (`15568543-a3840c38-d152-494c-b62c-cfee89347524`) and "Filter a database" (`15568543-ce121c40-153d-4a00-aade-25eb6cf5245a`) are variants of Query — already covered by `query-database/client.ts`. + +> "Update database properties" (`15568543-9d6773b2-72f5-4e81-929c-94498848cc4e`) is the same endpoint as "Update a database" — covered by `update-database/client.ts`. + +## Summary + +| Resource | Done | Remaining | +|----------|------|-----------| +| Users | 3 | 0 | +| Pages | 5 | 0 | +| Databases | 4 | 0 | +| Blocks | 5 | 0 | +| Comments | 3 | 0 | +| Search | 1 | 0 | +| **Total** | **21** | **0** | + +## Utility commands (not collection-backed) + +These commands exist in the CLI but are not direct mappings to a Notion API collection request, so they are not counted above: + +- `set-token` — stores the Notion token locally +- `integration pages` — derives root pages by paginating the Search API + +## Workflow per task + +1. `postman-code client install -c 15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2 -r -l typescript` +2. Create `client.ts` in the path listed above (follow the pattern in existing clients) +3. Add any new types to `shared/types.ts` (reuse existing types like `NotionUser` when they already cover the response shape) +4. Wire into `index.ts` (import function, import types, add to client object, add to type re-exports) +5. Add CLI command in `commands/.ts` +6. Register in `cli.ts` if new command group +7. Add integration test(s) in `test/integration.test.ts` — follow the existing pattern: use `--raw` for commands that need to capture IDs, formatted output for others. If the new command needs an ID from a prior command, add it in the right position in the test sequence. +8. Build and run `npm run test:integration` to verify +9. Update this file: move task to "What's done" table, remove from "What's left", update summary counts +10. Update `README.md`: add usage docs for the new command, update the testing table, update project structure if new directories were created diff --git a/notion-cli/UPGRADE.md b/notion-cli/UPGRADE.md new file mode 100644 index 0000000..4eeb9ff --- /dev/null +++ b/notion-cli/UPGRADE.md @@ -0,0 +1,450 @@ +# Notion API Upgrade: 2022-02-22 → 2025-09-03 + +Tracking the upgrade of the Notion CLI from the old Notion API collection to the **Notion API (2025-09-03)** collection. + +- **Old collection UID:** `15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2` +- **New collection UID:** `52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9` +- **Notion-Version header:** `2022-02-22` → `2025-09-03` + +--- + +## Summary of API changes + +### Breaking: Database query → Data source query + +The biggest change. Databases now act as containers for **data sources**, and querying moves from `/v1/databases/{database_id}/query` to `/v1/data_sources/{data_source_id}/query`. Database IDs and data source IDs are not interchangeable. To get a data source ID, first retrieve the database and extract it from the `data_sources` array. + +This breaks our `database list` command and the `databases.query()` client. + +### New resource: Data sources (5 endpoints) + +| Endpoint | Method | URL | +|----------|--------|-----| +| Retrieve a data source | GET | `/v1/data_sources/{data_source_id}` | +| Update a data source | PATCH | `/v1/data_sources/{data_source_id}` | +| Query a data source | POST | `/v1/data_sources/{data_source_id}/query` | +| List data source templates | GET | `/v1/data_sources/{data_source_id}/templates` | +| Create a data source | POST | `/v1/data_sources` | + +### New: Move page + +`POST /v1/pages/{page_id}/move` — moves a page to a new parent page or database. + +### New: Retrieve a comment + +`GET /v1/comments/{comment_id}` — retrieves a single comment by ID. + +### New resource: File uploads (5 endpoints) + +Multi-part file upload support. Lifecycle: create → send (chunks) → complete. + +| Endpoint | Method | URL | +|----------|--------|-----| +| Create a file upload | POST | `/v1/file_uploads` | +| Send file upload | POST | `/v1/file_uploads/{file_upload_id}/send` | +| Complete file upload | POST | `/v1/file_uploads/{file_upload_id}/complete` | +| Retrieve a file upload | GET | `/v1/file_uploads/{file_upload_id}` | +| List file uploads | GET | `/v1/file_uploads` | + +### New resource: OAuth (3 endpoints) + +Token management for OAuth integrations. These use **Basic auth** (`base64(client_id:client_secret)`), not bearer token auth. + +| Endpoint | Method | URL | Purpose | +|----------|--------|-----|---------| +| Token | POST | `/v1/oauth/token` | Exchange authorization code for access token | +| Revoke | POST | `/v1/oauth/revoke` | Revoke an access/refresh token | +| Introspect | POST | `/v1/oauth/introspect` | Inspect a token's metadata | + +### Response shape changes in existing endpoints + +Comparing the new collection's example responses against the old, two changes affect our code: + +**1. Page parent type: `database_id` → `data_source_id`** + +Pages that live inside databases now report their parent as `data_source_id` instead of `database_id`: + +```json +// Old +"parent": { "type": "database_id", "database_id": "668d797c-..." } + +// New (2025-09-03) +"parent": { "type": "data_source_id", "data_source_id": "668d797c-..." } +``` + +This affects any code that checks `parent.type`. In our CLI: +- `page get` — displays parent info +- `integration pages` — filters pages by parent type to find roots +- `search` — displays parent type in results +- The `formatParent()` helper or equivalent logic + +**2. Database response includes `data_sources` array** + +`GET /v1/databases/{database_id}` now returns a `data_sources` array: + +```json +"data_sources": [ + { "id": "abc-123-data-source-id", "name": "Main Data Source" } +] +``` + +Plus new fields: `is_locked`, `in_trash`. Our `database get` command should display the data source IDs (users will need them for the new query flow). + +**3. Page response includes `in_trash` field** + +Minor addition. The `in_trash` boolean is separate from `archived`. Not critical but worth surfacing in output. + +**Request shapes unchanged** — All existing endpoint URLs, HTTP methods, and request body structures are identical. The only request-level change is the `Notion-Version` header value (`2022-02-22` → `2025-09-03`), which is set at runtime via `shared/variables.ts`. + +### Comments consolidated + +The old collection had two separate requests: "Add comment to page" and "Add comment to discussion". The new collection has a single "Create a comment" — the body determines which behavior (include `parent.page_id` for a page comment, or `discussion_id` for a reply). Our two existing clients still work fine, but we could consolidate. + +### Archive page removed from collection + +The old collection had a dedicated "Archive a page" request. This was always just `PATCH /v1/pages/{page_id}` with `{ archived: true }` — same as update page properties. Our `archivePage` client is fine; it just won't have a dedicated collection request backing it in the new collection. + +--- + +## Understanding OAuth vs. integration tokens + +The CLI currently uses **internal integration tokens** — a user creates an integration at notion.so/my-integrations, copies the secret, and stores it via `set-token`. Simple and direct. + +**OAuth** is the mechanism for **public integrations** — apps that other Notion users can install into their workspaces (the "Add to Notion" flow). The flow: + +1. User visits an authorization URL in their browser +2. They approve the app's requested permissions +3. Notion redirects back with an authorization code +4. The app exchanges the code for an access token via `POST /v1/oauth/token` (using Basic auth with client_id:client_secret) +5. The access token is used as a bearer token for subsequent API calls (same as integration tokens) + +For the CLI, adding OAuth support would mean: +- A `login` or `auth` command that opens the browser, runs a local HTTP server for the redirect callback, captures the authorization code, and exchanges it for a token +- Token storage (and refresh, if applicable) +- The Token, Revoke, and Introspect endpoints are for managing these OAuth tokens + +**Decision:** CLI commands for the OAuth building blocks (`oauth token`, `oauth introspect`, `oauth revoke`) are implemented. They require `NOTION_OAUTH_CLIENT_ID` and `NOTION_OAUTH_CLIENT_SECRET` env vars. The interactive browser-based login flow (`oauth login`) is not yet built — it would open the browser, spin up a local HTTP server at `http://localhost:9876/callback` for the redirect, exchange the code, and store the token. The internal integration token flow (`set-token`) remains the primary auth method. + +--- + +## Upgrade steps + +### Phase 1: Foundation + +- [x] **1.1 Update `shared/variables.ts`** — Change `NOTION_VERSION` from `2022-02-22` to `2025-09-03`. Add new variables (`data_source_id`, `file_upload_id`, `comment_id`, etc.). + +- [x] **1.2 Regenerate all existing clients** — Updated all 21 client headers to reference the new collection UID and request UIDs. Code unchanged since `notionVersion` is passed as a parameter. The existing 21 clients: + - `blocks/`: append-block-children, delete-block, retrieve-block, retrieve-block-children, update-block + - `comments/`: add-comment-to-page, add-comment-to-discussion, retrieve-comments + - `databases/`: create-database, query-database, retrieve-database, update-database + - `pages/`: archive-page, create-page, retrieve-page, retrieve-page-property, update-page-properties + - `search/`: search + - `users/`: list-users, retrieve-bot-user, retrieve-user + +- [x] **1.3 Handle the query-database → data source migration** — The `query-database` client currently hits `/v1/databases/{database_id}/query`. In the new API this endpoint doesn't exist. Two options: + - (a) Replace `query-database` with the new `query-data-source` client and update `database list` to first retrieve the database, extract the data source ID, then query the data source + - (b) Keep a backwards-compatible wrapper + + Option (a) is cleaner. The `database list` command will need a two-step flow. + +- [x] **1.4 Update `index.ts`** — Add new namespaces (`dataSources`, `fileUploads`, `oauth`) and re-export new clients. + +### Phase 1b: Handle response shape changes + +- [x] **1b.1 Update parent type handling** — Everywhere we check `parent.type === "database_id"`, add handling for `"data_source_id"`. This includes the page display logic, the `integration pages` root-detection logic, and search result formatting. Grep for `database_id` in the commands layer to find all affected spots. + +- [x] **1b.2 Update `database get` output** — Display the `data_sources` array (ID and name for each) so users can see data source IDs. These IDs are needed for the new query flow. + +- [x] **1b.3 Update `database list` to extract data source ID** — Before querying, call `databases.retrieve()` first, extract the data source ID from `data_sources[0].id`, then call `dataSources.query()` with that ID. This is the migration path for the removed `/v1/databases/{id}/query` endpoint. + +- [x] **1b.4 Surface `in_trash` in page/database output** — Minor. Show `In trash: true` when present, alongside the existing `Archived` field. + +### Phase 2: New data source clients & commands + +- [x] **2.1 Generate data source clients** (5 new clients): + - `data-sources/retrieve-data-source/client.ts` + - `data-sources/update-data-source/client.ts` + - `data-sources/query-data-source/client.ts` + - `data-sources/list-data-source-templates/client.ts` + - `data-sources/create-data-source/client.ts` + +- [x] **2.2 Add `dataSources` namespace to `index.ts`** — Methods: `retrieve`, `update`, `query`, `create`, `listTemplates` + +- [x] **2.3 Update `database list` command** — Change to a two-step flow: retrieve database → extract data source ID → query data source. The command output should remain the same. + +- [x] **2.4 Add `datasource` CLI commands** — New top-level command group: + - `datasource get ` — retrieve a data source + - `datasource query ` — query entries from a data source + - `datasource templates ` — list page templates + - `datasource create --title "..."` — create a data source in a database + - `datasource update --title "..."` — update a data source + +### Phase 3: New page, comment, and file upload clients & commands + +- [x] **3.1 Generate `move-page` client** — `pages/move-page/client.ts` + +- [x] **3.2 Add `page move` command** — `notion-cli page move --parent ` + +- [x] **3.3 Generate `retrieve-comment` client** — `comments/retrieve-comment/client.ts` + +- [x] **3.4 Add `comment get` command** — `notion-cli comment get ` + +- [x] **3.5 Generate file upload clients** (5 new clients): + - `file-uploads/create-file-upload/client.ts` + - `file-uploads/send-file-upload/client.ts` + - `file-uploads/complete-file-upload/client.ts` + - `file-uploads/retrieve-file-upload/client.ts` + - `file-uploads/list-file-uploads/client.ts` + +- [x] **3.6 Add `fileUploads` namespace to `index.ts`** — Methods: `create`, `send`, `complete`, `retrieve`, `list` + +- [x] **3.7 Add `file` CLI commands**: + - `file upload ` — creates upload, sends via multipart/form-data, reports result + - `file list` — list file uploads + - `file get ` — retrieve a file upload + +### Phase 4: OAuth clients + +- [x] **4.1 Generate OAuth clients** (3 new clients): + - `oauth/token/client.ts` + - `oauth/revoke/client.ts` + - `oauth/introspect/client.ts` + + Note: these use Basic auth, not Bearer token. The clients will need a different auth mechanism than the rest. + +- [x] **4.2 Add `oauth` namespace to `index.ts`** — Methods: `token`, `revoke`, `introspect` + +- [x] **4.3 Add OAuth CLI commands** — `oauth token`, `oauth introspect`, `oauth revoke`. All require `NOTION_OAUTH_CLIENT_ID` and `NOTION_OAUTH_CLIENT_SECRET` env vars. The interactive `oauth login` browser flow is not yet built. + +### Phase 5: Cleanup & testing + +- [ ] **5.1 Consider consolidating comment clients** — The old "add-comment-to-page" and "add-comment-to-discussion" could become a single "create-comment" client. Low priority since existing behavior works. + +- [x] **5.2 Update integration tests** — Added tests for `page move`, `comment get` (formatted + raw), `datasource` (get, query, update, templates — all with raw variants), `file` (upload, list, get — all with raw variants). Updated OAuth tests to cover CLI commands alongside client tests. 43 tests across 10 suites, all passing. + +- [x] **5.3 Update README.md** — Documented all new commands (datasource, file, oauth, page move, comment get), the data source concept, file uploads, OAuth, and the API version. Updated the intro paragraph to mention 2025-09-03. + +- [x] **5.4 Update the command table in README** — Added rows for all 13 new commands with collection request links and generated client paths. + +--- + +## New collection request ID reference + +For use with `postman-code client install -c 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 -r `: + +### Existing endpoints (new request IDs) + +| Endpoint | Request UID | +|----------|-------------| +| Retrieve bot user | `52041987-30ad8b7b-5eb0-4bbe-bfbf-509d7f961cae` | +| Retrieve a user | `52041987-e23bf76f-cb3a-4bbc-9e5a-0a39cd59e909` | +| List all users | `52041987-1f6b9bec-dc7d-4412-9e80-46a33b8b11c6` | +| Retrieve a page property item | `52041987-e3c019ad-9c8b-4975-a142-5549ef771028` | +| Retrieve a page | `52041987-d7e520f6-0c75-4fe0-9b23-990f742d496e` | +| Create a page | `52041987-a2ef9963-62e0-4e87-a12b-f899f695280c` | +| Update page properties | `52041987-de2726f0-1465-4fdc-81d5-bd35415848b4` | +| Retrieve block children | `52041987-039ea5be-709a-4539-b021-170a63eba771` | +| Append block children | `52041987-a9376866-eb97-4cfa-b08d-5fc49f09ef26` | +| Retrieve a block | `52041987-30ea7fcd-b8b4-441f-935a-c9d143d59d66` | +| Update a block | `52041987-1a96de40-de2c-49c8-9697-fc91b077d06d` | +| Delete a block | `52041987-95e3f732-e993-42b4-8451-70178b3d2ac9` | +| Retrieve a database | `52041987-73359528-2278-415f-98f2-4d20274cc69e` | +| Create a database | `52041987-85ab373b-2fc1-4b9a-a8a6-ee6b9e72728c` | +| Update a database | `52041987-5febd2f6-c9ff-486d-8a4b-71e5c58f82ef` | +| Search | `52041987-0e8a4f2d-d453-4bc1-b4c6-286905b87f4a` | +| Create a comment | `52041987-9261f5ec-6b04-4d13-83a0-d12fe1b188c7` | +| Retrieve comments | `52041987-4def4425-319e-418c-9d96-56a4807a8ce7` | + +### New endpoints + +| Endpoint | Request UID | +|----------|-------------| +| Move page | `52041987-9686de7b-77c0-4d53-b800-bf6c748bc668` | +| Retrieve a comment | `52041987-2f312153-d16c-459b-9d51-88358a96fe03` | +| Retrieve a data source | `52041987-dfeeac14-f85e-4527-ad2e-d85f79284dd9` | +| Update a data source | `52041987-29f06253-bd7e-4c3c-b0d8-a36b285c4e0e` | +| Query a data source | `52041987-aa498c21-f7e7-4839-bbe7-78957fb7379d` | +| List data source templates | `52041987-f38c907f-36d5-40c7-b057-e4811b4b5cde` | +| Create a data source | `52041987-9c41977a-1606-4c76-a4e0-d094a3d0b4c7` | +| Create a file upload | `52041987-1548ae35-ac12-4fc3-a337-416ed2a92088` | +| Send file upload | `52041987-aaf29c9a-7236-432d-97e8-beebee88b3cd` | +| Complete file upload | `52041987-80549896-30b1-43a4-add0-a73958b602e1` | +| Retrieve a file upload | `52041987-2f30fd9c-c12b-40fc-bb98-03d153c3e353` | +| List file uploads | `52041987-d6b82f81-aaf2-4cc0-b92c-c8cdf709e67d` | +| OAuth Token | `52041987-ced3cc2e-170e-40fa-8f39-fee0f6a464d7` | +| OAuth Revoke | `52041987-2e4a8940-5ef0-42b2-9c3c-d56c1752e9ac` | +| OAuth Introspect | `52041987-3070c020-0b6d-402f-bd18-88e9e1348521` | + +--- + +## Upgrade log + +What actually happened when the agent executed this upgrade, in order. + +### 1. Assessed what needed to change vs. what didn't + +Before touching code, read the existing `search/search/client.ts` to check whether `notionVersion` was hardcoded or passed as a parameter. It's a parameter — injected from `shared/variables.ts` via `index.ts` at client creation time. This meant the 21 existing client functions didn't need code changes, only header metadata updates (collection UID, request UID, modified timestamp). The actual HTTP code is identical between the old and new collections for every existing endpoint. + +This was the key insight that shaped the whole approach: the generated client layer is a thin, parameterized HTTP wrapper. The version upgrade flows through `variables.ts` → `index.ts` → every client call. No per-client code changes needed for existing endpoints. + +### 2. Updated `shared/variables.ts` + +Changed `NOTION_VERSION` from `2022-02-22` to `2025-09-03`. Added the three new collection variables: `COMMENT_ID`, `DATA_SOURCE_ID`, `FILE_UPLOAD_ID`. Updated the collection UID in the file header. + +### 3. Updated all 21 existing client headers + +Used the request ID reference table (already in this document) to map each existing client file to its new request UID in the new collection. Updated in a single batch: + +- **Collection name**: `Notion API` → `Notion API (2025-09-03)` +- **Collection UID**: `15568543-d990f9b7-...` → `52041987-03f70d8f-...` +- **Request UIDs**: each mapped individually to the new collection's request ID +- **Request paths**: updated where names changed (e.g., "Add comment to page" → "Create a comment") + +Special cases: +- `archive-page` — no dedicated request in the new collection. Mapped to "Update page properties" with a note, since archive is just `PATCH /v1/pages/{id}` with `{ archived: true }`. +- `query-database` — endpoint removed in the new API. Header updated with deprecation note; the client is kept for fallback but `database list` now uses the data source flow. +- Both comment clients (`add-comment-to-page`, `add-comment-to-discussion`) — now map to the same "Create a comment" request UID, since the new collection consolidated them. + +### 4. Updated `shared/types.ts` for new response shapes + +Four changes to existing types: +- `PageParent.type` — added `"data_source_id"` to the union and `data_source_id?: string` field +- `NotionPage` — added `in_trash?: boolean` +- `NotionDatabase` — added `is_locked?: boolean`, `in_trash?: boolean`, `data_sources?: DataSourceReference[]` +- New `DataSourceReference` interface (`id`, `name`) + +New types added: +- `QueryDataSourceParams` / `QueryDataSourceResponse` — mirrors the old database query types +- `CreateDataSourceParams` / `UpdateDataSourceParams` +- `MovePageParams` + +### 5. Generated 15 new client files + +Used `postman-code client install` to fetch the API context for each new endpoint, then wrote client code matching the existing project conventions (same patterns as the 21 existing clients: explicit function parameters, typed responses, `NotionError` handling, JSDoc with `@see` links). + +**Data sources** (5 clients — `data-sources/` directory): +- `query-data-source` — POST `/v1/data_sources/{id}/query` +- `retrieve-data-source` — GET `/v1/data_sources/{id}` +- `update-data-source` — PATCH `/v1/data_sources/{id}` +- `create-data-source` — POST `/v1/data_sources` +- `list-data-source-templates` — GET `/v1/data_sources/{id}/templates` + +**Pages & comments** (2 clients): +- `move-page` — POST `/v1/pages/{id}/move` +- `retrieve-comment` — GET `/v1/comments/{id}` + +**File uploads** (5 clients — `file-uploads/` directory): +- `create-file-upload` — POST `/v1/file_uploads` +- `send-file-upload` — POST `/v1/file_uploads/{id}/send` +- `complete-file-upload` — POST `/v1/file_uploads/{id}/complete` +- `retrieve-file-upload` — GET `/v1/file_uploads/{id}` +- `list-file-uploads` — GET `/v1/file_uploads` + +**OAuth** (3 clients — `oauth/` directory): +- `token` — POST `/v1/oauth/token` (Basic auth, not Bearer) +- `revoke` — POST `/v1/oauth/revoke` (Basic auth) +- `introspect` — POST `/v1/oauth/introspect` (Basic auth) + +The OAuth clients are the only ones that don't use the standard Bearer token auth pattern — they accept `clientId` and `clientSecret` parameters and use Basic auth, matching the Notion OAuth spec. + +### 6. Rewrote `index.ts` with new namespaces + +Added four new namespaces to the `createNotionClient()` return object: +- `dataSources` — `retrieve`, `query`, `create`, `update`, `listTemplates` +- `pages.move` — added to existing pages namespace +- `comments.retrieve` — added to existing comments namespace +- `fileUploads` — `create`, `send`, `complete`, `retrieve`, `list` +- `oauth` — `token`, `revoke`, `introspect` + +Marked `databases.query()` as `@deprecated` with a pointer to `dataSources.query()`. + +Re-exported all new types. + +### 7. Updated command layer for response shape changes + +**Parent type handling** (`page.ts`, `search.ts`, `integration.ts`): +- Added `parent.type === "data_source_id"` branches wherever `"database_id"` was checked +- In `integration.ts`, the root-detection filter now explicitly handles `data_source_id` parents as non-roots (same as `database_id`) + +**`database get`** (`database.ts`): +- Now displays the `data_sources` array with IDs and names +- Shows `is_locked` and `in_trash` fields when present +- Hints the data source ID in the "To list entries" footer + +**`database list`** (`database.ts`): +- Changed to a two-step flow: first retrieves the database to extract `data_sources[0].id`, then calls `dataSources.query()` with that ID +- Falls back to the legacy `databases.query()` if no data sources array is present + +**`page get`** (`page.ts`): +- Now shows `Archived` and `In trash` fields in output + +### 8. Build verification + +Ran `npm run build` (TypeScript compiler). Clean build, no errors. All 36 client files (21 existing + 15 new), the updated `index.ts`, and the updated command files compile without issues. + +### 9. Integration tests and additional breaking changes + +Updated the integration test suite to account for API version changes and new response shapes. + +**Additional breaking changes discovered by running the tests:** + +- **Search filter value**: The 2025-09-03 API changed the search filter value from `"database"` to `"data_source"`. Updated `SearchFilter` type and added a `FILTER_API_VALUE` mapping in `search.ts` so the CLI's `--filter database` flag maps to the new API value transparently. +- **Database create parent requires explicit `type`**: The new API rejects `{ page_id: id }` and requires `{ type: "page_id", page_id: id }`. Updated `CreateDatabaseParams` type and `database.ts` command. +- **Database `properties` may be absent**: Freshly created databases no longer include a `properties` field in the response. Added a guard (`database.properties || {}`) in the `database get` display. +- **`archived` field on databases**: May be absent in the 2025-09-03 response. Now only displayed when present. + +**Test changes** (`integration.test.ts`): +- Added `Archived:` assertion to `page get` test +- Added new `database get shows data sources when present` test +- Relaxed `database get` schema assertion (new API may not return properties on fresh databases) + +**OAuth test suite** (`oauth.test.ts`): +- New file with env var gating — skips cleanly when `NOTION_OAUTH_CLIENT_ID` / `NOTION_OAUTH_CLIENT_SECRET` are not set +- Tests: token exchange error handling, introspect (when token provided), revoke (commented out — destructive) +- Added `test:oauth` npm script +- OAuth redirect URI convention: `http://localhost:9876/callback` (CLI will spin up a temp local server) + +All 29 integration tests pass. OAuth tests skip cleanly. + +### 10. Wired up all new CLI commands and fixed clients + +Added 10 new subcommands across 5 areas: + +**New command groups** (3 new files in `commands/`): +- `datasource` — `get`, `query`, `templates`, `create`, `update`. Operates directly on data source IDs. Keeps a clean boundary with `database` commands (no crossing API resources). +- `file` — `upload`, `list`, `get`. The `upload` command handles the full create → send → complete lifecycle in one step. +- `oauth` — `token`, `introspect`, `revoke`. All gated on `NOTION_OAUTH_CLIENT_ID` and `NOTION_OAUTH_CLIENT_SECRET` env vars. + +**Added to existing command groups:** +- `page move` — moves a page to a new parent via `--parent ` +- `comment get` — retrieves a single comment by ID + +**Client fixes** (3 clients had mismatches between the Postman collection definitions and the real Notion API): +- `create-file-upload` — collection had `{ name, size }` but API expects `{ mode, filename, content_type }`. Rewrote params and response type. +- `send-file-upload` — collection had JSON body with base64 data but API expects `multipart/form-data` with raw binary. Rewrote to use FormData/Blob. +- `list-data-source-templates` — response shape is `{ templates: [...] }` not `{ object: "list", results: [...] }`. Fixed response type, added query params support. + +**File upload workflow refinement:** +- For `single_part` mode, the send step auto-transitions the upload to `uploaded` status, so the complete step is skipped. Complete is only called for `multi_part` uploads. + +**Tests:** +- Added `page move` test (create two pages, move one under the other, verify, move back) +- Added `comment get` tests (formatted + raw) +- New `datasource.test.ts` — 6 tests covering get, query, update, templates (all with raw variants) +- New `file.test.ts` — 6 tests covering upload, list, get (all with raw variants) +- Updated `oauth.test.ts` to test CLI commands alongside client-level tests +- Added `test:datasource` and `test:file` npm scripts + +All 43 integration tests pass across 10 suites. OAuth tests skip cleanly without credentials. + +**README updates:** +- Intro mentions API version `2025-09-03` and OAuth support +- "What you can do" section covers data sources, file uploads, OAuth, page move +- Full usage documentation for all new commands +- Command table has rows for all 13 new commands +- Test coverage table updated with new suites + +### What's left + +- [x] **5.1 Consolidate comment clients** — Replaced `add-comment-to-page` and `add-comment-to-discussion` with a single `create-comment` client. The `CreateCommentParams` interface accepts either `parent.page_id` (new thread) or `discussion_id` (reply). The CLI commands (`comment add`, `comment reply`) now both call `notion.comments.create()`. +- [ ] **OAuth login flow** — An interactive `oauth login` command that opens the browser, spins up a local HTTP server for the redirect callback, exchanges the code, and stores the token. The building blocks (`oauth token`, `introspect`, `revoke`) are in place. diff --git a/notion-cli/cli.ts b/notion-cli/cli.ts index c74d83e..505978d 100644 --- a/notion-cli/cli.ts +++ b/notion-cli/cli.ts @@ -7,10 +7,19 @@ */ import { Command } from "commander"; +import { authInternalCommand } from "./commands/auth-internal.js"; +import { authPublicCommand } from "./commands/auth-public.js"; import { setTokenCommand } from "./commands/set-token.js"; import { searchCommand } from "./commands/search.js"; import { pageCommand } from "./commands/page.js"; import { databaseCommand } from "./commands/database.js"; +import { datasourceCommand } from "./commands/datasource.js"; +import { userCommand } from "./commands/user.js"; +import { blockCommand } from "./commands/block.js"; +import { commentCommand } from "./commands/comment.js"; +import { fileCommand } from "./commands/file.js"; +import { integrationCommand } from "./commands/integration.js"; +import { docsCommand } from "./commands/docs.js"; const program = new Command(); @@ -21,59 +30,23 @@ program .addHelpText( "after", ` -Setup: - 1. Create a Notion integration at https://www.notion.so/my-integrations - 2. Save your token: notion-cli set-token - 3. Share pages/databases with your integration in Notion - -Commands: - search Search for pages and databases - page get Read a page's content and properties - database get View a database's metadata and schema - database list List entries in a database - -Mapping a Workspace: - To build a complete tree of a Notion workspace, follow this workflow: - - Step 1 – Find root pages: - $ notion-cli search --workspace - - Step 2 – Read each root page: - $ notion-cli page get - Look for child pages (📄) and child databases (🗃️) in the output. - - Step 3 – View a database's schema: - $ notion-cli database get - - Step 4 – List entries in a database: - $ notion-cli database list - - Step 5 – Read an entry or child page: - $ notion-cli page get - - Repeat steps 2-5 to traverse the tree. - - Key points: - • Start from roots: search --workspace finds your starting points - • One page at a time: page get lists children but doesn't traverse them - • Databases have two views: schema (get) and entries (list) - • IDs are in output: every child shows its ID for easy extraction - -Output: - All commands output human-readable text by default. - Use --raw on page get and database get/list for JSON output. - -Performance: - • Block fetching uses parallel API calls for speed - • Large pages (100+ blocks) may take 5-15 seconds - • Database list queries are fast (single API call) - • search --workspace paginates internally (may take a few seconds) +Run "notion-cli docs" for setup instructions, workspace mapping workflow, and usage guides. `, ); -program.addCommand(setTokenCommand); +program.addCommand(authInternalCommand); +program.addCommand(authPublicCommand); +// Kept for backwards compatibility; prefer "auth-internal set" +program.addCommand(setTokenCommand, { hidden: true }); program.addCommand(searchCommand); program.addCommand(pageCommand); program.addCommand(databaseCommand); +program.addCommand(datasourceCommand); +program.addCommand(blockCommand); +program.addCommand(commentCommand); +program.addCommand(fileCommand); +program.addCommand(userCommand); +program.addCommand(integrationCommand); +program.addCommand(docsCommand); program.parse(); diff --git a/notion-cli/commands/auth-internal.ts b/notion-cli/commands/auth-internal.ts new file mode 100644 index 0000000..69cbd11 --- /dev/null +++ b/notion-cli/commands/auth-internal.ts @@ -0,0 +1,171 @@ +/** + * auth-internal command group — internal integration authentication + * + * Internal integrations are created at https://www.notion.so/my-integrations. + * Authentication is a single secret token — no OAuth flow needed. + * + * auth-internal set [token] — save your integration token + * auth-internal status — check if a token is configured + * auth-internal clear — remove the stored token + */ + +import { createInterface } from "readline"; +import { Command } from "commander"; +import { readConfig, writeConfig, CONFIG_FILE } from "../helpers.js"; + +// ============================================================================ +// Masked input helper +// ============================================================================ + +function promptMasked(message: string): Promise { + return new Promise((resolve) => { + process.stdout.write(message); + + if (!process.stdin.isTTY) { + const rl = createInterface({ input: process.stdin, terminal: false }); + rl.once("line", (line) => { + rl.close(); + resolve(line.trim()); + }); + return; + } + + process.stdin.setRawMode(true); + process.stdin.resume(); + let input = ""; + + const onData = (key: Buffer) => { + const chars = key.toString(); + for (const ch of chars) { + if (ch === "\n" || ch === "\r" || ch === "\u0004") { + process.stdin.setRawMode(false); + process.stdin.removeListener("data", onData); + process.stdin.pause(); + process.stdout.write("\n"); + resolve(input); + return; + } else if (ch === "\u0003") { + process.stdin.setRawMode(false); + process.stdout.write("\n"); + process.exit(0); + } else if (ch === "\u007F" || ch === "\b") { + if (input.length > 0) { + input = input.slice(0, -1); + process.stdout.write("\b \b"); + } + } else { + input += ch; + process.stdout.write("*"); + } + } + }; + process.stdin.on("data", onData); + }); +} + +// ============================================================================ +// auth-internal set +// ============================================================================ + +const setCommand = new Command("set") + .description("Save your integration token") + .argument("[token]", "integration secret (omit to enter interactively)") + .action(async (token?: string) => { + if (!token) { + token = await promptMasked("Integration token: "); + } + + if (!token) { + console.error("No token provided."); + process.exit(1); + } + + const config = readConfig(); + config.NOTION_TOKEN = token; + config.auth_method = "internal"; + // Clear any OAuth metadata if switching methods + delete (config as Record).oauth_bot_id; + delete (config as Record).oauth_workspace_name; + delete (config as Record).oauth_workspace_id; + writeConfig(config as Record); + console.log(`Token saved to ${CONFIG_FILE}`); + process.exit(0); + }); + +// ============================================================================ +// auth-internal status +// ============================================================================ + +const statusCommand = new Command("status") + .description("Check if an integration token is configured") + .action(async () => { + const config = readConfig(); + const envToken = process.env.NOTION_TOKEN; + const configToken = config.NOTION_TOKEN; + const token = envToken || configToken; + + if (!token) { + console.log("No integration token configured."); + console.log(""); + console.log("To set one:"); + console.log(" notion-cli auth-internal set "); + return; + } + + console.log("Integration token configured."); + if (envToken) { + console.log(" Source: NOTION_TOKEN environment variable"); + } else { + console.log(` Source: ${CONFIG_FILE}`); + } + console.log(` Token: ${token.slice(0, 12)}...`); + if (config.auth_method) { + console.log(` Auth method: ${config.auth_method}`); + } + }); + +// ============================================================================ +// auth-internal clear +// ============================================================================ + +const clearCommand = new Command("clear") + .description("Remove the stored integration token") + .action(async () => { + const config = readConfig(); + if (!config.NOTION_TOKEN) { + console.log("No stored token to remove."); + return; + } + + delete (config as Record).NOTION_TOKEN; + delete (config as Record).auth_method; + writeConfig(config as Record); + console.log("Integration token removed."); + + if (process.env.NOTION_TOKEN) { + console.log("Note: NOTION_TOKEN environment variable is still set."); + } + }); + +// ============================================================================ +// auth-internal command group +// ============================================================================ + +export const authInternalCommand = new Command("auth-internal") + .description("Authenticate with an internal integration token") + .addHelpText( + "after", + ` +Internal integrations use a single secret token for authentication. +No OAuth flow or browser interaction needed. + +To get started: + 1. Go to https://www.notion.so/my-integrations + 2. Create an integration (or select an existing one) + 3. Copy the "Internal Integration Secret" + 4. Run: notion-cli auth-internal set +`, + ) + .addCommand(setCommand) + .addCommand(statusCommand) + .addCommand(clearCommand); diff --git a/notion-cli/commands/auth-public.ts b/notion-cli/commands/auth-public.ts new file mode 100644 index 0000000..3eec75e --- /dev/null +++ b/notion-cli/commands/auth-public.ts @@ -0,0 +1,479 @@ +/** + * auth-public command group — public integration (OAuth) authentication + * + * Public integrations use OAuth so that other Notion users can install them + * via the "Add to Notion" flow. This requires a client ID and client secret + * from the integration's settings page. + * + * auth-public setup — save your OAuth client ID and secret + * auth-public login — authenticate via browser (OAuth flow) + * auth-public status — check current OAuth authentication state + * auth-public logout — revoke and clear the stored OAuth access token + */ + +import { createInterface } from "readline"; +import { createServer } from "http"; +import { exec } from "child_process"; +import { Command } from "commander"; +import { + readConfig, + writeConfig, + getOAuthCredentials, + CONFIG_FILE, +} from "../helpers.js"; +import { createNotionClient } from "../src/postman/notion-api/index.js"; + +// ============================================================================ +// Helpers +// ============================================================================ + +const REDIRECT_URI = "http://localhost:8787/callback"; +const NOTION_AUTH_URL = "https://api.notion.com/v1/oauth/authorize"; + +function promptMasked(message: string): Promise { + return new Promise((resolve) => { + process.stdout.write(message); + + if (!process.stdin.isTTY) { + const rl = createInterface({ input: process.stdin, terminal: false }); + rl.once("line", (line) => { + rl.close(); + resolve(line.trim()); + }); + return; + } + + process.stdin.setRawMode(true); + process.stdin.resume(); + let input = ""; + + const onData = (key: Buffer) => { + const chars = key.toString(); + for (const ch of chars) { + if (ch === "\n" || ch === "\r" || ch === "\u0004") { + process.stdin.setRawMode(false); + process.stdin.removeListener("data", onData); + process.stdin.pause(); + process.stdout.write("\n"); + resolve(input); + return; + } else if (ch === "\u0003") { + process.stdin.setRawMode(false); + process.stdout.write("\n"); + process.exit(0); + } else if (ch === "\u007F" || ch === "\b") { + if (input.length > 0) { + input = input.slice(0, -1); + process.stdout.write("\b \b"); + } + } else { + input += ch; + process.stdout.write("*"); + } + } + }; + process.stdin.on("data", onData); + }); +} + +function promptPlain(message: string): Promise { + return new Promise((resolve) => { + process.stdout.write(message); + const rl = createInterface({ input: process.stdin, terminal: false }); + rl.once("line", (line) => { + rl.close(); + process.stdin.pause(); + resolve(line.trim()); + }); + }); +} + +function openBrowser(url: string): void { + const cmd = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "start" + : "xdg-open"; + exec(`${cmd} "${url}"`); +} + +function waitForCallback(port: number): Promise { + return new Promise((resolve, reject) => { + let timer: ReturnType; + + function done() { + clearTimeout(timer); + server.close(); + } + + const server = createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${port}`); + + if (url.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + + if (error) { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(callbackPage("Authorization Failed", `Notion returned an error: ${error}. You can close this tab.`)); + done(); + reject(new Error(`OAuth authorization error: ${error}`)); + return; + } + + if (!code) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(callbackPage("Missing Code", "No authorization code received. You can close this tab.")); + done(); + reject(new Error("No authorization code in callback")); + return; + } + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(callbackPage("Success!", "Authorization complete. You can close this tab and return to the terminal.")); + done(); + resolve(code); + }); + + server.listen(port, () => { + // Server is ready + }); + + timer = setTimeout(() => { + server.close(); + reject(new Error("Timed out waiting for OAuth callback (2 minutes)")); + }, 120_000); + }); +} + +function callbackPage(title: string, message: string): string { + return ` + +notion-cli — ${title} + + +

${title}

${message}

+`; +} + +// ============================================================================ +// auth-public setup — save client credentials +// ============================================================================ + +const setupCommand = new Command("setup") + .description("Save your OAuth client ID and secret") + .action(async () => { + const existing = getOAuthCredentials(); + if (existing) { + console.log("OAuth client credentials are already configured."); + console.log(` Client ID: ${existing.clientId.slice(0, 12)}...`); + console.log(""); + const answer = await promptPlain("Overwrite? (y/N) "); + if (answer.toLowerCase() !== "y") { + console.log("Kept existing credentials."); + return; + } + console.log(""); + } + + console.log("Enter your OAuth client credentials."); + console.log("(Find these at https://www.notion.so/my-integrations under your public integration.)"); + console.log(""); + const clientId = await promptPlain("Client ID: "); + const clientSecret = await promptMasked("Client secret: "); + + if (!clientId || !clientSecret) { + console.error("Both client ID and client secret are required."); + process.exit(1); + } + + const config = readConfig(); + config.NOTION_OAUTH_CLIENT_ID = clientId; + config.NOTION_OAUTH_CLIENT_SECRET = clientSecret; + writeConfig(config); + + console.log(""); + console.log("Client credentials saved."); + console.log("Run 'notion-cli auth-public login' to authenticate."); + process.exit(0); + }); + +// ============================================================================ +// auth-public login — OAuth browser flow +// ============================================================================ + +const loginCommand = new Command("login") + .description("Authenticate via browser (OAuth authorization flow)") + .option("--no-browser", "print the authorization URL instead of opening it") + .action(async (options: { browser: boolean }) => { + // Check for client credentials + const creds = getOAuthCredentials(); + if (!creds) { + console.error("No OAuth client credentials found."); + console.error(""); + console.error("Run 'notion-cli auth-public setup' first to save your client ID and secret."); + process.exit(1); + } + + // Build authorization URL + const authUrl = `${NOTION_AUTH_URL}?client_id=${encodeURIComponent(creds.clientId)}&response_type=code&owner=user&redirect_uri=${encodeURIComponent(REDIRECT_URI)}`; + + // Start local server + console.log("Starting local server for OAuth callback..."); + const codePromise = waitForCallback(8787); + + // Open browser or print URL + if (options.browser) { + console.log("Opening browser for authorization..."); + console.log(""); + openBrowser(authUrl); + } else { + console.log("Open this URL in your browser to authorize:"); + console.log(""); + console.log(` ${authUrl}`); + console.log(""); + } + + console.log("Waiting for authorization..."); + + // Wait for callback + let code: string; + try { + code = await codePromise; + } catch (err) { + console.error(`\n${err instanceof Error ? err.message : err}`); + process.exit(1); + } + + console.log("Authorization code received. Exchanging for access token..."); + + // Exchange code for token + const notion = createNotionClient(""); + + try { + const response = await notion.oauth.token( + { + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + }, + creds.clientId, + creds.clientSecret, + ); + + // Store token and metadata + const config = readConfig(); + config.NOTION_TOKEN = response.access_token; + config.auth_method = "oauth"; + config.oauth_bot_id = response.bot_id; + config.oauth_workspace_name = response.workspace_name; + config.oauth_workspace_id = response.workspace_id; + writeConfig(config); + + console.log(""); + console.log("Authenticated successfully!"); + console.log(` Workspace: ${response.workspace_name}`); + console.log(` Bot ID: ${response.bot_id}`); + console.log(` Token saved to ${CONFIG_FILE}`); + process.exit(0); + } catch (err) { + console.error(`\nError exchanging code: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + }); + +// ============================================================================ +// auth-public status +// ============================================================================ + +const statusCommand = new Command("status") + .description("Check current OAuth authentication state") + .action(async () => { + const config = readConfig(); + const creds = getOAuthCredentials(); + + // Client credentials + if (creds) { + console.log("Client credentials: configured"); + console.log(` Client ID: ${creds.clientId.slice(0, 12)}...`); + } else { + console.log("Client credentials: not configured"); + console.log(" Run 'notion-cli auth-public setup' to save them."); + } + + console.log(""); + + // Access token + const isOAuth = config.auth_method === "oauth"; + if (isOAuth && config.NOTION_TOKEN) { + console.log("Access token: configured"); + console.log(` Token: ${config.NOTION_TOKEN.slice(0, 12)}...`); + if (config.oauth_workspace_name) console.log(` Workspace: ${config.oauth_workspace_name}`); + if (config.oauth_workspace_id) console.log(` Workspace ID: ${config.oauth_workspace_id}`); + if (config.oauth_bot_id) console.log(` Bot ID: ${config.oauth_bot_id}`); + } else if (config.NOTION_TOKEN && !isOAuth) { + console.log("Access token: using an internal integration token (not OAuth)"); + console.log(" Run 'notion-cli auth-public login' to authenticate via OAuth instead."); + } else { + console.log("Access token: not authenticated"); + if (creds) { + console.log(" Run 'notion-cli auth-public login' to authenticate."); + } + } + }); + +// ============================================================================ +// auth-public logout +// ============================================================================ + +const logoutCommand = new Command("logout") + .description("Revoke and clear the stored OAuth access token") + .option("--all", "also remove stored client credentials") + .action(async (options: { all?: boolean }) => { + const config = readConfig(); + let removed = false; + + if (config.auth_method === "oauth" && config.NOTION_TOKEN) { + // Revoke the token with Notion so re-auth issues a fresh token + const creds = getOAuthCredentials(); + if (creds) { + try { + const notion = createNotionClient(""); + await notion.oauth.revoke(config.NOTION_TOKEN, creds.clientId, creds.clientSecret); + console.log("Token revoked with Notion."); + } catch { + console.log("Warning: could not revoke token with Notion (it may already be expired)."); + } + } + + delete (config as Record).NOTION_TOKEN; + delete (config as Record).auth_method; + delete (config as Record).oauth_bot_id; + delete (config as Record).oauth_workspace_name; + delete (config as Record).oauth_workspace_id; + removed = true; + } + + if (options.all) { + if (config.NOTION_OAUTH_CLIENT_ID || config.NOTION_OAUTH_CLIENT_SECRET) { + delete (config as Record).NOTION_OAUTH_CLIENT_ID; + delete (config as Record).NOTION_OAUTH_CLIENT_SECRET; + removed = true; + } + } + + if (!removed) { + console.log("No OAuth credentials to remove."); + return; + } + + writeConfig(config as Record); + console.log("OAuth credentials removed."); + if (!options.all && (config.NOTION_OAUTH_CLIENT_ID || config.NOTION_OAUTH_CLIENT_SECRET)) { + console.log("(Client ID/secret retained — use --all to remove those too)"); + } + }); + +// ============================================================================ +// auth-public introspect — inspect a token's metadata +// ============================================================================ + +const introspectCommand = new Command("introspect") + .description("Inspect an OAuth token's metadata") + .argument("", "the token to introspect") + .option("-r, --raw", "output raw JSON instead of formatted text") + .action(async (token: string, options: { raw?: boolean }) => { + const creds = getOAuthCredentials(); + if (!creds) { + console.error("No OAuth client credentials found."); + console.error("Run 'notion-cli auth-public setup' first."); + process.exit(1); + } + + const notion = createNotionClient(""); + + try { + const response = await notion.oauth.introspect(token, creds.clientId, creds.clientSecret); + + if (options.raw) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + console.log(`Active: ${response.active}`); + if (response.bot_id) console.log(`Bot ID: ${response.bot_id}`); + if (response.workspace_id) console.log(`Workspace ID: ${response.workspace_id}`); + if (response.token_type) console.log(`Token type: ${response.token_type}`); + if (response.iat) console.log(`Issued at: ${response.iat}`); + if (response.exp) console.log(`Expires: ${response.exp}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// ============================================================================ +// auth-public revoke — revoke a token +// ============================================================================ + +const revokeCommand = new Command("revoke") + .description("Revoke an OAuth token") + .argument("", "the token to revoke") + .action(async (token: string) => { + const creds = getOAuthCredentials(); + if (!creds) { + console.error("No OAuth client credentials found."); + console.error("Run 'notion-cli auth-public setup' first."); + process.exit(1); + } + + const notion = createNotionClient(""); + + try { + await notion.oauth.revoke(token, creds.clientId, creds.clientSecret); + console.log("Token revoked."); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// ============================================================================ +// auth-public command group +// ============================================================================ + +export const authPublicCommand = new Command("auth-public") + .description("Authenticate with a public integration (OAuth)") + .addHelpText( + "after", + ` +Public integrations use OAuth so other Notion users can install them. +This requires a client ID and client secret from your integration's settings. + +To get started: + 1. Go to https://www.notion.so/my-integrations + 2. Create or select a public integration + 3. Copy the OAuth client ID and client secret + 4. Run: notion-cli auth-public setup + 5. Run: notion-cli auth-public login +`, + ) + .addCommand(setupCommand) + .addCommand(loginCommand) + .addCommand(statusCommand) + .addCommand(introspectCommand) + .addCommand(revokeCommand) + .addCommand(logoutCommand); diff --git a/notion-cli/commands/block.ts b/notion-cli/commands/block.ts new file mode 100644 index 0000000..1e29879 --- /dev/null +++ b/notion-cli/commands/block.ts @@ -0,0 +1,316 @@ +/** + * block command group + * block get — retrieve a single block + * block children — list a block's children + */ + +import { Command } from "commander"; +import { createNotionClient } from "../src/postman/notion-api/index.js"; +import { getBearerToken, formatDate, formatBlock } from "../helpers.js"; + +// -- block get ---------------------------------------------------------------- + +const blockGetCommand = new Command("get") + .description("Retrieve a single block by ID") + .argument("", "the ID of the block to retrieve") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Fetches a single block object. Shows the block type, content, + parent, dates, and whether it has children. + + Pages are also blocks — you can pass a page ID here. + +Examples: + $ notion-cli block get + $ notion-cli block get --raw +`, + ) + .action(async (blockId: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const block = await notion.blocks.retrieve(blockId); + + if (options.raw) { + console.log(JSON.stringify(block, null, 2)); + return; + } + + const parent = block.parent; + let parentInfo: string; + if (parent.type === "page_id") { + parentInfo = `page (ID: ${parent.page_id})`; + } else if (parent.type === "block_id") { + parentInfo = `block (ID: ${parent.block_id})`; + } else if (parent.type === "database_id") { + parentInfo = `database (ID: ${parent.database_id})`; + } else { + parentInfo = "workspace"; + } + + console.log(`ID: ${block.id}`); + console.log(`Type: ${block.type}`); + console.log(`Parent: ${parentInfo}`); + console.log(`Has children: ${block.has_children}`); + console.log(`Archived: ${block.archived}`); + console.log(`Created: ${formatDate(block.created_time)}`); + console.log(`Last edited: ${formatDate(block.last_edited_time)}`); + + const formatted = formatBlock(block); + if (formatted) { + console.log(`\nContent:\n ${formatted}`); + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- block children ----------------------------------------------------------- + +const blockChildrenCommand = new Command("children") + .description("List a block's child blocks") + .argument("", "the ID of the block or page to get children for") + .option("-r, --raw", "output raw JSON instead of formatted text") + .option("-c, --cursor ", "pagination cursor from a previous request") + .option("-n, --limit ", "max results per page, 1-100", "100") + .addHelpText( + "after", + ` +Details: + Returns the immediate children of a block. Pages are also blocks, + so you can pass a page ID to get the top-level content blocks. + + Unlike "page get", this does NOT recurse into nested blocks. + +Examples: + $ notion-cli block children + $ notion-cli block children + $ notion-cli block children --raw + $ notion-cli block children -n 10 +`, + ) + .action(async (blockId: string, options: { raw?: boolean; cursor?: string; limit: string }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + const pageSize = Math.min(parseInt(options.limit, 10) || 100, 100); + + try { + const response = await notion.blocks.retrieveChildren(blockId, { + start_cursor: options.cursor, + page_size: pageSize, + }); + + if (options.raw) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + if (response.results.length === 0) { + console.log("No child blocks found."); + return; + } + + console.log(`Found ${response.results.length} block(s):\n`); + + for (const block of response.results) { + const formatted = formatBlock(block); + const children = block.has_children ? " [has children]" : ""; + if (formatted) { + console.log(` ${formatted}${children}`); + } else { + console.log(` [${block.type}] (ID: ${block.id})${children}`); + } + } + + if (response.has_more && response.next_cursor) { + console.log(`\n📑 More results available. Use --cursor to get next page:`); + console.log(` notion-cli block children ${blockId} --cursor ${response.next_cursor}`); + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- block append ------------------------------------------------------------- + +const blockAppendCommand = new Command("append") + .description("Append child blocks to a page or block") + .argument("", "the ID of the parent page or block") + .argument("", "text content to append as a paragraph block") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Appends a paragraph block containing the given text to the parent. + The parent can be a page ID or a block ID. + + For more complex block structures, use the generated client directly. + +Examples: + $ notion-cli block append "Hello, world!" + $ notion-cli block append "Nested content" --raw +`, + ) + .action(async (parentId: string, text: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + const children = [ + { + object: "block", + type: "paragraph", + paragraph: { + rich_text: [{ type: "text", text: { content: text } }], + }, + }, + ]; + + try { + const response = await notion.blocks.appendChildren(parentId, children); + + if (options.raw) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + console.log(`Appended ${response.results.length} block(s).`); + for (const block of response.results) { + const formatted = formatBlock(block); + if (formatted) { + console.log(` ${formatted}`); + } else { + console.log(` [${block.type}] (ID: ${block.id})`); + } + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- block update ------------------------------------------------------------- + +const blockUpdateCommand = new Command("update") + .description("Update a block's text content") + .argument("", "the ID of the block to update") + .argument("", "new text content for the block") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Updates a paragraph, heading, bulleted list item, numbered list item, + to-do, toggle, callout, or quote block with new text content. + + First retrieves the block to determine its type, then sends the + update with the correct type key. + +Examples: + $ notion-cli block update "Updated text" + $ notion-cli block update "Updated text" --raw +`, + ) + .action(async (blockId: string, text: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + // First, retrieve the block to get its type + const existing = await notion.blocks.retrieve(blockId); + const blockType = existing.type; + + // Types that support rich_text updates + const richTextTypes = [ + "paragraph", "heading_1", "heading_2", "heading_3", + "bulleted_list_item", "numbered_list_item", "to_do", + "toggle", "callout", "quote", + ]; + + if (!richTextTypes.includes(blockType)) { + console.error(`Error: block type "${blockType}" does not support text updates via this command.`); + process.exit(1); + } + + const params: Record = { + [blockType]: { + rich_text: [{ type: "text", text: { content: text } }], + }, + }; + + const block = await notion.blocks.update(blockId, params); + + if (options.raw) { + console.log(JSON.stringify(block, null, 2)); + return; + } + + console.log(`Block updated.`); + console.log(` ID: ${block.id}`); + console.log(` Type: ${block.type}`); + const formatted = formatBlock(block); + if (formatted) { + console.log(` Content: ${formatted}`); + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- block delete ------------------------------------------------------------- + +const blockDeleteCommand = new Command("delete") + .description("Delete (archive) a block") + .argument("", "the ID of the block to delete") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Deletes a block by setting its archived status to true. + The block can be restored via the Notion UI or by updating + it with archived: false. + +Examples: + $ notion-cli block delete + $ notion-cli block delete --raw +`, + ) + .action(async (blockId: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const block = await notion.blocks.delete(blockId); + + if (options.raw) { + console.log(JSON.stringify(block, null, 2)); + return; + } + + console.log(`Block deleted.`); + console.log(` ID: ${block.id}`); + console.log(` Type: ${block.type}`); + console.log(` Archived: ${block.archived}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- block command group ------------------------------------------------------ + +export const blockCommand = new Command("block") + .description("Read and inspect Notion blocks") + .addCommand(blockGetCommand) + .addCommand(blockChildrenCommand) + .addCommand(blockAppendCommand) + .addCommand(blockUpdateCommand) + .addCommand(blockDeleteCommand); diff --git a/notion-cli/commands/comment.ts b/notion-cli/commands/comment.ts new file mode 100644 index 0000000..ef404bb --- /dev/null +++ b/notion-cli/commands/comment.ts @@ -0,0 +1,220 @@ +/** + * comment command group + * comment list — list comments + * comment add — add a comment to a page + * comment reply — reply to a comment thread + */ + +import { Command } from "commander"; +import { createNotionClient } from "../src/postman/notion-api/index.js"; +import { getBearerToken, formatDate } from "../helpers.js"; + +// -- comment list ------------------------------------------------------------- + +const commentListCommand = new Command("list") + .description("List comments on a page or block") + .argument("", "the ID of the page or block to get comments for") + .option("-r, --raw", "output raw JSON instead of formatted text") + .option("-c, --cursor ", "pagination cursor from a previous request") + .option("-n, --limit ", "max results per page, 1-100", "100") + .addHelpText( + "after", + ` +Details: + Returns comments on a page or block. Pages are also blocks, + so a page ID works here too. + + Comments are grouped by discussion_id — comments with the same + discussion_id are part of the same thread. + +Examples: + $ notion-cli comment list + $ notion-cli comment list --raw +`, + ) + .action(async (blockId: string, options: { raw?: boolean; cursor?: string; limit: string }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + const pageSize = Math.min(parseInt(options.limit, 10) || 100, 100); + + try { + const response = await notion.comments.list(blockId, { + start_cursor: options.cursor, + page_size: pageSize, + }); + + if (options.raw) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + if (response.results.length === 0) { + console.log("No comments found."); + return; + } + + console.log(`Found ${response.results.length} comment(s):\n`); + + for (const comment of response.results) { + const text = comment.rich_text.map((t) => t.plain_text).join("") || "(empty)"; + const created = formatDate(comment.created_time); + const author = comment.created_by.id; + + console.log(` 💬 ${text}`); + console.log(` ID: ${comment.id}`); + console.log(` Discussion: ${comment.discussion_id}`); + console.log(` Author: ${author}`); + console.log(` Created: ${created}`); + console.log(); + } + + if (response.has_more && response.next_cursor) { + console.log(`📑 More results available. Use --cursor to get next page:`); + console.log(` notion-cli comment list ${blockId} --cursor ${response.next_cursor}`); + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- comment add -------------------------------------------------------------- + +const commentAddCommand = new Command("add") + .description("Add a comment to a page") + .argument("", "the ID of the page to comment on") + .argument("", "the comment text") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Creates a new top-level comment thread on the specified page. + The text is added as a plain text comment. + +Examples: + $ notion-cli comment add "This looks great!" + $ notion-cli comment add "Needs review" --raw +`, + ) + .action(async (pageId: string, text: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const comment = await notion.comments.create({ + parent: { page_id: pageId }, + rich_text: [{ text: { content: text } }], + }); + + if (options.raw) { + console.log(JSON.stringify(comment, null, 2)); + return; + } + + console.log(`Comment added.`); + console.log(` ID: ${comment.id}`); + console.log(` Discussion: ${comment.discussion_id}`); + console.log(` Created: ${formatDate(comment.created_time)}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- comment reply ------------------------------------------------------------ + +const commentReplyCommand = new Command("reply") + .description("Reply to a comment thread") + .argument("", "the discussion ID to reply to") + .argument("", "the reply text") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Replies to an existing comment thread. The discussion_id can be + found in the output of "comment list". + +Examples: + $ notion-cli comment reply "I agree!" + $ notion-cli comment reply "Done." --raw +`, + ) + .action(async (discussionId: string, text: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const comment = await notion.comments.create({ + discussion_id: discussionId, + rich_text: [{ text: { content: text } }], + }); + + if (options.raw) { + console.log(JSON.stringify(comment, null, 2)); + return; + } + + console.log(`Reply added.`); + console.log(` ID: ${comment.id}`); + console.log(` Discussion: ${comment.discussion_id}`); + console.log(` Created: ${formatDate(comment.created_time)}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- comment get -------------------------------------------------------------- + +const commentGetCommand = new Command("get") + .description("Retrieve a single comment by ID") + .argument("", "the ID of the comment to retrieve") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Retrieves a single comment by its ID. Shows the comment text, + discussion thread ID, author, and creation date. + + Comment IDs can be found in the output of "comment list". + +Examples: + $ notion-cli comment get + $ notion-cli comment get --raw +`, + ) + .action(async (commentId: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const comment = await notion.comments.retrieve(commentId); + + if (options.raw) { + console.log(JSON.stringify(comment, null, 2)); + return; + } + + const text = comment.rich_text.map((t) => t.plain_text).join("") || "(empty)"; + console.log(`💬 ${text}`); + console.log(` ID: ${comment.id}`); + console.log(` Discussion: ${comment.discussion_id}`); + console.log(` Author: ${comment.created_by.id}`); + console.log(` Created: ${formatDate(comment.created_time)}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- comment command group ---------------------------------------------------- + +export const commentCommand = new Command("comment") + .description("Read and manage comments on pages and blocks") + .addCommand(commentListCommand) + .addCommand(commentGetCommand) + .addCommand(commentAddCommand) + .addCommand(commentReplyCommand); diff --git a/notion-cli/commands/database.ts b/notion-cli/commands/database.ts index 70e9d37..5379411 100644 --- a/notion-cli/commands/database.ts +++ b/notion-cli/commands/database.ts @@ -61,9 +61,26 @@ Examples: console.log(`Parent: ${parentInfo}`); console.log(`Created: ${formatDate(database.created_time)}`); console.log(`Last edited: ${formatDate(database.last_edited_time)}`); + if (database.archived !== undefined) { + console.log(`Archived: ${database.archived}`); + } + if (database.in_trash) { + console.log(`In trash: ${database.in_trash}`); + } + if (database.is_locked) { + console.log(`Locked: ${database.is_locked}`); + } console.log(`URL: ${database.url}`); - const propEntries = Object.entries(database.properties); + // Show data sources (new in 2025-09-03) + if (database.data_sources && database.data_sources.length > 0) { + console.log(`\nData sources (${database.data_sources.length}):`); + for (const ds of database.data_sources) { + console.log(` ${ds.name || "(Unnamed)"} — ID: ${ds.id}`); + } + } + + const propEntries = Object.entries(database.properties || {}); if (propEntries.length > 0) { console.log(`\nSchema (${propEntries.length} properties):`); for (const [name, prop] of propEntries) { @@ -72,7 +89,13 @@ Examples: } } - console.log(`\nTo list entries: notion-cli database list ${databaseId}`); + // Suggest using data source ID for queries if available + if (database.data_sources && database.data_sources.length > 0) { + console.log(`\nTo list entries: notion-cli database list ${databaseId}`); + console.log(` (uses data source ID: ${database.data_sources[0].id})`); + } else { + console.log(`\nTo list entries: notion-cli database list ${databaseId}`); + } } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : error}`); process.exit(1); @@ -114,10 +137,23 @@ Examples: console.log(`🗃️ Listing database entries...\n`); try { - const queryResponse = await notion.databases.query(databaseId, { - page_size: pageSize, - start_cursor: options.cursor, - }); + // Two-step flow: retrieve database to get data source ID, then query the data source + const database = await notion.databases.retrieve(databaseId); + const dataSourceId = database.data_sources?.[0]?.id; + + let queryResponse; + if (dataSourceId) { + queryResponse = await notion.dataSources.query(dataSourceId, { + page_size: pageSize, + start_cursor: options.cursor, + }); + } else { + // Fallback to legacy database query if no data sources available + queryResponse = await notion.databases.query(databaseId, { + page_size: pageSize, + start_cursor: options.cursor, + }); + } if (options.raw) { console.log(JSON.stringify(queryResponse, null, 2)); @@ -164,9 +200,117 @@ Examples: } }); +// -- database create ---------------------------------------------------------- + +const databaseCreateCommand = new Command("create") + .description("Create a database as a child of a page") + .argument("", "ID of the parent page") + .option("-t, --title ", "database title") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Creates a new inline database as a child of the specified page. + The database is created with a single "Name" title property. + Use "database update" to add more properties after creation. + +Examples: + $ notion-cli database create <parent-page-id> --title "Task Tracker" + $ notion-cli database create <parent-page-id> --title "Task Tracker" --raw +`, + ) + .action(async (parentPageId: string, options: { title?: string; raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const db = await notion.databases.create({ + parent: { type: "page_id", page_id: parentPageId }, + title: options.title ? [{ text: { content: options.title } }] : undefined, + properties: { + Name: { title: {} }, + }, + }); + + if (options.raw) { + console.log(JSON.stringify(db, null, 2)); + return; + } + + const title = db.title.map((t) => t.plain_text).join("") || "(Untitled)"; + console.log(`Database created.`); + console.log(` Title: ${title}`); + console.log(` ID: ${db.id}`); + console.log(` URL: ${db.url}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- database update ---------------------------------------------------------- + +const databaseUpdateCommand = new Command("update") + .description("Update a database's title or description") + .argument("<database-id>", "ID of the database to update") + .option("-t, --title <title>", "set a new title") + .option("-d, --description <description>", "set a new description") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Updates a database's title and/or description. + Only the specified fields are updated; others remain unchanged. + +Examples: + $ notion-cli database update <database-id> --title "New Title" + $ notion-cli database update <database-id> --description "Updated description" + $ notion-cli database update <database-id> --title "New" --description "Desc" --raw +`, + ) + .action(async (databaseId: string, options: { title?: string; description?: string; raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + const params: Record<string, unknown> = {}; + if (options.title) { + params.title = [{ text: { content: options.title } }]; + } + if (options.description) { + params.description = [{ text: { content: options.description } }]; + } + + if (Object.keys(params).length === 0) { + console.error("Error: nothing to update. Use --title or --description."); + process.exit(1); + } + + try { + const db = await notion.databases.update(databaseId, params); + + if (options.raw) { + console.log(JSON.stringify(db, null, 2)); + return; + } + + const title = db.title.map((t) => t.plain_text).join("") || "(Untitled)"; + console.log(`Database updated.`); + console.log(` Title: ${title}`); + console.log(` ID: ${db.id}`); + console.log(` URL: ${db.url}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + // -- database command group --------------------------------------------------- export const databaseCommand = new Command("database") .description("View and query Notion databases") .addCommand(databaseGetCommand) - .addCommand(databaseListCommand); + .addCommand(databaseListCommand) + .addCommand(databaseCreateCommand) + .addCommand(databaseUpdateCommand); diff --git a/notion-cli/commands/datasource.ts b/notion-cli/commands/datasource.ts new file mode 100644 index 0000000..6d64315 --- /dev/null +++ b/notion-cli/commands/datasource.ts @@ -0,0 +1,333 @@ +/** + * datasource command group + * datasource get <id> — retrieve a data source + * datasource query <id> — query entries from a data source + * datasource templates <id> — list available templates + * datasource create <database-id> — create a data source in a database + * datasource update <id> — update a data source + */ + +import { Command } from "commander"; +import { createNotionClient, type DatabasePropertySchema } from "../src/postman/notion-api/index.js"; +import { getBearerToken, getPageTitle, formatDate, formatPropertyValue } from "../helpers.js"; + +// -- datasource get ----------------------------------------------------------- + +const datasourceGetCommand = new Command("get") + .description("Retrieve a data source by ID") + .argument("<datasource-id>", "data source ID") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Retrieves a single data source and displays its metadata, parent + database, and property schema. + + Data source IDs can be found in the output of "database get". + +Examples: + $ notion-cli datasource get <datasource-id> + $ notion-cli datasource get <datasource-id> --raw +`, + ) + .action(async (datasourceId: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const ds = await notion.dataSources.retrieve(datasourceId); + + if (options.raw) { + console.log(JSON.stringify(ds, null, 2)); + return; + } + + const title = ds.title?.map((t) => t.plain_text).join("") || "(Untitled)"; + + console.log(`Title: ${title}`); + console.log(`ID: ${ds.id}`); + + // Data sources return a parent object — display it generically + const parent = ds.parent as unknown as Record<string, unknown>; + if (parent) { + if (parent.type === "database_id") { + console.log(`Parent database: ${parent.database_id}`); + } else if (parent.type === "page_id") { + console.log(`Parent page: ${parent.page_id}`); + } + } + + console.log(`Created: ${formatDate(ds.created_time)}`); + console.log(`Last edited: ${formatDate(ds.last_edited_time)}`); + if (ds.archived !== undefined) { + console.log(`Archived: ${ds.archived}`); + } + if (ds.in_trash) { + console.log(`In trash: ${ds.in_trash}`); + } + + const propEntries = Object.entries(ds.properties || {}); + if (propEntries.length > 0) { + console.log(`\nSchema (${propEntries.length} properties):`); + for (const [name, prop] of propEntries) { + const schema = prop as DatabasePropertySchema; + console.log(` ${name}: ${schema.type}`); + } + } + + console.log(`\nTo query entries: notion-cli datasource query ${datasourceId}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- datasource query --------------------------------------------------------- + +const datasourceQueryCommand = new Command("query") + .description("Query entries from a data source") + .argument("<datasource-id>", "data source ID") + .option("-r, --raw", "output raw JSON instead of formatted text") + .option("-n, --limit <number>", "max entries to return, 1-100", "20") + .option("-c, --cursor <cursor>", "pagination cursor from a previous query") + .addHelpText( + "after", + ` +Details: + Queries entries (pages) from a data source, with pagination. + + Each entry is a Notion page. To read an entry's full content, + use "page get <entry-id>". + + Data source IDs can be found in the output of "database get". + +Examples: + $ notion-cli datasource query <datasource-id> + $ notion-cli datasource query <datasource-id> --limit 50 + $ notion-cli datasource query <datasource-id> --raw +`, + ) + .action(async (datasourceId: string, options: { raw?: boolean; limit: string; cursor?: string }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + const pageSize = Math.min(parseInt(options.limit, 10) || 20, 100); + + try { + const response = await notion.dataSources.query(datasourceId, { + page_size: pageSize, + start_cursor: options.cursor, + }); + + if (options.raw) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + if (response.results.length === 0) { + console.log("No entries found."); + return; + } + + console.log(`Entries (${response.results.length}${response.has_more ? "+" : ""}):\n`); + console.log("─".repeat(60)); + + for (const page of response.results) { + const pageTitle = getPageTitle(page); + const lastEdited = formatDate(page.last_edited_time); + + console.log(` 📄 ${pageTitle}`); + console.log(` ID: ${page.id}`); + console.log(` Last edited: ${lastEdited}`); + console.log(` URL: ${page.url}`); + + for (const [name, prop] of Object.entries(page.properties)) { + if (prop.type === "title") continue; + const value = formatPropertyValue(prop); + console.log(` ${name}: ${value}`); + } + + console.log(); + } + + console.log("─".repeat(60)); + + if (response.has_more && response.next_cursor) { + console.log(`\n📑 More entries available. Next page:`); + console.log(` notion-cli datasource query ${datasourceId} --cursor ${response.next_cursor}`); + } + + console.log(`\nTo read an entry: notion-cli page get <entry-id>`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- datasource templates ----------------------------------------------------- + +const datasourceTemplatesCommand = new Command("templates") + .description("List available page templates for a data source") + .argument("<datasource-id>", "data source ID") + .option("-r, --raw", "output raw JSON instead of formatted text") + .option("-c, --cursor <cursor>", "pagination cursor from a previous request") + .option("-n, --limit <number>", "max results per page, 1-100") + .addHelpText( + "after", + ` +Details: + Lists page templates available for the data source. Templates are + pages that serve as blueprints for new entries. + +Examples: + $ notion-cli datasource templates <datasource-id> + $ notion-cli datasource templates <datasource-id> --raw +`, + ) + .action(async (datasourceId: string, options: { raw?: boolean; cursor?: string; limit?: string }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const response = await notion.dataSources.listTemplates(datasourceId, { + start_cursor: options.cursor, + page_size: options.limit ? Math.min(parseInt(options.limit, 10) || 100, 100) : undefined, + }); + + if (options.raw) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + if (response.templates.length === 0) { + console.log("No templates found."); + return; + } + + console.log(`Found ${response.templates.length} template(s):\n`); + + for (const template of response.templates) { + console.log(` 📋 ${template.name || "(unnamed)"}`); + console.log(` ID: ${template.id}`); + if (template.is_default) { + console.log(` Default: yes`); + } + console.log(); + } + + if (response.has_more && response.next_cursor) { + console.log(`📑 More templates available. Use --cursor to get next page:`); + console.log(` notion-cli datasource templates ${datasourceId} --cursor ${response.next_cursor}`); + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- datasource create -------------------------------------------------------- + +const datasourceCreateCommand = new Command("create") + .description("Create a data source in a database") + .argument("<database-id>", "the ID of the parent database") + .option("-t, --title <title>", "data source title") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Creates a new data source in the specified database. + + Use "datasource templates" to see available templates first. + +Examples: + $ notion-cli datasource create <database-id> --title "Q1 Data" + $ notion-cli datasource create <database-id> --title "Q1 Data" --raw +`, + ) + .action(async (databaseId: string, options: { title?: string; raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const ds = await notion.dataSources.create({ + parent: { type: "database_id", database_id: databaseId }, + title: options.title ? [{ text: { content: options.title } }] : undefined, + properties: {}, + }); + + if (options.raw) { + console.log(JSON.stringify(ds, null, 2)); + return; + } + + const title = ds.title?.map((t) => t.plain_text).join("") || "(Untitled)"; + console.log(`Data source created.`); + console.log(` Title: ${title}`); + console.log(` ID: ${ds.id}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- datasource update -------------------------------------------------------- + +const datasourceUpdateCommand = new Command("update") + .description("Update a data source") + .argument("<datasource-id>", "the ID of the data source to update") + .option("-t, --title <title>", "set a new title") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Updates a data source's title. Only the specified fields are + updated; others remain unchanged. + +Examples: + $ notion-cli datasource update <datasource-id> --title "New Title" + $ notion-cli datasource update <datasource-id> --title "New Title" --raw +`, + ) + .action(async (datasourceId: string, options: { title?: string; raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + const params: Record<string, unknown> = {}; + if (options.title) { + params.title = [{ text: { content: options.title } }]; + } + + if (Object.keys(params).length === 0) { + console.error("Error: nothing to update. Use --title to set a new title."); + process.exit(1); + } + + try { + const ds = await notion.dataSources.update(datasourceId, params); + + if (options.raw) { + console.log(JSON.stringify(ds, null, 2)); + return; + } + + const title = ds.title?.map((t) => t.plain_text).join("") || "(Untitled)"; + console.log(`Data source updated.`); + console.log(` Title: ${title}`); + console.log(` ID: ${ds.id}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- datasource command group ------------------------------------------------- + +export const datasourceCommand = new Command("datasource") + .description("Work with data sources directly") + .addCommand(datasourceGetCommand) + .addCommand(datasourceQueryCommand) + .addCommand(datasourceTemplatesCommand) + .addCommand(datasourceCreateCommand) + .addCommand(datasourceUpdateCommand); diff --git a/notion-cli/commands/docs.ts b/notion-cli/commands/docs.ts new file mode 100644 index 0000000..048311b --- /dev/null +++ b/notion-cli/commands/docs.ts @@ -0,0 +1,79 @@ +/** + * docs command - display guides and workflows for using the CLI + */ + +import { Command } from "commander"; + +const DOCS = ` +NOTION CLI — DOCUMENTATION +=========================== + +SETUP +----- + +1. Create a Notion integration at https://www.notion.so/my-integrations +2. Save your token: notion-cli set-token +3. Share pages/databases with your integration in Notion + +Your integration can only access pages that have been explicitly shared +with it. Share a top-level page to give access to all its children. + +The token is stored in ~/.notion-cli/config.json. You can also set the +NOTION_TOKEN environment variable, which takes precedence. + +MAPPING A WORKSPACE +------------------- + +The Notion API doesn't have a "list all pages" endpoint. To build a +complete picture of a workspace, start from the roots and traverse down: + + Step 1 — Find root pages: + $ notion-cli integration pages + + Step 2 — Read each root page: + $ notion-cli page get <page-id> + Look for child pages (📄) and child databases (🗃️) in the output. + + Step 3 — For each child database, view its schema then list entries: + $ notion-cli database get <database-id> + $ notion-cli database list <database-id> + + Step 4 — Read child pages and database entries: + $ notion-cli page get <page-id> + + Repeat steps 2–4 to traverse the full tree. + +Key points: + • Start from roots — integration pages finds all entry points + • One page at a time — page get shows children but doesn't recurse into them + • Databases have two views — schema (get) and entries (list) + • IDs are in the output — every child shows its ID for navigation + +OUTPUT & PERFORMANCE +-------------------- + +All commands output human-readable text by default. Use --raw on most +commands for JSON output (useful for piping or programmatic access). + + • Block fetching uses parallel API calls for speed + • Large pages (100+ blocks) may take 5–15 seconds + • Database list queries are fast (single API call) + • integration pages paginates internally (may take a few seconds) + +AGENT USAGE +----------- + +This CLI is designed to work well with AI coding agents. An agent can: + + 1. Run "notion-cli docs" to understand available workflows + 2. Use "notion-cli integration pages" to discover entry points + 3. Navigate the workspace tree using page get and database list + 4. Use search to find specific content by keyword + 5. Use --raw for structured JSON when parsing output programmatically +`.trimStart(); + +export const docsCommand = new Command("docs") + .description("Show guides and workflows for using the CLI") + .action(() => { + process.stdout.write(DOCS); + }); diff --git a/notion-cli/commands/file.ts b/notion-cli/commands/file.ts new file mode 100644 index 0000000..8c3f884 --- /dev/null +++ b/notion-cli/commands/file.ts @@ -0,0 +1,196 @@ +/** + * file command group + * file upload <file-path> — upload a file (create + send + complete) + * file list — list file uploads + * file get <file-id> — retrieve a file upload + */ + +import { Command } from "commander"; +import { readFileSync, statSync } from "fs"; +import { basename } from "path"; +import { createNotionClient } from "../src/postman/notion-api/index.js"; +import { getBearerToken, formatDate, lookup as mimeLookup } from "../helpers.js"; + +// -- file upload -------------------------------------------------------------- + +const fileUploadCommand = new Command("upload") + .description("Upload a file to Notion") + .argument("<file-path>", "path to the file to upload") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Uploads a local file to Notion in three steps: + 1. Creates a file upload (reserves an upload slot) + 2. Sends the file data (multipart/form-data) + 3. Completes the upload + + The resulting file upload ID can be used to attach the file + to a page or block via the API. + +Examples: + $ notion-cli file upload ./report.pdf + $ notion-cli file upload ./image.png --raw +`, + ) + .action(async (filePath: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const stat = statSync(filePath); + const fileName = basename(filePath); + const fileData = readFileSync(filePath); + const contentType = mimeLookup(fileName); + + console.log(`📎 Uploading ${fileName} (${stat.size} bytes)...\n`); + + // Step 1: Create the file upload + const upload = await notion.fileUploads.create({ + mode: "single_part", + filename: fileName, + content_type: contentType, + }); + + // Step 2: Send the file data as multipart/form-data + const blob = new Blob([fileData], { type: contentType }); + const sent = await notion.fileUploads.send(upload.id, blob, fileName); + + // Step 3: Complete (only needed for multi_part uploads) + // For single_part, the send step marks it as "uploaded" automatically. + let completed = sent; + if (sent.status === "pending" && sent.number_of_parts) { + completed = await notion.fileUploads.complete(upload.id); + } + + if (options.raw) { + console.log(JSON.stringify(completed, null, 2)); + return; + } + + console.log(`File uploaded.`); + console.log(` Filename: ${completed.filename}`); + console.log(` ID: ${completed.id}`); + console.log(` Content type: ${completed.content_type}`); + console.log(` Size: ${completed.content_length ?? "unknown"} bytes`); + console.log(` Status: ${completed.status}`); + console.log(` Created: ${formatDate(completed.created_time)}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- file list ---------------------------------------------------------------- + +const fileListCommand = new Command("list") + .description("List file uploads") + .option("-r, --raw", "output raw JSON instead of formatted text") + .option("-n, --limit <number>", "max results per page, 1-100", "20") + .option("-c, --cursor <cursor>", "pagination cursor from a previous request") + .addHelpText( + "after", + ` +Details: + Lists file uploads for the current integration. Shows each + file's name, ID, size, status, and creation date. + +Examples: + $ notion-cli file list + $ notion-cli file list --limit 50 + $ notion-cli file list --raw +`, + ) + .action(async (options: { raw?: boolean; limit: string; cursor?: string }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + const pageSize = Math.min(parseInt(options.limit, 10) || 20, 100); + + try { + const response = await notion.fileUploads.list({ + page_size: pageSize, + start_cursor: options.cursor, + }); + + if (options.raw) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + if (response.results.length === 0) { + console.log("No file uploads found."); + return; + } + + console.log(`Found ${response.results.length} file upload(s):\n`); + + for (const file of response.results) { + console.log(` 📎 ${file.filename || "(unnamed)"}`); + console.log(` ID: ${file.id}`); + console.log(` Content type: ${file.content_type || "unknown"}`); + console.log(` Size: ${file.content_length ?? "unknown"} bytes`); + console.log(` Status: ${file.status}`); + console.log(` Created: ${formatDate(file.created_time)}`); + console.log(); + } + + if (response.has_more && response.next_cursor) { + console.log(`📑 More results available. Use --cursor to get next page:`); + console.log(` notion-cli file list --cursor ${response.next_cursor}`); + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- file get ----------------------------------------------------------------- + +const fileGetCommand = new Command("get") + .description("Retrieve a file upload by ID") + .argument("<file-id>", "the ID of the file upload to retrieve") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Retrieves a single file upload by its ID. Shows the file's + name, size, status, and creation date. + +Examples: + $ notion-cli file get <file-id> + $ notion-cli file get <file-id> --raw +`, + ) + .action(async (fileId: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const file = await notion.fileUploads.retrieve(fileId); + + if (options.raw) { + console.log(JSON.stringify(file, null, 2)); + return; + } + + console.log(`📎 ${file.filename || "(unnamed)"}`); + console.log(` ID: ${file.id}`); + console.log(` Content type: ${file.content_type || "unknown"}`); + console.log(` Size: ${file.content_length ?? "unknown"} bytes`); + console.log(` Status: ${file.status}`); + console.log(` Created: ${formatDate(file.created_time)}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- file command group ------------------------------------------------------- + +export const fileCommand = new Command("file") + .description("Upload and manage files") + .addCommand(fileUploadCommand) + .addCommand(fileListCommand) + .addCommand(fileGetCommand); diff --git a/notion-cli/commands/integration.ts b/notion-cli/commands/integration.ts new file mode 100644 index 0000000..74b0be0 --- /dev/null +++ b/notion-cli/commands/integration.ts @@ -0,0 +1,113 @@ +/** + * integration command group + * integration pages — list the root pages the integration can access + */ + +import { Command } from "commander"; +import { createNotionClient, type NotionPage } from "../src/postman/notion-api/index.js"; +import { getBearerToken, getPageTitle, formatDate } from "../helpers.js"; + +// -- integration pages -------------------------------------------------------- + +const integrationPagesCommand = new Command("pages") + .description("List root pages the integration can access") + .addHelpText( + "after", + ` +Details: + Lists the root pages visible to this integration — the entry points + for navigating workspace content. + + A page is considered a root if: + 1. Its parent is the workspace itself (a true top-level page), OR + 2. Its parent page is not accessible to the integration. + + Case 2 matters when an integration is shared with a page that is + nested inside the workspace hierarchy. That page has a parent, but + the integration can't see the parent, so it's effectively a root + from the integration's perspective. + + How it works: paginates through all pages via the Search API, + collects every page ID, then returns pages whose parent is either + the workspace or a page ID not in the collected set. + + This is the recommended starting point for mapping content. From + these roots, use "page get" to discover child pages and databases, + then traverse the tree with page get / database get / database list. + +Examples: + $ notion-cli integration pages +`, + ) + .action(async () => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + console.log("🔍 Finding root pages...\n"); + + try { + const allPages: NotionPage[] = []; + let cursor: string | undefined; + + // Paginate through all pages the integration can see + do { + const response = await notion.search({ + filter: { value: "page", property: "object" }, + start_cursor: cursor, + page_size: 100, + }); + + for (const result of response.results) { + if (result.object === "page") { + allPages.push(result as NotionPage); + } + } + + cursor = response.has_more ? (response.next_cursor ?? undefined) : undefined; + } while (cursor); + + // Build a set of all visible page IDs + const visibleIds = new Set(allPages.map((p) => p.id)); + + // A page is a root if its parent is the workspace, or its parent + // page isn't in the set of pages the integration can see. + const rootPages = allPages.filter((page) => { + if (page.parent.type === "workspace") return true; + if (page.parent.type === "page_id" && page.parent.page_id) return !visibleIds.has(page.parent.page_id); + // database/data source children are not roots — they live inside a visible database + if (page.parent.type === "database_id" || page.parent.type === "data_source_id") return false; + return false; + }); + + if (rootPages.length === 0) { + console.log("No root pages found."); + console.log(`(Scanned ${allPages.length} total pages)`); + return; + } + + console.log(`Found ${rootPages.length} root page(s) (scanned ${allPages.length} total):\n`); + + for (const page of rootPages) { + const title = getPageTitle(page); + const lastEdited = formatDate(page.last_edited_time); + const created = formatDate(page.created_time); + + console.log(` 📄 ${title}`); + console.log(` ID: ${page.id}`); + console.log(` Created: ${created}`); + console.log(` Last edited: ${lastEdited}`); + console.log(` Archived: ${page.archived}`); + console.log(` URL: ${page.url}`); + console.log(); + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- integration command group ------------------------------------------------ + +export const integrationCommand = new Command("integration") + .description("Inspect the integration's access and capabilities") + .addCommand(integrationPagesCommand); diff --git a/notion-cli/commands/page.ts b/notion-cli/commands/page.ts index 0dbd519..88f9690 100644 --- a/notion-cli/commands/page.ts +++ b/notion-cli/commands/page.ts @@ -51,6 +51,8 @@ Examples: let parentInfo: string; if (parent.type === "database_id") { parentInfo = `database (ID: ${parent.database_id})`; + } else if (parent.type === "data_source_id") { + parentInfo = `data source (ID: ${parent.data_source_id})`; } else if (parent.type === "page_id") { parentInfo = `page (ID: ${parent.page_id})`; } else { @@ -62,6 +64,10 @@ Examples: console.log(`Parent: ${parentInfo}`); console.log(`Created: ${formatDate(page.created_time)}`); console.log(`Last edited: ${formatDate(page.last_edited_time)}`); + console.log(`Archived: ${page.archived}`); + if (page.in_trash) { + console.log(`In trash: ${page.in_trash}`); + } console.log(`URL: ${page.url}`); // Show properties (useful for database items) @@ -145,8 +151,281 @@ Examples: } }); +// -- page create -------------------------------------------------------------- + +const pageCreateCommand = new Command("create") + .description("Create a new page under a parent page or database") + .argument("<parent-id>", "ID of the parent page or database") + .option("-t, --title <title>", "page title") + .option("-d, --database", "parent is a database (default: parent is a page)") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Creates a new page. By default, the parent is treated as a page. + Use --database if the parent is a database (properties must match + the database schema). + + For simple pages under a parent page, --title is all you need. + +Examples: + $ notion-cli page create <parent-page-id> --title "My New Page" + $ notion-cli page create <parent-page-id> --title "My New Page" --raw +`, + ) + .action(async (parentId: string, options: { title?: string; database?: boolean; raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + const parent = options.database + ? { database_id: parentId } + : { page_id: parentId }; + + const properties: Record<string, unknown> = {}; + if (options.title) { + properties.title = [{ text: { content: options.title } }]; + } + + try { + const page = await notion.pages.create({ parent, properties }); + + if (options.raw) { + console.log(JSON.stringify(page, null, 2)); + return; + } + + const title = getPageTitle(page); + console.log(`Page created.`); + console.log(` Title: ${title}`); + console.log(` ID: ${page.id}`); + console.log(` URL: ${page.url}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- page archive ------------------------------------------------------------- + +const pageArchiveCommand = new Command("archive") + .description("Archive (soft-delete) a page") + .argument("<page-id>", "the ID of the page to archive") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Archives a page by setting its archived property to true. + The page can be restored later from the Notion UI. + +Examples: + $ notion-cli page archive <page-id> + $ notion-cli page archive <page-id> --raw +`, + ) + .action(async (pageId: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const page = await notion.pages.archive(pageId); + + if (options.raw) { + console.log(JSON.stringify(page, null, 2)); + return; + } + + console.log(`Page archived.`); + console.log(` ID: ${page.id}`); + console.log(` Archived: ${page.archived}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- page property ------------------------------------------------------------ + +const pagePropertyCommand = new Command("property") + .description("Retrieve a page property item") + .argument("<page-id>", "the ID of the page") + .argument("<property-id>", "the ID of the property to retrieve") + .option("-r, --raw", "output raw JSON instead of formatted text") + .option("-c, --cursor <cursor>", "pagination cursor (for paginated properties)") + .addHelpText( + "after", + ` +Details: + Retrieves a single property value from a page. Useful for paginated + properties (rollups, relations with many entries, long rich_text or + title values) where the full value isn't returned in page get. + + The property ID can be found in the "page get --raw" output or in + the database schema from "database get --raw". + +Examples: + $ notion-cli page property <page-id> <property-id> + $ notion-cli page property <page-id> title + $ notion-cli page property <page-id> <property-id> --raw +`, + ) + .action(async (pageId: string, propertyId: string, options: { raw?: boolean; cursor?: string }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const result = await notion.pages.retrieveProperty(pageId, propertyId, { + start_cursor: options.cursor, + }); + + if (options.raw) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`Type: ${result.type}`); + + // Paginated response (title, rich_text, relation, rollup) + if (result.object === "list" && result.results) { + console.log(`Items: ${result.results.length}`); + for (const item of result.results) { + // Try to extract a readable value + const val = item[item.type] as Record<string, unknown> | undefined; + if (val && "plain_text" in val) { + console.log(` ${val.plain_text}`); + } else if (val && "id" in val) { + console.log(` ID: ${val.id}`); + } else { + console.log(` ${JSON.stringify(val)}`); + } + } + if (result.has_more && result.next_cursor) { + console.log(`\n📑 More items available. Use --cursor to get next page:`); + console.log(` notion-cli page property ${pageId} ${propertyId} --cursor ${result.next_cursor}`); + } + } else { + // Single-value response (select, number, checkbox, etc.) + const val = result[result.type]; + if (val === null || val === undefined) { + console.log(`Value: (empty)`); + } else if (typeof val === "object") { + console.log(`Value: ${JSON.stringify(val, null, 2)}`); + } else { + console.log(`Value: ${val}`); + } + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- page update -------------------------------------------------------------- + +const pageUpdateCommand = new Command("update") + .description("Update a page's properties") + .argument("<page-id>", "the ID of the page to update") + .option("-t, --title <title>", "set a new title") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Updates properties on a page. Currently supports setting the title + via --title. For more complex property updates, use the generated + client directly. + +Examples: + $ notion-cli page update <page-id> --title "New Title" + $ notion-cli page update <page-id> --title "New Title" --raw +`, + ) + .action(async (pageId: string, options: { title?: string; raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + const properties: Record<string, unknown> = {}; + if (options.title) { + properties.title = [{ text: { content: options.title } }]; + } + + if (Object.keys(properties).length === 0) { + console.error("Error: nothing to update. Use --title to set a new title."); + process.exit(1); + } + + try { + const page = await notion.pages.update(pageId, { properties }); + + if (options.raw) { + console.log(JSON.stringify(page, null, 2)); + return; + } + + const title = getPageTitle(page); + console.log(`Page updated.`); + console.log(` Title: ${title}`); + console.log(` ID: ${page.id}`); + console.log(` URL: ${page.url}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- page move ---------------------------------------------------------------- + +const pageMoveCommand = new Command("move") + .description("Move a page to a new parent") + .argument("<page-id>", "the ID of the page to move") + .requiredOption("-p, --parent <parent-id>", "the ID of the new parent page") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Moves a page to a new parent page. The page keeps its content + and properties — only the parent changes. + +Examples: + $ notion-cli page move <page-id> --parent <new-parent-page-id> + $ notion-cli page move <page-id> --parent <new-parent-page-id> --raw +`, + ) + .action(async (pageId: string, options: { parent: string; raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const page = await notion.pages.move(pageId, { + parent: { type: "page_id", page_id: options.parent }, + }); + + if (options.raw) { + console.log(JSON.stringify(page, null, 2)); + return; + } + + const title = getPageTitle(page); + console.log(`Page moved.`); + console.log(` Title: ${title}`); + console.log(` ID: ${page.id}`); + console.log(` New parent: ${options.parent}`); + console.log(` URL: ${page.url}`); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + // -- page command group ------------------------------------------------------- export const pageCommand = new Command("page") .description("Read and manage Notion pages") - .addCommand(pageGetCommand); + .addCommand(pageGetCommand) + .addCommand(pageCreateCommand) + .addCommand(pageUpdateCommand) + .addCommand(pageArchiveCommand) + .addCommand(pagePropertyCommand) + .addCommand(pageMoveCommand); diff --git a/notion-cli/commands/search.ts b/notion-cli/commands/search.ts index c1308eb..4e3f422 100644 --- a/notion-cli/commands/search.ts +++ b/notion-cli/commands/search.ts @@ -1,5 +1,5 @@ /** - * search command - search for pages matching a query string + * search command — search for pages and databases via the Notion Search API */ import { Command } from "commander"; @@ -8,6 +8,12 @@ import { getBearerToken, getPageTitle, formatDate } from "../helpers.js"; type SearchFilterOption = "page" | "database" | "all"; +/** Map CLI filter names to API values (database → data_source in 2025-09-03) */ +const FILTER_API_VALUE: Record<string, "page" | "data_source"> = { + page: "page", + database: "data_source", +}; + function normalizeFilterOption(input: string | undefined): SearchFilterOption { if (!input) return "page"; const v = input.trim().toLowerCase(); @@ -25,11 +31,10 @@ function isNotionDatabase(result: NotionPage | NotionDatabase): result is Notion } export const searchCommand = new Command("search") - .description("Search for pages and databases, or list workspace roots") + .description("Search for pages and databases") .argument("[query]", "text to search for (omit to list all results)") .option("-c, --cursor <cursor>", "pagination cursor from a previous search") .option("-n, --limit <number>", "max results per page, 1-100", "20") - .option("-w, --workspace", "find only top-level workspace pages (roots)") .option("-f, --filter <object>", "filter results by object type: page, database, or all", "page") .addHelpText( "after", @@ -43,100 +48,22 @@ Details: Each result shows: title, ID, parent, dates, and URL. - The --workspace flag is special: it paginates through ALL results - internally and filters to only return pages whose parent is the - workspace itself (top-level pages). This is how you find the roots - of the workspace tree. When using --workspace, --cursor and --limit - are ignored. - Examples: $ notion-cli search # list all pages $ notion-cli search --filter database # list databases $ notion-cli search --filter all # list pages + databases $ notion-cli search "meeting notes" # search by text - $ notion-cli search --workspace # list root pages only $ notion-cli search -n 5 # limit to 5 results `, ) .action( async ( query: string | undefined, - options: { cursor?: string; limit: string; workspace?: boolean; filter?: string }, + options: { cursor?: string; limit: string; filter?: string }, ) => { const bearerToken = getBearerToken(); const notion = createNotionClient(bearerToken); const filterOption = normalizeFilterOption(options.filter); - - // Workspace mode: paginate internally and filter for workspace-level pages - if (options.workspace) { - if (filterOption !== "page") { - console.error("Error: --filter cannot be used with --workspace (workspace roots are pages only)."); - process.exit(1); - } - if (options.cursor || options.limit !== "20") { - console.error("Error: --cursor and --limit cannot be used with --workspace flag."); - console.error("When using --workspace, pagination is handled internally to find all top-level pages."); - process.exit(1); - } - - console.log("🔍 Finding workspace-level pages...\n"); - - try { - const workspacePages: NotionPage[] = []; - let cursor: string | undefined; - let totalScanned = 0; - - // Paginate through all results internally - do { - const response = await notion.search({ - query: query || undefined, - filter: { value: "page", property: "object" }, - start_cursor: cursor, - page_size: 100, // Max page size for efficiency - }); - - totalScanned += response.results.length; - - // Filter for workspace-level pages - for (const result of response.results) { - if (!isNotionPage(result)) continue; - if (result.parent.type === "workspace") { - workspacePages.push(result); - } - } - - cursor = response.has_more ? (response.next_cursor ?? undefined) : undefined; - } while (cursor); - - if (workspacePages.length === 0) { - console.log("No workspace-level pages found."); - console.log(`(Scanned ${totalScanned} total pages)`); - return; - } - - console.log(`Found ${workspacePages.length} workspace-level page(s) (scanned ${totalScanned} total):\n`); - - for (const page of workspacePages) { - const title = getPageTitle(page); - const lastEdited = formatDate(page.last_edited_time); - const created = formatDate(page.created_time); - - console.log(` 📄 ${title}`); - console.log(` ID: ${page.id}`); - console.log(` Created: ${created}`); - console.log(` Last edited: ${lastEdited}`); - console.log(` Archived: ${page.archived}`); - console.log(` URL: ${page.url}`); - console.log(); - } - } catch (error) { - console.error(`Error: ${error instanceof Error ? error.message : error}`); - process.exit(1); - } - return; - } - - // Normal search mode const pageSize = Math.min(parseInt(options.limit, 10) || 20, 100); const filterLabel = @@ -146,7 +73,7 @@ Examples: try { const response = await notion.search({ query: query || undefined, - ...(filterOption === "all" ? {} : { filter: { value: filterOption, property: "object" } }), + ...(filterOption === "all" ? {} : { filter: { value: FILTER_API_VALUE[filterOption], property: "object" } }), start_cursor: options.cursor, page_size: pageSize, }); @@ -169,6 +96,8 @@ Examples: let parentInfo: string; if (parent.type === "database_id") { parentInfo = `database (ID: ${parent.database_id})`; + } else if (parent.type === "data_source_id") { + parentInfo = `data source (ID: ${parent.data_source_id})`; } else if (parent.type === "page_id") { parentInfo = `page (ID: ${parent.page_id})`; } else { diff --git a/notion-cli/commands/set-token.ts b/notion-cli/commands/set-token.ts index f471118..06d226b 100644 --- a/notion-cli/commands/set-token.ts +++ b/notion-cli/commands/set-token.ts @@ -61,7 +61,8 @@ export const setTokenCommand = new Command("set-token") } const config = readConfig(); - config.NOTION_API_KEY = token; + config.NOTION_TOKEN = token; writeConfig(config); console.log(`Token saved to ${CONFIG_FILE}`); + process.exit(0); }); diff --git a/notion-cli/commands/user.ts b/notion-cli/commands/user.ts new file mode 100644 index 0000000..af1a868 --- /dev/null +++ b/notion-cli/commands/user.ts @@ -0,0 +1,205 @@ +/** + * user command group + * user me — show the bot user for the current integration token + */ + +import { Command } from "commander"; +import { createNotionClient } from "../src/postman/notion-api/index.js"; +import { getBearerToken } from "../helpers.js"; + +// -- user me ------------------------------------------------------------------ + +const userMeCommand = new Command("me") + .description("Show the bot user for your integration token") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Fetches the bot user associated with the current API token. + Useful for verifying your token works and checking which + workspace the integration belongs to. + +Examples: + $ notion-cli user me + $ notion-cli user me --raw +`, + ) + .action(async (options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const user = await notion.users.me(); + + if (options.raw) { + console.log(JSON.stringify(user, null, 2)); + return; + } + + console.log(`Name: ${user.name || "(unnamed)"}`); + console.log(`ID: ${user.id}`); + console.log(`Type: ${user.type}`); + if (user.bot) { + if (user.bot.workspace_name) { + console.log(`Workspace: ${user.bot.workspace_name}`); + } + if (user.bot.owner) { + const owner = user.bot.owner; + if (owner.type === "workspace") { + console.log(`Owner: workspace`); + } else if (owner.type === "user" && owner.user) { + const ownerName = owner.user.name || owner.user.id; + const ownerEmail = owner.user.person?.email; + console.log(`Owner: ${ownerName}${ownerEmail ? ` (${ownerEmail})` : ""}`); + } + } + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- user get ----------------------------------------------------------------- + +const userGetCommand = new Command("get") + .description("Retrieve a user by ID") + .argument("<user-id>", "the ID of the user to retrieve") + .option("-r, --raw", "output raw JSON instead of formatted text") + .addHelpText( + "after", + ` +Details: + Fetches a user object (person or bot) by ID. + +Examples: + $ notion-cli user get 6794760a-1f15-45cd-9c65-0dfe42f5135a + $ notion-cli user get <user-id> --raw +`, + ) + .action(async (userId: string, options: { raw?: boolean }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + + try { + const user = await notion.users.retrieve(userId); + + if (options.raw) { + console.log(JSON.stringify(user, null, 2)); + return; + } + + console.log(`Name: ${user.name || "(unnamed)"}`); + console.log(`ID: ${user.id}`); + console.log(`Type: ${user.type}`); + if (user.type === "person" && user.person?.email) { + console.log(`Email: ${user.person.email}`); + } + if (user.type === "bot" && user.bot) { + if (user.bot.workspace_name) { + console.log(`Workspace: ${user.bot.workspace_name}`); + } + if (user.bot.owner) { + const owner = user.bot.owner; + if (owner.type === "workspace") { + console.log(`Owner: workspace`); + } else if (owner.type === "user" && owner.user) { + const ownerName = owner.user.name || owner.user.id; + const ownerEmail = owner.user.person?.email; + console.log(`Owner: ${ownerName}${ownerEmail ? ` (${ownerEmail})` : ""}`); + } + } + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- user list ---------------------------------------------------------------- + +const userListCommand = new Command("list") + .description("List all users in the workspace") + .option("-r, --raw", "output raw JSON instead of formatted text") + .option("-c, --cursor <cursor>", "pagination cursor from a previous list") + .option("-n, --limit <number>", "max results per page, 1-100", "100") + .addHelpText( + "after", + ` +Details: + Returns a paginated list of all users (people and bots) in the workspace. + +Examples: + $ notion-cli user list + $ notion-cli user list --raw + $ notion-cli user list -n 10 + $ notion-cli user list --cursor <cursor> +`, + ) + .action(async (options: { raw?: boolean; cursor?: string; limit: string }) => { + const bearerToken = getBearerToken(); + const notion = createNotionClient(bearerToken); + const pageSize = Math.min(parseInt(options.limit, 10) || 100, 100); + + try { + const response = await notion.users.list({ + start_cursor: options.cursor, + page_size: pageSize, + }); + + if (options.raw) { + console.log(JSON.stringify(response, null, 2)); + return; + } + + if (response.results.length === 0) { + console.log("No users found."); + return; + } + + console.log(`Found ${response.results.length} user(s):\n`); + + for (const user of response.results) { + const icon = user.type === "bot" ? "🤖" : "👤"; + console.log(` ${icon} ${user.name || "(unnamed)"}`); + console.log(` ID: ${user.id}`); + console.log(` Type: ${user.type}`); + if (user.type === "person" && user.person?.email) { + console.log(` Email: ${user.person.email}`); + } + if (user.type === "bot" && user.bot) { + if (user.bot.workspace_name) { + console.log(` Workspace: ${user.bot.workspace_name}`); + } + if (user.bot.owner) { + const owner = user.bot.owner; + if (owner.type === "workspace") { + console.log(` Owner: workspace`); + } else if (owner.type === "user" && owner.user) { + const ownerName = owner.user.name || owner.user.id; + const ownerEmail = owner.user.person?.email; + console.log(` Owner: ${ownerName}${ownerEmail ? ` (${ownerEmail})` : ""}`); + } + } + } + console.log(); + } + + if (response.has_more && response.next_cursor) { + console.log(`📑 More results available. Use --cursor to get next page:`); + console.log(` notion-cli user list --cursor ${response.next_cursor}`); + } + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : error}`); + process.exit(1); + } + }); + +// -- user command group ------------------------------------------------------- + +export const userCommand = new Command("user") + .description("View Notion users and bot info") + .addCommand(userMeCommand) + .addCommand(userGetCommand) + .addCommand(userListCommand); diff --git a/notion-cli/helpers.ts b/notion-cli/helpers.ts index a4d8ff5..ad20c9b 100644 --- a/notion-cli/helpers.ts +++ b/notion-cli/helpers.ts @@ -3,10 +3,51 @@ */ import { readFileSync, writeFileSync, mkdirSync } from "fs"; -import { join } from "path"; +import { join, extname } from "path"; import { homedir } from "os"; import type { NotionPage, NotionBlock, PropertyValue } from "./src/postman/notion-api/index.js"; +// ============================================================================ +// MIME type lookup (no external dependency) +// ============================================================================ + +const MIME_TYPES: Record<string, string> = { + ".pdf": "application/pdf", + ".txt": "text/plain", + ".csv": "text/csv", + ".json": "application/json", + ".html": "text/html", + ".htm": "text/html", + ".xml": "application/xml", + ".md": "text/markdown", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".zip": "application/zip", + ".gz": "application/gzip", + ".tar": "application/x-tar", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", +}; + +export function lookup(filename: string): string { + const ext = extname(filename).toLowerCase(); + return MIME_TYPES[ext] || "application/octet-stream"; +} + // ============================================================================ // Config // ============================================================================ @@ -32,19 +73,61 @@ export function writeConfig(config: Record<string, string>): void { // ============================================================================ export function getBearerToken(): string { - const token = process.env.NOTION_API_KEY || readConfig().NOTION_API_KEY; + const token = process.env.NOTION_TOKEN || readConfig().NOTION_TOKEN; if (!token) { - console.error("Error: No Notion API key found."); + console.error("Error: No Notion token found."); console.error(""); - console.error("Set your key with:"); - console.error(" notion-cli set-token <your-integration-token>"); + console.error("Authenticate with one of:"); + console.error(" notion-cli auth-internal set <token> (internal integration)"); + console.error(" notion-cli auth-public login (public integration / OAuth)"); console.error(""); - console.error("Or set the NOTION_API_KEY environment variable."); + console.error("Or set the NOTION_TOKEN environment variable."); process.exit(1); } return token; } +// ============================================================================ +// OAuth Credential Helpers +// ============================================================================ + +export interface OAuthCredentials { + clientId: string; + clientSecret: string; +} + +/** + * Resolve OAuth client credentials from environment variables or config file. + * Returns null if neither source has them. + */ +export function getOAuthCredentials(): OAuthCredentials | null { + const clientId = process.env.NOTION_OAUTH_CLIENT_ID || readConfig().NOTION_OAUTH_CLIENT_ID; + const clientSecret = process.env.NOTION_OAUTH_CLIENT_SECRET || readConfig().NOTION_OAUTH_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + return null; + } + return { clientId, clientSecret }; +} + +/** + * Resolve OAuth client credentials, or exit with an error message. + */ +export function requireOAuthCredentials(): OAuthCredentials { + const creds = getOAuthCredentials(); + if (!creds) { + console.error("Error: OAuth client credentials not found."); + console.error(""); + console.error("Set them via environment variables:"); + console.error(" NOTION_OAUTH_CLIENT_ID"); + console.error(" NOTION_OAUTH_CLIENT_SECRET"); + console.error(""); + console.error("Or run 'notion-cli auth login' which will prompt and store them."); + process.exit(1); + } + return creds; +} + export function getPageTitle(page: NotionPage): string { for (const [, prop] of Object.entries(page.properties)) { if (prop.type === "title" && prop.title && prop.title.length > 0) { diff --git a/notion-cli/package.json b/notion-cli/package.json index 191755c..fc1a59a 100644 --- a/notion-cli/package.json +++ b/notion-cli/package.json @@ -9,7 +9,19 @@ "scripts": { "build": "tsc", "prepare": "npm run build", - "cli": "npx tsx cli.ts" + "cli": "npx tsx cli.ts", + "test": "node --import tsx --test ./test/docs.test.ts ./test/user.test.ts ./test/search.test.ts ./test/page.test.ts ./test/block.test.ts ./test/comment.test.ts ./test/database.test.ts ./test/datasource.test.ts ./test/file.test.ts ./test/integration-cmd.test.ts", + "test:docs": "node --import tsx --test ./test/docs.test.ts", + "test:user": "node --import tsx --test ./test/user.test.ts", + "test:search": "node --import tsx --test ./test/search.test.ts", + "test:page": "node --import tsx --test ./test/page.test.ts", + "test:block": "node --import tsx --test ./test/block.test.ts", + "test:comment": "node --import tsx --test ./test/comment.test.ts", + "test:database": "node --import tsx --test ./test/database.test.ts", + "test:datasource": "node --import tsx --test ./test/datasource.test.ts", + "test:file": "node --import tsx --test ./test/file.test.ts", + "test:integration": "node --import tsx --test ./test/integration-cmd.test.ts", + "test:auth": "node --import tsx --test ./test/auth.test.ts" }, "keywords": [ "notion", diff --git a/notion-cli/src/postman/notion-api/blocks/append-block-children/client.ts b/notion-cli/src/postman/notion-api/blocks/append-block-children/client.ts new file mode 100644 index 0000000..fed50c8 --- /dev/null +++ b/notion-cli/src/postman/notion-api/blocks/append-block-children/client.ts @@ -0,0 +1,53 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Blocks > Append block children + * Request UID: 52041987-a9376866-eb97-4cfa-b08d-5fc49f09ef26 + * Request modified at: 2023-09-04T19:06:11.000Z + */ + +import type { AppendBlockChildrenResponse, NotionError } from "../../shared/types.js"; + +/** + * Append child blocks to a parent block or page. + * + * Creates and appends new children blocks to the parent block_id specified. + * Returns the newly created first-level children blocks. + * + * @param blockId - The ID of the parent block or page + * @param children - Array of block objects to append + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns A list response containing the appended blocks + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/patch-block-children + */ +export async function appendBlockChildren( + blockId: string, + children: unknown[], + bearerToken: string, + notionVersion: string +): Promise<AppendBlockChildrenResponse> { + const url = `https://api.notion.com/v1/blocks/${encodeURIComponent(blockId)}/children`; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify({ children }), + }); + + if (response.ok) { + return (await response.json()) as AppendBlockChildrenResponse; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/blocks/delete-block/client.ts b/notion-cli/src/postman/notion-api/blocks/delete-block/client.ts new file mode 100644 index 0000000..9952ff0 --- /dev/null +++ b/notion-cli/src/postman/notion-api/blocks/delete-block/client.ts @@ -0,0 +1,49 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Blocks > Delete a block + * Request UID: 52041987-95e3f732-e993-42b4-8451-70178b3d2ac9 + * Request modified at: 2022-02-24T23:00:56.000Z + */ + +import type { NotionBlock, NotionError } from "../../shared/types.js"; + +/** + * Delete (archive) a block. + * + * Sets a block's archived property to true. The block can be restored + * via the update block endpoint by setting archived to false. + * + * @param blockId - The ID of the block to delete + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The deleted block object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/delete-a-block + */ +export async function deleteBlock( + blockId: string, + bearerToken: string, + notionVersion: string +): Promise<NotionBlock> { + const url = `https://api.notion.com/v1/blocks/${encodeURIComponent(blockId)}`; + + const response = await fetch(url, { + method: "DELETE", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as NotionBlock; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/blocks/retrieve-block-children/client.ts b/notion-cli/src/postman/notion-api/blocks/retrieve-block-children/client.ts index a2c86ff..a066e84 100644 --- a/notion-cli/src/postman/notion-api/blocks/retrieve-block-children/client.ts +++ b/notion-cli/src/postman/notion-api/blocks/retrieve-block-children/client.ts @@ -1,11 +1,11 @@ /** * Generated by Postman Code * - * Collection: Notion API - * Collection UID: 15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2 + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 * * Request: Blocks > Retrieve block children - * Request UID: 15568543-228caaa0-074b-4487-a515-efcea60e5906 + * Request UID: 52041987-039ea5be-709a-4539-b021-170a63eba771 * Request modified at: 2022-02-24T23:01:58.000Z */ @@ -23,7 +23,7 @@ import type { * * @param blockId - The ID of the block or page to get children for * @param bearerToken - Notion API bearer token (integration secret) - * @param notionVersion - Notion API version (default: 2022-02-22) + * @param notionVersion - Notion API version * @param params - Optional pagination parameters * @returns The list of child blocks * @throws Error if the request fails @@ -33,7 +33,7 @@ import type { export async function retrieveBlockChildren( blockId: string, bearerToken: string, - notionVersion: string = "2022-02-22", + notionVersion: string, params: RetrieveBlockChildrenParams = {} ): Promise<RetrieveBlockChildrenResponse> { const queryParams = new URLSearchParams(); diff --git a/notion-cli/src/postman/notion-api/blocks/retrieve-block/client.ts b/notion-cli/src/postman/notion-api/blocks/retrieve-block/client.ts new file mode 100644 index 0000000..db9cffe --- /dev/null +++ b/notion-cli/src/postman/notion-api/blocks/retrieve-block/client.ts @@ -0,0 +1,46 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Blocks > Retrieve a block + * Request UID: 52041987-30ea7fcd-b8b4-441f-935a-c9d143d59d66 + * Request modified at: 2022-02-24T23:01:58.000Z + */ + +import type { NotionBlock, NotionError } from "../../shared/types.js"; + +/** + * Retrieve a single block object by ID. + * + * @param blockId - The ID of the block to retrieve + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The block object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/retrieve-a-block + */ +export async function retrieveBlock( + blockId: string, + bearerToken: string, + notionVersion: string +): Promise<NotionBlock> { + const url = `https://api.notion.com/v1/blocks/${encodeURIComponent(blockId)}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as NotionBlock; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/blocks/update-block/client.ts b/notion-cli/src/postman/notion-api/blocks/update-block/client.ts new file mode 100644 index 0000000..d4d062e --- /dev/null +++ b/notion-cli/src/postman/notion-api/blocks/update-block/client.ts @@ -0,0 +1,56 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Blocks > Update a block + * Request UID: 52041987-1a96de40-de2c-49c8-9697-fc91b077d06d + * Request modified at: 2022-03-02T05:38:38.000Z + */ + +import type { NotionBlock, NotionError } from "../../shared/types.js"; + +/** + * Update a block's content. + * + * The request body should include the block type key with the updated + * fields. For example, to update a paragraph block: + * { paragraph: { rich_text: [{ text: { content: "new text" } }] } } + * + * Can also update the archived status to delete/restore a block. + * + * @param blockId - The ID of the block to update + * @param params - Block content to update (varies by block type) + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The updated block object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/update-a-block + */ +export async function updateBlock( + blockId: string, + params: Record<string, unknown>, + bearerToken: string, + notionVersion: string +): Promise<NotionBlock> { + const url = `https://api.notion.com/v1/blocks/${encodeURIComponent(blockId)}`; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as NotionBlock; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/comments/create-comment/client.ts b/notion-cli/src/postman/notion-api/comments/create-comment/client.ts new file mode 100644 index 0000000..33e53b8 --- /dev/null +++ b/notion-cli/src/postman/notion-api/comments/create-comment/client.ts @@ -0,0 +1,67 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Comments > Create a comment + * Request UID: 52041987-9261f5ec-6b04-4d13-83a0-d12fe1b188c7 + * Request modified at: 2022-07-20T17:24:32.000Z + */ + +import type { NotionComment, NotionError } from "../../shared/types.js"; + +type RichTextItem = { text: { content: string; link?: { url: string } | null } }; + +/** + * Parameters for creating a comment. + * + * Provide **one** of: + * - `parent.page_id` — creates a new top-level comment thread on a page + * - `discussion_id` — replies to an existing comment thread + */ +export interface CreateCommentParams { + /** Start a new comment thread on this page. */ + parent?: { page_id: string }; + /** Reply to an existing discussion thread. */ + discussion_id?: string; + /** The comment body as rich text. */ + rich_text: RichTextItem[]; +} + +/** + * Create a comment — either a new page thread or a reply to an existing discussion. + * + * The Notion API uses the same `POST /v1/comments` endpoint for both cases; + * the request body determines the behavior. + * + * @param params - Comment creation parameters (page comment or discussion reply) + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The created comment object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/create-a-comment + */ +export async function createComment( + params: CreateCommentParams, + bearerToken: string, + notionVersion: string +): Promise<NotionComment> { + const response = await fetch("https://api.notion.com/v1/comments", { + method: "POST", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as NotionComment; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/comments/retrieve-comment/client.ts b/notion-cli/src/postman/notion-api/comments/retrieve-comment/client.ts new file mode 100644 index 0000000..5c25281 --- /dev/null +++ b/notion-cli/src/postman/notion-api/comments/retrieve-comment/client.ts @@ -0,0 +1,46 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Comments > Retrieve a comment + * Request UID: 52041987-2f312153-d16c-459b-9d51-88358a96fe03 + * Request modified at: 2026-02-02T22:36:03.000Z + */ + +import type { NotionComment, NotionError } from "../../shared/types.js"; + +/** + * Retrieve a single comment by ID. + * + * @param commentId - The ID of the comment to retrieve + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The comment object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/retrieve-a-comment + */ +export async function retrieveComment( + commentId: string, + bearerToken: string, + notionVersion: string +): Promise<NotionComment> { + const url = `https://api.notion.com/v1/comments/${encodeURIComponent(commentId)}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as NotionComment; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/comments/retrieve-comments/client.ts b/notion-cli/src/postman/notion-api/comments/retrieve-comments/client.ts new file mode 100644 index 0000000..9cf7e2e --- /dev/null +++ b/notion-cli/src/postman/notion-api/comments/retrieve-comments/client.ts @@ -0,0 +1,61 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Comments > Retrieve comments + * Request UID: 52041987-4def4425-319e-418c-9d96-56a4807a8ce7 + * Request modified at: 2022-07-20T17:24:31.000Z + */ + +import type { RetrieveCommentsParams, RetrieveCommentsResponse, NotionError } from "../../shared/types.js"; + +/** + * Retrieve comments for a block or page. + * + * Returns a paginated list of comment objects for the given block_id. + * Pages are also blocks, so a page ID can be used here. + * + * @param blockId - The ID of the block or page to get comments for + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @param params - Optional pagination parameters + * @returns Paginated list of comments + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/retrieve-a-comment + */ +export async function retrieveComments( + blockId: string, + bearerToken: string, + notionVersion: string, + params?: RetrieveCommentsParams +): Promise<RetrieveCommentsResponse> { + const url = new URL("https://api.notion.com/v1/comments"); + url.searchParams.set("block_id", blockId); + + if (params?.start_cursor) { + url.searchParams.set("start_cursor", params.start_cursor); + } + if (params?.page_size !== undefined) { + url.searchParams.set("page_size", String(params.page_size)); + } else { + url.searchParams.set("page_size", "100"); + } + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as RetrieveCommentsResponse; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/data-sources/create-data-source/client.ts b/notion-cli/src/postman/notion-api/data-sources/create-data-source/client.ts new file mode 100644 index 0000000..8904b3e --- /dev/null +++ b/notion-cli/src/postman/notion-api/data-sources/create-data-source/client.ts @@ -0,0 +1,54 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Data sources > Create a data source + * Request UID: 52041987-9c41977a-1606-4c76-a4e0-d094a3d0b4c7 + * Request modified at: 2026-02-02T22:34:48.000Z + */ + +import type { + CreateDataSourceParams, + NotionDatabase, + NotionError, +} from "../../shared/types.js"; + +/** + * Create a new data source in a database. + * + * Creates a new data source in the specified parent database. + * + * @param params - Data source creation parameters (parent, title, properties) + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The created data source object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/create-a-data-source + */ +export async function createDataSource( + params: CreateDataSourceParams, + bearerToken: string, + notionVersion: string +): Promise<NotionDatabase> { + const url = "https://api.notion.com/v1/data_sources"; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as NotionDatabase; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/data-sources/list-data-source-templates/client.ts b/notion-cli/src/postman/notion-api/data-sources/list-data-source-templates/client.ts new file mode 100644 index 0000000..3f22987 --- /dev/null +++ b/notion-cli/src/postman/notion-api/data-sources/list-data-source-templates/client.ts @@ -0,0 +1,78 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Data sources > List data source templates + * Request UID: 52041987-f38c907f-36d5-40c7-b057-e4811b4b5cde + * Request modified at: 2026-02-02T22:36:03.000Z + */ + +import type { NotionError } from "../../shared/types.js"; + +/** A data source template */ +export interface DataSourceTemplate { + id: string; + name: string; + is_default: boolean; +} + +/** Response from list data source templates endpoint */ +export interface ListDataSourceTemplatesResponse { + templates: DataSourceTemplate[]; + has_more: boolean; + next_cursor: string | null; +} + +/** Parameters for listing data source templates */ +export interface ListDataSourceTemplatesParams { + /** Filter templates by name (case-insensitive substring match) */ + name?: string; + /** Pagination cursor */ + start_cursor?: string; + /** Number of results per page (max 100) */ + page_size?: number; +} + +/** + * List available templates for a data source. + * + * @param dataSourceId - The ID of the data source + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @param params - Optional filter and pagination parameters + * @returns List of available templates + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/list-data-source-templates + */ +export async function listDataSourceTemplates( + dataSourceId: string, + bearerToken: string, + notionVersion: string, + params: ListDataSourceTemplatesParams = {} +): Promise<ListDataSourceTemplatesResponse> { + const queryParams = new URLSearchParams(); + if (params.name) queryParams.set("name", params.name); + if (params.start_cursor) queryParams.set("start_cursor", params.start_cursor); + if (params.page_size) queryParams.set("page_size", String(params.page_size)); + + const qs = queryParams.toString(); + const url = `https://api.notion.com/v1/data_sources/${encodeURIComponent(dataSourceId)}/templates${qs ? `?${qs}` : ""}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as ListDataSourceTemplatesResponse; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/data-sources/query-data-source/client.ts b/notion-cli/src/postman/notion-api/data-sources/query-data-source/client.ts new file mode 100644 index 0000000..226e81d --- /dev/null +++ b/notion-cli/src/postman/notion-api/data-sources/query-data-source/client.ts @@ -0,0 +1,61 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Data sources > Query a data source + * Request UID: 52041987-aa498c21-f7e7-4839-bbe7-78957fb7379d + * Request modified at: 2026-02-02T22:36:03.000Z + */ + +import type { + QueryDataSourceParams, + QueryDataSourceResponse, + NotionError, +} from "../../shared/types.js"; + +/** + * Query a data source. + * + * Queries a data source and returns matching results. This replaces the old + * POST /v1/databases/{database_id}/query endpoint. + * + * IMPORTANT: You must use a data_source_id, NOT a database_id. To get the + * data_source_id, first call GET /v1/databases/{database_id} and extract the + * ID from the data_sources array in the response. + * + * @param dataSourceId - The ID of the data source to query + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @param params - Optional filter, sort, and pagination parameters + * @returns Paginated list of pages in the data source + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/post-data-source-query + */ +export async function queryDataSource( + dataSourceId: string, + bearerToken: string, + notionVersion: string, + params: QueryDataSourceParams = {} +): Promise<QueryDataSourceResponse> { + const url = `https://api.notion.com/v1/data_sources/${encodeURIComponent(dataSourceId)}/query`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as QueryDataSourceResponse; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/data-sources/retrieve-data-source/client.ts b/notion-cli/src/postman/notion-api/data-sources/retrieve-data-source/client.ts new file mode 100644 index 0000000..4e14a11 --- /dev/null +++ b/notion-cli/src/postman/notion-api/data-sources/retrieve-data-source/client.ts @@ -0,0 +1,50 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Data sources > Retrieve a data source + * Request UID: 52041987-dfeeac14-f85e-4527-ad2e-d85f79284dd9 + * Request modified at: 2026-02-02T22:36:03.000Z + */ + +import type { NotionDatabase, NotionError } from "../../shared/types.js"; + +/** + * Retrieve a data source by ID. + * + * Retrieves a data source object using the ID specified. To get data source IDs, + * first call GET /v1/databases/{database_id} and extract IDs from the data_sources + * array. + * + * @param dataSourceId - The ID of the data source to retrieve + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The data source object with schema + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/retrieve-a-data-source + */ +export async function retrieveDataSource( + dataSourceId: string, + bearerToken: string, + notionVersion: string +): Promise<NotionDatabase> { + const url = `https://api.notion.com/v1/data_sources/${encodeURIComponent(dataSourceId)}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as NotionDatabase; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/data-sources/update-data-source/client.ts b/notion-cli/src/postman/notion-api/data-sources/update-data-source/client.ts new file mode 100644 index 0000000..01fddc6 --- /dev/null +++ b/notion-cli/src/postman/notion-api/data-sources/update-data-source/client.ts @@ -0,0 +1,56 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Data sources > Update a data source + * Request UID: 52041987-29f06253-bd7e-4c3c-b0d8-a36b285c4e0e + * Request modified at: 2026-02-02T22:36:03.000Z + */ + +import type { + UpdateDataSourceParams, + NotionDatabase, + NotionError, +} from "../../shared/types.js"; + +/** + * Update a data source. + * + * Updates the specified data source object. + * + * @param dataSourceId - The ID of the data source to update + * @param params - Properties to update (title, properties schema) + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The updated data source object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/update-a-data-source + */ +export async function updateDataSource( + dataSourceId: string, + params: UpdateDataSourceParams, + bearerToken: string, + notionVersion: string +): Promise<NotionDatabase> { + const url = `https://api.notion.com/v1/data_sources/${encodeURIComponent(dataSourceId)}`; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as NotionDatabase; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/databases/create-database/client.ts b/notion-cli/src/postman/notion-api/databases/create-database/client.ts new file mode 100644 index 0000000..5c222e0 --- /dev/null +++ b/notion-cli/src/postman/notion-api/databases/create-database/client.ts @@ -0,0 +1,49 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Databases > Create a database + * Request UID: 52041987-85ab373b-2fc1-4b9a-a8a6-ee6b9e72728c + * Request modified at: 2022-03-02T05:38:33.000Z + */ + +import type { CreateDatabaseParams, NotionDatabase, NotionError } from "../../shared/types.js"; + +/** + * Create a database as a child of a page. + * + * The database schema is defined by the properties object. + * At minimum, a title property is required. + * + * @param params - Database creation parameters (parent, title, properties) + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The created database object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/create-a-database + */ +export async function createDatabase( + params: CreateDatabaseParams, + bearerToken: string, + notionVersion: string +): Promise<NotionDatabase> { + const response = await fetch("https://api.notion.com/v1/databases", { + method: "POST", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as NotionDatabase; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/databases/query-database/client.ts b/notion-cli/src/postman/notion-api/databases/query-database/client.ts index b0b00f1..0fa166f 100644 --- a/notion-cli/src/postman/notion-api/databases/query-database/client.ts +++ b/notion-cli/src/postman/notion-api/databases/query-database/client.ts @@ -1,11 +1,13 @@ /** * Generated by Postman Code * - * Collection: Notion API - * Collection UID: 15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2 + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 * * Request: Databases > Query a database * Request UID: 15568543-cddcc0aa-d534-4744-b37a-ddf36dee7d8f + * Note: This endpoint is REMOVED in the 2025-09-03 API. Use query-data-source instead. + * Kept for backwards compatibility during migration. * Request modified at: 2023-09-05T16:53:05.000Z */ @@ -23,7 +25,7 @@ import type { * * @param databaseId - The ID of the database to query * @param bearerToken - Notion API bearer token (integration secret) - * @param notionVersion - Notion API version (default: 2022-02-22) + * @param notionVersion - Notion API version * @param params - Optional filter, sort, and pagination parameters * @returns Paginated list of pages in the database * @throws Error if the request fails @@ -33,7 +35,7 @@ import type { export async function queryDatabase( databaseId: string, bearerToken: string, - notionVersion: string = "2022-02-22", + notionVersion: string, params: QueryDatabaseParams = {} ): Promise<QueryDatabaseResponse> { const url = `https://api.notion.com/v1/databases/${encodeURIComponent(databaseId)}/query`; diff --git a/notion-cli/src/postman/notion-api/databases/retrieve-database/client.ts b/notion-cli/src/postman/notion-api/databases/retrieve-database/client.ts index c507807..a4d7fa2 100644 --- a/notion-cli/src/postman/notion-api/databases/retrieve-database/client.ts +++ b/notion-cli/src/postman/notion-api/databases/retrieve-database/client.ts @@ -1,11 +1,11 @@ /** * Generated by Postman Code * - * Collection: Notion API - * Collection UID: 15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2 + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 * * Request: Databases > Retrieve a database - * Request UID: 15568543-095c448d-2373-4aee-9b9e-ed3cf58afbe3 + * Request UID: 52041987-73359528-2278-415f-98f2-4d20274cc69e * Request modified at: 2022-03-02T05:38:32.000Z */ @@ -19,7 +19,7 @@ import type { NotionDatabase, NotionError } from "../../shared/types.js"; * * @param databaseId - The ID of the database to retrieve * @param bearerToken - Notion API bearer token (integration secret) - * @param notionVersion - Notion API version (default: 2022-02-22) + * @param notionVersion - Notion API version * @returns The database object with schema * @throws Error if the request fails * @@ -28,7 +28,7 @@ import type { NotionDatabase, NotionError } from "../../shared/types.js"; export async function retrieveDatabase( databaseId: string, bearerToken: string, - notionVersion: string = "2022-02-22" + notionVersion: string ): Promise<NotionDatabase> { const url = `https://api.notion.com/v1/databases/${encodeURIComponent(databaseId)}`; diff --git a/notion-cli/src/postman/notion-api/databases/update-database/client.ts b/notion-cli/src/postman/notion-api/databases/update-database/client.ts new file mode 100644 index 0000000..859d8a9 --- /dev/null +++ b/notion-cli/src/postman/notion-api/databases/update-database/client.ts @@ -0,0 +1,53 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Databases > Update a database + * Request UID: 52041987-5febd2f6-c9ff-486d-8a4b-71e5c58f82ef + * Request modified at: 2023-09-04T19:04:51.000Z + */ + +import type { UpdateDatabaseParams, NotionDatabase, NotionError } from "../../shared/types.js"; + +/** + * Update a database's title, description, or properties. + * + * Only the fields included in the request body are updated; + * omitted fields remain unchanged. + * + * @param databaseId - The ID of the database to update + * @param params - Fields to update (title, description, properties) + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The updated database object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/update-a-database + */ +export async function updateDatabase( + databaseId: string, + params: UpdateDatabaseParams, + bearerToken: string, + notionVersion: string +): Promise<NotionDatabase> { + const url = `https://api.notion.com/v1/databases/${encodeURIComponent(databaseId)}`; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as NotionDatabase; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/file-uploads/complete-file-upload/client.ts b/notion-cli/src/postman/notion-api/file-uploads/complete-file-upload/client.ts new file mode 100644 index 0000000..d154388 --- /dev/null +++ b/notion-cli/src/postman/notion-api/file-uploads/complete-file-upload/client.ts @@ -0,0 +1,46 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Complete file upload + * Request UID: 52041987-80549896-30b1-43a4-add0-a73958b602e1 + * Request modified at: 2026-02-02T22:37:45.000Z + */ + +import type { NotionError } from "../../shared/types.js"; +import type { FileUpload } from "../create-file-upload/client.js"; + +/** + * Complete a file upload after all chunks have been sent. + * + * @param fileUploadId - The ID of the file upload to complete + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The completed file upload object + * @throws Error if the request fails + */ +export async function completeFileUpload( + fileUploadId: string, + bearerToken: string, + notionVersion: string +): Promise<FileUpload> { + const url = `https://api.notion.com/v1/file_uploads/${encodeURIComponent(fileUploadId)}/complete`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as FileUpload; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/file-uploads/create-file-upload/client.ts b/notion-cli/src/postman/notion-api/file-uploads/create-file-upload/client.ts new file mode 100644 index 0000000..f79617b --- /dev/null +++ b/notion-cli/src/postman/notion-api/file-uploads/create-file-upload/client.ts @@ -0,0 +1,80 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: File uploads > Create a file upload + * Request UID: 52041987-1548ae35-ac12-4fc3-a337-416ed2a92088 + * Request modified at: 2026-02-02T22:34:48.000Z + */ + +import type { NotionError } from "../../shared/types.js"; + +/** Parameters for creating a file upload */ +export interface CreateFileUploadParams { + /** How the file is being sent. Default is "single_part". */ + mode?: "single_part" | "multi_part" | "external_url"; + /** Name of the file. Must include an extension, or have one inferred from content_type. */ + filename?: string; + /** MIME type of the file. */ + content_type?: string; + /** When mode is "multi_part", the number of parts being uploaded. */ + number_of_parts?: number; + /** When mode is "external_url", the HTTPS URL of a publicly accessible file. */ + external_url?: string; +} + +/** File upload object returned by the Notion API */ +export interface FileUpload { + object: "file_upload"; + id: string; + created_time: string; + created_by: { id: string; type: string }; + last_edited_time: string; + archived: boolean; + expiry_time: string | null; + status: "pending" | "uploaded" | "expired" | "failed"; + filename: string | null; + content_type: string | null; + content_length: number | null; + upload_url?: string; + complete_url?: string; + number_of_parts?: { total: number; sent: number }; +} + +/** + * Initialize a new file upload. + * + * @param params - File upload parameters (mode, filename, content_type) + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The created file upload object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/create-a-file-upload + */ +export async function createFileUpload( + params: CreateFileUploadParams, + bearerToken: string, + notionVersion: string +): Promise<FileUpload> { + const url = "https://api.notion.com/v1/file_uploads"; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as FileUpload; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/file-uploads/list-file-uploads/client.ts b/notion-cli/src/postman/notion-api/file-uploads/list-file-uploads/client.ts new file mode 100644 index 0000000..e919459 --- /dev/null +++ b/notion-cli/src/postman/notion-api/file-uploads/list-file-uploads/client.ts @@ -0,0 +1,68 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: File uploads > List file uploads + * Request UID: 52041987-d6b82f81-aaf2-4cc0-b92c-c8cdf709e67d + * Request modified at: 2026-02-02T22:34:48.000Z + */ + +import type { NotionError } from "../../shared/types.js"; +import type { FileUpload } from "../create-file-upload/client.js"; + +/** Response from list file uploads endpoint */ +export interface ListFileUploadsResponse { + object: "list"; + results: FileUpload[]; + next_cursor: string | null; + has_more: boolean; +} + +/** Parameters for listing file uploads */ +export interface ListFileUploadsParams { + /** Cursor for pagination */ + start_cursor?: string; + /** Number of results per page (max 100) */ + page_size?: number; +} + +/** + * List file uploads. + * + * Returns a paginated list of file uploads. + * + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @param params - Optional pagination parameters + * @returns Paginated list of file uploads + * @throws Error if the request fails + */ +export async function listFileUploads( + bearerToken: string, + notionVersion: string, + params: ListFileUploadsParams = {} +): Promise<ListFileUploadsResponse> { + const queryParams = new URLSearchParams(); + if (params.start_cursor) queryParams.set("start_cursor", params.start_cursor); + if (params.page_size) queryParams.set("page_size", String(params.page_size)); + + const qs = queryParams.toString(); + const url = `https://api.notion.com/v1/file_uploads${qs ? `?${qs}` : ""}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as ListFileUploadsResponse; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/file-uploads/retrieve-file-upload/client.ts b/notion-cli/src/postman/notion-api/file-uploads/retrieve-file-upload/client.ts new file mode 100644 index 0000000..135e3e6 --- /dev/null +++ b/notion-cli/src/postman/notion-api/file-uploads/retrieve-file-upload/client.ts @@ -0,0 +1,45 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: File uploads > Retrieve a file upload + * Request UID: 52041987-2f30fd9c-c12b-40fc-bb98-03d153c3e353 + * Request modified at: 2026-02-02T22:37:45.000Z + */ + +import type { NotionError } from "../../shared/types.js"; +import type { FileUpload } from "../create-file-upload/client.js"; + +/** + * Retrieve a file upload by ID. + * + * @param fileUploadId - The ID of the file upload to retrieve + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The file upload object + * @throws Error if the request fails + */ +export async function retrieveFileUpload( + fileUploadId: string, + bearerToken: string, + notionVersion: string +): Promise<FileUpload> { + const url = `https://api.notion.com/v1/file_uploads/${encodeURIComponent(fileUploadId)}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as FileUpload; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/file-uploads/send-file-upload/client.ts b/notion-cli/src/postman/notion-api/file-uploads/send-file-upload/client.ts new file mode 100644 index 0000000..e30c34d --- /dev/null +++ b/notion-cli/src/postman/notion-api/file-uploads/send-file-upload/client.ts @@ -0,0 +1,65 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: File uploads > Send file upload + * Request UID: 52041987-aaf29c9a-7236-432d-97e8-beebee88b3cd + * Request modified at: 2026-02-02T22:37:45.000Z + */ + +import type { NotionError } from "../../shared/types.js"; +import type { FileUpload } from "../create-file-upload/client.js"; + +/** + * Send file data for an initialized file upload. + * + * Uses multipart/form-data to transmit the raw file binary under the "file" key. + * Do NOT set Content-Type manually — fetch sets it automatically with the + * correct multipart boundary when given a FormData body. + * + * @param fileUploadId - The ID of the file upload + * @param file - File data as a Blob (or Buffer wrapped in a Blob) + * @param filename - The file name to include in the form data + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @param partNumber - Optional part number for multi-part uploads + * @returns The updated file upload object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/send-a-file-upload + */ +export async function sendFileUpload( + fileUploadId: string, + file: Blob, + filename: string, + bearerToken: string, + notionVersion: string, + partNumber?: number +): Promise<FileUpload> { + const url = `https://api.notion.com/v1/file_uploads/${encodeURIComponent(fileUploadId)}/send`; + + const formData = new FormData(); + formData.append("file", file, filename); + if (partNumber !== undefined) { + formData.append("part_number", String(partNumber)); + } + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${bearerToken}`, + // Do NOT set Content-Type — fetch sets multipart/form-data with boundary automatically + "Notion-Version": notionVersion, + }, + body: formData, + }); + + if (response.ok) { + return (await response.json()) as FileUpload; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/index.ts b/notion-cli/src/postman/notion-api/index.ts index ba2730a..b32b9a5 100644 --- a/notion-cli/src/postman/notion-api/index.ts +++ b/notion-cli/src/postman/notion-api/index.ts @@ -8,7 +8,7 @@ * * const notion = createNotionClient(token); * const db = await notion.databases.retrieve(id); - * const results = await notion.databases.query(id); + * const results = await notion.dataSources.query(dataSourceId); * const page = await notion.pages.retrieve(id); * const children = await notion.blocks.retrieveChildren(id); * const hits = await notion.search(); @@ -18,24 +18,79 @@ * resource IDs and optional request parameters. */ +import { retrieveComments as _retrieveComments } from "./comments/retrieve-comments/client.js"; +import { createComment as _createComment } from "./comments/create-comment/client.js"; +import { retrieveComment as _retrieveComment } from "./comments/retrieve-comment/client.js"; +import { retrieveBlock as _retrieveBlock } from "./blocks/retrieve-block/client.js"; import { retrieveBlockChildren as _retrieveBlockChildren } from "./blocks/retrieve-block-children/client.js"; +import { appendBlockChildren as _appendBlockChildren } from "./blocks/append-block-children/client.js"; +import { updateBlock as _updateBlock } from "./blocks/update-block/client.js"; +import { deleteBlock as _deleteBlock } from "./blocks/delete-block/client.js"; import { queryDatabase as _queryDatabase } from "./databases/query-database/client.js"; import { retrieveDatabase as _retrieveDatabase } from "./databases/retrieve-database/client.js"; +import { createDatabase as _createDatabase } from "./databases/create-database/client.js"; +import { updateDatabase as _updateDatabase } from "./databases/update-database/client.js"; +import { queryDataSource as _queryDataSource } from "./data-sources/query-data-source/client.js"; +import { retrieveDataSource as _retrieveDataSource } from "./data-sources/retrieve-data-source/client.js"; +import { updateDataSource as _updateDataSource } from "./data-sources/update-data-source/client.js"; +import { createDataSource as _createDataSource } from "./data-sources/create-data-source/client.js"; +import { listDataSourceTemplates as _listDataSourceTemplates } from "./data-sources/list-data-source-templates/client.js"; +import { createPage as _createPage } from "./pages/create-page/client.js"; +import { archivePage as _archivePage } from "./pages/archive-page/client.js"; +import { updatePageProperties as _updatePageProperties } from "./pages/update-page-properties/client.js"; import { retrievePage as _retrievePage } from "./pages/retrieve-page/client.js"; +import { retrievePageProperty as _retrievePageProperty } from "./pages/retrieve-page-property/client.js"; +import { movePage as _movePage } from "./pages/move-page/client.js"; import { search as _search } from "./search/search/client.js"; +import { retrieveBotUser as _retrieveBotUser } from "./users/retrieve-bot-user/client.js"; +import { retrieveUser as _retrieveUser } from "./users/retrieve-user/client.js"; +import { listUsers as _listUsers } from "./users/list-users/client.js"; +import { createFileUpload as _createFileUpload } from "./file-uploads/create-file-upload/client.js"; +import { sendFileUpload as _sendFileUpload } from "./file-uploads/send-file-upload/client.js"; +import { completeFileUpload as _completeFileUpload } from "./file-uploads/complete-file-upload/client.js"; +import { retrieveFileUpload as _retrieveFileUpload } from "./file-uploads/retrieve-file-upload/client.js"; +import { listFileUploads as _listFileUploads } from "./file-uploads/list-file-uploads/client.js"; +import { oauthToken as _oauthToken } from "./oauth/token/client.js"; +import { oauthRevoke as _oauthRevoke } from "./oauth/revoke/client.js"; +import { oauthIntrospect as _oauthIntrospect } from "./oauth/introspect/client.js"; import { variables } from "./shared/variables.js"; import type { + NotionComment, + RetrieveCommentsParams, + RetrieveCommentsResponse, + NotionBlock, RetrieveBlockChildrenParams, RetrieveBlockChildrenResponse, + AppendBlockChildrenResponse, QueryDatabaseParams, QueryDatabaseResponse, + QueryDataSourceParams, + QueryDataSourceResponse, + CreateDatabaseParams, + UpdateDatabaseParams, + CreateDataSourceParams, + UpdateDataSourceParams, + MovePageParams, + ListUsersParams, + ListUsersResponse, NotionDatabase, + CreatePageParams, + UpdatePageParams, NotionPage, + NotionUser, + PagePropertyItemResponse, SearchParams, SearchResponse, } from "./shared/types.js"; +import type { CreateCommentParams } from "./comments/create-comment/client.js"; +import type { ListDataSourceTemplatesResponse, ListDataSourceTemplatesParams } from "./data-sources/list-data-source-templates/client.js"; +import type { CreateFileUploadParams, FileUpload } from "./file-uploads/create-file-upload/client.js"; +import type { ListFileUploadsParams, ListFileUploadsResponse } from "./file-uploads/list-file-uploads/client.js"; +import type { OAuthTokenParams, OAuthTokenResponse } from "./oauth/token/client.js"; +import type { OAuthIntrospectResponse } from "./oauth/introspect/client.js"; + const { NOTION_VERSION } = variables.collection; /** @@ -48,28 +103,121 @@ const { NOTION_VERSION } = variables.collection; */ export function createNotionClient(bearerToken: string) { return { + comments: { + list: ( + blockId: string, + params?: RetrieveCommentsParams + ): Promise<RetrieveCommentsResponse> => + _retrieveComments(blockId, bearerToken, NOTION_VERSION, params), + retrieve: (commentId: string): Promise<NotionComment> => + _retrieveComment(commentId, bearerToken, NOTION_VERSION), + create: (params: CreateCommentParams): Promise<NotionComment> => + _createComment(params, bearerToken, NOTION_VERSION), + }, blocks: { + retrieve: (blockId: string): Promise<NotionBlock> => + _retrieveBlock(blockId, bearerToken, NOTION_VERSION), retrieveChildren: ( blockId: string, params?: RetrieveBlockChildrenParams ): Promise<RetrieveBlockChildrenResponse> => _retrieveBlockChildren(blockId, bearerToken, NOTION_VERSION, params), + appendChildren: ( + blockId: string, + children: unknown[] + ): Promise<AppendBlockChildrenResponse> => + _appendBlockChildren(blockId, children, bearerToken, NOTION_VERSION), + update: ( + blockId: string, + params: Record<string, unknown> + ): Promise<NotionBlock> => + _updateBlock(blockId, params, bearerToken, NOTION_VERSION), + delete: (blockId: string): Promise<NotionBlock> => + _deleteBlock(blockId, bearerToken, NOTION_VERSION), }, databases: { retrieve: (databaseId: string): Promise<NotionDatabase> => _retrieveDatabase(databaseId, bearerToken, NOTION_VERSION), + /** @deprecated Use dataSources.query() instead — database query is removed in API 2025-09-03 */ query: ( databaseId: string, params?: QueryDatabaseParams ): Promise<QueryDatabaseResponse> => _queryDatabase(databaseId, bearerToken, NOTION_VERSION, params), + create: (params: CreateDatabaseParams): Promise<NotionDatabase> => + _createDatabase(params, bearerToken, NOTION_VERSION), + update: ( + databaseId: string, + params: UpdateDatabaseParams + ): Promise<NotionDatabase> => + _updateDatabase(databaseId, params, bearerToken, NOTION_VERSION), + }, + dataSources: { + retrieve: (dataSourceId: string): Promise<NotionDatabase> => + _retrieveDataSource(dataSourceId, bearerToken, NOTION_VERSION), + query: ( + dataSourceId: string, + params?: QueryDataSourceParams + ): Promise<QueryDataSourceResponse> => + _queryDataSource(dataSourceId, bearerToken, NOTION_VERSION, params), + create: (params: CreateDataSourceParams): Promise<NotionDatabase> => + _createDataSource(params, bearerToken, NOTION_VERSION), + update: ( + dataSourceId: string, + params: UpdateDataSourceParams + ): Promise<NotionDatabase> => + _updateDataSource(dataSourceId, params, bearerToken, NOTION_VERSION), + listTemplates: (dataSourceId: string, params?: ListDataSourceTemplatesParams): Promise<ListDataSourceTemplatesResponse> => + _listDataSourceTemplates(dataSourceId, bearerToken, NOTION_VERSION, params), }, pages: { + create: (params: CreatePageParams): Promise<NotionPage> => + _createPage(params, bearerToken, NOTION_VERSION), + update: (pageId: string, params: UpdatePageParams): Promise<NotionPage> => + _updatePageProperties(pageId, params, bearerToken, NOTION_VERSION), + archive: (pageId: string): Promise<NotionPage> => + _archivePage(pageId, bearerToken, NOTION_VERSION), retrieve: (pageId: string): Promise<NotionPage> => _retrievePage(pageId, bearerToken, NOTION_VERSION), + retrieveProperty: ( + pageId: string, + propertyId: string, + params?: { start_cursor?: string; page_size?: number } + ): Promise<PagePropertyItemResponse> => + _retrievePageProperty(pageId, propertyId, bearerToken, NOTION_VERSION, params), + move: (pageId: string, params: MovePageParams): Promise<NotionPage> => + _movePage(pageId, params, bearerToken, NOTION_VERSION), }, search: (params?: SearchParams): Promise<SearchResponse> => _search(bearerToken, NOTION_VERSION, params), + users: { + me: (): Promise<NotionUser> => + _retrieveBotUser(bearerToken, NOTION_VERSION), + retrieve: (userId: string): Promise<NotionUser> => + _retrieveUser(userId, bearerToken, NOTION_VERSION), + list: (params?: ListUsersParams): Promise<ListUsersResponse> => + _listUsers(bearerToken, NOTION_VERSION, params), + }, + fileUploads: { + create: (params: CreateFileUploadParams): Promise<FileUpload> => + _createFileUpload(params, bearerToken, NOTION_VERSION), + send: (fileUploadId: string, file: Blob, filename: string, partNumber?: number): Promise<FileUpload> => + _sendFileUpload(fileUploadId, file, filename, bearerToken, NOTION_VERSION, partNumber), + complete: (fileUploadId: string): Promise<FileUpload> => + _completeFileUpload(fileUploadId, bearerToken, NOTION_VERSION), + retrieve: (fileUploadId: string): Promise<FileUpload> => + _retrieveFileUpload(fileUploadId, bearerToken, NOTION_VERSION), + list: (params?: ListFileUploadsParams): Promise<ListFileUploadsResponse> => + _listFileUploads(bearerToken, NOTION_VERSION, params), + }, + oauth: { + token: (params: OAuthTokenParams, clientId: string, clientSecret: string): Promise<OAuthTokenResponse> => + _oauthToken(params, clientId, clientSecret), + revoke: (token: string, clientId: string, clientSecret: string): Promise<void> => + _oauthRevoke(token, clientId, clientSecret), + introspect: (token: string, clientId: string, clientSecret: string): Promise<OAuthIntrospectResponse> => + _oauthIntrospect(token, clientId, clientSecret), + }, } as const; } @@ -79,6 +227,8 @@ export type { NotionPage, NotionBlock, NotionDatabase, + NotionUser, + NotionComment, NotionError, // Building blocks UserReference, @@ -88,17 +238,58 @@ export type { PropertyValue, DatabasePropertySchema, DatabaseParent, + DataSourceReference, + // Page params/responses + CreatePageParams, + UpdatePageParams, + MovePageParams, + PagePropertyItemResponse, + // Database params + CreateDatabaseParams, + UpdateDatabaseParams, + // Data source params + QueryDataSourceParams, + QueryDataSourceResponse, + CreateDataSourceParams, + UpdateDataSourceParams, + // Block responses + AppendBlockChildrenResponse, // Request params + RetrieveCommentsParams, RetrieveBlockChildrenParams, QueryDatabaseParams, + ListUsersParams, SearchParams, SearchFilter, SearchSort, // Response types + RetrieveCommentsResponse, RetrieveBlockChildrenResponse, QueryDatabaseResponse, + ListUsersResponse, SearchResponse, } from "./shared/types.js"; +// Re-export file upload types +export type { + CreateFileUploadParams, + FileUpload, +} from "./file-uploads/create-file-upload/client.js"; +export type { + ListFileUploadsParams, + ListFileUploadsResponse, +} from "./file-uploads/list-file-uploads/client.js"; +export type { ListDataSourceTemplatesResponse, ListDataSourceTemplatesParams, DataSourceTemplate } from "./data-sources/list-data-source-templates/client.js"; + +// Re-export OAuth types +export type { + OAuthTokenParams, + OAuthTokenResponse, +} from "./oauth/token/client.js"; +export type { OAuthIntrospectResponse } from "./oauth/introspect/client.js"; + +// Re-export comment types +export type { CreateCommentParams } from "./comments/create-comment/client.js"; + // Re-export collection variables export { variables } from "./shared/variables.js"; diff --git a/notion-cli/src/postman/notion-api/oauth/introspect/client.ts b/notion-cli/src/postman/notion-api/oauth/introspect/client.ts new file mode 100644 index 0000000..3c96650 --- /dev/null +++ b/notion-cli/src/postman/notion-api/oauth/introspect/client.ts @@ -0,0 +1,61 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: OAuth > Introspect + * Request UID: 52041987-3070c020-0b6d-402f-bd18-88e9e1348521 + * Request modified at: 2026-02-02T22:34:48.000Z + */ + +import type { NotionError } from "../../shared/types.js"; + +/** Response from the OAuth introspect endpoint */ +export interface OAuthIntrospectResponse { + active: boolean; + bot_id?: string; + workspace_id?: string; + token_type?: string; + /** When the token was issued (ISO 8601) */ + iat?: string; + /** When the token expires (ISO 8601) */ + exp?: string; + [key: string]: unknown; +} + +/** + * Introspect a token to get its metadata. + * + * This endpoint uses Basic authentication with your OAuth client_id and client_secret. + * + * @param token - The token to introspect + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + * @returns Token metadata including active status + * @throws Error if the request fails + */ +export async function oauthIntrospect( + token: string, + clientId: string, + clientSecret: string +): Promise<OAuthIntrospectResponse> { + const url = "https://api.notion.com/v1/oauth/introspect"; + const credentials = btoa(`${clientId}:${clientSecret}`); + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Basic ${credentials}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ token }), + }); + + if (response.ok) { + return (await response.json()) as OAuthIntrospectResponse; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/oauth/revoke/client.ts b/notion-cli/src/postman/notion-api/oauth/revoke/client.ts new file mode 100644 index 0000000..e85af64 --- /dev/null +++ b/notion-cli/src/postman/notion-api/oauth/revoke/client.ts @@ -0,0 +1,45 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: OAuth > Revoke + * Request UID: 52041987-2e4a8940-5ef0-42b2-9c3c-d56c1752e9ac + * Request modified at: 2026-02-02T22:34:48.000Z + */ + +import type { NotionError } from "../../shared/types.js"; + +/** + * Revoke an access token or refresh token. + * + * This endpoint uses Basic authentication with your OAuth client_id and client_secret. + * + * @param token - The token to revoke + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + * @throws Error if the request fails + */ +export async function oauthRevoke( + token: string, + clientId: string, + clientSecret: string +): Promise<void> { + const url = "https://api.notion.com/v1/oauth/revoke"; + const credentials = btoa(`${clientId}:${clientSecret}`); + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Basic ${credentials}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ token }), + }); + + if (!response.ok) { + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); + } +} diff --git a/notion-cli/src/postman/notion-api/oauth/token/client.ts b/notion-cli/src/postman/notion-api/oauth/token/client.ts new file mode 100644 index 0000000..489173d --- /dev/null +++ b/notion-cli/src/postman/notion-api/oauth/token/client.ts @@ -0,0 +1,71 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: OAuth > Token + * Request UID: 52041987-ced3cc2e-170e-40fa-8f39-fee0f6a464d7 + * Request modified at: 2026-02-02T22:34:48.000Z + */ + +import type { NotionError } from "../../shared/types.js"; + +/** Parameters for the OAuth token exchange */ +export interface OAuthTokenParams { + /** Grant type — typically "authorization_code" */ + grant_type: string; + /** Authorization code received from the OAuth flow */ + code: string; + /** Redirect URI used in the authorization request */ + redirect_uri: string; +} + +/** Response from the OAuth token endpoint */ +export interface OAuthTokenResponse { + access_token: string; + token_type: string; + bot_id: string; + workspace_name: string; + workspace_icon: string | null; + workspace_id: string; + owner?: unknown; + duplicated_template_id?: string | null; +} + +/** + * Exchange an authorization code for an access token. + * + * This endpoint uses Basic authentication with your OAuth client_id and client_secret. + * Encode them as base64(client_id:client_secret) for the Authorization header. + * + * @param params - Token exchange parameters (grant_type, code, redirect_uri) + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + * @returns The OAuth token response with access_token + * @throws Error if the request fails + */ +export async function oauthToken( + params: OAuthTokenParams, + clientId: string, + clientSecret: string +): Promise<OAuthTokenResponse> { + const url = "https://api.notion.com/v1/oauth/token"; + const credentials = btoa(`${clientId}:${clientSecret}`); + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Basic ${credentials}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as OAuthTokenResponse; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/pages/archive-page/client.ts b/notion-cli/src/postman/notion-api/pages/archive-page/client.ts new file mode 100644 index 0000000..a23fb66 --- /dev/null +++ b/notion-cli/src/postman/notion-api/pages/archive-page/client.ts @@ -0,0 +1,52 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Pages > Update page properties + * Request UID: 52041987-de2726f0-1465-4fdc-81d5-bd35415848b4 + * Note: Archive uses PATCH /v1/pages/{id} with { archived: true } — same as Update page properties + * Request modified at: 2023-09-05T16:35:20.000Z + */ + +import type { NotionPage, NotionError } from "../../shared/types.js"; + +/** + * Archive (soft-delete) a page. + * + * Sets the page's archived property to true. The page can be restored + * later by setting archived back to false via the update endpoint. + * + * @param pageId - The ID of the page to archive + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The archived page object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/archive-a-page + */ +export async function archivePage( + pageId: string, + bearerToken: string, + notionVersion: string +): Promise<NotionPage> { + const url = `https://api.notion.com/v1/pages/${encodeURIComponent(pageId)}`; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify({ archived: true }), + }); + + if (response.ok) { + return (await response.json()) as NotionPage; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/pages/create-page/client.ts b/notion-cli/src/postman/notion-api/pages/create-page/client.ts new file mode 100644 index 0000000..676c466 --- /dev/null +++ b/notion-cli/src/postman/notion-api/pages/create-page/client.ts @@ -0,0 +1,49 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Pages > Create a page + * Request UID: 52041987-a2ef9963-62e0-4e87-a12b-f899f695280c + * Request modified at: 2023-09-04T19:05:19.000Z + */ + +import type { CreatePageParams, NotionPage, NotionError } from "../../shared/types.js"; + +/** + * Create a new page in Notion. + * + * The page can be created under a parent page or inside a database. + * When created in a database, properties must match the database schema. + * + * @param params - Page creation parameters (parent, properties, optional children) + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The created page object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/post-page + */ +export async function createPage( + params: CreatePageParams, + bearerToken: string, + notionVersion: string +): Promise<NotionPage> { + const response = await fetch("https://api.notion.com/v1/pages/", { + method: "POST", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as NotionPage; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/pages/move-page/client.ts b/notion-cli/src/postman/notion-api/pages/move-page/client.ts new file mode 100644 index 0000000..edfb06b --- /dev/null +++ b/notion-cli/src/postman/notion-api/pages/move-page/client.ts @@ -0,0 +1,54 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Pages > Move page + * Request UID: 52041987-9686de7b-77c0-4d53-b800-bf6c748bc668 + * Request modified at: 2026-02-02T22:36:37.000Z + */ + +import type { + MovePageParams, + NotionPage, + NotionError, +} from "../../shared/types.js"; + +/** + * Move a page to a new parent page or database. + * + * @param pageId - The ID of the page to move + * @param params - The new parent specification + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The updated page object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/move-a-page + */ +export async function movePage( + pageId: string, + params: MovePageParams, + bearerToken: string, + notionVersion: string +): Promise<NotionPage> { + const url = `https://api.notion.com/v1/pages/${encodeURIComponent(pageId)}/move`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as NotionPage; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/pages/retrieve-page-property/client.ts b/notion-cli/src/postman/notion-api/pages/retrieve-page-property/client.ts new file mode 100644 index 0000000..93d0709 --- /dev/null +++ b/notion-cli/src/postman/notion-api/pages/retrieve-page-property/client.ts @@ -0,0 +1,63 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Pages > Retrieve a page property item + * Request UID: 52041987-e3c019ad-9c8b-4975-a142-5549ef771028 + * Request modified at: 2023-09-05T16:45:46.000Z + */ + +import type { PagePropertyItemResponse, NotionError } from "../../shared/types.js"; + +/** + * Retrieve a page property item by page ID and property ID. + * + * For most property types this returns the value directly. For paginated + * properties (e.g. rollups, relations with many entries, rich_text, title) + * the response includes pagination fields. + * + * @param pageId - The ID of the page + * @param propertyId - The ID of the property to retrieve + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @param params - Optional pagination parameters + * @returns The property item (or paginated list of property items) + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/retrieve-a-page-property + */ +export async function retrievePageProperty( + pageId: string, + propertyId: string, + bearerToken: string, + notionVersion: string, + params?: { start_cursor?: string; page_size?: number } +): Promise<PagePropertyItemResponse> { + const url = new URL( + `https://api.notion.com/v1/pages/${encodeURIComponent(pageId)}/properties/${encodeURIComponent(propertyId)}` + ); + + if (params?.start_cursor) { + url.searchParams.set("start_cursor", params.start_cursor); + } + if (params?.page_size !== undefined) { + url.searchParams.set("page_size", String(params.page_size)); + } + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as PagePropertyItemResponse; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/pages/retrieve-page/client.ts b/notion-cli/src/postman/notion-api/pages/retrieve-page/client.ts index 53e58e9..1dd2a92 100644 --- a/notion-cli/src/postman/notion-api/pages/retrieve-page/client.ts +++ b/notion-cli/src/postman/notion-api/pages/retrieve-page/client.ts @@ -1,11 +1,11 @@ /** * Generated by Postman Code * - * Collection: Notion API - * Collection UID: 15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2 + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 * * Request: Pages > Retrieve a page - * Request UID: 15568543-8a70cbd1-7dbe-4699-a04e-108084a9c31b + * Request UID: 52041987-d7e520f6-0c75-4fe0-9b23-990f742d496e * Request modified at: 2023-09-04T19:08:43.000Z */ @@ -19,7 +19,7 @@ import type { NotionPage, NotionError } from "../../shared/types.js"; * * @param pageId - The ID of the page to retrieve * @param bearerToken - Notion API bearer token (integration secret) - * @param notionVersion - Notion API version (default: 2022-02-22) + * @param notionVersion - Notion API version * @returns The page object with properties * @throws Error if the request fails * @@ -28,7 +28,7 @@ import type { NotionPage, NotionError } from "../../shared/types.js"; export async function retrievePage( pageId: string, bearerToken: string, - notionVersion: string = "2022-02-22" + notionVersion: string ): Promise<NotionPage> { const url = `https://api.notion.com/v1/pages/${encodeURIComponent(pageId)}`; diff --git a/notion-cli/src/postman/notion-api/pages/update-page-properties/client.ts b/notion-cli/src/postman/notion-api/pages/update-page-properties/client.ts new file mode 100644 index 0000000..1e0018c --- /dev/null +++ b/notion-cli/src/postman/notion-api/pages/update-page-properties/client.ts @@ -0,0 +1,54 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Pages > Update page properties + * Request UID: 52041987-de2726f0-1465-4fdc-81d5-bd35415848b4 + * Request modified at: 2023-09-04T19:08:43.000Z + */ + +import type { UpdatePageParams, NotionPage, NotionError } from "../../shared/types.js"; + +/** + * Update a page's properties. + * + * Can update property values, icon, cover, and archived status. + * Only the properties included in the request body are updated; + * omitted properties remain unchanged. + * + * @param pageId - The ID of the page to update + * @param params - Properties and/or metadata to update + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The updated page object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/patch-page + */ +export async function updatePageProperties( + pageId: string, + params: UpdatePageParams, + bearerToken: string, + notionVersion: string +): Promise<NotionPage> { + const url = `https://api.notion.com/v1/pages/${encodeURIComponent(pageId)}`; + + const response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "Notion-Version": notionVersion, + }, + body: JSON.stringify(params), + }); + + if (response.ok) { + return (await response.json()) as NotionPage; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/search/search/client.ts b/notion-cli/src/postman/notion-api/search/search/client.ts index 9968b7d..9a848ac 100644 --- a/notion-cli/src/postman/notion-api/search/search/client.ts +++ b/notion-cli/src/postman/notion-api/search/search/client.ts @@ -1,12 +1,12 @@ /** * Generated by Postman Code * - * Collection: Notion API - * Collection UID: 15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2 + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 * * Request: Search > Search - * Request UID: 15568543-816435ec-1d78-4c55-a85e-a1a4a8a24564 - * Request modified at: 2022-02-24T23:01:58.000Z + * Request UID: 52041987-0e8a4f2d-d453-4bc1-b4c6-286905b87f4a + * Request modified at: 2026-02-02T22:34:48.000Z */ import type { @@ -22,7 +22,7 @@ import type { * shared with the integration. It will not return linked databases. * * @param bearerToken - Notion API bearer token (integration secret) - * @param notionVersion - Notion API version (default: 2022-02-22) + * @param notionVersion - Notion API version * @param params - Optional search parameters including query, filter, and pagination * @returns The search results containing pages and databases * @throws Error if the request fails @@ -31,7 +31,7 @@ import type { */ export async function search( bearerToken: string, - notionVersion: string = "2022-02-22", + notionVersion: string, params: SearchParams = {} ): Promise<SearchResponse> { const url = "https://api.notion.com/v1/search"; diff --git a/notion-cli/src/postman/notion-api/shared/types.ts b/notion-cli/src/postman/notion-api/shared/types.ts index 3b894fa..44d4d53 100644 --- a/notion-cli/src/postman/notion-api/shared/types.ts +++ b/notion-cli/src/postman/notion-api/shared/types.ts @@ -1,23 +1,49 @@ /** * Generated by Postman Code * - * Collection: Notion API - * Collection UID: 15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2 + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 * * Shared type definitions for Notion API clients. */ -/** User reference object */ +/** User reference object (lightweight, embedded in other objects) */ export interface UserReference { object: "user"; id: string; } +/** Full Notion user object */ +export interface NotionUser { + object: "user"; + id: string; + name: string | null; + avatar_url: string | null; + type: "person" | "bot"; + person?: { + email?: string; + }; + bot?: { + owner: { + type: "workspace" | "user"; + workspace?: boolean; + user?: UserReference & { + name?: string; + type?: string; + person?: { email?: string }; + }; + }; + workspace_name?: string; + workspace_id?: string; + }; +} + /** Parent reference for a page */ export interface PageParent { - type: "database_id" | "page_id" | "workspace"; + type: "database_id" | "page_id" | "workspace" | "data_source_id"; database_id?: string; page_id?: string; + data_source_id?: string; } /** Rich text annotation */ @@ -62,6 +88,7 @@ export interface NotionPage { icon: unknown | null; parent: PageParent; archived: boolean; + in_trash?: boolean; properties: Record<string, PropertyValue>; url: string; } @@ -74,13 +101,33 @@ export interface NotionError { message: string; } +// ============================================================================ +// Users API types +// ============================================================================ + +/** Parameters for listing users */ +export interface ListUsersParams { + /** Cursor for pagination */ + start_cursor?: string; + /** Number of results per page (max 100) */ + page_size?: number; +} + +/** Response from list users endpoint */ +export interface ListUsersResponse { + object: "list"; + results: NotionUser[]; + next_cursor: string | null; + has_more: boolean; +} + // ============================================================================ // Search API types // ============================================================================ -/** Filter for search to limit results to pages or databases */ +/** Filter for search to limit results to pages or data sources (databases) */ export interface SearchFilter { - value: "page" | "database"; + value: "page" | "data_source"; property: "object"; } @@ -112,6 +159,88 @@ export interface SearchResponse { has_more: boolean; } +// ============================================================================ +// Comments API types +// ============================================================================ + +/** A Notion comment object */ +export interface NotionComment { + object: "comment"; + id: string; + parent: { + type: "page_id" | "block_id"; + page_id?: string; + block_id?: string; + }; + discussion_id: string; + created_time: string; + last_edited_time: string; + created_by: UserReference; + rich_text: RichTextItem[]; +} + +/** Parameters for retrieving comments */ +export interface RetrieveCommentsParams { + /** Cursor for pagination */ + start_cursor?: string; + /** Number of results per page (max 100) */ + page_size?: number; +} + +/** Response from retrieve comments endpoint */ +export interface RetrieveCommentsResponse { + object: "list"; + results: NotionComment[]; + next_cursor: string | null; + has_more: boolean; + type: "comment"; + comment: Record<string, never>; +} + +// ============================================================================ +// Pages API types +// ============================================================================ + +/** Parameters for creating a page */ +export interface CreatePageParams { + /** Parent page or database */ + parent: { page_id: string } | { database_id: string }; + /** Page properties (must match database schema if parent is a database) */ + properties: Record<string, unknown>; + /** Optional child blocks to add as page content */ + children?: unknown[]; +} + +/** Parameters for updating page properties */ +export interface UpdatePageParams { + /** Properties to update (only included properties are changed) */ + properties?: Record<string, unknown>; + /** Set archived status */ + archived?: boolean; + /** Page icon */ + icon?: unknown; + /** Page cover */ + cover?: unknown; +} + +/** + * Response from the retrieve page property endpoint. + * + * For simple properties (select, number, checkbox, etc.) the response is a + * single property_item object. For paginated properties (title, rich_text, + * relation, rollup) the response is a paginated list. + */ +export interface PagePropertyItemResponse { + object: "property_item" | "list"; + type: string; + /** Present when object is "list" */ + results?: Array<{ object: "property_item"; type: string; [key: string]: unknown }>; + next_cursor?: string | null; + has_more?: boolean; + /** Property value — varies by type */ + [key: string]: unknown; +} + // ============================================================================ // Blocks API types // ============================================================================ @@ -137,6 +266,16 @@ export interface NotionBlock { [key: string]: unknown; } +/** Response from append block children endpoint */ +export interface AppendBlockChildrenResponse { + object: "list"; + results: NotionBlock[]; + next_cursor: string | null; + has_more: boolean; + type: "block"; + block: Record<string, never>; +} + /** Parameters for retrieving block children */ export interface RetrieveBlockChildrenParams { /** Cursor for pagination */ @@ -174,6 +313,12 @@ export interface DatabaseParent { block_id?: string; } +/** Data source reference (embedded in database responses) */ +export interface DataSourceReference { + id: string; + name: string; +} + /** A Notion database object */ export interface NotionDatabase { object: "database"; @@ -189,6 +334,29 @@ export interface NotionDatabase { parent: DatabaseParent; url: string; archived: boolean; + is_locked?: boolean; + in_trash?: boolean; + data_sources?: DataSourceReference[]; +} + +/** Parameters for creating a database */ +export interface CreateDatabaseParams { + /** Parent page for the database */ + parent: { type: "page_id"; page_id: string }; + /** Database title */ + title?: Array<{ text: { content: string } }>; + /** Database property schema definitions */ + properties: Record<string, unknown>; +} + +/** Parameters for updating a database */ +export interface UpdateDatabaseParams { + /** Updated title */ + title?: Array<{ text: { content: string } }>; + /** Updated description */ + description?: Array<{ text: { content: string } }>; + /** Updated property schema definitions */ + properties?: Record<string, unknown>; } /** Parameters for querying a database */ @@ -210,3 +378,55 @@ export interface QueryDatabaseResponse { next_cursor: string | null; has_more: boolean; } + +// ============================================================================ +// Data Sources API types +// ============================================================================ + +/** Parameters for querying a data source */ +export interface QueryDataSourceParams { + /** Filter to apply to the query */ + filter?: unknown; + /** Sorts to apply to the query */ + sorts?: unknown[]; + /** Cursor for pagination */ + start_cursor?: string; + /** Number of results per page (max 100) */ + page_size?: number; +} + +/** Response from query data source endpoint */ +export interface QueryDataSourceResponse { + object: "list"; + results: NotionPage[]; + next_cursor: string | null; + has_more: boolean; +} + +/** Parameters for creating a data source */ +export interface CreateDataSourceParams { + /** Parent database */ + parent: { type: "database_id"; database_id: string }; + /** Data source title */ + title?: Array<{ text: { content: string } }>; + /** Data source property schema definitions */ + properties: Record<string, unknown>; +} + +/** Parameters for updating a data source */ +export interface UpdateDataSourceParams { + /** Updated title */ + title?: Array<{ text: { content: string } }>; + /** Updated property schema definitions */ + properties?: Record<string, unknown>; +} + +// ============================================================================ +// Move Page types +// ============================================================================ + +/** Parameters for moving a page to a new parent */ +export interface MovePageParams { + /** New parent for the page */ + parent: { type: "page_id"; page_id: string } | { type: "database_id"; database_id: string }; +} diff --git a/notion-cli/src/postman/notion-api/shared/variables.ts b/notion-cli/src/postman/notion-api/shared/variables.ts index 7721561..83b80f8 100644 --- a/notion-cli/src/postman/notion-api/shared/variables.ts +++ b/notion-cli/src/postman/notion-api/shared/variables.ts @@ -2,7 +2,7 @@ * Generated by Postman Code * * Collection: Notion API - * Collection UID: 15568543-d990f9b7-98d3-47d3-9131-4866ab9c6df2 + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 * * Variables for Notion API collection. */ @@ -14,8 +14,11 @@ export const variables = { USER_ID: "", PAGE_ID: "", BLOCK_ID: "", - NOTION_VERSION: "2022-02-22", + NOTION_VERSION: "2025-09-03", DISCUSSION_ID: "", PROPERTY_ID: "", + COMMENT_ID: "", + DATA_SOURCE_ID: "", + FILE_UPLOAD_ID: "", }, }; diff --git a/notion-cli/src/postman/notion-api/users/list-users/client.ts b/notion-cli/src/postman/notion-api/users/list-users/client.ts new file mode 100644 index 0000000..f89e511 --- /dev/null +++ b/notion-cli/src/postman/notion-api/users/list-users/client.ts @@ -0,0 +1,53 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Users > List all users + * Request UID: 52041987-1f6b9bec-dc7d-4412-9e80-46a33b8b11c6 + * Request modified at: 2022-03-02T05:38:37.000Z + */ + +import type { ListUsersParams, ListUsersResponse, NotionError } from "../../shared/types.js"; + +/** + * Returns a paginated list of user objects for the workspace. + * + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @param params - Optional pagination parameters + * @returns Paginated list of user objects + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/get-users + */ +export async function listUsers( + bearerToken: string, + notionVersion: string, + params?: ListUsersParams +): Promise<ListUsersResponse> { + const url = new URL("https://api.notion.com/v1/users"); + + if (params?.start_cursor) { + url.searchParams.set("start_cursor", params.start_cursor); + } + if (params?.page_size !== undefined) { + url.searchParams.set("page_size", String(params.page_size)); + } + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as ListUsersResponse; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/users/retrieve-bot-user/client.ts b/notion-cli/src/postman/notion-api/users/retrieve-bot-user/client.ts new file mode 100644 index 0000000..2b66860 --- /dev/null +++ b/notion-cli/src/postman/notion-api/users/retrieve-bot-user/client.ts @@ -0,0 +1,48 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Users > Retrieve your token's bot user + * Request UID: 52041987-30ad8b7b-5eb0-4bbe-bfbf-509d7f961cae + * Request modified at: 2024-01-29T18:38:30.000Z + */ + +import type { NotionUser, NotionError } from "../../shared/types.js"; + +/** + * Retrieve the bot user associated with the current API token. + * + * Returns the bot user object for the integration that owns the bearer token. + * Useful for verifying the token is valid and checking which workspace the + * integration belongs to. + * + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The bot user object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/get-self + */ +export async function retrieveBotUser( + bearerToken: string, + notionVersion: string +): Promise<NotionUser> { + const url = "https://api.notion.com/v1/users/me"; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as NotionUser; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/src/postman/notion-api/users/retrieve-user/client.ts b/notion-cli/src/postman/notion-api/users/retrieve-user/client.ts new file mode 100644 index 0000000..50001ae --- /dev/null +++ b/notion-cli/src/postman/notion-api/users/retrieve-user/client.ts @@ -0,0 +1,48 @@ +/** + * Generated by Postman Code + * + * Collection: Notion API (2025-09-03) + * Collection UID: 52041987-03f70d8f-b6e5-4306-805c-f95f7cdf05b9 + * + * Request: Users > Retrieve a user + * Request UID: 52041987-e23bf76f-cb3a-4bbc-9e5a-0a39cd59e909 + * Request modified at: 2026-02-02T22:04:41.000Z + */ + +import type { NotionUser, NotionError } from "../../shared/types.js"; + +/** + * Retrieve a user object using the ID specified. + * + * Returns a full user object for any user (person or bot) in the workspace. + * + * @param userId - The ID of the user to retrieve + * @param bearerToken - Notion API bearer token (integration secret) + * @param notionVersion - Notion API version + * @returns The user object + * @throws Error if the request fails + * + * @see https://developers.notion.com/reference/get-user + */ +export async function retrieveUser( + userId: string, + bearerToken: string, + notionVersion: string +): Promise<NotionUser> { + const url = `https://api.notion.com/v1/users/${encodeURIComponent(userId)}`; + + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${bearerToken}`, + "Notion-Version": notionVersion, + }, + }); + + if (response.ok) { + return (await response.json()) as NotionUser; + } + + const error = (await response.json()) as NotionError; + throw new Error(`Notion API error (${response.status}): ${error.message}`); +} diff --git a/notion-cli/test/auth.test.ts b/notion-cli/test/auth.test.ts new file mode 100644 index 0000000..06eb17e --- /dev/null +++ b/notion-cli/test/auth.test.ts @@ -0,0 +1,269 @@ +/** + * Auth command tests for auth-internal and auth-public. + * + * Uses an isolated temp HOME directory so tests never touch the real + * ~/.notion-cli/config.json. + * + * The "auth-public (live API)" section requires real OAuth credentials: + * NOTION_OAUTH_CLIENT_ID, NOTION_OAUTH_CLIENT_SECRET + * and optionally NOTION_OAUTH_TOKEN for introspect tests. + * Those tests skip cleanly when the env vars are absent. + * + * Run: + * npm run test:auth + */ + +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CLI_ENTRY, PROJECT_DIR, extractJson } from "./helpers.js"; +import { oauthToken } from "../src/postman/notion-api/oauth/token/client.js"; +import { oauthIntrospect } from "../src/postman/notion-api/oauth/introspect/client.js"; + +// --------------------------------------------------------------------------- +// Isolated HOME — tests won't touch real config +// --------------------------------------------------------------------------- + +const testHome = mkdtempSync(join(tmpdir(), "notion-cli-auth-test-")); + +interface CliResult { + stdout: string; + stderr: string; + exitCode: number; +} + +function authCli(...args: string[]): Promise<CliResult> { + return new Promise((res) => { + const env: Record<string, string | undefined> = { ...process.env, HOME: testHome }; + // Strip auth env vars so tests start from a clean slate + delete env.NOTION_TOKEN; + delete env.NOTION_OAUTH_CLIENT_ID; + delete env.NOTION_OAUTH_CLIENT_SECRET; + + execFile( + process.execPath, + ["--import", "tsx", CLI_ENTRY, ...args], + { env, timeout: 30_000, cwd: PROJECT_DIR }, + (error, stdout, stderr) => { + const exitCode = error + ? typeof error.code === "number" ? error.code : 1 + : 0; + res({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode }); + }, + ); + }); +} + +function readTestConfig(): Record<string, string> { + try { + return JSON.parse( + readFileSync(join(testHome, ".notion-cli", "config.json"), "utf-8"), + ); + } catch { + return {}; + } +} + +after(() => { + rmSync(testHome, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// auth-internal +// --------------------------------------------------------------------------- + +describe("auth-internal", () => { + + it("status shows not configured when no token", async () => { + const { stdout, exitCode } = await authCli("auth-internal", "status"); + assert.equal(exitCode, 0); + assert.ok(stdout.includes("No integration token configured"), stdout); + }); + + it("set saves token to config file", async () => { + const { stdout, exitCode } = await authCli("auth-internal", "set", "secret_test_abc123"); + assert.equal(exitCode, 0); + assert.ok(stdout.includes("Token saved"), stdout); + + const config = readTestConfig(); + assert.equal(config.NOTION_TOKEN, "secret_test_abc123"); + assert.equal(config.auth_method, "internal"); + }); + + it("status shows configured after set", async () => { + const { stdout, exitCode } = await authCli("auth-internal", "status"); + assert.equal(exitCode, 0); + assert.ok(stdout.includes("Integration token configured"), stdout); + assert.ok(stdout.includes("secret_test_"), stdout); + }); + + it("clear removes token", async () => { + const { stdout, exitCode } = await authCli("auth-internal", "clear"); + assert.equal(exitCode, 0); + assert.ok(stdout.includes("Integration token removed"), stdout); + + const config = readTestConfig(); + assert.equal(config.NOTION_TOKEN, undefined); + assert.equal(config.auth_method, undefined); + }); + + it("clear when already empty shows message", async () => { + const { stdout, exitCode } = await authCli("auth-internal", "clear"); + assert.equal(exitCode, 0); + assert.ok(stdout.includes("No stored token"), stdout); + }); +}); + +// --------------------------------------------------------------------------- +// auth-public +// --------------------------------------------------------------------------- + +describe("auth-public", () => { + + it("status shows not configured when no credentials", async () => { + const { stdout, exitCode } = await authCli("auth-public", "status"); + assert.equal(exitCode, 0); + assert.ok(stdout.includes("not configured"), stdout); + }); + + it("login fails without client credentials", async () => { + const { stderr, exitCode } = await authCli("auth-public", "login"); + assert.equal(exitCode, 1); + assert.ok(stderr.includes("No OAuth client credentials"), stderr); + }); + + it("logout when not authenticated shows message", async () => { + const { stdout, exitCode } = await authCli("auth-public", "logout"); + assert.equal(exitCode, 0); + assert.ok(stdout.includes("No OAuth credentials to remove"), stdout); + }); +}); + +// --------------------------------------------------------------------------- +// help text +// --------------------------------------------------------------------------- + +describe("auth help text", () => { + + it("auth-internal --help shows setup steps", async () => { + const { stdout, exitCode } = await authCli("auth-internal", "--help"); + assert.equal(exitCode, 0); + assert.ok(stdout.includes("notion.so/my-integrations"), stdout); + assert.ok(stdout.includes("set"), stdout); + assert.ok(stdout.includes("status"), stdout); + assert.ok(stdout.includes("clear"), stdout); + }); + + it("auth-public --help shows setup steps", async () => { + const { stdout, exitCode } = await authCli("auth-public", "--help"); + assert.equal(exitCode, 0); + assert.ok(stdout.includes("setup"), stdout); + assert.ok(stdout.includes("login"), stdout); + assert.ok(stdout.includes("introspect"), stdout); + assert.ok(stdout.includes("revoke"), stdout); + assert.ok(stdout.includes("logout"), stdout); + assert.ok(stdout.includes("client ID"), stdout); + }); +}); + +// --------------------------------------------------------------------------- +// auth-public — live API tests (require real OAuth credentials) +// --------------------------------------------------------------------------- + +const oauthClientId = process.env.NOTION_OAUTH_CLIENT_ID; +const oauthClientSecret = process.env.NOTION_OAUTH_CLIENT_SECRET; +const oauthAccessToken = process.env.NOTION_OAUTH_TOKEN; + +/** + * CLI runner that passes OAuth client credentials via env vars. + * Uses the isolated HOME so it doesn't touch real config. + */ +function oauthCli(...args: string[]): Promise<CliResult> { + return new Promise((res) => { + const env: Record<string, string | undefined> = { + ...process.env, + HOME: testHome, + NOTION_OAUTH_CLIENT_ID: oauthClientId, + NOTION_OAUTH_CLIENT_SECRET: oauthClientSecret, + }; + // Don't inherit a token — auth-public commands use client creds + delete env.NOTION_TOKEN; + + execFile( + process.execPath, + ["--import", "tsx", CLI_ENTRY, ...args], + { env, timeout: 30_000, cwd: PROJECT_DIR }, + (error, stdout, stderr) => { + const exitCode = error + ? typeof error.code === "number" ? error.code : 1 + : 0; + res({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode }); + }, + ); + }); +} + +describe("auth-public (live API)", { skip: !oauthClientId || !oauthClientSecret ? "NOTION_OAUTH_CLIENT_ID and NOTION_OAUTH_CLIENT_SECRET not set" : undefined }, () => { + + // -- Client-level tests --------------------------------------------------- + + it("token client rejects an invalid authorization code", async () => { + try { + await oauthToken( + { + grant_type: "authorization_code", + code: "invalid_code_for_testing", + redirect_uri: "http://localhost:8787/callback", + }, + oauthClientId!, + oauthClientSecret!, + ); + assert.fail("should have thrown on invalid code"); + } catch (err) { + assert.ok(err instanceof Error, "should throw an Error"); + assert.ok( + err.message.includes("Notion API error"), + `should be a Notion API error, got: ${err.message}`, + ); + } + }); + + it("introspect client returns token metadata", { skip: !oauthAccessToken ? "NOTION_OAUTH_TOKEN not set" : undefined }, async () => { + const result = await oauthIntrospect(oauthAccessToken!, oauthClientId!, oauthClientSecret!); + assert.ok(typeof result.active === "boolean", "should have an 'active' field"); + if (result.active) { + assert.ok(result.bot_id, "active token should have a bot_id"); + } + }); + + // -- CLI command tests ---------------------------------------------------- + + it("auth-public introspect --raw", { skip: !oauthAccessToken ? "NOTION_OAUTH_TOKEN not set" : undefined }, async () => { + const { stdout, exitCode } = await oauthCli("auth-public", "introspect", oauthAccessToken!, "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const result = extractJson(stdout) as { active: boolean }; + assert.ok(typeof result.active === "boolean", "should have an active field"); + }); + + it("auth-public introspect (formatted)", { skip: !oauthAccessToken ? "NOTION_OAUTH_TOKEN not set" : undefined }, async () => { + const { stdout, exitCode } = await oauthCli("auth-public", "introspect", oauthAccessToken!); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Active:"), "should show active status"); + }); + + it("auth-public introspect fails without credentials", async () => { + // Use authCli which has no OAuth creds + const { stderr, exitCode } = await authCli("auth-public", "introspect", "fake_token"); + assert.equal(exitCode, 1); + assert.ok(stderr.includes("No OAuth client credentials"), stderr); + }); + + it("auth-public revoke fails without credentials", async () => { + const { stderr, exitCode } = await authCli("auth-public", "revoke", "fake_token"); + assert.equal(exitCode, 1); + assert.ok(stderr.includes("No OAuth client credentials"), stderr); + }); +}); diff --git a/notion-cli/test/block.test.ts b/notion-cli/test/block.test.ts new file mode 100644 index 0000000..f6e66e9 --- /dev/null +++ b/notion-cli/test/block.test.ts @@ -0,0 +1,70 @@ +/** + * Tests for `block` commands: get, children, append, update, delete. + * Creates a test page in before(), archives in after(). + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { + requireToken, createCli, extractJson, + setupTestPage, teardownTestPage, type TestContext, +} from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); +let ctx: TestContext | undefined; +let appendedBlockId: string; + +describe("block", () => { + before(async () => { + ctx = await setupTestPage(cli); + }); + + after(async () => { + await teardownTestPage(ctx); + }); + + it("block get <page-id> (pages are blocks)", async () => { + const { stdout, exitCode } = await cli("block", "get", ctx!.testPageId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Type:"), "should show block type"); + assert.ok(stdout.includes("ID:"), "should show block ID"); + }); + + it("block children <page-id>", async () => { + const { stdout, exitCode } = await cli("block", "children", ctx!.testPageId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok( + stdout.includes("No child blocks") || stdout.includes("block(s)"), + "should report blocks or empty", + ); + }); + + it("block append <page-id> <text> --raw", async () => { + const { stdout, exitCode } = await cli( + "block", "append", ctx!.testPageId, "Integration test paragraph", "--raw", + ); + assert.equal(exitCode, 0, "should exit 0"); + const response = extractJson(stdout) as { results: Array<{ id: string; type: string }> }; + assert.ok(response.results.length > 0, "should have appended at least one block"); + assert.equal(response.results[0].type, "paragraph"); + appendedBlockId = response.results[0].id; + }); + + it("block update <block-id> <text>", async () => { + assert.ok(appendedBlockId, "appendedBlockId must be set by 'block append' first"); + const { stdout, exitCode } = await cli( + "block", "update", appendedBlockId, "Updated paragraph text", + ); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Block updated"), "should confirm update"); + }); + + it("block delete <block-id>", async () => { + assert.ok(appendedBlockId, "appendedBlockId must be set by 'block append' first"); + const { stdout, exitCode } = await cli("block", "delete", appendedBlockId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Block deleted"), "should confirm deletion"); + assert.ok(stdout.includes("Archived: true"), "should show archived: true"); + }); +}); diff --git a/notion-cli/test/comment.test.ts b/notion-cli/test/comment.test.ts new file mode 100644 index 0000000..7e1b820 --- /dev/null +++ b/notion-cli/test/comment.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for `comment` commands: add, list, reply. + * Creates a test page in before(), archives in after(). + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { + requireToken, createCli, extractJson, isOAuth, + setupTestPage, teardownTestPage, type TestContext, +} from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); +let ctx: TestContext | undefined; +let discussionId: string; + +// Notion API bug: OAuth tokens cannot read comments (returns 404). +// See KNOWN-ISSUES.md for details. +const skipComments = isOAuth(); + +describe("comment", { skip: skipComments ? "OAuth tokens cannot read comments (known Notion API bug)" : false }, () => { + before(async () => { + ctx = await setupTestPage(cli); + }); + + after(async () => { + await teardownTestPage(ctx); + }); + + it("comment add <page-id> <text> --raw", async () => { + const { stdout, exitCode } = await cli( + "comment", "add", ctx!.testPageId, "Integration test comment", "--raw", + ); + assert.equal(exitCode, 0, "should exit 0"); + const comment = extractJson(stdout) as { id: string; discussion_id: string }; + assert.ok(comment.id, "should have a comment id"); + assert.ok(comment.discussion_id, "should have a discussion id"); + discussionId = comment.discussion_id; + }); + + it("comment list <page-id>", async () => { + const { stdout, exitCode } = await cli("comment", "list", ctx!.testPageId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Integration test comment"), "should show the comment we added"); + }); + + it("comment reply <discussion-id> <text>", async () => { + assert.ok(discussionId, "discussionId must be set by 'comment add' first"); + const { stdout, exitCode } = await cli( + "comment", "reply", discussionId, "Integration test reply", + ); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Reply added"), "should confirm reply was added"); + }); + + it("comment list shows both comments after reply", async () => { + const { stdout, exitCode } = await cli("comment", "list", ctx!.testPageId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("2 comment(s)"), "should now show 2 comments"); + }); + + it("comment get <comment-id>", async () => { + // First add a comment and capture its ID + const { stdout: addOut } = await cli( + "comment", "add", ctx!.testPageId, "Comment for get test", "--raw", + ); + const comment = extractJson(addOut) as { id: string; discussion_id: string }; + assert.ok(comment.id, "should have a comment id"); + + // Get the comment by ID + const { stdout, exitCode } = await cli("comment", "get", comment.id); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Comment for get test"), "should show comment text"); + assert.ok(stdout.includes(comment.id), "should show comment ID"); + assert.ok(stdout.includes(comment.discussion_id), "should show discussion ID"); + }); + + it("comment get <comment-id> --raw", async () => { + const { stdout: addOut } = await cli( + "comment", "add", ctx!.testPageId, "Raw get test", "--raw", + ); + const comment = extractJson(addOut) as { id: string }; + + const { stdout, exitCode } = await cli("comment", "get", comment.id, "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const result = extractJson(stdout) as { id: string; object: string }; + assert.equal(result.object, "comment"); + assert.equal(result.id, comment.id); + }); +}); diff --git a/notion-cli/test/database.test.ts b/notion-cli/test/database.test.ts new file mode 100644 index 0000000..d78a46e --- /dev/null +++ b/notion-cli/test/database.test.ts @@ -0,0 +1,74 @@ +/** + * Tests for `database` commands: create, update, get, list. + * Creates a test page in before(), archives in after(). + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { + requireToken, createCli, extractJson, + setupTestPage, teardownTestPage, type TestContext, +} from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); +let ctx: TestContext | undefined; +let testDatabaseId: string; + +describe("database", () => { + before(async () => { + ctx = await setupTestPage(cli); + }); + + after(async () => { + await teardownTestPage(ctx); + }); + + it("database create <parent-page-id> --title --raw", async () => { + const { stdout, exitCode } = await cli( + "database", "create", ctx!.testPageId, "--title", "Test Database", "--raw", + ); + assert.equal(exitCode, 0, "should exit 0"); + const db = extractJson(stdout) as { id: string; object: string }; + assert.equal(db.object, "database"); + assert.ok(db.id, "should have a database id"); + testDatabaseId = db.id; + }); + + it("database update <database-id> --title", async () => { + assert.ok(testDatabaseId, "testDatabaseId must be set by 'database create' first"); + const { stdout, exitCode } = await cli( + "database", "update", testDatabaseId, "--title", "Updated Test Database", + ); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Database updated"), "should confirm update"); + assert.ok(stdout.includes("Updated Test Database"), "should show new title"); + }); + + it("database get <id>", async () => { + assert.ok(testDatabaseId, "need a database ID from create"); + const { stdout, exitCode } = await cli("database", "get", testDatabaseId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Title:"), "should show database title"); + // Note: 2025-09-03 API may not return properties on a freshly created + // database, so we don't assert Schema is present. + }); + + it("database get shows data sources when present", async () => { + assert.ok(testDatabaseId, "need a database ID from create"); + const { stdout, exitCode } = await cli("database", "get", testDatabaseId); + assert.equal(exitCode, 0, "should exit 0"); + // The 2025-09-03 API returns data_sources on databases. + assert.ok(stdout.includes("To list entries:"), "should show list hint"); + }); + + it("database list <id>", async () => { + assert.ok(testDatabaseId, "need a database ID from create"); + const { stdout, exitCode } = await cli("database", "list", testDatabaseId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok( + stdout.includes("Entries") || stdout.includes("No entries"), + "should show entries or empty message", + ); + }); +}); diff --git a/notion-cli/test/datasource.test.ts b/notion-cli/test/datasource.test.ts new file mode 100644 index 0000000..db855cc --- /dev/null +++ b/notion-cli/test/datasource.test.ts @@ -0,0 +1,127 @@ +/** + * Tests for `datasource` commands: get, query, templates, create, update. + * Creates a test database (which yields a data source) in before(), archives in after(). + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { + requireToken, createCli, extractJson, + setupTestPage, teardownTestPage, type TestContext, +} from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); +let ctx: TestContext | undefined; +let testDatabaseId: string; +let testDataSourceId: string; + +describe("datasource", () => { + before(async () => { + ctx = await setupTestPage(cli); + + // Create a database — the API returns data source IDs with it + const { stdout, exitCode } = await cli( + "database", "create", ctx.testPageId, "--title", "DS Test Database", "--raw", + ); + assert.equal(exitCode, 0, "database create should exit 0"); + const db = extractJson(stdout) as { + id: string; + data_sources?: Array<{ id: string }>; + }; + testDatabaseId = db.id; + + // Extract the data source ID from the database response + if (db.data_sources && db.data_sources.length > 0) { + testDataSourceId = db.data_sources[0].id; + } else { + // Fallback: get the database again to find the data source + const { stdout: getOut } = await cli("database", "get", testDatabaseId, "--raw"); + const dbFull = extractJson(getOut) as { + data_sources?: Array<{ id: string }>; + }; + if (dbFull.data_sources && dbFull.data_sources.length > 0) { + testDataSourceId = dbFull.data_sources[0].id; + } + } + }); + + after(async () => { + await teardownTestPage(ctx); + }); + + it("datasource get <datasource-id>", async () => { + if (!testDataSourceId) { + console.log(" (skipped — no data source ID available)"); + return; + } + const { stdout, exitCode } = await cli("datasource", "get", testDataSourceId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Title:"), "should show title"); + assert.ok(stdout.includes("ID:"), "should show ID"); + assert.ok(stdout.includes("To query entries:"), "should show query hint"); + }); + + it("datasource get <datasource-id> --raw", async () => { + if (!testDataSourceId) { + console.log(" (skipped — no data source ID available)"); + return; + } + const { stdout, exitCode } = await cli("datasource", "get", testDataSourceId, "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const ds = extractJson(stdout) as { id: string; object: string }; + assert.ok(ds.id, "should have an id"); + }); + + it("datasource query <datasource-id>", async () => { + if (!testDataSourceId) { + console.log(" (skipped — no data source ID available)"); + return; + } + const { stdout, exitCode } = await cli("datasource", "query", testDataSourceId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok( + stdout.includes("Entries") || stdout.includes("No entries"), + "should show entries or empty message", + ); + }); + + it("datasource query <datasource-id> --raw", async () => { + if (!testDataSourceId) { + console.log(" (skipped — no data source ID available)"); + return; + } + const { stdout, exitCode } = await cli("datasource", "query", testDataSourceId, "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const result = extractJson(stdout) as { object: string; results: unknown[] }; + assert.equal(result.object, "list"); + assert.ok(Array.isArray(result.results), "should have results array"); + }); + + it("datasource update <datasource-id> --title", async () => { + if (!testDataSourceId) { + console.log(" (skipped — no data source ID available)"); + return; + } + const { stdout, exitCode } = await cli( + "datasource", "update", testDataSourceId, "--title", "Updated DS", + ); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Data source updated"), "should confirm update"); + assert.ok(stdout.includes("Updated DS"), "should show new title"); + }); + + it("datasource templates <datasource-id>", async () => { + if (!testDataSourceId) { + console.log(" (skipped — no data source ID available)"); + return; + } + const { stdout, exitCode } = await cli("datasource", "templates", testDataSourceId); + // This may succeed with 0 templates or fail depending on the API — we just check it runs + assert.equal(exitCode, 0, "should exit 0"); + assert.ok( + stdout.includes("template") || stdout.includes("No templates"), + "should show templates or empty message", + ); + }); +}); diff --git a/notion-cli/test/docs.test.ts b/notion-cli/test/docs.test.ts new file mode 100644 index 0000000..6485b64 --- /dev/null +++ b/notion-cli/test/docs.test.ts @@ -0,0 +1,21 @@ +/** + * Tests for the `docs` command. + * No API calls needed — just verifies the built-in guide content. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { requireToken, createCli } from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); + +describe("docs", () => { + it("docs prints guide content", async () => { + const { stdout, exitCode } = await cli("docs"); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("MAPPING A WORKSPACE"), "should include mapping section"); + assert.ok(stdout.includes("SETUP"), "should include setup section"); + assert.ok(stdout.includes("AGENT USAGE"), "should include agent usage section"); + }); +}); diff --git a/notion-cli/test/file.test.ts b/notion-cli/test/file.test.ts new file mode 100644 index 0000000..c923063 --- /dev/null +++ b/notion-cli/test/file.test.ts @@ -0,0 +1,90 @@ +/** + * Tests for `file` commands: upload, list, get. + * Uses a small temporary file for the upload test. + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { + requireToken, createCli, extractJson, PROJECT_DIR, +} from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); +let testFilePath: string; +let uploadedFileId: string; + +describe("file", () => { + before(() => { + // Create a small test file + testFilePath = join(PROJECT_DIR, "test", "tmp-test-upload.txt"); + writeFileSync(testFilePath, "Hello from integration test\n"); + }); + + after(() => { + // Clean up the temp file + try { + unlinkSync(testFilePath); + } catch { + // ignore + } + }); + + it("file upload <file-path>", async () => { + const { stdout, exitCode } = await cli("file", "upload", testFilePath); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("File uploaded"), "should confirm upload"); + assert.ok(stdout.includes("tmp-test-upload.txt"), "should show file name"); + assert.ok(stdout.includes("Status:"), "should show status"); + + // Extract the ID from formatted output + const idMatch = stdout.match(/ID:\s+([0-9a-f-]{36})/); + assert.ok(idMatch, "should show file upload ID"); + uploadedFileId = idMatch![1]; + }); + + it("file upload <file-path> --raw", async () => { + const { stdout, exitCode } = await cli("file", "upload", testFilePath, "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const file = extractJson(stdout) as { id: string; object: string; status: string; filename: string }; + assert.equal(file.object, "file_upload"); + assert.ok(file.id, "should have an id"); + assert.ok(file.filename, "should have a filename"); + }); + + it("file list", async () => { + const { stdout, exitCode } = await cli("file", "list"); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok( + stdout.includes("file upload") || stdout.includes("No file uploads"), + "should show file uploads or empty message", + ); + }); + + it("file list --raw", async () => { + const { stdout, exitCode } = await cli("file", "list", "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const result = extractJson(stdout) as { object: string; results: unknown[] }; + assert.equal(result.object, "list"); + assert.ok(Array.isArray(result.results), "should have results array"); + }); + + it("file get <file-id>", async () => { + assert.ok(uploadedFileId, "uploadedFileId must be set by 'file upload' first"); + const { stdout, exitCode } = await cli("file", "get", uploadedFileId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("tmp-test-upload.txt"), "should show file name"); + assert.ok(stdout.includes(uploadedFileId), "should show file ID"); + }); + + it("file get <file-id> --raw", async () => { + assert.ok(uploadedFileId, "uploadedFileId must be set by 'file upload' first"); + const { stdout, exitCode } = await cli("file", "get", uploadedFileId, "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const file = extractJson(stdout) as { id: string; object: string }; + assert.equal(file.object, "file_upload"); + assert.equal(file.id, uploadedFileId); + }); +}); diff --git a/notion-cli/test/helpers.ts b/notion-cli/test/helpers.ts new file mode 100644 index 0000000..285457b --- /dev/null +++ b/notion-cli/test/helpers.ts @@ -0,0 +1,170 @@ +/** + * Shared test utilities for the Notion CLI integration tests. + * + * Each test file is self-contained — it creates its own resources in + * before() and cleans up in after(). This module provides the shared + * helpers they all need: token loading, CLI runner, output parsers, + * and a convenience setup that creates a test page. + */ + +import { execFile } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +export const CLI_ENTRY = resolve(__dirname, "..", "cli.ts"); +export const PROJECT_DIR = resolve(__dirname, ".."); + +// --------------------------------------------------------------------------- +// Token +// --------------------------------------------------------------------------- + +export function loadToken(): string | undefined { + if (process.env.NOTION_TOKEN) return process.env.NOTION_TOKEN; + try { + const config = JSON.parse( + readFileSync(join(homedir(), ".notion-cli", "config.json"), "utf-8"), + ); + return config.NOTION_TOKEN; + } catch { + return undefined; + } +} + +/** + * Returns true when the current token comes from an OAuth (public) integration. + * Checks NOTION_AUTH_METHOD env var first, then falls back to config.json. + */ +export function isOAuth(): boolean { + if (process.env.NOTION_AUTH_METHOD) return process.env.NOTION_AUTH_METHOD === "oauth"; + try { + const config = JSON.parse( + readFileSync(join(homedir(), ".notion-cli", "config.json"), "utf-8"), + ); + return config.auth_method === "oauth"; + } catch { + return false; + } +} + +/** + * Load token or exit cleanly if not available. + * Call at the top of each test file. + */ +export function requireToken(): string { + const token = loadToken(); + if (!token) { + console.log("Skipping tests — no Notion API key found."); + console.log("Run 'notion-cli auth-internal set' or 'notion-cli auth-public login' to authenticate."); + process.exit(0); + } + return token; +} + +// --------------------------------------------------------------------------- +// CLI runner +// --------------------------------------------------------------------------- + +export interface CliResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** + * Create a CLI runner bound to a specific token. + */ +export function createCli(token: string) { + return function cli(...args: string[]): Promise<CliResult> { + return new Promise((res) => { + execFile( + process.execPath, + ["--import", "tsx", CLI_ENTRY, ...args], + { env: { ...process.env, NOTION_TOKEN: token }, timeout: 60_000, cwd: PROJECT_DIR }, + (error, stdout, stderr) => { + const exitCode = error + ? typeof error.code === "number" + ? error.code + : 1 + : 0; + res({ stdout: stdout ?? "", stderr: stderr ?? "", exitCode }); + }, + ); + }); + }; +} + +// --------------------------------------------------------------------------- +// Output parsers +// --------------------------------------------------------------------------- + +/** Extract a JSON object or array from stdout that may have prefixed text. */ +export function extractJson(stdout: string): unknown { + const match = stdout.match(/^[\[{]/m); + if (!match || match.index === undefined) { + throw new Error(`No JSON found in output:\n${stdout.slice(0, 500)}`); + } + return JSON.parse(stdout.slice(match.index)); +} + +/** Parse all UUIDs from "ID: <uuid>" patterns in formatted output. */ +export function parseIds(stdout: string): string[] { + return [...stdout.matchAll(/ID:\s+([0-9a-f-]{36})/g)].map((m) => m[1]); +} + +// --------------------------------------------------------------------------- +// Test page lifecycle +// --------------------------------------------------------------------------- + +export interface TestContext { + cli: ReturnType<typeof createCli>; + parentPageId: string; + testPageId: string; +} + +/** + * Create a test page under an accessible workspace page. + * Call in before() — returns IDs needed by tests. + */ +export async function setupTestPage(cli: ReturnType<typeof createCli>): Promise<TestContext> { + // Find an accessible page to use as parent + const searchResult = await cli("search", "-n", "1"); + if (searchResult.exitCode !== 0) { + throw new Error(`Setup search failed: ${searchResult.stderr}`); + } + const ids = parseIds(searchResult.stdout); + if (ids.length === 0) { + throw new Error("Integration must have access to at least one page"); + } + const parentPageId = ids[0]; + + // Create a test page + const timestamp = new Date().toISOString().slice(0, 19).replace("T", " "); + const title = `[test] notion-cli ${timestamp}`; + const createResult = await cli("page", "create", parentPageId, "--title", title, "--raw"); + if (createResult.exitCode !== 0) { + throw new Error(`Setup page create failed: ${createResult.stderr}`); + } + const page = extractJson(createResult.stdout) as { id: string }; + if (!page.id) { + throw new Error("Created page has no id"); + } + + return { cli, parentPageId, testPageId: page.id }; +} + +/** + * Archive the test page. Call in after(). + */ +export async function teardownTestPage(ctx: TestContext | undefined): Promise<void> { + if (!ctx?.testPageId) return; + await ctx.cli("page", "archive", ctx.testPageId); +} diff --git a/notion-cli/test/integration-cmd.test.ts b/notion-cli/test/integration-cmd.test.ts new file mode 100644 index 0000000..b0af16b --- /dev/null +++ b/notion-cli/test/integration-cmd.test.ts @@ -0,0 +1,22 @@ +/** + * Tests for the `integration` command (e.g., integration pages). + * No test page needed — reads existing workspace structure. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { requireToken, createCli } from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); + +describe("integration", () => { + it("integration pages", async () => { + const { stdout, exitCode } = await cli("integration", "pages"); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok( + stdout.includes("root page") || stdout.includes("No root"), + "should report root pages or empty", + ); + }); +}); diff --git a/notion-cli/test/page.test.ts b/notion-cli/test/page.test.ts new file mode 100644 index 0000000..192b248 --- /dev/null +++ b/notion-cli/test/page.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for `page` commands: get, property, update, create, archive. + * Creates a test page in before(), archives in after(). + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { + requireToken, createCli, extractJson, + setupTestPage, teardownTestPage, type TestContext, +} from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); +let ctx: TestContext | undefined; + +describe("page", () => { + before(async () => { + ctx = await setupTestPage(cli); + }); + + after(async () => { + await teardownTestPage(ctx); + }); + + it("page get <id>", async () => { + const { stdout, exitCode } = await cli("page", "get", ctx!.testPageId); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Title:"), "should show page title"); + assert.ok(stdout.includes("ID:"), "should show page ID"); + assert.ok(stdout.includes("Archived:"), "should show archived status"); + assert.ok(stdout.includes("URL:"), "should show page URL"); + }); + + it("page property <id> title", async () => { + const { stdout, exitCode } = await cli("page", "property", ctx!.testPageId, "title"); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("[test] notion-cli"), "should contain the test page title"); + }); + + it("page update <id> --title --raw", async () => { + const newTitle = `[test] updated ${Date.now()}`; + const { stdout, exitCode } = await cli( + "page", "update", ctx!.testPageId, "--title", newTitle, "--raw", + ); + assert.equal(exitCode, 0, "should exit 0"); + const page = extractJson(stdout) as { id: string; object: string }; + assert.equal(page.object, "page"); + assert.equal(page.id, ctx!.testPageId); + }); + + it("page create and archive lifecycle", async () => { + // Create a child page + const { stdout: createOut, exitCode: createCode } = await cli( + "page", "create", ctx!.testPageId, "--title", "Temp child page", "--raw", + ); + assert.equal(createCode, 0, "create should exit 0"); + const page = extractJson(createOut) as { id: string; object: string }; + assert.equal(page.object, "page"); + assert.ok(page.id, "should have a page id"); + + // Archive it + const { stdout: archiveOut, exitCode: archiveCode } = await cli("page", "archive", page.id); + assert.equal(archiveCode, 0, "archive should exit 0"); + assert.ok(archiveOut.includes("Page archived"), "should confirm archive"); + assert.ok(archiveOut.includes("Archived: true"), "should show archived: true"); + }); + + it("page move <id> --parent <new-parent-id>", async () => { + // Create two child pages + const { stdout: out1 } = await cli( + "page", "create", ctx!.testPageId, "--title", "Move target", "--raw", + ); + const target = extractJson(out1) as { id: string }; + + const { stdout: out2 } = await cli( + "page", "create", ctx!.testPageId, "--title", "Page to move", "--raw", + ); + const movee = extractJson(out2) as { id: string }; + + // Move the second page under the first + const { stdout, exitCode } = await cli( + "page", "move", movee.id, "--parent", target.id, + ); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Page moved"), "should confirm move"); + assert.ok(stdout.includes(target.id), "should show new parent ID"); + + // Verify with --raw + const { stdout: rawOut, exitCode: rawCode } = await cli( + "page", "move", movee.id, "--parent", ctx!.testPageId, "--raw", + ); + assert.equal(rawCode, 0, "raw move should exit 0"); + const moved = extractJson(rawOut) as { id: string; object: string }; + assert.equal(moved.object, "page"); + + // Clean up + await cli("page", "archive", movee.id); + await cli("page", "archive", target.id); + }); +}); diff --git a/notion-cli/test/search.test.ts b/notion-cli/test/search.test.ts new file mode 100644 index 0000000..df1a714 --- /dev/null +++ b/notion-cli/test/search.test.ts @@ -0,0 +1,47 @@ +/** + * Tests for the `search` command variants. + * No test page needed — searches existing workspace content. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { requireToken, createCli, parseIds } from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); + +describe("search", () => { + it("search (default — pages)", async () => { + const { stdout, exitCode } = await cli("search"); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Found"), "should report results"); + }); + + it("search --filter database", async () => { + const { stdout, exitCode } = await cli("search", "--filter", "database"); + assert.equal(exitCode, 0, "should exit 0"); + }); + + it("search --filter all", async () => { + const { stdout, exitCode } = await cli("search", "--filter", "all"); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok(stdout.includes("Found"), "should report results"); + }); + + it("search with query finds test pages", async () => { + const { stdout, exitCode } = await cli("search", "[test] notion-cli"); + assert.equal(exitCode, 0, "should exit 0"); + assert.ok( + stdout.includes("Found") || stdout.includes("No pages found"), + "should report results or empty", + ); + }); + + it("search with --limit", async () => { + const { stdout, exitCode } = await cli("search", "-n", "2"); + assert.equal(exitCode, 0, "should exit 0"); + const countMatch = stdout.match(/Found (\d+) result/); + assert.ok(countMatch, "should report result count"); + assert.ok(parseInt(countMatch![1], 10) <= 2, "should respect --limit"); + }); +}); diff --git a/notion-cli/test/user.test.ts b/notion-cli/test/user.test.ts new file mode 100644 index 0000000..99f4e0c --- /dev/null +++ b/notion-cli/test/user.test.ts @@ -0,0 +1,42 @@ +/** + * Tests for `user` commands: me, list, get. + * No test page needed — just exercises user endpoints. + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { requireToken, createCli, extractJson } from "./helpers.js"; + +const token = requireToken(); +const cli = createCli(token); + +let userId: string; + +describe("user", () => { + it("user me --raw", async () => { + const { stdout, exitCode } = await cli("user", "me", "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const user = extractJson(stdout) as Record<string, unknown>; + assert.ok(user.id, "bot user should have an id"); + assert.equal(user.object, "user"); + assert.equal(user.type, "bot"); + }); + + it("user list --raw", async () => { + const { stdout, exitCode } = await cli("user", "list", "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const data = extractJson(stdout) as { + results: Array<{ id: string; name: string }>; + }; + assert.ok(data.results.length > 0, "should have at least one user"); + userId = data.results[0].id; + }); + + it("user get <id> --raw", async () => { + assert.ok(userId, "userId must be set by 'user list' first"); + const { stdout, exitCode } = await cli("user", "get", userId, "--raw"); + assert.equal(exitCode, 0, "should exit 0"); + const user = extractJson(stdout) as Record<string, unknown>; + assert.equal(user.id, userId, "returned user should match requested id"); + }); +}); diff --git a/notion-cli/tsconfig.json b/notion-cli/tsconfig.json index 21d334b..9ca5a0d 100644 --- a/notion-cli/tsconfig.json +++ b/notion-cli/tsconfig.json @@ -9,5 +9,5 @@ "outDir": "dist", "declaration": true }, - "include": ["*.ts", "src/**/*.ts"] + "include": ["*.ts", "src/**/*.ts", "commands/**/*.ts", "test/**/*.ts"] }