@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
|
@@ -17,7 +17,7 @@ export async function initKit(root, bundle, options = {}) {
|
|
|
17
17
|
const emptyDir = !existingPackage && !(await exists(join(root, "src")));
|
|
18
18
|
if (existingPackage && !isSupportedApp(existingPackage))
|
|
19
19
|
throw new CliError("APP_UNSUPPORTED", "harbour init supports an empty directory or an existing Vite + React app (package.json must depend on vite and react).");
|
|
20
|
-
const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : emptyDir ? "starter" : "existing", bundleChanges: [], agents: options.env ? await agentSetup(options.env) : { created: [], updated: [], kept: [] } };
|
|
20
|
+
const result = { root, created: [], kept: [], updated: [], mode: options.upgrade ? "upgrade" : emptyDir ? "starter" : "existing", bundleChanges: [], agents: options.env ? await agentSetup(options.env) : { created: [], updated: [], kept: [], removed: [] } };
|
|
21
21
|
const write = async (path, content) => {
|
|
22
22
|
const absolute = join(root, path);
|
|
23
23
|
if (await exists(absolute)) {
|
|
@@ -92,7 +92,7 @@ export function managedBlock() {
|
|
|
92
92
|
"",
|
|
93
93
|
"- Identity, data and files go through `@harbour/app-sdk` only: `harbour.identity.current()`, `harbour.data.from(table)`, `harbour.files.*`. Never open a database, storage bucket or company system from browser code, and never `fetch` a company URL directly.",
|
|
94
94
|
"- Company systems (Slack, Gmail, warehouse views) are reached only through `harbour.integrations.execute(connection, {operation, resource, input})` with the connection and operation declared in `.harbour/integrations.json`. Operations are a closed set: `slack.channel.history` (user identity), `slack.message.post` (declared per app: `app` posts as the company's Slack bot, Isomorph AI, under the name IT approved; `user` posts as the signed-in person after their consent), `gmail.thread.list`, `gmail.message.read` and `gmail.message.send` (user identity — reads and one plain-text send as the signed-in person, resource `inbox`), `warehouse.view.read` (app identity). Resources are logical names, never IDs, URLs or tokens.",
|
|
95
|
-
"- `.harbour/integrations.json` starts with `\"connections\": {}` and stays that way until the app really calls a company system. Adding one is two steps: declare the connection — by the identifier `harbour integrations catalog --app-root .` lists, with only the operations the app calls and only channels, views and mailboxes the catalog shows as approved; never a guessed name — then `harbour integrations request <connection> --reason \"<why>\" --app-root .` (and the same command with `--environment preview` before shipping). Every declared connection blocks the deploy until IT grants it, so a connection the app does not call is a deploy that never happens; a connection that is not declared cannot be requested at all (`CONNECTION_NOT_DECLARED`), so it never gets a grant. The file is strict JSON and cannot hold comments; the worked Slack and warehouse examples are in README.md and in the comment in `src/App.tsx`.",
|
|
95
|
+
"- `.harbour/integrations.json` starts with `\"connections\": {}` and stays that way until the app really calls a company system. Adding one is two steps: declare the connection — by the identifier `harbour integrations catalog --app-root .` lists, with only the operations the app calls and only channels, views and mailboxes the catalog shows as approved; never a guessed name — then `harbour integrations request <connection> --reason \"<why>\" --app-root .` (and the same command with `--environment preview` before shipping). Every declared connection blocks the deploy until IT grants it, so a connection the app does not call is a deploy that never happens; a connection that is not declared cannot be requested at all (`CONNECTION_NOT_DECLARED`), so it never gets a grant. Before a real integration test, read `harbour integrations status --app-root . --json`: show pending IT approval separately from personal consent, and test ready destinations independently. After a partial send, retry only the failed destination with its original idempotency key; do not regenerate or resend a successful destination. The file is strict JSON and cannot hold comments; the worked Slack and warehouse examples are in README.md and in the comment in `src/App.tsx`.",
|
|
96
96
|
"- A Slack message is sent only after the person presses an explicit Send control; pass a fresh UUID `idempotencyKey` per send action and reuse the same key when retrying that action. Never send from an effect, a timer or a background queue, and never send during checks.",
|
|
97
97
|
"- A Slack message is posted either as the app (the company's Slack bot, Isomorph AI, under the IT-approved name: declare `\"presentation\": { \"displayName\": \"<app name>\" }` on the Slack connection, optional `iconEmoji`; every app-mode post ends with \"Posted by <app> on Isomorph\") or as the person (`\"identity\": \"user\"` on `slack.message.post`, their own consent; an older consent answers `USER_RECONNECT_REQUIRED` — offer Connect again) — never pretend one is the other. The declaration is the mode you request access for; when the app is approved for both, every post says which with `mode: \"app\"` or `mode: \"user\"` on the call (`MODE_REQUIRED` otherwise), and a mode IT has not approved is refused with `MODE_NOT_GRANTED`, never swapped. A post needs the bot in the channel: `/invite @Isomorph AI` there first.",
|
|
98
98
|
"- Consent is only for user-identity operations (`slack.channel.history`, `gmail.*`, and `slack.message.post` declared `\"identity\": \"user\"`): call `harbour.integrations.connect(connection)` and, when it returns `consent_required`, open `authorizationUrl`. App-identity operations (`slack.message.post` declared `\"identity\": \"app\"`, `warehouse.view.read`) never call connect — it is refused as unapproved user access. Missing consent never falls back to another account.",
|
|
@@ -105,7 +105,7 @@ export function managedBlock() {
|
|
|
105
105
|
"- `.harbour/checks/` holds the app's retained journeys: one per capability the app's own code uses (`harbour.data.*`, `harbour.files.*`, actions, realtime, telemetry), plus a cross-user denial per owner-scoped table. `harbour check` generates them from `migrations/` and the app's own source and deletes the ones the app no longer needs, so the way to keep them right is to run it in the same edit that changes the app — not to write or remove these files by hand. The pairing is two-way and the `flow` gate refuses the deploy in both directions. Start using a capability and it needs its own retained check: `harbour check` writes it, except for the ones it reports it cannot generate (`actions`, `realtime`), which you write yourself. Stop using one — a deleted section, a dropped table, a feature the app no longer has — and its retained check must be deleted in that same edit: `harbour check` deletes the ones it generated, and one you wrote or edited is yours to delete, because `harbour check` and the deployment pipeline replay `.harbour/checks/` against a real App Gateway and refuse the app (`flow.check-failed: the candidate's own retained checks no longer pass`) when a check exercises something the code no longer does.",
|
|
106
106
|
"- A generated check starts with a `// harbour:generated` line carrying a digest of its own body; that is how `harbour check` knows the file is still its to rewrite and remove. Edit one and it becomes yours: Harbour keeps your version, stops updating it and never deletes it, and keeping it honest is then your job. The starter's pairing is: `notes-journey.mjs` + `notes-cross-user.mjs` with the `notes` table and the Notes section of `src/App.tsx`; `files-journey.mjs` with the \"Private files\" section, the only code that calls `harbour.files.*`. Replace the notes table with the app's own, or remove the \"Private files\" section, and the next `harbour check` rewrites and deletes to match — `.harbour/checks/files-journey.mjs` goes with that section, and you delete it by hand in that same edit only if you have edited it. An inherited check for a feature the app replaced or dropped is the most common reason a first deploy is refused.",
|
|
107
107
|
"- Commands: `harbour dev --app-root .` (local runtime), `harbour check --app-root .` (types, build, then the pipeline's own kit gate in the local session: declaration, migrations + database gate, write probe with cross-user denial, the generated journeys, operation coverage), `harbour integrations catalog --app-root .` (the company's connections and approved names, before declaring one), `harbour integrations request <connection> --reason <text> --app-root .`, `harbour integrations status --app-root .`, `harbour productionise --app-root .`. Company calls in `dev` use the account from `harbour login`; the local fixture user is only the app's identity.",
|
|
108
|
-
"- Codex reads this AGENTS.md block; Claude Code also reads `.claude/skills/harbour-kit/SKILL.md`. The plain-English workflow (what to run when the person says \"run it\", \"check it\", \"ship it\") is
|
|
108
|
+
"- Codex reads this AGENTS.md block; Claude Code also reads `.claude/skills/harbour-kit/SKILL.md`. The plain-English workflow (what to run when the person says \"run it\", \"check it\", \"ship it\") is the user-level `isomorph` skill installed by `harbour agent-setup` (Claude Code: `~/.claude/skills/isomorph`, Codex: `~/.codex/skills/isomorph`); use it for every request about this app.",
|
|
109
109
|
MANAGED_END
|
|
110
110
|
].join("\n");
|
|
111
111
|
}
|
|
@@ -244,12 +244,12 @@ Add one in two steps, when the app really calls it:
|
|
|
244
244
|
Then call it from the app through \`integrations()\` in \`src/harbour.client.ts\`:
|
|
245
245
|
|
|
246
246
|
\`\`\`ts
|
|
247
|
-
const report = await integrations().execute
|
|
247
|
+
const report = await integrations().execute("sales-warehouse", {
|
|
248
248
|
operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
|
|
249
249
|
});
|
|
250
250
|
\`\`\`
|
|
251
251
|
|
|
252
|
-
A send (\`slack.message.post\`, \`gmail.message.send\`) runs only
|
|
252
|
+
A send (\`slack.message.post\`, \`gmail.message.send\`) runs only from an explicit Send control, pressed by the person or by the AI tool for an explicitly authorized test, with a fresh UUID \`idempotencyKey\` per press — never from an effect, a timer or a check. Consent is only for user-identity operations (\`slack.channel.history\`, \`gmail.*\`, and \`slack.message.post\` declared \`"identity": "user"\`): \`integrations().connect(connection)\`, and open its \`authorizationUrl\` when it answers \`consent_required\`; app-identity operations (\`slack.message.post\` declared \`"identity": "app"\`, \`warehouse.view.read\`) never call connect. Anything the app calls needs its own retained check under \`.harbour/checks/\`, and anything it stops calling loses its check in the same edit. Keep request construction in an app function that both the button and its retained check call, so the check exercises the actual payload. Do not bypass the SDK types with a generic string/unknown wrapper. A check that calls \`integrations().execute\` is answered by the gate's fixture — the contract's result shape for the declared connection, operation and resource, nothing sent or read, and the report says so; \`harbour check --integrations\` is what exercises real access.
|
|
253
253
|
`;
|
|
254
254
|
const VITE_CONFIG = `import { defineConfig } from "vite";
|
|
255
255
|
import react from "@vitejs/plugin-react";
|
|
@@ -268,33 +268,23 @@ export default defineConfig({
|
|
|
268
268
|
}
|
|
269
269
|
});
|
|
270
270
|
`;
|
|
271
|
-
const HARBOUR_CLIENT = `import { createClient
|
|
271
|
+
const HARBOUR_CLIENT = `import { createClient } from "@harbour/app-sdk";
|
|
272
272
|
|
|
273
273
|
// Single client for the whole app. Identity, data and files come from Harbour;
|
|
274
274
|
// the same calls work locally (harbour dev) and in preview/production.
|
|
275
275
|
export const harbour = createClient();
|
|
276
276
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
export type IntegrationCall = IntegrationRequest | { operation: string; resource: string; input: unknown; idempotencyKey?: string };
|
|
281
|
-
export type Integrations = {
|
|
282
|
-
connect(connection: string): Promise<ConnectResult>;
|
|
283
|
-
disconnect(connection: string): Promise<{ status: "disconnected" }>;
|
|
284
|
-
execute<T>(connection: string, call: IntegrationCall): Promise<T>;
|
|
285
|
-
};
|
|
277
|
+
// Keep the SDK's operation-specific inputs and inferred results intact.
|
|
278
|
+
export type Integrations = typeof harbour.integrations;
|
|
279
|
+
export function integrations(): Integrations { return harbour.integrations; }
|
|
286
280
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const surface = (harbour as { integrations?: Integrations }).integrations;
|
|
295
|
-
if (!surface) throw new Error("This @harbour/app-sdk build has no integrations surface; run harbour init --upgrade.");
|
|
296
|
-
return surface;
|
|
297
|
-
}
|
|
281
|
+
// Gmail sends plain text as the signed-in person, after IT approval and personal consent.
|
|
282
|
+
// Inside an authorized Send handler (use the catalog's actual connection and mailbox):
|
|
283
|
+
// await integrations().execute("company-gmail", {
|
|
284
|
+
// operation: "gmail.message.send", resource: "inbox",
|
|
285
|
+
// input: { to: ["recipient@example.com"], subject: "Update", text: "Your message" },
|
|
286
|
+
// idempotencyKey: crypto.randomUUID()
|
|
287
|
+
// });
|
|
298
288
|
|
|
299
289
|
export type AiMessage = { role: "system" | "user" | "assistant"; content: string };
|
|
300
290
|
export type AiChatResult = { content: string; model: string; finishReason: string; usage: { inputTokens: number; outputTokens: number }; traceId?: string };
|
|
@@ -373,10 +363,10 @@ export function App() {
|
|
|
373
363
|
// 2. Request access (harbour integrations request sales-warehouse --reason "<why>" --app-root .,
|
|
374
364
|
// and again with --environment preview before harbour productionise), then import
|
|
375
365
|
// { integrations } from "./harbour.client" and uncomment:
|
|
376
|
-
// const report = await integrations().execute
|
|
366
|
+
// const report = await integrations().execute("sales-warehouse", {
|
|
377
367
|
// operation: "warehouse.view.read", resource: "weekly_sales", input: { columns: ["week", "total"], limit: 100 }
|
|
378
368
|
// });
|
|
379
|
-
// A Slack send runs only
|
|
369
|
+
// A Slack send runs only from an explicit Send control, pressed by the person or by the AI tool for an explicitly authorized test, with a
|
|
380
370
|
// fresh UUID idempotencyKey per press — never from an effect, a timer or a check.
|
|
381
371
|
//
|
|
382
372
|
// Governed AI is not part of the starter either. When the person asks for it, add ONE
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "0.1.
|
|
1
|
+
export const CLI_VERSION = "0.1.34";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fourier-labs/harbour",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "Harbour productionisation helper",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,6 +25,9 @@
|
|
|
25
25
|
"engines": {
|
|
26
26
|
"node": ">=22.13.0"
|
|
27
27
|
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"embedded-postgres": "18.4.0-beta.17"
|
|
30
|
+
},
|
|
28
31
|
"scripts": {
|
|
29
32
|
"prebuild": "node scripts/kit-bundle.mjs verify",
|
|
30
33
|
"build": "tsc -p tsconfig.json",
|
|
@@ -34,7 +37,7 @@
|
|
|
34
37
|
"harbour": {
|
|
35
38
|
"kitBundle": {
|
|
36
39
|
"repository": "public.ecr.aws/y6t4p3i8/harbour-kit-bundle",
|
|
37
|
-
"version": "0.1.
|
|
40
|
+
"version": "0.1.34"
|
|
38
41
|
}
|
|
39
42
|
}
|
|
40
43
|
}
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { dirname } from "node:path";
|
|
2
|
-
/**
|
|
3
|
-
* Docker's bridge address pool. Every `harbour dev` / `harbour check` project
|
|
4
|
-
* is a Compose project with its own bridge network, and the engine's default
|
|
5
|
-
* pool holds only ~31 of them (Docker Desktop and colima: 172.17-172.31/16 plus
|
|
6
|
-
* 192.168.0.0/16 cut into /20s). Once it is full, `compose up` fails to create
|
|
7
|
-
* the network before a single container starts — a "Docker could not start"
|
|
8
|
-
* that has nothing to do with the app. This module names that failure, reclaims
|
|
9
|
-
* the networks Harbour itself left behind (projects of other app roots with no
|
|
10
|
-
* running container) and, when that is not enough, says what only the person
|
|
11
|
-
* can do.
|
|
12
|
-
*/
|
|
13
|
-
/** The network label every Harbour compose project carries (compose puts it on the project's default network). */
|
|
14
|
-
export const HARBOUR_NETWORK_LABEL = "com.harbour.kit";
|
|
15
|
-
/** The engine's wording for an exhausted pool, across versions and the compose wrapper. */
|
|
16
|
-
const POOL_EXHAUSTED = /all predefined address pools have been fully subnetted|could not find an available,? non-overlapping IPv4 address pool|no available IPv4 addresses on this network'?s address pools|could not allocate an IPv4 subnet/i;
|
|
17
|
-
export function isAddressPoolExhausted(stderr) { return POOL_EXHAUSTED.test(stderr); }
|
|
18
|
-
/** The name Harbour gives its compose projects: `harbour-<12 hex>` (+ `-check`) — the recognition rule for projects created before the network label existed. */
|
|
19
|
-
const HARBOUR_PROJECT = /^harbour-[0-9a-f]{12}(?:-check)?$/;
|
|
20
|
-
/**
|
|
21
|
-
* Every compose project on this machine that Harbour created: networks that
|
|
22
|
-
* carry the Harbour label or (older CLIs) the Harbour project-name pattern.
|
|
23
|
-
* Networks without a compose project label are never listed — a network some
|
|
24
|
-
* other tool made is not Harbour's to touch.
|
|
25
|
-
*/
|
|
26
|
-
export async function listHarbourProjects(run) {
|
|
27
|
-
const networks = await run("docker", ["network", "ls", "--filter", "label=com.docker.compose.project", "--format", "{{.Name}}\t{{.Labels}}"], { quiet: true });
|
|
28
|
-
if (networks.code !== 0)
|
|
29
|
-
return [];
|
|
30
|
-
const roots = await projectRoots(run);
|
|
31
|
-
const projects = [];
|
|
32
|
-
for (const line of networks.stdout.split("\n")) {
|
|
33
|
-
const [network, labels = ""] = line.trim().split("\t");
|
|
34
|
-
if (!network)
|
|
35
|
-
continue;
|
|
36
|
-
const project = labels.split(",").map(pair => pair.split("=")).find(([key]) => key === "com.docker.compose.project")?.[1];
|
|
37
|
-
if (!project)
|
|
38
|
-
continue;
|
|
39
|
-
const harbour = labels.split(",").some(pair => pair === `${HARBOUR_NETWORK_LABEL}=1`) || HARBOUR_PROJECT.test(project);
|
|
40
|
-
if (!harbour)
|
|
41
|
-
continue;
|
|
42
|
-
const containers = await run("docker", ["ps", "-a", "--filter", `label=com.docker.compose.project=${project}`, "--format", "{{.State}}"], { quiet: true });
|
|
43
|
-
const states = containers.code === 0 ? containers.stdout.split("\n").map(state => state.trim()).filter(Boolean) : [];
|
|
44
|
-
const running = states.filter(state => state === "running" || state === "restarting" || state === "paused").length;
|
|
45
|
-
projects.push({ project, network, root: roots.get(project), running, total: states.length });
|
|
46
|
-
}
|
|
47
|
-
return projects;
|
|
48
|
-
}
|
|
49
|
-
/** Project name -> app root, from the compose files compose still remembers (`<root>/.harbour/local/<compose>.yml`). */
|
|
50
|
-
async function projectRoots(run) {
|
|
51
|
-
const roots = new Map();
|
|
52
|
-
const result = await run("docker", ["compose", "ls", "-a", "--format", "json"], { quiet: true });
|
|
53
|
-
if (result.code !== 0)
|
|
54
|
-
return roots;
|
|
55
|
-
let entries = [];
|
|
56
|
-
try {
|
|
57
|
-
entries = JSON.parse(result.stdout || "[]");
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
return roots;
|
|
61
|
-
}
|
|
62
|
-
for (const entry of entries) {
|
|
63
|
-
const file = entry.ConfigFiles?.split(",")[0]?.trim();
|
|
64
|
-
if (entry.Name && file && /[\\/]\.harbour[\\/]local[\\/][^\\/]+\.yml$/.test(file))
|
|
65
|
-
roots.set(entry.Name, dirname(dirname(dirname(file))));
|
|
66
|
-
}
|
|
67
|
-
return roots;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Removes the containers and network of one Harbour project, keeping its
|
|
71
|
-
* named volumes (the app's local data) unless `volumes` is set. `compose down`
|
|
72
|
-
* needs no compose file for this — it works from the labels — and the network
|
|
73
|
-
* is removed by name afterwards in case compose found no container to anchor on.
|
|
74
|
-
*/
|
|
75
|
-
export async function downHarbourProject(run, project, volumes = false) {
|
|
76
|
-
const result = await run("docker", ["compose", "-p", project.project, "down", "--remove-orphans", ...(volumes ? ["-v"] : [])], { quiet: true });
|
|
77
|
-
const network = await run("docker", ["network", "rm", project.network], { quiet: true });
|
|
78
|
-
return result.code === 0 || network.code === 0;
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Self-heal: brings down every Harbour project other than `keep` whose
|
|
82
|
-
* containers are all stopped (or gone), which frees their networks. A project
|
|
83
|
-
* with a running container is another app someone is using — it is left alone
|
|
84
|
-
* and named in the returned `running` list. Never touches non-Harbour networks.
|
|
85
|
-
*/
|
|
86
|
-
export async function reclaimStaleHarbourProjects(run, keep, output) {
|
|
87
|
-
const reclaimed = [];
|
|
88
|
-
const running = [];
|
|
89
|
-
for (const project of await listHarbourProjects(run)) {
|
|
90
|
-
if (project.project === keep)
|
|
91
|
-
continue;
|
|
92
|
-
if (project.running > 0) {
|
|
93
|
-
running.push(project);
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
if (await downHarbourProject(run, project))
|
|
97
|
-
reclaimed.push(project);
|
|
98
|
-
}
|
|
99
|
-
if (reclaimed.length)
|
|
100
|
-
output?.(`Docker's network address pool was full; removed the stopped local services of ${reclaimed.length} other Harbour app${reclaimed.length === 1 ? "" : "s"} (their data volumes are kept) and retrying.`);
|
|
101
|
-
return { reclaimed, running };
|
|
102
|
-
}
|
|
103
|
-
/** The one sentence for a pool that is still full after the self-heal: the two remedies only the person can apply. */
|
|
104
|
-
export function poolExhaustedMessage(running) {
|
|
105
|
-
const stops = running.map(project => `\`harbour stop --app-root ${project.root ?? `<app of compose project ${project.project}>`}\``);
|
|
106
|
-
const others = stops.length ? `stop the ${stops.length === 1 ? "other Harbour app that is" : `${stops.length} other Harbour apps that are`} still running (${stops.join(", ")}) or ` : "";
|
|
107
|
-
return `Docker has no network address left for this app's local services (every subnet in its default address pool is taken, none by a stopped Harbour app): ${others}free the pool with \`docker network prune\` (removes networks no container uses) or a wider \`default-address-pools\` in Docker's daemon settings, then rerun.`;
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* `harbour stop --all`: brings down every Harbour project on this machine whose
|
|
111
|
-
* containers are all stopped (data volumes kept); with `force`, the running
|
|
112
|
-
* ones too. The complete remedy for a full pool when no single app is at fault.
|
|
113
|
-
*/
|
|
114
|
-
export async function stopAllHarbourProjects(run, options = {}) {
|
|
115
|
-
const stopped = [];
|
|
116
|
-
const running = [];
|
|
117
|
-
for (const project of await listHarbourProjects(run)) {
|
|
118
|
-
if (project.running > 0 && !options.force) {
|
|
119
|
-
running.push(project);
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
if (await downHarbourProject(run, project))
|
|
123
|
-
stopped.push(project);
|
|
124
|
-
}
|
|
125
|
-
return { stopped, running };
|
|
126
|
-
}
|