@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.
- package/README.md +2 -2
- package/dist/packages/harbour-cli/src/agent-setup.js +55 -18
- package/dist/packages/harbour-cli/src/check.js +9 -10
- package/dist/packages/harbour-cli/src/cli.js +19 -21
- package/dist/packages/harbour-cli/src/dev.js +2 -3
- package/dist/packages/harbour-cli/src/jobs.js +49 -0
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +7 -7
- package/dist/packages/harbour-cli/src/kit.js +2 -2
- package/dist/packages/harbour-cli/src/local-runtime.js +121 -102
- package/dist/packages/harbour-cli/src/productionise.js +160 -65
- package/dist/packages/harbour-cli/src/remote-mcp-client.js +1 -0
- package/dist/packages/harbour-cli/src/starter.js +18 -28
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +5 -2
- package/dist/packages/harbour-cli/src/docker-networks.js +0 -126
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
// ----
|
|
136
|
+
// ---- Native lifecycle -----------------------------------------------------------------
|
|
176
137
|
export class LocalRuntime {
|
|
177
138
|
root;
|
|
178
139
|
run;
|
|
179
140
|
project;
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
194
|
-
await writeFile(join(paths.state, "app-gateway.json"), `${JSON.stringify(
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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() {
|
|
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(
|
|
253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
"
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
299
|
-
return
|
|
285
|
+
const child = this.gates.get(container);
|
|
286
|
+
return Boolean(child && child.exitCode === null && !child.killed);
|
|
300
287
|
}
|
|
301
288
|
async gateLogs(container) {
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
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", "
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
+
}
|