@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.
@@ -0,0 +1,355 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
+ import { createServer } from "node:net";
5
+ import { join, relative } from "node:path";
6
+ import { kitPaths, projectName } from "./kit.js";
7
+ import { CliError } from "./output.js";
8
+ export const runCommand = (command, args, options = {}) => new Promise(resolve => {
9
+ const child = spawn(command, args, { cwd: options.cwd, env: { ...process.env, ...options.env }, stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
10
+ let stdout = "";
11
+ let stderr = "";
12
+ child.stdout?.on("data", chunk => { stdout += chunk; });
13
+ child.stderr?.on("data", chunk => { stderr += chunk; if (!options.quiet)
14
+ process.stderr.write(chunk); });
15
+ child.on("error", error => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
16
+ child.on("close", code => resolve({ code: code ?? 1, stdout, stderr }));
17
+ if (options.stdin !== undefined) {
18
+ child.stdin?.end(options.stdin);
19
+ }
20
+ });
21
+ export const LOCAL = {
22
+ tenant: "local-tenant",
23
+ app: "local-app",
24
+ userId: "local-user",
25
+ email: "local-user@example.test",
26
+ database: "harbour",
27
+ dbUser: "harbour",
28
+ dbPassword: "harbour-local",
29
+ bucket: "harbour-local",
30
+ s3Key: "harbour-local-access-key",
31
+ s3Secret: "harbour-local-secret-key",
32
+ gatewayRole: "harbour_app_gateway"
33
+ };
34
+ // ---- Compose ----------------------------------------------------------------------
35
+ /**
36
+ * Compose file for one project: every port bound to 127.0.0.1, named volumes
37
+ * prefixed with the project name, images pinned from the bundle manifest.
38
+ * The fixture writes the session env (identity token) to the shared state dir.
39
+ */
40
+ export function composeFile(project, bundle, ports, stateDir) {
41
+ const images = bundle.images;
42
+ const originUrl = `http://127.0.0.1:${ports.origin}`;
43
+ return [
44
+ `name: ${project}`,
45
+ "services:",
46
+ " postgres:",
47
+ ` image: ${images.postgres ?? "postgres:16-alpine"}`,
48
+ ` environment: { POSTGRES_USER: ${LOCAL.dbUser}, POSTGRES_PASSWORD: ${LOCAL.dbPassword}, POSTGRES_DB: ${LOCAL.database} }`,
49
+ ` ports: ["127.0.0.1:${ports.postgres}:5432"]`,
50
+ " volumes: [postgres-data:/var/lib/postgresql/data]",
51
+ ` healthcheck: { test: ["CMD-SHELL", "pg_isready -U ${LOCAL.dbUser} -d ${LOCAL.database}"], interval: 2s, timeout: 3s, retries: 30 }`,
52
+ " minio:",
53
+ ` image: ${images.minio ?? "minio/minio:RELEASE.2025-07-23T15-54-02Z"}`,
54
+ " command: server /data",
55
+ ` environment: { MINIO_ROOT_USER: ${LOCAL.s3Key}, MINIO_ROOT_PASSWORD: ${LOCAL.s3Secret} }`,
56
+ ` ports: ["127.0.0.1:${ports.minio}:9000"]`,
57
+ " volumes: [minio-data:/data]",
58
+ " healthcheck: { test: [\"CMD-SHELL\", \"curl -sf http://127.0.0.1:9000/minio/health/ready || wget -qO- http://127.0.0.1:9000/minio/health/ready\"], interval: 2s, timeout: 3s, retries: 30 }",
59
+ " nats:",
60
+ ` image: ${images.nats ?? "nats:2.11.17-alpine"}`,
61
+ " command: [\"-js\", \"-m\", \"8222\"]",
62
+ ` ports: ["127.0.0.1:${ports.nats}:4222"]`,
63
+ " fixture:",
64
+ ` image: ${images.sessionFixture}`,
65
+ " command: [\"--listen\", \"0.0.0.0:8080\", \"--session\", \"" + project + "\", \"--base-url\", \"http://127.0.0.1:" + ports.fixture + "\", \"--tenant\", \"" + LOCAL.tenant + "\", \"--app\", \"" + LOCAL.app + "\", \"--user-id\", \"" + LOCAL.userId + "\", \"--email\", \"" + LOCAL.email + "\", \"--metadata-file\", \"/state/session.json\", \"--env-file\", \"/state/session.env\", \"--display-directory\", \"" + stateDir + "\"]",
66
+ " environment:",
67
+ ` HARBOUR_LOCAL_COMPOSE_PROJECT: ${project}`,
68
+ ` HARBOUR_LOCAL_S3_INTERNAL_ENDPOINT: http://minio:9000`,
69
+ ` HARBOUR_LOCAL_S3_ENDPOINT: http://127.0.0.1:${ports.minio}`,
70
+ ` HARBOUR_LOCAL_S3_BUCKET: ${LOCAL.bucket}`,
71
+ ` HARBOUR_LOCAL_S3_ACCESS_KEY: ${LOCAL.s3Key}`,
72
+ ` HARBOUR_LOCAL_S3_SECRET_KEY: ${LOCAL.s3Secret}`,
73
+ ` HARBOUR_LOCAL_POSTGRES_INTERNAL_URL: ${internalDatabaseUrl()}`,
74
+ ` HARBOUR_LOCAL_POSTGRES_ADDRESS: 127.0.0.1:${ports.postgres}`,
75
+ ` HARBOUR_LOCAL_NATS_URL: nats://127.0.0.1:${ports.nats}`,
76
+ ` HARBOUR_LOCAL_NATS_INTERNAL_URL: nats://nats:4222`,
77
+ ` ports: ["127.0.0.1:${ports.fixture}:8080"]`,
78
+ ` volumes: ["${stateDir}:/state"]`,
79
+ " depends_on: { postgres: { condition: service_healthy }, minio: { condition: service_healthy } }",
80
+ " gateway:",
81
+ ` image: ${images.appGateway}`,
82
+ " environment:",
83
+ " HARBOUR_APP_GATEWAY_CONFIG_FILE: /config/app-gateway.json",
84
+ " HARBOUR_APP_GATEWAY_UPLOAD_KEY: " + uploadKey(project),
85
+ " HARBOUR_S3_ENDPOINT: http://minio:9000",
86
+ ` HARBOUR_S3_PUBLIC_ENDPOINT: http://127.0.0.1:${ports.minio}`,
87
+ " HARBOUR_NATS_URL: nats://nats:4222",
88
+ ` AWS_ACCESS_KEY_ID: ${LOCAL.s3Key}`,
89
+ ` AWS_SECRET_ACCESS_KEY: ${LOCAL.s3Secret}`,
90
+ " AWS_REGION: us-east-1",
91
+ " AWS_EC2_METADATA_DISABLED: \"true\"",
92
+ ` ports: ["127.0.0.1:${ports.gateway}:8080"]`,
93
+ ` volumes: ["${stateDir}/app-gateway.json:/config/app-gateway.json:ro"]`,
94
+ " depends_on: { postgres: { condition: service_healthy }, minio: { condition: service_healthy }, fixture: { condition: service_started } }",
95
+ "volumes:",
96
+ ` postgres-data: { name: ${project}-postgres }`,
97
+ ` minio-data: { name: ${project}-minio }`,
98
+ `# Browser origin: ${originUrl}`,
99
+ ""
100
+ ].join("\n");
101
+ }
102
+ /** App Gateway configuration in the shape cmd/appgateway/main.go decodes (unknown fields are rejected there). */
103
+ export function gatewayConfig(ports) {
104
+ return {
105
+ listen: ":8080",
106
+ issuer: `http://127.0.0.1:${ports.fixture}`,
107
+ jwksUrl: "http://fixture:8080/.well-known/jwks.json",
108
+ environment: "development",
109
+ publicBaseUrl: `http://127.0.0.1:${ports.origin}`,
110
+ // Verified against the local images (docs/local-kit.md): the fixture's `iss` equals its --base-url, so `issuer`
111
+ // verifies; `unrestrictedFiles` with no filePolicies is the local cell's file policy.
112
+ bindings: [{
113
+ tenant: LOCAL.tenant,
114
+ app: LOCAL.app,
115
+ databaseUrl: internalDatabaseUrl(LOCAL.gatewayRole),
116
+ bucket: LOCAL.bucket,
117
+ prefix: `${LOCAL.tenant}/${LOCAL.app}/development/`,
118
+ publicBaseUrl: `http://127.0.0.1:${ports.minio}/${LOCAL.bucket}`,
119
+ capabilities: ["data", "files", "realtime", "telemetry"],
120
+ filePolicies: [],
121
+ unrestrictedFiles: true
122
+ }]
123
+ };
124
+ }
125
+ function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@postgres:5432/${LOCAL.database}?sslmode=disable`; }
126
+ function uploadKey(project) { return createHash("sha256").update(`upload-key:${project}`).digest("base64"); }
127
+ // ---- Ports and lock ------------------------------------------------------------------
128
+ export async function freePort() {
129
+ return new Promise((resolve, reject) => {
130
+ const server = createServer();
131
+ server.on("error", reject);
132
+ server.listen(0, "127.0.0.1", () => { const address = server.address(); server.close(() => typeof address === "object" && address ? resolve(address.port) : reject(new Error("no port"))); });
133
+ });
134
+ }
135
+ export async function allocatePorts() {
136
+ const [postgres, minio, nats, fixture, gateway, vite, origin] = await Promise.all([freePort(), freePort(), freePort(), freePort(), freePort(), freePort(), freePort()]);
137
+ return { postgres: postgres, minio: minio, nats: nats, fixture: fixture, gateway: gateway, vite: vite, origin: origin };
138
+ }
139
+ /** Acquires `.harbour/local/dev.lock`; a lock whose process is gone is stale and replaced. */
140
+ export async function acquireDevLock(root, ports, isAlive = pidAlive) {
141
+ const existing = await readDevLock(root);
142
+ if (existing && existing.pid !== process.pid && isAlive(existing.pid))
143
+ throw new CliError("DEV_ALREADY_RUNNING", `harbour dev is already running for this app (pid ${existing.pid}, ${existing.origin}). Run \`harbour stop\` first.`);
144
+ const lock = { pid: process.pid, startedAt: new Date().toISOString(), ports, origin: `http://127.0.0.1:${ports.origin}` };
145
+ await mkdir(kitPaths(root).local, { recursive: true });
146
+ await writeFile(kitPaths(root).devLock, JSON.stringify(lock));
147
+ return lock;
148
+ }
149
+ export async function readDevLock(root) {
150
+ try {
151
+ return JSON.parse(await readFile(kitPaths(root).devLock, "utf8"));
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ }
157
+ export async function releaseDevLock(root) { await rm(kitPaths(root).devLock, { force: true }); }
158
+ /** The running dev origin for this app, if its lock holder is alive. */
159
+ export async function runningOrigin(root, isAlive = pidAlive) {
160
+ const lock = await readDevLock(root);
161
+ return lock && isAlive(lock.pid) ? lock.origin : undefined;
162
+ }
163
+ export function pidAlive(pid) { try {
164
+ process.kill(pid, 0);
165
+ return true;
166
+ }
167
+ catch {
168
+ return false;
169
+ } }
170
+ // ---- Compose lifecycle ----------------------------------------------------------------
171
+ export class LocalRuntime {
172
+ root;
173
+ run;
174
+ project;
175
+ composePath;
176
+ constructor(root, run = runCommand, project = projectName(root), composePath = kitPaths(root).compose) {
177
+ this.root = root;
178
+ this.run = run;
179
+ this.project = project;
180
+ this.composePath = composePath;
181
+ }
182
+ /** Disposable postgres-only project for `harbour check` migrations: separate name, separate volume, torn down after. */
183
+ static forCheck(root, run = runCommand) {
184
+ return new LocalRuntime(root, run, projectName(root, "-check"), join(kitPaths(root).local, "check-compose.yml"));
185
+ }
186
+ compose(args, options = {}) {
187
+ return this.run("docker", ["compose", "-p", this.project, "-f", this.composePath, ...args], { cwd: this.root, ...options });
188
+ }
189
+ async writeCheckFiles(bundle) {
190
+ await mkdir(kitPaths(this.root).local, { recursive: true });
191
+ await writeFile(this.composePath, [`name: ${this.project}`, "services:", " postgres:", ` image: ${bundle.images.postgres ?? "postgres:16-alpine"}`, ` environment: { POSTGRES_USER: ${LOCAL.dbUser}, POSTGRES_PASSWORD: ${LOCAL.dbPassword}, POSTGRES_DB: ${LOCAL.database} }`, " volumes: [postgres-data:/var/lib/postgresql/data]", ` healthcheck: { test: ["CMD-SHELL", "pg_isready -U ${LOCAL.dbUser} -d ${LOCAL.database}"], interval: 1s, timeout: 3s, retries: 30 }`, "volumes:", ` postgres-data: { name: ${this.project}-postgres }`, ""].join("\n"));
192
+ }
193
+ /** Removes this project's containers and volumes without the reset banner (check teardown). */
194
+ async down() { await this.compose(["down", "-v", "--remove-orphans"], { quiet: true }); }
195
+ async writeFiles(bundle, ports) {
196
+ const paths = kitPaths(this.root);
197
+ await mkdir(paths.state, { recursive: true });
198
+ await writeFile(paths.compose, composeFile(this.project, bundle, ports, paths.state));
199
+ await writeFile(join(paths.state, "app-gateway.json"), `${JSON.stringify(gatewayConfig(ports), null, 2)}\n`);
200
+ }
201
+ /**
202
+ * Pulls the pinned images before `up`, so a registry problem is named as such
203
+ * rather than surfacing as a failed start. Every kit image is a public
204
+ * `repository@sha256:<digest>` reference: no registry login is needed and
205
+ * a tag can never substitute other bytes.
206
+ */
207
+ async pull(bundle) {
208
+ const result = await this.compose(["pull", "--quiet"]);
209
+ if (result.code !== 0)
210
+ throw new CliError("KIT_IMAGES_UNAVAILABLE", `Docker could not pull the kit images (${bundle.images.appGateway}, ${bundle.images.sessionFixture}): ${result.stderr.trim().split("\n").at(-1) ?? "docker error"}. They are public and pinned by digest; \`docker compose\` (Compose v2) must be installed and Docker running with access to the registry.`);
211
+ }
212
+ async up() {
213
+ const result = await this.compose(["up", "-d", "--wait"]);
214
+ if (result.code !== 0)
215
+ throw new CliError("LOCAL_RUNTIME_FAILED", "Docker could not start the local Harbour services. Is Docker running and are the kit images available (see .harbour/kit.lock.json)?");
216
+ }
217
+ /** Stops containers and keeps volumes; safe to repeat. */
218
+ async stop() {
219
+ const result = await this.compose(["stop"], { quiet: true });
220
+ return { stopped: result.code === 0 };
221
+ }
222
+ /** Removes this project's containers and named volumes only. */
223
+ async reset(output) {
224
+ output(`Deleting local data for this app only: compose project ${this.project}, volumes ${this.project}-postgres and ${this.project}-minio.`);
225
+ const result = await this.compose(["down", "-v", "--remove-orphans"], { quiet: true });
226
+ if (result.code !== 0)
227
+ throw new CliError("LOCAL_RESET_FAILED", "Docker could not remove the local Harbour services for this app.");
228
+ await rm(kitPaths(this.root).state, { recursive: true, force: true });
229
+ }
230
+ /** Applies `migrations/*.sql` in name order through psql inside the postgres container, after ensuring the runtime role. */
231
+ async migrate() {
232
+ const dir = join(this.root, "migrations");
233
+ const names = (await readdir(dir).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
234
+ const psql = (sql) => this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
235
+ const role = await psql(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${LOCAL.gatewayRole}') THEN CREATE ROLE ${LOCAL.gatewayRole} LOGIN PASSWORD '${LOCAL.dbPassword}'; END IF; END $$;`);
236
+ if (role.code !== 0)
237
+ throw new CliError("MIGRATION_FAILED", "Could not prepare the local database role.");
238
+ for (const name of names) {
239
+ const result = await psql(await readFile(join(dir, name), "utf8"));
240
+ if (result.code !== 0)
241
+ throw new CliError("MIGRATION_FAILED", `Migration ${name} failed: ${result.stderr.trim().split("\n").at(-1) ?? "psql error"}`);
242
+ }
243
+ return names;
244
+ }
245
+ /** The fixture's session env (identity token for the local app user) once the fixture has written it. */
246
+ async sessionEnv(attempts = 60) {
247
+ const path = join(kitPaths(this.root).state, "session.env");
248
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
249
+ try {
250
+ const env = parseSessionEnv(await readFile(path, "utf8"));
251
+ if (env.HARBOUR_IDENTITY_CONTEXT_TOKEN)
252
+ return env;
253
+ }
254
+ catch { /* not written yet */ }
255
+ await new Promise(resolve => setTimeout(resolve, 500));
256
+ }
257
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The local identity fixture did not publish a session.");
258
+ }
259
+ }
260
+ /** The fixture writes a shell-sourceable file: `export KEY='value'`, a literal quote written as `'"'"'`. */
261
+ export function parseSessionEnv(text) {
262
+ const env = {};
263
+ for (const line of text.split("\n")) {
264
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim());
265
+ if (!match)
266
+ continue;
267
+ const raw = match[2];
268
+ env[match[1]] = raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2 ? raw.slice(1, -1).replace(/'"'"'/g, "'") : raw;
269
+ }
270
+ return env;
271
+ }
272
+ // ---- SDK tarball ---------------------------------------------------------------------------
273
+ /**
274
+ * The SDK ships in the kit bundle, not the public registry. Stages the tarball
275
+ * named by HARBOUR_KIT_SDK_TARBALL or downloaded from the manifest's sdk.url under
276
+ * `.harbour/local/sdk/` (so package.json gets a relative `file:` dependency), checks
277
+ * its sha256 against the bundle and installs it with the app's other dependencies.
278
+ * Returns "present" when the staged tarball is already installed, "missing" when
279
+ * no tarball is available and node_modules lacks the package.
280
+ */
281
+ export async function ensureSdk(root, bundle, env, run, output, fetchImpl = fetch) {
282
+ const installed = await readFile(join(root, "node_modules", ...bundle.sdk.package.split("/"), "package.json")).then(() => true, () => false);
283
+ const stageDir = join(kitPaths(root).local, "sdk");
284
+ const staged = join(stageDir, `harbour-app-sdk-${bundle.sdk.tarballSha256.slice(0, 12)}.tgz`);
285
+ const source = env.HARBOUR_KIT_SDK_TARBALL?.trim();
286
+ if (!(await exists(staged))) {
287
+ let bytes;
288
+ if (source)
289
+ bytes = await readFile(source).catch(() => { throw new CliError("SDK_UNAVAILABLE", `HARBOUR_KIT_SDK_TARBALL is not readable: ${source}`); });
290
+ else if (bundle.sdk.url) {
291
+ output(`Downloading ${bundle.sdk.package} from the kit bundle (sha256 ${bundle.sdk.tarballSha256.slice(0, 12)}…).`);
292
+ bytes = await downloadSdk(bundle.sdk.url, fetchImpl);
293
+ }
294
+ if (!bytes)
295
+ return installed ? "present" : "missing";
296
+ const digest = createHash("sha256").update(bytes).digest("hex");
297
+ if (bundle.sdk.tarballSha256 !== "unknown" && digest !== bundle.sdk.tarballSha256)
298
+ throw new CliError("KIT_BUNDLE_INCOMPATIBLE", `The SDK tarball digest ${digest} does not match the kit bundle ${bundle.sdk.tarballSha256}. Run \`harbour init --upgrade\` or use the matching tarball.`);
299
+ await mkdir(stageDir, { recursive: true });
300
+ await writeFile(staged, bytes);
301
+ }
302
+ else if (installed && (await readFile(join(root, "package.json"), "utf8").catch(() => "")).includes(relative(root, staged)))
303
+ return "present";
304
+ output(`Installing ${bundle.sdk.package} from the kit bundle.`);
305
+ const result = await run("npm", ["install", "--no-audit", "--no-fund", relative(root, staged)], { cwd: root, quiet: true });
306
+ if (result.code !== 0)
307
+ throw new CliError("SDK_INSTALL_FAILED", `npm could not install the kit SDK tarball: ${result.stderr.trim().split("\n").at(-1) ?? "npm error"}`);
308
+ return "installed";
309
+ }
310
+ /**
311
+ * Fetches the SDK tarball from the bundle's `sdk.url`. The published bundle
312
+ * stores it as a content-addressed OCI blob on a public registry, which answers
313
+ * an anonymous GET with a Bearer challenge naming a token endpoint that hands
314
+ * out pull tokens without credentials; the challenge is followed once and the
315
+ * request repeated. Redirects (a blob served from object storage) are followed;
316
+ * the bearer token is not forwarded across origins by fetch itself. The caller
317
+ * verifies the bytes against `sdk.tarballSha256` — a token or a redirect can
318
+ * change where the bytes come from, never which bytes are accepted.
319
+ */
320
+ export async function downloadSdk(url, fetchImpl = fetch) {
321
+ if (!/^https:\/\//.test(url))
322
+ throw new CliError("SDK_UNAVAILABLE", `The kit bundle's sdk.url must be https: ${url}`);
323
+ const attempt = (headers = {}) => fetchImpl(url, { redirect: "follow", headers }).catch(error => { throw new CliError("SDK_UNAVAILABLE", `The kit SDK tarball could not be downloaded from ${url}: ${error instanceof Error ? error.message : String(error)}`); });
324
+ let response = await attempt();
325
+ if (response.status === 401) {
326
+ const challenge = parseBearerChallenge(response.headers.get("www-authenticate"));
327
+ if (challenge && /^https:\/\//.test(challenge.realm)) {
328
+ const tokenUrl = new URL(challenge.realm);
329
+ if (challenge.service)
330
+ tokenUrl.searchParams.set("service", challenge.service);
331
+ if (challenge.scope)
332
+ tokenUrl.searchParams.set("scope", challenge.scope);
333
+ const tokenResponse = await fetchImpl(tokenUrl, { redirect: "follow" }).catch(() => undefined);
334
+ const body = tokenResponse?.ok ? await tokenResponse.json().catch(() => undefined) : undefined;
335
+ const token = typeof body?.token === "string" ? body.token : typeof body?.access_token === "string" ? body.access_token : undefined;
336
+ if (token)
337
+ response = await attempt({ authorization: `Bearer ${token}` });
338
+ }
339
+ }
340
+ if (!response.ok)
341
+ throw new CliError("SDK_UNAVAILABLE", `The kit SDK tarball could not be downloaded from ${url} (HTTP ${response.status}).`);
342
+ return new Uint8Array(await response.arrayBuffer());
343
+ }
344
+ /** `Bearer realm="…",service="…",scope="…"` (RFC 6750 / distribution token auth); anything else is no challenge. */
345
+ export function parseBearerChallenge(header) {
346
+ if (!header || !/^\s*bearer\s/i.test(header))
347
+ return undefined;
348
+ const params = {};
349
+ for (const match of header.slice(header.search(/\s/)).matchAll(/([a-z_]+)="([^"]*)"/gi))
350
+ params[match[1].toLowerCase()] = match[2];
351
+ if (!params.realm)
352
+ return undefined;
353
+ return { realm: params.realm, service: params.service, scope: params.scope };
354
+ }
355
+ const exists = (path) => readFile(path).then(() => true, () => false);
@@ -75,6 +75,8 @@ export async function waitForSettled(client, operationRef, output, options = {})
75
75
  // transiently (gateway timeout, dropped connection). The operation is
76
76
  // unaffected by our polling, so keep watching; give up only after a run
77
77
  // of failures, and say what the last one was instead of a generic error.
78
+ if (error instanceof CliError)
79
+ throw error; // e.g. AUTH_REQUIRED: retrying cannot help.
78
80
  consecutiveFailures += 1;
79
81
  if (consecutiveFailures >= 5)
80
82
  throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Harbour stopped answering status checks (${safeMessage(error)}). The deployment may still be running; check again with \`harbour status\`.`, operationRef);
@@ -9,6 +9,8 @@ import { CliError } from "./output.js";
9
9
  import { getAppSetup, outcomeFor, readLine, waitForSettled } from "./operations.js";
10
10
  import { CLI_VERSION } from "./version.js";
11
11
  import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
+ import { assertPreviewIntegrationsReady } from "./integrations.js";
13
+ import { recordKitAppId } from "./kit.js";
12
14
  export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
13
15
  const root = resolve(rootArg);
14
16
  output(`Harbour is checking ${basename(root)}.`);
@@ -21,6 +23,9 @@ export async function productionise(rootArg, client, output, tenantId, includePa
21
23
  throw new CliError("PREFLIGHT_SECRET_PATH", "The selected app boundary contains a prohibited secret file.");
22
24
  const files = await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
23
25
  output(`Harbour found ${files.length} app files.`);
26
+ // A declared connection without its preview grant would only park the
27
+ // deployment after the save; refuse here, before any operation exists.
28
+ const kitAppId = options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId;
24
29
  try {
25
30
  await client.initialize();
26
31
  }
@@ -28,8 +33,9 @@ export async function productionise(rootArg, client, output, tenantId, includePa
28
33
  throw new CliError("NOT_STARTED", "Harbour could not be reached before the operation started.");
29
34
  }
30
35
  let start;
36
+ const linked = kitAppId ? { appId: kitAppId } : {};
31
37
  try {
32
- start = structured(await client.call("harbour_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex" }));
38
+ start = structured(await client.call("harbour_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex", ...linked }));
33
39
  }
34
40
  catch {
35
41
  throw new CliError("NOT_STARTED", "Harbour could not start the productionisation operation.");
@@ -38,12 +44,16 @@ export async function productionise(rootArg, client, output, tenantId, includePa
38
44
  if (!operationRef)
39
45
  throw new CliError("NOT_STARTED", "Harbour did not start the productionisation operation.");
40
46
  try {
41
- const submitted = structured(await client.call("harbour_submit_application_graph", { operationId: operationRef, surface: "codex", graph }));
47
+ const submitted = structured(await client.call("harbour_submit_application_graph", { operationId: operationRef, surface: "codex", graph, ...linked }));
42
48
  if (submitted.nextTool && !matchesTool(submitted.nextTool, "harbour_prepare_source_upload"))
43
49
  throw new CliError("UNSUPPORTED_NEXT_STEP", "Harbour returned an unsupported next step.", operationRef);
44
50
  const appId = typeof submitted.appId === "string" ? submitted.appId.trim() : "";
45
51
  if (!appId)
46
52
  throw new CliError("APP_IDENTITY_MISSING", "Harbour did not return the app identity for this operation.", operationRef);
53
+ if (kitAppId && appId !== kitAppId)
54
+ throw new CliError("APP_IDENTITY_MISMATCH", "Harbour returned a different app identity than the one linked in .harbour/kit.lock.json.", operationRef);
55
+ // The canonical identity lands in kit.lock so a later `integrations request` reuses it instead of linking a second app.
56
+ await recordKitAppId(root, appId, tenantId);
47
57
  const manifest = await createSourceManifest({ tenantId, appId, operationId: operationRef, graphDigest: graph.graphDigest, files });
48
58
  const preliminaryArchive = await archiveForManifest(root, manifest);
49
59
  const prepared = structured(await client.call("harbour_prepare_source_upload", { operationId: operationRef, appId, surface: "codex", graph, format: "zip", filename: `${appId}.zip`, compressedBytes: preliminaryArchive.body.byteLength, manifest: { schema: "harbour.source-package-manifest/1.0", files: manifest.files.map(file => ({ path: file.path, size: file.bytes, sha256: file.sha256 })) } }));
@@ -1,14 +1,15 @@
1
1
  import { CLI_VERSION } from "./version.js";
2
+ import { CliError } from "./output.js";
2
3
  export class RemoteMcpClient {
3
4
  url;
4
- token;
5
5
  tenant;
6
6
  id = 0;
7
7
  initialized = false;
8
+ token;
8
9
  constructor(url, token, tenant) {
9
10
  this.url = url;
10
- this.token = token;
11
11
  this.tenant = tenant;
12
+ this.token = typeof token === "string" ? async () => token : token;
12
13
  }
13
14
  async initialize() {
14
15
  if (this.initialized)
@@ -25,17 +26,28 @@ export class RemoteMcpClient {
25
26
  return result;
26
27
  }
27
28
  async request(method, params, expectResponse = true) {
28
- const headers = { "content-type": "application/json", "mcp-protocol-version": "2025-11-25" };
29
- if (this.token)
30
- headers.authorization = `Bearer ${this.token}`;
31
- headers["x-harbour-tenant"] = this.tenant;
32
- const response = await fetch(this.url, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: ++this.id, method, ...(params === undefined ? {} : { params }) }) });
29
+ const body = JSON.stringify({ jsonrpc: "2.0", id: ++this.id, method, ...(params === undefined ? {} : { params }) });
30
+ let response = await this.post(body);
31
+ // A 401 mid-command usually means the stored token was rotated by another
32
+ // process; re-resolve (which reloads the store) and retry once. A second
33
+ // 401 means the sign-in itself is gone.
34
+ if (response.status === 401)
35
+ response = await this.post(body);
36
+ if (response.status === 401)
37
+ throw new CliError("AUTH_REQUIRED", "Harbour sign-in expired or was revoked. Run `harbour login` again.");
33
38
  if (!expectResponse)
34
39
  return undefined;
35
- const body = await response.json();
36
- if (!response.ok || body.error)
37
- throw new Error(body.error?.message ?? `Harbour request failed (${response.status}).`);
38
- return body.result;
40
+ const parsed = await response.json();
41
+ if (!response.ok || parsed.error)
42
+ throw new Error(parsed.error?.message ?? `Harbour request failed (${response.status}).`);
43
+ return parsed.result;
44
+ }
45
+ async post(body) {
46
+ const headers = { "content-type": "application/json", "mcp-protocol-version": "2025-11-25", "x-harbour-tenant": this.tenant };
47
+ const token = await this.token();
48
+ if (token)
49
+ headers.authorization = `Bearer ${token}`;
50
+ return fetch(this.url, { method: "POST", headers, body });
39
51
  }
40
52
  }
41
53
  export function structured(result) {