A CLI for publishing standard.site documents to ATProto

feat: add remanso CLI package and update GitHub Action

- Fix ../ relative path normalization in resolveInternalLinks and
findPostsWithStaleLinks (packages/cli/src/extensions/remanso.ts)
- Add packages/remanso: new CLI binary (remanso-cli npm package, remanso
command) with auth, init, publish, sync, and github commands
- publish: two-pass flow (site.standard.document + space.remanso.note),
.pub.md filter, siteUrl derived from agent DID, app-password only
- sync: matches .pub.md files to PDS documents, preserves note field comparison
- init: interactive wizard with GitHub remote detection, credential setup,
publication create/select, .gitignore, workflow generation, gh secret setup
- github: generates remanso-space/sequoia@main workflow YAML, sets secrets via gh CLI
- Update action.yml to build and link remanso CLI instead of sequoia CLI
- Add build:remanso and dev:remanso scripts to root package.json

+1914 -16
+14 -14
action.yml
··· 1 - name: 'Sequoia Publish' 2 - description: 'Publish your markdown content to ATProtocol using Sequoia CLI' 1 + name: 'Remanso Publish' 2 + description: 'Publish your .pub.md notes to ATProtocol using Remanso CLI' 3 3 branding: 4 4 icon: 'upload-cloud' 5 5 color: 'green' ··· 16 16 required: false 17 17 default: 'https://bsky.social' 18 18 force: 19 - description: 'Force publish all posts, ignoring change detection' 19 + description: 'Force publish all notes, ignoring change detection' 20 20 required: false 21 21 default: 'false' 22 22 commit-back: 23 - description: 'Commit updated frontmatter and state file back to the repo' 23 + description: 'Commit updated frontmatter back to the repo' 24 24 required: false 25 25 default: 'true' 26 26 working-directory: 27 - description: 'Directory containing sequoia.json (defaults to repo root)' 27 + description: 'Directory containing remanso.json (defaults to repo root)' 28 28 required: false 29 29 default: '.' 30 30 ··· 39 39 uses: actions/cache@v4 40 40 with: 41 41 path: ${{ github.action_path }}/node_modules 42 - key: sequoia-deps-${{ runner.os }}-${{ hashFiles(format('{0}/bun.lock', github.action_path)) }} 42 + key: remanso-deps-${{ runner.os }}-${{ hashFiles(format('{0}/bun.lock', github.action_path)) }} 43 43 44 44 - name: Install dependencies 45 45 if: steps.deps-cache.outputs.cache-hit != 'true' ··· 50 50 id: build-cache 51 51 uses: actions/cache@v4 52 52 with: 53 - path: ${{ github.action_path }}/packages/cli/dist 54 - key: sequoia-build-${{ runner.os }}-${{ hashFiles(format('{0}/bun.lock', github.action_path), format('{0}/packages/cli/src/**', github.action_path)) }} 53 + path: ${{ github.action_path }}/packages/remanso/dist 54 + key: remanso-build-${{ runner.os }}-${{ hashFiles(format('{0}/bun.lock', github.action_path), format('{0}/packages/remanso/src/**', github.action_path)) }} 55 55 56 56 - name: Build CLI 57 57 if: steps.build-cache.outputs.cache-hit != 'true' 58 58 shell: bash 59 - run: cd ${{ github.action_path }} && bun run build:cli 59 + run: cd ${{ github.action_path }} && bun run build:remanso 60 60 61 61 - name: Link CLI 62 62 shell: bash 63 - run: cd ${{ github.action_path }} && bun link --cwd packages/cli 63 + run: cd ${{ github.action_path }} && bun link --cwd packages/remanso 64 64 65 65 - name: Sync state from ATProtocol 66 66 shell: bash ··· 69 69 ATP_IDENTIFIER: ${{ inputs.identifier }} 70 70 ATP_APP_PASSWORD: ${{ inputs.app-password }} 71 71 PDS_URL: ${{ inputs.pds-url }} 72 - run: sequoia sync 72 + run: remanso sync 73 73 74 74 - name: Publish 75 75 shell: bash ··· 83 83 if [ "${{ inputs.force }}" = "true" ]; then 84 84 FLAGS="--force" 85 85 fi 86 - sequoia publish $FLAGS 86 + remanso publish $FLAGS 87 87 88 88 - name: Commit back changes 89 89 if: inputs.commit-back == 'true' ··· 92 92 run: | 93 93 git config user.name "$(git log -1 --format='%an')" 94 94 git config user.email "$(git log -1 --format='%ae')" 95 - git add -A -- '**/*.md' || true 95 + git add -A -- '**/*.pub.md' || true 96 96 if git diff --cached --quiet; then 97 97 echo "No changes to commit" 98 98 else 99 - git commit -m "chore: update sequoia state [skip ci]" 99 + git commit -m "chore: update remanso state [skip ci]" 100 100 git push 101 101 fi
+22
bun.lock
··· 47 47 "typescript": "^5", 48 48 }, 49 49 }, 50 + "packages/remanso": { 51 + "name": "remanso-cli", 52 + "version": "0.1.0", 53 + "bin": { 54 + "remanso": "dist/index.js", 55 + }, 56 + "dependencies": { 57 + "@atproto/api": "^0.18.17", 58 + "@clack/prompts": "^1.0.0", 59 + "cmd-ts": "^0.14.3", 60 + "glob": "^13.0.0", 61 + "mime-types": "^2.1.35", 62 + "minimatch": "^10.1.1", 63 + }, 64 + "devDependencies": { 65 + "@biomejs/biome": "^2.3.13", 66 + "@types/mime-types": "^3.0.1", 67 + "@types/node": "^20", 68 + }, 69 + }, 50 70 }, 51 71 "packages": { 52 72 "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], ··· 1316 1336 "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], 1317 1337 1318 1338 "rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="], 1339 + 1340 + "remanso-cli": ["remanso-cli@workspace:packages/remanso"], 1319 1341 1320 1342 "remark-directive": ["remark-directive@3.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-directive": "^3.0.0", "micromark-extension-directive": "^3.0.0", "unified": "^11.0.0" } }, "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A=="], 1321 1343
+2
package.json
··· 10 10 "dev:docs": "cd docs && bun run dev", 11 11 "build:docs": "cd docs && bun run build", 12 12 "build:cli": "cd packages/cli && bun run build", 13 + "build:remanso": "cd packages/remanso && bun run build", 14 + "dev:remanso": "cd packages/remanso && bun run dev", 13 15 "deploy:docs": "cd docs && bun run deploy", 14 16 "deploy:cli": "cd packages/cli && bun run deploy", 15 17 "test:cli": "cd packages/cli && bun test"
+2 -2
packages/cli/src/extensions/remanso.ts
··· 138 138 139 139 // Normalize to a slug-like string for comparison 140 140 const normalized = url 141 - .replace(/^\.?\/?/, "") 141 + .replace(/^(\.\.\/|\.\/)+/, "") 142 142 .replace(/\/?$/, "") 143 143 .replace(/\.mdx?$/, "") 144 144 .replace(/\/index$/, ""); ··· 292 292 if (!isLocalPath(url)) return false; 293 293 294 294 const normalized = url 295 - .replace(/^\.?\/?/, "") 295 + .replace(/^(\.\.\/|\.\/)+/, "") 296 296 .replace(/\/?$/, "") 297 297 .replace(/\.mdx?$/, "") 298 298 .replace(/\/index$/, "");
+25
packages/remanso/package.json
··· 1 + { 2 + "name": "remanso-cli", 3 + "version": "0.1.0", 4 + "type": "module", 5 + "bin": { "remanso": "dist/index.js" }, 6 + "scripts": { 7 + "build": "bun build src/index.ts --target node --outdir dist", 8 + "dev": "bun run build && bun link", 9 + "lint": "biome lint --write src", 10 + "format": "biome format --write src" 11 + }, 12 + "dependencies": { 13 + "@atproto/api": "^0.18.17", 14 + "@clack/prompts": "^1.0.0", 15 + "cmd-ts": "^0.14.3", 16 + "glob": "^13.0.0", 17 + "mime-types": "^2.1.35", 18 + "minimatch": "^10.1.1" 19 + }, 20 + "devDependencies": { 21 + "@biomejs/biome": "^2.3.13", 22 + "@types/mime-types": "^3.0.1", 23 + "@types/node": "^20" 24 + } 25 + }
+170
packages/remanso/src/commands/auth.ts
··· 1 + import { AtpAgent } from "@atproto/api"; 2 + import { 3 + confirm, 4 + log, 5 + note, 6 + password, 7 + select, 8 + spinner, 9 + text, 10 + } from "@clack/prompts"; 11 + import { command, flag, option, optional, string } from "cmd-ts"; 12 + import { resolveHandleToPDS } from "../../../cli/src/lib/atproto"; 13 + import { 14 + deleteCredentials, 15 + getCredentials, 16 + getCredentialsPath, 17 + listCredentials, 18 + saveCredentials, 19 + } from "../../../cli/src/lib/credentials"; 20 + import { exitOnCancel } from "../../../cli/src/lib/prompts"; 21 + 22 + export const authCommand = command({ 23 + name: "auth", 24 + description: "Authenticate with your ATProto PDS (App Password only)", 25 + args: { 26 + logout: option({ 27 + long: "logout", 28 + description: 29 + "Remove credentials for a specific identity (or all if only one exists)", 30 + type: optional(string), 31 + }), 32 + list: flag({ 33 + long: "list", 34 + description: "List all stored identities", 35 + }), 36 + }, 37 + handler: async ({ logout, list }) => { 38 + // List identities 39 + if (list) { 40 + const identities = await listCredentials(); 41 + if (identities.length === 0) { 42 + log.info("No stored identities"); 43 + } else { 44 + log.info("Stored identities:"); 45 + for (const id of identities) { 46 + console.log(` - ${id}`); 47 + } 48 + } 49 + return; 50 + } 51 + 52 + // Logout 53 + if (logout !== undefined) { 54 + const identifier = logout || undefined; 55 + 56 + if (!identifier) { 57 + const identities = await listCredentials(); 58 + if (identities.length === 0) { 59 + log.info("No saved credentials found"); 60 + return; 61 + } 62 + if (identities.length === 1) { 63 + const deleted = await deleteCredentials(identities[0]); 64 + if (deleted) { 65 + log.success(`Removed credentials for ${identities[0]}`); 66 + } 67 + return; 68 + } 69 + const selected = exitOnCancel( 70 + await select({ 71 + message: "Select identity to remove:", 72 + options: identities.map((id) => ({ value: id, label: id })), 73 + }), 74 + ); 75 + const deleted = await deleteCredentials(selected); 76 + if (deleted) { 77 + log.success(`Removed credentials for ${selected}`); 78 + } 79 + return; 80 + } 81 + 82 + const deleted = await deleteCredentials(identifier); 83 + if (deleted) { 84 + log.success(`Removed credentials for ${identifier}`); 85 + } else { 86 + log.info(`No credentials found for ${identifier}`); 87 + } 88 + return; 89 + } 90 + 91 + note( 92 + "To authenticate, you'll need an App Password.\n\n" + 93 + "Create one at: https://bsky.app/settings/app-passwords\n\n" + 94 + "App Passwords are safer than your main password and can be revoked.", 95 + "Authentication", 96 + ); 97 + 98 + const identifier = exitOnCancel( 99 + await text({ 100 + message: "Handle or DID:", 101 + placeholder: "yourhandle.bsky.social", 102 + }), 103 + ); 104 + 105 + const appPassword = exitOnCancel( 106 + await password({ 107 + message: "App Password:", 108 + }), 109 + ); 110 + 111 + if (!identifier || !appPassword) { 112 + log.error("Handle and password are required"); 113 + process.exit(1); 114 + } 115 + 116 + // Check if this identity already exists 117 + const existing = await getCredentials(identifier); 118 + if (existing) { 119 + const overwrite = exitOnCancel( 120 + await confirm({ 121 + message: `Credentials for ${identifier} already exist. Update?`, 122 + initialValue: false, 123 + }), 124 + ); 125 + if (!overwrite) { 126 + log.info("Keeping existing credentials"); 127 + return; 128 + } 129 + } 130 + 131 + // Resolve PDS from handle 132 + const s = spinner(); 133 + s.start("Resolving PDS..."); 134 + let pdsUrl: string; 135 + try { 136 + pdsUrl = await resolveHandleToPDS(identifier); 137 + s.stop(`Found PDS: ${pdsUrl}`); 138 + } catch (error) { 139 + s.stop("Failed to resolve PDS"); 140 + log.error(`Failed to resolve PDS from handle: ${error}`); 141 + process.exit(1); 142 + } 143 + 144 + // Verify credentials 145 + s.start("Verifying credentials..."); 146 + 147 + try { 148 + const agent = new AtpAgent({ service: pdsUrl }); 149 + await agent.login({ 150 + identifier: identifier, 151 + password: appPassword, 152 + }); 153 + 154 + s.stop(`Logged in as ${agent.session?.handle}`); 155 + 156 + await saveCredentials({ 157 + type: "app-password", 158 + pdsUrl, 159 + identifier: identifier, 160 + password: appPassword, 161 + }); 162 + 163 + log.success(`Credentials saved to ${getCredentialsPath()}`); 164 + } catch (error) { 165 + s.stop("Failed to login"); 166 + log.error(`Failed to login: ${error}`); 167 + process.exit(1); 168 + } 169 + }, 170 + });
+211
packages/remanso/src/commands/github.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import * as path from "node:path"; 3 + import { execSync, spawnSync } from "node:child_process"; 4 + import { command, flag } from "cmd-ts"; 5 + import { log, spinner, confirm, text } from "@clack/prompts"; 6 + import { exitOnCancel } from "../../../cli/src/lib/prompts"; 7 + import { getCredentials, listCredentials } from "../../../cli/src/lib/credentials"; 8 + 9 + export const WORKFLOW_YAML = `name: Publish to the PDS 10 + on: 11 + push: 12 + branches: [main] 13 + workflow_dispatch: 14 + jobs: 15 + publish: 16 + runs-on: ubuntu-latest 17 + permissions: 18 + contents: write 19 + steps: 20 + - uses: actions/checkout@v4 21 + - uses: remanso-space/sequoia@main 22 + with: 23 + identifier: \${{ secrets.ATP_IDENTIFIER }} 24 + app-password: \${{ secrets.ATP_APP_PASSWORD }} 25 + `; 26 + 27 + const WORKFLOW_PATH = ".github/workflows/remanso.yml"; 28 + 29 + async function fileExists(filePath: string): Promise<boolean> { 30 + try { 31 + await fs.access(filePath); 32 + return true; 33 + } catch { 34 + return false; 35 + } 36 + } 37 + 38 + function detectGitHubRemote(): { owner: string; repo: string } | null { 39 + try { 40 + const remote = execSync("git remote get-url origin", { 41 + encoding: "utf-8", 42 + stdio: ["pipe", "pipe", "pipe"], 43 + }).trim(); 44 + 45 + // Parse SSH: git@github.com:owner/repo.git 46 + const sshMatch = remote.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?$/); 47 + if (sshMatch) { 48 + return { owner: sshMatch[1]!, repo: sshMatch[2]! }; 49 + } 50 + 51 + // Parse HTTPS: https://github.com/owner/repo.git 52 + const httpsMatch = remote.match( 53 + /https:\/\/github\.com\/([^/]+)\/([^.]+?)(?:\.git)?$/, 54 + ); 55 + if (httpsMatch) { 56 + return { owner: httpsMatch[1]!, repo: httpsMatch[2]! }; 57 + } 58 + 59 + return null; 60 + } catch { 61 + return null; 62 + } 63 + } 64 + 65 + async function generateWorkflow(): Promise<void> { 66 + const workflowDir = path.dirname(WORKFLOW_PATH); 67 + 68 + if (await fileExists(WORKFLOW_PATH)) { 69 + const overwrite = exitOnCancel( 70 + await confirm({ 71 + message: `${WORKFLOW_PATH} already exists. Overwrite?`, 72 + initialValue: false, 73 + }), 74 + ); 75 + if (!overwrite) { 76 + log.info(`Keeping existing ${WORKFLOW_PATH}`); 77 + return; 78 + } 79 + } 80 + 81 + await fs.mkdir(workflowDir, { recursive: true }); 82 + await fs.writeFile(WORKFLOW_PATH, WORKFLOW_YAML); 83 + log.success(`Created ${WORKFLOW_PATH}`); 84 + } 85 + 86 + async function setSecrets(): Promise<void> { 87 + const remote = detectGitHubRemote(); 88 + if (!remote) { 89 + log.warn( 90 + "Could not detect GitHub remote. Skipping secret setup.", 91 + ); 92 + log.info( 93 + "Add ATP_IDENTIFIER and ATP_APP_PASSWORD manually in your GitHub repo settings.", 94 + ); 95 + return; 96 + } 97 + 98 + log.info(`GitHub repo: ${remote.owner}/${remote.repo}`); 99 + 100 + // Check if gh CLI is available 101 + const ghCheck = spawnSync("gh", ["--version"], { stdio: "pipe" }); 102 + if (ghCheck.status !== 0) { 103 + log.warn("gh CLI not found. Cannot set secrets automatically."); 104 + log.info( 105 + "Install the GitHub CLI (https://cli.github.com/) or set secrets manually.", 106 + ); 107 + return; 108 + } 109 + 110 + // Get identifier from stored credentials or prompt 111 + let identifier: string; 112 + let appPassword: string; 113 + 114 + const storedIds = await listCredentials(); 115 + if (storedIds.length === 1 && storedIds[0]) { 116 + const creds = await getCredentials(storedIds[0]); 117 + if (creds) { 118 + identifier = creds.identifier; 119 + appPassword = creds.password; 120 + log.info(`Using stored credentials for: ${identifier}`); 121 + } else { 122 + identifier = exitOnCancel( 123 + await text({ message: "ATProto handle (ATP_IDENTIFIER):", placeholder: "you.bsky.social" }), 124 + ); 125 + appPassword = exitOnCancel( 126 + await text({ message: "App Password (ATP_APP_PASSWORD):", placeholder: "xxxx-xxxx-xxxx-xxxx" }), 127 + ); 128 + } 129 + } else if (storedIds.length > 1) { 130 + const { select } = await import("@clack/prompts"); 131 + const selected = exitOnCancel( 132 + await select({ 133 + message: "Select identity for GitHub secrets:", 134 + options: storedIds.map((id) => ({ value: id, label: id })), 135 + }), 136 + ); 137 + const creds = await getCredentials(selected); 138 + if (!creds) { 139 + log.error("Could not load credentials for selected identity."); 140 + return; 141 + } 142 + identifier = creds.identifier; 143 + appPassword = creds.password; 144 + } else { 145 + identifier = exitOnCancel( 146 + await text({ message: "ATProto handle (ATP_IDENTIFIER):", placeholder: "you.bsky.social" }), 147 + ); 148 + appPassword = exitOnCancel( 149 + await text({ message: "App Password (ATP_APP_PASSWORD):", placeholder: "xxxx-xxxx-xxxx-xxxx" }), 150 + ); 151 + } 152 + 153 + const s = spinner(); 154 + const repoFlag = `${remote.owner}/${remote.repo}`; 155 + 156 + s.start("Setting ATP_IDENTIFIER secret..."); 157 + const r1 = spawnSync( 158 + "gh", 159 + ["secret", "set", "ATP_IDENTIFIER", "--body", identifier, "--repo", repoFlag], 160 + { stdio: "pipe" }, 161 + ); 162 + if (r1.status === 0) { 163 + s.stop("ATP_IDENTIFIER set"); 164 + } else { 165 + s.stop("Failed to set ATP_IDENTIFIER"); 166 + log.warn(r1.stderr?.toString() || "Unknown error"); 167 + } 168 + 169 + s.start("Setting ATP_APP_PASSWORD secret..."); 170 + const r2 = spawnSync( 171 + "gh", 172 + ["secret", "set", "ATP_APP_PASSWORD", "--body", appPassword, "--repo", repoFlag], 173 + { stdio: "pipe" }, 174 + ); 175 + if (r2.status === 0) { 176 + s.stop("ATP_APP_PASSWORD set"); 177 + } else { 178 + s.stop("Failed to set ATP_APP_PASSWORD"); 179 + log.warn(r2.stderr?.toString() || "Unknown error"); 180 + } 181 + 182 + log.success(`Secrets configured for ${repoFlag}`); 183 + } 184 + 185 + export const githubCommand = command({ 186 + name: "github", 187 + description: 188 + "Set up GitHub Actions workflow and secrets for automated publishing", 189 + args: { 190 + workflow: flag({ 191 + long: "workflow", 192 + description: "Only generate the GitHub Actions workflow YAML", 193 + }), 194 + secrets: flag({ 195 + long: "secrets", 196 + description: "Only set GitHub repository secrets via the gh CLI", 197 + }), 198 + }, 199 + handler: async ({ workflow, secrets }) => { 200 + const doWorkflow = workflow || (!workflow && !secrets); 201 + const doSecrets = secrets || (!workflow && !secrets); 202 + 203 + if (doWorkflow) { 204 + await generateWorkflow(); 205 + } 206 + 207 + if (doSecrets) { 208 + await setSecrets(); 209 + } 210 + }, 211 + });
+468
packages/remanso/src/commands/init.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import * as path from "node:path"; 3 + import { execSync, spawnSync } from "node:child_process"; 4 + import { command } from "cmd-ts"; 5 + import { 6 + confirm, 7 + intro, 8 + log, 9 + note, 10 + outro, 11 + password, 12 + select, 13 + spinner, 14 + text, 15 + } from "@clack/prompts"; 16 + import { AtpAgent } from "@atproto/api"; 17 + import { resolveHandleToPDS, createPublication, createAgent } from "../../../cli/src/lib/atproto"; 18 + import { 19 + loadCredentials, 20 + saveCredentials, 21 + getCredentials, 22 + listCredentials, 23 + } from "../../../cli/src/lib/credentials"; 24 + import { exitOnCancel } from "../../../cli/src/lib/prompts"; 25 + import type { RemansoConfig } from "../lib/config"; 26 + import { WORKFLOW_YAML } from "./github"; 27 + 28 + const CONFIG_FILENAME = "remanso.json"; 29 + const STATE_FILENAME = ".remanso-state.json"; 30 + const WORKFLOW_PATH = ".github/workflows/remanso.yml"; 31 + 32 + async function fileExists(filePath: string): Promise<boolean> { 33 + try { 34 + await fs.access(filePath); 35 + return true; 36 + } catch { 37 + return false; 38 + } 39 + } 40 + 41 + function detectGitHubRemote(): { owner: string; repo: string } | null { 42 + try { 43 + const remote = execSync("git remote get-url origin", { 44 + encoding: "utf-8", 45 + stdio: ["pipe", "pipe", "pipe"], 46 + }).trim(); 47 + 48 + const sshMatch = remote.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?$/); 49 + if (sshMatch) { 50 + return { owner: sshMatch[1]!, repo: sshMatch[2]! }; 51 + } 52 + 53 + const httpsMatch = remote.match( 54 + /https:\/\/github\.com\/([^/]+)\/([^.]+?)(?:\.git)?$/, 55 + ); 56 + if (httpsMatch) { 57 + return { owner: httpsMatch[1]!, repo: httpsMatch[2]! }; 58 + } 59 + 60 + return null; 61 + } catch { 62 + return null; 63 + } 64 + } 65 + 66 + async function listPublications( 67 + agent: InstanceType<typeof AtpAgent>, 68 + ): Promise<Array<{ uri: string; name: string; url: string }>> { 69 + const results: Array<{ uri: string; name: string; url: string }> = []; 70 + let cursor: string | undefined; 71 + 72 + do { 73 + const response = await agent.com.atproto.repo.listRecords({ 74 + repo: agent.did!, 75 + collection: "site.standard.publication", 76 + limit: 100, 77 + cursor, 78 + }); 79 + 80 + for (const record of response.data.records) { 81 + const value = record.value as Record<string, unknown>; 82 + if (value.$type === "site.standard.publication") { 83 + results.push({ 84 + uri: record.uri, 85 + name: (value.name as string) || record.uri, 86 + url: (value.url as string) || "", 87 + }); 88 + } 89 + } 90 + 91 + cursor = response.data.cursor; 92 + } while (cursor); 93 + 94 + return results; 95 + } 96 + 97 + const onCancel = () => { 98 + outro("Setup cancelled"); 99 + process.exit(0); 100 + }; 101 + 102 + export const initCommand = command({ 103 + name: "init", 104 + description: "Initialize a new Remanso notes configuration", 105 + args: {}, 106 + handler: async () => { 107 + intro("Remanso Configuration Setup"); 108 + 109 + // Step 1: Detect GitHub remote 110 + const remote = detectGitHubRemote(); 111 + if (remote) { 112 + log.info(`Detected GitHub repo: ${remote.owner}/${remote.repo}`); 113 + } else { 114 + log.info( 115 + "No GitHub remote detected. You can add GitHub integration later with 'remanso github'.", 116 + ); 117 + } 118 + 119 + // Check if config already exists 120 + if (await fileExists(CONFIG_FILENAME)) { 121 + const overwrite = exitOnCancel( 122 + await confirm({ 123 + message: `${CONFIG_FILENAME} already exists. Overwrite?`, 124 + initialValue: false, 125 + }), 126 + ); 127 + if (!overwrite) { 128 + log.info("Keeping existing configuration"); 129 + outro("No changes made."); 130 + return; 131 + } 132 + } 133 + 134 + // Step 2: Load credentials or prompt for them 135 + let credentials = await loadCredentials(); 136 + let pdsUrl: string | undefined; 137 + 138 + if (credentials?.type === "oauth") { 139 + log.warn("OAuth credentials detected but remanso requires App Passwords."); 140 + credentials = null; 141 + } 142 + 143 + if (!credentials) { 144 + const storedIds = await listCredentials(); 145 + if (storedIds.length > 0) { 146 + // Offer to use stored credentials or add new ones 147 + const choice = exitOnCancel( 148 + await select({ 149 + message: "Authentication:", 150 + options: [ 151 + ...storedIds.map((id) => ({ 152 + value: id, 153 + label: `Use existing: ${id}`, 154 + })), 155 + { value: "__new__", label: "Add new App Password" }, 156 + ], 157 + }), 158 + ); 159 + 160 + if (choice !== "__new__") { 161 + credentials = await getCredentials(choice); 162 + } 163 + } 164 + 165 + if (!credentials) { 166 + // Prompt for new credentials 167 + note( 168 + "Create an App Password at: https://bsky.app/settings/app-passwords", 169 + "Authentication", 170 + ); 171 + 172 + const identifier = exitOnCancel( 173 + await text({ 174 + message: "Handle or DID:", 175 + placeholder: "yourhandle.bsky.social", 176 + }), 177 + ); 178 + 179 + const appPassword = exitOnCancel( 180 + await password({ 181 + message: "App Password:", 182 + }), 183 + ); 184 + 185 + if (!identifier || !appPassword) { 186 + log.error("Handle and password are required"); 187 + process.exit(1); 188 + } 189 + 190 + const s = spinner(); 191 + s.start("Resolving PDS..."); 192 + try { 193 + pdsUrl = await resolveHandleToPDS(identifier); 194 + s.stop(`Found PDS: ${pdsUrl}`); 195 + } catch (error) { 196 + s.stop("Failed to resolve PDS"); 197 + log.error(`Failed to resolve PDS from handle: ${error}`); 198 + process.exit(1); 199 + } 200 + 201 + s.start("Verifying credentials..."); 202 + try { 203 + const agent = new AtpAgent({ service: pdsUrl }); 204 + await agent.login({ identifier, password: appPassword }); 205 + s.stop(`Logged in as ${agent.session?.handle}`); 206 + 207 + credentials = { 208 + type: "app-password", 209 + pdsUrl, 210 + identifier, 211 + password: appPassword, 212 + }; 213 + 214 + await saveCredentials(credentials); 215 + log.success("Credentials saved"); 216 + } catch (error) { 217 + s.stop("Failed to login"); 218 + log.error(`Failed to login: ${error}`); 219 + process.exit(1); 220 + } 221 + } 222 + } 223 + 224 + // Step 3: Connect and get DID 225 + const s = spinner(); 226 + s.start("Connecting to ATProto..."); 227 + let agent: Awaited<ReturnType<typeof createAgent>>; 228 + try { 229 + agent = await createAgent(credentials!); 230 + s.stop(`Connected as ${agent.did}`); 231 + } catch (error) { 232 + s.stop("Failed to connect"); 233 + log.error(`Failed to connect: ${error}`); 234 + process.exit(1); 235 + } 236 + 237 + if (credentials?.type === "app-password") { 238 + pdsUrl = credentials.pdsUrl; 239 + } 240 + 241 + // Step 4: Publication — list existing or create new 242 + let publicationUri: string; 243 + 244 + s.start("Fetching existing publications..."); 245 + let publications: Array<{ uri: string; name: string; url: string }> = []; 246 + try { 247 + publications = await listPublications(agent as unknown as InstanceType<typeof AtpAgent>); 248 + s.stop(`Found ${publications.length} existing publication(s)`); 249 + } catch { 250 + s.stop("Could not fetch publications"); 251 + } 252 + 253 + const siteUrl = `https://remanso.space/pub/@${agent.did}`; 254 + 255 + if (publications.length > 0) { 256 + const pubChoice = exitOnCancel( 257 + await select({ 258 + message: "Publication:", 259 + options: [ 260 + ...publications.map((p) => ({ 261 + value: p.uri, 262 + label: `${p.name} (${p.uri})`, 263 + })), 264 + { value: "__create__", label: "Create a new publication" }, 265 + ], 266 + }), 267 + ); 268 + 269 + if (pubChoice === "__create__") { 270 + const pubName = exitOnCancel( 271 + await text({ 272 + message: "Publication name:", 273 + placeholder: "My Notes", 274 + validate: (v) => (!v ? "Name is required" : undefined), 275 + }), 276 + ); 277 + 278 + s.start("Creating publication..."); 279 + try { 280 + publicationUri = await createPublication(agent, { 281 + url: siteUrl, 282 + name: pubName, 283 + }); 284 + s.stop(`Publication created: ${publicationUri}`); 285 + } catch (error) { 286 + s.stop("Failed to create publication"); 287 + log.error(`Failed to create publication: ${error}`); 288 + process.exit(1); 289 + } 290 + } else { 291 + publicationUri = pubChoice; 292 + } 293 + } else { 294 + // No publications — create one 295 + log.info("No existing publications found. Creating a new one."); 296 + const pubName = exitOnCancel( 297 + await text({ 298 + message: "Publication name:", 299 + placeholder: "My Notes", 300 + validate: (v) => (!v ? "Name is required" : undefined), 301 + }), 302 + ); 303 + 304 + s.start("Creating publication..."); 305 + try { 306 + publicationUri = await createPublication(agent, { 307 + url: siteUrl, 308 + name: pubName, 309 + }); 310 + s.stop(`Publication created: ${publicationUri}`); 311 + } catch (error) { 312 + s.stop("Failed to create publication"); 313 + log.error(`Failed to create publication: ${error}`); 314 + process.exit(1); 315 + } 316 + } 317 + 318 + // Step 5: Content directory 319 + const contentDir = exitOnCancel( 320 + await text({ 321 + message: "Content directory (where your .pub.md files live):", 322 + placeholder: ".", 323 + defaultValue: ".", 324 + }), 325 + ); 326 + 327 + // Step 6: Write remanso.json 328 + const config: RemansoConfig = { 329 + contentDir: contentDir || ".", 330 + publicationUri, 331 + }; 332 + 333 + if (pdsUrl && pdsUrl !== "https://bsky.social") { 334 + config.pdsUrl = pdsUrl; 335 + } 336 + 337 + const configContent = JSON.stringify(config, null, 2); 338 + await fs.writeFile(CONFIG_FILENAME, configContent); 339 + log.success(`Created ${CONFIG_FILENAME}`); 340 + 341 + // Step 7: Update .gitignore 342 + const gitignorePath = ".gitignore"; 343 + if (await fileExists(gitignorePath)) { 344 + const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); 345 + if (!gitignoreContent.includes(STATE_FILENAME)) { 346 + await fs.writeFile( 347 + gitignorePath, 348 + `${gitignoreContent}\n${STATE_FILENAME}\n`, 349 + ); 350 + log.info(`Added ${STATE_FILENAME} to .gitignore`); 351 + } 352 + } else { 353 + await fs.writeFile(gitignorePath, `${STATE_FILENAME}\n`); 354 + log.info(`Created .gitignore with ${STATE_FILENAME}`); 355 + } 356 + 357 + // Step 8: GitHub Action workflow 358 + const addWorkflow = exitOnCancel( 359 + await confirm({ 360 + message: "Generate GitHub Actions workflow for automated publishing?", 361 + initialValue: true, 362 + }), 363 + ); 364 + 365 + if (addWorkflow) { 366 + const workflowDir = path.dirname(WORKFLOW_PATH); 367 + 368 + if (await fileExists(WORKFLOW_PATH)) { 369 + const overwrite = exitOnCancel( 370 + await confirm({ 371 + message: `${WORKFLOW_PATH} already exists. Overwrite?`, 372 + initialValue: false, 373 + }), 374 + ); 375 + if (overwrite) { 376 + await fs.mkdir(workflowDir, { recursive: true }); 377 + await fs.writeFile(WORKFLOW_PATH, WORKFLOW_YAML); 378 + log.success(`Updated ${WORKFLOW_PATH}`); 379 + } 380 + } else { 381 + await fs.mkdir(workflowDir, { recursive: true }); 382 + await fs.writeFile(WORKFLOW_PATH, WORKFLOW_YAML); 383 + log.success(`Created ${WORKFLOW_PATH}`); 384 + } 385 + } 386 + 387 + // Step 9: GitHub secrets 388 + if (remote && credentials?.type === "app-password") { 389 + const ghCheck = spawnSync("gh", ["--version"], { stdio: "pipe" }); 390 + const ghAvailable = ghCheck.status === 0; 391 + 392 + if (ghAvailable) { 393 + const setGhSecrets = exitOnCancel( 394 + await confirm({ 395 + message: `Set GitHub secrets for ${remote.owner}/${remote.repo}?`, 396 + initialValue: true, 397 + }), 398 + ); 399 + 400 + if (setGhSecrets) { 401 + const repoFlag = `${remote.owner}/${remote.repo}`; 402 + 403 + s.start("Setting ATP_IDENTIFIER secret..."); 404 + const r1 = spawnSync( 405 + "gh", 406 + [ 407 + "secret", 408 + "set", 409 + "ATP_IDENTIFIER", 410 + "--body", 411 + credentials.identifier, 412 + "--repo", 413 + repoFlag, 414 + ], 415 + { stdio: "pipe" }, 416 + ); 417 + if (r1.status === 0) { 418 + s.stop("ATP_IDENTIFIER set"); 419 + } else { 420 + s.stop("Failed to set ATP_IDENTIFIER"); 421 + log.warn(r1.stderr?.toString() || "Unknown error"); 422 + } 423 + 424 + s.start("Setting ATP_APP_PASSWORD secret..."); 425 + const r2 = spawnSync( 426 + "gh", 427 + [ 428 + "secret", 429 + "set", 430 + "ATP_APP_PASSWORD", 431 + "--body", 432 + credentials.password, 433 + "--repo", 434 + repoFlag, 435 + ], 436 + { stdio: "pipe" }, 437 + ); 438 + if (r2.status === 0) { 439 + s.stop("ATP_APP_PASSWORD set"); 440 + } else { 441 + s.stop("Failed to set ATP_APP_PASSWORD"); 442 + log.warn(r2.stderr?.toString() || "Unknown error"); 443 + } 444 + 445 + log.success(`Secrets configured for ${repoFlag}`); 446 + } 447 + } else { 448 + log.info( 449 + "Install the GitHub CLI (https://cli.github.com/) to set secrets automatically.", 450 + ); 451 + log.info( 452 + `Or add ATP_IDENTIFIER and ATP_APP_PASSWORD manually in ${remote.owner}/${remote.repo} settings.`, 453 + ); 454 + } 455 + } 456 + 457 + note( 458 + "Next steps:\n" + 459 + "1. Create notes with a .pub.md extension to publish them\n" + 460 + "2. Run 'remanso publish --dry-run' to preview\n" + 461 + "3. Run 'remanso publish' to publish\n" + 462 + (remote ? "4. Push to GitHub to trigger automated publishing" : ""), 463 + "Setup complete!", 464 + ); 465 + 466 + outro("Happy publishing!"); 467 + }, 468 + });
+556
packages/remanso/src/commands/publish.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import { command, flag } from "cmd-ts"; 3 + import { log, spinner } from "@clack/prompts"; 4 + import * as path from "node:path"; 5 + import { findConfig, loadConfig, loadState, saveState } from "../lib/config"; 6 + import { 7 + loadCredentials, 8 + listAllCredentials, 9 + getCredentials, 10 + } from "../../../cli/src/lib/credentials"; 11 + import { 12 + createAgent, 13 + createDocument, 14 + updateDocument, 15 + uploadImage, 16 + resolveImagePath, 17 + deleteRecord, 18 + listDocuments, 19 + } from "../../../cli/src/lib/atproto"; 20 + import { 21 + scanContentDirectory, 22 + getContentHash, 23 + updateFrontmatterWithAtUri, 24 + resolvePostPath, 25 + } from "../../../cli/src/lib/markdown"; 26 + import type { 27 + BlogPost, 28 + BlobObject, 29 + AppPasswordCredentials, 30 + } from "../../../cli/src/lib/types"; 31 + import { exitOnCancel } from "../../../cli/src/lib/prompts"; 32 + import { 33 + createNote, 34 + updateNote, 35 + deleteNote, 36 + findPostsWithStaleLinks, 37 + type NoteOptions, 38 + } from "../../../cli/src/extensions/remanso"; 39 + 40 + async function fileExists(filePath: string): Promise<boolean> { 41 + try { 42 + await fs.access(filePath); 43 + return true; 44 + } catch { 45 + return false; 46 + } 47 + } 48 + 49 + async function selectIdentity(): Promise<AppPasswordCredentials | null> { 50 + const { select } = await import("@clack/prompts"); 51 + const identities = await listAllCredentials(); 52 + 53 + if (identities.length === 0) { 54 + log.error( 55 + "No credentials found. Run 'remanso auth' to set up an App Password.", 56 + ); 57 + process.exit(1); 58 + } 59 + 60 + // Filter to app-password only (remanso doesn't support OAuth) 61 + const appPasswordIds = identities.filter((c) => c.type === "app-password"); 62 + if (appPasswordIds.length === 0) { 63 + log.error( 64 + "No App Password credentials found. Run 'remanso auth' to set up one.", 65 + ); 66 + log.info("Note: OAuth credentials are not supported by remanso."); 67 + process.exit(1); 68 + } 69 + 70 + if (appPasswordIds.length === 1 && appPasswordIds[0]) { 71 + return await getCredentials(appPasswordIds[0].id); 72 + } 73 + 74 + log.info("Multiple identities found. Select one to use:"); 75 + const selected = exitOnCancel( 76 + await select({ 77 + message: "Identity:", 78 + options: appPasswordIds.map((c) => ({ 79 + value: c.id, 80 + label: `${c.id} (App Password)`, 81 + })), 82 + }), 83 + ); 84 + 85 + return await getCredentials(selected); 86 + } 87 + 88 + export const publishCommand = command({ 89 + name: "publish", 90 + description: "Publish .pub.md notes to ATProto / Remanso", 91 + args: { 92 + force: flag({ 93 + long: "force", 94 + short: "f", 95 + description: "Force publish all notes, ignoring change detection", 96 + }), 97 + dryRun: flag({ 98 + long: "dry-run", 99 + short: "n", 100 + description: "Preview what would be published without making changes", 101 + }), 102 + verbose: flag({ 103 + long: "verbose", 104 + short: "v", 105 + description: "Show more information", 106 + }), 107 + }, 108 + handler: async ({ force, dryRun, verbose }) => { 109 + // Load config 110 + const configPath = await findConfig(); 111 + if (!configPath) { 112 + log.error("No remanso.json found. Run 'remanso init' first."); 113 + process.exit(1); 114 + } 115 + 116 + const { config, configPath: resolvedConfigPath } = 117 + await loadConfig(configPath); 118 + const configDir = path.dirname(resolvedConfigPath); 119 + 120 + log.info(`Content directory: ${config.contentDir}`); 121 + log.info(`Publication: ${config.publicationUri}`); 122 + 123 + // Load credentials (app-password only) 124 + let credentials = await loadCredentials(config.identity); 125 + 126 + if (credentials?.type === "oauth") { 127 + log.error( 128 + "OAuth credentials are not supported by remanso. Run 'remanso auth' to set up an App Password.", 129 + ); 130 + process.exit(1); 131 + } 132 + 133 + if (!credentials) { 134 + credentials = await selectIdentity(); 135 + } 136 + 137 + if (!credentials) { 138 + log.error("Failed to load credentials."); 139 + process.exit(1); 140 + } 141 + 142 + const appCreds = credentials as AppPasswordCredentials; 143 + 144 + // Resolve content directory 145 + const contentDir = path.isAbsolute(config.contentDir) 146 + ? config.contentDir 147 + : path.join(configDir, config.contentDir); 148 + 149 + const imagesDir = config.imagesDir 150 + ? path.isAbsolute(config.imagesDir) 151 + ? config.imagesDir 152 + : path.join(configDir, config.imagesDir) 153 + : undefined; 154 + 155 + // Load state 156 + const state = await loadState(configDir); 157 + 158 + // Scan for posts (all .md files), then filter to .pub.md 159 + const s = spinner(); 160 + s.start("Scanning for notes..."); 161 + const allScanned = await scanContentDirectory(contentDir, { 162 + ignorePatterns: config.ignore, 163 + }); 164 + const posts = allScanned.filter((p) => p.filePath.endsWith(".pub.md")); 165 + s.stop(`Found ${posts.length} publishable notes (.pub.md)`); 166 + 167 + // Detect deleted files: state entries whose local files no longer exist 168 + const scannedPaths = new Set( 169 + posts.map((p) => path.relative(configDir, p.filePath)), 170 + ); 171 + const deletedEntries: Array<{ filePath: string; atUri: string }> = []; 172 + 173 + for (const [filePath, postState] of Object.entries(state.posts)) { 174 + if (!scannedPaths.has(filePath) && postState.atUri) { 175 + const absolutePath = path.resolve(configDir, filePath); 176 + if (!(await fileExists(absolutePath))) { 177 + deletedEntries.push({ filePath, atUri: postState.atUri }); 178 + } 179 + } 180 + } 181 + 182 + // Detect unmatched PDS records 183 + const unmatchedEntries: Array<{ atUri: string; title: string }> = []; 184 + 185 + // Shared agent — created lazily 186 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 187 + async function getAgent(): Promise< 188 + Awaited<ReturnType<typeof createAgent>> 189 + > { 190 + if (agent) return agent; 191 + 192 + s.start(`Connecting as ${appCreds.pdsUrl}...`); 193 + try { 194 + agent = await createAgent(appCreds); 195 + s.stop(`Logged in as ${agent.did}`); 196 + return agent; 197 + } catch (error) { 198 + s.stop("Failed to login"); 199 + log.error(`Failed to login: ${error}`); 200 + process.exit(1); 201 + } 202 + } 203 + 204 + // Determine which posts need publishing 205 + const postsToPublish: Array<{ 206 + post: BlogPost; 207 + action: "create" | "update"; 208 + reason: "content changed" | "forced" | "new post" | "missing state"; 209 + }> = []; 210 + const draftPosts: BlogPost[] = []; 211 + 212 + for (const post of posts) { 213 + if (post.frontmatter.draft) { 214 + draftPosts.push(post); 215 + continue; 216 + } 217 + 218 + const contentHash = await getContentHash(post.rawContent); 219 + const relativeFilePath = path.relative(configDir, post.filePath); 220 + const postState = state.posts[relativeFilePath]; 221 + 222 + if (force) { 223 + postsToPublish.push({ 224 + post, 225 + action: post.frontmatter.atUri ? "update" : "create", 226 + reason: "forced", 227 + }); 228 + } else if (!postState) { 229 + postsToPublish.push({ 230 + post, 231 + action: post.frontmatter.atUri ? "update" : "create", 232 + reason: post.frontmatter.atUri ? "missing state" : "new post", 233 + }); 234 + } else if (postState.contentHash !== contentHash) { 235 + postsToPublish.push({ 236 + post, 237 + action: post.frontmatter.atUri ? "update" : "create", 238 + reason: "content changed", 239 + }); 240 + } 241 + } 242 + 243 + if (draftPosts.length > 0) { 244 + log.info( 245 + `Skipping ${draftPosts.length} draft note${draftPosts.length === 1 ? "" : "s"}`, 246 + ); 247 + } 248 + 249 + // Fetch PDS records to detect unmatched documents 250 + async function fetchUnmatchedRecords() { 251 + const ag = await getAgent(); 252 + s.start("Fetching documents from PDS..."); 253 + const pdsDocuments = await listDocuments(ag, config.publicationUri); 254 + s.stop(`Found ${pdsDocuments.length} documents on PDS`); 255 + 256 + const pathPrefix = "/posts"; 257 + const postsByPath = new Map<string, BlogPost>(); 258 + for (const post of posts) { 259 + postsByPath.set(`${pathPrefix}/${post.slug}`, post); 260 + } 261 + const deletedAtUris = new Set(deletedEntries.map((e) => e.atUri)); 262 + for (const doc of pdsDocuments) { 263 + if (!postsByPath.has(doc.value.path) && !deletedAtUris.has(doc.uri)) { 264 + unmatchedEntries.push({ 265 + atUri: doc.uri, 266 + title: doc.value.title || doc.value.path, 267 + }); 268 + } 269 + } 270 + } 271 + 272 + if (postsToPublish.length === 0 && deletedEntries.length === 0) { 273 + await fetchUnmatchedRecords(); 274 + 275 + if (unmatchedEntries.length === 0) { 276 + log.success("All notes are up to date. Nothing to publish."); 277 + return; 278 + } 279 + } 280 + 281 + if (postsToPublish.length > 0) { 282 + log.info(`\n${postsToPublish.length} notes to publish:\n`); 283 + 284 + for (const { post, action, reason } of postsToPublish) { 285 + const icon = action === "create" ? "+" : "~"; 286 + let postUrl = ""; 287 + if (verbose) { 288 + postUrl = `\n ${post.filePath}`; 289 + } 290 + log.message(` ${icon} ${post.filePath} (${reason})${postUrl}`); 291 + } 292 + } 293 + 294 + if (deletedEntries.length > 0) { 295 + log.info( 296 + `\n${deletedEntries.length} deleted local files to remove from PDS:\n`, 297 + ); 298 + for (const { filePath } of deletedEntries) { 299 + log.message(` - ${filePath}`); 300 + } 301 + } 302 + 303 + if (unmatchedEntries.length > 0) { 304 + log.info( 305 + `\n${unmatchedEntries.length} unmatched PDS records to delete:\n`, 306 + ); 307 + for (const { title } of unmatchedEntries) { 308 + log.message(` - ${title}`); 309 + } 310 + } 311 + 312 + if (dryRun) { 313 + log.info("\nDry run complete. No changes made."); 314 + return; 315 + } 316 + 317 + // Ensure agent is connected 318 + await getAgent(); 319 + 320 + if (!agent) { 321 + throw new Error("agent is not connected"); 322 + } 323 + 324 + // Derive siteUrl from DID 325 + const siteUrl = `https://remanso.space/pub/@${agent.did}`; 326 + log.info(`Site URL: ${siteUrl}`); 327 + 328 + // Fetch PDS records to detect unmatched documents (if not already done) 329 + if (unmatchedEntries.length === 0) { 330 + await fetchUnmatchedRecords(); 331 + } 332 + 333 + // Build the publisher config object for createDocument/updateDocument 334 + const publisherConfig = { 335 + siteUrl, 336 + contentDir, 337 + publicationUri: config.publicationUri, 338 + imagesDir, 339 + pdsUrl: config.pdsUrl, 340 + pathPrefix: "", 341 + }; 342 + 343 + // Publish posts 344 + let publishedCount = 0; 345 + let updatedCount = 0; 346 + let errorCount = 0; 347 + 348 + const context: NoteOptions = { 349 + contentDir, 350 + imagesDir, 351 + allPosts: posts, 352 + }; 353 + 354 + // Pass 1: Create/update document records and collect note queue 355 + const noteQueue: Array<{ 356 + post: BlogPost; 357 + action: "create" | "update"; 358 + atUri: string; 359 + }> = []; 360 + 361 + for (const { post, action } of postsToPublish) { 362 + const trimmedContent = post.content.trim(); 363 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 364 + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; 365 + s.start(`Publishing: ${title}`); 366 + 367 + // Init publish date 368 + if (!post.frontmatter.publishDate) { 369 + const [publishDate] = new Date().toISOString().split("T"); 370 + post.frontmatter.publishDate = publishDate!; 371 + } 372 + 373 + try { 374 + // Handle cover image upload 375 + let coverImage: BlobObject | undefined; 376 + if (post.frontmatter.ogImage) { 377 + const imagePath = await resolveImagePath( 378 + post.frontmatter.ogImage, 379 + imagesDir, 380 + contentDir, 381 + ); 382 + 383 + if (imagePath) { 384 + log.info(` Uploading cover image: ${path.basename(imagePath)}`); 385 + coverImage = await uploadImage(agent, imagePath); 386 + if (coverImage) { 387 + log.info(` Uploaded image blob: ${coverImage.ref.$link}`); 388 + } 389 + } else { 390 + log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); 391 + } 392 + } 393 + 394 + let atUri: string; 395 + let contentForHash: string; 396 + const relativeFilePath = path.relative(configDir, post.filePath); 397 + 398 + if (action === "create") { 399 + atUri = await createDocument(agent, post, publisherConfig as Parameters<typeof createDocument>[2], coverImage); 400 + post.frontmatter.atUri = atUri; 401 + s.stop(`Created: ${atUri}`); 402 + 403 + // Update frontmatter with atUri 404 + const updatedContent = updateFrontmatterWithAtUri( 405 + post.rawContent, 406 + atUri, 407 + ); 408 + await fs.writeFile(post.filePath, updatedContent); 409 + log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); 410 + 411 + contentForHash = updatedContent; 412 + publishedCount++; 413 + } else { 414 + atUri = post.frontmatter.atUri!; 415 + await updateDocument(agent, post, atUri, publisherConfig as Parameters<typeof updateDocument>[3], coverImage); 416 + s.stop(`Updated: ${atUri}`); 417 + 418 + contentForHash = post.rawContent; 419 + updatedCount++; 420 + } 421 + 422 + // Update state 423 + const contentHash = await getContentHash(contentForHash); 424 + state.posts[relativeFilePath] = { 425 + contentHash, 426 + atUri, 427 + lastPublished: new Date().toISOString(), 428 + slug: post.slug, 429 + }; 430 + 431 + noteQueue.push({ post, action, atUri }); 432 + } catch (error) { 433 + const errorMessage = 434 + error instanceof Error ? error.message : String(error); 435 + s.stop(`Error publishing "${path.basename(post.filePath)}"`); 436 + log.error(` ${errorMessage}`); 437 + errorCount++; 438 + } 439 + } 440 + 441 + // Pass 2: Create/update Remanso notes 442 + for (const { post, action, atUri } of noteQueue) { 443 + try { 444 + if (action === "create") { 445 + await createNote(agent, post, atUri, context); 446 + } else { 447 + await updateNote(agent, post, atUri, context); 448 + } 449 + } catch (error) { 450 + log.warn( 451 + `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`, 452 + ); 453 + } 454 + } 455 + 456 + // Re-process already-published posts with stale links 457 + const newlyCreatedSlugs = noteQueue 458 + .filter((r) => r.action === "create") 459 + .map((r) => r.post.slug); 460 + 461 + if (newlyCreatedSlugs.length > 0) { 462 + const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath)); 463 + const stalePosts = findPostsWithStaleLinks( 464 + posts, 465 + newlyCreatedSlugs, 466 + batchFilePaths, 467 + ); 468 + 469 + for (const stalePost of stalePosts) { 470 + try { 471 + s.start(`Updating links in: ${stalePost.frontmatter.title}`); 472 + await updateNote( 473 + agent, 474 + stalePost, 475 + stalePost.frontmatter.atUri!, 476 + context, 477 + ); 478 + s.stop(`Updated links: ${stalePost.frontmatter.title}`); 479 + } catch (error) { 480 + s.stop(`Failed to update links: ${stalePost.frontmatter.title}`); 481 + log.warn( 482 + ` ${error instanceof Error ? error.message : String(error)}`, 483 + ); 484 + } 485 + } 486 + } 487 + 488 + // Delete records for removed files 489 + let deletedCount = 0; 490 + for (const { filePath, atUri } of deletedEntries) { 491 + try { 492 + const ag = await getAgent(); 493 + s.start(`Deleting: ${filePath}`); 494 + await deleteRecord(ag, atUri); 495 + 496 + try { 497 + const noteAtUri = atUri.replace( 498 + "site.standard.document", 499 + "space.remanso.note", 500 + ); 501 + await deleteNote(ag, noteAtUri); 502 + } catch { 503 + // Note may not exist, ignore 504 + } 505 + 506 + delete state.posts[filePath]; 507 + s.stop(`Deleted: ${filePath}`); 508 + deletedCount++; 509 + } catch (error) { 510 + s.stop(`Failed to delete: ${filePath}`); 511 + log.warn(` ${error instanceof Error ? error.message : String(error)}`); 512 + } 513 + } 514 + 515 + // Delete unmatched PDS records 516 + let unmatchedDeletedCount = 0; 517 + for (const { atUri, title } of unmatchedEntries) { 518 + try { 519 + const ag = await getAgent(); 520 + s.start(`Deleting unmatched: ${title}`); 521 + await deleteRecord(ag, atUri); 522 + 523 + try { 524 + const noteAtUri = atUri.replace( 525 + "site.standard.document", 526 + "space.remanso.note", 527 + ); 528 + await deleteNote(ag, noteAtUri); 529 + } catch { 530 + // Note may not exist, ignore 531 + } 532 + 533 + s.stop(`Deleted unmatched: ${title}`); 534 + unmatchedDeletedCount++; 535 + } catch (error) { 536 + s.stop(`Failed to delete: ${title}`); 537 + log.warn(` ${error instanceof Error ? error.message : String(error)}`); 538 + } 539 + } 540 + 541 + // Save state 542 + await saveState(configDir, state); 543 + 544 + // Summary 545 + log.message("\n---"); 546 + const totalDeleted = deletedCount + unmatchedDeletedCount; 547 + if (totalDeleted > 0) { 548 + log.info(`Deleted: ${totalDeleted}`); 549 + } 550 + log.info(`Published: ${publishedCount}`); 551 + log.info(`Updated: ${updatedCount}`); 552 + if (errorCount > 0) { 553 + log.warn(`Errors: ${errorCount}`); 554 + } 555 + }, 556 + });
+300
packages/remanso/src/commands/sync.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import { command, flag } from "cmd-ts"; 3 + import { log, spinner } from "@clack/prompts"; 4 + import * as path from "node:path"; 5 + import { findConfig, loadConfig, loadState, saveState } from "../lib/config"; 6 + import { 7 + loadCredentials, 8 + listAllCredentials, 9 + getCredentials, 10 + } from "../../../cli/src/lib/credentials"; 11 + import type { Agent } from "@atproto/api"; 12 + import { createAgent, listDocuments } from "../../../cli/src/lib/atproto"; 13 + import type { ListDocumentsResult } from "../../../cli/src/lib/atproto"; 14 + import type { BlogPost, AppPasswordCredentials } from "../../../cli/src/lib/types"; 15 + import { 16 + scanContentDirectory, 17 + getContentHash, 18 + getTextContent, 19 + updateFrontmatterWithAtUri, 20 + } from "../../../cli/src/lib/markdown"; 21 + import { exitOnCancel } from "../../../cli/src/lib/prompts"; 22 + 23 + async function matchesPDS( 24 + localPost: BlogPost, 25 + doc: ListDocumentsResult, 26 + agent: Agent, 27 + ): Promise<boolean> { 28 + // Compare body text content 29 + const localTextContent = getTextContent(localPost, undefined); 30 + if (localTextContent.slice(0, 10000) !== doc.value.textContent) { 31 + return false; 32 + } 33 + 34 + // Compare document fields: title, description, tags 35 + const trimmedContent = localPost.content.trim(); 36 + const titleMatch = trimmedContent.match(/^# (.+)$/m); 37 + const localTitle = titleMatch ? titleMatch[1] : localPost.frontmatter.title; 38 + if (localTitle !== doc.value.title) return false; 39 + 40 + const localDescription = localPost.frontmatter.description || undefined; 41 + if (localDescription !== doc.value.description) return false; 42 + 43 + const localTags = 44 + localPost.frontmatter.tags && localPost.frontmatter.tags.length > 0 45 + ? localPost.frontmatter.tags 46 + : undefined; 47 + if (JSON.stringify(localTags) !== JSON.stringify(doc.value.tags)) { 48 + return false; 49 + } 50 + 51 + // Compare note-specific fields: theme, fontSize, fontFamily 52 + const noteUriMatch = doc.uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/); 53 + if (noteUriMatch) { 54 + const repo = noteUriMatch[1]!; 55 + const rkey = noteUriMatch[2]!; 56 + try { 57 + const noteResponse = await agent.com.atproto.repo.getRecord({ 58 + repo, 59 + collection: "space.remanso.note", 60 + rkey, 61 + }); 62 + const noteValue = noteResponse.data.value as Record<string, unknown>; 63 + if ( 64 + (localPost.frontmatter.theme || undefined) !== 65 + (noteValue.theme as string | undefined) || 66 + (localPost.frontmatter.fontSize || undefined) !== 67 + (noteValue.fontSize as number | undefined) || 68 + (localPost.frontmatter.fontFamily || undefined) !== 69 + (noteValue.fontFamily as string | undefined) 70 + ) { 71 + return false; 72 + } 73 + } catch { 74 + // Note record doesn't exist — treat as matching 75 + } 76 + } 77 + 78 + return true; 79 + } 80 + 81 + async function selectIdentity(): Promise<AppPasswordCredentials | null> { 82 + const { select } = await import("@clack/prompts"); 83 + const identities = await listAllCredentials(); 84 + 85 + if (identities.length === 0) { 86 + log.error( 87 + "No credentials found. Run 'remanso auth' to set up an App Password.", 88 + ); 89 + process.exit(1); 90 + } 91 + 92 + const appPasswordIds = identities.filter((c) => c.type === "app-password"); 93 + if (appPasswordIds.length === 0) { 94 + log.error( 95 + "No App Password credentials found. Run 'remanso auth' to set up one.", 96 + ); 97 + process.exit(1); 98 + } 99 + 100 + if (appPasswordIds.length === 1 && appPasswordIds[0]) { 101 + return await getCredentials(appPasswordIds[0].id); 102 + } 103 + 104 + log.info("Multiple identities found. Select one to use:"); 105 + const selected = exitOnCancel( 106 + await select({ 107 + message: "Identity:", 108 + options: appPasswordIds.map((c) => ({ 109 + value: c.id, 110 + label: `${c.id} (App Password)`, 111 + })), 112 + }), 113 + ); 114 + 115 + return await getCredentials(selected); 116 + } 117 + 118 + export const syncCommand = command({ 119 + name: "sync", 120 + description: "Sync state from ATProto to restore .remanso-state.json", 121 + args: { 122 + updateFrontmatter: flag({ 123 + long: "update-frontmatter", 124 + short: "u", 125 + description: "Update frontmatter atUri fields in local markdown files", 126 + }), 127 + dryRun: flag({ 128 + long: "dry-run", 129 + short: "n", 130 + description: "Preview what would be synced without making changes", 131 + }), 132 + }, 133 + handler: async ({ updateFrontmatter, dryRun }) => { 134 + // Load config 135 + const configPath = await findConfig(); 136 + if (!configPath) { 137 + log.error("No remanso.json found. Run 'remanso init' first."); 138 + process.exit(1); 139 + } 140 + 141 + const { config, configPath: resolvedConfigPath } = 142 + await loadConfig(configPath); 143 + const configDir = path.dirname(resolvedConfigPath); 144 + 145 + log.info(`Publication: ${config.publicationUri}`); 146 + 147 + // Load credentials (app-password only) 148 + let credentials = await loadCredentials(config.identity); 149 + 150 + if (credentials?.type === "oauth") { 151 + log.error( 152 + "OAuth credentials are not supported by remanso. Run 'remanso auth' to set up an App Password.", 153 + ); 154 + process.exit(1); 155 + } 156 + 157 + if (!credentials) { 158 + credentials = await selectIdentity(); 159 + } 160 + 161 + if (!credentials) { 162 + log.error("Failed to load credentials."); 163 + process.exit(1); 164 + } 165 + 166 + // Create agent 167 + const s = spinner(); 168 + s.start(`Connecting as ${(credentials as AppPasswordCredentials).pdsUrl}...`); 169 + let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 170 + try { 171 + agent = await createAgent(credentials); 172 + s.stop(`Logged in as ${agent.did}`); 173 + } catch (error) { 174 + s.stop("Failed to login"); 175 + log.error(`Failed to login: ${error}`); 176 + process.exit(1); 177 + } 178 + 179 + // Fetch documents from PDS 180 + s.start("Fetching documents from PDS..."); 181 + const documents = await listDocuments(agent, config.publicationUri); 182 + s.stop(`Found ${documents.length} documents on PDS`); 183 + 184 + if (documents.length === 0) { 185 + log.info("No documents found for this publication."); 186 + return; 187 + } 188 + 189 + // Resolve content directory 190 + const contentDir = path.isAbsolute(config.contentDir) 191 + ? config.contentDir 192 + : path.join(configDir, config.contentDir); 193 + 194 + // Scan local posts (all .md), then filter to .pub.md 195 + s.start("Scanning local content..."); 196 + const allScanned = await scanContentDirectory(contentDir, { 197 + ignorePatterns: config.ignore, 198 + }); 199 + const localPosts = allScanned.filter((p) => 200 + p.filePath.endsWith(".pub.md"), 201 + ); 202 + s.stop(`Found ${localPosts.length} publishable notes (.pub.md)`); 203 + 204 + // Build a map of path -> local post for matching 205 + // Use "/posts" prefix (same default as publish command) 206 + const pathPrefix = "/posts"; 207 + const postsByPath = new Map<string, (typeof localPosts)[0]>(); 208 + for (const post of localPosts) { 209 + postsByPath.set(`${pathPrefix}/${post.slug}`, post); 210 + } 211 + 212 + // Load existing state 213 + const state = await loadState(configDir); 214 + const originalPostCount = Object.keys(state.posts).length; 215 + 216 + // Track changes 217 + let matchedCount = 0; 218 + let unmatchedCount = 0; 219 + const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; 220 + 221 + log.message("\nMatching documents to local files:\n"); 222 + 223 + for (const doc of documents) { 224 + const docPath = doc.value.path; 225 + const localPost = postsByPath.get(docPath); 226 + 227 + if (localPost) { 228 + matchedCount++; 229 + log.message(` ✓ ${doc.value.title}`); 230 + log.message(` Path: ${docPath}`); 231 + log.message(` URI: ${doc.uri}`); 232 + log.message(` File: ${path.basename(localPost.filePath)}`); 233 + 234 + const contentMatchesPDS = await matchesPDS(localPost, doc, agent); 235 + const contentHash = contentMatchesPDS 236 + ? await getContentHash(localPost.rawContent) 237 + : ""; 238 + const relativeFilePath = path.relative(configDir, localPost.filePath); 239 + state.posts[relativeFilePath] = { 240 + contentHash, 241 + atUri: doc.uri, 242 + lastPublished: doc.value.publishedAt, 243 + }; 244 + 245 + // Check if frontmatter needs updating 246 + if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { 247 + frontmatterUpdates.push({ 248 + filePath: localPost.filePath, 249 + atUri: doc.uri, 250 + }); 251 + log.message(` → Will update frontmatter`); 252 + } 253 + } else { 254 + unmatchedCount++; 255 + log.message(` ✗ ${doc.value.title} (no matching local file)`); 256 + log.message(` Path: ${docPath}`); 257 + log.message(` URI: ${doc.uri}`); 258 + } 259 + log.message(""); 260 + } 261 + 262 + // Summary 263 + log.message("---"); 264 + log.info(`Matched: ${matchedCount} documents`); 265 + if (unmatchedCount > 0) { 266 + log.warn( 267 + `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, 268 + ); 269 + log.info( 270 + `Run 'remanso publish' to delete unmatched records from your PDS.`, 271 + ); 272 + } 273 + 274 + if (dryRun) { 275 + log.info("\nDry run complete. No changes made."); 276 + return; 277 + } 278 + 279 + // Save updated state 280 + await saveState(configDir, state); 281 + const newPostCount = Object.keys(state.posts).length; 282 + log.success( 283 + `\nSaved .remanso-state.json (${originalPostCount} → ${newPostCount} entries)`, 284 + ); 285 + 286 + // Update frontmatter if requested 287 + if (frontmatterUpdates.length > 0) { 288 + s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); 289 + for (const { filePath, atUri } of frontmatterUpdates) { 290 + const content = await fs.readFile(filePath, "utf-8"); 291 + const updated = updateFrontmatterWithAtUri(content, atUri); 292 + await fs.writeFile(filePath, updated); 293 + log.message(` Updated: ${path.basename(filePath)}`); 294 + } 295 + s.stop("Frontmatter updated"); 296 + } 297 + 298 + log.success("\nSync complete!"); 299 + }, 300 + });
+23
packages/remanso/src/index.ts
··· 1 + #!/usr/bin/env node 2 + 3 + import { run, subcommands } from "cmd-ts"; 4 + import { authCommand } from "./commands/auth"; 5 + import { githubCommand } from "./commands/github"; 6 + import { initCommand } from "./commands/init"; 7 + import { publishCommand } from "./commands/publish"; 8 + import { syncCommand } from "./commands/sync"; 9 + 10 + const app = subcommands({ 11 + name: "remanso", 12 + description: "Publish private notes to Remanso (remanso.space)", 13 + version: "0.1.0", 14 + cmds: { 15 + auth: authCommand, 16 + init: initCommand, 17 + publish: publishCommand, 18 + sync: syncCommand, 19 + github: githubCommand, 20 + }, 21 + }); 22 + 23 + run(app, process.argv.slice(2));
+99
packages/remanso/src/lib/config.ts
··· 1 + import * as fs from "node:fs/promises"; 2 + import * as path from "node:path"; 3 + import type { PublisherState } from "../../../cli/src/lib/types"; 4 + 5 + export interface RemansoConfig { 6 + contentDir: string; 7 + publicationUri: string; 8 + pdsUrl?: string; 9 + identity?: string; 10 + ignore?: string[]; 11 + imagesDir?: string; 12 + } 13 + 14 + const CONFIG_FILENAME = "remanso.json"; 15 + const STATE_FILENAME = ".remanso-state.json"; 16 + 17 + export const DEFAULT_IGNORE = ["**/node_modules/**", ".*/**", "_*/**"]; 18 + 19 + async function fileExists(filePath: string): Promise<boolean> { 20 + try { 21 + await fs.access(filePath); 22 + return true; 23 + } catch { 24 + return false; 25 + } 26 + } 27 + 28 + export async function findConfig( 29 + startDir: string = process.cwd(), 30 + ): Promise<string | null> { 31 + let currentDir = startDir; 32 + 33 + while (true) { 34 + const configPath = path.join(currentDir, CONFIG_FILENAME); 35 + 36 + if (await fileExists(configPath)) { 37 + return configPath; 38 + } 39 + 40 + const parentDir = path.dirname(currentDir); 41 + if (parentDir === currentDir) { 42 + return null; 43 + } 44 + currentDir = parentDir; 45 + } 46 + } 47 + 48 + export async function loadConfig( 49 + configPath?: string, 50 + ): Promise<{ config: RemansoConfig; configPath: string }> { 51 + const resolvedPath = configPath || (await findConfig()); 52 + 53 + if (!resolvedPath) { 54 + throw new Error( 55 + `Could not find ${CONFIG_FILENAME}. Run 'remanso init' to create one.`, 56 + ); 57 + } 58 + 59 + try { 60 + const content = await fs.readFile(resolvedPath, "utf-8"); 61 + const config = JSON.parse(content) as RemansoConfig; 62 + 63 + if (!config.contentDir) throw new Error("contentDir is required in config"); 64 + if (!config.publicationUri) 65 + throw new Error("publicationUri is required in config"); 66 + 67 + return { config, configPath: resolvedPath }; 68 + } catch (error) { 69 + if (error instanceof Error && error.message.includes("required")) { 70 + throw error; 71 + } 72 + throw new Error(`Failed to load config from ${resolvedPath}: ${error}`); 73 + } 74 + } 75 + 76 + export async function loadState(configDir: string): Promise<PublisherState> { 77 + const statePath = path.join(configDir, STATE_FILENAME); 78 + 79 + if (!(await fileExists(statePath))) { 80 + return { posts: {} }; 81 + } 82 + 83 + try { 84 + const content = await fs.readFile(statePath, "utf-8"); 85 + return JSON.parse(content) as PublisherState; 86 + } catch { 87 + return { posts: {} }; 88 + } 89 + } 90 + 91 + export async function saveState( 92 + configDir: string, 93 + state: PublisherState, 94 + ): Promise<void> { 95 + const statePath = path.join(configDir, STATE_FILENAME); 96 + await fs.writeFile(statePath, JSON.stringify(state, null, 2)); 97 + } 98 + 99 + export const STATE_FILENAME_EXPORT = STATE_FILENAME;
+22
packages/remanso/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "lib": ["ES2022"], 4 + "target": "ES2022", 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "outDir": "./dist", 8 + "rootDir": "./src", 9 + "declaration": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "skipLibCheck": true, 13 + "esModuleInterop": true, 14 + "resolveJsonModule": true, 15 + "forceConsistentCasingInFileNames": true, 16 + "noFallthroughCasesInSwitch": true, 17 + "noUncheckedIndexedAccess": true, 18 + "noUnusedLocals": false, 19 + "noUnusedParameters": false 20 + }, 21 + "include": ["src"] 22 + }