easymd-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "easymd-cli",
3
+ "version": "0.1.0",
4
+ "description": "Google Docs for markdown — collaborate on the actual .md file in your repo, live with humans and AI agents. CLI: login, auto-sync, and open .md files for real-time editing.",
5
+ "type": "module",
6
+ "bin": {
7
+ "easymd": "./bin/easymd.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "client",
13
+ "dist",
14
+ "README.md"
15
+ ],
16
+ "scripts": {
17
+ "build": "node scripts/build.js",
18
+ "postinstall": "node scripts/build.js > /dev/null 2>&1 || true",
19
+ "prepublishOnly": "npm run build",
20
+ "start": "node bin/easymd.js",
21
+ "web:dev": "npm run dev --prefix web",
22
+ "web:build": "npm run build --prefix web"
23
+ },
24
+ "keywords": [
25
+ "markdown",
26
+ "collaboration",
27
+ "crdt",
28
+ "agents",
29
+ "claude",
30
+ "cursor"
31
+ ],
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "chokidar": "^4.0.3",
35
+ "express": "^4.21.2",
36
+ "open": "^10.1.0",
37
+ "ws": "^8.18.0",
38
+ "y-websocket": "^2.1.0",
39
+ "yjs": "^13.6.24"
40
+ },
41
+ "devDependencies": {
42
+ "@codemirror/commands": "^6.8.0",
43
+ "@codemirror/lang-markdown": "^6.3.2",
44
+ "@codemirror/state": "^6.5.2",
45
+ "@codemirror/view": "^6.36.4",
46
+ "esbuild": "^0.25.0",
47
+ "marked": "^15.0.7",
48
+ "y-codemirror.next": "^0.3.5",
49
+ "y-protocols": "^1.0.6",
50
+ "y-websocket": "^2.1.0"
51
+ },
52
+ "engines": {
53
+ "node": ">=18"
54
+ }
55
+ }
@@ -0,0 +1,91 @@
1
+ import { createServer } from 'http';
2
+ import openBrowser from 'open';
3
+ import {
4
+ DEFAULT_URL,
5
+ getCredentials,
6
+ saveCredentials,
7
+ clearCredentials,
8
+ clearAuto,
9
+ userIdFromToken,
10
+ } from './config.js';
11
+ import { autoOff } from './auto.js';
12
+
13
+ // Spin up a one-shot localhost server that the browser hands the token back to.
14
+ function startCallbackServer() {
15
+ return new Promise((resolve) => {
16
+ let resolveToken;
17
+ const tokenPromise = new Promise((r) => (resolveToken = r));
18
+ const server = createServer((req, res) => {
19
+ const u = new URL(req.url, 'http://127.0.0.1');
20
+ if (u.pathname === '/callback') {
21
+ const token = u.searchParams.get('token') || '';
22
+ res.writeHead(200, { 'Content-Type': 'text/html' });
23
+ res.end(
24
+ `<!doctype html><html><body style="font-family:system-ui;text-align:center;padding:4rem;background:#0b0e13;color:#e6e8eb">
25
+ <div style="display:inline-flex;width:48px;height:48px;align-items:center;justify-content:center;border-radius:12px;background:#c6f24e;color:#12160a;font-weight:700;font-size:20px">e</div>
26
+ <h2 style="margin-top:1rem">✓ easymd CLI authorized</h2>
27
+ <p style="color:#9aa3af">You can close this tab and return to your terminal.</p>
28
+ </body></html>`,
29
+ );
30
+ resolveToken(token);
31
+ } else {
32
+ res.writeHead(404);
33
+ res.end();
34
+ }
35
+ });
36
+ server.listen(0, '127.0.0.1', () => {
37
+ resolve({ port: server.address().port, tokenPromise, close: () => server.close() });
38
+ });
39
+ });
40
+ }
41
+
42
+ export async function login() {
43
+ const base = DEFAULT_URL;
44
+ const { port, tokenPromise, close } = await startCallbackServer();
45
+ const url = `${base}/cli-auth?port=${port}`;
46
+
47
+ console.log('\neasymd login');
48
+ console.log('─────────────────────────────────────');
49
+ console.log('Opening your browser to sign in with Clerk and authorize this machine:');
50
+ console.log(` ${url}\n`);
51
+
52
+ try {
53
+ await openBrowser(url);
54
+ } catch {
55
+ console.log('(Could not open a browser automatically — open the URL above manually.)');
56
+ }
57
+ console.log('Waiting for authorization… (Ctrl+C to cancel)');
58
+
59
+ const token = await Promise.race([
60
+ tokenPromise,
61
+ new Promise((_, rej) => setTimeout(() => rej(new Error('Timed out waiting for authorization.')), 300000)),
62
+ ]).finally(close);
63
+
64
+ if (!token) throw new Error('No token received from the browser.');
65
+
66
+ const { uid } = userIdFromToken(token);
67
+ await saveCredentials({ token, userId: uid, url: base, savedAt: new Date().toISOString() });
68
+ console.log(`\n✓ Logged in${uid ? ` as ${uid}` : ''}.`);
69
+ console.log(' Credentials saved to ~/.easymd/credentials.json');
70
+ console.log(' Next: `easymd auto on` to start syncing .md files to your account.\n');
71
+ }
72
+
73
+ export async function logout() {
74
+ await autoOff().catch(() => {});
75
+ await clearAuto().catch(() => {});
76
+ await clearCredentials();
77
+ console.log('✓ Logged out. Credentials removed.');
78
+ }
79
+
80
+ export async function whoami() {
81
+ const creds = await getCredentials();
82
+ if (!creds?.token) {
83
+ console.log('Not logged in. Run `easymd login`.');
84
+ return;
85
+ }
86
+ const { uid, exp } = userIdFromToken(creds.token);
87
+ const expired = exp && exp < Date.now();
88
+ console.log(`Account: ${uid || creds.userId || 'unknown'}`);
89
+ console.log(`Server: ${creds.url}`);
90
+ console.log(`Status: ${expired ? 'EXPIRED — run `easymd login` again' : 'active'}`);
91
+ }
@@ -0,0 +1,65 @@
1
+ import { spawn } from 'child_process';
2
+ import { fileURLToPath } from 'url';
3
+ import { getAuto, saveAuto, clearAuto, requireCredentials } from './config.js';
4
+
5
+ const BIN = fileURLToPath(new URL('../../bin/easymd.js', import.meta.url));
6
+
7
+ const isAlive = (pid) => {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+
16
+ // Toggle the background watcher ON: spawn a detached `easymd watch` process that
17
+ // keeps running after this command (and the terminal) exits.
18
+ export async function autoOn(root) {
19
+ await requireCredentials(); // fail fast if not logged in
20
+
21
+ const existing = await getAuto();
22
+ if (existing && isAlive(existing.pid)) {
23
+ console.log(`Auto-sync is already ON (pid ${existing.pid}, watching ${existing.root}).`);
24
+ return;
25
+ }
26
+
27
+ const child = spawn(process.execPath, [BIN, 'watch', root, '--quiet'], {
28
+ detached: true,
29
+ stdio: 'ignore',
30
+ env: process.env,
31
+ });
32
+ child.unref();
33
+ await saveAuto({ pid: child.pid, root, startedAt: new Date().toISOString() });
34
+
35
+ console.log(`✓ Auto-sync ON — watching ${root} (pid ${child.pid}).`);
36
+ console.log(' Every new or changed .md file now syncs to your easymd account automatically.');
37
+ console.log(' Turn it off with `easymd auto off`.');
38
+ }
39
+
40
+ export async function autoOff() {
41
+ const a = await getAuto();
42
+ if (!a) {
43
+ console.log('Auto-sync is already OFF.');
44
+ return;
45
+ }
46
+ if (isAlive(a.pid)) {
47
+ try {
48
+ process.kill(a.pid);
49
+ } catch {
50
+ /* already gone */
51
+ }
52
+ }
53
+ await clearAuto();
54
+ console.log('✓ Auto-sync OFF.');
55
+ }
56
+
57
+ export async function autoStatus() {
58
+ const a = await getAuto();
59
+ if (a && isAlive(a.pid)) {
60
+ console.log(`Auto-sync: ON — pid ${a.pid}, watching ${a.root} (since ${a.startedAt}).`);
61
+ } else {
62
+ if (a) await clearAuto(); // stale pid file
63
+ console.log('Auto-sync: OFF.');
64
+ }
65
+ }
@@ -0,0 +1,55 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { mkdir, readFile, writeFile, rm } from 'fs/promises';
4
+
5
+ export const CONFIG_DIR = join(homedir(), '.easymd');
6
+ const CRED_FILE = join(CONFIG_DIR, 'credentials.json');
7
+ const AUTO_FILE = join(CONFIG_DIR, 'auto.json');
8
+
9
+ // Where the easymd web app lives. Override with EASYMD_URL for self-hosted / production.
10
+ export const DEFAULT_URL = process.env.EASYMD_URL || 'http://localhost:3000';
11
+
12
+ async function readJson(p) {
13
+ try {
14
+ return JSON.parse(await readFile(p, 'utf8'));
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+ async function writeJson(p, obj) {
20
+ await mkdir(CONFIG_DIR, { recursive: true });
21
+ await writeFile(p, JSON.stringify(obj, null, 2), { mode: 0o600 });
22
+ }
23
+
24
+ export const getCredentials = () => readJson(CRED_FILE);
25
+ export const saveCredentials = (c) => writeJson(CRED_FILE, c);
26
+ export const clearCredentials = () => rm(CRED_FILE, { force: true });
27
+
28
+ export const getAuto = () => readJson(AUTO_FILE);
29
+ export const saveAuto = (a) => writeJson(AUTO_FILE, a);
30
+ export const clearAuto = () => rm(AUTO_FILE, { force: true });
31
+
32
+ // Decode the Clerk user id baked into a CLI token (easymd_<b64url payload>.<sig>).
33
+ export function userIdFromToken(token) {
34
+ try {
35
+ const body = token.slice('easymd_'.length);
36
+ const payload = body.slice(0, body.indexOf('.'));
37
+ const json = JSON.parse(Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
38
+ return { uid: json.uid || null, exp: json.exp || 0 };
39
+ } catch {
40
+ return { uid: null, exp: 0 };
41
+ }
42
+ }
43
+
44
+ // Returns credentials if present and unexpired, else throws a helpful error.
45
+ export async function requireCredentials() {
46
+ const creds = await getCredentials();
47
+ if (!creds?.token) {
48
+ throw new Error('Not logged in. Run `easymd login` first.');
49
+ }
50
+ const { exp } = userIdFromToken(creds.token);
51
+ if (exp && exp < Date.now()) {
52
+ throw new Error('Your session has expired. Run `easymd login` again.');
53
+ }
54
+ return creds;
55
+ }
@@ -0,0 +1,109 @@
1
+ import { readFile, readdir } from 'fs/promises';
2
+ import { basename, join, relative } from 'path';
3
+ import chokidar from 'chokidar';
4
+ import { requireCredentials } from './config.js';
5
+
6
+ const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', 'build', 'coverage']);
7
+
8
+ // Document name sent to the server: repo-relative path without the .md extension.
9
+ // The server slugs it (e.g. "docs/spec" → "docs-spec") and namespaces it to the account.
10
+ function docNameFor(filePath, root) {
11
+ const rel = relative(root, filePath).replace(/\\/g, '/').replace(/\.md$/i, '');
12
+ return rel || basename(filePath).replace(/\.md$/i, '');
13
+ }
14
+
15
+ export async function uploadFile(filePath, { root, creds }) {
16
+ const content = await readFile(filePath, 'utf8');
17
+ const name = docNameFor(filePath, root);
18
+ const res = await fetch(`${creds.url}/api/cli/documents`, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${creds.token}` },
21
+ body: JSON.stringify({ name, content, title: basename(name) }),
22
+ });
23
+ if (!res.ok) {
24
+ const j = await res.json().catch(() => ({}));
25
+ throw new Error(j.error || `HTTP ${res.status}`);
26
+ }
27
+ return { name, ...(await res.json().catch(() => ({}))) };
28
+ }
29
+
30
+ // Recursively collect .md files under root, skipping ignored dirs and dotfiles.
31
+ async function collectMarkdown(dir, root, out = []) {
32
+ let entries;
33
+ try {
34
+ entries = await readdir(dir, { withFileTypes: true });
35
+ } catch {
36
+ return out;
37
+ }
38
+ for (const e of entries) {
39
+ if (e.name.startsWith('.')) continue;
40
+ const full = join(dir, e.name);
41
+ if (e.isDirectory()) {
42
+ if (!IGNORE_DIRS.has(e.name)) await collectMarkdown(full, root, out);
43
+ } else if (e.isFile() && /\.md$/i.test(e.name)) {
44
+ out.push(full);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ // One-shot: push every .md under `root` to the account.
51
+ export async function syncDir(root, { quiet = false } = {}) {
52
+ const creds = await requireCredentials();
53
+ const files = await collectMarkdown(root, root);
54
+ if (!files.length) {
55
+ if (!quiet) console.log('No .md files found.');
56
+ return { ok: 0, fail: 0 };
57
+ }
58
+ let ok = 0;
59
+ let fail = 0;
60
+ for (const f of files) {
61
+ try {
62
+ const r = await uploadFile(f, { root, creds });
63
+ ok++;
64
+ if (!quiet) console.log(` ✓ ${relative(root, f)} → ${r.name}`);
65
+ } catch (e) {
66
+ fail++;
67
+ if (!quiet) console.log(` ✗ ${relative(root, f)} — ${e.message}`);
68
+ }
69
+ }
70
+ if (!quiet) console.log(`\nSynced ${ok} file(s)${fail ? `, ${fail} failed` : ''}.`);
71
+ return { ok, fail };
72
+ }
73
+
74
+ // Long-running: watch `root` and push .md files as they're added/changed.
75
+ export async function watchDir(root, { quiet = false } = {}) {
76
+ const creds = await requireCredentials();
77
+ const log = (...a) => !quiet && console.log(...a);
78
+
79
+ const pending = new Map(); // path -> timer (debounce)
80
+ const push = (filePath) => {
81
+ clearTimeout(pending.get(filePath));
82
+ pending.set(
83
+ filePath,
84
+ setTimeout(async () => {
85
+ pending.delete(filePath);
86
+ try {
87
+ const r = await uploadFile(filePath, { root, creds });
88
+ log(` ✓ synced ${relative(root, filePath)} → ${r.name}`);
89
+ } catch (e) {
90
+ log(` ✗ ${relative(root, filePath)} — ${e.message}`);
91
+ }
92
+ }, 400),
93
+ );
94
+ };
95
+
96
+ const watcher = chokidar.watch('**/*.md', {
97
+ cwd: root,
98
+ ignored: (p) => p.split(/[\\/]/).some((seg) => IGNORE_DIRS.has(seg) || (seg.startsWith('.') && seg.length > 1)),
99
+ ignoreInitial: false, // also sync existing files on startup ("auto add all .md")
100
+ awaitWriteFinish: { stabilityThreshold: 300, pollInterval: 100 },
101
+ });
102
+
103
+ watcher
104
+ .on('add', (p) => push(join(root, p)))
105
+ .on('change', (p) => push(join(root, p)))
106
+ .on('ready', () => log(`Watching ${root} for .md changes. Press Ctrl+C to stop.`));
107
+
108
+ return watcher;
109
+ }
@@ -0,0 +1,85 @@
1
+ import chokidar from 'chokidar';
2
+ import { readFile, writeFile, mkdir } from 'fs/promises';
3
+ import { dirname } from 'path';
4
+
5
+ const DEBOUNCE_MS = 250;
6
+
7
+ /**
8
+ * Keeps a Y.Text in sync with a file on disk — disk is canonical.
9
+ * External edits (git, agents, other tools) flow in; editor edits flow out.
10
+ */
11
+ export function createFileSync(filePath, ytext) {
12
+ let writing = false;
13
+ let debounceTimer = null;
14
+ let destroyed = false;
15
+
16
+ async function readDisk() {
17
+ try {
18
+ return await readFile(filePath, 'utf-8');
19
+ } catch (err) {
20
+ if (err.code === 'ENOENT') return '';
21
+ throw err;
22
+ }
23
+ }
24
+
25
+ async function loadFromDisk() {
26
+ const content = await readDisk();
27
+ if (ytext.toString() !== content) {
28
+ ytext.doc.transact(() => {
29
+ ytext.delete(0, ytext.length);
30
+ if (content) ytext.insert(0, content);
31
+ });
32
+ }
33
+ }
34
+
35
+ async function writeToDisk() {
36
+ if (destroyed || writing) return;
37
+ writing = true;
38
+ try {
39
+ const content = ytext.toString();
40
+ await mkdir(dirname(filePath), { recursive: true });
41
+ await writeFile(filePath, content, 'utf-8');
42
+ } finally {
43
+ writing = false;
44
+ }
45
+ }
46
+
47
+ function scheduleWrite() {
48
+ clearTimeout(debounceTimer);
49
+ debounceTimer = setTimeout(() => {
50
+ writeToDisk().catch((err) => console.error('easymd: write failed', err));
51
+ }, DEBOUNCE_MS);
52
+ }
53
+
54
+ const unobserve = ytext.observe(() => {
55
+ scheduleWrite();
56
+ });
57
+
58
+ const watcher = chokidar.watch(filePath, {
59
+ ignoreInitial: true,
60
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
61
+ });
62
+
63
+ watcher.on('change', async () => {
64
+ if (writing || destroyed) return;
65
+ const disk = await readDisk();
66
+ if (disk !== ytext.toString()) {
67
+ ytext.doc.transact(() => {
68
+ ytext.delete(0, ytext.length);
69
+ if (disk) ytext.insert(0, disk);
70
+ });
71
+ }
72
+ });
73
+
74
+ watcher.on('add', loadFromDisk);
75
+
76
+ return {
77
+ loadFromDisk,
78
+ destroy() {
79
+ destroyed = true;
80
+ clearTimeout(debounceTimer);
81
+ unobserve();
82
+ watcher.close();
83
+ },
84
+ };
85
+ }
package/src/server.js ADDED
@@ -0,0 +1,65 @@
1
+ import express from 'express';
2
+ import http from 'http';
3
+ import { WebSocketServer } from 'ws';
4
+ import { dirname, basename, resolve } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { getYDoc, setupWSConnection } from 'y-websocket/bin/utils';
7
+ import { createFileSync } from './file-sync.js';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const ROOT = resolve(__dirname, '..');
11
+
12
+ /**
13
+ * Start a local server that binds one Yjs doc to one markdown file on disk.
14
+ */
15
+ export async function startServer(filePath, options = {}) {
16
+ const absPath = resolve(filePath);
17
+ const docName = absPath;
18
+ const ydoc = getYDoc(docName, false);
19
+ const ytext = ydoc.getText('markdown');
20
+
21
+ const fileSync = createFileSync(absPath, ytext);
22
+ await fileSync.loadFromDisk();
23
+
24
+ const app = express();
25
+
26
+ app.get('/api/session', (_req, res) => {
27
+ res.json({
28
+ filePath: absPath,
29
+ fileName: basename(absPath),
30
+ docName,
31
+ });
32
+ });
33
+
34
+ app.use(express.static(resolve(ROOT, 'dist')));
35
+ app.use(express.static(resolve(ROOT, 'client')));
36
+
37
+ app.get('*', (_req, res) => {
38
+ res.sendFile(resolve(ROOT, 'client/index.html'));
39
+ });
40
+
41
+ const server = http.createServer(app);
42
+ const wss = new WebSocketServer({ noServer: true });
43
+
44
+ server.on('upgrade', (request, socket, head) => {
45
+ wss.handleUpgrade(request, socket, head, (ws) => {
46
+ setupWSConnection(ws, request, { docName, gc: false });
47
+ });
48
+ });
49
+
50
+ const port = await new Promise((resolvePort, reject) => {
51
+ server.listen(options.port ?? 0, options.host ?? '127.0.0.1', () => {
52
+ const addr = server.address();
53
+ resolvePort(typeof addr === 'object' ? addr.port : options.port);
54
+ });
55
+ server.on('error', reject);
56
+ });
57
+
58
+ const shutdown = () => {
59
+ fileSync.destroy();
60
+ wss.close();
61
+ server.close();
62
+ };
63
+
64
+ return { port, url: `http://127.0.0.1:${port}`, filePath: absPath, shutdown };
65
+ }