@sentry/junior-notion 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @sentry/junior-notion
2
2
 
3
- `@sentry/junior-notion` adds read-only Notion search workflows for pages and data sources to Junior via a shared internal Notion integration.
3
+ `@sentry/junior-notion` adds read-only Notion search workflows for pages and data sources to Junior through Notion's hosted MCP server.
4
4
 
5
5
  Install it alongside `@sentry/junior`:
6
6
 
@@ -8,21 +8,6 @@ Install it alongside `@sentry/junior`:
8
8
  pnpm add @sentry/junior @sentry/junior-notion
9
9
  ```
10
10
 
11
- Create an internal Notion integration by following Notion's Authorization guide:
12
-
13
- - https://developers.notion.com/guides/get-started/authorization
14
-
15
- In the Notion integration settings:
16
-
17
- - choose the workspace where the integration will live
18
- - enable the `Read content` capability
19
- - copy the integration secret from the `Configuration` tab
20
- - share any pages or data sources Junior should read via `•••` -> `Add connections`
21
-
22
- Set that value in your host environment:
23
-
24
- - `NOTION_TOKEN`
25
-
26
11
  Then register the plugin package in `withJunior(...)`:
27
12
 
28
13
  ```js
@@ -33,24 +18,23 @@ export default withJunior({
33
18
  });
34
19
  ```
35
20
 
36
- There is no `/notion auth` flow for this plugin. Once the token is configured and pages or data sources are shared with the integration, users can run `/notion <query>` directly.
21
+ This package does not use `NOTION_TOKEN` or a shared workspace integration. Each user connects their own Notion account the first time Junior calls a Notion MCP tool. Junior sends the OAuth link privately and resumes the thread automatically after the user authorizes.
37
22
 
38
- ## Search limitations
23
+ Junior intentionally keeps this package read-only by exposing only Notion's `notion-search` and `notion-fetch` MCP tools. The plugin does not expose create, update, move, or other write-capable Notion tools.
39
24
 
40
- This plugin currently uses Notion's public `v1` API for search and content retrieval.
25
+ ## Search limitations
41
26
 
42
- - `v1/search` is title-biased and does not match the richer `Best matches` behavior users see in notion.so.
43
- - Results can differ from the UI even when the user can see a page in the Notion app.
44
- - The most common cause of missing results is that the target page or data source is not directly shared with the integration.
45
- - Newly shared content can also lag behind search indexing.
27
+ This package uses Notion MCP search and fetch rather than the older REST helper flow.
46
28
 
47
- We also tested Notion's private `api/v3/search` endpoint with the same integration token. It accepted the token at the HTTP layer, but it did not return useful results for the same sample queries, so this plugin does not depend on `api/v3`.
29
+ - Search is still title-biased, so prompts work best when users search for the actual page or data source title.
30
+ - Results can differ from the Notion UI even when the user can see a page in the app.
31
+ - Search across connected sources like Slack, Google Drive, and Jira requires a Notion AI plan. Without Notion AI, search is limited to the user's Notion workspace.
32
+ - Missing results are usually a permissions problem on the user's Notion account or a weak query phrase.
48
33
 
49
- For local debugging, the package exposes one Notion helper script through two subcommands that load the workspace env first:
34
+ ## Auth model
50
35
 
51
- ```bash
52
- pnpm notion:search -- --query "company holidays"
53
- pnpm notion:fetch -- --id "<notion-id>" --object page
54
- ```
36
+ - Notion MCP requires user-based OAuth and does not support bearer token authentication.
37
+ - This package is not suitable for fully headless or unattended automation.
38
+ - Users can disconnect from Junior App Home with `Unlink`, or by asking Junior to disconnect Notion.
55
39
 
56
40
  Full setup guide: https://junior.sentry.dev/extend/notion-plugin/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentry/junior-notion",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/plugin.yaml CHANGED
@@ -1,13 +1,9 @@
1
1
  name: notion
2
2
  description: Notion page search and summarization
3
3
 
4
- capabilities:
5
- - api
6
-
7
- credentials:
8
- type: oauth-bearer
9
- api-domains:
10
- - api.notion.com
11
- api-headers:
12
- Notion-Version: "2025-09-03"
13
- auth-token-env: NOTION_TOKEN
4
+ mcp:
5
+ transport: http
6
+ url: https://mcp.notion.com/mcp
7
+ allowed-tools:
8
+ - notion-search
9
+ - notion-fetch
@@ -1,48 +1,51 @@
1
1
  ---
2
2
  name: notion
3
3
  description: Search Notion pages and data sources and summarize the best match. Use when users ask to look up docs, specs, notes, meeting notes, project context, roadmaps, trackers, or internal references stored in Notion.
4
- requires-capabilities: notion.api
5
- allowed-tools: bash
6
4
  ---
7
5
 
8
6
  # Notion Operations
9
7
 
10
- Use this skill for `/notion` workflows in the harness.
8
+ Use this skill for Notion search and summarization workflows in the harness.
11
9
 
12
10
  ## Workflow
13
11
 
14
- 1. Classify the request:
12
+ 1. Treat the request as a read-only query.
15
13
 
16
- - `auth`: explain that this plugin uses a shared internal Notion integration, so there is no per-user auth flow. Tell the user the workspace admin must configure `NOTION_TOKEN` and share the relevant pages or data sources with the integration.
17
- - `disconnect`: explain that there is no per-user Notion connection to remove because the plugin uses a shared internal integration.
18
- - otherwise treat the request as a read-only query.
14
+ 2. Keep tool work mostly silent:
19
15
 
20
- 2. Enable credentials:
16
+ - Send at most one short acknowledgment before Notion tool work.
17
+ - Keep intermediate search/fetch reasoning internal.
18
+ - Do not narrate each step with "let me...", "I found...", or partial findings while tools are still running.
19
+ - Reply with the real answer once you have enough evidence, or explain the actual blocker if you cannot finish.
21
20
 
22
- - Before any Notion API call, run `jr-rpc issue-credential notion.api`.
23
- - If credential issuance fails, explain that Notion is not configured on the host and the admin must set `NOTION_TOKEN`.
21
+ 3. Search with MCP:
24
22
 
25
- 3. Search with the checked-in helper:
26
-
27
- - Do not improvise `curl` requests or inline `node` snippets for Notion.
23
+ - `loadSkill` returns `available_tools` for this skill, including the exact `tool_name` values and argument schemas for the Notion tools exposed in this turn.
24
+ - Use `useTool` with those exact `tool_name` values.
25
+ - Use `searchTools` only if you need to rediscover or filter the active Notion tools later in the turn.
28
26
  - Decide the actual search phrases first. Notion search is title-biased, so search for the likely page or data source title, not the user's full sentence.
29
27
  - Use 1-3 short explicit search phrases.
30
28
  - Good: `deployment pipeline`, `launch tracker`, `incident review`
31
29
  - Bad: `how do we handle deployment pipelines for mobile releases`
32
- - Run search with the loaded `skill_dir` path:
33
- `node <skill_dir>/scripts/notion-cli.mjs search --query "<best phrase>" --query "<fallback phrase>"`
34
- - If the first phrase misses, rerun with 1-2 alternate title-style phrases.
35
- - Search returns ranked page/data-source candidates only. Pick the best candidate, then fetch content with:
36
- `node <skill_dir>/scripts/notion-cli.mjs fetch --id "<result id>" --object "page"`
37
- or
38
- `node <skill_dir>/scripts/notion-cli.mjs fetch --id "<result id>" --object "data_source"`
39
- - Use the fetch output to summarize page markdown, or summarize the data source schema and returned rows.
30
+ - For list/report/calendar requests, search for the canonical container first:
31
+ - page title: `holidays`, `company holidays`
32
+ - data source title: `people calendar`
33
+ - Prefer one refinement round at most. If the first search already found a plausible canonical page or data source, fetch it before searching again.
34
+
35
+ 4. Fetch efficiently:
36
+
37
+ - Search returns ranked page and data-source candidates only. Pick the best candidate, then fetch content with the disclosed Notion fetch tool via `useTool` using the returned URL or ID.
38
+ - If a fetched page clearly points at an inline data source or database, fetch that data source next and work from it.
39
+ - If the fetched data source already contains the rows and fields needed to answer, stop there and answer from that result.
40
+ - Do not serially fetch many individual row pages when the container page or data source already exposes the needed fields.
41
+ - Fetch individual rows only when a small number of important fields are still missing or ambiguous after fetching the canonical page or data source.
42
+ - Once you have enough evidence to answer, stop fetching and respond.
40
43
 
41
44
  ## Guardrails
42
45
 
43
46
  - Read-only only.
44
- - Do not print credential values.
45
- - The runtime injects `Authorization` and `Notion-Version`; the helper handles request-specific headers.
47
+ - Junior intentionally exposes only Notion search and fetch tools for this skill. Do not ask for writes, comments, or page moves.
46
48
  - Search results may be pages or data sources. Do not treat data sources as unsupported.
47
- - If search returns no accessible matches, say that no accessible pages or data sources matched and note the content may not be shared with the integration yet.
49
+ - For scoped requests like "US holidays" or "2026 holidays", apply the user's scope when reading the fetched content and state any assumption you made if the source mixes multiple geos or years.
50
+ - If search returns no accessible matches, say that no accessible pages or data sources matched and note that the content may be outside the user's Notion permissions or poorly matched by title.
48
51
  - If content retrieval fails for the top result, return the best matching Notion URL and explain that the result could not be fetched for summarization.
@@ -1,557 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { pathToFileURL } from "node:url";
4
-
5
- /**
6
- * Unified Notion helper for LLM-facing search and fetch operations.
7
- *
8
- * `search` preserves the public v1 Search API's native ordering as closely as possible and
9
- * returns broad candidate results for the raw query without forcing a winner.
10
- *
11
- * `fetch` loads normalized content for a specific page or data source chosen from search
12
- * results so the model can summarize a stable payload.
13
- */
14
-
15
- const DEFAULT_API_BASE_URL = "https://api.notion.com/v1";
16
- // Keep this pinned in sync with packages/junior-notion/plugin.yaml.
17
- const DEFAULT_NOTION_VERSION = "2025-09-03";
18
- const DEFAULT_PAGE_SIZE = 100;
19
- const DEFAULT_ROW_LIMIT = 10;
20
- const DEFAULT_TIMEOUT_MS = 15_000;
21
- const DEFAULT_RETRY_LIMIT = 2;
22
-
23
- function parseArgs(argv) {
24
- const parsed = {};
25
- for (let index = 0; index < argv.length; index += 1) {
26
- const token = argv[index];
27
- if (!token.startsWith("--")) {
28
- continue;
29
- }
30
- const [rawKey, inlineValue] = token.slice(2).split("=", 2);
31
- if (inlineValue !== undefined) {
32
- if (parsed[rawKey] === undefined) {
33
- parsed[rawKey] = inlineValue;
34
- } else if (Array.isArray(parsed[rawKey])) {
35
- parsed[rawKey].push(inlineValue);
36
- } else {
37
- parsed[rawKey] = [parsed[rawKey], inlineValue];
38
- }
39
- continue;
40
- }
41
- const next = argv[index + 1];
42
- if (!next || next.startsWith("--")) {
43
- parsed[rawKey] = "true";
44
- continue;
45
- }
46
- if (parsed[rawKey] === undefined) {
47
- parsed[rawKey] = next;
48
- } else if (Array.isArray(parsed[rawKey])) {
49
- parsed[rawKey].push(next);
50
- } else {
51
- parsed[rawKey] = [parsed[rawKey], next];
52
- }
53
- index += 1;
54
- }
55
- return parsed;
56
- }
57
-
58
- function normalizeWhitespace(value) {
59
- return String(value ?? "")
60
- .replace(/\s+/g, " ")
61
- .trim();
62
- }
63
-
64
- function toStringArray(value) {
65
- if (Array.isArray(value)) {
66
- return value.map((item) => normalizeWhitespace(item)).filter(Boolean);
67
- }
68
- const normalized = normalizeWhitespace(value);
69
- return normalized ? [normalized] : [];
70
- }
71
-
72
- function buildSearchQueries(queries) {
73
- const normalizedQueries = [];
74
- const seenQueries = new Set();
75
- for (const value of toStringArray(queries)) {
76
- if (seenQueries.has(value)) {
77
- continue;
78
- }
79
- seenQueries.add(value);
80
- normalizedQueries.push(value);
81
- }
82
- return normalizedQueries;
83
- }
84
-
85
- function extractPlainText(value) {
86
- if (typeof value === "string") {
87
- return value;
88
- }
89
- if (!Array.isArray(value)) {
90
- return "";
91
- }
92
- return value
93
- .map((item) => {
94
- if (typeof item?.plain_text === "string") {
95
- return item.plain_text;
96
- }
97
- if (typeof item?.text?.content === "string") {
98
- return item.text.content;
99
- }
100
- return "";
101
- })
102
- .join("")
103
- .trim();
104
- }
105
-
106
- function extractTitleFromProperties(properties) {
107
- if (!properties || typeof properties !== "object") {
108
- return "";
109
- }
110
- for (const property of Object.values(properties)) {
111
- if (property?.type === "title") {
112
- return extractPlainText(property.title);
113
- }
114
- }
115
- return "";
116
- }
117
-
118
- function extractResultTitle(result) {
119
- if (!result || typeof result !== "object") {
120
- return "";
121
- }
122
- if (Array.isArray(result.title)) {
123
- return extractPlainText(result.title);
124
- }
125
- if (typeof result.title === "string") {
126
- return result.title.trim();
127
- }
128
- if (result.properties) {
129
- return extractTitleFromProperties(result.properties);
130
- }
131
- return "";
132
- }
133
-
134
- function simplifySearchResult(result) {
135
- return {
136
- id: String(result?.id ?? ""),
137
- object: String(result?.object ?? ""),
138
- title: extractResultTitle(result),
139
- url: String(result?.url ?? ""),
140
- last_edited_time: result?.last_edited_time ?? null,
141
- };
142
- }
143
-
144
- function buildHeaders(extraHeaders) {
145
- const headers = {
146
- Accept: "application/json",
147
- "Notion-Version": process.env.NOTION_VERSION || DEFAULT_NOTION_VERSION,
148
- ...extraHeaders,
149
- };
150
- const token = normalizeWhitespace(process.env.NOTION_TOKEN);
151
- // In sandboxed runs the broker can set a placeholder value here and inject the
152
- // real Authorization header later, so skip the placeholder rather than sending it.
153
- if (token && token !== "host_managed_credential") {
154
- headers.Authorization = `Bearer ${token}`;
155
- }
156
- return headers;
157
- }
158
-
159
- function getApiBaseUrl() {
160
- return normalizeWhitespace(process.env.NOTION_API_BASE_URL) || DEFAULT_API_BASE_URL;
161
- }
162
-
163
- function isRetryableStatus(status) {
164
- return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
165
- }
166
-
167
- function parseRetryAfterMs(value) {
168
- const seconds = Number.parseFloat(String(value ?? ""));
169
- if (!Number.isFinite(seconds) || seconds < 0) {
170
- return 0;
171
- }
172
- return Math.ceil(seconds * 1000);
173
- }
174
-
175
- function sleep(ms) {
176
- return new Promise((resolve) => setTimeout(resolve, ms));
177
- }
178
-
179
- async function notionRequest(pathname, init = {}, options = {}) {
180
- const retryLimit = options.retryLimit ?? DEFAULT_RETRY_LIMIT;
181
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
182
- const method = init.method ?? "GET";
183
- const headers = buildHeaders(init.headers ?? {});
184
- const url = `${getApiBaseUrl()}${pathname}`;
185
-
186
- for (let attempt = 0; attempt <= retryLimit; attempt += 1) {
187
- const controller = new AbortController();
188
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
189
- try {
190
- const response = await fetch(url, {
191
- ...init,
192
- method,
193
- headers,
194
- signal: controller.signal,
195
- });
196
- if (!response.ok) {
197
- const bodyText = await response.text();
198
- if (attempt < retryLimit && isRetryableStatus(response.status)) {
199
- const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
200
- await sleep(retryAfterMs || 250 * (attempt + 1));
201
- continue;
202
- }
203
- throw new Error(
204
- `Notion API ${method} ${pathname} failed with ${response.status}: ${bodyText || response.statusText}`,
205
- );
206
- }
207
- const contentType = response.headers.get("content-type") || "";
208
- if (contentType.includes("application/json")) {
209
- return await response.json();
210
- }
211
- return await response.text();
212
- } finally {
213
- clearTimeout(timeout);
214
- }
215
- }
216
-
217
- throw new Error(`Notion API ${method} ${pathname} failed after retries`);
218
- }
219
-
220
- async function searchOnce(query, pageSize, object) {
221
- const body = {
222
- query,
223
- page_size: pageSize,
224
- };
225
- if (object) {
226
- body.filter = {
227
- property: "object",
228
- value: object,
229
- };
230
- }
231
- return await notionRequest("/search", {
232
- method: "POST",
233
- headers: {
234
- "Content-Type": "application/json",
235
- },
236
- body: JSON.stringify(body),
237
- });
238
- }
239
-
240
- async function searchNotion({ queries = [], pageSize = DEFAULT_PAGE_SIZE, object = "" } = {}) {
241
- const searchQueries = buildSearchQueries(queries);
242
- const attempts = [];
243
- const candidateMap = new Map();
244
-
245
- for (const variant of searchQueries) {
246
- const response = await searchOnce(variant, pageSize, object || undefined);
247
- const results = Array.isArray(response?.results) ? response.results : [];
248
- attempts.push({
249
- query: variant,
250
- object: object || "page_or_data_source",
251
- result_count: results.length,
252
- has_more: Boolean(response?.has_more),
253
- next_cursor: response?.next_cursor ?? null,
254
- });
255
-
256
- for (const result of results) {
257
- if (!result?.id || candidateMap.has(result.id)) {
258
- continue;
259
- }
260
- candidateMap.set(result.id, {
261
- ...simplifySearchResult(result),
262
- query: variant,
263
- });
264
- }
265
- }
266
-
267
- return {
268
- ok: true,
269
- query_variants: searchQueries,
270
- attempts,
271
- result_count: candidateMap.size,
272
- results: [...candidateMap.values()].map((candidate) => ({
273
- id: candidate.id,
274
- object: candidate.object,
275
- title: candidate.title,
276
- url: candidate.url,
277
- last_edited_time: candidate.last_edited_time,
278
- query: candidate.query,
279
- })),
280
- };
281
- }
282
-
283
- function simplifyFormulaValue(formula) {
284
- if (!formula || typeof formula !== "object") {
285
- return null;
286
- }
287
-
288
- switch (formula.type) {
289
- case "string":
290
- return formula.string ?? null;
291
- case "number":
292
- return formula.number ?? null;
293
- case "boolean":
294
- return formula.boolean ?? null;
295
- case "date":
296
- return formula.date
297
- ? {
298
- start: formula.date.start ?? null,
299
- end: formula.date.end ?? null,
300
- }
301
- : null;
302
- case "page":
303
- return formula.page?.id ?? null;
304
- case "person":
305
- return formula.person?.name || formula.person?.id || null;
306
- case "list":
307
- return Array.isArray(formula.list)
308
- ? formula.list.map((item) => simplifyFormulaValue(item)).filter((item) => item !== null)
309
- : [];
310
- default:
311
- return null;
312
- }
313
- }
314
-
315
- function simplifyPropertyValue(property) {
316
- if (!property || typeof property !== "object") {
317
- return null;
318
- }
319
- switch (property.type) {
320
- case "title":
321
- return extractPlainText(property.title);
322
- case "rich_text":
323
- return extractPlainText(property.rich_text);
324
- case "status":
325
- return property.status?.name ?? null;
326
- case "select":
327
- return property.select?.name ?? null;
328
- case "multi_select":
329
- return Array.isArray(property.multi_select)
330
- ? property.multi_select.map((item) => item?.name).filter(Boolean)
331
- : [];
332
- case "number":
333
- return property.number ?? null;
334
- case "checkbox":
335
- return property.checkbox ?? null;
336
- case "url":
337
- return property.url ?? null;
338
- case "email":
339
- return property.email ?? null;
340
- case "phone_number":
341
- return property.phone_number ?? null;
342
- case "date":
343
- return property.date
344
- ? {
345
- start: property.date.start ?? null,
346
- end: property.date.end ?? null,
347
- }
348
- : null;
349
- case "people":
350
- return Array.isArray(property.people)
351
- ? property.people.map((item) => item?.name || item?.id).filter(Boolean)
352
- : [];
353
- case "relation":
354
- return Array.isArray(property.relation)
355
- ? property.relation.map((item) => item?.id).filter(Boolean)
356
- : [];
357
- case "formula":
358
- return simplifyFormulaValue(property.formula);
359
- case "created_time":
360
- return property.created_time ?? null;
361
- case "last_edited_time":
362
- return property.last_edited_time ?? null;
363
- case "unique_id":
364
- if (!property.unique_id) {
365
- return null;
366
- }
367
- if (property.unique_id.prefix) {
368
- return `${property.unique_id.prefix}-${property.unique_id.number ?? ""}`;
369
- }
370
- return property.unique_id.number ?? null;
371
- default:
372
- return null;
373
- }
374
- }
375
-
376
- function simplifyPageRecord(page) {
377
- const properties = {};
378
- if (page?.properties && typeof page.properties === "object") {
379
- for (const [key, value] of Object.entries(page.properties)) {
380
- properties[key] = simplifyPropertyValue(value);
381
- }
382
- }
383
-
384
- return {
385
- id: String(page?.id ?? ""),
386
- object: String(page?.object ?? "page"),
387
- title: extractResultTitle(page),
388
- url: String(page?.url ?? ""),
389
- last_edited_time: page?.last_edited_time ?? null,
390
- properties,
391
- };
392
- }
393
-
394
- function simplifyDataSourceSchema(dataSource) {
395
- const properties = dataSource?.properties;
396
- if (!properties || typeof properties !== "object") {
397
- return [];
398
- }
399
- return Object.entries(properties).map(([name, value]) => ({
400
- name,
401
- type: String(value?.type ?? "unknown"),
402
- }));
403
- }
404
-
405
- async function fetchPageMetadata(pageId) {
406
- const page = await notionRequest(`/pages/${pageId}`);
407
- return simplifySearchResult(page);
408
- }
409
-
410
- async function fetchPageMarkdown(pageId) {
411
- const response = await notionRequest(`/pages/${pageId}/markdown`);
412
- if (typeof response === "string") {
413
- return response;
414
- }
415
- if (typeof response?.markdown === "string") {
416
- return response.markdown;
417
- }
418
- if (Array.isArray(response?.results)) {
419
- return response.results
420
- .map((item) => (typeof item === "string" ? item : item?.markdown ?? ""))
421
- .filter(Boolean)
422
- .join("\n");
423
- }
424
- return JSON.stringify(response, null, 2);
425
- }
426
-
427
- async function fetchDataSourceContent(dataSourceId, rowLimit) {
428
- const dataSource = await notionRequest(`/data_sources/${dataSourceId}`);
429
- const target = {
430
- id: String(dataSource?.id ?? dataSourceId),
431
- object: "data_source",
432
- title: extractResultTitle(dataSource),
433
- url: String(dataSource?.url ?? ""),
434
- last_edited_time: dataSource?.last_edited_time ?? null,
435
- };
436
-
437
- try {
438
- const rowsResponse = await notionRequest(`/data_sources/${dataSourceId}/query`, {
439
- method: "POST",
440
- headers: {
441
- "Content-Type": "application/json",
442
- },
443
- body: JSON.stringify({ page_size: rowLimit }),
444
- });
445
-
446
- return {
447
- target,
448
- content: {
449
- type: "data_source",
450
- schema: simplifyDataSourceSchema(dataSource),
451
- rows: Array.isArray(rowsResponse?.results)
452
- ? rowsResponse.results.map((row) => simplifyPageRecord(row))
453
- : [],
454
- },
455
- };
456
- } catch (error) {
457
- return {
458
- target,
459
- content: null,
460
- content_error: error instanceof Error ? error.message : String(error),
461
- };
462
- }
463
- }
464
-
465
- export async function fetchContent({ id, object, rowLimit = DEFAULT_ROW_LIMIT } = {}) {
466
- if (!id) {
467
- throw new Error("notion fetch requires --id");
468
- }
469
- if (object !== "page" && object !== "data_source") {
470
- throw new Error("notion fetch requires --object page|data_source");
471
- }
472
-
473
- if (object === "page") {
474
- let target = {
475
- id,
476
- object: "page",
477
- title: "",
478
- url: "",
479
- last_edited_time: null,
480
- };
481
- try {
482
- target = await fetchPageMetadata(id);
483
- const markdown = await fetchPageMarkdown(id);
484
- return {
485
- ok: true,
486
- target,
487
- content: {
488
- type: "page",
489
- markdown,
490
- },
491
- };
492
- } catch (error) {
493
- return {
494
- ok: true,
495
- target,
496
- content: null,
497
- content_error: error instanceof Error ? error.message : String(error),
498
- };
499
- }
500
- }
501
-
502
- try {
503
- return {
504
- ok: true,
505
- ...(await fetchDataSourceContent(id, rowLimit)),
506
- };
507
- } catch (error) {
508
- return {
509
- ok: true,
510
- target: {
511
- id,
512
- object: "data_source",
513
- title: "",
514
- url: "",
515
- last_edited_time: null,
516
- },
517
- content: null,
518
- content_error: error instanceof Error ? error.message : String(error),
519
- };
520
- }
521
- }
522
-
523
- async function main() {
524
- const [command, ...rest] = process.argv.slice(2);
525
- if (command !== "search" && command !== "fetch") {
526
- throw new Error("notion-cli requires a subcommand: search | fetch");
527
- }
528
-
529
- const args = parseArgs(rest[0] === "--" ? rest.slice(1) : rest);
530
- let result;
531
- if (command === "search") {
532
- const queries = toStringArray(args.query);
533
- if (queries.length === 0) {
534
- throw new Error("notion search requires at least one --query");
535
- }
536
- result = await searchNotion({
537
- queries,
538
- pageSize: args["page-size"] ? Number.parseInt(args["page-size"], 10) : DEFAULT_PAGE_SIZE,
539
- object: normalizeWhitespace(args.object),
540
- });
541
- } else {
542
- result = await fetchContent({
543
- id: normalizeWhitespace(args.id),
544
- object: normalizeWhitespace(args.object),
545
- rowLimit: args["row-limit"] ? Number.parseInt(args["row-limit"], 10) : DEFAULT_ROW_LIMIT,
546
- });
547
- }
548
-
549
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
550
- }
551
-
552
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
553
- main().catch((error) => {
554
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
555
- process.exitCode = 1;
556
- });
557
- }