@powerhousedao/registry 6.0.0-dev.99 → 6.0.0
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/{dist/cdn-cache/@powerhousedao/vetra/6.0.0-dev.59/LICENSE → LICENSE} +1 -1
- package/README.md +177 -28
- package/dist/cli.d.mts +49 -0
- package/dist/cli.d.mts.map +1 -0
- package/dist/cli.mjs +1059 -0
- package/dist/cli.mjs.map +1 -0
- package/package.json +18 -13
- package/static/favicon.ico +0 -0
- package/static/logo.svg +11 -0
- package/dist/bundle.d.ts +0 -2
- package/dist/bundle.d.ts.map +0 -1
- package/dist/cdn-cache/@powerhousedao/vetra/6.0.0-dev.59/package.json +0 -114
- package/dist/src/cdn.d.ts +0 -12
- package/dist/src/cdn.d.ts.map +0 -1
- package/dist/src/cli.d.ts +0 -32
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -161040
- package/dist/src/constants.d.ts +0 -4
- package/dist/src/constants.d.ts.map +0 -1
- package/dist/src/index.d.ts +0 -6
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js +0 -74
- package/dist/src/middleware.d.ts +0 -6
- package/dist/src/middleware.d.ts.map +0 -1
- package/dist/src/packages.d.ts +0 -5
- package/dist/src/packages.d.ts.map +0 -1
- package/dist/src/run.d.ts +0 -3
- package/dist/src/run.d.ts.map +0 -1
- package/dist/src/types.d.ts +0 -69
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/verdaccio-config.d.ts +0 -3
- package/dist/src/verdaccio-config.d.ts.map +0 -1
- package/dist/storage/@powerhousedao/vetra/package.json +0 -154
- package/dist/tests/e2e.test.d.ts +0 -2
- package/dist/tests/e2e.test.d.ts.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/dist/vitest.config.d.ts +0 -3
- package/dist/vitest.config.d.ts.map +0 -1
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
import { binary, command, flag, number, option, optional, run, string } from "cmd-ts";
|
|
2
|
+
import express, { Router } from "express";
|
|
3
|
+
import { findUp } from "find-up";
|
|
4
|
+
import crypto, { randomBytes } from "node:crypto";
|
|
5
|
+
import { mkdir } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { runServer } from "verdaccio";
|
|
8
|
+
import { signPayload } from "@verdaccio/signature";
|
|
9
|
+
import { verifyAuthBearerToken } from "@renown/sdk/node";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import { Readable } from "node:stream";
|
|
12
|
+
import { pipeline } from "node:stream/promises";
|
|
13
|
+
import { extract } from "tar";
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/auth/renown-middleware.ts
|
|
16
|
+
function audienceMatches(aud, expected) {
|
|
17
|
+
if (!aud) return false;
|
|
18
|
+
if (Array.isArray(aud)) return aud.includes(expected);
|
|
19
|
+
return aud === expected;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Translates a Renown-signed Bearer token (verifiable from the issuer's DID,
|
|
23
|
+
* stateless) into a verdaccio-format Bearer token (signed with verdaccio's
|
|
24
|
+
* own secret) that verdaccio's normal auth pipeline accepts.
|
|
25
|
+
*
|
|
26
|
+
* Falls through (calls `next()` without modifying auth) on any verification
|
|
27
|
+
* failure: malformed token, bad signature, expired, audience mismatch, or
|
|
28
|
+
* non-renown bearer token. This keeps the legacy htpasswd path usable during
|
|
29
|
+
* the migration grace period — verdaccio's own apiJWTmiddleware sees the
|
|
30
|
+
* original Authorization header and decides what to do with it.
|
|
31
|
+
*/
|
|
32
|
+
function createRenownAuthMiddleware(opts) {
|
|
33
|
+
const expectedAud = opts.publicUrl;
|
|
34
|
+
return async (req, _res, next) => {
|
|
35
|
+
const header = req.headers.authorization;
|
|
36
|
+
if (!header?.startsWith("Bearer ")) return next();
|
|
37
|
+
const token = header.slice(7).trim();
|
|
38
|
+
if (!token) return next();
|
|
39
|
+
let verified;
|
|
40
|
+
try {
|
|
41
|
+
verified = await verifyAuthBearerToken(token, { audience: expectedAud });
|
|
42
|
+
} catch {
|
|
43
|
+
return next();
|
|
44
|
+
}
|
|
45
|
+
if (!verified) return next();
|
|
46
|
+
const payload = verified.payload;
|
|
47
|
+
if (!audienceMatches(payload?.aud, expectedAud)) return next();
|
|
48
|
+
const subject = verified.verifiableCredential.credentialSubject;
|
|
49
|
+
if (!subject?.address) return next();
|
|
50
|
+
const address = subject.address.toLowerCase();
|
|
51
|
+
const groups = ["$authenticated", "renown"];
|
|
52
|
+
let verdaccioJwt;
|
|
53
|
+
try {
|
|
54
|
+
verdaccioJwt = await signPayload({
|
|
55
|
+
name: address,
|
|
56
|
+
real_groups: groups,
|
|
57
|
+
groups
|
|
58
|
+
}, opts.verdaccioSecret, { expiresIn: "5m" });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error("[registry] failed to mint internal verdaccio token:", err);
|
|
61
|
+
return next();
|
|
62
|
+
}
|
|
63
|
+
req.headers.authorization = `Bearer ${verdaccioJwt}`;
|
|
64
|
+
req.renownUser = {
|
|
65
|
+
address,
|
|
66
|
+
did: payload?.iss,
|
|
67
|
+
chainId: subject.chainId,
|
|
68
|
+
networkId: subject.networkId
|
|
69
|
+
};
|
|
70
|
+
return next();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/semver.ts
|
|
75
|
+
/**
|
|
76
|
+
* Compare two semver version strings for sorting.
|
|
77
|
+
* Returns negative if a < b, positive if a > b, 0 if equal.
|
|
78
|
+
*
|
|
79
|
+
* Handles numeric component comparison (so "1.0.10" > "1.0.9")
|
|
80
|
+
* and prerelease ordering (release > prerelease).
|
|
81
|
+
*/
|
|
82
|
+
function compareSemver(a, b) {
|
|
83
|
+
const [coreA, preA] = a.split("-", 2);
|
|
84
|
+
const [coreB, preB] = b.split("-", 2);
|
|
85
|
+
const partsA = coreA.split(".").map(Number);
|
|
86
|
+
const partsB = coreB.split(".").map(Number);
|
|
87
|
+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
88
|
+
const na = partsA[i] ?? 0;
|
|
89
|
+
const nb = partsB[i] ?? 0;
|
|
90
|
+
if (na !== nb) return na - nb;
|
|
91
|
+
}
|
|
92
|
+
if (!preA && preB) return 1;
|
|
93
|
+
if (preA && !preB) return -1;
|
|
94
|
+
if (preA && preB) return preA < preB ? -1 : preA > preB ? 1 : 0;
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
//#region src/cdn.ts
|
|
99
|
+
/**
|
|
100
|
+
* Parse a package specifier into name and version/tag.
|
|
101
|
+
* Supports:
|
|
102
|
+
* "@scope/pkg" -> { name: "@scope/pkg", tag: undefined }
|
|
103
|
+
* "@scope/pkg@dev" -> { name: "@scope/pkg", tag: "dev" }
|
|
104
|
+
* "@scope/pkg@1.0.0" -> { name: "@scope/pkg", tag: "1.0.0" }
|
|
105
|
+
* "pkg@latest" -> { name: "pkg", tag: "latest" }
|
|
106
|
+
*/
|
|
107
|
+
function parsePackageSpec(spec) {
|
|
108
|
+
if (spec.startsWith("@")) {
|
|
109
|
+
const lastAt = spec.lastIndexOf("@");
|
|
110
|
+
if (lastAt > 0 && lastAt !== spec.indexOf("@")) return {
|
|
111
|
+
name: spec.slice(0, lastAt),
|
|
112
|
+
tag: spec.slice(lastAt + 1)
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
name: spec,
|
|
116
|
+
tag: void 0
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const atIndex = spec.indexOf("@");
|
|
120
|
+
if (atIndex > 0) return {
|
|
121
|
+
name: spec.slice(0, atIndex),
|
|
122
|
+
tag: spec.slice(atIndex + 1)
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
name: spec,
|
|
126
|
+
tag: void 0
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
var CdnCache = class {
|
|
130
|
+
#extractionLocks = /* @__PURE__ */ new Map();
|
|
131
|
+
constructor(registryUrl, cdnCachePath) {
|
|
132
|
+
this.registryUrl = registryUrl;
|
|
133
|
+
this.cdnCachePath = cdnCachePath;
|
|
134
|
+
}
|
|
135
|
+
async getFileByVersion(packageName, version, filePath) {
|
|
136
|
+
const versionDir = path.join(this.cdnCachePath, packageName, version);
|
|
137
|
+
const resolved = this.#resolveFile(versionDir, filePath);
|
|
138
|
+
if (resolved) return resolved;
|
|
139
|
+
await this.#extractWithLock(packageName, version);
|
|
140
|
+
return this.#resolveFile(versionDir, filePath);
|
|
141
|
+
}
|
|
142
|
+
#resolveFile(versionDir, filePath) {
|
|
143
|
+
const candidates = [
|
|
144
|
+
path.join(versionDir, filePath),
|
|
145
|
+
path.join(versionDir, "cdn", filePath),
|
|
146
|
+
path.join(versionDir, "dist", "cdn", filePath),
|
|
147
|
+
path.join(versionDir, "dist", filePath)
|
|
148
|
+
];
|
|
149
|
+
for (const candidate of candidates) if (this.isSafePath(candidate) && fs.existsSync(candidate)) return candidate;
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
async #extractWithLock(packageName, version) {
|
|
153
|
+
const key = `${packageName}@${version}`;
|
|
154
|
+
const existing = this.#extractionLocks.get(key);
|
|
155
|
+
if (existing) return existing;
|
|
156
|
+
const promise = this.extractTarball(packageName, version).finally(() => {
|
|
157
|
+
this.#extractionLocks.delete(key);
|
|
158
|
+
});
|
|
159
|
+
this.#extractionLocks.set(key, promise);
|
|
160
|
+
return promise;
|
|
161
|
+
}
|
|
162
|
+
getLatestCachedVersion(packageName) {
|
|
163
|
+
const pkgDir = path.join(this.cdnCachePath, packageName);
|
|
164
|
+
try {
|
|
165
|
+
const versions = fs.readdirSync(pkgDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
166
|
+
if (versions.length === 0) return null;
|
|
167
|
+
versions.sort(compareSemver);
|
|
168
|
+
return versions[versions.length - 1];
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Resolve a version for a package. If tag is a semver version that exists
|
|
175
|
+
* in the registry, return it directly. If tag is a dist-tag name (e.g.
|
|
176
|
+
* "dev", "latest"), resolve it to the concrete version. If no tag is
|
|
177
|
+
* provided, prefer "latest", then fall back to any available dist-tag.
|
|
178
|
+
*/
|
|
179
|
+
async resolveVersion(packageName, tag) {
|
|
180
|
+
try {
|
|
181
|
+
const url = `${this.registryUrl}/${encodeURIComponent(packageName)}`;
|
|
182
|
+
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
|
183
|
+
if (!res.ok) return null;
|
|
184
|
+
const metadata = await res.json();
|
|
185
|
+
const distTags = metadata["dist-tags"];
|
|
186
|
+
const versions = metadata["versions"];
|
|
187
|
+
if (tag) {
|
|
188
|
+
if (versions && tag in versions) return tag;
|
|
189
|
+
if (distTags && tag in distTags) return distTags[tag];
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
if (!distTags) return null;
|
|
193
|
+
return distTags.latest ?? Object.values(distTags)[0] ?? null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async extractTarball(packageName, version) {
|
|
199
|
+
const destDir = path.join(this.cdnCachePath, packageName, version);
|
|
200
|
+
if (fs.existsSync(path.join(destDir, "package.json"))) return;
|
|
201
|
+
const shortName = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
|
|
202
|
+
const tarballUrl = `${this.registryUrl}/${encodeURIComponent(packageName)}/-/${shortName}-${version}.tgz`;
|
|
203
|
+
let res;
|
|
204
|
+
try {
|
|
205
|
+
res = await fetch(tarballUrl);
|
|
206
|
+
if (!res.ok || !res.body) return;
|
|
207
|
+
} catch {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
211
|
+
const tmpFile = path.join(destDir, `.tmp-tarball-${crypto.randomUUID()}.tgz`);
|
|
212
|
+
try {
|
|
213
|
+
const fileStream = fs.createWriteStream(tmpFile);
|
|
214
|
+
await pipeline(Readable.fromWeb(res.body), fileStream);
|
|
215
|
+
await extract({
|
|
216
|
+
file: tmpFile,
|
|
217
|
+
cwd: destDir,
|
|
218
|
+
strip: 1
|
|
219
|
+
});
|
|
220
|
+
} finally {
|
|
221
|
+
fs.rmSync(tmpFile, { force: true });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
invalidate(packageName) {
|
|
225
|
+
const cacheDir = path.join(this.cdnCachePath, packageName);
|
|
226
|
+
if (!this.isSafePath(cacheDir)) return;
|
|
227
|
+
fs.rmSync(cacheDir, {
|
|
228
|
+
recursive: true,
|
|
229
|
+
force: true
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
invalidateVersion(packageName, version) {
|
|
233
|
+
const versionDir = path.join(this.cdnCachePath, packageName, version);
|
|
234
|
+
if (!this.isSafePath(versionDir)) return;
|
|
235
|
+
fs.rmSync(versionDir, {
|
|
236
|
+
recursive: true,
|
|
237
|
+
force: true
|
|
238
|
+
});
|
|
239
|
+
const pkgDir = path.join(this.cdnCachePath, packageName);
|
|
240
|
+
try {
|
|
241
|
+
if (fs.readdirSync(pkgDir).length === 0) fs.rmdirSync(pkgDir);
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
/** Remove all cached version directories except the specified one. */
|
|
245
|
+
pruneOldVersions(packageName, keepVersion) {
|
|
246
|
+
const pkgDir = path.join(this.cdnCachePath, packageName);
|
|
247
|
+
try {
|
|
248
|
+
const entries = fs.readdirSync(pkgDir, { withFileTypes: true });
|
|
249
|
+
for (const entry of entries) if (entry.isDirectory() && entry.name !== keepVersion) {
|
|
250
|
+
const dir = path.join(pkgDir, entry.name);
|
|
251
|
+
if (this.isSafePath(dir)) fs.rmSync(dir, {
|
|
252
|
+
recursive: true,
|
|
253
|
+
force: true
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
isSafePath(filePath) {
|
|
259
|
+
const resolved = path.resolve(filePath);
|
|
260
|
+
const cacheRoot = path.resolve(this.cdnCachePath);
|
|
261
|
+
return resolved.startsWith(cacheRoot + path.sep) || resolved === cacheRoot;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
//#endregion
|
|
265
|
+
//#region src/packages.ts
|
|
266
|
+
/**
|
|
267
|
+
* Read dist-tags, the full version list, and the local-publish flag for a
|
|
268
|
+
* package from verdaccio's on-disk storage (`{storagePath}/{name}/package.json`).
|
|
269
|
+
*
|
|
270
|
+
* `locallyPublished` is tri-state:
|
|
271
|
+
* - `true` → storage metadata has `_attachments` (tarball uploaded here).
|
|
272
|
+
* - `false` → storage metadata exists but `_attachments` is empty (proxy
|
|
273
|
+
* from the npm uplink only; no local publish at this registry).
|
|
274
|
+
* - `undefined` → metadata file wasn't readable. Happens with non-filesystem
|
|
275
|
+
* backends (S3, etc.) or if verdaccio stores metadata elsewhere.
|
|
276
|
+
* Callers should treat this as "unknown" and default to including
|
|
277
|
+
* the package, to avoid filtering the whole /packages list to an
|
|
278
|
+
* empty array on deployments where we can't observe _attachments.
|
|
279
|
+
*/
|
|
280
|
+
function readPackageMetadata(storagePath, packageName) {
|
|
281
|
+
if (!storagePath) return { locallyPublished: void 0 };
|
|
282
|
+
try {
|
|
283
|
+
const metadataPath = path.join(storagePath, packageName, "package.json");
|
|
284
|
+
const raw = fs.readFileSync(metadataPath, "utf-8");
|
|
285
|
+
const parsed = JSON.parse(raw);
|
|
286
|
+
const distTags = parsed["dist-tags"];
|
|
287
|
+
const versions = (parsed.versions ? Object.keys(parsed.versions) : []).slice().sort(compareSemver);
|
|
288
|
+
const locallyPublished = !!parsed._attachments && Object.keys(parsed._attachments).length > 0;
|
|
289
|
+
return {
|
|
290
|
+
distTags: distTags && Object.keys(distTags).length > 0 ? distTags : void 0,
|
|
291
|
+
versions: versions.length > 0 ? versions : void 0,
|
|
292
|
+
locallyPublished
|
|
293
|
+
};
|
|
294
|
+
} catch {
|
|
295
|
+
return { locallyPublished: void 0 };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function readManifest(dir) {
|
|
299
|
+
const candidates = [
|
|
300
|
+
path.join(dir, "powerhouse.manifest.json"),
|
|
301
|
+
path.join(dir, "cdn", "powerhouse.manifest.json"),
|
|
302
|
+
path.join(dir, "dist", "powerhouse.manifest.json")
|
|
303
|
+
];
|
|
304
|
+
for (const manifestPath of candidates) try {
|
|
305
|
+
const raw = fs.readFileSync(manifestPath, "utf-8");
|
|
306
|
+
return JSON.parse(raw);
|
|
307
|
+
} catch {}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
function readPackageJsonVersion(dir) {
|
|
311
|
+
try {
|
|
312
|
+
const raw = fs.readFileSync(path.join(dir, "package.json"), "utf-8");
|
|
313
|
+
const pkg = JSON.parse(raw);
|
|
314
|
+
return typeof pkg.version === "string" ? pkg.version : void 0;
|
|
315
|
+
} catch {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function getLatestVersionDir(pkgDir) {
|
|
320
|
+
let entries;
|
|
321
|
+
try {
|
|
322
|
+
entries = fs.readdirSync(pkgDir, { withFileTypes: true });
|
|
323
|
+
} catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const versions = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
327
|
+
if (versions.length === 0) return null;
|
|
328
|
+
versions.sort(compareSemver);
|
|
329
|
+
return path.join(pkgDir, versions[versions.length - 1]);
|
|
330
|
+
}
|
|
331
|
+
function loadPackage(cdnCachePath, name, version) {
|
|
332
|
+
const pkgDir = path.join(cdnCachePath, name);
|
|
333
|
+
const manifestDir = (version ? path.join(pkgDir, version) : getLatestVersionDir(pkgDir)) ?? pkgDir;
|
|
334
|
+
const manifest = readManifest(manifestDir);
|
|
335
|
+
if (!manifest) return null;
|
|
336
|
+
return {
|
|
337
|
+
name: manifest.name ?? name,
|
|
338
|
+
path: `/-/cdn/${name}`,
|
|
339
|
+
manifest,
|
|
340
|
+
documentTypes: getDocumentTypesFromManifest(manifest),
|
|
341
|
+
version: readPackageJsonVersion(manifestDir)
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function getDocumentTypesFromManifest(manifest) {
|
|
345
|
+
if (!manifest) return [];
|
|
346
|
+
const documentTypes = [];
|
|
347
|
+
const { apps, documentModels, editors, subgraphs } = manifest;
|
|
348
|
+
if (apps?.length) documentTypes.push("powerhouse/document-drive");
|
|
349
|
+
documentTypes.push(...(documentModels ?? []).map((dm) => dm.id), ...(editors ?? []).flatMap((e) => e.documentTypes).filter((dt) => dt !== void 0), ...(subgraphs ?? []).flatMap((e) => e.documentTypes).filter((dt) => dt !== void 0));
|
|
350
|
+
return documentTypes;
|
|
351
|
+
}
|
|
352
|
+
function scanPackages(cdnCachePath, storagePath) {
|
|
353
|
+
const absDir = path.resolve(cdnCachePath);
|
|
354
|
+
const packages = [];
|
|
355
|
+
let entries;
|
|
356
|
+
try {
|
|
357
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
358
|
+
} catch {
|
|
359
|
+
return packages;
|
|
360
|
+
}
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
if (!entry.isDirectory()) continue;
|
|
363
|
+
if (entry.name.startsWith("@")) {
|
|
364
|
+
const scopeDir = path.join(absDir, entry.name);
|
|
365
|
+
let scopedEntries;
|
|
366
|
+
try {
|
|
367
|
+
scopedEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.log(error);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
for (const scopedEntry of scopedEntries) {
|
|
373
|
+
if (!scopedEntry.isDirectory()) continue;
|
|
374
|
+
const dirName = `${entry.name}/${scopedEntry.name}`;
|
|
375
|
+
const pkgDir = path.join(scopeDir, scopedEntry.name);
|
|
376
|
+
const manifestDir = getLatestVersionDir(pkgDir) ?? pkgDir;
|
|
377
|
+
const manifest = readManifest(manifestDir);
|
|
378
|
+
const name = manifest?.name ?? dirName;
|
|
379
|
+
const { distTags, versions, locallyPublished } = readPackageMetadata(storagePath, name);
|
|
380
|
+
if (locallyPublished === false) continue;
|
|
381
|
+
packages.push({
|
|
382
|
+
name,
|
|
383
|
+
path: `/-/cdn/${dirName}`,
|
|
384
|
+
manifest,
|
|
385
|
+
documentTypes: getDocumentTypesFromManifest(manifest),
|
|
386
|
+
version: readPackageJsonVersion(manifestDir),
|
|
387
|
+
distTags,
|
|
388
|
+
versions
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
const pkgDir = path.join(absDir, entry.name);
|
|
393
|
+
const manifestDir = getLatestVersionDir(pkgDir) ?? pkgDir;
|
|
394
|
+
const manifest = readManifest(manifestDir);
|
|
395
|
+
const name = manifest?.name ?? entry.name;
|
|
396
|
+
const { distTags, versions, locallyPublished } = readPackageMetadata(storagePath, name);
|
|
397
|
+
if (locallyPublished === false) continue;
|
|
398
|
+
packages.push({
|
|
399
|
+
name,
|
|
400
|
+
path: `/-/cdn/${entry.name}`,
|
|
401
|
+
manifest,
|
|
402
|
+
documentTypes: getDocumentTypesFromManifest(manifest),
|
|
403
|
+
version: readPackageJsonVersion(manifestDir),
|
|
404
|
+
distTags,
|
|
405
|
+
versions
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return packages;
|
|
410
|
+
}
|
|
411
|
+
function findPackagesByDocumentType(packagesDir, documentType) {
|
|
412
|
+
return scanPackages(packagesDir).filter((pkg) => {
|
|
413
|
+
if (!pkg.manifest?.documentModels) return false;
|
|
414
|
+
return pkg.manifest.documentModels.some((dm) => dm.id === documentType);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region src/middleware.ts
|
|
419
|
+
const MIME_TYPES = {
|
|
420
|
+
".js": "application/javascript",
|
|
421
|
+
".mjs": "application/javascript",
|
|
422
|
+
".css": "text/css",
|
|
423
|
+
".json": "application/json",
|
|
424
|
+
".wasm": "application/wasm",
|
|
425
|
+
".map": "application/json",
|
|
426
|
+
".html": "text/html",
|
|
427
|
+
".svg": "image/svg+xml"
|
|
428
|
+
};
|
|
429
|
+
function getContentType(filePath) {
|
|
430
|
+
return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
431
|
+
}
|
|
432
|
+
function createPowerhouseRouter(config, sse, webhooks) {
|
|
433
|
+
const cdn = new CdnCache(`http://localhost:${config.port}`, config.cdnCachePath);
|
|
434
|
+
const router = Router();
|
|
435
|
+
router.use((_req, res, next) => {
|
|
436
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
437
|
+
next();
|
|
438
|
+
});
|
|
439
|
+
router.get("/-/events", (_req, res) => {
|
|
440
|
+
sse.addClient(res);
|
|
441
|
+
});
|
|
442
|
+
router.get("/-/webhooks", (_req, res) => {
|
|
443
|
+
res.json(webhooks.getWebhooks());
|
|
444
|
+
});
|
|
445
|
+
router.post("/-/webhooks", express.json(), (req, res) => {
|
|
446
|
+
const { endpoint, headers } = req.body;
|
|
447
|
+
if (!endpoint) {
|
|
448
|
+
res.status(400).json({ error: "Missing required field: endpoint" });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
webhooks.addWebhook({
|
|
452
|
+
endpoint,
|
|
453
|
+
headers
|
|
454
|
+
});
|
|
455
|
+
res.status(201).json({
|
|
456
|
+
endpoint,
|
|
457
|
+
headers
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
router.delete("/-/webhooks", express.json(), (req, res) => {
|
|
461
|
+
const { endpoint } = req.body;
|
|
462
|
+
if (!endpoint) {
|
|
463
|
+
res.status(400).json({ error: "Missing required field: endpoint" });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (!webhooks.removeWebhook(endpoint)) {
|
|
467
|
+
res.status(404).json({ error: "Webhook not found" });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
res.status(204).end();
|
|
471
|
+
});
|
|
472
|
+
const WARM_INTERVAL_MS = 3e4;
|
|
473
|
+
let warmInFlight = false;
|
|
474
|
+
let lastWarmAt = 0;
|
|
475
|
+
async function warmCdnCacheFromVerdaccio() {
|
|
476
|
+
if (warmInFlight) return;
|
|
477
|
+
if (Date.now() - lastWarmAt < WARM_INTERVAL_MS) return;
|
|
478
|
+
warmInFlight = true;
|
|
479
|
+
try {
|
|
480
|
+
const r = await fetch(`http://localhost:${config.port}/-/verdaccio/data/packages`);
|
|
481
|
+
if (!r.ok) {
|
|
482
|
+
console.error(`[registry] verdaccio package listing returned ${r.status}`);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const known = await r.json();
|
|
486
|
+
const concurrency = 8;
|
|
487
|
+
let cursor = 0;
|
|
488
|
+
const workers = Array.from({ length: concurrency }).map(async () => {
|
|
489
|
+
while (cursor < known.length) {
|
|
490
|
+
const pkg = known[cursor++];
|
|
491
|
+
if (!pkg.version) continue;
|
|
492
|
+
try {
|
|
493
|
+
await cdn.extractTarball(pkg.name, pkg.version);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
console.error(`[registry] failed to warm cache for ${pkg.name}@${pkg.version}:`, err);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
await Promise.all(workers);
|
|
500
|
+
console.log(`[registry] /packages warm-up done (${known.length} pkgs)`);
|
|
501
|
+
} catch (err) {
|
|
502
|
+
console.error("[registry] /packages warm-up failed:", err);
|
|
503
|
+
} finally {
|
|
504
|
+
warmInFlight = false;
|
|
505
|
+
lastWarmAt = Date.now();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
warmCdnCacheFromVerdaccio();
|
|
509
|
+
router.get("/packages", (req, res) => {
|
|
510
|
+
warmCdnCacheFromVerdaccio();
|
|
511
|
+
const packages = scanPackages(config.cdnCachePath, config.storagePath);
|
|
512
|
+
const documentType = req.query.documentType;
|
|
513
|
+
if (documentType) {
|
|
514
|
+
const filtered = packages.filter((pkg) => pkg.manifest?.documentModels?.some((m) => m.id === documentType));
|
|
515
|
+
res.json(filtered);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
res.json(packages);
|
|
519
|
+
});
|
|
520
|
+
router.get("/packages/by-document-type", (req, res) => {
|
|
521
|
+
const documentType = req.query.type;
|
|
522
|
+
if (typeof documentType !== "string" || !documentType) {
|
|
523
|
+
res.status(400).json({ error: "Missing required query parameter: type" });
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const packageNames = findPackagesByDocumentType(config.cdnCachePath, documentType).map((pkg) => pkg.name);
|
|
527
|
+
res.json(packageNames);
|
|
528
|
+
});
|
|
529
|
+
router.get("/packages/*", async (req, res) => {
|
|
530
|
+
const raw = req.params[0];
|
|
531
|
+
const { name, tag } = parsePackageSpec(raw);
|
|
532
|
+
const version = await cdn.resolveVersion(name, tag) ?? cdn.getLatestCachedVersion(name);
|
|
533
|
+
const pkg = loadPackage(config.cdnCachePath, name, version ?? void 0);
|
|
534
|
+
if (!pkg) {
|
|
535
|
+
res.status(404).send("Package not found");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
res.json(pkg);
|
|
539
|
+
});
|
|
540
|
+
router.get("/-/cdn/*", async (req, res) => {
|
|
541
|
+
const fullPath = req.params[0];
|
|
542
|
+
let packageSpec;
|
|
543
|
+
let filePath;
|
|
544
|
+
if (fullPath.startsWith("@")) {
|
|
545
|
+
const segments = fullPath.split("/");
|
|
546
|
+
if (segments.length < 2) {
|
|
547
|
+
res.status(400).send("Invalid package path");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
packageSpec = `${segments[0]}/${segments[1]}`;
|
|
551
|
+
filePath = segments.slice(2).join("/") || "index.js";
|
|
552
|
+
} else {
|
|
553
|
+
const segments = fullPath.split("/");
|
|
554
|
+
packageSpec = segments[0];
|
|
555
|
+
filePath = segments.slice(1).join("/") || "index.js";
|
|
556
|
+
}
|
|
557
|
+
const { name: packageName, tag } = parsePackageSpec(packageSpec);
|
|
558
|
+
const version = await cdn.resolveVersion(packageName, tag) ?? cdn.getLatestCachedVersion(packageName);
|
|
559
|
+
if (!version) {
|
|
560
|
+
res.status(404).send("File not found");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const resolved = await cdn.getFileByVersion(packageName, version, filePath);
|
|
564
|
+
if (!resolved) {
|
|
565
|
+
res.status(404).send("File not found");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
res.setHeader("Content-Type", getContentType(filePath));
|
|
569
|
+
const content = fs.readFileSync(resolved);
|
|
570
|
+
res.send(content);
|
|
571
|
+
});
|
|
572
|
+
return router;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Parse verdaccio's unpublish URL shape:
|
|
576
|
+
* DELETE /<pkg>/-rev/<rev> → full package
|
|
577
|
+
* DELETE /<pkg>/-/<tarball-name>/-rev/<rev> → single version
|
|
578
|
+
* where <pkg> may be scoped (@scope%2Fname, encoded) or unscoped, and the
|
|
579
|
+
* tarball name is `<short-name>-<version>.tgz`.
|
|
580
|
+
*/
|
|
581
|
+
function parseUnpublishRequest(reqPath) {
|
|
582
|
+
const revIdx = reqPath.indexOf("/-rev/");
|
|
583
|
+
if (revIdx <= 0) return null;
|
|
584
|
+
const beforeRev = reqPath.slice(1, revIdx);
|
|
585
|
+
const tarballIdx = beforeRev.indexOf("/-/");
|
|
586
|
+
if (tarballIdx === -1) return {
|
|
587
|
+
packageName: decodeURIComponent(beforeRev),
|
|
588
|
+
version: null
|
|
589
|
+
};
|
|
590
|
+
const packageName = decodeURIComponent(beforeRev.slice(0, tarballIdx));
|
|
591
|
+
const tarballName = beforeRev.slice(tarballIdx + 3);
|
|
592
|
+
if (!tarballName.endsWith(".tgz")) return null;
|
|
593
|
+
const prefix = `${packageName.startsWith("@") ? packageName.split("/")[1] : packageName}-`;
|
|
594
|
+
if (!tarballName.startsWith(prefix)) return null;
|
|
595
|
+
const version = tarballName.slice(prefix.length, -4);
|
|
596
|
+
if (!version) return null;
|
|
597
|
+
return {
|
|
598
|
+
packageName,
|
|
599
|
+
version
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function createUnpublishHook(config, notifications) {
|
|
603
|
+
const cdn = new CdnCache(`http://localhost:${config.port}`, config.cdnCachePath);
|
|
604
|
+
return (req, res, next) => {
|
|
605
|
+
if (req.method !== "DELETE") {
|
|
606
|
+
next();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const parsed = parseUnpublishRequest(req.path);
|
|
610
|
+
if (!parsed) {
|
|
611
|
+
next();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const originalEnd = res.end.bind(res);
|
|
615
|
+
res.end = function(chunk, encoding, cb) {
|
|
616
|
+
if (res.statusCode >= 200 && res.statusCode < 300) try {
|
|
617
|
+
if (parsed.version) cdn.invalidateVersion(parsed.packageName, parsed.version);
|
|
618
|
+
else cdn.invalidate(parsed.packageName);
|
|
619
|
+
const renownUser = req.renownUser;
|
|
620
|
+
notifications.notifyUnpublish({
|
|
621
|
+
packageName: parsed.packageName,
|
|
622
|
+
version: parsed.version,
|
|
623
|
+
publishedBy: renownUser ? {
|
|
624
|
+
address: renownUser.address,
|
|
625
|
+
did: renownUser.did
|
|
626
|
+
} : void 0
|
|
627
|
+
});
|
|
628
|
+
} catch (err) {
|
|
629
|
+
console.error(`[registry] CDN purge failed for ${parsed.packageName}${parsed.version ? `@${parsed.version}` : ""}:`, err);
|
|
630
|
+
}
|
|
631
|
+
return originalEnd(chunk, encoding, cb);
|
|
632
|
+
};
|
|
633
|
+
next();
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function createPublishHook(config, notifications) {
|
|
637
|
+
const cdn = new CdnCache(`http://localhost:${config.port}`, config.cdnCachePath);
|
|
638
|
+
return (req, res, next) => {
|
|
639
|
+
if (req.method !== "PUT" || req.path.includes("/-rev/")) {
|
|
640
|
+
next();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const originalEnd = res.end.bind(res);
|
|
644
|
+
res.end = function(chunk, encoding, cb) {
|
|
645
|
+
const urlPath = req.path.replace(/^\//, "");
|
|
646
|
+
if (res.statusCode < 200 || res.statusCode >= 300 || !urlPath || urlPath.startsWith("-")) return originalEnd(chunk, encoding, cb);
|
|
647
|
+
const packageName = decodeURIComponent(urlPath);
|
|
648
|
+
const versionsObj = req.body.versions;
|
|
649
|
+
const versions = Object.keys(versionsObj);
|
|
650
|
+
const version = versions.at(0);
|
|
651
|
+
if (!version) {
|
|
652
|
+
console.error(`[registry] No version found for ${packageName}`);
|
|
653
|
+
return originalEnd(chunk, encoding, cb);
|
|
654
|
+
}
|
|
655
|
+
if (versions.length > 1) console.warn(`[registry] Multiple versions published for ${packageName}: ${JSON.stringify(versions)}`);
|
|
656
|
+
const renownUser = req.renownUser;
|
|
657
|
+
const publishedBy = renownUser ? {
|
|
658
|
+
address: renownUser.address,
|
|
659
|
+
did: renownUser.did
|
|
660
|
+
} : void 0;
|
|
661
|
+
cdn.extractTarball(packageName, version).then(() => {
|
|
662
|
+
notifications.notifyPublish({
|
|
663
|
+
packageName,
|
|
664
|
+
version,
|
|
665
|
+
publishedBy
|
|
666
|
+
});
|
|
667
|
+
}).catch((err) => {
|
|
668
|
+
console.error(`[registry] Failed to extract ${packageName} to CDN cache:`, err);
|
|
669
|
+
});
|
|
670
|
+
return originalEnd(chunk, encoding, cb);
|
|
671
|
+
};
|
|
672
|
+
next();
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region src/notifications/manager.ts
|
|
677
|
+
var NotificationManager = class {
|
|
678
|
+
#channels;
|
|
679
|
+
constructor(channels) {
|
|
680
|
+
this.#channels = channels;
|
|
681
|
+
}
|
|
682
|
+
notifyPublish(event) {
|
|
683
|
+
for (const channel of this.#channels) channel.notifyPublish(event);
|
|
684
|
+
}
|
|
685
|
+
notifyUnpublish(event) {
|
|
686
|
+
for (const channel of this.#channels) channel.notifyUnpublish(event);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
//#endregion
|
|
690
|
+
//#region src/notifications/sse.ts
|
|
691
|
+
var SSEChannel = class {
|
|
692
|
+
#clients = /* @__PURE__ */ new Set();
|
|
693
|
+
addClient(res) {
|
|
694
|
+
res.writeHead(200, {
|
|
695
|
+
"Content-Type": "text/event-stream",
|
|
696
|
+
"Cache-Control": "no-cache",
|
|
697
|
+
Connection: "keep-alive",
|
|
698
|
+
"Access-Control-Allow-Origin": "*"
|
|
699
|
+
});
|
|
700
|
+
res.write("event: connected\ndata: {}\n\n");
|
|
701
|
+
this.#clients.add(res);
|
|
702
|
+
res.on("close", () => {
|
|
703
|
+
this.#clients.delete(res);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
notifyPublish(event) {
|
|
707
|
+
this.#broadcast("publish", event);
|
|
708
|
+
}
|
|
709
|
+
notifyUnpublish(event) {
|
|
710
|
+
this.#broadcast("unpublish", event);
|
|
711
|
+
}
|
|
712
|
+
#broadcast(eventName, event) {
|
|
713
|
+
const payload = `event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`;
|
|
714
|
+
for (const client of this.#clients) try {
|
|
715
|
+
client.write(payload);
|
|
716
|
+
} catch (err) {
|
|
717
|
+
console.error("[registry] SSE client write failed:", err);
|
|
718
|
+
this.#clients.delete(client);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
//#endregion
|
|
723
|
+
//#region src/notifications/webhook.ts
|
|
724
|
+
const WEBHOOKS_FILE = "webhooks.json";
|
|
725
|
+
var WebhookChannel = class {
|
|
726
|
+
#predefined;
|
|
727
|
+
#dynamic;
|
|
728
|
+
#storagePath;
|
|
729
|
+
constructor(storagePath, config) {
|
|
730
|
+
this.#storagePath = storagePath;
|
|
731
|
+
this.#predefined = config?.webhooks ?? [];
|
|
732
|
+
this.#dynamic = this.#load();
|
|
733
|
+
}
|
|
734
|
+
getWebhooks() {
|
|
735
|
+
return [...this.#predefined, ...this.#dynamic];
|
|
736
|
+
}
|
|
737
|
+
addWebhook(webhook) {
|
|
738
|
+
if (this.getWebhooks().some((w) => w.endpoint === webhook.endpoint)) return;
|
|
739
|
+
this.#dynamic.push(webhook);
|
|
740
|
+
this.#save();
|
|
741
|
+
}
|
|
742
|
+
removeWebhook(endpoint) {
|
|
743
|
+
const before = this.#dynamic.length;
|
|
744
|
+
this.#dynamic = this.#dynamic.filter((w) => w.endpoint !== endpoint);
|
|
745
|
+
if (this.#dynamic.length === before) return false;
|
|
746
|
+
this.#save();
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
notifyPublish(event) {
|
|
750
|
+
this.#post({
|
|
751
|
+
type: "publish",
|
|
752
|
+
...event
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
notifyUnpublish(event) {
|
|
756
|
+
this.#post({
|
|
757
|
+
type: "unpublish",
|
|
758
|
+
...event
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
#post(body) {
|
|
762
|
+
for (const webhook of this.getWebhooks()) {
|
|
763
|
+
const headers = {
|
|
764
|
+
"Content-Type": "application/json",
|
|
765
|
+
...webhook.headers
|
|
766
|
+
};
|
|
767
|
+
fetch(webhook.endpoint, {
|
|
768
|
+
method: "POST",
|
|
769
|
+
headers,
|
|
770
|
+
body: JSON.stringify(body)
|
|
771
|
+
}).catch((err) => {
|
|
772
|
+
console.error(`[registry] Webhook to ${webhook.endpoint} failed:`, err);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
#filePath() {
|
|
777
|
+
return path.join(this.#storagePath, WEBHOOKS_FILE);
|
|
778
|
+
}
|
|
779
|
+
#load() {
|
|
780
|
+
try {
|
|
781
|
+
const raw = fs.readFileSync(this.#filePath(), "utf-8");
|
|
782
|
+
return JSON.parse(raw);
|
|
783
|
+
} catch {
|
|
784
|
+
return [];
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
#save() {
|
|
788
|
+
fs.mkdirSync(this.#storagePath, { recursive: true });
|
|
789
|
+
fs.writeFileSync(this.#filePath(), JSON.stringify(this.#dynamic, null, 2));
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
//#endregion
|
|
793
|
+
//#region src/verdaccio-config.ts
|
|
794
|
+
function buildVerdaccioConfig(config) {
|
|
795
|
+
const htpasswdPath = path.join(config.storagePath, "htpasswd");
|
|
796
|
+
const uplinkUrl = config.uplink ?? "https://registry.npmjs.org/";
|
|
797
|
+
const base = {
|
|
798
|
+
storage: config.storagePath,
|
|
799
|
+
self_path: "./",
|
|
800
|
+
...config.verdaccioSecret ? { secret: config.verdaccioSecret } : {},
|
|
801
|
+
security: { api: { jwt: {
|
|
802
|
+
sign: { expiresIn: "5m" },
|
|
803
|
+
verify: {}
|
|
804
|
+
} } },
|
|
805
|
+
auth: { htpasswd: { file: htpasswdPath } },
|
|
806
|
+
uplinks: { npmjs: {
|
|
807
|
+
url: uplinkUrl,
|
|
808
|
+
maxage: "15m",
|
|
809
|
+
timeout: "30s",
|
|
810
|
+
cache: true
|
|
811
|
+
} },
|
|
812
|
+
packages: (() => {
|
|
813
|
+
const local = config.localPackagePatterns ?? [];
|
|
814
|
+
const localSet = new Set(local);
|
|
815
|
+
const access = {
|
|
816
|
+
access: "$all",
|
|
817
|
+
publish: "$authenticated",
|
|
818
|
+
unpublish: "$authenticated"
|
|
819
|
+
};
|
|
820
|
+
const entries = [];
|
|
821
|
+
for (const pattern of local) entries.push([pattern, { ...access }]);
|
|
822
|
+
if (!localSet.has("@powerhousedao/*")) entries.push(["@powerhousedao/*", {
|
|
823
|
+
...access,
|
|
824
|
+
proxy: "npmjs"
|
|
825
|
+
}]);
|
|
826
|
+
if (!localSet.has("**")) entries.push(["**", {
|
|
827
|
+
...access,
|
|
828
|
+
proxy: "npmjs"
|
|
829
|
+
}]);
|
|
830
|
+
return Object.fromEntries(entries);
|
|
831
|
+
})(),
|
|
832
|
+
web: {
|
|
833
|
+
enable: config.webEnabled !== false,
|
|
834
|
+
title: "Powerhouse Registry",
|
|
835
|
+
logo: "https://raw.githubusercontent.com/powerhouse-inc/powerhouse/main/packages/registry/static/logo.svg",
|
|
836
|
+
favicon: "/-/static/favicon.ico",
|
|
837
|
+
primary_color: "#38C780",
|
|
838
|
+
darkMode: true
|
|
839
|
+
},
|
|
840
|
+
server: { keepAliveTimeout: 60 },
|
|
841
|
+
log: {
|
|
842
|
+
type: "stdout",
|
|
843
|
+
format: "pretty",
|
|
844
|
+
level: "warn"
|
|
845
|
+
},
|
|
846
|
+
max_body_size: config.maxBodySize ?? "300mb"
|
|
847
|
+
};
|
|
848
|
+
if (config.s3) base.store = { "aws-s3-storage": {
|
|
849
|
+
bucket: config.s3.bucket,
|
|
850
|
+
endpoint: config.s3.endpoint,
|
|
851
|
+
region: config.s3.region,
|
|
852
|
+
s3ForcePathStyle: config.s3.s3ForcePathStyle ?? true,
|
|
853
|
+
...config.s3.keyPrefix && { keyPrefix: config.s3.keyPrefix },
|
|
854
|
+
...config.s3.accessKeyId && { accessKeyId: config.s3.accessKeyId },
|
|
855
|
+
...config.s3.secretAccessKey && { secretAccessKey: config.s3.secretAccessKey }
|
|
856
|
+
} };
|
|
857
|
+
return base;
|
|
858
|
+
}
|
|
859
|
+
//#endregion
|
|
860
|
+
//#region src/run.ts
|
|
861
|
+
async function resolveDir(dir) {
|
|
862
|
+
if (path.isAbsolute(dir)) {
|
|
863
|
+
await mkdir(dir, { recursive: true });
|
|
864
|
+
return dir;
|
|
865
|
+
}
|
|
866
|
+
const found = await findUp(dir, { type: "directory" });
|
|
867
|
+
if (!found) {
|
|
868
|
+
await mkdir(dir, { recursive: true });
|
|
869
|
+
return dir;
|
|
870
|
+
}
|
|
871
|
+
return found;
|
|
872
|
+
}
|
|
873
|
+
async function runRegistry(args) {
|
|
874
|
+
const { port, storageDir, cdnCacheDir, uplink, webEnabled, webhooks, s3AccessKeyId, s3Bucket, s3Endpoint, s3ForcePathStyle, s3KeyPrefix, s3Region, s3SecretAccessKey, publicUrl, authRenown, verdaccioSecret: verdaccioSecretArg, localPackages } = args;
|
|
875
|
+
const storagePath = await resolveDir(storageDir);
|
|
876
|
+
const cdnCachePath = await resolveDir(cdnCacheDir);
|
|
877
|
+
const verdaccioSecret = verdaccioSecretArg ?? randomBytes(32).toString("hex");
|
|
878
|
+
const renownEnabled = authRenown === true && Boolean(publicUrl);
|
|
879
|
+
if (authRenown === true && !publicUrl) console.warn("[registry] auth-renown is enabled but --public-url / PH_REGISTRY_PUBLIC_URL is not set; Renown auth will be disabled.");
|
|
880
|
+
console.log({
|
|
881
|
+
storagePath,
|
|
882
|
+
cdnCachePath
|
|
883
|
+
});
|
|
884
|
+
const webhookConfigs = webhooks?.split(",").map((url) => url.trim()).filter(Boolean).map((endpoint) => ({ endpoint }));
|
|
885
|
+
const localPackagePatterns = localPackages?.split(",").map((p) => p.trim()).filter(Boolean);
|
|
886
|
+
const config = {
|
|
887
|
+
port,
|
|
888
|
+
storagePath,
|
|
889
|
+
cdnCachePath,
|
|
890
|
+
uplink,
|
|
891
|
+
webEnabled,
|
|
892
|
+
verdaccioSecret,
|
|
893
|
+
...localPackagePatterns?.length ? { localPackagePatterns } : {},
|
|
894
|
+
...renownEnabled && publicUrl ? { renown: { publicUrl } } : {},
|
|
895
|
+
...webhookConfigs?.length && { notify: { webhooks: webhookConfigs } },
|
|
896
|
+
...s3Bucket && s3Endpoint && s3Region && { s3: {
|
|
897
|
+
bucket: s3Bucket,
|
|
898
|
+
endpoint: s3Endpoint,
|
|
899
|
+
region: s3Region,
|
|
900
|
+
accessKeyId: s3AccessKeyId,
|
|
901
|
+
secretAccessKey: s3SecretAccessKey,
|
|
902
|
+
keyPrefix: s3KeyPrefix,
|
|
903
|
+
s3ForcePathStyle
|
|
904
|
+
} }
|
|
905
|
+
};
|
|
906
|
+
await mkdir(storagePath, { recursive: true });
|
|
907
|
+
await mkdir(cdnCachePath, { recursive: true });
|
|
908
|
+
const verdaccioHandler = (await runServer(buildVerdaccioConfig(config))).listeners("request")[0];
|
|
909
|
+
const app = express();
|
|
910
|
+
const sseChannel = new SSEChannel();
|
|
911
|
+
const webhookChannel = new WebhookChannel(config.storagePath, config.notify);
|
|
912
|
+
const notifications = new NotificationManager([sseChannel, webhookChannel]);
|
|
913
|
+
const staticDir = await findUp("static", { type: "directory" });
|
|
914
|
+
if (staticDir) app.use("/-/static", express.static(staticDir));
|
|
915
|
+
app.use(createPowerhouseRouter(config, sseChannel, webhookChannel));
|
|
916
|
+
if (config.renown) app.use(createRenownAuthMiddleware({
|
|
917
|
+
publicUrl: config.renown.publicUrl,
|
|
918
|
+
verdaccioSecret
|
|
919
|
+
}));
|
|
920
|
+
app.use(createPublishHook(config, notifications));
|
|
921
|
+
app.use(createUnpublishHook(config, notifications));
|
|
922
|
+
app.use((req, res) => verdaccioHandler(req, res));
|
|
923
|
+
return app.listen(port, () => {
|
|
924
|
+
console.log(`Powerhouse Registry running on http://localhost:${port}`);
|
|
925
|
+
console.log(` CDN: http://localhost:${port}/-/cdn/`);
|
|
926
|
+
console.log(` Packages: http://localhost:${port}/packages`);
|
|
927
|
+
console.log(` npm: http://localhost:${port}/`);
|
|
928
|
+
console.log(` Storage: ${storagePath}`);
|
|
929
|
+
console.log(` CDN cache: ${cdnCachePath}`);
|
|
930
|
+
if (config.s3) console.log(` S3: ${config.s3.endpoint}/${config.s3.bucket}`);
|
|
931
|
+
if (config.renown) console.log(` Renown auth: ${config.renown.publicUrl}`);
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
//#endregion
|
|
935
|
+
//#region cli.ts
|
|
936
|
+
const registryCommand = command({
|
|
937
|
+
name: "Package registry",
|
|
938
|
+
args: {
|
|
939
|
+
port: option({
|
|
940
|
+
long: "port",
|
|
941
|
+
type: number,
|
|
942
|
+
defaultValue: () => Number(process.env.PORT) || 8080,
|
|
943
|
+
defaultValueIsSerializable: true
|
|
944
|
+
}),
|
|
945
|
+
storageDir: option({
|
|
946
|
+
long: "storage-dir",
|
|
947
|
+
type: string,
|
|
948
|
+
defaultValue: () => process.env.REGISTRY_STORAGE || "./storage",
|
|
949
|
+
defaultValueIsSerializable: true
|
|
950
|
+
}),
|
|
951
|
+
cdnCacheDir: option({
|
|
952
|
+
long: "cdn-cache-dir",
|
|
953
|
+
type: string,
|
|
954
|
+
defaultValue: () => process.env.REGISTRY_CDN_CACHE || "./cdn-cache",
|
|
955
|
+
defaultValueIsSerializable: true
|
|
956
|
+
}),
|
|
957
|
+
uplink: option({
|
|
958
|
+
long: "uplink",
|
|
959
|
+
type: optional(string),
|
|
960
|
+
defaultValue: () => process.env.REGISTRY_UPLINK,
|
|
961
|
+
defaultValueIsSerializable: true
|
|
962
|
+
}),
|
|
963
|
+
s3Bucket: option({
|
|
964
|
+
long: "s3-bucket",
|
|
965
|
+
type: optional(string),
|
|
966
|
+
defaultValue: () => process.env.S3_BUCKET,
|
|
967
|
+
defaultValueIsSerializable: true
|
|
968
|
+
}),
|
|
969
|
+
s3Endpoint: option({
|
|
970
|
+
long: "s3-endpoint",
|
|
971
|
+
type: optional(string),
|
|
972
|
+
defaultValue: () => process.env.S3_ENDPOINT,
|
|
973
|
+
defaultValueIsSerializable: true
|
|
974
|
+
}),
|
|
975
|
+
s3Region: option({
|
|
976
|
+
long: "s3-region",
|
|
977
|
+
type: optional(string),
|
|
978
|
+
defaultValue: () => process.env.S3_REGION,
|
|
979
|
+
defaultValueIsSerializable: true
|
|
980
|
+
}),
|
|
981
|
+
s3AccessKeyId: option({
|
|
982
|
+
long: "s3-access-key-id",
|
|
983
|
+
type: optional(string),
|
|
984
|
+
defaultValue: () => process.env.S3_ACCESS_KEY_ID,
|
|
985
|
+
defaultValueIsSerializable: true
|
|
986
|
+
}),
|
|
987
|
+
s3SecretAccessKey: option({
|
|
988
|
+
long: "s3-secret-access-key",
|
|
989
|
+
type: optional(string),
|
|
990
|
+
defaultValue: () => process.env.S3_SECRET_ACCESS_KEY,
|
|
991
|
+
defaultValueIsSerializable: true
|
|
992
|
+
}),
|
|
993
|
+
s3KeyPrefix: option({
|
|
994
|
+
long: "s3-key-prefix",
|
|
995
|
+
type: optional(string),
|
|
996
|
+
defaultValue: () => process.env.S3_KEY_PREFIX,
|
|
997
|
+
defaultValueIsSerializable: true
|
|
998
|
+
}),
|
|
999
|
+
s3ForcePathStyle: flag({
|
|
1000
|
+
long: "s3-force-path-style",
|
|
1001
|
+
defaultValue: () => process.env.S3_FORCE_PATH_STYLE !== "false",
|
|
1002
|
+
defaultValueIsSerializable: true
|
|
1003
|
+
}),
|
|
1004
|
+
webEnabled: flag({
|
|
1005
|
+
long: "web-enabled",
|
|
1006
|
+
defaultValue: () => process.env.REGISTRY_WEB !== "false",
|
|
1007
|
+
defaultValueIsSerializable: true
|
|
1008
|
+
}),
|
|
1009
|
+
webhooks: option({
|
|
1010
|
+
long: "webhook",
|
|
1011
|
+
type: optional(string),
|
|
1012
|
+
description: "Comma-separated webhook URLs to notify on publish",
|
|
1013
|
+
defaultValue: () => process.env.REGISTRY_WEBHOOKS,
|
|
1014
|
+
defaultValueIsSerializable: true
|
|
1015
|
+
}),
|
|
1016
|
+
publicUrl: option({
|
|
1017
|
+
long: "public-url",
|
|
1018
|
+
type: optional(string),
|
|
1019
|
+
description: "Public origin of this registry (used as the JWT `aud` claim for Renown bearer tokens). Required when --auth-renown is true.",
|
|
1020
|
+
defaultValue: () => process.env.PH_REGISTRY_PUBLIC_URL,
|
|
1021
|
+
defaultValueIsSerializable: true
|
|
1022
|
+
}),
|
|
1023
|
+
authRenown: flag({
|
|
1024
|
+
long: "auth-renown",
|
|
1025
|
+
description: "Verify Renown-signed bearer tokens in front of verdaccio (stateless). Disabled when --public-url is unset.",
|
|
1026
|
+
defaultValue: () => process.env.PH_REGISTRY_AUTH_RENOWN === "true",
|
|
1027
|
+
defaultValueIsSerializable: true
|
|
1028
|
+
}),
|
|
1029
|
+
verdaccioSecret: option({
|
|
1030
|
+
long: "verdaccio-secret",
|
|
1031
|
+
type: optional(string),
|
|
1032
|
+
description: "Override verdaccio's internal JWT signing secret. Default: random per pod (fine — the swapped JWT never leaves this process).",
|
|
1033
|
+
defaultValue: () => process.env.PH_REGISTRY_VERDACCIO_SECRET,
|
|
1034
|
+
defaultValueIsSerializable: true
|
|
1035
|
+
}),
|
|
1036
|
+
localPackages: option({
|
|
1037
|
+
long: "local-packages",
|
|
1038
|
+
type: optional(string),
|
|
1039
|
+
description: "Comma-separated globs (e.g. '@powerhousedao/*,document-model,ph-cmd') served locally only — no npmjs uplink proxy. Lets you re-publish a workspace package whose version already exists on npmjs without bumping.",
|
|
1040
|
+
defaultValue: () => process.env.PH_REGISTRY_LOCAL_PACKAGES,
|
|
1041
|
+
defaultValueIsSerializable: true
|
|
1042
|
+
})
|
|
1043
|
+
},
|
|
1044
|
+
handler: async (args) => {
|
|
1045
|
+
console.log(args);
|
|
1046
|
+
try {
|
|
1047
|
+
await runRegistry(args);
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
console.error("Failed to start registry:");
|
|
1050
|
+
console.error(error);
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
await run(binary(registryCommand), process.argv);
|
|
1056
|
+
//#endregion
|
|
1057
|
+
export { registryCommand };
|
|
1058
|
+
|
|
1059
|
+
//# sourceMappingURL=cli.mjs.map
|