@wildo-ai/wildo-module-client 1.1.1

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 (98) hide show
  1. package/LICENSE +34 -0
  2. package/dist/esm/.builder.pid +9 -0
  3. package/dist/esm/__tests__/lockfile-repair.service.test.d.ts +0 -0
  4. package/dist/esm/__tests__/lockfile-repair.service.test.d.ts.map +0 -0
  5. package/dist/esm/__tests__/lockfile-repair.service.test.js.map +0 -0
  6. package/dist/esm/__tests__/module-adopter.service.test.d.ts +0 -0
  7. package/dist/esm/__tests__/module-adopter.service.test.d.ts.map +0 -0
  8. package/dist/esm/__tests__/module-adopter.service.test.js.map +0 -0
  9. package/dist/esm/__tests__/module-packager.service.test.d.ts +0 -0
  10. package/dist/esm/__tests__/module-packager.service.test.d.ts.map +0 -0
  11. package/dist/esm/__tests__/module-packager.service.test.js.map +0 -0
  12. package/dist/esm/__tests__/provider-materialization-hook.test.d.ts +0 -0
  13. package/dist/esm/__tests__/provider-materialization-hook.test.d.ts.map +0 -0
  14. package/dist/esm/__tests__/provider-materialization-hook.test.js.map +0 -0
  15. package/dist/esm/__tests__/provider-sync-runner.test.d.ts +0 -0
  16. package/dist/esm/__tests__/provider-sync-runner.test.d.ts.map +0 -0
  17. package/dist/esm/__tests__/provider-sync-runner.test.js.map +0 -0
  18. package/dist/esm/__tests__/registry-lockfile.service.test.d.ts +0 -0
  19. package/dist/esm/__tests__/registry-lockfile.service.test.d.ts.map +0 -0
  20. package/dist/esm/__tests__/registry-lockfile.service.test.js.map +0 -0
  21. package/dist/esm/__tests__/registry-server-client.test.d.ts +0 -0
  22. package/dist/esm/__tests__/registry-server-client.test.d.ts.map +0 -0
  23. package/dist/esm/__tests__/registry-server-client.test.js.map +0 -0
  24. package/dist/esm/index.d.ts +18 -0
  25. package/dist/esm/index.d.ts.map +1 -0
  26. package/dist/esm/index.js +18 -0
  27. package/dist/esm/index.js.map +1 -0
  28. package/dist/esm/services/_http-client.d.ts +44 -0
  29. package/dist/esm/services/_http-client.d.ts.map +1 -0
  30. package/dist/esm/services/_http-client.js +75 -0
  31. package/dist/esm/services/_http-client.js.map +1 -0
  32. package/dist/esm/services/_zod-issue-formatting.d.ts +12 -0
  33. package/dist/esm/services/_zod-issue-formatting.d.ts.map +1 -0
  34. package/dist/esm/services/_zod-issue-formatting.js +47 -0
  35. package/dist/esm/services/_zod-issue-formatting.js.map +1 -0
  36. package/dist/esm/services/index.d.ts +10 -0
  37. package/dist/esm/services/index.d.ts.map +1 -0
  38. package/dist/esm/services/index.js +10 -0
  39. package/dist/esm/services/index.js.map +1 -0
  40. package/dist/esm/services/lockfile-repair.service.d.ts +68 -0
  41. package/dist/esm/services/lockfile-repair.service.d.ts.map +1 -0
  42. package/dist/esm/services/lockfile-repair.service.js +160 -0
  43. package/dist/esm/services/lockfile-repair.service.js.map +1 -0
  44. package/dist/esm/services/module-adopter.service.d.ts +59 -0
  45. package/dist/esm/services/module-adopter.service.d.ts.map +1 -0
  46. package/dist/esm/services/module-adopter.service.js +126 -0
  47. package/dist/esm/services/module-adopter.service.js.map +1 -0
  48. package/dist/esm/services/module-packager.service.d.ts +105 -0
  49. package/dist/esm/services/module-packager.service.d.ts.map +1 -0
  50. package/dist/esm/services/module-packager.service.js +212 -0
  51. package/dist/esm/services/module-packager.service.js.map +1 -0
  52. package/dist/esm/services/provider-materialization-hook.d.ts +32 -0
  53. package/dist/esm/services/provider-materialization-hook.d.ts.map +1 -0
  54. package/dist/esm/services/provider-materialization-hook.js +33 -0
  55. package/dist/esm/services/provider-materialization-hook.js.map +1 -0
  56. package/dist/esm/services/provider-sync-runner.d.ts +87 -0
  57. package/dist/esm/services/provider-sync-runner.d.ts.map +1 -0
  58. package/dist/esm/services/provider-sync-runner.js +207 -0
  59. package/dist/esm/services/provider-sync-runner.js.map +1 -0
  60. package/dist/esm/services/registry-companion-client.d.ts +39 -0
  61. package/dist/esm/services/registry-companion-client.d.ts.map +1 -0
  62. package/dist/esm/services/registry-companion-client.js +109 -0
  63. package/dist/esm/services/registry-companion-client.js.map +1 -0
  64. package/dist/esm/services/registry-lockfile.service.d.ts +56 -0
  65. package/dist/esm/services/registry-lockfile.service.d.ts.map +1 -0
  66. package/dist/esm/services/registry-lockfile.service.js +99 -0
  67. package/dist/esm/services/registry-lockfile.service.js.map +1 -0
  68. package/dist/esm/services/registry-server-client.d.ts +94 -0
  69. package/dist/esm/services/registry-server-client.d.ts.map +1 -0
  70. package/dist/esm/services/registry-server-client.js +225 -0
  71. package/dist/esm/services/registry-server-client.js.map +1 -0
  72. package/dist/esm/services/types.d.ts +22 -0
  73. package/dist/esm/services/types.d.ts.map +1 -0
  74. package/dist/esm/services/types.js +16 -0
  75. package/dist/esm/services/types.js.map +1 -0
  76. package/dist/tsconfig.build.tsbuildinfo +1 -0
  77. package/dist/tsconfig.tsbuildinfo +1 -0
  78. package/package.json +51 -0
  79. package/src/__tests__/lockfile-repair.service.test.ts +106 -0
  80. package/src/__tests__/module-adopter.service.test.ts +96 -0
  81. package/src/__tests__/module-packager.service.test.ts +258 -0
  82. package/src/__tests__/provider-materialization-hook.test.ts +47 -0
  83. package/src/__tests__/provider-sync-runner.test.ts +113 -0
  84. package/src/__tests__/registry-lockfile.service.test.ts +134 -0
  85. package/src/__tests__/registry-server-client.test.ts +312 -0
  86. package/src/index.ts +18 -0
  87. package/src/services/_http-client.ts +91 -0
  88. package/src/services/_zod-issue-formatting.ts +50 -0
  89. package/src/services/index.ts +9 -0
  90. package/src/services/lockfile-repair.service.ts +215 -0
  91. package/src/services/module-adopter.service.ts +155 -0
  92. package/src/services/module-packager.service.ts +251 -0
  93. package/src/services/provider-materialization-hook.ts +54 -0
  94. package/src/services/provider-sync-runner.ts +298 -0
  95. package/src/services/registry-companion-client.ts +163 -0
  96. package/src/services/registry-lockfile.service.ts +122 -0
  97. package/src/services/registry-server-client.ts +334 -0
  98. package/src/services/types.ts +22 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * `runProviderSync` — Phase 2 real shell-out to
3
+ * `wildo config sync --domain config`.
4
+ *
5
+ * Module-registry install/update/remove/adopt flows mutate app
6
+ * source. When that source changes provider implementations,
7
+ * provider companion exports, or provider scopes, the
8
+ * provider-runtime delivery layer must regenerate its outputs
9
+ * (KD-18). The canonical command is owned by the provider
10
+ * workstream — module-registry MUST delegate, not fork.
11
+ *
12
+ * Phase 0.5 / 1 shipped a no-op `runProviderMaterializationHook`
13
+ * that recorded intent. Phase 2 replaces it with a real
14
+ * `child_process.spawn`-based runner that:
15
+ * - resolves the `wildo` CLI binary from the saas root's
16
+ * `node_modules/.bin/wildo` (the workspace's own version);
17
+ * - shells `wildo config sync --domain config` in the saas root;
18
+ * - times out at the operator-configurable budget;
19
+ * - classifies stderr-tail patterns into the
20
+ * `RegistryProviderSyncFailedError.failureKind` discriminator
21
+ * (`companion-discovery` / `runtime-import-reachability` /
22
+ * `invalid-scope` / `unknown`) so callers report repairable
23
+ * failures with first-class UX rather than generic install
24
+ * noise (`module-registry-analysis.md` / Provider Migration
25
+ * WIP Seams §1).
26
+ *
27
+ * Hard contract (KD-18): the runner NEVER writes provider runtime
28
+ * artifacts itself. It only invokes the provider workstream's
29
+ * canonical command. Tests assert no provider artifact is touched
30
+ * by this code path.
31
+ */
32
+
33
+ import { spawn } from 'node:child_process';
34
+ import { promises as fs } from 'node:fs';
35
+ import * as path from 'node:path';
36
+ import {
37
+ PROVIDER_LOCK_SECTION_PATH,
38
+ PROVIDER_RUNTIME_ARTIFACT_RELATIVE_PATHS,
39
+ PROVIDER_RUNTIME_GENERATED_MODULE_RELATIVE_PATH,
40
+ RegistryProviderSyncFailedError,
41
+ } from '@wildo-ai/platform-config-lib';
42
+ import type { RegistryClientLogger } from './types';
43
+
44
+ const DEFAULT_TIMEOUT_MS = 120_000;
45
+
46
+ export interface ProviderSyncRunnerInput {
47
+ /** Absolute path to the SaaS application root. */
48
+ saasRoot: string;
49
+ /** Reason for the call — drives diagnostic copy. */
50
+ reason: 'install' | 'update' | 'remove' | 'adopt' | 'smoke';
51
+ /** Optional logger. Defaults to no-op. */
52
+ log?: RegistryClientLogger;
53
+ /**
54
+ * Override the wildo CLI binary path. Defaults to
55
+ * `<saasRoot>/node_modules/.bin/wildo`. Phase 2 tests override
56
+ * this to point at a stub script.
57
+ */
58
+ wildoBinPath?: string;
59
+ /**
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.
63
+ */
64
+ timeoutMs?: number;
65
+ /**
66
+ * If true, do NOT actually shell out — log the intent and
67
+ * resolve as `mode: 'dry-run'`. Used by the Phase 0.5 smoke
68
+ * command which intentionally cannot rely on a working
69
+ * `wildo` binary in the smoke sandbox.
70
+ */
71
+ dryRun?: boolean;
72
+ }
73
+
74
+ export interface ProviderSyncRunnerResult {
75
+ invoked: true;
76
+ /**
77
+ * `'shell-out'` when the runner spawned `wildo config sync`.
78
+ * `'dry-run'` when `dryRun: true` was passed (smoke flow).
79
+ */
80
+ mode: 'shell-out' | 'dry-run';
81
+ /** Exit code reported by the wildo binary (0 on success). */
82
+ exitCode: number | null;
83
+ /** Wall-clock duration (ms). */
84
+ durationMs: number;
85
+ /** Reason that was passed in. */
86
+ reason: ProviderSyncRunnerInput['reason'];
87
+ /** Provider-output paths the canonical sync would refresh. */
88
+ providerJsonRelativePaths: readonly string[];
89
+ /** Source module the Node-runtime consumes. */
90
+ providerSourceRelativePath: string;
91
+ /** Provider lock section the materializer owns end-to-end. */
92
+ providerLockSectionPath: typeof PROVIDER_LOCK_SECTION_PATH;
93
+ }
94
+
95
+ /**
96
+ * Provider artifact paths sourced from the canonical
97
+ * `PROVIDER_RUNTIME_ARTIFACT_RELATIVE_PATHS` constant in
98
+ * `platform-config-lib`. Refactored on 2026-05-06 to import the
99
+ * single source of truth instead of duplicating the literal paths
100
+ * here — the artifacts moved from `.wildo-saas/providers/` to
101
+ * `.wildo-saas/generated/providers/` and any duplication would
102
+ * silently drift.
103
+ */
104
+ const PROVIDER_JSON_RELATIVE_PATHS = Object.values(
105
+ PROVIDER_RUNTIME_ARTIFACT_RELATIVE_PATHS,
106
+ ) as readonly string[];
107
+
108
+ const PROVIDER_SOURCE_RELATIVE_PATH = PROVIDER_RUNTIME_GENERATED_MODULE_RELATIVE_PATH;
109
+
110
+ /**
111
+ * Classify a `wildo config sync` failure based on its stderr tail.
112
+ * The matchers mirror the failure modes the provider workstream
113
+ * surfaces today; new modes get a new branch + a new
114
+ * discriminator value (out of `RegistryProviderSyncFailedError`).
115
+ */
116
+ function classifyStderr(
117
+ stderr: string,
118
+ ): 'companion-discovery' | 'runtime-import-reachability' | 'invalid-scope' | 'unknown' {
119
+ const tail = stderr.slice(-2000);
120
+ // Companion-export discovery failure — the provider sync walks
121
+ // each owner package's `./companion` export to discover provider
122
+ // contributions. A package whose export contract is broken
123
+ // surfaces a "companion export" / "DiscoveredProviderCompanionContribution"
124
+ // error.
125
+ if (
126
+ /companion[- ]export|DiscoveredProviderCompanionContribution|companion-discovery/i.test(tail)
127
+ ) {
128
+ return 'companion-discovery';
129
+ }
130
+ // Runtime-import reachability — the materializer can't resolve a
131
+ // contributed `runtimeImport` against the owning runtime package
132
+ // graph. Typical messages mention `runtimeImport`, `Cannot find
133
+ // module`, or `MODULE_NOT_FOUND`.
134
+ if (
135
+ /runtimeImport|MODULE_NOT_FOUND|Cannot find module|cannot resolve/i.test(tail)
136
+ ) {
137
+ return 'runtime-import-reachability';
138
+ }
139
+ // Scope validation — `defineSaaSProviders({ scopes })` rejects
140
+ // malformed scope inputs ahead of materialization.
141
+ if (
142
+ /defineSaaSProviders|invalid[- ]scope|providers\.scopes/i.test(tail)
143
+ ) {
144
+ return 'invalid-scope';
145
+ }
146
+ return 'unknown';
147
+ }
148
+
149
+ async function resolveWildoBin(input: ProviderSyncRunnerInput): Promise<string> {
150
+ if (input.wildoBinPath) return input.wildoBinPath;
151
+ const candidate = path.join(input.saasRoot, 'node_modules', '.bin', 'wildo');
152
+ try {
153
+ await fs.access(candidate);
154
+ return candidate;
155
+ } catch {
156
+ // Fall back to PATH-resolved `wildo`. spawn will raise ENOENT
157
+ // if it's not on PATH either, surfaced as
158
+ // `RegistryProviderSyncFailedError(failureKind: 'unknown')`.
159
+ return 'wildo';
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Grace window between SIGTERM and SIGKILL. A well-behaved
165
+ * `wildo config sync` should react to SIGTERM in well under a
166
+ * second — the grace exists for tools that need to flush a buffer
167
+ * or release a file lock. After the window we escalate to SIGKILL
168
+ * so a hung child cannot pin the parent forever.
169
+ */
170
+ const SIGKILL_GRACE_MS = 5_000;
171
+
172
+ function runWildoConfigSync(
173
+ binPath: string,
174
+ cwd: string,
175
+ timeoutMs: number,
176
+ ): Promise<{ exitCode: number | null; stderr: string; durationMs: number; signaled: boolean }> {
177
+ return new Promise((resolve) => {
178
+ const startedAt = Date.now();
179
+ const child = spawn(binPath, ['config', 'sync', '--domain', 'config'], {
180
+ cwd,
181
+ stdio: ['ignore', 'pipe', 'pipe'],
182
+ });
183
+ let stderr = '';
184
+ let signaled = false;
185
+ let killTimer: NodeJS.Timeout | undefined;
186
+ child.stderr?.on('data', (b: Buffer) => {
187
+ // Cap captured stderr so a runaway sync can't OOM the parent.
188
+ stderr += b.toString();
189
+ if (stderr.length > 16_384) stderr = stderr.slice(-16_384);
190
+ });
191
+ const termTimer = setTimeout(() => {
192
+ // Soft-kill first; record so the caller can synthesize a
193
+ // diagnostic message that distinguishes a TERM-induced exit
194
+ // from a normal one.
195
+ signaled = true;
196
+ stderr += `\n[provider-sync] timeout ${timeoutMs}ms exceeded — sent SIGTERM.`;
197
+ child.kill('SIGTERM');
198
+ // Escalate to SIGKILL if the child doesn't react within the
199
+ // grace window. Without this a child stuck in a syscall (or
200
+ // deliberately ignoring SIGTERM) would leave the parent
201
+ // blocked on the spawn promise forever.
202
+ killTimer = setTimeout(() => {
203
+ if (child.exitCode === null && child.signalCode === null) {
204
+ stderr += `\n[provider-sync] SIGTERM grace ${SIGKILL_GRACE_MS}ms exceeded — escalating to SIGKILL.`;
205
+ try {
206
+ child.kill('SIGKILL');
207
+ } catch {
208
+ /* swallow — best-effort escalation */
209
+ }
210
+ }
211
+ }, SIGKILL_GRACE_MS);
212
+ }, timeoutMs);
213
+ child.on('exit', (code) => {
214
+ clearTimeout(termTimer);
215
+ if (killTimer) clearTimeout(killTimer);
216
+ resolve({
217
+ exitCode: code,
218
+ stderr,
219
+ durationMs: Date.now() - startedAt,
220
+ signaled,
221
+ });
222
+ });
223
+ child.on('error', (err) => {
224
+ clearTimeout(termTimer);
225
+ if (killTimer) clearTimeout(killTimer);
226
+ resolve({
227
+ exitCode: null,
228
+ stderr: stderr + `\n[spawn error] ${err.message}`,
229
+ durationMs: Date.now() - startedAt,
230
+ signaled,
231
+ });
232
+ });
233
+ });
234
+ }
235
+
236
+ /**
237
+ * Run the canonical provider-sync command. Throws
238
+ * `RegistryProviderSyncFailedError` (with classified `failureKind`)
239
+ * when the spawned process exits non-zero or fails to start.
240
+ */
241
+ export async function runProviderSync(
242
+ input: ProviderSyncRunnerInput,
243
+ ): Promise<ProviderSyncRunnerResult> {
244
+ const log = input.log;
245
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
246
+ const baseResult = {
247
+ invoked: true as const,
248
+ reason: input.reason,
249
+ providerJsonRelativePaths: PROVIDER_JSON_RELATIVE_PATHS,
250
+ providerSourceRelativePath: PROVIDER_SOURCE_RELATIVE_PATH,
251
+ providerLockSectionPath: PROVIDER_LOCK_SECTION_PATH,
252
+ };
253
+
254
+ if (input.dryRun) {
255
+ log?.info?.(
256
+ `[registry] post-${input.reason} provider sync (dry-run): would shell ` +
257
+ `\`wildo config sync --domain config\` in ${input.saasRoot}.`,
258
+ );
259
+ return { ...baseResult, mode: 'dry-run', exitCode: 0, durationMs: 0 };
260
+ }
261
+
262
+ const binPath = await resolveWildoBin(input);
263
+ log?.info?.(
264
+ `[registry] post-${input.reason} provider sync: spawning ` +
265
+ `${binPath} config sync --domain config (cwd=${input.saasRoot}, timeout=${timeoutMs}ms)`,
266
+ );
267
+ const { exitCode, stderr, durationMs, signaled } = await runWildoConfigSync(
268
+ binPath,
269
+ input.saasRoot,
270
+ timeoutMs,
271
+ );
272
+
273
+ if (exitCode === 0) {
274
+ log?.success?.(`[registry] provider sync succeeded in ${durationMs}ms.`);
275
+ return { ...baseResult, mode: 'shell-out', exitCode, durationMs };
276
+ }
277
+
278
+ // H5: distinguish a timeout-signaled exit from a generic non-zero
279
+ // exit. `classifyStderr` runs regex against the appended
280
+ // `[provider-sync] timeout ... SIGTERM/SIGKILL` lines but those
281
+ // tails match no failure-mode regex — so prior to this branch
282
+ // operators saw `failureKind: 'unknown'` for a hard timeout. The
283
+ // typed error now carries the discriminator + a hint to either
284
+ // raise the timeout or investigate the wedge.
285
+ const failureKind = signaled ? ('timeout' as const) : classifyStderr(stderr);
286
+ log?.error?.(
287
+ `[registry] provider sync failed (${failureKind}, exit=${exitCode}, ${durationMs}ms).`,
288
+ );
289
+ const messageSuffix = failureKind === 'timeout'
290
+ ? `Wall-clock exceeded ${timeoutMs}ms; child was sent SIGTERM (then SIGKILL after grace). ` +
291
+ 'Either raise `timeoutMs` for legitimately heavy syncs or investigate why the sync wedged.'
292
+ : 'Run `wildo config sync --domain config` manually to inspect.';
293
+ throw new RegistryProviderSyncFailedError(
294
+ `Post-${input.reason} provider sync failed (${failureKind}). ${messageSuffix}`,
295
+ failureKind,
296
+ stderr.slice(-1024),
297
+ );
298
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `RegistryCompanionClient` — HTTP client for the local dev
3
+ * companion's registry routes.
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+.
16
+ */
17
+
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
+ import { fetchWithTimeout, joinUrl } from './_http-client';
30
+ import { formatZodIssuesAsSingleLine } from './_zod-issue-formatting';
31
+
32
+ const DIAGNOSTIC_LABEL = 'Companion registry';
33
+
34
+ export interface RegistryCompanionClientOptions {
35
+ baseUrl: string;
36
+ timeoutMs?: number;
37
+ }
38
+
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
+ export const RegistryCompanionClient = {
81
+ /**
82
+ * Liveness probe against `/health` (the same endpoint the
83
+ * dev-supervisor monitors). Used by `wildo registry ping`.
84
+ */
85
+ async ping(options: RegistryCompanionClientOptions): Promise<boolean> {
86
+ const url = joinUrl(options.baseUrl, '/health');
87
+ try {
88
+ const res = await fetchWithTimeout(url, {
89
+ method: 'GET',
90
+ timeoutMs: options.timeoutMs,
91
+ diagnosticLabel: DIAGNOSTIC_LABEL,
92
+ });
93
+ return res.ok;
94
+ } catch {
95
+ return false;
96
+ }
97
+ },
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
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `RegistryLockfileService` — section-scoped reader/writer for the
3
+ * `sections.modules` slice of `wildo-saas.lock.json` (KD-4).
4
+ *
5
+ * Phase 1 graduates the locking + atomic-write primitives into
6
+ * `@wildo-ai/platform-config-lib` (validated by the Phase 0.B
7
+ * contention spike). This service is a thin wrapper around
8
+ * `withLockfileSectionMutate` that pre-binds the `modules` section
9
+ * key + schema, so module-registry callers cannot accidentally
10
+ * touch sibling sections.
11
+ *
12
+ * Hard contracts (KD-4 / KD-15 / KD-18):
13
+ * - Mutates ONLY `sections.modules`. The shared library preserves
14
+ * `sections.providers`, `sections.i18n`, and every unknown
15
+ * `sections.*` payload verbatim — no defensive logic needed
16
+ * here.
17
+ * - Never writes `.wildo-saas/generated/providers/*.runtime-providers.generated.json`
18
+ * or `src/generated/wildo-provider-runtime.generated.ts` —
19
+ * those are produced by the provider materialization workflow.
20
+ * - Never mutates `sections.providers.runtimeScopes`.
21
+ */
22
+
23
+ import {
24
+ RegistryModulesLockSectionSchema,
25
+ withLockfileSectionMutate,
26
+ readWildoSaasLock,
27
+ type RegistryModulesLockSection,
28
+ type RegistryModulesLockEntry,
29
+ } from '@wildo-ai/platform-config-lib';
30
+ // L6 (third-pass review): the package's barrel-discipline rule says
31
+ // "Never re-export (except in the root `index.ts` of each package)."
32
+ // The previous `export { WILDO_SAAS_LOCK_RELATIVE_PATH }` here AND in
33
+ // `lockfile-repair.service.ts` surfaced the symbol twice from
34
+ // `wildo-module-client`'s root. Consumers that need it can import
35
+ // directly from `@wildo-ai/platform-config-lib`, OR rely on the root
36
+ // `index.ts` to re-export it once.
37
+
38
+ const SECTION_KEY = 'modules' as const;
39
+
40
+ function emptySection(): RegistryModulesLockSection {
41
+ return RegistryModulesLockSectionSchema.parse({});
42
+ }
43
+
44
+ export const RegistryLockfileService = {
45
+ /**
46
+ * Read the current `sections.modules` payload (or an empty section
47
+ * when missing). Unsynchronized — callers needing read-after-write
48
+ * coherence should read INSIDE `mutate`.
49
+ */
50
+ async read(saasRoot: string): Promise<RegistryModulesLockSection> {
51
+ const envelope = await readWildoSaasLock({ saasRoot });
52
+ const raw = envelope.sections.modules;
53
+ if (raw === undefined) return emptySection();
54
+ return RegistryModulesLockSectionSchema.parse(raw);
55
+ },
56
+
57
+ /**
58
+ * Run `mutator` against the current `sections.modules` payload
59
+ * under an OS-level lock + atomic-write commit. The mutator can
60
+ * either mutate-in-place or return a new value; either way the
61
+ * result is re-validated against `RegistryModulesLockSectionSchema`
62
+ * before commit.
63
+ */
64
+ async mutate(
65
+ saasRoot: string,
66
+ mutator: (
67
+ current: RegistryModulesLockSection,
68
+ ) =>
69
+ | RegistryModulesLockSection
70
+ | void
71
+ | Promise<RegistryModulesLockSection | void>,
72
+ ): Promise<RegistryModulesLockSection> {
73
+ return withLockfileSectionMutate(
74
+ {
75
+ saasRoot,
76
+ sectionKey: SECTION_KEY,
77
+ sectionSchema: RegistryModulesLockSectionSchema,
78
+ initialSection: emptySection,
79
+ },
80
+ mutator,
81
+ );
82
+ },
83
+
84
+ /** Convenience helper used by Phase 5+ install flow. */
85
+ async upsertEntry(saasRoot: string, entry: RegistryModulesLockEntry): Promise<void> {
86
+ await this.mutate(saasRoot, (current) => ({
87
+ ...current,
88
+ installed: { ...current.installed, [entry.moduleId]: entry },
89
+ }));
90
+ },
91
+
92
+ /**
93
+ * Find every other lockfile entry whose `files[]` claims any of the
94
+ * given paths. Used by `update`/`remove` to skip deletes that would
95
+ * silently pull files out from under modules that own them too
96
+ * (cross-module file overlap).
97
+ *
98
+ * Returns a `Map<relPath, owners[]>` where `owners` excludes
99
+ * `excludeModuleId`. Empty array → uniquely owned by the excluded
100
+ * module, safe to delete.
101
+ */
102
+ async findFileOwners(args: {
103
+ saasRoot: string;
104
+ paths: readonly string[];
105
+ excludeModuleId: string;
106
+ }): Promise<Map<string, string[]>> {
107
+ const section = await this.read(args.saasRoot);
108
+ const claimedBy = new Map<string, string[]>();
109
+ for (const p of args.paths) claimedBy.set(p, []);
110
+ for (const otherModuleId of Object.keys(section.installed)) {
111
+ if (otherModuleId === args.excludeModuleId) continue;
112
+ const otherEntry = section.installed[otherModuleId];
113
+ if (!otherEntry) continue;
114
+ for (const otherFile of otherEntry.files) {
115
+ if (claimedBy.has(otherFile)) {
116
+ claimedBy.get(otherFile)!.push(otherModuleId);
117
+ }
118
+ }
119
+ }
120
+ return claimedBy;
121
+ },
122
+ } as const;