@wildo-ai/wildo-module-client 1.1.4 → 1.1.6

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 (53) hide show
  1. package/dist/esm/index.d.ts.map +1 -1
  2. package/dist/esm/services/_http-client.d.ts +32 -3
  3. package/dist/esm/services/_http-client.d.ts.map +1 -1
  4. package/dist/esm/services/_http-client.js +100 -6
  5. package/dist/esm/services/_http-client.js.map +1 -1
  6. package/dist/esm/services/_zod-issue-formatting.d.ts.map +1 -1
  7. package/dist/esm/services/index.d.ts.map +1 -1
  8. package/dist/esm/services/lockfile-repair.service.d.ts +8 -23
  9. package/dist/esm/services/lockfile-repair.service.d.ts.map +1 -1
  10. package/dist/esm/services/lockfile-repair.service.js +10 -28
  11. package/dist/esm/services/lockfile-repair.service.js.map +1 -1
  12. package/dist/esm/services/module-adopter.service.d.ts +1 -1
  13. package/dist/esm/services/module-adopter.service.d.ts.map +1 -1
  14. package/dist/esm/services/module-adopter.service.js +7 -8
  15. package/dist/esm/services/module-adopter.service.js.map +1 -1
  16. package/dist/esm/services/module-packager.service.d.ts.map +1 -1
  17. package/dist/esm/services/module-packager.service.js +16 -19
  18. package/dist/esm/services/module-packager.service.js.map +1 -1
  19. package/dist/esm/services/provider-materialization-hook.d.ts.map +1 -1
  20. package/dist/esm/services/provider-sync-runner.d.ts +2 -3
  21. package/dist/esm/services/provider-sync-runner.d.ts.map +1 -1
  22. package/dist/esm/services/provider-sync-runner.js +14 -2
  23. package/dist/esm/services/provider-sync-runner.js.map +1 -1
  24. package/dist/esm/services/registry-companion-client.d.ts +10 -25
  25. package/dist/esm/services/registry-companion-client.d.ts.map +1 -1
  26. package/dist/esm/services/registry-companion-client.js +9 -83
  27. package/dist/esm/services/registry-companion-client.js.map +1 -1
  28. package/dist/esm/services/registry-lockfile.service.d.ts +7 -3
  29. package/dist/esm/services/registry-lockfile.service.d.ts.map +1 -1
  30. package/dist/esm/services/registry-lockfile.service.js +8 -3
  31. package/dist/esm/services/registry-lockfile.service.js.map +1 -1
  32. package/dist/esm/services/registry-server-client.d.ts +35 -9
  33. package/dist/esm/services/registry-server-client.d.ts.map +1 -1
  34. package/dist/esm/services/registry-server-client.js +115 -58
  35. package/dist/esm/services/registry-server-client.js.map +1 -1
  36. package/dist/tsconfig.build.tsbuildinfo +1 -1
  37. package/dist/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +2 -2
  39. package/src/__tests__/lockfile-repair.service.test.ts +2 -2
  40. package/src/__tests__/module-adopter.service.test.ts +6 -4
  41. package/src/__tests__/module-packager.service.test.ts +36 -34
  42. package/src/__tests__/provider-sync-runner.test.ts +12 -0
  43. package/src/__tests__/registry-lockfile.service.test.ts +3 -3
  44. package/src/__tests__/registry-server-client.test.ts +129 -0
  45. package/src/__tests__/registry-transfer-timeouts.test.ts +94 -0
  46. package/src/services/_http-client.ts +100 -6
  47. package/src/services/lockfile-repair.service.ts +9 -34
  48. package/src/services/module-adopter.service.ts +6 -9
  49. package/src/services/module-packager.service.ts +19 -16
  50. package/src/services/provider-sync-runner.ts +16 -5
  51. package/src/services/registry-companion-client.ts +10 -132
  52. package/src/services/registry-lockfile.service.ts +9 -3
  53. package/src/services/registry-server-client.ts +147 -57
@@ -1,21 +1,12 @@
1
1
  /**
2
- * `LockfileRepairService` — Phase 2 read-only lockfile diagnostics.
2
+ * `LockfileRepairService` — read-only lockfile diagnostics.
3
3
  *
4
- * Backs `wildo registry repair-lockfile` (CLI command lands in a
5
- * later step). The service does NOT mutate the lockfile by
6
- * default — its job is to surface a structured diagnostic the
7
- * operator can act on:
8
- * - validate the canonical envelope shape;
9
- * - validate `sections.modules` against
10
- * `RegistryModulesLockSectionSchema`;
11
- * - report which `installed[*].files` are missing on disk
12
- * (drift between the lockfile and the source tree);
13
- * - report which `installed[*].artifactDigest` strings are not
14
- * `Sha256DigestSchema`-shaped (residue from pre-tightening
15
- * publishes).
16
- *
17
- * Mutating repair is a Phase 7 deliverable — surfacing the gaps is
18
- * what unblocks the operator now.
4
+ * Backs `wildo registry repair-lockfile`. Strict envelope and module-section
5
+ * validation runs before file inspection. Malformed records, including invalid
6
+ * artifact digests, require restoration from a trusted record; they are never
7
+ * admitted into the automatic repair path. Valid records can report missing
8
+ * source files, which the CLI can re-fetch at their recorded exact version.
9
+ * This inspector does not mutate files.
19
10
  *
20
11
  * `i18n` validation is intentionally NOT performed here (KD-15 +
21
12
  * Review-pass-2 / MEDIUM-5): repair is the wrong place to
@@ -30,7 +21,6 @@ import { z } from 'zod';
30
21
  import { formatZodIssuesAsSingleLine } from './_zod-issue-formatting';
31
22
  import {
32
23
  RegistryModulesLockSectionSchema,
33
- Sha256DigestSchema,
34
24
  getWildoSaasLockPath,
35
25
  type RegistryModulesLockEntry,
36
26
  type RegistryModulesLockSection,
@@ -66,12 +56,6 @@ export type LockfileRepairFinding =
66
56
  kind: 'module-file-missing';
67
57
  moduleId: string;
68
58
  filePath: string;
69
- }
70
- | {
71
- severity: 'warn';
72
- kind: 'module-digest-not-sha256';
73
- moduleId: string;
74
- observedDigest: string;
75
59
  };
76
60
 
77
61
  export interface LockfileRepairReport {
@@ -163,7 +147,7 @@ export const LockfileRepairService = {
163
147
  findings.push({
164
148
  severity: 'error',
165
149
  kind: 'modules-section-malformed',
166
- message: '`sections.modules` failed schema validation.',
150
+ message: '`sections.modules` failed schema validation. Restore the affected record from a trusted lockfile revision; automatic repair does not infer missing or invalid metadata.',
167
151
  detail: formatZodIssuesAsSingleLine(parsed.error),
168
152
  });
169
153
  return { lockfilePath, ok: false, findings };
@@ -179,8 +163,7 @@ export const LockfileRepairService = {
179
163
  },
180
164
 
181
165
  /**
182
- * Per-entry diagnostic helper. Exposed so Phase 7 mutating-
183
- * repair flows can reuse the same predicates.
166
+ * Missing-file diagnostics for an entry already validated by the strict section schema.
184
167
  */
185
168
  async collectEntryFindings(
186
169
  saasRoot: string,
@@ -188,14 +171,6 @@ export const LockfileRepairService = {
188
171
  entry: RegistryModulesLockEntry,
189
172
  findings: LockfileRepairFinding[],
190
173
  ): Promise<void> {
191
- if (!Sha256DigestSchema.safeParse(entry.artifactDigest).success) {
192
- findings.push({
193
- severity: 'warn',
194
- kind: 'module-digest-not-sha256',
195
- moduleId,
196
- observedDigest: entry.artifactDigest,
197
- });
198
- }
199
174
  for (const filePath of entry.files) {
200
175
  const abs = path.resolve(saasRoot, filePath);
201
176
  if (!(await fileExists(abs))) {
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { promises as fs } from 'node:fs';
24
24
  import * as path from 'node:path';
25
- import type { ModuleManifest } from '@wildo-ai/platform-config-lib';
25
+ import { isModuleArtifactPathAllowed, MODULE_BOUNDARY_LAYERS, type ModuleManifest } from '@wildo-ai/platform-config-lib';
26
26
  import { ModulePackagerService, type StagingPaths } from './module-packager.service';
27
27
 
28
28
  export interface AdoptionPlan {
@@ -65,6 +65,9 @@ async function walkBoundary(
65
65
  const entries = await fs.readdir(path.resolve(saasRoot, cur), { withFileTypes: true });
66
66
  for (const entry of entries) {
67
67
  const rel = path.join(cur, entry.name);
68
+ // Skip what a module never ships, exactly as `wildo registry publish` does — otherwise the
69
+ // packager (which refuses those paths) throws on a module's own `.env` or `node_modules`.
70
+ if (!isModuleArtifactPathAllowed(rel.split(path.sep).join('/'))) continue;
68
71
  if (entry.isDirectory()) {
69
72
  queue.push(rel);
70
73
  } else if (entry.isFile()) {
@@ -88,14 +91,8 @@ export const ModuleAdopterService = {
88
91
  if (!boundaries) {
89
92
  return { files: [], emptyBoundaries: [] };
90
93
  }
91
- const layers: ReadonlyArray<keyof typeof boundaries> = [
92
- 'specifications',
93
- 'shared',
94
- 'frontend',
95
- 'backend',
96
- 'minions',
97
- 'workers',
98
- ];
94
+ // Every layer the registry knows, not a hand-kept copy: a copy here missed `providers` (#2014).
95
+ const layers = MODULE_BOUNDARY_LAYERS;
99
96
  const aggregate = new Set<string>();
100
97
  const empty: string[] = [];
101
98
  for (const layer of layers) {
@@ -31,14 +31,17 @@ import { createHash } from 'node:crypto';
31
31
  import { promises as fs } from 'node:fs';
32
32
  import * as path from 'node:path';
33
33
  import {
34
+ flattenModuleBoundaries,
35
+ isModuleArtifactPathAllowed,
36
+ isModuleId,
34
37
  ModuleManifestSchema,
35
38
  RegistryBoundaryViolationError,
36
39
  type ModuleManifest,
37
40
  type ModuleManifestBoundaries,
41
+ MODULE_MANIFEST_FILE_NAME as MANIFEST_FILE_NAME,
38
42
  } from '@wildo-ai/platform-config-lib';
39
43
 
40
44
  export const MODULES_ARTIFACTS_RELATIVE_DIR = '.wildo-saas/generated/modules-artifacts';
41
- const MANIFEST_FILE_NAME = 'wildo.module.json';
42
45
 
43
46
  export interface StagingPaths {
44
47
  /** Absolute path to the per-module staging root. */
@@ -52,7 +55,9 @@ export interface StagingPaths {
52
55
  * fs side effects.
53
56
  */
54
57
  export function getModuleStagingPaths(saasRoot: string, moduleId: string): StagingPaths {
55
- if (!moduleId.includes('/')) {
58
+ // The full `<scope>/<name>` shape, not merely "contains a `/`": `stageFresh` runs a recursive delete
59
+ // on the path built here, and `a/../../../..` contained a slash.
60
+ if (!isModuleId(moduleId)) {
56
61
  // C-L3 (4th-pass review): `TypeError` is Node's standard for
57
62
  // argument-shape violations. The typed `RegistryError` subclasses
58
63
  // model registry-runtime conditions (auth, integrity, version
@@ -62,10 +67,14 @@ export function getModuleStagingPaths(saasRoot: string, moduleId: string): Stagi
62
67
  // and stops the typed-error vocabulary from absorbing pure
63
68
  // argument-validation concerns.
64
69
  throw new TypeError(
65
- `Module id "${moduleId}" must be in <domain>/<name> form (e.g. "acme/billing").`,
70
+ `Module id "${moduleId}" must be in <scope>/<name> form (e.g. "acme/billing"), each half lowercase letters, digits, ".", "_" or "-".`,
66
71
  );
67
72
  }
68
- const stagingRoot = path.resolve(saasRoot, MODULES_ARTIFACTS_RELATIVE_DIR, moduleId);
73
+ const artifactsRoot = path.resolve(saasRoot, MODULES_ARTIFACTS_RELATIVE_DIR);
74
+ const stagingRoot = path.resolve(artifactsRoot, moduleId);
75
+ if (!isPathInside(stagingRoot, artifactsRoot) || stagingRoot === artifactsRoot) {
76
+ throw new TypeError(`Module id "${moduleId}" resolves outside ${MODULES_ARTIFACTS_RELATIVE_DIR}.`);
77
+ }
69
78
  return {
70
79
  stagingRoot,
71
80
  manifestPath: path.join(stagingRoot, MANIFEST_FILE_NAME),
@@ -114,20 +123,14 @@ export function assertWithinBoundaries(
114
123
  boundaries: ModuleManifestBoundaries | undefined,
115
124
  ): string {
116
125
  const absolute = path.resolve(saasRoot, appRelativePath);
117
- // Path must stay inside saasRoot (no `..` escapes).
118
- if (!isPathInside(absolute, path.resolve(saasRoot))) {
126
+ // Path must stay inside saasRoot (no `..` escapes), and be a path a module artifact may carry at
127
+ // all — no dot-segment (`.env`, `.git`), no `node_modules` — whatever the boundaries say.
128
+ if (!isPathInside(absolute, path.resolve(saasRoot)) || !isModuleArtifactPathAllowed(appRelativePath.split(path.sep).join('/'))) {
119
129
  throw new RegistryBoundaryViolationError(moduleId, appRelativePath);
120
130
  }
121
- if (!boundaries) return absolute;
122
- const allowed: readonly string[] = [
123
- ...boundaries.specifications,
124
- ...boundaries.shared,
125
- ...boundaries.frontend,
126
- ...boundaries.backend,
127
- ...boundaries.minions,
128
- ...boundaries.workers,
129
- ];
130
- if (allowed.length === 0) return absolute;
131
+ // No declared boundary means the module may write NOWHERE. This used to return early and admit
132
+ // every path in the application — the fail-open reading of an absent declaration.
133
+ const allowed: readonly string[] = flattenModuleBoundaries(boundaries);
131
134
  // The candidate must be inside at least one declared boundary.
132
135
  const isInside = allowed.some((rel) => isPathInside(absolute, path.resolve(saasRoot, rel)));
133
136
  if (!isInside) {
@@ -41,7 +41,16 @@ import {
41
41
  } from '@wildo-ai/platform-config-lib';
42
42
  import type { RegistryClientLogger } from './types';
43
43
 
44
- const DEFAULT_TIMEOUT_MS = 120_000;
44
+ /**
45
+ * How long the post-mutation `wildo config sync` may run before it is treated as wedged and killed.
46
+ *
47
+ * Five minutes, not two: the budget exists to stop a WEDGED sync, and a wall clock cannot tell wedged
48
+ * from slow. Measured 2026-09-27 on a scaffolded application (#1765): the same sync took 14.6 s on a
49
+ * quiet machine, 91 s while the framework's watchers were recompiling, and exceeded the old 120 s during
50
+ * an install under that load — so a healthy sync was killed part-way and the install reported a provider
51
+ * sync failure it did not have. The kill stays: a genuinely stuck sync still ends, five minutes on.
52
+ */
53
+ const DEFAULT_TIMEOUT_MS = 300_000;
45
54
 
46
55
  export interface ProviderSyncRunnerInput {
47
56
  /** Absolute path to the SaaS application root. */
@@ -57,9 +66,8 @@ export interface ProviderSyncRunnerInput {
57
66
  */
58
67
  wildoBinPath?: string;
59
68
  /**
60
- * Maximum wall-time before SIGTERM. Default 120s — provider sync
61
- * can be heavy on first run (artifact generation + cleanup),
62
- * but every additional second is operator-visible.
69
+ * Maximum wall-time before SIGTERM. Default {@link DEFAULT_TIMEOUT_MS} (five minutes): it only ever
70
+ * elapses on a wedged sync, and a healthy one under load has been measured past two minutes.
63
71
  */
64
72
  timeoutMs?: number;
65
73
  /**
@@ -178,7 +186,10 @@ function runWildoConfigSync(
178
186
  const startedAt = Date.now();
179
187
  const child = spawn(binPath, ['config', 'sync', '--domain', 'config'], {
180
188
  cwd,
181
- stdio: ['ignore', 'pipe', 'pipe'],
189
+ // stdout is IGNORED, not piped: nothing here reads it, and an unread pipe fills at the OS buffer
190
+ // (~64 KB) and blocks the child mid-write — a finished sync then looked like a wedged one and
191
+ // was killed at the timeout. stderr stays piped because the diagnostic reads it (bounded below).
192
+ stdio: ['ignore', 'ignore', 'pipe'],
182
193
  });
183
194
  let stderr = '';
184
195
  let signaled = false;
@@ -1,33 +1,17 @@
1
1
  /**
2
- * `RegistryCompanionClient` — HTTP client for the local dev
3
- * companion's registry routes.
2
+ * `RegistryCompanionClient` — liveness probe for the local dev companion, used by
3
+ * `wildo registry ping` beside the registry-server probe.
4
4
  *
5
- * Distinct from `RegistryServerClient` (which talks to the central
6
- * `platform-module-registry` server). The companion client targets
7
- * `/api/companion/registry/*` on `http://localhost:<companion-port>`
8
- * for AST/source mutations the CLI delegates to the companion
9
- * (KD-6).
10
- *
11
- * Phase 2 ships the four mutation routes as method calls; the
12
- * companion controller (under
13
- * `platform/factory/wildo-dev-companion/src/companion-api/controllers/`)
14
- * implements them with the actual ts-morph + filesystem work in
15
- * Phase 5+.
5
+ * That is all it is. It used to carry four mutation methods (install / update / remove / publish)
6
+ * against `/api/companion/registry/*` routes that only ever answered a "Phase 5/6 deliverable"
7
+ * not-implemented envelope and had no caller. Installation shipped CLI-direct instead
8
+ * (`wildo registry add` / `update` / `remove` / `publish` do the work themselves), so the routes,
9
+ * these methods and their wire schemas were deleted on 2026-09-25 rather than left describing a
10
+ * design that was never built. A future companion-owned mutation lane starts from the CLI commands,
11
+ * not from this file.
16
12
  */
17
13
 
18
- import {
19
- REGISTRY_COMPANION_ROUTES,
20
- RegistryMalformedPayloadError,
21
- RegistryRouteResultSchema,
22
- RegistryUnexpectedStatusError,
23
- type RegistryInstallRequest,
24
- type RegistryPublishRequest,
25
- type RegistryRemoveRequest,
26
- type RegistryRouteResult,
27
- type RegistryUpdateRequest,
28
- } from '@wildo-ai/platform-config-lib';
29
14
  import { fetchWithTimeout, joinUrl } from './_http-client';
30
- import { formatZodIssuesAsSingleLine } from './_zod-issue-formatting';
31
15
 
32
16
  const DIAGNOSTIC_LABEL = 'Companion registry';
33
17
 
@@ -36,47 +20,6 @@ export interface RegistryCompanionClientOptions {
36
20
  timeoutMs?: number;
37
21
  }
38
22
 
39
- async function postJson<R>(
40
- options: RegistryCompanionClientOptions,
41
- routePath: string,
42
- body: unknown,
43
- ): Promise<R> {
44
- const url = joinUrl(options.baseUrl, routePath);
45
- const res = await fetchWithTimeout(url, {
46
- method: 'POST',
47
- headers: { 'content-type': 'application/json' },
48
- body: JSON.stringify(body),
49
- timeoutMs: options.timeoutMs,
50
- diagnosticLabel: DIAGNOSTIC_LABEL,
51
- });
52
- if (!res.ok) {
53
- // 3rd-pass M5: typed `RegistryUnexpectedStatusError` instead of
54
- // bare `new Error(...)` so callers can `isRegistryError(err)` and
55
- // discriminate by status code without re-parsing message text.
56
- // The companion deliberately reuses the same error vocabulary
57
- // as `RegistryServerClient` since the wire shape (status + URL +
58
- // action) is identical even though the lane is different.
59
- throw new RegistryUnexpectedStatusError(
60
- options.baseUrl,
61
- res.status,
62
- res.statusText,
63
- `companion ${routePath}`,
64
- );
65
- }
66
- // 6th-pass M3: typed JSON-parse — a misconfigured proxy returning
67
- // HTML at 2xx would otherwise throw a bare `SyntaxError` that
68
- // bypasses the typed-error contract.
69
- try {
70
- return (await res.json()) as R;
71
- } catch (err) {
72
- throw new RegistryMalformedPayloadError(
73
- options.baseUrl,
74
- `companion ${routePath}`,
75
- `response is not valid JSON: ${err instanceof Error ? err.message : String(err)}`,
76
- );
77
- }
78
- }
79
-
80
23
  export const RegistryCompanionClient = {
81
24
  /**
82
25
  * Liveness probe against `/health` (the same endpoint the
@@ -95,69 +38,4 @@ export const RegistryCompanionClient = {
95
38
  return false;
96
39
  }
97
40
  },
98
-
99
- /**
100
- * Ask the companion to install a module: download bytes, copy
101
- * source, mutate `wildo.saas.config.ts > moduleRegistries` +
102
- * the three `modules-registry.*.ts` files, refresh
103
- * `sections.modules`, run provider sync.
104
- */
105
- async install(
106
- options: RegistryCompanionClientOptions,
107
- request: RegistryInstallRequest,
108
- ): Promise<RegistryRouteResult> {
109
- const raw = await postJson<unknown>(options, REGISTRY_COMPANION_ROUTES.install, request);
110
- return parseCompanionResult(options.baseUrl, REGISTRY_COMPANION_ROUTES.install, raw);
111
- },
112
-
113
- async update(
114
- options: RegistryCompanionClientOptions,
115
- request: RegistryUpdateRequest,
116
- ): Promise<RegistryRouteResult> {
117
- const raw = await postJson<unknown>(options, REGISTRY_COMPANION_ROUTES.update, request);
118
- return parseCompanionResult(options.baseUrl, REGISTRY_COMPANION_ROUTES.update, raw);
119
- },
120
-
121
- async remove(
122
- options: RegistryCompanionClientOptions,
123
- request: RegistryRemoveRequest,
124
- ): Promise<RegistryRouteResult> {
125
- const raw = await postJson<unknown>(options, REGISTRY_COMPANION_ROUTES.remove, request);
126
- return parseCompanionResult(options.baseUrl, REGISTRY_COMPANION_ROUTES.remove, raw);
127
- },
128
-
129
- async publish(
130
- options: RegistryCompanionClientOptions,
131
- request: RegistryPublishRequest,
132
- ): Promise<RegistryRouteResult> {
133
- const raw = await postJson<unknown>(options, REGISTRY_COMPANION_ROUTES.publish, request);
134
- return parseCompanionResult(options.baseUrl, REGISTRY_COMPANION_ROUTES.publish, raw);
135
- },
136
- } as const;
137
-
138
- /**
139
- * M5: thin wrapper around `RegistryRouteResultSchema.parse(raw)` that
140
- * converts a Zod parse failure into the typed
141
- * `RegistryMalformedPayloadError` instead of letting a bare `ZodError`
142
- * bubble up. Keeps the typed-handler contract aligned with
143
- * `RegistryServerClient`.
144
- */
145
- function parseCompanionResult(
146
- baseUrl: string,
147
- routePath: string,
148
- raw: unknown,
149
- ): RegistryRouteResult {
150
- const parsed = RegistryRouteResultSchema.safeParse(raw);
151
- if (!parsed.success) {
152
- // 7th-pass C-L2: format the issues array for operator
153
- // readability instead of using Zod 4's JSON-stringified
154
- // `error.message`.
155
- const detail = formatZodIssuesAsSingleLine(parsed.error);
156
- throw new RegistryMalformedPayloadError(
157
- baseUrl,
158
- `companion ${routePath}`,
159
- detail,
160
- );
161
- }
162
- return parsed.data;
163
- }
41
+ };
@@ -25,7 +25,8 @@ import {
25
25
  withLockfileSectionMutate,
26
26
  readWildoSaasLock,
27
27
  type RegistryModulesLockSection,
28
- type RegistryModulesLockEntry,
28
+ RegistryModulesLockEntrySchema,
29
+ type RegistryModulesLockEntryInput,
29
30
  } from '@wildo-ai/platform-config-lib';
30
31
  // L6 (third-pass review): the package's barrel-discipline rule says
31
32
  // "Never re-export (except in the root `index.ts` of each package)."
@@ -81,8 +82,13 @@ export const RegistryLockfileService = {
81
82
  );
82
83
  },
83
84
 
84
- /** Convenience helper used by Phase 5+ install flow. */
85
- async upsertEntry(saasRoot: string, entry: RegistryModulesLockEntry): Promise<void> {
85
+ /**
86
+ * Insert or replace one module's entry. The entry is parsed here, so its defaults (`distribution`,
87
+ * `files`) apply in one place and a malformed entry is refused naming the module, before the lockfile
88
+ * is touched (`mutate` then re-validates the whole section).
89
+ */
90
+ async upsertEntry(saasRoot: string, input: RegistryModulesLockEntryInput): Promise<void> {
91
+ const entry = RegistryModulesLockEntrySchema.parse(input);
86
92
  await this.mutate(saasRoot, (current) => ({
87
93
  ...current,
88
94
  installed: { ...current.installed, [entry.moduleId]: entry },