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