chore: initial refactor 64222257
Steve · 2026-01-29 23:03 8 file(s) · +323 −290
bun.lock +9 −7
30 30
      },
31 31
      "dependencies": {
32 32
        "@atproto/api": "^0.18.17",
33 +
        "@clack/prompts": "^1.0.0",
33 34
        "cmd-ts": "^0.14.3",
34 -
        "consola": "^3.4.2",
35 35
      },
36 36
      "devDependencies": {
37 37
        "@types/bun": "latest",
112 112
113 113
    "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="],
114 114
115 -
    "@clack/core": ["@clack/core@0.3.5", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ=="],
115 +
    "@clack/core": ["@clack/core@1.0.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ=="],
116 116
117 -
    "@clack/prompts": ["@clack/prompts@0.7.0", "", { "dependencies": { "@clack/core": "^0.3.3", "is-unicode-supported": "*", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA=="],
117 +
    "@clack/prompts": ["@clack/prompts@1.0.0", "", { "dependencies": { "@clack/core": "1.0.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A=="],
118 118
119 119
    "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="],
120 120
629 629
    "compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="],
630 630
631 631
    "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
632 -
633 -
    "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
634 632
635 633
    "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
636 634
1424 1422
1425 1423
    "@chevrotain/gast/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
1426 1424
1427 -
    "@clack/prompts/is-unicode-supported": ["is-unicode-supported@1.3.0", "", { "bundled": true }, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
1428 -
1429 1425
    "@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
1430 1426
1431 1427
    "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
1449 1445
    "chevrotain/lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
1450 1446
1451 1447
    "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
1448 +
1449 +
    "create-vocs/@clack/prompts": ["@clack/prompts@0.7.0", "", { "dependencies": { "@clack/core": "^0.3.3", "is-unicode-supported": "*", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA=="],
1452 1450
1453 1451
    "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
1454 1452
1491 1489
    "@shikijs/twoslash/twoslash/twoslash-protocol": ["twoslash-protocol@0.2.12", "", {}, "sha512-5qZLXVYfZ9ABdjqbvPc4RWMr7PrpPaaDSeaYY55vl/w1j6H6kzsWK/urAEIXlzYlyrFmyz1UbwIt+AA0ck+wbg=="],
1492 1490
1493 1491
    "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
1492 +
1493 +
    "create-vocs/@clack/prompts/@clack/core": ["@clack/core@0.3.5", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ=="],
1494 +
1495 +
    "create-vocs/@clack/prompts/is-unicode-supported": ["is-unicode-supported@1.3.0", "", { "bundled": true }, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
1494 1496
1495 1497
    "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
1496 1498
packages/cli/package.json +1 −1
28 28
	"dependencies": {
29 29
		"@atproto/api": "^0.18.17",
30 30
		"cmd-ts": "^0.14.3",
31 -
		"consola": "^3.4.2"
31 +
		"@clack/prompts": "^1.0.0"
32 32
	}
33 33
}
packages/cli/src/commands/auth.ts +46 −45
1 1
import { command, flag, option, optional, string } from "cmd-ts";
2 -
import { consola } from "consola";
2 +
import { note, text, password, confirm, select, spinner, log } from "@clack/prompts";
3 3
import { AtpAgent } from "@atproto/api";
4 4
import {
5 5
  saveCredentials,
9 9
  getCredentialsPath,
10 10
} from "../lib/credentials";
11 11
import { resolveHandleToPDS } from "../lib/atproto";
12 +
import { exitOnCancel } from "../lib/prompts";
12 13
13 14
export const authCommand = command({
14 15
  name: "auth",
29 30
    if (list) {
30 31
      const identities = await listCredentials();
31 32
      if (identities.length === 0) {
32 -
        consola.info("No stored identities");
33 +
        log.info("No stored identities");
33 34
      } else {
34 -
        consola.info("Stored identities:");
35 +
        log.info("Stored identities:");
35 36
        for (const id of identities) {
36 37
          console.log(`  - ${id}`);
37 38
        }
48 49
        // No identifier provided - show available and prompt
49 50
        const identities = await listCredentials();
50 51
        if (identities.length === 0) {
51 -
          consola.info("No saved credentials found");
52 +
          log.info("No saved credentials found");
52 53
          return;
53 54
        }
54 55
        if (identities.length === 1) {
55 56
          const deleted = await deleteCredentials(identities[0]);
56 57
          if (deleted) {
57 -
            consola.success(`Removed credentials for ${identities[0]}`);
58 +
            log.success(`Removed credentials for ${identities[0]}`);
58 59
          }
59 60
          return;
60 61
        }
61 62
        // Multiple identities - prompt
62 -
        const selected = await consola.prompt("Select identity to remove:", {
63 -
          type: "select",
64 -
          options: identities,
65 -
        });
66 -
        const deleted = await deleteCredentials(selected as string);
63 +
        const selected = exitOnCancel(await select({
64 +
          message: "Select identity to remove:",
65 +
          options: identities.map(id => ({ value: id, label: id })),
66 +
        }));
67 +
        const deleted = await deleteCredentials(selected);
67 68
        if (deleted) {
68 -
          consola.success(`Removed credentials for ${selected}`);
69 +
          log.success(`Removed credentials for ${selected}`);
69 70
        }
70 71
        return;
71 72
      }
72 73
73 74
      const deleted = await deleteCredentials(identifier);
74 75
      if (deleted) {
75 -
        consola.success(`Removed credentials for ${identifier}`);
76 +
        log.success(`Removed credentials for ${identifier}`);
76 77
      } else {
77 -
        consola.info(`No credentials found for ${identifier}`);
78 +
        log.info(`No credentials found for ${identifier}`);
78 79
      }
79 80
      return;
80 81
    }
81 82
82 -
    consola.box(
83 +
    note(
83 84
      "To authenticate, you'll need an App Password.\n\n" +
84 85
        "Create one at: https://bsky.app/settings/app-passwords\n\n" +
85 -
        "App Passwords are safer than your main password and can be revoked."
86 +
        "App Passwords are safer than your main password and can be revoked.",
87 +
      "Authentication"
86 88
    );
87 89
88 -
    const identifier = await consola.prompt("Handle or DID:", {
89 -
      type: "text",
90 +
    const identifier = exitOnCancel(await text({
91 +
      message: "Handle or DID:",
90 92
      placeholder: "yourhandle.bsky.social",
91 -
    });
93 +
    }));
92 94
93 -
    const password = await consola.prompt("App Password:", {
94 -
      type: "text",
95 -
      placeholder: "xxxx-xxxx-xxxx-xxxx",
96 -
    });
95 +
    const appPassword = exitOnCancel(await password({
96 +
      message: "App Password:",
97 +
    }));
97 98
98 -
    if (!identifier || !password) {
99 -
      consola.error("Handle and password are required");
99 +
    if (!identifier || !appPassword) {
100 +
      log.error("Handle and password are required");
100 101
      process.exit(1);
101 102
    }
102 103
103 104
    // Check if this identity already exists
104 -
    const existing = await getCredentials(identifier as string);
105 +
    const existing = await getCredentials(identifier);
105 106
    if (existing) {
106 -
      const overwrite = await consola.prompt(
107 -
        `Credentials for ${identifier} already exist. Update?`,
108 -
        {
109 -
          type: "confirm",
110 -
          initial: false,
111 -
        }
112 -
      );
107 +
      const overwrite = exitOnCancel(await confirm({
108 +
        message: `Credentials for ${identifier} already exist. Update?`,
109 +
        initialValue: false,
110 +
      }));
113 111
      if (!overwrite) {
114 -
        consola.info("Keeping existing credentials");
112 +
        log.info("Keeping existing credentials");
115 113
        return;
116 114
      }
117 115
    }
118 116
119 117
    // Resolve PDS from handle
120 -
    consola.start("Resolving PDS...");
118 +
    const s = spinner();
119 +
    s.start("Resolving PDS...");
121 120
    let pdsUrl: string;
122 121
    try {
123 -
      pdsUrl = await resolveHandleToPDS(identifier as string);
124 -
      consola.success(`Found PDS: ${pdsUrl}`);
122 +
      pdsUrl = await resolveHandleToPDS(identifier);
123 +
      s.stop(`Found PDS: ${pdsUrl}`);
125 124
    } catch (error) {
126 -
      consola.error("Failed to resolve PDS from handle:", error);
125 +
      s.stop("Failed to resolve PDS");
126 +
      log.error(`Failed to resolve PDS from handle: ${error}`);
127 127
      process.exit(1);
128 128
    }
129 129
130 130
    // Verify credentials
131 -
    consola.start("Verifying credentials...");
131 +
    s.start("Verifying credentials...");
132 132
133 133
    try {
134 134
      const agent = new AtpAgent({ service: pdsUrl });
135 135
      await agent.login({
136 -
        identifier: identifier as string,
137 -
        password: password as string,
136 +
        identifier: identifier,
137 +
        password: appPassword,
138 138
      });
139 139
140 -
      consola.success(`Logged in as ${agent.session?.handle}`);
140 +
      s.stop(`Logged in as ${agent.session?.handle}`);
141 141
142 142
      // Save credentials
143 143
      await saveCredentials({
144 144
        pdsUrl,
145 -
        identifier: identifier as string,
146 -
        password: password as string,
145 +
        identifier: identifier,
146 +
        password: appPassword,
147 147
      });
148 148
149 -
      consola.success(`Credentials saved to ${getCredentialsPath()}`);
149 +
      log.success(`Credentials saved to ${getCredentialsPath()}`);
150 150
    } catch (error) {
151 -
      consola.error("Failed to login:", error);
151 +
      s.stop("Failed to login");
152 +
      log.error(`Failed to login: ${error}`);
152 153
      process.exit(1);
153 154
    }
154 155
  },
packages/cli/src/commands/init.ts +160 −146
1 1
import { command } from "cmd-ts";
2 -
import { consola } from "consola";
2 +
import {
3 +
	intro,
4 +
	outro,
5 +
	note,
6 +
	text,
7 +
	confirm,
8 +
	select,
9 +
	spinner,
10 +
	log,
11 +
} from "@clack/prompts";
3 12
import * as path from "path";
4 13
import { findConfig, generateConfigTemplate } from "../lib/config";
5 14
import { loadCredentials } from "../lib/credentials";
6 15
import { createAgent, createPublication } from "../lib/atproto";
7 16
import type { FrontmatterMapping } from "../lib/types";
17 +
import { exitOnCancel } from "../lib/prompts";
8 18
9 19
export const initCommand = command({
10 20
	name: "init",
11 21
	description: "Initialize a new publisher configuration",
12 22
	args: {},
13 23
	handler: async () => {
14 -
		// Handle Ctrl+C to exit immediately instead of cancelling one prompt at a time
15 -
		const exitHandler = () => {
16 -
			consola.info("\nCancelled");
17 -
			process.exit(0);
18 -
		};
19 -
		process.on("SIGINT", exitHandler);
24 +
		intro("Sequoia Configuration Setup");
20 25
21 26
		// Check if config already exists
22 27
		const existingConfig = await findConfig();
23 28
		if (existingConfig) {
24 -
			const overwrite = await consola.prompt(
25 -
				`Config already exists at ${existingConfig}. Overwrite?`,
26 -
				{
27 -
					type: "confirm",
28 -
					initial: false,
29 -
				},
29 +
			const overwrite = exitOnCancel(
30 +
				await confirm({
31 +
					message: `Config already exists at ${existingConfig}. Overwrite?`,
32 +
					initialValue: false,
33 +
				}),
30 34
			);
31 35
			if (!overwrite) {
32 -
				consola.info("Keeping existing configuration");
36 +
				log.info("Keeping existing configuration");
33 37
				return;
34 38
			}
35 39
		}
36 40
37 -
		consola.box(
38 -
			"Sequoia Configuration Setup\n\n" +
39 -
				"Follow the prompts to build your config for publishing",
40 -
		);
41 +
		note("Follow the prompts to build your config for publishing", "Setup");
41 42
42 -
		const siteUrl = await consola.prompt(
43 -
			"Site URL (canonical URL of your site):",
44 -
			{
45 -
				type: "text",
43 +
		const siteUrl = exitOnCancel(
44 +
			await text({
45 +
				message: "Site URL (canonical URL of your site):",
46 46
				placeholder: "https://example.com",
47 -
			},
47 +
			}),
48 48
		);
49 49
50 50
		if (!siteUrl) {
51 -
			consola.error("Site URL is required");
51 +
			log.error("Site URL is required");
52 52
			process.exit(1);
53 53
		}
54 54
55 -
		const contentDir = await consola.prompt(
56 -
			"Content directory (relative path):",
57 -
			{
58 -
				type: "text",
59 -
				default: "./content",
60 -
				placeholder: "./content",
61 -
			},
55 +
		const contentDir = exitOnCancel(
56 +
			await text({
57 +
				message: "Content directory:",
58 +
				placeholder: "./src/content/blog",
59 +
			}),
62 60
		);
63 61
64 -
		const imagesDir = await consola.prompt(
65 -
			"Cover images directory (where cover/og images are stored, leave empty to skip):",
66 -
			{
67 -
				type: "text",
68 -
				placeholder: "./public/images",
69 -
			},
62 +
		const imagesDir = exitOnCancel(
63 +
			await text({
64 +
				message: "Cover images directory (leave empty to skip):",
65 +
				placeholder: "./src/assets",
66 +
			}),
70 67
		);
71 68
72 69
		// Public/static directory for .well-known files
73 -
		const publicDir = await consola.prompt(
74 -
			"Public/static directory (for .well-known files):",
75 -
			{
76 -
				type: "text",
77 -
				default: "./public",
78 -
				placeholder: "./public (Astro, Next.js) or ./static (Hugo)",
79 -
			},
70 +
		const publicDir = exitOnCancel(
71 +
			await text({
72 +
				message: "Public/static directory (for .well-known files):",
73 +
				placeholder: "./public",
74 +
			}),
80 75
		);
81 76
82 77
		// Output directory for inject command
83 -
		const outputDir = await consola.prompt(
84 -
			"Build output directory (for link tag injection):",
85 -
			{
86 -
				type: "text",
87 -
				default: "./dist",
88 -
				placeholder: "./dist (Astro) or ./public (Hugo) or ./out (Next.js)",
89 -
			},
78 +
		const outputDir = exitOnCancel(
79 +
			await text({
80 +
				message: "Build output directory (for link tag injection):",
81 +
				placeholder: "./dist",
82 +
			}),
90 83
		);
91 84
92 85
		// Path prefix for posts
93 -
		const pathPrefix = await consola.prompt("URL path prefix for posts:", {
94 -
			type: "text",
95 -
			default: "/posts",
96 -
			placeholder: "/posts, /blog, /articles, etc.",
97 -
		});
86 +
		const pathPrefix = exitOnCancel(
87 +
			await text({
88 +
				message: "URL path prefix for posts:",
89 +
				placeholder: "/posts, /blog, /articles, etc.",
90 +
			}),
91 +
		);
98 92
99 93
		// Frontmatter mapping configuration
100 -
		consola.info(
94 +
		log.info(
101 95
			"Configure your frontmatter field mappings (press Enter to use defaults):",
102 96
		);
103 97
104 -
		const titleField = await consola.prompt("Field name for title:", {
105 -
			type: "text",
106 -
			default: "title",
107 -
			placeholder: "title",
108 -
		});
98 +
		const titleField = exitOnCancel(
99 +
			await text({
100 +
				message: "Field name for title:",
101 +
				defaultValue: "title",
102 +
				placeholder: "title",
103 +
			}),
104 +
		);
109 105
110 -
		const descField = await consola.prompt("Field name for description:", {
111 -
			type: "text",
112 -
			default: "description",
113 -
			placeholder: "description",
114 -
		});
106 +
		const descField = exitOnCancel(
107 +
			await text({
108 +
				message: "Field name for description:",
109 +
				defaultValue: "description",
110 +
				placeholder: "description",
111 +
			}),
112 +
		);
115 113
116 -
		const dateField = await consola.prompt("Field name for publish date:", {
117 -
			type: "text",
118 -
			default: "publishDate",
119 -
			placeholder: "publishDate, pubDate, date, etc.",
120 -
		});
114 +
		const dateField = exitOnCancel(
115 +
			await text({
116 +
				message: "Field name for publish date:",
117 +
				defaultValue: "publishDate",
118 +
				placeholder: "publishDate, pubDate, date, etc.",
119 +
			}),
120 +
		);
121 121
122 -
		const coverField = await consola.prompt("Field name for cover image:", {
123 -
			type: "text",
124 -
			default: "ogImage",
125 -
			placeholder: "ogImage, coverImage, image, hero, etc.",
126 -
		});
122 +
		const coverField = exitOnCancel(
123 +
			await text({
124 +
				message: "Field name for cover image:",
125 +
				defaultValue: "ogImage",
126 +
				placeholder: "ogImage, coverImage, image, hero, etc.",
127 +
			}),
128 +
		);
127 129
128 -
		const tagsField = await consola.prompt("Field name for tags:", {
129 -
			type: "text",
130 -
			default: "tags",
131 -
			placeholder: "tags, categories, keywords, etc.",
132 -
		});
130 +
		const tagsField = exitOnCancel(
131 +
			await text({
132 +
				message: "Field name for tags:",
133 +
				defaultValue: "tags",
134 +
				placeholder: "tags, categories, keywords, etc.",
135 +
			}),
136 +
		);
133 137
134 138
		let frontmatterMapping: FrontmatterMapping | undefined = {};
135 139
136 140
		if (titleField && titleField !== "title") {
137 -
			frontmatterMapping.title = titleField as string;
141 +
			frontmatterMapping.title = titleField;
138 142
		}
139 143
		if (descField && descField !== "description") {
140 -
			frontmatterMapping.description = descField as string;
144 +
			frontmatterMapping.description = descField;
141 145
		}
142 146
		if (dateField && dateField !== "publishDate") {
143 -
			frontmatterMapping.publishDate = dateField as string;
147 +
			frontmatterMapping.publishDate = dateField;
144 148
		}
145 149
		if (coverField && coverField !== "ogImage") {
146 -
			frontmatterMapping.coverImage = coverField as string;
150 +
			frontmatterMapping.coverImage = coverField;
147 151
		}
148 152
		if (tagsField && tagsField !== "tags") {
149 -
			frontmatterMapping.tags = tagsField as string;
153 +
			frontmatterMapping.tags = tagsField;
150 154
		}
151 155
152 156
		// Only keep frontmatterMapping if it has any custom fields
155 159
		}
156 160
157 161
		// Publication setup
158 -
		const publicationChoice = await consola.prompt("Publication setup:", {
159 -
			type: "select",
160 -
			options: [
161 -
				{ label: "Create a new publication", value: "create" },
162 -
				{ label: "Use an existing publication AT URI", value: "existing" },
163 -
			],
164 -
		});
162 +
		const publicationChoice = exitOnCancel(
163 +
			await select({
164 +
				message: "Publication setup:",
165 +
				options: [
166 +
					{ label: "Create a new publication", value: "create" },
167 +
					{ label: "Use an existing publication AT URI", value: "existing" },
168 +
				],
169 +
			}),
170 +
		);
165 171
166 172
		let publicationUri: string;
167 173
		let credentials = await loadCredentials();
169 175
		if (publicationChoice === "create") {
170 176
			// Need credentials to create a publication
171 177
			if (!credentials) {
172 -
				consola.error(
178 +
				log.error(
173 179
					"You must authenticate first. Run 'sequoia auth' before creating a publication.",
174 180
				);
175 181
				process.exit(1);
176 182
			}
177 183
178 -
			consola.start("Connecting to ATProto...");
184 +
			const s = spinner();
185 +
			s.start("Connecting to ATProto...");
179 186
			let agent;
180 187
			try {
181 188
				agent = await createAgent(credentials);
182 -
				consola.success("Connected!");
189 +
				s.stop("Connected!");
183 190
			} catch (error) {
184 -
				consola.error(
191 +
				s.stop("Failed to connect");
192 +
				log.error(
185 193
					"Failed to connect. Check your credentials with 'sequoia auth'.",
186 194
				);
187 195
				process.exit(1);
188 196
			}
189 197
190 -
			const pubName = await consola.prompt("Publication name:", {
191 -
				type: "text",
192 -
				placeholder: "My Blog",
193 -
			});
198 +
			const pubName = exitOnCancel(
199 +
				await text({
200 +
					message: "Publication name:",
201 +
					placeholder: "My Blog",
202 +
				}),
203 +
			);
194 204
195 205
			if (!pubName) {
196 -
				consola.error("Publication name is required");
206 +
				log.error("Publication name is required");
197 207
				process.exit(1);
198 208
			}
199 209
200 -
			const pubDescription = await consola.prompt(
201 -
				"Publication description (optional):",
202 -
				{
203 -
					type: "text",
210 +
			const pubDescription = exitOnCancel(
211 +
				await text({
212 +
					message: "Publication description (optional):",
204 213
					placeholder: "A blog about...",
205 -
				},
214 +
				}),
206 215
			);
207 216
208 -
			const iconPath = await consola.prompt(
209 -
				"Icon image path (leave empty to skip):",
210 -
				{
211 -
					type: "text",
212 -
					placeholder: "./icon.png",
213 -
				},
217 +
			const iconPath = exitOnCancel(
218 +
				await pathPrompt({
219 +
					message: "Icon image path (leave empty to skip):",
220 +
				}),
214 221
			);
215 222
216 -
			const showInDiscover = await consola.prompt("Show in Discover feed?", {
217 -
				type: "confirm",
218 -
				initial: true,
219 -
			});
223 +
			const showInDiscover = exitOnCancel(
224 +
				await confirm({
225 +
					message: "Show in Discover feed?",
226 +
					initialValue: true,
227 +
				}),
228 +
			);
220 229
221 -
			consola.start("Creating publication...");
230 +
			s.start("Creating publication...");
222 231
			try {
223 232
				publicationUri = await createPublication(agent, {
224 -
					url: siteUrl as string,
225 -
					name: pubName as string,
226 -
					description: (pubDescription as string) || undefined,
227 -
					iconPath: (iconPath as string) || undefined,
233 +
					url: siteUrl,
234 +
					name: pubName,
235 +
					description: pubDescription || undefined,
236 +
					iconPath: iconPath || undefined,
228 237
					showInDiscover,
229 238
				});
230 -
				consola.success(`Publication created: ${publicationUri}`);
239 +
				s.stop(`Publication created: ${publicationUri}`);
231 240
			} catch (error) {
232 -
				consola.error("Failed to create publication:", error);
241 +
				s.stop("Failed to create publication");
242 +
				log.error(`Failed to create publication: ${error}`);
233 243
				process.exit(1);
234 244
			}
235 245
		} else {
236 -
			const uri = await consola.prompt("Publication AT URI:", {
237 -
				type: "text",
238 -
				placeholder: "at://did:plc:.../site.standard.publication/...",
239 -
			});
246 +
			const uri = exitOnCancel(
247 +
				await text({
248 +
					message: "Publication AT URI:",
249 +
					placeholder: "at://did:plc:.../site.standard.publication/...",
250 +
				}),
251 +
			);
240 252
241 253
			if (!uri) {
242 -
				consola.error("Publication URI is required");
254 +
				log.error("Publication URI is required");
243 255
				process.exit(1);
244 256
			}
245 -
			publicationUri = uri as string;
257 +
			publicationUri = uri;
246 258
		}
247 259
248 260
		// Get PDS URL from credentials (already loaded earlier)
250 262
251 263
		// Generate config file
252 264
		const configContent = generateConfigTemplate({
253 -
			siteUrl: siteUrl as string,
254 -
			contentDir: contentDir as string,
265 +
			siteUrl: siteUrl,
266 +
			contentDir: contentDir || "./content",
255 267
			imagesDir: imagesDir || undefined,
256 -
			publicDir: publicDir as string,
257 -
			outputDir: outputDir as string,
258 -
			pathPrefix: pathPrefix as string,
268 +
			publicDir: publicDir || "./public",
269 +
			outputDir: outputDir || "./dist",
270 +
			pathPrefix: pathPrefix || "/posts",
259 271
			publicationUri,
260 272
			pdsUrl,
261 273
			frontmatter: frontmatterMapping,
264 276
		const configPath = path.join(process.cwd(), "sequoia.json");
265 277
		await Bun.write(configPath, configContent);
266 278
267 -
		consola.success(`Configuration saved to ${configPath}`);
279 +
		log.success(`Configuration saved to ${configPath}`);
268 280
269 281
		// Create .well-known/site.standard.publication file
270 -
		const resolvedPublicDir = path.isAbsolute(publicDir as string)
271 -
			? (publicDir as string)
272 -
			: path.join(process.cwd(), publicDir as string);
282 +
		const resolvedPublicDir = path.isAbsolute(publicDir || "./public")
283 +
			? publicDir || "./public"
284 +
			: path.join(process.cwd(), publicDir || "./public");
273 285
		const wellKnownDir = path.join(resolvedPublicDir, ".well-known");
274 286
		const wellKnownPath = path.join(wellKnownDir, "site.standard.publication");
275 287
277 289
		await Bun.write(path.join(wellKnownDir, ".gitkeep"), "");
278 290
		await Bun.write(wellKnownPath, publicationUri);
279 291
280 -
		consola.success(`Created ${wellKnownPath}`);
292 +
		log.success(`Created ${wellKnownPath}`);
281 293
282 294
		// Update .gitignore
283 295
		const gitignorePath = path.join(process.cwd(), ".gitignore");
291 303
					gitignorePath,
292 304
					gitignoreContent + `\n${stateFilename}\n`,
293 305
				);
294 -
				consola.info(`Added ${stateFilename} to .gitignore`);
306 +
				log.info(`Added ${stateFilename} to .gitignore`);
295 307
			}
296 308
		} else {
297 309
			await Bun.write(gitignorePath, `${stateFilename}\n`);
298 -
			consola.info(`Created .gitignore with ${stateFilename}`);
310 +
			log.info(`Created .gitignore with ${stateFilename}`);
299 311
		}
300 312
301 -
		consola.box(
302 -
			"Setup complete!\n\n" +
303 -
				"Next steps:\n" +
313 +
		note(
314 +
			"Next steps:\n" +
304 315
				"1. Run 'sequoia publish --dry-run' to preview\n" +
305 316
				"2. Run 'sequoia publish' to publish your content",
317 +
			"Setup complete!",
306 318
		);
319 +
320 +
		outro("Happy publishing!");
307 321
	},
308 322
});
packages/cli/src/commands/inject.ts +17 −17
1 1
import { command, flag, option, optional, string } from "cmd-ts";
2 -
import { consola } from "consola";
2 +
import { log } from "@clack/prompts";
3 3
import * as path from "path";
4 4
import { Glob } from "bun";
5 5
import { loadConfig, loadState, findConfig } from "../lib/config";
25 25
		// Load config
26 26
		const configPath = await findConfig();
27 27
		if (!configPath) {
28 -
			consola.error("No sequoia.json found. Run 'sequoia init' first.");
28 +
			log.error("No sequoia.json found. Run 'sequoia init' first.");
29 29
			process.exit(1);
30 30
		}
31 31
38 38
			? outputDir
39 39
			: path.join(configDir, outputDir);
40 40
41 -
		consola.info(`Scanning for HTML files in: ${resolvedOutputDir}`);
41 +
		log.info(`Scanning for HTML files in: ${resolvedOutputDir}`);
42 42
43 43
		// Load state to get atUri mappings
44 44
		const state = await loadState(configDir);
88 88
		}
89 89
90 90
		if (pathToAtUri.size === 0) {
91 -
			consola.warn(
91 +
			log.warn(
92 92
				"No published posts found in state. Run 'sequoia publish' first.",
93 93
			);
94 94
			return;
95 95
		}
96 96
97 -
		consola.info(`Found ${pathToAtUri.size} published posts in state`);
97 +
		log.info(`Found ${pathToAtUri.size} published posts in state`);
98 98
99 99
		// Scan for HTML files
100 100
		const glob = new Glob("**/*.html");
105 105
		}
106 106
107 107
		if (htmlFiles.length === 0) {
108 -
			consola.warn(`No HTML files found in ${resolvedOutputDir}`);
108 +
			log.warn(`No HTML files found in ${resolvedOutputDir}`);
109 109
			return;
110 110
		}
111 111
112 -
		consola.info(`Found ${htmlFiles.length} HTML files`);
112 +
		log.info(`Found ${htmlFiles.length} HTML files`);
113 113
114 114
		let injectedCount = 0;
115 115
		let skippedCount = 0;
165 165
			// Find </head> and inject before it
166 166
			const headCloseIndex = content.indexOf("</head>");
167 167
			if (headCloseIndex === -1) {
168 -
				consola.warn(`  No </head> found in ${relativePath}, skipping`);
168 +
				log.warn(`  No </head> found in ${relativePath}, skipping`);
169 169
				skippedCount++;
170 170
				continue;
171 171
			}
172 172
173 173
			if (dryRun) {
174 -
				consola.log(`  Would inject into: ${relativePath}`);
175 -
				consola.log(`    ${linkTag}`);
174 +
				log.message(`  Would inject into: ${relativePath}`);
175 +
				log.message(`    ${linkTag}`);
176 176
				injectedCount++;
177 177
				continue;
178 178
			}
185 185
				content.slice(headCloseIndex);
186 186
187 187
			await Bun.write(htmlPath, content);
188 -
			consola.success(`  Injected into: ${relativePath}`);
188 +
			log.success(`  Injected into: ${relativePath}`);
189 189
			injectedCount++;
190 190
		}
191 191
192 192
		// Summary
193 -
		consola.log("\n---");
193 +
		log.message("\n---");
194 194
		if (dryRun) {
195 -
			consola.info("Dry run complete. No changes made.");
195 +
			log.info("Dry run complete. No changes made.");
196 196
		}
197 -
		consola.info(`Injected: ${injectedCount}`);
198 -
		consola.info(`Already has tag: ${alreadyHasCount}`);
199 -
		consola.info(`Skipped (no match): ${skippedCount}`);
197 +
		log.info(`Injected: ${injectedCount}`);
198 +
		log.info(`Already has tag: ${alreadyHasCount}`);
199 +
		log.info(`Skipped (no match): ${skippedCount}`);
200 200
201 201
		if (skippedCount > 0 && !dryRun) {
202 -
			consola.info(
202 +
			log.info(
203 203
				"\nTip: Skipped files had no matching published post. This is normal for non-post pages.",
204 204
			);
205 205
		}
packages/cli/src/commands/publish.ts +39 −35
1 1
import { command, flag } from "cmd-ts";
2 -
import { consola } from "consola";
2 +
import { select, spinner, log } from "@clack/prompts";
3 3
import * as path from "path";
4 4
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
5 5
import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
10 10
  updateFrontmatterWithAtUri,
11 11
} from "../lib/markdown";
12 12
import type { BlogPost, BlobObject } from "../lib/types";
13 +
import { exitOnCancel } from "../lib/prompts";
13 14
14 15
export const publishCommand = command({
15 16
  name: "publish",
30 31
    // Load config
31 32
    const configPath = await findConfig();
32 33
    if (!configPath) {
33 -
      consola.error("No publisher.config.ts found. Run 'publisher init' first.");
34 +
      log.error("No publisher.config.ts found. Run 'publisher init' first.");
34 35
      process.exit(1);
35 36
    }
36 37
37 38
    const config = await loadConfig(configPath);
38 39
    const configDir = path.dirname(configPath);
39 40
40 -
    consola.info(`Site: ${config.siteUrl}`);
41 -
    consola.info(`Content directory: ${config.contentDir}`);
41 +
    log.info(`Site: ${config.siteUrl}`);
42 +
    log.info(`Content directory: ${config.contentDir}`);
42 43
43 44
    // Load credentials
44 45
    let credentials = await loadCredentials(config.identity);
47 48
    if (!credentials) {
48 49
      const identities = await listCredentials();
49 50
      if (identities.length === 0) {
50 -
        consola.error("No credentials found. Run 'sequoia auth' first.");
51 -
        consola.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.");
51 +
        log.error("No credentials found. Run 'sequoia auth' first.");
52 +
        log.info("Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.");
52 53
        process.exit(1);
53 54
      }
54 55
55 56
      // Multiple identities exist but none selected - prompt user
56 -
      consola.info("Multiple identities found. Select one to use:");
57 -
      const selected = await consola.prompt("Identity:", {
58 -
        type: "select",
59 -
        options: identities,
60 -
      });
57 +
      log.info("Multiple identities found. Select one to use:");
58 +
      const selected = exitOnCancel(await select({
59 +
        message: "Identity:",
60 +
        options: identities.map(id => ({ value: id, label: id })),
61 +
      }));
61 62
62 -
      credentials = await getCredentials(selected as string);
63 +
      credentials = await getCredentials(selected);
63 64
      if (!credentials) {
64 -
        consola.error("Failed to load selected credentials.");
65 +
        log.error("Failed to load selected credentials.");
65 66
        process.exit(1);
66 67
      }
67 68
68 -
      consola.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`);
69 +
      log.info(`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`);
69 70
    }
70 71
71 72
    // Resolve content directory
83 84
    const state = await loadState(configDir);
84 85
85 86
    // Scan for posts
86 -
    consola.start("Scanning for posts...");
87 +
    const s = spinner();
88 +
    s.start("Scanning for posts...");
87 89
    const posts = await scanContentDirectory(contentDir, config.frontmatter, config.ignore);
88 -
    consola.info(`Found ${posts.length} posts`);
90 +
    s.stop(`Found ${posts.length} posts`);
89 91
90 92
    // Determine which posts need publishing
91 93
    const postsToPublish: Array<{
123 125
    }
124 126
125 127
    if (postsToPublish.length === 0) {
126 -
      consola.success("All posts are up to date. Nothing to publish.");
128 +
      log.success("All posts are up to date. Nothing to publish.");
127 129
      return;
128 130
    }
129 131
130 -
    consola.info(`\n${postsToPublish.length} posts to publish:\n`);
132 +
    log.info(`\n${postsToPublish.length} posts to publish:\n`);
131 133
    for (const { post, action, reason } of postsToPublish) {
132 134
      const icon = action === "create" ? "+" : "~";
133 -
      consola.log(`  ${icon} ${post.frontmatter.title} (${reason})`);
135 +
      log.message(`  ${icon} ${post.frontmatter.title} (${reason})`);
134 136
    }
135 137
136 138
    if (dryRun) {
137 -
      consola.info("\nDry run complete. No changes made.");
139 +
      log.info("\nDry run complete. No changes made.");
138 140
      return;
139 141
    }
140 142
141 143
    // Create agent
142 -
    consola.start(`\nConnecting to ${credentials.pdsUrl}...`);
144 +
    s.start(`Connecting to ${credentials.pdsUrl}...`);
143 145
    let agent;
144 146
    try {
145 147
      agent = await createAgent(credentials);
146 -
      consola.success(`Logged in as ${agent.session?.handle}`);
148 +
      s.stop(`Logged in as ${agent.session?.handle}`);
147 149
    } catch (error) {
148 -
      consola.error("Failed to login:", error);
150 +
      s.stop("Failed to login");
151 +
      log.error(`Failed to login: ${error}`);
149 152
      process.exit(1);
150 153
    }
151 154
155 158
    let errorCount = 0;
156 159
157 160
    for (const { post, action } of postsToPublish) {
158 -
      consola.start(`Publishing: ${post.frontmatter.title}`);
161 +
      s.start(`Publishing: ${post.frontmatter.title}`);
159 162
160 163
      try {
161 164
        // Handle cover image upload
168 171
          );
169 172
170 173
          if (imagePath) {
171 -
            consola.info(`  Uploading cover image: ${path.basename(imagePath)}`);
174 +
            log.info(`  Uploading cover image: ${path.basename(imagePath)}`);
172 175
            coverImage = await uploadImage(agent, imagePath);
173 176
            if (coverImage) {
174 -
              consola.info(`  Uploaded image blob: ${coverImage.ref.$link}`);
177 +
              log.info(`  Uploaded image blob: ${coverImage.ref.$link}`);
175 178
            }
176 179
          } else {
177 -
            consola.warn(`  Cover image not found: ${post.frontmatter.ogImage}`);
180 +
            log.warn(`  Cover image not found: ${post.frontmatter.ogImage}`);
178 181
          }
179 182
        }
180 183
184 187
185 188
        if (action === "create") {
186 189
          atUri = await createDocument(agent, post, config, coverImage);
187 -
          consola.success(`  Created: ${atUri}`);
190 +
          s.stop(`Created: ${atUri}`);
188 191
189 192
          // Update frontmatter with atUri
190 193
          const updatedContent = updateFrontmatterWithAtUri(post.rawContent, atUri);
191 194
          await Bun.write(post.filePath, updatedContent);
192 -
          consola.info(`  Updated frontmatter in ${path.basename(post.filePath)}`);
195 +
          log.info(`  Updated frontmatter in ${path.basename(post.filePath)}`);
193 196
194 197
          // Use updated content (with atUri) for hash so next run sees matching hash
195 198
          contentForHash = updatedContent;
197 200
        } else {
198 201
          atUri = post.frontmatter.atUri!;
199 202
          await updateDocument(agent, post, atUri, config, coverImage);
200 -
          consola.success(`  Updated: ${atUri}`);
203 +
          s.stop(`Updated: ${atUri}`);
201 204
202 205
          // For updates, rawContent already has atUri
203 206
          contentForHash = post.rawContent;
214 217
        };
215 218
      } catch (error) {
216 219
        const errorMessage = error instanceof Error ? error.message : String(error);
217 -
        consola.error(`  Error publishing "${path.basename(post.filePath)}": ${errorMessage}`);
220 +
        s.stop(`Error publishing "${path.basename(post.filePath)}"`);
221 +
        log.error(`  ${errorMessage}`);
218 222
        errorCount++;
219 223
      }
220 224
    }
223 227
    await saveState(configDir, state);
224 228
225 229
    // Summary
226 -
    consola.log("\n---");
227 -
    consola.info(`Published: ${publishedCount}`);
228 -
    consola.info(`Updated: ${updatedCount}`);
230 +
    log.message("\n---");
231 +
    log.info(`Published: ${publishedCount}`);
232 +
    log.info(`Updated: ${updatedCount}`);
229 233
    if (errorCount > 0) {
230 -
      consola.warn(`Errors: ${errorCount}`);
234 +
      log.warn(`Errors: ${errorCount}`);
231 235
    }
232 236
  },
233 237
});
packages/cli/src/commands/sync.ts +42 −39
1 1
import { command, flag } from "cmd-ts";
2 -
import { consola } from "consola";
2 +
import { select, spinner, log } from "@clack/prompts";
3 3
import * as path from "path";
4 4
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
5 5
import { loadCredentials, listCredentials, getCredentials } from "../lib/credentials";
6 6
import { createAgent, listDocuments } from "../lib/atproto";
7 7
import { scanContentDirectory, getContentHash, updateFrontmatterWithAtUri } from "../lib/markdown";
8 +
import { exitOnCancel } from "../lib/prompts";
8 9
9 10
export const syncCommand = command({
10 11
  name: "sync",
25 26
    // Load config
26 27
    const configPath = await findConfig();
27 28
    if (!configPath) {
28 -
      consola.error("No sequoia.json found. Run 'sequoia init' first.");
29 +
      log.error("No sequoia.json found. Run 'sequoia init' first.");
29 30
      process.exit(1);
30 31
    }
31 32
32 33
    const config = await loadConfig(configPath);
33 34
    const configDir = path.dirname(configPath);
34 35
35 -
    consola.info(`Site: ${config.siteUrl}`);
36 -
    consola.info(`Publication: ${config.publicationUri}`);
36 +
    log.info(`Site: ${config.siteUrl}`);
37 +
    log.info(`Publication: ${config.publicationUri}`);
37 38
38 39
    // Load credentials
39 40
    let credentials = await loadCredentials(config.identity);
41 42
    if (!credentials) {
42 43
      const identities = await listCredentials();
43 44
      if (identities.length === 0) {
44 -
        consola.error("No credentials found. Run 'sequoia auth' first.");
45 +
        log.error("No credentials found. Run 'sequoia auth' first.");
45 46
        process.exit(1);
46 47
      }
47 48
48 -
      consola.info("Multiple identities found. Select one to use:");
49 -
      const selected = await consola.prompt("Identity:", {
50 -
        type: "select",
51 -
        options: identities,
52 -
      });
49 +
      log.info("Multiple identities found. Select one to use:");
50 +
      const selected = exitOnCancel(await select({
51 +
        message: "Identity:",
52 +
        options: identities.map(id => ({ value: id, label: id })),
53 +
      }));
53 54
54 -
      credentials = await getCredentials(selected as string);
55 +
      credentials = await getCredentials(selected);
55 56
      if (!credentials) {
56 -
        consola.error("Failed to load selected credentials.");
57 +
        log.error("Failed to load selected credentials.");
57 58
        process.exit(1);
58 59
      }
59 60
    }
60 61
61 62
    // Create agent
62 -
    consola.start(`Connecting to ${credentials.pdsUrl}...`);
63 +
    const s = spinner();
64 +
    s.start(`Connecting to ${credentials.pdsUrl}...`);
63 65
    let agent;
64 66
    try {
65 67
      agent = await createAgent(credentials);
66 -
      consola.success(`Logged in as ${agent.session?.handle}`);
68 +
      s.stop(`Logged in as ${agent.session?.handle}`);
67 69
    } catch (error) {
68 -
      consola.error("Failed to login:", error);
70 +
      s.stop("Failed to login");
71 +
      log.error(`Failed to login: ${error}`);
69 72
      process.exit(1);
70 73
    }
71 74
72 75
    // Fetch documents from PDS
73 -
    consola.start("Fetching documents from PDS...");
76 +
    s.start("Fetching documents from PDS...");
74 77
    const documents = await listDocuments(agent, config.publicationUri);
75 -
    consola.info(`Found ${documents.length} documents on PDS`);
78 +
    s.stop(`Found ${documents.length} documents on PDS`);
76 79
77 80
    if (documents.length === 0) {
78 -
      consola.info("No documents found for this publication.");
81 +
      log.info("No documents found for this publication.");
79 82
      return;
80 83
    }
81 84
85 88
      : path.join(configDir, config.contentDir);
86 89
87 90
    // Scan local posts
88 -
    consola.start("Scanning local content...");
91 +
    s.start("Scanning local content...");
89 92
    const localPosts = await scanContentDirectory(contentDir, config.frontmatter);
90 -
    consola.info(`Found ${localPosts.length} local posts`);
93 +
    s.stop(`Found ${localPosts.length} local posts`);
91 94
92 95
    // Build a map of path -> local post for matching
93 96
    // Document path is like /posts/my-post-slug
106 109
    let unmatchedCount = 0;
107 110
    let frontmatterUpdates: Array<{ filePath: string; atUri: string }> = [];
108 111
109 -
    consola.log("\nMatching documents to local files:\n");
112 +
    log.message("\nMatching documents to local files:\n");
110 113
111 114
    for (const doc of documents) {
112 115
      const docPath = doc.value.path;
114 117
115 118
      if (localPost) {
116 119
        matchedCount++;
117 -
        consola.log(`  ✓ ${doc.value.title}`);
118 -
        consola.log(`    Path: ${docPath}`);
119 -
        consola.log(`    URI: ${doc.uri}`);
120 -
        consola.log(`    File: ${path.basename(localPost.filePath)}`);
120 +
        log.message(`  ✓ ${doc.value.title}`);
121 +
        log.message(`    Path: ${docPath}`);
122 +
        log.message(`    URI: ${doc.uri}`);
123 +
        log.message(`    File: ${path.basename(localPost.filePath)}`);
121 124
122 125
        // Update state (use relative path from config directory)
123 126
        const contentHash = await getContentHash(localPost.rawContent);
134 137
            filePath: localPost.filePath,
135 138
            atUri: doc.uri,
136 139
          });
137 -
          consola.log(`    → Will update frontmatter`);
140 +
          log.message(`    → Will update frontmatter`);
138 141
        }
139 142
      } else {
140 143
        unmatchedCount++;
141 -
        consola.log(`  ✗ ${doc.value.title} (no matching local file)`);
142 -
        consola.log(`    Path: ${docPath}`);
143 -
        consola.log(`    URI: ${doc.uri}`);
144 +
        log.message(`  ✗ ${doc.value.title} (no matching local file)`);
145 +
        log.message(`    Path: ${docPath}`);
146 +
        log.message(`    URI: ${doc.uri}`);
144 147
      }
145 -
      consola.log("");
148 +
      log.message("");
146 149
    }
147 150
148 151
    // Summary
149 -
    consola.log("---");
150 -
    consola.info(`Matched: ${matchedCount} documents`);
152 +
    log.message("---");
153 +
    log.info(`Matched: ${matchedCount} documents`);
151 154
    if (unmatchedCount > 0) {
152 -
      consola.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`);
155 +
      log.warn(`Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`);
153 156
    }
154 157
155 158
    if (dryRun) {
156 -
      consola.info("\nDry run complete. No changes made.");
159 +
      log.info("\nDry run complete. No changes made.");
157 160
      return;
158 161
    }
159 162
160 163
    // Save updated state
161 164
    await saveState(configDir, state);
162 165
    const newPostCount = Object.keys(state.posts).length;
163 -
    consola.success(`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`);
166 +
    log.success(`\nSaved .sequoia-state.json (${originalPostCount} → ${newPostCount} entries)`);
164 167
165 168
    // Update frontmatter if requested
166 169
    if (frontmatterUpdates.length > 0) {
167 -
      consola.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
170 +
      s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`);
168 171
      for (const { filePath, atUri } of frontmatterUpdates) {
169 172
        const file = Bun.file(filePath);
170 173
        const content = await file.text();
171 174
        const updated = updateFrontmatterWithAtUri(content, atUri);
172 175
        await Bun.write(filePath, updated);
173 -
        consola.log(`  Updated: ${path.basename(filePath)}`);
176 +
        log.message(`  Updated: ${path.basename(filePath)}`);
174 177
      }
175 -
      consola.success("Frontmatter updated");
178 +
      s.stop("Frontmatter updated");
176 179
    }
177 180
178 -
    consola.success("\nSync complete!");
181 +
    log.success("\nSync complete!");
179 182
  },
180 183
});
packages/cli/src/lib/prompts.ts (added) +9 −0
1 +
import { isCancel, cancel } from "@clack/prompts";
2 +
3 +
export function exitOnCancel<T>(value: T | symbol): T {
4 +
  if (isCancel(value)) {
5 +
    cancel("Cancelled");
6 +
    process.exit(0);
7 +
  }
8 +
  return value as T;
9 +
}