@andrzejchm/notion-cli 0.1.2 → 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
  }
@@ -347,14 +350,468 @@ function initCommand() {
347
350
  } catch {
348
351
  stderrWrite(dim("(Could not verify integration access \u2014 run `notion ls` to check)"));
349
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"));
350
361
  }));
351
362
  return cmd;
352
363
  }
353
364
 
354
- // src/commands/profile/list.ts
365
+ // src/commands/auth/login.ts
355
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";
356
813
  function profileListCommand() {
357
- const cmd = new Command2("list");
814
+ const cmd = new Command5("list");
358
815
  cmd.description("list all authentication profiles").action(withErrorHandling(async () => {
359
816
  const config = await readGlobalConfig();
360
817
  const profiles = config.profiles ?? {};
@@ -377,9 +834,9 @@ function profileListCommand() {
377
834
  }
378
835
 
379
836
  // src/commands/profile/use.ts
380
- import { Command as Command3 } from "commander";
837
+ import { Command as Command6 } from "commander";
381
838
  function profileUseCommand() {
382
- const cmd = new Command3("use");
839
+ const cmd = new Command6("use");
383
840
  cmd.description("switch the active profile").argument("<name>", "profile name to activate").action(withErrorHandling(async (name) => {
384
841
  const config = await readGlobalConfig();
385
842
  const profiles = config.profiles ?? {};
@@ -400,9 +857,9 @@ function profileUseCommand() {
400
857
  }
401
858
 
402
859
  // src/commands/profile/remove.ts
403
- import { Command as Command4 } from "commander";
860
+ import { Command as Command7 } from "commander";
404
861
  function profileRemoveCommand() {
405
- const cmd = new Command4("remove");
862
+ const cmd = new Command7("remove");
406
863
  cmd.description("remove an authentication profile").argument("<name>", "profile name to remove").action(withErrorHandling(async (name) => {
407
864
  const config = await readGlobalConfig();
408
865
  const profiles = { ...config.profiles ?? {} };
@@ -426,7 +883,7 @@ function profileRemoveCommand() {
426
883
  }
427
884
 
428
885
  // src/commands/completion.ts
429
- import { Command as Command5 } from "commander";
886
+ import { Command as Command8 } from "commander";
430
887
  var BASH_COMPLETION = `# notion bash completion
431
888
  _notion_completion() {
432
889
  local cur prev words cword
@@ -528,7 +985,7 @@ complete -c notion -n '__fish_seen_subcommand_from completion' -a zsh -d 'zsh co
528
985
  complete -c notion -n '__fish_seen_subcommand_from completion' -a fish -d 'fish completion script'
529
986
  `;
530
987
  function completionCommand() {
531
- const cmd = new Command5("completion");
988
+ const cmd = new Command8("completion");
532
989
  cmd.description("output shell completion script").argument("<shell>", "shell type (bash, zsh, fish)").action(withErrorHandling(async (shell) => {
533
990
  switch (shell) {
534
991
  case "bash":
@@ -552,7 +1009,7 @@ function completionCommand() {
552
1009
  }
553
1010
 
554
1011
  // src/commands/search.ts
555
- import { Command as Command6 } from "commander";
1012
+ import { Command as Command9 } from "commander";
556
1013
  import { isFullPageOrDataSource } from "@notionhq/client";
557
1014
 
558
1015
  // src/config/local-config.ts
@@ -595,6 +1052,36 @@ async function readLocalConfig() {
595
1052
  }
596
1053
 
597
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
+ }
598
1085
  async function resolveToken() {
599
1086
  const envToken = process.env["NOTION_API_TOKEN"];
600
1087
  if (envToken) {
@@ -607,17 +1094,29 @@ async function resolveToken() {
607
1094
  }
608
1095
  if (localConfig.profile) {
609
1096
  const globalConfig2 = await readGlobalConfig();
610
- const profileToken = globalConfig2.profiles?.[localConfig.profile]?.token;
611
- if (profileToken) {
612
- 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
+ }
613
1106
  }
614
1107
  }
615
1108
  }
616
1109
  const globalConfig = await readGlobalConfig();
617
1110
  if (globalConfig.active_profile) {
618
- const profileToken = globalConfig.profiles?.[globalConfig.active_profile]?.token;
619
- if (profileToken) {
620
- return { token: profileToken, source: `profile: ${globalConfig.active_profile}` };
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
+ }
621
1120
  }
622
1121
  }
623
1122
  throw new CliError(
@@ -645,7 +1144,7 @@ function displayType(item) {
645
1144
  return item.object === "data_source" ? "database" : item.object;
646
1145
  }
647
1146
  function searchCommand() {
648
- const cmd = new Command6("search");
1147
+ const cmd = new Command9("search");
649
1148
  cmd.description("search Notion workspace by keyword").argument("<query>", "search keyword").option("--type <type>", "filter by object type (page or database)", (val) => {
650
1149
  if (val !== "page" && val !== "database") {
651
1150
  throw new Error('--type must be "page" or "database"');
@@ -690,7 +1189,7 @@ function searchCommand() {
690
1189
  }
691
1190
 
692
1191
  // src/commands/ls.ts
693
- import { Command as Command7 } from "commander";
1192
+ import { Command as Command10 } from "commander";
694
1193
  import { isFullPageOrDataSource as isFullPageOrDataSource2 } from "@notionhq/client";
695
1194
  function getTitle2(item) {
696
1195
  if (item.object === "data_source") {
@@ -706,7 +1205,7 @@ function displayType2(item) {
706
1205
  return item.object === "data_source" ? "database" : item.object;
707
1206
  }
708
1207
  function lsCommand() {
709
- const cmd = new Command7("ls");
1208
+ const cmd = new Command10("ls");
710
1209
  cmd.description("list accessible Notion pages and databases").option("--type <type>", "filter by object type (page or database)", (val) => {
711
1210
  if (val !== "page" && val !== "database") {
712
1211
  throw new Error('--type must be "page" or "database"');
@@ -756,7 +1255,7 @@ function lsCommand() {
756
1255
  // src/commands/open.ts
757
1256
  import { exec } from "child_process";
758
1257
  import { promisify } from "util";
759
- import { Command as Command8 } from "commander";
1258
+ import { Command as Command11 } from "commander";
760
1259
 
761
1260
  // src/notion/url-parser.ts
762
1261
  var NOTION_ID_REGEX = /^[0-9a-f]{32}$/i;
@@ -790,7 +1289,7 @@ function toUuid(id) {
790
1289
  // src/commands/open.ts
791
1290
  var execAsync = promisify(exec);
792
1291
  function openCommand() {
793
- const cmd = new Command8("open");
1292
+ const cmd = new Command11("open");
794
1293
  cmd.description("open a Notion page in the default browser").argument("<id/url>", "Notion page ID or URL").action(withErrorHandling(async (idOrUrl) => {
795
1294
  const id = parseNotionId(idOrUrl);
796
1295
  const url = `https://www.notion.so/${id}`;
@@ -804,7 +1303,7 @@ function openCommand() {
804
1303
  }
805
1304
 
806
1305
  // src/commands/users.ts
807
- import { Command as Command9 } from "commander";
1306
+ import { Command as Command12 } from "commander";
808
1307
 
809
1308
  // src/output/paginate.ts
810
1309
  async function paginateResults(fetcher) {
@@ -832,7 +1331,7 @@ function getEmailOrWorkspace(user) {
832
1331
  return "\u2014";
833
1332
  }
834
1333
  function usersCommand() {
835
- const cmd = new Command9("users");
1334
+ const cmd = new Command12("users");
836
1335
  cmd.description("list all users in the workspace").option("--json", "output as JSON").action(withErrorHandling(async (opts) => {
837
1336
  if (opts.json) setOutputMode("json");
838
1337
  const { token, source } = await resolveToken();
@@ -854,9 +1353,9 @@ function usersCommand() {
854
1353
  }
855
1354
 
856
1355
  // src/commands/comments.ts
857
- import { Command as Command10 } from "commander";
1356
+ import { Command as Command13 } from "commander";
858
1357
  function commentsCommand() {
859
- const cmd = new Command10("comments");
1358
+ const cmd = new Command13("comments");
860
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) => {
861
1360
  if (opts.json) setOutputMode("json");
862
1361
  const id = parseNotionId(idOrUrl);
@@ -885,7 +1384,7 @@ function commentsCommand() {
885
1384
  }
886
1385
 
887
1386
  // src/commands/read.ts
888
- import { Command as Command11 } from "commander";
1387
+ import { Command as Command14 } from "commander";
889
1388
 
890
1389
  // src/services/page.service.ts
891
1390
  import { collectPaginatedAPI, isFullBlock } from "@notionhq/client";
@@ -1281,7 +1780,7 @@ function renderInline(text) {
1281
1780
 
1282
1781
  // src/commands/read.ts
1283
1782
  function readCommand() {
1284
- return new Command11("read").description("Read a Notion page as markdown").argument("<id>", "Notion page ID or URL").option("--json", "Output raw JSON instead of markdown").option("--md", "Output raw markdown (no terminal styling)").action(
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(
1285
1784
  withErrorHandling(async (id, options) => {
1286
1785
  const { token } = await resolveToken();
1287
1786
  const client = createNotionClient(token);
@@ -1302,7 +1801,7 @@ function readCommand() {
1302
1801
  }
1303
1802
 
1304
1803
  // src/commands/db/schema.ts
1305
- import { Command as Command12 } from "commander";
1804
+ import { Command as Command15 } from "commander";
1306
1805
 
1307
1806
  // src/services/database.service.ts
1308
1807
  import { isFullPage as isFullPage2 } from "@notionhq/client";
@@ -1465,7 +1964,7 @@ function displayPropertyValue(prop) {
1465
1964
 
1466
1965
  // src/commands/db/schema.ts
1467
1966
  function dbSchemaCommand() {
1468
- return new Command12("schema").description("Show database schema (property names, types, and options)").argument("<id>", "Notion database ID or URL").option("--json", "Output raw JSON").action(
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(
1469
1968
  withErrorHandling(async (id, options) => {
1470
1969
  const { token } = await resolveToken();
1471
1970
  const client = createNotionClient(token);
@@ -1487,7 +1986,7 @@ function dbSchemaCommand() {
1487
1986
  }
1488
1987
 
1489
1988
  // src/commands/db/query.ts
1490
- import { Command as Command13 } from "commander";
1989
+ import { Command as Command16 } from "commander";
1491
1990
  var SKIP_TYPES_IN_AUTO = /* @__PURE__ */ new Set(["relation", "rich_text", "people"]);
1492
1991
  function autoSelectColumns(schema, entries) {
1493
1992
  const termWidth = process.stdout.columns || 120;
@@ -1512,7 +2011,7 @@ function autoSelectColumns(schema, entries) {
1512
2011
  return selected;
1513
2012
  }
1514
2013
  function dbQueryCommand() {
1515
- return new Command13("query").description("Query database entries with optional filtering and sorting").argument("<id>", "Notion database ID or URL").option("--filter <filter>", 'Filter entries (repeatable): --filter "Status=Done"', collect, []).option("--sort <sort>", 'Sort entries (repeatable): --sort "Name:asc"', collect, []).option("--columns <columns>", 'Comma-separated list of columns to display: --columns "Title,Status"').option("--json", "Output raw JSON").action(
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(
1516
2015
  withErrorHandling(
1517
2016
  async (id, options) => {
1518
2017
  const { token } = await resolveToken();
@@ -1547,11 +2046,291 @@ function collect(value, previous) {
1547
2046
  return previous.concat([value]);
1548
2047
  }
1549
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
+
1550
2329
  // src/cli.ts
1551
2330
  var __filename = fileURLToPath(import.meta.url);
1552
2331
  var __dirname = dirname(__filename);
1553
2332
  var pkg = JSON.parse(readFileSync(join3(__dirname, "../package.json"), "utf-8"));
1554
- var program = new Command14();
2333
+ var program = new Command20();
1555
2334
  program.name("notion").description("Notion CLI \u2014 read Notion pages and databases from the terminal").version(pkg.version);
1556
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");
1557
2336
  program.configureOutput({
@@ -1573,7 +2352,12 @@ program.hook("preAction", (thisCommand) => {
1573
2352
  }
1574
2353
  });
1575
2354
  program.addCommand(initCommand());
1576
- 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");
1577
2361
  profileCmd.addCommand(profileListCommand());
1578
2362
  profileCmd.addCommand(profileUseCommand());
1579
2363
  profileCmd.addCommand(profileRemoveCommand());
@@ -1584,7 +2368,10 @@ program.addCommand(openCommand());
1584
2368
  program.addCommand(usersCommand());
1585
2369
  program.addCommand(commentsCommand());
1586
2370
  program.addCommand(readCommand());
1587
- 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");
1588
2375
  dbCmd.addCommand(dbSchemaCommand());
1589
2376
  dbCmd.addCommand(dbQueryCommand());
1590
2377
  program.addCommand(dbCmd);