@anyship/cli 0.1.0 → 0.2.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 CHANGED
@@ -21,15 +21,24 @@ never hand-pick files.
21
21
 
22
22
  ### `anyship login`
23
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.
24
+ Run **`anyship login`** with no arguments and it opens your browser to sign in
25
+ (OAuth) the same login you use for the anyship connector, no key required. It
26
+ stores the resulting token in `~/.anyship/config.json` (mode `0600`), refreshing
27
+ it automatically. This is the common path now that most people connect with
28
+ browser login.
29
+
30
+ Prefer an API key (CI, scripts)? Pass one and it's validated + stored instead:
27
31
 
28
32
  ```bash
29
- anyship login --key anyship_sk_… # or: echo $KEY | anyship login --with-token
30
- anyship logout # remove stored credentials
33
+ anyship login # browser login (OAuth) no key
34
+ anyship login --key anyship_sk_… # or an API key: echo $KEY | anyship login --with-token
35
+ anyship logout # remove stored credentials
31
36
  ```
32
37
 
38
+ `deploy` and `mcp` then authenticate automatically. If neither a key nor a stored
39
+ token is present, `deploy` triggers the browser login on first use; the `mcp`
40
+ server uses only stored auth (run `anyship login` once first).
41
+
33
42
  ### `anyship deploy [path]`
34
43
 
35
44
  Archive and deploy a project directory (defaults to `.`). anyship detects the
@@ -60,11 +69,14 @@ files and never handles your API key.
60
69
 
61
70
  ## Configuration
62
71
 
63
- Key and control-plane URL resolve in this order:
72
+ Auth resolves in this order an **API key** if you have one, otherwise **OAuth**:
64
73
 
65
- 1. explicit flag (`--key` / `--api`)
66
- 2. environment (`ANYSHIP_API_KEY` / `ANYSHIP_API_URL`)
67
- 3. stored login (`~/.anyship/config.json`)
74
+ 1. `--key` flag
75
+ 2. `ANYSHIP_API_KEY` env
76
+ 3. a stored API key (`~/.anyship/config.json`)
68
77
  4. an `mcp_servers.anyship` entry in `~/.codex/config.toml`
78
+ 5. a stored **OAuth token** (`~/.anyship/config.json`, auto-refreshed)
79
+ 6. otherwise → browser login (interactive commands only)
69
80
 
70
- Requires Node.js 18+.
81
+ The control-plane URL comes from `--api`, `ANYSHIP_API_URL`, the stored config, or
82
+ the default. Requires Node.js 18+.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyship/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "anyship CLI — deploy a local project directory, run a local MCP server for AI agents, and log in.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -13,13 +13,12 @@ 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, oauth, …) so writes preserve
17
+ // every field — the OAuth token block must survive an apiKey/apiUrl patch.
16
18
  export function readAnyshipConfig(home = homedir()) {
17
19
  try {
18
20
  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
- };
21
+ return parsed && typeof parsed === "object" ? parsed : {};
23
22
  } catch {
24
23
  return {};
25
24
  }
@@ -5,6 +5,7 @@ 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";
8
9
 
9
10
  export const DEFAULT_API = "https://drydock.anyship.workers.dev";
10
11
 
@@ -130,6 +131,25 @@ export function apiUrl(args, config = codexAnyshipConfig(), stored = readAnyship
130
131
  ).replace(/\/+$/, "");
131
132
  }
132
133
 
134
+ // Bearer for control-plane calls. Precedence: an API key (flag / env / stored /
135
+ // codex config) → a stored OAuth token (refreshed if expiring) → an interactive
136
+ // browser login. The no-key OAuth path is the common one now that most users
137
+ // connect anyship with browser login. Pass opts.interactive === false to fail
138
+ // instead of opening a browser (CI / non-interactive).
139
+ export async function resolveBearer(args, io, opts = {}) {
140
+ const stored = readAnyshipConfig(opts.home);
141
+ const key = apiKey(args, undefined, stored);
142
+ if (key) return key;
143
+ const api = apiUrl(args, undefined, stored);
144
+ const existing = await storedAccessToken(api, { home: opts.home, fetch: opts.fetch });
145
+ if (existing) return existing;
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;
151
+ }
152
+
133
153
  function pathParts(rel) {
134
154
  return rel.replace(/\\/g, "/").split("/").filter(Boolean);
135
155
  }
@@ -220,12 +240,9 @@ export async function deployDirectory(args, hooks = {}) {
220
240
 
221
241
  if (args.dryRun) return { dryRun: true, ...summary };
222
242
 
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);
243
+ const api = apiUrl(args);
244
+ const io = hooks.io ?? { stdout: () => {}, stderr: (t) => process.stderr.write(`${t}\n`) };
245
+ const token = await resolveBearer(args, io, { interactive: hooks.interactive });
229
246
  hooks.onUpload?.({ ...summary, api });
230
247
 
231
248
  const form = new FormData();
@@ -235,7 +252,7 @@ export async function deployDirectory(args, hooks = {}) {
235
252
 
236
253
  const res = await fetch(`${api}/api/deploy-from-archive`, {
237
254
  method: "POST",
238
- headers: { Authorization: `Bearer ${key}` },
255
+ headers: { Authorization: `Bearer ${token}` },
239
256
  body: form,
240
257
  });
241
258
  const text = await res.text();
package/src/login.mjs CHANGED
@@ -4,6 +4,7 @@
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";
7
8
  import {
8
9
  anyshipConfigPath,
9
10
  clearAnyshipConfig,
@@ -66,17 +67,17 @@ function promptSecret(query, input, output) {
66
67
  });
67
68
  }
68
69
 
70
+ // An explicitly-supplied key (flag / env / piped). Returns "" when none — that's
71
+ // the signal to fall back to browser-login OAuth.
69
72
  async function resolveKey(args, deps) {
70
73
  if (args.key) return args.key.trim();
74
+ if (process.env.ANYSHIP_API_KEY) return process.env.ANYSHIP_API_KEY.trim();
71
75
  const input = deps.input ?? process.stdin;
72
- const output = deps.output ?? process.stdout;
73
76
  // Piped input (scripts, `echo $KEY | anyship login --with-token`).
74
77
  if (args.withToken || !input.isTTY) {
75
78
  const piped = await readAll(input);
76
79
  if (piped.trim()) return piped.trim();
77
80
  }
78
- // Interactive, hidden.
79
- if (input.isTTY) return (await promptSecret("Paste your anyship API key (anyship_sk_…): ", input, output)).trim();
80
81
  return "";
81
82
  }
82
83
 
@@ -108,13 +109,19 @@ export async function runLogin(argv, io = defaultIo(), deps = {}) {
108
109
  io.stdout(loginUsage());
109
110
  return 0;
110
111
  }
112
+ const api = (args.api || process.env.ANYSHIP_API_URL || readAnyshipConfig(deps.home).apiUrl || DEFAULT_API).replace(/\/+$/, "");
111
113
  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");
114
+
115
+ // No key supplied → browser-login OAuth (the common case). A key → validate + store.
116
+ if (!key) {
117
+ await browserLogin(api, io, deps);
118
+ io.stdout("✅ Signed in to anyship. `anyship deploy` and `anyship mcp` are now authenticated.");
119
+ return 0;
120
+ }
121
+
113
122
  if (!/^anyship_sk_/.test(key)) {
114
123
  io.stderr("warning: keys usually start with 'anyship_sk_'; continuing anyway");
115
124
  }
116
- const api = (args.api || process.env.ANYSHIP_API_URL || readAnyshipConfig(deps.home).apiUrl || DEFAULT_API).replace(/\/+$/, "");
117
-
118
125
  const who = await validateKey(api, key, deps.fetch ?? fetch);
119
126
  // Only persist a non-default URL so the default can still shift centrally.
120
127
  const patch = { apiKey: key };
@@ -10,6 +10,7 @@
10
10
  // whatever tools the platform exposes — only `deploy` is handled locally.
11
11
 
12
12
  import { deployDirectory, apiKey, apiUrl, codexAnyshipConfig } from "./deploy-directory.mjs";
13
+ import { storedAccessToken } from "./oauth.mjs";
13
14
  import { createInterface } from "node:readline";
14
15
  import { resolve } from "node:path";
15
16
 
@@ -51,15 +52,28 @@ const LOCAL_DEPLOY_TOOL = {
51
52
  // limitation — irrelevant here, where deploy reads the disk directly.
52
53
  const LOCAL_OVERRIDDEN = new Set(["deploy", "deploy_archive", "cli_instructions"]);
53
54
 
54
- // Forward a JSON-RPC call to the remote control-plane /mcp with the stored key.
55
+ // Bearer for the remote proxy: an API key (test-injected, env, stored, or codex
56
+ // config) or a stored OAuth token from a prior `anyship login`. This background
57
+ // server never opens a browser — the user authenticates once with `anyship login`
58
+ // (which does the browser flow); here we only use what's already stored.
59
+ async function remoteBearer(deps) {
60
+ const config = deps.config ?? codexAnyshipConfig();
61
+ const key = deps.apiKey ? deps.apiKey({}, config) : apiKey({}, config);
62
+ if (key) return key;
63
+ const api = deps.apiUrl ? deps.apiUrl({}, config) : apiUrl({}, config);
64
+ const oauthTok = await storedAccessToken(api);
65
+ if (oauthTok) return oauthTok;
66
+ throw new Error("not authenticated — run `anyship login` first");
67
+ }
68
+
69
+ // Forward a JSON-RPC call to the remote control-plane /mcp.
55
70
  async function remoteProxy(method, params, deps) {
56
71
  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");
72
+ const api = deps.apiUrl ? deps.apiUrl({}, config) : apiUrl({}, config);
73
+ const token = await remoteBearer(deps);
60
74
  const res = await (deps.fetch ?? fetch)(`${api}/mcp`, {
61
75
  method: "POST",
62
- headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
76
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
63
77
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
64
78
  });
65
79
  const data = await res.json().catch(() => null);
@@ -76,7 +90,7 @@ async function localDeploy(args, deps) {
76
90
  ? JSON.stringify(args.env)
77
91
  : undefined;
78
92
  const runDeploy = deps.deployDirectory ?? deployDirectory;
79
- const result = await runDeploy({ project, path, env });
93
+ const result = await runDeploy({ project, path, env }, { interactive: false });
80
94
  const root = resolve(path);
81
95
  const message = result?.response?.message ?? "Deploying. Poll deployment_status for progress.";
82
96
  const depId = result?.response?.deployment?.id;
package/src/oauth.mjs ADDED
@@ -0,0 +1,162 @@
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" });
47
+ res.end(
48
+ "<!doctype html><body style='font:16px/1.5 system-ui;text-align:center;padding:48px'>" +
49
+ "<h2>anyship — signed in ✓</h2><p>You can close this tab and return to your terminal.</p>",
50
+ );
51
+ resolveCode({ code: u.searchParams.get("code"), state: u.searchParams.get("state") });
52
+ });
53
+ server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port, waitForCode }));
54
+ });
55
+ }
56
+
57
+ // Run the full interactive browser login and persist the tokens. Returns the
58
+ // stored oauth block.
59
+ export async function browserLogin(api, io = defaultIo(), deps = {}) {
60
+ const fetchImpl = deps.fetch ?? fetch;
61
+ const meta = await discover(api, fetchImpl);
62
+
63
+ const { server, port, waitForCode } = await startCallbackServer();
64
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
65
+ try {
66
+ const reg = await fetchImpl(meta.registration_endpoint, {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({
70
+ client_name: "Anyship CLI",
71
+ redirect_uris: [redirectUri],
72
+ token_endpoint_auth_method: "none",
73
+ grant_types: ["authorization_code", "refresh_token"],
74
+ response_types: ["code"],
75
+ }),
76
+ });
77
+ const client = await reg.json().catch(() => null);
78
+ if (!client?.client_id) throw new Error("OAuth client registration failed");
79
+
80
+ const verifier = b64url(randomBytes(32));
81
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
82
+ const state = b64url(randomBytes(16));
83
+ const authUrl = `${meta.authorization_endpoint}?${new URLSearchParams({
84
+ response_type: "code",
85
+ client_id: client.client_id,
86
+ redirect_uri: redirectUri,
87
+ scope: "openid profile email offline_access",
88
+ state,
89
+ code_challenge: challenge,
90
+ code_challenge_method: "S256",
91
+ })}`;
92
+ io.stderr(`Opening your browser to sign in to anyship…\nIf it doesn't open, visit:\n ${authUrl}`);
93
+ openBrowser(authUrl);
94
+
95
+ const got = await waitForCode;
96
+ if (got.state !== state) throw new Error("OAuth state mismatch — aborting");
97
+ if (!got.code) throw new Error("no authorization code returned");
98
+
99
+ const tok = await fetchImpl(meta.token_endpoint, {
100
+ method: "POST",
101
+ headers: { "content-type": "application/x-www-form-urlencoded" },
102
+ body: new URLSearchParams({
103
+ grant_type: "authorization_code",
104
+ code: got.code,
105
+ redirect_uri: redirectUri,
106
+ client_id: client.client_id,
107
+ code_verifier: verifier,
108
+ }).toString(),
109
+ });
110
+ const t = await tok.json().catch(() => null);
111
+ if (!t?.access_token) throw new Error("token exchange failed");
112
+
113
+ const oauth = {
114
+ apiUrl: api,
115
+ clientId: client.client_id,
116
+ accessToken: t.access_token,
117
+ refreshToken: t.refresh_token || null,
118
+ expiresAt: t.expires_in ? Date.now() + t.expires_in * 1000 : null,
119
+ };
120
+ writeAnyshipConfig({ oauth }, deps.home);
121
+ io.stderr("Signed in to anyship.");
122
+ return oauth;
123
+ } finally {
124
+ server.close();
125
+ }
126
+ }
127
+
128
+ // Return a valid stored access token (refreshing if near expiry), or null if
129
+ // there's no usable OAuth token for this api.
130
+ export async function storedAccessToken(api, deps = {}) {
131
+ const oauth = readAnyshipConfig(deps.home).oauth;
132
+ if (!oauth?.accessToken || oauth.apiUrl !== api) return null;
133
+ if (!oauth.expiresAt || oauth.expiresAt - Date.now() > 60_000) return oauth.accessToken;
134
+ if (!oauth.refreshToken) return null;
135
+
136
+ const fetchImpl = deps.fetch ?? fetch;
137
+ const meta = await discover(api, fetchImpl).catch(() => null);
138
+ if (!meta?.token_endpoint) return null;
139
+ const res = await fetchImpl(meta.token_endpoint, {
140
+ method: "POST",
141
+ headers: { "content-type": "application/x-www-form-urlencoded" },
142
+ body: new URLSearchParams({
143
+ grant_type: "refresh_token",
144
+ refresh_token: oauth.refreshToken,
145
+ client_id: oauth.clientId,
146
+ }).toString(),
147
+ }).catch(() => null);
148
+ const t = res && (await res.json().catch(() => null));
149
+ if (!t?.access_token) return null;
150
+ const updated = {
151
+ ...oauth,
152
+ accessToken: t.access_token,
153
+ refreshToken: t.refresh_token || oauth.refreshToken,
154
+ expiresAt: t.expires_in ? Date.now() + t.expires_in * 1000 : null,
155
+ };
156
+ writeAnyshipConfig({ oauth: updated }, deps.home);
157
+ return updated.accessToken;
158
+ }
159
+
160
+ function defaultIo() {
161
+ return { stdout: (t) => console.log(t), stderr: (t) => console.error(t) };
162
+ }