@andrzejchm/notion-cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { Command as Command14 } from "commander";
4
+ import { Command as Command20 } from "commander";
5
5
  import { fileURLToPath } from "url";
6
6
  import { dirname, join as join3 } from "path";
7
7
  import { readFileSync } from "fs";
@@ -21,6 +21,9 @@ function createChalk() {
21
21
  const level = isColorEnabled() ? void 0 : 0;
22
22
  return new Chalk({ level });
23
23
  }
24
+ function error(msg) {
25
+ return createChalk().red(msg);
26
+ }
24
27
  function success(msg) {
25
28
  return createChalk().green(msg);
26
29
  }
@@ -32,7 +35,6 @@ function bold(msg) {
32
35
  }
33
36
 
34
37
  // src/output/format.ts
35
- import { spawnSync } from "child_process";
36
38
  var _mode = "auto";
37
39
  function setOutputMode(mode) {
38
40
  _mode = mode;
@@ -94,17 +96,7 @@ function printOutput(data, tableHeaders, tableRows) {
94
96
  }
95
97
  }
96
98
  function printWithPager(text) {
97
- if (!isatty()) {
98
- process.stdout.write(text);
99
- return;
100
- }
101
- const result = spawnSync("less", ["-IR"], {
102
- input: text,
103
- stdio: ["pipe", "inherit", "inherit"]
104
- });
105
- if (result.error) {
106
- process.stdout.write(text);
107
- }
99
+ process.stdout.write(text);
108
100
  }
109
101
 
110
102
  // src/commands/init.ts
@@ -358,14 +350,468 @@ function initCommand() {
358
350
  } catch {
359
351
  stderrWrite(dim("(Could not verify integration access \u2014 run `notion ls` to check)"));
360
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
361
  }));
362
362
  return cmd;
363
363
  }
364
364
 
365
- // src/commands/profile/list.ts
365
+ // src/commands/auth/login.ts
366
366
  import { Command as Command2 } from "commander";
367
+
368
+ // src/oauth/oauth-client.ts
369
+ var OAUTH_CLIENT_ID = "314d872b-594c-818d-8c02-0037a9d4be1f";
370
+ var OAUTH_CLIENT_SECRET = "secret_DfokG3sCdjylsYxIX26cSlFUadspf6D4Jr5gaVjO7xv";
371
+ var OAUTH_REDIRECT_URI = "http://localhost:54321/oauth/callback";
372
+ function buildAuthUrl(state) {
373
+ const params = new URLSearchParams({
374
+ client_id: OAUTH_CLIENT_ID,
375
+ redirect_uri: OAUTH_REDIRECT_URI,
376
+ response_type: "code",
377
+ owner: "user",
378
+ state
379
+ });
380
+ return `https://api.notion.com/v1/oauth/authorize?${params.toString()}`;
381
+ }
382
+ function basicAuth() {
383
+ return Buffer.from(`${OAUTH_CLIENT_ID}:${OAUTH_CLIENT_SECRET}`).toString("base64");
384
+ }
385
+ async function exchangeCode(code, redirectUri = OAUTH_REDIRECT_URI) {
386
+ const response = await fetch("https://api.notion.com/v1/oauth/token", {
387
+ method: "POST",
388
+ headers: {
389
+ "Authorization": `Basic ${basicAuth()}`,
390
+ "Content-Type": "application/json",
391
+ "Notion-Version": "2022-06-28"
392
+ },
393
+ body: JSON.stringify({
394
+ grant_type: "authorization_code",
395
+ code,
396
+ redirect_uri: redirectUri
397
+ })
398
+ });
399
+ if (!response.ok) {
400
+ let errorMessage = `OAuth token exchange failed (HTTP ${response.status})`;
401
+ try {
402
+ const body = await response.json();
403
+ if (body.error_description) errorMessage = body.error_description;
404
+ else if (body.error) errorMessage = body.error;
405
+ } catch {
406
+ }
407
+ throw new CliError(
408
+ ErrorCodes.AUTH_INVALID,
409
+ errorMessage,
410
+ 'Run "notion auth login" to restart the OAuth flow'
411
+ );
412
+ }
413
+ const data = await response.json();
414
+ return data;
415
+ }
416
+ async function refreshAccessToken(refreshToken) {
417
+ const response = await fetch("https://api.notion.com/v1/oauth/token", {
418
+ method: "POST",
419
+ headers: {
420
+ "Authorization": `Basic ${basicAuth()}`,
421
+ "Content-Type": "application/json",
422
+ "Notion-Version": "2022-06-28"
423
+ },
424
+ body: JSON.stringify({
425
+ grant_type: "refresh_token",
426
+ refresh_token: refreshToken
427
+ })
428
+ });
429
+ if (!response.ok) {
430
+ let errorMessage = `OAuth token refresh failed (HTTP ${response.status})`;
431
+ try {
432
+ const body = await response.json();
433
+ if (body.error_description) errorMessage = body.error_description;
434
+ else if (body.error) errorMessage = body.error;
435
+ } catch {
436
+ }
437
+ throw new CliError(
438
+ ErrorCodes.AUTH_INVALID,
439
+ errorMessage,
440
+ 'Run "notion auth login" to re-authenticate'
441
+ );
442
+ }
443
+ const data = await response.json();
444
+ return data;
445
+ }
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
+ }
466
+ 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;
475
+ }
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);
546
+ }
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
+ }
662
+
663
+ // src/oauth/token-store.ts
664
+ var OAUTH_EXPIRY_DURATION_MS = 60 * 60 * 1e3;
665
+ async function saveOAuthTokens(profileName, response) {
666
+ const config = await readGlobalConfig();
667
+ const existing = config.profiles?.[profileName] ?? {};
668
+ const updatedProfile = {
669
+ ...existing,
670
+ oauth_access_token: response.access_token,
671
+ oauth_refresh_token: response.refresh_token,
672
+ oauth_expiry_ms: Date.now() + OAUTH_EXPIRY_DURATION_MS,
673
+ workspace_id: response.workspace_id,
674
+ 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 }
677
+ };
678
+ config.profiles = {
679
+ ...config.profiles,
680
+ [profileName]: updatedProfile
681
+ };
682
+ await writeGlobalConfig(config);
683
+ }
684
+ async function clearOAuthTokens(profileName) {
685
+ const config = await readGlobalConfig();
686
+ const existing = config.profiles?.[profileName];
687
+ if (!existing) return;
688
+ const {
689
+ oauth_access_token: _access,
690
+ oauth_refresh_token: _refresh,
691
+ oauth_expiry_ms: _expiry,
692
+ oauth_user_id: _userId,
693
+ oauth_user_name: _userName,
694
+ ...rest
695
+ } = existing;
696
+ config.profiles = {
697
+ ...config.profiles,
698
+ [profileName]: rest
699
+ };
700
+ await writeGlobalConfig(config);
701
+ }
702
+
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;
730
+ }
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;
748
+ }
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
+ )
755
+ );
756
+ })
757
+ );
758
+ return cmd;
759
+ }
760
+
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`);
796
+ }
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')`)
804
+ );
805
+ }
806
+ })
807
+ );
808
+ return cmd;
809
+ }
810
+
811
+ // src/commands/profile/list.ts
812
+ import { Command as Command5 } from "commander";
367
813
  function profileListCommand() {
368
- const cmd = new Command2("list");
814
+ const cmd = new Command5("list");
369
815
  cmd.description("list all authentication profiles").action(withErrorHandling(async () => {
370
816
  const config = await readGlobalConfig();
371
817
  const profiles = config.profiles ?? {};
@@ -388,9 +834,9 @@ function profileListCommand() {
388
834
  }
389
835
 
390
836
  // src/commands/profile/use.ts
391
- import { Command as Command3 } from "commander";
837
+ import { Command as Command6 } from "commander";
392
838
  function profileUseCommand() {
393
- const cmd = new Command3("use");
839
+ const cmd = new Command6("use");
394
840
  cmd.description("switch the active profile").argument("<name>", "profile name to activate").action(withErrorHandling(async (name) => {
395
841
  const config = await readGlobalConfig();
396
842
  const profiles = config.profiles ?? {};
@@ -411,9 +857,9 @@ function profileUseCommand() {
411
857
  }
412
858
 
413
859
  // src/commands/profile/remove.ts
414
- import { Command as Command4 } from "commander";
860
+ import { Command as Command7 } from "commander";
415
861
  function profileRemoveCommand() {
416
- const cmd = new Command4("remove");
862
+ const cmd = new Command7("remove");
417
863
  cmd.description("remove an authentication profile").argument("<name>", "profile name to remove").action(withErrorHandling(async (name) => {
418
864
  const config = await readGlobalConfig();
419
865
  const profiles = { ...config.profiles ?? {} };
@@ -437,7 +883,7 @@ function profileRemoveCommand() {
437
883
  }
438
884
 
439
885
  // src/commands/completion.ts
440
- import { Command as Command5 } from "commander";
886
+ import { Command as Command8 } from "commander";
441
887
  var BASH_COMPLETION = `# notion bash completion
442
888
  _notion_completion() {
443
889
  local cur prev words cword
@@ -539,7 +985,7 @@ complete -c notion -n '__fish_seen_subcommand_from completion' -a zsh -d 'zsh co
539
985
  complete -c notion -n '__fish_seen_subcommand_from completion' -a fish -d 'fish completion script'
540
986
  `;
541
987
  function completionCommand() {
542
- const cmd = new Command5("completion");
988
+ const cmd = new Command8("completion");
543
989
  cmd.description("output shell completion script").argument("<shell>", "shell type (bash, zsh, fish)").action(withErrorHandling(async (shell) => {
544
990
  switch (shell) {
545
991
  case "bash":
@@ -563,7 +1009,7 @@ function completionCommand() {
563
1009
  }
564
1010
 
565
1011
  // src/commands/search.ts
566
- import { Command as Command6 } from "commander";
1012
+ import { Command as Command9 } from "commander";
567
1013
  import { isFullPageOrDataSource } from "@notionhq/client";
568
1014
 
569
1015
  // src/config/local-config.ts
@@ -606,6 +1052,36 @@ async function readLocalConfig() {
606
1052
  }
607
1053
 
608
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
+ }
1059
+ async function resolveOAuthToken(profileName, profile) {
1060
+ if (!profile.oauth_access_token) return null;
1061
+ if (!isOAuthExpired(profile)) {
1062
+ return profile.oauth_access_token;
1063
+ }
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'
1070
+ );
1071
+ }
1072
+ try {
1073
+ const refreshed = await refreshAccessToken(profile.oauth_refresh_token);
1074
+ await saveOAuthTokens(profileName, refreshed);
1075
+ return refreshed.access_token;
1076
+ } 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"
1082
+ );
1083
+ }
1084
+ }
609
1085
  async function resolveToken() {
610
1086
  const envToken = process.env["NOTION_API_TOKEN"];
611
1087
  if (envToken) {
@@ -618,17 +1094,29 @@ async function resolveToken() {
618
1094
  }
619
1095
  if (localConfig.profile) {
620
1096
  const globalConfig2 = await readGlobalConfig();
621
- const profileToken = globalConfig2.profiles?.[localConfig.profile]?.token;
622
- if (profileToken) {
623
- return { token: profileToken, source: `profile: ${localConfig.profile}` };
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
+ }
624
1106
  }
625
1107
  }
626
1108
  }
627
1109
  const globalConfig = await readGlobalConfig();
628
1110
  if (globalConfig.active_profile) {
629
- const profileToken = globalConfig.profiles?.[globalConfig.active_profile]?.token;
630
- if (profileToken) {
631
- return { token: profileToken, source: `profile: ${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" };
1116
+ }
1117
+ if (profile.token) {
1118
+ return { token: profile.token, source: `profile: ${globalConfig.active_profile}` };
1119
+ }
632
1120
  }
633
1121
  }
634
1122
  throw new CliError(
@@ -656,7 +1144,7 @@ function displayType(item) {
656
1144
  return item.object === "data_source" ? "database" : item.object;
657
1145
  }
658
1146
  function searchCommand() {
659
- const cmd = new Command6("search");
1147
+ const cmd = new Command9("search");
660
1148
  cmd.description("search Notion workspace by keyword").argument("<query>", "search keyword").option("--type <type>", "filter by object type (page or database)", (val) => {
661
1149
  if (val !== "page" && val !== "database") {
662
1150
  throw new Error('--type must be "page" or "database"');
@@ -701,7 +1189,7 @@ function searchCommand() {
701
1189
  }
702
1190
 
703
1191
  // src/commands/ls.ts
704
- import { Command as Command7 } from "commander";
1192
+ import { Command as Command10 } from "commander";
705
1193
  import { isFullPageOrDataSource as isFullPageOrDataSource2 } from "@notionhq/client";
706
1194
  function getTitle2(item) {
707
1195
  if (item.object === "data_source") {
@@ -717,7 +1205,7 @@ function displayType2(item) {
717
1205
  return item.object === "data_source" ? "database" : item.object;
718
1206
  }
719
1207
  function lsCommand() {
720
- const cmd = new Command7("ls");
1208
+ const cmd = new Command10("ls");
721
1209
  cmd.description("list accessible Notion pages and databases").option("--type <type>", "filter by object type (page or database)", (val) => {
722
1210
  if (val !== "page" && val !== "database") {
723
1211
  throw new Error('--type must be "page" or "database"');
@@ -767,7 +1255,7 @@ function lsCommand() {
767
1255
  // src/commands/open.ts
768
1256
  import { exec } from "child_process";
769
1257
  import { promisify } from "util";
770
- import { Command as Command8 } from "commander";
1258
+ import { Command as Command11 } from "commander";
771
1259
 
772
1260
  // src/notion/url-parser.ts
773
1261
  var NOTION_ID_REGEX = /^[0-9a-f]{32}$/i;
@@ -801,7 +1289,7 @@ function toUuid(id) {
801
1289
  // src/commands/open.ts
802
1290
  var execAsync = promisify(exec);
803
1291
  function openCommand() {
804
- const cmd = new Command8("open");
1292
+ const cmd = new Command11("open");
805
1293
  cmd.description("open a Notion page in the default browser").argument("<id/url>", "Notion page ID or URL").action(withErrorHandling(async (idOrUrl) => {
806
1294
  const id = parseNotionId(idOrUrl);
807
1295
  const url = `https://www.notion.so/${id}`;
@@ -815,7 +1303,7 @@ function openCommand() {
815
1303
  }
816
1304
 
817
1305
  // src/commands/users.ts
818
- import { Command as Command9 } from "commander";
1306
+ import { Command as Command12 } from "commander";
819
1307
 
820
1308
  // src/output/paginate.ts
821
1309
  async function paginateResults(fetcher) {
@@ -843,7 +1331,7 @@ function getEmailOrWorkspace(user) {
843
1331
  return "\u2014";
844
1332
  }
845
1333
  function usersCommand() {
846
- const cmd = new Command9("users");
1334
+ const cmd = new Command12("users");
847
1335
  cmd.description("list all users in the workspace").option("--json", "output as JSON").action(withErrorHandling(async (opts) => {
848
1336
  if (opts.json) setOutputMode("json");
849
1337
  const { token, source } = await resolveToken();
@@ -865,9 +1353,9 @@ function usersCommand() {
865
1353
  }
866
1354
 
867
1355
  // src/commands/comments.ts
868
- import { Command as Command10 } from "commander";
1356
+ import { Command as Command13 } from "commander";
869
1357
  function commentsCommand() {
870
- const cmd = new Command10("comments");
1358
+ const cmd = new Command13("comments");
871
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) => {
872
1360
  if (opts.json) setOutputMode("json");
873
1361
  const id = parseNotionId(idOrUrl);
@@ -896,7 +1384,7 @@ function commentsCommand() {
896
1384
  }
897
1385
 
898
1386
  // src/commands/read.ts
899
- import { Command as Command11 } from "commander";
1387
+ import { Command as Command14 } from "commander";
900
1388
 
901
1389
  // src/services/page.service.ts
902
1390
  import { collectPaginatedAPI, isFullBlock } from "@notionhq/client";
@@ -1292,7 +1780,7 @@ function renderInline(text) {
1292
1780
 
1293
1781
  // src/commands/read.ts
1294
1782
  function readCommand() {
1295
- return new Command11("read").description("Read a Notion page as markdown").argument("<id>", "Notion page ID or URL").option("--json", "Output raw JSON instead of markdown").option("--md", "Output raw markdown (no terminal styling)").action(
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(
1296
1784
  withErrorHandling(async (id, options) => {
1297
1785
  const { token } = await resolveToken();
1298
1786
  const client = createNotionClient(token);
@@ -1313,7 +1801,7 @@ function readCommand() {
1313
1801
  }
1314
1802
 
1315
1803
  // src/commands/db/schema.ts
1316
- import { Command as Command12 } from "commander";
1804
+ import { Command as Command15 } from "commander";
1317
1805
 
1318
1806
  // src/services/database.service.ts
1319
1807
  import { isFullPage as isFullPage2 } from "@notionhq/client";
@@ -1476,7 +1964,7 @@ function displayPropertyValue(prop) {
1476
1964
 
1477
1965
  // src/commands/db/schema.ts
1478
1966
  function dbSchemaCommand() {
1479
- return new Command12("schema").description("Show database schema (property names, types, and options)").argument("<id>", "Notion database ID or URL").option("--json", "Output raw JSON").action(
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(
1480
1968
  withErrorHandling(async (id, options) => {
1481
1969
  const { token } = await resolveToken();
1482
1970
  const client = createNotionClient(token);
@@ -1498,7 +1986,7 @@ function dbSchemaCommand() {
1498
1986
  }
1499
1987
 
1500
1988
  // src/commands/db/query.ts
1501
- import { Command as Command13 } from "commander";
1989
+ import { Command as Command16 } from "commander";
1502
1990
  var SKIP_TYPES_IN_AUTO = /* @__PURE__ */ new Set(["relation", "rich_text", "people"]);
1503
1991
  function autoSelectColumns(schema, entries) {
1504
1992
  const termWidth = process.stdout.columns || 120;
@@ -1523,7 +2011,7 @@ function autoSelectColumns(schema, entries) {
1523
2011
  return selected;
1524
2012
  }
1525
2013
  function dbQueryCommand() {
1526
- return new Command13("query").description("Query database entries with optional filtering and sorting").argument("<id>", "Notion database ID or URL").option("--filter <filter>", 'Filter entries (repeatable): --filter "Status=Done"', collect, []).option("--sort <sort>", 'Sort entries (repeatable): --sort "Name:asc"', collect, []).option("--columns <columns>", 'Comma-separated list of columns to display: --columns "Title,Status"').option("--json", "Output raw JSON").action(
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(
1527
2015
  withErrorHandling(
1528
2016
  async (id, options) => {
1529
2017
  const { token } = await resolveToken();
@@ -1558,11 +2046,291 @@ function collect(value, previous) {
1558
2046
  return previous.concat([value]);
1559
2047
  }
1560
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;
2105
+ }
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;
2144
+ }
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;
2154
+ }
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
+ });
2160
+ }
2161
+ return result;
2162
+ }
2163
+ function makeRichText(text) {
2164
+ return parseInlineMarkdown(text);
2165
+ }
2166
+ function mdToBlocks(md) {
2167
+ if (!md) return [];
2168
+ const lines = md.split("\n");
2169
+ const blocks = [];
2170
+ let inFence = false;
2171
+ let fenceLang = "";
2172
+ const fenceLines = [];
2173
+ 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
+ });
2228
+ continue;
2229
+ }
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
+ });
2236
+ continue;
2237
+ }
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
+ });
2244
+ continue;
2245
+ }
2246
+ const quote = line.match(/^> (.+)$/);
2247
+ if (quote) {
2248
+ blocks.push({
2249
+ type: "quote",
2250
+ quote: { rich_text: makeRichText(quote[1]) }
2251
+ });
2252
+ continue;
2253
+ }
2254
+ blocks.push({
2255
+ type: "paragraph",
2256
+ paragraph: { rich_text: makeRichText(line) }
2257
+ });
2258
+ }
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" }
2269
+ }
2270
+ ],
2271
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2272
+ language: fenceLang
2273
+ }
2274
+ });
2275
+ }
2276
+ return blocks;
2277
+ }
2278
+
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;
2292
+ }
2293
+ await appendBlocks(client, uuid, blocks);
2294
+ process.stdout.write(`Appended ${blocks.length} block(s).
2295
+ `);
2296
+ }));
2297
+ return cmd;
2298
+ }
2299
+
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));
2306
+ }
2307
+ return Buffer.concat(chunks).toString("utf-8");
2308
+ }
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
+ }));
2326
+ return cmd;
2327
+ }
2328
+
1561
2329
  // src/cli.ts
1562
2330
  var __filename = fileURLToPath(import.meta.url);
1563
2331
  var __dirname = dirname(__filename);
1564
2332
  var pkg = JSON.parse(readFileSync(join3(__dirname, "../package.json"), "utf-8"));
1565
- var program = new Command14();
2333
+ var program = new Command20();
1566
2334
  program.name("notion").description("Notion CLI \u2014 read Notion pages and databases from the terminal").version(pkg.version);
1567
2335
  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");
1568
2336
  program.configureOutput({
@@ -1584,7 +2352,12 @@ program.hook("preAction", (thisCommand) => {
1584
2352
  }
1585
2353
  });
1586
2354
  program.addCommand(initCommand());
1587
- var profileCmd = new Command14("profile").description("manage authentication profiles");
2355
+ var authCmd = new Command20("auth").description("manage Notion authentication");
2356
+ authCmd.addCommand(loginCommand());
2357
+ authCmd.addCommand(logoutCommand());
2358
+ authCmd.addCommand(statusCommand());
2359
+ program.addCommand(authCmd);
2360
+ var profileCmd = new Command20("profile").description("manage authentication profiles");
1588
2361
  profileCmd.addCommand(profileListCommand());
1589
2362
  profileCmd.addCommand(profileUseCommand());
1590
2363
  profileCmd.addCommand(profileRemoveCommand());
@@ -1595,7 +2368,10 @@ program.addCommand(openCommand());
1595
2368
  program.addCommand(usersCommand());
1596
2369
  program.addCommand(commentsCommand());
1597
2370
  program.addCommand(readCommand());
1598
- var dbCmd = new Command14("db").description("Database operations");
2371
+ program.addCommand(commentAddCommand());
2372
+ program.addCommand(appendCommand());
2373
+ program.addCommand(createPageCommand());
2374
+ var dbCmd = new Command20("db").description("Database operations");
1599
2375
  dbCmd.addCommand(dbSchemaCommand());
1600
2376
  dbCmd.addCommand(dbQueryCommand());
1601
2377
  program.addCommand(dbCmd);