@triflare/fx 1.0.3 → 2.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/README.md +32 -0
- package/bin/fx.js +4 -1162
- package/package.json +5 -3
- package/product.yml +1 -1
- package/src/brand.js +9 -3
- package/src/cli/commands/build.js +98 -0
- package/src/cli/commands/catalog.js +42 -0
- package/src/cli/commands/check.js +44 -0
- package/src/cli/commands/completion.js +54 -0
- package/src/cli/commands/decompile.js +266 -0
- package/src/cli/commands/dev.js +179 -0
- package/src/cli/commands/extension.js +110 -0
- package/src/cli/commands/index.js +39 -0
- package/src/cli/commands/init.js +169 -0
- package/src/cli/commands/package.js +137 -0
- package/src/cli/commands/sprite/add.js +85 -0
- package/src/cli/commands/sprite/build.js +62 -0
- package/src/cli/commands/sprite/helpers.js +66 -0
- package/src/cli/commands/sprite/index.js +29 -0
- package/src/cli/commands/sprite/list.js +31 -0
- package/src/cli/commands/sprite/remove.js +51 -0
- package/src/cli/commands/vendor/add.js +98 -0
- package/src/cli/commands/vendor/helpers.js +50 -0
- package/src/cli/commands/vendor/index.js +39 -0
- package/src/cli/commands/vendor/install.js +64 -0
- package/src/cli/commands/vendor/list.js +45 -0
- package/src/cli/commands/vendor/migrate.js +47 -0
- package/src/cli/commands/vendor/outdated.js +51 -0
- package/src/cli/commands/vendor/remove.js +34 -0
- package/src/cli/commands/vendor/update.js +49 -0
- package/src/cli/commands/vendor/verify.js +44 -0
- package/src/cli/completion/bash.js +10 -0
- package/src/cli/completion/fish.js +9 -0
- package/src/cli/completion/index.js +97 -0
- package/src/cli/completion/zsh.js +11 -0
- package/src/cli/context.js +257 -0
- package/src/cli/define-command.js +140 -0
- package/src/cli/errors.js +111 -0
- package/src/cli/format.js +31 -0
- package/src/cli/prompts.js +183 -0
- package/src/cli/reporter.js +194 -0
- package/src/cli/run.js +248 -0
- package/src/cli/templates/index.js +112 -0
- package/src/cli/theme.js +84 -0
- package/src/config-edit.js +280 -1
- package/src/config.js +137 -3
- package/src/extensions.js +72 -19
- package/src/index.js +21 -0
- package/src/vendor.js +1323 -0
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
package/src/vendor.js
ADDED
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored extension support.
|
|
3
|
+
*
|
|
4
|
+
* `fx.yml` can declare extensions under `vendor:`; their source is committed
|
|
5
|
+
* under `extensions/` (or `vendorDir`) and recorded in `fx.lock`. Builds load
|
|
6
|
+
* the vendored copy, so extension URLs never need to be reachable after the
|
|
7
|
+
* first install, and the archive embeds verified source instead of a URL.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import * as yaml from "js-yaml";
|
|
13
|
+
import { DEFAULT_VENDOR_DIR, normalizeVendorEntry } from "./config.js";
|
|
14
|
+
import {
|
|
15
|
+
addVendorEntry,
|
|
16
|
+
extensionKey,
|
|
17
|
+
removeProjectExtensions,
|
|
18
|
+
removeVendorEntry,
|
|
19
|
+
} from "./config-edit.js";
|
|
20
|
+
import {
|
|
21
|
+
inspectExtension,
|
|
22
|
+
loadExtensions,
|
|
23
|
+
readExtensionDataUri,
|
|
24
|
+
resolveSource,
|
|
25
|
+
} from "./extensions.js";
|
|
26
|
+
|
|
27
|
+
export const LOCK_FILENAME = "fx.lock";
|
|
28
|
+
export const LOCK_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
export class VendorError extends Error {
|
|
31
|
+
constructor(message, options) {
|
|
32
|
+
super(message, options);
|
|
33
|
+
this.name = "VendorError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {Buffer|string} value */
|
|
38
|
+
export function sha256(value) {
|
|
39
|
+
return crypto
|
|
40
|
+
.createHash("sha256")
|
|
41
|
+
.update(Buffer.isBuffer(value) ? value : Buffer.from(String(value)))
|
|
42
|
+
.digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isRemoteSource(source) {
|
|
46
|
+
return /^https?:\/\//i.test(String(source));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isDataSource(source) {
|
|
50
|
+
return /^data:/i.test(String(source));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve the configured vendor directory against the config directory. */
|
|
54
|
+
export function vendorDirPath(configDir, config = {}) {
|
|
55
|
+
const configured =
|
|
56
|
+
(typeof config === "string" ? config : config?.vendorDir) ||
|
|
57
|
+
config?.fx?.vendorDir ||
|
|
58
|
+
DEFAULT_VENDOR_DIR;
|
|
59
|
+
return path.resolve(configDir, String(configured));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Path relative to the config directory, using forward slashes. */
|
|
63
|
+
export function relativeVendorPath(configDir, file) {
|
|
64
|
+
return path.relative(configDir, file).split(path.sep).join("/");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Normalize a path string to forward slashes for lock/config records. */
|
|
68
|
+
function posix(value) {
|
|
69
|
+
return String(value).replace(/\\/g, "/");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A safe file stem for an extension name. */
|
|
73
|
+
export function vendorFileStem(name) {
|
|
74
|
+
return (
|
|
75
|
+
String(name)
|
|
76
|
+
.replace(/\.js$/i, "")
|
|
77
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
78
|
+
.replace(/^[.-]+|[.-]+$/g, "") || "extension"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Pick a name from a URL or path, like `signals` for `.../signals.js`. */
|
|
83
|
+
export function defaultVendorName(source) {
|
|
84
|
+
if (isDataSource(source)) return "extension";
|
|
85
|
+
const clean = String(source).split(/[?#]/)[0];
|
|
86
|
+
const base = clean.split(/[\\/]/).pop() || "";
|
|
87
|
+
let decoded = base;
|
|
88
|
+
try {
|
|
89
|
+
decoded = decodeURIComponent(base);
|
|
90
|
+
} catch {
|
|
91
|
+
// Keep the raw basename when it is not valid percent-encoding.
|
|
92
|
+
}
|
|
93
|
+
const stem = decoded
|
|
94
|
+
.replace(/\.js$/i, "")
|
|
95
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
96
|
+
.replace(/^[.-]+|[.-]+$/g, "");
|
|
97
|
+
return stem || "extension";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Make a proposed name unique by appending a numeric suffix. */
|
|
101
|
+
export function uniqueVendorName(name, used) {
|
|
102
|
+
const base = String(name) || "extension";
|
|
103
|
+
if (!used.has(base)) return base;
|
|
104
|
+
for (let suffix = 2; ; suffix += 1) {
|
|
105
|
+
const candidate = `${base}-${suffix}`;
|
|
106
|
+
if (!used.has(candidate)) return candidate;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True when a mapping looks like a lock entry rather than metadata. */
|
|
111
|
+
function looksLikeLockEntry(value) {
|
|
112
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
113
|
+
return ["source", "url", "path", "file", "sha256", "hash", "integrity"].some(
|
|
114
|
+
(key) => Object.hasOwn(value, key),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Normalize legacy lock-entry spellings into the current shape. */
|
|
119
|
+
function normalizeLockEntry(name, raw) {
|
|
120
|
+
const file = raw.file ?? raw.vendored ?? raw.vendorFile ?? raw.vendor_path;
|
|
121
|
+
const source = raw.source ?? raw.url ?? raw.sourcePath ?? raw.source_path;
|
|
122
|
+
return {
|
|
123
|
+
name: String(raw.name ?? name),
|
|
124
|
+
source: source == null ? null : String(source),
|
|
125
|
+
file: file == null ? null : posix(file),
|
|
126
|
+
sha256: raw.sha256 ?? raw.hash ?? raw.integrity ?? null,
|
|
127
|
+
fetched:
|
|
128
|
+
raw.fetched ?? raw.time ?? raw.installedAt ?? raw.fetchedAt ?? null,
|
|
129
|
+
remote:
|
|
130
|
+
raw.remote != null
|
|
131
|
+
? Boolean(raw.remote)
|
|
132
|
+
: source != null
|
|
133
|
+
? isRemoteSource(source)
|
|
134
|
+
: undefined,
|
|
135
|
+
local: raw.local != null ? Boolean(raw.local) : undefined,
|
|
136
|
+
volatile: raw.volatile != null ? Boolean(raw.volatile) : undefined,
|
|
137
|
+
etag: raw.etag ?? null,
|
|
138
|
+
lastModified: raw.lastModified ?? raw.last_modified ?? null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read `fx.lock`. Missing files are an empty lock, not an error.
|
|
144
|
+
* @param {string} configDir
|
|
145
|
+
* @returns {{version: number, extensions: Record<string, object>, path: string}}
|
|
146
|
+
*/
|
|
147
|
+
export function readLock(configDir) {
|
|
148
|
+
const file = path.join(configDir, LOCK_FILENAME);
|
|
149
|
+
if (!fs.existsSync(file))
|
|
150
|
+
return { version: LOCK_VERSION, extensions: {}, path: file };
|
|
151
|
+
|
|
152
|
+
let parsed;
|
|
153
|
+
try {
|
|
154
|
+
parsed = yaml.load(fs.readFileSync(file, "utf8")) ?? {};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new VendorError(`Could not read ${LOCK_FILENAME}: ${error.message}`, {
|
|
157
|
+
cause: error,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (typeof parsed !== "object" || Array.isArray(parsed))
|
|
161
|
+
throw new VendorError(`${LOCK_FILENAME} must contain a mapping`);
|
|
162
|
+
|
|
163
|
+
const extensions = {};
|
|
164
|
+
const nested = parsed.extensions ?? parsed.vendor ?? parsed.entries;
|
|
165
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
166
|
+
for (const [name, entry] of Object.entries(nested))
|
|
167
|
+
if (entry && typeof entry === "object")
|
|
168
|
+
extensions[name] = normalizeLockEntry(name, entry);
|
|
169
|
+
} else {
|
|
170
|
+
// Also accept a lock whose entries are top-level keys.
|
|
171
|
+
for (const [name, entry] of Object.entries(parsed)) {
|
|
172
|
+
if (["version", "lockfileVersion", "schema", "packages"].includes(name))
|
|
173
|
+
continue;
|
|
174
|
+
if (looksLikeLockEntry(entry))
|
|
175
|
+
extensions[name] = normalizeLockEntry(name, entry);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
version: Number(parsed.version ?? parsed.lockfileVersion ?? LOCK_VERSION),
|
|
181
|
+
extensions,
|
|
182
|
+
path: file,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Write `fx.lock` as JSON (which is also valid YAML).
|
|
188
|
+
* @param {string} configDir
|
|
189
|
+
* @param {{version?: number, extensions: Record<string, object>}} lock
|
|
190
|
+
*/
|
|
191
|
+
export function writeLock(configDir, lock) {
|
|
192
|
+
const file = path.join(configDir, LOCK_FILENAME);
|
|
193
|
+
const out = { version: lock.version ?? LOCK_VERSION, extensions: {} };
|
|
194
|
+
for (const [name, entry] of Object.entries(lock.extensions || {})) {
|
|
195
|
+
const clean = {
|
|
196
|
+
name,
|
|
197
|
+
source: entry.source ?? null,
|
|
198
|
+
file: entry.file ? posix(entry.file) : null,
|
|
199
|
+
sha256: entry.sha256 ?? null,
|
|
200
|
+
fetched: entry.fetched ?? null,
|
|
201
|
+
remote: Boolean(entry.remote),
|
|
202
|
+
local: Boolean(entry.local),
|
|
203
|
+
volatile: Boolean(entry.volatile),
|
|
204
|
+
};
|
|
205
|
+
if (entry.etag) clean.etag = entry.etag;
|
|
206
|
+
if (entry.lastModified) clean.lastModified = entry.lastModified;
|
|
207
|
+
out.extensions[name] = clean;
|
|
208
|
+
}
|
|
209
|
+
writeFileAtomic(file, `${JSON.stringify(out, null, 2)}\n`);
|
|
210
|
+
return file;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Add the generated-from header used for copied local extensions. */
|
|
214
|
+
function localVendoredContent(entry, buffer) {
|
|
215
|
+
const header = `// generated from ${entry.source}, do not edit\n`;
|
|
216
|
+
return Buffer.concat([Buffer.from(header, "utf8"), buffer]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Apply source-specific storage rules before hashing or writing. */
|
|
220
|
+
function storedContent(entry, buffer) {
|
|
221
|
+
return entry.local ? localVendoredContent(entry, buffer) : buffer;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Read a local/data source and return its bytes plus resolved file. */
|
|
225
|
+
function readLocalSource(entry, configDir) {
|
|
226
|
+
if (entry.data) {
|
|
227
|
+
const source = readExtensionDataUri(entry.source);
|
|
228
|
+
if (source == null) throw new VendorError("unsupported data URI");
|
|
229
|
+
return { buffer: Buffer.from(source, "utf8"), file: null };
|
|
230
|
+
}
|
|
231
|
+
const file = path.isAbsolute(entry.source)
|
|
232
|
+
? entry.source
|
|
233
|
+
: path.resolve(configDir, entry.source);
|
|
234
|
+
try {
|
|
235
|
+
return { buffer: fs.readFileSync(file), file };
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new VendorError(
|
|
238
|
+
`Could not read local extension ${entry.source}: ${error.message}`,
|
|
239
|
+
{ cause: error },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read a configured source, either from disk/data URI or over HTTP.
|
|
246
|
+
* @returns {Promise<{buffer: Buffer, extra: object}>}
|
|
247
|
+
*/
|
|
248
|
+
async function readEntrySource(entry, configDir, fetchImpl) {
|
|
249
|
+
if (entry.local || entry.data)
|
|
250
|
+
return { buffer: readLocalSource(entry, configDir).buffer, extra: {} };
|
|
251
|
+
const fetched = await fetchVendorSource(entry.source, { fetchImpl });
|
|
252
|
+
return {
|
|
253
|
+
buffer: fetched.buffer,
|
|
254
|
+
extra: { etag: fetched.etag, lastModified: fetched.lastModified },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Build conditional request headers when the lock has validators. */
|
|
259
|
+
function fetchOptions(etag, lastModified) {
|
|
260
|
+
const headers = {};
|
|
261
|
+
if (etag) headers["If-None-Match"] = etag;
|
|
262
|
+
if (lastModified) headers["If-Modified-Since"] = lastModified;
|
|
263
|
+
return Object.keys(headers).length ? { headers } : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Fetch an extension over HTTP.
|
|
268
|
+
* @returns {Promise<{buffer: Buffer, etag: string|null, lastModified: string|null, notModified: boolean}>}
|
|
269
|
+
*/
|
|
270
|
+
export async function fetchVendorSource(
|
|
271
|
+
source,
|
|
272
|
+
{ fetchImpl, etag, lastModified } = {},
|
|
273
|
+
) {
|
|
274
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
275
|
+
if (typeof fetchFn !== "function")
|
|
276
|
+
throw new VendorError("global fetch is unavailable");
|
|
277
|
+
let response;
|
|
278
|
+
try {
|
|
279
|
+
response = await fetchFn(source, fetchOptions(etag, lastModified));
|
|
280
|
+
} catch (error) {
|
|
281
|
+
throw new VendorError(`Could not download ${source}: ${error.message}`, {
|
|
282
|
+
cause: error,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (response.status === 304)
|
|
286
|
+
return {
|
|
287
|
+
buffer: null,
|
|
288
|
+
etag: response.headers?.get?.("etag") ?? etag ?? null,
|
|
289
|
+
lastModified:
|
|
290
|
+
response.headers?.get?.("last-modified") ?? lastModified ?? null,
|
|
291
|
+
notModified: true,
|
|
292
|
+
};
|
|
293
|
+
if (!response.ok) {
|
|
294
|
+
const status = `${response.status} ${response.statusText ?? ""}`.trim();
|
|
295
|
+
throw new VendorError(`Could not download ${source}: HTTP ${status}`);
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
buffer: Buffer.from(await response.arrayBuffer()),
|
|
299
|
+
etag: response.headers?.get?.("etag") ?? null,
|
|
300
|
+
lastModified: response.headers?.get?.("last-modified") ?? null,
|
|
301
|
+
notModified: false,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Reject paths whose existing components include a symbolic link. Walking the
|
|
307
|
+
* components prevents a lock path from escaping through a linked parent.
|
|
308
|
+
*/
|
|
309
|
+
function assertNoSymlinkPath(start, target, relative) {
|
|
310
|
+
const base = path.resolve(start);
|
|
311
|
+
const absolute = path.resolve(target);
|
|
312
|
+
const suffix = path.relative(base, absolute);
|
|
313
|
+
if (
|
|
314
|
+
suffix === "" ||
|
|
315
|
+
suffix === ".." ||
|
|
316
|
+
suffix.startsWith(`..${path.sep}`) ||
|
|
317
|
+
path.isAbsolute(suffix)
|
|
318
|
+
)
|
|
319
|
+
return;
|
|
320
|
+
let current = base;
|
|
321
|
+
for (const part of suffix.split(path.sep)) {
|
|
322
|
+
current = path.join(current, part);
|
|
323
|
+
const stat = fs.lstatSync(current, { throwIfNoEntry: false });
|
|
324
|
+
if (!stat) break;
|
|
325
|
+
if (stat.isSymbolicLink())
|
|
326
|
+
throw new VendorError(
|
|
327
|
+
`Refusing to access ${relative}: ${path.relative(
|
|
328
|
+
base,
|
|
329
|
+
current,
|
|
330
|
+
)} is a symbolic link`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Resolve a relative vendor path and enforce the filesystem boundary. */
|
|
336
|
+
function ensureVendorTarget(configDir, vendorDir, relative) {
|
|
337
|
+
const base = path.resolve(configDir);
|
|
338
|
+
const root = path.resolve(base, vendorDir);
|
|
339
|
+
const rootInside = path.relative(base, root);
|
|
340
|
+
if (
|
|
341
|
+
rootInside === "" ||
|
|
342
|
+
rootInside === ".." ||
|
|
343
|
+
rootInside.startsWith(`..${path.sep}`) ||
|
|
344
|
+
path.isAbsolute(rootInside)
|
|
345
|
+
)
|
|
346
|
+
throw new VendorError(
|
|
347
|
+
`Refusing to use vendor directory ${JSON.stringify(
|
|
348
|
+
relativeVendorPath(base, root) || ".",
|
|
349
|
+
)}: it must be inside the config directory`,
|
|
350
|
+
);
|
|
351
|
+
const absolute = path.resolve(base, relative);
|
|
352
|
+
const inside = path.relative(root, absolute);
|
|
353
|
+
if (
|
|
354
|
+
inside === ".." ||
|
|
355
|
+
inside.startsWith(`..${path.sep}`) ||
|
|
356
|
+
path.isAbsolute(inside)
|
|
357
|
+
)
|
|
358
|
+
throw new VendorError(
|
|
359
|
+
`Refusing to write ${relative}: it is outside ${relativeVendorPath(
|
|
360
|
+
configDir,
|
|
361
|
+
root,
|
|
362
|
+
)}`,
|
|
363
|
+
);
|
|
364
|
+
// Resolve links as well as lexical traversal: a project-controlled lock path
|
|
365
|
+
// must not escape the vendor directory through a symlinked ancestor.
|
|
366
|
+
assertNoSymlinkPath(base, root, relative);
|
|
367
|
+
const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
|
|
368
|
+
if (rootStat?.isSymbolicLink())
|
|
369
|
+
throw new VendorError(
|
|
370
|
+
`Refusing to access ${relative}: ${relativeVendorPath(
|
|
371
|
+
configDir,
|
|
372
|
+
root,
|
|
373
|
+
)} is a symbolic link`,
|
|
374
|
+
);
|
|
375
|
+
assertNoSymlinkPath(root, absolute, relative);
|
|
376
|
+
return absolute;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Reject a build/restore when the configured source no longer matches. */
|
|
380
|
+
function assertLockSource(entry, lockEntry) {
|
|
381
|
+
if (!lockEntry?.source || lockEntry.source === entry.source) return;
|
|
382
|
+
throw new VendorError(
|
|
383
|
+
`extension "${entry.name}": source changed from ${JSON.stringify(
|
|
384
|
+
lockEntry.source,
|
|
385
|
+
)} to ${JSON.stringify(entry.source)}; run "fx vendor update ${entry.name}"`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Allocate a collision-free, non-existing file name for a vendor entry. */
|
|
390
|
+
function chooseVendorFile(
|
|
391
|
+
configDir,
|
|
392
|
+
vendorDir,
|
|
393
|
+
name,
|
|
394
|
+
lock,
|
|
395
|
+
reserved = new Set(),
|
|
396
|
+
) {
|
|
397
|
+
const used = new Set(
|
|
398
|
+
Object.values(lock?.extensions || {})
|
|
399
|
+
.map((entry) => entry.file)
|
|
400
|
+
.filter(Boolean),
|
|
401
|
+
);
|
|
402
|
+
for (const file of reserved) used.add(posix(file));
|
|
403
|
+
const base = vendorFileStem(name);
|
|
404
|
+
for (let suffix = 1; ; suffix += 1) {
|
|
405
|
+
const stem = suffix === 1 ? base : `${base}-${suffix}`;
|
|
406
|
+
const relative = posix(
|
|
407
|
+
path.join(relativeVendorPath(configDir, vendorDir), `${stem}.js`),
|
|
408
|
+
);
|
|
409
|
+
if (used.has(relative)) continue;
|
|
410
|
+
// Never allocate a path that already exists on disk: untracked files are
|
|
411
|
+
// not ours to overwrite.
|
|
412
|
+
if (fs.existsSync(path.resolve(configDir, relative))) continue;
|
|
413
|
+
return relative;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Pick the vendored file for every configured entry, reusing lock paths and
|
|
419
|
+
* allocating collision-free names for entries that are not locked yet.
|
|
420
|
+
*
|
|
421
|
+
* @returns {Map<string, {file: string, lockEntry: object|null}>}
|
|
422
|
+
*/
|
|
423
|
+
export function resolveVendorFiles(configDir, vendorDir, entries, lock) {
|
|
424
|
+
const used = new Set(
|
|
425
|
+
Object.values(lock?.extensions || {})
|
|
426
|
+
.map((entry) => entry.file)
|
|
427
|
+
.filter(Boolean),
|
|
428
|
+
);
|
|
429
|
+
const files = new Map();
|
|
430
|
+
for (const entry of entries || []) {
|
|
431
|
+
const lockEntry = lock?.extensions?.[entry.name] ?? null;
|
|
432
|
+
let file = lockEntry?.file;
|
|
433
|
+
if (!file) {
|
|
434
|
+
file = chooseVendorFile(configDir, vendorDir, entry.name, lock, used);
|
|
435
|
+
used.add(file);
|
|
436
|
+
}
|
|
437
|
+
files.set(entry.name, { file, lockEntry });
|
|
438
|
+
}
|
|
439
|
+
return files;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Replace a file atomically so an interrupted write cannot truncate it. */
|
|
443
|
+
function writeFileAtomic(file, data) {
|
|
444
|
+
const temp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
445
|
+
try {
|
|
446
|
+
fs.writeFileSync(temp, data, { flag: "wx" });
|
|
447
|
+
fs.renameSync(temp, file);
|
|
448
|
+
} finally {
|
|
449
|
+
fs.rmSync(temp, { force: true });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Write a vendored file after checking containment, symlinks and ownership.
|
|
455
|
+
* Untracked regular files are never overwritten unless the caller explicitly
|
|
456
|
+
* identifies them as owned by the lock.
|
|
457
|
+
*/
|
|
458
|
+
function writeVendorFile(
|
|
459
|
+
configDir,
|
|
460
|
+
vendorDir,
|
|
461
|
+
relative,
|
|
462
|
+
buffer,
|
|
463
|
+
{ allowOverwrite = false } = {},
|
|
464
|
+
) {
|
|
465
|
+
const absolute = ensureVendorTarget(configDir, vendorDir, relative);
|
|
466
|
+
const existing = fs.lstatSync(absolute, { throwIfNoEntry: false });
|
|
467
|
+
if (existing && !existing.isFile())
|
|
468
|
+
throw new VendorError(
|
|
469
|
+
`Refusing to write ${relative}: it is not a regular file`,
|
|
470
|
+
);
|
|
471
|
+
if (existing && !allowOverwrite)
|
|
472
|
+
throw new VendorError(
|
|
473
|
+
`Refusing to overwrite ${relative}: it is not recorded in ${LOCK_FILENAME}`,
|
|
474
|
+
);
|
|
475
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
476
|
+
writeFileAtomic(absolute, buffer);
|
|
477
|
+
return absolute;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Build the lock record for a source's stored bytes. */
|
|
481
|
+
function lockEntryForInstall(name, entry, file, buffer, extra = {}) {
|
|
482
|
+
return {
|
|
483
|
+
name,
|
|
484
|
+
source: entry.source,
|
|
485
|
+
file: posix(file),
|
|
486
|
+
sha256: sha256(buffer),
|
|
487
|
+
fetched: new Date().toISOString(),
|
|
488
|
+
remote: Boolean(entry.remote),
|
|
489
|
+
local: Boolean(entry.local),
|
|
490
|
+
volatile: Boolean(entry.volatile),
|
|
491
|
+
etag: extra.etag ?? null,
|
|
492
|
+
lastModified: extra.lastModified ?? null,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Fetch or copy one extension and write it under the vendor directory.
|
|
498
|
+
*
|
|
499
|
+
* @param {object} options
|
|
500
|
+
* @param {string} options.configPath
|
|
501
|
+
* @param {string} options.configDir
|
|
502
|
+
* @param {string} options.name
|
|
503
|
+
* @param {string} options.source
|
|
504
|
+
* @param {string} [options.vendorDir]
|
|
505
|
+
* @param {object} [options.lock]
|
|
506
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
507
|
+
* @param {boolean} [options.unsandboxed]
|
|
508
|
+
* @param {boolean} [options.volatile]
|
|
509
|
+
* @returns {Promise<{entry: object, lockEntry: object, file: string}>}
|
|
510
|
+
*/
|
|
511
|
+
export async function installVendorEntry({
|
|
512
|
+
configPath,
|
|
513
|
+
configDir,
|
|
514
|
+
name,
|
|
515
|
+
source,
|
|
516
|
+
vendorDir,
|
|
517
|
+
lock,
|
|
518
|
+
fetchImpl,
|
|
519
|
+
unsandboxed,
|
|
520
|
+
volatile,
|
|
521
|
+
}) {
|
|
522
|
+
const dir = vendorDir
|
|
523
|
+
? path.resolve(configDir, vendorDir)
|
|
524
|
+
: path.join(configDir, DEFAULT_VENDOR_DIR);
|
|
525
|
+
const currentLock = lock || readLock(configDir);
|
|
526
|
+
const entry = normalizeVendorEntry(String(name), {
|
|
527
|
+
url: source,
|
|
528
|
+
...(unsandboxed ? { unsandboxed: true } : {}),
|
|
529
|
+
...(volatile != null ? { volatile } : {}),
|
|
530
|
+
});
|
|
531
|
+
const configVolatile =
|
|
532
|
+
volatile == null ? entry.volatile || undefined : Boolean(volatile);
|
|
533
|
+
|
|
534
|
+
let buffer;
|
|
535
|
+
let extra = {};
|
|
536
|
+
if (entry.remote) {
|
|
537
|
+
const read = await readEntrySource(entry, configDir, fetchImpl);
|
|
538
|
+
buffer = read.buffer;
|
|
539
|
+
extra = read.extra;
|
|
540
|
+
} else {
|
|
541
|
+
buffer = readLocalSource(entry, configDir).buffer;
|
|
542
|
+
}
|
|
543
|
+
const stored = storedContent(entry, buffer);
|
|
544
|
+
|
|
545
|
+
const previous = currentLock.extensions[name];
|
|
546
|
+
const file =
|
|
547
|
+
previous?.file || chooseVendorFile(configDir, dir, name, currentLock);
|
|
548
|
+
const absolute = ensureVendorTarget(configDir, dir, file);
|
|
549
|
+
const existing = fs.lstatSync(absolute, { throwIfNoEntry: false });
|
|
550
|
+
const before = existing?.isFile() ? fs.readFileSync(absolute) : null;
|
|
551
|
+
const configBefore = fs.readFileSync(configPath, "utf8");
|
|
552
|
+
let wrote = false;
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
writeVendorFile(configDir, dir, file, stored, {
|
|
556
|
+
allowOverwrite: Boolean(previous),
|
|
557
|
+
});
|
|
558
|
+
wrote = true;
|
|
559
|
+
addVendorEntry(configPath, name, entry.source, {
|
|
560
|
+
unsandboxed: entry.unsandboxed,
|
|
561
|
+
volatile: configVolatile,
|
|
562
|
+
});
|
|
563
|
+
const lockEntry = lockEntryForInstall(name, entry, file, stored, extra);
|
|
564
|
+
currentLock.extensions[name] = lockEntry;
|
|
565
|
+
writeLock(configDir, currentLock);
|
|
566
|
+
return { entry, lockEntry, file };
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if (wrote) {
|
|
569
|
+
if (before) fs.writeFileSync(absolute, before);
|
|
570
|
+
else fs.rmSync(absolute, { force: true });
|
|
571
|
+
}
|
|
572
|
+
fs.writeFileSync(configPath, configBefore);
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Install (or restore) every extension under `vendor:`.
|
|
579
|
+
*
|
|
580
|
+
* Existing files are verified and left alone; only missing remote files are
|
|
581
|
+
* fetched. Local extensions are always re-copied from their source.
|
|
582
|
+
*/
|
|
583
|
+
export async function installVendor({ configDir, config, fetchImpl } = {}) {
|
|
584
|
+
const entries = config?.vendor || [];
|
|
585
|
+
const dirName = config?.vendorDir || DEFAULT_VENDOR_DIR;
|
|
586
|
+
const vendorDir = path.resolve(configDir, dirName);
|
|
587
|
+
const lock = readLock(configDir);
|
|
588
|
+
const files = resolveVendorFiles(configDir, vendorDir, entries, lock);
|
|
589
|
+
const warnings = [];
|
|
590
|
+
const results = [];
|
|
591
|
+
const backups = new Map();
|
|
592
|
+
const remember = (absolute) => {
|
|
593
|
+
if (backups.has(absolute)) return;
|
|
594
|
+
const existing = fs.lstatSync(absolute, { throwIfNoEntry: false });
|
|
595
|
+
backups.set(absolute, {
|
|
596
|
+
existed: Boolean(existing),
|
|
597
|
+
buffer: existing?.isFile() ? fs.readFileSync(absolute) : Buffer.alloc(0),
|
|
598
|
+
});
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
for (const entry of entries) {
|
|
603
|
+
const { file, lockEntry: previous } = files.get(entry.name);
|
|
604
|
+
|
|
605
|
+
if (entry.local || entry.data) {
|
|
606
|
+
const local = readLocalSource(entry, configDir);
|
|
607
|
+
const stored = storedContent(entry, local.buffer);
|
|
608
|
+
const absolute = ensureVendorTarget(configDir, vendorDir, file);
|
|
609
|
+
remember(absolute);
|
|
610
|
+
writeVendorFile(configDir, vendorDir, file, stored, {
|
|
611
|
+
allowOverwrite: Boolean(previous),
|
|
612
|
+
});
|
|
613
|
+
lock.extensions[entry.name] = lockEntryForInstall(
|
|
614
|
+
entry.name,
|
|
615
|
+
entry,
|
|
616
|
+
file,
|
|
617
|
+
stored,
|
|
618
|
+
);
|
|
619
|
+
results.push({
|
|
620
|
+
name: entry.name,
|
|
621
|
+
file,
|
|
622
|
+
status: entry.local ? "copied" : "verified",
|
|
623
|
+
});
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const absolute = ensureVendorTarget(configDir, vendorDir, file);
|
|
628
|
+
assertLockSource(entry, previous);
|
|
629
|
+
if (fs.existsSync(absolute)) {
|
|
630
|
+
const buffer = fs.readFileSync(absolute);
|
|
631
|
+
const hash = sha256(buffer);
|
|
632
|
+
if (previous?.sha256 && hash !== previous.sha256) {
|
|
633
|
+
if (entry.volatile) {
|
|
634
|
+
warnings.push(
|
|
635
|
+
`extension "${entry.name}" changed in ${file}; volatile entry accepted`,
|
|
636
|
+
);
|
|
637
|
+
lock.extensions[entry.name] = {
|
|
638
|
+
...previous,
|
|
639
|
+
sha256: hash,
|
|
640
|
+
fetched: new Date().toISOString(),
|
|
641
|
+
};
|
|
642
|
+
} else {
|
|
643
|
+
throw new VendorError(
|
|
644
|
+
`extension "${entry.name}" in ${file} does not match ${LOCK_FILENAME}; ` +
|
|
645
|
+
'the vendored copy changed. Run "fx vendor update ' +
|
|
646
|
+
`${entry.name}" to accept it, or restore the file`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
} else if (!previous?.sha256) {
|
|
650
|
+
lock.extensions[entry.name] = lockEntryForInstall(
|
|
651
|
+
entry.name,
|
|
652
|
+
entry,
|
|
653
|
+
file,
|
|
654
|
+
buffer,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
results.push({ name: entry.name, file, status: "verified" });
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const read = await readEntrySource(entry, configDir, fetchImpl);
|
|
662
|
+
const hash = sha256(read.buffer);
|
|
663
|
+
if (previous?.sha256 && hash !== previous.sha256) {
|
|
664
|
+
if (entry.volatile) {
|
|
665
|
+
warnings.push(
|
|
666
|
+
`extension "${entry.name}" changed upstream; volatile entry accepted`,
|
|
667
|
+
);
|
|
668
|
+
} else {
|
|
669
|
+
throw new VendorError(
|
|
670
|
+
`extension "${entry.name}" changed upstream while restoring ${LOCK_FILENAME}. ` +
|
|
671
|
+
`Run "fx vendor update ${entry.name}" to accept the new source`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
remember(absolute);
|
|
676
|
+
writeVendorFile(configDir, vendorDir, file, read.buffer, {
|
|
677
|
+
allowOverwrite: Boolean(previous),
|
|
678
|
+
});
|
|
679
|
+
lock.extensions[entry.name] = lockEntryForInstall(
|
|
680
|
+
entry.name,
|
|
681
|
+
entry,
|
|
682
|
+
file,
|
|
683
|
+
read.buffer,
|
|
684
|
+
read.extra,
|
|
685
|
+
);
|
|
686
|
+
results.push({ name: entry.name, file, status: "fetched" });
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
writeLock(configDir, lock);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
for (const [absolute, backup] of backups) {
|
|
692
|
+
if (backup.existed) fs.writeFileSync(absolute, backup.buffer);
|
|
693
|
+
else fs.rmSync(absolute, { force: true });
|
|
694
|
+
}
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return { lock, warnings, entries: results };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Re-fetch remote entries (or re-copy local ones) and update the lock.
|
|
703
|
+
* Returns the changed entries and a textual diff for each.
|
|
704
|
+
*/
|
|
705
|
+
export async function updateVendor({
|
|
706
|
+
configDir,
|
|
707
|
+
config,
|
|
708
|
+
name,
|
|
709
|
+
fetchImpl,
|
|
710
|
+
write = true,
|
|
711
|
+
} = {}) {
|
|
712
|
+
const entries = config?.vendor || [];
|
|
713
|
+
const dirName = config?.vendorDir || DEFAULT_VENDOR_DIR;
|
|
714
|
+
const vendorDir = path.resolve(configDir, dirName);
|
|
715
|
+
const lock = readLock(configDir);
|
|
716
|
+
const files = resolveVendorFiles(configDir, vendorDir, entries, lock);
|
|
717
|
+
const selected = name
|
|
718
|
+
? entries.filter((entry) => entry.name === String(name))
|
|
719
|
+
: entries;
|
|
720
|
+
if (name && selected.length === 0)
|
|
721
|
+
throw new VendorError(
|
|
722
|
+
`No vendored extension named ${JSON.stringify(name)}`,
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
const results = [];
|
|
726
|
+
const warnings = [];
|
|
727
|
+
const pending = [];
|
|
728
|
+
let changed = 0;
|
|
729
|
+
|
|
730
|
+
for (const entry of selected) {
|
|
731
|
+
const { file, lockEntry } = files.get(entry.name);
|
|
732
|
+
const absolute = ensureVendorTarget(configDir, vendorDir, file);
|
|
733
|
+
const existed = fs.existsSync(absolute);
|
|
734
|
+
const before = existed ? fs.readFileSync(absolute) : Buffer.alloc(0);
|
|
735
|
+
|
|
736
|
+
let after;
|
|
737
|
+
let extra = {};
|
|
738
|
+
if (entry.local || entry.data) {
|
|
739
|
+
after = storedContent(entry, readLocalSource(entry, configDir).buffer);
|
|
740
|
+
} else {
|
|
741
|
+
const read = await readEntrySource(entry, configDir, fetchImpl);
|
|
742
|
+
after = read.buffer;
|
|
743
|
+
extra = read.extra;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const different = !after.equals(before);
|
|
747
|
+
const sourceChanged = lockEntry?.source !== entry.source;
|
|
748
|
+
results.push({
|
|
749
|
+
name: entry.name,
|
|
750
|
+
file,
|
|
751
|
+
changed: different,
|
|
752
|
+
sourceChanged,
|
|
753
|
+
diff: different
|
|
754
|
+
? formatDiff(before.toString("utf8"), after.toString("utf8"), file)
|
|
755
|
+
: "",
|
|
756
|
+
});
|
|
757
|
+
if (!different && !sourceChanged) continue;
|
|
758
|
+
if (different) changed += 1;
|
|
759
|
+
pending.push({
|
|
760
|
+
entry,
|
|
761
|
+
file,
|
|
762
|
+
absolute,
|
|
763
|
+
existed,
|
|
764
|
+
before,
|
|
765
|
+
after,
|
|
766
|
+
extra,
|
|
767
|
+
different,
|
|
768
|
+
lockEntry,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (write && pending.length > 0) {
|
|
773
|
+
const backups = new Map();
|
|
774
|
+
try {
|
|
775
|
+
for (const item of pending) {
|
|
776
|
+
if (!item.different) continue;
|
|
777
|
+
backups.set(item.absolute, {
|
|
778
|
+
existed: item.existed,
|
|
779
|
+
buffer: item.before,
|
|
780
|
+
});
|
|
781
|
+
writeVendorFile(configDir, vendorDir, item.file, item.after, {
|
|
782
|
+
allowOverwrite: Boolean(item.lockEntry),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
for (const item of pending)
|
|
786
|
+
lock.extensions[item.entry.name] = lockEntryForInstall(
|
|
787
|
+
item.entry.name,
|
|
788
|
+
item.entry,
|
|
789
|
+
item.file,
|
|
790
|
+
item.after,
|
|
791
|
+
item.extra,
|
|
792
|
+
);
|
|
793
|
+
writeLock(configDir, lock);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
for (const [absolute, backup] of backups) {
|
|
796
|
+
if (backup.existed) fs.writeFileSync(absolute, backup.buffer);
|
|
797
|
+
else fs.rmSync(absolute, { force: true });
|
|
798
|
+
}
|
|
799
|
+
throw error;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return { entries: results, warnings, changed, lock };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Check vendored files against `fx.lock` without touching the network.
|
|
808
|
+
* @returns {{ok: boolean, problems: string[], warnings: string[], entries: object[]}}
|
|
809
|
+
*/
|
|
810
|
+
export function verifyVendor({ configDir, config } = {}) {
|
|
811
|
+
const entries = config?.vendor || [];
|
|
812
|
+
const dirName = config?.vendorDir || DEFAULT_VENDOR_DIR;
|
|
813
|
+
const vendorDir = path.resolve(configDir, dirName);
|
|
814
|
+
const lock = readLock(configDir);
|
|
815
|
+
const files = resolveVendorFiles(configDir, vendorDir, entries, lock);
|
|
816
|
+
const problems = [];
|
|
817
|
+
const warnings = [];
|
|
818
|
+
const results = [];
|
|
819
|
+
|
|
820
|
+
for (const entry of entries) {
|
|
821
|
+
const { file, lockEntry } = files.get(entry.name);
|
|
822
|
+
const absolute = ensureVendorTarget(configDir, vendorDir, file);
|
|
823
|
+
if (!fs.existsSync(absolute)) {
|
|
824
|
+
problems.push(`extension "${entry.name}": ${file} is missing`);
|
|
825
|
+
results.push({ name: entry.name, file, status: "missing" });
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const buffer = fs.readFileSync(absolute);
|
|
829
|
+
const hash = sha256(buffer);
|
|
830
|
+
|
|
831
|
+
if (lockEntry && lockEntry.source !== entry.source) {
|
|
832
|
+
problems.push(
|
|
833
|
+
`extension "${entry.name}": source changed from ${JSON.stringify(
|
|
834
|
+
lockEntry.source,
|
|
835
|
+
)} to ${JSON.stringify(entry.source)}; run "fx vendor update ${entry.name}"`,
|
|
836
|
+
);
|
|
837
|
+
results.push({ name: entry.name, file, status: "source-changed", hash });
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (entry.local || entry.data) {
|
|
842
|
+
if (lockEntry?.sha256 && hash !== lockEntry.sha256) {
|
|
843
|
+
warnings.push(
|
|
844
|
+
`extension "${entry.name}": local copy differs from ${LOCK_FILENAME}; ` +
|
|
845
|
+
'the hash is informational only (run "fx vendor install" to refresh it)',
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (!entry.data) {
|
|
849
|
+
const sourceFile = path.isAbsolute(entry.source)
|
|
850
|
+
? entry.source
|
|
851
|
+
: path.resolve(configDir, entry.source);
|
|
852
|
+
if (!fs.existsSync(sourceFile))
|
|
853
|
+
problems.push(
|
|
854
|
+
`extension "${entry.name}": local source ${entry.source} is missing`,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
results.push({ name: entry.name, file, status: "ok", hash });
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!lockEntry?.sha256) {
|
|
862
|
+
problems.push(`extension "${entry.name}": no entry in ${LOCK_FILENAME}`);
|
|
863
|
+
results.push({ name: entry.name, file, status: "unlocked", hash });
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (hash !== lockEntry.sha256) {
|
|
867
|
+
if (entry.volatile) {
|
|
868
|
+
warnings.push(
|
|
869
|
+
`extension "${entry.name}": ${file} differs from ${LOCK_FILENAME}; ` +
|
|
870
|
+
"volatile entry accepted",
|
|
871
|
+
);
|
|
872
|
+
results.push({ name: entry.name, file, status: "volatile", hash });
|
|
873
|
+
} else {
|
|
874
|
+
problems.push(
|
|
875
|
+
`extension "${entry.name}": ${file} does not match ${LOCK_FILENAME} ` +
|
|
876
|
+
`(expected ${lockEntry.sha256.slice(0, 12)}…, found ${hash.slice(0, 12)}…)`,
|
|
877
|
+
);
|
|
878
|
+
results.push({ name: entry.name, file, status: "mismatch", hash });
|
|
879
|
+
}
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
results.push({ name: entry.name, file, status: "ok", hash });
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
ok: problems.length === 0,
|
|
887
|
+
problems,
|
|
888
|
+
warnings,
|
|
889
|
+
entries: results,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Compare upstream sources with `fx.lock` without writing anything.
|
|
895
|
+
* ETag/Last-Modified are sent when the lock has them.
|
|
896
|
+
*/
|
|
897
|
+
export async function outdatedVendor({ configDir, config, fetchImpl } = {}) {
|
|
898
|
+
const entries = config?.vendor || [];
|
|
899
|
+
const dirName = config?.vendorDir || DEFAULT_VENDOR_DIR;
|
|
900
|
+
const vendorDir = path.resolve(configDir, dirName);
|
|
901
|
+
const lock = readLock(configDir);
|
|
902
|
+
const files = resolveVendorFiles(configDir, vendorDir, entries, lock);
|
|
903
|
+
const results = [];
|
|
904
|
+
|
|
905
|
+
for (const entry of entries) {
|
|
906
|
+
const { file, lockEntry } = files.get(entry.name);
|
|
907
|
+
if (entry.local || entry.data) {
|
|
908
|
+
let after;
|
|
909
|
+
try {
|
|
910
|
+
after = storedContent(entry, readLocalSource(entry, configDir).buffer);
|
|
911
|
+
} catch (error) {
|
|
912
|
+
results.push({
|
|
913
|
+
name: entry.name,
|
|
914
|
+
status: "error",
|
|
915
|
+
error: error.message,
|
|
916
|
+
});
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
results.push({
|
|
920
|
+
name: entry.name,
|
|
921
|
+
status:
|
|
922
|
+
lockEntry?.sha256 && sha256(after) === lockEntry.sha256
|
|
923
|
+
? "current"
|
|
924
|
+
: "outdated",
|
|
925
|
+
file,
|
|
926
|
+
});
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
try {
|
|
931
|
+
const sameSource = lockEntry?.source === entry.source;
|
|
932
|
+
const fetched = await fetchVendorSource(entry.source, {
|
|
933
|
+
fetchImpl,
|
|
934
|
+
etag: sameSource ? lockEntry?.etag : undefined,
|
|
935
|
+
lastModified: sameSource ? lockEntry?.lastModified : undefined,
|
|
936
|
+
});
|
|
937
|
+
if (fetched.notModified) {
|
|
938
|
+
results.push({
|
|
939
|
+
name: entry.name,
|
|
940
|
+
status: sameSource ? "current" : "outdated",
|
|
941
|
+
file,
|
|
942
|
+
});
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
results.push({
|
|
946
|
+
name: entry.name,
|
|
947
|
+
status:
|
|
948
|
+
sameSource &&
|
|
949
|
+
lockEntry?.sha256 &&
|
|
950
|
+
sha256(fetched.buffer) === lockEntry.sha256
|
|
951
|
+
? "current"
|
|
952
|
+
: "outdated",
|
|
953
|
+
file,
|
|
954
|
+
});
|
|
955
|
+
} catch (error) {
|
|
956
|
+
results.push({
|
|
957
|
+
name: entry.name,
|
|
958
|
+
status: "error",
|
|
959
|
+
error: error.message,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return results;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Remove a vendored extension from fx.yml, the lock, and the vendor directory.
|
|
969
|
+
*/
|
|
970
|
+
export function removeVendoredExtension({
|
|
971
|
+
configPath,
|
|
972
|
+
configDir,
|
|
973
|
+
config,
|
|
974
|
+
name,
|
|
975
|
+
}) {
|
|
976
|
+
const entries = config?.vendor || [];
|
|
977
|
+
const target = String(name);
|
|
978
|
+
const entry = entries.find((candidate) => candidate.name === target);
|
|
979
|
+
if (!entry)
|
|
980
|
+
throw new VendorError(
|
|
981
|
+
`No vendored extension named ${JSON.stringify(name)}`,
|
|
982
|
+
);
|
|
983
|
+
|
|
984
|
+
const dirName = config?.vendorDir || DEFAULT_VENDOR_DIR;
|
|
985
|
+
const vendorDir = path.resolve(configDir, dirName);
|
|
986
|
+
const lock = readLock(configDir);
|
|
987
|
+
const files = resolveVendorFiles(configDir, vendorDir, entries, lock);
|
|
988
|
+
const { file } = files.get(target);
|
|
989
|
+
const absolute = ensureVendorTarget(configDir, vendorDir, file);
|
|
990
|
+
|
|
991
|
+
const result = removeVendorEntry(configPath, target);
|
|
992
|
+
delete lock.extensions[target];
|
|
993
|
+
writeLock(configDir, lock);
|
|
994
|
+
|
|
995
|
+
const stillUsed = Object.values(lock.extensions).some(
|
|
996
|
+
(candidate) => candidate.file === file,
|
|
997
|
+
);
|
|
998
|
+
if (!stillUsed) fs.rmSync(absolute, { force: true });
|
|
999
|
+
|
|
1000
|
+
return { entry, file, source: result.removed };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Load every configured vendor entry from disk.
|
|
1005
|
+
*
|
|
1006
|
+
* Remote copies are checked against `fx.lock` before they are evaluated.
|
|
1007
|
+
* Missing files are restored from the lock when possible; local copies are
|
|
1008
|
+
* refreshed from their source path on every build.
|
|
1009
|
+
*/
|
|
1010
|
+
export async function loadVendorExtensions({
|
|
1011
|
+
configDir,
|
|
1012
|
+
config,
|
|
1013
|
+
fetchImpl,
|
|
1014
|
+
} = {}) {
|
|
1015
|
+
const entries = config?.vendor || [];
|
|
1016
|
+
const dirName = config?.vendorDir || DEFAULT_VENDOR_DIR;
|
|
1017
|
+
const vendorDir = path.resolve(configDir, dirName);
|
|
1018
|
+
const lock = readLock(configDir);
|
|
1019
|
+
const resolved = resolveVendorFiles(configDir, vendorDir, entries, lock);
|
|
1020
|
+
const warnings = [];
|
|
1021
|
+
const files = [];
|
|
1022
|
+
const lockChanged = { value: false };
|
|
1023
|
+
|
|
1024
|
+
for (const entry of entries) {
|
|
1025
|
+
const { file, lockEntry } = resolved.get(entry.name);
|
|
1026
|
+
const absolute = ensureVendorTarget(configDir, vendorDir, file);
|
|
1027
|
+
|
|
1028
|
+
if (entry.local || entry.data) {
|
|
1029
|
+
const local = readLocalSource(entry, configDir);
|
|
1030
|
+
const stored = storedContent(entry, local.buffer);
|
|
1031
|
+
const previous = fs.existsSync(absolute)
|
|
1032
|
+
? fs.readFileSync(absolute)
|
|
1033
|
+
: null;
|
|
1034
|
+
const hash = sha256(stored);
|
|
1035
|
+
const needsWrite = !previous || !previous.equals(stored);
|
|
1036
|
+
const needsLock =
|
|
1037
|
+
!lockEntry ||
|
|
1038
|
+
lockEntry.sha256 !== hash ||
|
|
1039
|
+
lockEntry.source !== entry.source ||
|
|
1040
|
+
lockEntry.file !== file ||
|
|
1041
|
+
Boolean(lockEntry.local) !== Boolean(entry.local) ||
|
|
1042
|
+
Boolean(lockEntry.volatile) !== Boolean(entry.volatile);
|
|
1043
|
+
if (needsWrite)
|
|
1044
|
+
writeVendorFile(configDir, vendorDir, file, stored, {
|
|
1045
|
+
allowOverwrite: Boolean(lockEntry),
|
|
1046
|
+
});
|
|
1047
|
+
if (needsWrite || needsLock) {
|
|
1048
|
+
lock.extensions[entry.name] = lockEntryForInstall(
|
|
1049
|
+
entry.name,
|
|
1050
|
+
entry,
|
|
1051
|
+
file,
|
|
1052
|
+
stored,
|
|
1053
|
+
);
|
|
1054
|
+
lockChanged.value = true;
|
|
1055
|
+
}
|
|
1056
|
+
files.push({ entry, file, absolute });
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
assertLockSource(entry, lockEntry);
|
|
1061
|
+
let buffer;
|
|
1062
|
+
if (fs.existsSync(absolute)) {
|
|
1063
|
+
buffer = fs.readFileSync(absolute);
|
|
1064
|
+
const hash = sha256(buffer);
|
|
1065
|
+
if (lockEntry?.sha256 && hash !== lockEntry.sha256) {
|
|
1066
|
+
if (entry.volatile) {
|
|
1067
|
+
warnings.push(
|
|
1068
|
+
`extension "${entry.name}" changed in ${file}; volatile entry accepted`,
|
|
1069
|
+
);
|
|
1070
|
+
} else {
|
|
1071
|
+
throw new VendorError(
|
|
1072
|
+
`extension "${entry.name}" in ${file} does not match ${LOCK_FILENAME}; ` +
|
|
1073
|
+
'the vendored copy changed. Run "fx vendor update ' +
|
|
1074
|
+
`${entry.name}" to accept it, or restore the file`,
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
} else if (!lockEntry?.sha256) {
|
|
1078
|
+
lock.extensions[entry.name] = lockEntryForInstall(
|
|
1079
|
+
entry.name,
|
|
1080
|
+
entry,
|
|
1081
|
+
file,
|
|
1082
|
+
buffer,
|
|
1083
|
+
);
|
|
1084
|
+
lockChanged.value = true;
|
|
1085
|
+
}
|
|
1086
|
+
} else {
|
|
1087
|
+
const read = await readEntrySource(entry, configDir, fetchImpl);
|
|
1088
|
+
const hash = sha256(read.buffer);
|
|
1089
|
+
if (lockEntry?.sha256 && hash !== lockEntry.sha256) {
|
|
1090
|
+
if (entry.volatile) {
|
|
1091
|
+
warnings.push(
|
|
1092
|
+
`extension "${entry.name}" changed upstream; volatile entry accepted`,
|
|
1093
|
+
);
|
|
1094
|
+
} else {
|
|
1095
|
+
throw new VendorError(
|
|
1096
|
+
`extension "${entry.name}" is missing and upstream content changed. ` +
|
|
1097
|
+
`Run "fx vendor update ${entry.name}" to accept the new source`,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
buffer = read.buffer;
|
|
1102
|
+
writeVendorFile(configDir, vendorDir, file, buffer, {
|
|
1103
|
+
allowOverwrite: Boolean(lockEntry),
|
|
1104
|
+
});
|
|
1105
|
+
lock.extensions[entry.name] = lockEntryForInstall(
|
|
1106
|
+
entry.name,
|
|
1107
|
+
entry,
|
|
1108
|
+
file,
|
|
1109
|
+
buffer,
|
|
1110
|
+
read.extra,
|
|
1111
|
+
);
|
|
1112
|
+
lockChanged.value = true;
|
|
1113
|
+
}
|
|
1114
|
+
files.push({ entry, file, absolute });
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (lockChanged.value) writeLock(configDir, lock);
|
|
1118
|
+
|
|
1119
|
+
const expectUnsandboxed = [
|
|
1120
|
+
...(config?.project?.unsandboxed || []),
|
|
1121
|
+
...files
|
|
1122
|
+
.filter(({ entry }) => entry.unsandboxed)
|
|
1123
|
+
.map(({ absolute }) => absolute),
|
|
1124
|
+
];
|
|
1125
|
+
const loaded = await loadExtensions(
|
|
1126
|
+
files.map(({ absolute }) => absolute),
|
|
1127
|
+
{
|
|
1128
|
+
baseDir: configDir,
|
|
1129
|
+
fetchImpl,
|
|
1130
|
+
expectUnsandboxed,
|
|
1131
|
+
reportUnmatchedUnsandboxed: false,
|
|
1132
|
+
},
|
|
1133
|
+
);
|
|
1134
|
+
|
|
1135
|
+
return {
|
|
1136
|
+
...loaded,
|
|
1137
|
+
warnings: [...warnings, ...loaded.warnings],
|
|
1138
|
+
matchedUnsandboxed: loaded.matchedUnsandboxed,
|
|
1139
|
+
unnecessaryUnsandboxed: loaded.unnecessaryUnsandboxed,
|
|
1140
|
+
files: files.map(({ file }) => file),
|
|
1141
|
+
entries,
|
|
1142
|
+
lock,
|
|
1143
|
+
vendorDir: relativeVendorPath(configDir, vendorDir),
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** A compact line diff, enough to review an extension or config change. */
|
|
1148
|
+
export function formatDiff(before, after, filename = "") {
|
|
1149
|
+
const a = String(before).split(/\r?\n/);
|
|
1150
|
+
const b = String(after).split(/\r?\n/);
|
|
1151
|
+
const rows = a.length;
|
|
1152
|
+
const columns = b.length;
|
|
1153
|
+
// Keep very large extensions from allocating an enormous matrix.
|
|
1154
|
+
if (rows * columns > 4_000_000) {
|
|
1155
|
+
return `--- ${filename}\n+++ ${filename}\n@@ source changed @@\n`;
|
|
1156
|
+
}
|
|
1157
|
+
const table = Array.from(
|
|
1158
|
+
{ length: rows + 1 },
|
|
1159
|
+
() => new Uint32Array(columns + 1),
|
|
1160
|
+
);
|
|
1161
|
+
for (let i = rows - 1; i >= 0; i--) {
|
|
1162
|
+
for (let j = columns - 1; j >= 0; j--) {
|
|
1163
|
+
table[i][j] =
|
|
1164
|
+
a[i] === b[j]
|
|
1165
|
+
? table[i + 1][j + 1] + 1
|
|
1166
|
+
: Math.max(table[i + 1][j], table[i][j + 1]);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const lines = [`--- ${filename}`, `+++ ${filename}`];
|
|
1170
|
+
let i = 0;
|
|
1171
|
+
let j = 0;
|
|
1172
|
+
while (i < rows && j < columns) {
|
|
1173
|
+
if (a[i] === b[j]) {
|
|
1174
|
+
lines.push(` ${a[i]}`);
|
|
1175
|
+
i += 1;
|
|
1176
|
+
j += 1;
|
|
1177
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
1178
|
+
lines.push(`-${a[i]}`);
|
|
1179
|
+
i += 1;
|
|
1180
|
+
} else {
|
|
1181
|
+
lines.push(`+${b[j]}`);
|
|
1182
|
+
j += 1;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
while (i < rows) lines.push(`-${a[i++]}`);
|
|
1186
|
+
while (j < columns) lines.push(`+${b[j++]}`);
|
|
1187
|
+
return `${lines.join("\n")}\n`;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Move `project.extensions` into the vendor system.
|
|
1192
|
+
*
|
|
1193
|
+
* The config is backed up before it is changed. Names come from
|
|
1194
|
+
* `getInfo().id` when the extension can be inspected, otherwise from the
|
|
1195
|
+
* filename, with numeric suffixes for collisions. All downloaded/read source
|
|
1196
|
+
* is written before fx.yml changes; a failure restores the original fx.yml.
|
|
1197
|
+
*/
|
|
1198
|
+
export async function migrateProjectExtensions({
|
|
1199
|
+
configPath,
|
|
1200
|
+
configDir,
|
|
1201
|
+
config,
|
|
1202
|
+
fetchImpl,
|
|
1203
|
+
nameFor,
|
|
1204
|
+
} = {}) {
|
|
1205
|
+
const legacy = config?.project?.extensions || [];
|
|
1206
|
+
if (legacy.length === 0)
|
|
1207
|
+
return {
|
|
1208
|
+
migrated: [],
|
|
1209
|
+
backup: null,
|
|
1210
|
+
diff: "",
|
|
1211
|
+
warnings: [],
|
|
1212
|
+
configPath,
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
const dirName = config?.vendorDir || DEFAULT_VENDOR_DIR;
|
|
1216
|
+
const vendorDir = path.resolve(configDir, dirName);
|
|
1217
|
+
const lock = readLock(configDir);
|
|
1218
|
+
const used = new Set((config?.vendor || []).map((entry) => entry.name));
|
|
1219
|
+
const warnings = [];
|
|
1220
|
+
const prepared = [];
|
|
1221
|
+
|
|
1222
|
+
// Resolve everything before touching fx.yml, so a broken URL cannot leave the
|
|
1223
|
+
// project with a partially migrated extension list.
|
|
1224
|
+
for (const source of new Set(legacy.map(String))) {
|
|
1225
|
+
let resolved;
|
|
1226
|
+
try {
|
|
1227
|
+
resolved = await resolveSource(source, configDir, fetchImpl);
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
throw new VendorError(
|
|
1230
|
+
`Could not vendor ${source}: ${error.message}; fx.yml was not changed`,
|
|
1231
|
+
{ cause: error },
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
let proposed = extensionKey(source);
|
|
1236
|
+
let needsUnsandboxed = false;
|
|
1237
|
+
try {
|
|
1238
|
+
const info = await inspectExtension(
|
|
1239
|
+
resolved.source,
|
|
1240
|
+
resolved.remote ? resolved.url : (resolved.file ?? "extension"),
|
|
1241
|
+
);
|
|
1242
|
+
if (info.infos[0]?.id) proposed = String(info.infos[0].id);
|
|
1243
|
+
needsUnsandboxed = Boolean(info.unsandboxed);
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
warnings.push(
|
|
1246
|
+
`could not inspect ${source}: ${error.message}; using the filename`,
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
prepared.push({ source, resolved, proposed, needsUnsandboxed });
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const items = [];
|
|
1253
|
+
for (const item of prepared) {
|
|
1254
|
+
let name = uniqueVendorName(item.proposed || "extension", used);
|
|
1255
|
+
if (typeof nameFor === "function") {
|
|
1256
|
+
const edited = await nameFor(name, item.source);
|
|
1257
|
+
if (edited && String(edited).trim())
|
|
1258
|
+
name = uniqueVendorName(String(edited).trim(), used);
|
|
1259
|
+
}
|
|
1260
|
+
used.add(name);
|
|
1261
|
+
const entry = normalizeVendorEntry(name, {
|
|
1262
|
+
url: item.source,
|
|
1263
|
+
...(item.needsUnsandboxed ? { unsandboxed: true } : {}),
|
|
1264
|
+
});
|
|
1265
|
+
items.push({ ...item, name, entry });
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const original = fs.readFileSync(configPath, "utf8");
|
|
1269
|
+
const backup = `${configPath}.bak`;
|
|
1270
|
+
fs.writeFileSync(backup, original);
|
|
1271
|
+
const lockPath = path.join(configDir, LOCK_FILENAME);
|
|
1272
|
+
const lockBefore = fs.existsSync(lockPath) ? fs.readFileSync(lockPath) : null;
|
|
1273
|
+
|
|
1274
|
+
const migrated = [];
|
|
1275
|
+
const written = [];
|
|
1276
|
+
try {
|
|
1277
|
+
for (const item of items) {
|
|
1278
|
+
const buffer =
|
|
1279
|
+
item.resolved.buffer ?? Buffer.from(item.resolved.source, "utf8");
|
|
1280
|
+
const stored = storedContent(item.entry, buffer);
|
|
1281
|
+
const file = chooseVendorFile(configDir, vendorDir, item.name, lock);
|
|
1282
|
+
const absolute = ensureVendorTarget(configDir, vendorDir, file);
|
|
1283
|
+
const existing = fs.lstatSync(absolute, { throwIfNoEntry: false });
|
|
1284
|
+
const existed = Boolean(existing);
|
|
1285
|
+
const before = existing?.isFile() ? fs.readFileSync(absolute) : null;
|
|
1286
|
+
writeVendorFile(configDir, vendorDir, file, stored);
|
|
1287
|
+
written.push({ absolute, existed, before });
|
|
1288
|
+
lock.extensions[item.name] = lockEntryForInstall(
|
|
1289
|
+
item.name,
|
|
1290
|
+
item.entry,
|
|
1291
|
+
file,
|
|
1292
|
+
stored,
|
|
1293
|
+
);
|
|
1294
|
+
migrated.push({ name: item.name, source: item.source, file });
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
for (const item of items)
|
|
1298
|
+
addVendorEntry(configPath, item.name, item.source, {
|
|
1299
|
+
unsandboxed: item.entry.unsandboxed,
|
|
1300
|
+
volatile: item.entry.volatile || undefined,
|
|
1301
|
+
});
|
|
1302
|
+
removeProjectExtensions(configPath);
|
|
1303
|
+
writeLock(configDir, lock);
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
fs.writeFileSync(configPath, original);
|
|
1306
|
+
for (const { absolute, existed, before } of written.reverse()) {
|
|
1307
|
+
if (existed && before) fs.writeFileSync(absolute, before);
|
|
1308
|
+
else if (!existed) fs.rmSync(absolute, { force: true });
|
|
1309
|
+
}
|
|
1310
|
+
if (lockBefore) fs.writeFileSync(lockPath, lockBefore);
|
|
1311
|
+
else fs.rmSync(lockPath, { force: true });
|
|
1312
|
+
throw error;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
const updated = fs.readFileSync(configPath, "utf8");
|
|
1316
|
+
return {
|
|
1317
|
+
migrated,
|
|
1318
|
+
backup,
|
|
1319
|
+
diff: formatDiff(original, updated, path.basename(configPath)),
|
|
1320
|
+
warnings,
|
|
1321
|
+
configPath,
|
|
1322
|
+
};
|
|
1323
|
+
}
|