@atarashi/registry 1.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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/index.d.ts +477 -0
- package/dist/index.js +1191 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1191 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { mkdir, writeFile, rm, readdir, rename, readFile, stat, lstat } from 'fs/promises';
|
|
3
|
+
import { join, posix, resolve, sep, dirname, isAbsolute, relative } from 'path';
|
|
4
|
+
import { error, DIAGNOSTIC_CODES, parseRegistryIndex, warning, parseBlueprintManifest, parsePresetManifest } from '@atarashi/schema';
|
|
5
|
+
import { gunzipSync } from 'zlib';
|
|
6
|
+
import { Parser } from 'tar';
|
|
7
|
+
import { createHash, timingSafeEqual } from 'crypto';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
10
|
+
import { valid, rcompare, gt } from 'semver';
|
|
11
|
+
import { createRequire } from 'module';
|
|
12
|
+
|
|
13
|
+
// src/cache.ts
|
|
14
|
+
var RegistryError = class extends Error {
|
|
15
|
+
code;
|
|
16
|
+
suggestions;
|
|
17
|
+
detail;
|
|
18
|
+
constructor(code, message, options = {}) {
|
|
19
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
20
|
+
this.name = "RegistryError";
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.suggestions = options.suggestions ?? [];
|
|
23
|
+
this.detail = options.detail;
|
|
24
|
+
}
|
|
25
|
+
toDiagnostic() {
|
|
26
|
+
const rest = {};
|
|
27
|
+
if (this.suggestions.length > 0) rest.suggestions = this.suggestions;
|
|
28
|
+
if (this.detail) rest.detail = this.detail;
|
|
29
|
+
return error(this.code, this.message, rest);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
var unavailable = (message, suggestions = []) => new RegistryError(DIAGNOSTIC_CODES.REGISTRY_UNAVAILABLE, message, { suggestions });
|
|
33
|
+
var integrityFailure = (message, detail) => new RegistryError(DIAGNOSTIC_CODES.INTEGRITY_MISMATCH, message, {
|
|
34
|
+
detail,
|
|
35
|
+
suggestions: [
|
|
36
|
+
"Run `atarashi registry clear` and retry \u2014 a corrupt cache entry is the usual cause",
|
|
37
|
+
"If it reproduces, the artifact has been tampered with: do not use it, and report it"
|
|
38
|
+
]
|
|
39
|
+
});
|
|
40
|
+
var DEFAULT_LIMITS = {
|
|
41
|
+
maxEntries: 4e3,
|
|
42
|
+
maxFileSize: 8 * 1024 * 1024,
|
|
43
|
+
maxTotalSize: 64 * 1024 * 1024
|
|
44
|
+
};
|
|
45
|
+
var unsafe = (message, detail) => new RegistryError(DIAGNOSTIC_CODES.PATH_ESCAPE, message, {
|
|
46
|
+
detail,
|
|
47
|
+
suggestions: ["This artifact is malformed or malicious; do not use it, and report it"]
|
|
48
|
+
});
|
|
49
|
+
var GZIP_MAGIC = [31, 139];
|
|
50
|
+
function decompress(tarball, limits) {
|
|
51
|
+
const gzipped = tarball[0] === GZIP_MAGIC[0] && tarball[1] === GZIP_MAGIC[1];
|
|
52
|
+
if (!gzipped) return tarball;
|
|
53
|
+
try {
|
|
54
|
+
return gunzipSync(tarball, { maxOutputLength: limits.maxTotalSize });
|
|
55
|
+
} catch (cause) {
|
|
56
|
+
throw new RegistryError(
|
|
57
|
+
DIAGNOSTIC_CODES.INTEGRITY_MISMATCH,
|
|
58
|
+
"Tarball could not be decompressed, or exceeds the uncompressed size limit",
|
|
59
|
+
{ cause, detail: `limit ${limits.maxTotalSize} bytes` }
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function safeEntryPath(raw, strip = 0) {
|
|
64
|
+
const unified = raw.replace(/\\/g, "/");
|
|
65
|
+
if (unified.includes("\0")) throw unsafe("Archive member path contains a null byte", raw);
|
|
66
|
+
if (unified.startsWith("//")) throw unsafe(`Archive member \`${raw}\` is a UNC path`);
|
|
67
|
+
if (unified.startsWith("/")) throw unsafe(`Archive member \`${raw}\` is an absolute path`);
|
|
68
|
+
if (/^[a-zA-Z]:\//.test(unified)) throw unsafe(`Archive member \`${raw}\` is a drive path`);
|
|
69
|
+
const segments = unified.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
70
|
+
if (segments.includes("..")) {
|
|
71
|
+
throw unsafe(`Archive member \`${raw}\` escapes the extraction directory`);
|
|
72
|
+
}
|
|
73
|
+
const stripped = segments.slice(strip);
|
|
74
|
+
if (stripped.length === 0) return void 0;
|
|
75
|
+
return posix.join(...stripped);
|
|
76
|
+
}
|
|
77
|
+
async function readTarball(tarball, options = {}) {
|
|
78
|
+
const limits = { ...DEFAULT_LIMITS, ...options.limits };
|
|
79
|
+
const raw = decompress(tarball, limits);
|
|
80
|
+
const files = [];
|
|
81
|
+
const seen = /* @__PURE__ */ new Set();
|
|
82
|
+
let entries = 0;
|
|
83
|
+
let total = 0;
|
|
84
|
+
let failure;
|
|
85
|
+
const parser = new Parser({
|
|
86
|
+
onReadEntry(entry) {
|
|
87
|
+
try {
|
|
88
|
+
if (failure) {
|
|
89
|
+
entry.resume();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (++entries > limits.maxEntries) {
|
|
93
|
+
throw unsafe(`Archive has more than ${limits.maxEntries} members`);
|
|
94
|
+
}
|
|
95
|
+
if (entry.type === "SymbolicLink" || entry.type === "Link") {
|
|
96
|
+
throw unsafe(`Archive member \`${entry.path}\` is a link`);
|
|
97
|
+
}
|
|
98
|
+
if (entry.type === "Directory" || entry.type === "GNUDumpDir") {
|
|
99
|
+
entry.resume();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (entry.type !== "File" && entry.type !== "OldFile" && entry.type !== "ContiguousFile") {
|
|
103
|
+
throw unsafe(
|
|
104
|
+
`Archive member \`${entry.path}\` has unsupported type ${entry.type}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const path = safeEntryPath(String(entry.path), options.strip ?? 0);
|
|
108
|
+
if (path === void 0) {
|
|
109
|
+
entry.resume();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(path.toLowerCase())) {
|
|
113
|
+
throw unsafe(`Archive contains \`${path}\` twice, or two names that collide`);
|
|
114
|
+
}
|
|
115
|
+
seen.add(path.toLowerCase());
|
|
116
|
+
if ((entry.size ?? 0) > limits.maxFileSize) {
|
|
117
|
+
throw unsafe(
|
|
118
|
+
`Archive member \`${path}\` is larger than the ${limits.maxFileSize} byte limit`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const chunks = [];
|
|
122
|
+
let size = 0;
|
|
123
|
+
entry.on("data", (chunk) => {
|
|
124
|
+
size += chunk.length;
|
|
125
|
+
total += chunk.length;
|
|
126
|
+
if (size > limits.maxFileSize || total > limits.maxTotalSize) {
|
|
127
|
+
failure ??= unsafe(`Archive exceeds the ${limits.maxTotalSize} byte limit`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
chunks.push(chunk);
|
|
131
|
+
});
|
|
132
|
+
entry.on("end", () => {
|
|
133
|
+
if (failure) return;
|
|
134
|
+
const mode = (entry.mode ?? 420) & 73 ? 493 : 420;
|
|
135
|
+
files.push({ path, mode, contents: Buffer.concat(chunks) });
|
|
136
|
+
});
|
|
137
|
+
} catch (thrown) {
|
|
138
|
+
failure ??= thrown;
|
|
139
|
+
entry.resume();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
144
|
+
parser.on("error", rejectPromise);
|
|
145
|
+
parser.on("end", () => resolvePromise());
|
|
146
|
+
parser.end(raw);
|
|
147
|
+
});
|
|
148
|
+
if (failure) throw failure;
|
|
149
|
+
if (files.length === 0) throw unsafe("Archive contains no files");
|
|
150
|
+
return files.sort((a, b) => a.path < b.path ? -1 : 1);
|
|
151
|
+
}
|
|
152
|
+
async function extractTarball(tarball, dest, options = {}) {
|
|
153
|
+
const files = await readTarball(tarball, options);
|
|
154
|
+
const root = resolve(dest);
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const target = resolve(root, file.path);
|
|
157
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
158
|
+
throw unsafe(
|
|
159
|
+
`Archive member \`${file.path}\` resolves outside the extraction directory`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
await mkdir(root, { recursive: true });
|
|
164
|
+
for (const file of files) {
|
|
165
|
+
const target = join(root, file.path);
|
|
166
|
+
await mkdir(dirname(target), { recursive: true });
|
|
167
|
+
await writeFile(target, file.contents, { mode: file.mode, flag: "wx" });
|
|
168
|
+
}
|
|
169
|
+
return files.map((file) => file.path);
|
|
170
|
+
}
|
|
171
|
+
var SRI = /^(sha256|sha384|sha512)-([A-Za-z0-9+/]+={0,2})$/;
|
|
172
|
+
function digest(data, algorithm = "sha256") {
|
|
173
|
+
return `${algorithm}-${createHash(algorithm).update(data).digest("base64")}`;
|
|
174
|
+
}
|
|
175
|
+
function hexDigest(data, algorithm = "sha256") {
|
|
176
|
+
return createHash(algorithm).update(data).digest("hex");
|
|
177
|
+
}
|
|
178
|
+
function parseIntegrity(value) {
|
|
179
|
+
const match = SRI.exec(value);
|
|
180
|
+
if (!match) {
|
|
181
|
+
throw integrityFailure(`\`${value}\` is not a valid integrity digest`);
|
|
182
|
+
}
|
|
183
|
+
return { algorithm: match[1], base64: match[2] };
|
|
184
|
+
}
|
|
185
|
+
function verifyIntegrity(data, expected, what) {
|
|
186
|
+
const { algorithm } = parseIntegrity(expected);
|
|
187
|
+
const actual = digest(data, algorithm);
|
|
188
|
+
const a = Buffer.from(actual);
|
|
189
|
+
const b = Buffer.from(expected);
|
|
190
|
+
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
|
191
|
+
throw integrityFailure(
|
|
192
|
+
`Integrity check failed for ${what}`,
|
|
193
|
+
`expected ${expected}, computed ${actual}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function canonicalize(value) {
|
|
198
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value ?? null);
|
|
199
|
+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
|
200
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, v]) => `${JSON.stringify(key)}:${canonicalize(v)}`);
|
|
201
|
+
return `{${entries.join(",")}}`;
|
|
202
|
+
}
|
|
203
|
+
function indexRootHash(index) {
|
|
204
|
+
return digest(
|
|
205
|
+
canonicalize({
|
|
206
|
+
indexVersion: index.indexVersion,
|
|
207
|
+
version: index.version,
|
|
208
|
+
entries: index.entries,
|
|
209
|
+
versionManifest: index.versionManifest ?? null
|
|
210
|
+
})
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
function verifyIndexIntegrity(index, source) {
|
|
214
|
+
if (!index.integrity) return;
|
|
215
|
+
const actual = indexRootHash(index);
|
|
216
|
+
if (actual !== index.integrity) {
|
|
217
|
+
throw integrityFailure(
|
|
218
|
+
`Registry index ${index.version} failed its root hash check (${source})`,
|
|
219
|
+
`expected ${index.integrity}, computed ${actual}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
var DEFAULT_REGISTRY_URL = "https://atarashi.gautamsuthar.in/registry";
|
|
224
|
+
var DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
225
|
+
function resolveCacheRoot(env = process.env, platform = process.platform) {
|
|
226
|
+
const override = env.ATARASHI_CACHE_DIR?.trim();
|
|
227
|
+
if (override) return override;
|
|
228
|
+
if (platform === "win32") {
|
|
229
|
+
const base = env.LOCALAPPDATA?.trim() || join(homedir(), "AppData", "Local");
|
|
230
|
+
return join(base, "atarashi", "Cache");
|
|
231
|
+
}
|
|
232
|
+
if (platform === "darwin") {
|
|
233
|
+
return join(homedir(), "Library", "Caches", "atarashi");
|
|
234
|
+
}
|
|
235
|
+
const xdg = env.XDG_CACHE_HOME?.trim();
|
|
236
|
+
return join(xdg || join(homedir(), ".cache"), "atarashi");
|
|
237
|
+
}
|
|
238
|
+
function cacheLayout(root) {
|
|
239
|
+
return {
|
|
240
|
+
root,
|
|
241
|
+
index: join(root, "index"),
|
|
242
|
+
blueprints: join(root, "blueprints"),
|
|
243
|
+
meta: join(root, "meta.json")
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
var slugifyId = (id) => id.replace(/\//g, "-");
|
|
247
|
+
|
|
248
|
+
// src/cache.ts
|
|
249
|
+
var EMPTY_META = { metaVersion: 1, index: {}, blueprints: {} };
|
|
250
|
+
var hasManifest = (dir) => existsSync(join(dir, "blueprint.json")) || existsSync(join(dir, "preset.json"));
|
|
251
|
+
var readJson = async (path) => {
|
|
252
|
+
const raw = await readFile(path, "utf8");
|
|
253
|
+
return JSON.parse(raw);
|
|
254
|
+
};
|
|
255
|
+
var writeJson = async (path, value) => {
|
|
256
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
257
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
258
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}
|
|
259
|
+
`, "utf8");
|
|
260
|
+
await rename(temporary, path);
|
|
261
|
+
};
|
|
262
|
+
var RegistryCache = class {
|
|
263
|
+
layout;
|
|
264
|
+
meta;
|
|
265
|
+
constructor(root) {
|
|
266
|
+
this.layout = cacheLayout(root);
|
|
267
|
+
}
|
|
268
|
+
async readMeta() {
|
|
269
|
+
if (this.meta) return this.meta;
|
|
270
|
+
try {
|
|
271
|
+
const parsed = await readJson(this.layout.meta);
|
|
272
|
+
this.meta = parsed && parsed.metaVersion === 1 ? { ...EMPTY_META, ...parsed } : { ...EMPTY_META };
|
|
273
|
+
} catch {
|
|
274
|
+
this.meta = { ...EMPTY_META };
|
|
275
|
+
}
|
|
276
|
+
return this.meta;
|
|
277
|
+
}
|
|
278
|
+
async writeMeta(update) {
|
|
279
|
+
const meta = await this.readMeta();
|
|
280
|
+
update(meta);
|
|
281
|
+
this.meta = meta;
|
|
282
|
+
await writeJson(this.layout.meta, meta);
|
|
283
|
+
}
|
|
284
|
+
indexPath(version) {
|
|
285
|
+
return join(this.layout.index, `${version}.json`);
|
|
286
|
+
}
|
|
287
|
+
async readIndex(version, source) {
|
|
288
|
+
const path = this.indexPath(version);
|
|
289
|
+
if (!existsSync(path)) return void 0;
|
|
290
|
+
let raw;
|
|
291
|
+
try {
|
|
292
|
+
raw = await readJson(path);
|
|
293
|
+
} catch (cause) {
|
|
294
|
+
await rm(path, { force: true });
|
|
295
|
+
throw new RegistryError(
|
|
296
|
+
DIAGNOSTIC_CODES.REGISTRY_UNAVAILABLE,
|
|
297
|
+
`Cached registry index ${version} is unreadable`,
|
|
298
|
+
{ cause, suggestions: ["Run `atarashi registry update` to fetch it again"] }
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const parsed = parseRegistryIndex(raw, path);
|
|
302
|
+
if (!parsed.ok) {
|
|
303
|
+
await rm(path, { force: true });
|
|
304
|
+
throw new RegistryError(parsed.error.code, parsed.error.message, {
|
|
305
|
+
suggestions: ["Run `atarashi registry update` to fetch a fresh index"]
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
verifyIndexIntegrity(parsed.value, `${source} (cached)`);
|
|
309
|
+
return parsed.value;
|
|
310
|
+
}
|
|
311
|
+
async writeIndex(index) {
|
|
312
|
+
await mkdir(this.layout.index, { recursive: true });
|
|
313
|
+
await writeJson(this.indexPath(index.version), index);
|
|
314
|
+
}
|
|
315
|
+
/** Every registry version this machine has already verified. */
|
|
316
|
+
async cachedIndexVersions() {
|
|
317
|
+
try {
|
|
318
|
+
const files = await readdir(this.layout.index);
|
|
319
|
+
return files.filter((file) => file.endsWith(".json")).map((file) => file.slice(0, -".json".length));
|
|
320
|
+
} catch {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Content-addressed, but still legible: `db-postgres-1.2.0-8f2c…`. The hash
|
|
326
|
+
* suffix is what makes it content-addressed; the rest is for humans reading
|
|
327
|
+
* `atarashi registry verify` output.
|
|
328
|
+
*/
|
|
329
|
+
blueprintDir(id, version, integrity) {
|
|
330
|
+
const { base64 } = parseIntegrity(integrity);
|
|
331
|
+
const short = Buffer.from(base64, "base64").toString("hex").slice(0, 12);
|
|
332
|
+
return join(this.layout.blueprints, `${slugifyId(id)}-${version}-${short}`);
|
|
333
|
+
}
|
|
334
|
+
hasBlueprint(id, version, integrity) {
|
|
335
|
+
return hasManifest(this.blueprintDir(id, version, integrity));
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Verifies, extracts into a temp directory, then renames into place — so a
|
|
339
|
+
* cache directory only ever exists in its complete, verified form.
|
|
340
|
+
*/
|
|
341
|
+
async storeBlueprint(entry, tarball) {
|
|
342
|
+
verifyIntegrity(tarball, entry.integrity, `${entry.id}@${entry.version}`);
|
|
343
|
+
const target = this.blueprintDir(entry.id, entry.version, entry.integrity);
|
|
344
|
+
if (hasManifest(target)) return target;
|
|
345
|
+
const staging = `${target}.${process.pid}.${hexDigest(String(Date.now())).slice(0, 8)}`;
|
|
346
|
+
await rm(staging, { recursive: true, force: true });
|
|
347
|
+
try {
|
|
348
|
+
await extractTarball(tarball, staging);
|
|
349
|
+
if (!hasManifest(staging)) {
|
|
350
|
+
throw integrityFailure(
|
|
351
|
+
`${entry.id}@${entry.version} has no \`blueprint.json\` at its root`
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
await mkdir(this.layout.blueprints, { recursive: true });
|
|
355
|
+
await rm(target, { recursive: true, force: true });
|
|
356
|
+
await rename(staging, target);
|
|
357
|
+
} finally {
|
|
358
|
+
await rm(staging, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
await this.writeMeta((meta) => {
|
|
361
|
+
meta.blueprints[`${entry.id}@${entry.version}`] = {
|
|
362
|
+
id: entry.id,
|
|
363
|
+
version: entry.version,
|
|
364
|
+
integrity: entry.integrity,
|
|
365
|
+
dir: target
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
return target;
|
|
369
|
+
}
|
|
370
|
+
async clear() {
|
|
371
|
+
this.meta = void 0;
|
|
372
|
+
await rm(this.layout.root, { recursive: true, force: true });
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Re-checks that everything the meta file claims is cached is still there.
|
|
376
|
+
* Extracted trees cannot be re-hashed against the tarball digest, so this
|
|
377
|
+
* reports presence and shape, and prunes what has gone missing.
|
|
378
|
+
*/
|
|
379
|
+
async verify() {
|
|
380
|
+
const meta = await this.readMeta();
|
|
381
|
+
const removed = [];
|
|
382
|
+
let checked = 0;
|
|
383
|
+
for (const [key, record] of Object.entries(meta.blueprints)) {
|
|
384
|
+
checked += 1;
|
|
385
|
+
if (!hasManifest(record.dir)) removed.push(key);
|
|
386
|
+
}
|
|
387
|
+
for (const version of await this.cachedIndexVersions()) {
|
|
388
|
+
checked += 1;
|
|
389
|
+
try {
|
|
390
|
+
await this.readIndex(version, "cache");
|
|
391
|
+
} catch {
|
|
392
|
+
removed.push(`index@${version}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (removed.length > 0) {
|
|
396
|
+
await this.writeMeta((current) => {
|
|
397
|
+
for (const key of removed) delete current.blueprints[key];
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return { checked, removed };
|
|
401
|
+
}
|
|
402
|
+
async size() {
|
|
403
|
+
const walk = async (dir) => {
|
|
404
|
+
let total = 0;
|
|
405
|
+
let entries;
|
|
406
|
+
try {
|
|
407
|
+
entries = await readdir(dir);
|
|
408
|
+
} catch {
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
for (const entry of entries) {
|
|
412
|
+
const path = join(dir, entry);
|
|
413
|
+
const info = await stat(path).catch(() => void 0);
|
|
414
|
+
if (!info) continue;
|
|
415
|
+
total += info.isDirectory() ? await walk(path) : info.size;
|
|
416
|
+
}
|
|
417
|
+
return total;
|
|
418
|
+
};
|
|
419
|
+
return walk(this.layout.root);
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
function assertFetchable(url) {
|
|
423
|
+
let parsed;
|
|
424
|
+
try {
|
|
425
|
+
parsed = new URL(url);
|
|
426
|
+
} catch {
|
|
427
|
+
throw unavailable(`\`${url}\` is not a valid registry URL`);
|
|
428
|
+
}
|
|
429
|
+
if (parsed.protocol === "https:" || parsed.protocol === "file:") return parsed;
|
|
430
|
+
if (parsed.protocol === "http:") {
|
|
431
|
+
throw unavailable(`Refusing to fetch the registry over plain HTTP: ${url}`, [
|
|
432
|
+
"Use an `https://` registry URL"
|
|
433
|
+
]);
|
|
434
|
+
}
|
|
435
|
+
throw unavailable(`Unsupported registry protocol \`${parsed.protocol}\` in ${url}`);
|
|
436
|
+
}
|
|
437
|
+
async function httpGet(url, options = {}) {
|
|
438
|
+
const parsed = assertFetchable(url);
|
|
439
|
+
const maxBytes = options.maxBytes ?? DEFAULT_LIMITS.maxTotalSize;
|
|
440
|
+
if (parsed.protocol === "file:") {
|
|
441
|
+
try {
|
|
442
|
+
const body = await readFile(fileURLToPath(parsed));
|
|
443
|
+
return { status: 200, body, etag: void 0 };
|
|
444
|
+
} catch {
|
|
445
|
+
throw unavailable(`Cannot read ${url}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
449
|
+
if (!fetchImpl) {
|
|
450
|
+
throw unavailable("This Node build has no global `fetch`", [
|
|
451
|
+
"Upgrade to Node 20.11 or newer"
|
|
452
|
+
]);
|
|
453
|
+
}
|
|
454
|
+
const controller = new AbortController();
|
|
455
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15e3);
|
|
456
|
+
try {
|
|
457
|
+
const headers = { accept: "application/json, application/gzip" };
|
|
458
|
+
if (options.etag) headers["if-none-match"] = options.etag;
|
|
459
|
+
const response = await fetchImpl(url, { headers, signal: controller.signal });
|
|
460
|
+
const etag = response.headers.get("etag") ?? void 0;
|
|
461
|
+
if (response.status === 304) return { status: 304, body: Buffer.alloc(0), etag };
|
|
462
|
+
if (!response.ok) {
|
|
463
|
+
throw unavailable(`${url} responded ${response.status} ${response.statusText}`.trim());
|
|
464
|
+
}
|
|
465
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
466
|
+
if (body.byteLength > maxBytes) {
|
|
467
|
+
throw unavailable(
|
|
468
|
+
`${url} returned ${body.byteLength} bytes, over the ${maxBytes} limit`
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return { status: response.status, body, etag };
|
|
472
|
+
} catch (cause) {
|
|
473
|
+
if (cause instanceof RegistryError) throw cause;
|
|
474
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
475
|
+
throw unavailable(`Cannot reach ${url}: ${reason}`, [
|
|
476
|
+
"Check your network connection, or run with `--offline` to use the cache"
|
|
477
|
+
]);
|
|
478
|
+
} finally {
|
|
479
|
+
clearTimeout(timeout);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
var joinUrl = (base, path) => /^[a-z][a-z0-9+.-]*:/i.test(path) ? path : `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
|
483
|
+
var indexUrl = (base, pin) => pin ? joinUrl(base, `v1/${pin}/index.json`) : joinUrl(base, "v1/index.json");
|
|
484
|
+
var IndexStore = class {
|
|
485
|
+
options;
|
|
486
|
+
loaded;
|
|
487
|
+
diagnostics = [];
|
|
488
|
+
constructor(options) {
|
|
489
|
+
this.options = {
|
|
490
|
+
url: DEFAULT_REGISTRY_URL,
|
|
491
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
492
|
+
...options
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
get url() {
|
|
496
|
+
return this.options.url;
|
|
497
|
+
}
|
|
498
|
+
get origin() {
|
|
499
|
+
return this.loaded?.origin;
|
|
500
|
+
}
|
|
501
|
+
/** Resolves once per process; subsequent calls reuse the verified document. */
|
|
502
|
+
async load() {
|
|
503
|
+
if (this.loaded) return this.loaded;
|
|
504
|
+
this.loaded = this.options.pin ? await this.loadPinned(this.options.pin) : await this.loadLatest();
|
|
505
|
+
return this.loaded;
|
|
506
|
+
}
|
|
507
|
+
/** Discards the TTL and re-checks the network — `atarashi registry update`. */
|
|
508
|
+
async refresh() {
|
|
509
|
+
this.loaded = void 0;
|
|
510
|
+
const cache = this.options.cache;
|
|
511
|
+
await cache.writeMeta((meta) => {
|
|
512
|
+
delete meta.index[indexUrl(this.options.url, this.options.pin)];
|
|
513
|
+
});
|
|
514
|
+
return this.load();
|
|
515
|
+
}
|
|
516
|
+
async loadPinned(version) {
|
|
517
|
+
const cache = this.options.cache;
|
|
518
|
+
if (!valid(version)) {
|
|
519
|
+
throw unavailable(`\`${version}\` is not a valid registry version`, [
|
|
520
|
+
"Registry versions are exact semver, e.g. `atarashi registry pin 1.4.2`"
|
|
521
|
+
]);
|
|
522
|
+
}
|
|
523
|
+
const cached = await cache.readIndex(version, this.options.url);
|
|
524
|
+
if (cached) {
|
|
525
|
+
return { index: cached, origin: this.originOf(cached, "cache") };
|
|
526
|
+
}
|
|
527
|
+
if (this.options.offline) {
|
|
528
|
+
throw unavailable(`Registry ${version} is pinned but not cached, and you are offline`, [
|
|
529
|
+
"Run `atarashi registry update` with a network connection",
|
|
530
|
+
"Or remove the pin with `atarashi registry unpin`"
|
|
531
|
+
]);
|
|
532
|
+
}
|
|
533
|
+
const url = indexUrl(this.options.url, version);
|
|
534
|
+
const response = await httpGet(url, { fetchImpl: this.options.fetchImpl });
|
|
535
|
+
const index = this.parse(response.body, url);
|
|
536
|
+
if (index.version !== version) {
|
|
537
|
+
throw unavailable(
|
|
538
|
+
`Registry ${version} was requested but ${url} served ${index.version}`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
await this.persist(index, url, response.etag);
|
|
542
|
+
return { index, origin: this.originOf(index, "network") };
|
|
543
|
+
}
|
|
544
|
+
async loadLatest() {
|
|
545
|
+
const cache = this.options.cache;
|
|
546
|
+
const url = indexUrl(this.options.url);
|
|
547
|
+
const meta = await cache.readMeta();
|
|
548
|
+
const record = meta.index[url];
|
|
549
|
+
const cachedIndex = record ? await cache.readIndex(record.version, url) : void 0;
|
|
550
|
+
if (this.options.offline) {
|
|
551
|
+
const offlineIndex = cachedIndex ?? await this.newestCached();
|
|
552
|
+
if (offlineIndex)
|
|
553
|
+
return { index: offlineIndex, origin: this.originOf(offlineIndex, "cache") };
|
|
554
|
+
throw unavailable("No cached registry index, and the network is disabled", [
|
|
555
|
+
"Run `atarashi registry update` with a network connection",
|
|
556
|
+
"Bundled blueprints still work offline \u2014 drop `--offline` only if you need registry-only ones"
|
|
557
|
+
]);
|
|
558
|
+
}
|
|
559
|
+
if (cachedIndex && record && this.isFresh(record.fetchedAt)) {
|
|
560
|
+
return {
|
|
561
|
+
index: cachedIndex,
|
|
562
|
+
origin: this.originOf(cachedIndex, "cache", record.fetchedAt)
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
const response = await httpGet(url, {
|
|
567
|
+
etag: record?.etag,
|
|
568
|
+
fetchImpl: this.options.fetchImpl
|
|
569
|
+
});
|
|
570
|
+
if (response.status === 304 && cachedIndex) {
|
|
571
|
+
await this.touch(url, cachedIndex.version, record?.etag);
|
|
572
|
+
return { index: cachedIndex, origin: this.originOf(cachedIndex, "cache") };
|
|
573
|
+
}
|
|
574
|
+
const index = this.parse(response.body, url);
|
|
575
|
+
await this.persist(index, url, response.etag);
|
|
576
|
+
return { index, origin: this.originOf(index, "network") };
|
|
577
|
+
} catch (thrown) {
|
|
578
|
+
if (thrown instanceof RegistryError && thrown.code !== DIAGNOSTIC_CODES.REGISTRY_UNAVAILABLE) {
|
|
579
|
+
throw thrown;
|
|
580
|
+
}
|
|
581
|
+
const fallback = cachedIndex ?? await this.newestCached();
|
|
582
|
+
if (!fallback) throw thrown;
|
|
583
|
+
this.diagnostics.push(
|
|
584
|
+
warning(
|
|
585
|
+
DIAGNOSTIC_CODES.REGISTRY_UNAVAILABLE,
|
|
586
|
+
`Registry unreachable; using the cached index ${fallback.version}`,
|
|
587
|
+
{
|
|
588
|
+
detail: thrown instanceof Error ? thrown.message : String(thrown),
|
|
589
|
+
suggestions: ["Run `atarashi registry update` once you are back online"]
|
|
590
|
+
}
|
|
591
|
+
)
|
|
592
|
+
);
|
|
593
|
+
return { index: fallback, origin: this.originOf(fallback, "cache") };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
parse(body, url) {
|
|
597
|
+
let raw;
|
|
598
|
+
try {
|
|
599
|
+
raw = JSON.parse(body.toString("utf8"));
|
|
600
|
+
} catch (cause) {
|
|
601
|
+
throw new RegistryError(
|
|
602
|
+
DIAGNOSTIC_CODES.REGISTRY_UNAVAILABLE,
|
|
603
|
+
`${url} did not return valid JSON`,
|
|
604
|
+
{ cause }
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const parsed = parseRegistryIndex(raw, url);
|
|
608
|
+
if (!parsed.ok) {
|
|
609
|
+
throw new RegistryError(parsed.error.code, `${parsed.error.message} (${url})`, {
|
|
610
|
+
suggestions: ["Upgrade Atarashi \u2014 this index may use a newer index version"]
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
verifyIndexIntegrity(parsed.value, url);
|
|
614
|
+
return parsed.value;
|
|
615
|
+
}
|
|
616
|
+
async persist(index, url, etag) {
|
|
617
|
+
await this.options.cache.writeIndex(index);
|
|
618
|
+
await this.touch(url, index.version, etag);
|
|
619
|
+
}
|
|
620
|
+
async touch(url, version, etag) {
|
|
621
|
+
const fetchedAt = this.nowIso();
|
|
622
|
+
await this.options.cache.writeMeta((meta) => {
|
|
623
|
+
meta.index[url] = etag ? { etag, fetchedAt, version } : { fetchedAt, version };
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
async newestCached() {
|
|
627
|
+
const versions = (await this.options.cache.cachedIndexVersions()).filter((version) => valid(version)).sort(rcompare);
|
|
628
|
+
for (const version of versions) {
|
|
629
|
+
const index = await this.options.cache.readIndex(version, this.options.url);
|
|
630
|
+
if (index) return index;
|
|
631
|
+
}
|
|
632
|
+
return void 0;
|
|
633
|
+
}
|
|
634
|
+
isFresh(fetchedAt) {
|
|
635
|
+
const age = this.now() - Date.parse(fetchedAt);
|
|
636
|
+
return Number.isFinite(age) && age >= 0 && age < this.options.ttlMs;
|
|
637
|
+
}
|
|
638
|
+
originOf(index, from, fetchedAt) {
|
|
639
|
+
const origin = { version: index.version, url: this.options.url, from };
|
|
640
|
+
if (fetchedAt) origin.fetchedAt = fetchedAt;
|
|
641
|
+
if (index.integrity) origin.integrity = index.integrity;
|
|
642
|
+
return origin;
|
|
643
|
+
}
|
|
644
|
+
now() {
|
|
645
|
+
return (this.options.now?.() ?? /* @__PURE__ */ new Date()).getTime();
|
|
646
|
+
}
|
|
647
|
+
nowIso() {
|
|
648
|
+
return new Date(this.now()).toISOString();
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
var MANIFEST_FILE = "blueprint.json";
|
|
652
|
+
var PRESET_FILE = "preset.json";
|
|
653
|
+
var invalid = (message, suggestions = []) => new RegistryError(DIAGNOSTIC_CODES.SCHEMA_INVALID, message, { suggestions });
|
|
654
|
+
async function readInside(root, path) {
|
|
655
|
+
if (path.includes("\0")) throw invalid(`Blueprint file path contains a null byte`);
|
|
656
|
+
if (isAbsolute(path)) throw invalid(`Blueprint file \`${path}\` must be relative`);
|
|
657
|
+
const target = resolve(root, path);
|
|
658
|
+
const rel = relative(root, target);
|
|
659
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
|
|
660
|
+
throw new RegistryError(
|
|
661
|
+
DIAGNOSTIC_CODES.PATH_ESCAPE,
|
|
662
|
+
`Blueprint file \`${path}\` resolves outside its own directory`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
let walked = root;
|
|
666
|
+
for (const segment of rel.split(sep)) {
|
|
667
|
+
walked = join(walked, segment);
|
|
668
|
+
const info = await lstat(walked).catch(() => void 0);
|
|
669
|
+
if (info?.isSymbolicLink()) {
|
|
670
|
+
throw new RegistryError(
|
|
671
|
+
DIAGNOSTIC_CODES.PATH_ESCAPE,
|
|
672
|
+
`Blueprint file \`${path}\` passes through the symlink \`${segment}\``
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return readFile(target);
|
|
677
|
+
}
|
|
678
|
+
async function loadBlueprintDir(dir, options) {
|
|
679
|
+
const manifestPath = join(dir, MANIFEST_FILE);
|
|
680
|
+
if (!existsSync(manifestPath)) {
|
|
681
|
+
throw invalid(`No \`${MANIFEST_FILE}\` in ${dir}`, [
|
|
682
|
+
"Run `atarashi create-blueprint --validate .` inside the blueprint"
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
const raw = await readFile(manifestPath, "utf8");
|
|
686
|
+
let parsedJson;
|
|
687
|
+
try {
|
|
688
|
+
parsedJson = JSON.parse(raw);
|
|
689
|
+
} catch (cause) {
|
|
690
|
+
throw new RegistryError(
|
|
691
|
+
DIAGNOSTIC_CODES.SCHEMA_INVALID,
|
|
692
|
+
`${manifestPath} is not valid JSON`,
|
|
693
|
+
{ cause }
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
const parsed = parseBlueprintManifest(parsedJson, manifestPath);
|
|
697
|
+
if (!parsed.ok) {
|
|
698
|
+
throw new RegistryError(parsed.error.code, parsed.error.message, {
|
|
699
|
+
detail: parsed.error.diagnostics.map((d) => d.message).join("\n")
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
manifest: parsed.value,
|
|
704
|
+
origin: options.origin ?? dir,
|
|
705
|
+
dir,
|
|
706
|
+
trusted: options.trusted,
|
|
707
|
+
readFile: (path) => readInside(dir, path)
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
async function loadPresetFile(path) {
|
|
711
|
+
const parsed = parsePresetManifest(JSON.parse(await readFile(path, "utf8")), path);
|
|
712
|
+
if (!parsed.ok) {
|
|
713
|
+
throw new RegistryError(parsed.error.code, parsed.error.message, {
|
|
714
|
+
detail: parsed.error.diagnostics.map((d) => d.message).join("\n")
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
return parsed.value;
|
|
718
|
+
}
|
|
719
|
+
var summarize = (manifest) => ({
|
|
720
|
+
id: manifest.id,
|
|
721
|
+
version: manifest.version,
|
|
722
|
+
provides: manifest.provides,
|
|
723
|
+
requires: manifest.requires,
|
|
724
|
+
conflicts: manifest.conflicts,
|
|
725
|
+
deprecated: Boolean(manifest.deprecated)
|
|
726
|
+
});
|
|
727
|
+
var DirectorySource = class {
|
|
728
|
+
constructor(root, options) {
|
|
729
|
+
this.root = root;
|
|
730
|
+
this.options = options;
|
|
731
|
+
}
|
|
732
|
+
root;
|
|
733
|
+
options;
|
|
734
|
+
manifests;
|
|
735
|
+
/** Scans lazily and once — a cold `atarashi list` should not pay twice. */
|
|
736
|
+
async scan() {
|
|
737
|
+
if (this.manifests) return this.manifests;
|
|
738
|
+
const found = /* @__PURE__ */ new Map();
|
|
739
|
+
if (existsSync(this.root)) {
|
|
740
|
+
for (const namespace of await readdir(this.root, { withFileTypes: true })) {
|
|
741
|
+
if (!namespace.isDirectory()) continue;
|
|
742
|
+
const namespaceDir = join(this.root, namespace.name);
|
|
743
|
+
for (const name of await readdir(namespaceDir, { withFileTypes: true })) {
|
|
744
|
+
if (!name.isDirectory()) continue;
|
|
745
|
+
const dir = join(namespaceDir, name.name);
|
|
746
|
+
if (existsSync(join(dir, MANIFEST_FILE))) {
|
|
747
|
+
found.set(`${namespace.name}/${name.name}`, dir);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
this.manifests = found;
|
|
753
|
+
return found;
|
|
754
|
+
}
|
|
755
|
+
async ids() {
|
|
756
|
+
return [...(await this.scan()).keys()].sort();
|
|
757
|
+
}
|
|
758
|
+
async has(id) {
|
|
759
|
+
return (await this.scan()).has(id);
|
|
760
|
+
}
|
|
761
|
+
async load(id) {
|
|
762
|
+
const dir = (await this.scan()).get(id);
|
|
763
|
+
if (!dir) return void 0;
|
|
764
|
+
return loadBlueprintDir(dir, this.options);
|
|
765
|
+
}
|
|
766
|
+
async summaries() {
|
|
767
|
+
const out = [];
|
|
768
|
+
for (const id of await this.ids()) {
|
|
769
|
+
const blueprint = await this.load(id);
|
|
770
|
+
if (blueprint) out.push(summarize(blueprint.manifest));
|
|
771
|
+
}
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
async loadPreset(id) {
|
|
775
|
+
const dir = this.options.presetsDir;
|
|
776
|
+
if (!dir) return void 0;
|
|
777
|
+
const file = join(dir, `${id.replace(/^preset\//, "")}.json`);
|
|
778
|
+
if (!existsSync(file)) return void 0;
|
|
779
|
+
return loadPresetFile(file);
|
|
780
|
+
}
|
|
781
|
+
async presets() {
|
|
782
|
+
const dir = this.options.presetsDir;
|
|
783
|
+
if (!dir || !existsSync(dir)) return [];
|
|
784
|
+
const files = (await readdir(dir)).filter((file) => file.endsWith(".json")).sort();
|
|
785
|
+
return Promise.all(files.map((file) => loadPresetFile(join(dir, file))));
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
// src/loaders/bundled.ts
|
|
790
|
+
var require_ = createRequire(import.meta.url);
|
|
791
|
+
function findBundledRoot(explicit) {
|
|
792
|
+
if (explicit) return existsSync(explicit) ? explicit : void 0;
|
|
793
|
+
try {
|
|
794
|
+
return dirname(require_.resolve("@atarashi/blueprints/package.json"));
|
|
795
|
+
} catch {
|
|
796
|
+
return void 0;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
function createBundledSource(explicit) {
|
|
800
|
+
const root = findBundledRoot(explicit);
|
|
801
|
+
if (!root) return void 0;
|
|
802
|
+
return {
|
|
803
|
+
root,
|
|
804
|
+
versionManifestPath: join(root, "version-manifest.json"),
|
|
805
|
+
source: new DirectorySource(join(root, "blueprints"), {
|
|
806
|
+
trusted: true,
|
|
807
|
+
origin: "bundled",
|
|
808
|
+
presetsDir: join(root, "presets")
|
|
809
|
+
})
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
async function readBundledVersionManifest(path) {
|
|
813
|
+
if (!existsSync(path)) return void 0;
|
|
814
|
+
try {
|
|
815
|
+
const raw = JSON.parse(await readFile(path, "utf8"));
|
|
816
|
+
return raw.packages ?? {};
|
|
817
|
+
} catch {
|
|
818
|
+
return void 0;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
var LOCAL_BLUEPRINTS_DIR = join(".atarashi", "blueprints");
|
|
822
|
+
function createLocalSource(cwd, dir) {
|
|
823
|
+
const root = dir ?? join(cwd, LOCAL_BLUEPRINTS_DIR);
|
|
824
|
+
return new DirectorySource(root, {
|
|
825
|
+
trusted: false,
|
|
826
|
+
origin: `local:${root}`,
|
|
827
|
+
presetsDir: join(root, "..", "presets")
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
var NPM_PREFIX = "npm:";
|
|
831
|
+
var NPM_PACKAGE_PATTERN = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?atarashi-blueprint-[a-z0-9-]+$/;
|
|
832
|
+
var parseNpmSpec = (spec) => spec.startsWith(NPM_PREFIX) ? spec.slice(NPM_PREFIX.length) : void 0;
|
|
833
|
+
var NpmSource = class {
|
|
834
|
+
constructor(cwd, packages = []) {
|
|
835
|
+
this.cwd = cwd;
|
|
836
|
+
this.packages = packages;
|
|
837
|
+
}
|
|
838
|
+
cwd;
|
|
839
|
+
packages;
|
|
840
|
+
cache = /* @__PURE__ */ new Map();
|
|
841
|
+
resolved;
|
|
842
|
+
/** Adds a package for this run, e.g. from `--add npm:atarashi-blueprint-redis`. */
|
|
843
|
+
add(spec) {
|
|
844
|
+
const name = parseNpmSpec(spec) ?? spec;
|
|
845
|
+
if (!this.packages.includes(name)) {
|
|
846
|
+
this.packages.push(name);
|
|
847
|
+
this.resolved = void 0;
|
|
848
|
+
}
|
|
849
|
+
return this;
|
|
850
|
+
}
|
|
851
|
+
async loadPackage(name) {
|
|
852
|
+
const existing = this.cache.get(name);
|
|
853
|
+
if (existing) return existing;
|
|
854
|
+
if (!NPM_PACKAGE_PATTERN.test(name)) {
|
|
855
|
+
throw new RegistryError(
|
|
856
|
+
DIAGNOSTIC_CODES.UNKNOWN_BLUEPRINT,
|
|
857
|
+
`\`${name}\` is not a blueprint package name`,
|
|
858
|
+
{
|
|
859
|
+
suggestions: [
|
|
860
|
+
"Community blueprint packages are named `atarashi-blueprint-<name>`"
|
|
861
|
+
]
|
|
862
|
+
}
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
const require_2 = createRequire(pathToFileURL(join(this.cwd, "noop.js")));
|
|
866
|
+
let packageJsonPath;
|
|
867
|
+
try {
|
|
868
|
+
packageJsonPath = require_2.resolve(`${name}/package.json`);
|
|
869
|
+
} catch {
|
|
870
|
+
throw new RegistryError(
|
|
871
|
+
DIAGNOSTIC_CODES.UNKNOWN_BLUEPRINT,
|
|
872
|
+
`\`${name}\` is not installed in ${this.cwd}`,
|
|
873
|
+
{ suggestions: [`Install it first: \`npm install ${name}\``] }
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
const packageRoot = dirname(packageJsonPath);
|
|
877
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
|
|
878
|
+
if (!packageJson.atarashi) {
|
|
879
|
+
throw new RegistryError(
|
|
880
|
+
DIAGNOSTIC_CODES.SCHEMA_INVALID,
|
|
881
|
+
`\`${name}\` has no \`atarashi\` field pointing at its blueprint`,
|
|
882
|
+
{ suggestions: ['Add `"atarashi": "./blueprint"` to the package'] }
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
const dir = resolve(packageRoot, packageJson.atarashi);
|
|
886
|
+
if (!dir.startsWith(packageRoot) || !existsSync(dir)) {
|
|
887
|
+
throw new RegistryError(
|
|
888
|
+
DIAGNOSTIC_CODES.SCHEMA_INVALID,
|
|
889
|
+
`\`${name}\` points its \`atarashi\` field outside the package`
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
const blueprint = await loadBlueprintDir(dir, {
|
|
893
|
+
trusted: false,
|
|
894
|
+
origin: `npm:${name}@${packageJson.version ?? "unknown"}`
|
|
895
|
+
});
|
|
896
|
+
this.cache.set(name, blueprint);
|
|
897
|
+
return blueprint;
|
|
898
|
+
}
|
|
899
|
+
async all() {
|
|
900
|
+
if (this.resolved) return this.resolved;
|
|
901
|
+
const map = /* @__PURE__ */ new Map();
|
|
902
|
+
for (const name of this.packages) {
|
|
903
|
+
const blueprint = await this.loadPackage(name);
|
|
904
|
+
map.set(blueprint.manifest.id, blueprint);
|
|
905
|
+
}
|
|
906
|
+
this.resolved = map;
|
|
907
|
+
return map;
|
|
908
|
+
}
|
|
909
|
+
async load(id) {
|
|
910
|
+
const name = parseNpmSpec(id);
|
|
911
|
+
if (name) return this.loadPackage(name);
|
|
912
|
+
return (await this.all()).get(id);
|
|
913
|
+
}
|
|
914
|
+
async summaries() {
|
|
915
|
+
return [...(await this.all()).values()].map((entry) => summarize(entry.manifest));
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
var entrySummary = (entry) => ({
|
|
919
|
+
id: entry.id,
|
|
920
|
+
version: entry.version,
|
|
921
|
+
provides: entry.provides,
|
|
922
|
+
requires: entry.requires,
|
|
923
|
+
conflicts: entry.conflicts,
|
|
924
|
+
deprecated: entry.deprecated
|
|
925
|
+
});
|
|
926
|
+
var RemoteSource = class {
|
|
927
|
+
constructor(store, cache, options = {}) {
|
|
928
|
+
this.store = store;
|
|
929
|
+
this.cache = cache;
|
|
930
|
+
this.options = options;
|
|
931
|
+
}
|
|
932
|
+
store;
|
|
933
|
+
cache;
|
|
934
|
+
options;
|
|
935
|
+
/** `undefined` when the index cannot be reached at all — bundled still works. */
|
|
936
|
+
async index() {
|
|
937
|
+
try {
|
|
938
|
+
return (await this.store.load()).index;
|
|
939
|
+
} catch (thrown) {
|
|
940
|
+
if (thrown instanceof RegistryError && thrown.code === DIAGNOSTIC_CODES.INTEGRITY_MISMATCH) {
|
|
941
|
+
throw thrown;
|
|
942
|
+
}
|
|
943
|
+
return void 0;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
async entry(id) {
|
|
947
|
+
const index = await this.index();
|
|
948
|
+
return index?.entries.find(
|
|
949
|
+
(candidate) => candidate.id === id && candidate.kind !== "preset"
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
async summaries() {
|
|
953
|
+
const index = await this.index();
|
|
954
|
+
return (index?.entries ?? []).filter((entry) => entry.kind !== "preset").map(entrySummary);
|
|
955
|
+
}
|
|
956
|
+
async versionManifest() {
|
|
957
|
+
const index = await this.index();
|
|
958
|
+
return index?.versionManifest?.packages;
|
|
959
|
+
}
|
|
960
|
+
async load(id) {
|
|
961
|
+
const entry = await this.entry(id);
|
|
962
|
+
if (!entry) return void 0;
|
|
963
|
+
return this.loadEntry(entry);
|
|
964
|
+
}
|
|
965
|
+
async loadEntry(entry) {
|
|
966
|
+
const dir = await this.materialize(entry);
|
|
967
|
+
return loadBlueprintDir(dir, {
|
|
968
|
+
// Trust is a property of the source, not of the document it
|
|
969
|
+
// serves: an index cannot promote its own entries.
|
|
970
|
+
trusted: this.options.trusted ?? false,
|
|
971
|
+
origin: `registry:${entry.id}@${entry.version}`
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
async preset(id) {
|
|
975
|
+
const index = await this.index();
|
|
976
|
+
return index?.entries.find((entry) => entry.id === id && entry.kind === "preset");
|
|
977
|
+
}
|
|
978
|
+
async loadPreset(id) {
|
|
979
|
+
const entry = await this.preset(id);
|
|
980
|
+
if (!entry) return void 0;
|
|
981
|
+
const dir = await this.materialize(entry);
|
|
982
|
+
return loadPresetFile(join(dir, "preset.json"));
|
|
983
|
+
}
|
|
984
|
+
/** Downloads and verifies once; every later run is a cache hit. */
|
|
985
|
+
async materialize(entry) {
|
|
986
|
+
if (this.cache.hasBlueprint(entry.id, entry.version, entry.integrity)) {
|
|
987
|
+
return this.cache.blueprintDir(entry.id, entry.version, entry.integrity);
|
|
988
|
+
}
|
|
989
|
+
if (this.options.offline) {
|
|
990
|
+
throw unavailable(
|
|
991
|
+
`${entry.id}@${entry.version} is not cached, and the network is disabled`,
|
|
992
|
+
["Run the same command without `--offline` once to populate the cache"]
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
const url = joinUrl(this.store.url, entry.tarball);
|
|
996
|
+
const response = await httpGet(url, { fetchImpl: this.options.fetchImpl });
|
|
997
|
+
return this.cache.storeBlueprint(entry, response.body);
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
var isOffline = (options) => {
|
|
1001
|
+
if (options.offline !== void 0) return options.offline;
|
|
1002
|
+
const env = options.env ?? process.env;
|
|
1003
|
+
return env.ATARASHI_OFFLINE === "1" || env.ATARASHI_OFFLINE === "true";
|
|
1004
|
+
};
|
|
1005
|
+
var Registry = class {
|
|
1006
|
+
local;
|
|
1007
|
+
npm;
|
|
1008
|
+
bundled;
|
|
1009
|
+
remotes = [];
|
|
1010
|
+
primary;
|
|
1011
|
+
cache;
|
|
1012
|
+
offline;
|
|
1013
|
+
constructor(options = {}) {
|
|
1014
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1015
|
+
this.offline = isOffline(options);
|
|
1016
|
+
this.cache = new RegistryCache(
|
|
1017
|
+
options.cacheDir ?? resolveCacheRoot(options.env ?? process.env)
|
|
1018
|
+
);
|
|
1019
|
+
this.local = createLocalSource(cwd, options.localDir);
|
|
1020
|
+
this.npm = new NpmSource(cwd, [...options.npmPackages ?? []]);
|
|
1021
|
+
this.bundled = createBundledSource(options.bundledDir);
|
|
1022
|
+
const configured = options.sources?.length ? options.sources : [
|
|
1023
|
+
{
|
|
1024
|
+
name: "atarashi",
|
|
1025
|
+
url: options.url ?? DEFAULT_REGISTRY_URL,
|
|
1026
|
+
trusted: true,
|
|
1027
|
+
enabled: true
|
|
1028
|
+
}
|
|
1029
|
+
];
|
|
1030
|
+
for (const source of configured.filter((candidate) => candidate.enabled !== false)) {
|
|
1031
|
+
const store = new IndexStore({
|
|
1032
|
+
cache: this.cache,
|
|
1033
|
+
url: source.url,
|
|
1034
|
+
pin: options.pin ?? null,
|
|
1035
|
+
offline: this.offline,
|
|
1036
|
+
ttlMs: options.ttlMs ?? DEFAULT_TTL_MS,
|
|
1037
|
+
fetchImpl: options.fetchImpl,
|
|
1038
|
+
now: options.now
|
|
1039
|
+
});
|
|
1040
|
+
this.remotes.push({
|
|
1041
|
+
name: source.name,
|
|
1042
|
+
store,
|
|
1043
|
+
source: new RemoteSource(store, this.cache, {
|
|
1044
|
+
offline: this.offline,
|
|
1045
|
+
fetchImpl: options.fetchImpl,
|
|
1046
|
+
trusted: source.trusted === true
|
|
1047
|
+
})
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
this.primary = this.remotes[0];
|
|
1051
|
+
}
|
|
1052
|
+
/** Warnings collected while resolving — degraded-network notices, mostly. */
|
|
1053
|
+
get diagnostics() {
|
|
1054
|
+
return this.remotes.flatMap((layer) => layer.store.diagnostics);
|
|
1055
|
+
}
|
|
1056
|
+
// ── BlueprintSource ──────────────────────────────────────────────────────
|
|
1057
|
+
async load(id) {
|
|
1058
|
+
if (id.startsWith(NPM_PREFIX)) return this.npm.load(id);
|
|
1059
|
+
const local = await this.local.load(id);
|
|
1060
|
+
if (local) return local;
|
|
1061
|
+
const fromNpm = await this.npm.load(id);
|
|
1062
|
+
if (fromNpm) return fromNpm;
|
|
1063
|
+
const bundled = await this.bundled?.source.load(id);
|
|
1064
|
+
for (const layer of this.remotes) {
|
|
1065
|
+
const entry = await layer.source.entry(id);
|
|
1066
|
+
if (!entry) continue;
|
|
1067
|
+
if (bundled && !isNewer(entry.version, bundled.manifest.version)) break;
|
|
1068
|
+
try {
|
|
1069
|
+
return await layer.source.loadEntry(entry);
|
|
1070
|
+
} catch (thrown) {
|
|
1071
|
+
if (thrown instanceof RegistryError && thrown.code === DIAGNOSTIC_CODES.INTEGRITY_MISMATCH) {
|
|
1072
|
+
throw thrown;
|
|
1073
|
+
}
|
|
1074
|
+
if (!bundled) throw thrown;
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return bundled;
|
|
1079
|
+
}
|
|
1080
|
+
async providersOf(capability) {
|
|
1081
|
+
const all = await this.summaries();
|
|
1082
|
+
return all.filter((summary) => summary.provides.includes(capability)).sort((a, b) => a.id < b.id ? -1 : 1);
|
|
1083
|
+
}
|
|
1084
|
+
async loadPreset(id) {
|
|
1085
|
+
const local = await this.local.loadPreset(id);
|
|
1086
|
+
if (local) return local;
|
|
1087
|
+
for (const layer of this.remotes) {
|
|
1088
|
+
const preset = await layer.source.loadPreset(id);
|
|
1089
|
+
if (preset) return preset;
|
|
1090
|
+
}
|
|
1091
|
+
return this.bundled?.source.loadPreset(id);
|
|
1092
|
+
}
|
|
1093
|
+
/** Every preset visible from every layer, deduplicated by precedence — `atarashi list presets`. */
|
|
1094
|
+
async presets() {
|
|
1095
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1096
|
+
for (const preset of await this.bundled?.source.presets() ?? [])
|
|
1097
|
+
merged.set(preset.id, preset);
|
|
1098
|
+
for (const layer of this.remotes) {
|
|
1099
|
+
const loaded = await layer.store.load().catch(() => void 0);
|
|
1100
|
+
const presetEntries = loaded?.index.entries.filter((entry) => entry.kind === "preset") ?? [];
|
|
1101
|
+
for (const entry of presetEntries) {
|
|
1102
|
+
const preset = await layer.source.loadPreset(entry.id);
|
|
1103
|
+
if (preset) merged.set(preset.id, preset);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
for (const preset of await this.local.presets()) merged.set(preset.id, preset);
|
|
1107
|
+
return [...merged.values()].sort((a, b) => a.id < b.id ? -1 : 1);
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* One place defines the range every blueprint uses. The registry's manifest
|
|
1111
|
+
* wins over the bundled one; anything the registry does not pin keeps the
|
|
1112
|
+
* bundled value, so an offline run resolves the same names.
|
|
1113
|
+
*/
|
|
1114
|
+
async versionManifest() {
|
|
1115
|
+
const bundled = this.bundled ? await readBundledVersionManifest(this.bundled.versionManifestPath) ?? {} : {};
|
|
1116
|
+
const remote = await this.primary?.source.versionManifest() ?? {};
|
|
1117
|
+
return { ...bundled, ...remote };
|
|
1118
|
+
}
|
|
1119
|
+
// ── Beyond the core interface ────────────────────────────────────────────
|
|
1120
|
+
/** Every blueprint visible from every layer, deduplicated by precedence. */
|
|
1121
|
+
async summaries() {
|
|
1122
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1123
|
+
const put = (summary, overwrite) => {
|
|
1124
|
+
const existing = merged.get(summary.id);
|
|
1125
|
+
if (!existing || overwrite && isNewer(summary.version, existing.version)) {
|
|
1126
|
+
merged.set(summary.id, summary);
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
for (const summary of await this.bundled?.source.summaries() ?? []) put(summary, false);
|
|
1130
|
+
for (const layer of this.remotes) {
|
|
1131
|
+
for (const summary of await layer.source.summaries()) put(summary, true);
|
|
1132
|
+
}
|
|
1133
|
+
for (const summary of await this.npm.summaries()) merged.set(summary.id, summary);
|
|
1134
|
+
for (const summary of await this.local.summaries()) merged.set(summary.id, summary);
|
|
1135
|
+
return [...merged.values()].sort((a, b) => a.id < b.id ? -1 : 1);
|
|
1136
|
+
}
|
|
1137
|
+
async list() {
|
|
1138
|
+
return this.summaries();
|
|
1139
|
+
}
|
|
1140
|
+
/** Which layer an id resolves from, without loading its files. */
|
|
1141
|
+
async originOf(id) {
|
|
1142
|
+
if (await this.local.has(id)) return "local";
|
|
1143
|
+
if (await this.npm.load(id).catch(() => void 0)) return "npm";
|
|
1144
|
+
for (const layer of this.remotes) {
|
|
1145
|
+
if (await layer.source.entry(id)) return "registry";
|
|
1146
|
+
}
|
|
1147
|
+
return await this.bundled?.source.load(id) ? "bundled" : void 0;
|
|
1148
|
+
}
|
|
1149
|
+
/** What gets written into `atarashi.json` so this run can be reproduced. */
|
|
1150
|
+
async pin() {
|
|
1151
|
+
if (!this.primary) return void 0;
|
|
1152
|
+
try {
|
|
1153
|
+
const { index, origin } = await this.primary.store.load();
|
|
1154
|
+
const record = { version: index.version };
|
|
1155
|
+
if (index.integrity) record.integrity = index.integrity;
|
|
1156
|
+
if (origin.url !== DEFAULT_REGISTRY_URL) record.source = origin.url;
|
|
1157
|
+
return record;
|
|
1158
|
+
} catch {
|
|
1159
|
+
return void 0;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/** `atarashi registry update` — ignore the TTL and re-check every source. */
|
|
1163
|
+
async update() {
|
|
1164
|
+
const results = [];
|
|
1165
|
+
for (const layer of this.remotes) {
|
|
1166
|
+
const { index, origin } = await layer.store.refresh();
|
|
1167
|
+
results.push({ name: layer.name, version: index.version, from: origin.from });
|
|
1168
|
+
}
|
|
1169
|
+
return results;
|
|
1170
|
+
}
|
|
1171
|
+
/** `atarashi registry list` — every version this machine has verified. */
|
|
1172
|
+
async cachedVersions() {
|
|
1173
|
+
return this.cache.cachedIndexVersions();
|
|
1174
|
+
}
|
|
1175
|
+
verifyCache() {
|
|
1176
|
+
return this.cache.verify();
|
|
1177
|
+
}
|
|
1178
|
+
clearCache() {
|
|
1179
|
+
return this.cache.clear();
|
|
1180
|
+
}
|
|
1181
|
+
addNpmPackage(spec) {
|
|
1182
|
+
this.npm.add(spec);
|
|
1183
|
+
return this;
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
var isNewer = (candidate, incumbent) => valid(candidate) && valid(incumbent) ? gt(candidate, incumbent) : false;
|
|
1187
|
+
var createRegistry = (options = {}) => new Registry(options);
|
|
1188
|
+
|
|
1189
|
+
export { DEFAULT_LIMITS, DEFAULT_REGISTRY_URL, DEFAULT_TTL_MS, DirectorySource, IndexStore, LOCAL_BLUEPRINTS_DIR, MANIFEST_FILE, NPM_PREFIX, NpmSource, PRESET_FILE, Registry, RegistryCache, RegistryError, RemoteSource, assertFetchable, cacheLayout, canonicalize, createBundledSource, createLocalSource, createRegistry, digest, entrySummary, extractTarball, findBundledRoot, hexDigest, httpGet, indexRootHash, integrityFailure, joinUrl, loadBlueprintDir, loadPresetFile, parseIntegrity, parseNpmSpec, readBundledVersionManifest, readTarball, resolveCacheRoot, safeEntryPath, slugifyId, summarize, unavailable, verifyIndexIntegrity, verifyIntegrity };
|
|
1190
|
+
//# sourceMappingURL=index.js.map
|
|
1191
|
+
//# sourceMappingURL=index.js.map
|