@ree-auz/nook 0.1.0 → 0.1.2

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.
Files changed (42) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +422 -410
  3. package/bin/nk.mjs +3 -3
  4. package/docs/setup-hooks.md +107 -107
  5. package/integrations/linear/README.md +94 -84
  6. package/integrations/linear/coding-tools.json +7 -7
  7. package/integrations/linear/open-issue.mjs +193 -147
  8. package/package.json +34 -34
  9. package/skills/nook/SKILL.md +71 -71
  10. package/src/agents.mjs +92 -92
  11. package/src/backup.mjs +74 -74
  12. package/src/ci.mjs +378 -378
  13. package/src/cli.mjs +151 -151
  14. package/src/commands/check-hooks.mjs +128 -128
  15. package/src/commands/clean.mjs +175 -175
  16. package/src/commands/doctor.mjs +100 -91
  17. package/src/commands/forget.mjs +57 -57
  18. package/src/commands/hooks.mjs +34 -27
  19. package/src/commands/init.mjs +154 -154
  20. package/src/commands/ls.mjs +107 -107
  21. package/src/commands/rm.mjs +190 -190
  22. package/src/commands/workspace.mjs +163 -163
  23. package/src/commands/write-hooks.mjs +185 -185
  24. package/src/compose-plan.mjs +228 -228
  25. package/src/compose.mjs +123 -123
  26. package/src/config.mjs +115 -115
  27. package/src/context.mjs +189 -189
  28. package/src/errors.mjs +8 -8
  29. package/src/forge.mjs +75 -75
  30. package/src/generate-hooks.mjs +367 -367
  31. package/src/git.mjs +86 -86
  32. package/src/home.mjs +48 -48
  33. package/src/hooks.mjs +135 -135
  34. package/src/names.mjs +65 -65
  35. package/src/occupancy-windows.ps1 +59 -59
  36. package/src/occupancy.mjs +131 -131
  37. package/src/ports.mjs +89 -45
  38. package/src/prompt.mjs +100 -100
  39. package/src/shared.mjs +97 -97
  40. package/src/status.mjs +30 -30
  41. package/src/tools.mjs +23 -23
  42. package/src/ui.mjs +60 -60
@@ -1,228 +1,228 @@
1
- import { readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { parse } from "yaml";
4
- import { exists } from "./home.mjs";
5
-
6
- /**
7
- * Reads the repository's Docker Compose file for what a workspace needs from
8
- * it: the services it uses as they are (a database, a queue), and the
9
- * variables that choose host ports. Services the repository builds itself are
10
- * the app; a workspace starts those when the developer wants.
11
- */
12
-
13
- /** The files Compose itself looks for, in its order. */
14
- export const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"];
15
-
16
- export function composeFile(repoPath) {
17
- return COMPOSE_FILES.find((file) => exists(join(repoPath, file))) ?? null;
18
- }
19
-
20
- /** The override file Compose merges in by default: compose.override.yaml next to compose.yaml, and so on. */
21
- export function overrideFile(repoPath, file = composeFile(repoPath)) {
22
- if (!file) return null;
23
- const stem = file.replace(/\.ya?ml$/, "");
24
- const override = [`${stem}.override.yaml`, `${stem}.override.yml`].find((name) => exists(join(repoPath, name)));
25
- if (override) return override;
26
- // A base file that cannot run alone (a service with neither image nor build, as coolify's) is meant to be
27
- // combined with its development file: docker compose -f docker-compose.yml -f docker-compose.dev.yml.
28
- let services = {};
29
- try {
30
- services = parse(readFileSync(join(repoPath, file), "utf8"))?.services ?? {};
31
- } catch {}
32
- if (!Object.values(services).some((service) => service && typeof service === "object" && !service.image && !service.build && !service.extends)) return null;
33
- return [".dev.yaml", ".dev.yml", ".development.yaml", ".development.yml"].map((end) => stem + end).find((name) => exists(join(repoPath, name))) ?? null;
34
- }
35
-
36
- /**
37
- * Networks and volumes the files give a fixed name (networks: { coolify: { name: coolify } }): every
38
- * workspace and the main checkout would share them, data too. external ones are shared on purpose.
39
- */
40
- function fixedNames(repoPath, files) {
41
- const found = { networks: [], volumes: [] };
42
- for (const file of files)
43
- for (const kind of ["networks", "volumes"])
44
- for (const [key, value] of Object.entries(parse(readFileSync(join(repoPath, file), "utf8"))?.[kind] ?? {}))
45
- if (value?.name && !value.external && !found[kind].includes(key)) found[kind].push(key);
46
- return found;
47
- }
48
-
49
- /** Both files, as `docker compose` reads them with no -f: ports add up, other keys are replaced. */
50
- function readServices(repoPath, file, override) {
51
- const services = { ...(parse(readFileSync(join(repoPath, file), "utf8"))?.services ?? {}) };
52
- if (override)
53
- for (const [name, extra] of Object.entries(parse(readFileSync(join(repoPath, override), "utf8"))?.services ?? {})) {
54
- const base = services[name] ?? {};
55
- services[name] = { ...base, ...extra, ports: [...(base.ports ?? []), ...(extra?.ports ?? [])] };
56
- }
57
- return services;
58
- }
59
-
60
- /** "${PORT:-3001}" → { name: "PORT", fallback: "3001" }; a literal port → null. */
61
- function portVariable(text) {
62
- const match = /^\$\{(\w+)(?::?-([^}]*))?\}$/.exec(text.trim());
63
- return match ? { name: match[1], fallback: match[2] ?? "" } : null;
64
- }
65
-
66
- /**
67
- * A ports entry as { host, target }: "127.0.0.1:${P:-80}:80" → { host: "${P:-80}", target: "80" },
68
- * "5432:5432/tcp" → { host: "5432", target: "5432/tcp" }. host is null when Compose picks one.
69
- */
70
- function portEntry(entry) {
71
- if (typeof entry === "object" && entry) {
72
- const target = `${entry.target}${entry.protocol && entry.protocol !== "tcp" ? `/${entry.protocol}` : ""}`;
73
- return { host: entry.published === undefined ? null : String(entry.published), target };
74
- }
75
- // Split on ":" outside ${...}.
76
- const parts = [];
77
- let depth = 0;
78
- let current = "";
79
- for (const char of String(entry)) {
80
- if (char === "{") depth++;
81
- if (char === "}") depth--;
82
- if (char === ":" && depth === 0) {
83
- parts.push(current);
84
- current = "";
85
- } else current += char;
86
- }
87
- parts.push(current);
88
- return { host: parts.length >= 2 ? parts.at(-2) : null, target: parts.at(-1) };
89
- }
90
-
91
- /**
92
- * Images a project uses as they are, beside its own code: databases, caches,
93
- * queues, search, object storage, mail catchers, cloud emulators. An image the
94
- * project publishes itself (its app) is not one of them.
95
- */
96
- const BACKING = /(^|[/:_-])(postgres|postgis|pgvector|timescale(db)?|mysql|mariadb|percona|mongo(db)?|redis|valkey|keydb|dragonfly(db)?|memcached|rabbitmq|kafka|redpanda|zookeeper|nats|elasticsearch|opensearch|meilisearch|typesense|minio|clickhouse(-server)?|cassandra|scylla|neo4j|couchdb|cockroach(db)?|mssql(-server)?|azure-sql-edge|localstack|dynamodb-local|azurite|mailpit|mailhog|maildev|smtp4dev|qdrant|weaviate|milvus|chroma|influxdb|questdb|surrealdb|etcd|pubsub-emulator|firebase-emulator|spanner-emulator|garage|seaweedfs|valkey-bundle)($|[/:._@-]|\d)/i;
97
-
98
- /** Variables a service needs set: ${NAME:?message} or ${NAME?message}. */
99
- function requiredVariables(service) {
100
- return [...JSON.stringify(service).matchAll(/\$\{(\w+):?\?/g)].map((match) => match[1]);
101
- }
102
-
103
- /**
104
- * { file, files, start, ports, remap, required, notStarted, untranslated } for the
105
- * repository's Compose file and its override file, or null without one.
106
- */
107
- const PORTS = 10;
108
-
109
- /**
110
- * The repository paths a service mounts (./vendor/x.sh:/docker-entrypoint-initdb.d/x.sh). When one is
111
- * missing, docker creates it as a root-owned folder, so the create hook waits for it instead.
112
- */
113
- function bindMounts(service) {
114
- const paths = [];
115
- for (const volume of service.volumes ?? []) {
116
- const source = typeof volume === "string" ? volume.split(":")[0] : volume?.type === "bind" ? volume.source : null;
117
- if (typeof source === "string" && /^\.\.?\//.test(source) && !source.includes("$")) paths.push(source);
118
- }
119
- return paths;
120
- }
121
-
122
- /** startAny false: start nothing (CI has its own services). reserved: ports CI's services take first. */
123
- export function planCompose(repoPath, { start: startAny = true, reserved = 0 } = {}) {
124
- const file = composeFile(repoPath);
125
- if (!file) return null;
126
- const untranslated = [];
127
- const override = overrideFile(repoPath, file);
128
- const where = override ? `${file} + ${override}` : file;
129
- let services;
130
- try {
131
- services = readServices(repoPath, file, override);
132
- } catch (error) {
133
- untranslated.push({ step: where, why: `could not be read (${error.message.split("\n")[0]})` });
134
- return { file: where, files: [file, override].filter(Boolean), start: [], ports: {}, remap: {}, required: [], notStarted: [], untranslated };
135
- }
136
- const ports = {};
137
- const remap = {};
138
- const start = [];
139
- const notStarted = [];
140
- const required = new Set();
141
- const mounts = {};
142
- const named = [];
143
- const ownNetwork = [];
144
- for (const [name, service] of Object.entries(services)) {
145
- if (!service || typeof service !== "object") continue;
146
- // A fixed container_name (plane-redis) would clash between workspaces, even for a service you start yourself: each gets its own.
147
- if (service.container_name) named.push(name);
148
- const fixed = [];
149
- // On the host's network a service listens on the host's own ports, the main checkout's too (forem's
150
- // postgres and redis): a workspace runs it on its project network instead, its ports moved to its own.
151
- const hostNetwork = service.network_mode === "host";
152
- for (const entry of service.ports ?? []) {
153
- const { host, target } = portEntry(entry);
154
- if (hostNetwork && (host === null || host === "")) fixed.push({ host: target.split("/")[0], target });
155
- if (host === null || host === "") continue;
156
- const variable = portVariable(host);
157
- if (variable) ports[variable.name] ??= variable.fallback;
158
- else fixed.push({ host, target });
159
- }
160
- if (service.build) notStarted.push({ name, why: "built from this repository" });
161
- else if (service.profiles?.length) notStarted.push({ name, why: `only with the profile ${service.profiles.join(", ")}` });
162
- else if (!service.image) notStarted.push({ name, why: "no image" });
163
- else if (!BACKING.test(service.image)) notStarted.push({ name, why: `${service.image} is not a database or similar service` });
164
- else if (!startAny) notStarted.push({ name, why: "CI starts its own services" });
165
- else if (hostNetwork && !fixed.length) notStarted.push({ name, why: "uses the host's network and names no ports, so it would share the main checkout's" });
166
- else {
167
- start.push(name);
168
- for (const variable of requiredVariables(service)) required.add(variable);
169
- const files = bindMounts(service);
170
- if (files.length) mounts[name] = files;
171
- // A fixed host port (5432:5432) would be the same for every workspace, and the main checkout may hold
172
- // it: each workspace moves it to one of its own ports.
173
- if (fixed.length) remap[name] = fixed;
174
- if (hostNetwork) ownNetwork.push(name);
175
- }
176
- }
177
- // A workspace has ten ports: services that do not fit are left out, last first.
178
- const used = (extra = 0) => Object.keys(ports).length + Object.values(remap).flat().length + extra;
179
- while (start.length && used() > PORTS - reserved) {
180
- const name = start.pop();
181
- delete remap[name];
182
- if (ownNetwork.includes(name)) ownNetwork.splice(ownNetwork.indexOf(name), 1);
183
- notStarted.push({ name, why: "no port left of the workspace's ten" });
184
- untranslated.push({ step: `${where} › ${name}`, why: "not started: the workspace's ten ports are taken by the other services" });
185
- }
186
- return { file: where, files: [file, override].filter(Boolean), start, ports, remap, required: [...required], mounts, named, ownNetwork, fixed: fixedNames(repoPath, [file, override].filter(Boolean)), notStarted, untranslated };
187
- }
188
-
189
- /**
190
- * Where the Compose services a workspace starts are reached from this machine:
191
- * { "postgres:5432": "127.0.0.1:5432" }. A template written for apps running
192
- * inside Docker (DATABASE_URL=...@postgres:5432) is rewritten with it, since
193
- * here the app runs on the machine; each workspace then moves the port to its own.
194
- * start false: CI's own services are started instead, so there is nothing to point at.
195
- */
196
- export function hostAddresses(repoPath, { start = true } = {}) {
197
- const plan = planCompose(repoPath, { start });
198
- if (!plan?.start.length) return {};
199
- const services = readServices(repoPath, plan.files[0], plan.files[1]);
200
- const addresses = {};
201
- for (const name of plan.start)
202
- for (const entry of services[name]?.ports ?? []) {
203
- const { host, target } = portEntry(entry);
204
- if (!host) continue;
205
- const variable = portVariable(host);
206
- const port = variable ? variable.fallback : host;
207
- if (/^\d+$/.test(port)) addresses[`${name}:${target.split("/")[0]}`] = `127.0.0.1:${port}`;
208
- }
209
- return addresses;
210
- }
211
-
212
- /** Rewrites service addresses in an env file's values; returns { text, keys } with the keys it changed. */
213
- export function pointAtHost(text, addresses) {
214
- const keys = [];
215
- const lines = text.split(/(\r?\n)/).map((line) => {
216
- const match = /^(\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=)(.*)$/.exec(line);
217
- if (!match) return line;
218
- let value = match[3];
219
- for (const [from, to] of Object.entries(addresses)) {
220
- const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
221
- value = value.replace(new RegExp(`(^|[\\s"'=/@,])${escaped}(?!\\d)`, "g"), `$1${to}`);
222
- }
223
- if (value === match[3]) return line;
224
- keys.push(match[2]);
225
- return match[1] + value;
226
- });
227
- return { text: lines.join(""), keys };
228
- }
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { exists } from "./home.mjs";
5
+
6
+ /**
7
+ * Reads the repository's Docker Compose file for what a workspace needs from
8
+ * it: the services it uses as they are (a database, a queue), and the
9
+ * variables that choose host ports. Services the repository builds itself are
10
+ * the app; a workspace starts those when the developer wants.
11
+ */
12
+
13
+ /** The files Compose itself looks for, in its order. */
14
+ export const COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"];
15
+
16
+ export function composeFile(repoPath) {
17
+ return COMPOSE_FILES.find((file) => exists(join(repoPath, file))) ?? null;
18
+ }
19
+
20
+ /** The override file Compose merges in by default: compose.override.yaml next to compose.yaml, and so on. */
21
+ export function overrideFile(repoPath, file = composeFile(repoPath)) {
22
+ if (!file) return null;
23
+ const stem = file.replace(/\.ya?ml$/, "");
24
+ const override = [`${stem}.override.yaml`, `${stem}.override.yml`].find((name) => exists(join(repoPath, name)));
25
+ if (override) return override;
26
+ // A base file that cannot run alone (a service with neither image nor build, as coolify's) is meant to be
27
+ // combined with its development file: docker compose -f docker-compose.yml -f docker-compose.dev.yml.
28
+ let services = {};
29
+ try {
30
+ services = parse(readFileSync(join(repoPath, file), "utf8"))?.services ?? {};
31
+ } catch {}
32
+ if (!Object.values(services).some((service) => service && typeof service === "object" && !service.image && !service.build && !service.extends)) return null;
33
+ return [".dev.yaml", ".dev.yml", ".development.yaml", ".development.yml"].map((end) => stem + end).find((name) => exists(join(repoPath, name))) ?? null;
34
+ }
35
+
36
+ /**
37
+ * Networks and volumes the files give a fixed name (networks: { coolify: { name: coolify } }): every
38
+ * workspace and the main checkout would share them, data too. external ones are shared on purpose.
39
+ */
40
+ function fixedNames(repoPath, files) {
41
+ const found = { networks: [], volumes: [] };
42
+ for (const file of files)
43
+ for (const kind of ["networks", "volumes"])
44
+ for (const [key, value] of Object.entries(parse(readFileSync(join(repoPath, file), "utf8"))?.[kind] ?? {}))
45
+ if (value?.name && !value.external && !found[kind].includes(key)) found[kind].push(key);
46
+ return found;
47
+ }
48
+
49
+ /** Both files, as `docker compose` reads them with no -f: ports add up, other keys are replaced. */
50
+ function readServices(repoPath, file, override) {
51
+ const services = { ...(parse(readFileSync(join(repoPath, file), "utf8"))?.services ?? {}) };
52
+ if (override)
53
+ for (const [name, extra] of Object.entries(parse(readFileSync(join(repoPath, override), "utf8"))?.services ?? {})) {
54
+ const base = services[name] ?? {};
55
+ services[name] = { ...base, ...extra, ports: [...(base.ports ?? []), ...(extra?.ports ?? [])] };
56
+ }
57
+ return services;
58
+ }
59
+
60
+ /** "${PORT:-3001}" → { name: "PORT", fallback: "3001" }; a literal port → null. */
61
+ function portVariable(text) {
62
+ const match = /^\$\{(\w+)(?::?-([^}]*))?\}$/.exec(text.trim());
63
+ return match ? { name: match[1], fallback: match[2] ?? "" } : null;
64
+ }
65
+
66
+ /**
67
+ * A ports entry as { host, target }: "127.0.0.1:${P:-80}:80" → { host: "${P:-80}", target: "80" },
68
+ * "5432:5432/tcp" → { host: "5432", target: "5432/tcp" }. host is null when Compose picks one.
69
+ */
70
+ function portEntry(entry) {
71
+ if (typeof entry === "object" && entry) {
72
+ const target = `${entry.target}${entry.protocol && entry.protocol !== "tcp" ? `/${entry.protocol}` : ""}`;
73
+ return { host: entry.published === undefined ? null : String(entry.published), target };
74
+ }
75
+ // Split on ":" outside ${...}.
76
+ const parts = [];
77
+ let depth = 0;
78
+ let current = "";
79
+ for (const char of String(entry)) {
80
+ if (char === "{") depth++;
81
+ if (char === "}") depth--;
82
+ if (char === ":" && depth === 0) {
83
+ parts.push(current);
84
+ current = "";
85
+ } else current += char;
86
+ }
87
+ parts.push(current);
88
+ return { host: parts.length >= 2 ? parts.at(-2) : null, target: parts.at(-1) };
89
+ }
90
+
91
+ /**
92
+ * Images a project uses as they are, beside its own code: databases, caches,
93
+ * queues, search, object storage, mail catchers, cloud emulators. An image the
94
+ * project publishes itself (its app) is not one of them.
95
+ */
96
+ const BACKING = /(^|[/:_-])(postgres|postgis|pgvector|timescale(db)?|mysql|mariadb|percona|mongo(db)?|redis|valkey|keydb|dragonfly(db)?|memcached|rabbitmq|kafka|redpanda|zookeeper|nats|elasticsearch|opensearch|meilisearch|typesense|minio|clickhouse(-server)?|cassandra|scylla|neo4j|couchdb|cockroach(db)?|mssql(-server)?|azure-sql-edge|localstack|dynamodb-local|azurite|mailpit|mailhog|maildev|smtp4dev|qdrant|weaviate|milvus|chroma|influxdb|questdb|surrealdb|etcd|pubsub-emulator|firebase-emulator|spanner-emulator|garage|seaweedfs|valkey-bundle)($|[/:._@-]|\d)/i;
97
+
98
+ /** Variables a service needs set: ${NAME:?message} or ${NAME?message}. */
99
+ function requiredVariables(service) {
100
+ return [...JSON.stringify(service).matchAll(/\$\{(\w+):?\?/g)].map((match) => match[1]);
101
+ }
102
+
103
+ /**
104
+ * { file, files, start, ports, remap, required, notStarted, untranslated } for the
105
+ * repository's Compose file and its override file, or null without one.
106
+ */
107
+ const PORTS = 10;
108
+
109
+ /**
110
+ * The repository paths a service mounts (./vendor/x.sh:/docker-entrypoint-initdb.d/x.sh). When one is
111
+ * missing, docker creates it as a root-owned folder, so the create hook waits for it instead.
112
+ */
113
+ function bindMounts(service) {
114
+ const paths = [];
115
+ for (const volume of service.volumes ?? []) {
116
+ const source = typeof volume === "string" ? volume.split(":")[0] : volume?.type === "bind" ? volume.source : null;
117
+ if (typeof source === "string" && /^\.\.?\//.test(source) && !source.includes("$")) paths.push(source);
118
+ }
119
+ return paths;
120
+ }
121
+
122
+ /** startAny false: start nothing (CI has its own services). reserved: ports CI's services take first. */
123
+ export function planCompose(repoPath, { start: startAny = true, reserved = 0 } = {}) {
124
+ const file = composeFile(repoPath);
125
+ if (!file) return null;
126
+ const untranslated = [];
127
+ const override = overrideFile(repoPath, file);
128
+ const where = override ? `${file} + ${override}` : file;
129
+ let services;
130
+ try {
131
+ services = readServices(repoPath, file, override);
132
+ } catch (error) {
133
+ untranslated.push({ step: where, why: `could not be read (${error.message.split("\n")[0]})` });
134
+ return { file: where, files: [file, override].filter(Boolean), start: [], ports: {}, remap: {}, required: [], notStarted: [], untranslated };
135
+ }
136
+ const ports = {};
137
+ const remap = {};
138
+ const start = [];
139
+ const notStarted = [];
140
+ const required = new Set();
141
+ const mounts = {};
142
+ const named = [];
143
+ const ownNetwork = [];
144
+ for (const [name, service] of Object.entries(services)) {
145
+ if (!service || typeof service !== "object") continue;
146
+ // A fixed container_name (plane-redis) would clash between workspaces, even for a service you start yourself: each gets its own.
147
+ if (service.container_name) named.push(name);
148
+ const fixed = [];
149
+ // On the host's network a service listens on the host's own ports, the main checkout's too (forem's
150
+ // postgres and redis): a workspace runs it on its project network instead, its ports moved to its own.
151
+ const hostNetwork = service.network_mode === "host";
152
+ for (const entry of service.ports ?? []) {
153
+ const { host, target } = portEntry(entry);
154
+ if (hostNetwork && (host === null || host === "")) fixed.push({ host: target.split("/")[0], target });
155
+ if (host === null || host === "") continue;
156
+ const variable = portVariable(host);
157
+ if (variable) ports[variable.name] ??= variable.fallback;
158
+ else fixed.push({ host, target });
159
+ }
160
+ if (service.build) notStarted.push({ name, why: "built from this repository" });
161
+ else if (service.profiles?.length) notStarted.push({ name, why: `only with the profile ${service.profiles.join(", ")}` });
162
+ else if (!service.image) notStarted.push({ name, why: "no image" });
163
+ else if (!BACKING.test(service.image)) notStarted.push({ name, why: `${service.image} is not a database or similar service` });
164
+ else if (!startAny) notStarted.push({ name, why: "CI starts its own services" });
165
+ else if (hostNetwork && !fixed.length) notStarted.push({ name, why: "uses the host's network and names no ports, so it would share the main checkout's" });
166
+ else {
167
+ start.push(name);
168
+ for (const variable of requiredVariables(service)) required.add(variable);
169
+ const files = bindMounts(service);
170
+ if (files.length) mounts[name] = files;
171
+ // A fixed host port (5432:5432) would be the same for every workspace, and the main checkout may hold
172
+ // it: each workspace moves it to one of its own ports.
173
+ if (fixed.length) remap[name] = fixed;
174
+ if (hostNetwork) ownNetwork.push(name);
175
+ }
176
+ }
177
+ // A workspace has ten ports: services that do not fit are left out, last first.
178
+ const used = (extra = 0) => Object.keys(ports).length + Object.values(remap).flat().length + extra;
179
+ while (start.length && used() > PORTS - reserved) {
180
+ const name = start.pop();
181
+ delete remap[name];
182
+ if (ownNetwork.includes(name)) ownNetwork.splice(ownNetwork.indexOf(name), 1);
183
+ notStarted.push({ name, why: "no port left of the workspace's ten" });
184
+ untranslated.push({ step: `${where} › ${name}`, why: "not started: the workspace's ten ports are taken by the other services" });
185
+ }
186
+ return { file: where, files: [file, override].filter(Boolean), start, ports, remap, required: [...required], mounts, named, ownNetwork, fixed: fixedNames(repoPath, [file, override].filter(Boolean)), notStarted, untranslated };
187
+ }
188
+
189
+ /**
190
+ * Where the Compose services a workspace starts are reached from this machine:
191
+ * { "postgres:5432": "127.0.0.1:5432" }. A template written for apps running
192
+ * inside Docker (DATABASE_URL=...@postgres:5432) is rewritten with it, since
193
+ * here the app runs on the machine; each workspace then moves the port to its own.
194
+ * start false: CI's own services are started instead, so there is nothing to point at.
195
+ */
196
+ export function hostAddresses(repoPath, { start = true } = {}) {
197
+ const plan = planCompose(repoPath, { start });
198
+ if (!plan?.start.length) return {};
199
+ const services = readServices(repoPath, plan.files[0], plan.files[1]);
200
+ const addresses = {};
201
+ for (const name of plan.start)
202
+ for (const entry of services[name]?.ports ?? []) {
203
+ const { host, target } = portEntry(entry);
204
+ if (!host) continue;
205
+ const variable = portVariable(host);
206
+ const port = variable ? variable.fallback : host;
207
+ if (/^\d+$/.test(port)) addresses[`${name}:${target.split("/")[0]}`] = `127.0.0.1:${port}`;
208
+ }
209
+ return addresses;
210
+ }
211
+
212
+ /** Rewrites service addresses in an env file's values; returns { text, keys } with the keys it changed. */
213
+ export function pointAtHost(text, addresses) {
214
+ const keys = [];
215
+ const lines = text.split(/(\r?\n)/).map((line) => {
216
+ const match = /^(\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=)(.*)$/.exec(line);
217
+ if (!match) return line;
218
+ let value = match[3];
219
+ for (const [from, to] of Object.entries(addresses)) {
220
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
221
+ value = value.replace(new RegExp(`(^|[\\s"'=/@,])${escaped}(?!\\d)`, "g"), `$1${to}`);
222
+ }
223
+ if (value === match[3]) return line;
224
+ keys.push(match[2]);
225
+ return match[1] + value;
226
+ });
227
+ return { text: lines.join(""), keys };
228
+ }