@fourier-labs/harbour 0.1.27 → 0.1.28
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/dist/packages/harbour-cli/src/agent-setup.js +4 -2
- package/dist/packages/harbour-cli/src/app-schema.js +8 -44
- package/dist/packages/harbour-cli/src/check.js +185 -145
- package/dist/packages/harbour-cli/src/cli.js +2 -2
- package/dist/packages/harbour-cli/src/dev.js +8 -0
- package/dist/packages/harbour-cli/src/forwarder.js +15 -11
- package/dist/packages/harbour-cli/src/integrations.js +26 -1
- package/dist/packages/harbour-cli/src/kit-bundle.js +9 -3
- package/dist/packages/harbour-cli/src/kit-bundle.manifest.js +7 -7
- package/dist/packages/harbour-cli/src/kit.js +13 -56
- package/dist/packages/harbour-cli/src/local-runtime.js +43 -23
- package/dist/packages/harbour-cli/src/productionise.js +21 -22
- package/dist/packages/harbour-cli/src/retained-checks.js +60 -22
- package/dist/packages/harbour-cli/src/source-inventory.js +171 -0
- package/dist/packages/harbour-cli/src/starter.js +53 -4
- package/dist/packages/harbour-cli/src/version.js +1 -1
- package/package.json +2 -2
- package/dist/packages/harbour-cli/src/database-gate.js +0 -61
- package/dist/packages/harbour-cli/src/flow-coverage.js +0 -490
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { PUBLISHED_KIT_BUNDLE } from "./kit-bundle.manifest.js";
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The local stack's supporting services. Not part of the published bundle: they run only under `harbour dev`.
|
|
4
|
+
* MinIO is the exact image the pipeline's validation cell runs (data plane `transformbuild.validationMinIOImage`),
|
|
5
|
+
* from MinIO's own registry by digest: Docker Hub's `minio/minio` repository disappeared on 2026-09-11
|
|
6
|
+
* (404, "pull access denied"), and `harbour dev`/`check` pull before every start, so a tag there took the
|
|
7
|
+
* whole local kit down. A digest cannot be moved or removed from under an app.
|
|
8
|
+
*/
|
|
9
|
+
export const LOCAL_SERVICE_IMAGES = {
|
|
4
10
|
postgres: "postgres:16-alpine",
|
|
5
|
-
minio: "minio/minio:
|
|
11
|
+
minio: "quay.io/minio/minio@sha256:d249d1fb6966de4d8ad26c04754b545205ff15a62e4fd19ebd0f26fa5baacbc0"
|
|
6
12
|
};
|
|
7
13
|
/**
|
|
8
14
|
* The bundle this CLI was built with: `manifest.json` of the
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
export const PUBLISHED_KIT_BUNDLE = {
|
|
2
2
|
"schema": "harbour.kit-bundle/1.0",
|
|
3
|
-
"kitVersion": "0.1.
|
|
3
|
+
"kitVersion": "0.1.28",
|
|
4
4
|
"sdk": {
|
|
5
5
|
"package": "@harbour/app-sdk",
|
|
6
|
-
"version": "1.
|
|
7
|
-
"tarballSha256": "
|
|
8
|
-
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:
|
|
6
|
+
"version": "1.1.0",
|
|
7
|
+
"tarballSha256": "998102545ba9b0eec3687d8ded2609a16dbb1d3254b006401155106bf8b80355",
|
|
8
|
+
"url": "https://public.ecr.aws/v2/y6t4p3i8/harbour-kit-bundle/blobs/sha256:998102545ba9b0eec3687d8ded2609a16dbb1d3254b006401155106bf8b80355"
|
|
9
9
|
},
|
|
10
10
|
"images": {
|
|
11
|
-
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:
|
|
12
|
-
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:
|
|
11
|
+
"appGateway": "public.ecr.aws/y6t4p3i8/harbour-app-gateway@sha256:1effa54091b76c5668193618f1a781be7157fdd5ad49da831ded354b9e4162ac",
|
|
12
|
+
"sessionFixture": "public.ecr.aws/y6t4p3i8/harbour-session-fixture@sha256:47b305a2a3234c66702aebccfe53d3929d91556dde29b3c27128e2d18e3f1977"
|
|
13
13
|
},
|
|
14
14
|
"brief": {
|
|
15
|
-
"fingerprint": "
|
|
15
|
+
"fingerprint": "2efee82148238282a9aae255d071395dfdba69b68b398208cc0b8ea1d5b9a3b0"
|
|
16
16
|
},
|
|
17
17
|
"declarationSchema": "harbour.app-integrations/2.0"
|
|
18
18
|
};
|
|
@@ -18,61 +18,12 @@ export const OPERATIONS = {
|
|
|
18
18
|
export const READ_OPERATIONS = ["slack.channel.history", "gmail.thread.list", "warehouse.view.read"];
|
|
19
19
|
/** Reads whose input needs an identifier from a prior read (a message id): listed as reads, exercised only through the list. */
|
|
20
20
|
export const DEPENDENT_READ_OPERATIONS = ["gmail.message.read"];
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return { declaration: emptyDeclaration(), errors: ["declaration must be a JSON object"] };
|
|
28
|
-
if (record.schema !== "harbour.app-integrations/2.0")
|
|
29
|
-
errors.push("schema must be harbour.app-integrations/2.0");
|
|
30
|
-
const connections = record.connections && typeof record.connections === "object" && !Array.isArray(record.connections) ? record.connections : undefined;
|
|
31
|
-
if (!connections)
|
|
32
|
-
errors.push("connections must be an object keyed by connection alias");
|
|
33
|
-
for (const [alias, connection] of Object.entries(connections ?? {})) {
|
|
34
|
-
if (!LOGICAL_NAME.test(alias))
|
|
35
|
-
errors.push(`connections.${alias}: alias must be a lowercase logical name`);
|
|
36
|
-
if (!connection || typeof connection !== "object") {
|
|
37
|
-
errors.push(`connections.${alias}: must be an object`);
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
if (connection.kind !== "saas" && connection.kind !== "database")
|
|
41
|
-
errors.push(`connections.${alias}.kind: must be saas or database`);
|
|
42
|
-
const operations = connection.operations && typeof connection.operations === "object" ? connection.operations : undefined;
|
|
43
|
-
if (!operations || !Object.keys(operations).length) {
|
|
44
|
-
errors.push(`connections.${alias}.operations: at least one operation is required`);
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
for (const [name, operation] of Object.entries(operations)) {
|
|
48
|
-
const path = `connections.${alias}.operations.${name}`;
|
|
49
|
-
const rule = OPERATIONS[name];
|
|
50
|
-
if (!rule) {
|
|
51
|
-
errors.push(`${path}: unsupported operation (allowed: ${Object.keys(OPERATIONS).join(", ")})`);
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (rule.kind !== connection.kind)
|
|
55
|
-
errors.push(`${path}: ${name} belongs to a ${rule.kind} connection`);
|
|
56
|
-
if (operation?.identity !== rule.identity)
|
|
57
|
-
errors.push(`${path}.identity: ${name} is always ${rule.identity}`);
|
|
58
|
-
const resources = operation?.resources;
|
|
59
|
-
if (name === "warehouse.view.read") {
|
|
60
|
-
if (!resources || Array.isArray(resources) || typeof resources !== "object" || !Object.keys(resources).length)
|
|
61
|
-
errors.push(`${path}.resources: must map view names to {columns}`);
|
|
62
|
-
else
|
|
63
|
-
for (const [view, spec] of Object.entries(resources)) {
|
|
64
|
-
if (!LOGICAL_NAME.test(view))
|
|
65
|
-
errors.push(`${path}.resources.${view}: view name must be a logical name`);
|
|
66
|
-
if (!spec || !Array.isArray(spec.columns) || !spec.columns.length || spec.columns.length > 32 || spec.columns.some(column => typeof column !== "string" || !LOGICAL_NAME.test(column)))
|
|
67
|
-
errors.push(`${path}.resources.${view}.columns: 1..32 column names are required`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
else if (!Array.isArray(resources) || !resources.length || resources.some(resource => typeof resource !== "string" || !LOGICAL_NAME.test(resource)))
|
|
71
|
-
errors.push(`${path}.resources: must be a non-empty list of logical resource names (no IDs, tokens or URLs)`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return { declaration: record, errors };
|
|
75
|
-
}
|
|
21
|
+
/**
|
|
22
|
+
* Reads the committed declaration as JSON. The closed rules (schema, connection
|
|
23
|
+
* kinds, the operation set, identity modes, logical resource names) are the
|
|
24
|
+
* kit gate's `declaration` check — the pipeline's own — so a defect is named
|
|
25
|
+
* once, in the same words, by `harbour check` and by CodeBuild.
|
|
26
|
+
*/
|
|
76
27
|
export function emptyDeclaration() { return { schema: "harbour.app-integrations/2.0", connections: {} }; }
|
|
77
28
|
export function resourceNames(operation) { return Array.isArray(operation.resources) ? operation.resources : Object.keys(operation.resources); }
|
|
78
29
|
export function requestResources(operation) {
|
|
@@ -86,12 +37,18 @@ export async function readDeclaration(root) {
|
|
|
86
37
|
catch {
|
|
87
38
|
return { declaration: emptyDeclaration(), errors: [".harbour/integrations.json is missing (run `harbour init`)"] };
|
|
88
39
|
}
|
|
40
|
+
let parsed;
|
|
89
41
|
try {
|
|
90
|
-
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
91
43
|
}
|
|
92
44
|
catch {
|
|
93
45
|
return { declaration: emptyDeclaration(), errors: [".harbour/integrations.json is not valid JSON"] };
|
|
94
46
|
}
|
|
47
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
48
|
+
return { declaration: emptyDeclaration(), errors: [".harbour/integrations.json must be a JSON object"] };
|
|
49
|
+
const record = parsed;
|
|
50
|
+
const connections = record.connections && typeof record.connections === "object" && !Array.isArray(record.connections) ? record.connections : {};
|
|
51
|
+
return { declaration: { schema: record.schema, connections }, errors: [] };
|
|
95
52
|
}
|
|
96
53
|
export function isKitLock(value) {
|
|
97
54
|
const record = value;
|
|
@@ -3,7 +3,7 @@ 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
5
|
import { join, relative } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { LOCAL_SERVICE_IMAGES } from "./kit-bundle.js";
|
|
7
7
|
import { kitPaths, projectName } from "./kit.js";
|
|
8
8
|
import { CliError } from "./output.js";
|
|
9
9
|
export const runCommand = (command, args, options = {}) => new Promise(resolve => {
|
|
@@ -54,13 +54,13 @@ export function composeFile(project, bundle, ports, stateDir) {
|
|
|
54
54
|
`name: ${project}`,
|
|
55
55
|
"services:",
|
|
56
56
|
" postgres:",
|
|
57
|
-
` image: ${images.postgres ??
|
|
57
|
+
` image: ${images.postgres ?? LOCAL_SERVICE_IMAGES.postgres}`,
|
|
58
58
|
` environment: { POSTGRES_USER: ${LOCAL.dbUser}, POSTGRES_PASSWORD: ${LOCAL.dbPassword}, POSTGRES_DB: ${LOCAL.database} }`,
|
|
59
59
|
` ports: ["127.0.0.1:${ports.postgres}:5432"]`,
|
|
60
60
|
" volumes: [postgres-data:/var/lib/postgresql/data]",
|
|
61
61
|
` healthcheck: { test: ["CMD-SHELL", "pg_isready -U ${LOCAL.dbUser} -d ${LOCAL.database}"], interval: 2s, timeout: 3s, retries: 30 }`,
|
|
62
62
|
" minio:",
|
|
63
|
-
` image: ${images.minio ??
|
|
63
|
+
` image: ${images.minio ?? LOCAL_SERVICE_IMAGES.minio}`,
|
|
64
64
|
" command: server /data",
|
|
65
65
|
` environment: { MINIO_ROOT_USER: ${LOCAL.s3Key}, MINIO_ROOT_PASSWORD: ${LOCAL.s3Secret} }`,
|
|
66
66
|
` ports: ["127.0.0.1:${ports.minio}:9000"]`,
|
|
@@ -123,7 +123,7 @@ export function gatewayConfig(project, ports, stateDir) {
|
|
|
123
123
|
async function migrationNames(root) {
|
|
124
124
|
return (await readdir(join(root, "migrations")).catch(() => [])).filter(name => name.endsWith(".sql")).sort();
|
|
125
125
|
}
|
|
126
|
-
function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@postgres:5432/${LOCAL.database}?sslmode=disable`; }
|
|
126
|
+
export function internalDatabaseUrl(user = LOCAL.dbUser) { return `postgresql://${user}:${LOCAL.dbPassword}@postgres:5432/${LOCAL.database}?sslmode=disable`; }
|
|
127
127
|
function uploadKey(project) { return createHash("sha256").update(`upload-key:${project}`).digest("base64"); }
|
|
128
128
|
// ---- Ports and lock ------------------------------------------------------------------
|
|
129
129
|
export async function freePort() {
|
|
@@ -180,19 +180,9 @@ export class LocalRuntime {
|
|
|
180
180
|
this.project = project;
|
|
181
181
|
this.composePath = composePath;
|
|
182
182
|
}
|
|
183
|
-
/** Disposable postgres-only project for `harbour check` migrations: separate name, separate volume, torn down after. */
|
|
184
|
-
static forCheck(root, run = runCommand) {
|
|
185
|
-
return new LocalRuntime(root, run, projectName(root, "-check"), join(kitPaths(root).local, "check-compose.yml"));
|
|
186
|
-
}
|
|
187
183
|
compose(args, options = {}) {
|
|
188
184
|
return this.run("docker", ["compose", "-p", this.project, "-f", this.composePath, ...args], { cwd: this.root, ...options });
|
|
189
185
|
}
|
|
190
|
-
async writeCheckFiles(bundle) {
|
|
191
|
-
await mkdir(kitPaths(this.root).local, { recursive: true });
|
|
192
|
-
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"));
|
|
193
|
-
}
|
|
194
|
-
/** Removes this project's containers and volumes without the reset banner (check teardown). */
|
|
195
|
-
async down() { await this.compose(["down", "-v", "--remove-orphans"], { quiet: true }); }
|
|
196
186
|
async writeFiles(bundle, ports) {
|
|
197
187
|
const paths = kitPaths(this.root);
|
|
198
188
|
await mkdir(paths.state, { recursive: true });
|
|
@@ -229,6 +219,14 @@ export class LocalRuntime {
|
|
|
229
219
|
const result = await this.compose(["stop"], { quiet: true });
|
|
230
220
|
return { stopped: result.code === 0 };
|
|
231
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Removes this project's containers and network, keeping its named volumes
|
|
224
|
+
* (the app's local data). What the kit gate uses for a session it started
|
|
225
|
+
* itself: `stop` would leave one Docker network per checked app behind,
|
|
226
|
+
* and Docker's address pools run out after a dozen or so ("all predefined
|
|
227
|
+
* address pools have been fully subnetted").
|
|
228
|
+
*/
|
|
229
|
+
async down() { await this.compose(["down", "--remove-orphans"], { quiet: true }); }
|
|
232
230
|
/** Removes this project's containers and named volumes only. */
|
|
233
231
|
async reset(output) {
|
|
234
232
|
output(`Deleting local data for this app only: compose project ${this.project}, volumes ${this.project}-postgres and ${this.project}-minio.`);
|
|
@@ -252,18 +250,40 @@ export class LocalRuntime {
|
|
|
252
250
|
}
|
|
253
251
|
return names;
|
|
254
252
|
}
|
|
253
|
+
psql(sql) {
|
|
254
|
+
return this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
|
|
255
|
+
}
|
|
255
256
|
/**
|
|
256
|
-
*
|
|
257
|
-
* this project
|
|
258
|
-
*
|
|
257
|
+
* Starts the kit gate: the pinned gateway image's `gate` subcommand as a
|
|
258
|
+
* one-shot service of this project (`docker compose run`), the app mounted
|
|
259
|
+
* read-only at /workspace, its control listener published on `port`, the
|
|
260
|
+
* session's PostgreSQL and bucket named for its scratch database and files.
|
|
261
|
+
* Resolves to the container id; the caller drives the control API
|
|
262
|
+
* (check.ts) and stops it with stopGate.
|
|
259
263
|
*/
|
|
260
|
-
async
|
|
261
|
-
const result = await this.
|
|
262
|
-
|
|
264
|
+
async startGate(bundle, port, publicUrl) {
|
|
265
|
+
const result = await this.compose([
|
|
266
|
+
"run", "--detach", "--no-deps", "--rm", "--publish", `127.0.0.1:${port}:${port}`,
|
|
267
|
+
"--volume", `${this.root}:/workspace:ro`,
|
|
268
|
+
"--env", "HARBOUR_GATE_WORKSPACE=/workspace", "--env", `HARBOUR_GATE_LISTEN=:${port}`, "--env", `HARBOUR_GATE_PUBLIC_URL=${publicUrl}`,
|
|
269
|
+
"--env", `HARBOUR_GATE_DATABASE_URL=${internalDatabaseUrl()}`, "--env", `HARBOUR_GATE_S3_BUCKET=${LOCAL.bucket}`,
|
|
270
|
+
"gateway", "gate"
|
|
271
|
+
], { quiet: true });
|
|
272
|
+
const id = result.code === 0 ? result.stdout.trim().split("\n").at(-1)?.trim() ?? "" : "";
|
|
273
|
+
if (!id)
|
|
274
|
+
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
|
+
return id;
|
|
276
|
+
}
|
|
277
|
+
async gateRunning(container) {
|
|
278
|
+
const result = await this.run("docker", ["inspect", "--format", "{{.State.Running}}", container], { quiet: true });
|
|
279
|
+
return result.code === 0 && result.stdout.trim() === "true";
|
|
280
|
+
}
|
|
281
|
+
async gateLogs(container) {
|
|
282
|
+
const result = await this.run("docker", ["logs", "--tail", "20", container], { quiet: true });
|
|
283
|
+
return `${result.stdout}${result.stderr}`.trim().split("\n").slice(-5).join(" | ").slice(0, 800);
|
|
263
284
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return this.compose(["exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1", ...flags, "-U", LOCAL.dbUser, "-d", LOCAL.database], { stdin: sql, quiet: true });
|
|
285
|
+
async stopGate(container) {
|
|
286
|
+
await this.run("docker", ["rm", "-f", container], { quiet: true });
|
|
267
287
|
}
|
|
268
288
|
/**
|
|
269
289
|
* Applies the platform's realtime outbox migration the gateway generated
|
|
@@ -11,7 +11,7 @@ import { CLI_VERSION } from "./version.js";
|
|
|
11
11
|
import { isProhibitedSecretPath } from "../../../src/secret-paths.js";
|
|
12
12
|
import { assertPreviewIntegrationsReady } from "./integrations.js";
|
|
13
13
|
import { kitPaths, recordKitAppId, sourceDigest } from "./kit.js";
|
|
14
|
-
import {
|
|
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)}.`);
|
|
@@ -35,12 +35,12 @@ export async function productionise(rootArg, client, output, tenantId, includePa
|
|
|
35
35
|
// A declared connection without its preview grant would only park the
|
|
36
36
|
// deployment after the save; refuse here, before any operation exists.
|
|
37
37
|
const kitAppId = options.integrations ? await assertPreviewIntegrationsReady(root, options.integrations.governance, tenantId, options.integrations.bundle, output) : options.appId;
|
|
38
|
-
// A kit
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
const gate = options.
|
|
38
|
+
// A kit app the pipeline's gate would refuse (a table without RLS, an
|
|
39
|
+
// operation no journey exercises, a cross-user leak) is refused here,
|
|
40
|
+
// before the save, by the same gate with the same wording.
|
|
41
|
+
const gate = options.kitGate === false ? undefined : options.kitGate ?? (options.integrations ? { bundle: options.integrations.bundle } : undefined);
|
|
42
42
|
if (gate)
|
|
43
|
-
await
|
|
43
|
+
await preflightKitGate(root, gate.bundle, output, gate.run);
|
|
44
44
|
try {
|
|
45
45
|
await client.initialize();
|
|
46
46
|
}
|
|
@@ -140,18 +140,17 @@ export async function productionise(rootArg, client, output, tenantId, includePa
|
|
|
140
140
|
* against the running app, so both commands are named: the app has to be up
|
|
141
141
|
* before the checks that matter can run at all.
|
|
142
142
|
*/
|
|
143
|
-
const RUN_THE_CHECKS = "
|
|
144
|
-
const FIX_THE_CHECKS = "Fix what the checks reported (the detail is in .harbour/local/check-report.json), then run `harbour check --app-root .` again
|
|
143
|
+
const RUN_THE_CHECKS = "Run `harbour check --app-root .` (it starts the local Harbour services itself when `harbour dev` is not running). Fix whatever it reports, then run this again.";
|
|
144
|
+
const FIX_THE_CHECKS = "Fix what the checks reported (the detail is in .harbour/local/check-report.json), then run `harbour check --app-root .` again.";
|
|
145
145
|
/**
|
|
146
|
-
* The checks the deployment pipeline replays in the cloud
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* run is recorded under its own `journey:<file>` name.
|
|
146
|
+
* The checks the deployment pipeline replays in the cloud: each retained
|
|
147
|
+
* journey under its own `journey:<file>` name and the operation-coverage gate
|
|
148
|
+
* under the pipeline's rule for it. The kit gate records them as `not_run` —
|
|
149
|
+
* not as a pass — when an earlier check stopped the run, which is exactly the
|
|
150
|
+
* state that used to reach the pipeline and fail there.
|
|
152
151
|
*/
|
|
153
152
|
function isReplayedGate(name) {
|
|
154
|
-
return name ===
|
|
153
|
+
return name === FLOW_CHECK || name.startsWith("journey:");
|
|
155
154
|
}
|
|
156
155
|
/** The retained checks this app keeps, enumerated exactly as `harbour check` and the pipeline enumerate them. */
|
|
157
156
|
async function retainedChecks(root) {
|
|
@@ -193,18 +192,18 @@ async function assertChecksPassedForTree(root, output) {
|
|
|
193
192
|
if (report.sourceDigest !== tree.digest)
|
|
194
193
|
throw new CliError("CHECKS_STALE", "The app's code has changed since its checks last ran, so that result no longer describes what would be deployed. Harbour does not deploy an app whose own checks have not passed for this exact code.", undefined, RUN_THE_CHECKS);
|
|
195
194
|
const checks = Array.isArray(report.checks) ? report.checks : [];
|
|
195
|
+
const failed = checks.filter(check => check.status === "fail").map(check => check.name);
|
|
196
|
+
if (failed.length || report.passed !== true)
|
|
197
|
+
throw new CliError("CHECKS_FAILED", `The app's own checks last ran and did not pass${failed.length ? ` (${nameList(failed)})` : ""}. Harbour does not deploy an app whose checks are failing; the deployment pipeline would refuse it too.`, undefined, FIX_THE_CHECKS);
|
|
196
198
|
// "Skipped" is not "passed". A report whose journeys and coverage gate never
|
|
197
199
|
// ran says nothing about whether the app works, and accepting it would hand
|
|
198
200
|
// the discovery straight back to the pipeline.
|
|
199
|
-
const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass"
|
|
200
|
-
const flowRan = checks.some(check => check.name ===
|
|
201
|
+
const notRun = checks.filter(check => isReplayedGate(check.name) && check.status !== "pass").map(check => check.name);
|
|
202
|
+
const flowRan = checks.some(check => check.name === FLOW_CHECK && check.status === "pass");
|
|
201
203
|
if (notRun.length || !flowRan) {
|
|
202
|
-
const names = notRun.length ? notRun : [
|
|
203
|
-
throw new CliError("CHECKS_NOT_RUN", `The last check run skipped ${nameList(names)} instead of passing ${names.length === 1 ? "it" : "them"}
|
|
204
|
+
const names = notRun.length ? notRun : [FLOW_CHECK];
|
|
205
|
+
throw new CliError("CHECKS_NOT_RUN", `The last check run skipped ${nameList(names)} instead of passing ${names.length === 1 ? "it" : "them"}. The deployment pipeline runs the same ones in the cloud, so skipping them here only moves the failure.`, undefined, RUN_THE_CHECKS);
|
|
204
206
|
}
|
|
205
|
-
const failed = checks.filter(check => check.status === "fail").map(check => check.name);
|
|
206
|
-
if (failed.length || report.passed !== true)
|
|
207
|
-
throw new CliError("CHECKS_FAILED", `The app's own checks last ran and did not pass${failed.length ? ` (${nameList(failed)})` : ""}. Harbour does not deploy an app whose checks are failing; the deployment pipeline would refuse it too.`, undefined, FIX_THE_CHECKS);
|
|
208
207
|
output(`The app's own checks passed for this exact code (${retained.length} retained check${plural}, last run ${report.createdAt}).`);
|
|
209
208
|
}
|
|
210
209
|
async function waitForIntake(client, operationRef) {
|
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { allowedLiterals, numericMinimum, textLengthBounds } from "./app-schema.js";
|
|
5
|
-
import { COVERED_CAPABILITIES, capabilitiesCalled, capabilityCallSurface, capabilityUsage, sourceTableUsage } from "./
|
|
5
|
+
import { COVERED_CAPABILITIES, capabilitiesCalled, capabilityCallSurface, capabilityUsage, sourceTableUsage } from "./source-inventory.js";
|
|
6
6
|
import { kitPaths } from "./kit.js";
|
|
7
7
|
/**
|
|
8
8
|
* Retained checks, generated from what the app declares.
|
|
@@ -43,12 +43,21 @@ import { kitPaths } from "./kit.js";
|
|
|
43
43
|
/** First line of every file Harbour wrote, carrying the digest of the body below it. */
|
|
44
44
|
const GENERATED_PREFIX = "// harbour:generated sha256:";
|
|
45
45
|
/** Capability names that own a fixed file name, so a table of the same name does not shadow one. */
|
|
46
|
-
const RESERVED_NAMES = new Set(["files", "telemetry", "actions", "realtime"]);
|
|
47
|
-
/** The data verbs a generated journey performs, in the order it performs them — the set `
|
|
46
|
+
const RESERVED_NAMES = new Set(["files", "telemetry", "actions", "realtime", "ai"]);
|
|
47
|
+
/** The data verbs a generated journey performs, in the order it performs them — the set `source-inventory.ts` inventories. */
|
|
48
48
|
const VERB_ORDER = ["insert", "upsert", "select", "update", "delete"];
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Regenerates `.harbour/checks/` in place from the app's code and its own
|
|
51
|
+
* database, and reports every decision. `declared`, when given, is the
|
|
52
|
+
* capability set the kit gate derived from the source — the set its coverage
|
|
53
|
+
* gate will demand evidence for — and it is the authority: the kit lane traces
|
|
54
|
+
* a namespace wrapper (`export function ai() { return harbour.ai }`) to the
|
|
55
|
+
* capability it wraps whether or not anything calls it, which a call-site
|
|
56
|
+
* reading of the source never sees. Without it (tests, no gate) the source
|
|
57
|
+
* reading decides.
|
|
58
|
+
*/
|
|
59
|
+
export async function syncRetainedChecks(root, schema, declared) {
|
|
60
|
+
const plan = await planRetainedChecks(root, schema, declared);
|
|
52
61
|
const created = [];
|
|
53
62
|
const updated = [];
|
|
54
63
|
for (const check of plan.write) {
|
|
@@ -75,14 +84,26 @@ export function describeRetainedChecks(result) {
|
|
|
75
84
|
];
|
|
76
85
|
}
|
|
77
86
|
/** Reads the app and decides what `.harbour/checks/` should hold, writing nothing. */
|
|
78
|
-
export async function planRetainedChecks(root, schema) {
|
|
79
|
-
const [tables,
|
|
87
|
+
export async function planRetainedChecks(root, schema, declared) {
|
|
88
|
+
const [tables, used, surface, existing] = await Promise.all([sourceTableUsage(root), capabilityUsage(root), capabilityCallSurface(root), readChecks(root)]);
|
|
89
|
+
// The gate's derivation, when it spoke, names the capabilities; the source
|
|
90
|
+
// reading keeps the file attribution where it has one.
|
|
91
|
+
const capabilities = declared ? new Map(declared.map(capability => [capability, used.get(capability) ?? ["the app's source, as the kit lane derives it"]])) : used;
|
|
92
|
+
const gateDeclared = (capability) => declared !== undefined && declared.includes(capability);
|
|
80
93
|
const adopted = existing.filter(check => !check.pristine);
|
|
81
94
|
const plan = { write: [], remove: [], adopted: adopted.map(check => check.path), blocked: [], orphaned: [] };
|
|
82
95
|
const planned = new Set();
|
|
83
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* A planned check, named by the one capability it exercises: outside the
|
|
98
|
+
* `flow` gate's own surface, Harbour writes nothing. And never over a file
|
|
99
|
+
* the builder owns: an adopted check under the name Harbour would use is
|
|
100
|
+
* theirs, however little of the table it still exercises — the gate says
|
|
101
|
+
* what it misses, and only its author decides.
|
|
102
|
+
*/
|
|
84
103
|
const add = (capability, name, content) => {
|
|
85
|
-
if (!surface.has(capability))
|
|
104
|
+
if (!surface.has(capability) && !gateDeclared(capability))
|
|
105
|
+
return;
|
|
106
|
+
if (adopted.some(check => check.name === name))
|
|
86
107
|
return;
|
|
87
108
|
planned.add(name);
|
|
88
109
|
plan.write.push({ path: `.harbour/checks/${name}`, content });
|
|
@@ -94,7 +115,7 @@ export async function planRetainedChecks(root, schema) {
|
|
|
94
115
|
// second person is the cross-user coverage. The two are decided separately: editing a
|
|
95
116
|
// journey must not take its table's cross-user denial down with it.
|
|
96
117
|
const journeyCovered = verbs.length > 0 && verbs.every(verb => adopted.some(check => coversTableVerb(check.text, table, verb)));
|
|
97
|
-
const crossUserCovered = adopted.some(check => check.text
|
|
118
|
+
const crossUserCovered = adopted.some(check => actsAsSecondUser(check.text) && coversTableVerb(check.text, table, "select"));
|
|
98
119
|
if (journeyCovered && (crossUserCovered || !declared?.ownerScoped))
|
|
99
120
|
continue;
|
|
100
121
|
const reason = (detail) => plan.blocked.push(`\`${table}\` (${verbs.join(", ")}), which ${usage.files.join(", ")} queries: ${detail}`);
|
|
@@ -118,9 +139,9 @@ export async function planRetainedChecks(root, schema) {
|
|
|
118
139
|
continue;
|
|
119
140
|
if (adopted.some(check => capabilitiesCalled(check.text).has(capability)))
|
|
120
141
|
continue;
|
|
121
|
-
const generic = capability === "files" ? filesJourney(files) : capability === "telemetry" ? telemetryJourney(files) : undefined;
|
|
142
|
+
const generic = capability === "files" ? filesJourney(files) : capability === "telemetry" ? telemetryJourney(files) : capability === "ai" ? aiJourney(files) : undefined;
|
|
122
143
|
if (!generic) {
|
|
123
|
-
plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Harbour writes journeys for data, files and
|
|
144
|
+
plan.blocked.push(`the \`${capability}\` capability ${files.join(", ")} uses: Harbour writes journeys for data, files, telemetry and ai only — write \`.harbour/checks/${capability}-journey.mjs\` yourself`);
|
|
124
145
|
continue;
|
|
125
146
|
}
|
|
126
147
|
add(capability, `${capability}-journey.mjs`, generic);
|
|
@@ -163,8 +184,8 @@ function stewardship(sources, uuid = false) {
|
|
|
163
184
|
}
|
|
164
185
|
/** The single signed-in identity every journey but the cross-user one runs as. */
|
|
165
186
|
const SIGNED_IN = [
|
|
166
|
-
"// The signed-in identity is attached by the SDK itself
|
|
167
|
-
"//
|
|
187
|
+
"// The signed-in identity is attached by the SDK itself, from the kit gate's",
|
|
188
|
+
"// runner (the same under `harbour check` and in the pipeline): one injector per request.",
|
|
168
189
|
"const harbour = sdk.createClient({ baseUrl: appUrl });",
|
|
169
190
|
"",
|
|
170
191
|
"const user = await harbour.identity.current();",
|
|
@@ -216,14 +237,11 @@ function crossUserCheck(table, schema, row, sources) {
|
|
|
216
237
|
`// with current_setting('harbour.user_id')${schema.ownerColumn ? ` on \`${schema.ownerColumn}\`` : ""}. Running it here means user isolation can`,
|
|
217
238
|
"// no longer be green under `harbour check` and red in CodeBuild.",
|
|
218
239
|
...stewardship([schema.file, ...sources], row.uuid),
|
|
219
|
-
"
|
|
220
|
-
"
|
|
221
|
-
"//
|
|
240
|
+
"// Both people come from the SDK itself: the kit gate's runner signs two",
|
|
241
|
+
"// identities and the SDK attaches the first to every request by default and",
|
|
242
|
+
"// the second when asked. A check never reads a token name.",
|
|
222
243
|
"const owner = sdk.createClient({ baseUrl: appUrl });",
|
|
223
|
-
"
|
|
224
|
-
"const header = (process.env.HARBOUR_IDENTITY_CONTEXT_HEADER ?? \"X-Harbour-Identity-Context\").toLowerCase();",
|
|
225
|
-
"const asSecondUser = (input, init = {}) => fetch(input, { ...init, headers: { ...(init.headers ?? {}), [header]: secondUser } });",
|
|
226
|
-
"const other = sdk.createClient({ baseUrl: appUrl, fetch: asSecondUser });",
|
|
244
|
+
"const other = sdk.createClient({ baseUrl: appUrl, identity: \"second-user\" });",
|
|
227
245
|
"",
|
|
228
246
|
"const me = await owner.identity.current();",
|
|
229
247
|
"const them = await other.identity.current();",
|
|
@@ -289,6 +307,24 @@ function telemetryJourney(sources) {
|
|
|
289
307
|
];
|
|
290
308
|
return sign(`${body.join("\n")}\n`);
|
|
291
309
|
}
|
|
310
|
+
function aiJourney(sources) {
|
|
311
|
+
const body = [
|
|
312
|
+
"// Retained journey for the app's governed AI: one chat completion reaches the gateway",
|
|
313
|
+
`// through the running app (HARBOUR_APP_URL) — the capability ${sources.join(", ")} uses`,
|
|
314
|
+
"// through `harbour.ai.chat`. Locally this is a real governed call through the Harbour",
|
|
315
|
+
"// development route (your `harbour login`; IT must have enabled an AI provider). In the",
|
|
316
|
+
"// deployment pipeline it is answered by a canned completion and reported as not tested.",
|
|
317
|
+
"// The pipeline refuses an app whose checks never exercise a capability its code uses.",
|
|
318
|
+
...stewardship(sources),
|
|
319
|
+
...SIGNED_IN,
|
|
320
|
+
"",
|
|
321
|
+
"const reply = await harbour.ai.chat({ messages: [{ role: \"user\", content: \"Reply with one word: ready\" }], maxTokens: 16 });",
|
|
322
|
+
"assert.equal(typeof reply.content, \"string\", \"ai.chat must answer with content\");",
|
|
323
|
+
"assert.ok(reply.content.length > 0, \"the governed completion is not empty\");",
|
|
324
|
+
"assert.equal(typeof reply.model, \"string\", \"the answer names the governed model alias\");"
|
|
325
|
+
];
|
|
326
|
+
return sign(`${body.join("\n")}\n`);
|
|
327
|
+
}
|
|
292
328
|
// ---- Reading the app ------------------------------------------------------------
|
|
293
329
|
/** How a generated journey writes one row: the values to insert, the column it finds the row by, and the column it changes. */
|
|
294
330
|
function rowShape(schema, needsUpdate) {
|
|
@@ -381,6 +417,8 @@ function staleSurface(text, tables, capabilities) {
|
|
|
381
417
|
}
|
|
382
418
|
/** `insert, update and delete`, for a generated check's opening line. */
|
|
383
419
|
const sentence = (verbs) => verbs.length > 1 ? `${verbs.slice(0, -1).join(", ")} and ${verbs.at(-1)}` : verbs.join("");
|
|
420
|
+
/** A check that acts as the runner's second person, in the SDK's form or the older header form. */
|
|
421
|
+
const actsAsSecondUser = (text) => /identity:\s*["'`]second-user["'`]/.test(text) || text.includes("HARBOUR_IDENTITY_CONTEXT_SECOND_USER");
|
|
384
422
|
const coversTableVerb = (text, table, verb) => new RegExp(`\\.from\\(\\s*["'\`]${table}["'\`]\\s*\\)`).test(text) && new RegExp(`\\.${verb}\\s*\\(`).test(text);
|
|
385
423
|
/** `notes` gets `notes-journey.mjs`; a table sharing a capability's name does not shadow it. */
|
|
386
424
|
const fileName = (table, kind) => `${table}${RESERVED_NAMES.has(table) ? "-table" : ""}-${kind}.mjs`;
|