@andrzejchm/notion-cli 0.2.0 → 0.3.1

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,954 @@ 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 closeServer(ctx, cb) {
928
+ for (const socket of ctx.sockets) {
929
+ socket.destroy();
930
+ }
931
+ ctx.sockets.clear();
932
+ ctx.server.close(cb);
933
+ }
934
+ function handleCallbackRequest(req, res, ctx) {
935
+ if (ctx.settled) {
936
+ res.writeHead(200, { "Content-Type": "text/html" });
937
+ res.end(
938
+ "<html><body><h1>Already handled. You can close this tab.</h1></body></html>"
1042
939
  );
940
+ return;
1043
941
  }
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'
942
+ try {
943
+ const reqUrl = new URL(req.url ?? "/", "http://localhost:54321");
944
+ const code = reqUrl.searchParams.get("code");
945
+ const returnedState = reqUrl.searchParams.get("state");
946
+ const errorParam = reqUrl.searchParams.get("error");
947
+ if (errorParam === "access_denied") {
948
+ ctx.settled = true;
949
+ res.writeHead(200, { "Content-Type": "text/html" });
950
+ res.end(
951
+ "<html><body><h1>Access Denied</h1><p>You cancelled the Notion OAuth request. You can close this tab.</p></body></html>"
952
+ );
953
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
954
+ closeServer(ctx, () => {
955
+ ctx.reject(
956
+ new CliError(
957
+ ErrorCodes.AUTH_INVALID,
958
+ "Notion OAuth access was denied.",
959
+ 'Run "notion auth login" to try again'
960
+ )
961
+ );
962
+ });
963
+ return;
964
+ }
965
+ if (!code || !returnedState) {
966
+ res.writeHead(200, { "Content-Type": "text/html" });
967
+ res.end("<html><body><p>Waiting for OAuth callback...</p></body></html>");
968
+ return;
969
+ }
970
+ if (returnedState !== ctx.expectedState) {
971
+ ctx.settled = true;
972
+ res.writeHead(400, { "Content-Type": "text/html" });
973
+ res.end(
974
+ "<html><body><h1>Security Error</h1><p>State mismatch \u2014 possible CSRF attempt. You can close this tab.</p></body></html>"
975
+ );
976
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
977
+ closeServer(ctx, () => {
978
+ ctx.reject(
979
+ new CliError(
980
+ ErrorCodes.AUTH_INVALID,
981
+ "OAuth state mismatch \u2014 possible CSRF attempt. Aborting.",
982
+ 'Run "notion auth login" to start a fresh OAuth flow'
983
+ )
984
+ );
985
+ });
986
+ return;
987
+ }
988
+ ctx.settled = true;
989
+ res.writeHead(200, { "Content-Type": "text/html" });
990
+ res.end(
991
+ "<html><body><h1>Authenticated!</h1><p>You can close this tab and return to the terminal.</p></body></html>"
1049
992
  );
993
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
994
+ closeServer(ctx, () => {
995
+ ctx.resolve({ code, state: returnedState });
996
+ });
997
+ } catch {
998
+ res.writeHead(500, { "Content-Type": "text/html" });
999
+ res.end("<html><body><h1>Error processing callback</h1></body></html>");
1050
1000
  }
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
1001
  }
1059
- async function resolveOAuthToken(profileName, profile) {
1060
- if (!profile.oauth_access_token) return null;
1061
- if (!isOAuthExpired(profile)) {
1062
- return profile.oauth_access_token;
1002
+ async function runOAuthFlow(options) {
1003
+ const state = randomBytes(16).toString("hex");
1004
+ const authUrl = buildAuthUrl(state);
1005
+ if (options?.manual) {
1006
+ return manualFlow(authUrl);
1063
1007
  }
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'
1008
+ return new Promise((resolve, reject) => {
1009
+ const ctx = {
1010
+ expectedState: state,
1011
+ settled: false,
1012
+ timeoutHandle: null,
1013
+ resolve,
1014
+ reject,
1015
+ // assigned below after server is created
1016
+ server: null,
1017
+ sockets: /* @__PURE__ */ new Set()
1018
+ };
1019
+ const server = createServer(
1020
+ (req, res) => handleCallbackRequest(req, res, ctx)
1070
1021
  );
1022
+ ctx.server = server;
1023
+ server.on("connection", (socket) => {
1024
+ ctx.sockets.add(socket);
1025
+ socket.once("close", () => ctx.sockets.delete(socket));
1026
+ });
1027
+ server.on("error", (err) => {
1028
+ if (ctx.settled) return;
1029
+ ctx.settled = true;
1030
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
1031
+ reject(
1032
+ new CliError(
1033
+ ErrorCodes.AUTH_INVALID,
1034
+ `Failed to start OAuth callback server: ${err.message}`,
1035
+ "Make sure port 54321 is not in use, or use --manual flag"
1036
+ )
1037
+ );
1038
+ });
1039
+ server.listen(54321, "127.0.0.1", () => {
1040
+ const browserOpened = openBrowser(authUrl);
1041
+ if (!browserOpened) {
1042
+ server.close();
1043
+ ctx.settled = true;
1044
+ if (ctx.timeoutHandle) clearTimeout(ctx.timeoutHandle);
1045
+ manualFlow(authUrl).then(resolve, reject);
1046
+ return;
1047
+ }
1048
+ process.stderr.write(
1049
+ `
1050
+ Opening browser for Notion OAuth...
1051
+ If your browser didn't open, visit:
1052
+ ${authUrl}
1053
+
1054
+ Waiting for callback (up to 120 seconds)...
1055
+ `
1056
+ );
1057
+ ctx.timeoutHandle = setTimeout(() => {
1058
+ if (ctx.settled) return;
1059
+ ctx.settled = true;
1060
+ closeServer(ctx, () => {
1061
+ reject(
1062
+ new CliError(
1063
+ ErrorCodes.AUTH_INVALID,
1064
+ "OAuth login timed out after 120 seconds.",
1065
+ 'Run "notion auth login" to try again, or use --manual flag'
1066
+ )
1067
+ );
1068
+ });
1069
+ }, 12e4);
1070
+ });
1071
+ });
1072
+ }
1073
+
1074
+ // src/commands/init.ts
1075
+ import { confirm, input, password } from "@inquirer/prompts";
1076
+ import { Command as Command2 } from "commander";
1077
+ async function runInitFlow() {
1078
+ const profileName = await input({
1079
+ message: "Profile name:",
1080
+ default: "default"
1081
+ });
1082
+ const token = await password({
1083
+ message: "Integration token (from notion.so/profile/integrations/internal):",
1084
+ mask: "*"
1085
+ });
1086
+ stderrWrite("Validating token...");
1087
+ const { workspaceName, workspaceId } = await validateToken(token);
1088
+ stderrWrite(success(`\u2713 Connected to workspace: ${bold(workspaceName)}`));
1089
+ const config = await readGlobalConfig();
1090
+ if (config.profiles?.[profileName]) {
1091
+ const replace = await confirm({
1092
+ message: `Profile "${profileName}" already exists. Replace?`,
1093
+ default: false
1094
+ });
1095
+ if (!replace) {
1096
+ stderrWrite("Aborted.");
1097
+ return;
1098
+ }
1071
1099
  }
1100
+ const profiles = config.profiles ?? {};
1101
+ profiles[profileName] = {
1102
+ token,
1103
+ workspace_name: workspaceName,
1104
+ workspace_id: workspaceId
1105
+ };
1106
+ await writeGlobalConfig({
1107
+ ...config,
1108
+ profiles,
1109
+ active_profile: profileName
1110
+ });
1111
+ stderrWrite(success(`Profile "${profileName}" saved and set as active.`));
1112
+ stderrWrite(dim("Checking integration access..."));
1072
1113
  try {
1073
- const refreshed = await refreshAccessToken(profile.oauth_refresh_token);
1074
- await saveOAuthTokens(profileName, refreshed);
1075
- return refreshed.access_token;
1114
+ const notion = createNotionClient(token);
1115
+ const probe = await notion.search({ page_size: 1 });
1116
+ if (probe.results.length === 0) {
1117
+ stderrWrite("");
1118
+ stderrWrite("\u26A0\uFE0F Your integration has no pages connected.");
1119
+ stderrWrite(" To grant access, open any Notion page or database:");
1120
+ stderrWrite(" 1. Click \xB7\xB7\xB7 (three dots) in the top-right corner");
1121
+ stderrWrite(' 2. Select "Connect to"');
1122
+ stderrWrite(` 3. Choose "${workspaceName}"`);
1123
+ stderrWrite(" Then re-run any notion command to confirm access.");
1124
+ } else {
1125
+ stderrWrite(
1126
+ success(
1127
+ `\u2713 Integration has access to content in ${bold(workspaceName)}.`
1128
+ )
1129
+ );
1130
+ }
1076
1131
  } 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"
1132
+ stderrWrite(
1133
+ dim("(Could not verify integration access \u2014 run `notion ls` to check)")
1082
1134
  );
1083
1135
  }
1136
+ stderrWrite("");
1137
+ stderrWrite(
1138
+ dim("Write commands (comment, append, create-page) require additional")
1139
+ );
1140
+ stderrWrite(dim("capabilities in your integration settings:"));
1141
+ stderrWrite(
1142
+ dim(" notion.so/profile/integrations/internal \u2192 your integration \u2192")
1143
+ );
1144
+ stderrWrite(
1145
+ dim(
1146
+ ' Capabilities: enable "Read content", "Insert content", "Read comments", "Insert comments"'
1147
+ )
1148
+ );
1149
+ stderrWrite("");
1150
+ stderrWrite(
1151
+ dim("To post comments and create pages attributed to your user account:")
1152
+ );
1153
+ stderrWrite(dim(" notion auth login"));
1084
1154
  }
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
- }
1155
+ function initCommand() {
1156
+ const cmd = new Command2("init");
1157
+ cmd.description("authenticate with Notion and save a profile").action(
1158
+ withErrorHandling(async () => {
1159
+ if (!process.stdin.isTTY) {
1160
+ throw new CliError(
1161
+ ErrorCodes.AUTH_NO_TOKEN,
1162
+ "Cannot run interactive init in non-TTY mode.",
1163
+ "Set NOTION_API_TOKEN environment variable or create .notion.yaml"
1164
+ );
1106
1165
  }
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" };
1166
+ await runInitFlow();
1167
+ })
1168
+ );
1169
+ return cmd;
1170
+ }
1171
+
1172
+ // src/commands/auth/login.ts
1173
+ function loginCommand() {
1174
+ const cmd = new Command3("login");
1175
+ cmd.description("authenticate with Notion \u2014 choose OAuth or integration token").option("--profile <name>", "profile name to store credentials in").option(
1176
+ "--manual",
1177
+ "print auth URL instead of opening browser (for headless OAuth)"
1178
+ ).action(
1179
+ withErrorHandling(async (opts) => {
1180
+ if (!process.stdin.isTTY && !opts.manual) {
1181
+ throw new CliError(
1182
+ ErrorCodes.AUTH_NO_TOKEN,
1183
+ "Cannot run interactive login in non-TTY mode.",
1184
+ "Use --manual flag to get an auth URL you can open in a browser"
1185
+ );
1116
1186
  }
1117
- if (profile.token) {
1118
- return { token: profile.token, source: `profile: ${globalConfig.active_profile}` };
1187
+ const method = await select({
1188
+ message: "How do you want to authenticate with Notion?",
1189
+ choices: [
1190
+ {
1191
+ name: "OAuth user login (browser required)",
1192
+ value: "oauth",
1193
+ description: "Opens Notion in your browser. Comments and pages are attributed to your account. Tokens auto-refresh."
1194
+ },
1195
+ {
1196
+ name: "Internal integration token (CI/headless friendly)",
1197
+ value: "token",
1198
+ description: "Paste a token from notion.so/profile/integrations. No browser needed. Write ops attributed to integration bot."
1199
+ }
1200
+ ]
1201
+ });
1202
+ if (method === "oauth") {
1203
+ let profileName = opts.profile;
1204
+ if (!profileName) {
1205
+ const config = await readGlobalConfig();
1206
+ profileName = config.active_profile ?? "default";
1207
+ }
1208
+ const result = await runOAuthFlow({ manual: opts.manual });
1209
+ const response = await exchangeCode(result.code);
1210
+ await saveOAuthTokens(profileName, response);
1211
+ const userName = response.owner?.user?.name ?? "unknown user";
1212
+ const workspaceName = response.workspace_name ?? "unknown workspace";
1213
+ stderrWrite(
1214
+ success(`\u2713 Logged in as ${userName} to workspace ${workspaceName}`)
1215
+ );
1216
+ stderrWrite(
1217
+ dim(
1218
+ "Your comments and pages will now be attributed to your Notion account."
1219
+ )
1220
+ );
1221
+ } else {
1222
+ await runInitFlow();
1119
1223
  }
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'
1224
+ })
1126
1225
  );
1226
+ return cmd;
1127
1227
  }
1128
1228
 
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;
1229
+ // src/commands/auth/logout.ts
1230
+ import { select as select2 } from "@inquirer/prompts";
1231
+ import { Command as Command4 } from "commander";
1232
+ function profileLabel(name, profile) {
1233
+ const parts = [];
1234
+ if (profile.oauth_access_token)
1235
+ parts.push(
1236
+ `OAuth${profile.oauth_user_name ? ` (${profile.oauth_user_name})` : ""}`
1237
+ );
1238
+ if (profile.token) parts.push("integration token");
1239
+ const authDesc = parts.length > 0 ? parts.join(" + ") : "no credentials";
1240
+ const workspace = profile.workspace_name ? dim(` \u2014 ${profile.workspace_name}`) : "";
1241
+ return `${bold(name)} ${dim(authDesc)}${workspace}`;
1145
1242
  }
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
- `);
1243
+ function logoutCommand() {
1244
+ const cmd = new Command4("logout");
1245
+ cmd.description("remove a profile and its credentials").option(
1246
+ "--profile <name>",
1247
+ "profile name to remove (skips interactive selector)"
1248
+ ).action(
1249
+ withErrorHandling(async (opts) => {
1250
+ const config = await readGlobalConfig();
1251
+ const profiles = config.profiles ?? {};
1252
+ const profileNames = Object.keys(profiles);
1253
+ if (profileNames.length === 0) {
1254
+ stderrWrite("No profiles configured.");
1171
1255
  return;
1172
1256
  }
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
- `);
1257
+ let profileName = opts.profile;
1258
+ if (!profileName) {
1259
+ if (!process.stdin.isTTY) {
1260
+ throw new CliError(
1261
+ ErrorCodes.AUTH_NO_TOKEN,
1262
+ "Cannot run interactive logout in non-TTY mode.",
1263
+ "Use --profile <name> to specify the profile to remove"
1264
+ );
1265
+ }
1266
+ profileName = await select2({
1267
+ message: "Which profile do you want to log out of?",
1268
+ choices: profileNames.map((name) => ({
1269
+ // biome-ignore lint/style/noNonNullAssertion: key is from Object.keys, always present
1270
+ name: profileLabel(name, profiles[name]),
1271
+ value: name
1272
+ }))
1273
+ });
1274
+ }
1275
+ if (!profiles[profileName]) {
1276
+ throw new CliError(
1277
+ ErrorCodes.AUTH_PROFILE_NOT_FOUND,
1278
+ `Profile "${profileName}" not found.`,
1279
+ `Run "notion auth list" to see available profiles`
1280
+ );
1281
+ }
1282
+ const updatedProfiles = { ...profiles };
1283
+ delete updatedProfiles[profileName];
1284
+ const newActiveProfile = config.active_profile === profileName ? void 0 : config.active_profile;
1285
+ await writeGlobalConfig({
1286
+ ...config,
1287
+ profiles: updatedProfiles,
1288
+ active_profile: newActiveProfile
1289
+ });
1290
+ stderrWrite(success(`\u2713 Logged out of profile "${profileName}".`));
1291
+ if (newActiveProfile === void 0 && Object.keys(updatedProfiles).length > 0) {
1292
+ stderrWrite(
1293
+ dim(`Run "notion auth use <name>" to set a new active profile.`)
1294
+ );
1185
1295
  }
1186
1296
  })
1187
1297
  );
1188
1298
  return cmd;
1189
1299
  }
1190
1300
 
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(
1301
+ // src/commands/auth/status.ts
1302
+ import { Command as Command5 } from "commander";
1303
+ function statusCommand() {
1304
+ const cmd = new Command5("status");
1305
+ cmd.description("show authentication status for the active profile").option("--profile <name>", "profile name to check").action(
1215
1306
  withErrorHandling(async (opts) => {
1216
- if (opts.json) {
1217
- setOutputMode("json");
1307
+ let profileName = opts.profile;
1308
+ const config = await readGlobalConfig();
1309
+ if (!profileName) {
1310
+ profileName = config.active_profile ?? "default";
1311
+ }
1312
+ const profile = config.profiles?.[profileName];
1313
+ stderrWrite(`Profile: ${profileName}`);
1314
+ if (!profile) {
1315
+ stderrWrite(
1316
+ ` ${error("\u2717")} No profile found (run 'notion init' to create one)`
1317
+ );
1318
+ return;
1319
+ }
1320
+ if (profile.oauth_access_token) {
1321
+ const userName = profile.oauth_user_name ?? "unknown";
1322
+ const userId = profile.oauth_user_id ?? "unknown";
1323
+ stderrWrite(
1324
+ ` OAuth: ${success("\u2713")} Logged in as ${userName} (user: ${userId})`
1325
+ );
1326
+ if (profile.oauth_expiry_ms != null) {
1327
+ const expiryDate = new Date(profile.oauth_expiry_ms).toISOString();
1328
+ stderrWrite(dim(` Access token expires: ${expiryDate}`));
1329
+ }
1330
+ } else {
1331
+ stderrWrite(
1332
+ ` OAuth: ${error("\u2717")} Not logged in (run 'notion auth login')`
1333
+ );
1334
+ }
1335
+ if (profile.token) {
1336
+ const tokenPreview = `${profile.token.substring(0, 10)}...`;
1337
+ stderrWrite(
1338
+ ` Internal token: ${success("\u2713")} Configured (${tokenPreview})`
1339
+ );
1340
+ } else {
1341
+ stderrWrite(` Internal token: ${error("\u2717")} Not configured`);
1342
+ }
1343
+ if (profile.oauth_access_token) {
1344
+ stderrWrite(` Active method: OAuth (user-attributed)`);
1345
+ } else if (profile.token) {
1346
+ stderrWrite(
1347
+ ` Active method: Internal integration token (bot-attributed)`
1348
+ );
1349
+ } else {
1350
+ stderrWrite(
1351
+ dim(
1352
+ ` Active method: None (run 'notion auth login' or 'notion init')`
1353
+ )
1354
+ );
1218
1355
  }
1356
+ })
1357
+ );
1358
+ return cmd;
1359
+ }
1360
+
1361
+ // src/commands/comment-add.ts
1362
+ import { Command as Command6 } from "commander";
1363
+ function commentAddCommand() {
1364
+ const cmd = new Command6("comment");
1365
+ 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(
1366
+ withErrorHandling(async (idOrUrl, opts) => {
1219
1367
  const { token, source } = await resolveToken();
1220
1368
  reportTokenSource(source);
1221
- const notion = createNotionClient(token);
1222
- const response = await notion.search({
1223
- start_cursor: opts.cursor,
1224
- page_size: 20
1369
+ const client = createNotionClient(token);
1370
+ const id = parseNotionId(idOrUrl);
1371
+ const uuid = toUuid(id);
1372
+ await addComment(client, uuid, opts.message, {
1373
+ asUser: source === "oauth"
1225
1374
  });
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
- }
1375
+ process.stdout.write("Comment added.\n");
1250
1376
  })
1251
1377
  );
1252
1378
  return cmd;
1253
1379
  }
1254
1380
 
1255
- // src/commands/open.ts
1256
- import { exec } from "child_process";
1257
- import { promisify } from "util";
1258
- import { Command as Command11 } from "commander";
1381
+ // src/commands/comments.ts
1382
+ import { Command as Command7 } from "commander";
1259
1383
 
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
- );
1384
+ // src/output/format.ts
1385
+ var _mode = "auto";
1386
+ function setOutputMode(mode) {
1387
+ _mode = mode;
1270
1388
  }
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);
1389
+ function getOutputMode() {
1390
+ return _mode;
1284
1391
  }
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)}`;
1392
+ function isatty() {
1393
+ return Boolean(process.stdout.isTTY);
1287
1394
  }
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}
1395
+ function isHumanMode() {
1396
+ if (_mode === "json") return false;
1397
+ if (_mode === "md") return false;
1398
+ return true;
1399
+ }
1400
+ function formatJSON(data) {
1401
+ return JSON.stringify(data, null, 2);
1402
+ }
1403
+ var COLUMN_CAPS = {
1404
+ TITLE: 50,
1405
+ ID: 36
1406
+ };
1407
+ var DEFAULT_MAX_COL_WIDTH = 40;
1408
+ function getColumnCap(header) {
1409
+ return COLUMN_CAPS[header.toUpperCase()] ?? DEFAULT_MAX_COL_WIDTH;
1410
+ }
1411
+ function truncate(str, maxLen) {
1412
+ if (str.length <= maxLen) return str;
1413
+ return `${str.slice(0, maxLen - 1)}\u2026`;
1414
+ }
1415
+ function formatTable(rows, headers) {
1416
+ const colWidths = headers.map((header, colIdx) => {
1417
+ const cap = getColumnCap(header);
1418
+ const headerLen = header.length;
1419
+ const maxRowLen = rows.reduce((max, row) => {
1420
+ const cell = row[colIdx] ?? "";
1421
+ return Math.max(max, cell.length);
1422
+ }, 0);
1423
+ return Math.min(Math.max(headerLen, maxRowLen), cap);
1424
+ });
1425
+ const sep = "\u2500";
1426
+ const colSep = " ";
1427
+ const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(colSep);
1428
+ const separatorRow = colWidths.map((w) => sep.repeat(w)).join(colSep);
1429
+ const dataRows = rows.map(
1430
+ (row) => headers.map((_, i) => {
1431
+ const cell = row[i] ?? "";
1432
+ return truncate(cell, colWidths[i]).padEnd(colWidths[i]);
1433
+ }).join(colSep)
1434
+ );
1435
+ return [headerRow, separatorRow, ...dataRows].join("\n");
1436
+ }
1437
+ function printOutput(data, tableHeaders, tableRows) {
1438
+ const mode = getOutputMode();
1439
+ if (mode === "json") {
1440
+ process.stdout.write(`${formatJSON(data)}
1300
1441
  `);
1301
- }));
1302
- return cmd;
1442
+ } else if (isHumanMode() && tableHeaders && tableRows) {
1443
+ printWithPager(`${formatTable(tableRows, tableHeaders)}
1444
+ `);
1445
+ }
1446
+ }
1447
+ function printWithPager(text) {
1448
+ process.stdout.write(text);
1303
1449
  }
1304
-
1305
- // src/commands/users.ts
1306
- import { Command as Command12 } from "commander";
1307
1450
 
1308
1451
  // src/output/paginate.ts
1309
1452
  async function paginateResults(fetcher) {
@@ -1319,264 +1462,337 @@ async function paginateResults(fetcher) {
1319
1462
  return allResults;
1320
1463
  }
1321
1464
 
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
- }));
1465
+ // src/commands/comments.ts
1466
+ function commentsCommand() {
1467
+ const cmd = new Command7("comments");
1468
+ cmd.description("list comments on a Notion page").argument("<id/url>", "Notion page ID or URL").option("--json", "output as JSON").action(
1469
+ withErrorHandling(async (idOrUrl, opts) => {
1470
+ if (opts.json) setOutputMode("json");
1471
+ const id = parseNotionId(idOrUrl);
1472
+ const uuid = toUuid(id);
1473
+ const { token, source } = await resolveToken();
1474
+ reportTokenSource(source);
1475
+ const notion = createNotionClient(token);
1476
+ const comments = await paginateResults(
1477
+ (cursor) => notion.comments.list({ block_id: uuid, start_cursor: cursor })
1478
+ );
1479
+ if (comments.length === 0) {
1480
+ process.stdout.write("No comments found on this page\n");
1481
+ return;
1482
+ }
1483
+ const rows = comments.map((comment) => {
1484
+ const text = comment.rich_text.map((t) => t.plain_text).join("");
1485
+ return [
1486
+ comment.created_time.split("T")[0],
1487
+ `${comment.created_by.id.slice(0, 8)}...`,
1488
+ text.slice(0, 80) + (text.length > 80 ? "\u2026" : "")
1489
+ ];
1490
+ });
1491
+ printOutput(comments, ["DATE", "AUTHOR ID", "COMMENT"], rows);
1492
+ })
1493
+ );
1352
1494
  return cmd;
1353
1495
  }
1354
1496
 
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
- }));
1497
+ // src/commands/completion.ts
1498
+ import { Command as Command8 } from "commander";
1499
+ var BASH_COMPLETION = `# notion bash completion
1500
+ _notion_completion() {
1501
+ local cur prev words cword
1502
+ _init_completion || return
1503
+
1504
+ local commands="init profile completion --help --version --verbose --color"
1505
+ local profile_commands="list use remove"
1506
+
1507
+ case "$prev" in
1508
+ notion)
1509
+ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
1510
+ return 0
1511
+ ;;
1512
+ profile)
1513
+ COMPREPLY=($(compgen -W "$profile_commands" -- "$cur"))
1514
+ return 0
1515
+ ;;
1516
+ completion)
1517
+ COMPREPLY=($(compgen -W "bash zsh fish" -- "$cur"))
1518
+ return 0
1519
+ ;;
1520
+ esac
1521
+
1522
+ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
1523
+ }
1524
+
1525
+ complete -F _notion_completion notion
1526
+ `;
1527
+ var ZSH_COMPLETION = `#compdef notion
1528
+ # notion zsh completion
1529
+
1530
+ _notion() {
1531
+ local -a commands
1532
+
1533
+ commands=(
1534
+ 'init:authenticate with Notion and save a profile'
1535
+ 'profile:manage authentication profiles'
1536
+ 'completion:output shell completion script'
1537
+ )
1538
+
1539
+ local -a global_opts
1540
+ global_opts=(
1541
+ '--help[display help]'
1542
+ '--version[output version]'
1543
+ '--verbose[show API requests/responses]'
1544
+ '--color[force color output]'
1545
+ )
1546
+
1547
+ if (( CURRENT == 2 )); then
1548
+ _describe 'command' commands
1549
+ _arguments $global_opts
1550
+ return
1551
+ fi
1552
+
1553
+ case $words[2] in
1554
+ profile)
1555
+ local -a profile_cmds
1556
+ profile_cmds=(
1557
+ 'list:list all authentication profiles'
1558
+ 'use:switch the active profile'
1559
+ 'remove:remove an authentication profile'
1560
+ )
1561
+ _describe 'profile command' profile_cmds
1562
+ ;;
1563
+ completion)
1564
+ local -a shells
1565
+ shells=('bash' 'zsh' 'fish')
1566
+ _describe 'shell' shells
1567
+ ;;
1568
+ esac
1569
+ }
1570
+
1571
+ _notion "$@"
1572
+ `;
1573
+ var FISH_COMPLETION = `# notion fish completion
1574
+
1575
+ # Disable file completion by default
1576
+ complete -c notion -f
1577
+
1578
+ # Global options
1579
+ complete -c notion -l help -d 'display help'
1580
+ complete -c notion -l version -d 'output version'
1581
+ complete -c notion -l verbose -d 'show API requests/responses'
1582
+ complete -c notion -l color -d 'force color output'
1583
+
1584
+ # Top-level commands
1585
+ complete -c notion -n '__fish_use_subcommand' -a init -d 'authenticate with Notion and save a profile'
1586
+ complete -c notion -n '__fish_use_subcommand' -a profile -d 'manage authentication profiles'
1587
+ complete -c notion -n '__fish_use_subcommand' -a completion -d 'output shell completion script'
1588
+
1589
+ # profile subcommands
1590
+ complete -c notion -n '__fish_seen_subcommand_from profile' -a list -d 'list all authentication profiles'
1591
+ complete -c notion -n '__fish_seen_subcommand_from profile' -a use -d 'switch the active profile'
1592
+ complete -c notion -n '__fish_seen_subcommand_from profile' -a remove -d 'remove an authentication profile'
1593
+
1594
+ # completion shells
1595
+ complete -c notion -n '__fish_seen_subcommand_from completion' -a bash -d 'bash completion script'
1596
+ complete -c notion -n '__fish_seen_subcommand_from completion' -a zsh -d 'zsh completion script'
1597
+ complete -c notion -n '__fish_seen_subcommand_from completion' -a fish -d 'fish completion script'
1598
+ `;
1599
+ function completionCommand() {
1600
+ const cmd = new Command8("completion");
1601
+ cmd.description("output shell completion script").argument("<shell>", "shell type (bash, zsh, fish)").action(
1602
+ withErrorHandling(async (shell) => {
1603
+ switch (shell) {
1604
+ case "bash":
1605
+ process.stdout.write(BASH_COMPLETION);
1606
+ break;
1607
+ case "zsh":
1608
+ process.stdout.write(ZSH_COMPLETION);
1609
+ break;
1610
+ case "fish":
1611
+ process.stdout.write(FISH_COMPLETION);
1612
+ break;
1613
+ default:
1614
+ throw new CliError(
1615
+ ErrorCodes.UNKNOWN,
1616
+ `Unknown shell: "${shell}".`,
1617
+ "Supported shells: bash, zsh, fish"
1618
+ );
1619
+ }
1620
+ })
1621
+ );
1383
1622
  return cmd;
1384
1623
  }
1385
1624
 
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);
1625
+ // src/commands/create-page.ts
1626
+ import { Command as Command9 } from "commander";
1627
+ async function readStdin() {
1628
+ const chunks = [];
1629
+ for await (const chunk of process.stdin) {
1630
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1409
1631
  }
1410
- return nodes;
1632
+ return Buffer.concat(chunks).toString("utf-8");
1411
1633
  }
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 };
1634
+ function createPageCommand() {
1635
+ const cmd = new Command9("create-page");
1636
+ 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(
1637
+ "-m, --message <markdown>",
1638
+ "inline markdown content for the page body"
1639
+ ).action(
1640
+ withErrorHandling(
1641
+ async (opts) => {
1642
+ const { token, source } = await resolveToken();
1643
+ reportTokenSource(source);
1644
+ const client = createNotionClient(token);
1645
+ let markdown = "";
1646
+ if (opts.message) {
1647
+ markdown = opts.message;
1648
+ } else if (!process.stdin.isTTY) {
1649
+ markdown = await readStdin();
1650
+ }
1651
+ const blocks = mdToBlocks(markdown);
1652
+ const parentUuid = toUuid(parseNotionId(opts.parent));
1653
+ const url = await createPage(client, parentUuid, opts.title, blocks);
1654
+ process.stdout.write(`${url}
1655
+ `);
1656
+ }
1657
+ )
1658
+ );
1659
+ return cmd;
1416
1660
  }
1417
1661
 
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
- }
1662
+ // src/commands/db/query.ts
1663
+ import { Command as Command10 } from "commander";
1441
1664
 
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);
1665
+ // src/services/database.service.ts
1666
+ import { isFullPage } from "@notionhq/client";
1667
+ async function fetchDatabaseSchema(client, dbId) {
1668
+ const ds = await client.dataSources.retrieve({ data_source_id: dbId });
1669
+ const title = "title" in ds ? ds.title.map((rt) => rt.plain_text).join("") || dbId : dbId;
1670
+ const properties = {};
1671
+ if ("properties" in ds) {
1672
+ for (const [name, prop] of Object.entries(ds.properties)) {
1673
+ const config = {
1674
+ id: prop.id,
1675
+ name,
1676
+ type: prop.type
1677
+ };
1678
+ if (prop.type === "select" && "select" in prop) {
1679
+ config.options = prop.select.options;
1680
+ } else if (prop.type === "status" && "status" in prop) {
1681
+ config.options = prop.status.options;
1682
+ } else if (prop.type === "multi_select" && "multi_select" in prop) {
1683
+ config.options = prop.multi_select.options;
1684
+ }
1685
+ properties[name] = config;
1474
1686
  }
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
- `;
1687
+ }
1688
+ return { id: dbId, title, properties };
1689
+ }
1690
+ async function queryDatabase(client, dbId, opts = {}) {
1691
+ const rawPages = await paginateResults(
1692
+ (cursor) => client.dataSources.query({
1693
+ data_source_id: dbId,
1694
+ filter: opts.filter,
1695
+ sorts: opts.sorts,
1696
+ start_cursor: cursor,
1697
+ page_size: 100
1698
+ })
1699
+ );
1700
+ return rawPages.filter(isFullPage).map((page) => {
1701
+ const propValues = {};
1702
+ for (const [name, prop] of Object.entries(page.properties)) {
1703
+ if (opts.columns && !opts.columns.includes(name)) continue;
1704
+ propValues[name] = displayPropertyValue(prop);
1513
1705
  }
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;
1706
+ return { id: page.id, properties: propValues, raw: page };
1707
+ });
1708
+ }
1709
+ function buildFilter(filterStrings, schema) {
1710
+ if (!filterStrings.length) return void 0;
1711
+ const filters = filterStrings.map((raw) => {
1712
+ const eqIdx = raw.indexOf("=");
1713
+ if (eqIdx === -1) {
1714
+ throw new CliError(
1715
+ ErrorCodes.INVALID_ARG,
1716
+ `Invalid filter syntax: "${raw}"`,
1717
+ 'Use format: --filter "PropertyName=Value"'
1718
+ );
1523
1719
  }
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
- `;
1720
+ const propName = raw.slice(0, eqIdx).trim();
1721
+ const value = raw.slice(eqIdx + 1).trim();
1722
+ const propConfig = schema.properties[propName];
1723
+ if (!propConfig) {
1724
+ const available = Object.keys(schema.properties).join(", ");
1725
+ throw new CliError(
1726
+ ErrorCodes.INVALID_ARG,
1727
+ `Property "${propName}" not found`,
1728
+ `Available properties: ${available}`
1729
+ );
1534
1730
  }
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);
1731
+ return buildPropertyFilter(propName, propConfig.type, value);
1732
+ });
1733
+ return filters.length === 1 ? filters[0] : { and: filters };
1734
+ }
1735
+ function buildPropertyFilter(property, type, value) {
1736
+ switch (type) {
1737
+ case "select":
1738
+ return { property, select: { equals: value } };
1739
+ case "status":
1740
+ return { property, status: { equals: value } };
1741
+ case "multi_select":
1742
+ return { property, multi_select: { contains: value } };
1743
+ case "checkbox":
1744
+ return { property, checkbox: { equals: value.toLowerCase() === "true" } };
1745
+ case "number":
1746
+ return { property, number: { equals: Number(value) } };
1747
+ case "title":
1748
+ return { property, title: { contains: value } };
1749
+ case "rich_text":
1750
+ return { property, rich_text: { contains: value } };
1751
+ case "url":
1752
+ return { property, url: { contains: value } };
1753
+ case "email":
1754
+ return { property, email: { contains: value } };
1755
+ default:
1756
+ throw new CliError(
1757
+ ErrorCodes.INVALID_ARG,
1758
+ `Filtering by property type "${type}" is not supported`
1759
+ );
1566
1760
  }
1567
- return `<!-- unsupported block: ${block.type} -->
1568
- `;
1569
1761
  }
1570
-
1571
- // src/blocks/properties.ts
1572
- function formatPropertyValue(name, prop) {
1762
+ function buildSorts(sortStrings) {
1763
+ return sortStrings.map((raw) => {
1764
+ const colonIdx = raw.lastIndexOf(":");
1765
+ if (colonIdx === -1) {
1766
+ return { property: raw.trim(), direction: "ascending" };
1767
+ }
1768
+ const property = raw.slice(0, colonIdx).trim();
1769
+ const dir = raw.slice(colonIdx + 1).trim().toLowerCase();
1770
+ return {
1771
+ property,
1772
+ direction: dir === "desc" || dir === "descending" ? "descending" : "ascending"
1773
+ };
1774
+ });
1775
+ }
1776
+ function displayFormula(f) {
1777
+ if (f.type === "string") return f.string ?? "";
1778
+ if (f.type === "number")
1779
+ return f.number !== null && f.number !== void 0 ? String(f.number) : "";
1780
+ if (f.type === "boolean") return f.boolean ? "true" : "false";
1781
+ if (f.type === "date") return f.date?.start ?? "";
1782
+ return "";
1783
+ }
1784
+ function displayDate(date) {
1785
+ if (!date) return "";
1786
+ return date.end ? `${date.start} \u2192 ${date.end}` : date.start;
1787
+ }
1788
+ function displayPropertyValue(prop) {
1573
1789
  switch (prop.type) {
1574
1790
  case "title":
1575
- return prop.title.map((rt) => rt.plain_text).join("");
1791
+ return prop.title.map((r) => r.plain_text).join("").replace(/\n/g, " ");
1576
1792
  case "rich_text":
1577
- return prop.rich_text.map((rt) => rt.plain_text).join("");
1793
+ return prop.rich_text.map((r) => r.plain_text).join("").replace(/\n/g, " ");
1578
1794
  case "number":
1579
- return prop.number !== null ? String(prop.number) : "";
1795
+ return prop.number !== null && prop.number !== void 0 ? String(prop.number) : "";
1580
1796
  case "select":
1581
1797
  return prop.select?.name ?? "";
1582
1798
  case "status":
@@ -1584,10 +1800,9 @@ function formatPropertyValue(name, prop) {
1584
1800
  case "multi_select":
1585
1801
  return prop.multi_select.map((s) => s.name).join(", ");
1586
1802
  case "date":
1587
- if (!prop.date) return "";
1588
- return prop.date.end ? `${prop.date.start} \u2192 ${prop.date.end}` : prop.date.start;
1803
+ return displayDate(prop.date);
1589
1804
  case "checkbox":
1590
- return prop.checkbox ? "true" : "false";
1805
+ return prop.checkbox ? "\u2713" : "\u2717";
1591
1806
  case "url":
1592
1807
  return prop.url ?? "";
1593
1808
  case "email":
@@ -1597,35 +1812,13 @@ function formatPropertyValue(name, prop) {
1597
1812
  case "people":
1598
1813
  return prop.people.map((p) => "name" in p && p.name ? p.name : p.id).join(", ");
1599
1814
  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
- }
1815
+ return prop.relation.length > 0 ? `[${prop.relation.length}]` : "";
1816
+ case "formula":
1817
+ return displayFormula(prop.formula);
1616
1818
  case "created_time":
1617
1819
  return prop.created_time;
1618
1820
  case "last_edited_time":
1619
1821
  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
1822
  case "unique_id":
1630
1823
  return prop.unique_id.prefix ? `${prop.unique_id.prefix}-${prop.unique_id.number}` : String(prop.unique_id.number ?? "");
1631
1824
  default:
@@ -1633,295 +1826,452 @@ function formatPropertyValue(name, prop) {
1633
1826
  }
1634
1827
  }
1635
1828
 
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
- }
1829
+ // src/commands/db/query.ts
1830
+ var SKIP_TYPES_IN_AUTO = /* @__PURE__ */ new Set(["relation", "rich_text", "people"]);
1831
+ function autoSelectColumns(schema, entries) {
1832
+ const termWidth = process.stdout.columns || 120;
1833
+ const COL_SEP = 2;
1834
+ const candidates = Object.values(schema.properties).filter((p) => !SKIP_TYPES_IN_AUTO.has(p.type)).map((p) => p.name);
1835
+ const widths = candidates.map((col) => {
1836
+ const header = col.toUpperCase().length;
1837
+ const maxData = entries.reduce(
1838
+ (max, e) => Math.max(max, (e.properties[col] ?? "").length),
1839
+ 0
1840
+ );
1841
+ return Math.min(Math.max(header, maxData), 40);
1842
+ });
1843
+ const selected = [];
1844
+ let usedWidth = 0;
1845
+ for (let i = 0; i < candidates.length; i++) {
1846
+ const needed = (selected.length > 0 ? COL_SEP : 0) + widths[i];
1847
+ if (usedWidth + needed > termWidth) break;
1848
+ selected.push(candidates[i]);
1849
+ usedWidth += needed;
1644
1850
  }
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);
1851
+ if (selected.length === 0 && candidates.length > 0) {
1852
+ selected.push(candidates[0]);
1663
1853
  }
1664
- return parts.join("");
1854
+ return selected;
1665
1855
  }
1666
- function renderPageMarkdown({ page, blocks }) {
1667
- const header = buildPropertiesHeader(page);
1668
- const content = renderBlockTree(blocks);
1669
- return header + content;
1856
+ function dbQueryCommand() {
1857
+ return new Command10("query").description("Query database entries with optional filtering and sorting").argument("<id>", "Notion database ID or URL").option(
1858
+ "--filter <filter>",
1859
+ 'Filter entries (repeatable): --filter "Status=Done"',
1860
+ collect,
1861
+ []
1862
+ ).option(
1863
+ "--sort <sort>",
1864
+ 'Sort entries (repeatable): --sort "Name:asc"',
1865
+ collect,
1866
+ []
1867
+ ).option(
1868
+ "--columns <columns>",
1869
+ 'Comma-separated list of columns to display: --columns "Title,Status"'
1870
+ ).option("--json", "Output raw JSON").action(
1871
+ withErrorHandling(
1872
+ async (id, options) => {
1873
+ const { token } = await resolveToken();
1874
+ const client = createNotionClient(token);
1875
+ const dbId = parseNotionId(id);
1876
+ const schema = await fetchDatabaseSchema(client, dbId);
1877
+ const columns = options.columns ? options.columns.split(",").map((c2) => c2.trim()) : void 0;
1878
+ const filter = options.filter.length ? buildFilter(options.filter, schema) : void 0;
1879
+ const sorts = options.sort.length ? buildSorts(options.sort) : void 0;
1880
+ const entries = await queryDatabase(client, dbId, {
1881
+ filter,
1882
+ sorts,
1883
+ columns
1884
+ });
1885
+ if (options.json) {
1886
+ process.stdout.write(`${formatJSON(entries.map((e) => e.raw))}
1887
+ `);
1888
+ return;
1889
+ }
1890
+ if (entries.length === 0) {
1891
+ process.stdout.write("No entries found.\n");
1892
+ return;
1893
+ }
1894
+ const displayColumns = columns ?? autoSelectColumns(schema, entries);
1895
+ const headers = displayColumns.map((c2) => c2.toUpperCase());
1896
+ const rows = entries.map(
1897
+ (entry) => displayColumns.map((col) => entry.properties[col] ?? "")
1898
+ );
1899
+ process.stdout.write(`${formatTable(rows, headers)}
1900
+ `);
1901
+ process.stderr.write(`${entries.length} entries
1902
+ `);
1903
+ }
1904
+ )
1905
+ );
1906
+ }
1907
+ function collect(value, previous) {
1908
+ return previous.concat([value]);
1670
1909
  }
1671
1910
 
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));
1911
+ // src/commands/db/schema.ts
1912
+ import { Command as Command11 } from "commander";
1913
+ function dbSchemaCommand() {
1914
+ 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(
1915
+ withErrorHandling(async (id, options) => {
1916
+ const { token } = await resolveToken();
1917
+ const client = createNotionClient(token);
1918
+ const dbId = parseNotionId(id);
1919
+ const schema = await fetchDatabaseSchema(client, dbId);
1920
+ if (options.json) {
1921
+ process.stdout.write(`${formatJSON(schema)}
1922
+ `);
1923
+ return;
1696
1924
  }
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));
1925
+ const headers = ["PROPERTY", "TYPE", "OPTIONS"];
1926
+ const rows = Object.values(schema.properties).map((prop) => [
1927
+ prop.name,
1928
+ prop.type,
1929
+ prop.options ? prop.options.map((o) => o.name).join(", ") : ""
1930
+ ]);
1931
+ process.stdout.write(`${formatTable(rows, headers)}
1932
+ `);
1933
+ })
1934
+ );
1935
+ }
1936
+
1937
+ // src/commands/ls.ts
1938
+ import { isFullPageOrDataSource } from "@notionhq/client";
1939
+ import { Command as Command12 } from "commander";
1940
+ function getTitle(item) {
1941
+ if (item.object === "data_source") {
1942
+ return item.title.map((t) => t.plain_text).join("") || "(untitled)";
1943
+ }
1944
+ const titleProp = Object.values(item.properties).find(
1945
+ (p) => p.type === "title"
1946
+ );
1947
+ if (titleProp?.type === "title") {
1948
+ return titleProp.title.map((t) => t.plain_text).join("") || "(untitled)";
1949
+ }
1950
+ return "(untitled)";
1951
+ }
1952
+ function displayType(item) {
1953
+ return item.object === "data_source" ? "database" : item.object;
1954
+ }
1955
+ function lsCommand() {
1956
+ const cmd = new Command12("ls");
1957
+ cmd.description("list accessible Notion pages and databases").option(
1958
+ "--type <type>",
1959
+ "filter by object type (page or database)",
1960
+ (val) => {
1961
+ if (val !== "page" && val !== "database") {
1962
+ throw new Error('--type must be "page" or "database"');
1755
1963
  }
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;
1964
+ return val;
1765
1965
  }
1766
- out.push(renderInline(line));
1767
- }
1768
- return out.join("\n") + "\n";
1966
+ ).option(
1967
+ "--cursor <cursor>",
1968
+ "start from this pagination cursor (from a previous --next hint)"
1969
+ ).option("--json", "force JSON output").action(
1970
+ withErrorHandling(
1971
+ async (opts) => {
1972
+ if (opts.json) {
1973
+ setOutputMode("json");
1974
+ }
1975
+ const { token, source } = await resolveToken();
1976
+ reportTokenSource(source);
1977
+ const notion = createNotionClient(token);
1978
+ const response = await notion.search({
1979
+ start_cursor: opts.cursor,
1980
+ page_size: 20
1981
+ });
1982
+ let items = response.results.filter(
1983
+ (r) => isFullPageOrDataSource(r)
1984
+ );
1985
+ if (opts.type) {
1986
+ const filterType = opts.type;
1987
+ items = items.filter(
1988
+ (r) => filterType === "database" ? r.object === "data_source" : r.object === filterType
1989
+ );
1990
+ }
1991
+ if (items.length === 0) {
1992
+ process.stdout.write("No accessible content found\n");
1993
+ return;
1994
+ }
1995
+ const headers = ["TYPE", "TITLE", "ID", "MODIFIED"];
1996
+ const rows = items.map((item) => [
1997
+ displayType(item),
1998
+ getTitle(item),
1999
+ item.id,
2000
+ item.last_edited_time.split("T")[0]
2001
+ ]);
2002
+ printOutput(items, headers, rows);
2003
+ if (response.has_more && response.next_cursor) {
2004
+ process.stderr.write(
2005
+ `
2006
+ --next: notion ls --cursor ${response.next_cursor}
2007
+ `
2008
+ );
2009
+ }
2010
+ }
2011
+ )
2012
+ );
2013
+ return cmd;
1769
2014
  }
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;
2015
+
2016
+ // src/commands/open.ts
2017
+ import { exec } from "child_process";
2018
+ import { promisify } from "util";
2019
+ import { Command as Command13 } from "commander";
2020
+ var execAsync = promisify(exec);
2021
+ function openCommand() {
2022
+ const cmd = new Command13("open");
2023
+ cmd.description("open a Notion page in the default browser").argument("<id/url>", "Notion page ID or URL").action(
2024
+ withErrorHandling(async (idOrUrl) => {
2025
+ const id = parseNotionId(idOrUrl);
2026
+ const url = `https://www.notion.so/${id}`;
2027
+ const platform = process.platform;
2028
+ const opener = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
2029
+ await execAsync(`${opener} "${url}"`);
2030
+ process.stdout.write(`Opening ${url}
2031
+ `);
2032
+ })
2033
+ );
2034
+ return cmd;
1779
2035
  }
1780
2036
 
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
- }
2037
+ // src/commands/profile/list.ts
2038
+ import { Command as Command14 } from "commander";
2039
+ function profileListCommand() {
2040
+ const cmd = new Command14("list");
2041
+ cmd.description("list all authentication profiles").action(
2042
+ withErrorHandling(async () => {
2043
+ const config = await readGlobalConfig();
2044
+ const profiles = config.profiles ?? {};
2045
+ const profileNames = Object.keys(profiles);
2046
+ if (profileNames.length === 0) {
2047
+ process.stdout.write(
2048
+ "No profiles configured. Run `notion auth login` to get started.\n"
2049
+ );
2050
+ return;
2051
+ }
2052
+ for (const name of profileNames) {
2053
+ const profile = profiles[name];
2054
+ const isActive = config.active_profile === name;
2055
+ const marker = isActive ? bold("* ") : " ";
2056
+ const activeLabel = isActive ? " (active)" : "";
2057
+ const workspaceInfo = profile.workspace_name ? dim(` \u2014 ${profile.workspace_name}`) : "";
2058
+ process.stdout.write(
2059
+ `${marker}${name}${activeLabel}${workspaceInfo}
2060
+ `
2061
+ );
1798
2062
  }
1799
2063
  })
1800
2064
  );
2065
+ return cmd;
1801
2066
  }
1802
2067
 
1803
- // src/commands/db/schema.ts
2068
+ // src/commands/profile/use.ts
1804
2069
  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;
2070
+ function profileUseCommand() {
2071
+ const cmd = new Command15("use");
2072
+ cmd.description("switch the active profile").argument("<name>", "profile name to activate").action(
2073
+ withErrorHandling(async (name) => {
2074
+ const config = await readGlobalConfig();
2075
+ const profiles = config.profiles ?? {};
2076
+ if (!profiles[name]) {
2077
+ throw new CliError(
2078
+ ErrorCodes.AUTH_PROFILE_NOT_FOUND,
2079
+ `Profile "${name}" not found.`,
2080
+ `Run "notion auth list" to see available profiles`
2081
+ );
1825
2082
  }
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
2083
+ await writeGlobalConfig({
2084
+ ...config,
2085
+ active_profile: name
2086
+ });
2087
+ stderrWrite(success(`Switched to profile "${name}".`));
1839
2088
  })
1840
2089
  );
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
- });
2090
+ return cmd;
1849
2091
  }
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 };
2092
+
2093
+ // src/commands/read.ts
2094
+ import { Command as Command16 } from "commander";
2095
+
2096
+ // src/blocks/rich-text.ts
2097
+ function richTextToMd(richText) {
2098
+ return richText.map(segmentToMd).join("");
1875
2099
  }
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
- );
2100
+ function segmentToMd(segment) {
2101
+ if (segment.type === "equation") {
2102
+ return `$${segment.equation.expression}$`;
2103
+ }
2104
+ if (segment.type === "mention") {
2105
+ const text = segment.plain_text;
2106
+ return segment.href ? `[${text}](${segment.href})` : text;
1901
2107
  }
2108
+ const annotated = applyAnnotations(segment.text.content, segment.annotations);
2109
+ return segment.text.link ? `[${annotated}](${segment.text.link.url})` : annotated;
1902
2110
  }
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" };
2111
+ function applyAnnotations(text, annotations) {
2112
+ let result = text;
2113
+ if (annotations.code) result = `\`${result}\``;
2114
+ if (annotations.strikethrough) result = `~~${result}~~`;
2115
+ if (annotations.italic) result = `_${result}_`;
2116
+ if (annotations.bold) result = `**${result}**`;
2117
+ return result;
2118
+ }
2119
+
2120
+ // src/blocks/converters.ts
2121
+ function indentChildren(childrenMd) {
2122
+ return `${childrenMd.split("\n").filter(Boolean).map((line) => ` ${line}`).join("\n")}
2123
+ `;
2124
+ }
2125
+ var converters = {
2126
+ paragraph(block) {
2127
+ const b = block;
2128
+ return `${richTextToMd(b.paragraph.rich_text)}
2129
+ `;
2130
+ },
2131
+ heading_1(block) {
2132
+ const b = block;
2133
+ return `# ${richTextToMd(b.heading_1.rich_text)}
2134
+ `;
2135
+ },
2136
+ heading_2(block) {
2137
+ const b = block;
2138
+ return `## ${richTextToMd(b.heading_2.rich_text)}
2139
+ `;
2140
+ },
2141
+ heading_3(block) {
2142
+ const b = block;
2143
+ return `### ${richTextToMd(b.heading_3.rich_text)}
2144
+ `;
2145
+ },
2146
+ bulleted_list_item(block, ctx) {
2147
+ const b = block;
2148
+ const text = richTextToMd(b.bulleted_list_item.rich_text);
2149
+ const header = `- ${text}
2150
+ `;
2151
+ if (ctx?.childrenMd) {
2152
+ return header + indentChildren(ctx.childrenMd);
2153
+ }
2154
+ return header;
2155
+ },
2156
+ numbered_list_item(block, ctx) {
2157
+ const b = block;
2158
+ const num = ctx?.listNumber ?? 1;
2159
+ return `${num}. ${richTextToMd(b.numbered_list_item.rich_text)}
2160
+ `;
2161
+ },
2162
+ to_do(block) {
2163
+ const b = block;
2164
+ const checkbox = b.to_do.checked ? "[x]" : "[ ]";
2165
+ return `- ${checkbox} ${richTextToMd(b.to_do.rich_text)}
2166
+ `;
2167
+ },
2168
+ code(block) {
2169
+ const b = block;
2170
+ const lang = b.code.language === "plain text" ? "" : b.code.language;
2171
+ const content = richTextToMd(b.code.rich_text);
2172
+ return `\`\`\`${lang}
2173
+ ${content}
2174
+ \`\`\`
2175
+ `;
2176
+ },
2177
+ quote(block) {
2178
+ const b = block;
2179
+ return `> ${richTextToMd(b.quote.rich_text)}
2180
+ `;
2181
+ },
2182
+ divider() {
2183
+ return "---\n";
2184
+ },
2185
+ callout(block) {
2186
+ const b = block;
2187
+ const text = richTextToMd(b.callout.rich_text);
2188
+ const icon = b.callout.icon;
2189
+ if (icon?.type === "emoji") {
2190
+ return `> ${icon.emoji} ${text}
2191
+ `;
2192
+ }
2193
+ return `> ${text}
2194
+ `;
2195
+ },
2196
+ toggle(block, ctx) {
2197
+ const b = block;
2198
+ const header = `**${richTextToMd(b.toggle.rich_text)}**
2199
+ `;
2200
+ if (ctx?.childrenMd) {
2201
+ return header + ctx.childrenMd;
2202
+ }
2203
+ return header;
2204
+ },
2205
+ image(block) {
2206
+ const b = block;
2207
+ const caption = richTextToMd(b.image.caption);
2208
+ if (b.image.type === "file") {
2209
+ const url2 = b.image.file.url;
2210
+ const expiry = b.image.file.expiry_time;
2211
+ return `![${caption}](${url2}) <!-- expires: ${expiry} -->
2212
+ `;
1908
2213
  }
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
- });
2214
+ const url = b.image.external.url;
2215
+ return `![${caption}](${url})
2216
+ `;
2217
+ },
2218
+ bookmark(block) {
2219
+ const b = block;
2220
+ const caption = richTextToMd(b.bookmark.caption);
2221
+ const text = caption || b.bookmark.url;
2222
+ return `[${text}](${b.bookmark.url})
2223
+ `;
2224
+ },
2225
+ child_page(block) {
2226
+ const b = block;
2227
+ return `### ${b.child_page.title}
2228
+ `;
2229
+ },
2230
+ child_database(block) {
2231
+ const b = block;
2232
+ return `### ${b.child_database.title}
2233
+ `;
2234
+ },
2235
+ link_preview(block) {
2236
+ const b = block;
2237
+ return `[${b.link_preview.url}](${b.link_preview.url})
2238
+ `;
2239
+ }
2240
+ };
2241
+ function blockToMd(block, ctx) {
2242
+ const converter = converters[block.type];
2243
+ if (converter) {
2244
+ return converter(block, ctx);
2245
+ }
2246
+ return `<!-- unsupported block: ${block.type} -->
2247
+ `;
1916
2248
  }
1917
- function displayPropertyValue(prop) {
2249
+
2250
+ // src/blocks/properties.ts
2251
+ function formatFormula(f) {
2252
+ if (f.type === "string") return f.string ?? "";
2253
+ if (f.type === "number") return f.number !== null ? String(f.number) : "";
2254
+ if (f.type === "boolean") return String(f.boolean);
2255
+ if (f.type === "date") return f.date?.start ?? "";
2256
+ return "";
2257
+ }
2258
+ function formatRollup(r) {
2259
+ if (r.type === "number") return r.number !== null ? String(r.number) : "";
2260
+ if (r.type === "date") return r.date?.start ?? "";
2261
+ if (r.type === "array") return `[${r.array.length} items]`;
2262
+ return "";
2263
+ }
2264
+ function formatUser(p) {
2265
+ return "name" in p && p.name ? p.name : p.id;
2266
+ }
2267
+ function formatPropertyValue(_name, prop) {
1918
2268
  switch (prop.type) {
1919
2269
  case "title":
1920
- return prop.title.map((r) => r.plain_text).join("").replace(/\n/g, " ");
2270
+ return prop.title.map((rt) => rt.plain_text).join("");
1921
2271
  case "rich_text":
1922
- return prop.rich_text.map((r) => r.plain_text).join("").replace(/\n/g, " ");
2272
+ return prop.rich_text.map((rt) => rt.plain_text).join("");
1923
2273
  case "number":
1924
- return prop.number !== null && prop.number !== void 0 ? String(prop.number) : "";
2274
+ return prop.number !== null ? String(prop.number) : "";
1925
2275
  case "select":
1926
2276
  return prop.select?.name ?? "";
1927
2277
  case "status":
@@ -1929,9 +2279,10 @@ function displayPropertyValue(prop) {
1929
2279
  case "multi_select":
1930
2280
  return prop.multi_select.map((s) => s.name).join(", ");
1931
2281
  case "date":
1932
- return prop.date ? prop.date.end ? `${prop.date.start} \u2192 ${prop.date.end}` : prop.date.start : "";
2282
+ if (!prop.date) return "";
2283
+ return prop.date.end ? `${prop.date.start} \u2192 ${prop.date.end}` : prop.date.start;
1933
2284
  case "checkbox":
1934
- return prop.checkbox ? "\u2713" : "\u2717";
2285
+ return prop.checkbox ? "true" : "false";
1935
2286
  case "url":
1936
2287
  return prop.url ?? "";
1937
2288
  case "email":
@@ -1939,22 +2290,23 @@ function displayPropertyValue(prop) {
1939
2290
  case "phone_number":
1940
2291
  return prop.phone_number ?? "";
1941
2292
  case "people":
1942
- return prop.people.map((p) => "name" in p && p.name ? p.name : p.id).join(", ");
2293
+ return prop.people.map(formatUser).join(", ");
1943
2294
  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
- }
2295
+ return prop.relation.map((r) => r.id).join(", ");
2296
+ case "formula":
2297
+ return formatFormula(prop.formula);
2298
+ case "rollup":
2299
+ return formatRollup(prop.rollup);
1954
2300
  case "created_time":
1955
2301
  return prop.created_time;
1956
2302
  case "last_edited_time":
1957
2303
  return prop.last_edited_time;
2304
+ case "created_by":
2305
+ return "name" in prop.created_by ? prop.created_by.name ?? prop.created_by.id : prop.created_by.id;
2306
+ case "last_edited_by":
2307
+ return "name" in prop.last_edited_by ? prop.last_edited_by.name ?? prop.last_edited_by.id : prop.last_edited_by.id;
2308
+ case "files":
2309
+ return prop.files.map((f) => f.type === "external" ? f.external.url : f.name).join(", ");
1958
2310
  case "unique_id":
1959
2311
  return prop.unique_id.prefix ? `${prop.unique_id.prefix}-${prop.unique_id.number}` : String(prop.unique_id.number ?? "");
1960
2312
  default:
@@ -1962,375 +2314,354 @@ function displayPropertyValue(prop) {
1962
2314
  }
1963
2315
  }
1964
2316
 
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]);
2317
+ // src/blocks/render.ts
2318
+ function buildPropertiesHeader(page) {
2319
+ const lines = ["---"];
2320
+ for (const [name, prop] of Object.entries(page.properties)) {
2321
+ const value = formatPropertyValue(name, prop);
2322
+ if (value) {
2323
+ lines.push(`${name}: ${value}`);
2324
+ }
2010
2325
  }
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;
2326
+ lines.push("---", "");
2327
+ return lines.join("\n");
2105
2328
  }
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;
2329
+ function renderBlockTree(blocks) {
2330
+ const parts = [];
2331
+ let listCounter = 0;
2332
+ for (const node of blocks) {
2333
+ if (node.block.type === "numbered_list_item") {
2334
+ listCounter++;
2335
+ } else {
2336
+ listCounter = 0;
2144
2337
  }
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;
2338
+ const childrenMd = node.children.length > 0 ? renderBlockTree(node.children) : "";
2339
+ const md = blockToMd(node.block, {
2340
+ listNumber: node.block.type === "numbered_list_item" ? listCounter : void 0,
2341
+ childrenMd: childrenMd || void 0
2342
+ });
2343
+ parts.push(md);
2344
+ }
2345
+ return parts.join("");
2346
+ }
2347
+ function renderPageMarkdown({ page, blocks }) {
2348
+ const header = buildPropertiesHeader(page);
2349
+ const content = renderBlockTree(blocks);
2350
+ return header + content;
2351
+ }
2352
+
2353
+ // src/output/markdown.ts
2354
+ import { Chalk as Chalk2 } from "chalk";
2355
+ var c = new Chalk2({ level: 3 });
2356
+ function handleFenceLine(line, state, out) {
2357
+ const fenceMatch = line.match(/^```(\w*)$/);
2358
+ if (fenceMatch && !state.inFence) {
2359
+ state.inFence = true;
2360
+ state.fenceLang = fenceMatch[1] ?? "";
2361
+ state.fenceLines = [];
2362
+ return true;
2363
+ }
2364
+ if (line === "```" && state.inFence) {
2365
+ state.inFence = false;
2366
+ const header = state.fenceLang ? c.dim(`[${state.fenceLang}]`) : "";
2367
+ if (header) out.push(header);
2368
+ for (const fl of state.fenceLines) {
2369
+ out.push(c.green(` ${fl}`));
2154
2370
  }
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
- });
2371
+ out.push("");
2372
+ return true;
2160
2373
  }
2161
- return result;
2374
+ return false;
2162
2375
  }
2163
- function makeRichText(text) {
2164
- return parseInlineMarkdown(text);
2376
+ function handleHeading(line, out) {
2377
+ const h1 = line.match(/^# (.+)/);
2378
+ if (h1) {
2379
+ out.push(`
2380
+ ${c.bold.cyan(h1[1])}`);
2381
+ return true;
2382
+ }
2383
+ const h2 = line.match(/^## (.+)/);
2384
+ if (h2) {
2385
+ out.push(`
2386
+ ${c.bold.blue(h2[1])}`);
2387
+ return true;
2388
+ }
2389
+ const h3 = line.match(/^### (.+)/);
2390
+ if (h3) {
2391
+ out.push(`
2392
+ ${c.bold(h3[1])}`);
2393
+ return true;
2394
+ }
2395
+ const h4 = line.match(/^#### (.+)/);
2396
+ if (h4) {
2397
+ out.push(c.bold.underline(h4[1]));
2398
+ return true;
2399
+ }
2400
+ return false;
2401
+ }
2402
+ function handleListLine(line, out) {
2403
+ const bulletMatch = line.match(/^(\s*)- (\[[ x]\] )?(.+)/);
2404
+ if (bulletMatch) {
2405
+ const indent = bulletMatch[1] ?? "";
2406
+ const checkbox = bulletMatch[2];
2407
+ const text = bulletMatch[3] ?? "";
2408
+ if (checkbox) {
2409
+ const checked = checkbox.trim() === "[x]";
2410
+ const box = checked ? c.green("\u2611") : c.dim("\u2610");
2411
+ out.push(`${indent + box} ${renderInline(text)}`);
2412
+ } else {
2413
+ out.push(`${indent + c.cyan("\u2022")} ${renderInline(text)}`);
2414
+ }
2415
+ return true;
2416
+ }
2417
+ const numMatch = line.match(/^(\s*)(\d+)\. (.+)/);
2418
+ if (numMatch) {
2419
+ const indent = numMatch[1] ?? "";
2420
+ const num = numMatch[2] ?? "";
2421
+ const text = numMatch[3] ?? "";
2422
+ out.push(`${indent + c.cyan(`${num}.`)} ${renderInline(text)}`);
2423
+ return true;
2424
+ }
2425
+ return false;
2165
2426
  }
2166
- function mdToBlocks(md) {
2167
- if (!md) return [];
2427
+ function renderMarkdown(md) {
2428
+ if (!isatty()) return md;
2168
2429
  const lines = md.split("\n");
2169
- const blocks = [];
2170
- let inFence = false;
2171
- let fenceLang = "";
2172
- const fenceLines = [];
2430
+ const out = [];
2431
+ const fence = { inFence: false, fenceLang: "", fenceLines: [] };
2173
2432
  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
- });
2433
+ if (handleFenceLine(line, fence, out)) continue;
2434
+ if (fence.inFence) {
2435
+ fence.fenceLines.push(line);
2228
2436
  continue;
2229
2437
  }
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
- });
2438
+ if (line === "---") {
2439
+ out.push(c.dim("\u2500".repeat(40)));
2236
2440
  continue;
2237
2441
  }
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
- });
2442
+ if (/^<!--.*-->$/.test(line.trim())) continue;
2443
+ if (handleHeading(line, out)) continue;
2444
+ if (line.startsWith("> ")) {
2445
+ out.push(c.yellow("\u258E ") + renderInline(line.slice(2)));
2244
2446
  continue;
2245
2447
  }
2246
- const quote = line.match(/^> (.+)$/);
2247
- if (quote) {
2248
- blocks.push({
2249
- type: "quote",
2250
- quote: { rich_text: makeRichText(quote[1]) }
2251
- });
2448
+ const propMatch = line.match(/^([A-Za-z_][A-Za-z0-9_ ]*): (.+)$/);
2449
+ if (propMatch) {
2450
+ out.push(c.dim(`${propMatch[1]}: `) + c.white(propMatch[2]));
2252
2451
  continue;
2253
2452
  }
2254
- blocks.push({
2255
- type: "paragraph",
2256
- paragraph: { rich_text: makeRichText(line) }
2257
- });
2453
+ if (handleListLine(line, out)) continue;
2454
+ out.push(renderInline(line));
2258
2455
  }
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" }
2456
+ return `${out.join("\n")}
2457
+ `;
2458
+ }
2459
+ function renderInline(text) {
2460
+ const codeSpans = [];
2461
+ let result = text.replace(/`([^`]+)`/g, (_, code) => {
2462
+ codeSpans.push(c.green(code));
2463
+ return `\0CODE${codeSpans.length - 1}\0`;
2464
+ });
2465
+ result = result.replace(
2466
+ /!\[([^\]]*)\]\([^)]+\)/g,
2467
+ (_, alt) => alt ? c.dim(`[image: ${alt}]`) : c.dim("[image]")
2468
+ ).replace(
2469
+ /\[([^\]]+)\]\(([^)]+)\)/g,
2470
+ (_, t, url) => c.cyan.underline(t) + c.dim(` (${url})`)
2471
+ ).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));
2472
+ result = result.replace(
2473
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: sentinel chars are intentional
2474
+ /\x00CODE(\d+)\x00/g,
2475
+ (_, i) => codeSpans[Number(i)] ?? ""
2476
+ );
2477
+ return result;
2478
+ }
2479
+
2480
+ // src/services/page.service.ts
2481
+ import {
2482
+ collectPaginatedAPI,
2483
+ isFullBlock
2484
+ } from "@notionhq/client";
2485
+ var MAX_CONCURRENT_REQUESTS = 3;
2486
+ async function fetchBlockTree(client, blockId, depth, maxDepth) {
2487
+ if (depth >= maxDepth) return [];
2488
+ const rawBlocks = await collectPaginatedAPI(client.blocks.children.list, {
2489
+ block_id: blockId
2490
+ });
2491
+ const blocks = rawBlocks.filter(isFullBlock);
2492
+ const SKIP_RECURSE = /* @__PURE__ */ new Set(["child_page", "child_database"]);
2493
+ const nodes = [];
2494
+ for (let i = 0; i < blocks.length; i += MAX_CONCURRENT_REQUESTS) {
2495
+ const batch = blocks.slice(i, i + MAX_CONCURRENT_REQUESTS);
2496
+ const batchNodes = await Promise.all(
2497
+ batch.map(async (block) => {
2498
+ const children = block.has_children && !SKIP_RECURSE.has(block.type) ? await fetchBlockTree(client, block.id, depth + 1, maxDepth) : [];
2499
+ return { block, children };
2500
+ })
2501
+ );
2502
+ nodes.push(...batchNodes);
2503
+ }
2504
+ return nodes;
2505
+ }
2506
+ async function fetchPageWithBlocks(client, pageId) {
2507
+ const page = await client.pages.retrieve({
2508
+ page_id: pageId
2509
+ });
2510
+ const blocks = await fetchBlockTree(client, pageId, 0, 10);
2511
+ return { page, blocks };
2512
+ }
2513
+
2514
+ // src/commands/read.ts
2515
+ function readCommand() {
2516
+ 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(
2517
+ withErrorHandling(
2518
+ async (id, options) => {
2519
+ const { token } = await resolveToken();
2520
+ const client = createNotionClient(token);
2521
+ const pageId = parseNotionId(id);
2522
+ const pageWithBlocks = await fetchPageWithBlocks(client, pageId);
2523
+ if (options.json) {
2524
+ process.stdout.write(
2525
+ `${JSON.stringify(pageWithBlocks, null, 2)}
2526
+ `
2527
+ );
2528
+ } else {
2529
+ const markdown = renderPageMarkdown(pageWithBlocks);
2530
+ if (options.md || !isatty()) {
2531
+ process.stdout.write(markdown);
2532
+ } else {
2533
+ process.stdout.write(renderMarkdown(markdown));
2269
2534
  }
2270
- ],
2271
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2272
- language: fenceLang
2535
+ }
2273
2536
  }
2274
- });
2275
- }
2276
- return blocks;
2537
+ )
2538
+ );
2277
2539
  }
2278
2540
 
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;
2541
+ // src/commands/search.ts
2542
+ import { isFullPageOrDataSource as isFullPageOrDataSource2 } from "@notionhq/client";
2543
+ import { Command as Command17 } from "commander";
2544
+ function getTitle2(item) {
2545
+ if (item.object === "data_source") {
2546
+ return item.title.map((t) => t.plain_text).join("") || "(untitled)";
2547
+ }
2548
+ const titleProp = Object.values(item.properties).find(
2549
+ (p) => p.type === "title"
2550
+ );
2551
+ if (titleProp?.type === "title") {
2552
+ return titleProp.title.map((t) => t.plain_text).join("") || "(untitled)";
2553
+ }
2554
+ return "(untitled)";
2555
+ }
2556
+ function toSdkFilterValue(type) {
2557
+ return type === "database" ? "data_source" : "page";
2558
+ }
2559
+ function displayType2(item) {
2560
+ return item.object === "data_source" ? "database" : item.object;
2561
+ }
2562
+ function searchCommand() {
2563
+ const cmd = new Command17("search");
2564
+ cmd.description("search Notion workspace by keyword").argument("<query>", "search keyword").option(
2565
+ "--type <type>",
2566
+ "filter by object type (page or database)",
2567
+ (val) => {
2568
+ if (val !== "page" && val !== "database") {
2569
+ throw new Error('--type must be "page" or "database"');
2570
+ }
2571
+ return val;
2292
2572
  }
2293
- await appendBlocks(client, uuid, blocks);
2294
- process.stdout.write(`Appended ${blocks.length} block(s).
2573
+ ).option(
2574
+ "--cursor <cursor>",
2575
+ "start from this pagination cursor (from a previous --next hint)"
2576
+ ).option("--json", "force JSON output").action(
2577
+ withErrorHandling(
2578
+ async (query, opts) => {
2579
+ if (opts.json) {
2580
+ setOutputMode("json");
2581
+ }
2582
+ const { token, source } = await resolveToken();
2583
+ reportTokenSource(source);
2584
+ const notion = createNotionClient(token);
2585
+ const response = await notion.search({
2586
+ query,
2587
+ filter: opts.type ? { property: "object", value: toSdkFilterValue(opts.type) } : void 0,
2588
+ start_cursor: opts.cursor,
2589
+ page_size: 20
2590
+ });
2591
+ const fullResults = response.results.filter(
2592
+ (r) => isFullPageOrDataSource2(r)
2593
+ );
2594
+ if (fullResults.length === 0) {
2595
+ process.stdout.write(`No results found for "${query}"
2295
2596
  `);
2296
- }));
2597
+ return;
2598
+ }
2599
+ const headers = ["TYPE", "TITLE", "ID", "MODIFIED"];
2600
+ const rows = fullResults.map((item) => [
2601
+ displayType2(item),
2602
+ getTitle2(item),
2603
+ item.id,
2604
+ item.last_edited_time.split("T")[0]
2605
+ ]);
2606
+ printOutput(fullResults, headers, rows);
2607
+ if (response.has_more && response.next_cursor) {
2608
+ process.stderr.write(
2609
+ `
2610
+ --next: notion search "${query}" --cursor ${response.next_cursor}
2611
+ `
2612
+ );
2613
+ }
2614
+ }
2615
+ )
2616
+ );
2297
2617
  return cmd;
2298
2618
  }
2299
2619
 
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));
2620
+ // src/commands/users.ts
2621
+ import { Command as Command18 } from "commander";
2622
+ function getEmailOrWorkspace(user) {
2623
+ if (user.type === "person") {
2624
+ return user.person.email ?? "\u2014";
2306
2625
  }
2307
- return Buffer.concat(chunks).toString("utf-8");
2626
+ if (user.type === "bot") {
2627
+ const bot = user.bot;
2628
+ return "workspace_name" in bot && bot.workspace_name ? bot.workspace_name : "\u2014";
2629
+ }
2630
+ return "\u2014";
2308
2631
  }
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
- }));
2632
+ function usersCommand() {
2633
+ const cmd = new Command18("users");
2634
+ cmd.description("list all users in the workspace").option("--json", "output as JSON").action(
2635
+ withErrorHandling(async (opts) => {
2636
+ if (opts.json) setOutputMode("json");
2637
+ const { token, source } = await resolveToken();
2638
+ reportTokenSource(source);
2639
+ const notion = createNotionClient(token);
2640
+ const allUsers = await paginateResults(
2641
+ (cursor) => notion.users.list({ start_cursor: cursor })
2642
+ );
2643
+ const users = allUsers.filter(
2644
+ (u) => u.name !== void 0
2645
+ );
2646
+ const rows = users.map((user) => [
2647
+ user.type,
2648
+ user.name ?? "(unnamed)",
2649
+ getEmailOrWorkspace(user),
2650
+ user.id
2651
+ ]);
2652
+ printOutput(users, ["TYPE", "NAME", "EMAIL / WORKSPACE", "ID"], rows);
2653
+ })
2654
+ );
2326
2655
  return cmd;
2327
2656
  }
2328
2657
 
2329
2658
  // src/cli.ts
2330
2659
  var __filename = fileURLToPath(import.meta.url);
2331
2660
  var __dirname = dirname(__filename);
2332
- var pkg = JSON.parse(readFileSync(join3(__dirname, "../package.json"), "utf-8"));
2333
- var program = new Command20();
2661
+ var pkg = JSON.parse(
2662
+ readFileSync(join3(__dirname, "../package.json"), "utf-8")
2663
+ );
2664
+ var program = new Command19();
2334
2665
  program.name("notion").description("Notion CLI \u2014 read Notion pages and databases from the terminal").version(pkg.version);
2335
2666
  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
2667
  program.configureOutput({
@@ -2351,17 +2682,21 @@ program.hook("preAction", (thisCommand) => {
2351
2682
  setOutputMode("md");
2352
2683
  }
2353
2684
  });
2354
- program.addCommand(initCommand());
2355
- var authCmd = new Command20("auth").description("manage Notion authentication");
2685
+ var authCmd = new Command19("auth").description("manage Notion authentication");
2686
+ authCmd.action(authDefaultAction(authCmd));
2356
2687
  authCmd.addCommand(loginCommand());
2357
2688
  authCmd.addCommand(logoutCommand());
2358
2689
  authCmd.addCommand(statusCommand());
2690
+ authCmd.addCommand(profileListCommand());
2691
+ authCmd.addCommand(profileUseCommand());
2359
2692
  program.addCommand(authCmd);
2360
- var profileCmd = new Command20("profile").description("manage authentication profiles");
2693
+ program.addCommand(initCommand(), { hidden: true });
2694
+ var profileCmd = new Command19("profile").description(
2695
+ "manage authentication profiles"
2696
+ );
2361
2697
  profileCmd.addCommand(profileListCommand());
2362
2698
  profileCmd.addCommand(profileUseCommand());
2363
- profileCmd.addCommand(profileRemoveCommand());
2364
- program.addCommand(profileCmd);
2699
+ program.addCommand(profileCmd, { hidden: true });
2365
2700
  program.addCommand(searchCommand());
2366
2701
  program.addCommand(lsCommand());
2367
2702
  program.addCommand(openCommand());
@@ -2371,7 +2706,7 @@ program.addCommand(readCommand());
2371
2706
  program.addCommand(commentAddCommand());
2372
2707
  program.addCommand(appendCommand());
2373
2708
  program.addCommand(createPageCommand());
2374
- var dbCmd = new Command20("db").description("Database operations");
2709
+ var dbCmd = new Command19("db").description("Database operations");
2375
2710
  dbCmd.addCommand(dbSchemaCommand());
2376
2711
  dbCmd.addCommand(dbQueryCommand());
2377
2712
  program.addCommand(dbCmd);