diff --git a/action.yml b/action.yml index 601a033..9cb16d7 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ -name: 'Sequoia Publish' -description: 'Publish your markdown content to ATProtocol using Sequoia CLI' +name: 'Remanso Publish' +description: 'Publish your .pub.md notes to ATProtocol using Remanso CLI' branding: icon: 'upload-cloud' color: 'green' @@ -16,15 +16,15 @@ inputs: required: false default: 'https://bsky.social' force: - description: 'Force publish all posts, ignoring change detection' + description: 'Force publish all notes, ignoring change detection' required: false default: 'false' commit-back: - description: 'Commit updated frontmatter and state file back to the repo' + description: 'Commit updated frontmatter back to the repo' required: false default: 'true' working-directory: - description: 'Directory containing sequoia.json (defaults to repo root)' + description: 'Directory containing remanso.json (defaults to repo root)' required: false default: '.' @@ -39,7 +39,7 @@ runs: uses: actions/cache@v4 with: path: ${{ github.action_path }}/node_modules - key: sequoia-deps-${{ runner.os }}-${{ hashFiles(format('{0}/bun.lock', github.action_path)) }} + key: remanso-deps-${{ runner.os }}-${{ hashFiles(format('{0}/bun.lock', github.action_path)) }} - name: Install dependencies if: steps.deps-cache.outputs.cache-hit != 'true' @@ -50,17 +50,17 @@ runs: id: build-cache uses: actions/cache@v4 with: - path: ${{ github.action_path }}/packages/cli/dist - key: sequoia-build-${{ runner.os }}-${{ hashFiles(format('{0}/bun.lock', github.action_path), format('{0}/packages/cli/src/**', github.action_path)) }} + path: ${{ github.action_path }}/packages/remanso/dist + key: remanso-build-${{ runner.os }}-${{ hashFiles(format('{0}/bun.lock', github.action_path), format('{0}/packages/remanso/src/**', github.action_path)) }} - name: Build CLI if: steps.build-cache.outputs.cache-hit != 'true' shell: bash - run: cd ${{ github.action_path }} && bun run build:cli + run: cd ${{ github.action_path }} && bun run build:remanso - name: Link CLI shell: bash - run: cd ${{ github.action_path }} && bun link --cwd packages/cli + run: cd ${{ github.action_path }} && bun link --cwd packages/remanso - name: Sync state from ATProtocol shell: bash @@ -69,7 +69,7 @@ runs: ATP_IDENTIFIER: ${{ inputs.identifier }} ATP_APP_PASSWORD: ${{ inputs.app-password }} PDS_URL: ${{ inputs.pds-url }} - run: sequoia sync + run: remanso sync - name: Publish shell: bash @@ -83,7 +83,7 @@ runs: if [ "${{ inputs.force }}" = "true" ]; then FLAGS="--force" fi - sequoia publish $FLAGS + remanso publish $FLAGS - name: Commit back changes if: inputs.commit-back == 'true' @@ -92,10 +92,10 @@ runs: run: | git config user.name "$(git log -1 --format='%an')" git config user.email "$(git log -1 --format='%ae')" - git add -A -- '**/*.md' || true + git add -A -- '**/*.pub.md' || true if git diff --cached --quiet; then echo "No changes to commit" else - git commit -m "chore: update sequoia state [skip ci]" + git commit -m "chore: update remanso state [skip ci]" git push fi diff --git a/bun.lock b/bun.lock index 4bc51b6..283d4a3 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,26 @@ "typescript": "^5", }, }, + "packages/remanso": { + "name": "remanso-cli", + "version": "0.1.0", + "bin": { + "remanso": "dist/index.js", + }, + "dependencies": { + "@atproto/api": "^0.18.17", + "@clack/prompts": "^1.0.0", + "cmd-ts": "^0.14.3", + "glob": "^13.0.0", + "mime-types": "^2.1.35", + "minimatch": "^10.1.1", + }, + "devDependencies": { + "@biomejs/biome": "^2.3.13", + "@types/mime-types": "^3.0.1", + "@types/node": "^20", + }, + }, }, "packages": { "@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=="], @@ -1335,6 +1355,8 @@ "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=="], + "remanso-cli": ["remanso-cli@workspace:packages/remanso"], + "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=="], "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], diff --git a/package.json b/package.json index 24ac7eb..e2fdd31 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "dev:docs": "cd docs && bun run dev", "build:docs": "cd docs && bun run build", "build:cli": "cd packages/cli && bun run build", + "build:remanso": "cd packages/remanso && bun run build", + "dev:remanso": "cd packages/remanso && bun run dev", "deploy:docs": "cd docs && bun run deploy", "deploy:cli": "cd packages/cli && bun run deploy", "test:cli": "cd packages/cli && bun test" diff --git a/packages/cli/src/extensions/remanso.ts b/packages/cli/src/extensions/remanso.ts index 0d7a9fa..14caa72 100644 --- a/packages/cli/src/extensions/remanso.ts +++ b/packages/cli/src/extensions/remanso.ts @@ -138,7 +138,7 @@ export function resolveInternalLinks( // Normalize to a slug-like string for comparison const normalized = url - .replace(/^\.?\/?/, "") + .replace(/^(\.\.\/|\.\/)+/, "") .replace(/\/?$/, "") .replace(/\.mdx?$/, "") .replace(/\/index$/, ""); @@ -292,7 +292,7 @@ export function findPostsWithStaleLinks( if (!isLocalPath(url)) return false; const normalized = url - .replace(/^\.?\/?/, "") + .replace(/^(\.\.\/|\.\/)+/, "") .replace(/\/?$/, "") .replace(/\.mdx?$/, "") .replace(/\/index$/, ""); diff --git a/packages/remanso/package.json b/packages/remanso/package.json new file mode 100644 index 0000000..7a5c615 --- /dev/null +++ b/packages/remanso/package.json @@ -0,0 +1,25 @@ +{ + "name": "remanso-cli", + "version": "0.1.0", + "type": "module", + "bin": { "remanso": "dist/index.js" }, + "scripts": { + "build": "bun build src/index.ts --target node --outdir dist", + "dev": "bun run build && bun link", + "lint": "biome lint --write src", + "format": "biome format --write src" + }, + "dependencies": { + "@atproto/api": "^0.18.17", + "@clack/prompts": "^1.0.0", + "cmd-ts": "^0.14.3", + "glob": "^13.0.0", + "mime-types": "^2.1.35", + "minimatch": "^10.1.1" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.13", + "@types/mime-types": "^3.0.1", + "@types/node": "^20" + } +} diff --git a/packages/remanso/src/commands/auth.ts b/packages/remanso/src/commands/auth.ts new file mode 100644 index 0000000..4212f5f --- /dev/null +++ b/packages/remanso/src/commands/auth.ts @@ -0,0 +1,170 @@ +import { AtpAgent } from "@atproto/api"; +import { + confirm, + log, + note, + password, + select, + spinner, + text, +} from "@clack/prompts"; +import { command, flag, option, optional, string } from "cmd-ts"; +import { resolveHandleToPDS } from "../../../cli/src/lib/atproto"; +import { + deleteCredentials, + getCredentials, + getCredentialsPath, + listCredentials, + saveCredentials, +} from "../../../cli/src/lib/credentials"; +import { exitOnCancel } from "../../../cli/src/lib/prompts"; + +export const authCommand = command({ + name: "auth", + description: "Authenticate with your ATProto PDS (App Password only)", + args: { + logout: option({ + long: "logout", + description: + "Remove credentials for a specific identity (or all if only one exists)", + type: optional(string), + }), + list: flag({ + long: "list", + description: "List all stored identities", + }), + }, + handler: async ({ logout, list }) => { + // List identities + if (list) { + const identities = await listCredentials(); + if (identities.length === 0) { + log.info("No stored identities"); + } else { + log.info("Stored identities:"); + for (const id of identities) { + console.log(` - ${id}`); + } + } + return; + } + + // Logout + if (logout !== undefined) { + const identifier = logout || undefined; + + if (!identifier) { + const identities = await listCredentials(); + if (identities.length === 0) { + log.info("No saved credentials found"); + return; + } + if (identities.length === 1) { + const deleted = await deleteCredentials(identities[0]); + if (deleted) { + log.success(`Removed credentials for ${identities[0]}`); + } + return; + } + const selected = exitOnCancel( + await select({ + message: "Select identity to remove:", + options: identities.map((id) => ({ value: id, label: id })), + }), + ); + const deleted = await deleteCredentials(selected); + if (deleted) { + log.success(`Removed credentials for ${selected}`); + } + return; + } + + const deleted = await deleteCredentials(identifier); + if (deleted) { + log.success(`Removed credentials for ${identifier}`); + } else { + log.info(`No credentials found for ${identifier}`); + } + return; + } + + note( + "To authenticate, you'll need an App Password.\n\n" + + "Create one at: https://bsky.app/settings/app-passwords\n\n" + + "App Passwords are safer than your main password and can be revoked.", + "Authentication", + ); + + const identifier = exitOnCancel( + await text({ + message: "Handle or DID:", + placeholder: "yourhandle.bsky.social", + }), + ); + + const appPassword = exitOnCancel( + await password({ + message: "App Password:", + }), + ); + + if (!identifier || !appPassword) { + log.error("Handle and password are required"); + process.exit(1); + } + + // Check if this identity already exists + const existing = await getCredentials(identifier); + if (existing) { + const overwrite = exitOnCancel( + await confirm({ + message: `Credentials for ${identifier} already exist. Update?`, + initialValue: false, + }), + ); + if (!overwrite) { + log.info("Keeping existing credentials"); + return; + } + } + + // Resolve PDS from handle + const s = spinner(); + s.start("Resolving PDS..."); + let pdsUrl: string; + try { + pdsUrl = await resolveHandleToPDS(identifier); + s.stop(`Found PDS: ${pdsUrl}`); + } catch (error) { + s.stop("Failed to resolve PDS"); + log.error(`Failed to resolve PDS from handle: ${error}`); + process.exit(1); + } + + // Verify credentials + s.start("Verifying credentials..."); + + try { + const agent = new AtpAgent({ service: pdsUrl }); + await agent.login({ + identifier: identifier, + password: appPassword, + }); + + s.stop(`Logged in as ${agent.session?.handle}`); + + await saveCredentials({ + type: "app-password", + pdsUrl, + identifier: identifier, + password: appPassword, + }); + + log.success(`Credentials saved to ${getCredentialsPath()}`); + } catch (error) { + s.stop("Failed to login"); + log.error(`Failed to login: ${error}`); + process.exit(1); + } + }, +}); diff --git a/packages/remanso/src/commands/github.ts b/packages/remanso/src/commands/github.ts new file mode 100644 index 0000000..81b929a --- /dev/null +++ b/packages/remanso/src/commands/github.ts @@ -0,0 +1,211 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { execSync, spawnSync } from "node:child_process"; +import { command, flag } from "cmd-ts"; +import { log, spinner, confirm, text } from "@clack/prompts"; +import { exitOnCancel } from "../../../cli/src/lib/prompts"; +import { getCredentials, listCredentials } from "../../../cli/src/lib/credentials"; + +export const WORKFLOW_YAML = `name: Publish to the PDS +on: + push: + branches: [main] + workflow_dispatch: +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: remanso-space/sequoia@main + with: + identifier: \${{ secrets.ATP_IDENTIFIER }} + app-password: \${{ secrets.ATP_APP_PASSWORD }} +`; + +const WORKFLOW_PATH = ".github/workflows/remanso.yml"; + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function detectGitHubRemote(): { owner: string; repo: string } | null { + try { + const remote = execSync("git remote get-url origin", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + // Parse SSH: git@github.com:owner/repo.git + const sshMatch = remote.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?$/); + if (sshMatch) { + return { owner: sshMatch[1]!, repo: sshMatch[2]! }; + } + + // Parse HTTPS: https://github.com/owner/repo.git + const httpsMatch = remote.match( + /https:\/\/github\.com\/([^/]+)\/([^.]+?)(?:\.git)?$/, + ); + if (httpsMatch) { + return { owner: httpsMatch[1]!, repo: httpsMatch[2]! }; + } + + return null; + } catch { + return null; + } +} + +async function generateWorkflow(): Promise { + const workflowDir = path.dirname(WORKFLOW_PATH); + + if (await fileExists(WORKFLOW_PATH)) { + const overwrite = exitOnCancel( + await confirm({ + message: `${WORKFLOW_PATH} already exists. Overwrite?`, + initialValue: false, + }), + ); + if (!overwrite) { + log.info(`Keeping existing ${WORKFLOW_PATH}`); + return; + } + } + + await fs.mkdir(workflowDir, { recursive: true }); + await fs.writeFile(WORKFLOW_PATH, WORKFLOW_YAML); + log.success(`Created ${WORKFLOW_PATH}`); +} + +async function setSecrets(): Promise { + const remote = detectGitHubRemote(); + if (!remote) { + log.warn( + "Could not detect GitHub remote. Skipping secret setup.", + ); + log.info( + "Add ATP_IDENTIFIER and ATP_APP_PASSWORD manually in your GitHub repo settings.", + ); + return; + } + + log.info(`GitHub repo: ${remote.owner}/${remote.repo}`); + + // Check if gh CLI is available + const ghCheck = spawnSync("gh", ["--version"], { stdio: "pipe" }); + if (ghCheck.status !== 0) { + log.warn("gh CLI not found. Cannot set secrets automatically."); + log.info( + "Install the GitHub CLI (https://cli.github.com/) or set secrets manually.", + ); + return; + } + + // Get identifier from stored credentials or prompt + let identifier: string; + let appPassword: string; + + const storedIds = await listCredentials(); + if (storedIds.length === 1 && storedIds[0]) { + const creds = await getCredentials(storedIds[0]); + if (creds) { + identifier = creds.identifier; + appPassword = creds.password; + log.info(`Using stored credentials for: ${identifier}`); + } else { + identifier = exitOnCancel( + await text({ message: "ATProto handle (ATP_IDENTIFIER):", placeholder: "you.bsky.social" }), + ); + appPassword = exitOnCancel( + await text({ message: "App Password (ATP_APP_PASSWORD):", placeholder: "xxxx-xxxx-xxxx-xxxx" }), + ); + } + } else if (storedIds.length > 1) { + const { select } = await import("@clack/prompts"); + const selected = exitOnCancel( + await select({ + message: "Select identity for GitHub secrets:", + options: storedIds.map((id) => ({ value: id, label: id })), + }), + ); + const creds = await getCredentials(selected); + if (!creds) { + log.error("Could not load credentials for selected identity."); + return; + } + identifier = creds.identifier; + appPassword = creds.password; + } else { + identifier = exitOnCancel( + await text({ message: "ATProto handle (ATP_IDENTIFIER):", placeholder: "you.bsky.social" }), + ); + appPassword = exitOnCancel( + await text({ message: "App Password (ATP_APP_PASSWORD):", placeholder: "xxxx-xxxx-xxxx-xxxx" }), + ); + } + + const s = spinner(); + const repoFlag = `${remote.owner}/${remote.repo}`; + + s.start("Setting ATP_IDENTIFIER secret..."); + const r1 = spawnSync( + "gh", + ["secret", "set", "ATP_IDENTIFIER", "--body", identifier, "--repo", repoFlag], + { stdio: "pipe" }, + ); + if (r1.status === 0) { + s.stop("ATP_IDENTIFIER set"); + } else { + s.stop("Failed to set ATP_IDENTIFIER"); + log.warn(r1.stderr?.toString() || "Unknown error"); + } + + s.start("Setting ATP_APP_PASSWORD secret..."); + const r2 = spawnSync( + "gh", + ["secret", "set", "ATP_APP_PASSWORD", "--body", appPassword, "--repo", repoFlag], + { stdio: "pipe" }, + ); + if (r2.status === 0) { + s.stop("ATP_APP_PASSWORD set"); + } else { + s.stop("Failed to set ATP_APP_PASSWORD"); + log.warn(r2.stderr?.toString() || "Unknown error"); + } + + log.success(`Secrets configured for ${repoFlag}`); +} + +export const githubCommand = command({ + name: "github", + description: + "Set up GitHub Actions workflow and secrets for automated publishing", + args: { + workflow: flag({ + long: "workflow", + description: "Only generate the GitHub Actions workflow YAML", + }), + secrets: flag({ + long: "secrets", + description: "Only set GitHub repository secrets via the gh CLI", + }), + }, + handler: async ({ workflow, secrets }) => { + const doWorkflow = workflow || (!workflow && !secrets); + const doSecrets = secrets || (!workflow && !secrets); + + if (doWorkflow) { + await generateWorkflow(); + } + + if (doSecrets) { + await setSecrets(); + } + }, +}); diff --git a/packages/remanso/src/commands/init.ts b/packages/remanso/src/commands/init.ts new file mode 100644 index 0000000..6ee7f5b --- /dev/null +++ b/packages/remanso/src/commands/init.ts @@ -0,0 +1,468 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { execSync, spawnSync } from "node:child_process"; +import { command } from "cmd-ts"; +import { + confirm, + intro, + log, + note, + outro, + password, + select, + spinner, + text, +} from "@clack/prompts"; +import { AtpAgent } from "@atproto/api"; +import { resolveHandleToPDS, createPublication, createAgent } from "../../../cli/src/lib/atproto"; +import { + loadCredentials, + saveCredentials, + getCredentials, + listCredentials, +} from "../../../cli/src/lib/credentials"; +import { exitOnCancel } from "../../../cli/src/lib/prompts"; +import type { RemansoConfig } from "../lib/config"; +import { WORKFLOW_YAML } from "./github"; + +const CONFIG_FILENAME = "remanso.json"; +const STATE_FILENAME = ".remanso-state.json"; +const WORKFLOW_PATH = ".github/workflows/remanso.yml"; + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function detectGitHubRemote(): { owner: string; repo: string } | null { + try { + const remote = execSync("git remote get-url origin", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + + const sshMatch = remote.match(/git@github\.com:([^/]+)\/([^.]+)(?:\.git)?$/); + if (sshMatch) { + return { owner: sshMatch[1]!, repo: sshMatch[2]! }; + } + + const httpsMatch = remote.match( + /https:\/\/github\.com\/([^/]+)\/([^.]+?)(?:\.git)?$/, + ); + if (httpsMatch) { + return { owner: httpsMatch[1]!, repo: httpsMatch[2]! }; + } + + return null; + } catch { + return null; + } +} + +async function listPublications( + agent: InstanceType, +): Promise> { + const results: Array<{ uri: string; name: string; url: string }> = []; + let cursor: string | undefined; + + do { + const response = await agent.com.atproto.repo.listRecords({ + repo: agent.did!, + collection: "site.standard.publication", + limit: 100, + cursor, + }); + + for (const record of response.data.records) { + const value = record.value as Record; + if (value.$type === "site.standard.publication") { + results.push({ + uri: record.uri, + name: (value.name as string) || record.uri, + url: (value.url as string) || "", + }); + } + } + + cursor = response.data.cursor; + } while (cursor); + + return results; +} + +const onCancel = () => { + outro("Setup cancelled"); + process.exit(0); +}; + +export const initCommand = command({ + name: "init", + description: "Initialize a new Remanso notes configuration", + args: {}, + handler: async () => { + intro("Remanso Configuration Setup"); + + // Step 1: Detect GitHub remote + const remote = detectGitHubRemote(); + if (remote) { + log.info(`Detected GitHub repo: ${remote.owner}/${remote.repo}`); + } else { + log.info( + "No GitHub remote detected. You can add GitHub integration later with 'remanso github'.", + ); + } + + // Check if config already exists + if (await fileExists(CONFIG_FILENAME)) { + const overwrite = exitOnCancel( + await confirm({ + message: `${CONFIG_FILENAME} already exists. Overwrite?`, + initialValue: false, + }), + ); + if (!overwrite) { + log.info("Keeping existing configuration"); + outro("No changes made."); + return; + } + } + + // Step 2: Load credentials or prompt for them + let credentials = await loadCredentials(); + let pdsUrl: string | undefined; + + if (credentials?.type === "oauth") { + log.warn("OAuth credentials detected but remanso requires App Passwords."); + credentials = null; + } + + if (!credentials) { + const storedIds = await listCredentials(); + if (storedIds.length > 0) { + // Offer to use stored credentials or add new ones + const choice = exitOnCancel( + await select({ + message: "Authentication:", + options: [ + ...storedIds.map((id) => ({ + value: id, + label: `Use existing: ${id}`, + })), + { value: "__new__", label: "Add new App Password" }, + ], + }), + ); + + if (choice !== "__new__") { + credentials = await getCredentials(choice); + } + } + + if (!credentials) { + // Prompt for new credentials + note( + "Create an App Password at: https://bsky.app/settings/app-passwords", + "Authentication", + ); + + const identifier = exitOnCancel( + await text({ + message: "Handle or DID:", + placeholder: "yourhandle.bsky.social", + }), + ); + + const appPassword = exitOnCancel( + await password({ + message: "App Password:", + }), + ); + + if (!identifier || !appPassword) { + log.error("Handle and password are required"); + process.exit(1); + } + + const s = spinner(); + s.start("Resolving PDS..."); + try { + pdsUrl = await resolveHandleToPDS(identifier); + s.stop(`Found PDS: ${pdsUrl}`); + } catch (error) { + s.stop("Failed to resolve PDS"); + log.error(`Failed to resolve PDS from handle: ${error}`); + process.exit(1); + } + + s.start("Verifying credentials..."); + try { + const agent = new AtpAgent({ service: pdsUrl }); + await agent.login({ identifier, password: appPassword }); + s.stop(`Logged in as ${agent.session?.handle}`); + + credentials = { + type: "app-password", + pdsUrl, + identifier, + password: appPassword, + }; + + await saveCredentials(credentials); + log.success("Credentials saved"); + } catch (error) { + s.stop("Failed to login"); + log.error(`Failed to login: ${error}`); + process.exit(1); + } + } + } + + // Step 3: Connect and get DID + const s = spinner(); + s.start("Connecting to ATProto..."); + let agent: Awaited>; + try { + agent = await createAgent(credentials!); + s.stop(`Connected as ${agent.did}`); + } catch (error) { + s.stop("Failed to connect"); + log.error(`Failed to connect: ${error}`); + process.exit(1); + } + + if (credentials?.type === "app-password") { + pdsUrl = credentials.pdsUrl; + } + + // Step 4: Publication — list existing or create new + let publicationUri: string; + + s.start("Fetching existing publications..."); + let publications: Array<{ uri: string; name: string; url: string }> = []; + try { + publications = await listPublications(agent as unknown as InstanceType); + s.stop(`Found ${publications.length} existing publication(s)`); + } catch { + s.stop("Could not fetch publications"); + } + + const siteUrl = `https://remanso.space/pub/@${agent.did}`; + + if (publications.length > 0) { + const pubChoice = exitOnCancel( + await select({ + message: "Publication:", + options: [ + ...publications.map((p) => ({ + value: p.uri, + label: `${p.name} (${p.uri})`, + })), + { value: "__create__", label: "Create a new publication" }, + ], + }), + ); + + if (pubChoice === "__create__") { + const pubName = exitOnCancel( + await text({ + message: "Publication name:", + placeholder: "My Notes", + validate: (v) => (!v ? "Name is required" : undefined), + }), + ); + + s.start("Creating publication..."); + try { + publicationUri = await createPublication(agent, { + url: siteUrl, + name: pubName, + }); + s.stop(`Publication created: ${publicationUri}`); + } catch (error) { + s.stop("Failed to create publication"); + log.error(`Failed to create publication: ${error}`); + process.exit(1); + } + } else { + publicationUri = pubChoice; + } + } else { + // No publications — create one + log.info("No existing publications found. Creating a new one."); + const pubName = exitOnCancel( + await text({ + message: "Publication name:", + placeholder: "My Notes", + validate: (v) => (!v ? "Name is required" : undefined), + }), + ); + + s.start("Creating publication..."); + try { + publicationUri = await createPublication(agent, { + url: siteUrl, + name: pubName, + }); + s.stop(`Publication created: ${publicationUri}`); + } catch (error) { + s.stop("Failed to create publication"); + log.error(`Failed to create publication: ${error}`); + process.exit(1); + } + } + + // Step 5: Content directory + const contentDir = exitOnCancel( + await text({ + message: "Content directory (where your .pub.md files live):", + placeholder: ".", + defaultValue: ".", + }), + ); + + // Step 6: Write remanso.json + const config: RemansoConfig = { + contentDir: contentDir || ".", + publicationUri, + }; + + if (pdsUrl && pdsUrl !== "https://bsky.social") { + config.pdsUrl = pdsUrl; + } + + const configContent = JSON.stringify(config, null, 2); + await fs.writeFile(CONFIG_FILENAME, configContent); + log.success(`Created ${CONFIG_FILENAME}`); + + // Step 7: Update .gitignore + const gitignorePath = ".gitignore"; + if (await fileExists(gitignorePath)) { + const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); + if (!gitignoreContent.includes(STATE_FILENAME)) { + await fs.writeFile( + gitignorePath, + `${gitignoreContent}\n${STATE_FILENAME}\n`, + ); + log.info(`Added ${STATE_FILENAME} to .gitignore`); + } + } else { + await fs.writeFile(gitignorePath, `${STATE_FILENAME}\n`); + log.info(`Created .gitignore with ${STATE_FILENAME}`); + } + + // Step 8: GitHub Action workflow + const addWorkflow = exitOnCancel( + await confirm({ + message: "Generate GitHub Actions workflow for automated publishing?", + initialValue: true, + }), + ); + + if (addWorkflow) { + const workflowDir = path.dirname(WORKFLOW_PATH); + + if (await fileExists(WORKFLOW_PATH)) { + const overwrite = exitOnCancel( + await confirm({ + message: `${WORKFLOW_PATH} already exists. Overwrite?`, + initialValue: false, + }), + ); + if (overwrite) { + await fs.mkdir(workflowDir, { recursive: true }); + await fs.writeFile(WORKFLOW_PATH, WORKFLOW_YAML); + log.success(`Updated ${WORKFLOW_PATH}`); + } + } else { + await fs.mkdir(workflowDir, { recursive: true }); + await fs.writeFile(WORKFLOW_PATH, WORKFLOW_YAML); + log.success(`Created ${WORKFLOW_PATH}`); + } + } + + // Step 9: GitHub secrets + if (remote && credentials?.type === "app-password") { + const ghCheck = spawnSync("gh", ["--version"], { stdio: "pipe" }); + const ghAvailable = ghCheck.status === 0; + + if (ghAvailable) { + const setGhSecrets = exitOnCancel( + await confirm({ + message: `Set GitHub secrets for ${remote.owner}/${remote.repo}?`, + initialValue: true, + }), + ); + + if (setGhSecrets) { + const repoFlag = `${remote.owner}/${remote.repo}`; + + s.start("Setting ATP_IDENTIFIER secret..."); + const r1 = spawnSync( + "gh", + [ + "secret", + "set", + "ATP_IDENTIFIER", + "--body", + credentials.identifier, + "--repo", + repoFlag, + ], + { stdio: "pipe" }, + ); + if (r1.status === 0) { + s.stop("ATP_IDENTIFIER set"); + } else { + s.stop("Failed to set ATP_IDENTIFIER"); + log.warn(r1.stderr?.toString() || "Unknown error"); + } + + s.start("Setting ATP_APP_PASSWORD secret..."); + const r2 = spawnSync( + "gh", + [ + "secret", + "set", + "ATP_APP_PASSWORD", + "--body", + credentials.password, + "--repo", + repoFlag, + ], + { stdio: "pipe" }, + ); + if (r2.status === 0) { + s.stop("ATP_APP_PASSWORD set"); + } else { + s.stop("Failed to set ATP_APP_PASSWORD"); + log.warn(r2.stderr?.toString() || "Unknown error"); + } + + log.success(`Secrets configured for ${repoFlag}`); + } + } else { + log.info( + "Install the GitHub CLI (https://cli.github.com/) to set secrets automatically.", + ); + log.info( + `Or add ATP_IDENTIFIER and ATP_APP_PASSWORD manually in ${remote.owner}/${remote.repo} settings.`, + ); + } + } + + note( + "Next steps:\n" + + "1. Create notes with a .pub.md extension to publish them\n" + + "2. Run 'remanso publish --dry-run' to preview\n" + + "3. Run 'remanso publish' to publish\n" + + (remote ? "4. Push to GitHub to trigger automated publishing" : ""), + "Setup complete!", + ); + + outro("Happy publishing!"); + }, +}); diff --git a/packages/remanso/src/commands/publish.ts b/packages/remanso/src/commands/publish.ts new file mode 100644 index 0000000..1a4996e --- /dev/null +++ b/packages/remanso/src/commands/publish.ts @@ -0,0 +1,556 @@ +import * as fs from "node:fs/promises"; +import { command, flag } from "cmd-ts"; +import { log, spinner } from "@clack/prompts"; +import * as path from "node:path"; +import { findConfig, loadConfig, loadState, saveState } from "../lib/config"; +import { + loadCredentials, + listAllCredentials, + getCredentials, +} from "../../../cli/src/lib/credentials"; +import { + createAgent, + createDocument, + updateDocument, + uploadImage, + resolveImagePath, + deleteRecord, + listDocuments, +} from "../../../cli/src/lib/atproto"; +import { + scanContentDirectory, + getContentHash, + updateFrontmatterWithAtUri, + resolvePostPath, +} from "../../../cli/src/lib/markdown"; +import type { + BlogPost, + BlobObject, + AppPasswordCredentials, +} from "../../../cli/src/lib/types"; +import { exitOnCancel } from "../../../cli/src/lib/prompts"; +import { + createNote, + updateNote, + deleteNote, + findPostsWithStaleLinks, + type NoteOptions, +} from "../../../cli/src/extensions/remanso"; + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function selectIdentity(): Promise { + const { select } = await import("@clack/prompts"); + const identities = await listAllCredentials(); + + if (identities.length === 0) { + log.error( + "No credentials found. Run 'remanso auth' to set up an App Password.", + ); + process.exit(1); + } + + // Filter to app-password only (remanso doesn't support OAuth) + const appPasswordIds = identities.filter((c) => c.type === "app-password"); + if (appPasswordIds.length === 0) { + log.error( + "No App Password credentials found. Run 'remanso auth' to set up one.", + ); + log.info("Note: OAuth credentials are not supported by remanso."); + process.exit(1); + } + + if (appPasswordIds.length === 1 && appPasswordIds[0]) { + return await getCredentials(appPasswordIds[0].id); + } + + log.info("Multiple identities found. Select one to use:"); + const selected = exitOnCancel( + await select({ + message: "Identity:", + options: appPasswordIds.map((c) => ({ + value: c.id, + label: `${c.id} (App Password)`, + })), + }), + ); + + return await getCredentials(selected); +} + +export const publishCommand = command({ + name: "publish", + description: "Publish .pub.md notes to ATProto / Remanso", + args: { + force: flag({ + long: "force", + short: "f", + description: "Force publish all notes, ignoring change detection", + }), + dryRun: flag({ + long: "dry-run", + short: "n", + description: "Preview what would be published without making changes", + }), + verbose: flag({ + long: "verbose", + short: "v", + description: "Show more information", + }), + }, + handler: async ({ force, dryRun, verbose }) => { + // Load config + const configPath = await findConfig(); + if (!configPath) { + log.error("No remanso.json found. Run 'remanso init' first."); + process.exit(1); + } + + const { config, configPath: resolvedConfigPath } = + await loadConfig(configPath); + const configDir = path.dirname(resolvedConfigPath); + + log.info(`Content directory: ${config.contentDir}`); + log.info(`Publication: ${config.publicationUri}`); + + // Load credentials (app-password only) + let credentials = await loadCredentials(config.identity); + + if (credentials?.type === "oauth") { + log.error( + "OAuth credentials are not supported by remanso. Run 'remanso auth' to set up an App Password.", + ); + process.exit(1); + } + + if (!credentials) { + credentials = await selectIdentity(); + } + + if (!credentials) { + log.error("Failed to load credentials."); + process.exit(1); + } + + const appCreds = credentials as AppPasswordCredentials; + + // Resolve content directory + const contentDir = path.isAbsolute(config.contentDir) + ? config.contentDir + : path.join(configDir, config.contentDir); + + const imagesDir = config.imagesDir + ? path.isAbsolute(config.imagesDir) + ? config.imagesDir + : path.join(configDir, config.imagesDir) + : undefined; + + // Load state + const state = await loadState(configDir); + + // Scan for posts (all .md files), then filter to .pub.md + const s = spinner(); + s.start("Scanning for notes..."); + const allScanned = await scanContentDirectory(contentDir, { + ignorePatterns: config.ignore, + }); + const posts = allScanned.filter((p) => p.filePath.endsWith(".pub.md")); + s.stop(`Found ${posts.length} publishable notes (.pub.md)`); + + // Detect deleted files: state entries whose local files no longer exist + const scannedPaths = new Set( + posts.map((p) => path.relative(configDir, p.filePath)), + ); + const deletedEntries: Array<{ filePath: string; atUri: string }> = []; + + for (const [filePath, postState] of Object.entries(state.posts)) { + if (!scannedPaths.has(filePath) && postState.atUri) { + const absolutePath = path.resolve(configDir, filePath); + if (!(await fileExists(absolutePath))) { + deletedEntries.push({ filePath, atUri: postState.atUri }); + } + } + } + + // Detect unmatched PDS records + const unmatchedEntries: Array<{ atUri: string; title: string }> = []; + + // Shared agent — created lazily + let agent: Awaited> | undefined; + async function getAgent(): Promise< + Awaited> + > { + if (agent) return agent; + + s.start(`Connecting as ${appCreds.pdsUrl}...`); + try { + agent = await createAgent(appCreds); + s.stop(`Logged in as ${agent.did}`); + return agent; + } catch (error) { + s.stop("Failed to login"); + log.error(`Failed to login: ${error}`); + process.exit(1); + } + } + + // Determine which posts need publishing + const postsToPublish: Array<{ + post: BlogPost; + action: "create" | "update"; + reason: "content changed" | "forced" | "new post" | "missing state"; + }> = []; + const draftPosts: BlogPost[] = []; + + for (const post of posts) { + if (post.frontmatter.draft) { + draftPosts.push(post); + continue; + } + + const contentHash = await getContentHash(post.rawContent); + const relativeFilePath = path.relative(configDir, post.filePath); + const postState = state.posts[relativeFilePath]; + + if (force) { + postsToPublish.push({ + post, + action: post.frontmatter.atUri ? "update" : "create", + reason: "forced", + }); + } else if (!postState) { + postsToPublish.push({ + post, + action: post.frontmatter.atUri ? "update" : "create", + reason: post.frontmatter.atUri ? "missing state" : "new post", + }); + } else if (postState.contentHash !== contentHash) { + postsToPublish.push({ + post, + action: post.frontmatter.atUri ? "update" : "create", + reason: "content changed", + }); + } + } + + if (draftPosts.length > 0) { + log.info( + `Skipping ${draftPosts.length} draft note${draftPosts.length === 1 ? "" : "s"}`, + ); + } + + // Fetch PDS records to detect unmatched documents + async function fetchUnmatchedRecords() { + const ag = await getAgent(); + s.start("Fetching documents from PDS..."); + const pdsDocuments = await listDocuments(ag, config.publicationUri); + s.stop(`Found ${pdsDocuments.length} documents on PDS`); + + const pathPrefix = "/posts"; + const postsByPath = new Map(); + for (const post of posts) { + postsByPath.set(`${pathPrefix}/${post.slug}`, post); + } + const deletedAtUris = new Set(deletedEntries.map((e) => e.atUri)); + for (const doc of pdsDocuments) { + if (!postsByPath.has(doc.value.path) && !deletedAtUris.has(doc.uri)) { + unmatchedEntries.push({ + atUri: doc.uri, + title: doc.value.title || doc.value.path, + }); + } + } + } + + if (postsToPublish.length === 0 && deletedEntries.length === 0) { + await fetchUnmatchedRecords(); + + if (unmatchedEntries.length === 0) { + log.success("All notes are up to date. Nothing to publish."); + return; + } + } + + if (postsToPublish.length > 0) { + log.info(`\n${postsToPublish.length} notes to publish:\n`); + + for (const { post, action, reason } of postsToPublish) { + const icon = action === "create" ? "+" : "~"; + let postUrl = ""; + if (verbose) { + postUrl = `\n ${post.filePath}`; + } + log.message(` ${icon} ${post.filePath} (${reason})${postUrl}`); + } + } + + if (deletedEntries.length > 0) { + log.info( + `\n${deletedEntries.length} deleted local files to remove from PDS:\n`, + ); + for (const { filePath } of deletedEntries) { + log.message(` - ${filePath}`); + } + } + + if (unmatchedEntries.length > 0) { + log.info( + `\n${unmatchedEntries.length} unmatched PDS records to delete:\n`, + ); + for (const { title } of unmatchedEntries) { + log.message(` - ${title}`); + } + } + + if (dryRun) { + log.info("\nDry run complete. No changes made."); + return; + } + + // Ensure agent is connected + await getAgent(); + + if (!agent) { + throw new Error("agent is not connected"); + } + + // Derive siteUrl from DID + const siteUrl = `https://remanso.space/pub/@${agent.did}`; + log.info(`Site URL: ${siteUrl}`); + + // Fetch PDS records to detect unmatched documents (if not already done) + if (unmatchedEntries.length === 0) { + await fetchUnmatchedRecords(); + } + + // Build the publisher config object for createDocument/updateDocument + const publisherConfig = { + siteUrl, + contentDir, + publicationUri: config.publicationUri, + imagesDir, + pdsUrl: config.pdsUrl, + pathPrefix: "", + }; + + // Publish posts + let publishedCount = 0; + let updatedCount = 0; + let errorCount = 0; + + const context: NoteOptions = { + contentDir, + imagesDir, + allPosts: posts, + }; + + // Pass 1: Create/update document records and collect note queue + const noteQueue: Array<{ + post: BlogPost; + action: "create" | "update"; + atUri: string; + }> = []; + + for (const { post, action } of postsToPublish) { + const trimmedContent = post.content.trim(); + const titleMatch = trimmedContent.match(/^# (.+)$/m); + const title = titleMatch ? titleMatch[1] : post.frontmatter.title; + s.start(`Publishing: ${title}`); + + // Init publish date + if (!post.frontmatter.publishDate) { + const [publishDate] = new Date().toISOString().split("T"); + post.frontmatter.publishDate = publishDate!; + } + + try { + // Handle cover image upload + let coverImage: BlobObject | undefined; + if (post.frontmatter.ogImage) { + const imagePath = await resolveImagePath( + post.frontmatter.ogImage, + imagesDir, + contentDir, + ); + + if (imagePath) { + log.info(` Uploading cover image: ${path.basename(imagePath)}`); + coverImage = await uploadImage(agent, imagePath); + if (coverImage) { + log.info(` Uploaded image blob: ${coverImage.ref.$link}`); + } + } else { + log.warn(` Cover image not found: ${post.frontmatter.ogImage}`); + } + } + + let atUri: string; + let contentForHash: string; + const relativeFilePath = path.relative(configDir, post.filePath); + + if (action === "create") { + atUri = await createDocument(agent, post, publisherConfig as Parameters[2], coverImage); + post.frontmatter.atUri = atUri; + s.stop(`Created: ${atUri}`); + + // Update frontmatter with atUri + const updatedContent = updateFrontmatterWithAtUri( + post.rawContent, + atUri, + ); + await fs.writeFile(post.filePath, updatedContent); + log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); + + contentForHash = updatedContent; + publishedCount++; + } else { + atUri = post.frontmatter.atUri!; + await updateDocument(agent, post, atUri, publisherConfig as Parameters[3], coverImage); + s.stop(`Updated: ${atUri}`); + + contentForHash = post.rawContent; + updatedCount++; + } + + // Update state + const contentHash = await getContentHash(contentForHash); + state.posts[relativeFilePath] = { + contentHash, + atUri, + lastPublished: new Date().toISOString(), + slug: post.slug, + }; + + noteQueue.push({ post, action, atUri }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + s.stop(`Error publishing "${path.basename(post.filePath)}"`); + log.error(` ${errorMessage}`); + errorCount++; + } + } + + // Pass 2: Create/update Remanso notes + for (const { post, action, atUri } of noteQueue) { + try { + if (action === "create") { + await createNote(agent, post, atUri, context); + } else { + await updateNote(agent, post, atUri, context); + } + } catch (error) { + log.warn( + `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + // Re-process already-published posts with stale links + const newlyCreatedSlugs = noteQueue + .filter((r) => r.action === "create") + .map((r) => r.post.slug); + + if (newlyCreatedSlugs.length > 0) { + const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath)); + const stalePosts = findPostsWithStaleLinks( + posts, + newlyCreatedSlugs, + batchFilePaths, + ); + + for (const stalePost of stalePosts) { + try { + s.start(`Updating links in: ${stalePost.frontmatter.title}`); + await updateNote( + agent, + stalePost, + stalePost.frontmatter.atUri!, + context, + ); + s.stop(`Updated links: ${stalePost.frontmatter.title}`); + } catch (error) { + s.stop(`Failed to update links: ${stalePost.frontmatter.title}`); + log.warn( + ` ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + // Delete records for removed files + let deletedCount = 0; + for (const { filePath, atUri } of deletedEntries) { + try { + const ag = await getAgent(); + s.start(`Deleting: ${filePath}`); + await deleteRecord(ag, atUri); + + try { + const noteAtUri = atUri.replace( + "site.standard.document", + "space.remanso.note", + ); + await deleteNote(ag, noteAtUri); + } catch { + // Note may not exist, ignore + } + + delete state.posts[filePath]; + s.stop(`Deleted: ${filePath}`); + deletedCount++; + } catch (error) { + s.stop(`Failed to delete: ${filePath}`); + log.warn(` ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Delete unmatched PDS records + let unmatchedDeletedCount = 0; + for (const { atUri, title } of unmatchedEntries) { + try { + const ag = await getAgent(); + s.start(`Deleting unmatched: ${title}`); + await deleteRecord(ag, atUri); + + try { + const noteAtUri = atUri.replace( + "site.standard.document", + "space.remanso.note", + ); + await deleteNote(ag, noteAtUri); + } catch { + // Note may not exist, ignore + } + + s.stop(`Deleted unmatched: ${title}`); + unmatchedDeletedCount++; + } catch (error) { + s.stop(`Failed to delete: ${title}`); + log.warn(` ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Save state + await saveState(configDir, state); + + // Summary + log.message("\n---"); + const totalDeleted = deletedCount + unmatchedDeletedCount; + if (totalDeleted > 0) { + log.info(`Deleted: ${totalDeleted}`); + } + log.info(`Published: ${publishedCount}`); + log.info(`Updated: ${updatedCount}`); + if (errorCount > 0) { + log.warn(`Errors: ${errorCount}`); + } + }, +}); diff --git a/packages/remanso/src/commands/sync.ts b/packages/remanso/src/commands/sync.ts new file mode 100644 index 0000000..e4074bc --- /dev/null +++ b/packages/remanso/src/commands/sync.ts @@ -0,0 +1,300 @@ +import * as fs from "node:fs/promises"; +import { command, flag } from "cmd-ts"; +import { log, spinner } from "@clack/prompts"; +import * as path from "node:path"; +import { findConfig, loadConfig, loadState, saveState } from "../lib/config"; +import { + loadCredentials, + listAllCredentials, + getCredentials, +} from "../../../cli/src/lib/credentials"; +import type { Agent } from "@atproto/api"; +import { createAgent, listDocuments } from "../../../cli/src/lib/atproto"; +import type { ListDocumentsResult } from "../../../cli/src/lib/atproto"; +import type { BlogPost, AppPasswordCredentials } from "../../../cli/src/lib/types"; +import { + scanContentDirectory, + getContentHash, + getTextContent, + updateFrontmatterWithAtUri, +} from "../../../cli/src/lib/markdown"; +import { exitOnCancel } from "../../../cli/src/lib/prompts"; + +async function matchesPDS( + localPost: BlogPost, + doc: ListDocumentsResult, + agent: Agent, +): Promise { + // Compare body text content + const localTextContent = getTextContent(localPost, undefined); + if (localTextContent.slice(0, 10000) !== doc.value.textContent) { + return false; + } + + // Compare document fields: title, description, tags + const trimmedContent = localPost.content.trim(); + const titleMatch = trimmedContent.match(/^# (.+)$/m); + const localTitle = titleMatch ? titleMatch[1] : localPost.frontmatter.title; + if (localTitle !== doc.value.title) return false; + + const localDescription = localPost.frontmatter.description || undefined; + if (localDescription !== doc.value.description) return false; + + const localTags = + localPost.frontmatter.tags && localPost.frontmatter.tags.length > 0 + ? localPost.frontmatter.tags + : undefined; + if (JSON.stringify(localTags) !== JSON.stringify(doc.value.tags)) { + return false; + } + + // Compare note-specific fields: theme, fontSize, fontFamily + const noteUriMatch = doc.uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/); + if (noteUriMatch) { + const repo = noteUriMatch[1]!; + const rkey = noteUriMatch[2]!; + try { + const noteResponse = await agent.com.atproto.repo.getRecord({ + repo, + collection: "space.remanso.note", + rkey, + }); + const noteValue = noteResponse.data.value as Record; + if ( + (localPost.frontmatter.theme || undefined) !== + (noteValue.theme as string | undefined) || + (localPost.frontmatter.fontSize || undefined) !== + (noteValue.fontSize as number | undefined) || + (localPost.frontmatter.fontFamily || undefined) !== + (noteValue.fontFamily as string | undefined) + ) { + return false; + } + } catch { + // Note record doesn't exist — treat as matching + } + } + + return true; +} + +async function selectIdentity(): Promise { + const { select } = await import("@clack/prompts"); + const identities = await listAllCredentials(); + + if (identities.length === 0) { + log.error( + "No credentials found. Run 'remanso auth' to set up an App Password.", + ); + process.exit(1); + } + + const appPasswordIds = identities.filter((c) => c.type === "app-password"); + if (appPasswordIds.length === 0) { + log.error( + "No App Password credentials found. Run 'remanso auth' to set up one.", + ); + process.exit(1); + } + + if (appPasswordIds.length === 1 && appPasswordIds[0]) { + return await getCredentials(appPasswordIds[0].id); + } + + log.info("Multiple identities found. Select one to use:"); + const selected = exitOnCancel( + await select({ + message: "Identity:", + options: appPasswordIds.map((c) => ({ + value: c.id, + label: `${c.id} (App Password)`, + })), + }), + ); + + return await getCredentials(selected); +} + +export const syncCommand = command({ + name: "sync", + description: "Sync state from ATProto to restore .remanso-state.json", + args: { + updateFrontmatter: flag({ + long: "update-frontmatter", + short: "u", + description: "Update frontmatter atUri fields in local markdown files", + }), + dryRun: flag({ + long: "dry-run", + short: "n", + description: "Preview what would be synced without making changes", + }), + }, + handler: async ({ updateFrontmatter, dryRun }) => { + // Load config + const configPath = await findConfig(); + if (!configPath) { + log.error("No remanso.json found. Run 'remanso init' first."); + process.exit(1); + } + + const { config, configPath: resolvedConfigPath } = + await loadConfig(configPath); + const configDir = path.dirname(resolvedConfigPath); + + log.info(`Publication: ${config.publicationUri}`); + + // Load credentials (app-password only) + let credentials = await loadCredentials(config.identity); + + if (credentials?.type === "oauth") { + log.error( + "OAuth credentials are not supported by remanso. Run 'remanso auth' to set up an App Password.", + ); + process.exit(1); + } + + if (!credentials) { + credentials = await selectIdentity(); + } + + if (!credentials) { + log.error("Failed to load credentials."); + process.exit(1); + } + + // Create agent + const s = spinner(); + s.start(`Connecting as ${(credentials as AppPasswordCredentials).pdsUrl}...`); + let agent: Awaited> | undefined; + try { + agent = await createAgent(credentials); + s.stop(`Logged in as ${agent.did}`); + } catch (error) { + s.stop("Failed to login"); + log.error(`Failed to login: ${error}`); + process.exit(1); + } + + // Fetch documents from PDS + s.start("Fetching documents from PDS..."); + const documents = await listDocuments(agent, config.publicationUri); + s.stop(`Found ${documents.length} documents on PDS`); + + if (documents.length === 0) { + log.info("No documents found for this publication."); + return; + } + + // Resolve content directory + const contentDir = path.isAbsolute(config.contentDir) + ? config.contentDir + : path.join(configDir, config.contentDir); + + // Scan local posts (all .md), then filter to .pub.md + s.start("Scanning local content..."); + const allScanned = await scanContentDirectory(contentDir, { + ignorePatterns: config.ignore, + }); + const localPosts = allScanned.filter((p) => + p.filePath.endsWith(".pub.md"), + ); + s.stop(`Found ${localPosts.length} publishable notes (.pub.md)`); + + // Build a map of path -> local post for matching + // Use "/posts" prefix (same default as publish command) + const pathPrefix = "/posts"; + const postsByPath = new Map(); + for (const post of localPosts) { + postsByPath.set(`${pathPrefix}/${post.slug}`, post); + } + + // Load existing state + const state = await loadState(configDir); + const originalPostCount = Object.keys(state.posts).length; + + // Track changes + let matchedCount = 0; + let unmatchedCount = 0; + const frontmatterUpdates: Array<{ filePath: string; atUri: string }> = []; + + log.message("\nMatching documents to local files:\n"); + + for (const doc of documents) { + const docPath = doc.value.path; + const localPost = postsByPath.get(docPath); + + if (localPost) { + matchedCount++; + log.message(` ✓ ${doc.value.title}`); + log.message(` Path: ${docPath}`); + log.message(` URI: ${doc.uri}`); + log.message(` File: ${path.basename(localPost.filePath)}`); + + const contentMatchesPDS = await matchesPDS(localPost, doc, agent); + const contentHash = contentMatchesPDS + ? await getContentHash(localPost.rawContent) + : ""; + const relativeFilePath = path.relative(configDir, localPost.filePath); + state.posts[relativeFilePath] = { + contentHash, + atUri: doc.uri, + lastPublished: doc.value.publishedAt, + }; + + // Check if frontmatter needs updating + if (updateFrontmatter && localPost.frontmatter.atUri !== doc.uri) { + frontmatterUpdates.push({ + filePath: localPost.filePath, + atUri: doc.uri, + }); + log.message(` → Will update frontmatter`); + } + } else { + unmatchedCount++; + log.message(` ✗ ${doc.value.title} (no matching local file)`); + log.message(` Path: ${docPath}`); + log.message(` URI: ${doc.uri}`); + } + log.message(""); + } + + // Summary + log.message("---"); + log.info(`Matched: ${matchedCount} documents`); + if (unmatchedCount > 0) { + log.warn( + `Unmatched: ${unmatchedCount} documents (exist on PDS but not locally)`, + ); + log.info( + `Run 'remanso publish' to delete unmatched records from your PDS.`, + ); + } + + if (dryRun) { + log.info("\nDry run complete. No changes made."); + return; + } + + // Save updated state + await saveState(configDir, state); + const newPostCount = Object.keys(state.posts).length; + log.success( + `\nSaved .remanso-state.json (${originalPostCount} → ${newPostCount} entries)`, + ); + + // Update frontmatter if requested + if (frontmatterUpdates.length > 0) { + s.start(`Updating frontmatter in ${frontmatterUpdates.length} files...`); + for (const { filePath, atUri } of frontmatterUpdates) { + const content = await fs.readFile(filePath, "utf-8"); + const updated = updateFrontmatterWithAtUri(content, atUri); + await fs.writeFile(filePath, updated); + log.message(` Updated: ${path.basename(filePath)}`); + } + s.stop("Frontmatter updated"); + } + + log.success("\nSync complete!"); + }, +}); diff --git a/packages/remanso/src/index.ts b/packages/remanso/src/index.ts new file mode 100644 index 0000000..46d4df6 --- /dev/null +++ b/packages/remanso/src/index.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import { run, subcommands } from "cmd-ts"; +import { authCommand } from "./commands/auth"; +import { githubCommand } from "./commands/github"; +import { initCommand } from "./commands/init"; +import { publishCommand } from "./commands/publish"; +import { syncCommand } from "./commands/sync"; + +const app = subcommands({ + name: "remanso", + description: "Publish private notes to Remanso (remanso.space)", + version: "0.1.0", + cmds: { + auth: authCommand, + init: initCommand, + publish: publishCommand, + sync: syncCommand, + github: githubCommand, + }, +}); + +run(app, process.argv.slice(2)); diff --git a/packages/remanso/src/lib/config.ts b/packages/remanso/src/lib/config.ts new file mode 100644 index 0000000..36e016c --- /dev/null +++ b/packages/remanso/src/lib/config.ts @@ -0,0 +1,99 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import type { PublisherState } from "../../../cli/src/lib/types"; + +export interface RemansoConfig { + contentDir: string; + publicationUri: string; + pdsUrl?: string; + identity?: string; + ignore?: string[]; + imagesDir?: string; +} + +const CONFIG_FILENAME = "remanso.json"; +const STATE_FILENAME = ".remanso-state.json"; + +export const DEFAULT_IGNORE = ["**/node_modules/**", ".*/**", "_*/**"]; + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function findConfig( + startDir: string = process.cwd(), +): Promise { + let currentDir = startDir; + + while (true) { + const configPath = path.join(currentDir, CONFIG_FILENAME); + + if (await fileExists(configPath)) { + return configPath; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + return null; + } + currentDir = parentDir; + } +} + +export async function loadConfig( + configPath?: string, +): Promise<{ config: RemansoConfig; configPath: string }> { + const resolvedPath = configPath || (await findConfig()); + + if (!resolvedPath) { + throw new Error( + `Could not find ${CONFIG_FILENAME}. Run 'remanso init' to create one.`, + ); + } + + try { + const content = await fs.readFile(resolvedPath, "utf-8"); + const config = JSON.parse(content) as RemansoConfig; + + if (!config.contentDir) throw new Error("contentDir is required in config"); + if (!config.publicationUri) + throw new Error("publicationUri is required in config"); + + return { config, configPath: resolvedPath }; + } catch (error) { + if (error instanceof Error && error.message.includes("required")) { + throw error; + } + throw new Error(`Failed to load config from ${resolvedPath}: ${error}`); + } +} + +export async function loadState(configDir: string): Promise { + const statePath = path.join(configDir, STATE_FILENAME); + + if (!(await fileExists(statePath))) { + return { posts: {} }; + } + + try { + const content = await fs.readFile(statePath, "utf-8"); + return JSON.parse(content) as PublisherState; + } catch { + return { posts: {} }; + } +} + +export async function saveState( + configDir: string, + state: PublisherState, +): Promise { + const statePath = path.join(configDir, STATE_FILENAME); + await fs.writeFile(statePath, JSON.stringify(state, null, 2)); +} + +export const STATE_FILENAME_EXPORT = STATE_FILENAME; diff --git a/packages/remanso/tsconfig.json b/packages/remanso/tsconfig.json new file mode 100644 index 0000000..b42a78c --- /dev/null +++ b/packages/remanso/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "lib": ["ES2022"], + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"] +}