@anyship/cli 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/config.mjs +2 -2
- package/src/deploy-directory.mjs +52 -18
- package/src/login.mjs +6 -7
- package/src/mcp-server.mjs +7 -12
- package/src/oauth.mjs +0 -164
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -13,8 +13,8 @@ export function anyshipConfigPath(home = homedir()) {
|
|
|
13
13
|
return join(anyshipConfigDir(home), "config.json");
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// Returns the whole stored object (apiKey, apiUrl,
|
|
17
|
-
//
|
|
16
|
+
// Returns the whole stored object (apiKey, apiUrl, …) so a patch write preserves
|
|
17
|
+
// any fields it doesn't touch.
|
|
18
18
|
export function readAnyshipConfig(home = homedir()) {
|
|
19
19
|
try {
|
|
20
20
|
const parsed = JSON.parse(readFileSync(anyshipConfigPath(home), "utf8"));
|
package/src/deploy-directory.mjs
CHANGED
|
@@ -5,7 +5,6 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { basename, join, relative, resolve } from "node:path";
|
|
6
6
|
import { zipSync } from "fflate";
|
|
7
7
|
import { readAnyshipConfig } from "./config.mjs";
|
|
8
|
-
import { browserLogin, storedAccessToken } from "./oauth.mjs";
|
|
9
8
|
|
|
10
9
|
export const DEFAULT_API = "https://drydock.anyship.workers.dev";
|
|
11
10
|
|
|
@@ -31,8 +30,8 @@ export function deployUsage(commandName = "anyship deploy") {
|
|
|
31
30
|
|
|
32
31
|
Options:
|
|
33
32
|
--project <name> anyship project name
|
|
34
|
-
--api <url> control-plane URL (defaults to
|
|
35
|
-
--key <token> anyship API key (defaults to ANYSHIP_API_KEY or ~/.codex/config.toml)
|
|
33
|
+
--api <url> control-plane URL (defaults to your MCP connector config, ANYSHIP_API_URL, or ${DEFAULT_API})
|
|
34
|
+
--key <token> anyship API key (defaults to ANYSHIP_API_KEY, ~/.anyship, or the key in your MCP connector config — ~/.codex/config.toml or ~/.claude.json)
|
|
36
35
|
--env <json> JSON object of env vars/secrets to merge for the project
|
|
37
36
|
--dry-run Print the files that would be archived without uploading
|
|
38
37
|
--json Print dry-run output as JSON
|
|
@@ -110,7 +109,49 @@ export function codexAnyshipConfig(home = homedir()) {
|
|
|
110
109
|
return { url, token };
|
|
111
110
|
}
|
|
112
111
|
|
|
113
|
-
|
|
112
|
+
// Read the anyship connector's key/url from Claude Code's MCP config
|
|
113
|
+
// (~/.claude.json). The connector is stored with the key baked into an
|
|
114
|
+
// `Authorization: Bearer` header, either at the top level or under a per-project
|
|
115
|
+
// `projects[<path>].mcpServers`. Prefer an entry that actually carries a token.
|
|
116
|
+
export function claudeCodeAnyshipConfig(home = homedir()) {
|
|
117
|
+
const configPath = join(home, ".claude.json");
|
|
118
|
+
if (!existsSync(configPath)) return {};
|
|
119
|
+
let parsed;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
122
|
+
} catch {
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
const fromServers = (servers) => {
|
|
126
|
+
const entry = servers?.anyship;
|
|
127
|
+
if (!entry || typeof entry !== "object") return null;
|
|
128
|
+
const url = typeof entry.url === "string" ? entry.url.replace(/\/mcp\/?$/, "") : undefined;
|
|
129
|
+
const auth = (entry.headers && (entry.headers.Authorization || entry.headers.authorization)) || "";
|
|
130
|
+
const token = /Bearer\s+(anyship_sk_[A-Za-z0-9]+)/.exec(auth)?.[1];
|
|
131
|
+
return url || token ? { url, token } : null;
|
|
132
|
+
};
|
|
133
|
+
const found = [];
|
|
134
|
+
const push = (r) => { if (r) found.push(r); };
|
|
135
|
+
push(fromServers(parsed.mcpServers));
|
|
136
|
+
if (parsed.projects && typeof parsed.projects === "object") {
|
|
137
|
+
for (const proj of Object.values(parsed.projects)) push(fromServers(proj?.mcpServers));
|
|
138
|
+
}
|
|
139
|
+
return found.find((c) => c.token) || found[0] || {};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// The anyship connector config from whichever MCP client the user set up —
|
|
143
|
+
// Codex (~/.codex/config.toml) or Claude Code (~/.claude.json). Prefer whichever
|
|
144
|
+
// actually carries a key, so configuring the connector is enough for the CLI too
|
|
145
|
+
// (no separate `anyship login`).
|
|
146
|
+
export function mcpConnectorConfig(home = homedir()) {
|
|
147
|
+
const codex = codexAnyshipConfig(home);
|
|
148
|
+
if (codex.token) return codex;
|
|
149
|
+
const claudeCode = claudeCodeAnyshipConfig(home);
|
|
150
|
+
if (claudeCode.token) return claudeCode;
|
|
151
|
+
return codex.url ? codex : claudeCode;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function apiKey(args, config = mcpConnectorConfig(), stored = readAnyshipConfig()) {
|
|
114
155
|
return (
|
|
115
156
|
args.key ||
|
|
116
157
|
process.env.ANYSHIP_API_KEY ||
|
|
@@ -121,7 +162,7 @@ export function apiKey(args, config = codexAnyshipConfig(), stored = readAnyship
|
|
|
121
162
|
);
|
|
122
163
|
}
|
|
123
164
|
|
|
124
|
-
export function apiUrl(args, config =
|
|
165
|
+
export function apiUrl(args, config = mcpConnectorConfig(), stored = readAnyshipConfig()) {
|
|
125
166
|
return (
|
|
126
167
|
args.api ||
|
|
127
168
|
process.env.ANYSHIP_API_URL ||
|
|
@@ -131,23 +172,16 @@ export function apiUrl(args, config = codexAnyshipConfig(), stored = readAnyship
|
|
|
131
172
|
).replace(/\/+$/, "");
|
|
132
173
|
}
|
|
133
174
|
|
|
134
|
-
// Bearer for control-plane calls
|
|
135
|
-
// codex config)
|
|
136
|
-
//
|
|
137
|
-
// connect anyship with browser login. Pass opts.interactive === false to fail
|
|
138
|
-
// instead of opening a browser (CI / non-interactive).
|
|
175
|
+
// Bearer for control-plane calls: the anyship_sk_ API key (flag / env / stored /
|
|
176
|
+
// codex config). The API key is the only credential — there is no OAuth. Fails
|
|
177
|
+
// loudly when none is set rather than falling back to any other account.
|
|
139
178
|
export async function resolveBearer(args, io, opts = {}) {
|
|
140
179
|
const stored = readAnyshipConfig(opts.home);
|
|
141
180
|
const key = apiKey(args, undefined, stored);
|
|
142
181
|
if (key) return key;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (opts.interactive === false) {
|
|
147
|
-
throw new Error("not authenticated — set ANYSHIP_API_KEY or run `anyship login`");
|
|
148
|
-
}
|
|
149
|
-
const oauth = await browserLogin(api, io, { home: opts.home, fetch: opts.fetch });
|
|
150
|
-
return oauth.accessToken;
|
|
182
|
+
throw new Error(
|
|
183
|
+
"not authenticated — pass --key anyship_sk_…, set ANYSHIP_API_KEY, or run `anyship login --key anyship_sk_…`. Get your key from the anyship dashboard.",
|
|
184
|
+
);
|
|
151
185
|
}
|
|
152
186
|
|
|
153
187
|
function pathParts(rel) {
|
package/src/login.mjs
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// no --env and no token in shell history.
|
|
5
5
|
import { createInterface } from "node:readline";
|
|
6
6
|
import { DEFAULT_API } from "./deploy-directory.mjs";
|
|
7
|
-
import { browserLogin } from "./oauth.mjs";
|
|
8
7
|
import {
|
|
9
8
|
anyshipConfigPath,
|
|
10
9
|
clearAnyshipConfig,
|
|
@@ -67,8 +66,8 @@ function promptSecret(query, input, output) {
|
|
|
67
66
|
});
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
// An explicitly-supplied key (flag / env / piped). Returns "" when none —
|
|
71
|
-
//
|
|
69
|
+
// An explicitly-supplied key (flag / env / piped). Returns "" when none — login
|
|
70
|
+
// then errors, since an anyship_sk_ key is the only credential.
|
|
72
71
|
async function resolveKey(args, deps) {
|
|
73
72
|
if (args.key) return args.key.trim();
|
|
74
73
|
if (process.env.ANYSHIP_API_KEY) return process.env.ANYSHIP_API_KEY.trim();
|
|
@@ -112,11 +111,11 @@ export async function runLogin(argv, io = defaultIo(), deps = {}) {
|
|
|
112
111
|
const api = (args.api || process.env.ANYSHIP_API_URL || readAnyshipConfig(deps.home).apiUrl || DEFAULT_API).replace(/\/+$/, "");
|
|
113
112
|
const key = await resolveKey(args, deps);
|
|
114
113
|
|
|
115
|
-
//
|
|
114
|
+
// An anyship_sk_ key is the only credential — no browser login.
|
|
116
115
|
if (!key) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
throw new Error(
|
|
117
|
+
"no API key provided. Pass --key anyship_sk_…, set ANYSHIP_API_KEY, or pipe it with --with-token. Get your key from the anyship dashboard.",
|
|
118
|
+
);
|
|
120
119
|
}
|
|
121
120
|
|
|
122
121
|
if (!/^anyship_sk_/.test(key)) {
|
package/src/mcp-server.mjs
CHANGED
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
// remote control-plane `/mcp`, so this server automatically stays in sync with
|
|
10
10
|
// whatever tools the platform exposes — only `deploy` is handled locally.
|
|
11
11
|
|
|
12
|
-
import { deployDirectory, apiKey, apiUrl,
|
|
13
|
-
import { storedAccessToken } from "./oauth.mjs";
|
|
12
|
+
import { deployDirectory, apiKey, apiUrl, mcpConnectorConfig } from "./deploy-directory.mjs";
|
|
14
13
|
import { createInterface } from "node:readline";
|
|
15
14
|
import { resolve } from "node:path";
|
|
16
15
|
|
|
@@ -52,23 +51,19 @@ const LOCAL_DEPLOY_TOOL = {
|
|
|
52
51
|
// limitation — irrelevant here, where deploy reads the disk directly.
|
|
53
52
|
const LOCAL_OVERRIDDEN = new Set(["deploy", "deploy_archive", "cli_instructions"]);
|
|
54
53
|
|
|
55
|
-
// Bearer for the remote proxy:
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// (which does the browser flow); here we only use what's already stored.
|
|
54
|
+
// Bearer for the remote proxy: the anyship_sk_ API key (test-injected, env,
|
|
55
|
+
// ~/.anyship, or the MCP connector config — Codex or Claude Code). The API key
|
|
56
|
+
// is the only credential.
|
|
59
57
|
async function remoteBearer(deps) {
|
|
60
|
-
const config = deps.config ??
|
|
58
|
+
const config = deps.config ?? mcpConnectorConfig();
|
|
61
59
|
const key = deps.apiKey ? deps.apiKey({}, config) : apiKey({}, config);
|
|
62
60
|
if (key) return key;
|
|
63
|
-
|
|
64
|
-
const oauthTok = await storedAccessToken(api);
|
|
65
|
-
if (oauthTok) return oauthTok;
|
|
66
|
-
throw new Error("not authenticated — run `anyship login` first");
|
|
61
|
+
throw new Error("not authenticated — set ANYSHIP_API_KEY or run `anyship login --key anyship_sk_…`");
|
|
67
62
|
}
|
|
68
63
|
|
|
69
64
|
// Forward a JSON-RPC call to the remote control-plane /mcp.
|
|
70
65
|
async function remoteProxy(method, params, deps) {
|
|
71
|
-
const config = deps.config ??
|
|
66
|
+
const config = deps.config ?? mcpConnectorConfig();
|
|
72
67
|
const api = deps.apiUrl ? deps.apiUrl({}, config) : apiUrl({}, config);
|
|
73
68
|
const token = await remoteBearer(deps);
|
|
74
69
|
const res = await (deps.fetch ?? fetch)(`${api}/mcp`, {
|
package/src/oauth.mjs
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// Browser-login OAuth for the CLI — the same flow an MCP client runs, so a user
|
|
2
|
-
// who connected anyship with browser login (no API key) can also use the CLI.
|
|
3
|
-
// Loopback Authorization Code + PKCE: discover the auth server, dynamically
|
|
4
|
-
// register, open the browser, catch the redirect on 127.0.0.1, exchange the code
|
|
5
|
-
// for tokens, and store them in ~/.anyship/config.json. Refreshes on expiry.
|
|
6
|
-
import { createServer } from "node:http";
|
|
7
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
9
|
-
import { readAnyshipConfig, writeAnyshipConfig } from "./config.mjs";
|
|
10
|
-
|
|
11
|
-
const b64url = (b) => Buffer.from(b).toString("base64url");
|
|
12
|
-
|
|
13
|
-
async function discover(api, fetchImpl) {
|
|
14
|
-
const res = await fetchImpl(`${api}/.well-known/oauth-authorization-server`);
|
|
15
|
-
if (!res.ok) throw new Error(`OAuth discovery failed at ${api} (HTTP ${res.status})`);
|
|
16
|
-
const meta = await res.json();
|
|
17
|
-
if (!meta?.authorization_endpoint || !meta?.token_endpoint || !meta?.registration_endpoint) {
|
|
18
|
-
throw new Error(`${api} does not advertise the OAuth endpoints needed for login`);
|
|
19
|
-
}
|
|
20
|
-
return meta;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function openBrowser(url) {
|
|
24
|
-
const p = process.platform;
|
|
25
|
-
const [cmd, args] =
|
|
26
|
-
p === "darwin" ? ["open", [url]] : p === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
27
|
-
try {
|
|
28
|
-
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
29
|
-
} catch {
|
|
30
|
-
/* headless / no browser — the URL is printed for manual use */
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Loopback server on an ephemeral port; resolves with the OAuth redirect params.
|
|
35
|
-
function startCallbackServer() {
|
|
36
|
-
return new Promise((resolve) => {
|
|
37
|
-
let resolveCode;
|
|
38
|
-
const waitForCode = new Promise((r) => (resolveCode = r));
|
|
39
|
-
const server = createServer((req, res) => {
|
|
40
|
-
const u = new URL(req.url, "http://127.0.0.1");
|
|
41
|
-
if (u.pathname !== "/callback") {
|
|
42
|
-
res.writeHead(404);
|
|
43
|
-
res.end();
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
47
|
-
res.end(
|
|
48
|
-
"<!doctype html><html><head><meta charset='utf-8'></head>" +
|
|
49
|
-
"<body style='font:16px/1.5 system-ui;text-align:center;padding:48px'>" +
|
|
50
|
-
"<h2>anyship — signed in ✓</h2><p>You can close this tab and return to your terminal.</p>" +
|
|
51
|
-
"</body></html>",
|
|
52
|
-
);
|
|
53
|
-
resolveCode({ code: u.searchParams.get("code"), state: u.searchParams.get("state") });
|
|
54
|
-
});
|
|
55
|
-
server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port, waitForCode }));
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Run the full interactive browser login and persist the tokens. Returns the
|
|
60
|
-
// stored oauth block.
|
|
61
|
-
export async function browserLogin(api, io = defaultIo(), deps = {}) {
|
|
62
|
-
const fetchImpl = deps.fetch ?? fetch;
|
|
63
|
-
const meta = await discover(api, fetchImpl);
|
|
64
|
-
|
|
65
|
-
const { server, port, waitForCode } = await startCallbackServer();
|
|
66
|
-
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
67
|
-
try {
|
|
68
|
-
const reg = await fetchImpl(meta.registration_endpoint, {
|
|
69
|
-
method: "POST",
|
|
70
|
-
headers: { "content-type": "application/json" },
|
|
71
|
-
body: JSON.stringify({
|
|
72
|
-
client_name: "Anyship CLI",
|
|
73
|
-
redirect_uris: [redirectUri],
|
|
74
|
-
token_endpoint_auth_method: "none",
|
|
75
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
76
|
-
response_types: ["code"],
|
|
77
|
-
}),
|
|
78
|
-
});
|
|
79
|
-
const client = await reg.json().catch(() => null);
|
|
80
|
-
if (!client?.client_id) throw new Error("OAuth client registration failed");
|
|
81
|
-
|
|
82
|
-
const verifier = b64url(randomBytes(32));
|
|
83
|
-
const challenge = b64url(createHash("sha256").update(verifier).digest());
|
|
84
|
-
const state = b64url(randomBytes(16));
|
|
85
|
-
const authUrl = `${meta.authorization_endpoint}?${new URLSearchParams({
|
|
86
|
-
response_type: "code",
|
|
87
|
-
client_id: client.client_id,
|
|
88
|
-
redirect_uri: redirectUri,
|
|
89
|
-
scope: "openid profile email offline_access",
|
|
90
|
-
state,
|
|
91
|
-
code_challenge: challenge,
|
|
92
|
-
code_challenge_method: "S256",
|
|
93
|
-
})}`;
|
|
94
|
-
io.stderr(`Opening your browser to sign in to anyship…\nIf it doesn't open, visit:\n ${authUrl}`);
|
|
95
|
-
openBrowser(authUrl);
|
|
96
|
-
|
|
97
|
-
const got = await waitForCode;
|
|
98
|
-
if (got.state !== state) throw new Error("OAuth state mismatch — aborting");
|
|
99
|
-
if (!got.code) throw new Error("no authorization code returned");
|
|
100
|
-
|
|
101
|
-
const tok = await fetchImpl(meta.token_endpoint, {
|
|
102
|
-
method: "POST",
|
|
103
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
104
|
-
body: new URLSearchParams({
|
|
105
|
-
grant_type: "authorization_code",
|
|
106
|
-
code: got.code,
|
|
107
|
-
redirect_uri: redirectUri,
|
|
108
|
-
client_id: client.client_id,
|
|
109
|
-
code_verifier: verifier,
|
|
110
|
-
}).toString(),
|
|
111
|
-
});
|
|
112
|
-
const t = await tok.json().catch(() => null);
|
|
113
|
-
if (!t?.access_token) throw new Error("token exchange failed");
|
|
114
|
-
|
|
115
|
-
const oauth = {
|
|
116
|
-
apiUrl: api,
|
|
117
|
-
clientId: client.client_id,
|
|
118
|
-
accessToken: t.access_token,
|
|
119
|
-
refreshToken: t.refresh_token || null,
|
|
120
|
-
expiresAt: t.expires_in ? Date.now() + t.expires_in * 1000 : null,
|
|
121
|
-
};
|
|
122
|
-
writeAnyshipConfig({ oauth }, deps.home);
|
|
123
|
-
io.stderr("Signed in to anyship.");
|
|
124
|
-
return oauth;
|
|
125
|
-
} finally {
|
|
126
|
-
server.close();
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Return a valid stored access token (refreshing if near expiry), or null if
|
|
131
|
-
// there's no usable OAuth token for this api.
|
|
132
|
-
export async function storedAccessToken(api, deps = {}) {
|
|
133
|
-
const oauth = readAnyshipConfig(deps.home).oauth;
|
|
134
|
-
if (!oauth?.accessToken || oauth.apiUrl !== api) return null;
|
|
135
|
-
if (!oauth.expiresAt || oauth.expiresAt - Date.now() > 60_000) return oauth.accessToken;
|
|
136
|
-
if (!oauth.refreshToken) return null;
|
|
137
|
-
|
|
138
|
-
const fetchImpl = deps.fetch ?? fetch;
|
|
139
|
-
const meta = await discover(api, fetchImpl).catch(() => null);
|
|
140
|
-
if (!meta?.token_endpoint) return null;
|
|
141
|
-
const res = await fetchImpl(meta.token_endpoint, {
|
|
142
|
-
method: "POST",
|
|
143
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
144
|
-
body: new URLSearchParams({
|
|
145
|
-
grant_type: "refresh_token",
|
|
146
|
-
refresh_token: oauth.refreshToken,
|
|
147
|
-
client_id: oauth.clientId,
|
|
148
|
-
}).toString(),
|
|
149
|
-
}).catch(() => null);
|
|
150
|
-
const t = res && (await res.json().catch(() => null));
|
|
151
|
-
if (!t?.access_token) return null;
|
|
152
|
-
const updated = {
|
|
153
|
-
...oauth,
|
|
154
|
-
accessToken: t.access_token,
|
|
155
|
-
refreshToken: t.refresh_token || oauth.refreshToken,
|
|
156
|
-
expiresAt: t.expires_in ? Date.now() + t.expires_in * 1000 : null,
|
|
157
|
-
};
|
|
158
|
-
writeAnyshipConfig({ oauth: updated }, deps.home);
|
|
159
|
-
return updated.accessToken;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function defaultIo() {
|
|
163
|
-
return { stdout: (t) => console.log(t), stderr: (t) => console.error(t) };
|
|
164
|
-
}
|