constructor-research-mcp 0.0.0-stage → 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/INSTALL.md +145 -0
- package/LICENSE +21 -0
- package/README.md +320 -2
- package/dist/api.d.ts +66 -0
- package/dist/api.js +280 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +127 -0
- package/dist/deployment.d.ts +94 -0
- package/dist/deployment.js +1233 -0
- package/dist/files.d.ts +122 -0
- package/dist/files.js +822 -0
- package/dist/location.d.ts +17 -0
- package/dist/location.js +60 -0
- package/dist/login.d.ts +2 -0
- package/dist/login.js +25 -0
- package/dist/logs.d.ts +59 -0
- package/dist/logs.js +330 -0
- package/dist/runtime.d.ts +23 -0
- package/dist/runtime.js +238 -0
- package/dist/schemas.d.ts +232 -0
- package/dist/schemas.js +277 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +351 -0
- package/dist/session.d.ts +21 -0
- package/dist/session.js +108 -0
- package/dist/set-token.d.ts +2 -0
- package/dist/set-token.js +48 -0
- package/dist/signin.d.ts +19 -0
- package/dist/signin.js +209 -0
- package/dist/store.d.ts +85 -0
- package/dist/store.js +150 -0
- package/dist/wait.d.ts +26 -0
- package/dist/wait.js +241 -0
- package/examples/claude-hooks.json +16 -0
- package/examples/claude.mcp.json +8 -0
- package/examples/codex-hooks.json +16 -0
- package/examples/codex.toml +6 -0
- package/examples/deploy-app.json +39 -0
- package/examples/opencode-plugin.js +43 -0
- package/examples/opencode-v2.json +11 -0
- package/examples/opencode.json +9 -0
- package/package.json +60 -4
package/dist/session.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
/** The cookies the MCP keeps: the access token for requests, the refresh token to renew it. */
|
|
5
|
+
export const SESSION_COOKIES = [
|
|
6
|
+
"ap_access_token",
|
|
7
|
+
"ap_id_token",
|
|
8
|
+
"ap_refresh_token",
|
|
9
|
+
];
|
|
10
|
+
/** Claims of a JWT, or undefined when it is not one. */
|
|
11
|
+
export function claims(jwt) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(Buffer.from(jwt.split(".")[1], "base64url").toString());
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Session cookies found in a Cookie header, a cURL, a Set-Cookie list or a bare JWT. */
|
|
20
|
+
export function sessionCookies(text) {
|
|
21
|
+
const found = new Map();
|
|
22
|
+
for (const name of SESSION_COOKIES) {
|
|
23
|
+
const value = [
|
|
24
|
+
...text.matchAll(new RegExp(`(?:^|[\\s;'"])${name}=([\\w.~+/=-]+)`, "g")),
|
|
25
|
+
].at(-1)?.[1];
|
|
26
|
+
if (value)
|
|
27
|
+
found.set(name, value);
|
|
28
|
+
}
|
|
29
|
+
const bare = /^\s*(ey[\w-]+\.[\w-]+\.[\w-]+)\s*$/.exec(text)?.[1];
|
|
30
|
+
if (bare && !found.size)
|
|
31
|
+
found.set("ap_access_token", bare);
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
/** Index of the line holding the tokens in a cookie file, or -1. */
|
|
35
|
+
const cookieLine = (lines) => lines.findIndex((l) => /^\s*(Cookie:\s*)?(ey[\w-]+\.|.*\bap_(access|refresh)_token=)/.test(l));
|
|
36
|
+
/** The session saved in a cookie file; only its cookie line is read. */
|
|
37
|
+
export async function readSession(file) {
|
|
38
|
+
const lines = (await readFile(file, "utf8")).split(/\r?\n/);
|
|
39
|
+
const line = lines[cookieLine(lines)] ?? "";
|
|
40
|
+
if (/[\x00-\x1f]/.test(line))
|
|
41
|
+
return new Map();
|
|
42
|
+
return sessionCookies(line.trim().replace(/^Cookie:\s*/i, ""));
|
|
43
|
+
}
|
|
44
|
+
/** The line naming the Constructor the session belongs to. */
|
|
45
|
+
const HOST = /^host=(\S+)\s*$/;
|
|
46
|
+
/**
|
|
47
|
+
* The Constructor (an origin) a saved session belongs to: its host line, written at sign-in,
|
|
48
|
+
* or else the issuer of its access token. Undefined when nothing is saved.
|
|
49
|
+
*/
|
|
50
|
+
export async function sessionHost(file) {
|
|
51
|
+
let lines;
|
|
52
|
+
try {
|
|
53
|
+
lines = (await readFile(file, "utf8")).split(/\r?\n/);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
const named = lines.map((l) => HOST.exec(l)?.[1]).find(Boolean);
|
|
59
|
+
const line = lines[cookieLine(lines)] ?? "";
|
|
60
|
+
const issuer = claims(sessionCookies(line.replace(/^\s*Cookie:\s*/i, "")).get("ap_access_token"))?.iss;
|
|
61
|
+
try {
|
|
62
|
+
return new URL(named ?? issuer).origin;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Replaces the cookie line of a file with these cookies, keeping its other lines, and with
|
|
70
|
+
* `host` its host line. Written to a temporary file and renamed, mode 600, so a reader never
|
|
71
|
+
* sees half a token and a crash never loses the session.
|
|
72
|
+
*/
|
|
73
|
+
export async function saveSession(file, cookies, host) {
|
|
74
|
+
let lines = [];
|
|
75
|
+
try {
|
|
76
|
+
lines = (await readFile(file, "utf8")).split(/\r?\n/);
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
if (e.code !== "ENOENT")
|
|
80
|
+
throw e;
|
|
81
|
+
}
|
|
82
|
+
const line = SESSION_COOKIES.filter((n) => cookies.get(n))
|
|
83
|
+
.map((n) => `${n}=${cookies.get(n)}`)
|
|
84
|
+
.join("; ");
|
|
85
|
+
const i = cookieLine(lines);
|
|
86
|
+
if (i >= 0)
|
|
87
|
+
lines[i] = line;
|
|
88
|
+
else
|
|
89
|
+
lines.unshift(line);
|
|
90
|
+
if (host) {
|
|
91
|
+
lines = lines.filter((l) => !HOST.test(l));
|
|
92
|
+
lines.splice(lines.indexOf(line) + 1, 0, `host=${host}`);
|
|
93
|
+
}
|
|
94
|
+
await mkdir(dirname(file), { recursive: true });
|
|
95
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
96
|
+
await writeFile(tmp, lines.join("\n").replace(/\n*$/, "\n"), {
|
|
97
|
+
mode: 0o600,
|
|
98
|
+
flag: "wx",
|
|
99
|
+
});
|
|
100
|
+
await rename(tmp, file);
|
|
101
|
+
}
|
|
102
|
+
/** New session cookies from a response's Set-Cookie headers. */
|
|
103
|
+
export function setCookies(response) {
|
|
104
|
+
return sessionCookies(response.headers
|
|
105
|
+
.getSetCookie()
|
|
106
|
+
.map((c) => c.split(";")[0])
|
|
107
|
+
.join("; "));
|
|
108
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Save the session from a copied cURL (or a bare token) for the MCP without printing it.
|
|
3
|
+
// Usage: npx -y -p constructor-research-mcp constructor-research-set-token <file with a copied cURL, or a token> --for claude-code|codex|opencode
|
|
4
|
+
// (or --to <session file>); the session belongs to the Constructor the cURL was sent to.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { configFromEnv, HARNESSES, originOf, sessionArgs } from "./config.js";
|
|
7
|
+
import { claims, saveSession, sessionCookies } from "./session.js";
|
|
8
|
+
const { file: target, rest } = sessionArgs(process.argv.slice(2), configFromEnv());
|
|
9
|
+
const source = rest[0];
|
|
10
|
+
if (!source || !target) {
|
|
11
|
+
console.error(`Usage: npx -y -p constructor-research-mcp constructor-research-set-token <file with a copied cURL> --for ${HARNESSES.join("|")} (or --to <session file>)`);
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
const text = readFileSync(source, "utf8");
|
|
15
|
+
const found = sessionCookies(text);
|
|
16
|
+
const token = found.get("ap_access_token");
|
|
17
|
+
if (!token) {
|
|
18
|
+
console.error("No ap_access_token found in " + source + ".");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const { exp, max_slt } = claims(token) ?? {};
|
|
22
|
+
if (exp && exp * 1000 <= Date.now() && !found.has("ap_refresh_token")) {
|
|
23
|
+
console.error("That token already expired at " +
|
|
24
|
+
new Date(exp * 1000).toISOString() +
|
|
25
|
+
"; copy a fresh one.");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
// The Constructor the cURL was sent to; a bare token names its own issuer.
|
|
29
|
+
const sent = /\bcurl\s+(?:-\S+\s+)*['"]?(https?:\/\/[^\s'"]+)/.exec(text)?.[1];
|
|
30
|
+
let host;
|
|
31
|
+
try {
|
|
32
|
+
host = sent ? originOf(sent) : undefined;
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
await saveSession(target, found, host);
|
|
36
|
+
const time = (s) => new Date(s * 1000).toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
37
|
+
console.log("Saved to " +
|
|
38
|
+
target +
|
|
39
|
+
(host ? ` (for ${host})` : "") +
|
|
40
|
+
(found.has("ap_refresh_token")
|
|
41
|
+
? `, with the refresh token: the MCP renews the session itself${max_slt ? " until " + time(max_slt) : ""}. The browser this cURL came from holds the same session, and a refresh token works once: whichever renews first logs the other out. The sign_in tool (or constructor-research-login) gives the MCP a session of its own.`
|
|
42
|
+
: exp
|
|
43
|
+
? "; expires " +
|
|
44
|
+
time(exp) +
|
|
45
|
+
" (in " +
|
|
46
|
+
Math.round((exp * 1000 - Date.now()) / 60000) +
|
|
47
|
+
" min). Copy the cURL with the ap_refresh_token cookie, or use the sign_in tool, and the MCP renews it itself."
|
|
48
|
+
: "."));
|
package/dist/signin.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
export type SignInResult = {
|
|
3
|
+
status: "signed_in";
|
|
4
|
+
message: string;
|
|
5
|
+
session_file: string;
|
|
6
|
+
host: string;
|
|
7
|
+
until?: string;
|
|
8
|
+
} | {
|
|
9
|
+
status: "sign_in_failed";
|
|
10
|
+
message: string;
|
|
11
|
+
};
|
|
12
|
+
/** A Chromium-based browser on macOS, Windows or Linux; CONSTRUCTOR_BROWSER overrides. */
|
|
13
|
+
export declare function findBrowser(): string | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Opens the sign-in window, waits for the user to sign in (up to 15 min), saves the
|
|
16
|
+
* session cookies to `file`, and closes the window at once. Never returns a token.
|
|
17
|
+
* `opened` runs once the window is up.
|
|
18
|
+
*/
|
|
19
|
+
export declare function signIn(config: Pick<Config, "baseUrl" | "apiPath" | "requestTimeoutMs">, file: string, opened?: () => void): Promise<SignInResult>;
|
package/dist/signin.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Sign in to Constructor in a separate Chrome window and save the session.
|
|
2
|
+
// The window uses a fresh temporary profile: a session nothing else holds, so the MCP can
|
|
3
|
+
// renew it without logging out the user's own browser (a refresh token works only once).
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import { delimiter, join } from "node:path";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
import { claims, saveSession, setCookies, SESSION_COOKIES } from "./session.js";
|
|
11
|
+
const SIGN_IN_MINUTES = 15;
|
|
12
|
+
/** A Chromium-based browser on macOS, Windows or Linux; CONSTRUCTOR_BROWSER overrides. */
|
|
13
|
+
export function findBrowser() {
|
|
14
|
+
if (process.env.CONSTRUCTOR_BROWSER)
|
|
15
|
+
return existsSync(process.env.CONSTRUCTOR_BROWSER)
|
|
16
|
+
? process.env.CONSTRUCTOR_BROWSER
|
|
17
|
+
: undefined;
|
|
18
|
+
const env = process.env;
|
|
19
|
+
const installed = process.platform === "win32"
|
|
20
|
+
? [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA]
|
|
21
|
+
.filter((d) => !!d)
|
|
22
|
+
.flatMap((d) => [
|
|
23
|
+
join(d, "Google", "Chrome", "Application", "chrome.exe"),
|
|
24
|
+
join(d, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
25
|
+
join(d, "Chromium", "Application", "chrome.exe"),
|
|
26
|
+
join(d, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
27
|
+
])
|
|
28
|
+
: process.platform === "darwin"
|
|
29
|
+
? [
|
|
30
|
+
"Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
31
|
+
"Chromium.app/Contents/MacOS/Chromium",
|
|
32
|
+
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
33
|
+
"Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
34
|
+
].flatMap((app) => [
|
|
35
|
+
join("/Applications", app),
|
|
36
|
+
join(homedir(), "Applications", app),
|
|
37
|
+
])
|
|
38
|
+
: [];
|
|
39
|
+
// Then the PATH, as Linux installs them (and any OS where the user put one there).
|
|
40
|
+
const names = [
|
|
41
|
+
"google-chrome",
|
|
42
|
+
"google-chrome-stable",
|
|
43
|
+
"chromium",
|
|
44
|
+
"chromium-browser",
|
|
45
|
+
"microsoft-edge",
|
|
46
|
+
"brave-browser",
|
|
47
|
+
"chrome",
|
|
48
|
+
"msedge",
|
|
49
|
+
];
|
|
50
|
+
const exts = process.platform === "win32"
|
|
51
|
+
? (env.PATHEXT ?? ".EXE").split(";").filter(Boolean)
|
|
52
|
+
: [""];
|
|
53
|
+
const onPath = (env.PATH ?? "")
|
|
54
|
+
.split(delimiter)
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.flatMap((dir) => names.flatMap((n) => exts.map((e) => join(dir, n + e.toLowerCase()))));
|
|
57
|
+
return [...installed, ...onPath].find((c) => existsSync(c));
|
|
58
|
+
}
|
|
59
|
+
/** Minimal Chrome DevTools Protocol client over the browser's WebSocket. */
|
|
60
|
+
async function devtools(url) {
|
|
61
|
+
const ws = new WebSocket(url);
|
|
62
|
+
await new Promise((ok, fail) => {
|
|
63
|
+
ws.onopen = ok;
|
|
64
|
+
ws.onerror = () => fail(new Error("Could not connect to the browser."));
|
|
65
|
+
});
|
|
66
|
+
let id = 0;
|
|
67
|
+
const waiting = new Map();
|
|
68
|
+
ws.onmessage = (e) => {
|
|
69
|
+
const m = JSON.parse(String(e.data));
|
|
70
|
+
waiting.get(m.id)?.(m);
|
|
71
|
+
waiting.delete(m.id);
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
send(method, params = {}) {
|
|
75
|
+
return new Promise((ok, fail) => {
|
|
76
|
+
const n = ++id;
|
|
77
|
+
waiting.set(n, (m) => m.error ? fail(new Error(m.error.message)) : ok(m.result));
|
|
78
|
+
ws.send(JSON.stringify({ id: n, method, params }));
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
close: () => ws.close(),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Opens the sign-in window, waits for the user to sign in (up to 15 min), saves the
|
|
86
|
+
* session cookies to `file`, and closes the window at once. Never returns a token.
|
|
87
|
+
* `opened` runs once the window is up.
|
|
88
|
+
*/
|
|
89
|
+
export async function signIn(config, file, opened = () => { }) {
|
|
90
|
+
const fallback = `save a session from a copied cURL instead: npx -y -p constructor-research-mcp constructor-research-set-token <file with the cURL> --to ${file}`;
|
|
91
|
+
const headless = !!process.env.CONSTRUCTOR_LOGIN_HEADLESS;
|
|
92
|
+
if (process.platform === "linux" &&
|
|
93
|
+
!headless &&
|
|
94
|
+
!process.env.DISPLAY &&
|
|
95
|
+
!process.env.WAYLAND_DISPLAY)
|
|
96
|
+
return {
|
|
97
|
+
status: "sign_in_failed",
|
|
98
|
+
message: `This machine has no screen to open a sign-in window on (no DISPLAY or WAYLAND_DISPLAY). Sign in where the MCP has a desktop, or ${fallback}.`,
|
|
99
|
+
};
|
|
100
|
+
const exe = findBrowser();
|
|
101
|
+
if (!exe)
|
|
102
|
+
return {
|
|
103
|
+
status: "sign_in_failed",
|
|
104
|
+
message: `No Chrome, Edge, Chromium or Brave found. Set CONSTRUCTOR_BROWSER in the MCP settings to the browser's executable, or ${fallback}.`,
|
|
105
|
+
};
|
|
106
|
+
const host = new URL(config.baseUrl).hostname;
|
|
107
|
+
const profile = await mkdtemp(join(tmpdir(), "constructor-login-"));
|
|
108
|
+
const chrome = spawn(exe, [
|
|
109
|
+
`--user-data-dir=${profile}`,
|
|
110
|
+
"--remote-debugging-port=0",
|
|
111
|
+
"--remote-allow-origins=http://127.0.0.1",
|
|
112
|
+
"--no-first-run",
|
|
113
|
+
"--no-default-browser-check",
|
|
114
|
+
"--disable-sync",
|
|
115
|
+
// Tests run the browser without a window.
|
|
116
|
+
...(headless ? ["--headless=new"] : []),
|
|
117
|
+
"--new-window",
|
|
118
|
+
`${config.baseUrl}/research/`,
|
|
119
|
+
], { stdio: "ignore" });
|
|
120
|
+
let exited = false;
|
|
121
|
+
chrome.on("exit", () => (exited = true));
|
|
122
|
+
chrome.on("error", () => (exited = true));
|
|
123
|
+
const failed = (message) => ({
|
|
124
|
+
status: "sign_in_failed",
|
|
125
|
+
message,
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
let port, path;
|
|
129
|
+
for (let i = 0; i < 300 && !port && !exited; i++) {
|
|
130
|
+
try {
|
|
131
|
+
[port, path] = (await readFile(join(profile, "DevToolsActivePort"), "utf8")).split("\n");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
await delay(100);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!port)
|
|
138
|
+
return failed(`The browser did not start (${exe}); ${fallback}.`);
|
|
139
|
+
const cdp = await devtools(`ws://127.0.0.1:${port}${path}`);
|
|
140
|
+
opened();
|
|
141
|
+
const session = async () => {
|
|
142
|
+
const { cookies } = await cdp.send("Storage.getCookies");
|
|
143
|
+
return new Map(cookies
|
|
144
|
+
.filter((c) => SESSION_COOKIES.includes(c.name) &&
|
|
145
|
+
(c.domain.replace(/^\./, "") === host ||
|
|
146
|
+
host.endsWith(c.domain.startsWith(".") ? c.domain : "." + c.domain)))
|
|
147
|
+
.map((c) => [c.name, c.value]));
|
|
148
|
+
};
|
|
149
|
+
const deadline = Date.now() + SIGN_IN_MINUTES * 60000;
|
|
150
|
+
let found;
|
|
151
|
+
while (!exited && Date.now() < deadline) {
|
|
152
|
+
const s = await session().catch(() => undefined);
|
|
153
|
+
const exp = claims(s?.get("ap_access_token"))?.exp;
|
|
154
|
+
if (s?.has("ap_refresh_token") && exp && exp * 1000 > Date.now()) {
|
|
155
|
+
// The page may still be loading; read again once it settles, then close at once so
|
|
156
|
+
// the window never renews this session itself.
|
|
157
|
+
await delay(3000);
|
|
158
|
+
found = (await session().catch(() => undefined)) ?? s;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
await delay(1000);
|
|
162
|
+
}
|
|
163
|
+
await cdp.send("Browser.close").catch(() => { });
|
|
164
|
+
cdp.close();
|
|
165
|
+
if (!found)
|
|
166
|
+
return failed(exited
|
|
167
|
+
? "The window was closed before sign-in finished; nothing was saved."
|
|
168
|
+
: `No sign-in within ${SIGN_IN_MINUTES} min; nothing was saved.`);
|
|
169
|
+
// Check the access token without sending the refresh token, so nothing is renewed.
|
|
170
|
+
const response = await fetch(config.baseUrl + config.apiPath + "/auth/session", {
|
|
171
|
+
headers: {
|
|
172
|
+
Cookie: `ap_access_token=${found.get("ap_access_token")}`,
|
|
173
|
+
Accept: "application/json",
|
|
174
|
+
},
|
|
175
|
+
redirect: "manual",
|
|
176
|
+
signal: AbortSignal.timeout(config.requestTimeoutMs),
|
|
177
|
+
}).catch(() => undefined);
|
|
178
|
+
await response?.body?.cancel();
|
|
179
|
+
if (response?.status === 401)
|
|
180
|
+
return failed("Constructor rejected the new session; nothing was saved.");
|
|
181
|
+
await saveSession(file, new Map([...found, ...(response ? setCookies(response) : [])]), config.baseUrl);
|
|
182
|
+
const end = claims(found.get("ap_access_token"))?.max_slt;
|
|
183
|
+
const until = end
|
|
184
|
+
? new Date(end * 1000).toISOString().slice(0, 16).replace("T", " ") +
|
|
185
|
+
" UTC"
|
|
186
|
+
: undefined;
|
|
187
|
+
return {
|
|
188
|
+
status: "signed_in",
|
|
189
|
+
message: `Signed in to ${config.baseUrl}. Session saved to ${file}` +
|
|
190
|
+
(until ? `; the MCP renews it automatically until ${until}.` : "."),
|
|
191
|
+
session_file: file,
|
|
192
|
+
host: config.baseUrl,
|
|
193
|
+
until,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
return failed(`Sign-in failed: ${e?.message ?? e}. Nothing was saved.`);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
if (!exited) {
|
|
201
|
+
chrome.kill();
|
|
202
|
+
for (let i = 0; i < 50 && !exited; i++)
|
|
203
|
+
await delay(100);
|
|
204
|
+
if (!exited)
|
|
205
|
+
chrome.kill("SIGKILL"); // TerminateProcess on Windows
|
|
206
|
+
}
|
|
207
|
+
await rm(profile, { recursive: true, force: true }).catch(() => { });
|
|
208
|
+
}
|
|
209
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
export interface Operation {
|
|
3
|
+
id: string;
|
|
4
|
+
kind: "deploy" | "update" | "build" | "start" | "stop";
|
|
5
|
+
endpoint: string;
|
|
6
|
+
input: any;
|
|
7
|
+
status: string;
|
|
8
|
+
volumes_to_wipe?: string[];
|
|
9
|
+
project_id?: string;
|
|
10
|
+
application_id?: string;
|
|
11
|
+
desks: Record<string, string>;
|
|
12
|
+
images: Record<string, string>;
|
|
13
|
+
services: Record<string, string>;
|
|
14
|
+
applied: string[];
|
|
15
|
+
stopped: string[];
|
|
16
|
+
started: string[];
|
|
17
|
+
pending?: {
|
|
18
|
+
method: string;
|
|
19
|
+
path: string;
|
|
20
|
+
};
|
|
21
|
+
message?: string;
|
|
22
|
+
created_at: string;
|
|
23
|
+
updated_at: string;
|
|
24
|
+
result?: any;
|
|
25
|
+
action?: {
|
|
26
|
+
stage: string;
|
|
27
|
+
service?: string;
|
|
28
|
+
dockerfile?: string;
|
|
29
|
+
};
|
|
30
|
+
revision?: number;
|
|
31
|
+
/** Image ID seen with build_status error once; a failure counts on the second sighting. */
|
|
32
|
+
errorSeen?: Record<string, string>;
|
|
33
|
+
/** Name of the failed image per build that was already rebuilt once. */
|
|
34
|
+
retried?: Record<string, string>;
|
|
35
|
+
/** Public-URL checks after start. */
|
|
36
|
+
readiness?: {
|
|
37
|
+
since: number;
|
|
38
|
+
checks: Record<string, {
|
|
39
|
+
url: string;
|
|
40
|
+
status: number | null;
|
|
41
|
+
ok: boolean;
|
|
42
|
+
}>;
|
|
43
|
+
/** Services that printed nothing since they started, once the window ran out. */
|
|
44
|
+
silent?: string[];
|
|
45
|
+
};
|
|
46
|
+
/** Earlier deployments of the same application this one replaced. */
|
|
47
|
+
replaced?: {
|
|
48
|
+
deployment_id: string;
|
|
49
|
+
services_not_included: string[];
|
|
50
|
+
}[];
|
|
51
|
+
submission_key?: string;
|
|
52
|
+
failure?: {
|
|
53
|
+
stage: string;
|
|
54
|
+
service?: string;
|
|
55
|
+
dockerfile?: string;
|
|
56
|
+
image_id?: string;
|
|
57
|
+
image_name?: string;
|
|
58
|
+
first_image_name?: string;
|
|
59
|
+
desk_link?: string;
|
|
60
|
+
/** Last build log lines, when the MCP followed the build. */
|
|
61
|
+
log_tail?: string[];
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export declare class StateStore {
|
|
65
|
+
readonly config: Config;
|
|
66
|
+
constructor(config: Config);
|
|
67
|
+
/** The Constructor API saved operations belong to; follows a sign-in to another host. */
|
|
68
|
+
get endpoint(): string;
|
|
69
|
+
private init;
|
|
70
|
+
load(id: string): Promise<Operation | undefined>;
|
|
71
|
+
ids(): Promise<string[]>;
|
|
72
|
+
/** Marks that a process is executing this deployment right now. */
|
|
73
|
+
beat(id: string): Promise<void>;
|
|
74
|
+
/** Epoch ms of the last beat; 0 when none. */
|
|
75
|
+
lastBeat(id: string): Promise<number>;
|
|
76
|
+
private alive;
|
|
77
|
+
save(op: Operation): Promise<void>;
|
|
78
|
+
exclusive<T>(fn: () => Promise<T>): Promise<T>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Runs `fn` while holding `lock`, a file created exclusively and shared by every process.
|
|
82
|
+
* Waits up to `waitMs` for another holder, then throws `busy()`. A lock left by a process
|
|
83
|
+
* that no longer exists is removed.
|
|
84
|
+
*/
|
|
85
|
+
export declare function withLock<T>(lock: string, fn: () => Promise<T>, waitMs: number, busy: () => Error): Promise<T>;
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, unlink, chmod, readdir, stat, link, } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { ToolError } from "./api.js";
|
|
5
|
+
export class StateStore {
|
|
6
|
+
config;
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
}
|
|
10
|
+
/** The Constructor API saved operations belong to; follows a sign-in to another host. */
|
|
11
|
+
get endpoint() {
|
|
12
|
+
return this.config.baseUrl + this.config.apiPath;
|
|
13
|
+
}
|
|
14
|
+
async init() {
|
|
15
|
+
await mkdir(this.config.stateDir, { recursive: true, mode: 0o700 });
|
|
16
|
+
await chmod(this.config.stateDir, 0o700);
|
|
17
|
+
}
|
|
18
|
+
async load(id) {
|
|
19
|
+
if (!/^[0-9a-f-]{36}$/i.test(id))
|
|
20
|
+
throw new ToolError("invalid_input", "Invalid deployment ID.");
|
|
21
|
+
try {
|
|
22
|
+
const op = JSON.parse(await readFile(join(this.config.stateDir, `${id}.json`), "utf8"));
|
|
23
|
+
for (const field of ["desks", "images", "services"])
|
|
24
|
+
op[field] = Object.assign(Object.create(null), op[field]);
|
|
25
|
+
if (op.endpoint !== this.endpoint)
|
|
26
|
+
throw new ToolError("endpoint_mismatch", "This operation belongs to a different Constructor endpoint.");
|
|
27
|
+
return op;
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
if (e.code === "ENOENT")
|
|
31
|
+
return undefined;
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async ids() {
|
|
36
|
+
await this.init();
|
|
37
|
+
return (await readdir(this.config.stateDir))
|
|
38
|
+
.filter((n) => /^[0-9a-f-]{36}\.json$/i.test(n))
|
|
39
|
+
.map((n) => n.slice(0, -5));
|
|
40
|
+
}
|
|
41
|
+
/** Marks that a process is executing this deployment right now. */
|
|
42
|
+
async beat(id) {
|
|
43
|
+
await this.init();
|
|
44
|
+
await writeFile(this.alive(id), "", { mode: 0o600 });
|
|
45
|
+
}
|
|
46
|
+
/** Epoch ms of the last beat; 0 when none. */
|
|
47
|
+
async lastBeat(id) {
|
|
48
|
+
try {
|
|
49
|
+
return (await stat(this.alive(id))).mtimeMs;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
alive(id) {
|
|
56
|
+
if (!/^[0-9a-f-]{36}$/i.test(id))
|
|
57
|
+
throw new ToolError("invalid_input", "Invalid deployment ID.");
|
|
58
|
+
return join(this.config.stateDir, `${id}.alive`);
|
|
59
|
+
}
|
|
60
|
+
async save(op) {
|
|
61
|
+
await this.init();
|
|
62
|
+
op.updated_at = new Date().toISOString();
|
|
63
|
+
const target = join(this.config.stateDir, `${op.id}.json`), tmp = target + "." + randomUUID() + ".tmp";
|
|
64
|
+
await writeFile(tmp, JSON.stringify(op, null, 2), {
|
|
65
|
+
mode: 0o600,
|
|
66
|
+
flag: "wx",
|
|
67
|
+
});
|
|
68
|
+
await rename(tmp, target);
|
|
69
|
+
}
|
|
70
|
+
async exclusive(fn) {
|
|
71
|
+
await this.init();
|
|
72
|
+
const lock = join(this.config.stateDir, ".lock");
|
|
73
|
+
return withLock(lock, fn, 0, () => new ToolError("busy", `Another operation holds ${lock}. Retry later.`));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Runs `fn` while holding `lock`, a file created exclusively and shared by every process.
|
|
78
|
+
* Waits up to `waitMs` for another holder, then throws `busy()`. A lock left by a process
|
|
79
|
+
* that no longer exists is removed.
|
|
80
|
+
*/
|
|
81
|
+
export async function withLock(lock, fn, waitMs, busy) {
|
|
82
|
+
const token = JSON.stringify({ pid: process.pid, token: randomUUID() });
|
|
83
|
+
const deadline = Date.now() + waitMs;
|
|
84
|
+
for (;;) {
|
|
85
|
+
try {
|
|
86
|
+
await writeFile(lock, token, { flag: "wx", mode: 0o600 });
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
if (e.code !== "EEXIST")
|
|
91
|
+
throw e;
|
|
92
|
+
if (await breakStale(lock))
|
|
93
|
+
continue;
|
|
94
|
+
if (Date.now() >= deadline)
|
|
95
|
+
throw busy();
|
|
96
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return await fn();
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
if ((await readFile(lock, "utf8")) === token)
|
|
104
|
+
await unlink(lock);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Removes a lock whose process is gone (a server killed or restarted mid-step). The
|
|
109
|
+
* lock is moved aside first and only deleted if it is still the dead one, so two
|
|
110
|
+
* processes breaking it at once cannot delete a lock just taken by a live one.
|
|
111
|
+
*/
|
|
112
|
+
async function breakStale(lock) {
|
|
113
|
+
let held;
|
|
114
|
+
try {
|
|
115
|
+
held = await readFile(lock, "utf8");
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
return e.code === "ENOENT";
|
|
119
|
+
}
|
|
120
|
+
let pid = NaN;
|
|
121
|
+
try {
|
|
122
|
+
pid = Number(JSON.parse(held).pid);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* Not a lock this server wrote; leave it. */
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid || running(pid))
|
|
128
|
+
return false;
|
|
129
|
+
const aside = `${lock}.${randomUUID()}.stale`;
|
|
130
|
+
try {
|
|
131
|
+
await rename(lock, aside);
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
return e.code === "ENOENT";
|
|
135
|
+
}
|
|
136
|
+
if ((await readFile(aside, "utf8")) !== held)
|
|
137
|
+
// A live process took the lock in between: put it back unless another took it since.
|
|
138
|
+
await link(aside, lock).catch(() => { });
|
|
139
|
+
await unlink(aside);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
function running(pid) {
|
|
143
|
+
try {
|
|
144
|
+
process.kill(pid, 0);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
return e.code === "EPERM";
|
|
149
|
+
}
|
|
150
|
+
}
|
package/dist/wait.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StateStore } from "./store.js";
|
|
3
|
+
import { DeploymentRuntime } from "./runtime.js";
|
|
4
|
+
export declare const EXIT: {
|
|
5
|
+
readonly done: 0;
|
|
6
|
+
readonly error: 1;
|
|
7
|
+
readonly attention: 2;
|
|
8
|
+
readonly running: 3;
|
|
9
|
+
readonly nothing: 4;
|
|
10
|
+
};
|
|
11
|
+
export interface WaitOptions {
|
|
12
|
+
maxMs: number;
|
|
13
|
+
/** A deployment nobody advanced for this long is continued here; Infinity only watches. */
|
|
14
|
+
staleMs?: number;
|
|
15
|
+
pollMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface WaitResult {
|
|
18
|
+
results: any[];
|
|
19
|
+
running: string[];
|
|
20
|
+
code: number;
|
|
21
|
+
}
|
|
22
|
+
/** Deployments still in progress that were touched in the last two hours. */
|
|
23
|
+
export declare function active(store: StateStore): Promise<string[]>;
|
|
24
|
+
export declare function waitFor(runtime: DeploymentRuntime, ids: string[], { maxMs, staleMs, pollMs }: WaitOptions): Promise<WaitResult>;
|
|
25
|
+
/** One line per deployment, for an agent to read. */
|
|
26
|
+
export declare function summary({ results, running }: WaitResult): string;
|