@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,134 @@
1
+ /**
2
+ * Phase 0.5 sanity tests for `RegistryLockfileService`.
3
+ *
4
+ * These pin the cross-section preservation invariants Phase 1 must
5
+ * inherit when it graduates the locking + atomic-write primitives
6
+ * into `platform-config-lib`. The test runs against a per-test
7
+ * tmpdir; the real `wildo-saas.lock.json` is never touched.
8
+ */
9
+
10
+ import { promises as fs } from 'node:fs';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
+ import { RegistryLockfileService } from '../services/registry-lockfile.service';
15
+ // L6 (third-pass review): the symbol is no longer re-exported from
16
+ // `wildo-module-client`; import it directly from `platform-config-lib`.
17
+ import { WILDO_SAAS_LOCK_RELATIVE_PATH } from '@wildo-ai/platform-config-lib';
18
+
19
+ let saasRoot: string;
20
+
21
+ beforeEach(async () => {
22
+ saasRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wildo-module-client-spec-'));
23
+ });
24
+
25
+ afterEach(async () => {
26
+ await fs.rm(saasRoot, { recursive: true, force: true });
27
+ });
28
+
29
+ describe('RegistryLockfileService', () => {
30
+ it('returns an empty modules section when the lockfile does not exist', async () => {
31
+ const section = await RegistryLockfileService.read(saasRoot);
32
+ expect(section.installed).toEqual({});
33
+ });
34
+
35
+ it('writes a fresh envelope with only sections.modules populated', async () => {
36
+ await RegistryLockfileService.upsertEntry(saasRoot, {
37
+ moduleId: 'acme/billing',
38
+ registryId: 'wildo',
39
+ registryUrl: 'https://registry.wildo.ai',
40
+ domain: 'acme',
41
+ version: '1.2.3',
42
+ artifactDigest: 'sha256:' + 'a'.repeat(64),
43
+ installedAt: '2026-05-05T12:00:00.000Z',
44
+ files: ['shared-lib/src/modules/acme-billing/index.ts'],
45
+ source: 'install',
46
+ });
47
+ const raw = await fs.readFile(path.join(saasRoot, WILDO_SAAS_LOCK_RELATIVE_PATH), 'utf8');
48
+ const env = JSON.parse(raw) as { schemaVersion: number; sections: Record<string, unknown> };
49
+ expect(env.schemaVersion).toBe(2);
50
+ expect((env.sections.modules as { installed: Record<string, unknown> }).installed['acme/billing'])
51
+ .toMatchObject({ moduleId: 'acme/billing', version: '1.2.3' });
52
+ expect(env.sections.providers).toBeUndefined();
53
+ });
54
+
55
+ it('preserves sections.providers and unknown sections across module-section mutation', async () => {
56
+ const lockPath = path.join(saasRoot, WILDO_SAAS_LOCK_RELATIVE_PATH);
57
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
58
+ const original = {
59
+ schemaVersion: 2 as const,
60
+ generatedAt: '2026-05-05T11:00:00.000Z',
61
+ sections: {
62
+ providers: {
63
+ runtimeScopes: {
64
+ backend: {
65
+ resend: {
66
+ ref: 'resend',
67
+ digest: 'sha256:fixture',
68
+ engineCapabilities: ['EMAIL_TRANSACTIONAL'],
69
+ providerCapabilities: ['EMAIL_TRANSACTIONAL'],
70
+ protocols: ['EMAIL_PROVIDER'],
71
+ secretsContractDigest: 'sha256:contract',
72
+ },
73
+ },
74
+ },
75
+ },
76
+ i18n: {
77
+ sectionVersion: 1,
78
+ units: { welcome: { inputSha: 'abc' } },
79
+ },
80
+ future: { whatever: ['preserved'] },
81
+ },
82
+ };
83
+ await fs.writeFile(lockPath, JSON.stringify(original, null, 2), 'utf8');
84
+
85
+ await RegistryLockfileService.mutate(saasRoot, (current) => ({
86
+ ...current,
87
+ installed: {
88
+ ...current.installed,
89
+ 'acme/billing': {
90
+ moduleId: 'acme/billing',
91
+ registryId: 'wildo',
92
+ registryUrl: 'https://registry.wildo.ai',
93
+ domain: 'acme',
94
+ version: '2.0.0',
95
+ artifactDigest: 'sha256:' + 'b'.repeat(64),
96
+ installedAt: '2026-05-05T12:00:00.000Z',
97
+ files: [],
98
+ source: 'install',
99
+ },
100
+ },
101
+ }));
102
+
103
+ const after = JSON.parse(await fs.readFile(lockPath, 'utf8'));
104
+ expect(after.sections.providers).toEqual(original.sections.providers);
105
+ expect(after.sections.i18n).toEqual(original.sections.i18n);
106
+ expect(after.sections.future).toEqual(original.sections.future);
107
+ expect(after.sections.modules.installed['acme/billing'].version).toBe('2.0.0');
108
+ // generatedAt MUST be refreshed.
109
+ expect(after.generatedAt).not.toBe(original.generatedAt);
110
+ });
111
+
112
+ it('rejects an invalid module entry with a Zod parse error and never writes the section', async () => {
113
+ const lockPath = path.join(saasRoot, WILDO_SAAS_LOCK_RELATIVE_PATH);
114
+ await expect(
115
+ RegistryLockfileService.mutate(saasRoot, () => ({
116
+ installed: {
117
+ 'acme/billing': {
118
+ // missing required fields on purpose
119
+ moduleId: 'acme/billing',
120
+ } as never,
121
+ },
122
+ })),
123
+ ).rejects.toThrow();
124
+ // The shared library pre-creates an empty envelope before locking
125
+ // (proper-lockfile requires the file to exist), so the FILE may
126
+ // exist — but `sections.modules` MUST NOT contain the invalid
127
+ // entry, and the original section (if any) is unchanged.
128
+ const raw = await fs.readFile(lockPath, 'utf8').catch(() => null);
129
+ if (raw !== null) {
130
+ const env = JSON.parse(raw) as { sections: { modules?: { installed?: Record<string, unknown> } } };
131
+ expect(env.sections.modules?.installed?.['acme/billing']).toBeUndefined();
132
+ }
133
+ });
134
+ });
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Phase 2 tests for `RegistryServerClient` real methods.
3
+ *
4
+ * We use Node's `node:http` to stand up a tiny in-process server
5
+ * so each route's wire shape, error classification, and digest
6
+ * verification can be exercised without depending on Phase 3's
7
+ * `platform-module-registry`.
8
+ */
9
+
10
+ import { createServer, type Server } from 'node:http';
11
+ import { createHash } from 'node:crypto';
12
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
13
+ import {
14
+ RegistryArtifactIntegrityMismatchError,
15
+ RegistryAuthForbiddenError,
16
+ RegistryAuthRequiredError,
17
+ RegistryMalformedPayloadError,
18
+ RegistryModuleNotFoundError,
19
+ RegistryVersionNotFoundError,
20
+ } from '@wildo-ai/platform-config-lib';
21
+ import { RegistryServerClient } from '../services/registry-server-client';
22
+
23
+ interface TestServer {
24
+ baseUrl: string;
25
+ close: () => Promise<void>;
26
+ server: Server;
27
+ }
28
+
29
+ async function startServer(
30
+ handler: (
31
+ req: import('node:http').IncomingMessage,
32
+ res: import('node:http').ServerResponse,
33
+ ) => void | Promise<void>,
34
+ ): Promise<TestServer> {
35
+ return new Promise((resolve) => {
36
+ const server = createServer((req, res) => {
37
+ Promise.resolve(handler(req, res)).catch((err) => {
38
+ res.writeHead(500);
39
+ res.end(String(err));
40
+ });
41
+ });
42
+ server.listen(0, '127.0.0.1', () => {
43
+ const addr = server.address();
44
+ if (!addr || typeof addr === 'string') throw new Error('no addr');
45
+ resolve({
46
+ baseUrl: `http://127.0.0.1:${addr.port}`,
47
+ close: () => new Promise((r) => server.close(() => r())),
48
+ server,
49
+ });
50
+ });
51
+ });
52
+ }
53
+
54
+ let svc: TestServer | null = null;
55
+
56
+ afterEach(async () => {
57
+ if (svc) {
58
+ await svc.close();
59
+ svc = null;
60
+ }
61
+ });
62
+
63
+ describe('RegistryServerClient.ping', () => {
64
+ it('hits /health (no /v1 prefix — framework auto-mounts the engine HealthController) and returns true on 200', async () => {
65
+ let observedPath: string | undefined;
66
+ svc = await startServer((req, res) => {
67
+ observedPath = req.url;
68
+ if (req.url === '/health') {
69
+ res.writeHead(200);
70
+ res.end('ok');
71
+ } else {
72
+ res.writeHead(404);
73
+ res.end();
74
+ }
75
+ });
76
+ expect(await RegistryServerClient.ping({ baseUrl: svc.baseUrl })).toBe(true);
77
+ // Wire-shape pin: a future re-prefixing of `ping()` should fail
78
+ // CI explicitly rather than silently re-introducing the /v1
79
+ // mismatch the second-pass review surfaced as a RED test.
80
+ expect(observedPath).toBe('/health');
81
+ });
82
+
83
+ it('returns false on network failure', async () => {
84
+ expect(await RegistryServerClient.ping({ baseUrl: 'http://127.0.0.1:1' })).toBe(false);
85
+ });
86
+ });
87
+
88
+ describe('RegistryServerClient.resolve', () => {
89
+ it('returns version + digest on success', async () => {
90
+ const VALID = 'sha256:' + 'a'.repeat(64);
91
+ svc = await startServer((req, res) => {
92
+ res.writeHead(200, { 'content-type': 'application/json' });
93
+ res.end(JSON.stringify({ version: '1.2.3', artifactDigest: VALID }));
94
+ });
95
+ const result = await RegistryServerClient.resolve(
96
+ { baseUrl: svc.baseUrl },
97
+ { domain: 'acme', moduleId: 'acme/billing', range: '^1.0.0' },
98
+ );
99
+ expect(result.version).toBe('1.2.3');
100
+ expect(result.artifactDigest).toBe(VALID);
101
+ });
102
+
103
+ // M8 (third-pass review): pin the wire URL shape so a future
104
+ // "encode the slash differently" refactor fails CI explicitly. The
105
+ // server route is `/v1/registry/:domain/modules/:moduleId/resolve`
106
+ // and a `<scope>/<name>` moduleId (`acme/billing`) is passed
107
+ // through `encodeURIComponent` → `acme%2Fbilling` so it lands as
108
+ // ONE path segment. Express's path-to-regexp captures the encoded
109
+ // segment, and the controller `String(req.params.moduleId)` gets
110
+ // the decoded `'acme/billing'` back — verified end-to-end here.
111
+ it('encodes a `<scope>/<name>` moduleId as a single %2F-escaped path segment', async () => {
112
+ const VALID = 'sha256:' + 'a'.repeat(64);
113
+ let observedPath: string | undefined;
114
+ svc = await startServer((req, res) => {
115
+ observedPath = req.url;
116
+ res.writeHead(200, { 'content-type': 'application/json' });
117
+ res.end(JSON.stringify({ version: '1.2.3', artifactDigest: VALID }));
118
+ });
119
+ await RegistryServerClient.resolve(
120
+ { baseUrl: svc.baseUrl },
121
+ { domain: 'wildo', moduleId: 'acme/billing', range: '^1.0.0' },
122
+ );
123
+ expect(observedPath).toBe(
124
+ '/v1/registry/wildo/modules/acme%2Fbilling/resolve?range=%5E1.0.0',
125
+ );
126
+ });
127
+
128
+ it('throws RegistryVersionNotFoundError on 404', async () => {
129
+ svc = await startServer((req, res) => {
130
+ res.writeHead(404);
131
+ res.end();
132
+ });
133
+ await expect(
134
+ RegistryServerClient.resolve(
135
+ { baseUrl: svc.baseUrl },
136
+ { domain: 'acme', moduleId: 'missing', range: '^1.0.0' },
137
+ ),
138
+ ).rejects.toBeInstanceOf(RegistryVersionNotFoundError);
139
+ });
140
+
141
+ it('throws RegistryAuthRequiredError on 401', async () => {
142
+ svc = await startServer((req, res) => {
143
+ res.writeHead(401);
144
+ res.end();
145
+ });
146
+ await expect(
147
+ RegistryServerClient.resolve(
148
+ { baseUrl: svc.baseUrl },
149
+ { domain: 'acme', moduleId: 'acme/billing', range: '^1.0.0' },
150
+ ),
151
+ ).rejects.toBeInstanceOf(RegistryAuthRequiredError);
152
+ });
153
+
154
+ it('throws RegistryAuthForbiddenError on 403', async () => {
155
+ svc = await startServer((req, res) => {
156
+ res.writeHead(403);
157
+ res.end();
158
+ });
159
+ await expect(
160
+ RegistryServerClient.resolve(
161
+ { baseUrl: svc.baseUrl },
162
+ { domain: 'acme', moduleId: 'acme/billing', range: '^1.0.0' },
163
+ ),
164
+ ).rejects.toBeInstanceOf(RegistryAuthForbiddenError);
165
+ });
166
+ });
167
+
168
+ describe('RegistryServerClient.info', () => {
169
+ it('returns the JSON body on 200', async () => {
170
+ svc = await startServer((req, res) => {
171
+ res.writeHead(200, { 'content-type': 'application/json' });
172
+ res.end(
173
+ JSON.stringify({
174
+ moduleId: 'acme/billing',
175
+ manifest: { id: 'acme/billing', version: '1.2.3' },
176
+ versions: [],
177
+ tags: { latest: '1.2.3' },
178
+ }),
179
+ );
180
+ });
181
+ const result = await RegistryServerClient.info(
182
+ { baseUrl: svc.baseUrl },
183
+ { domain: 'acme', moduleId: 'acme/billing' },
184
+ );
185
+ expect(result.moduleId).toBe('acme/billing');
186
+ expect(result.tags.latest).toBe('1.2.3');
187
+ });
188
+
189
+ it('throws RegistryModuleNotFoundError on 404', async () => {
190
+ svc = await startServer((req, res) => {
191
+ res.writeHead(404);
192
+ res.end();
193
+ });
194
+ await expect(
195
+ RegistryServerClient.info(
196
+ { baseUrl: svc.baseUrl },
197
+ { domain: 'acme', moduleId: 'missing' },
198
+ ),
199
+ ).rejects.toBeInstanceOf(RegistryModuleNotFoundError);
200
+ });
201
+ });
202
+
203
+ describe('RegistryServerClient.download', () => {
204
+ it('verifies the integrity digest and returns bytes', async () => {
205
+ const payload = Buffer.from('artifact-bytes');
206
+ const expectedDigest = `sha256:${createHash('sha256').update(payload).digest('hex')}`;
207
+ svc = await startServer((req, res) => {
208
+ res.writeHead(200, { 'content-type': 'application/zip' });
209
+ res.end(payload);
210
+ });
211
+ const result = await RegistryServerClient.download(
212
+ { baseUrl: svc.baseUrl },
213
+ {
214
+ domain: 'acme',
215
+ moduleId: 'acme/billing',
216
+ version: '1.2.3',
217
+ expectedDigest,
218
+ },
219
+ );
220
+ expect(Buffer.from(result.bytes).equals(payload)).toBe(true);
221
+ expect(result.integrity).toBe(expectedDigest);
222
+ });
223
+
224
+ it('throws RegistryArtifactIntegrityMismatchError on digest mismatch', async () => {
225
+ svc = await startServer((req, res) => {
226
+ res.writeHead(200);
227
+ res.end('different-bytes');
228
+ });
229
+ await expect(
230
+ RegistryServerClient.download(
231
+ { baseUrl: svc.baseUrl },
232
+ {
233
+ domain: 'acme',
234
+ moduleId: 'acme/billing',
235
+ version: '1.2.3',
236
+ expectedDigest: 'sha256:' + 'a'.repeat(64),
237
+ },
238
+ ),
239
+ ).rejects.toBeInstanceOf(RegistryArtifactIntegrityMismatchError);
240
+ });
241
+ });
242
+
243
+ /**
244
+ * 7th-pass C-M3: regression tests for the 6th-pass `parseJsonOrThrow`
245
+ * helper. The "misconfigured proxy returns HTML at 2xx" scenario that
246
+ * motivated the helper had no regression pin until this pass. One
247
+ * test per affected method covers `resolve`, `info`, `search`.
248
+ */
249
+ describe('RegistryServerClient — parseJsonOrThrow (malformed 2xx body)', () => {
250
+ it('resolve() throws RegistryMalformedPayloadError when the server returns HTML at 200', async () => {
251
+ svc = await startServer((req, res) => {
252
+ res.writeHead(200, { 'content-type': 'application/json' });
253
+ res.end('<html>not json</html>');
254
+ });
255
+ await expect(
256
+ RegistryServerClient.resolve(
257
+ { baseUrl: svc.baseUrl },
258
+ { domain: 'acme', moduleId: 'acme/billing', range: '^1.0.0' },
259
+ ),
260
+ ).rejects.toBeInstanceOf(RegistryMalformedPayloadError);
261
+ });
262
+
263
+ it('info() throws RegistryMalformedPayloadError when the server returns HTML at 200', async () => {
264
+ svc = await startServer((req, res) => {
265
+ res.writeHead(200, { 'content-type': 'application/json' });
266
+ res.end('<html>not json</html>');
267
+ });
268
+ await expect(
269
+ RegistryServerClient.info(
270
+ { baseUrl: svc.baseUrl },
271
+ { domain: 'acme', moduleId: 'acme/billing' },
272
+ ),
273
+ ).rejects.toBeInstanceOf(RegistryMalformedPayloadError);
274
+ });
275
+
276
+ it('search() throws RegistryMalformedPayloadError when the server returns HTML at 200', async () => {
277
+ svc = await startServer((req, res) => {
278
+ res.writeHead(200, { 'content-type': 'application/json' });
279
+ res.end('<html>not json</html>');
280
+ });
281
+ await expect(
282
+ RegistryServerClient.search(
283
+ { baseUrl: svc.baseUrl },
284
+ { domain: 'acme', query: 'billing' },
285
+ ),
286
+ ).rejects.toBeInstanceOf(RegistryMalformedPayloadError);
287
+ });
288
+ });
289
+
290
+ /**
291
+ * 7th-pass C-M1: `download()` argument-shape violation. Caller passes
292
+ * a malformed `expectedDigest` — must throw `TypeError` (not Zod's
293
+ * raw error, not `RegistryError`). Mirrors the precedent in
294
+ * `module-packager.service.ts > getModuleStagingPaths`.
295
+ */
296
+ describe('RegistryServerClient.download — malformed expectedDigest (C-M1)', () => {
297
+ it('throws TypeError when expectedDigest is not a sha256:<64-hex> string', async () => {
298
+ // No server needed — the digest validation runs BEFORE the
299
+ // network call.
300
+ await expect(
301
+ RegistryServerClient.download(
302
+ { baseUrl: 'http://localhost:1' },
303
+ {
304
+ domain: 'acme',
305
+ moduleId: 'acme/billing',
306
+ version: '1.2.3',
307
+ expectedDigest: 'not-a-valid-digest',
308
+ },
309
+ ),
310
+ ).rejects.toBeInstanceOf(TypeError);
311
+ });
312
+ });
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@wildo-ai/wildo-module-client` — runtime services for the Wildo
3
+ * module-registry workstream (KD-3).
4
+ *
5
+ * Phase 0.5 surface:
6
+ * - `RegistryServerClient` — HTTP client for the registry server.
7
+ * - `RegistryCompanionClient` — HTTP client for the dev companion.
8
+ * - `RegistryLockfileService` — section-scoped `sections.modules`
9
+ * reader/writer.
10
+ * - `runProviderMaterializationHook` — post-mutation no-op hook
11
+ * that delegates to `wildo config sync --domain config`.
12
+ *
13
+ * Phase 1+ adds `ModulePackagerService`, `ModuleAdopterService`,
14
+ * `LockfileRepairService`, and the real (locked + atomic) lockfile
15
+ * mutation surface.
16
+ */
17
+
18
+ export * from './services';
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Internal HTTP client utilities shared by `RegistryServerClient`
3
+ * and `RegistryCompanionClient`.
4
+ *
5
+ * Both clients want the same primitives:
6
+ * - `joinUrl(base, path)` — single-source-of-truth path joiner
7
+ * that tolerates leading/trailing slashes;
8
+ * - `fetchWithTimeout(url, init, timeoutMs)` — abort-on-timeout
9
+ * wrapper that converts `AbortError`/`TimeoutError` into a
10
+ * uniform diagnostic message;
11
+ * - `fetchJsonOk(...)` — convenience for liveness probes that
12
+ * want a boolean answer.
13
+ *
14
+ * The leading underscore signals "package-internal" — these are
15
+ * intentionally NOT re-exported from `services/index.ts`. Phase 2
16
+ * services that need fancier HTTP (multipart upload, streaming
17
+ * download) extend this file rather than copy the primitives.
18
+ */
19
+
20
+ const DEFAULT_TIMEOUT_MS = 10_000;
21
+
22
+ export function joinUrl(base: string, path: string): string {
23
+ const normalized = path.startsWith('/') ? path : `/${path}`;
24
+ return `${base.replace(/\/$/, '')}${normalized}`;
25
+ }
26
+
27
+ export interface FetchWithTimeoutInit extends RequestInit {
28
+ timeoutMs?: number;
29
+ /** Used in the diagnostic timeout message (e.g. "Registry", "Companion registry"). */
30
+ diagnosticLabel?: string;
31
+ }
32
+
33
+ export async function fetchWithTimeout(
34
+ url: string,
35
+ init: FetchWithTimeoutInit = {},
36
+ ): Promise<Response> {
37
+ const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
38
+ const label = init.diagnosticLabel ?? 'HTTP';
39
+ const signal = AbortSignal.timeout(timeoutMs);
40
+ // Strip the wrapper-only fields before forwarding to `fetch`. The
41
+ // `_` names are conventional unused-binding markers.
42
+ //
43
+ // 6th-pass M1 — tsconfig premise (corrected): the wider repo's
44
+ // `engine/tsconfig.common.json` currently sets both `noUnusedLocals`
45
+ // and `noUnusedParameters` to `false`, so any unused binding (with
46
+ // or without `_` prefix) compiles clean today. If either flag is
47
+ // ever flipped to `true`, TypeScript's underscore-prefix convention
48
+ // applies to parameters reliably and to destructured `let`/`const`
49
+ // bindings depending on TS version + setting — so the `_` prefix
50
+ // here is a defensive signal of "intentional discard" should that
51
+ // happen. Today it's purely cosmetic.
52
+ const { timeoutMs: _timeoutMs, diagnosticLabel: _diagnosticLabel, ...rest } = init;
53
+ try {
54
+ return await fetch(url, { ...rest, signal });
55
+ } catch (err) {
56
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) {
57
+ throw new Error(`${label} request timed out after ${timeoutMs}ms (${url})`);
58
+ }
59
+ throw err;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Throws on non-2xx; returns the Response otherwise.
65
+ *
66
+ * C-L2 (4th-pass review) waiver: this helper deliberately throws
67
+ * bare `Error` and is NOT part of the typed `RegistryError`
68
+ * vocabulary. It exists for liveness probes (`ping()`) that wrap
69
+ * the call in their own try/catch and convert any thrown exception
70
+ * to `false`. Future callers MUST do the same — if a caller wants
71
+ * to surface a typed error to the user, they should not use
72
+ * `fetchJsonOk` and should call `fetchWithTimeout` directly +
73
+ * inspect `res.ok` themselves to throw the right
74
+ * `RegistryUnexpectedStatusError` / `RegistryAuthRequiredError` /
75
+ * etc. variant.
76
+ *
77
+ * @internal — internal lib helper, not re-exported from the
78
+ * package root.
79
+ */
80
+ export async function fetchJsonOk(
81
+ url: string,
82
+ init: FetchWithTimeoutInit = {},
83
+ ): Promise<Response> {
84
+ const res = await fetchWithTimeout(url, init);
85
+ if (!res.ok) {
86
+ throw new Error(
87
+ `${init.diagnosticLabel ?? 'HTTP'} request rejected (${res.status} ${res.statusText}) at ${url}`,
88
+ );
89
+ }
90
+ return res;
91
+ }
@@ -0,0 +1,50 @@
1
+ import type { ZodError } from 'zod';
2
+
3
+ /**
4
+ * Package-internal mirror of `summarizeZodIssues` from `@wildo-ai/saas-models`,
5
+ * which is the canonical authority every other Wildo surface delegates to.
6
+ *
7
+ * zod-issue-formatter-mirror: engine/saas-models/src/errors/zod-issue-formatting.shared.utils.ts
8
+ *
9
+ * Why a mirror rather than an import: this package declares exactly ONE
10
+ * workspace dependency (`@wildo-ai/platform-config-lib`) plus a `zod` peer.
11
+ * Importing `saas-models` would pull its decorator-bound schema tree into a
12
+ * lean registry client to save a handful of lines — a bad trade. The boundary
13
+ * is the reason for the duplication; keep the RENDERING identical, and mirror
14
+ * any change to the canonical helper's path / root-label / overflow semantics
15
+ * here.
16
+ *
17
+ * The marker above is not decoration — `pnpm check:zod-issue-formatter-mirror`
18
+ * discovers this file by it and fails if the constants below drift from the
19
+ * canonical source it names. If the dependency constraint that justifies this
20
+ * mirror ever stops holding, delete the file and import the canonical formatter
21
+ * rather than keeping a mirror nobody needs.
22
+ *
23
+ * Within this package this is the single source: `registry-server-client.ts`
24
+ * and `lockfile-repair.service.ts` both call it instead of re-inlining the
25
+ * `.slice(0, 5).map(...).join('; ')` chain.
26
+ */
27
+
28
+ /** Label for an issue whose `path` is empty — the value itself failed. */
29
+ const ROOT_ISSUE_PATH_LABEL = '<root>';
30
+
31
+ /** Issue cap; a malformed payload can fail on hundreds of paths. */
32
+ const ISSUE_RENDER_LIMIT = 5;
33
+
34
+ /**
35
+ * Render a `ZodError` as one operator-readable line —
36
+ * `path: message; path: message; …` — bounded at {@link ISSUE_RENDER_LIMIT}
37
+ * with an explicit `(+N more)` so truncation is never silent.
38
+ *
39
+ * Used as the `detail` on `RegistryMalformedPayloadError` and on lockfile
40
+ * repair findings. The raw `error.message` in Zod 4 is the JSON-stringified
41
+ * issues array — verbose, multi-line, and unreadable in CLI / SIEM output.
42
+ */
43
+ export function formatZodIssuesAsSingleLine(error: ZodError): string {
44
+ const rendered = error.issues
45
+ .slice(0, ISSUE_RENDER_LIMIT)
46
+ .map((issue) => `${issue.path.join('.') || ROOT_ISSUE_PATH_LABEL}: ${issue.message}`);
47
+ const omitted = error.issues.length - ISSUE_RENDER_LIMIT;
48
+ if (omitted > 0) rendered.push(`(+${omitted} more)`);
49
+ return rendered.join('; ');
50
+ }
@@ -0,0 +1,9 @@
1
+ export * from './types';
2
+ export * from './registry-server-client';
3
+ export * from './registry-companion-client';
4
+ export * from './registry-lockfile.service';
5
+ export * from './provider-sync-runner';
6
+ export * from './provider-materialization-hook';
7
+ export * from './module-packager.service';
8
+ export * from './module-adopter.service';
9
+ export * from './lockfile-repair.service';