@fourier-labs/harbour 0.1.24 → 0.1.25

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.
@@ -1,7 +1,7 @@
1
1
  import { readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { loadDatabaseGate } from "./database-gate.js";
4
- import { CoverageLedger, createCoverageProxy, declaredCapabilities, missingFlowOperations, sourceTableVerbs } from "./flow-coverage.js";
4
+ import { CoverageLedger, capabilityCallSurface, createCoverageProxy, declaredCapabilities, flowDetail, missingFlowOperations, orphanedChecks, sourceTableVerbs } from "./flow-coverage.js";
5
5
  import { CliError } from "./output.js";
6
6
  import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
7
7
  import { LOCAL, LocalRuntime, identityEnvironment, runCommand, runningOrigin } from "./local-runtime.js";
@@ -72,17 +72,25 @@ export async function runChecks(root, options) {
72
72
  const appUrl = await proxy.listen();
73
73
  try {
74
74
  for (const name of journeys) {
75
+ // The journeys run one at a time, so naming the running check makes the
76
+ // gateway traffic that follows attributable to it — which is what lets
77
+ // an orphaned check be named as the file to delete.
78
+ ledger.nowRunning(name);
75
79
  const result = await run("node", [join(kitPaths(root).checks, name)], { cwd: root, quiet: true, env: { HARBOUR_APP_URL: appUrl, HARBOUR_SDK_MODULE: join(root, "node_modules", "@harbour", "app-sdk", "dist", "index.js"), ...identityEnvironment(session) } });
80
+ ledger.nowRunning(undefined);
76
81
  record(`journey:${name}`, result.code === 0 ? "pass" : "fail", result.code === 0 ? undefined : lastLines(result.stderr || result.stdout));
77
82
  }
78
83
  }
79
84
  finally {
85
+ ledger.nowRunning(undefined);
80
86
  await proxy.close();
81
87
  }
88
+ // Both directions of the pipeline's gate: an operation or capability the
89
+ // app has with no check exercising it, and a check exercising a capability
90
+ // the app does not have.
82
91
  const missing = missingFlowOperations(await sourceTableVerbs(root), await declaredCapabilities(root), ledger);
83
- record("flow", missing.length ? "fail" : "pass", missing.length
84
- ? `${missing.join("; ")}. Exercise the converted application operations with source-valid inputs and assertions; an unrelated passing check is insufficient. The pipeline refuses this deployment as kit.check-failed: flow.check-failed.`
85
- : "every operation the app performs and every capability it declares was exercised by a retained check");
92
+ const orphaned = orphanedChecks(await capabilityCallSurface(root), ledger);
93
+ record("flow", missing.length || orphaned.length ? "fail" : "pass", flowDetail(missing, orphaned));
86
94
  }
87
95
  let integrations = "not tested";
88
96
  if (options.governance && lock?.appId && !declaration.errors.length) {
@@ -25,16 +25,58 @@ export const COVERED_CAPABILITIES = ["data", "files", "actions", "telemetry", "r
25
25
  const SDK_CAPABILITIES = ["data", "files", "actions", "realtime", "telemetry", "integrations"];
26
26
  const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
27
27
  const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".harbour"]);
28
- const TABLE_CHAIN = /\.from\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/g;
29
- const VERB_CALL = /\.(select|insert|update|delete|upsert)\s*\(/g;
28
+ /**
29
+ * TypeScript call sites carry explicit type arguments between a method name and
30
+ * its call — `harbour.data.from<Note>("notes")`, `.select<Row>(` — and the
31
+ * starter's own list is one of them. Matching only the bare `.from(` made every
32
+ * typed call site invisible, so a freshly generated starter inventoried
33
+ * `notes: delete, insert, update` with no SELECT: the gate was reading the
34
+ * starter's own capabilities wrong. This is the pipeline's own
35
+ * `appSDKTypeArguments` (transformbuild/source_inspection.go), one nesting
36
+ * level of generics, so both scanners see the same call sites.
37
+ */
38
+ const TYPE_ARGUMENTS = String.raw `(?:<[^<>()]*(?:<[^<>()]*>[^<>()]*)*>)?\s*`;
39
+ const TABLE_CHAIN_SOURCE = String.raw `\.from${TYPE_ARGUMENTS}\(\s*["'\`]([A-Za-z0-9_]+)["'\`]\s*\)`;
40
+ const TABLE_CHAIN = new RegExp(TABLE_CHAIN_SOURCE, "g");
41
+ /** The same chain, unanchored and non-global: where THIS chain's verbs stop. */
42
+ const NEXT_TABLE_CHAIN = new RegExp(TABLE_CHAIN_SOURCE);
43
+ const VERB_CALL = new RegExp(String.raw `\.(select|insert|update|delete|upsert)\s*${TYPE_ARGUMENTS}\(`, "g");
30
44
  /** `createClient()` bindings name the identifier a capability call must be made on. */
31
45
  const CLIENT_BINDING = /(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:await\s+)?createClient\s*\(/g;
46
+ /** `<anything>.<namespace>.<method>(` — the receiver is deliberately not constrained; see capabilityCallSurface. */
47
+ const CAPABILITY_CALL = new RegExp(String.raw `\.\s*(${SDK_CAPABILITIES.join("|")})\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*\s*${TYPE_ARGUMENTS}\(`, "g");
32
48
  /** Records the gateway operations observed while the retained checks ran. */
33
49
  export class CoverageLedger {
34
50
  seen = new Set();
51
+ /** capability -> the retained checks that asked the gateway for it. */
52
+ asked = new Map();
53
+ running;
35
54
  add(key) { this.seen.add(key); }
36
55
  has(key) { return this.seen.has(key); }
37
56
  keys() { return [...this.seen].sort(); }
57
+ /**
58
+ * Names the retained check whose gateway traffic follows. The journeys run
59
+ * one at a time, so a request the proxy sees between this call and the next
60
+ * belongs to this check — which is how a refusal can name the file to delete.
61
+ */
62
+ nowRunning(check) { this.running = check; }
63
+ /**
64
+ * One request the running check sent at a capability-gated path, recorded
65
+ * whatever the answer was. The deployed gateway resolves the capability
66
+ * BEFORE any handler runs and answers 404 when the app did not declare it
67
+ * (appgateway/resolver.go "capability is not declared"), so the request
68
+ * itself — not its local success — is what decides the check's fate there.
69
+ */
70
+ askedFor(path) {
71
+ const capability = capabilityForRequest(path);
72
+ if (!capability || !this.running)
73
+ return;
74
+ const checks = this.asked.get(capability) ?? new Set();
75
+ checks.add(this.running);
76
+ this.asked.set(capability, checks);
77
+ }
78
+ /** capability -> the checks that asked for it, for the reverse coverage question. */
79
+ capabilitiesAsked() { return this.asked; }
38
80
  /**
39
81
  * The gateway's data boundary. A write that changed no row proves nothing, so
40
82
  * only a select — or a mutation that returned rows — counts (observeData in
@@ -82,7 +124,7 @@ export async function sourceTableVerbs(root) {
82
124
  const semicolon = tail.indexOf(";");
83
125
  if (/[;\n]/.test(tail) && semicolon >= 0)
84
126
  tail = tail.slice(0, semicolon);
85
- const next = tail.indexOf(".from(");
127
+ const next = tail.search(NEXT_TABLE_CHAIN);
86
128
  if (next > 0)
87
129
  tail = tail.slice(0, next);
88
130
  tail = tail.slice(0, 200);
@@ -119,6 +161,101 @@ export async function declaredCapabilities(root) {
119
161
  }
120
162
  return used;
121
163
  }
164
+ /**
165
+ * Every capability namespace the app's own source calls a method on, whatever
166
+ * object the call is made on: `harbour.files.list(`, `client().files.list(`,
167
+ * `(harbour as {files: F}).files.list(`.
168
+ *
169
+ * This is deliberately looser than `declaredCapabilities`, and the two answer
170
+ * different questions. The forward gate asks "was this capability exercised?"
171
+ * and must be precise about what the app really uses. The reverse gate below
172
+ * asks "is this check exercising something the app no longer has?" — a
173
+ * question whose wrong answer refuses a good app — so it needs a lower bound on
174
+ * what is unused, never an upper bound on what is used.
175
+ *
176
+ * The pipeline's own evidence for a capability is `<client>.<namespace>.<method>(`
177
+ * with the client traced through the module graph (transformbuild
178
+ * analyzeAppSDKClientCalls / analyzeAppSDKNamespaceWrappers). Dropping the
179
+ * receiver makes this a superset of that set — every call site the pipeline can
180
+ * trace, plus ones it cannot — so a capability this cannot see is one the kit
181
+ * lane cannot declare either, and the reverse gate refuses only trees the
182
+ * pipeline already refuses. Where it errs it errs by staying quiet: a
183
+ * commented-out call still counts here, and the pipeline masks comments.
184
+ */
185
+ export async function capabilityCallSurface(root) {
186
+ const surface = new Set();
187
+ for (const path of await sourceFiles(root)) {
188
+ const text = await readFile(path, "utf8").catch(() => "");
189
+ for (const match of text.matchAll(CAPABILITY_CALL))
190
+ surface.add(match[1]);
191
+ }
192
+ return surface;
193
+ }
194
+ /**
195
+ * The gateway's own path -> capability map (appgateway/gateway.go
196
+ * `capabilityForRequest`). Every one of these paths is resolved against the
197
+ * app's declared capability set before it is served.
198
+ */
199
+ export function capabilityForRequest(path) {
200
+ if (path === "/_harbour/realtime")
201
+ return "realtime";
202
+ if (path.startsWith("/_harbour/files"))
203
+ return "files";
204
+ if (path.startsWith("/_harbour/actions/"))
205
+ return "actions";
206
+ if (path === "/_harbour/telemetry")
207
+ return "telemetry";
208
+ if (path.startsWith("/_harbour/integrations/"))
209
+ return "integrations";
210
+ if (path.startsWith("/_harbour/data/"))
211
+ return "data";
212
+ return undefined;
213
+ }
214
+ /**
215
+ * The other direction, and the one that let a real deploy fail twice: a
216
+ * retained check exercising a capability the app does not have any more.
217
+ *
218
+ * A builder's agent replaced the starter's notes schema and deleted the
219
+ * "Private files" section but left `.harbour/checks/files-journey.mjs` behind.
220
+ * The kit lane derives `.harbour/app-capabilities.json` from the browser SDK
221
+ * surface, the cell's App Gateway serves only what that file declares, and the
222
+ * journey's first `harbour.files.*` call came back 404 — `flow.check-failed …
223
+ * files-journey.mjs: exit status 1`, twice, 2m23s and 2m12s. Locally the check
224
+ * was green: `harbour dev` hard-codes data/files/realtime/telemetry
225
+ * (local-runtime.ts `gatewayConfig`) and always provisions MinIO, so the
226
+ * orphaned journey passed on its own terms.
227
+ *
228
+ * This reproduces the pipeline's refusal rather than inventing a second
229
+ * opinion, at the pipeline's own granularity — the capability, not the table.
230
+ * The pipeline does have a table-level reverse check
231
+ * (`rejectServerTierOperationsDrivenFromTheBrowser`), but it is scoped to apps
232
+ * that declare a workload directory; a kit app has none, so a check that reads
233
+ * a table the browser no longer reads is served there and must be served here.
234
+ */
235
+ export function orphanedChecks(surface, ledger) {
236
+ const orphaned = [];
237
+ for (const [capability, checks] of ledger.capabilitiesAsked()) {
238
+ if (surface.has(capability))
239
+ continue;
240
+ for (const check of checks) {
241
+ orphaned.push(`${check}: exercises the ${capability} capability, which no code in this app calls any more. ` +
242
+ `The deployed gateway serves only the capabilities the kit lane derives from the source, so it refuses this check as ` +
243
+ `flow.check-failed: ${check}: exit status 1. Delete .harbour/checks/${check}, or restore the harbour.${capability}.* feature it was written for.`);
244
+ }
245
+ }
246
+ return orphaned.sort();
247
+ }
248
+ /** The `flow` check's detail, in the pipeline's words, for both directions. */
249
+ export function flowDetail(missing, orphaned) {
250
+ const parts = [];
251
+ if (missing.length)
252
+ parts.push(`${missing.join("; ")}. Exercise the converted application operations with source-valid inputs and assertions; an unrelated passing check is insufficient.`);
253
+ if (orphaned.length)
254
+ parts.push(orphaned.join("; "));
255
+ if (!parts.length)
256
+ return "every operation the app performs and every capability it declares was exercised by a retained check, and no retained check exercises a capability the app no longer has";
257
+ return `${parts.join(" ")} The pipeline refuses this deployment as kit.check-failed: flow.check-failed.`;
258
+ }
122
259
  /**
123
260
  * What the pipeline would refuse, in its own words: an inventoried operation or
124
261
  * a declared capability with no successful observation while the checks ran.
@@ -151,6 +288,10 @@ export function createCoverageProxy(origin, ledger) {
151
288
  const upstreamOrigin = target.origin;
152
289
  const server = createServer((incoming, response) => {
153
290
  const path = (incoming.url ?? "/").split("?")[0];
291
+ // Before the answer: the deployed gateway refuses an undeclared capability
292
+ // at this path whatever the handler would have said, so what the check
293
+ // asked for is the evidence the reverse gate needs.
294
+ ledger.askedFor(path);
154
295
  const chunks = [];
155
296
  incoming.on("data", chunk => { if (chunks.length < 64)
156
297
  chunks.push(chunk); });
@@ -177,6 +318,7 @@ export function createCoverageProxy(origin, ledger) {
177
318
  // receives nothing does not clear the capability here either.
178
319
  server.on("upgrade", (incoming, socket, head) => {
179
320
  const path = (incoming.url ?? "/").split("?")[0];
321
+ ledger.askedFor(path);
180
322
  const upstream = connect(port, host, () => {
181
323
  const headers = Object.entries({ ...forwardable(incoming.headers, upstreamOrigin), host: `${host}:${port}`, connection: "Upgrade", upgrade: "websocket" })
182
324
  .map(([key, value]) => `${key}: ${value}`);
@@ -105,43 +105,110 @@ export function renderFailure(envelope) {
105
105
  const error = envelope.error;
106
106
  return `${error?.message ?? NO_DETAIL}${error?.remediationHint ? ` ${error.remediationHint}` : ""}\n`;
107
107
  }
108
- /** Human-readable stdout for runs without `--json`: the operation reference and
109
- * whatever link or next step Harbour reported, nothing internal. */
108
+ /**
109
+ * Server-supplied text on its way to a terminal, given exactly the treatment
110
+ * `safeError` gives the failure path: secrets stripped, whitespace collapsed,
111
+ * bounded at `DETAIL_BOUND` keeping both ends.
112
+ *
113
+ * `safeError` was `redact`’s only caller. `renderSummary` printed the server’s
114
+ * words straight from the payload, so on the success-shaped path — a deployment
115
+ * that reports a failure inside an otherwise-successful envelope, which is what
116
+ * `status` and `productionise` emit after a failed build — that text reached the
117
+ * terminal both unredacted and unbounded. A presigned URL, a bearer token or a
118
+ * forwarded header dump printed verbatim into whatever captured stdout, and a
119
+ * pathological message printed whole.
120
+ *
121
+ * That is the opposite defect to the one the tail-keeping change fixed: that
122
+ * one lost information, this one leaks and floods. One function and not a
123
+ * second implementation, because a second implementation is how the two paths
124
+ * came to differ at all.
125
+ */
126
+ function said(value) {
127
+ return value ? redact(value) : "";
128
+ }
129
+ /**
130
+ * Human-readable stdout for runs without `--json`: the operation reference and
131
+ * whatever link or next step Harbour reported, nothing internal.
132
+ *
133
+ * Every server-supplied string printed here goes through `said`. What does not
134
+ * is listed below, because each is a value whose whole job is to be reproduced
135
+ * verbatim, and each would be destroyed by the very rules that make redaction
136
+ * worth doing:
137
+ *
138
+ * - `protectedUrl`, `productionUrl` — `SECRETS` strips URLs, and these two are
139
+ * the app’s address: the thing the builder ran the command to get.
140
+ * - `operationRef` — the handle every later `--operation` needs, and already
141
+ * exempt on the failure path: `safeError` redacts the message and the hint,
142
+ * never the reference they travel with. The same policy on both paths.
143
+ * - `secrets.asks[].name` and `.prefilledFromPath` — identifiers, not prose. A
144
+ * secret named `AUTHORIZATION_TOKEN` matches the header pattern and would be
145
+ * erased whole, printing `Secret : needs a value`; the path is read off the
146
+ * local checkout rather than sent by the server.
147
+ * - `audience.emails` — the builder’s own confirmed list, echoed back. A
148
+ * 240-character bound over a joined list silently drops members, which is
149
+ * the information loss the tail-keeping change was about.
150
+ * - `ask.status`, `ask.scope`, `secrets.available`, `audience.confirmed` and
151
+ * the three mapped `pending` steps — compared against fixed literals, so
152
+ * only Harbour’s own words ever print. An unmapped `pending` step is the
153
+ * server’s own id, so that one does go through `said`.
154
+ *
155
+ * `deployment.message` and `production.message` are declared on the payload and
156
+ * never printed here; nothing routes them, and a line that printed one would
157
+ * need `said` like the rest. The `--json` envelope is unchanged: it carries the
158
+ * raw structured result by design and this is about what is printed. Whether
159
+ * the envelope should be redacted too is a decision about that contract, not
160
+ * one to take in the renderer.
161
+ */
110
162
  export function renderSummary(envelope) {
111
163
  const lines = [];
112
164
  const result = (envelope.result ?? {});
113
165
  if (envelope.operationRef)
114
166
  lines.push(`Operation reference: ${envelope.operationRef}`);
115
- if (result.verification)
116
- lines.push(`Saved app: ${result.verification}${result.assuranceLevel ? ` (${result.assuranceLevel})` : ""}`);
167
+ const verification = said(result.verification);
168
+ const assurance = said(result.assuranceLevel);
169
+ if (verification)
170
+ lines.push(`Saved app: ${verification}${assurance ? ` (${assurance})` : ""}`);
117
171
  if (result.production) {
118
- lines.push(`Production: ${result.production.state}${result.production.productionUrl ? ` — ${result.production.productionUrl}` : ""}`);
172
+ lines.push(`Production: ${said(result.production.state)}${result.production.productionUrl ? ` — ${result.production.productionUrl}` : ""}`);
173
+ // `|| NO_DETAIL` for the reason `safeError` has it: redaction can empty a
174
+ // string, and a failure reported as two spaces is worse than one that says
175
+ // plainly this CLI has nothing to show.
119
176
  if (result.production.failure?.message)
120
- lines.push(` ${result.production.failure.message}`);
177
+ lines.push(` ${said(result.production.failure.message) || NO_DETAIL}`);
121
178
  }
122
179
  if (result.deployment) {
123
- lines.push(`Preview deployment: ${result.deployment.status ?? result.deployment.state}${result.deployment.protectedUrl ? ` — ${result.deployment.protectedUrl}` : ""}`);
124
- if (result.deployment.failure?.message)
125
- lines.push(` ${result.deployment.failure.message}${result.deployment.failure.remediationHint ? ` ${result.deployment.failure.remediationHint}` : ""}`);
180
+ lines.push(`Preview deployment: ${said(result.deployment.status ?? result.deployment.state)}${result.deployment.protectedUrl ? ` — ${result.deployment.protectedUrl}` : ""}`);
181
+ if (result.deployment.failure?.message) {
182
+ // The hint is redacted and bounded on its own, exactly as `safeError`
183
+ // bounds it separately from the message: appending it first would spend
184
+ // the message’s own budget on it.
185
+ const hint = said(result.deployment.failure.remediationHint);
186
+ lines.push(` ${said(result.deployment.failure.message) || NO_DETAIL}${hint ? ` ${hint}` : ""}`);
187
+ }
126
188
  }
127
- if (result.waiting?.plainEnglish)
128
- lines.push(`Action needed: ${result.waiting.plainEnglish}`);
129
- else if (result.nextStep)
130
- lines.push(`Next: ${result.nextStep}`);
189
+ // Gated on the redacted value rather than the raw one, so guidance that is
190
+ // nothing but a link falls through to the next step instead of printing a
191
+ // label with nothing after it.
192
+ const waiting = said(result.waiting?.plainEnglish);
193
+ const nextStep = said(result.nextStep);
194
+ if (waiting)
195
+ lines.push(`Action needed: ${waiting}`);
196
+ else if (nextStep)
197
+ lines.push(`Next: ${nextStep}`);
131
198
  const setup = envelope.result?.setup;
132
199
  const secrets = setup?.secrets ?? envelope.result?.secrets;
133
200
  if (setup?.profile) {
134
- lines.push(`App name: ${setup.profile.displayName ?? "(none)"}${setup.profile?.confirmed ? " (confirmed)" : setup.profile?.suggested?.displayName ? ` — suggested: ${setup.profile.suggested.displayName}` : " (not confirmed)"}`);
201
+ lines.push(`App name: ${said(setup.profile.displayName) || "(none)"}${setup.profile?.confirmed ? " (confirmed)" : setup.profile?.suggested?.displayName ? ` — suggested: ${said(setup.profile.suggested.displayName)}` : " (not confirmed)"}`);
135
202
  if (setup.profile?.description || setup.profile?.suggested?.description)
136
- lines.push(`Description: ${setup.profile?.confirmed ? setup.profile.description : setup.profile?.suggested?.description ?? setup.profile?.description}`);
203
+ lines.push(`Description: ${said(setup.profile?.confirmed ? setup.profile.description : setup.profile?.suggested?.description ?? setup.profile?.description)}`);
137
204
  if (setup.audience?.available === false)
138
- lines.push(`Audience: ${setup.audience.plainEnglish ?? "console only"}`);
205
+ lines.push(`Audience: ${said(setup.audience.plainEnglish) || "console only"}`);
139
206
  else
140
207
  lines.push(`Audience: ${setup.audience?.confirmed ? (setup.audience.emails?.length ? setup.audience.emails.join(", ") : "only you") + " (confirmed)" : "not confirmed"}`);
141
208
  }
142
209
  if (secrets) {
143
210
  if (secrets.available === false)
144
- lines.push(`Secrets: ${secrets.plainEnglish ?? "console only"}`);
211
+ lines.push(`Secrets: ${said(secrets.plainEnglish) || "console only"}`);
145
212
  else if (!secrets.asks?.length)
146
213
  lines.push("Secrets: none asked for");
147
214
  else
@@ -149,7 +216,7 @@ export function renderSummary(envelope) {
149
216
  lines.push(`Secret ${ask.name}: ${ask.status === "UNSET" ? "needs a value" : ask.status === "SET" ? "set" : "not needed"}${ask.scope === "personal" ? " (personal)" : ""}${ask.prefilledFromPath ? ` (from ${ask.prefilledFromPath})` : ""}`);
150
217
  }
151
218
  // productionise carries only the open steps; the setup commands carry the full view.
152
- const openSteps = setup?.pending?.length ? setup.pending.map(step => step === "confirm_app_profile" ? "confirm the app's name and description" : step === "confirm_app_audience" ? "confirm who may open it" : step === "provide_secrets" ? "provide the secrets it asked for" : step) : [];
219
+ const openSteps = setup?.pending?.length ? setup.pending.map(step => step === "confirm_app_profile" ? "confirm the app's name and description" : step === "confirm_app_audience" ? "confirm who may open it" : step === "provide_secrets" ? "provide the secrets it asked for" : said(step)) : [];
153
220
  if (openSteps.length)
154
221
  lines.push(`Still needed: ${openSteps.join("; ")}${envelope.result.setup?.plainEnglish && !setup?.profile ? ` — run \`harbour setup --operation ${envelope.operationRef ?? "<reference>"}\`` : ""}`);
155
222
  else if (setup?.profile)
@@ -1 +1 @@
1
- export const CLI_VERSION = "0.1.24";
1
+ export const CLI_VERSION = "0.1.25";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fourier-labs/harbour",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Harbour productionisation helper",
5
5
  "type": "module",
6
6
  "bin": {