@monster-orange/den 0.2.0 → 0.2.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.
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { hasRuntime, repoKind } from "../repo-kind.js";
10
10
  import { effectiveLocalCommands } from "../stacks/manifest-commands.js";
11
+ import { manifestStackName } from "../stacks/config.js";
11
12
  import fs from "fs";
12
13
  import path from "path";
13
14
  import { readManifest } from "../manifest/read-manifest.js";
@@ -40,6 +41,7 @@ import {
40
41
  getActiveWorkspaces,
41
42
  getFocusedWorkspaceName,
42
43
  getWorkspaceByName,
44
+ parseAddress,
43
45
  resolveServiceSource,
44
46
  listWorkspaceServices,
45
47
  } from "./workspaces.js";
@@ -49,7 +51,12 @@ import {
49
51
  type ServiceHealthResult,
50
52
  } from "./health.js";
51
53
  import { EventEmitter } from "events";
52
- import type { CommandDef, ResolvedService } from "../types/index.js";
54
+ import type {
55
+ CommandDef,
56
+ ResolvedService,
57
+ ServiceCatalog,
58
+ WorkspaceConfig,
59
+ } from "../types/index.js";
53
60
  import type { ChildProcess } from "child_process";
54
61
  import { getStateDir } from "../paths.js";
55
62
 
@@ -658,7 +665,95 @@ export function resolveService(
658
665
  : getActiveWorkspace();
659
666
  if (!ws) return null;
660
667
  const catalog = readCatalog();
668
+ const listed = resolveListedService(address, ws, catalog);
669
+ if (listed || address.includes("/")) return listed;
670
+ // Not a repo or a listed address: a service key or a stack name, when it
671
+ // names exactly one service in the workspace.
672
+ const candidates = aliasCandidates(address, ws, catalog);
673
+ return candidates.length === 1
674
+ ? resolveListedService(candidates[0], ws, catalog)
675
+ : null;
676
+ }
677
+
678
+ type NamedWorkspace = { name: string; config: WorkspaceConfig };
679
+
680
+ /**
681
+ * The addresses `alias` could mean in `ws`: every service whose manifest key
682
+ * is `alias`, and the repo of every compose stack named `alias` (a stack
683
+ * stands for its repo; when that repo also has a service named `alias`, the
684
+ * service is the one match). Sorted; empty for a `repo/service` address and
685
+ * for a catalog repo's name.
686
+ */
687
+ function aliasCandidates(
688
+ alias: string,
689
+ ws: NamedWorkspace,
690
+ catalog: ServiceCatalog,
691
+ ): string[] {
692
+ // A repo name is never an alias, in the workspace or not.
693
+ if (alias.includes("/") || catalog[alias]) return [];
694
+ const services = new Set<string>();
695
+ const stackRepos = new Set<string>();
696
+ for (const s of listWorkspaceServices(ws.config, catalog, ws.name)) {
697
+ if (s.subsystem === alias) services.add(s.address);
698
+ const manifest = readManifest(s.repoPath);
699
+ if (manifest?.compose && manifestStackName(manifest, s.repoName) === alias)
700
+ stackRepos.add(s.repoName);
701
+ }
702
+ const withService = new Set(
703
+ [...services].map((a) => parseAddress(a, catalog).repoName),
704
+ );
705
+ return [
706
+ ...services,
707
+ ...[...stackRepos].filter((r) => !withService.has(r)),
708
+ ].sort();
709
+ }
661
710
 
711
+ function namedWorkspace(workspaceName?: string): NamedWorkspace | null {
712
+ return workspaceName
713
+ ? getWorkspaceByName(workspaceName)
714
+ : getActiveWorkspace();
715
+ }
716
+
717
+ /**
718
+ * What `alias` could address in the workspace (the focused one by default)
719
+ * when it is not a repo or a listed address: see resolveService.
720
+ */
721
+ export function serviceAliasCandidates(
722
+ alias: string,
723
+ workspaceName?: string,
724
+ ): string[] {
725
+ const ws = namedWorkspace(workspaceName);
726
+ if (!ws) return [];
727
+ const catalog = readCatalog();
728
+ if (resolveListedService(alias, ws, catalog)) return [];
729
+ return aliasCandidates(alias, ws, catalog);
730
+ }
731
+
732
+ /**
733
+ * The address to use for what someone typed: an unambiguous alias becomes
734
+ * the service's own address; anything else is returned as is. An ambiguous
735
+ * alias throws, naming the candidates.
736
+ */
737
+ export function canonicalServiceAddress(
738
+ address: string,
739
+ workspaceName?: string,
740
+ ): string {
741
+ const candidates = serviceAliasCandidates(address, workspaceName);
742
+ if (candidates.length > 1) {
743
+ const ws = namedWorkspace(workspaceName)?.name ?? "default";
744
+ throw new Error(
745
+ `"${address}" is ambiguous in workspace "${ws}": ${candidates.join(", ")}; name one of them`,
746
+ );
747
+ }
748
+ return candidates[0] ?? address;
749
+ }
750
+
751
+ /** resolveService for a repo name or a listed address (no aliases). */
752
+ function resolveListedService(
753
+ address: string,
754
+ ws: NamedWorkspace,
755
+ catalog: ServiceCatalog,
756
+ ): ResolvedService | null {
662
757
  let resolved = resolveServiceSource(address, ws.name, ws.config, catalog);
663
758
 
664
759
  // Bare multi-service repo registered ONLY by subsystem (e.g. "shop/db" +
@@ -998,6 +1093,109 @@ export interface RunCommandResult {
998
1093
  };
999
1094
  }
1000
1095
 
1096
+ /**
1097
+ * A service with no commands of its own is started by its repo's up
1098
+ * (placement's repo-level rule). For `up` that is the repo's up (`address`
1099
+ * set); any other command would reach the repo's other services too, so it
1100
+ * points at the repo instead: a refusal, except that down succeeds (nothing
1101
+ * of the service's own runs, and a workspace Down sends every service).
1102
+ * Null when the rule does not apply.
1103
+ */
1104
+ function repoStartedFallback(
1105
+ resolved: ResolvedService,
1106
+ command: string,
1107
+ workspace: string | undefined,
1108
+ ): { address?: string; note: string } | null {
1109
+ const repoEntry = resolveService(resolved.repoName, workspace);
1110
+ if (!repoEntry || repoEntry.subsystem === resolved.subsystem) return null;
1111
+ const manifest = readManifest(resolved.repoPath);
1112
+ if (!manifest) return null;
1113
+ const repoCommands = effectiveLocalCommands(
1114
+ manifest,
1115
+ repoEntry.subsystem,
1116
+ resolved.repoName,
1117
+ workspace ?? "default",
1118
+ );
1119
+ if (!repoCommands["up"]) return null;
1120
+ if (command === "up")
1121
+ return {
1122
+ address: resolved.repoName,
1123
+ note: `${resolved.address} has no up of its own; running ${resolved.repoName}'s up, which starts it`,
1124
+ };
1125
+ return command === "down"
1126
+ ? {
1127
+ note:
1128
+ `${resolved.address} has no down of its own; it is stopped by ${resolved.repoName}'s down. ` +
1129
+ `Run \`${product().cli} down ${resolved.repoName}\` (that stops every service it starts).`,
1130
+ }
1131
+ : {
1132
+ note:
1133
+ `${resolved.address} has no ${command} of its own; it is started by ${resolved.repoName}'s up. ` +
1134
+ `Run \`${product().cli} ${command} ${resolved.repoName}\` (that reaches every service it starts).`,
1135
+ };
1136
+ }
1137
+
1138
+ /** What a run of `command` on `address` would do, without running it. */
1139
+ export type ServiceCommandPreview =
1140
+ | { kind: "run"; address: string }
1141
+ | { kind: "via-repo"; address: string; note: string }
1142
+ | { kind: "nothing"; address: string; note: string }
1143
+ | { kind: "error"; address: string; note: string };
1144
+
1145
+ /**
1146
+ * The dry-run twin of runServiceCommand's command lookup: the same
1147
+ * resolution, own-command, restart-synthesis and repo-started rules, so a
1148
+ * dry run says what a real run does.
1149
+ */
1150
+ export function previewServiceCommand(
1151
+ address: string,
1152
+ command: string,
1153
+ workspace?: string,
1154
+ ): ServiceCommandPreview {
1155
+ const resolved = resolveService(address, workspace);
1156
+ if (!resolved) {
1157
+ const candidates = serviceAliasCandidates(address, workspace);
1158
+ return {
1159
+ kind: "error",
1160
+ address,
1161
+ note:
1162
+ `service "${address}" not found in workspace "${workspace ?? "default"}"` +
1163
+ (candidates.length > 1
1164
+ ? `; it is ambiguous: ${candidates.join(", ")}`
1165
+ : ""),
1166
+ };
1167
+ }
1168
+ const manifest = readManifest(resolved.repoPath);
1169
+ const own = manifest
1170
+ ? effectiveLocalCommands(
1171
+ manifest,
1172
+ resolved.subsystem,
1173
+ resolved.repoName,
1174
+ workspace ?? "default",
1175
+ )
1176
+ : {};
1177
+ if (own[command] || (command === "restart" && own["down"] && own["up"]))
1178
+ return { kind: "run", address };
1179
+ const viaRepo = repoStartedFallback(resolved, command, workspace);
1180
+ if (viaRepo?.address)
1181
+ return { kind: "via-repo", address: viaRepo.address, note: viaRepo.note };
1182
+ if (viaRepo)
1183
+ return command === "down"
1184
+ ? { kind: "nothing", address, note: viaRepo.note }
1185
+ : { kind: "error", address, note: viaRepo.note };
1186
+ if (command === "down")
1187
+ return {
1188
+ kind: "nothing",
1189
+ address,
1190
+ note: "no down command: nothing to stop",
1191
+ };
1192
+ return {
1193
+ kind: "error",
1194
+ address,
1195
+ note: `command "${command}" not found for service "${address}"`,
1196
+ };
1197
+ }
1198
+
1001
1199
  /**
1002
1200
  * Resolve and execute a service command.
1003
1201
  * Returns the spawned child process and whether it is a long-running (detachable) command.
@@ -1048,8 +1246,12 @@ export function runServiceCommand(
1048
1246
 
1049
1247
  const resolved = resolveService(address, workspace);
1050
1248
  if (!resolved) {
1249
+ const candidates = serviceAliasCandidates(address, workspace);
1051
1250
  onLine(
1052
- `Error: service "${address}" not found in catalog or workspace "${workspace}"`,
1251
+ `Error: service "${address}" not found in catalog or workspace "${workspace}"` +
1252
+ (candidates.length > 1
1253
+ ? `; it is ambiguous: ${candidates.join(", ")}`
1254
+ : ""),
1053
1255
  );
1054
1256
  closeAsync(1);
1055
1257
  return dummy();
@@ -1063,7 +1265,7 @@ export function runServiceCommand(
1063
1265
  resolved.address,
1064
1266
  servicePlacement(readManifest(repoPath), subsystem, "local"),
1065
1267
  (e) => isProductionEnv(e) === true,
1066
- command === "down" ? "stop" : "start",
1268
+ command,
1067
1269
  );
1068
1270
  if (refusal) {
1069
1271
  onLine(refusal);
@@ -1094,7 +1296,9 @@ export function runServiceCommand(
1094
1296
  workspace,
1095
1297
  ];
1096
1298
  onLine(
1097
- `already running (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`,
1299
+ command === "restart"
1300
+ ? `restarting the running instance (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`
1301
+ : `already running (shared) — owners: [${owners.join(", ")}] at ${resolved.repoPath}`,
1098
1302
  );
1099
1303
  // restart still needs to bounce; only short-circuit pure `up`.
1100
1304
  if (command === "up") {
@@ -1279,6 +1483,16 @@ export function runServiceCommand(
1279
1483
  }
1280
1484
 
1281
1485
  if (!cmdDef) {
1486
+ const viaRepo = repoStartedFallback(resolved, command, workspace);
1487
+ if (viaRepo?.address) {
1488
+ onLine(viaRepo.note);
1489
+ return runServiceCommand({ ...options, address: viaRepo.address });
1490
+ }
1491
+ if (viaRepo) {
1492
+ onLine(command === "down" ? viaRepo.note : `Error: ${viaRepo.note}`);
1493
+ closeAsync(command === "down" ? 0 : 1);
1494
+ return dummy();
1495
+ }
1282
1496
  // Down is opctl-conventional: when no container is running AND the manifest
1283
1497
  // declares no down command, there is simply nothing to stop — that's a
1284
1498
  // successful down, not an error (keeps the ⋮ menu / workspace Down safe to
@@ -34,6 +34,8 @@ export interface PlacementTarget {
34
34
  env: string;
35
35
  account?: string;
36
36
  url?: string;
37
+ /** The status codes that count as up for `url`, as declared. */
38
+ expect?: number | number[];
37
39
  }
38
40
 
39
41
  export interface Placement {
@@ -84,6 +86,7 @@ function targetFor(svc: ServiceManagementConfig, env: string): PlacementTarget {
84
86
  env: tEnv,
85
87
  ...(own?.account ? { account: own.account } : {}),
86
88
  ...(url ? { url } : {}),
89
+ ...(own?.url && own.expect !== undefined ? { expect: own.expect } : {}),
87
90
  };
88
91
  }
89
92
 
@@ -186,16 +189,25 @@ export function isProtectedRemote(p: Placement, isProduction: IsProduction) {
186
189
  return p.runs === "remote" && !!p.target && isProduction(p.target.env);
187
190
  }
188
191
 
192
+ /** How a refusal names the command: up → "start", exec → `run "exec" on`. */
193
+ function refusedVerb(command: string): string {
194
+ if (command === "up" || command === "start") return "start";
195
+ if (command === "down" || command === "stop") return "stop";
196
+ if (command === "restart") return "restart";
197
+ return `run "${command}" on`;
198
+ }
199
+
189
200
  /**
190
- * Why Den must not run a command on `address`, or null. Every command on a
191
- * remote service is refused ("runs in dev (AWS); nothing to start here").
201
+ * Why Den must not run `command` on `address`, or null. Every command on a
202
+ * remote service is refused ("runs in dev (AWS); nothing to restart here").
192
203
  */
193
204
  export function placementRefusal(
194
205
  address: string,
195
206
  p: Placement,
196
207
  isProduction: IsProduction,
197
- verb: "start" | "stop" = "start",
208
+ command = "up",
198
209
  ): string | null {
210
+ const verb = refusedVerb(command);
199
211
  if (p.runs !== "remote") return null;
200
212
  const where = p.target
201
213
  ? `${p.target.env}${p.target.account ? ` (${p.target.account})` : ""}`
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import * as fs from "fs";
18
+ import * as path from "path";
18
19
  import { z } from "zod";
19
20
  import type { ValidationError, ValidationResult } from "../types/index.js";
20
21
  import {
@@ -24,11 +25,14 @@ import {
24
25
  type ManifestMap,
25
26
  } from "./merge-override.js";
26
27
  import {
28
+ heldManifestPath,
27
29
  loadManifest,
28
30
  orphanOverrideMessage,
31
+ overrideSuffix,
29
32
  parseManifestText,
30
33
  type ManifestLayer,
31
34
  } from "./read-manifest.js";
35
+ import { product } from "../product.js";
32
36
 
33
37
  /** What layered validation needs from the schema module. */
34
38
  export interface SchemaTools {
@@ -170,6 +174,31 @@ function readLayer(file: string): { value: unknown } | { error: string } {
170
174
  }
171
175
  }
172
176
 
177
+ /**
178
+ * A warning for a held manifest the repo's own manifest has made redundant:
179
+ * the repo's file always wins, so the held one is never read.
180
+ */
181
+ function redundantHeldWarning(
182
+ heldFile: string,
183
+ overrideFile: string | null,
184
+ ): ValidationError {
185
+ const yml = product().manifestYml;
186
+ const name = path.basename(heldFile);
187
+ const key = name.endsWith(yml) ? name.slice(0, -yml.length) : name;
188
+ const asOverride = path.join(
189
+ path.dirname(heldFile),
190
+ `${key}${overrideSuffix()}`,
191
+ );
192
+ return {
193
+ path: "",
194
+ file: heldFile,
195
+ message: `${heldFile} is not used: the repo now carries its own manifest, which always wins`,
196
+ suggestion: overrideFile
197
+ ? `delete it; ${overrideFile} already layers onto the repo's manifest`
198
+ : `delete it, or turn it into an override (${asOverride}) that keeps only what this distribution changes`,
199
+ };
200
+ }
201
+
173
202
  /** Validate every manifest layer of `repoPath`, then the merged result. */
174
203
  export function validateLayers(
175
204
  tools: SchemaTools,
@@ -189,10 +218,20 @@ export function validateLayers(
189
218
  }
190
219
 
191
220
  const base = readLayer(baseFile);
192
- const baseResult =
221
+ let baseResult =
193
222
  "error" in base
194
223
  ? failure(tools.version, baseFile, base.error)
195
224
  : withFile(tools.validate(base.value), baseFile);
225
+ const redundantHeld =
226
+ baseLayer === "repo" ? heldManifestPath(repoPath) : null;
227
+ if (redundantHeld)
228
+ baseResult = {
229
+ ...baseResult,
230
+ warnings: [
231
+ ...baseResult.warnings,
232
+ redundantHeldWarning(redundantHeld, overrideFile),
233
+ ],
234
+ };
196
235
  layers.push({ layer: baseLayer, file: baseFile, result: baseResult });
197
236
 
198
237
  if (overrideFile) {
@@ -31,13 +31,23 @@ export function composeCommands(
31
31
  repoName: string,
32
32
  workspace: string,
33
33
  ): Record<string, CommandDef> {
34
- if (!manifest.compose || !isPrimary(manifest, subsystem)) return {};
34
+ if (!manifest.compose) return {};
35
35
  // `docker compose up -d` returns once the containers are started, so these
36
36
  // finish on their own: never daemonize them.
37
37
  const stack = (sub: string) => ({
38
38
  cmd: `${denBin()} stack ${sub} ${manifestStackName(manifest, repoName)} -w ${workspace}`,
39
39
  detachable: false,
40
40
  });
41
+ // Any other manifest service names a compose service: it restarts on its
42
+ // own (`docker compose restart`, same container and env). Up and down stay
43
+ // the stack's, through the primary service.
44
+ if (!isPrimary(manifest, subsystem))
45
+ return {
46
+ restart: {
47
+ ...stack("restart"),
48
+ cmd: `${stack("restart").cmd} --service ${JSON.stringify(subsystem)}`,
49
+ },
50
+ };
41
51
  return {
42
52
  up: stack("up"),
43
53
  down: stack("down"),
@@ -266,6 +266,26 @@ export function stackDown(ctx: StackContext, s: StackConfig): void {
266
266
  }
267
267
  }
268
268
 
269
+ /**
270
+ * Restart one compose service of a running stack in place (`docker compose
271
+ * restart`): the same container, so the same env; nothing is recreated.
272
+ */
273
+ export function stackRestartService(
274
+ ctx: StackContext,
275
+ s: StackConfig,
276
+ service: string,
277
+ ): void {
278
+ const override = path.join(stackDir(ctx, s), "override.yml");
279
+ const args = fs.existsSync(override)
280
+ ? overrideArgs(ctx, s)
281
+ : baseArgs(ctx, s);
282
+ must("docker", [...args, "restart", service], {
283
+ cwd: repoDir(s),
284
+ env: composeEnv(ctx, s),
285
+ inherit: true,
286
+ });
287
+ }
288
+
269
289
  export function stackPs(ctx: StackContext, s: StackConfig): string {
270
290
  return run(
271
291
  "docker",
@@ -176,8 +176,9 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
176
176
  "--force",
177
177
  "-A",
178
178
  "--app",
179
+ "--with-deps",
179
180
  ],
180
- restart: ["-A", "--app"],
181
+ restart: ["-A", "--app", "-n", "--dry-run"],
181
182
  logs: [],
182
183
  setup: ["-f", "--force"],
183
184
  sync: [],