@anyship/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/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @anyship/cli
2
+
3
+ Command-line companion for [anyship](https://anyship.dev) — deploy a local
4
+ project directory to a live URL, or run a local MCP server so an AI agent (Claude
5
+ Code, Codex) can deploy directories directly.
6
+
7
+ ```bash
8
+ npx @anyship/cli login # save your anyship API key (validated + stored 0600)
9
+ npx @anyship/cli deploy . # deploy the current directory
10
+ ```
11
+
12
+ ## Why a local CLI
13
+
14
+ anyship's control plane is a remote service — it can't read your disk. This CLI
15
+ runs on your machine, so it gathers your project itself: it uses `git ls-files`
16
+ (respecting `.gitignore`) and uploads everything that isn't ignored, excluding
17
+ `node_modules`, build output, caches, `.env` files, and local databases. You
18
+ never hand-pick files.
19
+
20
+ ## Commands
21
+
22
+ ### `anyship login`
23
+
24
+ Validate an `anyship_sk_…` key against the control plane and store it in
25
+ `~/.anyship/config.json` (mode `0600`). After this, `deploy` and `mcp`
26
+ authenticate automatically — no `--env`, no token in your shell history.
27
+
28
+ ```bash
29
+ anyship login --key anyship_sk_… # or: echo $KEY | anyship login --with-token
30
+ anyship logout # remove stored credentials
31
+ ```
32
+
33
+ ### `anyship deploy [path]`
34
+
35
+ Archive and deploy a project directory (defaults to `.`). anyship detects the
36
+ stack, builds if needed, and returns a live URL.
37
+
38
+ ```bash
39
+ anyship deploy --project my-app .
40
+ anyship deploy --project my-app . --dry-run # list what would be uploaded
41
+ ```
42
+
43
+ Options: `--project <name>`, `--api <url>`, `--key <token>`, `--env <json>`,
44
+ `--dry-run`, `--json`.
45
+
46
+ ### `anyship mcp`
47
+
48
+ Run a local [MCP](https://modelcontextprotocol.io) server over stdio. Its
49
+ `deploy` tool takes a **directory path** and gathers the files for you; every
50
+ other tool (`list_projects`, `deployment_status`, `add_domain`, `logs`, …) is
51
+ proxied to the control plane. Add it to Claude Code:
52
+
53
+ ```bash
54
+ anyship login
55
+ claude mcp add anyship -- npx -y @anyship/cli mcp
56
+ ```
57
+
58
+ Then in any conversation: **"deploy this to anyship."** The agent never selects
59
+ files and never handles your API key.
60
+
61
+ ## Configuration
62
+
63
+ Key and control-plane URL resolve in this order:
64
+
65
+ 1. explicit flag (`--key` / `--api`)
66
+ 2. environment (`ANYSHIP_API_KEY` / `ANYSHIP_API_URL`)
67
+ 3. stored login (`~/.anyship/config.json`)
68
+ 4. an `mcp_servers.anyship` entry in `~/.codex/config.toml`
69
+
70
+ Requires Node.js 18+.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../src/index.mjs";
3
+
4
+ try {
5
+ const code = await runCli(process.argv.slice(2));
6
+ process.exitCode = code;
7
+ } catch (error) {
8
+ console.error(error instanceof Error ? error.message : String(error));
9
+ process.exitCode = 1;
10
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@anyship/cli",
3
+ "version": "0.1.0",
4
+ "description": "anyship CLI — deploy a local project directory, run a local MCP server for AI agents, and log in.",
5
+ "type": "module",
6
+ "bin": {
7
+ "anyship": "./bin/anyship.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.mjs"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "keywords": [
20
+ "anyship",
21
+ "deploy",
22
+ "cloudflare",
23
+ "mcp",
24
+ "cli"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/anyship/drydock.git",
29
+ "directory": "packages/cli"
30
+ },
31
+ "homepage": "https://github.com/anyship/drydock/tree/main/packages/cli#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/anyship/drydock/issues"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "fflate": "^0.8.2"
40
+ }
41
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,50 @@
1
+ // Stored anyship credentials: `~/.anyship/config.json` (0600). Written by
2
+ // `anyship login`, read by `deploy` and the local MCP server so the key the user
3
+ // logged in with "just works" — no --env, no token pasted per command.
4
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, chmodSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ export function anyshipConfigDir(home = homedir()) {
9
+ return join(home, ".anyship");
10
+ }
11
+
12
+ export function anyshipConfigPath(home = homedir()) {
13
+ return join(anyshipConfigDir(home), "config.json");
14
+ }
15
+
16
+ export function readAnyshipConfig(home = homedir()) {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(anyshipConfigPath(home), "utf8"));
19
+ return {
20
+ apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey : undefined,
21
+ apiUrl: typeof parsed.apiUrl === "string" ? parsed.apiUrl : undefined,
22
+ };
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ export function writeAnyshipConfig(patch, home = homedir()) {
29
+ const dir = anyshipConfigDir(home);
30
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
31
+ const path = anyshipConfigPath(home);
32
+ const merged = { ...readAnyshipConfig(home), ...patch };
33
+ for (const key of Object.keys(merged)) {
34
+ if (merged[key] == null) delete merged[key];
35
+ }
36
+ writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
37
+ // writeFileSync's mode only applies on create; force it in case the file existed.
38
+ try {
39
+ chmodSync(path, 0o600);
40
+ } catch {
41
+ /* best effort (e.g. Windows) */
42
+ }
43
+ return path;
44
+ }
45
+
46
+ export function clearAnyshipConfig(home = homedir()) {
47
+ if (!existsSync(anyshipConfigPath(home))) return false;
48
+ rmSync(anyshipConfigPath(home));
49
+ return true;
50
+ }
@@ -0,0 +1,316 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { readFile, readdir, stat } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { basename, join, relative, resolve } from "node:path";
6
+ import { zipSync } from "fflate";
7
+ import { readAnyshipConfig } from "./config.mjs";
8
+
9
+ export const DEFAULT_API = "https://drydock.anyship.workers.dev";
10
+
11
+ export const EXCLUDED_DIRS = new Set([
12
+ ".git",
13
+ ".codex",
14
+ ".agents",
15
+ ".wrangler",
16
+ "node_modules",
17
+ "dist",
18
+ "build",
19
+ ".next",
20
+ ".svelte-kit",
21
+ ".output",
22
+ ".vite",
23
+ ".turbo",
24
+ ".cache",
25
+ "coverage",
26
+ ]);
27
+
28
+ export function deployUsage(commandName = "anyship deploy") {
29
+ return `Usage: ${commandName} --project <name> [path]
30
+
31
+ Options:
32
+ --project <name> anyship project name
33
+ --api <url> control-plane URL (defaults to ~/.codex/config.toml, ANYSHIP_API_URL, or ${DEFAULT_API})
34
+ --key <token> anyship API key (defaults to ANYSHIP_API_KEY or ~/.codex/config.toml)
35
+ --env <json> JSON object of env vars/secrets to merge for the project
36
+ --dry-run Print the files that would be archived without uploading
37
+ --json Print dry-run output as JSON
38
+
39
+ The archive uses git ls-files when available, respects .gitignore, and excludes
40
+ node_modules/build outputs/caches/secrets/local DB files.`;
41
+ }
42
+
43
+ export function parseDeployArgs(argv) {
44
+ const args = { path: "." };
45
+ for (let i = 0; i < argv.length; i += 1) {
46
+ const arg = argv[i];
47
+ if (arg === "--help" || arg === "-h") {
48
+ args.help = true;
49
+ continue;
50
+ }
51
+ if (arg === "--dry-run") {
52
+ args.dryRun = true;
53
+ continue;
54
+ }
55
+ if (arg === "--json") {
56
+ args.json = true;
57
+ continue;
58
+ }
59
+ if (arg === "--project") {
60
+ args.project = requiredValue(argv, i, arg);
61
+ i += 1;
62
+ continue;
63
+ }
64
+ if (arg === "--api") {
65
+ args.api = requiredValue(argv, i, arg);
66
+ i += 1;
67
+ continue;
68
+ }
69
+ if (arg === "--key") {
70
+ args.key = requiredValue(argv, i, arg);
71
+ i += 1;
72
+ continue;
73
+ }
74
+ if (arg === "--env") {
75
+ args.env = requiredValue(argv, i, arg);
76
+ i += 1;
77
+ continue;
78
+ }
79
+ if (!arg.startsWith("-")) {
80
+ args.path = arg;
81
+ continue;
82
+ }
83
+ throw new Error(`unknown argument: ${arg}`);
84
+ }
85
+ if (!args.help && !args.project) throw new Error("--project is required");
86
+ return args;
87
+ }
88
+
89
+ function requiredValue(argv, index, flag) {
90
+ const value = argv[index + 1];
91
+ if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
92
+ return value;
93
+ }
94
+
95
+ export function codexAnyshipConfig(home = homedir()) {
96
+ const configPath = join(home, ".codex", "config.toml");
97
+ if (!existsSync(configPath)) return {};
98
+ const lines = readFileSync(configPath, "utf8").split(/\n/);
99
+ const start = lines.findIndex((line) => /^\[mcp_servers\.("?anyship"?)\]\s*$/.test(line.trim()));
100
+ if (start < 0) return {};
101
+ const section = [];
102
+ for (let i = start; i < lines.length; i += 1) {
103
+ if (i !== start && lines[i].trim().startsWith("[")) break;
104
+ section.push(lines[i]);
105
+ }
106
+ const text = section.join("\n");
107
+ const url = text.match(/url\s*=\s*"([^"]+)"/)?.[1]?.replace(/\/mcp\/?$/, "");
108
+ const token = text.match(/Bearer ([^"]+)/)?.[1]?.trim();
109
+ return { url, token };
110
+ }
111
+
112
+ export function apiKey(args, config = codexAnyshipConfig(), stored = readAnyshipConfig()) {
113
+ return (
114
+ args.key ||
115
+ process.env.ANYSHIP_API_KEY ||
116
+ process.env.ANYSHP_API_KEY ||
117
+ stored.apiKey ||
118
+ config.token ||
119
+ ""
120
+ );
121
+ }
122
+
123
+ export function apiUrl(args, config = codexAnyshipConfig(), stored = readAnyshipConfig()) {
124
+ return (
125
+ args.api ||
126
+ process.env.ANYSHIP_API_URL ||
127
+ stored.apiUrl ||
128
+ config.url ||
129
+ DEFAULT_API
130
+ ).replace(/\/+$/, "");
131
+ }
132
+
133
+ function pathParts(rel) {
134
+ return rel.replace(/\\/g, "/").split("/").filter(Boolean);
135
+ }
136
+
137
+ function inExcludedDir(rel) {
138
+ return pathParts(rel).some((part) => EXCLUDED_DIRS.has(part));
139
+ }
140
+
141
+ function isSecretEnv(rel) {
142
+ const base = basename(rel);
143
+ return base === ".env" || (base.startsWith(".env.") && base !== ".env.example");
144
+ }
145
+
146
+ function isLocalDb(rel) {
147
+ return /\.(sqlite|sqlite3|db|db3)(?:-(?:wal|shm))?$/i.test(basename(rel));
148
+ }
149
+
150
+ export function isIncluded(rel) {
151
+ if (!rel || rel.startsWith("..") || inExcludedDir(rel)) return false;
152
+ if (isSecretEnv(rel)) return false;
153
+ if (isLocalDb(rel)) return false;
154
+ if (rel.endsWith(".log")) return false;
155
+ return true;
156
+ }
157
+
158
+ export function gitFiles(root) {
159
+ try {
160
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
161
+ cwd: root,
162
+ stdio: ["ignore", "ignore", "ignore"],
163
+ });
164
+ const out = execFileSync("git", ["ls-files", "-co", "--exclude-standard"], {
165
+ cwd: root,
166
+ encoding: "utf8",
167
+ });
168
+ return out.split("\n").map((s) => s.trim()).filter(Boolean);
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ export async function walkFiles(root, dir = root, out = []) {
175
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
176
+ const abs = join(dir, entry.name);
177
+ const rel = relative(root, abs);
178
+ if (!isIncluded(rel)) continue;
179
+ if (entry.isDirectory()) await walkFiles(root, abs, out);
180
+ else if (entry.isFile()) out.push(rel);
181
+ }
182
+ return out;
183
+ }
184
+
185
+ export async function sourceFiles(root) {
186
+ const fromGit = gitFiles(root);
187
+ const files = fromGit ?? await walkFiles(root);
188
+ return files.filter(isIncluded).sort();
189
+ }
190
+
191
+ export async function buildZip(root) {
192
+ const files = await sourceFiles(root);
193
+ if (files.length === 0) throw new Error("no source files found");
194
+ const entries = {};
195
+ let total = 0;
196
+ for (const rel of files) {
197
+ const abs = join(root, rel);
198
+ const st = await stat(abs).catch(() => null);
199
+ if (!st?.isFile()) continue;
200
+ const bytes = new Uint8Array(await readFile(abs));
201
+ entries[rel] = bytes;
202
+ total += bytes.length;
203
+ }
204
+ const includedFiles = Object.keys(entries).sort();
205
+ if (includedFiles.length === 0) throw new Error("no source files found after filtering");
206
+ return { zip: zipSync(entries), files: includedFiles, total };
207
+ }
208
+
209
+ export async function deployDirectory(args, hooks = {}) {
210
+ const root = resolve(args.path ?? ".");
211
+ const { zip, files, total } = await buildZip(root);
212
+ const summary = {
213
+ project: args.project,
214
+ root,
215
+ files,
216
+ fileCount: files.length,
217
+ sourceBytes: total,
218
+ archiveBytes: zip.length,
219
+ };
220
+
221
+ if (args.dryRun) return { dryRun: true, ...summary };
222
+
223
+ const config = codexAnyshipConfig();
224
+ const key = apiKey(args, config);
225
+ if (!key) {
226
+ throw new Error("missing Anyship API key; set ANYSHIP_API_KEY or configure global mcp_servers.anyship");
227
+ }
228
+ const api = apiUrl(args, config);
229
+ hooks.onUpload?.({ ...summary, api });
230
+
231
+ const form = new FormData();
232
+ form.append("project", args.project);
233
+ if (args.env) form.append("env", args.env);
234
+ form.append("file", new Blob([zip], { type: "application/zip" }), `${basename(root) || "source"}.zip`);
235
+
236
+ const res = await fetch(`${api}/api/deploy-from-archive`, {
237
+ method: "POST",
238
+ headers: { Authorization: `Bearer ${key}` },
239
+ body: form,
240
+ });
241
+ const text = await res.text();
242
+ const data = parseJson(text);
243
+ if (!res.ok) {
244
+ throw new Error(apiErrorMessage(res.status, data, text));
245
+ }
246
+ return { dryRun: false, ...summary, api, response: data ?? text };
247
+ }
248
+
249
+ export function apiErrorMessage(status, data, text = "") {
250
+ if (data && Array.isArray(data.missingEnv) && data.missingEnv.length > 0) {
251
+ const missing = data.missingEnv
252
+ .map((item) => {
253
+ const name = typeof item?.name === "string" ? item.name : "";
254
+ const hint = typeof item?.hint === "string" ? item.hint : "";
255
+ if (!name) return null;
256
+ return ` ${name}${hint ? ` - ${hint}` : ""}`;
257
+ })
258
+ .filter(Boolean);
259
+ return [
260
+ data.message || data.error || `upload failed (${status})`,
261
+ "",
262
+ "Missing environment variables:",
263
+ ...missing,
264
+ "",
265
+ "Re-run with --env '{\"NAME\":\"value\"}' or configure those variables on the project.",
266
+ ].join("\n");
267
+ }
268
+ return data?.error || data?.message || text || `upload failed (${status})`;
269
+ }
270
+
271
+ function parseJson(text) {
272
+ if (!text) return null;
273
+ try {
274
+ return JSON.parse(text);
275
+ } catch {
276
+ return null;
277
+ }
278
+ }
279
+
280
+ export async function runDeploy(argv, io = defaultIo(), options = {}) {
281
+ const commandName = options.commandName ?? "anyship deploy";
282
+ const args = parseDeployArgs(argv);
283
+ if (args.help) {
284
+ io.stdout(deployUsage(commandName));
285
+ return 0;
286
+ }
287
+ const result = await deployDirectory(args, {
288
+ onUpload: (summary) => {
289
+ io.stderr(
290
+ `Uploading ${summary.fileCount} files (${summary.sourceBytes} source bytes, ${summary.archiveBytes} zipped) from ${summary.root}`,
291
+ );
292
+ },
293
+ });
294
+ if (result.dryRun) {
295
+ io.stdout(args.json ? JSON.stringify(result, null, 2) : formatDryRun(result));
296
+ } else {
297
+ io.stdout(JSON.stringify(result.response, null, 2));
298
+ }
299
+ return 0;
300
+ }
301
+
302
+ function formatDryRun(result) {
303
+ return [
304
+ `Dry run for "${result.project}" from ${result.root}`,
305
+ `${result.fileCount} files, ${result.sourceBytes} source bytes, ${result.archiveBytes} zipped`,
306
+ "",
307
+ ...result.files.map((file) => ` ${file}`),
308
+ ].join("\n");
309
+ }
310
+
311
+ function defaultIo() {
312
+ return {
313
+ stdout: (text) => console.log(text),
314
+ stderr: (text) => console.error(text),
315
+ };
316
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,46 @@
1
+ import { runDeploy } from "./deploy-directory.mjs";
2
+ import { runMcpServer } from "./mcp-server.mjs";
3
+ import { runLogin, runLogout } from "./login.mjs";
4
+
5
+ export function usage(commandName = "anyship") {
6
+ return `Usage: ${commandName} <command>
7
+
8
+ Commands:
9
+ login Save your anyship API key so deploy/mcp authenticate automatically
10
+ logout Remove stored credentials
11
+ deploy Upload a complete safe source archive and deploy it to anyship
12
+ mcp Run a local MCP server (stdio) so an AI agent can deploy directories directly
13
+
14
+ Run "${commandName} <command> --help" for command options.`;
15
+ }
16
+
17
+ export async function runCli(argv, io = defaultIo()) {
18
+ const [command, ...rest] = argv;
19
+ if (!command || command === "--help" || command === "-h" || command === "help") {
20
+ io.stdout(usage());
21
+ return 0;
22
+ }
23
+ if (command === "login") {
24
+ return runLogin(rest, io);
25
+ }
26
+ if (command === "logout") {
27
+ return runLogout(rest, io);
28
+ }
29
+ if (command === "deploy") {
30
+ return runDeploy(rest, io, { commandName: "anyship deploy" });
31
+ }
32
+ if (command === "mcp") {
33
+ // Long-lived: reads JSON-RPC on stdin, writes on stdout. Configured in a
34
+ // client via `claude mcp add anyship -- npx -y @anyship/cli mcp` (after
35
+ // `anyship login`, no --env needed).
36
+ return runMcpServer();
37
+ }
38
+ throw new Error(`unknown command: ${command}`);
39
+ }
40
+
41
+ function defaultIo() {
42
+ return {
43
+ stdout: (text) => console.log(text),
44
+ stderr: (text) => console.error(text),
45
+ };
46
+ }
package/src/login.mjs ADDED
@@ -0,0 +1,140 @@
1
+ // `anyship login` / `anyship logout`. Login validates an anyship_sk_… key against
2
+ // the control plane (via the API-key-authenticated /mcp `whoami`) and stores it
3
+ // in ~/.anyship/config.json, so `deploy` and the local MCP server pick it up with
4
+ // no --env and no token in shell history.
5
+ import { createInterface } from "node:readline";
6
+ import { DEFAULT_API } from "./deploy-directory.mjs";
7
+ import {
8
+ anyshipConfigPath,
9
+ clearAnyshipConfig,
10
+ readAnyshipConfig,
11
+ writeAnyshipConfig,
12
+ } from "./config.mjs";
13
+
14
+ export function loginUsage(commandName = "anyship login") {
15
+ return `Usage: ${commandName} [options]
16
+
17
+ Options:
18
+ --key <token> anyship API key (anyship_sk_…); omit to read from stdin or a prompt
19
+ --with-token read the key from stdin (for scripts / piping)
20
+ --api <url> control-plane URL (defaults to ANYSHIP_API_URL or ${DEFAULT_API})
21
+
22
+ Validates the key and saves it to ~/.anyship/config.json (0600). After this,
23
+ \`anyship deploy\` and \`anyship mcp\` authenticate automatically.`;
24
+ }
25
+
26
+ export function parseLoginArgs(argv) {
27
+ const args = {};
28
+ for (let i = 0; i < argv.length; i += 1) {
29
+ const arg = argv[i];
30
+ if (arg === "--help" || arg === "-h") args.help = true;
31
+ else if (arg === "--with-token") args.withToken = true;
32
+ else if (arg === "--key") args.key = requiredValue(argv, (i += 1), arg);
33
+ else if (arg === "--api") args.api = requiredValue(argv, (i += 1), arg);
34
+ else throw new Error(`unknown argument: ${arg}`);
35
+ }
36
+ return args;
37
+ }
38
+
39
+ function requiredValue(argv, index, flag) {
40
+ const value = argv[index];
41
+ if (!value || value.startsWith("--")) throw new Error(`${flag} requires a value`);
42
+ return value;
43
+ }
44
+
45
+ function readAll(stream) {
46
+ return new Promise((resolve, reject) => {
47
+ let data = "";
48
+ stream.setEncoding?.("utf8");
49
+ stream.on("data", (chunk) => (data += chunk));
50
+ stream.on("end", () => resolve(data));
51
+ stream.on("error", reject);
52
+ });
53
+ }
54
+
55
+ // Prompt for the key without echoing it to the terminal.
56
+ function promptSecret(query, input, output) {
57
+ return new Promise((resolve) => {
58
+ const rl = createInterface({ input, output, terminal: true });
59
+ output.write(query);
60
+ rl._writeToOutput = () => {}; // mute keystroke echo
61
+ rl.question("", (answer) => {
62
+ output.write("\n");
63
+ rl.close();
64
+ resolve(answer);
65
+ });
66
+ });
67
+ }
68
+
69
+ async function resolveKey(args, deps) {
70
+ if (args.key) return args.key.trim();
71
+ const input = deps.input ?? process.stdin;
72
+ const output = deps.output ?? process.stdout;
73
+ // Piped input (scripts, `echo $KEY | anyship login --with-token`).
74
+ if (args.withToken || !input.isTTY) {
75
+ const piped = await readAll(input);
76
+ if (piped.trim()) return piped.trim();
77
+ }
78
+ // Interactive, hidden.
79
+ if (input.isTTY) return (await promptSecret("Paste your anyship API key (anyship_sk_…): ", input, output)).trim();
80
+ return "";
81
+ }
82
+
83
+ // Validate a key by calling the API-key-authenticated /mcp `whoami`. Returns the
84
+ // human-readable account line, or throws with an actionable message.
85
+ export async function validateKey(api, key, fetchImpl = fetch) {
86
+ let res;
87
+ try {
88
+ res = await fetchImpl(`${api}/mcp`, {
89
+ method: "POST",
90
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
91
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "whoami", arguments: {} } }),
92
+ });
93
+ } catch {
94
+ throw new Error(`could not reach ${api} — check the URL and your connection`);
95
+ }
96
+ if (res.status === 401) throw new Error("that key was rejected (401) — check the anyship_sk_… token");
97
+ const data = await res.json().catch(() => null);
98
+ if (!res.ok || !data) throw new Error(`unexpected response from ${api}/mcp (HTTP ${res.status})`);
99
+ if (data.error) throw new Error(data.error.message || "validation failed");
100
+ const text = data.result?.content?.[0]?.text ?? "";
101
+ if (data.result?.isError) throw new Error(text || "validation failed");
102
+ return text;
103
+ }
104
+
105
+ export async function runLogin(argv, io = defaultIo(), deps = {}) {
106
+ const args = parseLoginArgs(argv);
107
+ if (args.help) {
108
+ io.stdout(loginUsage());
109
+ return 0;
110
+ }
111
+ const key = await resolveKey(args, deps);
112
+ if (!key) throw new Error("no API key provided — pass --key, pipe it with --with-token, or run interactively");
113
+ if (!/^anyship_sk_/.test(key)) {
114
+ io.stderr("warning: keys usually start with 'anyship_sk_'; continuing anyway");
115
+ }
116
+ const api = (args.api || process.env.ANYSHIP_API_URL || readAnyshipConfig(deps.home).apiUrl || DEFAULT_API).replace(/\/+$/, "");
117
+
118
+ const who = await validateKey(api, key, deps.fetch ?? fetch);
119
+ // Only persist a non-default URL so the default can still shift centrally.
120
+ const patch = { apiKey: key };
121
+ if (args.api) patch.apiUrl = api;
122
+ const path = writeAnyshipConfig(patch, deps.home);
123
+
124
+ io.stdout(`✅ ${who.split("\n")[0]}`);
125
+ io.stdout(`Saved credentials to ${path}. \`anyship deploy\` and \`anyship mcp\` are now authenticated.`);
126
+ return 0;
127
+ }
128
+
129
+ export function runLogout(_argv, io = defaultIo(), deps = {}) {
130
+ const removed = clearAnyshipConfig(deps.home);
131
+ io.stdout(removed ? `Logged out — removed ${anyshipConfigPath(deps.home)}.` : "No stored anyship credentials.");
132
+ return 0;
133
+ }
134
+
135
+ function defaultIo() {
136
+ return {
137
+ stdout: (text) => console.log(text),
138
+ stderr: (text) => console.error(text),
139
+ };
140
+ }
@@ -0,0 +1,178 @@
1
+ // Local (stdio) MCP server for anyship. This is the piece that removes the
2
+ // "remote server can't read your disk" seam: it runs on the user's machine, so
3
+ // `deploy` takes a directory PATH, gathers every file that isn't gitignored
4
+ // (reusing the CLI's git-aware archive), and uploads with the key this process
5
+ // was started with. The agent never selects files and never touches a token.
6
+ //
7
+ // Everything that isn't a local-filesystem operation (list_projects,
8
+ // deployment_status, add_domain, logs, whoami, …) is proxied verbatim to the
9
+ // remote control-plane `/mcp`, so this server automatically stays in sync with
10
+ // whatever tools the platform exposes — only `deploy` is handled locally.
11
+
12
+ import { deployDirectory, apiKey, apiUrl, codexAnyshipConfig } from "./deploy-directory.mjs";
13
+ import { createInterface } from "node:readline";
14
+ import { resolve } from "node:path";
15
+
16
+ const PROTOCOL_VERSION = "2025-06-18";
17
+ const SERVER_INFO = { name: "anyship", version: "0.1.0" };
18
+
19
+ const SERVER_INSTRUCTIONS = [
20
+ "anyship deploys a web app to a live URL. To deploy a project on disk, call `deploy` with the project name and the directory path — anyship gathers every file that isn't gitignored (via git), uploads it, detects the stack, and builds. You never select, list, or exclude files, and you never handle an API key: this server is already authenticated.",
21
+ "",
22
+ "Point `deploy` at the project's web root — the folder holding index.html / package.json (defaults to the current directory).",
23
+ "",
24
+ "Deploys are async: `deploy` returns a \"building\" status. Poll `deployment_status` for the project until it reports deployed or failed (cold builds take ~1–4 min); don't redeploy just because it isn't live yet. Redeploying the same project name updates the same site.",
25
+ ].join("\n");
26
+
27
+ // The local `deploy` tool: a directory path, not inline files.
28
+ const LOCAL_DEPLOY_TOOL = {
29
+ name: "deploy",
30
+ description:
31
+ "Deploy a local project directory to a live URL. Give the project name and the directory path; anyship gathers every file that isn't gitignored (via git ls-files), uploads it, detects the stack, and builds. You never list, select, or exclude files — .gitignore already decides what ships. Deploys are async; poll deployment_status until deployed or failed. Redeploying the same project name updates the same site.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ project: { type: "string", description: "Project name, e.g. 'my-landing-page'. Reused across deploys." },
36
+ path: {
37
+ type: "string",
38
+ description: "Path to the project directory to deploy (its web root — the folder holding index.html / package.json). Defaults to the current directory.",
39
+ },
40
+ env: {
41
+ type: "object",
42
+ additionalProperties: { type: "string" },
43
+ description: "Environment variables / secrets to merge for the project. Stored encrypted and injected as Worker secrets.",
44
+ },
45
+ },
46
+ required: ["project"],
47
+ },
48
+ };
49
+
50
+ // Tools the remote exposes that only exist to work around the no-local-files
51
+ // limitation — irrelevant here, where deploy reads the disk directly.
52
+ const LOCAL_OVERRIDDEN = new Set(["deploy", "deploy_archive", "cli_instructions"]);
53
+
54
+ // Forward a JSON-RPC call to the remote control-plane /mcp with the stored key.
55
+ async function remoteProxy(method, params, deps) {
56
+ const config = deps.config ?? codexAnyshipConfig();
57
+ const api = deps.apiUrl?.({}, config) ?? apiUrl({}, config);
58
+ const key = deps.apiKey?.({}, config) ?? apiKey({}, config);
59
+ if (!key) throw new Error("missing Anyship API key; start this server with ANYSHIP_API_KEY set");
60
+ const res = await (deps.fetch ?? fetch)(`${api}/mcp`, {
61
+ method: "POST",
62
+ headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
63
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
64
+ });
65
+ const data = await res.json().catch(() => null);
66
+ if (!data) throw new Error(`unexpected response from ${api}/mcp (${res.status})`);
67
+ return data;
68
+ }
69
+
70
+ async function localDeploy(args, deps) {
71
+ const project = String(args?.project ?? "").trim();
72
+ if (!project) throw new Error("`project` is required");
73
+ const path = typeof args?.path === "string" && args.path.trim() ? args.path : ".";
74
+ const env =
75
+ args?.env && typeof args.env === "object" && !Array.isArray(args.env)
76
+ ? JSON.stringify(args.env)
77
+ : undefined;
78
+ const runDeploy = deps.deployDirectory ?? deployDirectory;
79
+ const result = await runDeploy({ project, path, env });
80
+ const root = resolve(path);
81
+ const message = result?.response?.message ?? "Deploying. Poll deployment_status for progress.";
82
+ const depId = result?.response?.deployment?.id;
83
+ return (
84
+ `🚧 Uploaded ${result.fileCount} file${result.fileCount === 1 ? "" : "s"} from ${root} for "${project}"` +
85
+ `${depId ? ` (deployment ${depId})` : ""}.\n${message}`
86
+ );
87
+ }
88
+
89
+ // Core message handler — pure and injectable so it can be unit-tested without a
90
+ // real socket or process. Returns a JSON-RPC response object, or null for
91
+ // notifications.
92
+ export async function handleMcpMessage(msg, deps = {}) {
93
+ const { id, method, params } = msg ?? {};
94
+ const isNotification = id === undefined || id === null;
95
+
96
+ try {
97
+ switch (method) {
98
+ case "initialize":
99
+ return {
100
+ jsonrpc: "2.0",
101
+ id,
102
+ result: {
103
+ protocolVersion: params?.protocolVersion || PROTOCOL_VERSION,
104
+ capabilities: { tools: {} },
105
+ serverInfo: SERVER_INFO,
106
+ instructions: SERVER_INSTRUCTIONS,
107
+ },
108
+ };
109
+ case "tools/list": {
110
+ // Local deploy first, then whatever else the platform offers (proxied).
111
+ let tools = [LOCAL_DEPLOY_TOOL];
112
+ try {
113
+ const remote = await remoteProxy("tools/list", {}, deps);
114
+ const extra = (remote?.result?.tools ?? []).filter((t) => !LOCAL_OVERRIDDEN.has(t.name));
115
+ tools = [LOCAL_DEPLOY_TOOL, ...extra];
116
+ } catch (e) {
117
+ // Offline / no key: still advertise local deploy so the agent can act.
118
+ deps.onWarn?.(e instanceof Error ? e.message : String(e));
119
+ }
120
+ return { jsonrpc: "2.0", id, result: { tools } };
121
+ }
122
+ case "ping":
123
+ return { jsonrpc: "2.0", id, result: {} };
124
+ case "tools/call": {
125
+ const name = params?.name;
126
+ const args = params?.arguments ?? {};
127
+ if (name === "deploy") {
128
+ const text = await localDeploy(args, deps);
129
+ return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text }] } };
130
+ }
131
+ // Proxy any other tool to the remote control plane unchanged.
132
+ const data = await remoteProxy("tools/call", { name, arguments: args }, deps);
133
+ if (data?.error) throw new Error(data.error.message ?? "remote tool error");
134
+ return { jsonrpc: "2.0", id, result: data?.result ?? { content: [] } };
135
+ }
136
+ default:
137
+ if (isNotification || method?.startsWith("notifications/")) return null;
138
+ return { jsonrpc: "2.0", id: id ?? null, error: { code: -32601, message: `method not found: ${method}` } };
139
+ }
140
+ } catch (e) {
141
+ const message = e instanceof Error ? e.message : String(e);
142
+ if (isNotification) return null;
143
+ if (method === "tools/call") {
144
+ return { jsonrpc: "2.0", id, result: { content: [{ type: "text", text: message }], isError: true } };
145
+ }
146
+ return { jsonrpc: "2.0", id: id ?? null, error: { code: -32603, message } };
147
+ }
148
+ }
149
+
150
+ // Run the stdio loop: newline-delimited JSON-RPC in, one JSON object per line
151
+ // out. stdout is reserved for protocol messages; diagnostics go to stderr.
152
+ export async function runMcpServer(deps = {}) {
153
+ const input = deps.input ?? process.stdin;
154
+ const write = deps.write ?? ((line) => process.stdout.write(line + "\n"));
155
+ const warn = deps.onWarn ?? ((m) => process.stderr.write(`[anyship mcp] ${m}\n`));
156
+
157
+ const rl = createInterface({ input, crlfDelay: Infinity });
158
+ for await (const line of rl) {
159
+ const trimmed = line.trim();
160
+ if (!trimmed) continue;
161
+ let msg;
162
+ try {
163
+ msg = JSON.parse(trimmed);
164
+ } catch {
165
+ write(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }));
166
+ continue;
167
+ }
168
+ const messages = Array.isArray(msg) ? msg : [msg];
169
+ const out = [];
170
+ for (const m of messages) {
171
+ const res = await handleMcpMessage(m, { ...deps, onWarn: warn });
172
+ if (res) out.push(res);
173
+ }
174
+ if (out.length === 0) continue; // only notifications
175
+ write(JSON.stringify(Array.isArray(msg) ? out : out[0]));
176
+ }
177
+ return 0;
178
+ }