@fourier-labs/harbour 0.1.13 → 0.1.14

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.
@@ -108,15 +108,38 @@ export async function refreshStoredToken(mcpUrl, tenant, path = tokenStorePath()
108
108
  throw new Error("Harbour sign-in expired or was revoked. Run `harbour login` again.");
109
109
  }
110
110
  }
111
- export async function logout(mcpUrl, tenant) {
112
- const stored = await clearStoredToken(mcpUrl, tenant);
113
- if (stored?.refreshToken && stored.revocationEndpoint) {
111
+ /**
112
+ * Removes the local record and revokes both stored tokens when the server
113
+ * advertises a revocation endpoint. Offline revocation cannot reach a token
114
+ * already held elsewhere; the local copy is removed regardless.
115
+ */
116
+ export async function logout(mcpUrl, tenant, path = tokenStorePath()) {
117
+ const stored = await clearStoredToken(mcpUrl, tenant, path);
118
+ if (!stored?.revocationEndpoint)
119
+ return;
120
+ for (const [token, hint] of [[stored.refreshToken, "refresh_token"], [stored.accessToken, "access_token"]]) {
121
+ if (!token)
122
+ continue;
114
123
  try {
115
- 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" });
124
+ await fetch(stored.revocationEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token, token_type_hint: hint, client_id: stored.clientId }), redirect: "error" });
116
125
  }
117
126
  catch { /* Local credentials are removed even if offline revocation cannot complete. */ }
118
127
  }
119
128
  }
129
+ /** Email of the signed-in company account, from the OAuth userinfo endpoint beside the token endpoint; undefined when unavailable. */
130
+ export async function connectedAccount(mcpUrl, tenant, accessToken, path = tokenStorePath()) {
131
+ const stored = await loadStoredToken(mcpUrl, tenant, path);
132
+ if (!stored)
133
+ return undefined;
134
+ try {
135
+ const response = await fetch(stored.tokenEndpoint.replace(/\/token$/, "/userinfo"), { headers: { authorization: `Bearer ${accessToken}` }, redirect: "error" });
136
+ const body = response.ok ? await response.json() : {};
137
+ return typeof body.email === "string" ? body.email : undefined;
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
120
143
  async function discover(mcpUrl) {
121
144
  const resource = new URL(mcpUrl);
122
145
  resource.pathname = `${resource.pathname.replace(/\/mcp$/, "")}/.well-known/oauth-protected-resource`.replace(/\/\//g, "/");
@@ -0,0 +1,119 @@
1
+ import { readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
4
+ import { LocalRuntime, runningOrigin } from "./local-runtime.js";
5
+ import { CLI_VERSION } from "./version.js";
6
+ /** Runs the kit checks and writes `.harbour/local/check-report.json`; a source edit changes sourceDigest and so invalidates the previous report. */
7
+ export async function runChecks(root, options) {
8
+ const { output, run, bundle } = options;
9
+ const checks = [];
10
+ const record = (name, status, detail) => { checks.push({ name, status, ...(detail ? { detail } : {}) }); output(`${status === "pass" ? "ok " : status === "fail" ? "FAIL" : "skip"} ${name}${detail ? ` — ${detail}` : ""}`); };
11
+ const source = await sourceDigest(root);
12
+ const previous = await readReport(root);
13
+ if (previous && previous.sourceDigest !== source.digest)
14
+ output("Source changed since the last report; the previous report is no longer valid.");
15
+ const declaration = await readDeclaration(root);
16
+ record("declaration", declaration.errors.length ? "fail" : "pass", declaration.errors[0]);
17
+ let lockDetail;
18
+ const lock = await readKitLock(root).catch(error => { lockDetail = error instanceof Error ? error.message : String(error); return undefined; });
19
+ record("kit_lock", lock ? "pass" : "fail", lockDetail ?? (lock ? undefined : ".harbour/kit.lock.json is missing (run `harbour init`)"));
20
+ const typecheck = await run("npx", ["tsc", "--noEmit"], { cwd: root, quiet: true });
21
+ record("typecheck", typecheck.code === 0 ? "pass" : "fail", typecheck.code === 0 ? undefined : lastLines(typecheck.stdout || typecheck.stderr));
22
+ const build = await run("npm", ["run", "build"], { cwd: root, quiet: true });
23
+ record("build", build.code === 0 ? "pass" : "fail", build.code === 0 ? undefined : lastLines(build.stderr || build.stdout));
24
+ const throwaway = LocalRuntime.forCheck(root, run);
25
+ try {
26
+ await throwaway.writeCheckFiles(bundle);
27
+ await throwaway.up();
28
+ const applied = await throwaway.migrate();
29
+ record("migrations", "pass", `${applied.length} file(s) applied to a disposable database`);
30
+ }
31
+ catch (error) {
32
+ record("migrations", "fail", error instanceof Error ? error.message : String(error));
33
+ }
34
+ finally {
35
+ await throwaway.down();
36
+ }
37
+ const origin = await (options.localOrigin ?? (() => runningOrigin(root)))();
38
+ const journeys = (await readdir(kitPaths(root).checks).catch(() => [])).filter(name => /\.(mjs|js|cjs)$/.test(name)).sort();
39
+ if (!journeys.length)
40
+ record("journeys", "not_run", "no checks under .harbour/checks/");
41
+ else if (!origin)
42
+ record("journeys", "not_run", "harbour dev is not running; start it to exercise the journey checks");
43
+ else
44
+ for (const name of journeys) {
45
+ const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { HARBOUR_APP_URL: origin, HARBOUR_SDK_MODULE: join(root, "node_modules", "@harbour", "app-sdk", "dist", "index.js") } });
46
+ record(`journey:${name}`, result.code === 0 ? "pass" : "fail", result.code === 0 ? undefined : lastLines(result.stderr || result.stdout));
47
+ }
48
+ let integrations = "not tested";
49
+ if (options.governance && lock?.appId && !declaration.errors.length) {
50
+ integrations = await testIntegrationReads(lock.appId, options.governance, declaration.declaration, output);
51
+ if (!integrations.length)
52
+ output("No READY development grants with read operations; real integrations were not exercised.");
53
+ }
54
+ else if (options.governance)
55
+ output("Integrations were not tested: the app is not linked yet or the declaration is invalid.");
56
+ const report = {
57
+ schema: "harbour.check-report/1.0",
58
+ createdAt: new Date().toISOString(),
59
+ sourceDigest: source.digest,
60
+ toolchain: { node: process.version, cliVersion: CLI_VERSION, bundle: { kitVersion: bundle.kitVersion, sdkTarballSha256: bundle.sdk.tarballSha256, appGateway: bundle.images.appGateway, sessionFixture: bundle.images.sessionFixture, briefFingerprint: bundle.brief.fingerprint } },
61
+ checks,
62
+ integrations,
63
+ passed: checks.every(check => check.status !== "fail") && (integrations === "not tested" || integrations.every(item => item.status !== "fail"))
64
+ };
65
+ await writeFile(kitPaths(root).report, `${JSON.stringify(report, null, 2)}\n`).catch(() => undefined);
66
+ return report;
67
+ }
68
+ /** Explicit read operations only (READ_OPERATIONS) against READY development grants; sends never run. A dependent read (gmail.message.read) is exercised through its list: the newest message of the first thread, when there is one. */
69
+ async function testIntegrationReads(appId, client, declaration, output) {
70
+ const results = [];
71
+ const { grants } = await client.list(appId);
72
+ for (const grant of grants) {
73
+ if (grant.environment !== "development" || grant.readiness !== "ready")
74
+ continue;
75
+ const declared = declaration.connections[grant.connection];
76
+ for (const operation of grant.operations) {
77
+ if (!READ_OPERATIONS.includes(operation))
78
+ continue;
79
+ const declaredOperation = declared?.operations[operation];
80
+ if (!declaredOperation)
81
+ continue;
82
+ for (const resource of resourceNames(declaredOperation)) {
83
+ const input = operation === "warehouse.view.read" ? { columns: declaredOperation.resources[resource]?.columns ?? [], limit: 1 } : { limit: 1 };
84
+ const record = (name, status, detail) => { results.push({ connection: grant.connection, operation: name, resource, status, ...(detail ? { detail } : {}) }); output(`${status === "pass" ? "ok " : "FAIL"} integration ${grant.connection} ${name} ${resource}`); };
85
+ let listed;
86
+ try {
87
+ listed = await client.execute(appId, { connection: grant.connection, operation, resource, input });
88
+ record(operation, "pass");
89
+ }
90
+ catch (error) {
91
+ record(operation, "fail", error instanceof Error ? error.message : String(error));
92
+ continue;
93
+ }
94
+ // gmail.message.read needs a message id: read the newest message of the first listed thread when the grant covers it.
95
+ const dependent = DEPENDENT_READ_OPERATIONS.find(name => name === "gmail.message.read" && operation === "gmail.thread.list" && grant.operations.includes(name) && declared?.operations[name]);
96
+ const messageId = listed?.threads?.[0]?.messageId;
97
+ if (!dependent || !messageId)
98
+ continue;
99
+ try {
100
+ await client.execute(appId, { connection: grant.connection, operation: dependent, resource, input: { messageId } });
101
+ record(dependent, "pass");
102
+ }
103
+ catch (error) {
104
+ record(dependent, "fail", error instanceof Error ? error.message : String(error));
105
+ }
106
+ }
107
+ }
108
+ }
109
+ return results;
110
+ }
111
+ export async function readReport(root) {
112
+ try {
113
+ return JSON.parse(await readFile(kitPaths(root).report, "utf8"));
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ }
119
+ function lastLines(text, count = 5) { return text.trim().split("\n").slice(-count).join(" | ").slice(0, 600); }
@@ -4,8 +4,15 @@ import { productionise } from "./productionise.js";
4
4
  import { confirmAudience, confirmProfile, dismissSecret, fetchStatus, getAppSetup, listSecrets, outcomeFor, promoteToProduction, readSecretFromStdin, readSecretFromTerminal, retryDeployment, setSecret, summarize, waitForSettled } from "./operations.js";
5
5
  import { safeError, CliError, renderSummary } from "./output.js";
6
6
  import { CLI_VERSION } from "./version.js";
7
- import { login, logout, refreshStoredToken } from "./auth.js";
7
+ import { connectedAccount, login, logout, refreshStoredToken } from "./auth.js";
8
8
  import { connect, loadConfig, resolveConfig } from "./config.js";
9
+ import { loadKitBundle } from "./kit-bundle.js";
10
+ import { appRoot } from "./kit.js";
11
+ import { initKit } from "./starter.js";
12
+ import { startDev } from "./dev.js";
13
+ import { ensureSdk, LocalRuntime, readDevLock, releaseDevLock, runCommand } from "./local-runtime.js";
14
+ import { runChecks } from "./check.js";
15
+ import { GovernanceClient, integrationsStatus, requestIntegrations } from "./integrations.js";
9
16
  const args = process.argv.slice(2);
10
17
  const command = args[0];
11
18
  const connectUrl = args[1];
@@ -21,6 +28,13 @@ const personal = args.includes("--personal");
21
28
  const valueStdin = args.includes("--value-stdin");
22
29
  const subcommand = args[1] && !args[1].startsWith("--") ? args[1] : undefined;
23
30
  const wait = args.includes("--wait");
31
+ const reset = args.includes("--reset");
32
+ const upgrade = args.includes("--upgrade");
33
+ const testIntegrations = args.includes("--integrations");
34
+ const reason = optionValue("--reason");
35
+ const environment = optionValue("--environment");
36
+ const operations = optionValue("--operations");
37
+ const expiresAt = optionValue("--expires-at");
24
38
  const includePaths = [];
25
39
  let optionError;
26
40
  for (let index = 0; index < args.length; index += 1) {
@@ -49,22 +63,35 @@ const usage = [
49
63
  " harbour secrets list --operation <reference> [--json]",
50
64
  " harbour secrets set --operation <reference> --name <NAME> [--personal] [--value-stdin]",
51
65
  " harbour secrets dismiss --operation <reference> --name <NAME>",
66
+ " harbour init --app-root <path> [--upgrade] create the starter or add the kit files; --upgrade re-pins the kit bundle",
67
+ " harbour dev --app-root <path> [--reset] run the app locally on one loopback origin (--reset deletes this app's local data)",
68
+ " harbour stop --app-root <path> stop this app's local services, keeping data",
69
+ " harbour check --app-root <path> [--integrations] [--json] declaration, types, build, migrations, journeys (+ authorised real reads)",
70
+ " harbour integrations request <connection> --reason <text> --app-root <path> [--environment <env>] [--operations a,b] [--expires-at <UTC>] [--json]",
71
+ " harbour integrations status --app-root <path> [--json]",
52
72
  "Run `harbour connect <company-start-url>` once, then sign in when Harbour asks.",
53
73
  "productionise saves the app, follows its deployment, and prints the protected preview link; promote sends a tested preview to production.",
74
+ "For kit apps, productionise first checks that every connection in .harbour/integrations.json has a preview grant and exits 2 (INTEGRATIONS_NOT_READY) with the requests to make.",
54
75
  "secrets set reads the value from your terminal with echo off (or from stdin with --value-stdin); it is never printed or passed to any other program.",
55
76
  ""
56
77
  ].join("\n");
57
78
  const OPERATION_COMMANDS = ["status", "retry", "promote", "setup", "profile", "audience", "secrets"];
79
+ const LOCAL_COMMANDS = ["init", "dev", "stop", "check"];
58
80
  const progress = (message) => { process.stderr.write(`${message}\n`); };
81
+ /** Envelope for commands that start no Harbour operation (local kit commands, integrations). */
82
+ const summaryEnvelope = (result) => ({ schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: false, result });
83
+ const emit = (value) => { process.stdout.write(json ? `${JSON.stringify(value)}\n` : renderSummary(value)); process.exitCode = 0; };
59
84
  if (command === "--version" || command === "version") {
60
85
  process.stdout.write(`${CLI_VERSION}\n`);
61
86
  }
62
87
  else if (!command || command === "help" || command === "--help" || args.includes("-h")) {
63
88
  process.stdout.write(usage);
64
89
  }
65
- else if (!["connect", "login", "logout", "productionise", ...OPERATION_COMMANDS].includes(command)
90
+ else if (!["connect", "login", "logout", "productionise", "integrations", ...LOCAL_COMMANDS, ...OPERATION_COMMANDS].includes(command)
66
91
  || (command === "connect" && (!connectUrl || connectUrl.startsWith("--")))
67
92
  || (command === "productionise" && (optionError || !root))
93
+ || (LOCAL_COMMANDS.includes(command) && !root)
94
+ || (command === "integrations" && (!root || !subcommand || !["request", "status"].includes(subcommand) || (subcommand === "request" && (!args[2] || args[2].startsWith("--") || !reason))))
68
95
  || (OPERATION_COMMANDS.includes(command) && !operationRef)
69
96
  || (command === "secrets" && (!subcommand || !["list", "set", "dismiss"].includes(subcommand) || (subcommand !== "list" && !secretName)))) {
70
97
  if (optionError)
@@ -79,6 +106,69 @@ else {
79
106
  process.stdout.write(`Harbour is connected for ${saved.tenantId}.\n`);
80
107
  process.exitCode = 0;
81
108
  }
109
+ else if (LOCAL_COMMANDS.includes(command)) {
110
+ // Local commands run before the company config/login requirement: the base app needs neither.
111
+ const bundle = await loadKitBundle();
112
+ const target = appRoot(root);
113
+ const config = resolveConfig(process.env, await loadConfig());
114
+ const companyToken = async () => config ? (explicitToken || await refreshStoredToken(config.mcpUrl, config.tenantId)) : undefined;
115
+ if (command === "init") {
116
+ const result = await initKit(target, bundle, { upgrade, tenantId: config?.tenantId });
117
+ for (const line of [...result.created.map(path => `created ${path}`), ...result.updated.map(path => `updated ${path}`), ...result.kept.map(path => `kept ${path}`), ...result.bundleChanges.map(change => `bundle ${change}`)])
118
+ progress(line);
119
+ // The SDK is not on the public registry: init installs the pinned tarball (and the app's other dependencies) itself.
120
+ const sdk = await ensureSdk(target, bundle, process.env, runCommand, progress);
121
+ if (sdk === "missing")
122
+ progress("The kit SDK was not installed: set HARBOUR_KIT_SDK_TARBALL to the bundle's @harbour/app-sdk tarball (or use a bundle with sdk.url), then rerun `harbour init`.");
123
+ progress(result.mode === "starter" ? "Starter created. Next: `harbour dev --app-root <path>`. Codex reads the AGENTS.md block; Claude Code reads .claude/skills/harbour-kit/SKILL.md." : upgrade ? (result.bundleChanges.length ? "Kit bundle upgraded; running checks." : "Kit bundle already current; running checks.") : "Kit files added; existing files were kept.");
124
+ if (upgrade) {
125
+ const report = await runChecks(target, { run: runCommand, bundle, output: progress });
126
+ emit(summaryEnvelope({ ...result, report }));
127
+ }
128
+ else
129
+ emit(summaryEnvelope(result));
130
+ }
131
+ else if (command === "stop") {
132
+ const runtime = new LocalRuntime(target);
133
+ const lock = await readDevLock(target);
134
+ if (lock && lock.pid !== process.pid) {
135
+ try {
136
+ process.kill(lock.pid, "SIGTERM");
137
+ }
138
+ catch { /* already gone */ }
139
+ }
140
+ const stopped = await runtime.stop();
141
+ await releaseDevLock(target);
142
+ emit(summaryEnvelope({ project: runtime.project, stopped: stopped.stopped, volumesRetained: true }));
143
+ }
144
+ else if (command === "check") {
145
+ let governance;
146
+ if (testIntegrations) {
147
+ if (!config)
148
+ throw new CliError("CONFIG_REQUIRED", "`--integrations` needs a company connection: run `harbour connect <company-start-url>`.");
149
+ const token = await companyToken();
150
+ if (!token)
151
+ throw new CliError("AUTH_REQUIRED", "`--integrations` needs a Harbour sign-in: run `harbour login`.");
152
+ governance = new GovernanceClient(config.apiUrl, token, config.tenantId);
153
+ }
154
+ const report = await runChecks(target, { run: runCommand, bundle, output: progress, governance });
155
+ if (!report.passed) {
156
+ process.stdout.write(json ? `${JSON.stringify({ ...summaryEnvelope(report), status: "FAILED", error: { code: "CHECKS_FAILED", message: "One or more checks failed; see .harbour/local/check-report.json." } })}\n` : "Checks failed; see .harbour/local/check-report.json.\n");
157
+ process.exitCode = 1;
158
+ }
159
+ else
160
+ emit(summaryEnvelope(report));
161
+ }
162
+ else {
163
+ const started = await startDev(target, { bundle, output: progress, reset, company: config ? { apiUrl: config.apiUrl, tenantId: config.tenantId, accessToken: companyToken, account: async () => { const token = await companyToken(); return token ? connectedAccount(config.mcpUrl, config.tenantId, token) : undefined; } } : undefined });
164
+ let stopping = false;
165
+ const shutdown = () => { if (stopping)
166
+ return; stopping = true; progress("Stopping local Harbour services (data kept)."); void started.stop().finally(() => process.exit(0)); };
167
+ process.on("SIGINT", shutdown);
168
+ process.on("SIGTERM", shutdown);
169
+ await new Promise(() => { });
170
+ }
171
+ }
82
172
  else {
83
173
  const config = resolveConfig(process.env, await loadConfig());
84
174
  if (!config)
@@ -96,13 +186,26 @@ else {
96
186
  process.exitCode = 0;
97
187
  }
98
188
  else {
99
- const token = explicitToken || await refreshStoredToken(url, tenant);
189
+ const resolveToken = async () => explicitToken || await refreshStoredToken(url, tenant);
190
+ const token = await resolveToken();
100
191
  if (!token)
101
192
  throw new CliError("AUTH_REQUIRED", "Please sign in to Harbour with `harbour login`.");
102
- const client = new RemoteMcpClient(url, token, tenant);
193
+ // The client re-resolves per request so a token rotated by a sibling process mid-poll is picked up.
194
+ const client = new RemoteMcpClient(url, resolveToken, tenant);
103
195
  let envelope;
104
- if (command === "productionise") {
105
- const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait });
196
+ if (command === "integrations") {
197
+ const target = appRoot(root);
198
+ const governance = new GovernanceClient(config.apiUrl, token, tenant);
199
+ const result = subcommand === "status"
200
+ ? await integrationsStatus(target, governance)
201
+ : await requestIntegrations(target, governance, tenant, await loadKitBundle(), { connection: args[2], reason: reason, environment, operations: operations?.split(",").map(value => value.trim()).filter(Boolean), expiresAt });
202
+ envelope = summaryEnvelope(result);
203
+ if (!json)
204
+ progress(subcommand === "status" ? renderIntegrationsStatus(result) : renderRequest(result));
205
+ }
206
+ else if (command === "productionise") {
207
+ // Kit apps: preview grants are checked (and the app linked) before any operation starts, so productionise never mints a second app for the same root.
208
+ const result = await productionise(root, client, progress, tenant, includePaths, { waitForDeployment: !noWait, integrations: { governance: new GovernanceClient(config.apiUrl, token, tenant), bundle: await loadKitBundle() } });
106
209
  envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "SUCCEEDED", operationStarted: true, operationRef: result.operationRef, result: result.result };
107
210
  }
108
211
  else {
@@ -137,9 +240,25 @@ else {
137
240
  const envelope = { schema: "harbour.cli-result/1.0", cliVersion: CLI_VERSION, status: "FAILED", operationStarted: error instanceof CliError ? Boolean(error.operationRef) : false, ...(error instanceof CliError && error.operationRef ? { operationRef: error.operationRef } : {}), error: safe };
138
241
  if (json || command === "productionise")
139
242
  process.stdout.write(`${JSON.stringify(envelope)}\n`);
140
- process.exitCode = 1;
243
+ // Exit 2, like a usage error: nothing started and the fix is a command the maker runs.
244
+ process.exitCode = safe.code === "INTEGRATIONS_NOT_READY" ? 2 : 1;
141
245
  }
142
246
  }
247
+ function renderIntegrationsStatus(status) {
248
+ if (!status.linked)
249
+ return "This app is not linked yet; `harbour integrations request` links it.";
250
+ const lines = [`App ${status.appId}`];
251
+ for (const grant of status.grants)
252
+ lines.push(` ${grant.connection} [${grant.environment}, ${grant.identityMode}] ${grant.status} — ${grant.readiness}${grant.expiresAt ? ` until ${grant.expiresAt}` : ""}: ${grant.operations.join(", ")}`);
253
+ for (const request of status.requests)
254
+ lines.push(` request ${request.requestId}: ${request.state} (${request.reason})`);
255
+ if (!status.grants.length && !status.requests.length)
256
+ lines.push(" no grants or requests yet");
257
+ return lines.join("\n");
258
+ }
259
+ function renderRequest(result) {
260
+ return [`App ${result.appId}: ${result.connection} (${result.environment})`, ...result.requests.map(item => ` ${item.identityMode} identity — ${item.operations.join(", ")} on ${item.resources.join(", ")}: ${item.state === "READY" ? "READY" : item.state === "PENDING" ? "PENDING (not ready; IT approval or provider setup is outstanding)" : item.state}${item.readiness && item.readiness !== "ready" && item.readiness !== "pending" ? ` (${item.readiness})` : ""}`)].join("\n");
261
+ }
143
262
  function optionValue(flag) {
144
263
  const index = args.indexOf(flag);
145
264
  const value = index >= 0 ? args[index + 1] : undefined;
@@ -40,7 +40,8 @@ export async function connect(startUrl, path = configPath()) {
40
40
  schema: "harbour.cli-config/1.0",
41
41
  tenantId: String(body.tenantId ?? ""),
42
42
  mcpUrl: String(body.mcpUrl ?? ""),
43
- ...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {})
43
+ ...(typeof body.uploadUrl === "string" ? { uploadUrl: body.uploadUrl } : {}),
44
+ ...(typeof body.apiUrl === "string" ? { apiUrl: body.apiUrl } : {})
44
45
  };
45
46
  await saveConfig(config, path);
46
47
  return config;
@@ -50,7 +51,14 @@ export function resolveConfig(env, saved) {
50
51
  const tenantId = env.HARBOUR_TENANT?.trim() || saved?.tenantId;
51
52
  if (!mcpUrl || !tenantId)
52
53
  return undefined;
53
- return { schema: "harbour.cli-config/1.0", tenantId, mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}) };
54
+ return { schema: "harbour.cli-config/1.0", tenantId, mcpUrl, ...(saved?.uploadUrl ? { uploadUrl: saved.uploadUrl } : {}), apiUrl: env.HARBOUR_API_URL?.trim() || saved?.apiUrl || apiUrlFromMcpUrl(mcpUrl) };
55
+ }
56
+ /** `https://host/stage/mcp` -> `https://host/stage`: the dev routes live beside the MCP endpoint. */
57
+ export function apiUrlFromMcpUrl(mcpUrl) {
58
+ const url = new URL(mcpUrl);
59
+ url.pathname = url.pathname.replace(/\/mcp\/?$/, "");
60
+ url.search = "";
61
+ return url.toString().replace(/\/$/, "");
54
62
  }
55
63
  function validConfig(value) {
56
64
  return Boolean(isRecord(value)
@@ -59,7 +67,8 @@ function validConfig(value) {
59
67
  && value.tenantId.length > 0
60
68
  && typeof value.mcpUrl === "string"
61
69
  && /^https?:\/\//.test(value.mcpUrl)
62
- && (value.uploadUrl === undefined || typeof value.uploadUrl === "string"));
70
+ && (value.uploadUrl === undefined || typeof value.uploadUrl === "string")
71
+ && (value.apiUrl === undefined || (typeof value.apiUrl === "string" && /^https?:\/\//.test(value.apiUrl))));
63
72
  }
64
73
  function isRecord(value) {
65
74
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -0,0 +1,74 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createForwarder } from "./forwarder.js";
3
+ import { readKitLock } from "./kit.js";
4
+ import { acquireDevLock, allocatePorts, ensureSdk, LocalRuntime, releaseDevLock, runCommand } from "./local-runtime.js";
5
+ import { CliError } from "./output.js";
6
+ /**
7
+ * Starts the project's Compose services, applies migrations, starts Vite and the
8
+ * loopback origin. Resolves when the runtime is up; the returned `stop` runs
9
+ * `docker compose stop` (volumes retained) and is what Ctrl-C calls.
10
+ */
11
+ export async function startDev(root, options) {
12
+ const run = options.run ?? runCommand;
13
+ const env = options.env ?? process.env;
14
+ const runtime = new LocalRuntime(root, run);
15
+ if (options.reset)
16
+ await runtime.reset(options.output);
17
+ const ports = await allocatePorts();
18
+ await acquireDevLock(root, ports);
19
+ let vite;
20
+ let forwarder;
21
+ const stop = async () => {
22
+ forwarder?.close();
23
+ vite?.kill("SIGTERM");
24
+ await runtime.stop();
25
+ await releaseDevLock(root);
26
+ };
27
+ try {
28
+ const sdk = await ensureSdk(root, options.bundle, env, run, options.output);
29
+ if (sdk === "missing")
30
+ options.output("The kit SDK is not installed: set HARBOUR_KIT_SDK_TARBALL to the bundle's @harbour/app-sdk tarball (or use a bundle with sdk.url).");
31
+ await runtime.writeFiles(options.bundle, ports);
32
+ options.output("Pulling the kit images by digest (public registry, no login).");
33
+ await runtime.pull(options.bundle);
34
+ options.output("Starting local Harbour services (postgres, storage, realtime, identity fixture, app gateway).");
35
+ await runtime.up();
36
+ const applied = await runtime.migrate();
37
+ options.output(`Applied ${applied.length} migration file(s).`);
38
+ const session = await runtime.sessionEnv();
39
+ const origin = `http://127.0.0.1:${ports.origin}`;
40
+ vite = spawn("npm", ["run", "dev"], { cwd: root, env: { ...env, HARBOUR_LOCAL_ORIGIN: origin, HARBOUR_VITE_PORT: String(ports.vite) }, stdio: ["ignore", "inherit", "inherit"] });
41
+ vite.on("error", () => options.output("Vite could not be started; is npm installed and `npm install` done?"));
42
+ const company = options.company;
43
+ let warnedAuth = false;
44
+ let lockAppId = (await readKitLock(root))?.appId || undefined;
45
+ forwarder = createForwarder({
46
+ port: ports.origin, vitePort: ports.vite, gatewayPort: ports.gateway, identityToken: session.HARBOUR_IDENTITY_CONTEXT_TOKEN,
47
+ apiUrl: company?.apiUrl ?? "", tenantId: company?.tenantId ?? "",
48
+ appId: () => lockAppId,
49
+ accessToken: async () => company ? company.accessToken() : undefined,
50
+ onAuthRequired: () => { if (!warnedAuth) {
51
+ warnedAuth = true;
52
+ options.output("Company integrations need a Harbour sign-in: run `harbour login` (in another terminal) and retry in the app.");
53
+ } }
54
+ });
55
+ const refreshLock = setInterval(() => { void readKitLock(root).then(lock => { lockAppId = lock?.appId || undefined; }).catch(() => undefined); }, 5_000);
56
+ refreshLock.unref();
57
+ await new Promise((resolve, reject) => { forwarder.on("error", reject); forwarder.listen(ports.origin, "127.0.0.1", resolve); });
58
+ const account = company ? await company.account().catch(() => undefined) : undefined;
59
+ options.output([
60
+ "",
61
+ `Harbour dev is running: ${origin}`,
62
+ ` App identity (local fixture): ${session.HARBOUR_LOCAL_USER_EMAIL ?? "local-user@example.test"}`,
63
+ ` Company account for integrations: ${account ?? (company ? "not signed in — run `harbour login`" : "not connected — run `harbour connect`")}`,
64
+ lockAppId ? ` Linked app: ${lockAppId}` : " App not linked yet (harbour integrations request links it).",
65
+ " Ctrl-C or `harbour stop` stops the services and keeps local data; `harbour dev --reset` deletes it.",
66
+ ""
67
+ ].join("\n"));
68
+ return { origin, stop };
69
+ }
70
+ catch (error) {
71
+ await stop().catch(() => undefined);
72
+ throw error instanceof CliError ? error : new CliError("DEV_FAILED", error instanceof Error ? error.message : "harbour dev could not start.");
73
+ }
74
+ }
@@ -0,0 +1,147 @@
1
+ import { createServer, request as httpRequest } from "node:http";
2
+ import { connect } from "node:net";
3
+ const INTEGRATION_ROUTES = { "/_harbour/integrations/execute": ["POST"], "/_harbour/integrations/connect": ["POST", "DELETE"] };
4
+ const HOP_HEADERS = new Set(["connection", "keep-alive", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"]);
5
+ export function createForwarder(options) {
6
+ const origin = `http://127.0.0.1:${options.port}`;
7
+ const host = `127.0.0.1:${options.port}`;
8
+ const server = createServer((request, response) => {
9
+ const path = (request.url ?? "/").split("?")[0];
10
+ if (!sameOrigin(request, host, origin) || path.startsWith("/_harbour/integrations/")) {
11
+ void answerLocally(request, response, path, host, origin, options);
12
+ return;
13
+ }
14
+ if (path.startsWith("/_harbour/")) {
15
+ pipe(request, response, options.gatewayPort, { "x-harbour-identity-context": options.identityToken });
16
+ return;
17
+ }
18
+ pipe(request, response, options.vitePort);
19
+ });
20
+ server.on("upgrade", (request, socket, head) => {
21
+ if (!sameOrigin(request, host, origin)) {
22
+ socket.destroy();
23
+ return;
24
+ }
25
+ tunnel(request, socket, head, (request.url ?? "/").startsWith("/_harbour/") ? options.gatewayPort : options.vitePort, (request.url ?? "/").startsWith("/_harbour/") ? { "x-harbour-identity-context": options.identityToken } : {});
26
+ });
27
+ return server;
28
+ }
29
+ export function sameOrigin(request, host, origin) {
30
+ if (request.headers.host !== host)
31
+ return false;
32
+ const requestOrigin = request.headers.origin;
33
+ return requestOrigin === undefined || requestOrigin === origin;
34
+ }
35
+ /** Locally answered routes read the whole request first so a keep-alive socket is left clean. */
36
+ async function answerLocally(request, response, path, host, origin, options) {
37
+ const body = await readBody(request, 16 * 1024);
38
+ if (!sameOrigin(request, host, origin)) {
39
+ reject(response, 403, "FORBIDDEN", "Requests must come from the local app origin.");
40
+ return;
41
+ }
42
+ if (path === "/_harbour/integrations/oauth/complete") {
43
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
44
+ response.end(COMPLETE_PAGE);
45
+ return;
46
+ }
47
+ const methods = INTEGRATION_ROUTES[path];
48
+ if (!methods) {
49
+ reject(response, 404, "NOT_FOUND", "Unknown integration route.");
50
+ return;
51
+ }
52
+ const method = request.method ?? "GET";
53
+ if (!methods.includes(method)) {
54
+ reject(response, 405, "VALIDATION_FAILED", "Method not allowed.");
55
+ return;
56
+ }
57
+ const appId = options.appId();
58
+ if (!appId) {
59
+ reject(response, 409, "CONFLICT", "This app is not linked yet. Run `harbour integrations request <connection> --reason <text>` once.", "APP_NOT_LINKED");
60
+ return;
61
+ }
62
+ const token = await options.accessToken().catch(() => undefined);
63
+ if (!token) {
64
+ options.onAuthRequired?.();
65
+ reject(response, 401, "AUTH_REQUIRED", "Sign in to Harbour with `harbour login` to use company integrations locally.", "CLI_LOGIN_REQUIRED");
66
+ return;
67
+ }
68
+ if (body === undefined) {
69
+ reject(response, 413, "VALIDATION_FAILED", "Request too large.", "REQUEST_TOO_LARGE");
70
+ return;
71
+ }
72
+ const target = `${options.apiUrl.replace(/\/$/, "")}/v1/development/apps/${encodeURIComponent(appId)}/integrations/${path.slice("/_harbour/integrations/".length)}`;
73
+ const payload = path.endsWith("/connect") && method === "POST" ? withReturnUrl(body, `${origin}/_harbour/integrations/oauth/complete`) : body.length ? Buffer.from(body).toString("utf8") : undefined;
74
+ let upstream;
75
+ try {
76
+ upstream = await (options.fetch ?? fetch)(target, { method, headers: { authorization: `Bearer ${token}`, "x-harbour-tenant": options.tenantId, "content-type": "application/json", accept: "application/json" }, ...(payload === undefined ? {} : { body: payload }), redirect: "error", signal: AbortSignal.timeout(12_000) });
77
+ }
78
+ catch {
79
+ reject(response, 503, "UNAVAILABLE", "Harbour governance could not be reached.", "PROVIDER_UNAVAILABLE");
80
+ return;
81
+ }
82
+ if (upstream.status === 401)
83
+ options.onAuthRequired?.();
84
+ const headers = { "content-type": upstream.headers.get("content-type") ?? "application/json", "cache-control": "no-store" };
85
+ const retryAfter = upstream.headers.get("retry-after");
86
+ if (retryAfter)
87
+ headers["retry-after"] = retryAfter;
88
+ response.writeHead(upstream.status, headers);
89
+ response.end(new Uint8Array(await upstream.arrayBuffer()));
90
+ }
91
+ function pipe(request, response, port, extraHeaders = {}) {
92
+ const headers = {};
93
+ for (const [name, value] of Object.entries(request.headers))
94
+ if (value !== undefined && !HOP_HEADERS.has(name))
95
+ headers[name] = value;
96
+ if (request.headers["content-length"])
97
+ headers["content-length"] = request.headers["content-length"];
98
+ Object.assign(headers, extraHeaders);
99
+ const upstream = httpRequest({ host: "127.0.0.1", port, method: request.method, path: request.url, headers: { ...headers, host: `127.0.0.1:${port}` } }, upstreamResponse => {
100
+ response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
101
+ upstreamResponse.pipe(response);
102
+ });
103
+ upstream.on("error", () => reject(response, 502, "UNAVAILABLE", "The local service is not responding."));
104
+ request.pipe(upstream);
105
+ }
106
+ function tunnel(request, socket, head, port, extraHeaders) {
107
+ const upstream = connect(port, "127.0.0.1", () => {
108
+ const lines = [`${request.method} ${request.url} HTTP/1.1`];
109
+ for (const [name, value] of Object.entries({ ...request.headers, ...extraHeaders, host: `127.0.0.1:${port}` }))
110
+ if (value !== undefined)
111
+ for (const item of Array.isArray(value) ? value : [value])
112
+ lines.push(`${name}: ${item}`);
113
+ upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
114
+ if (head.length)
115
+ upstream.write(head);
116
+ upstream.pipe(socket);
117
+ socket.pipe(upstream);
118
+ });
119
+ upstream.on("error", () => socket.destroy());
120
+ socket.on("error", () => upstream.destroy());
121
+ }
122
+ /** Consent flows return the browser to this origin's completion page; governance allowlists exactly that loopback URL. */
123
+ function withReturnUrl(body, returnUrl) {
124
+ let parsed;
125
+ try {
126
+ parsed = JSON.parse(Buffer.from(body).toString("utf8") || "{}");
127
+ }
128
+ catch {
129
+ parsed = {};
130
+ }
131
+ return JSON.stringify({ ...(parsed && typeof parsed === "object" ? parsed : {}), returnUrl });
132
+ }
133
+ function readBody(request, limit) {
134
+ return new Promise(resolve => {
135
+ const chunks = [];
136
+ let size = 0;
137
+ request.on("data", (chunk) => { size += chunk.length; if (size <= limit)
138
+ chunks.push(chunk); });
139
+ request.on("end", () => resolve(size > limit ? undefined : new Uint8Array(Buffer.concat(chunks))));
140
+ request.on("error", () => resolve(undefined));
141
+ });
142
+ }
143
+ function reject(response, status, category, message, code = category) {
144
+ response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
145
+ response.end(JSON.stringify({ error: { category, message, details: { code } }, requestId: "local" }));
146
+ }
147
+ const COMPLETE_PAGE = "<!doctype html><meta charset=\"utf-8\"><title>Harbour</title><body style=\"font-family:system-ui;margin:3rem\"><h1>Connected</h1><p>Your account is linked for this app. You can close this tab and return to the app.</p></body>";