aem-ext-daemon 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/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # aem-ext-daemon
2
+
3
+ Local daemon for **AEM Extension Builder**. Connects your machine to the cloud UI so the builder can scaffold extensions, run CLI commands, and manage files on your local filesystem.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npx aem-ext-daemon
9
+ ```
10
+
11
+ That's it. On first run the daemon will display a QR code and pairing URL. Open it in your browser to connect.
12
+
13
+ ## How It Works
14
+
15
+ 1. The daemon runs on your machine and connects outbound to the cloud app via WebSocket
16
+ 2. On first run, it generates a unique identity and a pairing code
17
+ 3. You scan the QR code (or open the URL) in your browser to link the daemon to your session
18
+ 4. Once paired, the cloud UI can send commands (file operations, git, AIO CLI) that execute locally
19
+ 5. On subsequent starts, the daemon reconnects automatically — no re-pairing needed
20
+
21
+ ## Options
22
+
23
+ ```
24
+ npx aem-ext-daemon [options]
25
+
26
+ --server <url> Cloud app URL (uses default if omitted)
27
+ --workspace <path> Set the workspace root directory
28
+ --reset Clear identity and force re-pairing
29
+ --help, -h Show help
30
+ --version, -v Show version
31
+ ```
32
+
33
+ ## Prerequisites
34
+
35
+ - **Node.js 18+**
36
+ - **AIO CLI** (optional, for extension scaffolding): `npm install -g @adobe/aio-cli`
37
+
38
+ ## Config Location
39
+
40
+ - **macOS/Linux**: `~/.config/aem-ext-daemon/config.json`
41
+ - **Windows**: `%APPDATA%\aem-ext-daemon\config.json`
42
+
43
+ ## Security
44
+
45
+ - The daemon only executes commands within the configured workspace directory
46
+ - All paths are validated to prevent traversal outside the workspace
47
+ - Communication uses WSS (TLS-encrypted WebSocket)
48
+ - Identity is verified with a 256-bit secret hashed server-side
package/bin/cli.js ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI entry point for aem-ext-daemon.
5
+ *
6
+ * Usage:
7
+ * npx aem-ext-daemon # connect to default server
8
+ * npx aem-ext-daemon --server https://my.app.com # custom server
9
+ * npx aem-ext-daemon --workspace /path/to/projects # set workspace
10
+ * npx aem-ext-daemon --reset # clear identity and re-pair
11
+ */
12
+
13
+ import { Daemon } from "../dist/daemon.js";
14
+ import { resetIdentity, getConfigPath, getRailwayUrl } from "../dist/identity.js";
15
+
16
+ const DEFAULT_SERVER = "https://adobe-extension-builder-production.up.railway.app";
17
+
18
+ function parseArgs(argv) {
19
+ const args = {};
20
+ for (let i = 2; i < argv.length; i++) {
21
+ const arg = argv[i];
22
+ if (arg === "--server" && argv[i + 1]) {
23
+ args.server = argv[++i];
24
+ } else if (arg === "--workspace" && argv[i + 1]) {
25
+ args.workspace = argv[++i];
26
+ } else if (arg === "--reset") {
27
+ args.reset = true;
28
+ } else if (arg === "--help" || arg === "-h") {
29
+ args.help = true;
30
+ } else if (arg === "--version" || arg === "-v") {
31
+ args.version = true;
32
+ }
33
+ }
34
+ return args;
35
+ }
36
+
37
+ function printHelp() {
38
+ console.log(`
39
+ aem-ext-daemon — Local daemon for AEM Extension Builder
40
+
41
+ Usage:
42
+ npx aem-ext-daemon [options]
43
+
44
+ Options:
45
+ --server <url> Cloud app URL (default: Railway production)
46
+ --workspace <path> Set the workspace root directory
47
+ --reset Clear identity and force re-pairing
48
+ --help, -h Show this help message
49
+ --version, -v Show version
50
+
51
+ Config location:
52
+ ${getConfigPath()}
53
+ `);
54
+ }
55
+
56
+ const args = parseArgs(process.argv);
57
+
58
+ if (args.help) {
59
+ printHelp();
60
+ process.exit(0);
61
+ }
62
+
63
+ if (args.version) {
64
+ console.log("0.1.0");
65
+ process.exit(0);
66
+ }
67
+
68
+ if (args.reset) {
69
+ resetIdentity();
70
+ console.log(" ✓ Identity reset. Will re-pair on next connection.");
71
+ console.log("");
72
+ }
73
+
74
+ // Resolve server URL: CLI flag > saved config > default
75
+ const serverUrl = args.server || getRailwayUrl() || DEFAULT_SERVER;
76
+
77
+ const daemon = new Daemon({
78
+ serverUrl,
79
+ workspace: args.workspace,
80
+ });
81
+
82
+ // Graceful shutdown
83
+ process.on("SIGINT", () => {
84
+ daemon.stop();
85
+ process.exit(0);
86
+ });
87
+
88
+ process.on("SIGTERM", () => {
89
+ daemon.stop();
90
+ process.exit(0);
91
+ });
92
+
93
+ daemon.start();
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Filesystem capabilities — browse, read, write, list.
3
+ */
4
+ export interface BrowseEntry {
5
+ name: string;
6
+ path: string;
7
+ isDirectory: boolean;
8
+ isHidden: boolean;
9
+ }
10
+ /** Browse a directory — returns immediate children with metadata. */
11
+ export declare function browse(dirPath: string, showHidden?: boolean): string;
12
+ /** Read a file and return its content. */
13
+ export declare function readFile(filePath: string): string;
14
+ /** Write content to a file. Creates parent directories if needed. */
15
+ export declare function writeFile(filePath: string, content: string): string;
16
+ /** Recursive directory listing up to a max depth. */
17
+ export declare function listRecursive(dirPath: string, maxDepth?: number): string;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Filesystem capabilities — browse, read, write, list.
3
+ */
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ /** Browse a directory — returns immediate children with metadata. */
7
+ export function browse(dirPath, showHidden = false) {
8
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
9
+ const result = entries
10
+ .filter((e) => showHidden || !e.name.startsWith("."))
11
+ .map((e) => ({
12
+ name: e.name,
13
+ path: path.join(dirPath, e.name),
14
+ isDirectory: e.isDirectory(),
15
+ isHidden: e.name.startsWith("."),
16
+ }))
17
+ .sort((a, b) => {
18
+ // Directories first, then alphabetical
19
+ if (a.isDirectory && !b.isDirectory)
20
+ return -1;
21
+ if (!a.isDirectory && b.isDirectory)
22
+ return 1;
23
+ return a.name.localeCompare(b.name);
24
+ });
25
+ return JSON.stringify(result);
26
+ }
27
+ /** Read a file and return its content. */
28
+ export function readFile(filePath) {
29
+ if (!fs.existsSync(filePath)) {
30
+ throw new Error(`File not found: ${filePath}`);
31
+ }
32
+ const stat = fs.statSync(filePath);
33
+ if (stat.size > 2 * 1024 * 1024) {
34
+ throw new Error(`File too large (${stat.size} bytes). Max 2MB.`);
35
+ }
36
+ return fs.readFileSync(filePath, "utf-8");
37
+ }
38
+ /** Write content to a file. Creates parent directories if needed. */
39
+ export function writeFile(filePath, content) {
40
+ const dir = path.dirname(filePath);
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ fs.writeFileSync(filePath, content, "utf-8");
43
+ return `Written ${content.length} bytes to ${filePath}`;
44
+ }
45
+ /** Recursive directory listing up to a max depth. */
46
+ export function listRecursive(dirPath, maxDepth = 3) {
47
+ const result = [];
48
+ function walk(dir, prefix, depth) {
49
+ if (depth > maxDepth)
50
+ return;
51
+ let entries;
52
+ try {
53
+ entries = fs.readdirSync(dir, { withFileTypes: true });
54
+ }
55
+ catch {
56
+ return;
57
+ }
58
+ // Filter out common noise
59
+ const filtered = entries.filter((e) => !e.name.startsWith(".") &&
60
+ e.name !== "node_modules" &&
61
+ e.name !== "__pycache__" &&
62
+ e.name !== "dist" &&
63
+ e.name !== ".git");
64
+ for (let i = 0; i < filtered.length; i++) {
65
+ const entry = filtered[i];
66
+ const isLast = i === filtered.length - 1;
67
+ const connector = isLast ? "└── " : "├── ";
68
+ const childPrefix = isLast ? " " : "│ ";
69
+ result.push(`${prefix}${connector}${entry.name}${entry.isDirectory() ? "/" : ""}`);
70
+ if (entry.isDirectory()) {
71
+ walk(path.join(dir, entry.name), prefix + childPrefix, depth + 1);
72
+ }
73
+ }
74
+ }
75
+ result.push(path.basename(dirPath) + "/");
76
+ walk(dirPath, "", 0);
77
+ return result.join("\n");
78
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Git capabilities — clone, status.
3
+ * Uses child_process to avoid heavy dependencies.
4
+ */
5
+ /** Clone a repository into a destination directory. */
6
+ export declare function clone(url: string, dest: string, branch?: string): string;
7
+ /** Get git status of a directory. */
8
+ export declare function status(dir: string): string;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Git capabilities — clone, status.
3
+ * Uses child_process to avoid heavy dependencies.
4
+ */
5
+ import { execSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ /** Clone a repository into a destination directory. */
8
+ export function clone(url, dest, branch) {
9
+ if (!url)
10
+ throw new Error("url is required");
11
+ if (!dest)
12
+ throw new Error("dest is required");
13
+ const args = ["git", "clone"];
14
+ if (branch)
15
+ args.push("--branch", branch);
16
+ args.push("--depth", "1", url, dest);
17
+ execSync(args.join(" "), {
18
+ timeout: 120_000,
19
+ maxBuffer: 10 * 1024 * 1024,
20
+ stdio: "pipe",
21
+ });
22
+ return `Cloned ${url} into ${dest}`;
23
+ }
24
+ /** Get git status of a directory. */
25
+ export function status(dir) {
26
+ if (!fs.existsSync(dir)) {
27
+ throw new Error(`Directory not found: ${dir}`);
28
+ }
29
+ try {
30
+ const result = execSync("git status --porcelain -b", {
31
+ cwd: dir,
32
+ timeout: 10_000,
33
+ maxBuffer: 1024 * 1024,
34
+ encoding: "utf-8",
35
+ });
36
+ return result || "Clean working tree";
37
+ }
38
+ catch {
39
+ throw new Error(`Not a git repository: ${dir}`);
40
+ }
41
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shell capabilities — execute commands, run AIO CLI.
3
+ *
4
+ * aio:run streams stdout/stderr back through the WebSocket.
5
+ * shell:exec runs a command and returns the full output.
6
+ */
7
+ import type { DaemonConnection } from "../connection.js";
8
+ /** Run a shell command synchronously and return stdout. */
9
+ export declare function exec(command: string, cwd: string): string;
10
+ /** Check if AIO CLI is installed and return its version. */
11
+ export declare function checkAio(): string;
12
+ /**
13
+ * Run an AIO CLI command with streaming output.
14
+ * Streams stdout/stderr chunks back through the WebSocket,
15
+ * then returns the exit code.
16
+ */
17
+ export declare function runAio(args: string[], cwd: string, requestId: string, connection: DaemonConnection): Promise<string>;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shell capabilities — execute commands, run AIO CLI.
3
+ *
4
+ * aio:run streams stdout/stderr back through the WebSocket.
5
+ * shell:exec runs a command and returns the full output.
6
+ */
7
+ import { execSync, spawn } from "node:child_process";
8
+ const EXEC_TIMEOUT = 120_000; // 2 minutes
9
+ const MAX_BUFFER = 5 * 1024 * 1024; // 5MB
10
+ /** Run a shell command synchronously and return stdout. */
11
+ export function exec(command, cwd) {
12
+ if (!command)
13
+ throw new Error("command is required");
14
+ const result = execSync(command, {
15
+ cwd,
16
+ timeout: EXEC_TIMEOUT,
17
+ maxBuffer: MAX_BUFFER,
18
+ encoding: "utf-8",
19
+ shell: "/bin/sh",
20
+ });
21
+ return result;
22
+ }
23
+ /** Check if AIO CLI is installed and return its version. */
24
+ export function checkAio() {
25
+ try {
26
+ const version = execSync("aio --version", {
27
+ timeout: 10_000,
28
+ encoding: "utf-8",
29
+ shell: "/bin/sh",
30
+ }).trim();
31
+ return JSON.stringify({ installed: true, version });
32
+ }
33
+ catch {
34
+ return JSON.stringify({
35
+ installed: false,
36
+ error: "aio CLI not found. Install it: npm install -g @adobe/aio-cli",
37
+ });
38
+ }
39
+ }
40
+ /**
41
+ * Run an AIO CLI command with streaming output.
42
+ * Streams stdout/stderr chunks back through the WebSocket,
43
+ * then returns the exit code.
44
+ */
45
+ export function runAio(args, cwd, requestId, connection) {
46
+ return new Promise((resolve, reject) => {
47
+ const child = spawn("aio", args, {
48
+ cwd,
49
+ shell: true,
50
+ env: { ...process.env },
51
+ });
52
+ let stdout = "";
53
+ let stderr = "";
54
+ child.stdout?.on("data", (chunk) => {
55
+ const data = chunk.toString();
56
+ stdout += data;
57
+ connection.send({
58
+ type: "stream",
59
+ id: requestId,
60
+ channel: "stdout",
61
+ data,
62
+ });
63
+ });
64
+ child.stderr?.on("data", (chunk) => {
65
+ const data = chunk.toString();
66
+ stderr += data;
67
+ connection.send({
68
+ type: "stream",
69
+ id: requestId,
70
+ channel: "stderr",
71
+ data,
72
+ });
73
+ });
74
+ child.on("close", (exitCode) => {
75
+ connection.send({
76
+ type: "stream_done",
77
+ id: requestId,
78
+ exitCode: exitCode ?? 1,
79
+ });
80
+ if (exitCode === 0) {
81
+ resolve(stdout || "Command completed successfully");
82
+ }
83
+ else {
84
+ reject(new Error(stderr || `aio exited with code ${exitCode}`));
85
+ }
86
+ });
87
+ child.on("error", (err) => {
88
+ reject(new Error(`Failed to spawn aio: ${err.message}`));
89
+ });
90
+ // Timeout after 5 minutes
91
+ setTimeout(() => {
92
+ child.kill("SIGTERM");
93
+ reject(new Error("aio command timed out after 5 minutes"));
94
+ }, 300_000);
95
+ });
96
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * WebSocket connection manager with auto-reconnect.
3
+ *
4
+ * Connects outbound to Railway — no tunnels or port-forwarding needed.
5
+ * Uses exponential backoff for reconnection.
6
+ */
7
+ import type { DaemonOutbound, RailwayOutbound } from "./protocol.js";
8
+ export type MessageHandler = (msg: RailwayOutbound) => void;
9
+ export type StatusHandler = (status: "connecting" | "connected" | "disconnected") => void;
10
+ interface ConnectionOptions {
11
+ url: string;
12
+ onMessage: MessageHandler;
13
+ onStatus: StatusHandler;
14
+ }
15
+ export declare class DaemonConnection {
16
+ private ws;
17
+ private url;
18
+ private onMessage;
19
+ private onStatus;
20
+ private backoff;
21
+ private reconnectTimer;
22
+ private pingTimer;
23
+ private intentionalClose;
24
+ constructor(opts: ConnectionOptions);
25
+ /** Initiate the WebSocket connection. */
26
+ connect(): void;
27
+ /** Send a message to Railway. */
28
+ send(msg: DaemonOutbound): void;
29
+ /** Gracefully disconnect. */
30
+ disconnect(): void;
31
+ get isConnected(): boolean;
32
+ private startPing;
33
+ private stopPing;
34
+ private scheduleReconnect;
35
+ private cleanup;
36
+ }
37
+ export {};
@@ -0,0 +1,113 @@
1
+ /**
2
+ * WebSocket connection manager with auto-reconnect.
3
+ *
4
+ * Connects outbound to Railway — no tunnels or port-forwarding needed.
5
+ * Uses exponential backoff for reconnection.
6
+ */
7
+ import WebSocket from "ws";
8
+ import { encode, decode } from "./protocol.js";
9
+ const MIN_BACKOFF = 1000; // 1 second
10
+ const MAX_BACKOFF = 30000; // 30 seconds
11
+ const PING_INTERVAL = 25000; // 25 seconds (keep-alive)
12
+ export class DaemonConnection {
13
+ ws = null;
14
+ url;
15
+ onMessage;
16
+ onStatus;
17
+ backoff = MIN_BACKOFF;
18
+ reconnectTimer = null;
19
+ pingTimer = null;
20
+ intentionalClose = false;
21
+ constructor(opts) {
22
+ this.url = opts.url;
23
+ this.onMessage = opts.onMessage;
24
+ this.onStatus = opts.onStatus;
25
+ }
26
+ /** Initiate the WebSocket connection. */
27
+ connect() {
28
+ this.intentionalClose = false;
29
+ this.onStatus("connecting");
30
+ try {
31
+ this.ws = new WebSocket(this.url);
32
+ }
33
+ catch {
34
+ this.scheduleReconnect();
35
+ return;
36
+ }
37
+ this.ws.on("open", () => {
38
+ this.backoff = MIN_BACKOFF;
39
+ this.onStatus("connected");
40
+ this.startPing();
41
+ });
42
+ this.ws.on("message", (data) => {
43
+ try {
44
+ const msg = decode(data.toString());
45
+ if (msg.type === "ping") {
46
+ this.send({ type: "pong" });
47
+ return;
48
+ }
49
+ this.onMessage(msg);
50
+ }
51
+ catch {
52
+ // ignore malformed messages
53
+ }
54
+ });
55
+ this.ws.on("close", () => {
56
+ this.cleanup();
57
+ this.onStatus("disconnected");
58
+ if (!this.intentionalClose) {
59
+ this.scheduleReconnect();
60
+ }
61
+ });
62
+ this.ws.on("error", () => {
63
+ // error always followed by close, handled there
64
+ });
65
+ }
66
+ /** Send a message to Railway. */
67
+ send(msg) {
68
+ if (this.ws?.readyState === WebSocket.OPEN) {
69
+ this.ws.send(encode(msg));
70
+ }
71
+ }
72
+ /** Gracefully disconnect. */
73
+ disconnect() {
74
+ this.intentionalClose = true;
75
+ this.cleanup();
76
+ if (this.ws) {
77
+ this.ws.close();
78
+ this.ws = null;
79
+ }
80
+ }
81
+ get isConnected() {
82
+ return this.ws?.readyState === WebSocket.OPEN;
83
+ }
84
+ startPing() {
85
+ this.stopPing();
86
+ this.pingTimer = setInterval(() => {
87
+ this.send({ type: "pong" }); // heartbeat
88
+ }, PING_INTERVAL);
89
+ }
90
+ stopPing() {
91
+ if (this.pingTimer) {
92
+ clearInterval(this.pingTimer);
93
+ this.pingTimer = null;
94
+ }
95
+ }
96
+ scheduleReconnect() {
97
+ if (this.intentionalClose)
98
+ return;
99
+ const jitter = Math.random() * 500;
100
+ const delay = Math.min(this.backoff + jitter, MAX_BACKOFF);
101
+ this.reconnectTimer = setTimeout(() => {
102
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF);
103
+ this.connect();
104
+ }, delay);
105
+ }
106
+ cleanup() {
107
+ this.stopPing();
108
+ if (this.reconnectTimer) {
109
+ clearTimeout(this.reconnectTimer);
110
+ this.reconnectTimer = null;
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Main daemon process.
3
+ *
4
+ * Lifecycle:
5
+ * 1. Load/generate identity
6
+ * 2. Connect to Railway WebSocket
7
+ * 3. Send register message
8
+ * 4. Handle needs_pairing → generate code, display QR
9
+ * 5. Handle paired/reconnected → ready for commands
10
+ * 6. Dispatch incoming tool_exec messages to capabilities
11
+ */
12
+ export interface DaemonOptions {
13
+ serverUrl: string;
14
+ workspace?: string;
15
+ }
16
+ export declare class Daemon {
17
+ private connection;
18
+ private identity;
19
+ private serverUrl;
20
+ private pairCode;
21
+ constructor(opts: DaemonOptions);
22
+ /** Start the daemon. */
23
+ start(): void;
24
+ /** Stop the daemon gracefully. */
25
+ stop(): void;
26
+ private handleStatus;
27
+ private handleMessage;
28
+ private handleNeedsPairing;
29
+ private handlePaired;
30
+ private handleReconnected;
31
+ private handleToolExec;
32
+ }
package/dist/daemon.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Main daemon process.
3
+ *
4
+ * Lifecycle:
5
+ * 1. Load/generate identity
6
+ * 2. Connect to Railway WebSocket
7
+ * 3. Send register message
8
+ * 4. Handle needs_pairing → generate code, display QR
9
+ * 5. Handle paired/reconnected → ready for commands
10
+ * 6. Dispatch incoming tool_exec messages to capabilities
11
+ */
12
+ import os from "node:os";
13
+ import { DaemonConnection } from "./connection.js";
14
+ import { ensureIdentity, getWorkspaceRoot, setWorkspaceRoot } from "./identity.js";
15
+ import { generatePairCode, displayPairCode } from "./pairing.js";
16
+ import { dispatch } from "./dispatcher.js";
17
+ const VERSION = "0.1.0";
18
+ export class Daemon {
19
+ connection;
20
+ identity;
21
+ serverUrl;
22
+ pairCode = null;
23
+ constructor(opts) {
24
+ this.serverUrl = opts.serverUrl.replace(/\/$/, "");
25
+ this.identity = ensureIdentity();
26
+ if (opts.workspace) {
27
+ setWorkspaceRoot(opts.workspace);
28
+ }
29
+ // Build the WebSocket URL
30
+ const wsBase = this.serverUrl
31
+ .replace(/^https:/, "wss:")
32
+ .replace(/^http:/, "ws:");
33
+ const wsUrl = `${wsBase}/api/daemon/ws`;
34
+ this.connection = new DaemonConnection({
35
+ url: wsUrl,
36
+ onMessage: this.handleMessage.bind(this),
37
+ onStatus: this.handleStatus.bind(this),
38
+ });
39
+ }
40
+ /** Start the daemon. */
41
+ start() {
42
+ console.log("");
43
+ console.log(" ┌─────────────────────────────────────┐");
44
+ console.log(" │ AEM Extension Builder — Daemon │");
45
+ console.log(" │ v" + VERSION.padEnd(35) + "│");
46
+ console.log(" └─────────────────────────────────────┘");
47
+ console.log("");
48
+ console.log(` Daemon ID: ${this.identity.daemonId.slice(0, 12)}...`);
49
+ console.log(` Server: ${this.serverUrl}`);
50
+ const workspace = getWorkspaceRoot();
51
+ if (workspace) {
52
+ console.log(` Workspace: ${workspace}`);
53
+ }
54
+ console.log("");
55
+ console.log(" Connecting...");
56
+ this.connection.connect();
57
+ }
58
+ /** Stop the daemon gracefully. */
59
+ stop() {
60
+ console.log("\n Shutting down...");
61
+ this.connection.disconnect();
62
+ }
63
+ handleStatus(status) {
64
+ switch (status) {
65
+ case "connecting":
66
+ // quiet — initial connecting logged in start()
67
+ break;
68
+ case "connected":
69
+ // Send register message
70
+ this.connection.send({
71
+ type: "register",
72
+ daemonId: this.identity.daemonId,
73
+ secret: this.identity.secret,
74
+ version: VERSION,
75
+ platform: `${os.platform()}-${os.arch()}`,
76
+ workspaceRoot: getWorkspaceRoot(),
77
+ });
78
+ break;
79
+ case "disconnected":
80
+ console.log(" ⚠ Disconnected — will retry...");
81
+ break;
82
+ }
83
+ }
84
+ handleMessage(msg) {
85
+ switch (msg.type) {
86
+ case "needs_pairing":
87
+ this.handleNeedsPairing();
88
+ break;
89
+ case "paired":
90
+ this.handlePaired(msg.appUrl);
91
+ break;
92
+ case "reconnected":
93
+ this.handleReconnected(msg.appUrl);
94
+ break;
95
+ case "tool_exec":
96
+ this.handleToolExec(msg.id, msg.command, msg.payload);
97
+ break;
98
+ case "error":
99
+ console.error(` ✗ Server error: ${msg.message}`);
100
+ break;
101
+ default:
102
+ // Unknown message type — ignore
103
+ break;
104
+ }
105
+ }
106
+ handleNeedsPairing() {
107
+ this.pairCode = generatePairCode();
108
+ // Send pair code to Railway
109
+ this.connection.send({
110
+ type: "pair_code",
111
+ code: this.pairCode,
112
+ });
113
+ // Display to user
114
+ const pairUrl = `${this.serverUrl}/pair?code=${this.pairCode}`;
115
+ console.log("");
116
+ console.log(" ╔═══════════════════════════════════════╗");
117
+ console.log(" ║ PAIR WITH YOUR BROWSER ║");
118
+ console.log(" ╚═══════════════════════════════════════╝");
119
+ console.log("");
120
+ console.log(` Code: ${this.pairCode}`);
121
+ console.log("");
122
+ console.log(` Open: ${pairUrl}`);
123
+ console.log("");
124
+ displayPairCode(pairUrl);
125
+ console.log("");
126
+ console.log(" Waiting for browser to confirm...");
127
+ }
128
+ handlePaired(appUrl) {
129
+ this.pairCode = null;
130
+ console.log("");
131
+ console.log(" ✓ Paired — ready");
132
+ console.log(` ✓ Open ${appUrl} in your browser`);
133
+ console.log("");
134
+ }
135
+ handleReconnected(appUrl) {
136
+ console.log(" ✓ Reconnected — ready");
137
+ console.log(` ✓ ${appUrl}`);
138
+ console.log("");
139
+ }
140
+ async handleToolExec(id, command, payload) {
141
+ try {
142
+ const result = await dispatch(command, payload, this.connection);
143
+ this.connection.send({
144
+ type: "tool_result",
145
+ id,
146
+ result: typeof result === "string" ? result : JSON.stringify(result),
147
+ });
148
+ }
149
+ catch (err) {
150
+ const message = err instanceof Error ? err.message : "Unknown error";
151
+ this.connection.send({
152
+ type: "tool_result",
153
+ id,
154
+ error: message,
155
+ });
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Command dispatcher — routes incoming tool_exec commands to capability handlers.
3
+ *
4
+ * Each command maps to a capability module. All path arguments are validated
5
+ * to be within the configured workspace root.
6
+ */
7
+ import type { DaemonConnection } from "./connection.js";
8
+ /**
9
+ * Dispatch a command to the appropriate capability handler.
10
+ */
11
+ export declare function dispatch(command: string, payload: Record<string, unknown>, connection: DaemonConnection): Promise<string | Record<string, unknown>>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Command dispatcher — routes incoming tool_exec commands to capability handlers.
3
+ *
4
+ * Each command maps to a capability module. All path arguments are validated
5
+ * to be within the configured workspace root.
6
+ */
7
+ import path from "node:path";
8
+ import { getWorkspaceRoot, setWorkspaceRoot } from "./identity.js";
9
+ import * as fsCap from "./capabilities/fs.js";
10
+ import * as gitCap from "./capabilities/git.js";
11
+ import * as shellCap from "./capabilities/shell.js";
12
+ /**
13
+ * Validate that a given path is within the workspace root.
14
+ * Prevents path traversal attacks.
15
+ */
16
+ function validatePath(targetPath) {
17
+ const workspace = getWorkspaceRoot();
18
+ if (!workspace) {
19
+ throw new Error("No workspace root configured. Set one via the UI or --workspace flag.");
20
+ }
21
+ const resolved = path.resolve(workspace, targetPath);
22
+ if (!resolved.startsWith(path.resolve(workspace))) {
23
+ throw new Error(`Path "${targetPath}" is outside the workspace root.`);
24
+ }
25
+ return resolved;
26
+ }
27
+ /**
28
+ * Dispatch a command to the appropriate capability handler.
29
+ */
30
+ export async function dispatch(command, payload, connection) {
31
+ switch (command) {
32
+ // ─── Filesystem ─────────────────────────────────────
33
+ case "fs:browse": {
34
+ const target = payload.path
35
+ ? validatePath(payload.path)
36
+ : getWorkspaceRoot();
37
+ return fsCap.browse(target, payload.showHidden);
38
+ }
39
+ case "fs:read": {
40
+ const filePath = validatePath(payload.path);
41
+ return fsCap.readFile(filePath);
42
+ }
43
+ case "fs:write": {
44
+ const filePath = validatePath(payload.path);
45
+ return fsCap.writeFile(filePath, payload.content);
46
+ }
47
+ case "fs:list": {
48
+ const target = payload.path
49
+ ? validatePath(payload.path)
50
+ : getWorkspaceRoot();
51
+ return fsCap.listRecursive(target, payload.depth);
52
+ }
53
+ // ─── Workspace ──────────────────────────────────────
54
+ case "workspace:get": {
55
+ return { workspaceRoot: getWorkspaceRoot() };
56
+ }
57
+ case "workspace:set": {
58
+ const newPath = payload.path;
59
+ if (!newPath)
60
+ throw new Error("path is required");
61
+ setWorkspaceRoot(newPath);
62
+ return { workspaceRoot: newPath };
63
+ }
64
+ // ─── Git ────────────────────────────────────────────
65
+ case "git:clone": {
66
+ const dest = validatePath(payload.dest);
67
+ return gitCap.clone(payload.url, dest, payload.branch);
68
+ }
69
+ case "git:status": {
70
+ const dir = validatePath(payload.path);
71
+ return gitCap.status(dir);
72
+ }
73
+ // ─── Shell / AIO CLI ────────────────────────────────
74
+ case "aio:run": {
75
+ const cwd = payload.cwd
76
+ ? validatePath(payload.cwd)
77
+ : getWorkspaceRoot();
78
+ return shellCap.runAio(payload.args, cwd, payload.id, connection);
79
+ }
80
+ case "aio:check": {
81
+ return shellCap.checkAio();
82
+ }
83
+ case "shell:exec": {
84
+ const cwd = payload.cwd
85
+ ? validatePath(payload.cwd)
86
+ : getWorkspaceRoot();
87
+ return shellCap.exec(payload.command, cwd);
88
+ }
89
+ default:
90
+ throw new Error(`Unknown command: ${command}`);
91
+ }
92
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Identity module — generates and persists a unique daemon identity.
3
+ *
4
+ * Stored at:
5
+ * macOS/Linux: ~/.config/aem-ext-daemon/config.json
6
+ * Windows: %APPDATA%\aem-ext-daemon\config.json
7
+ *
8
+ * Fields:
9
+ * daemonId — 128-bit hex, generated once, never changes
10
+ * secret — 256-bit hex, generated once, sent on every connection
11
+ * workspaceRoot — user-selected workspace path (set after first pairing)
12
+ * railwayUrl — WebSocket URL for the Railway app
13
+ */
14
+ export interface DaemonConfig {
15
+ daemonId: string;
16
+ secret: string;
17
+ workspaceRoot: string;
18
+ railwayUrl: string;
19
+ }
20
+ /** Ensure daemonId and secret exist; generate if missing. */
21
+ export declare function ensureIdentity(): {
22
+ daemonId: string;
23
+ secret: string;
24
+ };
25
+ export declare function getWorkspaceRoot(): string;
26
+ export declare function setWorkspaceRoot(path: string): void;
27
+ export declare function getRailwayUrl(): string;
28
+ export declare function setRailwayUrl(url: string): void;
29
+ export declare function getConfigPath(): string;
30
+ export declare function getAllConfig(): DaemonConfig;
31
+ /** Reset identity — used for testing or "unpair" flow. */
32
+ export declare function resetIdentity(): void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Identity module — generates and persists a unique daemon identity.
3
+ *
4
+ * Stored at:
5
+ * macOS/Linux: ~/.config/aem-ext-daemon/config.json
6
+ * Windows: %APPDATA%\aem-ext-daemon\config.json
7
+ *
8
+ * Fields:
9
+ * daemonId — 128-bit hex, generated once, never changes
10
+ * secret — 256-bit hex, generated once, sent on every connection
11
+ * workspaceRoot — user-selected workspace path (set after first pairing)
12
+ * railwayUrl — WebSocket URL for the Railway app
13
+ */
14
+ import Conf from "conf";
15
+ import crypto from "node:crypto";
16
+ const store = new Conf({
17
+ projectName: "aem-ext-daemon",
18
+ schema: {
19
+ daemonId: { type: "string", default: "" },
20
+ secret: { type: "string", default: "" },
21
+ workspaceRoot: { type: "string", default: "" },
22
+ railwayUrl: { type: "string", default: "" },
23
+ },
24
+ });
25
+ /** Ensure daemonId and secret exist; generate if missing. */
26
+ export function ensureIdentity() {
27
+ let daemonId = store.get("daemonId");
28
+ let secret = store.get("secret");
29
+ if (!daemonId) {
30
+ daemonId = crypto.randomBytes(16).toString("hex");
31
+ store.set("daemonId", daemonId);
32
+ }
33
+ if (!secret) {
34
+ secret = crypto.randomBytes(32).toString("hex");
35
+ store.set("secret", secret);
36
+ }
37
+ return { daemonId, secret };
38
+ }
39
+ export function getWorkspaceRoot() {
40
+ return store.get("workspaceRoot") || "";
41
+ }
42
+ export function setWorkspaceRoot(path) {
43
+ store.set("workspaceRoot", path);
44
+ }
45
+ export function getRailwayUrl() {
46
+ return store.get("railwayUrl") || "";
47
+ }
48
+ export function setRailwayUrl(url) {
49
+ store.set("railwayUrl", url);
50
+ }
51
+ export function getConfigPath() {
52
+ return store.path;
53
+ }
54
+ export function getAllConfig() {
55
+ return {
56
+ daemonId: store.get("daemonId") || "",
57
+ secret: store.get("secret") || "",
58
+ workspaceRoot: store.get("workspaceRoot") || "",
59
+ railwayUrl: store.get("railwayUrl") || "",
60
+ };
61
+ }
62
+ /** Reset identity — used for testing or "unpair" flow. */
63
+ export function resetIdentity() {
64
+ store.clear();
65
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Pairing module — generates human-readable pair codes and displays QR.
3
+ *
4
+ * Code format: WORD-WORD-NNNN (e.g. AMBER-WOLF-4821)
5
+ */
6
+ /** Generate a human-readable pair code: WORD-WORD-NNNN */
7
+ export declare function generatePairCode(): string;
8
+ /** Display a QR code in the terminal pointing to the pair URL. */
9
+ export declare function displayPairCode(url: string): void;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pairing module — generates human-readable pair codes and displays QR.
3
+ *
4
+ * Code format: WORD-WORD-NNNN (e.g. AMBER-WOLF-4821)
5
+ */
6
+ import crypto from "node:crypto";
7
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
8
+ // @ts-ignore — no types for qrcode-terminal
9
+ import qrcode from "qrcode-terminal";
10
+ const ADJECTIVES = [
11
+ "AMBER", "AZURE", "BLAZE", "CEDAR", "CLOUD", "CORAL", "DRIFT",
12
+ "EMBER", "FROST", "GLEAM", "HAVEN", "IVORY", "LUNAR", "MAPLE",
13
+ "NOBLE", "OCEAN", "PEARL", "QUIET", "RIVER", "SOLAR", "STORM",
14
+ "TIDAL", "ULTRA", "VIVID", "SWIFT", "CRISP", "BRAVE", "STARK",
15
+ ];
16
+ const NOUNS = [
17
+ "BEAR", "CROW", "DOVE", "EAGLE", "FALCON", "HAWK", "HERON",
18
+ "LYNX", "OTTER", "RAVEN", "STAG", "TIGER", "VIPER", "WOLF",
19
+ "CRANE", "FINCH", "LARK", "PUMA", "ROBIN", "SHARK", "WREN",
20
+ "BISON", "COBRA", "DRAKE", "GECKO", "IBIS", "KOALA", "MANTA",
21
+ ];
22
+ /** Generate a human-readable pair code: WORD-WORD-NNNN */
23
+ export function generatePairCode() {
24
+ const adj = ADJECTIVES[crypto.randomInt(ADJECTIVES.length)];
25
+ const noun = NOUNS[crypto.randomInt(NOUNS.length)];
26
+ const num = crypto.randomInt(1000, 9999);
27
+ return `${adj}-${noun}-${num}`;
28
+ }
29
+ /** Display a QR code in the terminal pointing to the pair URL. */
30
+ export function displayPairCode(url) {
31
+ qrcode.generate(url, { small: true }, (code) => {
32
+ // Indent each line for consistent formatting
33
+ const indented = code
34
+ .split("\n")
35
+ .map((line) => ` ${line}`)
36
+ .join("\n");
37
+ console.log(indented);
38
+ });
39
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Message protocol for daemon <-> Railway communication.
3
+ *
4
+ * All messages are JSON with: { id, type, payload? }
5
+ * The `id` is for request/response correlation.
6
+ */
7
+ export interface RegisterMessage {
8
+ type: "register";
9
+ daemonId: string;
10
+ secret: string;
11
+ version: string;
12
+ platform: string;
13
+ workspaceRoot: string;
14
+ }
15
+ export interface PairCodeMessage {
16
+ type: "pair_code";
17
+ code: string;
18
+ }
19
+ export interface ToolResultMessage {
20
+ type: "tool_result";
21
+ id: string;
22
+ result?: string;
23
+ error?: string;
24
+ }
25
+ export interface StreamMessage {
26
+ type: "stream";
27
+ id: string;
28
+ channel: "stdout" | "stderr";
29
+ data: string;
30
+ }
31
+ export interface StreamDoneMessage {
32
+ type: "stream_done";
33
+ id: string;
34
+ exitCode: number;
35
+ }
36
+ export interface PongMessage {
37
+ type: "pong";
38
+ }
39
+ export interface NeedsPairingMessage {
40
+ type: "needs_pairing";
41
+ }
42
+ export interface PairedMessage {
43
+ type: "paired";
44
+ appUrl: string;
45
+ }
46
+ export interface ReconnectedMessage {
47
+ type: "reconnected";
48
+ appUrl: string;
49
+ }
50
+ export interface ToolExecMessage {
51
+ type: "tool_exec";
52
+ id: string;
53
+ command: string;
54
+ payload: Record<string, unknown>;
55
+ }
56
+ export interface PingMessage {
57
+ type: "ping";
58
+ }
59
+ export interface ErrorMessage {
60
+ type: "error";
61
+ message: string;
62
+ }
63
+ export type DaemonOutbound = RegisterMessage | PairCodeMessage | ToolResultMessage | StreamMessage | StreamDoneMessage | PongMessage;
64
+ export type RailwayOutbound = NeedsPairingMessage | PairedMessage | ReconnectedMessage | ToolExecMessage | PingMessage | ErrorMessage;
65
+ export declare function encode(msg: DaemonOutbound | RailwayOutbound): string;
66
+ export declare function decode(raw: string): DaemonOutbound | RailwayOutbound;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Message protocol for daemon <-> Railway communication.
3
+ *
4
+ * All messages are JSON with: { id, type, payload? }
5
+ * The `id` is for request/response correlation.
6
+ */
7
+ // ─── Helpers ────────────────────────────────────────────────
8
+ export function encode(msg) {
9
+ return JSON.stringify(msg);
10
+ }
11
+ export function decode(raw) {
12
+ return JSON.parse(raw);
13
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "aem-ext-daemon",
3
+ "version": "0.1.0",
4
+ "description": "Local daemon for AEM Extension Builder — connects your machine to the cloud UI",
5
+ "type": "module",
6
+ "bin": {
7
+ "aem-ext-daemon": "./bin/cli.js"
8
+ },
9
+ "main": "./dist/daemon.js",
10
+ "types": "./dist/daemon.d.ts",
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "dev": "tsc --watch",
14
+ "start": "node bin/cli.js",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "dependencies": {
18
+ "conf": "^13.0.1",
19
+ "qrcode-terminal": "^0.12.0",
20
+ "ws": "^8.18.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.0.0",
24
+ "@types/ws": "^8.5.0",
25
+ "typescript": "^5.7.0"
26
+ },
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "dist/",
33
+ "README.md"
34
+ ],
35
+ "keywords": [
36
+ "adobe",
37
+ "aem",
38
+ "extension-builder",
39
+ "daemon",
40
+ "local-agent"
41
+ ],
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/znikolovski/adobe-extension-builder.git",
45
+ "directory": "packages/daemon"
46
+ },
47
+ "author": "Zoran Nikolovski",
48
+ "license": "MIT"
49
+ }