@andrzejchm/notion-cli 0.1.2 → 0.3.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/dist/cli.js CHANGED
@@ -1,109 +1,236 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command as Command14 } from "commander";
5
- import { fileURLToPath } from "url";
6
- import { dirname, join as join3 } from "path";
7
4
  import { readFileSync } from "fs";
5
+ import { dirname, join as join3 } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { Command as Command19 } from "commander";
8
8
 
9
- // src/output/color.ts
10
- import { Chalk } from "chalk";
11
- var _colorForced = false;
12
- function setColorForced(forced) {
13
- _colorForced = forced;
14
- }
15
- function isColorEnabled() {
16
- if (process.env.NO_COLOR) return false;
17
- if (_colorForced) return true;
18
- return Boolean(process.stderr.isTTY);
19
- }
20
- function createChalk() {
21
- const level = isColorEnabled() ? void 0 : 0;
22
- return new Chalk({ level });
23
- }
24
- function success(msg) {
25
- return createChalk().green(msg);
26
- }
27
- function dim(msg) {
28
- return createChalk().dim(msg);
29
- }
30
- function bold(msg) {
31
- return createChalk().bold(msg);
32
- }
9
+ // src/commands/append.ts
10
+ import { Command } from "commander";
33
11
 
34
- // src/output/format.ts
35
- var _mode = "auto";
36
- function setOutputMode(mode) {
37
- _mode = mode;
38
- }
39
- function getOutputMode() {
40
- return _mode;
41
- }
42
- function isatty() {
43
- return Boolean(process.stdout.isTTY);
44
- }
45
- function isHumanMode() {
46
- if (_mode === "json") return false;
47
- if (_mode === "md") return false;
48
- return true;
49
- }
50
- function formatJSON(data) {
51
- return JSON.stringify(data, null, 2);
52
- }
53
- var COLUMN_CAPS = {
54
- TITLE: 50,
55
- ID: 36
56
- };
57
- var DEFAULT_MAX_COL_WIDTH = 40;
58
- function getColumnCap(header) {
59
- return COLUMN_CAPS[header.toUpperCase()] ?? DEFAULT_MAX_COL_WIDTH;
60
- }
61
- function truncate(str, maxLen) {
62
- if (str.length <= maxLen) return str;
63
- return str.slice(0, maxLen - 1) + "\u2026";
12
+ // src/blocks/md-to-blocks.ts
13
+ var INLINE_RE = /(\*\*[^*]+\*\*|_[^_]+_|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\)|[^*_`[]+)/g;
14
+ function parseInlineMarkdown(text) {
15
+ const result = [];
16
+ let match;
17
+ INLINE_RE.lastIndex = 0;
18
+ while ((match = INLINE_RE.exec(text)) !== null) {
19
+ const segment = match[0];
20
+ if (segment.startsWith("**") && segment.endsWith("**")) {
21
+ const content = segment.slice(2, -2);
22
+ result.push({
23
+ type: "text",
24
+ text: { content, link: null },
25
+ annotations: {
26
+ bold: true,
27
+ italic: false,
28
+ strikethrough: false,
29
+ underline: false,
30
+ code: false,
31
+ color: "default"
32
+ }
33
+ });
34
+ continue;
35
+ }
36
+ if (segment.startsWith("_") && segment.endsWith("_") || segment.startsWith("*") && segment.endsWith("*")) {
37
+ const content = segment.slice(1, -1);
38
+ result.push({
39
+ type: "text",
40
+ text: { content, link: null },
41
+ annotations: {
42
+ bold: false,
43
+ italic: true,
44
+ strikethrough: false,
45
+ underline: false,
46
+ code: false,
47
+ color: "default"
48
+ }
49
+ });
50
+ continue;
51
+ }
52
+ if (segment.startsWith("`") && segment.endsWith("`")) {
53
+ const content = segment.slice(1, -1);
54
+ result.push({
55
+ type: "text",
56
+ text: { content, link: null },
57
+ annotations: {
58
+ bold: false,
59
+ italic: false,
60
+ strikethrough: false,
61
+ underline: false,
62
+ code: true,
63
+ color: "default"
64
+ }
65
+ });
66
+ continue;
67
+ }
68
+ const linkMatch = segment.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
69
+ if (linkMatch) {
70
+ const [, label, url] = linkMatch;
71
+ result.push({
72
+ type: "text",
73
+ text: { content: label, link: { url } },
74
+ annotations: {
75
+ bold: false,
76
+ italic: false,
77
+ strikethrough: false,
78
+ underline: false,
79
+ code: false,
80
+ color: "default"
81
+ }
82
+ });
83
+ continue;
84
+ }
85
+ result.push({
86
+ type: "text",
87
+ text: { content: segment, link: null },
88
+ annotations: {
89
+ bold: false,
90
+ italic: false,
91
+ strikethrough: false,
92
+ underline: false,
93
+ code: false,
94
+ color: "default"
95
+ }
96
+ });
97
+ }
98
+ return result;
64
99
  }
65
- function formatTable(rows, headers) {
66
- const colWidths = headers.map((header, colIdx) => {
67
- const cap = getColumnCap(header);
68
- const headerLen = header.length;
69
- const maxRowLen = rows.reduce((max, row) => {
70
- const cell = row[colIdx] ?? "";
71
- return Math.max(max, cell.length);
72
- }, 0);
73
- return Math.min(Math.max(headerLen, maxRowLen), cap);
74
- });
75
- const sep = "\u2500";
76
- const colSep = " ";
77
- const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(colSep);
78
- const separatorRow = colWidths.map((w) => sep.repeat(w)).join(colSep);
79
- const dataRows = rows.map(
80
- (row) => headers.map((_, i) => {
81
- const cell = row[i] ?? "";
82
- return truncate(cell, colWidths[i]).padEnd(colWidths[i]);
83
- }).join(colSep)
84
- );
85
- return [headerRow, separatorRow, ...dataRows].join("\n");
100
+ function makeRichText(text) {
101
+ return parseInlineMarkdown(text);
86
102
  }
87
- function printOutput(data, tableHeaders, tableRows) {
88
- const mode = getOutputMode();
89
- if (mode === "json") {
90
- process.stdout.write(formatJSON(data) + "\n");
91
- } else if (isHumanMode() && tableHeaders && tableRows) {
92
- printWithPager(formatTable(tableRows, tableHeaders) + "\n");
103
+ function mdToBlocks(md) {
104
+ if (!md) return [];
105
+ const lines = md.split("\n");
106
+ const blocks = [];
107
+ let inFence = false;
108
+ let fenceLang = "";
109
+ const fenceLines = [];
110
+ for (const line of lines) {
111
+ if (!inFence && line.startsWith("```")) {
112
+ inFence = true;
113
+ fenceLang = line.slice(3).trim() || "plain text";
114
+ fenceLines.length = 0;
115
+ continue;
116
+ }
117
+ if (inFence) {
118
+ if (line.startsWith("```")) {
119
+ const content = fenceLines.join("\n");
120
+ blocks.push({
121
+ type: "code",
122
+ code: {
123
+ rich_text: [
124
+ {
125
+ type: "text",
126
+ text: { content, link: null },
127
+ annotations: {
128
+ bold: false,
129
+ italic: false,
130
+ strikethrough: false,
131
+ underline: false,
132
+ code: false,
133
+ color: "default"
134
+ }
135
+ }
136
+ ],
137
+ // biome-ignore lint/suspicious/noExplicitAny: Notion SDK language type is too narrow
138
+ language: fenceLang
139
+ }
140
+ });
141
+ inFence = false;
142
+ fenceLang = "";
143
+ fenceLines.length = 0;
144
+ } else {
145
+ fenceLines.push(line);
146
+ }
147
+ continue;
148
+ }
149
+ if (line.trim() === "") continue;
150
+ const h1 = line.match(/^# (.+)$/);
151
+ if (h1) {
152
+ blocks.push({
153
+ type: "heading_1",
154
+ heading_1: { rich_text: makeRichText(h1[1]) }
155
+ });
156
+ continue;
157
+ }
158
+ const h2 = line.match(/^## (.+)$/);
159
+ if (h2) {
160
+ blocks.push({
161
+ type: "heading_2",
162
+ heading_2: { rich_text: makeRichText(h2[1]) }
163
+ });
164
+ continue;
165
+ }
166
+ const h3 = line.match(/^### (.+)$/);
167
+ if (h3) {
168
+ blocks.push({
169
+ type: "heading_3",
170
+ heading_3: { rich_text: makeRichText(h3[1]) }
171
+ });
172
+ continue;
173
+ }
174
+ const bullet = line.match(/^[-*] (.+)$/);
175
+ if (bullet) {
176
+ blocks.push({
177
+ type: "bulleted_list_item",
178
+ bulleted_list_item: { rich_text: makeRichText(bullet[1]) }
179
+ });
180
+ continue;
181
+ }
182
+ const numbered = line.match(/^\d+\. (.+)$/);
183
+ if (numbered) {
184
+ blocks.push({
185
+ type: "numbered_list_item",
186
+ numbered_list_item: { rich_text: makeRichText(numbered[1]) }
187
+ });
188
+ continue;
189
+ }
190
+ const quote = line.match(/^> (.+)$/);
191
+ if (quote) {
192
+ blocks.push({
193
+ type: "quote",
194
+ quote: { rich_text: makeRichText(quote[1]) }
195
+ });
196
+ continue;
197
+ }
198
+ blocks.push({
199
+ type: "paragraph",
200
+ paragraph: { rich_text: makeRichText(line) }
201
+ });
93
202
  }
94
- }
95
- function printWithPager(text) {
96
- process.stdout.write(text);
203
+ if (inFence && fenceLines.length > 0) {
204
+ const content = fenceLines.join("\n");
205
+ blocks.push({
206
+ type: "code",
207
+ code: {
208
+ rich_text: [
209
+ {
210
+ type: "text",
211
+ text: { content, link: null },
212
+ annotations: {
213
+ bold: false,
214
+ italic: false,
215
+ strikethrough: false,
216
+ underline: false,
217
+ code: false,
218
+ color: "default"
219
+ }
220
+ }
221
+ ],
222
+ // biome-ignore lint/suspicious/noExplicitAny: Notion SDK language type is too narrow
223
+ language: fenceLang
224
+ }
225
+ });
226
+ }
227
+ return blocks;
97
228
  }
98
229
 
99
- // src/commands/init.ts
100
- import { Command } from "commander";
101
- import { input, password, confirm } from "@inquirer/prompts";
102
-
103
230
  // src/errors/cli-error.ts
104
231
  var CliError = class extends Error {
105
- constructor(code, message, suggestion) {
106
- super(message);
232
+ constructor(code, message, suggestion, cause) {
233
+ super(message, { cause });
107
234
  this.code = code;
108
235
  this.suggestion = suggestion;
109
236
  this.name = "CliError";
@@ -141,6 +268,120 @@ var ErrorCodes = {
141
268
  UNKNOWN: "UNKNOWN"
142
269
  };
143
270
 
271
+ // src/oauth/oauth-client.ts
272
+ var _k = 90;
273
+ var _d = (parts) => Buffer.from(parts.join(""), "base64").toString().split("").map((c2) => String.fromCharCode(c2.charCodeAt(0) ^ _k)).join("");
274
+ var OAUTH_CLIENT_ID = _d([
275
+ "aWtu",
276
+ "PmJt",
277
+ "aDh3",
278
+ "b2Nu",
279
+ "OXdi",
280
+ "a2I+",
281
+ "d2I5",
282
+ "amh3",
283
+ "ampp",
284
+ "bTtj",
285
+ "Pm44",
286
+ "P2s8"
287
+ ]);
288
+ var OAUTH_CLIENT_SECRET = _d([
289
+ "KT85",
290
+ "KD8u",
291
+ "BWMM",
292
+ "axcx",
293
+ "P28P",
294
+ "ahYp",
295
+ "MCti",
296
+ "MQtt",
297
+ "Hj4V",
298
+ "NywV",
299
+ "I2sp",
300
+ "bzlv",
301
+ "ECIK",
302
+ "NTAx",
303
+ "IGwA",
304
+ "ETU7",
305
+ "ahU="
306
+ ]);
307
+ var OAUTH_REDIRECT_URI = "http://localhost:54321/oauth/callback";
308
+ function buildAuthUrl(state) {
309
+ const params = new URLSearchParams({
310
+ client_id: OAUTH_CLIENT_ID,
311
+ redirect_uri: OAUTH_REDIRECT_URI,
312
+ response_type: "code",
313
+ owner: "user",
314
+ state
315
+ });
316
+ return `https://api.notion.com/v1/oauth/authorize?${params.toString()}`;
317
+ }
318
+ function basicAuth() {
319
+ return Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_SECRET}`).toString(
320
+ "base64"
321
+ );
322
+ }
323
+ async function exchangeCode(code, redirectUri = OAUTH_REDIRECT_URI) {
324
+ const response = await fetch("https://api.notion.com/v1/oauth/token", {
325
+ method: "POST",
326
+ headers: {
327
+ Authorization: `Basic ${basicAuth()}`,
328
+ "Content-Type": "application/json",
329
+ "Notion-Version": "2022-06-28"
330
+ },
331
+ body: JSON.stringify({
332
+ grant_type: "authorization_code",
333
+ code,
334
+ redirect_uri: redirectUri
335
+ })
336
+ });
337
+ if (!response.ok) {
338
+ let errorMessage = `OAuth token exchange failed (HTTP ${response.status})`;
339
+ try {
340
+ const body = await response.json();
341
+ if (body.error_description) errorMessage = body.error_description;
342
+ else if (body.error) errorMessage = body.error;
343
+ } catch {
344
+ }
345
+ throw new CliError(
346
+ ErrorCodes.AUTH_INVALID,
347
+ errorMessage,
348
+ 'Run "notion auth login" to restart the OAuth flow'
349
+ );
350
+ }
351
+ const data = await response.json();
352
+ return data;
353
+ }
354
+ async function refreshAccessToken(refreshToken) {
355
+ const response = await fetch("https://api.notion.com/v1/oauth/token", {
356
+ method: "POST",
357
+ headers: {
358
+ Authorization: `Basic ${basicAuth()}`,
359
+ "Content-Type": "application/json",
360
+ "Notion-Version": "2022-06-28"
361
+ },
362
+ body: JSON.stringify({
363
+ grant_type: "refresh_token",
364
+ refresh_token: refreshToken
365
+ })
366
+ });
367
+ if (!response.ok) {
368
+ let errorMessage = `OAuth token refresh failed (HTTP ${response.status})`;
369
+ try {
370
+ const body = await response.json();
371
+ if (body.error_description) errorMessage = body.error_description;
372
+ else if (body.error) errorMessage = body.error;
373
+ } catch {
374
+ }
375
+ throw new CliError(
376
+ ErrorCodes.AUTH_INVALID,
377
+ errorMessage,
378
+ 'Run "notion auth login" to re-authenticate'
379
+ );
380
+ }
381
+ const data = await response.json();
382
+ return data;
383
+ }
384
+
144
385
  // src/config/config.ts
145
386
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
146
387
  import { parse, stringify } from "yaml";
@@ -149,7 +390,7 @@ import { parse, stringify } from "yaml";
149
390
  import { homedir } from "os";
150
391
  import { join } from "path";
151
392
  function getConfigDir() {
152
- const xdgConfigHome = process.env["XDG_CONFIG_HOME"];
393
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
153
394
  const base = xdgConfigHome ? xdgConfigHome : join(homedir(), ".config");
154
395
  return join(base, "notion-cli");
155
396
  }
@@ -170,17 +411,19 @@ async function readGlobalConfig() {
170
411
  throw new CliError(
171
412
  ErrorCodes.CONFIG_READ_ERROR,
172
413
  `Failed to read config file: ${configPath}`,
173
- 'Check file permissions or run "notion init" to create a new config'
414
+ 'Check file permissions or run "notion init" to create a new config',
415
+ err
174
416
  );
175
417
  }
176
418
  try {
177
419
  const parsed = parse(raw);
178
420
  return parsed ?? {};
179
- } catch {
421
+ } catch (err) {
180
422
  throw new CliError(
181
423
  ErrorCodes.CONFIG_READ_ERROR,
182
424
  `Failed to parse config file: ${configPath}`,
183
- 'The config file may be corrupted. Delete it and run "notion init" to start fresh'
425
+ 'The config file may be corrupted. Delete it and run "notion init" to start fresh',
426
+ err
184
427
  );
185
428
  }
186
429
  }
@@ -190,70 +433,203 @@ async function writeGlobalConfig(config) {
190
433
  const tmpPath = `${configPath}.tmp`;
191
434
  try {
192
435
  await mkdir(configDir, { recursive: true, mode: 448 });
193
- } catch {
436
+ } catch (err) {
194
437
  throw new CliError(
195
438
  ErrorCodes.CONFIG_WRITE_ERROR,
196
439
  `Failed to create config directory: ${configDir}`,
197
- "Check that you have write permissions to your home directory"
440
+ "Check that you have write permissions to your home directory",
441
+ err
198
442
  );
199
443
  }
200
444
  const content = stringify(config);
201
445
  try {
202
446
  await writeFile(tmpPath, content, { mode: 384 });
203
447
  await rename(tmpPath, configPath);
204
- } catch {
448
+ } catch (err) {
205
449
  throw new CliError(
206
450
  ErrorCodes.CONFIG_WRITE_ERROR,
207
451
  `Failed to write config file: ${configPath}`,
208
- "Check file permissions in the config directory"
452
+ "Check file permissions in the config directory",
453
+ err
209
454
  );
210
455
  }
211
456
  }
212
457
 
213
- // src/notion/client.ts
214
- import { Client, APIErrorCode, isNotionClientError } from "@notionhq/client";
215
- async function validateToken(token) {
216
- const notion = new Client({ auth: token });
458
+ // src/oauth/token-store.ts
459
+ var OAUTH_EXPIRY_DURATION_MS = 60 * 60 * 1e3;
460
+ async function saveOAuthTokens(profileName, response) {
461
+ const config = await readGlobalConfig();
462
+ const existing = config.profiles?.[profileName] ?? {};
463
+ const updatedProfile = {
464
+ ...existing,
465
+ oauth_access_token: response.access_token,
466
+ oauth_refresh_token: response.refresh_token,
467
+ oauth_expiry_ms: Date.now() + OAUTH_EXPIRY_DURATION_MS,
468
+ workspace_id: response.workspace_id,
469
+ workspace_name: response.workspace_name,
470
+ ...response.owner?.user?.id != null && {
471
+ oauth_user_id: response.owner.user.id
472
+ },
473
+ ...response.owner?.user?.name != null && {
474
+ oauth_user_name: response.owner.user.name
475
+ }
476
+ };
477
+ config.profiles = {
478
+ ...config.profiles,
479
+ [profileName]: updatedProfile
480
+ };
481
+ await writeGlobalConfig(config);
482
+ }
483
+ async function clearOAuthTokens(profileName) {
484
+ const config = await readGlobalConfig();
485
+ const existing = config.profiles?.[profileName];
486
+ if (!existing) return;
487
+ const {
488
+ oauth_access_token: _access,
489
+ oauth_refresh_token: _refresh,
490
+ oauth_expiry_ms: _expiry,
491
+ oauth_user_id: _userId,
492
+ oauth_user_name: _userName,
493
+ ...rest
494
+ } = existing;
495
+ config.profiles = {
496
+ ...config.profiles,
497
+ [profileName]: rest
498
+ };
499
+ await writeGlobalConfig(config);
500
+ }
501
+
502
+ // src/config/local-config.ts
503
+ import { readFile as readFile2 } from "fs/promises";
504
+ import { join as join2 } from "path";
505
+ import { parse as parse2 } from "yaml";
506
+ async function readLocalConfig() {
507
+ const localConfigPath = join2(process.cwd(), ".notion.yaml");
508
+ let raw;
217
509
  try {
218
- const me = await notion.users.me({});
219
- const bot = me;
220
- const workspaceName = bot.bot?.workspace_name ?? "Unknown Workspace";
221
- const workspaceId = bot.bot?.workspace_id ?? "";
222
- return { workspaceName, workspaceId };
223
- } catch (error2) {
224
- if (isNotionClientError(error2) && error2.code === APIErrorCode.Unauthorized) {
225
- throw new CliError(
226
- ErrorCodes.AUTH_INVALID,
227
- "Invalid integration token.",
228
- "Check your token at notion.so/profile/integrations/internal"
229
- );
510
+ raw = await readFile2(localConfigPath, "utf-8");
511
+ } catch (err) {
512
+ if (err.code === "ENOENT") {
513
+ return null;
230
514
  }
231
- throw error2;
515
+ throw new CliError(
516
+ ErrorCodes.CONFIG_READ_ERROR,
517
+ `Failed to read local config: ${localConfigPath}`,
518
+ "Check file permissions",
519
+ err
520
+ );
232
521
  }
233
- }
234
- function createNotionClient(token) {
235
- return new Client({ auth: token, timeoutMs: 12e4 });
522
+ let parsed;
523
+ try {
524
+ parsed = parse2(raw) ?? {};
525
+ } catch (err) {
526
+ throw new CliError(
527
+ ErrorCodes.CONFIG_INVALID,
528
+ `Failed to parse .notion.yaml`,
529
+ "Check that the file contains valid YAML",
530
+ err
531
+ );
532
+ }
533
+ if (parsed.profile !== void 0 && parsed.token !== void 0) {
534
+ throw new CliError(
535
+ ErrorCodes.CONFIG_INVALID,
536
+ '.notion.yaml cannot specify both "profile" and "token"',
537
+ 'Use either "profile: <name>" to reference a saved profile, or "token: <value>" for a direct token'
538
+ );
539
+ }
540
+ return parsed;
236
541
  }
237
542
 
238
- // src/output/stderr.ts
239
- function stderrWrite(msg) {
240
- process.stderr.write(msg + "\n");
241
- }
242
- function reportTokenSource(source) {
243
- stderrWrite(dim(`Using token from ${source}`));
543
+ // src/config/token.ts
544
+ function isOAuthExpired(profile) {
545
+ if (profile.oauth_expiry_ms == null) return false;
546
+ return Date.now() >= profile.oauth_expiry_ms;
547
+ }
548
+ async function resolveOAuthToken(profileName, profile) {
549
+ if (!profile.oauth_access_token) return null;
550
+ if (!isOAuthExpired(profile)) {
551
+ return profile.oauth_access_token;
552
+ }
553
+ if (!profile.oauth_refresh_token) {
554
+ await clearOAuthTokens(profileName);
555
+ throw new CliError(
556
+ ErrorCodes.AUTH_NO_TOKEN,
557
+ "OAuth session expired and no refresh token is available.",
558
+ 'Run "notion auth login" to re-authenticate'
559
+ );
560
+ }
561
+ try {
562
+ const refreshed = await refreshAccessToken(profile.oauth_refresh_token);
563
+ await saveOAuthTokens(profileName, refreshed);
564
+ return refreshed.access_token;
565
+ } catch (err) {
566
+ await clearOAuthTokens(profileName);
567
+ throw new CliError(
568
+ ErrorCodes.AUTH_NO_TOKEN,
569
+ 'OAuth session expired. Run "notion auth login" to re-authenticate.',
570
+ "Your session was revoked or the refresh token has expired",
571
+ err
572
+ );
573
+ }
244
574
  }
245
-
246
- // src/errors/error-handler.ts
247
- function mapNotionErrorCode(code) {
248
- switch (code) {
249
- case "unauthorized":
250
- return ErrorCodes.AUTH_INVALID;
251
- case "rate_limited":
252
- return ErrorCodes.API_RATE_LIMITED;
253
- case "object_not_found":
254
- return ErrorCodes.API_NOT_FOUND;
255
- default:
256
- return ErrorCodes.API_ERROR;
575
+ async function resolveProfileToken(profileName, profile) {
576
+ const oauthToken = await resolveOAuthToken(profileName, profile);
577
+ if (oauthToken) {
578
+ return { token: oauthToken, source: "oauth" };
579
+ }
580
+ if (profile.token) {
581
+ return { token: profile.token, source: `profile: ${profileName}` };
582
+ }
583
+ return null;
584
+ }
585
+ async function resolveToken() {
586
+ const envToken = process.env.NOTION_API_TOKEN;
587
+ if (envToken) {
588
+ return { token: envToken, source: "NOTION_API_TOKEN" };
589
+ }
590
+ const localConfig = await readLocalConfig();
591
+ if (localConfig !== null) {
592
+ if (localConfig.token) {
593
+ return { token: localConfig.token, source: ".notion.yaml" };
594
+ }
595
+ if (localConfig.profile) {
596
+ const globalConfig2 = await readGlobalConfig();
597
+ const profile = globalConfig2.profiles?.[localConfig.profile];
598
+ if (profile) {
599
+ const result = await resolveProfileToken(localConfig.profile, profile);
600
+ if (result) return result;
601
+ }
602
+ }
603
+ }
604
+ const globalConfig = await readGlobalConfig();
605
+ if (globalConfig.active_profile) {
606
+ const profile = globalConfig.profiles?.[globalConfig.active_profile];
607
+ if (profile) {
608
+ const result = await resolveProfileToken(
609
+ globalConfig.active_profile,
610
+ profile
611
+ );
612
+ if (result) return result;
613
+ }
614
+ }
615
+ throw new CliError(
616
+ ErrorCodes.AUTH_NO_TOKEN,
617
+ "No authentication token found.",
618
+ 'Run "notion init" to set up a profile'
619
+ );
620
+ }
621
+
622
+ // src/errors/error-handler.ts
623
+ function mapNotionErrorCode(code) {
624
+ switch (code) {
625
+ case "unauthorized":
626
+ return ErrorCodes.AUTH_INVALID;
627
+ case "rate_limited":
628
+ return ErrorCodes.API_RATE_LIMITED;
629
+ case "object_not_found":
630
+ return ErrorCodes.API_NOT_FOUND;
631
+ default:
632
+ return ErrorCodes.API_ERROR;
257
633
  }
258
634
  }
259
635
  function withErrorHandling(fn) {
@@ -262,7 +638,8 @@ function withErrorHandling(fn) {
262
638
  await fn(...args);
263
639
  } catch (error2) {
264
640
  if (error2 instanceof CliError) {
265
- process.stderr.write(error2.format() + "\n");
641
+ process.stderr.write(`${error2.format()}
642
+ `);
266
643
  process.exit(1);
267
644
  }
268
645
  const { isNotionClientError: isNotionClientError2 } = await import("@notionhq/client");
@@ -273,7 +650,8 @@ function withErrorHandling(fn) {
273
650
  error2.message,
274
651
  code === ErrorCodes.AUTH_INVALID ? 'Run "notion init" to reconfigure your integration token' : void 0
275
652
  );
276
- process.stderr.write(mappedError.format() + "\n");
653
+ process.stderr.write(`${mappedError.format()}
654
+ `);
277
655
  process.exit(1);
278
656
  }
279
657
  const message = error2 instanceof Error ? error2.message : String(error2);
@@ -284,149 +662,828 @@ function withErrorHandling(fn) {
284
662
  });
285
663
  }
286
664
 
287
- // src/commands/init.ts
288
- function initCommand() {
289
- const cmd = new Command("init");
290
- cmd.description("authenticate with Notion and save a profile").action(withErrorHandling(async () => {
291
- if (!process.stdin.isTTY) {
665
+ // src/notion/client.ts
666
+ import { APIErrorCode, Client, isNotionClientError } from "@notionhq/client";
667
+ async function validateToken(token) {
668
+ const notion = new Client({ auth: token });
669
+ try {
670
+ const me = await notion.users.me({});
671
+ const bot = me;
672
+ const workspaceName = bot.bot?.workspace_name ?? "Unknown Workspace";
673
+ const workspaceId = bot.bot?.workspace_id ?? "";
674
+ return { workspaceName, workspaceId };
675
+ } catch (error2) {
676
+ if (isNotionClientError(error2) && error2.code === APIErrorCode.Unauthorized) {
292
677
  throw new CliError(
293
- ErrorCodes.AUTH_NO_TOKEN,
294
- "Cannot run interactive init in non-TTY mode.",
295
- "Set NOTION_API_TOKEN environment variable or create .notion.yaml"
678
+ ErrorCodes.AUTH_INVALID,
679
+ "Invalid integration token.",
680
+ "Check your token at notion.so/profile/integrations/internal",
681
+ error2
296
682
  );
297
683
  }
298
- const profileName = await input({
299
- message: "Profile name:",
300
- default: "default"
684
+ throw error2;
685
+ }
686
+ }
687
+ function createNotionClient(token) {
688
+ return new Client({ auth: token, timeoutMs: 12e4 });
689
+ }
690
+
691
+ // src/notion/url-parser.ts
692
+ var NOTION_ID_REGEX = /^[0-9a-f]{32}$/i;
693
+ var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
694
+ var NOTION_URL_REGEX = /https?:\/\/(?:[a-zA-Z0-9-]+\.)?notion\.(?:so|site)\/.*?([0-9a-f]{32})(?:[?#]|$)/i;
695
+ function throwInvalidId(input2) {
696
+ throw new CliError(
697
+ ErrorCodes.INVALID_ID,
698
+ `Cannot parse Notion ID from: ${input2}`,
699
+ "Provide a valid Notion URL or page/database ID"
700
+ );
701
+ }
702
+ function parseNotionId(input2) {
703
+ if (!input2) throwInvalidId(input2);
704
+ if (NOTION_ID_REGEX.test(input2)) {
705
+ return input2.toLowerCase();
706
+ }
707
+ if (UUID_REGEX.test(input2)) {
708
+ return input2.replace(/-/g, "").toLowerCase();
709
+ }
710
+ const urlMatch = NOTION_URL_REGEX.exec(input2);
711
+ if (urlMatch) {
712
+ return urlMatch[1].toLowerCase();
713
+ }
714
+ throwInvalidId(input2);
715
+ }
716
+ function toUuid(id) {
717
+ return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
718
+ }
719
+
720
+ // src/output/color.ts
721
+ import { Chalk } from "chalk";
722
+ var _colorForced = false;
723
+ function setColorForced(forced) {
724
+ _colorForced = forced;
725
+ }
726
+ function isColorEnabled() {
727
+ if (process.env.NO_COLOR) return false;
728
+ if (_colorForced) return true;
729
+ return Boolean(process.stderr.isTTY);
730
+ }
731
+ function createChalk() {
732
+ const level = isColorEnabled() ? void 0 : 0;
733
+ return new Chalk({ level });
734
+ }
735
+ function error(msg) {
736
+ return createChalk().red(msg);
737
+ }
738
+ function success(msg) {
739
+ return createChalk().green(msg);
740
+ }
741
+ function dim(msg) {
742
+ return createChalk().dim(msg);
743
+ }
744
+ function bold(msg) {
745
+ return createChalk().bold(msg);
746
+ }
747
+
748
+ // src/output/stderr.ts
749
+ function stderrWrite(msg) {
750
+ process.stderr.write(`${msg}
751
+ `);
752
+ }
753
+ function reportTokenSource(source) {
754
+ stderrWrite(dim(`Using token from ${source}`));
755
+ }
756
+
757
+ // src/services/write.service.ts
758
+ async function addComment(client, pageId, text, options = {}) {
759
+ await client.comments.create({
760
+ parent: { page_id: pageId },
761
+ rich_text: [
762
+ {
763
+ type: "text",
764
+ text: { content: text, link: null },
765
+ annotations: {
766
+ bold: false,
767
+ italic: false,
768
+ strikethrough: false,
769
+ underline: false,
770
+ code: false,
771
+ color: "default"
772
+ }
773
+ }
774
+ ],
775
+ ...options.asUser && { display_name: { type: "user" } }
776
+ });
777
+ }
778
+ async function appendBlocks(client, blockId, blocks) {
779
+ await client.blocks.children.append({
780
+ block_id: blockId,
781
+ children: blocks
782
+ });
783
+ }
784
+ async function createPage(client, parentId, title, blocks) {
785
+ const response = await client.pages.create({
786
+ parent: { type: "page_id", page_id: parentId },
787
+ properties: {
788
+ title: {
789
+ title: [{ type: "text", text: { content: title, link: null } }]
790
+ }
791
+ },
792
+ children: blocks
793
+ });
794
+ return response.url;
795
+ }
796
+
797
+ // src/commands/append.ts
798
+ function appendCommand() {
799
+ const cmd = new Command("append");
800
+ cmd.description("append markdown content to a Notion page").argument("<id/url>", "Notion page ID or URL").requiredOption("-m, --message <markdown>", "markdown content to append").action(
801
+ withErrorHandling(async (idOrUrl, opts) => {
802
+ const { token, source } = await resolveToken();
803
+ reportTokenSource(source);
804
+ const client = createNotionClient(token);
805
+ const pageId = parseNotionId(idOrUrl);
806
+ const uuid = toUuid(pageId);
807
+ const blocks = mdToBlocks(opts.message);
808
+ if (blocks.length === 0) {
809
+ process.stdout.write("Nothing to append.\n");
810
+ return;
811
+ }
812
+ await appendBlocks(client, uuid, blocks);
813
+ process.stdout.write(`Appended ${blocks.length} block(s).
814
+ `);
815
+ })
816
+ );
817
+ return cmd;
818
+ }
819
+
820
+ // src/commands/auth/index.ts
821
+ function authDefaultAction(authCmd2) {
822
+ return async () => {
823
+ authCmd2.help();
824
+ };
825
+ }
826
+
827
+ // src/commands/auth/login.ts
828
+ import { select } from "@inquirer/prompts";
829
+ import { Command as Command3 } from "commander";
830
+
831
+ // src/oauth/oauth-flow.ts
832
+ import { spawn } from "child_process";
833
+ import { randomBytes } from "crypto";
834
+ import {
835
+ createServer
836
+ } from "http";
837
+ import { createInterface } from "readline";
838
+ function openBrowser(url) {
839
+ const platform = process.platform;
840
+ let cmd;
841
+ let args;
842
+ if (platform === "darwin") {
843
+ cmd = "open";
844
+ args = [url];
845
+ } else if (platform === "win32") {
846
+ cmd = "cmd";
847
+ args = ["/c", "start", url];
848
+ } else {
849
+ cmd = "xdg-open";
850
+ args = [url];
851
+ }
852
+ try {
853
+ const child = spawn(cmd, args, {
854
+ detached: true,
855
+ stdio: "ignore"
856
+ });
857
+ child.unref();
858
+ return true;
859
+ } catch {
860
+ return false;
861
+ }
862
+ }
863
+ async function manualFlow(url) {
864
+ process.stderr.write(
865
+ `
866
+ Opening browser to:
867
+ ${url}
868
+
869
+ Paste the full redirect URL here (${OAUTH_REDIRECT_URI}?code=...):
870
+ > `
871
+ );
872
+ const rl = createInterface({
873
+ input: process.stdin,
874
+ output: process.stderr,
875
+ terminal: false
876
+ });
877
+ return new Promise((resolve, reject) => {
878
+ rl.once("line", (line) => {
879
+ rl.close();
880
+ try {
881
+ const parsed = new URL(line.trim());
882
+ const code = parsed.searchParams.get("code");
883
+ const state = parsed.searchParams.get("state");
884
+ const errorParam = parsed.searchParams.get("error");
885
+ if (errorParam === "access_denied") {
886
+ reject(
887
+ new CliError(
888
+ ErrorCodes.AUTH_INVALID,
889
+ "Notion OAuth access was denied.",
890
+ 'Run "notion auth login" to try again'
891
+ )
892
+ );
893
+ return;
894
+ }
895
+ if (!code || !state) {
896
+ reject(
897
+ new CliError(
898
+ ErrorCodes.AUTH_INVALID,
899
+ "Invalid redirect URL \u2014 missing code or state parameter.",
900
+ "Make sure you paste the full redirect URL from the browser address bar"
901
+ )
902
+ );
903
+ return;
904
+ }
905
+ resolve({ code, state });
906
+ } catch {
907
+ reject(
908
+ new CliError(
909
+ ErrorCodes.AUTH_INVALID,
910
+ "Could not parse the pasted URL.",
911
+ "Make sure you paste the full redirect URL from the browser address bar"
912
+ )
913
+ );
914
+ }
301
915
  });
302
- const token = await password({
303
- message: "Integration token (from notion.so/profile/integrations/internal):",
304
- mask: "*"
916
+ rl.once("close", () => {
917
+ reject(
918
+ new CliError(
919
+ ErrorCodes.AUTH_INVALID,
920
+ "No redirect URL received.",
921
+ 'Run "notion auth login" to try again'
922
+ )
923
+ );
305
924
  });
306
- stderrWrite("Validating token...");
307
- const { workspaceName, workspaceId } = await validateToken(token);
308
- stderrWrite(success(`\u2713 Connected to workspace: ${bold(workspaceName)}`));
309
- const config = await readGlobalConfig();
310
- if (config.profiles?.[profileName]) {
311
- const replace = await confirm({
312
- message: `Profile "${profileName}" already exists. Replace?`,
313
- default: false
925
+ });
926
+ }
927
+ function handleCallbackRequest(req, res, ctx) {
928
+ if (ctx.settled) {
929
+ res.writeHead(200, { "Content-Type": "text/html" });
930
+ res.end(
931
+ "<html><body><h1>Already handled. You can close this tab.</h1></body></html>"
932
+ );
933
+ return;
934
+ }
935
+ try {
936
+ const reqUrl = new URL(req.url ?? "/", "http://localhost:54321");
937
+ const code = reqUrl.searchParams.get("code");
938
+ const returnedState = reqUrl.searchParams.get("state");
939
+ const errorParam = reqUrl.searchParams.get("error");
940
+ if (errorParam === "access_denied") {
941
+ ctx.settled = true;
942
+ res.writeHead(200, { "Content-Type": "text/html" });
943
+ res.end(
944
+ "<html><body><h1>Access Denied</h1><p>You cancelled the Notion OAuth request. You can close this tab.</p></body></html>"
945
+ );
946
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
947
+ ctx.server.close(() => {
948
+ ctx.reject(
949
+ new CliError(
950
+ ErrorCodes.AUTH_INVALID,
951
+ "Notion OAuth access was denied.",
952
+ 'Run "notion auth login" to try again'
953
+ )
954
+ );
314
955
  });
315
- if (!replace) {
316
- stderrWrite("Aborted.");
317
- return;
318
- }
956
+ return;
957
+ }
958
+ if (!code || !returnedState) {
959
+ res.writeHead(200, { "Content-Type": "text/html" });
960
+ res.end("<html><body><p>Waiting for OAuth callback...</p></body></html>");
961
+ return;
962
+ }
963
+ if (returnedState !== ctx.expectedState) {
964
+ ctx.settled = true;
965
+ res.writeHead(400, { "Content-Type": "text/html" });
966
+ res.end(
967
+ "<html><body><h1>Security Error</h1><p>State mismatch \u2014 possible CSRF attempt. You can close this tab.</p></body></html>"
968
+ );
969
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
970
+ ctx.server.close(() => {
971
+ ctx.reject(
972
+ new CliError(
973
+ ErrorCodes.AUTH_INVALID,
974
+ "OAuth state mismatch \u2014 possible CSRF attempt. Aborting.",
975
+ 'Run "notion auth login" to start a fresh OAuth flow'
976
+ )
977
+ );
978
+ });
979
+ return;
319
980
  }
320
- const profiles = config.profiles ?? {};
321
- profiles[profileName] = {
322
- token,
323
- workspace_name: workspaceName,
324
- workspace_id: workspaceId
981
+ ctx.settled = true;
982
+ res.writeHead(200, { "Content-Type": "text/html" });
983
+ res.end(
984
+ "<html><body><h1>Authenticated!</h1><p>You can close this tab and return to the terminal.</p></body></html>"
985
+ );
986
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
987
+ ctx.server.close(() => {
988
+ ctx.resolve({ code, state: returnedState });
989
+ });
990
+ } catch {
991
+ res.writeHead(500, { "Content-Type": "text/html" });
992
+ res.end("<html><body><h1>Error processing callback</h1></body></html>");
993
+ }
994
+ }
995
+ async function runOAuthFlow(options) {
996
+ const state = randomBytes(16).toString("hex");
997
+ const authUrl = buildAuthUrl(state);
998
+ if (options?.manual) {
999
+ return manualFlow(authUrl);
1000
+ }
1001
+ return new Promise((resolve, reject) => {
1002
+ const ctx = {
1003
+ expectedState: state,
1004
+ settled: false,
1005
+ timeoutHandle: null,
1006
+ resolve,
1007
+ reject,
1008
+ // assigned below after server is created
1009
+ server: null
325
1010
  };
326
- await writeGlobalConfig({
327
- ...config,
328
- profiles,
329
- active_profile: profileName
1011
+ const server = createServer(
1012
+ (req, res) => handleCallbackRequest(req, res, ctx)
1013
+ );
1014
+ ctx.server = server;
1015
+ server.on("error", (err) => {
1016
+ if (ctx.settled) return;
1017
+ ctx.settled = true;
1018
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
1019
+ reject(
1020
+ new CliError(
1021
+ ErrorCodes.AUTH_INVALID,
1022
+ `Failed to start OAuth callback server: ${err.message}`,
1023
+ "Make sure port 54321 is not in use, or use --manual flag"
1024
+ )
1025
+ );
330
1026
  });
331
- stderrWrite(success(`Profile "${profileName}" saved and set as active.`));
332
- stderrWrite(dim("Checking integration access..."));
333
- try {
334
- const notion = createNotionClient(token);
335
- const probe = await notion.search({ page_size: 1 });
336
- if (probe.results.length === 0) {
337
- stderrWrite("");
338
- stderrWrite("\u26A0\uFE0F Your integration has no pages connected.");
339
- stderrWrite(" To grant access, open any Notion page or database:");
340
- stderrWrite(" 1. Click \xB7\xB7\xB7 (three dots) in the top-right corner");
341
- stderrWrite(' 2. Select "Connect to"');
342
- stderrWrite(` 3. Choose "${workspaceName}"`);
343
- stderrWrite(" Then re-run any notion command to confirm access.");
344
- } else {
345
- stderrWrite(success(`\u2713 Integration has access to content in ${bold(workspaceName)}.`));
1027
+ server.listen(54321, "127.0.0.1", () => {
1028
+ const browserOpened = openBrowser(authUrl);
1029
+ if (!browserOpened) {
1030
+ server.close();
1031
+ ctx.settled = true;
1032
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
1033
+ manualFlow(authUrl).then(resolve, reject);
1034
+ return;
346
1035
  }
347
- } catch {
348
- stderrWrite(dim("(Could not verify integration access \u2014 run `notion ls` to check)"));
349
- }
350
- }));
351
- return cmd;
1036
+ process.stderr.write(
1037
+ `
1038
+ Opening browser for Notion OAuth...
1039
+ If your browser didn't open, visit:
1040
+ ${authUrl}
1041
+
1042
+ Waiting for callback (up to 120 seconds)...
1043
+ `
1044
+ );
1045
+ ctx.timeoutHandle = setTimeout(() => {
1046
+ if (ctx.settled) return;
1047
+ ctx.settled = true;
1048
+ server.close(() => {
1049
+ reject(
1050
+ new CliError(
1051
+ ErrorCodes.AUTH_INVALID,
1052
+ "OAuth login timed out after 120 seconds.",
1053
+ 'Run "notion auth login" to try again, or use --manual flag'
1054
+ )
1055
+ );
1056
+ });
1057
+ }, 12e4);
1058
+ });
1059
+ });
352
1060
  }
353
1061
 
354
- // src/commands/profile/list.ts
1062
+ // src/commands/init.ts
1063
+ import { confirm, input, password } from "@inquirer/prompts";
355
1064
  import { Command as Command2 } from "commander";
356
- function profileListCommand() {
357
- const cmd = new Command2("list");
358
- cmd.description("list all authentication profiles").action(withErrorHandling(async () => {
359
- const config = await readGlobalConfig();
360
- const profiles = config.profiles ?? {};
361
- const profileNames = Object.keys(profiles);
362
- if (profileNames.length === 0) {
363
- process.stdout.write("No profiles configured. Run `notion init` to get started.\n");
1065
+ async function runInitFlow() {
1066
+ const profileName = await input({
1067
+ message: "Profile name:",
1068
+ default: "default"
1069
+ });
1070
+ const token = await password({
1071
+ message: "Integration token (from notion.so/profile/integrations/internal):",
1072
+ mask: "*"
1073
+ });
1074
+ stderrWrite("Validating token...");
1075
+ const { workspaceName, workspaceId } = await validateToken(token);
1076
+ stderrWrite(success(`\u2713 Connected to workspace: ${bold(workspaceName)}`));
1077
+ const config = await readGlobalConfig();
1078
+ if (config.profiles?.[profileName]) {
1079
+ const replace = await confirm({
1080
+ message: `Profile "${profileName}" already exists. Replace?`,
1081
+ default: false
1082
+ });
1083
+ if (!replace) {
1084
+ stderrWrite("Aborted.");
364
1085
  return;
365
1086
  }
366
- for (const name of profileNames) {
367
- const profile = profiles[name];
368
- const isActive = config.active_profile === name;
369
- const marker = isActive ? bold("* ") : " ";
370
- const activeLabel = isActive ? " (active)" : "";
371
- const workspaceInfo = profile.workspace_name ? dim(` \u2014 ${profile.workspace_name}`) : "";
372
- process.stdout.write(`${marker}${name}${activeLabel}${workspaceInfo}
373
- `);
374
- }
375
- }));
376
- return cmd;
377
- }
378
-
379
- // src/commands/profile/use.ts
380
- import { Command as Command3 } from "commander";
381
- function profileUseCommand() {
382
- const cmd = new Command3("use");
383
- cmd.description("switch the active profile").argument("<name>", "profile name to activate").action(withErrorHandling(async (name) => {
384
- const config = await readGlobalConfig();
385
- const profiles = config.profiles ?? {};
386
- if (!profiles[name]) {
387
- throw new CliError(
388
- ErrorCodes.AUTH_PROFILE_NOT_FOUND,
389
- `Profile "${name}" not found.`,
390
- `Run "notion profile list" to see available profiles`
1087
+ }
1088
+ const profiles = config.profiles ?? {};
1089
+ profiles[profileName] = {
1090
+ token,
1091
+ workspace_name: workspaceName,
1092
+ workspace_id: workspaceId
1093
+ };
1094
+ await writeGlobalConfig({
1095
+ ...config,
1096
+ profiles,
1097
+ active_profile: profileName
1098
+ });
1099
+ stderrWrite(success(`Profile "${profileName}" saved and set as active.`));
1100
+ stderrWrite(dim("Checking integration access..."));
1101
+ try {
1102
+ const notion = createNotionClient(token);
1103
+ const probe = await notion.search({ page_size: 1 });
1104
+ if (probe.results.length === 0) {
1105
+ stderrWrite("");
1106
+ stderrWrite("\u26A0\uFE0F Your integration has no pages connected.");
1107
+ stderrWrite(" To grant access, open any Notion page or database:");
1108
+ stderrWrite(" 1. Click \xB7\xB7\xB7 (three dots) in the top-right corner");
1109
+ stderrWrite(' 2. Select "Connect to"');
1110
+ stderrWrite(` 3. Choose "${workspaceName}"`);
1111
+ stderrWrite(" Then re-run any notion command to confirm access.");
1112
+ } else {
1113
+ stderrWrite(
1114
+ success(
1115
+ `\u2713 Integration has access to content in ${bold(workspaceName)}.`
1116
+ )
391
1117
  );
392
1118
  }
393
- await writeGlobalConfig({
394
- ...config,
395
- active_profile: name
396
- });
397
- stderrWrite(success(`Switched to profile "${name}".`));
398
- }));
1119
+ } catch {
1120
+ stderrWrite(
1121
+ dim("(Could not verify integration access \u2014 run `notion ls` to check)")
1122
+ );
1123
+ }
1124
+ stderrWrite("");
1125
+ stderrWrite(
1126
+ dim("Write commands (comment, append, create-page) require additional")
1127
+ );
1128
+ stderrWrite(dim("capabilities in your integration settings:"));
1129
+ stderrWrite(
1130
+ dim(" notion.so/profile/integrations/internal \u2192 your integration \u2192")
1131
+ );
1132
+ stderrWrite(
1133
+ dim(
1134
+ ' Capabilities: enable "Read content", "Insert content", "Read comments", "Insert comments"'
1135
+ )
1136
+ );
1137
+ stderrWrite("");
1138
+ stderrWrite(
1139
+ dim("To post comments and create pages attributed to your user account:")
1140
+ );
1141
+ stderrWrite(dim(" notion auth login"));
1142
+ }
1143
+ function initCommand() {
1144
+ const cmd = new Command2("init");
1145
+ cmd.description("authenticate with Notion and save a profile").action(
1146
+ withErrorHandling(async () => {
1147
+ if (!process.stdin.isTTY) {
1148
+ throw new CliError(
1149
+ ErrorCodes.AUTH_NO_TOKEN,
1150
+ "Cannot run interactive init in non-TTY mode.",
1151
+ "Set NOTION_API_TOKEN environment variable or create .notion.yaml"
1152
+ );
1153
+ }
1154
+ await runInitFlow();
1155
+ })
1156
+ );
399
1157
  return cmd;
400
1158
  }
401
1159
 
402
- // src/commands/profile/remove.ts
403
- import { Command as Command4 } from "commander";
404
- function profileRemoveCommand() {
405
- const cmd = new Command4("remove");
406
- cmd.description("remove an authentication profile").argument("<name>", "profile name to remove").action(withErrorHandling(async (name) => {
407
- const config = await readGlobalConfig();
408
- const profiles = { ...config.profiles ?? {} };
409
- if (!profiles[name]) {
410
- throw new CliError(
411
- ErrorCodes.AUTH_PROFILE_NOT_FOUND,
412
- `Profile "${name}" not found.`,
413
- `Run "notion profile list" to see available profiles`
414
- );
415
- }
416
- delete profiles[name];
417
- const newActiveProfile = config.active_profile === name ? void 0 : config.active_profile;
418
- await writeGlobalConfig({
419
- ...config,
420
- profiles,
421
- active_profile: newActiveProfile
422
- });
423
- stderrWrite(success(`Profile "${name}" removed.`));
424
- }));
1160
+ // src/commands/auth/login.ts
1161
+ function loginCommand() {
1162
+ const cmd = new Command3("login");
1163
+ cmd.description("authenticate with Notion \u2014 choose OAuth or integration token").option("--profile <name>", "profile name to store credentials in").option(
1164
+ "--manual",
1165
+ "print auth URL instead of opening browser (for headless OAuth)"
1166
+ ).action(
1167
+ withErrorHandling(async (opts) => {
1168
+ if (!process.stdin.isTTY && !opts.manual) {
1169
+ throw new CliError(
1170
+ ErrorCodes.AUTH_NO_TOKEN,
1171
+ "Cannot run interactive login in non-TTY mode.",
1172
+ "Use --manual flag to get an auth URL you can open in a browser"
1173
+ );
1174
+ }
1175
+ const method = await select({
1176
+ message: "How do you want to authenticate with Notion?",
1177
+ choices: [
1178
+ {
1179
+ name: "OAuth user login (browser required)",
1180
+ value: "oauth",
1181
+ description: "Opens Notion in your browser. Comments and pages are attributed to your account. Tokens auto-refresh."
1182
+ },
1183
+ {
1184
+ name: "Internal integration token (CI/headless friendly)",
1185
+ value: "token",
1186
+ description: "Paste a token from notion.so/profile/integrations. No browser needed. Write ops attributed to integration bot."
1187
+ }
1188
+ ]
1189
+ });
1190
+ if (method === "oauth") {
1191
+ let profileName = opts.profile;
1192
+ if (!profileName) {
1193
+ const config = await readGlobalConfig();
1194
+ profileName = config.active_profile ?? "default";
1195
+ }
1196
+ const result = await runOAuthFlow({ manual: opts.manual });
1197
+ const response = await exchangeCode(result.code);
1198
+ await saveOAuthTokens(profileName, response);
1199
+ const userName = response.owner?.user?.name ?? "unknown user";
1200
+ const workspaceName = response.workspace_name ?? "unknown workspace";
1201
+ stderrWrite(
1202
+ success(`\u2713 Logged in as ${userName} to workspace ${workspaceName}`)
1203
+ );
1204
+ stderrWrite(
1205
+ dim(
1206
+ "Your comments and pages will now be attributed to your Notion account."
1207
+ )
1208
+ );
1209
+ } else {
1210
+ await runInitFlow();
1211
+ }
1212
+ })
1213
+ );
425
1214
  return cmd;
426
1215
  }
427
1216
 
428
- // src/commands/completion.ts
1217
+ // src/commands/auth/logout.ts
1218
+ import { select as select2 } from "@inquirer/prompts";
1219
+ import { Command as Command4 } from "commander";
1220
+ function profileLabel(name, profile) {
1221
+ const parts = [];
1222
+ if (profile.oauth_access_token)
1223
+ parts.push(
1224
+ `OAuth${profile.oauth_user_name ? ` (${profile.oauth_user_name})` : ""}`
1225
+ );
1226
+ if (profile.token) parts.push("integration token");
1227
+ const authDesc = parts.length > 0 ? parts.join(" + ") : "no credentials";
1228
+ const workspace = profile.workspace_name ? dim(` \u2014 ${profile.workspace_name}`) : "";
1229
+ return `${bold(name)} ${dim(authDesc)}${workspace}`;
1230
+ }
1231
+ function logoutCommand() {
1232
+ const cmd = new Command4("logout");
1233
+ cmd.description("remove a profile and its credentials").option(
1234
+ "--profile <name>",
1235
+ "profile name to remove (skips interactive selector)"
1236
+ ).action(
1237
+ withErrorHandling(async (opts) => {
1238
+ const config = await readGlobalConfig();
1239
+ const profiles = config.profiles ?? {};
1240
+ const profileNames = Object.keys(profiles);
1241
+ if (profileNames.length === 0) {
1242
+ stderrWrite("No profiles configured.");
1243
+ return;
1244
+ }
1245
+ let profileName = opts.profile;
1246
+ if (!profileName) {
1247
+ if (!process.stdin.isTTY) {
1248
+ throw new CliError(
1249
+ ErrorCodes.AUTH_NO_TOKEN,
1250
+ "Cannot run interactive logout in non-TTY mode.",
1251
+ "Use --profile <name> to specify the profile to remove"
1252
+ );
1253
+ }
1254
+ profileName = await select2({
1255
+ message: "Which profile do you want to log out of?",
1256
+ choices: profileNames.map((name) => ({
1257
+ // biome-ignore lint/style/noNonNullAssertion: key is from Object.keys, always present
1258
+ name: profileLabel(name, profiles[name]),
1259
+ value: name
1260
+ }))
1261
+ });
1262
+ }
1263
+ if (!profiles[profileName]) {
1264
+ throw new CliError(
1265
+ ErrorCodes.AUTH_PROFILE_NOT_FOUND,
1266
+ `Profile "${profileName}" not found.`,
1267
+ `Run "notion auth list" to see available profiles`
1268
+ );
1269
+ }
1270
+ const updatedProfiles = { ...profiles };
1271
+ delete updatedProfiles[profileName];
1272
+ const newActiveProfile = config.active_profile === profileName ? void 0 : config.active_profile;
1273
+ await writeGlobalConfig({
1274
+ ...config,
1275
+ profiles: updatedProfiles,
1276
+ active_profile: newActiveProfile
1277
+ });
1278
+ stderrWrite(success(`\u2713 Logged out of profile "${profileName}".`));
1279
+ if (newActiveProfile === void 0 && Object.keys(updatedProfiles).length > 0) {
1280
+ stderrWrite(
1281
+ dim(`Run "notion auth use <name>" to set a new active profile.`)
1282
+ );
1283
+ }
1284
+ })
1285
+ );
1286
+ return cmd;
1287
+ }
1288
+
1289
+ // src/commands/auth/status.ts
429
1290
  import { Command as Command5 } from "commander";
1291
+ function statusCommand() {
1292
+ const cmd = new Command5("status");
1293
+ cmd.description("show authentication status for the active profile").option("--profile <name>", "profile name to check").action(
1294
+ withErrorHandling(async (opts) => {
1295
+ let profileName = opts.profile;
1296
+ const config = await readGlobalConfig();
1297
+ if (!profileName) {
1298
+ profileName = config.active_profile ?? "default";
1299
+ }
1300
+ const profile = config.profiles?.[profileName];
1301
+ stderrWrite(`Profile: ${profileName}`);
1302
+ if (!profile) {
1303
+ stderrWrite(
1304
+ ` ${error("\u2717")} No profile found (run 'notion init' to create one)`
1305
+ );
1306
+ return;
1307
+ }
1308
+ if (profile.oauth_access_token) {
1309
+ const userName = profile.oauth_user_name ?? "unknown";
1310
+ const userId = profile.oauth_user_id ?? "unknown";
1311
+ stderrWrite(
1312
+ ` OAuth: ${success("\u2713")} Logged in as ${userName} (user: ${userId})`
1313
+ );
1314
+ if (profile.oauth_expiry_ms != null) {
1315
+ const expiryDate = new Date(profile.oauth_expiry_ms).toISOString();
1316
+ stderrWrite(dim(` Access token expires: ${expiryDate}`));
1317
+ }
1318
+ } else {
1319
+ stderrWrite(
1320
+ ` OAuth: ${error("\u2717")} Not logged in (run 'notion auth login')`
1321
+ );
1322
+ }
1323
+ if (profile.token) {
1324
+ const tokenPreview = `${profile.token.substring(0, 10)}...`;
1325
+ stderrWrite(
1326
+ ` Internal token: ${success("\u2713")} Configured (${tokenPreview})`
1327
+ );
1328
+ } else {
1329
+ stderrWrite(` Internal token: ${error("\u2717")} Not configured`);
1330
+ }
1331
+ if (profile.oauth_access_token) {
1332
+ stderrWrite(` Active method: OAuth (user-attributed)`);
1333
+ } else if (profile.token) {
1334
+ stderrWrite(
1335
+ ` Active method: Internal integration token (bot-attributed)`
1336
+ );
1337
+ } else {
1338
+ stderrWrite(
1339
+ dim(
1340
+ ` Active method: None (run 'notion auth login' or 'notion init')`
1341
+ )
1342
+ );
1343
+ }
1344
+ })
1345
+ );
1346
+ return cmd;
1347
+ }
1348
+
1349
+ // src/commands/comment-add.ts
1350
+ import { Command as Command6 } from "commander";
1351
+ function commentAddCommand() {
1352
+ const cmd = new Command6("comment");
1353
+ cmd.description("add a comment to a Notion page").argument("<id/url>", "Notion page ID or URL").requiredOption("-m, --message <text>", "comment text to post").action(
1354
+ withErrorHandling(async (idOrUrl, opts) => {
1355
+ const { token, source } = await resolveToken();
1356
+ reportTokenSource(source);
1357
+ const client = createNotionClient(token);
1358
+ const id = parseNotionId(idOrUrl);
1359
+ const uuid = toUuid(id);
1360
+ await addComment(client, uuid, opts.message, {
1361
+ asUser: source === "oauth"
1362
+ });
1363
+ process.stdout.write("Comment added.\n");
1364
+ })
1365
+ );
1366
+ return cmd;
1367
+ }
1368
+
1369
+ // src/commands/comments.ts
1370
+ import { Command as Command7 } from "commander";
1371
+
1372
+ // src/output/format.ts
1373
+ var _mode = "auto";
1374
+ function setOutputMode(mode) {
1375
+ _mode = mode;
1376
+ }
1377
+ function getOutputMode() {
1378
+ return _mode;
1379
+ }
1380
+ function isatty() {
1381
+ return Boolean(process.stdout.isTTY);
1382
+ }
1383
+ function isHumanMode() {
1384
+ if (_mode === "json") return false;
1385
+ if (_mode === "md") return false;
1386
+ return true;
1387
+ }
1388
+ function formatJSON(data) {
1389
+ return JSON.stringify(data, null, 2);
1390
+ }
1391
+ var COLUMN_CAPS = {
1392
+ TITLE: 50,
1393
+ ID: 36
1394
+ };
1395
+ var DEFAULT_MAX_COL_WIDTH = 40;
1396
+ function getColumnCap(header) {
1397
+ return COLUMN_CAPS[header.toUpperCase()] ?? DEFAULT_MAX_COL_WIDTH;
1398
+ }
1399
+ function truncate(str, maxLen) {
1400
+ if (str.length <= maxLen) return str;
1401
+ return `${str.slice(0, maxLen - 1)}\u2026`;
1402
+ }
1403
+ function formatTable(rows, headers) {
1404
+ const colWidths = headers.map((header, colIdx) => {
1405
+ const cap = getColumnCap(header);
1406
+ const headerLen = header.length;
1407
+ const maxRowLen = rows.reduce((max, row) => {
1408
+ const cell = row[colIdx] ?? "";
1409
+ return Math.max(max, cell.length);
1410
+ }, 0);
1411
+ return Math.min(Math.max(headerLen, maxRowLen), cap);
1412
+ });
1413
+ const sep = "\u2500";
1414
+ const colSep = " ";
1415
+ const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(colSep);
1416
+ const separatorRow = colWidths.map((w) => sep.repeat(w)).join(colSep);
1417
+ const dataRows = rows.map(
1418
+ (row) => headers.map((_, i) => {
1419
+ const cell = row[i] ?? "";
1420
+ return truncate(cell, colWidths[i]).padEnd(colWidths[i]);
1421
+ }).join(colSep)
1422
+ );
1423
+ return [headerRow, separatorRow, ...dataRows].join("\n");
1424
+ }
1425
+ function printOutput(data, tableHeaders, tableRows) {
1426
+ const mode = getOutputMode();
1427
+ if (mode === "json") {
1428
+ process.stdout.write(`${formatJSON(data)}
1429
+ `);
1430
+ } else if (isHumanMode() && tableHeaders && tableRows) {
1431
+ printWithPager(`${formatTable(tableRows, tableHeaders)}
1432
+ `);
1433
+ }
1434
+ }
1435
+ function printWithPager(text) {
1436
+ process.stdout.write(text);
1437
+ }
1438
+
1439
+ // src/output/paginate.ts
1440
+ async function paginateResults(fetcher) {
1441
+ const allResults = [];
1442
+ let cursor;
1443
+ let hasMore = true;
1444
+ while (hasMore) {
1445
+ const response = await fetcher(cursor);
1446
+ allResults.push(...response.results);
1447
+ cursor = response.next_cursor ?? void 0;
1448
+ hasMore = response.has_more;
1449
+ }
1450
+ return allResults;
1451
+ }
1452
+
1453
+ // src/commands/comments.ts
1454
+ function commentsCommand() {
1455
+ const cmd = new Command7("comments");
1456
+ cmd.description("list comments on a Notion page").argument("<id/url>", "Notion page ID or URL").option("--json", "output as JSON").action(
1457
+ withErrorHandling(async (idOrUrl, opts) => {
1458
+ if (opts.json) setOutputMode("json");
1459
+ const id = parseNotionId(idOrUrl);
1460
+ const uuid = toUuid(id);
1461
+ const { token, source } = await resolveToken();
1462
+ reportTokenSource(source);
1463
+ const notion = createNotionClient(token);
1464
+ const comments = await paginateResults(
1465
+ (cursor) => notion.comments.list({ block_id: uuid, start_cursor: cursor })
1466
+ );
1467
+ if (comments.length === 0) {
1468
+ process.stdout.write("No comments found on this page\n");
1469
+ return;
1470
+ }
1471
+ const rows = comments.map((comment) => {
1472
+ const text = comment.rich_text.map((t) => t.plain_text).join("");
1473
+ return [
1474
+ comment.created_time.split("T")[0],
1475
+ `${comment.created_by.id.slice(0, 8)}...`,
1476
+ text.slice(0, 80) + (text.length > 80 ? "\u2026" : "")
1477
+ ];
1478
+ });
1479
+ printOutput(comments, ["DATE", "AUTHOR ID", "COMMENT"], rows);
1480
+ })
1481
+ );
1482
+ return cmd;
1483
+ }
1484
+
1485
+ // src/commands/completion.ts
1486
+ import { Command as Command8 } from "commander";
430
1487
  var BASH_COMPLETION = `# notion bash completion
431
1488
  _notion_completion() {
432
1489
  local cur prev words cword
@@ -528,394 +1585,502 @@ complete -c notion -n '__fish_seen_subcommand_from completion' -a zsh -d 'zsh co
528
1585
  complete -c notion -n '__fish_seen_subcommand_from completion' -a fish -d 'fish completion script'
529
1586
  `;
530
1587
  function completionCommand() {
531
- const cmd = new Command5("completion");
532
- cmd.description("output shell completion script").argument("<shell>", "shell type (bash, zsh, fish)").action(withErrorHandling(async (shell) => {
533
- switch (shell) {
534
- case "bash":
535
- process.stdout.write(BASH_COMPLETION);
536
- break;
537
- case "zsh":
538
- process.stdout.write(ZSH_COMPLETION);
539
- break;
540
- case "fish":
541
- process.stdout.write(FISH_COMPLETION);
542
- break;
543
- default:
544
- throw new CliError(
545
- ErrorCodes.UNKNOWN,
546
- `Unknown shell: "${shell}".`,
547
- "Supported shells: bash, zsh, fish"
548
- );
549
- }
550
- }));
551
- return cmd;
552
- }
553
-
554
- // src/commands/search.ts
555
- import { Command as Command6 } from "commander";
556
- import { isFullPageOrDataSource } from "@notionhq/client";
557
-
558
- // src/config/local-config.ts
559
- import { readFile as readFile2 } from "fs/promises";
560
- import { join as join2 } from "path";
561
- import { parse as parse2 } from "yaml";
562
- async function readLocalConfig() {
563
- const localConfigPath = join2(process.cwd(), ".notion.yaml");
564
- let raw;
565
- try {
566
- raw = await readFile2(localConfigPath, "utf-8");
567
- } catch (err) {
568
- if (err.code === "ENOENT") {
569
- return null;
570
- }
571
- throw new CliError(
572
- ErrorCodes.CONFIG_READ_ERROR,
573
- `Failed to read local config: ${localConfigPath}`,
574
- "Check file permissions"
575
- );
576
- }
577
- let parsed;
578
- try {
579
- parsed = parse2(raw) ?? {};
580
- } catch {
581
- throw new CliError(
582
- ErrorCodes.CONFIG_INVALID,
583
- `Failed to parse .notion.yaml`,
584
- "Check that the file contains valid YAML"
585
- );
586
- }
587
- if (parsed.profile !== void 0 && parsed.token !== void 0) {
588
- throw new CliError(
589
- ErrorCodes.CONFIG_INVALID,
590
- '.notion.yaml cannot specify both "profile" and "token"',
591
- 'Use either "profile: <name>" to reference a saved profile, or "token: <value>" for a direct token'
592
- );
593
- }
594
- return parsed;
595
- }
596
-
597
- // src/config/token.ts
598
- async function resolveToken() {
599
- const envToken = process.env["NOTION_API_TOKEN"];
600
- if (envToken) {
601
- return { token: envToken, source: "NOTION_API_TOKEN" };
602
- }
603
- const localConfig = await readLocalConfig();
604
- if (localConfig !== null) {
605
- if (localConfig.token) {
606
- return { token: localConfig.token, source: ".notion.yaml" };
607
- }
608
- if (localConfig.profile) {
609
- const globalConfig2 = await readGlobalConfig();
610
- const profileToken = globalConfig2.profiles?.[localConfig.profile]?.token;
611
- if (profileToken) {
612
- return { token: profileToken, source: `profile: ${localConfig.profile}` };
1588
+ const cmd = new Command8("completion");
1589
+ cmd.description("output shell completion script").argument("<shell>", "shell type (bash, zsh, fish)").action(
1590
+ withErrorHandling(async (shell) => {
1591
+ switch (shell) {
1592
+ case "bash":
1593
+ process.stdout.write(BASH_COMPLETION);
1594
+ break;
1595
+ case "zsh":
1596
+ process.stdout.write(ZSH_COMPLETION);
1597
+ break;
1598
+ case "fish":
1599
+ process.stdout.write(FISH_COMPLETION);
1600
+ break;
1601
+ default:
1602
+ throw new CliError(
1603
+ ErrorCodes.UNKNOWN,
1604
+ `Unknown shell: "${shell}".`,
1605
+ "Supported shells: bash, zsh, fish"
1606
+ );
613
1607
  }
614
- }
615
- }
616
- const globalConfig = await readGlobalConfig();
617
- if (globalConfig.active_profile) {
618
- const profileToken = globalConfig.profiles?.[globalConfig.active_profile]?.token;
619
- if (profileToken) {
620
- return { token: profileToken, source: `profile: ${globalConfig.active_profile}` };
621
- }
622
- }
623
- throw new CliError(
624
- ErrorCodes.AUTH_NO_TOKEN,
625
- "No authentication token found.",
626
- 'Run "notion init" to set up a profile'
1608
+ })
627
1609
  );
1610
+ return cmd;
628
1611
  }
629
1612
 
630
- // src/commands/search.ts
631
- function getTitle(item) {
632
- if (item.object === "data_source") {
633
- return item.title.map((t) => t.plain_text).join("") || "(untitled)";
634
- }
635
- const titleProp = Object.values(item.properties).find((p) => p.type === "title");
636
- if (titleProp?.type === "title") {
637
- return titleProp.title.map((t) => t.plain_text).join("") || "(untitled)";
1613
+ // src/commands/create-page.ts
1614
+ import { Command as Command9 } from "commander";
1615
+ async function readStdin() {
1616
+ const chunks = [];
1617
+ for await (const chunk of process.stdin) {
1618
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
638
1619
  }
639
- return "(untitled)";
640
- }
641
- function toSdkFilterValue(type) {
642
- return type === "database" ? "data_source" : "page";
643
- }
644
- function displayType(item) {
645
- return item.object === "data_source" ? "database" : item.object;
646
- }
647
- function searchCommand() {
648
- const cmd = new Command6("search");
649
- cmd.description("search Notion workspace by keyword").argument("<query>", "search keyword").option("--type <type>", "filter by object type (page or database)", (val) => {
650
- if (val !== "page" && val !== "database") {
651
- throw new Error('--type must be "page" or "database"');
652
- }
653
- return val;
654
- }).option("--cursor <cursor>", "start from this pagination cursor (from a previous --next hint)").option("--json", "force JSON output").action(
655
- withErrorHandling(async (query, opts) => {
656
- if (opts.json) {
657
- setOutputMode("json");
658
- }
659
- const { token, source } = await resolveToken();
660
- reportTokenSource(source);
661
- const notion = createNotionClient(token);
662
- const response = await notion.search({
663
- query,
664
- filter: opts.type ? { property: "object", value: toSdkFilterValue(opts.type) } : void 0,
665
- start_cursor: opts.cursor,
666
- page_size: 20
667
- });
668
- const fullResults = response.results.filter((r) => isFullPageOrDataSource(r));
669
- if (fullResults.length === 0) {
670
- process.stdout.write(`No results found for "${query}"
671
- `);
672
- return;
673
- }
674
- const headers = ["TYPE", "TITLE", "ID", "MODIFIED"];
675
- const rows = fullResults.map((item) => [
676
- displayType(item),
677
- getTitle(item),
678
- item.id,
679
- item.last_edited_time.split("T")[0]
680
- ]);
681
- printOutput(fullResults, headers, rows);
682
- if (response.has_more && response.next_cursor) {
683
- process.stderr.write(`
684
- --next: notion search "${query}" --cursor ${response.next_cursor}
1620
+ return Buffer.concat(chunks).toString("utf-8");
1621
+ }
1622
+ function createPageCommand() {
1623
+ const cmd = new Command9("create-page");
1624
+ cmd.description("create a new Notion page under a parent page").requiredOption("--parent <id/url>", "parent page ID or URL").requiredOption("--title <title>", "page title").option(
1625
+ "-m, --message <markdown>",
1626
+ "inline markdown content for the page body"
1627
+ ).action(
1628
+ withErrorHandling(
1629
+ async (opts) => {
1630
+ const { token, source } = await resolveToken();
1631
+ reportTokenSource(source);
1632
+ const client = createNotionClient(token);
1633
+ let markdown = "";
1634
+ if (opts.message) {
1635
+ markdown = opts.message;
1636
+ } else if (!process.stdin.isTTY) {
1637
+ markdown = await readStdin();
1638
+ }
1639
+ const blocks = mdToBlocks(markdown);
1640
+ const parentUuid = toUuid(parseNotionId(opts.parent));
1641
+ const url = await createPage(client, parentUuid, opts.title, blocks);
1642
+ process.stdout.write(`${url}
685
1643
  `);
686
1644
  }
687
- })
1645
+ )
688
1646
  );
689
1647
  return cmd;
690
1648
  }
691
1649
 
692
- // src/commands/ls.ts
693
- import { Command as Command7 } from "commander";
694
- import { isFullPageOrDataSource as isFullPageOrDataSource2 } from "@notionhq/client";
695
- function getTitle2(item) {
696
- if (item.object === "data_source") {
697
- return item.title.map((t) => t.plain_text).join("") || "(untitled)";
698
- }
699
- const titleProp = Object.values(item.properties).find((p) => p.type === "title");
700
- if (titleProp?.type === "title") {
701
- return titleProp.title.map((t) => t.plain_text).join("") || "(untitled)";
1650
+ // src/commands/db/query.ts
1651
+ import { Command as Command10 } from "commander";
1652
+
1653
+ // src/services/database.service.ts
1654
+ import { isFullPage } from "@notionhq/client";
1655
+ async function fetchDatabaseSchema(client, dbId) {
1656
+ const ds = await client.dataSources.retrieve({ data_source_id: dbId });
1657
+ const title = "title" in ds ? ds.title.map((rt) => rt.plain_text).join("") || dbId : dbId;
1658
+ const properties = {};
1659
+ if ("properties" in ds) {
1660
+ for (const [name, prop] of Object.entries(ds.properties)) {
1661
+ const config = {
1662
+ id: prop.id,
1663
+ name,
1664
+ type: prop.type
1665
+ };
1666
+ if (prop.type === "select" && "select" in prop) {
1667
+ config.options = prop.select.options;
1668
+ } else if (prop.type === "status" && "status" in prop) {
1669
+ config.options = prop.status.options;
1670
+ } else if (prop.type === "multi_select" && "multi_select" in prop) {
1671
+ config.options = prop.multi_select.options;
1672
+ }
1673
+ properties[name] = config;
1674
+ }
702
1675
  }
703
- return "(untitled)";
704
- }
705
- function displayType2(item) {
706
- return item.object === "data_source" ? "database" : item.object;
1676
+ return { id: dbId, title, properties };
707
1677
  }
708
- function lsCommand() {
709
- const cmd = new Command7("ls");
710
- cmd.description("list accessible Notion pages and databases").option("--type <type>", "filter by object type (page or database)", (val) => {
711
- if (val !== "page" && val !== "database") {
712
- throw new Error('--type must be "page" or "database"');
713
- }
714
- return val;
715
- }).option("--cursor <cursor>", "start from this pagination cursor (from a previous --next hint)").option("--json", "force JSON output").action(
716
- withErrorHandling(async (opts) => {
717
- if (opts.json) {
718
- setOutputMode("json");
719
- }
720
- const { token, source } = await resolveToken();
721
- reportTokenSource(source);
722
- const notion = createNotionClient(token);
723
- const response = await notion.search({
724
- start_cursor: opts.cursor,
725
- page_size: 20
726
- });
727
- let items = response.results.filter((r) => isFullPageOrDataSource2(r));
728
- if (opts.type) {
729
- const filterType = opts.type;
730
- items = items.filter(
731
- (r) => filterType === "database" ? r.object === "data_source" : r.object === filterType
732
- );
733
- }
734
- if (items.length === 0) {
735
- process.stdout.write("No accessible content found\n");
736
- return;
737
- }
738
- const headers = ["TYPE", "TITLE", "ID", "MODIFIED"];
739
- const rows = items.map((item) => [
740
- displayType2(item),
741
- getTitle2(item),
742
- item.id,
743
- item.last_edited_time.split("T")[0]
744
- ]);
745
- printOutput(items, headers, rows);
746
- if (response.has_more && response.next_cursor) {
747
- process.stderr.write(`
748
- --next: notion ls --cursor ${response.next_cursor}
749
- `);
750
- }
1678
+ async function queryDatabase(client, dbId, opts = {}) {
1679
+ const rawPages = await paginateResults(
1680
+ (cursor) => client.dataSources.query({
1681
+ data_source_id: dbId,
1682
+ filter: opts.filter,
1683
+ sorts: opts.sorts,
1684
+ start_cursor: cursor,
1685
+ page_size: 100
751
1686
  })
752
1687
  );
753
- return cmd;
1688
+ return rawPages.filter(isFullPage).map((page) => {
1689
+ const propValues = {};
1690
+ for (const [name, prop] of Object.entries(page.properties)) {
1691
+ if (opts.columns && !opts.columns.includes(name)) continue;
1692
+ propValues[name] = displayPropertyValue(prop);
1693
+ }
1694
+ return { id: page.id, properties: propValues, raw: page };
1695
+ });
754
1696
  }
755
-
756
- // src/commands/open.ts
757
- import { exec } from "child_process";
758
- import { promisify } from "util";
759
- import { Command as Command8 } from "commander";
760
-
761
- // src/notion/url-parser.ts
762
- var NOTION_ID_REGEX = /^[0-9a-f]{32}$/i;
763
- var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
764
- var NOTION_URL_REGEX = /https?:\/\/(?:[a-zA-Z0-9-]+\.)?notion\.(?:so|site)\/.*?([0-9a-f]{32})(?:[?#]|$)/i;
765
- function throwInvalidId(input2) {
766
- throw new CliError(
767
- ErrorCodes.INVALID_ID,
768
- `Cannot parse Notion ID from: ${input2}`,
769
- "Provide a valid Notion URL or page/database ID"
770
- );
1697
+ function buildFilter(filterStrings, schema) {
1698
+ if (!filterStrings.length) return void 0;
1699
+ const filters = filterStrings.map((raw) => {
1700
+ const eqIdx = raw.indexOf("=");
1701
+ if (eqIdx === -1) {
1702
+ throw new CliError(
1703
+ ErrorCodes.INVALID_ARG,
1704
+ `Invalid filter syntax: "${raw}"`,
1705
+ 'Use format: --filter "PropertyName=Value"'
1706
+ );
1707
+ }
1708
+ const propName = raw.slice(0, eqIdx).trim();
1709
+ const value = raw.slice(eqIdx + 1).trim();
1710
+ const propConfig = schema.properties[propName];
1711
+ if (!propConfig) {
1712
+ const available = Object.keys(schema.properties).join(", ");
1713
+ throw new CliError(
1714
+ ErrorCodes.INVALID_ARG,
1715
+ `Property "${propName}" not found`,
1716
+ `Available properties: ${available}`
1717
+ );
1718
+ }
1719
+ return buildPropertyFilter(propName, propConfig.type, value);
1720
+ });
1721
+ return filters.length === 1 ? filters[0] : { and: filters };
771
1722
  }
772
- function parseNotionId(input2) {
773
- if (!input2) throwInvalidId(input2);
774
- if (NOTION_ID_REGEX.test(input2)) {
775
- return input2.toLowerCase();
776
- }
777
- if (UUID_REGEX.test(input2)) {
778
- return input2.replace(/-/g, "").toLowerCase();
779
- }
780
- const urlMatch = NOTION_URL_REGEX.exec(input2);
781
- if (urlMatch) {
782
- return urlMatch[1].toLowerCase();
1723
+ function buildPropertyFilter(property, type, value) {
1724
+ switch (type) {
1725
+ case "select":
1726
+ return { property, select: { equals: value } };
1727
+ case "status":
1728
+ return { property, status: { equals: value } };
1729
+ case "multi_select":
1730
+ return { property, multi_select: { contains: value } };
1731
+ case "checkbox":
1732
+ return { property, checkbox: { equals: value.toLowerCase() === "true" } };
1733
+ case "number":
1734
+ return { property, number: { equals: Number(value) } };
1735
+ case "title":
1736
+ return { property, title: { contains: value } };
1737
+ case "rich_text":
1738
+ return { property, rich_text: { contains: value } };
1739
+ case "url":
1740
+ return { property, url: { contains: value } };
1741
+ case "email":
1742
+ return { property, email: { contains: value } };
1743
+ default:
1744
+ throw new CliError(
1745
+ ErrorCodes.INVALID_ARG,
1746
+ `Filtering by property type "${type}" is not supported`
1747
+ );
783
1748
  }
784
- throwInvalidId(input2);
785
1749
  }
786
- function toUuid(id) {
787
- return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
1750
+ function buildSorts(sortStrings) {
1751
+ return sortStrings.map((raw) => {
1752
+ const colonIdx = raw.lastIndexOf(":");
1753
+ if (colonIdx === -1) {
1754
+ return { property: raw.trim(), direction: "ascending" };
1755
+ }
1756
+ const property = raw.slice(0, colonIdx).trim();
1757
+ const dir = raw.slice(colonIdx + 1).trim().toLowerCase();
1758
+ return {
1759
+ property,
1760
+ direction: dir === "desc" || dir === "descending" ? "descending" : "ascending"
1761
+ };
1762
+ });
788
1763
  }
789
-
790
- // src/commands/open.ts
791
- var execAsync = promisify(exec);
792
- function openCommand() {
793
- const cmd = new Command8("open");
794
- cmd.description("open a Notion page in the default browser").argument("<id/url>", "Notion page ID or URL").action(withErrorHandling(async (idOrUrl) => {
795
- const id = parseNotionId(idOrUrl);
796
- const url = `https://www.notion.so/${id}`;
797
- const platform = process.platform;
798
- const opener = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
799
- await execAsync(`${opener} "${url}"`);
800
- process.stdout.write(`Opening ${url}
801
- `);
802
- }));
803
- return cmd;
1764
+ function displayFormula(f) {
1765
+ if (f.type === "string") return f.string ?? "";
1766
+ if (f.type === "number")
1767
+ return f.number !== null && f.number !== void 0 ? String(f.number) : "";
1768
+ if (f.type === "boolean") return f.boolean ? "true" : "false";
1769
+ if (f.type === "date") return f.date?.start ?? "";
1770
+ return "";
804
1771
  }
805
-
806
- // src/commands/users.ts
807
- import { Command as Command9 } from "commander";
808
-
809
- // src/output/paginate.ts
810
- async function paginateResults(fetcher) {
811
- const allResults = [];
812
- let cursor;
813
- let hasMore = true;
814
- while (hasMore) {
815
- const response = await fetcher(cursor);
816
- allResults.push(...response.results);
817
- cursor = response.next_cursor ?? void 0;
818
- hasMore = response.has_more;
1772
+ function displayDate(date) {
1773
+ if (!date) return "";
1774
+ return date.end ? `${date.start} \u2192 ${date.end}` : date.start;
1775
+ }
1776
+ function displayPropertyValue(prop) {
1777
+ switch (prop.type) {
1778
+ case "title":
1779
+ return prop.title.map((r) => r.plain_text).join("").replace(/\n/g, " ");
1780
+ case "rich_text":
1781
+ return prop.rich_text.map((r) => r.plain_text).join("").replace(/\n/g, " ");
1782
+ case "number":
1783
+ return prop.number !== null && prop.number !== void 0 ? String(prop.number) : "";
1784
+ case "select":
1785
+ return prop.select?.name ?? "";
1786
+ case "status":
1787
+ return prop.status?.name ?? "";
1788
+ case "multi_select":
1789
+ return prop.multi_select.map((s) => s.name).join(", ");
1790
+ case "date":
1791
+ return displayDate(prop.date);
1792
+ case "checkbox":
1793
+ return prop.checkbox ? "\u2713" : "\u2717";
1794
+ case "url":
1795
+ return prop.url ?? "";
1796
+ case "email":
1797
+ return prop.email ?? "";
1798
+ case "phone_number":
1799
+ return prop.phone_number ?? "";
1800
+ case "people":
1801
+ return prop.people.map((p) => "name" in p && p.name ? p.name : p.id).join(", ");
1802
+ case "relation":
1803
+ return prop.relation.length > 0 ? `[${prop.relation.length}]` : "";
1804
+ case "formula":
1805
+ return displayFormula(prop.formula);
1806
+ case "created_time":
1807
+ return prop.created_time;
1808
+ case "last_edited_time":
1809
+ return prop.last_edited_time;
1810
+ case "unique_id":
1811
+ return prop.unique_id.prefix ? `${prop.unique_id.prefix}-${prop.unique_id.number}` : String(prop.unique_id.number ?? "");
1812
+ default:
1813
+ return "";
819
1814
  }
820
- return allResults;
821
1815
  }
822
1816
 
823
- // src/commands/users.ts
824
- function getEmailOrWorkspace(user) {
825
- if (user.type === "person") {
826
- return user.person.email ?? "\u2014";
1817
+ // src/commands/db/query.ts
1818
+ var SKIP_TYPES_IN_AUTO = /* @__PURE__ */ new Set(["relation", "rich_text", "people"]);
1819
+ function autoSelectColumns(schema, entries) {
1820
+ const termWidth = process.stdout.columns || 120;
1821
+ const COL_SEP = 2;
1822
+ const candidates = Object.values(schema.properties).filter((p) => !SKIP_TYPES_IN_AUTO.has(p.type)).map((p) => p.name);
1823
+ const widths = candidates.map((col) => {
1824
+ const header = col.toUpperCase().length;
1825
+ const maxData = entries.reduce(
1826
+ (max, e) => Math.max(max, (e.properties[col] ?? "").length),
1827
+ 0
1828
+ );
1829
+ return Math.min(Math.max(header, maxData), 40);
1830
+ });
1831
+ const selected = [];
1832
+ let usedWidth = 0;
1833
+ for (let i = 0; i < candidates.length; i++) {
1834
+ const needed = (selected.length > 0 ? COL_SEP : 0) + widths[i];
1835
+ if (usedWidth + needed > termWidth) break;
1836
+ selected.push(candidates[i]);
1837
+ usedWidth += needed;
827
1838
  }
828
- if (user.type === "bot") {
829
- const bot = user.bot;
830
- return "workspace_name" in bot && bot.workspace_name ? bot.workspace_name : "\u2014";
1839
+ if (selected.length === 0 && candidates.length > 0) {
1840
+ selected.push(candidates[0]);
831
1841
  }
832
- return "\u2014";
833
- }
834
- function usersCommand() {
835
- const cmd = new Command9("users");
836
- cmd.description("list all users in the workspace").option("--json", "output as JSON").action(withErrorHandling(async (opts) => {
837
- if (opts.json) setOutputMode("json");
838
- const { token, source } = await resolveToken();
839
- reportTokenSource(source);
840
- const notion = createNotionClient(token);
841
- const allUsers = await paginateResults(
842
- (cursor) => notion.users.list({ start_cursor: cursor })
843
- );
844
- const users = allUsers.filter((u) => u.name !== void 0);
845
- const rows = users.map((user) => [
846
- user.type,
847
- user.name ?? "(unnamed)",
848
- getEmailOrWorkspace(user),
849
- user.id
850
- ]);
851
- printOutput(users, ["TYPE", "NAME", "EMAIL / WORKSPACE", "ID"], rows);
852
- }));
853
- return cmd;
1842
+ return selected;
854
1843
  }
855
-
856
- // src/commands/comments.ts
857
- import { Command as Command10 } from "commander";
858
- function commentsCommand() {
859
- const cmd = new Command10("comments");
860
- cmd.description("list comments on a Notion page").argument("<id/url>", "Notion page ID or URL").option("--json", "output as JSON").action(withErrorHandling(async (idOrUrl, opts) => {
861
- if (opts.json) setOutputMode("json");
862
- const id = parseNotionId(idOrUrl);
863
- const uuid = toUuid(id);
864
- const { token, source } = await resolveToken();
865
- reportTokenSource(source);
866
- const notion = createNotionClient(token);
867
- const comments = await paginateResults(
868
- (cursor) => notion.comments.list({ block_id: uuid, start_cursor: cursor })
869
- );
870
- if (comments.length === 0) {
871
- process.stdout.write("No comments found on this page\n");
872
- return;
873
- }
874
- const rows = comments.map((comment) => {
875
- const text = comment.rich_text.map((t) => t.plain_text).join("");
876
- return [
877
- comment.created_time.split("T")[0],
878
- comment.created_by.id.slice(0, 8) + "...",
879
- text.slice(0, 80) + (text.length > 80 ? "\u2026" : "")
880
- ];
881
- });
882
- printOutput(comments, ["DATE", "AUTHOR ID", "COMMENT"], rows);
883
- }));
884
- return cmd;
1844
+ function dbQueryCommand() {
1845
+ return new Command10("query").description("Query database entries with optional filtering and sorting").argument("<id>", "Notion database ID or URL").option(
1846
+ "--filter <filter>",
1847
+ 'Filter entries (repeatable): --filter "Status=Done"',
1848
+ collect,
1849
+ []
1850
+ ).option(
1851
+ "--sort <sort>",
1852
+ 'Sort entries (repeatable): --sort "Name:asc"',
1853
+ collect,
1854
+ []
1855
+ ).option(
1856
+ "--columns <columns>",
1857
+ 'Comma-separated list of columns to display: --columns "Title,Status"'
1858
+ ).option("--json", "Output raw JSON").action(
1859
+ withErrorHandling(
1860
+ async (id, options) => {
1861
+ const { token } = await resolveToken();
1862
+ const client = createNotionClient(token);
1863
+ const dbId = parseNotionId(id);
1864
+ const schema = await fetchDatabaseSchema(client, dbId);
1865
+ const columns = options.columns ? options.columns.split(",").map((c2) => c2.trim()) : void 0;
1866
+ const filter = options.filter.length ? buildFilter(options.filter, schema) : void 0;
1867
+ const sorts = options.sort.length ? buildSorts(options.sort) : void 0;
1868
+ const entries = await queryDatabase(client, dbId, {
1869
+ filter,
1870
+ sorts,
1871
+ columns
1872
+ });
1873
+ if (options.json) {
1874
+ process.stdout.write(`${formatJSON(entries.map((e) => e.raw))}
1875
+ `);
1876
+ return;
1877
+ }
1878
+ if (entries.length === 0) {
1879
+ process.stdout.write("No entries found.\n");
1880
+ return;
1881
+ }
1882
+ const displayColumns = columns ?? autoSelectColumns(schema, entries);
1883
+ const headers = displayColumns.map((c2) => c2.toUpperCase());
1884
+ const rows = entries.map(
1885
+ (entry) => displayColumns.map((col) => entry.properties[col] ?? "")
1886
+ );
1887
+ process.stdout.write(`${formatTable(rows, headers)}
1888
+ `);
1889
+ process.stderr.write(`${entries.length} entries
1890
+ `);
1891
+ }
1892
+ )
1893
+ );
1894
+ }
1895
+ function collect(value, previous) {
1896
+ return previous.concat([value]);
885
1897
  }
886
1898
 
887
- // src/commands/read.ts
1899
+ // src/commands/db/schema.ts
888
1900
  import { Command as Command11 } from "commander";
1901
+ function dbSchemaCommand() {
1902
+ return new Command11("schema").description("Show database schema (property names, types, and options)").argument("<id>", "Notion database ID or URL").option("--json", "Output raw JSON").action(
1903
+ withErrorHandling(async (id, options) => {
1904
+ const { token } = await resolveToken();
1905
+ const client = createNotionClient(token);
1906
+ const dbId = parseNotionId(id);
1907
+ const schema = await fetchDatabaseSchema(client, dbId);
1908
+ if (options.json) {
1909
+ process.stdout.write(`${formatJSON(schema)}
1910
+ `);
1911
+ return;
1912
+ }
1913
+ const headers = ["PROPERTY", "TYPE", "OPTIONS"];
1914
+ const rows = Object.values(schema.properties).map((prop) => [
1915
+ prop.name,
1916
+ prop.type,
1917
+ prop.options ? prop.options.map((o) => o.name).join(", ") : ""
1918
+ ]);
1919
+ process.stdout.write(`${formatTable(rows, headers)}
1920
+ `);
1921
+ })
1922
+ );
1923
+ }
889
1924
 
890
- // src/services/page.service.ts
891
- import { collectPaginatedAPI, isFullBlock } from "@notionhq/client";
892
- var MAX_CONCURRENT_REQUESTS = 3;
893
- async function fetchBlockTree(client, blockId, depth, maxDepth) {
894
- if (depth >= maxDepth) return [];
895
- const rawBlocks = await collectPaginatedAPI(client.blocks.children.list, {
896
- block_id: blockId
897
- });
898
- const blocks = rawBlocks.filter(isFullBlock);
899
- const SKIP_RECURSE = /* @__PURE__ */ new Set(["child_page", "child_database"]);
900
- const nodes = [];
901
- for (let i = 0; i < blocks.length; i += MAX_CONCURRENT_REQUESTS) {
902
- const batch = blocks.slice(i, i + MAX_CONCURRENT_REQUESTS);
903
- const batchNodes = await Promise.all(
904
- batch.map(async (block) => {
905
- const children = block.has_children && !SKIP_RECURSE.has(block.type) ? await fetchBlockTree(client, block.id, depth + 1, maxDepth) : [];
906
- return { block, children };
907
- })
908
- );
909
- nodes.push(...batchNodes);
1925
+ // src/commands/ls.ts
1926
+ import { isFullPageOrDataSource } from "@notionhq/client";
1927
+ import { Command as Command12 } from "commander";
1928
+ function getTitle(item) {
1929
+ if (item.object === "data_source") {
1930
+ return item.title.map((t) => t.plain_text).join("") || "(untitled)";
910
1931
  }
911
- return nodes;
1932
+ const titleProp = Object.values(item.properties).find(
1933
+ (p) => p.type === "title"
1934
+ );
1935
+ if (titleProp?.type === "title") {
1936
+ return titleProp.title.map((t) => t.plain_text).join("") || "(untitled)";
1937
+ }
1938
+ return "(untitled)";
912
1939
  }
913
- async function fetchPageWithBlocks(client, pageId) {
914
- const page = await client.pages.retrieve({ page_id: pageId });
915
- const blocks = await fetchBlockTree(client, pageId, 0, 10);
916
- return { page, blocks };
1940
+ function displayType(item) {
1941
+ return item.object === "data_source" ? "database" : item.object;
1942
+ }
1943
+ function lsCommand() {
1944
+ const cmd = new Command12("ls");
1945
+ cmd.description("list accessible Notion pages and databases").option(
1946
+ "--type <type>",
1947
+ "filter by object type (page or database)",
1948
+ (val) => {
1949
+ if (val !== "page" && val !== "database") {
1950
+ throw new Error('--type must be "page" or "database"');
1951
+ }
1952
+ return val;
1953
+ }
1954
+ ).option(
1955
+ "--cursor <cursor>",
1956
+ "start from this pagination cursor (from a previous --next hint)"
1957
+ ).option("--json", "force JSON output").action(
1958
+ withErrorHandling(
1959
+ async (opts) => {
1960
+ if (opts.json) {
1961
+ setOutputMode("json");
1962
+ }
1963
+ const { token, source } = await resolveToken();
1964
+ reportTokenSource(source);
1965
+ const notion = createNotionClient(token);
1966
+ const response = await notion.search({
1967
+ start_cursor: opts.cursor,
1968
+ page_size: 20
1969
+ });
1970
+ let items = response.results.filter(
1971
+ (r) => isFullPageOrDataSource(r)
1972
+ );
1973
+ if (opts.type) {
1974
+ const filterType = opts.type;
1975
+ items = items.filter(
1976
+ (r) => filterType === "database" ? r.object === "data_source" : r.object === filterType
1977
+ );
1978
+ }
1979
+ if (items.length === 0) {
1980
+ process.stdout.write("No accessible content found\n");
1981
+ return;
1982
+ }
1983
+ const headers = ["TYPE", "TITLE", "ID", "MODIFIED"];
1984
+ const rows = items.map((item) => [
1985
+ displayType(item),
1986
+ getTitle(item),
1987
+ item.id,
1988
+ item.last_edited_time.split("T")[0]
1989
+ ]);
1990
+ printOutput(items, headers, rows);
1991
+ if (response.has_more && response.next_cursor) {
1992
+ process.stderr.write(
1993
+ `
1994
+ --next: notion ls --cursor ${response.next_cursor}
1995
+ `
1996
+ );
1997
+ }
1998
+ }
1999
+ )
2000
+ );
2001
+ return cmd;
917
2002
  }
918
2003
 
2004
+ // src/commands/open.ts
2005
+ import { exec } from "child_process";
2006
+ import { promisify } from "util";
2007
+ import { Command as Command13 } from "commander";
2008
+ var execAsync = promisify(exec);
2009
+ function openCommand() {
2010
+ const cmd = new Command13("open");
2011
+ cmd.description("open a Notion page in the default browser").argument("<id/url>", "Notion page ID or URL").action(
2012
+ withErrorHandling(async (idOrUrl) => {
2013
+ const id = parseNotionId(idOrUrl);
2014
+ const url = `https://www.notion.so/${id}`;
2015
+ const platform = process.platform;
2016
+ const opener = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
2017
+ await execAsync(`${opener} "${url}"`);
2018
+ process.stdout.write(`Opening ${url}
2019
+ `);
2020
+ })
2021
+ );
2022
+ return cmd;
2023
+ }
2024
+
2025
+ // src/commands/profile/list.ts
2026
+ import { Command as Command14 } from "commander";
2027
+ function profileListCommand() {
2028
+ const cmd = new Command14("list");
2029
+ cmd.description("list all authentication profiles").action(
2030
+ withErrorHandling(async () => {
2031
+ const config = await readGlobalConfig();
2032
+ const profiles = config.profiles ?? {};
2033
+ const profileNames = Object.keys(profiles);
2034
+ if (profileNames.length === 0) {
2035
+ process.stdout.write(
2036
+ "No profiles configured. Run `notion auth login` to get started.\n"
2037
+ );
2038
+ return;
2039
+ }
2040
+ for (const name of profileNames) {
2041
+ const profile = profiles[name];
2042
+ const isActive = config.active_profile === name;
2043
+ const marker = isActive ? bold("* ") : " ";
2044
+ const activeLabel = isActive ? " (active)" : "";
2045
+ const workspaceInfo = profile.workspace_name ? dim(` \u2014 ${profile.workspace_name}`) : "";
2046
+ process.stdout.write(
2047
+ `${marker}${name}${activeLabel}${workspaceInfo}
2048
+ `
2049
+ );
2050
+ }
2051
+ })
2052
+ );
2053
+ return cmd;
2054
+ }
2055
+
2056
+ // src/commands/profile/use.ts
2057
+ import { Command as Command15 } from "commander";
2058
+ function profileUseCommand() {
2059
+ const cmd = new Command15("use");
2060
+ cmd.description("switch the active profile").argument("<name>", "profile name to activate").action(
2061
+ withErrorHandling(async (name) => {
2062
+ const config = await readGlobalConfig();
2063
+ const profiles = config.profiles ?? {};
2064
+ if (!profiles[name]) {
2065
+ throw new CliError(
2066
+ ErrorCodes.AUTH_PROFILE_NOT_FOUND,
2067
+ `Profile "${name}" not found.`,
2068
+ `Run "notion auth list" to see available profiles`
2069
+ );
2070
+ }
2071
+ await writeGlobalConfig({
2072
+ ...config,
2073
+ active_profile: name
2074
+ });
2075
+ stderrWrite(success(`Switched to profile "${name}".`));
2076
+ })
2077
+ );
2078
+ return cmd;
2079
+ }
2080
+
2081
+ // src/commands/read.ts
2082
+ import { Command as Command16 } from "commander";
2083
+
919
2084
  // src/blocks/rich-text.ts
920
2085
  function richTextToMd(richText) {
921
2086
  return richText.map(segmentToMd).join("");
@@ -942,7 +2107,8 @@ function applyAnnotations(text, annotations) {
942
2107
 
943
2108
  // src/blocks/converters.ts
944
2109
  function indentChildren(childrenMd) {
945
- return childrenMd.split("\n").filter(Boolean).map((line) => " " + line).join("\n") + "\n";
2110
+ return `${childrenMd.split("\n").filter(Boolean).map((line) => ` ${line}`).join("\n")}
2111
+ `;
946
2112
  }
947
2113
  var converters = {
948
2114
  paragraph(block) {
@@ -1053,376 +2219,47 @@ ${content}
1053
2219
  const b = block;
1054
2220
  return `### ${b.child_database.title}
1055
2221
  `;
1056
- },
1057
- link_preview(block) {
1058
- const b = block;
1059
- return `[${b.link_preview.url}](${b.link_preview.url})
1060
- `;
1061
- }
1062
- };
1063
- function blockToMd(block, ctx) {
1064
- const converter = converters[block.type];
1065
- if (converter) {
1066
- return converter(block, ctx);
1067
- }
1068
- return `<!-- unsupported block: ${block.type} -->
1069
- `;
1070
- }
1071
-
1072
- // src/blocks/properties.ts
1073
- function formatPropertyValue(name, prop) {
1074
- switch (prop.type) {
1075
- case "title":
1076
- return prop.title.map((rt) => rt.plain_text).join("");
1077
- case "rich_text":
1078
- return prop.rich_text.map((rt) => rt.plain_text).join("");
1079
- case "number":
1080
- return prop.number !== null ? String(prop.number) : "";
1081
- case "select":
1082
- return prop.select?.name ?? "";
1083
- case "status":
1084
- return prop.status?.name ?? "";
1085
- case "multi_select":
1086
- return prop.multi_select.map((s) => s.name).join(", ");
1087
- case "date":
1088
- if (!prop.date) return "";
1089
- return prop.date.end ? `${prop.date.start} \u2192 ${prop.date.end}` : prop.date.start;
1090
- case "checkbox":
1091
- return prop.checkbox ? "true" : "false";
1092
- case "url":
1093
- return prop.url ?? "";
1094
- case "email":
1095
- return prop.email ?? "";
1096
- case "phone_number":
1097
- return prop.phone_number ?? "";
1098
- case "people":
1099
- return prop.people.map((p) => "name" in p && p.name ? p.name : p.id).join(", ");
1100
- case "relation":
1101
- return prop.relation.map((r) => r.id).join(", ");
1102
- case "formula": {
1103
- const f = prop.formula;
1104
- if (f.type === "string") return f.string ?? "";
1105
- if (f.type === "number") return f.number !== null ? String(f.number) : "";
1106
- if (f.type === "boolean") return String(f.boolean);
1107
- if (f.type === "date") return f.date?.start ?? "";
1108
- return "";
1109
- }
1110
- case "rollup": {
1111
- const r = prop.rollup;
1112
- if (r.type === "number") return r.number !== null ? String(r.number) : "";
1113
- if (r.type === "date") return r.date?.start ?? "";
1114
- if (r.type === "array") return `[${r.array.length} items]`;
1115
- return "";
1116
- }
1117
- case "created_time":
1118
- return prop.created_time;
1119
- case "last_edited_time":
1120
- return prop.last_edited_time;
1121
- case "created_by":
1122
- return "name" in prop.created_by ? prop.created_by.name ?? prop.created_by.id : prop.created_by.id;
1123
- case "last_edited_by":
1124
- return "name" in prop.last_edited_by ? prop.last_edited_by.name ?? prop.last_edited_by.id : prop.last_edited_by.id;
1125
- case "files":
1126
- return prop.files.map((f) => {
1127
- if (f.type === "external") return f.external.url;
1128
- return f.name;
1129
- }).join(", ");
1130
- case "unique_id":
1131
- return prop.unique_id.prefix ? `${prop.unique_id.prefix}-${prop.unique_id.number}` : String(prop.unique_id.number ?? "");
1132
- default:
1133
- return "";
1134
- }
1135
- }
1136
-
1137
- // src/blocks/render.ts
1138
- function buildPropertiesHeader(page) {
1139
- const lines = ["---"];
1140
- for (const [name, prop] of Object.entries(page.properties)) {
1141
- const value = formatPropertyValue(name, prop);
1142
- if (value) {
1143
- lines.push(`${name}: ${value}`);
1144
- }
1145
- }
1146
- lines.push("---", "");
1147
- return lines.join("\n");
1148
- }
1149
- function renderBlockTree(blocks) {
1150
- const parts = [];
1151
- let listCounter = 0;
1152
- for (const node of blocks) {
1153
- if (node.block.type === "numbered_list_item") {
1154
- listCounter++;
1155
- } else {
1156
- listCounter = 0;
1157
- }
1158
- const childrenMd = node.children.length > 0 ? renderBlockTree(node.children) : "";
1159
- const md = blockToMd(node.block, {
1160
- listNumber: node.block.type === "numbered_list_item" ? listCounter : void 0,
1161
- childrenMd: childrenMd || void 0
1162
- });
1163
- parts.push(md);
1164
- }
1165
- return parts.join("");
1166
- }
1167
- function renderPageMarkdown({ page, blocks }) {
1168
- const header = buildPropertiesHeader(page);
1169
- const content = renderBlockTree(blocks);
1170
- return header + content;
1171
- }
1172
-
1173
- // src/output/markdown.ts
1174
- import { Chalk as Chalk2 } from "chalk";
1175
- var c = new Chalk2({ level: 3 });
1176
- function renderMarkdown(md) {
1177
- if (!isatty()) return md;
1178
- const lines = md.split("\n");
1179
- const out = [];
1180
- let inFence = false;
1181
- let fenceLang = "";
1182
- let fenceLines = [];
1183
- for (const line of lines) {
1184
- const fenceMatch = line.match(/^```(\w*)$/);
1185
- if (fenceMatch && !inFence) {
1186
- inFence = true;
1187
- fenceLang = fenceMatch[1] ?? "";
1188
- fenceLines = [];
1189
- continue;
1190
- }
1191
- if (line === "```" && inFence) {
1192
- inFence = false;
1193
- const header = fenceLang ? c.dim(`[${fenceLang}]`) : "";
1194
- if (header) out.push(header);
1195
- for (const fl of fenceLines) {
1196
- out.push(c.green(" " + fl));
1197
- }
1198
- out.push("");
1199
- continue;
1200
- }
1201
- if (inFence) {
1202
- fenceLines.push(line);
1203
- continue;
1204
- }
1205
- if (line === "---") {
1206
- out.push(c.dim("\u2500".repeat(40)));
1207
- continue;
1208
- }
1209
- if (/^<!--.*-->$/.test(line.trim())) {
1210
- continue;
1211
- }
1212
- const h1 = line.match(/^# (.+)/);
1213
- if (h1) {
1214
- out.push("\n" + c.bold.cyan(h1[1]));
1215
- continue;
1216
- }
1217
- const h2 = line.match(/^## (.+)/);
1218
- if (h2) {
1219
- out.push("\n" + c.bold.blue(h2[1]));
1220
- continue;
1221
- }
1222
- const h3 = line.match(/^### (.+)/);
1223
- if (h3) {
1224
- out.push("\n" + c.bold(h3[1]));
1225
- continue;
1226
- }
1227
- const h4 = line.match(/^#### (.+)/);
1228
- if (h4) {
1229
- out.push(c.bold.underline(h4[1]));
1230
- continue;
1231
- }
1232
- if (line.startsWith("> ")) {
1233
- out.push(c.yellow("\u258E ") + renderInline(line.slice(2)));
1234
- continue;
1235
- }
1236
- if (line === "---") {
1237
- out.push(c.dim("\u2500".repeat(40)));
1238
- continue;
1239
- }
1240
- const propMatch = line.match(/^([A-Za-z_][A-Za-z0-9_ ]*): (.+)$/);
1241
- if (propMatch) {
1242
- out.push(c.dim(propMatch[1] + ": ") + c.white(propMatch[2]));
1243
- continue;
1244
- }
1245
- const bulletMatch = line.match(/^(\s*)- (\[[ x]\] )?(.+)/);
1246
- if (bulletMatch) {
1247
- const indent = bulletMatch[1] ?? "";
1248
- const checkbox = bulletMatch[2];
1249
- const text = bulletMatch[3] ?? "";
1250
- if (checkbox) {
1251
- const checked = checkbox.trim() === "[x]";
1252
- const box = checked ? c.green("\u2611") : c.dim("\u2610");
1253
- out.push(indent + box + " " + renderInline(text));
1254
- } else {
1255
- out.push(indent + c.cyan("\u2022") + " " + renderInline(text));
1256
- }
1257
- continue;
1258
- }
1259
- const numMatch = line.match(/^(\s*)(\d+)\. (.+)/);
1260
- if (numMatch) {
1261
- const indent = numMatch[1] ?? "";
1262
- const num = numMatch[2] ?? "";
1263
- const text = numMatch[3] ?? "";
1264
- out.push(indent + c.cyan(num + ".") + " " + renderInline(text));
1265
- continue;
1266
- }
1267
- out.push(renderInline(line));
1268
- }
1269
- return out.join("\n") + "\n";
1270
- }
1271
- function renderInline(text) {
1272
- const codeSpans = [];
1273
- let result = text.replace(/`([^`]+)`/g, (_, code) => {
1274
- codeSpans.push(c.green(code));
1275
- return `\0CODE${codeSpans.length - 1}\0`;
1276
- });
1277
- result = result.replace(/!\[([^\]]*)\]\([^)]+\)/g, (_, alt) => alt ? c.dim(`[image: ${alt}]`) : c.dim("[image]")).replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, url) => c.cyan.underline(t) + c.dim(` (${url})`)).replace(/\*\*\*(.+?)\*\*\*/g, (_, t) => c.bold.italic(t)).replace(/\*\*(.+?)\*\*/g, (_, t) => c.bold(t)).replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, (_, t) => c.italic(t)).replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, (_, t) => c.italic(t)).replace(/~~(.+?)~~/g, (_, t) => c.strikethrough(t));
1278
- result = result.replace(/\x00CODE(\d+)\x00/g, (_, i) => codeSpans[Number(i)] ?? "");
1279
- return result;
1280
- }
1281
-
1282
- // src/commands/read.ts
1283
- function readCommand() {
1284
- return new Command11("read").description("Read a Notion page as markdown").argument("<id>", "Notion page ID or URL").option("--json", "Output raw JSON instead of markdown").option("--md", "Output raw markdown (no terminal styling)").action(
1285
- withErrorHandling(async (id, options) => {
1286
- const { token } = await resolveToken();
1287
- const client = createNotionClient(token);
1288
- const pageId = parseNotionId(id);
1289
- const pageWithBlocks = await fetchPageWithBlocks(client, pageId);
1290
- if (options.json) {
1291
- process.stdout.write(JSON.stringify(pageWithBlocks, null, 2) + "\n");
1292
- } else {
1293
- const markdown = renderPageMarkdown(pageWithBlocks);
1294
- if (options.md || !isatty()) {
1295
- process.stdout.write(markdown);
1296
- } else {
1297
- process.stdout.write(renderMarkdown(markdown));
1298
- }
1299
- }
1300
- })
1301
- );
1302
- }
1303
-
1304
- // src/commands/db/schema.ts
1305
- import { Command as Command12 } from "commander";
1306
-
1307
- // src/services/database.service.ts
1308
- import { isFullPage as isFullPage2 } from "@notionhq/client";
1309
- async function fetchDatabaseSchema(client, dbId) {
1310
- const ds = await client.dataSources.retrieve({ data_source_id: dbId });
1311
- const title = "title" in ds ? ds.title.map((rt) => rt.plain_text).join("") || dbId : dbId;
1312
- const properties = {};
1313
- if ("properties" in ds) {
1314
- for (const [name, prop] of Object.entries(ds.properties)) {
1315
- const config = {
1316
- id: prop.id,
1317
- name,
1318
- type: prop.type
1319
- };
1320
- if (prop.type === "select" && "select" in prop) {
1321
- config.options = prop.select.options;
1322
- } else if (prop.type === "status" && "status" in prop) {
1323
- config.options = prop.status.options;
1324
- } else if (prop.type === "multi_select" && "multi_select" in prop) {
1325
- config.options = prop.multi_select.options;
1326
- }
1327
- properties[name] = config;
1328
- }
1329
- }
1330
- return { id: dbId, title, properties };
1331
- }
1332
- async function queryDatabase(client, dbId, opts = {}) {
1333
- const rawPages = await paginateResults(
1334
- (cursor) => client.dataSources.query({
1335
- data_source_id: dbId,
1336
- filter: opts.filter,
1337
- sorts: opts.sorts,
1338
- start_cursor: cursor,
1339
- page_size: 100
1340
- })
1341
- );
1342
- return rawPages.filter(isFullPage2).map((page) => {
1343
- const propValues = {};
1344
- for (const [name, prop] of Object.entries(page.properties)) {
1345
- if (opts.columns && !opts.columns.includes(name)) continue;
1346
- propValues[name] = displayPropertyValue(prop);
1347
- }
1348
- return { id: page.id, properties: propValues, raw: page };
1349
- });
1350
- }
1351
- function buildFilter(filterStrings, schema) {
1352
- if (!filterStrings.length) return void 0;
1353
- const filters = filterStrings.map((raw) => {
1354
- const eqIdx = raw.indexOf("=");
1355
- if (eqIdx === -1) {
1356
- throw new CliError(
1357
- ErrorCodes.INVALID_ARG,
1358
- `Invalid filter syntax: "${raw}"`,
1359
- 'Use format: --filter "PropertyName=Value"'
1360
- );
1361
- }
1362
- const propName = raw.slice(0, eqIdx).trim();
1363
- const value = raw.slice(eqIdx + 1).trim();
1364
- const propConfig = schema.properties[propName];
1365
- if (!propConfig) {
1366
- const available = Object.keys(schema.properties).join(", ");
1367
- throw new CliError(
1368
- ErrorCodes.INVALID_ARG,
1369
- `Property "${propName}" not found`,
1370
- `Available properties: ${available}`
1371
- );
1372
- }
1373
- return buildPropertyFilter(propName, propConfig.type, value);
1374
- });
1375
- return filters.length === 1 ? filters[0] : { and: filters };
1376
- }
1377
- function buildPropertyFilter(property, type, value) {
1378
- switch (type) {
1379
- case "select":
1380
- return { property, select: { equals: value } };
1381
- case "status":
1382
- return { property, status: { equals: value } };
1383
- case "multi_select":
1384
- return { property, multi_select: { contains: value } };
1385
- case "checkbox":
1386
- return { property, checkbox: { equals: value.toLowerCase() === "true" } };
1387
- case "number":
1388
- return { property, number: { equals: Number(value) } };
1389
- case "title":
1390
- return { property, title: { contains: value } };
1391
- case "rich_text":
1392
- return { property, rich_text: { contains: value } };
1393
- case "url":
1394
- return { property, url: { contains: value } };
1395
- case "email":
1396
- return { property, email: { contains: value } };
1397
- default:
1398
- throw new CliError(
1399
- ErrorCodes.INVALID_ARG,
1400
- `Filtering by property type "${type}" is not supported`
1401
- );
1402
- }
1403
- }
1404
- function buildSorts(sortStrings) {
1405
- return sortStrings.map((raw) => {
1406
- const colonIdx = raw.lastIndexOf(":");
1407
- if (colonIdx === -1) {
1408
- return { property: raw.trim(), direction: "ascending" };
1409
- }
1410
- const property = raw.slice(0, colonIdx).trim();
1411
- const dir = raw.slice(colonIdx + 1).trim().toLowerCase();
1412
- return {
1413
- property,
1414
- direction: dir === "desc" || dir === "descending" ? "descending" : "ascending"
1415
- };
1416
- });
2222
+ },
2223
+ link_preview(block) {
2224
+ const b = block;
2225
+ return `[${b.link_preview.url}](${b.link_preview.url})
2226
+ `;
2227
+ }
2228
+ };
2229
+ function blockToMd(block, ctx) {
2230
+ const converter = converters[block.type];
2231
+ if (converter) {
2232
+ return converter(block, ctx);
2233
+ }
2234
+ return `<!-- unsupported block: ${block.type} -->
2235
+ `;
1417
2236
  }
1418
- function displayPropertyValue(prop) {
2237
+
2238
+ // src/blocks/properties.ts
2239
+ function formatFormula(f) {
2240
+ if (f.type === "string") return f.string ?? "";
2241
+ if (f.type === "number") return f.number !== null ? String(f.number) : "";
2242
+ if (f.type === "boolean") return String(f.boolean);
2243
+ if (f.type === "date") return f.date?.start ?? "";
2244
+ return "";
2245
+ }
2246
+ function formatRollup(r) {
2247
+ if (r.type === "number") return r.number !== null ? String(r.number) : "";
2248
+ if (r.type === "date") return r.date?.start ?? "";
2249
+ if (r.type === "array") return `[${r.array.length} items]`;
2250
+ return "";
2251
+ }
2252
+ function formatUser(p) {
2253
+ return "name" in p && p.name ? p.name : p.id;
2254
+ }
2255
+ function formatPropertyValue(_name, prop) {
1419
2256
  switch (prop.type) {
1420
2257
  case "title":
1421
- return prop.title.map((r) => r.plain_text).join("").replace(/\n/g, " ");
2258
+ return prop.title.map((rt) => rt.plain_text).join("");
1422
2259
  case "rich_text":
1423
- return prop.rich_text.map((r) => r.plain_text).join("").replace(/\n/g, " ");
2260
+ return prop.rich_text.map((rt) => rt.plain_text).join("");
1424
2261
  case "number":
1425
- return prop.number !== null && prop.number !== void 0 ? String(prop.number) : "";
2262
+ return prop.number !== null ? String(prop.number) : "";
1426
2263
  case "select":
1427
2264
  return prop.select?.name ?? "";
1428
2265
  case "status":
@@ -1430,9 +2267,10 @@ function displayPropertyValue(prop) {
1430
2267
  case "multi_select":
1431
2268
  return prop.multi_select.map((s) => s.name).join(", ");
1432
2269
  case "date":
1433
- return prop.date ? prop.date.end ? `${prop.date.start} \u2192 ${prop.date.end}` : prop.date.start : "";
2270
+ if (!prop.date) return "";
2271
+ return prop.date.end ? `${prop.date.start} \u2192 ${prop.date.end}` : prop.date.start;
1434
2272
  case "checkbox":
1435
- return prop.checkbox ? "\u2713" : "\u2717";
2273
+ return prop.checkbox ? "true" : "false";
1436
2274
  case "url":
1437
2275
  return prop.url ?? "";
1438
2276
  case "email":
@@ -1440,22 +2278,23 @@ function displayPropertyValue(prop) {
1440
2278
  case "phone_number":
1441
2279
  return prop.phone_number ?? "";
1442
2280
  case "people":
1443
- return prop.people.map((p) => "name" in p && p.name ? p.name : p.id).join(", ");
2281
+ return prop.people.map(formatUser).join(", ");
1444
2282
  case "relation":
1445
- return prop.relation.length > 0 ? `[${prop.relation.length}]` : "";
1446
- case "formula": {
1447
- const f = prop.formula;
1448
- if (f.type === "string") return f.string ?? "";
1449
- if (f.type === "number")
1450
- return f.number !== null && f.number !== void 0 ? String(f.number) : "";
1451
- if (f.type === "boolean") return f.boolean ? "true" : "false";
1452
- if (f.type === "date") return f.date?.start ?? "";
1453
- return "";
1454
- }
2283
+ return prop.relation.map((r) => r.id).join(", ");
2284
+ case "formula":
2285
+ return formatFormula(prop.formula);
2286
+ case "rollup":
2287
+ return formatRollup(prop.rollup);
1455
2288
  case "created_time":
1456
2289
  return prop.created_time;
1457
2290
  case "last_edited_time":
1458
2291
  return prop.last_edited_time;
2292
+ case "created_by":
2293
+ return "name" in prop.created_by ? prop.created_by.name ?? prop.created_by.id : prop.created_by.id;
2294
+ case "last_edited_by":
2295
+ return "name" in prop.last_edited_by ? prop.last_edited_by.name ?? prop.last_edited_by.id : prop.last_edited_by.id;
2296
+ case "files":
2297
+ return prop.files.map((f) => f.type === "external" ? f.external.url : f.name).join(", ");
1459
2298
  case "unique_id":
1460
2299
  return prop.unique_id.prefix ? `${prop.unique_id.prefix}-${prop.unique_id.number}` : String(prop.unique_id.number ?? "");
1461
2300
  default:
@@ -1463,95 +2302,354 @@ function displayPropertyValue(prop) {
1463
2302
  }
1464
2303
  }
1465
2304
 
1466
- // src/commands/db/schema.ts
1467
- function dbSchemaCommand() {
1468
- return new Command12("schema").description("Show database schema (property names, types, and options)").argument("<id>", "Notion database ID or URL").option("--json", "Output raw JSON").action(
1469
- withErrorHandling(async (id, options) => {
1470
- const { token } = await resolveToken();
1471
- const client = createNotionClient(token);
1472
- const dbId = parseNotionId(id);
1473
- const schema = await fetchDatabaseSchema(client, dbId);
1474
- if (options.json) {
1475
- process.stdout.write(formatJSON(schema) + "\n");
1476
- return;
1477
- }
1478
- const headers = ["PROPERTY", "TYPE", "OPTIONS"];
1479
- const rows = Object.values(schema.properties).map((prop) => [
1480
- prop.name,
1481
- prop.type,
1482
- prop.options ? prop.options.map((o) => o.name).join(", ") : ""
1483
- ]);
1484
- process.stdout.write(formatTable(rows, headers) + "\n");
1485
- })
2305
+ // src/blocks/render.ts
2306
+ function buildPropertiesHeader(page) {
2307
+ const lines = ["---"];
2308
+ for (const [name, prop] of Object.entries(page.properties)) {
2309
+ const value = formatPropertyValue(name, prop);
2310
+ if (value) {
2311
+ lines.push(`${name}: ${value}`);
2312
+ }
2313
+ }
2314
+ lines.push("---", "");
2315
+ return lines.join("\n");
2316
+ }
2317
+ function renderBlockTree(blocks) {
2318
+ const parts = [];
2319
+ let listCounter = 0;
2320
+ for (const node of blocks) {
2321
+ if (node.block.type === "numbered_list_item") {
2322
+ listCounter++;
2323
+ } else {
2324
+ listCounter = 0;
2325
+ }
2326
+ const childrenMd = node.children.length > 0 ? renderBlockTree(node.children) : "";
2327
+ const md = blockToMd(node.block, {
2328
+ listNumber: node.block.type === "numbered_list_item" ? listCounter : void 0,
2329
+ childrenMd: childrenMd || void 0
2330
+ });
2331
+ parts.push(md);
2332
+ }
2333
+ return parts.join("");
2334
+ }
2335
+ function renderPageMarkdown({ page, blocks }) {
2336
+ const header = buildPropertiesHeader(page);
2337
+ const content = renderBlockTree(blocks);
2338
+ return header + content;
2339
+ }
2340
+
2341
+ // src/output/markdown.ts
2342
+ import { Chalk as Chalk2 } from "chalk";
2343
+ var c = new Chalk2({ level: 3 });
2344
+ function handleFenceLine(line, state, out) {
2345
+ const fenceMatch = line.match(/^```(\w*)$/);
2346
+ if (fenceMatch && !state.inFence) {
2347
+ state.inFence = true;
2348
+ state.fenceLang = fenceMatch[1] ?? "";
2349
+ state.fenceLines = [];
2350
+ return true;
2351
+ }
2352
+ if (line === "```" && state.inFence) {
2353
+ state.inFence = false;
2354
+ const header = state.fenceLang ? c.dim(`[${state.fenceLang}]`) : "";
2355
+ if (header) out.push(header);
2356
+ for (const fl of state.fenceLines) {
2357
+ out.push(c.green(` ${fl}`));
2358
+ }
2359
+ out.push("");
2360
+ return true;
2361
+ }
2362
+ return false;
2363
+ }
2364
+ function handleHeading(line, out) {
2365
+ const h1 = line.match(/^# (.+)/);
2366
+ if (h1) {
2367
+ out.push(`
2368
+ ${c.bold.cyan(h1[1])}`);
2369
+ return true;
2370
+ }
2371
+ const h2 = line.match(/^## (.+)/);
2372
+ if (h2) {
2373
+ out.push(`
2374
+ ${c.bold.blue(h2[1])}`);
2375
+ return true;
2376
+ }
2377
+ const h3 = line.match(/^### (.+)/);
2378
+ if (h3) {
2379
+ out.push(`
2380
+ ${c.bold(h3[1])}`);
2381
+ return true;
2382
+ }
2383
+ const h4 = line.match(/^#### (.+)/);
2384
+ if (h4) {
2385
+ out.push(c.bold.underline(h4[1]));
2386
+ return true;
2387
+ }
2388
+ return false;
2389
+ }
2390
+ function handleListLine(line, out) {
2391
+ const bulletMatch = line.match(/^(\s*)- (\[[ x]\] )?(.+)/);
2392
+ if (bulletMatch) {
2393
+ const indent = bulletMatch[1] ?? "";
2394
+ const checkbox = bulletMatch[2];
2395
+ const text = bulletMatch[3] ?? "";
2396
+ if (checkbox) {
2397
+ const checked = checkbox.trim() === "[x]";
2398
+ const box = checked ? c.green("\u2611") : c.dim("\u2610");
2399
+ out.push(`${indent + box} ${renderInline(text)}`);
2400
+ } else {
2401
+ out.push(`${indent + c.cyan("\u2022")} ${renderInline(text)}`);
2402
+ }
2403
+ return true;
2404
+ }
2405
+ const numMatch = line.match(/^(\s*)(\d+)\. (.+)/);
2406
+ if (numMatch) {
2407
+ const indent = numMatch[1] ?? "";
2408
+ const num = numMatch[2] ?? "";
2409
+ const text = numMatch[3] ?? "";
2410
+ out.push(`${indent + c.cyan(`${num}.`)} ${renderInline(text)}`);
2411
+ return true;
2412
+ }
2413
+ return false;
2414
+ }
2415
+ function renderMarkdown(md) {
2416
+ if (!isatty()) return md;
2417
+ const lines = md.split("\n");
2418
+ const out = [];
2419
+ const fence = { inFence: false, fenceLang: "", fenceLines: [] };
2420
+ for (const line of lines) {
2421
+ if (handleFenceLine(line, fence, out)) continue;
2422
+ if (fence.inFence) {
2423
+ fence.fenceLines.push(line);
2424
+ continue;
2425
+ }
2426
+ if (line === "---") {
2427
+ out.push(c.dim("\u2500".repeat(40)));
2428
+ continue;
2429
+ }
2430
+ if (/^<!--.*-->$/.test(line.trim())) continue;
2431
+ if (handleHeading(line, out)) continue;
2432
+ if (line.startsWith("> ")) {
2433
+ out.push(c.yellow("\u258E ") + renderInline(line.slice(2)));
2434
+ continue;
2435
+ }
2436
+ const propMatch = line.match(/^([A-Za-z_][A-Za-z0-9_ ]*): (.+)$/);
2437
+ if (propMatch) {
2438
+ out.push(c.dim(`${propMatch[1]}: `) + c.white(propMatch[2]));
2439
+ continue;
2440
+ }
2441
+ if (handleListLine(line, out)) continue;
2442
+ out.push(renderInline(line));
2443
+ }
2444
+ return `${out.join("\n")}
2445
+ `;
2446
+ }
2447
+ function renderInline(text) {
2448
+ const codeSpans = [];
2449
+ let result = text.replace(/`([^`]+)`/g, (_, code) => {
2450
+ codeSpans.push(c.green(code));
2451
+ return `\0CODE${codeSpans.length - 1}\0`;
2452
+ });
2453
+ result = result.replace(
2454
+ /!\[([^\]]*)\]\([^)]+\)/g,
2455
+ (_, alt) => alt ? c.dim(`[image: ${alt}]`) : c.dim("[image]")
2456
+ ).replace(
2457
+ /\[([^\]]+)\]\(([^)]+)\)/g,
2458
+ (_, t, url) => c.cyan.underline(t) + c.dim(` (${url})`)
2459
+ ).replace(/\*\*\*(.+?)\*\*\*/g, (_, t) => c.bold.italic(t)).replace(/\*\*(.+?)\*\*/g, (_, t) => c.bold(t)).replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, (_, t) => c.italic(t)).replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, (_, t) => c.italic(t)).replace(/~~(.+?)~~/g, (_, t) => c.strikethrough(t));
2460
+ result = result.replace(
2461
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: sentinel chars are intentional
2462
+ /\x00CODE(\d+)\x00/g,
2463
+ (_, i) => codeSpans[Number(i)] ?? ""
1486
2464
  );
2465
+ return result;
1487
2466
  }
1488
2467
 
1489
- // src/commands/db/query.ts
1490
- import { Command as Command13 } from "commander";
1491
- var SKIP_TYPES_IN_AUTO = /* @__PURE__ */ new Set(["relation", "rich_text", "people"]);
1492
- function autoSelectColumns(schema, entries) {
1493
- const termWidth = process.stdout.columns || 120;
1494
- const COL_SEP = 2;
1495
- const candidates = Object.values(schema.properties).filter((p) => !SKIP_TYPES_IN_AUTO.has(p.type)).map((p) => p.name);
1496
- const widths = candidates.map((col) => {
1497
- const header = col.toUpperCase().length;
1498
- const maxData = entries.reduce((max, e) => Math.max(max, (e.properties[col] ?? "").length), 0);
1499
- return Math.min(Math.max(header, maxData), 40);
2468
+ // src/services/page.service.ts
2469
+ import {
2470
+ collectPaginatedAPI,
2471
+ isFullBlock
2472
+ } from "@notionhq/client";
2473
+ var MAX_CONCURRENT_REQUESTS = 3;
2474
+ async function fetchBlockTree(client, blockId, depth, maxDepth) {
2475
+ if (depth >= maxDepth) return [];
2476
+ const rawBlocks = await collectPaginatedAPI(client.blocks.children.list, {
2477
+ block_id: blockId
1500
2478
  });
1501
- const selected = [];
1502
- let usedWidth = 0;
1503
- for (let i = 0; i < candidates.length; i++) {
1504
- const needed = (selected.length > 0 ? COL_SEP : 0) + widths[i];
1505
- if (usedWidth + needed > termWidth) break;
1506
- selected.push(candidates[i]);
1507
- usedWidth += needed;
1508
- }
1509
- if (selected.length === 0 && candidates.length > 0) {
1510
- selected.push(candidates[0]);
2479
+ const blocks = rawBlocks.filter(isFullBlock);
2480
+ const SKIP_RECURSE = /* @__PURE__ */ new Set(["child_page", "child_database"]);
2481
+ const nodes = [];
2482
+ for (let i = 0; i < blocks.length; i += MAX_CONCURRENT_REQUESTS) {
2483
+ const batch = blocks.slice(i, i + MAX_CONCURRENT_REQUESTS);
2484
+ const batchNodes = await Promise.all(
2485
+ batch.map(async (block) => {
2486
+ const children = block.has_children && !SKIP_RECURSE.has(block.type) ? await fetchBlockTree(client, block.id, depth + 1, maxDepth) : [];
2487
+ return { block, children };
2488
+ })
2489
+ );
2490
+ nodes.push(...batchNodes);
1511
2491
  }
1512
- return selected;
2492
+ return nodes;
1513
2493
  }
1514
- function dbQueryCommand() {
1515
- return new Command13("query").description("Query database entries with optional filtering and sorting").argument("<id>", "Notion database ID or URL").option("--filter <filter>", 'Filter entries (repeatable): --filter "Status=Done"', collect, []).option("--sort <sort>", 'Sort entries (repeatable): --sort "Name:asc"', collect, []).option("--columns <columns>", 'Comma-separated list of columns to display: --columns "Title,Status"').option("--json", "Output raw JSON").action(
2494
+ async function fetchPageWithBlocks(client, pageId) {
2495
+ const page = await client.pages.retrieve({
2496
+ page_id: pageId
2497
+ });
2498
+ const blocks = await fetchBlockTree(client, pageId, 0, 10);
2499
+ return { page, blocks };
2500
+ }
2501
+
2502
+ // src/commands/read.ts
2503
+ function readCommand() {
2504
+ return new Command16("read").description("Read a Notion page as markdown").argument("<id>", "Notion page ID or URL").option("--json", "Output raw JSON instead of markdown").option("--md", "Output raw markdown (no terminal styling)").action(
1516
2505
  withErrorHandling(
1517
2506
  async (id, options) => {
1518
2507
  const { token } = await resolveToken();
1519
2508
  const client = createNotionClient(token);
1520
- const dbId = parseNotionId(id);
1521
- const schema = await fetchDatabaseSchema(client, dbId);
1522
- const columns = options.columns ? options.columns.split(",").map((c2) => c2.trim()) : void 0;
1523
- const filter = options.filter.length ? buildFilter(options.filter, schema) : void 0;
1524
- const sorts = options.sort.length ? buildSorts(options.sort) : void 0;
1525
- const entries = await queryDatabase(client, dbId, { filter, sorts, columns });
2509
+ const pageId = parseNotionId(id);
2510
+ const pageWithBlocks = await fetchPageWithBlocks(client, pageId);
1526
2511
  if (options.json) {
1527
- process.stdout.write(formatJSON(entries.map((e) => e.raw)) + "\n");
1528
- return;
2512
+ process.stdout.write(
2513
+ `${JSON.stringify(pageWithBlocks, null, 2)}
2514
+ `
2515
+ );
2516
+ } else {
2517
+ const markdown = renderPageMarkdown(pageWithBlocks);
2518
+ if (options.md || !isatty()) {
2519
+ process.stdout.write(markdown);
2520
+ } else {
2521
+ process.stdout.write(renderMarkdown(markdown));
2522
+ }
1529
2523
  }
1530
- if (entries.length === 0) {
1531
- process.stdout.write("No entries found.\n");
1532
- return;
2524
+ }
2525
+ )
2526
+ );
2527
+ }
2528
+
2529
+ // src/commands/search.ts
2530
+ import { isFullPageOrDataSource as isFullPageOrDataSource2 } from "@notionhq/client";
2531
+ import { Command as Command17 } from "commander";
2532
+ function getTitle2(item) {
2533
+ if (item.object === "data_source") {
2534
+ return item.title.map((t) => t.plain_text).join("") || "(untitled)";
2535
+ }
2536
+ const titleProp = Object.values(item.properties).find(
2537
+ (p) => p.type === "title"
2538
+ );
2539
+ if (titleProp?.type === "title") {
2540
+ return titleProp.title.map((t) => t.plain_text).join("") || "(untitled)";
2541
+ }
2542
+ return "(untitled)";
2543
+ }
2544
+ function toSdkFilterValue(type) {
2545
+ return type === "database" ? "data_source" : "page";
2546
+ }
2547
+ function displayType2(item) {
2548
+ return item.object === "data_source" ? "database" : item.object;
2549
+ }
2550
+ function searchCommand() {
2551
+ const cmd = new Command17("search");
2552
+ cmd.description("search Notion workspace by keyword").argument("<query>", "search keyword").option(
2553
+ "--type <type>",
2554
+ "filter by object type (page or database)",
2555
+ (val) => {
2556
+ if (val !== "page" && val !== "database") {
2557
+ throw new Error('--type must be "page" or "database"');
2558
+ }
2559
+ return val;
2560
+ }
2561
+ ).option(
2562
+ "--cursor <cursor>",
2563
+ "start from this pagination cursor (from a previous --next hint)"
2564
+ ).option("--json", "force JSON output").action(
2565
+ withErrorHandling(
2566
+ async (query, opts) => {
2567
+ if (opts.json) {
2568
+ setOutputMode("json");
1533
2569
  }
1534
- const displayColumns = columns ?? autoSelectColumns(schema, entries);
1535
- const headers = displayColumns.map((c2) => c2.toUpperCase());
1536
- const rows = entries.map(
1537
- (entry) => displayColumns.map((col) => entry.properties[col] ?? "")
2570
+ const { token, source } = await resolveToken();
2571
+ reportTokenSource(source);
2572
+ const notion = createNotionClient(token);
2573
+ const response = await notion.search({
2574
+ query,
2575
+ filter: opts.type ? { property: "object", value: toSdkFilterValue(opts.type) } : void 0,
2576
+ start_cursor: opts.cursor,
2577
+ page_size: 20
2578
+ });
2579
+ const fullResults = response.results.filter(
2580
+ (r) => isFullPageOrDataSource2(r)
1538
2581
  );
1539
- process.stdout.write(formatTable(rows, headers) + "\n");
1540
- process.stderr.write(`${entries.length} entries
2582
+ if (fullResults.length === 0) {
2583
+ process.stdout.write(`No results found for "${query}"
1541
2584
  `);
2585
+ return;
2586
+ }
2587
+ const headers = ["TYPE", "TITLE", "ID", "MODIFIED"];
2588
+ const rows = fullResults.map((item) => [
2589
+ displayType2(item),
2590
+ getTitle2(item),
2591
+ item.id,
2592
+ item.last_edited_time.split("T")[0]
2593
+ ]);
2594
+ printOutput(fullResults, headers, rows);
2595
+ if (response.has_more && response.next_cursor) {
2596
+ process.stderr.write(
2597
+ `
2598
+ --next: notion search "${query}" --cursor ${response.next_cursor}
2599
+ `
2600
+ );
2601
+ }
1542
2602
  }
1543
2603
  )
1544
2604
  );
2605
+ return cmd;
1545
2606
  }
1546
- function collect(value, previous) {
1547
- return previous.concat([value]);
2607
+
2608
+ // src/commands/users.ts
2609
+ import { Command as Command18 } from "commander";
2610
+ function getEmailOrWorkspace(user) {
2611
+ if (user.type === "person") {
2612
+ return user.person.email ?? "\u2014";
2613
+ }
2614
+ if (user.type === "bot") {
2615
+ const bot = user.bot;
2616
+ return "workspace_name" in bot && bot.workspace_name ? bot.workspace_name : "\u2014";
2617
+ }
2618
+ return "\u2014";
2619
+ }
2620
+ function usersCommand() {
2621
+ const cmd = new Command18("users");
2622
+ cmd.description("list all users in the workspace").option("--json", "output as JSON").action(
2623
+ withErrorHandling(async (opts) => {
2624
+ if (opts.json) setOutputMode("json");
2625
+ const { token, source } = await resolveToken();
2626
+ reportTokenSource(source);
2627
+ const notion = createNotionClient(token);
2628
+ const allUsers = await paginateResults(
2629
+ (cursor) => notion.users.list({ start_cursor: cursor })
2630
+ );
2631
+ const users = allUsers.filter(
2632
+ (u) => u.name !== void 0
2633
+ );
2634
+ const rows = users.map((user) => [
2635
+ user.type,
2636
+ user.name ?? "(unnamed)",
2637
+ getEmailOrWorkspace(user),
2638
+ user.id
2639
+ ]);
2640
+ printOutput(users, ["TYPE", "NAME", "EMAIL / WORKSPACE", "ID"], rows);
2641
+ })
2642
+ );
2643
+ return cmd;
1548
2644
  }
1549
2645
 
1550
2646
  // src/cli.ts
1551
2647
  var __filename = fileURLToPath(import.meta.url);
1552
2648
  var __dirname = dirname(__filename);
1553
- var pkg = JSON.parse(readFileSync(join3(__dirname, "../package.json"), "utf-8"));
1554
- var program = new Command14();
2649
+ var pkg = JSON.parse(
2650
+ readFileSync(join3(__dirname, "../package.json"), "utf-8")
2651
+ );
2652
+ var program = new Command19();
1555
2653
  program.name("notion").description("Notion CLI \u2014 read Notion pages and databases from the terminal").version(pkg.version);
1556
2654
  program.option("--verbose", "show API requests/responses").option("--color", "force color output").option("--json", "force JSON output (overrides TTY detection)").option("--md", "force markdown output for page content");
1557
2655
  program.configureOutput({
@@ -1572,19 +2670,31 @@ program.hook("preAction", (thisCommand) => {
1572
2670
  setOutputMode("md");
1573
2671
  }
1574
2672
  });
1575
- program.addCommand(initCommand());
1576
- var profileCmd = new Command14("profile").description("manage authentication profiles");
2673
+ var authCmd = new Command19("auth").description("manage Notion authentication");
2674
+ authCmd.action(authDefaultAction(authCmd));
2675
+ authCmd.addCommand(loginCommand());
2676
+ authCmd.addCommand(logoutCommand());
2677
+ authCmd.addCommand(statusCommand());
2678
+ authCmd.addCommand(profileListCommand());
2679
+ authCmd.addCommand(profileUseCommand());
2680
+ program.addCommand(authCmd);
2681
+ program.addCommand(initCommand(), { hidden: true });
2682
+ var profileCmd = new Command19("profile").description(
2683
+ "manage authentication profiles"
2684
+ );
1577
2685
  profileCmd.addCommand(profileListCommand());
1578
2686
  profileCmd.addCommand(profileUseCommand());
1579
- profileCmd.addCommand(profileRemoveCommand());
1580
- program.addCommand(profileCmd);
2687
+ program.addCommand(profileCmd, { hidden: true });
1581
2688
  program.addCommand(searchCommand());
1582
2689
  program.addCommand(lsCommand());
1583
2690
  program.addCommand(openCommand());
1584
2691
  program.addCommand(usersCommand());
1585
2692
  program.addCommand(commentsCommand());
1586
2693
  program.addCommand(readCommand());
1587
- var dbCmd = new Command14("db").description("Database operations");
2694
+ program.addCommand(commentAddCommand());
2695
+ program.addCommand(appendCommand());
2696
+ program.addCommand(createPageCommand());
2697
+ var dbCmd = new Command19("db").description("Database operations");
1588
2698
  dbCmd.addCommand(dbSchemaCommand());
1589
2699
  dbCmd.addCommand(dbQueryCommand());
1590
2700
  program.addCommand(dbCmd);