@crewhaus/spec-registry 0.1.3 → 0.1.5

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.
@@ -0,0 +1,26 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ export declare class SpecRegistryError extends CrewhausError {
3
+ readonly name = "SpecRegistryError";
4
+ constructor(message: string, cause?: unknown);
5
+ }
6
+ export type Manifest = {
7
+ versions: string[];
8
+ pins: Record<string, string>;
9
+ };
10
+ export interface RegistryAdapter {
11
+ put(name: string, version: string, yaml: string): Promise<void>;
12
+ get(name: string, version: string): Promise<string>;
13
+ list(name: string): Promise<ReadonlyArray<string>>;
14
+ listSpecs(): Promise<ReadonlyArray<string>>;
15
+ delete(name: string, version: string): Promise<void>;
16
+ pin(name: string, environment: string, version: string): Promise<void>;
17
+ aliasFor(name: string, environment: string): Promise<string | undefined>;
18
+ manifest(name: string): Promise<Manifest>;
19
+ pinForTenant(tenantId: string, name: string, environment: string, version: string): Promise<void>;
20
+ aliasForTenant(tenantId: string, name: string, environment: string): Promise<string | undefined>;
21
+ }
22
+ export type FileBackedRegistryOptions = {
23
+ /** Default: `.crewhaus/specs`. */
24
+ readonly rootDir: string;
25
+ };
26
+ export declare function createFileBackedRegistry(opts: FileBackedRegistryOptions): RegistryAdapter;
package/dist/index.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Section 28 — `spec-registry`. Multi-version spec storage with
3
+ * environment pinning + per-tenant overlays. File-backed by default;
4
+ * the `RegistryAdapter` interface accepts SQLite/Postgres/S3 plugins
5
+ * so production deployments can swap in their preferred storage.
6
+ *
7
+ * Layout (file-backed):
8
+ * <root>/
9
+ * <name>/
10
+ * v1.yaml
11
+ * v2.yaml
12
+ * manifest.json ← `{ versions: ["v1", "v2"], pins: { prod: "v2", staging: "v1" } }`
13
+ * <other-name>/
14
+ * ...
15
+ * _tenants/<tenantId>/
16
+ * <name>.json ← per-tenant pin overlay
17
+ *
18
+ * Operations:
19
+ * put(name, version, yaml) write a new version
20
+ * get(name, version) read a specific version
21
+ * list(name) all versions for a spec
22
+ * pin(name, env, version) attach an environment alias
23
+ * aliasFor(name, env) resolve env → version
24
+ * pinForTenant(tenantId, name, env, version)
25
+ * aliasForTenant(tenantId, name, env)
26
+ */
27
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { CrewhausError } from "@crewhaus/errors";
30
+ export class SpecRegistryError extends CrewhausError {
31
+ name = "SpecRegistryError";
32
+ constructor(message, cause) {
33
+ super("config", message, cause);
34
+ }
35
+ }
36
+ const MANIFEST_FILE = "manifest.json";
37
+ const NAME_REGEX = /^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/;
38
+ const VERSION_REGEX = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
39
+ const ENV_REGEX = /^[A-Za-z0-9_-]+$/;
40
+ const TENANT_REGEX = /^[A-Za-z0-9_-]+$/;
41
+ function ensureSafeName(s) {
42
+ if (!NAME_REGEX.test(s))
43
+ throw new SpecRegistryError(`invalid spec name "${s}"`);
44
+ }
45
+ function ensureSafeVersion(v) {
46
+ if (!VERSION_REGEX.test(v))
47
+ throw new SpecRegistryError(`invalid version "${v}"`);
48
+ }
49
+ function ensureSafeEnv(e) {
50
+ if (!ENV_REGEX.test(e))
51
+ throw new SpecRegistryError(`invalid environment "${e}"`);
52
+ }
53
+ function ensureSafeTenant(t) {
54
+ if (!TENANT_REGEX.test(t))
55
+ throw new SpecRegistryError(`invalid tenant id "${t}"`);
56
+ }
57
+ export function createFileBackedRegistry(opts) {
58
+ const rootDir = opts.rootDir;
59
+ function specDir(name) {
60
+ ensureSafeName(name);
61
+ return join(rootDir, name);
62
+ }
63
+ function specVersionPath(name, version) {
64
+ ensureSafeVersion(version);
65
+ return join(specDir(name), `${version}.yaml`);
66
+ }
67
+ function manifestPath(name) {
68
+ return join(specDir(name), MANIFEST_FILE);
69
+ }
70
+ function tenantDir(tenantId) {
71
+ ensureSafeTenant(tenantId);
72
+ return join(rootDir, "_tenants", tenantId);
73
+ }
74
+ function tenantPinPath(tenantId, name) {
75
+ return join(tenantDir(tenantId), `${name}.json`);
76
+ }
77
+ function loadManifest(name) {
78
+ const p = manifestPath(name);
79
+ if (!existsSync(p))
80
+ return { versions: [], pins: {} };
81
+ return JSON.parse(readFileSync(p, "utf8"));
82
+ }
83
+ function saveManifest(name, m) {
84
+ const p = manifestPath(name);
85
+ mkdirSync(specDir(name), { recursive: true });
86
+ writeFileSync(p, JSON.stringify(m, null, 2), { mode: 0o600 });
87
+ }
88
+ return {
89
+ async put(name, version, yaml) {
90
+ mkdirSync(specDir(name), { recursive: true });
91
+ const p = specVersionPath(name, version);
92
+ writeFileSync(p, yaml, { mode: 0o600 });
93
+ const m = loadManifest(name);
94
+ if (!m.versions.includes(version))
95
+ m.versions.push(version);
96
+ saveManifest(name, m);
97
+ },
98
+ async get(name, version) {
99
+ const p = specVersionPath(name, version);
100
+ if (!existsSync(p)) {
101
+ throw new SpecRegistryError(`spec "${name}" version "${version}" not found at ${p}`);
102
+ }
103
+ return readFileSync(p, "utf8");
104
+ },
105
+ async list(name) {
106
+ const m = loadManifest(name);
107
+ return [...m.versions].sort();
108
+ },
109
+ async listSpecs() {
110
+ if (!existsSync(rootDir))
111
+ return [];
112
+ return readdirSync(rootDir).filter((d) => d !== "_tenants" && !d.startsWith("_") && !d.startsWith("."));
113
+ },
114
+ async delete(name, version) {
115
+ const p = specVersionPath(name, version);
116
+ if (existsSync(p))
117
+ rmSync(p);
118
+ const m = loadManifest(name);
119
+ m.versions = m.versions.filter((v) => v !== version);
120
+ // Remove any pin pointing at the deleted version.
121
+ for (const [env, v] of Object.entries(m.pins)) {
122
+ if (v === version)
123
+ delete m.pins[env];
124
+ }
125
+ saveManifest(name, m);
126
+ },
127
+ async pin(name, environment, version) {
128
+ ensureSafeEnv(environment);
129
+ const m = loadManifest(name);
130
+ if (!m.versions.includes(version)) {
131
+ throw new SpecRegistryError(`cannot pin "${name}" "${environment}" → "${version}": version not in registry`);
132
+ }
133
+ m.pins[environment] = version;
134
+ saveManifest(name, m);
135
+ },
136
+ async aliasFor(name, environment) {
137
+ ensureSafeEnv(environment);
138
+ const m = loadManifest(name);
139
+ return m.pins[environment];
140
+ },
141
+ async manifest(name) {
142
+ return loadManifest(name);
143
+ },
144
+ async pinForTenant(tenantId, name, environment, version) {
145
+ ensureSafeTenant(tenantId);
146
+ ensureSafeName(name);
147
+ ensureSafeEnv(environment);
148
+ // Verify the version exists in the global registry first.
149
+ const m = loadManifest(name);
150
+ if (!m.versions.includes(version)) {
151
+ throw new SpecRegistryError(`cannot pin tenant "${tenantId}" "${name}" "${environment}" → "${version}": version not in registry`);
152
+ }
153
+ mkdirSync(tenantDir(tenantId), { recursive: true });
154
+ const path = tenantPinPath(tenantId, name);
155
+ let overlay = {};
156
+ if (existsSync(path))
157
+ overlay = JSON.parse(readFileSync(path, "utf8"));
158
+ overlay[environment] = version;
159
+ writeFileSync(path, JSON.stringify(overlay, null, 2), { mode: 0o600 });
160
+ },
161
+ async aliasForTenant(tenantId, name, environment) {
162
+ ensureSafeTenant(tenantId);
163
+ ensureSafeEnv(environment);
164
+ const path = tenantPinPath(tenantId, name);
165
+ if (existsSync(path)) {
166
+ const overlay = JSON.parse(readFileSync(path, "utf8"));
167
+ if (overlay[environment])
168
+ return overlay[environment];
169
+ }
170
+ // Fall through to global pin.
171
+ const m = loadManifest(name);
172
+ return m.pins[environment];
173
+ },
174
+ };
175
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/spec-registry",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Versioned spec storage with environment pinning + tenant overlays. File-backed default; pluggable for SQLite/Postgres/S3.",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.3",
16
- "@crewhaus/spec": "0.1.3"
18
+ "@crewhaus/errors": "0.1.5",
19
+ "@crewhaus/spec": "0.1.5"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
package/src/index.test.ts DELETED
@@ -1,127 +0,0 @@
1
- /**
2
- * Section 28 — `spec-registry` tests:
3
- * - T1 file backend round-trip (put/get/list/pin/aliasFor)
4
- * - T9 pin/aliasFor invariants
5
- * - T8 cross-tenant pin isolation
6
- */
7
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
- import { mkdtempSync, rmSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join } from "node:path";
11
- import { SpecRegistryError, createFileBackedRegistry } from "./index";
12
-
13
- let tmpRoot = "";
14
-
15
- beforeEach(() => {
16
- tmpRoot = mkdtempSync(join(tmpdir(), "spec-registry-test-"));
17
- });
18
-
19
- afterEach(() => {
20
- rmSync(tmpRoot, { recursive: true, force: true });
21
- });
22
-
23
- describe("spec-registry — T1 file backend", () => {
24
- test("put + get + list round-trip", async () => {
25
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
26
- await reg.put("hello-cli", "v1", "name: hello\ntarget: cli\n");
27
- await reg.put("hello-cli", "v2", "name: hello\ntarget: cli\nmodel: claude-opus-4-7\n");
28
- expect(await reg.list("hello-cli")).toEqual(["v1", "v2"]);
29
- expect(await reg.get("hello-cli", "v1")).toContain("target: cli");
30
- expect(await reg.get("hello-cli", "v2")).toContain("claude-opus-4-7");
31
- });
32
-
33
- test("get on missing version throws", async () => {
34
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
35
- expect(reg.get("missing", "v1")).rejects.toBeInstanceOf(SpecRegistryError);
36
- });
37
-
38
- test("listSpecs returns all spec names", async () => {
39
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
40
- await reg.put("a", "v1", "x");
41
- await reg.put("b", "v1", "y");
42
- const specs = await reg.listSpecs();
43
- expect([...specs].sort()).toEqual(["a", "b"]);
44
- });
45
-
46
- test("delete removes a version and any pointing pins", async () => {
47
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
48
- await reg.put("hello", "v1", "x");
49
- await reg.put("hello", "v2", "y");
50
- await reg.pin("hello", "prod", "v2");
51
- await reg.delete("hello", "v2");
52
- expect(await reg.list("hello")).toEqual(["v1"]);
53
- expect(await reg.aliasFor("hello", "prod")).toBeUndefined();
54
- });
55
-
56
- test("rejects malformed names / versions / envs", async () => {
57
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
58
- expect(reg.put("../etc/passwd", "v1", "")).rejects.toBeInstanceOf(SpecRegistryError);
59
- expect(reg.put("ok", "../v1", "")).rejects.toBeInstanceOf(SpecRegistryError);
60
- await reg.put("ok", "v1", "");
61
- expect(reg.pin("ok", "../prod", "v1")).rejects.toBeInstanceOf(SpecRegistryError);
62
- });
63
- });
64
-
65
- describe("spec-registry — T9 pin/aliasFor invariants", () => {
66
- test("pin requires the version to be in the registry", async () => {
67
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
68
- await reg.put("hello", "v1", "x");
69
- expect(reg.pin("hello", "prod", "v999")).rejects.toBeInstanceOf(SpecRegistryError);
70
- });
71
-
72
- test("aliasFor returns undefined for unknown env", async () => {
73
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
74
- await reg.put("hello", "v1", "x");
75
- expect(await reg.aliasFor("hello", "prod")).toBeUndefined();
76
- });
77
-
78
- test("re-pinning overwrites the previous pin", async () => {
79
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
80
- await reg.put("hello", "v1", "x");
81
- await reg.put("hello", "v2", "y");
82
- await reg.pin("hello", "prod", "v1");
83
- expect(await reg.aliasFor("hello", "prod")).toBe("v1");
84
- await reg.pin("hello", "prod", "v2");
85
- expect(await reg.aliasFor("hello", "prod")).toBe("v2");
86
- });
87
-
88
- test("multiple environments coexist", async () => {
89
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
90
- await reg.put("hello", "v1", "x");
91
- await reg.put("hello", "v2", "y");
92
- await reg.pin("hello", "prod", "v2");
93
- await reg.pin("hello", "staging", "v1");
94
- const m = await reg.manifest("hello");
95
- expect(m.pins).toEqual({ prod: "v2", staging: "v1" });
96
- });
97
- });
98
-
99
- describe("spec-registry — T8 cross-tenant isolation", () => {
100
- test("tenant pin overrides global pin only for that tenant", async () => {
101
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
102
- await reg.put("hello", "v1", "x");
103
- await reg.put("hello", "v2", "y");
104
- await reg.pin("hello", "prod", "v1");
105
- await reg.pinForTenant("tenant-a", "hello", "prod", "v2");
106
- expect(await reg.aliasFor("hello", "prod")).toBe("v1");
107
- expect(await reg.aliasForTenant("tenant-a", "hello", "prod")).toBe("v2");
108
- // Tenant B has no overlay → falls through to global pin.
109
- expect(await reg.aliasForTenant("tenant-b", "hello", "prod")).toBe("v1");
110
- });
111
-
112
- test("pinForTenant requires version exists in the registry", async () => {
113
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
114
- await reg.put("hello", "v1", "x");
115
- expect(reg.pinForTenant("tenant-a", "hello", "prod", "v2")).rejects.toBeInstanceOf(
116
- SpecRegistryError,
117
- );
118
- });
119
-
120
- test("rejects malformed tenant ids", async () => {
121
- const reg = createFileBackedRegistry({ rootDir: tmpRoot });
122
- await reg.put("hello", "v1", "x");
123
- expect(reg.pinForTenant("../bad", "hello", "prod", "v1")).rejects.toBeInstanceOf(
124
- SpecRegistryError,
125
- );
126
- });
127
- });
package/src/index.ts DELETED
@@ -1,202 +0,0 @@
1
- /**
2
- * Section 28 — `spec-registry`. Multi-version spec storage with
3
- * environment pinning + per-tenant overlays. File-backed by default;
4
- * the `RegistryAdapter` interface accepts SQLite/Postgres/S3 plugins
5
- * so production deployments can swap in their preferred storage.
6
- *
7
- * Layout (file-backed):
8
- * <root>/
9
- * <name>/
10
- * v1.yaml
11
- * v2.yaml
12
- * manifest.json ← `{ versions: ["v1", "v2"], pins: { prod: "v2", staging: "v1" } }`
13
- * <other-name>/
14
- * ...
15
- * _tenants/<tenantId>/
16
- * <name>.json ← per-tenant pin overlay
17
- *
18
- * Operations:
19
- * put(name, version, yaml) write a new version
20
- * get(name, version) read a specific version
21
- * list(name) all versions for a spec
22
- * pin(name, env, version) attach an environment alias
23
- * aliasFor(name, env) resolve env → version
24
- * pinForTenant(tenantId, name, env, version)
25
- * aliasForTenant(tenantId, name, env)
26
- */
27
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
28
- import { join } from "node:path";
29
- import { CrewhausError } from "@crewhaus/errors";
30
-
31
- export class SpecRegistryError extends CrewhausError {
32
- override readonly name = "SpecRegistryError";
33
- constructor(message: string, cause?: unknown) {
34
- super("config", message, cause);
35
- }
36
- }
37
-
38
- export type Manifest = {
39
- versions: string[];
40
- pins: Record<string, string>;
41
- };
42
-
43
- export interface RegistryAdapter {
44
- put(name: string, version: string, yaml: string): Promise<void>;
45
- get(name: string, version: string): Promise<string>;
46
- list(name: string): Promise<ReadonlyArray<string>>;
47
- listSpecs(): Promise<ReadonlyArray<string>>;
48
- delete(name: string, version: string): Promise<void>;
49
-
50
- pin(name: string, environment: string, version: string): Promise<void>;
51
- aliasFor(name: string, environment: string): Promise<string | undefined>;
52
- manifest(name: string): Promise<Manifest>;
53
-
54
- pinForTenant(tenantId: string, name: string, environment: string, version: string): Promise<void>;
55
- aliasForTenant(tenantId: string, name: string, environment: string): Promise<string | undefined>;
56
- }
57
-
58
- const MANIFEST_FILE = "manifest.json";
59
- const NAME_REGEX = /^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/;
60
- const VERSION_REGEX = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
61
- const ENV_REGEX = /^[A-Za-z0-9_-]+$/;
62
- const TENANT_REGEX = /^[A-Za-z0-9_-]+$/;
63
-
64
- function ensureSafeName(s: string): void {
65
- if (!NAME_REGEX.test(s)) throw new SpecRegistryError(`invalid spec name "${s}"`);
66
- }
67
- function ensureSafeVersion(v: string): void {
68
- if (!VERSION_REGEX.test(v)) throw new SpecRegistryError(`invalid version "${v}"`);
69
- }
70
- function ensureSafeEnv(e: string): void {
71
- if (!ENV_REGEX.test(e)) throw new SpecRegistryError(`invalid environment "${e}"`);
72
- }
73
- function ensureSafeTenant(t: string): void {
74
- if (!TENANT_REGEX.test(t)) throw new SpecRegistryError(`invalid tenant id "${t}"`);
75
- }
76
-
77
- export type FileBackedRegistryOptions = {
78
- /** Default: `.crewhaus/specs`. */
79
- readonly rootDir: string;
80
- };
81
-
82
- export function createFileBackedRegistry(opts: FileBackedRegistryOptions): RegistryAdapter {
83
- const rootDir = opts.rootDir;
84
-
85
- function specDir(name: string): string {
86
- ensureSafeName(name);
87
- return join(rootDir, name);
88
- }
89
- function specVersionPath(name: string, version: string): string {
90
- ensureSafeVersion(version);
91
- return join(specDir(name), `${version}.yaml`);
92
- }
93
- function manifestPath(name: string): string {
94
- return join(specDir(name), MANIFEST_FILE);
95
- }
96
- function tenantDir(tenantId: string): string {
97
- ensureSafeTenant(tenantId);
98
- return join(rootDir, "_tenants", tenantId);
99
- }
100
- function tenantPinPath(tenantId: string, name: string): string {
101
- return join(tenantDir(tenantId), `${name}.json`);
102
- }
103
-
104
- function loadManifest(name: string): Manifest {
105
- const p = manifestPath(name);
106
- if (!existsSync(p)) return { versions: [], pins: {} };
107
- return JSON.parse(readFileSync(p, "utf8")) as Manifest;
108
- }
109
- function saveManifest(name: string, m: Manifest): void {
110
- const p = manifestPath(name);
111
- mkdirSync(specDir(name), { recursive: true });
112
- writeFileSync(p, JSON.stringify(m, null, 2), { mode: 0o600 });
113
- }
114
-
115
- return {
116
- async put(name, version, yaml): Promise<void> {
117
- mkdirSync(specDir(name), { recursive: true });
118
- const p = specVersionPath(name, version);
119
- writeFileSync(p, yaml, { mode: 0o600 });
120
- const m = loadManifest(name);
121
- if (!m.versions.includes(version)) m.versions.push(version);
122
- saveManifest(name, m);
123
- },
124
- async get(name, version): Promise<string> {
125
- const p = specVersionPath(name, version);
126
- if (!existsSync(p)) {
127
- throw new SpecRegistryError(`spec "${name}" version "${version}" not found at ${p}`);
128
- }
129
- return readFileSync(p, "utf8");
130
- },
131
- async list(name): Promise<ReadonlyArray<string>> {
132
- const m = loadManifest(name);
133
- return [...m.versions].sort();
134
- },
135
- async listSpecs(): Promise<ReadonlyArray<string>> {
136
- if (!existsSync(rootDir)) return [];
137
- return readdirSync(rootDir).filter(
138
- (d) => d !== "_tenants" && !d.startsWith("_") && !d.startsWith("."),
139
- );
140
- },
141
- async delete(name, version): Promise<void> {
142
- const p = specVersionPath(name, version);
143
- if (existsSync(p)) rmSync(p);
144
- const m = loadManifest(name);
145
- m.versions = m.versions.filter((v) => v !== version);
146
- // Remove any pin pointing at the deleted version.
147
- for (const [env, v] of Object.entries(m.pins)) {
148
- if (v === version) delete m.pins[env];
149
- }
150
- saveManifest(name, m);
151
- },
152
- async pin(name, environment, version): Promise<void> {
153
- ensureSafeEnv(environment);
154
- const m = loadManifest(name);
155
- if (!m.versions.includes(version)) {
156
- throw new SpecRegistryError(
157
- `cannot pin "${name}" "${environment}" → "${version}": version not in registry`,
158
- );
159
- }
160
- m.pins[environment] = version;
161
- saveManifest(name, m);
162
- },
163
- async aliasFor(name, environment): Promise<string | undefined> {
164
- ensureSafeEnv(environment);
165
- const m = loadManifest(name);
166
- return m.pins[environment];
167
- },
168
- async manifest(name): Promise<Manifest> {
169
- return loadManifest(name);
170
- },
171
- async pinForTenant(tenantId, name, environment, version): Promise<void> {
172
- ensureSafeTenant(tenantId);
173
- ensureSafeName(name);
174
- ensureSafeEnv(environment);
175
- // Verify the version exists in the global registry first.
176
- const m = loadManifest(name);
177
- if (!m.versions.includes(version)) {
178
- throw new SpecRegistryError(
179
- `cannot pin tenant "${tenantId}" "${name}" "${environment}" → "${version}": version not in registry`,
180
- );
181
- }
182
- mkdirSync(tenantDir(tenantId), { recursive: true });
183
- const path = tenantPinPath(tenantId, name);
184
- let overlay: Record<string, string> = {};
185
- if (existsSync(path)) overlay = JSON.parse(readFileSync(path, "utf8"));
186
- overlay[environment] = version;
187
- writeFileSync(path, JSON.stringify(overlay, null, 2), { mode: 0o600 });
188
- },
189
- async aliasForTenant(tenantId, name, environment): Promise<string | undefined> {
190
- ensureSafeTenant(tenantId);
191
- ensureSafeEnv(environment);
192
- const path = tenantPinPath(tenantId, name);
193
- if (existsSync(path)) {
194
- const overlay = JSON.parse(readFileSync(path, "utf8")) as Record<string, string>;
195
- if (overlay[environment]) return overlay[environment];
196
- }
197
- // Fall through to global pin.
198
- const m = loadManifest(name);
199
- return m.pins[environment];
200
- },
201
- };
202
- }