@fourier-labs/harbour 0.1.6 → 0.1.7
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.
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir, platform } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
const scopes = "harbour:operate harbour:source-save offline_access";
|
|
8
|
+
export function tokenStorePath(env = process.env) {
|
|
9
|
+
return join(env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config"), "harbour", "tokens.json");
|
|
10
|
+
}
|
|
11
|
+
export function tokenStoreKey(mcpUrl, tenant) { return `${new URL(mcpUrl).toString()}|${tenant}`; }
|
|
12
|
+
export async function loadStoredToken(mcpUrl, tenant, path = tokenStorePath()) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
15
|
+
const token = parsed.version === 1 ? parsed.tokens?.[tokenStoreKey(mcpUrl, tenant)] : undefined;
|
|
16
|
+
return validToken(token) ? token : undefined;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (isMissing(error))
|
|
20
|
+
return undefined;
|
|
21
|
+
throw new Error("Harbour could not read its local sign-in record. Run `harbour logout` and sign in again.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function saveStoredToken(mcpUrl, tenant, token, path = tokenStorePath()) {
|
|
25
|
+
if (!validToken(token))
|
|
26
|
+
throw new Error("Harbour received an incomplete sign-in response.");
|
|
27
|
+
let store = { version: 1, tokens: {} };
|
|
28
|
+
try {
|
|
29
|
+
store = JSON.parse(await readFile(path, "utf8"));
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (!isMissing(error))
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
if (store.version !== 1 || !store.tokens || typeof store.tokens !== "object")
|
|
36
|
+
store = { version: 1, tokens: {} };
|
|
37
|
+
store.tokens[tokenStoreKey(mcpUrl, tenant)] = token;
|
|
38
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
39
|
+
await chmod(dirname(path), 0o700);
|
|
40
|
+
await writeFile(path, JSON.stringify(store), { mode: 0o600 });
|
|
41
|
+
await chmod(path, 0o600);
|
|
42
|
+
}
|
|
43
|
+
export async function clearStoredToken(mcpUrl, tenant, path = tokenStorePath()) {
|
|
44
|
+
const token = await loadStoredToken(mcpUrl, tenant, path);
|
|
45
|
+
if (!token)
|
|
46
|
+
return undefined;
|
|
47
|
+
const store = JSON.parse(await readFile(path, "utf8"));
|
|
48
|
+
delete store.tokens[tokenStoreKey(mcpUrl, tenant)];
|
|
49
|
+
if (Object.keys(store.tokens).length)
|
|
50
|
+
await writeFile(path, JSON.stringify(store), { mode: 0o600 });
|
|
51
|
+
else
|
|
52
|
+
await rm(path, { force: true });
|
|
53
|
+
return token;
|
|
54
|
+
}
|
|
55
|
+
export async function login(mcpUrl, tenant, output) {
|
|
56
|
+
const metadata = await discover(mcpUrl);
|
|
57
|
+
if (!metadata.registration_endpoint || !metadata.authorization_endpoint || !metadata.token_endpoint)
|
|
58
|
+
throw new Error("Harbour sign-in is not available on this endpoint yet.");
|
|
59
|
+
const callback = await loopbackCallback();
|
|
60
|
+
try {
|
|
61
|
+
const registered = await json(metadata.registration_endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ client_name: "Harbour CLI", redirect_uris: [callback.redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" }) });
|
|
62
|
+
if (!registered.client_id)
|
|
63
|
+
throw new Error("Harbour did not register this sign-in session.");
|
|
64
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
65
|
+
const state = randomBytes(24).toString("base64url");
|
|
66
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
67
|
+
const authorize = new URL(metadata.authorization_endpoint);
|
|
68
|
+
authorize.search = new URLSearchParams({ response_type: "code", client_id: registered.client_id, redirect_uri: callback.redirectUri, scope: scopes, state, resource: mcpUrl, code_challenge: challenge, code_challenge_method: "S256" }).toString();
|
|
69
|
+
output("Harbour is opening your browser to sign in.");
|
|
70
|
+
openBrowser(authorize.toString());
|
|
71
|
+
output(`If it does not open, visit: ${authorize}`);
|
|
72
|
+
const code = await callback.wait(state);
|
|
73
|
+
const response = await token(metadata.token_endpoint, { grant_type: "authorization_code", client_id: registered.client_id, code, redirect_uri: callback.redirectUri, code_verifier: verifier });
|
|
74
|
+
await saveStoredToken(mcpUrl, tenant, { ...response, clientId: registered.client_id, tokenEndpoint: metadata.token_endpoint, ...(metadata.revocation_endpoint ? { revocationEndpoint: metadata.revocation_endpoint } : {}) });
|
|
75
|
+
output("Harbour sign-in is complete.");
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
callback.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function refreshStoredToken(mcpUrl, tenant, path = tokenStorePath()) {
|
|
82
|
+
const stored = await loadStoredToken(mcpUrl, tenant, path);
|
|
83
|
+
if (!stored)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (!stored.refreshToken)
|
|
86
|
+
return stored.accessToken;
|
|
87
|
+
try {
|
|
88
|
+
const refreshed = await token(stored.tokenEndpoint, { grant_type: "refresh_token", client_id: stored.clientId, refresh_token: stored.refreshToken });
|
|
89
|
+
await saveStoredToken(mcpUrl, tenant, { ...stored, ...refreshed, refreshToken: refreshed.refreshToken ?? stored.refreshToken }, path);
|
|
90
|
+
return refreshed.accessToken;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error("Harbour sign-in expired or was revoked. Run `harbour login` again.");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function logout(mcpUrl, tenant) {
|
|
97
|
+
const stored = await clearStoredToken(mcpUrl, tenant);
|
|
98
|
+
if (stored?.refreshToken && stored.revocationEndpoint) {
|
|
99
|
+
try {
|
|
100
|
+
await fetch(stored.revocationEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: stored.refreshToken, client_id: stored.clientId }), redirect: "error" });
|
|
101
|
+
}
|
|
102
|
+
catch { /* Local credentials are removed even if offline revocation cannot complete. */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function discover(mcpUrl) {
|
|
106
|
+
const resource = new URL(mcpUrl);
|
|
107
|
+
resource.pathname = `${resource.pathname.replace(/\/mcp$/, "")}/.well-known/oauth-protected-resource`.replace(/\/\//g, "/");
|
|
108
|
+
const protectedResource = await json(resource.toString());
|
|
109
|
+
const server = protectedResource.authorization_servers?.[0];
|
|
110
|
+
if (!server)
|
|
111
|
+
throw new Error("Harbour did not advertise a sign-in service.");
|
|
112
|
+
const wellKnown = new URL("/.well-known/oauth-authorization-server", server).toString();
|
|
113
|
+
const metadata = await json(wellKnown);
|
|
114
|
+
if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.registration_endpoint)
|
|
115
|
+
throw new Error("Harbour sign-in metadata is incomplete.");
|
|
116
|
+
return { authorization_endpoint: metadata.authorization_endpoint, token_endpoint: metadata.token_endpoint, registration_endpoint: metadata.registration_endpoint, revocation_endpoint: metadata.revocation_endpoint ?? "" };
|
|
117
|
+
}
|
|
118
|
+
async function token(endpoint, values) {
|
|
119
|
+
const response = await json(endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(values), redirect: "error" });
|
|
120
|
+
if (!response.access_token)
|
|
121
|
+
throw new Error("Harbour sign-in did not return an access token.");
|
|
122
|
+
return { accessToken: response.access_token, ...(response.refresh_token ? { refreshToken: response.refresh_token } : {}) };
|
|
123
|
+
}
|
|
124
|
+
async function json(url, init) { const response = await fetch(url, { ...init, redirect: "error" }); if (!response.ok)
|
|
125
|
+
throw new Error(`Harbour sign-in request failed (${response.status}).`); return response.json(); }
|
|
126
|
+
function validToken(value) { return Boolean(value && typeof value === "object" && typeof value.accessToken === "string" && typeof value.clientId === "string" && typeof value.tokenEndpoint === "string"); }
|
|
127
|
+
function isMissing(error) { return Boolean(error && typeof error === "object" && error.code === "ENOENT"); }
|
|
128
|
+
function loopbackCallback() {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const server = createServer();
|
|
131
|
+
const timer = setTimeout(() => { server.close(); reject(new Error("Harbour sign-in timed out. Run `harbour login` again.")); }, 5 * 60_000);
|
|
132
|
+
server.on("request", (request, response) => {
|
|
133
|
+
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
134
|
+
const code = requestUrl.searchParams.get("code");
|
|
135
|
+
const state = requestUrl.searchParams.get("state");
|
|
136
|
+
response.writeHead(code && state ? 200 : 400, { "content-type": "text/plain; charset=utf-8" });
|
|
137
|
+
response.end(code && state ? "Harbour sign-in complete. You can close this tab." : "Harbour sign-in could not be completed.");
|
|
138
|
+
if (code && state)
|
|
139
|
+
server.callback = { code, state };
|
|
140
|
+
});
|
|
141
|
+
server.listen(0, "127.0.0.1", () => {
|
|
142
|
+
const address = server.address();
|
|
143
|
+
if (!address || typeof address === "string") {
|
|
144
|
+
reject(new Error("Harbour could not start local sign-in."));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
resolve({ redirectUri: `http://127.0.0.1:${address.port}/callback`, wait: expected => new Promise((done, fail) => {
|
|
148
|
+
const poll = setInterval(() => { const callback = server.callback; if (!callback)
|
|
149
|
+
return; clearInterval(poll); clearTimeout(timer); if (callback.state !== expected)
|
|
150
|
+
fail(new Error("Harbour sign-in returned an invalid state."));
|
|
151
|
+
else
|
|
152
|
+
done(callback.code); }, 25);
|
|
153
|
+
}), close: () => { clearTimeout(timer); server.close(); } });
|
|
154
|
+
});
|
|
155
|
+
server.on("error", reject);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function openBrowser(url) { const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open"; const args = platform() === "win32" ? ["/c", "start", "", url] : [url]; const child = spawn(command, args, { detached: true, stdio: "ignore" }); child.unref(); child.on("error", () => { }); }
|
|
@@ -3,6 +3,7 @@ import { RemoteMcpClient } from "./remote-mcp-client.js";
|
|
|
3
3
|
import { productionise } from "./productionise.js";
|
|
4
4
|
import { safeError, CliError } from "./output.js";
|
|
5
5
|
import { CLI_VERSION } from "./version.js";
|
|
6
|
+
import { login, logout, refreshStoredToken } from "./auth.js";
|
|
6
7
|
const args = process.argv.slice(2);
|
|
7
8
|
const command = args[0];
|
|
8
9
|
const rootIndex = args.indexOf("--app-root");
|
|
@@ -21,27 +22,42 @@ for (let index = 0; index < args.length; index += 1) {
|
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
const url = process.env.HARBOUR_MCP_URL;
|
|
24
|
-
const
|
|
25
|
+
const explicitToken = process.env.HARBOUR_TOKEN?.trim() ?? "";
|
|
25
26
|
const tenant = process.env.HARBOUR_TENANT ?? "";
|
|
26
|
-
const usage = "Usage: harbour productionise --app-root <path> [--include <relative-path>]... [--json]\nSet HARBOUR_MCP_URL
|
|
27
|
+
const usage = "Usage: harbour login | logout | productionise --app-root <path> [--include <relative-path>]... [--json]\nSet HARBOUR_MCP_URL and HARBOUR_TENANT. HARBOUR_TOKEN is a controlled non-production override.\n";
|
|
27
28
|
if (command === "--version" || command === "version") {
|
|
28
29
|
process.stdout.write(`${CLI_VERSION}\n`);
|
|
29
30
|
}
|
|
30
31
|
else if (!command || command === "help" || command === "--help" || args.includes("-h")) {
|
|
31
32
|
process.stdout.write(usage);
|
|
32
33
|
}
|
|
33
|
-
else if (
|
|
34
|
+
else if (!url || !tenant || !["login", "logout", "productionise"].includes(command) || (command === "productionise" && (optionError || !root || root.startsWith("--")))) {
|
|
34
35
|
if (optionError)
|
|
35
36
|
process.stderr.write(`${optionError}\n`);
|
|
36
37
|
process.stderr.write(usage);
|
|
37
38
|
process.exitCode = 2;
|
|
38
39
|
}
|
|
39
40
|
else {
|
|
40
|
-
const client = new RemoteMcpClient(url, token, tenant);
|
|
41
41
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
if (command === "login") {
|
|
43
|
+
await login(url, tenant, message => process.stderr.write(`${message}\n`));
|
|
44
|
+
process.stdout.write("Harbour sign-in complete.\n");
|
|
45
|
+
process.exitCode = 0;
|
|
46
|
+
}
|
|
47
|
+
else if (command === "logout") {
|
|
48
|
+
await logout(url, tenant);
|
|
49
|
+
process.stdout.write("Harbour sign-in removed from this device.\n");
|
|
50
|
+
process.exitCode = 0;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
const token = explicitToken || await refreshStoredToken(url, tenant);
|
|
54
|
+
if (!token)
|
|
55
|
+
throw new CliError("AUTH_REQUIRED", "Sign in with `harbour login` before productionising an app.");
|
|
56
|
+
const client = new RemoteMcpClient(url, token, tenant);
|
|
57
|
+
const result = await productionise(root, client, message => { process.stderr.write(`${message}\n`); }, tenant, includePaths);
|
|
58
|
+
const envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: true, operationRef: result.operationRef, result: result.result };
|
|
59
|
+
process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
60
|
+
}
|
|
45
61
|
}
|
|
46
62
|
catch (error) {
|
|
47
63
|
const safe = safeError(error);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.7";
|