cinatra 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/LICENSE +202 -0
- package/README.md +77 -0
- package/bin/cinatra.mjs +8 -0
- package/package.json +32 -0
- package/src/agents-install.mjs +801 -0
- package/src/checkout-resolve.mjs +236 -0
- package/src/cinatra-dev-extensions.mjs +338 -0
- package/src/clone-registry.mjs +623 -0
- package/src/clone-runtime.mjs +543 -0
- package/src/command-table.mjs +390 -0
- package/src/dev-apps.mjs +79 -0
- package/src/dev-cli-modules.mjs +91 -0
- package/src/dev-refresh.mjs +117 -0
- package/src/dev-repo-sync.mjs +297 -0
- package/src/extensions-dependency-gate.mjs +258 -0
- package/src/extensions-submit.mjs +137 -0
- package/src/index.mjs +9203 -0
- package/src/install.mjs +815 -0
- package/src/login.mjs +508 -0
- package/src/marketplace-mcp.mjs +100 -0
- package/src/mcp-public-base-url-shape.mjs +134 -0
- package/src/prod-extension-acquisition.mjs +679 -0
- package/src/seed-local-registry.mjs +538 -0
- package/src/tailscale-provision.mjs +219 -0
- package/src/teardown-config.mjs +113 -0
- package/src/worktree-collision-guard.mjs +157 -0
package/src/login.mjs
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
// packages/cli/src/login.mjs
|
|
2
|
+
//
|
|
3
|
+
// `cinatra login` — interactive browser sign-in to a Cinatra instance, plus
|
|
4
|
+
// the token cache + profile model the Class-A control-plane client uses.
|
|
5
|
+
//
|
|
6
|
+
// cinatra#255 (G2). Builds STRICTLY on the instance's EXISTING OAuth surface —
|
|
7
|
+
// it invents no new server auth:
|
|
8
|
+
// * Discovers OAuth metadata via the standard
|
|
9
|
+
// `/.well-known/oauth-authorization-server` document.
|
|
10
|
+
// * Registers a PUBLIC client via Dynamic Client Registration (DCR is on by
|
|
11
|
+
// default on the instance), no secret stored.
|
|
12
|
+
// * Runs the `authorization_code` + PKCE flow with a loopback redirect
|
|
13
|
+
// listener, opening the system browser.
|
|
14
|
+
// * Caches tokens per named PROFILE (keyed by `--app-url`) under
|
|
15
|
+
// `~/.config/cinatra/credentials.json` at mode 0600, and refreshes with the
|
|
16
|
+
// stored `refresh_token` before expiry.
|
|
17
|
+
//
|
|
18
|
+
// All OAuth mechanics come from `@modelcontextprotocol/sdk/client/auth.js`
|
|
19
|
+
// (already a CLI dependency, ships an OAuth client) — we drive its pure
|
|
20
|
+
// primitives (`discoverAuthorizationServerMetadata`, `registerClient`,
|
|
21
|
+
// `startAuthorization`, `exchangeAuthorization`, `refreshAuthorization`) rather
|
|
22
|
+
// than reimplementing the protocol.
|
|
23
|
+
//
|
|
24
|
+
// SECURITY: tokens are NEVER logged. The cache file is created/chmod'd 0600.
|
|
25
|
+
// The loopback listener binds 127.0.0.1 on an ephemeral port, accepts exactly
|
|
26
|
+
// ONE redirect, validates `state`, and shuts down immediately.
|
|
27
|
+
|
|
28
|
+
import { createServer } from "node:http";
|
|
29
|
+
import { mkdir, readFile, writeFile, chmod, rename, unlink } from "node:fs/promises";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { join, dirname } from "node:path";
|
|
32
|
+
import { spawn } from "node:child_process";
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
discoverAuthorizationServerMetadata,
|
|
37
|
+
registerClient,
|
|
38
|
+
startAuthorization,
|
|
39
|
+
exchangeAuthorization,
|
|
40
|
+
refreshAuthorization,
|
|
41
|
+
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
42
|
+
|
|
43
|
+
// The OAuth scopes the CLI requests. `mcp:connect` is the instance's admission
|
|
44
|
+
// scope for the control plane; the rest are standard OIDC scopes for the token
|
|
45
|
+
// + refresh. Authorization (role) is enforced SERVER-SIDE per endpoint — the
|
|
46
|
+
// scope only admits.
|
|
47
|
+
export const CLI_OAUTH_SCOPES = [
|
|
48
|
+
"openid",
|
|
49
|
+
"profile",
|
|
50
|
+
"email",
|
|
51
|
+
"offline_access",
|
|
52
|
+
"mcp:connect",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const CLIENT_NAME = "Cinatra CLI";
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Credentials store
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the credentials file path. Honors `XDG_CONFIG_HOME`, else
|
|
63
|
+
* `~/.config/cinatra/credentials.json`.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveCredentialsPath(env = process.env) {
|
|
66
|
+
const xdg = env.XDG_CONFIG_HOME?.trim();
|
|
67
|
+
const base = xdg ? xdg : join(homedir(), ".config");
|
|
68
|
+
return join(base, "cinatra", "credentials.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Loopback hostnames that may legitimately be served over plain HTTP in dev. */
|
|
72
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
73
|
+
|
|
74
|
+
function isLoopbackHostname(hostname) {
|
|
75
|
+
return LOOPBACK_HOSTNAMES.has(hostname);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize an app URL into a stable profile key (origin only, no trailing
|
|
80
|
+
* slash). Throws on an invalid URL so a typo can't silently create a junk
|
|
81
|
+
* profile.
|
|
82
|
+
*
|
|
83
|
+
* SECURITY: rejects plaintext `http:` for any NON-loopback host. OAuth tokens
|
|
84
|
+
* (and the later `Authorization: Bearer …` calls) must never cross the network
|
|
85
|
+
* in the clear — only `https:`, or `http:` to a loopback dev host, is allowed.
|
|
86
|
+
*/
|
|
87
|
+
export function appUrlToProfileKey(appUrl) {
|
|
88
|
+
let u;
|
|
89
|
+
try {
|
|
90
|
+
u = new URL(appUrl);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`Invalid --app-url: ${appUrl}`);
|
|
93
|
+
}
|
|
94
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
95
|
+
throw new Error(`--app-url must be http(s): ${appUrl}`);
|
|
96
|
+
}
|
|
97
|
+
if (u.protocol === "http:" && !isLoopbackHostname(u.hostname)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`--app-url must use https for a remote host (plaintext http is allowed only for loopback): ${appUrl}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return u.origin;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Read the full credentials store ({ version, profiles, defaultProfile }). */
|
|
106
|
+
export async function readCredentialsStore(env = process.env) {
|
|
107
|
+
const path = resolveCredentialsPath(env);
|
|
108
|
+
try {
|
|
109
|
+
const raw = await readFile(path, "utf8");
|
|
110
|
+
const parsed = JSON.parse(raw);
|
|
111
|
+
if (parsed && typeof parsed === "object") {
|
|
112
|
+
return {
|
|
113
|
+
version: parsed.version ?? 1,
|
|
114
|
+
defaultProfile: parsed.defaultProfile ?? null,
|
|
115
|
+
profiles:
|
|
116
|
+
parsed.profiles && typeof parsed.profiles === "object"
|
|
117
|
+
? parsed.profiles
|
|
118
|
+
: {},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// ENOENT / malformed → empty store.
|
|
123
|
+
}
|
|
124
|
+
return { version: 1, defaultProfile: null, profiles: {} };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Write the credentials store ATOMICALLY with strict 0600 perms.
|
|
129
|
+
*
|
|
130
|
+
* SECURITY: the token body is written to a FRESH 0600 temp file (so it never
|
|
131
|
+
* exists at the final path while world-readable, even if a pre-existing
|
|
132
|
+
* `credentials.json` was 0644), then atomically renamed into place. The
|
|
133
|
+
* `wx` flag ensures the temp file is newly created (never an attacker-planted
|
|
134
|
+
* symlink) and `mode: 0o600` sets its perms at creation. The parent dir is
|
|
135
|
+
* created 0700.
|
|
136
|
+
*/
|
|
137
|
+
export async function writeCredentialsStore(store, env = process.env) {
|
|
138
|
+
const path = resolveCredentialsPath(env);
|
|
139
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
140
|
+
const body = JSON.stringify(store, null, 2);
|
|
141
|
+
const tmpPath = `${path}.${randomUUID()}.tmp`;
|
|
142
|
+
try {
|
|
143
|
+
// `wx` = create + exclusive (fail if exists); mode 0o600 at creation time.
|
|
144
|
+
await writeFile(tmpPath, body, { mode: 0o600, flag: "wx" });
|
|
145
|
+
await chmod(tmpPath, 0o600).catch(() => {});
|
|
146
|
+
await rename(tmpPath, path);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
await unlink(tmpPath).catch(() => {});
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Persist a single profile's record, optionally marking it the default. */
|
|
154
|
+
export async function saveProfile(profileKey, record, options = {}, env = process.env) {
|
|
155
|
+
const store = await readCredentialsStore(env);
|
|
156
|
+
store.profiles[profileKey] = record;
|
|
157
|
+
if (options.makeDefault || !store.defaultProfile) {
|
|
158
|
+
store.defaultProfile = profileKey;
|
|
159
|
+
}
|
|
160
|
+
await writeCredentialsStore(store, env);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Loopback redirect listener
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Start a one-shot loopback listener on 127.0.0.1:<ephemeral>. Resolves with
|
|
169
|
+
* `{ redirectUrl, waitForCode() }`. `waitForCode()` resolves with the `code`
|
|
170
|
+
* once the browser is redirected back (validating `state`), or rejects on an
|
|
171
|
+
* OAuth error redirect / state mismatch. The server is always closed.
|
|
172
|
+
*/
|
|
173
|
+
export async function startLoopbackListener(expectedState) {
|
|
174
|
+
return await new Promise((resolveListener, rejectListener) => {
|
|
175
|
+
let resolveCode;
|
|
176
|
+
let rejectCode;
|
|
177
|
+
const codePromise = new Promise((res, rej) => {
|
|
178
|
+
resolveCode = res;
|
|
179
|
+
rejectCode = rej;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const server = createServer((req, res) => {
|
|
183
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
184
|
+
if (url.pathname !== "/callback") {
|
|
185
|
+
res.writeHead(404).end("Not found");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const error = url.searchParams.get("error");
|
|
189
|
+
const code = url.searchParams.get("code");
|
|
190
|
+
const state = url.searchParams.get("state");
|
|
191
|
+
|
|
192
|
+
const finish = (statusText) => {
|
|
193
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
194
|
+
res.end(
|
|
195
|
+
`<!doctype html><html><body style="font-family:system-ui;padding:2rem">` +
|
|
196
|
+
`<h2>Cinatra CLI</h2><p>${statusText}</p>` +
|
|
197
|
+
`<p>You can close this tab and return to your terminal.</p></body></html>`,
|
|
198
|
+
);
|
|
199
|
+
server.close();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
if (error) {
|
|
203
|
+
finish("Sign-in failed. See your terminal for details.");
|
|
204
|
+
rejectCode(new Error(`Authorization error: ${error}`));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!code) {
|
|
208
|
+
finish("Sign-in failed: no authorization code returned.");
|
|
209
|
+
rejectCode(new Error("No authorization code in redirect."));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (state !== expectedState) {
|
|
213
|
+
finish("Sign-in failed: state mismatch.");
|
|
214
|
+
rejectCode(new Error("State mismatch in OAuth redirect (possible CSRF)."));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
finish("Signed in successfully.");
|
|
218
|
+
resolveCode(code);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
server.on("error", rejectListener);
|
|
222
|
+
// Bind loopback + ephemeral port.
|
|
223
|
+
server.listen(0, "127.0.0.1", () => {
|
|
224
|
+
const addr = server.address();
|
|
225
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
226
|
+
const redirectUrl = `http://127.0.0.1:${port}/callback`;
|
|
227
|
+
resolveListener({ redirectUrl, waitForCode: () => codePromise, close: () => server.close() });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Open a URL in the system browser (best-effort; never throws). */
|
|
233
|
+
export function openBrowser(url) {
|
|
234
|
+
const platform = process.platform;
|
|
235
|
+
const cmd =
|
|
236
|
+
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
237
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
238
|
+
try {
|
|
239
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
240
|
+
child.on("error", () => {});
|
|
241
|
+
child.unref();
|
|
242
|
+
} catch {
|
|
243
|
+
// No browser launcher available — the caller prints the URL as a fallback.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Login flow
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Run the interactive `cinatra login` flow against `appUrl`.
|
|
253
|
+
*
|
|
254
|
+
* @param {object} opts
|
|
255
|
+
* @param {string} opts.appUrl Target instance origin (required).
|
|
256
|
+
* @param {string} [opts.profile] Profile name override (defaults to the origin).
|
|
257
|
+
* @param {boolean} [opts.makeDefault] Mark this profile the default target.
|
|
258
|
+
* @param {(url:string)=>void} [opts.open] Browser opener (injectable for tests).
|
|
259
|
+
* @param {(m:string)=>void} [opts.log] Logger (injectable for tests).
|
|
260
|
+
* @param {object} [opts.env]
|
|
261
|
+
* @returns {Promise<{ profileKey: string }>}
|
|
262
|
+
*/
|
|
263
|
+
export async function runLogin(opts) {
|
|
264
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
265
|
+
const open = opts.open ?? openBrowser;
|
|
266
|
+
const env = opts.env ?? process.env;
|
|
267
|
+
|
|
268
|
+
if (!opts.appUrl) {
|
|
269
|
+
throw new Error("Usage: cinatra login --app-url <https://instance> [--profile <name>] [--default]");
|
|
270
|
+
}
|
|
271
|
+
const profileKey = opts.profile ?? appUrlToProfileKey(opts.appUrl);
|
|
272
|
+
const origin = appUrlToProfileKey(opts.appUrl);
|
|
273
|
+
|
|
274
|
+
// 1. Discover the authorization-server metadata.
|
|
275
|
+
log(`Discovering OAuth configuration at ${origin} …`);
|
|
276
|
+
const metadata = await discoverAuthorizationServerMetadata(origin);
|
|
277
|
+
if (!metadata) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Could not discover OAuth metadata at ${origin}/.well-known/oauth-authorization-server.`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 2. Start the loopback listener so we have the concrete redirect URI for DCR.
|
|
284
|
+
const expectedState = randomUUID();
|
|
285
|
+
const listener = await startLoopbackListener(expectedState);
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
// 3. Register a PUBLIC client (DCR). No secret is requested or stored.
|
|
289
|
+
const clientInformation = await registerClient(origin, {
|
|
290
|
+
metadata,
|
|
291
|
+
clientMetadata: {
|
|
292
|
+
client_name: CLIENT_NAME,
|
|
293
|
+
redirect_uris: [listener.redirectUrl],
|
|
294
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
295
|
+
response_types: ["code"],
|
|
296
|
+
token_endpoint_auth_method: "none", // public client (PKCE)
|
|
297
|
+
scope: CLI_OAUTH_SCOPES.join(" "),
|
|
298
|
+
},
|
|
299
|
+
scope: CLI_OAUTH_SCOPES.join(" "),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// FAIL CLOSED if the AS handed back a CONFIDENTIAL client. We only persist
|
|
303
|
+
// `client_id` (a public client has no secret), so a confidential client
|
|
304
|
+
// would exchange once but break on refresh — and we must not be tricked
|
|
305
|
+
// into a secret-bearing flow on a CLI. Require a public (`none`) client.
|
|
306
|
+
const issuedAuthMethod =
|
|
307
|
+
clientInformation.token_endpoint_auth_method ?? "none";
|
|
308
|
+
if (clientInformation.client_secret || issuedAuthMethod !== "none") {
|
|
309
|
+
throw new Error(
|
|
310
|
+
"Refusing to continue: the instance issued a confidential OAuth client " +
|
|
311
|
+
`(token_endpoint_auth_method="${issuedAuthMethod}"). The CLI requires a ` +
|
|
312
|
+
"public PKCE client. Check the instance's Dynamic Client Registration policy.",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 4. Build the authorization URL (PKCE) and open the browser.
|
|
317
|
+
const { authorizationUrl, codeVerifier } = await startAuthorization(origin, {
|
|
318
|
+
metadata,
|
|
319
|
+
clientInformation,
|
|
320
|
+
redirectUrl: listener.redirectUrl,
|
|
321
|
+
scope: CLI_OAUTH_SCOPES.join(" "),
|
|
322
|
+
state: expectedState,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
log("Opening your browser to sign in …");
|
|
326
|
+
log(`If it doesn't open, visit:\n ${authorizationUrl.toString()}`);
|
|
327
|
+
open(authorizationUrl.toString());
|
|
328
|
+
|
|
329
|
+
// 5. Wait for the redirect + exchange the code for tokens.
|
|
330
|
+
const code = await listener.waitForCode();
|
|
331
|
+
const tokens = await exchangeAuthorization(origin, {
|
|
332
|
+
metadata,
|
|
333
|
+
clientInformation,
|
|
334
|
+
authorizationCode: code,
|
|
335
|
+
codeVerifier,
|
|
336
|
+
redirectUri: listener.redirectUrl,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// 6. Persist the profile (tokens never logged).
|
|
340
|
+
const record = buildProfileRecord({ origin, clientInformation, tokens });
|
|
341
|
+
await saveProfile(profileKey, record, { makeDefault: opts.makeDefault }, env);
|
|
342
|
+
|
|
343
|
+
log(`Signed in. Saved profile "${profileKey}" → ${origin}.`);
|
|
344
|
+
return { profileKey };
|
|
345
|
+
} finally {
|
|
346
|
+
listener.close();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Shape a stored profile record from an issued token set. */
|
|
351
|
+
export function buildProfileRecord({ origin, clientInformation, tokens }, now = Date.now()) {
|
|
352
|
+
const expiresAt =
|
|
353
|
+
typeof tokens.expires_in === "number"
|
|
354
|
+
? now + tokens.expires_in * 1000
|
|
355
|
+
: null;
|
|
356
|
+
return {
|
|
357
|
+
origin,
|
|
358
|
+
clientId: clientInformation.client_id,
|
|
359
|
+
// Public client (PKCE) — no client secret is stored.
|
|
360
|
+
accessToken: tokens.access_token,
|
|
361
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
362
|
+
tokenType: tokens.token_type ?? "Bearer",
|
|
363
|
+
scope: tokens.scope ?? CLI_OAUTH_SCOPES.join(" "),
|
|
364
|
+
expiresAt,
|
|
365
|
+
obtainedAt: now,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Token resolution for Class-A client calls
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
// Refresh when the access token is within this window of expiry (or expired).
|
|
374
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Resolve a usable bearer access token for a target. Selects the profile by
|
|
378
|
+
* `appUrl` (or the store's default), refreshes it if near/over expiry using the
|
|
379
|
+
* stored refresh token, persists the refreshed record, and returns the token.
|
|
380
|
+
*
|
|
381
|
+
* @returns {Promise<{ accessToken: string, origin: string, profileKey: string }>}
|
|
382
|
+
* @throws when no matching profile exists (caller falls back to direct-PG).
|
|
383
|
+
*/
|
|
384
|
+
export async function resolveAccessToken(opts = {}) {
|
|
385
|
+
const env = opts.env ?? process.env;
|
|
386
|
+
const now = opts.now ?? Date.now();
|
|
387
|
+
const store = await readCredentialsStore(env);
|
|
388
|
+
|
|
389
|
+
const profileKey = opts.appUrl
|
|
390
|
+
? appUrlToProfileKey(opts.appUrl)
|
|
391
|
+
: (opts.profile ?? store.defaultProfile);
|
|
392
|
+
if (!profileKey) {
|
|
393
|
+
throw new Error("No Cinatra login profile. Run `cinatra login --app-url <url>` first.");
|
|
394
|
+
}
|
|
395
|
+
const record = store.profiles[profileKey];
|
|
396
|
+
if (!record) {
|
|
397
|
+
throw new Error(`No login profile "${profileKey}". Run \`cinatra login --app-url <url>\`.`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const fresh =
|
|
401
|
+
record.expiresAt == null || record.expiresAt - now > REFRESH_SKEW_MS;
|
|
402
|
+
if (fresh) {
|
|
403
|
+
return { accessToken: record.accessToken, origin: record.origin, profileKey };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Near/over expiry → refresh.
|
|
407
|
+
if (!record.refreshToken) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`Login for "${profileKey}" expired and has no refresh token. Run \`cinatra login\` again.`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
const metadata = await discoverAuthorizationServerMetadata(record.origin);
|
|
413
|
+
if (!metadata) {
|
|
414
|
+
throw new Error(`Could not discover OAuth metadata at ${record.origin} to refresh.`);
|
|
415
|
+
}
|
|
416
|
+
const tokens = await refreshAuthorization(record.origin, {
|
|
417
|
+
metadata,
|
|
418
|
+
clientInformation: { client_id: record.clientId },
|
|
419
|
+
refreshToken: record.refreshToken,
|
|
420
|
+
});
|
|
421
|
+
const updated = buildProfileRecord(
|
|
422
|
+
{
|
|
423
|
+
origin: record.origin,
|
|
424
|
+
clientInformation: { client_id: record.clientId },
|
|
425
|
+
tokens: {
|
|
426
|
+
...tokens,
|
|
427
|
+
// A refresh response may omit refresh_token — keep the existing one.
|
|
428
|
+
refresh_token: tokens.refresh_token ?? record.refreshToken,
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
now,
|
|
432
|
+
);
|
|
433
|
+
await saveProfile(profileKey, updated, {}, env);
|
|
434
|
+
return { accessToken: updated.accessToken, origin: record.origin, profileKey };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Mask a token for any human-facing diagnostic. NEVER print the raw token. */
|
|
438
|
+
export function maskToken(token) {
|
|
439
|
+
if (typeof token !== "string" || token.length < 8) return "***";
|
|
440
|
+
return `${token.slice(0, 4)}…${token.slice(-2)}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Class-A remote client — authenticated calls to the instance control plane
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Resolve the target origin + a bearer token for a Class-A call. Prefers an
|
|
449
|
+
* explicit `--app-url`; else the named/default profile.
|
|
450
|
+
*/
|
|
451
|
+
async function resolveTarget({ appUrl, profile, env } = {}) {
|
|
452
|
+
const resolved = await resolveAccessToken({ appUrl, profile, env });
|
|
453
|
+
// When --app-url was given, honor it as the request origin (the profile's
|
|
454
|
+
// stored origin should match, but the caller's explicit choice wins).
|
|
455
|
+
const origin = appUrl ? appUrlToProfileKey(appUrl) : resolved.origin;
|
|
456
|
+
return { origin, accessToken: resolved.accessToken };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Authenticated GET against the instance, returning parsed JSON. Throws a clean
|
|
461
|
+
* error on a non-2xx (surfacing the server's `error` message when present).
|
|
462
|
+
* NEVER logs the bearer token.
|
|
463
|
+
*/
|
|
464
|
+
export async function cliApiGet(path, { appUrl, profile, env, fetchFn = fetch } = {}) {
|
|
465
|
+
const { origin, accessToken } = await resolveTarget({ appUrl, profile, env });
|
|
466
|
+
const res = await fetchFn(`${origin}${path}`, {
|
|
467
|
+
method: "GET",
|
|
468
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
|
469
|
+
});
|
|
470
|
+
if (!res.ok) {
|
|
471
|
+
throw await httpError(res, accessToken);
|
|
472
|
+
}
|
|
473
|
+
return await res.json();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Redact any token material from a string before it is surfaced to the user:
|
|
478
|
+
* the exact access token, and any `Bearer <token>` pattern. Defense in depth —
|
|
479
|
+
* the server should never echo the token, but if it did, the CLI must not print
|
|
480
|
+
* it (the bin wrapper prints raw error messages).
|
|
481
|
+
*/
|
|
482
|
+
export function redactTokens(text, accessToken) {
|
|
483
|
+
let out = String(text ?? "");
|
|
484
|
+
if (accessToken && accessToken.length >= 8) {
|
|
485
|
+
out = out.split(accessToken).join("[REDACTED]");
|
|
486
|
+
}
|
|
487
|
+
out = out.replace(/Bearer\s+[A-Za-z0-9._\-+/=]+/gi, "Bearer [REDACTED]");
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Build a token-redacted error from a non-2xx response. */
|
|
492
|
+
async function httpError(res, accessToken) {
|
|
493
|
+
let detail = "";
|
|
494
|
+
try {
|
|
495
|
+
const body = await res.json();
|
|
496
|
+
if (body?.error) detail = `: ${redactTokens(body.error, accessToken)}`;
|
|
497
|
+
} catch {
|
|
498
|
+
// non-JSON body — ignore.
|
|
499
|
+
}
|
|
500
|
+
return new Error(`Request failed (${res.status} ${res.statusText})${detail}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** `cinatra status` remote path — GET /api/cli/status. */
|
|
504
|
+
export async function fetchRemoteStatus(opts = {}) {
|
|
505
|
+
return await cliApiGet("/api/cli/status", opts);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export { REFRESH_SKEW_MS };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Marketplace MCP call helper for the CLI. Mirrors the TS http-client
|
|
2
|
+
// pattern (StreamableHTTPClientTransport, `cinatra-<kebab>` tool names,
|
|
3
|
+
// structuredContent-preferred parse) but stays in .mjs because the CLI is
|
|
4
|
+
// plain Node ESM with no TS loader.
|
|
5
|
+
//
|
|
6
|
+
// Brand wording: prose says "Cinatra"; `cinatra-ai` only for the npm scope
|
|
7
|
+
// / GitHub org. The Marketplace base URL is hardcoded; an env override is
|
|
8
|
+
// honored only outside production.
|
|
9
|
+
|
|
10
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
11
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
12
|
+
|
|
13
|
+
export const MARKETPLACE_BASE_URL = "https://marketplace.cinatra.ai";
|
|
14
|
+
const MCP_ROUTE = "/wp-json/cinatra/mcp";
|
|
15
|
+
|
|
16
|
+
export function resolveMarketplaceBaseUrl(override) {
|
|
17
|
+
if (process.env.NODE_ENV !== "production") {
|
|
18
|
+
const candidate = (override ?? process.env.MARKETPLACE_BASE_URL ?? "").trim();
|
|
19
|
+
if (candidate) {
|
|
20
|
+
return candidate.replace(/\/+$/, "");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return MARKETPLACE_BASE_URL;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function authHeaders(token) {
|
|
27
|
+
if (!token) return {};
|
|
28
|
+
const value = /^(Bearer|Basic)\s/i.test(token) ? token : `Bearer ${token}`;
|
|
29
|
+
return { Authorization: value };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractText(result) {
|
|
33
|
+
const content = result?.content;
|
|
34
|
+
if (!Array.isArray(content)) return null;
|
|
35
|
+
const textItem = content.find((c) => c.type === "text");
|
|
36
|
+
return textItem && typeof textItem.text === "string" ? textItem.text : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the MCP tool name from an extender ability snake_case key.
|
|
41
|
+
*
|
|
42
|
+
* The WP ability id is `cinatra/<kebab>`, but MCP tool names cannot contain a
|
|
43
|
+
* `/`, so the WordPress mcp-adapter exposes them with the namespace separator
|
|
44
|
+
* flattened to a dash: `cinatra-<kebab>`. Match the exposed name exactly, or
|
|
45
|
+
* tool calls fail with "Tool not found: cinatra/<kebab>".
|
|
46
|
+
*/
|
|
47
|
+
function mcpToolName(abilityKey) {
|
|
48
|
+
return `cinatra-${abilityKey.replace(/_/g, "-")}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Connect to the marketplace MCP, call one tool, parse + return the result,
|
|
53
|
+
* close the client. Throws on tool-level errors (with the marketplace's error
|
|
54
|
+
* text included).
|
|
55
|
+
*/
|
|
56
|
+
export async function callMarketplaceTool(abilityKey, args, opts = {}) {
|
|
57
|
+
const baseUrl = resolveMarketplaceBaseUrl(opts.baseUrl);
|
|
58
|
+
// Token precedence: an explicit vendor token (e.g. from publish automation)
|
|
59
|
+
// wins, falling back to the instance principal token for local/manual use.
|
|
60
|
+
// CINATRA_MARKETPLACE_VENDOR_TOKEN is the vendor token;
|
|
61
|
+
// a developer's shell may still export MARKETPLACE_INSTANCE_TOKEN.
|
|
62
|
+
const token =
|
|
63
|
+
opts.token ??
|
|
64
|
+
process.env.CINATRA_MARKETPLACE_VENDOR_TOKEN ??
|
|
65
|
+
process.env.MARKETPLACE_INSTANCE_TOKEN;
|
|
66
|
+
if (!token) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"No marketplace token set. Export CINATRA_MARKETPLACE_VENDOR_TOKEN (CI vendor token) " +
|
|
69
|
+
"or MARKETPLACE_INSTANCE_TOKEN (local) before submitting to the marketplace.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const endpoint = new URL(baseUrl + MCP_ROUTE);
|
|
74
|
+
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
75
|
+
requestInit: { headers: authHeaders(token) },
|
|
76
|
+
});
|
|
77
|
+
const client = new Client({ name: "cinatra-cli", version: "1.0.0" });
|
|
78
|
+
try {
|
|
79
|
+
await client.connect(transport);
|
|
80
|
+
const result = await client.callTool({ name: mcpToolName(abilityKey), arguments: args });
|
|
81
|
+
if (result?.isError) {
|
|
82
|
+
const text = extractText(result) ?? "unknown error";
|
|
83
|
+
throw new Error(`Marketplace ${abilityKey} returned an error: ${text}`);
|
|
84
|
+
}
|
|
85
|
+
if (result?.structuredContent && typeof result.structuredContent === "object") {
|
|
86
|
+
return result.structuredContent;
|
|
87
|
+
}
|
|
88
|
+
const text = extractText(result);
|
|
89
|
+
if (text != null) {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(text);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(`Marketplace ${abilityKey}: response was not JSON.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Marketplace ${abilityKey}: empty response.`);
|
|
97
|
+
} finally {
|
|
98
|
+
await client.close().catch(() => {});
|
|
99
|
+
}
|
|
100
|
+
}
|