@droidagentkit/launcher 0.2.0-alpha

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.
Files changed (3) hide show
  1. package/README.md +43 -0
  2. package/index.js +231 -0
  3. package/package.json +26 -0
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @droidagentkit/launcher
2
+
3
+ Thin Node launcher for the **DroidAgentKit** JVM MCP server.
4
+
5
+ - **Runtime:** pure JVM. Node is only an install-time shim.
6
+ - **Behavior:** on first run, downloads the `droidagent-cli-<version>.jar` matching this
7
+ package's version from [GitHub Releases](https://github.com/iVamsi/droid-agent-kit/releases),
8
+ verifies its SHA-256, caches it under `~/.droidagentkit/cli/`, and runs
9
+ `java -jar <jar> serve-mcp --transport stdio --project auto`. Later runs reuse the cached,
10
+ already-verified jar without hitting the network again.
11
+ - **Version flag:** `droidagent-mcp --version` prints immutable launcher/server version metadata.
12
+ - **Requires:** a JDK 17+ runtime on `PATH` (the same prerequisite as building from source).
13
+ - **Overrides:**
14
+ - `DROIDAGENT_BIN` — absolute path to an existing `droidagent` CLI; skips auto-fetch entirely.
15
+ - `DROIDAGENT_CACHE_DIR` — change where the downloaded jar is cached.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npx -y @droidagentkit/launcher --version
21
+ ```
22
+
23
+ or install it once:
24
+
25
+ ```bash
26
+ npm install -g @droidagentkit/launcher
27
+ droidagent-mcp --version
28
+ ```
29
+
30
+ ## Fails closed
31
+
32
+ A checksum mismatch, a failed download, or a missing `java` on `PATH` all exit non-zero with a
33
+ clear message instead of silently degrading the MCP connection or running unverified code.
34
+
35
+ ## Smoke test
36
+
37
+ ```bash
38
+ ../smoke-test.sh # exercises --version/--help, the auto-fetch/cache/checksum paths, and
39
+ # (with DROIDAGENT_E2E=1) a real stdio round-trip against a built CLI
40
+ ```
41
+
42
+ See `docs/adrs/0001-packaging.md` for the packaging decision (npm launcher primary,
43
+ MCPB secondary, OCI rejected) and the release pipeline that publishes this package.
package/index.js ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * DroidAgentKit MCP launcher.
4
+ *
5
+ * Default behavior: downloads (once, then caches) the `droidagent-cli` fat jar matching this
6
+ * launcher's own version from GitHub Releases, verifies its SHA-256, and runs it with
7
+ * `java -jar ... serve-mcp --transport stdio --project auto`.
8
+ *
9
+ * Set DROIDAGENT_BIN to an existing `droidagent` CLI to skip auto-fetch entirely (e.g. a local
10
+ * `./gradlew :cli:installDist` build). Node is an install-time shim only; the MCP server itself
11
+ * is pure JVM and has no Node runtime dependency. The launcher fails closed on any error rather
12
+ * than silently degrading the MCP connection.
13
+ */
14
+ "use strict";
15
+
16
+ const { spawn, spawnSync } = require("node:child_process");
17
+ const { existsSync, mkdirSync, renameSync, unlinkSync, createWriteStream, readFileSync } = require("node:fs");
18
+ const path = require("node:path");
19
+ const os = require("node:os");
20
+ const http = require("node:http");
21
+ const https = require("node:https");
22
+ const crypto = require("node:crypto");
23
+
24
+ const LAUNCHER_VERSION = require("./package.json").version;
25
+
26
+ const GITHUB_OWNER = "iVamsi";
27
+ const GITHUB_REPO = "droid-agent-kit";
28
+ const DEFAULT_RELEASE_BASE_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download`;
29
+ const MAX_REDIRECTS = 5;
30
+ const REQUEST_TIMEOUT_MS = 60_000;
31
+
32
+ function cacheDir() {
33
+ return process.env.DROIDAGENT_CACHE_DIR || path.join(os.homedir(), ".droidagentkit", "cli");
34
+ }
35
+
36
+ function jarFileName(version) {
37
+ return `droidagent-cli-${version}.jar`;
38
+ }
39
+
40
+ // Overridable only for tests (see smoke-test.sh); production always uses the real GitHub
41
+ // Releases URL for this repo.
42
+ function releaseAssetUrl(version, fileName) {
43
+ const base = process.env.DROIDAGENT_RELEASE_BASE_URL || DEFAULT_RELEASE_BASE_URL;
44
+ return `${base}/v${version}/${fileName}`;
45
+ }
46
+
47
+ function httpModuleFor(url) {
48
+ return new URL(url).protocol === "http:" ? http : https;
49
+ }
50
+
51
+ function fetchBuffer(url, redirectsLeft = MAX_REDIRECTS) {
52
+ return new Promise((resolve, reject) => {
53
+ const req = httpModuleFor(url).get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
54
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
55
+ res.resume();
56
+ resolve(fetchBuffer(res.headers.location, redirectsLeft - 1));
57
+ return;
58
+ }
59
+ if (res.statusCode !== 200) {
60
+ res.resume();
61
+ reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
62
+ return;
63
+ }
64
+ const chunks = [];
65
+ res.on("data", (chunk) => chunks.push(chunk));
66
+ res.on("end", () => resolve(Buffer.concat(chunks)));
67
+ res.on("error", reject);
68
+ });
69
+ req.on("timeout", () => req.destroy(new Error(`timed out fetching ${url}`)));
70
+ req.on("error", reject);
71
+ });
72
+ }
73
+
74
+ function downloadToFile(url, destPath, redirectsLeft = MAX_REDIRECTS) {
75
+ return new Promise((resolve, reject) => {
76
+ const req = httpModuleFor(url).get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
77
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
78
+ res.resume();
79
+ resolve(downloadToFile(res.headers.location, destPath, redirectsLeft - 1));
80
+ return;
81
+ }
82
+ if (res.statusCode !== 200) {
83
+ res.resume();
84
+ reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
85
+ return;
86
+ }
87
+ const out = createWriteStream(destPath);
88
+ res.pipe(out);
89
+ out.on("finish", () => out.close(() => resolve()));
90
+ out.on("error", reject);
91
+ res.on("error", reject);
92
+ });
93
+ req.on("timeout", () => req.destroy(new Error(`timed out fetching ${url}`)));
94
+ req.on("error", reject);
95
+ });
96
+ }
97
+
98
+ function sha256File(filePath) {
99
+ return crypto.createHash("sha256").update(readFileSync(filePath)).digest("hex");
100
+ }
101
+
102
+ function parseChecksum(buffer) {
103
+ const text = buffer.toString("utf8").trim();
104
+ const hash = text.split(/\s+/)[0] ?? "";
105
+ if (!/^[0-9a-f]{64}$/i.test(hash)) {
106
+ throw new Error(`unrecognized checksum file format: ${text.slice(0, 80)}`);
107
+ }
108
+ return hash.toLowerCase();
109
+ }
110
+
111
+ // Downloads and verifies the droidagent-cli fat jar for `version` into the cache dir, or
112
+ // reuses it if already present. Fails closed: a checksum mismatch deletes the partial download
113
+ // and throws rather than falling back to an unverified jar. Verification only happens on a
114
+ // fresh download; a cache hit is trusted on the strength of that earlier verification, so a
115
+ // GitHub outage doesn't block an already-working install.
116
+ async function ensureCliJar(version) {
117
+ const dir = cacheDir();
118
+ const finalPath = path.join(dir, jarFileName(version));
119
+ if (existsSync(finalPath)) {
120
+ return finalPath;
121
+ }
122
+
123
+ mkdirSync(dir, { recursive: true });
124
+ const fileName = jarFileName(version);
125
+ const jarUrl = releaseAssetUrl(version, fileName);
126
+ const checksumUrl = `${jarUrl}.sha256`;
127
+ const tmpPath = path.join(dir, `.${fileName}.${process.pid}.tmp`);
128
+
129
+ let expectedHash;
130
+ try {
131
+ expectedHash = parseChecksum(await fetchBuffer(checksumUrl));
132
+ } catch (err) {
133
+ throw new Error(`could not fetch checksum from ${checksumUrl}: ${err.message}`);
134
+ }
135
+
136
+ try {
137
+ await downloadToFile(jarUrl, tmpPath);
138
+ const actualHash = sha256File(tmpPath);
139
+ if (actualHash !== expectedHash) {
140
+ throw new Error(`checksum mismatch: expected ${expectedHash}, got ${actualHash}`);
141
+ }
142
+ renameSync(tmpPath, finalPath);
143
+ } catch (err) {
144
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
145
+ throw err;
146
+ }
147
+ return finalPath;
148
+ }
149
+
150
+ function javaAvailable() {
151
+ return !spawnSync("java", ["-version"]).error;
152
+ }
153
+
154
+ function spawnServer(command, args) {
155
+ const child = spawn(command, args, { stdio: "inherit" });
156
+ child.on("error", (err) => {
157
+ process.stderr.write(`droidagent-mcp: failed to launch '${command}': ${err.message}\n`);
158
+ process.exit(127);
159
+ });
160
+ child.on("exit", (code, signal) => {
161
+ if (signal) process.exit(1);
162
+ process.exit(code ?? 1);
163
+ });
164
+ }
165
+
166
+ function printHelp() {
167
+ process.stdout.write(
168
+ "Usage: droidagent-mcp [--version | --help]\n" +
169
+ " (no args) fetch (if needed) and run the DroidAgentKit MCP server over stdio\n" +
170
+ "\n" +
171
+ "Env:\n" +
172
+ " DROIDAGENT_BIN absolute path to an existing droidagent CLI (skips auto-fetch)\n" +
173
+ " DROIDAGENT_CACHE_DIR override the jar download cache (default ~/.droidagentkit/cli)\n"
174
+ );
175
+ }
176
+
177
+ function resolveOverrideBin() {
178
+ if (process.env.DROIDAGENT_BIN && existsSync(process.env.DROIDAGENT_BIN)) {
179
+ return process.env.DROIDAGENT_BIN;
180
+ }
181
+ return null;
182
+ }
183
+
184
+ async function main(argv) {
185
+ if (argv.includes("--version") || argv.includes("-v")) {
186
+ process.stdout.write(`droidagent-mcp launcher ${LAUNCHER_VERSION}\n`);
187
+ return 0;
188
+ }
189
+ if (argv.includes("--help") || argv.includes("-h")) {
190
+ printHelp();
191
+ return 0;
192
+ }
193
+
194
+ const overrideBin = resolveOverrideBin();
195
+ if (overrideBin) {
196
+ spawnServer(overrideBin, ["serve-mcp", "--transport", "stdio", "--project", "auto"]);
197
+ return null;
198
+ }
199
+
200
+ if (!javaAvailable()) {
201
+ process.stderr.write(
202
+ "droidagent-mcp: no working `java` found on PATH.\n" +
203
+ "DroidAgentKit's server is a JVM tool and needs a JDK 17+ runtime (e.g. https://adoptium.net).\n" +
204
+ "Alternatively, set DROIDAGENT_BIN to a prebuilt droidagent CLI.\n"
205
+ );
206
+ process.exit(1);
207
+ }
208
+
209
+ let jarPath;
210
+ try {
211
+ jarPath = await ensureCliJar(LAUNCHER_VERSION);
212
+ } catch (err) {
213
+ process.stderr.write(
214
+ `droidagent-mcp: could not fetch droidagent-cli ${LAUNCHER_VERSION}: ${err.message}\n` +
215
+ "Set DROIDAGENT_BIN to a prebuilt droidagent CLI to bypass auto-fetch.\n"
216
+ );
217
+ process.exit(1);
218
+ }
219
+
220
+ spawnServer("java", ["-jar", jarPath, "serve-mcp", "--transport", "stdio", "--project", "auto"]);
221
+ return null;
222
+ }
223
+
224
+ main(process.argv.slice(2))
225
+ .then((code) => {
226
+ if (code !== null && code !== undefined) process.exit(code);
227
+ })
228
+ .catch((err) => {
229
+ process.stderr.write(`droidagent-mcp: unexpected error: ${err.message}\n`);
230
+ process.exit(1);
231
+ });
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@droidagentkit/launcher",
3
+ "version": "0.2.0-alpha",
4
+ "description": "Thin Node launcher for the DroidAgentKit JVM MCP server. Locates the `droidagent` CLI (or a fetched archive), verifies its SHA-256, and spawns `droidagent serve-mcp --transport stdio`. Node is an install-time dependency only; the server runtime is pure JVM.",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/iVamsi/droid-agent-kit.git",
9
+ "directory": "distribution/npm-launcher"
10
+ },
11
+ "homepage": "https://github.com/iVamsi/droid-agent-kit",
12
+ "bugs": "https://github.com/iVamsi/droid-agent-kit/issues",
13
+ "bin": {
14
+ "droidagent-mcp": "index.js"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "files": [
20
+ "index.js",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "smoke": "node index.js --version"
25
+ }
26
+ }