@fourier-labs/harbour 0.1.33 → 0.1.34

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.
@@ -2,11 +2,11 @@ import { spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
4
  import { createServer } from "node:net";
5
- import { join, relative } from "node:path";
6
- import { LOCAL_SERVICE_IMAGES } from "./kit-bundle.js";
5
+ import { dirname, join, relative } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import EmbeddedPostgres from "embedded-postgres";
7
8
  import { kitPaths, projectName } from "./kit.js";
8
9
  import { CliError } from "./output.js";
9
- import { HARBOUR_NETWORK_LABEL, isAddressPoolExhausted, poolExhaustedMessage, reclaimStaleHarbourProjects } from "./docker-networks.js";
10
10
  export const runCommand = (command, args, options = {}) => new Promise(resolve => {
11
11
  const child = spawn(command, args, { cwd: options.cwd, env: { ...process.env, ...options.env }, stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
12
12
  let stdout = "";
@@ -36,59 +36,6 @@ export const LOCAL = {
36
36
  s3Secret: "harbour-local-secret-key",
37
37
  gatewayRole: "harbour_app_gateway"
38
38
  };
39
- // ---- Compose ----------------------------------------------------------------------
40
- /**
41
- * Compose file for one project — three containers: Postgres, MinIO and one
42
- * Harbour process, the app gateway in local-session mode. Every port bound to
43
- * 127.0.0.1, named volumes prefixed with the project name, images pinned from
44
- * the bundle manifest. The gateway mints the session identities and serves
45
- * their JWKS itself, serves the provider fixtures beside its routes, relays
46
- * the realtime outbox in-process, and writes the session env (both identity
47
- * tokens) and the platform's realtime outbox migration (generated from the
48
- * app's migrations, staged under state/migrations) to the shared state dir
49
- * (data plane docs/local-kit.md).
50
- */
51
- export function composeFile(project, bundle, ports, stateDir) {
52
- const images = bundle.images;
53
- const originUrl = `http://127.0.0.1:${ports.origin}`;
54
- return [
55
- `name: ${project}`,
56
- "services:",
57
- " postgres:",
58
- ` image: ${images.postgres ?? LOCAL_SERVICE_IMAGES.postgres}`,
59
- ` environment: { POSTGRES_USER: ${LOCAL.dbUser}, POSTGRES_PASSWORD: ${LOCAL.dbPassword}, POSTGRES_DB: ${LOCAL.database} }`,
60
- ` ports: ["127.0.0.1:${ports.postgres}:5432"]`,
61
- " volumes: [postgres-data:/var/lib/postgresql/data]",
62
- ` healthcheck: { test: ["CMD-SHELL", "pg_isready -U ${LOCAL.dbUser} -d ${LOCAL.database}"], interval: 2s, timeout: 3s, retries: 30 }`,
63
- " minio:",
64
- ` image: ${images.minio ?? LOCAL_SERVICE_IMAGES.minio}`,
65
- " command: server /data",
66
- ` environment: { MINIO_ROOT_USER: ${LOCAL.s3Key}, MINIO_ROOT_PASSWORD: ${LOCAL.s3Secret} }`,
67
- ` ports: ["127.0.0.1:${ports.minio}:9000"]`,
68
- " volumes: [minio-data:/data]",
69
- " 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 }",
70
- " gateway:",
71
- ` image: ${images.appGateway}`,
72
- " environment:",
73
- " HARBOUR_APP_GATEWAY_CONFIG_FILE: /config/app-gateway.json",
74
- " HARBOUR_APP_GATEWAY_UPLOAD_KEY: " + uploadKey(project),
75
- " HARBOUR_S3_ENDPOINT: http://minio:9000",
76
- ` HARBOUR_S3_PUBLIC_ENDPOINT: http://127.0.0.1:${ports.minio}`,
77
- ` AWS_ACCESS_KEY_ID: ${LOCAL.s3Key}`,
78
- ` AWS_SECRET_ACCESS_KEY: ${LOCAL.s3Secret}`,
79
- " AWS_REGION: us-east-1",
80
- " AWS_EC2_METADATA_DISABLED: \"true\"",
81
- ` ports: ["127.0.0.1:${ports.gateway}:8080"]`,
82
- ` volumes: ["${stateDir}/app-gateway.json:/config/app-gateway.json:ro", "${stateDir}:/state"]`,
83
- " depends_on: { postgres: { condition: service_healthy }, minio: { condition: service_healthy } }",
84
- "volumes:",
85
- ` postgres-data: { name: ${project}-postgres }`,
86
- ` minio-data: { name: ${project}-minio }`,
87
- ...networkLabelLines(),
88
- `# Browser origin: ${originUrl}`,
89
- ""
90
- ].join("\n");
91
- }
92
39
  /**
93
40
  * App Gateway configuration in the shape cmd/appgateway/main.go decodes (unknown fields are rejected there).
94
41
  * `localSession` is what makes the gateway the whole local runtime: issuer = its own published origin (the
@@ -121,13 +68,27 @@ export function gatewayConfig(project, ports, stateDir) {
121
68
  }
122
69
  };
123
70
  }
71
+ export function nativeGatewayConfig(project, ports, stateDir) {
72
+ const config = gatewayConfig(project, ports, stateDir);
73
+ config.listen = `127.0.0.1:${ports.gateway}`;
74
+ config.bindings[0] = {
75
+ ...config.bindings[0],
76
+ databaseUrl: `postgresql://${LOCAL.gatewayRole}:${LOCAL.dbPassword}@127.0.0.1:${ports.postgres}/${LOCAL.database}?sslmode=disable`,
77
+ publicBaseUrl: `http://127.0.0.1:${ports.origin}`,
78
+ localFilesDirectory: join(stateDir, "files")
79
+ };
80
+ config.localSession = {
81
+ ...config.localSession,
82
+ metadataFile: join(stateDir, "session.json"), envFile: join(stateDir, "session.env"),
83
+ migrationsDir: join(stateDir, "migrations"), realtimeOutboxFile: join(stateDir, "realtime-outbox.sql")
84
+ };
85
+ return config;
86
+ }
124
87
  /** `migrations/*.sql` in name order — what `harbour dev` applies and what the fixture derives the outbox from. */
125
88
  async function migrationNames(root) {
126
89
  return (await readdir(join(root, "migrations")).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
127
90
  }
128
- /** Marks the project's default network as Harbour's, so a full address pool can be healed by removing only what Harbour left behind (docker-networks.ts). */
129
- export function networkLabelLines() { return ["networks:", ` default: { labels: { ${HARBOUR_NETWORK_LABEL}: "1" } }`]; }
130
- export function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@postgres:5432/${LOCAL.database}?sslmode=disable`; }
91
+ export function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@127.0.0.1/${LOCAL.database}?sslmode=disable`; }
131
92
  function uploadKey(project) { return createHash("sha256").update(`upload-key:${project}`).digest("base64"); }
132
93
  // ---- Ports and lock ------------------------------------------------------------------
133
94
  export async function freePort() {
@@ -172,26 +133,27 @@ export function pidAlive(pid) { try {
172
133
  catch {
173
134
  return false;
174
135
  } }
175
- // ---- Compose lifecycle ----------------------------------------------------------------
136
+ // ---- Native lifecycle -----------------------------------------------------------------
176
137
  export class LocalRuntime {
177
138
  root;
178
139
  run;
179
140
  project;
180
- composePath;
181
- constructor(root, run = runCommand, project = projectName(root), composePath = kitPaths(root).compose) {
141
+ postgres;
142
+ gateway;
143
+ gates = new Map();
144
+ gateOutput = new Map();
145
+ ports;
146
+ binary;
147
+ constructor(root, run = runCommand, project = projectName(root)) {
182
148
  this.root = root;
183
149
  this.run = run;
184
150
  this.project = project;
185
- this.composePath = composePath;
186
- }
187
- compose(args, options = {}) {
188
- return this.run("docker", ["compose", "-p", this.project, "-f", this.composePath, ...args], { cwd: this.root, ...options });
189
151
  }
190
152
  async writeFiles(bundle, ports) {
191
153
  const paths = kitPaths(this.root);
192
154
  await mkdir(paths.state, { recursive: true });
193
- await writeFile(paths.compose, composeFile(this.project, bundle, ports, paths.state));
194
- await writeFile(join(paths.state, "app-gateway.json"), `${JSON.stringify(gatewayConfig(this.project, ports, paths.state), null, 2)}\n`);
155
+ this.ports = ports;
156
+ await writeFile(join(paths.state, "app-gateway.json"), `${JSON.stringify(nativeGatewayConfig(this.project, ports, paths.state), null, 2)}\n`);
195
157
  // The gateway derives the realtime outbox from the app's migrations; they
196
158
  // are staged into the state dir (already mounted at /state) rather than
197
159
  // bind-mounting migrations/, which Docker would create as root if absent.
@@ -209,9 +171,10 @@ export class LocalRuntime {
209
171
  * a tag can never substitute other bytes.
210
172
  */
211
173
  async pull(bundle) {
212
- const result = await this.compose(["pull", "--quiet"]);
213
- if (result.code !== 0)
214
- throw new CliError("KIT_IMAGES_UNAVAILABLE", `Docker could not pull the kit images (${bundle.images.appGateway}): ${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.`);
174
+ this.binary = process.env.HARBOUR_APP_GATEWAY_BINARY || await ensureNativeGateway(this.root, bundle);
175
+ const result = await this.run(this.binary, ["--help"], { quiet: true });
176
+ if (result.code === 127)
177
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", "The native Harbour runtime could not be started. Reinstall the current Harbour CLI.");
215
178
  }
216
179
  /**
217
180
  * `compose up`. When Docker's bridge address pool is full (each Harbour
@@ -221,16 +184,24 @@ export class LocalRuntime {
221
184
  * person can apply, never as a generic "could not start".
222
185
  */
223
186
  async up(output) {
224
- let result = await this.compose(["up", "-d", "--wait"]);
225
- if (result.code !== 0 && isAddressPoolExhausted(result.stderr)) {
226
- const { reclaimed, running } = await reclaimStaleHarbourProjects(this.run, this.project, output);
227
- if (reclaimed.length)
228
- result = await this.compose(["up", "-d", "--wait"]);
229
- if (result.code !== 0 && isAddressPoolExhausted(result.stderr))
230
- throw new CliError("DOCKER_NETWORK_POOL_EXHAUSTED", poolExhaustedMessage(running));
231
- }
232
- if (result.code !== 0)
233
- 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)?");
187
+ void output;
188
+ if (!this.ports)
189
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The local runtime was not prepared.");
190
+ const paths = kitPaths(this.root);
191
+ await hydrateEmbeddedPostgres(this.run);
192
+ this.postgres = new EmbeddedPostgres({ databaseDir: join(paths.state, "postgres"), port: this.ports.postgres, user: LOCAL.dbUser, password: LOCAL.dbPassword, persistent: true, onLog: () => undefined, onError: () => undefined });
193
+ if (!(await readdir(join(paths.state, "postgres")).catch(() => [])).length)
194
+ await this.postgres.initialise();
195
+ await this.postgres.start();
196
+ await this.postgres.createDatabase(LOCAL.database).catch(error => { if (!String(error).includes("already exists"))
197
+ throw error; });
198
+ const admin = this.postgres.getPgClient(LOCAL.database, "127.0.0.1");
199
+ await admin.connect();
200
+ await admin.query(`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 $$;`);
201
+ await admin.end();
202
+ const binary = this.binary;
203
+ this.gateway = spawn(binary, [], { cwd: this.root, env: { ...process.env, HARBOUR_APP_GATEWAY_CONFIG_FILE: join(paths.state, "app-gateway.json"), HARBOUR_APP_GATEWAY_UPLOAD_KEY: uploadKey(this.project), AWS_EC2_METADATA_DISABLED: "true" }, stdio: ["ignore", "ignore", "pipe"] });
204
+ this.gateway.stderr?.on("data", chunk => process.stderr.write(chunk));
234
205
  }
235
206
  /**
236
207
  * `harbour stop` and Ctrl-C: the project's containers and network go, its
@@ -246,13 +217,17 @@ export class LocalRuntime {
246
217
  * and Docker's address pools run out after a dozen or so ("all predefined
247
218
  * address pools have been fully subnetted").
248
219
  */
249
- async down() { return (await this.compose(["down", "--remove-orphans"], { quiet: true })).code === 0; }
220
+ async down() {
221
+ this.gateway?.kill("SIGTERM");
222
+ for (const child of this.gates.values())
223
+ child.kill("SIGTERM");
224
+ await this.postgres?.stop().catch(() => undefined);
225
+ return true;
226
+ }
250
227
  /** Removes this project's containers and named volumes only. */
251
228
  async reset(output) {
252
- output(`Deleting local data for this app only: compose project ${this.project}, volumes ${this.project}-postgres and ${this.project}-minio.`);
253
- const result = await this.compose(["down", "-v", "--remove-orphans"], { quiet: true });
254
- if (result.code !== 0)
255
- throw new CliError("LOCAL_RESET_FAILED", "Docker could not remove the local Harbour services for this app.");
229
+ output("Deleting this app's local database and files.");
230
+ await this.down();
256
231
  await rm(kitPaths(this.root).state, { recursive: true, force: true });
257
232
  }
258
233
  /** Applies `migrations/*.sql` in name order through psql inside the postgres container, after ensuring the runtime role. */
@@ -271,7 +246,22 @@ export class LocalRuntime {
271
246
  return names;
272
247
  }
273
248
  psql(sql) {
274
- return this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
249
+ if (!this.postgres)
250
+ return Promise.resolve({ code: 1, stdout: "", stderr: "Postgres is not running" });
251
+ return (async () => {
252
+ const client = this.postgres.getPgClient(LOCAL.database, "127.0.0.1");
253
+ try {
254
+ await client.connect();
255
+ await client.query(sql);
256
+ return { code: 0, stdout: "", stderr: "" };
257
+ }
258
+ catch (error) {
259
+ return { code: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
260
+ }
261
+ finally {
262
+ await client.end().catch(() => undefined);
263
+ }
264
+ })();
275
265
  }
276
266
  /**
277
267
  * Starts the kit gate: the pinned gateway image's `gate` subcommand as a
@@ -282,28 +272,26 @@ export class LocalRuntime {
282
272
  * (check.ts) and stops it with stopGate.
283
273
  */
284
274
  async startGate(bundle, port, publicUrl) {
285
- const result = await this.compose([
286
- "run", "--detach", "--no-deps", "--rm", "--publish", `127.0.0.1:${port}:${port}`,
287
- "--volume", `${this.root}:/workspace:ro`,
288
- "--env", "HARBOUR_GATE_WORKSPACE=/workspace", "--env", `HARBOUR_GATE_LISTEN=:${port}`, "--env", `HARBOUR_GATE_PUBLIC_URL=${publicUrl}`,
289
- "--env", `HARBOUR_GATE_DATABASE_URL=${internalDatabaseUrl()}`, "--env", `HARBOUR_GATE_S3_BUCKET=${LOCAL.bucket}`,
290
- "gateway", "gate"
291
- ], { quiet: true });
292
- const id = result.code === 0 ? result.stdout.trim().split("\n").at(-1)?.trim() ?? "" : "";
293
- if (!id)
294
- throw new CliError("LOCAL_RUNTIME_FAILED", `Docker could not start the kit gate (${bundle.images.appGateway}): ${result.stderr.trim().split("\n").at(-1) ?? "docker error"}.`);
275
+ void bundle;
276
+ if (!this.ports)
277
+ throw new CliError("LOCAL_RUNTIME_FAILED", "The local runtime was not prepared.");
278
+ const id = `gate-${Date.now()}`;
279
+ const child = spawn(this.binary, ["gate"], { cwd: this.root, env: { ...process.env, HARBOUR_GATE_WORKSPACE: this.root, HARBOUR_GATE_LISTEN: `127.0.0.1:${port}`, HARBOUR_GATE_PUBLIC_URL: publicUrl, HARBOUR_GATE_DATABASE_URL: `postgresql://${LOCAL.dbUser}:${LOCAL.dbPassword}@127.0.0.1:${this.ports.postgres}/${LOCAL.database}?sslmode=disable`, HARBOUR_GATE_S3_BUCKET: LOCAL.bucket, HARBOUR_GATE_FILES_DIRECTORY: join(kitPaths(this.root).state, "gate-files") }, stdio: ["ignore", "ignore", "pipe"] });
280
+ this.gates.set(id, child);
281
+ child.stderr?.on("data", chunk => this.gateOutput.set(id, `${this.gateOutput.get(id) ?? ""}${chunk}`.slice(-4000)));
295
282
  return id;
296
283
  }
297
284
  async gateRunning(container) {
298
- const result = await this.run("docker", ["inspect", "--format", "{{.State.Running}}", container], { quiet: true });
299
- return result.code === 0 && result.stdout.trim() === "true";
285
+ const child = this.gates.get(container);
286
+ return Boolean(child && child.exitCode === null && !child.killed);
300
287
  }
301
288
  async gateLogs(container) {
302
- const result = await this.run("docker", ["logs", "--tail", "20", container], { quiet: true });
303
- return `${result.stdout}${result.stderr}`.trim().split("\n").slice(-5).join(" | ").slice(0, 800);
289
+ return (this.gateOutput.get(container) || (this.gates.has(container) ? "native gate exited" : "native gate was not started")).trim().split("\n").slice(-20).join(" | ").slice(0, 3000);
304
290
  }
305
291
  async stopGate(container) {
306
- await this.run("docker", ["rm", "-f", container], { quiet: true });
292
+ this.gates.get(container)?.kill("SIGTERM");
293
+ this.gates.delete(container);
294
+ this.gateOutput.delete(container);
307
295
  }
308
296
  /**
309
297
  * Applies the platform's realtime outbox migration the gateway generated
@@ -343,6 +331,37 @@ export class LocalRuntime {
343
331
  throw new CliError("LOCAL_RUNTIME_FAILED", "The local Harbour gateway did not publish a session.");
344
332
  }
345
333
  }
334
+ async function hydrateEmbeddedPostgres(run) {
335
+ const packageName = `@embedded-postgres/${process.platform}-${process.arch}`;
336
+ let entry;
337
+ try {
338
+ entry = fileURLToPath(import.meta.resolve(packageName));
339
+ }
340
+ catch {
341
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", `This Harbour CLI does not include Postgres for ${process.platform}-${process.arch}.`);
342
+ }
343
+ const packageRoot = dirname(dirname(entry));
344
+ const script = join(packageRoot, "scripts", "hydrate-symlinks.js");
345
+ const result = await run(process.execPath, [script], { cwd: packageRoot, quiet: true });
346
+ if (result.code !== 0)
347
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", "The bundled Postgres files could not be prepared.");
348
+ }
349
+ async function ensureNativeGateway(root, bundle) {
350
+ const runtime = process.platform === "darwin" && process.arch === "arm64" ? bundle.nativeRuntime?.darwinArm64 : undefined;
351
+ if (!runtime)
352
+ throw new CliError("KIT_RUNTIME_UNAVAILABLE", `This kit bundle has no native Harbour runtime for ${process.platform}-${process.arch}.`);
353
+ const directory = join(kitPaths(root).local, "runtime");
354
+ const target = join(directory, `harbour-app-gateway-${runtime.sha256.slice(0, 12)}`);
355
+ if (!(await exists(target))) {
356
+ const bytes = await downloadSdk(runtime.url);
357
+ const digest = createHash("sha256").update(bytes).digest("hex");
358
+ if (digest !== runtime.sha256)
359
+ throw new CliError("KIT_BUNDLE_INCOMPATIBLE", `The native runtime digest ${digest} does not match the kit bundle ${runtime.sha256}.`);
360
+ await mkdir(directory, { recursive: true });
361
+ await writeFile(target, bytes, { mode: 0o755 });
362
+ }
363
+ return target;
364
+ }
346
365
  /** The three names the pipeline's retained-check runner exports for the signed identities, from a session env. */
347
366
  export function identityEnvironment(session) {
348
367
  return {
@@ -1,23 +1,62 @@
1
1
  import { homedir } from "node:os";
2
- import { basename, parse, resolve } from "node:path";
3
- import { readdir, readFile } from "node:fs/promises";
2
+ import { basename, dirname, parse, resolve } from "node:path";
3
+ import { readdir, readFile, mkdir, rename, writeFile } from "node:fs/promises";
4
4
  import { scanWorkspace } from "../../../src/analyzer.js";
5
5
  import { createSourceManifest } from "../../../src/source-intake.js";
6
6
  import { structured } from "./remote-mcp-client.js";
7
7
  import { archiveForManifest, putMultipart } from "./upload.js";
8
8
  import { CliError } from "./output.js";
9
- import { follow, getAppSetup, pickSourceFailure, readLine } from "./operations.js";
9
+ import { follow, getAppSetup, pickSourceFailure, readLine, fetchStatus, outcomeFor } from "./operations.js";
10
10
  import { CLI_VERSION } from "./version.js";
11
11
  import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
12
12
  import { assertAiReady, assertPreviewIntegrationsReady } from "./integrations.js";
13
- import { kitPaths, recordKitAppId, sourceDigest } from "./kit.js";
13
+ import { kitPaths, readKitLock, recordKitAppId, sourceDigest } from "./kit.js";
14
14
  import { FLOW_CHECK, preflightKitGate, readReport } from "./check.js";
15
15
  export async function productionise(rootArg, client, output, tenantId, includePaths = [], options = {}) {
16
16
  const root = resolve(rootArg);
17
17
  output(`Harbour is checking ${basename(root)}.`);
18
18
  if (root === parse(root).root || root === resolve(homedir()))
19
19
  throw new CliError("PREFLIGHT_APP_ROOT", "The selected app boundary is unsafe.");
20
- const graph = await scanWorkspace(root, { sourceBoundary: "root", ...(includePaths.length ? { includePaths } : {}) });
20
+ let pending = await readPending(root, tenantId, client.endpoint);
21
+ let prior;
22
+ if (pending) {
23
+ try {
24
+ prior = await fetchStatus(client, pending.operationRef);
25
+ }
26
+ catch (error) {
27
+ if (error instanceof CliError)
28
+ throw withOperation(error, pending.operationRef);
29
+ throw new CliError("OPERATION_STATUS_UNAVAILABLE", "Harbour could not read the existing operation. Continue it when the connection recovers; do not start another deployment.", pending.operationRef);
30
+ }
31
+ if (prior.operation?.stage === "failed" && pending.sourceDigest === (await sourceDigest(root)).digest)
32
+ throw intakeRejection(prior, pending.operationRef);
33
+ if (operationFinished(prior) && pending.sourceDigest !== (await sourceDigest(root)).digest)
34
+ pending = undefined;
35
+ }
36
+ else {
37
+ const appId = options.appId ?? (await readKitLock(root))?.appId;
38
+ if (appId) {
39
+ await client.initialize();
40
+ const recovered = structured(await client.call("harbour_get_operation_status", { appId }));
41
+ if (!recovered || !Object.hasOwn(recovered, "operation"))
42
+ throw new CliError("RECOVERY_UNAVAILABLE", "This company needs Harbour's deployment recovery update before shipping. No new deployment was started.");
43
+ if (recovered.operation?.operationId && !operationFinished(recovered)) {
44
+ prior = recovered;
45
+ pending = { tenantId, endpoint: client.endpoint, operationRef: recovered.operation.operationId, sourceDigest: (await sourceDigest(root)).digest, uploaded: Boolean(recovered.sourceSave || recovered.deployment || recovered.waiting || recovered.operation.stage === "awaiting-approval"), graph: await scanWorkspace(root, { sourceBoundary: "root", ...(includePaths.length ? { includePaths } : {}) }), appId };
46
+ await writePending(root, pending);
47
+ }
48
+ }
49
+ }
50
+ if (pending && (prior?.deployment || prior?.production || prior?.waiting || operationFinished(prior))) {
51
+ output("Harbour is continuing the existing operation; no new deployment was started.");
52
+ return finishDeployment(client, pending.operationRef, output, options);
53
+ }
54
+ if (pending && prior?.sourceSave)
55
+ pending.uploaded = true;
56
+ if (pending && !pending.uploaded && pending.sourceDigest !== (await sourceDigest(root)).digest) {
57
+ throw new CliError("UPLOAD_SOURCE_CHANGED", "The app changed before its upload finished. Restore the submitted files before continuing this operation.", pending.operationRef);
58
+ }
59
+ const graph = pending?.graph ?? await scanWorkspace(root, { sourceBoundary: "root", ...(includePaths.length ? { includePaths } : {}) });
21
60
  if (!graph.deploymentScope.includedFiles.length)
22
61
  throw new CliError("PREFLIGHT_EMPTY", "The selected app boundary contains no eligible files.");
23
62
  if (graph.deploymentScope.includedFiles.some(isProhibitedSecretPath))
@@ -29,21 +68,22 @@ export async function productionise(rootArg, client, output, tenantId, includePa
29
68
  // discovery — for a mismatch `harbour check` names locally in about five
30
69
  // seconds. Refuse here, before the tree is even read into memory, and long
31
70
  // before any grant lookup, database replay, operation or upload.
32
- await assertChecksPassedForTree(root, output);
33
- const files = await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
34
- output(`Harbour found ${files.length} app files.`);
71
+ if (!pending?.uploaded)
72
+ await assertChecksPassedForTree(root, output);
73
+ const files = pending?.uploaded ? [] : await Promise.all(graph.deploymentScope.includedFiles.map(async (path) => ({ path, content: new Uint8Array(await readFile(resolve(root, path))) })));
74
+ output(`Harbour found ${graph.deploymentScope.includedFiles.length} app files.`);
35
75
  // A declared connection without its preview grant would only park the
36
76
  // deployment after the save; refuse here, before any operation exists.
37
- const kitAppId = options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId;
77
+ const kitAppId = pending?.appId ?? (options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId);
38
78
  // An app that calls governed AI needs the company's AI setup to pass the
39
79
  // deployment PLAN; ask governance now rather than discover it on the deployed button.
40
- if (options.integrations && kitAppId && await appCallsAi(root))
80
+ if (!pending?.uploaded && options.integrations && kitAppId && await appCallsAi(root))
41
81
  await assertAiReady(kitAppId, "preview", options.integrations.governance, output);
42
82
  // A kit app the pipeline's gate would refuse (a table without RLS, an
43
83
  // operation no journey exercises, a cross-user leak) is refused here,
44
84
  // before the save, by the same gate with the same wording.
45
85
  const gate = options.kitGate === false ? undefined : options.kitGate ?? (options.integrations ? { bundle: options.integrations.bundle } : undefined);
46
- if (gate)
86
+ if (gate && !pending?.uploaded)
47
87
  await preflightKitGate(root, gate.bundle, output, gate.run);
48
88
  try {
49
89
  await client.initialize();
@@ -54,40 +94,59 @@ export async function productionise(rootArg, client, output, tenantId, includePa
54
94
  let start;
55
95
  const linked = kitAppId ? { appId: kitAppId } : {};
56
96
  try {
57
- start = structured(await client.call("harbour_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex", ...linked }));
97
+ start = pending ? { operation: { operationId: pending.operationRef } } : structured(await client.call("harbour_start_productionization", { appPath: basename(root), appName: basename(root), surface: "codex", ...linked }));
58
98
  }
59
- catch {
60
- throw new CliError("NOT_STARTED", "Harbour could not start the productionisation operation.");
99
+ catch (error) {
100
+ if (error instanceof CliError)
101
+ throw error;
102
+ throw new CliError("START_UNCONFIRMED", "Harbour did not confirm the start response. Run productionise again to recover the app operation before starting another deployment.");
61
103
  }
62
104
  const operationRef = start.operation?.operationId;
63
105
  if (!operationRef)
64
- throw new CliError("NOT_STARTED", "Harbour did not start the productionisation operation.");
106
+ throw new CliError("START_UNCONFIRMED", "Harbour did not return the operation reference. Recover the app operation before starting another deployment.");
107
+ pending ??= { tenantId, endpoint: client.endpoint, operationRef, sourceDigest: (await sourceDigest(root)).digest, uploaded: false, graph, ...(kitAppId ? { appId: kitAppId } : {}) };
108
+ await writePending(root, pending);
109
+ output(`Harbour operation saved. Continue with harbour status --operation ${operationRef} --wait --json.`);
65
110
  try {
66
- const submitted = structured(await client.call("harbour_submit_application_graph", { operationId: operationRef, surface: "codex", graph, ...linked }));
67
- if (submitted.nextTool && !matchesTool(submitted.nextTool, "harbour_prepare_source_upload"))
68
- throw new CliError("UNSUPPORTED_NEXT_STEP", "Harbour returned an unsupported next step.", operationRef);
69
- const appId = typeof submitted.appId === "string" ? submitted.appId.trim() : "";
111
+ let appId = pending.appId;
112
+ if (!pending.uploaded && !pending.uploadCompletion) {
113
+ const submitted = structured(await client.call("harbour_submit_application_graph", { operationId: operationRef, surface: "codex", graph, ...linked }));
114
+ if (submitted.nextTool && !matchesTool(submitted.nextTool, "harbour_prepare_source_upload"))
115
+ throw new CliError("UNSUPPORTED_NEXT_STEP", "Harbour returned an unsupported next step.", operationRef);
116
+ appId = typeof submitted.appId === "string" ? submitted.appId.trim() : "";
117
+ if (!appId)
118
+ throw new CliError("APP_IDENTITY_MISSING", "Harbour did not return the app identity for this operation.", operationRef);
119
+ if (kitAppId && appId !== kitAppId)
120
+ throw new CliError("APP_IDENTITY_MISMATCH", "Harbour returned a different app identity than the one linked in .harbour/kit.lock.json.", operationRef);
121
+ // The canonical identity lands in kit.lock so a later `integrations request` reuses it instead of linking a second app.
122
+ await recordKitAppId(root, appId, tenantId);
123
+ pending = { ...pending, appId, sourceDigest: (await sourceDigest(root)).digest };
124
+ await writePending(root, pending);
125
+ const manifest = await createSourceManifest({ tenantId, appId, operationId: operationRef, graphDigest: graph.graphDigest, files });
126
+ const preliminaryArchive = await archiveForManifest(root, manifest);
127
+ 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 })) } }));
128
+ if (!prepared.acceptedManifest || !prepared.sourceUpload)
129
+ throw new CliError("UPLOAD_CONTRACT_MISSING", "Harbour did not return a safe upload contract.", operationRef);
130
+ if (prepared.nextAction?.tool && !matchesTool(prepared.nextAction.tool, "harbour_complete_source_upload"))
131
+ throw new CliError("UNSUPPORTED_NEXT_STEP", "Harbour returned an unsupported next step.", operationRef);
132
+ const archive = await archiveForManifest(root, prepared.acceptedManifest);
133
+ const parts = await putMultipart(prepared.sourceUpload, archive.body, (sent, total) => output(`Harbour is uploading the app package (${Math.floor(sent * 100 / total)}%).`));
134
+ pending = { ...pending, uploadCompletion: { intentId: prepared.sourceUpload.intentId, parts, clientSha256: archive.digest } };
135
+ await writePending(root, pending);
136
+ }
137
+ if (!pending.uploaded && pending.uploadCompletion) {
138
+ await client.call("harbour_complete_source_upload", { operationId: operationRef, ...pending.uploadCompletion });
139
+ pending = { ...pending, uploaded: true };
140
+ await writePending(root, pending);
141
+ output("Harbour uploaded the secure app package and is inspecting it.");
142
+ }
70
143
  if (!appId)
71
- throw new CliError("APP_IDENTITY_MISSING", "Harbour did not return the app identity for this operation.", operationRef);
72
- if (kitAppId && appId !== kitAppId)
73
- throw new CliError("APP_IDENTITY_MISMATCH", "Harbour returned a different app identity than the one linked in .harbour/kit.lock.json.", operationRef);
74
- // The canonical identity lands in kit.lock so a later `integrations request` reuses it instead of linking a second app.
75
- await recordKitAppId(root, appId, tenantId);
76
- const manifest = await createSourceManifest({ tenantId, appId, operationId: operationRef, graphDigest: graph.graphDigest, files });
77
- const preliminaryArchive = await archiveForManifest(root, manifest);
78
- 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 })) } }));
79
- if (!prepared.acceptedManifest || !prepared.sourceUpload)
80
- throw new CliError("UPLOAD_CONTRACT_MISSING", "Harbour did not return a safe upload contract.", operationRef);
81
- if (prepared.nextAction?.tool && !matchesTool(prepared.nextAction.tool, "harbour_complete_source_upload"))
82
- throw new CliError("UNSUPPORTED_NEXT_STEP", "Harbour returned an unsupported next step.", operationRef);
83
- const archive = await archiveForManifest(root, prepared.acceptedManifest);
84
- const parts = await putMultipart(prepared.sourceUpload, archive.body, (sent, total) => output(`Harbour is uploading the app package (${Math.floor(sent * 100 / total)}%).`));
85
- await client.call("harbour_complete_source_upload", { operationId: operationRef, intentId: prepared.sourceUpload.intentId, parts, clientSha256: archive.digest });
86
- output("Harbour uploaded the secure app package and is inspecting it.");
87
- await waitForIntake(client, operationRef);
88
- let execution = await execute(client, operationRef, appId, graph);
144
+ throw new CliError("APP_IDENTITY_MISSING", "The saved operation has no app identity.", operationRef);
145
+ if (!prior?.sourceSave)
146
+ await waitForIntake(client, operationRef);
147
+ let execution = prior?.sourceSave?.status && prior.sourceSave.status !== "AWAITING_APPROVAL" ? { status: prior.sourceSave.status } : await execute(client, operationRef, appId, graph);
89
148
  if (execution.status === "APPROVAL_REQUIRED" || execution.approvalRequiredBeforeExternalAction === true) {
90
- output(`Approval required: Harbour can copy ${files.length} app files to the approved company code system. Nothing will be deployed or released. Type Approved. then press Enter.`);
149
+ output(`Approval required: Harbour can copy ${graph.deploymentScope.includedFiles.length} app files to the approved company code system. Nothing will be deployed or released. Type Approved. then press Enter.`);
91
150
  const approval = await readApproval();
92
151
  if (approval !== "Approved.")
93
152
  throw new CliError("APPROVAL_NOT_GRANTED", "The Harbour save was not approved.", operationRef);
@@ -105,33 +164,7 @@ export async function productionise(rootArg, client, output, tenantId, includePa
105
164
  const verification = safeVerificationResult(report, evidence);
106
165
  if (options.waitForDeployment === false)
107
166
  return { cliVersion: CLI_VERSION, operationRef, result: verification };
108
- // The saved app is not the finished product. The same source save that the
109
- // console follows to "live" starts a preview deployment; follow it here so
110
- // the maker gets the app's real protected link, not the example one.
111
- // Past this point the app is saved and verified; a failure here is about
112
- // following the deployment, not about the save, and must say so.
113
- let summary;
114
- try {
115
- summary = await follow(client, operationRef, output, options.waitOptions);
116
- }
117
- catch (error) {
118
- if (error instanceof CliError)
119
- throw error;
120
- throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Harbour saved and verified the app but could not follow its deployment (${error instanceof Error ? error.message.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").slice(0, 160) : "unknown error"}). Check again with \`harbour status\`.`, operationRef);
121
- }
122
- output(outcomeLine(summary));
123
- // The console asks for the app's name, audience and secrets before it
124
- // offers promotion; say what is still open so a CLI-only builder knows.
125
- let setup;
126
- try {
127
- setup = await getAppSetup(client, operationRef);
128
- }
129
- catch {
130
- setup = undefined;
131
- }
132
- if (setup?.pending?.length)
133
- output(`${setup.nextAction?.plainEnglish ?? "The app still needs setup."} Run \`harbour setup --operation ${operationRef}\`.`);
134
- return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary, ...(setup ? { setup: { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}) } } : {}) } };
167
+ return finishDeployment(client, operationRef, output, options, verification);
135
168
  }
136
169
  catch (error) {
137
170
  throw await operationFailure(client, operationRef, error);
@@ -216,6 +249,7 @@ async function waitForIntake(client, operationRef) {
216
249
  return;
217
250
  if (status.operation?.stage === "failed")
218
251
  throw intakeRejection(status, operationRef);
252
+ await new Promise(resolve => setTimeout(resolve, 1_000));
219
253
  }
220
254
  throw new CliError("INSPECTION_TIMEOUT", "Harbour is still inspecting the app package. Resume this operation safely.", operationRef);
221
255
  }
@@ -398,3 +432,64 @@ export async function appCallsAi(root) {
398
432
  const capabilities = report?.gate?.inventory?.capabilities;
399
433
  return Array.isArray(capabilities) && capabilities.includes("ai");
400
434
  }
435
+ async function finishDeployment(client, operationRef, output, options, verification = {}) {
436
+ let summary;
437
+ try {
438
+ summary = options.waitForDeployment === false ? outcomeFor(await fetchStatus(client, operationRef), operationRef) : await follow(client, operationRef, output, options.waitOptions);
439
+ }
440
+ catch (error) {
441
+ if (error instanceof CliError)
442
+ throw withOperation(error, operationRef);
443
+ throw new CliError("DEPLOYMENT_STATUS_UNAVAILABLE", `Harbour could not follow the existing operation (${error instanceof Error ? error.message.replace(/https?:\/\/\S+|Bearer\s+\S+/gi, "").slice(0, 160) : "unknown error"}). Check again with \`harbour status\`.`, operationRef);
444
+ }
445
+ output(outcomeLine(summary));
446
+ // The console asks for the app's name, audience and secrets before it
447
+ // offers promotion; say what is still open so a CLI-only builder knows.
448
+ let setup;
449
+ try {
450
+ setup = await getAppSetup(client, operationRef);
451
+ }
452
+ catch {
453
+ setup = undefined;
454
+ }
455
+ if (setup?.pending?.length)
456
+ output(`${setup.nextAction?.plainEnglish ?? "The app still needs setup."} Run \`harbour setup --operation ${operationRef}\`.`);
457
+ return { cliVersion: CLI_VERSION, operationRef, result: { ...verification, ...summary, ...(setup ? { setup: { pending: setup.pending ?? [], ...(setup.nextAction?.plainEnglish ? { plainEnglish: setup.nextAction.plainEnglish } : {}) } } : {}) } };
458
+ }
459
+ function operationFinished(status) {
460
+ if (status.production)
461
+ return ["LIVE", "FAILED", "MANUAL_RECOVERY"].includes(status.production.state);
462
+ if (status.deployment)
463
+ return ["LIVE", "FAILED", "STALE", "RETRYABLE_FAILURE"].includes(status.deployment.state);
464
+ return status.operation?.stage === "failed";
465
+ }
466
+ async function readPending(root, tenantId, endpoint) {
467
+ let raw;
468
+ try {
469
+ raw = await readFile(resolve(root, ".harbour/local/productionise.json"), "utf8");
470
+ }
471
+ catch (error) {
472
+ if (error.code === "ENOENT")
473
+ return undefined;
474
+ throw error;
475
+ }
476
+ const record = JSON.parse(raw);
477
+ if (!record.operationRef || !record.graph || typeof record.uploaded !== "boolean" || !record.sourceDigest)
478
+ throw new CliError("OPERATION_RECORD_INVALID", "The saved deployment reference cannot be read. Recover the operation from Harbour before starting another.");
479
+ return record.tenantId === tenantId && record.endpoint === endpoint ? record : undefined;
480
+ }
481
+ async function writePending(root, record) {
482
+ const path = resolve(root, ".harbour/local/productionise.json");
483
+ try {
484
+ await mkdir(dirname(path), { recursive: true });
485
+ const temporary = `${path}.${process.pid}.tmp`;
486
+ await writeFile(temporary, JSON.stringify(record), { mode: 0o600 });
487
+ await rename(temporary, path);
488
+ }
489
+ catch {
490
+ throw new CliError("OPERATION_RECORD_WRITE_FAILED", "Harbour started the operation but could not save its local reference. Continue with harbour status using this operation reference.", record.operationRef);
491
+ }
492
+ }
493
+ function withOperation(error, operationRef) {
494
+ return new CliError(error.code, error.message, operationRef, error.remediationHint, error.result);
495
+ }
@@ -56,6 +56,7 @@ export class RemoteMcpClient {
56
56
  this.tenant = tenant;
57
57
  this.token = typeof token === "string" ? async () => token : token;
58
58
  }
59
+ get endpoint() { return this.url; }
59
60
  async initialize() {
60
61
  if (this.initialized)
61
62
  return;