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 +48 -0
- package/bin/cli.js +93 -0
- package/dist/capabilities/fs.d.ts +17 -0
- package/dist/capabilities/fs.js +78 -0
- package/dist/capabilities/git.d.ts +8 -0
- package/dist/capabilities/git.js +41 -0
- package/dist/capabilities/shell.d.ts +17 -0
- package/dist/capabilities/shell.js +96 -0
- package/dist/connection.d.ts +37 -0
- package/dist/connection.js +113 -0
- package/dist/daemon.d.ts +32 -0
- package/dist/daemon.js +158 -0
- package/dist/dispatcher.d.ts +11 -0
- package/dist/dispatcher.js +92 -0
- package/dist/identity.d.ts +32 -0
- package/dist/identity.js +65 -0
- package/dist/pairing.d.ts +9 -0
- package/dist/pairing.js +39 -0
- package/dist/protocol.d.ts +66 -0
- package/dist/protocol.js +13 -0
- package/package.json +49 -0
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
|
+
}
|
package/dist/daemon.d.ts
ADDED
|
@@ -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;
|
package/dist/identity.js
ADDED
|
@@ -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;
|
package/dist/pairing.js
ADDED
|
@@ -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;
|
package/dist/protocol.js
ADDED
|
@@ -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
|
+
}
|