@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,215 @@
1
+ /**
2
+ * `LockfileRepairService` — Phase 2 read-only lockfile diagnostics.
3
+ *
4
+ * Backs `wildo registry repair-lockfile` (CLI command lands in a
5
+ * later step). The service does NOT mutate the lockfile by
6
+ * default — its job is to surface a structured diagnostic the
7
+ * operator can act on:
8
+ * - validate the canonical envelope shape;
9
+ * - validate `sections.modules` against
10
+ * `RegistryModulesLockSectionSchema`;
11
+ * - report which `installed[*].files` are missing on disk
12
+ * (drift between the lockfile and the source tree);
13
+ * - report which `installed[*].artifactDigest` strings are not
14
+ * `Sha256DigestSchema`-shaped (residue from pre-tightening
15
+ * publishes).
16
+ *
17
+ * Mutating repair is a Phase 7 deliverable — surfacing the gaps is
18
+ * what unblocks the operator now.
19
+ *
20
+ * `i18n` validation is intentionally NOT performed here (KD-15 +
21
+ * Review-pass-2 / MEDIUM-5): repair is the wrong place to
22
+ * second-guess companion-owned section semantics. The shared
23
+ * lockfile library STILL validates i18n on the companion-owned
24
+ * read path; this service only owns module diagnostics.
25
+ */
26
+
27
+ import { promises as fs } from 'node:fs';
28
+ import * as path from 'node:path';
29
+ import { z } from 'zod';
30
+ import { formatZodIssuesAsSingleLine } from './_zod-issue-formatting';
31
+ import {
32
+ RegistryModulesLockSectionSchema,
33
+ Sha256DigestSchema,
34
+ getWildoSaasLockPath,
35
+ type RegistryModulesLockEntry,
36
+ type RegistryModulesLockSection,
37
+ } from '@wildo-ai/platform-config-lib';
38
+
39
+ /**
40
+ * Permissive envelope schema used by repair only — it accepts any
41
+ * `sections.*` payload without delegating to per-section schemas
42
+ * so the repair flow can isolate "envelope is malformed" from
43
+ * "envelope is fine but modules section is malformed".
44
+ */
45
+ const RelaxedEnvelopeSchema = z.strictObject({
46
+ schemaVersion: z.literal(2),
47
+ generatedAt: z.iso.datetime({ offset: true }),
48
+ sections: z.record(z.string(), z.unknown()),
49
+ });
50
+
51
+ export type LockfileRepairFinding =
52
+ | {
53
+ severity: 'error';
54
+ kind: 'envelope-malformed';
55
+ message: string;
56
+ detail?: string;
57
+ }
58
+ | {
59
+ severity: 'error';
60
+ kind: 'modules-section-malformed';
61
+ message: string;
62
+ detail?: string;
63
+ }
64
+ | {
65
+ severity: 'warn';
66
+ kind: 'module-file-missing';
67
+ moduleId: string;
68
+ filePath: string;
69
+ }
70
+ | {
71
+ severity: 'warn';
72
+ kind: 'module-digest-not-sha256';
73
+ moduleId: string;
74
+ observedDigest: string;
75
+ };
76
+
77
+ export interface LockfileRepairReport {
78
+ /** Absolute path the report covers. */
79
+ lockfilePath: string;
80
+ /** True when no errors were produced (warnings allowed). */
81
+ ok: boolean;
82
+ findings: readonly LockfileRepairFinding[];
83
+ }
84
+
85
+ async function fileExists(absPath: string): Promise<boolean> {
86
+ try {
87
+ await fs.access(absPath);
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ export const LockfileRepairService = {
95
+ /** Compute the lockfile path for a saas root. */
96
+ pathFor(saasRoot: string): string {
97
+ return getWildoSaasLockPath(saasRoot);
98
+ },
99
+
100
+ /**
101
+ * Read the envelope + run every diagnostic. Read-only.
102
+ */
103
+ async inspect(saasRoot: string): Promise<LockfileRepairReport> {
104
+ const lockfilePath = getWildoSaasLockPath(saasRoot);
105
+ const findings: LockfileRepairFinding[] = [];
106
+
107
+ // Read raw bytes ourselves so we can produce per-section
108
+ // diagnostics: a malformed `modules` section under an
109
+ // otherwise-valid envelope should report `modules-section-malformed`,
110
+ // NOT `envelope-malformed` (the latter would tell the operator
111
+ // to re-author the whole file when only one section is bad).
112
+ let raw: string;
113
+ try {
114
+ raw = await fs.readFile(lockfilePath, 'utf8');
115
+ } catch (err: unknown) {
116
+ const errno = (err as NodeJS.ErrnoException)?.code;
117
+ if (errno === 'ENOENT') {
118
+ // Missing lockfile is "ok" from a repair POV.
119
+ return { lockfilePath, ok: true, findings: [] };
120
+ }
121
+ findings.push({
122
+ severity: 'error',
123
+ kind: 'envelope-malformed',
124
+ message: 'Failed to read lockfile bytes.',
125
+ detail: err instanceof Error ? err.message : String(err),
126
+ });
127
+ return { lockfilePath, ok: false, findings };
128
+ }
129
+
130
+ let parsedJson: unknown;
131
+ try {
132
+ parsedJson = JSON.parse(raw);
133
+ } catch (err) {
134
+ findings.push({
135
+ severity: 'error',
136
+ kind: 'envelope-malformed',
137
+ message: 'Lockfile is not valid JSON.',
138
+ detail: err instanceof Error ? err.message : String(err),
139
+ });
140
+ return { lockfilePath, ok: false, findings };
141
+ }
142
+
143
+ const envelopeParsed = RelaxedEnvelopeSchema.safeParse(parsedJson);
144
+ if (!envelopeParsed.success) {
145
+ findings.push({
146
+ severity: 'error',
147
+ kind: 'envelope-malformed',
148
+ message: 'Top-level envelope failed to parse.',
149
+ detail: formatZodIssuesAsSingleLine(envelopeParsed.error),
150
+ });
151
+ return { lockfilePath, ok: false, findings };
152
+ }
153
+ const envelope = envelopeParsed.data;
154
+
155
+ const modulesPayload = envelope.sections.modules;
156
+ if (modulesPayload === undefined) {
157
+ // No modules section present — nothing to inspect.
158
+ return { lockfilePath, ok: true, findings };
159
+ }
160
+
161
+ const parsed = RegistryModulesLockSectionSchema.safeParse(modulesPayload);
162
+ if (!parsed.success) {
163
+ findings.push({
164
+ severity: 'error',
165
+ kind: 'modules-section-malformed',
166
+ message: '`sections.modules` failed schema validation.',
167
+ detail: formatZodIssuesAsSingleLine(parsed.error),
168
+ });
169
+ return { lockfilePath, ok: false, findings };
170
+ }
171
+ const modules: RegistryModulesLockSection = parsed.data;
172
+
173
+ for (const [moduleId, entry] of Object.entries(modules.installed)) {
174
+ await this.collectEntryFindings(saasRoot, moduleId, entry, findings);
175
+ }
176
+
177
+ const ok = findings.every((f) => f.severity !== 'error');
178
+ return { lockfilePath, ok, findings };
179
+ },
180
+
181
+ /**
182
+ * Per-entry diagnostic helper. Exposed so Phase 7 mutating-
183
+ * repair flows can reuse the same predicates.
184
+ */
185
+ async collectEntryFindings(
186
+ saasRoot: string,
187
+ moduleId: string,
188
+ entry: RegistryModulesLockEntry,
189
+ findings: LockfileRepairFinding[],
190
+ ): Promise<void> {
191
+ if (!Sha256DigestSchema.safeParse(entry.artifactDigest).success) {
192
+ findings.push({
193
+ severity: 'warn',
194
+ kind: 'module-digest-not-sha256',
195
+ moduleId,
196
+ observedDigest: entry.artifactDigest,
197
+ });
198
+ }
199
+ for (const filePath of entry.files) {
200
+ const abs = path.resolve(saasRoot, filePath);
201
+ if (!(await fileExists(abs))) {
202
+ findings.push({
203
+ severity: 'warn',
204
+ kind: 'module-file-missing',
205
+ moduleId,
206
+ filePath,
207
+ });
208
+ }
209
+ }
210
+ },
211
+ } as const;
212
+
213
+ // L6 (third-pass review): re-export removed — barrel-discipline rule
214
+ // says "Never re-export except in the root `index.ts`." Consumers
215
+ // import the symbol directly from `@wildo-ai/platform-config-lib`.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `ModuleAdopterService` — Phase 2 CLI-direct adoption flow.
3
+ *
4
+ * `wildo registry adopt` takes existing app code that fits the
5
+ * Wildo module-artifact shape and prepares it for publication
6
+ * (KD-14). Adoption is intentionally CLI-DIRECT (Review-pass-3 /
7
+ * MEDIUM-G) — it only reads source slices and stages files. AST
8
+ * mutations on the host app's source are NOT part of adopt; if a
9
+ * future flow needs them, they go through the companion HTTP
10
+ * surface like every other source-mutating verb.
11
+ *
12
+ * Service surface:
13
+ * - `discoverModuleFiles(saasRoot, manifest)` — walks each
14
+ * declared `boundaries.*` allow-list and returns the set of
15
+ * files that exist on disk and are within the boundary.
16
+ * - `adoptToStaging(saasRoot, manifest)` — clears + populates
17
+ * `<saasRoot>/.wildo-saas/generated/modules-artifacts/<id>/`
18
+ * with the manifest + every discovered file, then computes
19
+ * the staging digest. Phase 6 plugs this into the publish
20
+ * flow.
21
+ */
22
+
23
+ import { promises as fs } from 'node:fs';
24
+ import * as path from 'node:path';
25
+ import type { ModuleManifest } from '@wildo-ai/platform-config-lib';
26
+ import { ModulePackagerService, type StagingPaths } from './module-packager.service';
27
+
28
+ export interface AdoptionPlan {
29
+ /** Per-layer file list, relative to saas root. */
30
+ files: readonly string[];
31
+ /** Empty when every declared boundary contributed at least one file. */
32
+ emptyBoundaries: readonly string[];
33
+ }
34
+
35
+ export interface AdoptionResult {
36
+ manifest: ModuleManifest;
37
+ staging: StagingPaths;
38
+ /** Files that were copied into the staging root. */
39
+ staged: readonly string[];
40
+ /** sha256 digest of the staged tree (matches `Sha256DigestSchema`). */
41
+ digest: string;
42
+ }
43
+
44
+ async function fileExists(absPath: string): Promise<boolean> {
45
+ try {
46
+ const stat = await fs.stat(absPath);
47
+ return stat.isFile() || stat.isDirectory();
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ async function walkBoundary(
54
+ saasRoot: string,
55
+ appRelative: string,
56
+ ): Promise<string[]> {
57
+ const abs = path.resolve(saasRoot, appRelative);
58
+ if (!(await fileExists(abs))) return [];
59
+ const stat = await fs.stat(abs);
60
+ if (stat.isFile()) return [appRelative];
61
+ const out: string[] = [];
62
+ const queue: string[] = [appRelative];
63
+ while (queue.length > 0) {
64
+ const cur = queue.shift()!;
65
+ const entries = await fs.readdir(path.resolve(saasRoot, cur), { withFileTypes: true });
66
+ for (const entry of entries) {
67
+ const rel = path.join(cur, entry.name);
68
+ if (entry.isDirectory()) {
69
+ queue.push(rel);
70
+ } else if (entry.isFile()) {
71
+ out.push(rel);
72
+ }
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ export const ModuleAdopterService = {
79
+ /**
80
+ * Walk every declared `boundaries.*` allow-list and return the
81
+ * files that actually exist on disk.
82
+ */
83
+ async discoverModuleFiles(
84
+ saasRoot: string,
85
+ manifest: ModuleManifest,
86
+ ): Promise<AdoptionPlan> {
87
+ const boundaries = manifest.boundaries;
88
+ if (!boundaries) {
89
+ return { files: [], emptyBoundaries: [] };
90
+ }
91
+ const layers: ReadonlyArray<keyof typeof boundaries> = [
92
+ 'specifications',
93
+ 'shared',
94
+ 'frontend',
95
+ 'backend',
96
+ 'minions',
97
+ 'workers',
98
+ ];
99
+ const aggregate = new Set<string>();
100
+ const empty: string[] = [];
101
+ for (const layer of layers) {
102
+ const allows = boundaries[layer];
103
+ let layerCount = 0;
104
+ for (const rel of allows) {
105
+ const collected = await walkBoundary(saasRoot, rel);
106
+ for (const file of collected) aggregate.add(file);
107
+ layerCount += collected.length;
108
+ }
109
+ if (allows.length > 0 && layerCount === 0) empty.push(layer);
110
+ }
111
+ return {
112
+ files: [...aggregate].sort(),
113
+ emptyBoundaries: empty,
114
+ };
115
+ },
116
+
117
+ /**
118
+ * Stage the module from existing app source. Idempotent — calling
119
+ * twice with the same source produces the same digest.
120
+ */
121
+ async adoptToStaging(
122
+ saasRoot: string,
123
+ manifest: ModuleManifest,
124
+ ): Promise<AdoptionResult> {
125
+ const plan = await this.discoverModuleFiles(saasRoot, manifest);
126
+ const { staging, written } = await this.runAdoptionPlan(saasRoot, manifest, plan);
127
+ const digest = await ModulePackagerService.computeStagingDigest(saasRoot, manifest.id);
128
+ return { manifest, staging, staged: written, digest };
129
+ },
130
+
131
+ /**
132
+ * Re-usable apply step: writes manifest + copies every file in
133
+ * the plan. Exposed so Phase 6 publish can reuse it after a
134
+ * separate staging-prep step.
135
+ */
136
+ async runAdoptionPlan(
137
+ saasRoot: string,
138
+ manifest: ModuleManifest,
139
+ plan: AdoptionPlan,
140
+ ): Promise<{ staging: StagingPaths; written: readonly string[] }> {
141
+ const staging = await ModulePackagerService.stageFresh(saasRoot, manifest.id);
142
+ await ModulePackagerService.writeManifest({ saasRoot, manifest });
143
+ const written: string[] = [];
144
+ for (const appRelativePath of plan.files) {
145
+ const result = await ModulePackagerService.copyFile({
146
+ saasRoot,
147
+ moduleId: manifest.id,
148
+ appRelativePath,
149
+ manifest,
150
+ });
151
+ written.push(result.appRelativePath);
152
+ }
153
+ return { staging, written };
154
+ },
155
+ } as const;
@@ -0,0 +1,251 @@
1
+ /**
2
+ * `ModulePackagerService` — Phase 2 staging logic for module
3
+ * publishing.
4
+ *
5
+ * Per KD-8 / `module-registry-analysis.md`:
6
+ * - Published artifacts are zip archives staged under
7
+ * `<saasRoot>/.wildo-saas/generated/modules-artifacts/<module.id>/`.
8
+ * - The staged folder is the source of the artifact. It must
9
+ * include the `wildo.module.json` manifest, copied source
10
+ * files, and dependency declarations.
11
+ *
12
+ * Phase 2 ships the staging-folder primitives (path resolution,
13
+ * stage-fresh, copy-source-tree, write-manifest, compute-digest).
14
+ * Phase 6 wires `wildo registry publish` to call these and the zip
15
+ * step. Phase 5 install consumes the same digest.
16
+ *
17
+ * Hard contracts:
18
+ * - Staging is always a fresh tree per call (`stageFresh`
19
+ * removes the destination first). The artifact's content
20
+ * digest is over-the-wire deterministic — leftover bytes
21
+ * from a previous failed stage MUST NOT leak into the digest.
22
+ * - The staged tree is a CHILD of `<saasRoot>/.wildo-saas/`,
23
+ * never reaches outside `saasRoot`.
24
+ * - Boundary enforcement (KD-12) lives in this service: the
25
+ * staging primitive rejects file paths that escape the
26
+ * declared `boundaries.<layer>` allow-list, so a publish
27
+ * can never claim app surface it doesn't own.
28
+ */
29
+
30
+ import { createHash } from 'node:crypto';
31
+ import { promises as fs } from 'node:fs';
32
+ import * as path from 'node:path';
33
+ import {
34
+ ModuleManifestSchema,
35
+ RegistryBoundaryViolationError,
36
+ type ModuleManifest,
37
+ type ModuleManifestBoundaries,
38
+ } from '@wildo-ai/platform-config-lib';
39
+
40
+ export const MODULES_ARTIFACTS_RELATIVE_DIR = '.wildo-saas/generated/modules-artifacts';
41
+ const MANIFEST_FILE_NAME = 'wildo.module.json';
42
+
43
+ export interface StagingPaths {
44
+ /** Absolute path to the per-module staging root. */
45
+ stagingRoot: string;
46
+ /** Absolute path to the manifest file inside the staging root. */
47
+ manifestPath: string;
48
+ }
49
+
50
+ /**
51
+ * Resolve the per-module staging paths. Pure path arithmetic — no
52
+ * fs side effects.
53
+ */
54
+ export function getModuleStagingPaths(saasRoot: string, moduleId: string): StagingPaths {
55
+ if (!moduleId.includes('/')) {
56
+ // C-L3 (4th-pass review): `TypeError` is Node's standard for
57
+ // argument-shape violations. The typed `RegistryError` subclasses
58
+ // model registry-runtime conditions (auth, integrity, version
59
+ // not found); a malformed `moduleId` argument is a developer
60
+ // contract violation that downstream `isRegistryError(err)`
61
+ // handlers should NOT catch. `TypeError` keeps the error visible
62
+ // and stops the typed-error vocabulary from absorbing pure
63
+ // argument-validation concerns.
64
+ throw new TypeError(
65
+ `Module id "${moduleId}" must be in <domain>/<name> form (e.g. "acme/billing").`,
66
+ );
67
+ }
68
+ const stagingRoot = path.resolve(saasRoot, MODULES_ARTIFACTS_RELATIVE_DIR, moduleId);
69
+ return {
70
+ stagingRoot,
71
+ manifestPath: path.join(stagingRoot, MANIFEST_FILE_NAME),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Returns `true` iff `child` is the same path as `parent` or
77
+ * a descendant of it. Both arguments are expected to be absolute,
78
+ * already resolved (no `..` segments). Tail-`/` arithmetic prevents
79
+ * the classic `/foo/barx` → `/foo/bar` false-positive.
80
+ *
81
+ * Extracted as a helper (5th-pass C-L2) so the boundary check below
82
+ * and the future zip-slip / symlink-escape guards in this package
83
+ * share one reading of the containment rule.
84
+ *
85
+ * 6th-pass C-L1 — root edge case: when `parent` is the filesystem
86
+ * root (`/` on POSIX, `C:\` on Windows), appending `path.sep` would
87
+ * produce `//` or `C:\\` and `startsWith` would reject every child.
88
+ * Treat the root as a special case: any absolute child path is
89
+ * "inside" the root by definition. Vanishingly unlikely in practice
90
+ * (containerized SaaS roots are not literally `/`) but the helper
91
+ * is also reusable for zip-slip guards where the assumption is
92
+ * weaker.
93
+ */
94
+ function isPathInside(child: string, parent: string): boolean {
95
+ // Special-case the filesystem root: any absolute child is inside it.
96
+ if (parent === path.sep || /^[a-zA-Z]:[\\/]?$/.test(parent)) {
97
+ return path.isAbsolute(child);
98
+ }
99
+ const childWithSep = child + path.sep;
100
+ const parentWithSep = parent + path.sep;
101
+ return childWithSep.startsWith(parentWithSep);
102
+ }
103
+
104
+ /**
105
+ * Verify a candidate file path is contained inside one of the
106
+ * manifest's declared `boundaries` (KD-12). Returns the resolved
107
+ * absolute path on success; throws `RegistryBoundaryViolationError`
108
+ * otherwise.
109
+ */
110
+ export function assertWithinBoundaries(
111
+ saasRoot: string,
112
+ moduleId: string,
113
+ appRelativePath: string,
114
+ boundaries: ModuleManifestBoundaries | undefined,
115
+ ): string {
116
+ const absolute = path.resolve(saasRoot, appRelativePath);
117
+ // Path must stay inside saasRoot (no `..` escapes).
118
+ if (!isPathInside(absolute, path.resolve(saasRoot))) {
119
+ throw new RegistryBoundaryViolationError(moduleId, appRelativePath);
120
+ }
121
+ if (!boundaries) return absolute;
122
+ const allowed: readonly string[] = [
123
+ ...boundaries.specifications,
124
+ ...boundaries.shared,
125
+ ...boundaries.frontend,
126
+ ...boundaries.backend,
127
+ ...boundaries.minions,
128
+ ...boundaries.workers,
129
+ ];
130
+ if (allowed.length === 0) return absolute;
131
+ // The candidate must be inside at least one declared boundary.
132
+ const isInside = allowed.some((rel) => isPathInside(absolute, path.resolve(saasRoot, rel)));
133
+ if (!isInside) {
134
+ throw new RegistryBoundaryViolationError(moduleId, appRelativePath);
135
+ }
136
+ return absolute;
137
+ }
138
+
139
+ export const ModulePackagerService = {
140
+ /**
141
+ * Compute the per-module staging paths.
142
+ */
143
+ paths(saasRoot: string, moduleId: string): StagingPaths {
144
+ return getModuleStagingPaths(saasRoot, moduleId);
145
+ },
146
+
147
+ /**
148
+ * Remove and recreate the staging root. The freshness guarantee
149
+ * is critical for digest determinism — `stageFresh` is the only
150
+ * way to enter the per-module staging root.
151
+ */
152
+ async stageFresh(saasRoot: string, moduleId: string): Promise<StagingPaths> {
153
+ const paths = getModuleStagingPaths(saasRoot, moduleId);
154
+ await fs.rm(paths.stagingRoot, { recursive: true, force: true });
155
+ await fs.mkdir(paths.stagingRoot, { recursive: true });
156
+ return paths;
157
+ },
158
+
159
+ /**
160
+ * Copy a single file from the app source into the staging tree.
161
+ * Path is verified against the manifest's declared boundaries
162
+ * (KD-12) — files outside `boundaries.*` are rejected with
163
+ * `RegistryBoundaryViolationError`. The relative path INSIDE the
164
+ * staging root mirrors the relative path inside the saas root,
165
+ * so callers don't need to translate paths between the two
166
+ * trees.
167
+ */
168
+ async copyFile(args: {
169
+ saasRoot: string;
170
+ moduleId: string;
171
+ appRelativePath: string;
172
+ manifest: ModuleManifest;
173
+ }): Promise<{ stagedAbsolute: string; appRelativePath: string }> {
174
+ const { saasRoot, moduleId, appRelativePath, manifest } = args;
175
+ const sourceAbsolute = assertWithinBoundaries(
176
+ saasRoot,
177
+ moduleId,
178
+ appRelativePath,
179
+ manifest.boundaries,
180
+ );
181
+ const { stagingRoot } = getModuleStagingPaths(saasRoot, moduleId);
182
+ const destAbsolute = path.join(stagingRoot, appRelativePath);
183
+ await fs.mkdir(path.dirname(destAbsolute), { recursive: true });
184
+ await fs.copyFile(sourceAbsolute, destAbsolute);
185
+ return { stagedAbsolute: destAbsolute, appRelativePath };
186
+ },
187
+
188
+ /**
189
+ * Validate the manifest, then write `wildo.module.json` into the
190
+ * staging root. Manifest authors who pass a typo-laden object
191
+ * get a Zod parse error here, before any zip work.
192
+ */
193
+ async writeManifest(args: {
194
+ saasRoot: string;
195
+ manifest: ModuleManifest;
196
+ }): Promise<{ manifestPath: string; manifest: ModuleManifest }> {
197
+ const validated = ModuleManifestSchema.parse(args.manifest);
198
+ const { manifestPath, stagingRoot } = getModuleStagingPaths(args.saasRoot, validated.id);
199
+ await fs.mkdir(stagingRoot, { recursive: true });
200
+ await fs.writeFile(manifestPath, JSON.stringify(validated, null, 2) + '\n', 'utf8');
201
+ return { manifestPath, manifest: validated };
202
+ },
203
+
204
+ /**
205
+ * Compute the sha256 digest of every file in the staging tree, in
206
+ * stable lexicographic path order. Returns the canonical
207
+ * `sha256:<64-hex>` form.
208
+ *
209
+ * The digest is over-the-wire deterministic for the same set of
210
+ * staged files because:
211
+ * 1. File order is sorted lexicographically (no fs traversal
212
+ * randomness leaks in).
213
+ * 2. The path prefix is the relative-from-staging-root path
214
+ * (no absolute-path noise).
215
+ * 3. We append the file content bytes verbatim.
216
+ */
217
+ async computeStagingDigest(saasRoot: string, moduleId: string): Promise<string> {
218
+ const { stagingRoot } = getModuleStagingPaths(saasRoot, moduleId);
219
+ const files = await collectFilesRelative(stagingRoot, '');
220
+ // Normalize separators to POSIX before sorting + hashing — the
221
+ // digest must reproduce across platforms for the same staged
222
+ // tree, so the bytes fed into the hash cannot depend on the
223
+ // publisher's OS path-separator convention.
224
+ const normalized = files.map((f) => f.split(path.sep).join(path.posix.sep));
225
+ normalized.sort();
226
+ const hash = createHash('sha256');
227
+ for (const rel of normalized) {
228
+ const abs = path.join(stagingRoot, rel.split(path.posix.sep).join(path.sep));
229
+ hash.update(rel);
230
+ hash.update('\0');
231
+ hash.update(await fs.readFile(abs));
232
+ hash.update('\0');
233
+ }
234
+ return `sha256:${hash.digest('hex')}`;
235
+ },
236
+ } as const;
237
+
238
+ async function collectFilesRelative(rootAbs: string, prefix: string): Promise<string[]> {
239
+ const here = path.join(rootAbs, prefix);
240
+ const entries = await fs.readdir(here, { withFileTypes: true });
241
+ const out: string[] = [];
242
+ for (const entry of entries) {
243
+ const rel = path.join(prefix, entry.name);
244
+ if (entry.isDirectory()) {
245
+ out.push(...(await collectFilesRelative(rootAbs, rel)));
246
+ } else if (entry.isFile()) {
247
+ out.push(rel);
248
+ }
249
+ }
250
+ return out;
251
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `runProviderMaterializationHook` — Phase 0.5 dry-run hook.
3
+ *
4
+ * Phase 2 superseded this by `runProviderSync` (the real
5
+ * `wildo config sync --domain config` shell-out with classified
6
+ * failure surfaces). This file is kept as a thin
7
+ * `dryRun: true` forwarder so the Phase 0.5 lockfile-smoke
8
+ * command and any external caller continue to work unchanged.
9
+ *
10
+ * @deprecated Phase 2+ flows MUST call `runProviderSync` directly.
11
+ * This forwarder will be removed once Wonder Todos's
12
+ * `lockfile-smoke` command is rewritten in the Phase 4 CLI
13
+ * read-flow series.
14
+ */
15
+
16
+ import { PROVIDER_LOCK_SECTION_PATH } from '@wildo-ai/platform-config-lib';
17
+ import { runProviderSync } from './provider-sync-runner';
18
+ import type { RegistryClientLogger } from './types';
19
+
20
+ export interface ProviderMaterializationHookInput {
21
+ saasRoot: string;
22
+ reason: 'install' | 'update' | 'remove' | 'adopt' | 'smoke';
23
+ log?: RegistryClientLogger;
24
+ }
25
+
26
+ export interface ProviderMaterializationHookResult {
27
+ invoked: true;
28
+ mode: 'dry-run' | 'shell-out';
29
+ performedFsWrite: false;
30
+ providerJsonRelativePaths: readonly string[];
31
+ providerSourceRelativePath: string;
32
+ providerLockSectionPath: typeof PROVIDER_LOCK_SECTION_PATH;
33
+ reason: ProviderMaterializationHookInput['reason'];
34
+ }
35
+
36
+ export async function runProviderMaterializationHook(
37
+ input: ProviderMaterializationHookInput,
38
+ ): Promise<ProviderMaterializationHookResult> {
39
+ const result = await runProviderSync({
40
+ saasRoot: input.saasRoot,
41
+ reason: input.reason,
42
+ log: input.log,
43
+ dryRun: true,
44
+ });
45
+ return {
46
+ invoked: true,
47
+ mode: result.mode,
48
+ performedFsWrite: false,
49
+ providerJsonRelativePaths: result.providerJsonRelativePaths,
50
+ providerSourceRelativePath: result.providerSourceRelativePath,
51
+ providerLockSectionPath: result.providerLockSectionPath,
52
+ reason: result.reason,
53
+ };
54
+ }