@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.
- package/LICENSE +34 -0
- package/dist/esm/.builder.pid +9 -0
- package/dist/esm/__tests__/lockfile-repair.service.test.d.ts +0 -0
- package/dist/esm/__tests__/lockfile-repair.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/lockfile-repair.service.test.js.map +0 -0
- package/dist/esm/__tests__/module-adopter.service.test.d.ts +0 -0
- package/dist/esm/__tests__/module-adopter.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/module-adopter.service.test.js.map +0 -0
- package/dist/esm/__tests__/module-packager.service.test.d.ts +0 -0
- package/dist/esm/__tests__/module-packager.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/module-packager.service.test.js.map +0 -0
- package/dist/esm/__tests__/provider-materialization-hook.test.d.ts +0 -0
- package/dist/esm/__tests__/provider-materialization-hook.test.d.ts.map +0 -0
- package/dist/esm/__tests__/provider-materialization-hook.test.js.map +0 -0
- package/dist/esm/__tests__/provider-sync-runner.test.d.ts +0 -0
- package/dist/esm/__tests__/provider-sync-runner.test.d.ts.map +0 -0
- package/dist/esm/__tests__/provider-sync-runner.test.js.map +0 -0
- package/dist/esm/__tests__/registry-lockfile.service.test.d.ts +0 -0
- package/dist/esm/__tests__/registry-lockfile.service.test.d.ts.map +0 -0
- package/dist/esm/__tests__/registry-lockfile.service.test.js.map +0 -0
- package/dist/esm/__tests__/registry-server-client.test.d.ts +0 -0
- package/dist/esm/__tests__/registry-server-client.test.d.ts.map +0 -0
- package/dist/esm/__tests__/registry-server-client.test.js.map +0 -0
- package/dist/esm/index.d.ts +18 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +18 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/services/_http-client.d.ts +44 -0
- package/dist/esm/services/_http-client.d.ts.map +1 -0
- package/dist/esm/services/_http-client.js +75 -0
- package/dist/esm/services/_http-client.js.map +1 -0
- package/dist/esm/services/_zod-issue-formatting.d.ts +12 -0
- package/dist/esm/services/_zod-issue-formatting.d.ts.map +1 -0
- package/dist/esm/services/_zod-issue-formatting.js +47 -0
- package/dist/esm/services/_zod-issue-formatting.js.map +1 -0
- package/dist/esm/services/index.d.ts +10 -0
- package/dist/esm/services/index.d.ts.map +1 -0
- package/dist/esm/services/index.js +10 -0
- package/dist/esm/services/index.js.map +1 -0
- package/dist/esm/services/lockfile-repair.service.d.ts +68 -0
- package/dist/esm/services/lockfile-repair.service.d.ts.map +1 -0
- package/dist/esm/services/lockfile-repair.service.js +160 -0
- package/dist/esm/services/lockfile-repair.service.js.map +1 -0
- package/dist/esm/services/module-adopter.service.d.ts +59 -0
- package/dist/esm/services/module-adopter.service.d.ts.map +1 -0
- package/dist/esm/services/module-adopter.service.js +126 -0
- package/dist/esm/services/module-adopter.service.js.map +1 -0
- package/dist/esm/services/module-packager.service.d.ts +105 -0
- package/dist/esm/services/module-packager.service.d.ts.map +1 -0
- package/dist/esm/services/module-packager.service.js +212 -0
- package/dist/esm/services/module-packager.service.js.map +1 -0
- package/dist/esm/services/provider-materialization-hook.d.ts +32 -0
- package/dist/esm/services/provider-materialization-hook.d.ts.map +1 -0
- package/dist/esm/services/provider-materialization-hook.js +33 -0
- package/dist/esm/services/provider-materialization-hook.js.map +1 -0
- package/dist/esm/services/provider-sync-runner.d.ts +87 -0
- package/dist/esm/services/provider-sync-runner.d.ts.map +1 -0
- package/dist/esm/services/provider-sync-runner.js +207 -0
- package/dist/esm/services/provider-sync-runner.js.map +1 -0
- package/dist/esm/services/registry-companion-client.d.ts +39 -0
- package/dist/esm/services/registry-companion-client.d.ts.map +1 -0
- package/dist/esm/services/registry-companion-client.js +109 -0
- package/dist/esm/services/registry-companion-client.js.map +1 -0
- package/dist/esm/services/registry-lockfile.service.d.ts +56 -0
- package/dist/esm/services/registry-lockfile.service.d.ts.map +1 -0
- package/dist/esm/services/registry-lockfile.service.js +99 -0
- package/dist/esm/services/registry-lockfile.service.js.map +1 -0
- package/dist/esm/services/registry-server-client.d.ts +94 -0
- package/dist/esm/services/registry-server-client.d.ts.map +1 -0
- package/dist/esm/services/registry-server-client.js +225 -0
- package/dist/esm/services/registry-server-client.js.map +1 -0
- package/dist/esm/services/types.d.ts +22 -0
- package/dist/esm/services/types.d.ts.map +1 -0
- package/dist/esm/services/types.js +16 -0
- package/dist/esm/services/types.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +51 -0
- package/src/__tests__/lockfile-repair.service.test.ts +106 -0
- package/src/__tests__/module-adopter.service.test.ts +96 -0
- package/src/__tests__/module-packager.service.test.ts +258 -0
- package/src/__tests__/provider-materialization-hook.test.ts +47 -0
- package/src/__tests__/provider-sync-runner.test.ts +113 -0
- package/src/__tests__/registry-lockfile.service.test.ts +134 -0
- package/src/__tests__/registry-server-client.test.ts +312 -0
- package/src/index.ts +18 -0
- package/src/services/_http-client.ts +91 -0
- package/src/services/_zod-issue-formatting.ts +50 -0
- package/src/services/index.ts +9 -0
- package/src/services/lockfile-repair.service.ts +215 -0
- package/src/services/module-adopter.service.ts +155 -0
- package/src/services/module-packager.service.ts +251 -0
- package/src/services/provider-materialization-hook.ts +54 -0
- package/src/services/provider-sync-runner.ts +298 -0
- package/src/services/registry-companion-client.ts +163 -0
- package/src/services/registry-lockfile.service.ts +122 -0
- package/src/services/registry-server-client.ts +334 -0
- 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';
|