@dolphy-app/extension-tools 0.2.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 +24 -0
- package/dist/cli/main.js +2050 -0
- package/package.json +29 -0
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,2050 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { builtinModules } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { build } from "vite";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
//#region packages/extension-host/src/protocol.ts
|
|
12
|
+
const typed = {
|
|
13
|
+
type: z.string(),
|
|
14
|
+
exerciseId: z.string(),
|
|
15
|
+
spec: z.unknown(),
|
|
16
|
+
isolated: z.boolean()
|
|
17
|
+
};
|
|
18
|
+
const extRequestSchema = z.discriminatedUnion("method", [
|
|
19
|
+
z.strictObject({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
method: z.literal("project"),
|
|
22
|
+
params: z.strictObject(typed)
|
|
23
|
+
}),
|
|
24
|
+
z.strictObject({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
method: z.literal("grade"),
|
|
27
|
+
params: z.strictObject({
|
|
28
|
+
...typed,
|
|
29
|
+
answer: z.unknown(),
|
|
30
|
+
timeoutMs: z.number(),
|
|
31
|
+
authorMode: z.boolean()
|
|
32
|
+
})
|
|
33
|
+
}),
|
|
34
|
+
z.strictObject({
|
|
35
|
+
id: z.string(),
|
|
36
|
+
method: z.literal("referenceAnswer"),
|
|
37
|
+
params: z.strictObject(typed)
|
|
38
|
+
}),
|
|
39
|
+
z.strictObject({
|
|
40
|
+
id: z.string(),
|
|
41
|
+
method: z.literal("gradePolicy"),
|
|
42
|
+
params: z.strictObject({
|
|
43
|
+
policyId: z.string(),
|
|
44
|
+
verdicts: z.array(z.strictObject({
|
|
45
|
+
outcome: z.enum([
|
|
46
|
+
"passed",
|
|
47
|
+
"failed",
|
|
48
|
+
"error"
|
|
49
|
+
]),
|
|
50
|
+
reason: z.string().optional()
|
|
51
|
+
})),
|
|
52
|
+
gaveUp: z.boolean(),
|
|
53
|
+
isolated: z.boolean()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
]);
|
|
57
|
+
/** Результат правила оценки: целое 1–5 или `null`. */
|
|
58
|
+
const gradeValueSchema = z.union([z.literal([
|
|
59
|
+
1,
|
|
60
|
+
2,
|
|
61
|
+
3,
|
|
62
|
+
4,
|
|
63
|
+
5
|
|
64
|
+
]), z.null()]);
|
|
65
|
+
const reason = z.string().min(1).max(100);
|
|
66
|
+
const text$1 = z.string().max(4e3);
|
|
67
|
+
const gradeResultSchema = z.discriminatedUnion("outcome", [
|
|
68
|
+
z.strictObject({
|
|
69
|
+
outcome: z.literal("passed"),
|
|
70
|
+
feedback: text$1.optional(),
|
|
71
|
+
data: z.unknown().optional()
|
|
72
|
+
}),
|
|
73
|
+
z.strictObject({
|
|
74
|
+
outcome: z.literal("failed"),
|
|
75
|
+
reason,
|
|
76
|
+
feedback: text$1.optional(),
|
|
77
|
+
detail: text$1.optional(),
|
|
78
|
+
data: z.unknown().optional()
|
|
79
|
+
}),
|
|
80
|
+
z.strictObject({
|
|
81
|
+
outcome: z.literal("error"),
|
|
82
|
+
reason,
|
|
83
|
+
feedback: text$1.optional(),
|
|
84
|
+
data: z.unknown().optional()
|
|
85
|
+
})
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region packages/extension-api/src/index.ts
|
|
90
|
+
const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)*$/;
|
|
91
|
+
/** GitHub-логин автора расширения (`author` в манифесте и каталоге). */
|
|
92
|
+
const GITHUB_LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
|
93
|
+
/** Возможности, которые расширение объявляет в манифесте; без объявления — ни одной. */
|
|
94
|
+
const EXTENSION_PERMISSIONS = [
|
|
95
|
+
"library.read",
|
|
96
|
+
"process.spawn",
|
|
97
|
+
"worker.threads",
|
|
98
|
+
"native.addons",
|
|
99
|
+
"network"
|
|
100
|
+
];
|
|
101
|
+
/** Платформы, на которых расширение может работать (`process.platform`). */
|
|
102
|
+
const EXTENSION_PLATFORMS = [
|
|
103
|
+
"darwin",
|
|
104
|
+
"linux",
|
|
105
|
+
"win32"
|
|
106
|
+
];
|
|
107
|
+
const ELEMENT_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
|
|
108
|
+
/** Идентификаторы встроенных тем: расширения не могут их занять. */
|
|
109
|
+
const BUILTIN_THEME_IDS = [
|
|
110
|
+
"system",
|
|
111
|
+
"light",
|
|
112
|
+
"dark"
|
|
113
|
+
];
|
|
114
|
+
/** Допустимые ключи `ThemeContribution.colors`. */
|
|
115
|
+
const THEME_COLOR_KEYS = [
|
|
116
|
+
"background",
|
|
117
|
+
"surface",
|
|
118
|
+
"surface-bright",
|
|
119
|
+
"surface-light",
|
|
120
|
+
"surface-variant",
|
|
121
|
+
"on-background",
|
|
122
|
+
"on-surface",
|
|
123
|
+
"on-surface-variant",
|
|
124
|
+
"primary",
|
|
125
|
+
"on-primary",
|
|
126
|
+
"secondary",
|
|
127
|
+
"on-secondary",
|
|
128
|
+
"error",
|
|
129
|
+
"on-error",
|
|
130
|
+
"warning",
|
|
131
|
+
"on-warning",
|
|
132
|
+
"success",
|
|
133
|
+
"on-success",
|
|
134
|
+
"info",
|
|
135
|
+
"on-info",
|
|
136
|
+
"hero-start",
|
|
137
|
+
"hero-end",
|
|
138
|
+
"hero-contrast"
|
|
139
|
+
];
|
|
140
|
+
/** Допустимые ключи `ThemeContribution.variables`. */
|
|
141
|
+
const THEME_VARIABLE_KEYS = [
|
|
142
|
+
"border-color",
|
|
143
|
+
"border-opacity",
|
|
144
|
+
"medium-emphasis-opacity",
|
|
145
|
+
"high-emphasis-opacity",
|
|
146
|
+
"disabled-opacity"
|
|
147
|
+
];
|
|
148
|
+
const DEFAULT_MARKDOWN_RENDERER = "./markdown.mjs";
|
|
149
|
+
const DEFAULT_MAIN = "./main.mjs";
|
|
150
|
+
const DEFAULT_RENDERER = "./view.mjs";
|
|
151
|
+
/** Тег элемента по умолчанию: `dolphy.sql` → `dolphy-sql-answer`. */
|
|
152
|
+
const defaultElementName = (id) => `${id.replaceAll(".", "-")}-answer`;
|
|
153
|
+
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region packages/extension-catalog/src/errors.ts
|
|
156
|
+
const MAX_ISSUES = 10;
|
|
157
|
+
var CatalogFormatError = class extends Error {
|
|
158
|
+
issues;
|
|
159
|
+
constructor(issues) {
|
|
160
|
+
const kept = issues.slice(0, MAX_ISSUES);
|
|
161
|
+
super(`invalid catalog: ${kept.join("; ")}`);
|
|
162
|
+
this.name = "CatalogFormatError";
|
|
163
|
+
this.issues = kept;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region packages/extension-catalog/src/semver.ts
|
|
169
|
+
const NUMERIC = "(0|[1-9]\\d*)";
|
|
170
|
+
const SEMVER = new RegExp(`^${NUMERIC}\\.${NUMERIC}\\.${NUMERIC}(?:-([0-9A-Za-z.-]+))?$`);
|
|
171
|
+
const NUMERIC_ID = /^\d+$/;
|
|
172
|
+
const parseSemver = (text) => {
|
|
173
|
+
const match = SEMVER.exec(text);
|
|
174
|
+
if (match === null) return null;
|
|
175
|
+
const [, major = "", minor = "", patch = "", pre] = match;
|
|
176
|
+
const prerelease = pre === void 0 ? [] : pre.split(".");
|
|
177
|
+
if (prerelease.some((id) => id === "" || NUMERIC_ID.test(id) && /^0\d/.test(id))) return null;
|
|
178
|
+
return {
|
|
179
|
+
major: Number(major),
|
|
180
|
+
minor: Number(minor),
|
|
181
|
+
patch: Number(patch),
|
|
182
|
+
prerelease
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
const isSemver = (text) => parseSemver(text) !== null;
|
|
186
|
+
const sign = (difference) => {
|
|
187
|
+
if (difference < 0) return -1;
|
|
188
|
+
return difference > 0 ? 1 : 0;
|
|
189
|
+
};
|
|
190
|
+
const compareIdentifier = (a, b) => {
|
|
191
|
+
const aNumeric = NUMERIC_ID.test(a);
|
|
192
|
+
const bNumeric = NUMERIC_ID.test(b);
|
|
193
|
+
if (aNumeric && bNumeric) return sign(Number(a) - Number(b));
|
|
194
|
+
if (aNumeric) return -1;
|
|
195
|
+
if (bNumeric) return 1;
|
|
196
|
+
if (a === b) return 0;
|
|
197
|
+
return a < b ? -1 : 1;
|
|
198
|
+
};
|
|
199
|
+
const comparePrerelease = (a, b) => {
|
|
200
|
+
if (a.length === 0 && b.length === 0) return 0;
|
|
201
|
+
if (a.length === 0) return 1;
|
|
202
|
+
if (b.length === 0) return -1;
|
|
203
|
+
const common = Math.min(a.length, b.length);
|
|
204
|
+
for (let i = 0; i < common; i++) {
|
|
205
|
+
const order = compareIdentifier(a[i] ?? "", b[i] ?? "");
|
|
206
|
+
if (order !== 0) return order;
|
|
207
|
+
}
|
|
208
|
+
return sign(a.length - b.length);
|
|
209
|
+
};
|
|
210
|
+
const compareParsed = (a, b) => sign(a.major - b.major) || sign(a.minor - b.minor) || sign(a.patch - b.patch) || comparePrerelease(a.prerelease, b.prerelease);
|
|
211
|
+
const mustParse = (text) => {
|
|
212
|
+
const parsed = parseSemver(text);
|
|
213
|
+
if (parsed === null) throw new CatalogFormatError([`invalid semver '${text}'`]);
|
|
214
|
+
return parsed;
|
|
215
|
+
};
|
|
216
|
+
const compareSemver = (a, b) => compareParsed(mustParse(a), mustParse(b));
|
|
217
|
+
const COMPARATOR = /^(<=|>=|<|>|=)?(.+)$/;
|
|
218
|
+
const parseComparator = (token, range) => {
|
|
219
|
+
const match = COMPARATOR.exec(token);
|
|
220
|
+
const version = match?.[2] === void 0 ? null : parseSemver(match[2]);
|
|
221
|
+
if (match === null || version === null) throw new CatalogFormatError([`invalid version range '${range}'`]);
|
|
222
|
+
return {
|
|
223
|
+
operator: match[1] ?? "=",
|
|
224
|
+
version
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
const parseRange = (range) => {
|
|
228
|
+
const tokens = range.split(" ").filter((token) => token !== "");
|
|
229
|
+
if (tokens.length === 0 || range.trim() !== range) throw new CatalogFormatError([`invalid version range '${range}'`]);
|
|
230
|
+
return tokens.map((token) => parseComparator(token, range));
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region packages/extension-catalog/src/compat.ts
|
|
235
|
+
/** Пустой список платформ — любая. */
|
|
236
|
+
const platformFailure = (platforms, platform) => platforms.length === 0 || platforms.some((p) => p === platform) ? null : {
|
|
237
|
+
reason: "platform",
|
|
238
|
+
detail: `not available on ${platform}`
|
|
239
|
+
};
|
|
240
|
+
const minAppVersionFailure = (minAppVersion, appVersion) => minAppVersion !== null && appVersion !== void 0 && compareSemver(minAppVersion, appVersion) > 0 ? {
|
|
241
|
+
reason: "app",
|
|
242
|
+
detail: `requires app >= ${minAppVersion}`
|
|
243
|
+
} : null;
|
|
244
|
+
/** `minAppVersion` и `platforms` манифеста или записи каталога. */
|
|
245
|
+
const checkCompatibility = (requirements, context) => minAppVersionFailure(requirements.minAppVersion, context.appVersion) ?? platformFailure(requirements.platforms, context.platform);
|
|
246
|
+
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region packages/extension-catalog/src/install-meta.ts
|
|
249
|
+
const httpUrl = z.url().refine((value) => /^https?:\/\//i.test(value), "catalogUrl must be an http(s) URL");
|
|
250
|
+
const installMetaSchema = z.strictObject({
|
|
251
|
+
catalogUrl: httpUrl,
|
|
252
|
+
version: z.string().refine(isSemver, "version must be semver"),
|
|
253
|
+
installedAt: z.iso.datetime()
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region packages/extension-catalog/src/schema.ts
|
|
258
|
+
const CATALOG_SCHEMA_VERSION = 1;
|
|
259
|
+
const MAX_VERSIONS = 5;
|
|
260
|
+
const MAX_FILES = 50;
|
|
261
|
+
const MAX_TOTAL_BYTES = 1e7;
|
|
262
|
+
const MAX_PATH_LENGTH = 200;
|
|
263
|
+
const CATALOG_FILE_EXTENSIONS = [
|
|
264
|
+
"json",
|
|
265
|
+
"js",
|
|
266
|
+
"mjs",
|
|
267
|
+
"md",
|
|
268
|
+
"txt"
|
|
269
|
+
];
|
|
270
|
+
const FILE_EXTENSIONS = new Set(CATALOG_FILE_EXTENSIONS);
|
|
271
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
272
|
+
/** Допустимые символы сегмента пути: без `:` (потоки NTFS), пробелов и управляющих символов. */
|
|
273
|
+
const SEGMENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
274
|
+
/** Имена устройств Windows: недоступны как файлы, с расширением или без. */
|
|
275
|
+
const RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
|
|
276
|
+
const semver = z.string().refine(isSemver, "must be semver");
|
|
277
|
+
const timestamp = z.iso.datetime({ offset: true });
|
|
278
|
+
const httpsUrl = z.url().refine((value) => value.startsWith("https://"), "must be an https URL");
|
|
279
|
+
const isSafeSegment = (segment) => SEGMENT.test(segment) && !segment.endsWith(".") && !RESERVED_NAME.test(segment);
|
|
280
|
+
/** Путь файла версии: безопасные сегменты и расширение из `FILE_EXTENSIONS`. */
|
|
281
|
+
const isSafeCatalogPath = (value) => {
|
|
282
|
+
if (value.length > MAX_PATH_LENGTH) return false;
|
|
283
|
+
const segments = value.split("/");
|
|
284
|
+
if (!segments.every(isSafeSegment)) return false;
|
|
285
|
+
const name = segments[segments.length - 1] ?? "";
|
|
286
|
+
const dot = name.lastIndexOf(".");
|
|
287
|
+
return dot > 0 && FILE_EXTENSIONS.has(name.slice(dot + 1));
|
|
288
|
+
};
|
|
289
|
+
const filePath = z.string().refine(isSafeCatalogPath, "must be a safe relative path");
|
|
290
|
+
/**
|
|
291
|
+
* Каталог файлов версии относительно адреса `index.json` (`extensions/<id>/<version>/`).
|
|
292
|
+
* Абсолютный адрес не допускается: файлы версии по построению лежат на origin индекса,
|
|
293
|
+
* а локальный каталог разработчика и e2e не требуют https.
|
|
294
|
+
*/
|
|
295
|
+
const baseUrl = z.string().max(MAX_PATH_LENGTH).refine((value) => value.endsWith("/") && value.slice(0, -1).split("/").every(isSafeSegment), "must be a relative directory path ending with /");
|
|
296
|
+
const fileSchema = z.strictObject({
|
|
297
|
+
path: filePath,
|
|
298
|
+
size: z.number().int().min(0).max(MAX_TOTAL_BYTES),
|
|
299
|
+
sha256: z.string().regex(SHA256, "must be lowercase hex sha256")
|
|
300
|
+
});
|
|
301
|
+
const extensionId$1 = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid extension id");
|
|
302
|
+
const versionSchema = z.strictObject({
|
|
303
|
+
version: semver,
|
|
304
|
+
apiVersion: z.number().int().min(1),
|
|
305
|
+
minAppVersion: semver.nullable(),
|
|
306
|
+
permissions: z.array(z.enum(EXTENSION_PERMISSIONS)),
|
|
307
|
+
publishedAt: timestamp,
|
|
308
|
+
baseUrl,
|
|
309
|
+
files: z.array(fileSchema).max(50).superRefine((files, ctx) => {
|
|
310
|
+
const seen = /* @__PURE__ */ new Set();
|
|
311
|
+
files.forEach((file, index) => {
|
|
312
|
+
const key = file.path.toLowerCase();
|
|
313
|
+
if (seen.has(key)) ctx.addIssue({
|
|
314
|
+
code: "custom",
|
|
315
|
+
path: [index, "path"],
|
|
316
|
+
message: `duplicate path '${file.path}'`
|
|
317
|
+
});
|
|
318
|
+
seen.add(key);
|
|
319
|
+
});
|
|
320
|
+
files.forEach((file, index) => {
|
|
321
|
+
const segments = file.path.toLowerCase().split("/");
|
|
322
|
+
for (let depth = 1; depth < segments.length; depth++) if (seen.has(segments.slice(0, depth).join("/"))) {
|
|
323
|
+
ctx.addIssue({
|
|
324
|
+
code: "custom",
|
|
325
|
+
path: [index, "path"],
|
|
326
|
+
message: `'${file.path}' lies inside a file`
|
|
327
|
+
});
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
if (!files.some((file) => file.path === "extension.json")) ctx.addIssue({
|
|
332
|
+
code: "custom",
|
|
333
|
+
message: "extension.json is required"
|
|
334
|
+
});
|
|
335
|
+
const total = files.reduce((sum, file) => sum + file.size, 0);
|
|
336
|
+
if (total > 1e7) ctx.addIssue({
|
|
337
|
+
code: "custom",
|
|
338
|
+
message: `total size ${total} exceeds ${MAX_TOTAL_BYTES}`
|
|
339
|
+
});
|
|
340
|
+
})
|
|
341
|
+
});
|
|
342
|
+
const contributesSchema$1 = z.strictObject({
|
|
343
|
+
exerciseTypes: z.array(z.string()),
|
|
344
|
+
themes: z.array(z.string()),
|
|
345
|
+
markdownRenderers: z.array(z.string()),
|
|
346
|
+
gradePolicies: z.array(z.string())
|
|
347
|
+
});
|
|
348
|
+
const descendingUnique = (versions, ctx) => {
|
|
349
|
+
for (let i = 1; i < versions.length; i++) {
|
|
350
|
+
const previous = versions[i - 1]?.version ?? "";
|
|
351
|
+
const current = versions[i]?.version ?? "";
|
|
352
|
+
if (previous === current) ctx.addIssue({
|
|
353
|
+
code: "custom",
|
|
354
|
+
path: [i, "version"],
|
|
355
|
+
message: `duplicate version '${current}'`
|
|
356
|
+
});
|
|
357
|
+
else if (isSemver(previous) && isSemver(current)) {
|
|
358
|
+
if (compareSemver(previous, current) < 0) ctx.addIssue({
|
|
359
|
+
code: "custom",
|
|
360
|
+
path: [i, "version"],
|
|
361
|
+
message: "versions must be sorted newest first"
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const entrySchema = z.strictObject({
|
|
367
|
+
id: extensionId$1,
|
|
368
|
+
name: z.string().min(1).max(80),
|
|
369
|
+
description: z.string().min(1).max(500),
|
|
370
|
+
author: z.string().regex(GITHUB_LOGIN_PATTERN, "must be a GitHub login"),
|
|
371
|
+
source: httpsUrl,
|
|
372
|
+
platforms: z.array(z.enum(EXTENSION_PLATFORMS)),
|
|
373
|
+
contributes: contributesSchema$1,
|
|
374
|
+
versions: z.array(versionSchema).min(1).max(5).superRefine(descendingUnique)
|
|
375
|
+
});
|
|
376
|
+
const rangeText = z.string().superRefine((value, ctx) => {
|
|
377
|
+
try {
|
|
378
|
+
parseRange(value);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (!(error instanceof CatalogFormatError)) throw error;
|
|
381
|
+
ctx.addIssue({
|
|
382
|
+
code: "custom",
|
|
383
|
+
message: "invalid version range"
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
const revokedSchema = z.strictObject({
|
|
388
|
+
id: extensionId$1,
|
|
389
|
+
versions: rangeText,
|
|
390
|
+
reason: z.string().min(1).max(300)
|
|
391
|
+
});
|
|
392
|
+
const indexSchema = z.strictObject({
|
|
393
|
+
schemaVersion: z.literal(1),
|
|
394
|
+
generatedAt: timestamp,
|
|
395
|
+
extensions: z.array(entrySchema),
|
|
396
|
+
revoked: z.array(revokedSchema)
|
|
397
|
+
});
|
|
398
|
+
const parseIndex = (raw) => {
|
|
399
|
+
const parsed = indexSchema.safeParse(raw);
|
|
400
|
+
if (parsed.success) return parsed.data;
|
|
401
|
+
throw new CatalogFormatError(parsed.error.issues.map((issue) => `${issue.path.join(".") || "/"}: ${issue.message}`));
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region packages/extension-host/src/points/support.ts
|
|
406
|
+
/** Относительный путь внутри каталога расширения: без `..`, `\` и ведущего `/`. */
|
|
407
|
+
const isSafeRelativePath = (value) => value.length > 0 && !value.includes("\\") && !value.startsWith("/") && !value.split("/").includes("..");
|
|
408
|
+
const safePath = (extensions) => z.string().refine(isSafeRelativePath, "must be a safe relative path").refine((value) => extensions.some((ext) => value.endsWith(ext)), `must end with ${extensions.join(" or ")}`);
|
|
409
|
+
const extensionId = z.string().max(64).regex(EXTENSION_ID_PATTERN, "invalid extension id");
|
|
410
|
+
/** Пути с нарушением правила «`id` равен id расширения или начинается с ним». */
|
|
411
|
+
const idPrefixIssues = (key, ids, owner) => ids.flatMap((id, index) => id === owner || id.startsWith(`${owner}.`) ? [] : [`contributes.${key}.${index}.id: id must be '${owner}' or start with '${owner}.'`]);
|
|
412
|
+
const isFile = async (file) => (await stat(file).catch(() => null))?.isFile() === true;
|
|
413
|
+
/** Путь внутри каталога расширения; выход за каталог — ошибка. */
|
|
414
|
+
const inside = (dir, relative) => {
|
|
415
|
+
const resolved = path.resolve(dir, relative);
|
|
416
|
+
if (!resolved.startsWith(dir + path.sep)) throw new Error(`path '${relative}' escapes the extension directory`);
|
|
417
|
+
return resolved;
|
|
418
|
+
};
|
|
419
|
+
const rendererUrlOf = (id, renderer) => `dolphy-ext://${id}/${renderer.replace(/^\.\//, "").split("/").map(encodeURIComponent).join("/")}`;
|
|
420
|
+
const defaultNote = (value, fallback) => value === fallback ? " (default)" : "";
|
|
421
|
+
/** Проверяет, что файл модуля существует (если проверка включена), и даёт адрес. */
|
|
422
|
+
const resolveModuleUrl = async (extensionId, dir, file, fallback, verifyFiles, label) => {
|
|
423
|
+
const absolute = inside(dir, file);
|
|
424
|
+
if (verifyFiles && !await isFile(absolute)) throw new Error(`${label} '${file}'${defaultNote(file, fallback)} is not a file`);
|
|
425
|
+
return rendererUrlOf(extensionId, file);
|
|
426
|
+
};
|
|
427
|
+
const deepFreeze = (value) => {
|
|
428
|
+
if (typeof value === "object" && value !== null) {
|
|
429
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
430
|
+
Object.freeze(value);
|
|
431
|
+
}
|
|
432
|
+
return value;
|
|
433
|
+
};
|
|
434
|
+
const compileSchema = (ajv, schema, label) => {
|
|
435
|
+
try {
|
|
436
|
+
ajv.compile(schema);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
throw new Error(`schema ${label} does not compile: ${error instanceof Error ? error.message : String(error)}`);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
/** Схема объектом (копия, заморожена) или файлом внутри каталога расширения. */
|
|
442
|
+
const resolveSchema = async (ajv, dir, source, label) => {
|
|
443
|
+
if (typeof source !== "string") {
|
|
444
|
+
const schema = deepFreeze(structuredClone(source));
|
|
445
|
+
compileSchema(ajv, schema, `${label} (inline)`);
|
|
446
|
+
return schema;
|
|
447
|
+
}
|
|
448
|
+
const file = inside(dir, source);
|
|
449
|
+
let parsed;
|
|
450
|
+
try {
|
|
451
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
452
|
+
} catch (error) {
|
|
453
|
+
throw new Error(`schema '${source}' is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
454
|
+
}
|
|
455
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`schema '${source}' is not an object`);
|
|
456
|
+
compileSchema(ajv, parsed, `'${source}'`);
|
|
457
|
+
return parsed;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region packages/extension-host/src/points/exercise-types.ts
|
|
462
|
+
const schemaField = z.union([safePath([".json"]), z.record(z.string(), z.unknown()).refine((value) => Object.keys(value).length > 0, "must not be empty")]);
|
|
463
|
+
const exerciseTypes = {
|
|
464
|
+
key: "exerciseTypes",
|
|
465
|
+
needsMain: true,
|
|
466
|
+
schema: z.strictObject({
|
|
467
|
+
id: extensionId,
|
|
468
|
+
specSchema: schemaField,
|
|
469
|
+
answerSchema: schemaField,
|
|
470
|
+
element: z.string().optional(),
|
|
471
|
+
renderer: safePath([".js", ".mjs"]).optional()
|
|
472
|
+
}),
|
|
473
|
+
normalize: (entries) => entries.map((type) => ({
|
|
474
|
+
id: type.id,
|
|
475
|
+
specSchema: type.specSchema,
|
|
476
|
+
answerSchema: type.answerSchema,
|
|
477
|
+
element: type.element ?? defaultElementName(type.id),
|
|
478
|
+
renderer: type.renderer ?? "./view.mjs"
|
|
479
|
+
})),
|
|
480
|
+
check: (entries, owner) => [...idPrefixIssues("exerciseTypes", entries.map(({ id }) => id), owner), ...entries.flatMap((type, index) => ELEMENT_NAME_PATTERN.test(type.element) ? [] : [`contributes.exerciseTypes.${index}.element: invalid element name '${type.element}'`])],
|
|
481
|
+
resolve: async (entries, { dir, extensionId: owner, verifyFiles, ajv }) => {
|
|
482
|
+
const resolved = [];
|
|
483
|
+
for (const contribution of entries) {
|
|
484
|
+
const rendererUrl = await resolveModuleUrl(owner, dir, contribution.renderer, DEFAULT_RENDERER, verifyFiles, "renderer");
|
|
485
|
+
resolved.push({
|
|
486
|
+
id: contribution.id,
|
|
487
|
+
specSchema: await resolveSchema(ajv, dir, contribution.specSchema, `specSchema of '${contribution.id}'`),
|
|
488
|
+
answerSchema: await resolveSchema(ajv, dir, contribution.answerSchema, `answerSchema of '${contribution.id}'`),
|
|
489
|
+
element: contribution.element,
|
|
490
|
+
rendererUrl
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return resolved;
|
|
494
|
+
},
|
|
495
|
+
claims: (resolved) => resolved.flatMap((type) => [`exerciseType:${type.id}`, `element:${type.element}`])
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region packages/extension-host/src/points/grade-policies.ts
|
|
500
|
+
const BUILTIN_POLICY_ID = "passAtN";
|
|
501
|
+
const gradePolicies = {
|
|
502
|
+
key: "gradePolicies",
|
|
503
|
+
needsMain: true,
|
|
504
|
+
schema: z.strictObject({
|
|
505
|
+
id: extensionId.refine((id) => id !== BUILTIN_POLICY_ID, `id '${BUILTIN_POLICY_ID}' is reserved for the built-in policy`),
|
|
506
|
+
label: z.string().min(1).max(60)
|
|
507
|
+
}),
|
|
508
|
+
normalize: (entries) => entries,
|
|
509
|
+
check: (entries, owner) => idPrefixIssues("gradePolicies", entries.map(({ id }) => id), owner),
|
|
510
|
+
resolve: async (entries) => entries.map(({ id, label }) => ({
|
|
511
|
+
id,
|
|
512
|
+
label
|
|
513
|
+
})),
|
|
514
|
+
claims: (resolved) => resolved.map((policy) => `gradePolicy:${policy.id}`)
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
//#endregion
|
|
518
|
+
//#region packages/extension-host/src/points/markdown-renderers.ts
|
|
519
|
+
const markdownRenderers = {
|
|
520
|
+
key: "markdownRenderers",
|
|
521
|
+
needsMain: false,
|
|
522
|
+
schema: z.strictObject({
|
|
523
|
+
language: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/, "invalid language"),
|
|
524
|
+
renderer: safePath([".js", ".mjs"]).optional()
|
|
525
|
+
}),
|
|
526
|
+
normalize: (entries) => entries.map((entry) => ({
|
|
527
|
+
language: entry.language,
|
|
528
|
+
renderer: entry.renderer ?? "./markdown.mjs"
|
|
529
|
+
})),
|
|
530
|
+
check: () => [],
|
|
531
|
+
resolve: async (entries, { dir, extensionId, verifyFiles }) => {
|
|
532
|
+
const resolved = [];
|
|
533
|
+
for (const entry of entries) resolved.push({
|
|
534
|
+
language: entry.language,
|
|
535
|
+
rendererUrl: await resolveModuleUrl(extensionId, dir, entry.renderer, DEFAULT_MARKDOWN_RENDERER, verifyFiles, "markdown renderer")
|
|
536
|
+
});
|
|
537
|
+
return resolved;
|
|
538
|
+
},
|
|
539
|
+
claims: (resolved) => resolved.map((entry) => `markdown:${entry.language}`)
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region packages/extension-host/src/points/themes.ts
|
|
544
|
+
const HEX_COLOR = /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/;
|
|
545
|
+
const hexColor = z.string().regex(HEX_COLOR, "must be #rrggbb or #rrggbbaa");
|
|
546
|
+
const unknownKeys = (record, allowed) => Object.keys(record).filter((key) => !allowed.includes(key));
|
|
547
|
+
const rejectUnknownKeys = (allowed, noun) => (value, ctx) => {
|
|
548
|
+
const unknown = unknownKeys(value, allowed);
|
|
549
|
+
if (unknown.length === 0) return;
|
|
550
|
+
ctx.addIssue({
|
|
551
|
+
code: "custom",
|
|
552
|
+
message: `unknown ${noun} keys: ${unknown.join(", ")}`
|
|
553
|
+
});
|
|
554
|
+
};
|
|
555
|
+
const colors = z.record(z.string(), hexColor).refine((value) => Object.keys(value).length > 0, "must not be empty").superRefine(rejectUnknownKeys(THEME_COLOR_KEYS, "color"));
|
|
556
|
+
const isVariableValid = (key, value) => key === "border-color" ? typeof value === "string" && HEX_COLOR.test(value) : typeof value === "number" && value >= 0 && value <= 1;
|
|
557
|
+
const variables = z.record(z.string(), z.union([z.string(), z.number()])).superRefine(rejectUnknownKeys(THEME_VARIABLE_KEYS, "variable")).refine((value) => Object.entries(value).every(([key, item]) => isVariableValid(key, item)), "border-color must be a hex color, other variables numbers in [0, 1]");
|
|
558
|
+
const themes = {
|
|
559
|
+
key: "themes",
|
|
560
|
+
needsMain: false,
|
|
561
|
+
schema: z.strictObject({
|
|
562
|
+
id: extensionId.refine((id) => !BUILTIN_THEME_IDS.includes(id), "id is reserved for a built-in theme"),
|
|
563
|
+
label: z.string().min(1).max(60),
|
|
564
|
+
dark: z.boolean(),
|
|
565
|
+
colors,
|
|
566
|
+
variables: variables.optional()
|
|
567
|
+
}),
|
|
568
|
+
normalize: (entries) => entries,
|
|
569
|
+
check: (entries, owner) => idPrefixIssues("themes", entries.map(({ id }) => id), owner),
|
|
570
|
+
resolve: async (entries) => entries.map((theme) => ({
|
|
571
|
+
id: theme.id,
|
|
572
|
+
label: theme.label,
|
|
573
|
+
dark: theme.dark,
|
|
574
|
+
colors: { ...theme.colors },
|
|
575
|
+
variables: { ...theme.variables }
|
|
576
|
+
})),
|
|
577
|
+
claims: (resolved) => resolved.map((theme) => `theme:${theme.id}`)
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
//#endregion
|
|
581
|
+
//#region packages/extension-host/src/points/index.ts
|
|
582
|
+
/** Реестр точек вклада: порядок значим для диагностики и разбора. */
|
|
583
|
+
const CONTRIBUTION_POINTS = [
|
|
584
|
+
exerciseTypes,
|
|
585
|
+
themes,
|
|
586
|
+
markdownRenderers,
|
|
587
|
+
gradePolicies
|
|
588
|
+
];
|
|
589
|
+
const POINT_KEYS = CONTRIBUTION_POINTS.map(({ key }) => key);
|
|
590
|
+
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region packages/extension-host/src/manifest.ts
|
|
593
|
+
const contributesSchema = z.strictObject(Object.fromEntries(CONTRIBUTION_POINTS.map((point) => [point.key, z.array(point.schema).optional()])));
|
|
594
|
+
const entriesOf = (contributes, key) => contributes[key] ?? [];
|
|
595
|
+
const isEmpty = (contributes) => CONTRIBUTION_POINTS.every((point) => entriesOf(contributes, point.key).length === 0);
|
|
596
|
+
const manifestSchema = z.strictObject({
|
|
597
|
+
id: extensionId,
|
|
598
|
+
version: z.string().refine(isSemver, "version must be semver"),
|
|
599
|
+
apiVersion: z.literal(1),
|
|
600
|
+
main: safePath([".mjs"]).optional(),
|
|
601
|
+
permissions: z.array(z.enum(EXTENSION_PERMISSIONS)).optional(),
|
|
602
|
+
name: z.string().min(1).max(80).optional(),
|
|
603
|
+
description: z.string().min(1).max(500).optional(),
|
|
604
|
+
author: z.string().regex(GITHUB_LOGIN_PATTERN, "must be a GitHub login").optional(),
|
|
605
|
+
platforms: z.array(z.enum(EXTENSION_PLATFORMS)).optional(),
|
|
606
|
+
minAppVersion: z.string().refine(isSemver, "minAppVersion must be semver x.y.z").optional(),
|
|
607
|
+
contributes: contributesSchema
|
|
608
|
+
}).superRefine((manifest, ctx) => {
|
|
609
|
+
const { permissions = [], platforms = [] } = manifest;
|
|
610
|
+
platforms.forEach((platform, index) => {
|
|
611
|
+
if (platforms.indexOf(platform) !== index) ctx.addIssue({
|
|
612
|
+
code: "custom",
|
|
613
|
+
path: ["platforms", index],
|
|
614
|
+
message: `duplicate platform '${platform}'`
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
permissions.forEach((permission, index) => {
|
|
618
|
+
if (permissions.indexOf(permission) !== index) ctx.addIssue({
|
|
619
|
+
code: "custom",
|
|
620
|
+
path: ["permissions", index],
|
|
621
|
+
message: `duplicate permission '${permission}'`
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
if (isEmpty(manifest.contributes)) ctx.addIssue({
|
|
625
|
+
code: "custom",
|
|
626
|
+
path: ["contributes"],
|
|
627
|
+
message: "at least one contribution is required"
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
/** Код нужен, если есть записи в точке, которая его требует. */
|
|
631
|
+
const needsMain = (contributes) => CONTRIBUTION_POINTS.some((point) => point.needsMain && entriesOf(contributes, point.key).length > 0);
|
|
632
|
+
/** Применяет умолчания: `main`, `renderer`, `element`, пустые точки. */
|
|
633
|
+
const normalizeManifest = (input) => {
|
|
634
|
+
const contributes = Object.fromEntries(CONTRIBUTION_POINTS.map((point) => [point.key, point.normalize(entriesOf(input.contributes, point.key))]));
|
|
635
|
+
return {
|
|
636
|
+
id: input.id,
|
|
637
|
+
version: input.version,
|
|
638
|
+
apiVersion: input.apiVersion,
|
|
639
|
+
main: input.main ?? (needsMain(input.contributes) ? "./main.mjs" : null),
|
|
640
|
+
permissions: [...input.permissions ?? []],
|
|
641
|
+
name: input.name ?? null,
|
|
642
|
+
description: input.description ?? null,
|
|
643
|
+
author: input.author ?? null,
|
|
644
|
+
platforms: [...input.platforms ?? []],
|
|
645
|
+
minAppVersion: input.minAppVersion ?? null,
|
|
646
|
+
contributes
|
|
647
|
+
};
|
|
648
|
+
};
|
|
649
|
+
const formatIssues = (error) => error.issues.map((issue) => `${issue.path.join(".") || "/"}: ${issue.message}`).join("; ");
|
|
650
|
+
/** Сообщения о нарушениях в нормализованном манифесте. */
|
|
651
|
+
const normalizedIssues = (manifest) => CONTRIBUTION_POINTS.flatMap((point) => point.check(manifest.contributes[point.key], manifest.id));
|
|
652
|
+
const parseManifest = (raw) => {
|
|
653
|
+
const parsed = manifestSchema.safeParse(raw);
|
|
654
|
+
if (!parsed.success) return {
|
|
655
|
+
ok: false,
|
|
656
|
+
message: formatIssues(parsed.error)
|
|
657
|
+
};
|
|
658
|
+
const manifest = normalizeManifest(parsed.data);
|
|
659
|
+
const issues = normalizedIssues(manifest);
|
|
660
|
+
if (issues.length > 0) return {
|
|
661
|
+
ok: false,
|
|
662
|
+
message: issues.join("; ")
|
|
663
|
+
};
|
|
664
|
+
return {
|
|
665
|
+
ok: true,
|
|
666
|
+
manifest
|
|
667
|
+
};
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
//#endregion
|
|
671
|
+
//#region packages/extension-host/src/discover.ts
|
|
672
|
+
const compatibilityIssue = (manifest, appVersion, platform) => checkCompatibility(manifest, {
|
|
673
|
+
appVersion,
|
|
674
|
+
platform
|
|
675
|
+
})?.detail ?? null;
|
|
676
|
+
const resolveMain = async (dir, main, verifyFiles) => {
|
|
677
|
+
if (main === null) return null;
|
|
678
|
+
const mainPath = inside(dir, main);
|
|
679
|
+
if (verifyFiles && !await isFile(mainPath)) throw new Error(`main '${main}'${defaultNote(main, DEFAULT_MAIN)} is not a file`);
|
|
680
|
+
return mainPath;
|
|
681
|
+
};
|
|
682
|
+
const inspectExtensionDir = async (directory, options = {}) => {
|
|
683
|
+
const { verifyFiles = true, expectedId = null, appVersion, platform = process.platform } = options;
|
|
684
|
+
const dir = path.resolve(directory);
|
|
685
|
+
const dirName = expectedId ?? path.basename(dir);
|
|
686
|
+
const ajv = new Ajv2020({
|
|
687
|
+
allErrors: true,
|
|
688
|
+
strict: false
|
|
689
|
+
});
|
|
690
|
+
let raw;
|
|
691
|
+
try {
|
|
692
|
+
raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
|
|
693
|
+
} catch (error) {
|
|
694
|
+
return {
|
|
695
|
+
ok: false,
|
|
696
|
+
id: dirName,
|
|
697
|
+
message: `extension.json is unreadable: ${error instanceof Error ? error.message : String(error)}`
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
const parsed = parseManifest(raw);
|
|
701
|
+
if (!parsed.ok) return {
|
|
702
|
+
ok: false,
|
|
703
|
+
id: dirName,
|
|
704
|
+
message: parsed.message
|
|
705
|
+
};
|
|
706
|
+
const { manifest } = parsed;
|
|
707
|
+
if (expectedId !== null && manifest.id !== expectedId) return {
|
|
708
|
+
ok: false,
|
|
709
|
+
id: dirName,
|
|
710
|
+
message: `directory name '${expectedId}' does not match manifest id '${manifest.id}'`
|
|
711
|
+
};
|
|
712
|
+
const incompatible = compatibilityIssue(manifest, appVersion, platform);
|
|
713
|
+
if (incompatible !== null) return {
|
|
714
|
+
ok: false,
|
|
715
|
+
id: manifest.id,
|
|
716
|
+
message: incompatible
|
|
717
|
+
};
|
|
718
|
+
try {
|
|
719
|
+
const mainPath = await resolveMain(dir, manifest.main, verifyFiles);
|
|
720
|
+
const context = {
|
|
721
|
+
dir,
|
|
722
|
+
extensionId: manifest.id,
|
|
723
|
+
verifyFiles,
|
|
724
|
+
ajv
|
|
725
|
+
};
|
|
726
|
+
const resolved = {};
|
|
727
|
+
for (const point of CONTRIBUTION_POINTS) resolved[point.key] = await point.resolve(manifest.contributes[point.key], context);
|
|
728
|
+
return {
|
|
729
|
+
ok: true,
|
|
730
|
+
extension: {
|
|
731
|
+
id: manifest.id,
|
|
732
|
+
version: manifest.version,
|
|
733
|
+
dir,
|
|
734
|
+
mainPath,
|
|
735
|
+
permissions: manifest.permissions,
|
|
736
|
+
name: manifest.name,
|
|
737
|
+
description: manifest.description,
|
|
738
|
+
author: manifest.author,
|
|
739
|
+
platforms: manifest.platforms,
|
|
740
|
+
minAppVersion: manifest.minAppVersion,
|
|
741
|
+
...resolved
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
} catch (error) {
|
|
745
|
+
return {
|
|
746
|
+
ok: false,
|
|
747
|
+
id: manifest.id,
|
|
748
|
+
message: error instanceof Error ? error.message : String(error)
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
//#endregion
|
|
754
|
+
//#region packages/extension-tools/src/errors.ts
|
|
755
|
+
/** Ошибка сборки/проверки расширения; `subject` — id расширения или каталог. */
|
|
756
|
+
var BuildError = class extends Error {
|
|
757
|
+
subject;
|
|
758
|
+
constructor(message, subject) {
|
|
759
|
+
super(message);
|
|
760
|
+
this.name = "BuildError";
|
|
761
|
+
this.subject = subject;
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
/** Неверные входные данные команды (несуществующий каталог): код выхода 2. */
|
|
765
|
+
var CatalogUsageError = class extends BuildError {};
|
|
766
|
+
|
|
767
|
+
//#endregion
|
|
768
|
+
//#region packages/extension-tools/src/bundle.ts
|
|
769
|
+
const isWatcher = (value) => typeof value === "object" && value !== null && "on" in value && "close" in value;
|
|
770
|
+
const errorText$2 = (error) => error instanceof Error ? error.message : String(error);
|
|
771
|
+
const nodeExternal = (external) => [
|
|
772
|
+
/^node:/,
|
|
773
|
+
...builtinModules,
|
|
774
|
+
...external
|
|
775
|
+
];
|
|
776
|
+
const bundleConfig = (project, entry, outDir, isNode) => ({
|
|
777
|
+
root: project.root,
|
|
778
|
+
configFile: false,
|
|
779
|
+
publicDir: false,
|
|
780
|
+
logLevel: "warn",
|
|
781
|
+
build: {
|
|
782
|
+
target: isNode ? "node22" : "es2022",
|
|
783
|
+
outDir,
|
|
784
|
+
emptyOutDir: false,
|
|
785
|
+
minify: false,
|
|
786
|
+
copyPublicDir: false,
|
|
787
|
+
lib: {
|
|
788
|
+
entry: path.resolve(project.root, entry.source),
|
|
789
|
+
formats: ["es"],
|
|
790
|
+
fileName: () => entry.output
|
|
791
|
+
},
|
|
792
|
+
rolldownOptions: { external: isNode ? nodeExternal(project.external) : [] }
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
const configsOf = (project, outDir) => [...project.nodeEntries.map((entry) => bundleConfig(project, entry, outDir, true)), ...project.browserEntries.map((entry) => bundleConfig(project, entry, outDir, false))];
|
|
796
|
+
const sourcesOf = (project) => [...project.nodeEntries, ...project.browserEntries];
|
|
797
|
+
const buildOne = async (config, entry, subject) => {
|
|
798
|
+
try {
|
|
799
|
+
await build(config);
|
|
800
|
+
} catch (error) {
|
|
801
|
+
throw new BuildError(`failed to bundle '${entry.source}': ${errorText$2(error)}`, subject);
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
/** Собирает все бандлы (по одному, без общих чанков) в `outDir`. */
|
|
805
|
+
const bundleAll = async (project, outDir) => {
|
|
806
|
+
const configs = configsOf(project, outDir);
|
|
807
|
+
const entries = sourcesOf(project);
|
|
808
|
+
for (const [index, config] of configs.entries()) await buildOne(config, entries[index], project.manifest.id);
|
|
809
|
+
};
|
|
810
|
+
const whenFirstEnd = (watcher, entry, onRebuild) => {
|
|
811
|
+
let isFirst = true;
|
|
812
|
+
return new Promise((resolve, reject) => {
|
|
813
|
+
watcher.on("event", (event) => {
|
|
814
|
+
if (event.code === "ERROR") {
|
|
815
|
+
if (isFirst) reject(event.error);
|
|
816
|
+
else onRebuild(entry, event.error);
|
|
817
|
+
}
|
|
818
|
+
if (event.code === "END") {
|
|
819
|
+
if (isFirst) resolve();
|
|
820
|
+
else onRebuild(entry, null);
|
|
821
|
+
isFirst = false;
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
};
|
|
826
|
+
/**
|
|
827
|
+
* Запускает вотчеры всех бандлов; резолвится после первой сборки каждого.
|
|
828
|
+
* `onRebuild` вызывается после последующих пересборок (`error` — причина сбоя).
|
|
829
|
+
*/
|
|
830
|
+
const watchAll = async (project, outDir, onRebuild) => {
|
|
831
|
+
const watchers = [];
|
|
832
|
+
const close = async () => {
|
|
833
|
+
await Promise.all(watchers.map((watcher) => watcher.close()));
|
|
834
|
+
};
|
|
835
|
+
const entries = sourcesOf(project);
|
|
836
|
+
try {
|
|
837
|
+
const firsts = [];
|
|
838
|
+
for (const [index, config] of configsOf(project, outDir).entries()) {
|
|
839
|
+
const entry = entries[index];
|
|
840
|
+
const started = await build({
|
|
841
|
+
...config,
|
|
842
|
+
build: {
|
|
843
|
+
...config.build,
|
|
844
|
+
watch: {}
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
if (!isWatcher(started)) throw new Error(`watch mode is not available for '${entry.source}'`);
|
|
848
|
+
watchers.push(started);
|
|
849
|
+
firsts.push(whenFirstEnd(started, entry, onRebuild));
|
|
850
|
+
}
|
|
851
|
+
await Promise.all(firsts);
|
|
852
|
+
} catch (error) {
|
|
853
|
+
await close();
|
|
854
|
+
throw new BuildError(`failed to bundle: ${errorText$2(error)}`, project.manifest.id);
|
|
855
|
+
}
|
|
856
|
+
return { close };
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
//#endregion
|
|
860
|
+
//#region packages/extension-tools/src/project.ts
|
|
861
|
+
const MANIFEST_FILE = "extension.json";
|
|
862
|
+
const CONFIG_FILE = "dolphy-ext.config.json";
|
|
863
|
+
const DEFAULT_OUT_DIR = "dist-ext";
|
|
864
|
+
const stripDot = (file) => file.replace(/^\.\//, "");
|
|
865
|
+
const stripExtension = (file) => file.slice(0, file.length - path.posix.extname(file).length);
|
|
866
|
+
const entrySource = (file) => `src/${path.posix.basename(stripExtension(file))}.ts`;
|
|
867
|
+
const errorText$1 = (error) => error instanceof Error ? error.message : String(error);
|
|
868
|
+
const isStringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
|
|
869
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
870
|
+
const readJson = async (file, subject) => {
|
|
871
|
+
try {
|
|
872
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
873
|
+
} catch (error) {
|
|
874
|
+
throw new BuildError(`${path.basename(file)} is unreadable: ${errorText$1(error)}`, subject);
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
const readConfig = async (root) => {
|
|
878
|
+
const file = path.join(root, CONFIG_FILE);
|
|
879
|
+
if (!((await stat(file).catch(() => null))?.isFile() === true)) return {
|
|
880
|
+
nodeEntries: {},
|
|
881
|
+
external: []
|
|
882
|
+
};
|
|
883
|
+
const config = await readJson(file, root);
|
|
884
|
+
const nodeEntries = config.nodeEntries ?? {};
|
|
885
|
+
const external = config.external ?? [];
|
|
886
|
+
if (!isStringRecord(nodeEntries) || !isStringArray(external)) throw new BuildError(`${CONFIG_FILE}: 'nodeEntries' must map output files to sources and 'external' must be a string array`, root);
|
|
887
|
+
return {
|
|
888
|
+
nodeEntries,
|
|
889
|
+
external
|
|
890
|
+
};
|
|
891
|
+
};
|
|
892
|
+
const schemaPathsOf = (manifest) => {
|
|
893
|
+
const paths = manifest.contributes.exerciseTypes.flatMap((type) => [type.specSchema, type.answerSchema]).filter((schema) => typeof schema === "string").map(stripDot);
|
|
894
|
+
return [...new Set(paths)];
|
|
895
|
+
};
|
|
896
|
+
/** Без кода (`main: null`) node-входов нет. */
|
|
897
|
+
const nodeEntriesOf = (manifest, config) => {
|
|
898
|
+
if (manifest.main === null) return [];
|
|
899
|
+
const main = stripDot(manifest.main);
|
|
900
|
+
return [{
|
|
901
|
+
source: entrySource(main),
|
|
902
|
+
output: main
|
|
903
|
+
}, ...Object.entries(config.nodeEntries).map(([output, source]) => ({
|
|
904
|
+
source,
|
|
905
|
+
output: stripDot(output)
|
|
906
|
+
}))];
|
|
907
|
+
};
|
|
908
|
+
/** Читает исходный манифест и конфиг проекта, вычисляет точки входа. */
|
|
909
|
+
const loadProject = async (rootDir) => {
|
|
910
|
+
const root = path.resolve(rootDir);
|
|
911
|
+
const manifestFile = path.join(root, MANIFEST_FILE);
|
|
912
|
+
let manifestBytes;
|
|
913
|
+
try {
|
|
914
|
+
manifestBytes = await readFile(manifestFile);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
throw new BuildError(`${MANIFEST_FILE} is unreadable: ${errorText$1(error)}`, root);
|
|
917
|
+
}
|
|
918
|
+
const raw = await readJson(manifestFile, root);
|
|
919
|
+
const parsed = parseManifest(raw);
|
|
920
|
+
if (!parsed.ok) throw new BuildError(parsed.message, root);
|
|
921
|
+
const { manifest } = parsed;
|
|
922
|
+
const config = await readConfig(root);
|
|
923
|
+
const nodeEntries = nodeEntriesOf(manifest, config);
|
|
924
|
+
const browserEntries = [.../* @__PURE__ */ new Set([...manifest.contributes.exerciseTypes.map((type) => type.renderer), ...manifest.contributes.markdownRenderers.map((entry) => entry.renderer)])].map(stripDot).map((output) => ({
|
|
925
|
+
source: entrySource(output),
|
|
926
|
+
output
|
|
927
|
+
}));
|
|
928
|
+
return {
|
|
929
|
+
root,
|
|
930
|
+
manifest,
|
|
931
|
+
manifestBytes,
|
|
932
|
+
nodeEntries,
|
|
933
|
+
browserEntries,
|
|
934
|
+
external: config.external,
|
|
935
|
+
schemaPaths: schemaPathsOf(manifest)
|
|
936
|
+
};
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
//#endregion
|
|
940
|
+
//#region packages/extension-tools/src/index.ts
|
|
941
|
+
const STATIC_DIRS = ["schema", "assets"];
|
|
942
|
+
const isPresent = async (file) => await stat(file).catch(() => null) !== null;
|
|
943
|
+
const targetDir = (project, options) => {
|
|
944
|
+
const base = options.outDir ?? path.join(project.root, "dist-ext");
|
|
945
|
+
return path.join(path.resolve(base), project.manifest.id);
|
|
946
|
+
};
|
|
947
|
+
const requireSources = async (project) => {
|
|
948
|
+
const entries = [...project.nodeEntries, ...project.browserEntries];
|
|
949
|
+
for (const entry of entries) {
|
|
950
|
+
const file = path.join(project.root, entry.source);
|
|
951
|
+
if (!await isPresent(file)) throw new BuildError(`entry source '${entry.source}' for '${entry.output}' is not found`, project.manifest.id);
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
const copyStatic = async (project, dir) => {
|
|
955
|
+
await writeFile(path.join(dir, MANIFEST_FILE), project.manifestBytes);
|
|
956
|
+
for (const name of STATIC_DIRS) {
|
|
957
|
+
const from = path.join(project.root, name);
|
|
958
|
+
if (await isPresent(from)) await cp(from, path.join(dir, name), { recursive: true });
|
|
959
|
+
}
|
|
960
|
+
for (const schema of project.schemaPaths) {
|
|
961
|
+
const from = path.join(project.root, schema);
|
|
962
|
+
if (!await isPresent(from)) throw new BuildError(`schema '${schema}' referenced by the manifest is not found`, project.manifest.id);
|
|
963
|
+
await mkdir(path.dirname(path.join(dir, schema)), { recursive: true });
|
|
964
|
+
await cp(from, path.join(dir, schema));
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
const listFiles = async (dir, prefix = "") => {
|
|
968
|
+
const entries = await readdir(path.join(dir, prefix), { withFileTypes: true });
|
|
969
|
+
return (await Promise.all(entries.map(async (entry) => {
|
|
970
|
+
const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
971
|
+
return entry.isDirectory() ? listFiles(dir, relative) : [relative];
|
|
972
|
+
}))).flat().sort();
|
|
973
|
+
};
|
|
974
|
+
const validateExtension = async (dir) => {
|
|
975
|
+
const result = await inspectExtensionDir(path.resolve(dir), {
|
|
976
|
+
verifyFiles: true,
|
|
977
|
+
expectedId: null
|
|
978
|
+
});
|
|
979
|
+
return result.ok ? {
|
|
980
|
+
ok: true,
|
|
981
|
+
problems: []
|
|
982
|
+
} : {
|
|
983
|
+
ok: false,
|
|
984
|
+
problems: [result.message]
|
|
985
|
+
};
|
|
986
|
+
};
|
|
987
|
+
const assertValid = async (project, dir) => {
|
|
988
|
+
const { ok, problems } = await validateExtension(dir);
|
|
989
|
+
if (!ok) throw new BuildError(problems.join("; "), project.manifest.id);
|
|
990
|
+
};
|
|
991
|
+
const prepare = async (options) => {
|
|
992
|
+
const project = await loadProject(options.root);
|
|
993
|
+
await requireSources(project);
|
|
994
|
+
const dir = targetDir(project, options);
|
|
995
|
+
await rm(dir, {
|
|
996
|
+
recursive: true,
|
|
997
|
+
force: true
|
|
998
|
+
});
|
|
999
|
+
await mkdir(dir, { recursive: true });
|
|
1000
|
+
return {
|
|
1001
|
+
project,
|
|
1002
|
+
dir
|
|
1003
|
+
};
|
|
1004
|
+
};
|
|
1005
|
+
const resultOf$1 = async (project, dir) => ({
|
|
1006
|
+
id: project.manifest.id,
|
|
1007
|
+
dir,
|
|
1008
|
+
files: await listFiles(dir)
|
|
1009
|
+
});
|
|
1010
|
+
const buildExtension = async (options) => {
|
|
1011
|
+
const { project, dir } = await prepare(options);
|
|
1012
|
+
await bundleAll(project, dir);
|
|
1013
|
+
await copyStatic(project, dir);
|
|
1014
|
+
await assertValid(project, dir);
|
|
1015
|
+
return resultOf$1(project, dir);
|
|
1016
|
+
};
|
|
1017
|
+
/**
|
|
1018
|
+
* Первая сборка + пересборка бандлов при правке исходников до `close()`.
|
|
1019
|
+
* Манифест, схемы и assets копируются один раз: их правка требует перезапуска.
|
|
1020
|
+
*/
|
|
1021
|
+
const watchExtension = async (options) => {
|
|
1022
|
+
const { project, dir } = await prepare(options);
|
|
1023
|
+
const { logger } = options;
|
|
1024
|
+
const watch = await watchAll(project, dir, (entry, error) => {
|
|
1025
|
+
if (error === null) logger?.info(`rebuilt ${entry.output}`);
|
|
1026
|
+
else {
|
|
1027
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1028
|
+
logger?.error(`error ${project.manifest.id}: ${message}`);
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
try {
|
|
1032
|
+
await copyStatic(project, dir);
|
|
1033
|
+
await assertValid(project, dir);
|
|
1034
|
+
} catch (error) {
|
|
1035
|
+
await watch.close();
|
|
1036
|
+
throw error;
|
|
1037
|
+
}
|
|
1038
|
+
return {
|
|
1039
|
+
result: await resultOf$1(project, dir),
|
|
1040
|
+
close: watch.close
|
|
1041
|
+
};
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
//#endregion
|
|
1045
|
+
//#region packages/extension-tools/src/catalog/github.ts
|
|
1046
|
+
const API = "https://api.github.com/users/";
|
|
1047
|
+
const statusOf = (response) => {
|
|
1048
|
+
if (response.status === 200) return "exists";
|
|
1049
|
+
if (response.status === 404) return "missing";
|
|
1050
|
+
return "unknown";
|
|
1051
|
+
};
|
|
1052
|
+
/** Результат кэшируется на время жизни проверяющего: один автор — один запрос. */
|
|
1053
|
+
const createGithubChecker = (options = {}) => {
|
|
1054
|
+
const fetchUser = options.fetch ?? fetch;
|
|
1055
|
+
const headers = {
|
|
1056
|
+
accept: "application/vnd.github+json",
|
|
1057
|
+
"user-agent": "dolphy-ext",
|
|
1058
|
+
...options.token ? { authorization: `Bearer ${options.token}` } : {}
|
|
1059
|
+
};
|
|
1060
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1061
|
+
const lookup = async (login) => {
|
|
1062
|
+
try {
|
|
1063
|
+
const url = `${API}${encodeURIComponent(login)}`;
|
|
1064
|
+
return statusOf(await fetchUser(url, { headers }));
|
|
1065
|
+
} catch {
|
|
1066
|
+
return "unknown";
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
return (login) => {
|
|
1070
|
+
const cached = cache.get(login);
|
|
1071
|
+
if (cached !== void 0) return cached;
|
|
1072
|
+
const pending = lookup(login);
|
|
1073
|
+
cache.set(login, pending);
|
|
1074
|
+
return pending;
|
|
1075
|
+
};
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
//#endregion
|
|
1079
|
+
//#region packages/extension-tools/src/catalog/rules.ts
|
|
1080
|
+
const MAX_SOURCE_FILES = 200;
|
|
1081
|
+
const MAX_SOURCE_BYTES = 5e6;
|
|
1082
|
+
const MAX_SOURCE_FILE_BYTES = 1e6;
|
|
1083
|
+
const LOCKFILES = [
|
|
1084
|
+
"package-lock.json",
|
|
1085
|
+
"pnpm-lock.yaml",
|
|
1086
|
+
"yarn.lock",
|
|
1087
|
+
"bun.lock"
|
|
1088
|
+
];
|
|
1089
|
+
const LIFECYCLE_SCRIPTS = [
|
|
1090
|
+
"preinstall",
|
|
1091
|
+
"install",
|
|
1092
|
+
"postinstall",
|
|
1093
|
+
"prepare",
|
|
1094
|
+
"prepublish",
|
|
1095
|
+
"prepublishOnly",
|
|
1096
|
+
"prepack",
|
|
1097
|
+
"postpack"
|
|
1098
|
+
];
|
|
1099
|
+
const EXECUTABLE_EXTENSIONS = [
|
|
1100
|
+
".exe",
|
|
1101
|
+
".dll",
|
|
1102
|
+
".so",
|
|
1103
|
+
".dylib",
|
|
1104
|
+
".node",
|
|
1105
|
+
".sh",
|
|
1106
|
+
".bat"
|
|
1107
|
+
];
|
|
1108
|
+
const REQUIRED_SCOPE = "@dolphy-app";
|
|
1109
|
+
const DEPENDENCY_FIELDS = [
|
|
1110
|
+
"dependencies",
|
|
1111
|
+
"devDependencies",
|
|
1112
|
+
"optionalDependencies",
|
|
1113
|
+
"peerDependencies"
|
|
1114
|
+
];
|
|
1115
|
+
const NON_REGISTRY_SPECIFIER = /^(git[+:@]|github:|gitlab:|bitbucket:|gist:|https?:|file:|link:|workspace:|portal:|patch:|\.{0,2}\/|[\w.-]+\/[\w.-]+(#.*)?$)/;
|
|
1116
|
+
const MAX_NAME_LENGTH = 80;
|
|
1117
|
+
const MAX_DESCRIPTION_LENGTH = 500;
|
|
1118
|
+
const error = (field, message) => ({
|
|
1119
|
+
severity: "error",
|
|
1120
|
+
field,
|
|
1121
|
+
message
|
|
1122
|
+
});
|
|
1123
|
+
const warning = (field, message) => ({
|
|
1124
|
+
severity: "warning",
|
|
1125
|
+
field,
|
|
1126
|
+
message
|
|
1127
|
+
});
|
|
1128
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1129
|
+
const readPackageJson = async (context) => {
|
|
1130
|
+
const text = await context.readText("package.json");
|
|
1131
|
+
if (text === null) return null;
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = JSON.parse(text);
|
|
1134
|
+
return isRecord(parsed) ? parsed : null;
|
|
1135
|
+
} catch {
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
const manifestValid = {
|
|
1140
|
+
id: "CHECK-001",
|
|
1141
|
+
title: "extension.json читается и проходит разбор манифеста",
|
|
1142
|
+
run: ({ manifestProblem }) => manifestProblem === null ? [] : [error("extension.json", manifestProblem)]
|
|
1143
|
+
};
|
|
1144
|
+
const directoryName = {
|
|
1145
|
+
id: "CHECK-002",
|
|
1146
|
+
title: "имя каталога равно id из манифеста",
|
|
1147
|
+
run: ({ manifest, dirName }) => manifest === null || manifest.id === dirName ? [] : [error("id", `directory name '${dirName}' does not match manifest id '${manifest.id}'`)]
|
|
1148
|
+
};
|
|
1149
|
+
const textFinding = (field, value, maxLength) => {
|
|
1150
|
+
if (value === null || value.trim() === "") return [error(field, `'${field}' is required for publication`)];
|
|
1151
|
+
return value.length > maxLength ? [error(field, `'${field}' is longer than ${maxLength} characters`)] : [];
|
|
1152
|
+
};
|
|
1153
|
+
const publicationMetadata = {
|
|
1154
|
+
id: "CHECK-003",
|
|
1155
|
+
title: "заданы name, description и author",
|
|
1156
|
+
run: ({ declared }) => declared === null ? [] : [
|
|
1157
|
+
...textFinding("name", declared.name, MAX_NAME_LENGTH),
|
|
1158
|
+
...textFinding("description", declared.description, MAX_DESCRIPTION_LENGTH),
|
|
1159
|
+
...textFinding("author", declared.author, Infinity)
|
|
1160
|
+
]
|
|
1161
|
+
};
|
|
1162
|
+
const readme = {
|
|
1163
|
+
id: "CHECK-004",
|
|
1164
|
+
title: "README.md существует и не пуст",
|
|
1165
|
+
run: async (context) => {
|
|
1166
|
+
const text = await context.readText("README.md");
|
|
1167
|
+
return text === null || text.trim() === "" ? [error("README.md", "README.md is missing or empty")] : [];
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
const isLogin = (author) => GITHUB_LOGIN_PATTERN.test(author);
|
|
1171
|
+
const authorLogin = {
|
|
1172
|
+
id: "CHECK-005",
|
|
1173
|
+
title: "author имеет форму GitHub-логина",
|
|
1174
|
+
run: ({ declared }) => declared?.author && !isLogin(declared.author) ? [error("author", `'${declared.author}' is not a GitHub login`)] : []
|
|
1175
|
+
};
|
|
1176
|
+
const authorExists = {
|
|
1177
|
+
id: "CHECK-006",
|
|
1178
|
+
title: "author — существующий пользователь GitHub",
|
|
1179
|
+
run: async ({ declared, skipGithubCheck, checkGithubUser }) => {
|
|
1180
|
+
const author = declared?.author;
|
|
1181
|
+
if (skipGithubCheck || !author || !isLogin(author)) return [];
|
|
1182
|
+
const status = await checkGithubUser(author);
|
|
1183
|
+
if (status === "missing") return [error("author", `GitHub user '${author}' does not exist`)];
|
|
1184
|
+
return status === "unknown" ? [warning("author", `could not verify GitHub user '${author}'`)] : [];
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
const packageJson = {
|
|
1188
|
+
id: "CHECK-007",
|
|
1189
|
+
title: "package.json существует и разбирается",
|
|
1190
|
+
run: async (context) => {
|
|
1191
|
+
if (await context.readText("package.json") === null) return [error("package.json", "package.json is missing")];
|
|
1192
|
+
return await readPackageJson(context) === null ? [error("package.json", "package.json is not a valid JSON object")] : [];
|
|
1193
|
+
}
|
|
1194
|
+
};
|
|
1195
|
+
const lockfile = {
|
|
1196
|
+
id: "CHECK-008",
|
|
1197
|
+
title: "есть lock-файл зависимостей",
|
|
1198
|
+
run: ({ tree }) => LOCKFILES.some((name) => tree.files.some((file) => file.path === name)) ? [] : [error("package.json", `no lockfile (${LOCKFILES.join(", ")})`)]
|
|
1199
|
+
};
|
|
1200
|
+
const lifecycleScripts = {
|
|
1201
|
+
id: "CHECK-009",
|
|
1202
|
+
title: "нет lifecycle-скриптов установки и публикации",
|
|
1203
|
+
run: async (context) => {
|
|
1204
|
+
const scripts = (await readPackageJson(context))?.scripts;
|
|
1205
|
+
if (!isRecord(scripts)) return [];
|
|
1206
|
+
return LIFECYCLE_SCRIPTS.filter((name) => name in scripts).map((name) => error(`scripts.${name}`, `lifecycle script '${name}' is not allowed`));
|
|
1207
|
+
}
|
|
1208
|
+
};
|
|
1209
|
+
const registryDependencies = {
|
|
1210
|
+
id: "CHECK-010",
|
|
1211
|
+
title: "зависимости только из реестра (без git, http, file, link, workspace)",
|
|
1212
|
+
run: async (context) => {
|
|
1213
|
+
const manifest = await readPackageJson(context);
|
|
1214
|
+
if (manifest === null) return [];
|
|
1215
|
+
return DEPENDENCY_FIELDS.flatMap((field) => {
|
|
1216
|
+
const section = manifest[field];
|
|
1217
|
+
if (!isRecord(section)) return [];
|
|
1218
|
+
return Object.entries(section).filter(([, spec]) => typeof spec !== "string" || NON_REGISTRY_SPECIFIER.test(spec.trim())).map(([name, spec]) => error(`${field}.${name}`, `specifier '${String(spec)}' is not a registry range`));
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
const packageScope = {
|
|
1223
|
+
id: "CHECK-011",
|
|
1224
|
+
title: "name в package.json не занимает чужой scope",
|
|
1225
|
+
run: async (context) => {
|
|
1226
|
+
const name = (await readPackageJson(context))?.name;
|
|
1227
|
+
if (typeof name !== "string" || !name.startsWith("@")) return [];
|
|
1228
|
+
return name.startsWith(`${"@dolphy-app"}/`) ? [] : [warning("name", `package scope of '${name}' is not ${REQUIRED_SCOPE}`)];
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
const newerThanPublished = {
|
|
1232
|
+
id: "CHECK-012",
|
|
1233
|
+
title: "версия строго больше опубликованной",
|
|
1234
|
+
run: ({ manifest, published }) => {
|
|
1235
|
+
if (manifest === null || published === void 0) return [];
|
|
1236
|
+
const { version } = manifest;
|
|
1237
|
+
if (published.versions.some((item) => item.version === version)) return [error("version", `version ${version} is already published`)];
|
|
1238
|
+
const newest = published.versions[0]?.version;
|
|
1239
|
+
return newest !== void 0 && compareSemver(version, newest) < 0 ? [error("version", `version ${version} is not greater than published ${newest}`)] : [];
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
const sourceLimits = {
|
|
1243
|
+
id: "CHECK-013",
|
|
1244
|
+
title: `не более ${200} файлов и 5 МБ исходников, файл не больше 1 МБ`,
|
|
1245
|
+
run: ({ tree }) => {
|
|
1246
|
+
const findings = [];
|
|
1247
|
+
const total = tree.files.reduce((sum, file) => sum + file.size, 0);
|
|
1248
|
+
if (tree.files.length > 200) findings.push(error("files", `${tree.files.length} files exceed the limit of ${200}`));
|
|
1249
|
+
if (total > 5e6) findings.push(error("files", `${total} bytes exceed the limit of ${MAX_SOURCE_BYTES}`));
|
|
1250
|
+
for (const file of tree.files) if (file.size > 1e6) findings.push(error(file.path, `file is ${file.size} bytes, limit is ${MAX_SOURCE_FILE_BYTES}`));
|
|
1251
|
+
return findings;
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
const noSymlinks = {
|
|
1255
|
+
id: "CHECK-014",
|
|
1256
|
+
title: "нет символических ссылок",
|
|
1257
|
+
run: ({ tree }) => tree.symlinks.map((link) => error(link, "symbolic links are not allowed"))
|
|
1258
|
+
};
|
|
1259
|
+
const noExecutables = {
|
|
1260
|
+
id: "CHECK-015",
|
|
1261
|
+
title: `нет исполняемых файлов (${EXECUTABLE_EXTENSIONS.join(", ")})`,
|
|
1262
|
+
run: ({ tree }) => tree.files.filter((file) => EXECUTABLE_EXTENSIONS.includes(path.posix.extname(file.path).toLowerCase())).map((file) => error(file.path, "executable files are not allowed"))
|
|
1263
|
+
};
|
|
1264
|
+
const appVersionBound = {
|
|
1265
|
+
id: "CHECK-016",
|
|
1266
|
+
title: "minAppVersion не новее --max-app-version",
|
|
1267
|
+
run: ({ manifest, maxAppVersion }) => manifest?.minAppVersion && maxAppVersion !== null && compareSemver(manifest.minAppVersion, maxAppVersion) > 0 ? [error("minAppVersion", `minAppVersion ${manifest.minAppVersion} is newer than the released app ${maxAppVersion}`)] : []
|
|
1268
|
+
};
|
|
1269
|
+
const RULES = [
|
|
1270
|
+
manifestValid,
|
|
1271
|
+
directoryName,
|
|
1272
|
+
publicationMetadata,
|
|
1273
|
+
readme,
|
|
1274
|
+
authorLogin,
|
|
1275
|
+
authorExists,
|
|
1276
|
+
packageJson,
|
|
1277
|
+
lockfile,
|
|
1278
|
+
lifecycleScripts,
|
|
1279
|
+
registryDependencies,
|
|
1280
|
+
packageScope,
|
|
1281
|
+
newerThanPublished,
|
|
1282
|
+
sourceLimits,
|
|
1283
|
+
noSymlinks,
|
|
1284
|
+
noExecutables,
|
|
1285
|
+
appVersionBound
|
|
1286
|
+
];
|
|
1287
|
+
|
|
1288
|
+
//#endregion
|
|
1289
|
+
//#region packages/extension-tools/src/catalog/tree.ts
|
|
1290
|
+
const compareText = (a, b) => {
|
|
1291
|
+
if (a === b) return 0;
|
|
1292
|
+
return a < b ? -1 : 1;
|
|
1293
|
+
};
|
|
1294
|
+
const sha256Of = (data) => createHash("sha256").update(data).digest("hex");
|
|
1295
|
+
const readTree = async (root, skipped = /* @__PURE__ */ new Set()) => {
|
|
1296
|
+
const tree = {
|
|
1297
|
+
files: [],
|
|
1298
|
+
symlinks: []
|
|
1299
|
+
};
|
|
1300
|
+
const visit = async (prefix) => {
|
|
1301
|
+
const entries = await readdir(path.join(root, prefix), { withFileTypes: true });
|
|
1302
|
+
entries.sort((a, b) => compareText(a.name, b.name));
|
|
1303
|
+
for (const entry of entries) {
|
|
1304
|
+
if (skipped.has(entry.name)) continue;
|
|
1305
|
+
const relative = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
1306
|
+
if (entry.isSymbolicLink()) tree.symlinks.push(relative);
|
|
1307
|
+
else if (entry.isDirectory()) await visit(relative);
|
|
1308
|
+
else {
|
|
1309
|
+
const { size } = await lstat(path.join(root, relative));
|
|
1310
|
+
tree.files.push({
|
|
1311
|
+
path: relative,
|
|
1312
|
+
size
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
await visit("");
|
|
1318
|
+
return tree;
|
|
1319
|
+
};
|
|
1320
|
+
const hashTree = async (root, files) => Promise.all(files.map(async (file) => ({
|
|
1321
|
+
...file,
|
|
1322
|
+
sha256: sha256Of(await readFile(path.join(root, file.path)))
|
|
1323
|
+
})));
|
|
1324
|
+
|
|
1325
|
+
//#endregion
|
|
1326
|
+
//#region packages/extension-tools/src/catalog/check.ts
|
|
1327
|
+
const SKIPPED_SOURCE_DIRS = /* @__PURE__ */ new Set([
|
|
1328
|
+
"node_modules",
|
|
1329
|
+
"dist-ext",
|
|
1330
|
+
".git"
|
|
1331
|
+
]);
|
|
1332
|
+
const formatFinding = (finding) => `${finding.severity} ${finding.extensionId} ${finding.ruleId} ${finding.field}: ${finding.message}`;
|
|
1333
|
+
const hasErrors = (findings) => findings.some((finding) => finding.severity === "error");
|
|
1334
|
+
const isMissing = (error) => error.code === "ENOENT";
|
|
1335
|
+
const loadIndexFile = async (file) => {
|
|
1336
|
+
let text;
|
|
1337
|
+
try {
|
|
1338
|
+
text = await readFile(file, "utf8");
|
|
1339
|
+
} catch (error) {
|
|
1340
|
+
if (isMissing(error)) return null;
|
|
1341
|
+
throw error;
|
|
1342
|
+
}
|
|
1343
|
+
try {
|
|
1344
|
+
return parseIndex(JSON.parse(text));
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
throw new BuildError(`${file} is not a valid catalog index: ${error instanceof Error ? error.message : String(error)}`, file);
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
1349
|
+
const listExtensionDirs = async (root) => {
|
|
1350
|
+
return (await readdir(root, { withFileTypes: true }).catch((error) => {
|
|
1351
|
+
throw new CatalogUsageError(`cannot read extensions directory: ${error instanceof Error ? error.message : String(error)}`, root);
|
|
1352
|
+
})).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => entry.name).sort();
|
|
1353
|
+
};
|
|
1354
|
+
const selectDirs = async (options) => {
|
|
1355
|
+
const all = await listExtensionDirs(options.extensionsDir);
|
|
1356
|
+
if (options.ids === void 0) return all;
|
|
1357
|
+
const missing = options.ids.filter((id) => !all.includes(id));
|
|
1358
|
+
if (missing.length > 0) throw new CatalogUsageError(`no such extension directories: ${missing.join(", ")}`, options.extensionsDir);
|
|
1359
|
+
return [...options.ids].sort();
|
|
1360
|
+
};
|
|
1361
|
+
const readTextOrNull = async (file) => {
|
|
1362
|
+
return (await stat(file).catch(() => null))?.isFile() === true ? readFile(file, "utf8") : null;
|
|
1363
|
+
};
|
|
1364
|
+
const inspectManifest = async (dir) => {
|
|
1365
|
+
const result = await inspectExtensionDir(dir, {
|
|
1366
|
+
verifyFiles: false,
|
|
1367
|
+
expectedId: null
|
|
1368
|
+
});
|
|
1369
|
+
return result.ok ? {
|
|
1370
|
+
manifest: result.extension,
|
|
1371
|
+
problem: null
|
|
1372
|
+
} : {
|
|
1373
|
+
manifest: null,
|
|
1374
|
+
problem: result.message
|
|
1375
|
+
};
|
|
1376
|
+
};
|
|
1377
|
+
const stringOrNull = (value) => typeof value === "string" ? value : null;
|
|
1378
|
+
const readDeclared = async (dir) => {
|
|
1379
|
+
try {
|
|
1380
|
+
const raw = JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8"));
|
|
1381
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
1382
|
+
const fields = raw;
|
|
1383
|
+
return {
|
|
1384
|
+
name: stringOrNull(fields.name),
|
|
1385
|
+
description: stringOrNull(fields.description),
|
|
1386
|
+
author: stringOrNull(fields.author)
|
|
1387
|
+
};
|
|
1388
|
+
} catch {
|
|
1389
|
+
return null;
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
const contextFor = async (options, dirName, published, checkGithubUser) => {
|
|
1393
|
+
const dir = path.resolve(options.extensionsDir, dirName);
|
|
1394
|
+
const { manifest, problem } = await inspectManifest(dir);
|
|
1395
|
+
return {
|
|
1396
|
+
dirName,
|
|
1397
|
+
dir,
|
|
1398
|
+
manifest,
|
|
1399
|
+
declared: await readDeclared(dir),
|
|
1400
|
+
manifestProblem: problem,
|
|
1401
|
+
tree: await readTree(dir, SKIPPED_SOURCE_DIRS),
|
|
1402
|
+
readText: (file) => readTextOrNull(path.join(dir, file)),
|
|
1403
|
+
published: published?.extensions.find((entry) => entry.id === (manifest?.id ?? dirName)),
|
|
1404
|
+
maxAppVersion: options.maxAppVersion ?? null,
|
|
1405
|
+
skipGithubCheck: options.skipGithubCheck === true,
|
|
1406
|
+
checkGithubUser
|
|
1407
|
+
};
|
|
1408
|
+
};
|
|
1409
|
+
const checkCatalog = async (options) => {
|
|
1410
|
+
const dirs = await selectDirs(options);
|
|
1411
|
+
const published = options.publishedIndex === void 0 ? null : await loadIndexFile(options.publishedIndex);
|
|
1412
|
+
const checkGithubUser = options.checkGithubUser ?? createGithubChecker({
|
|
1413
|
+
...options.fetch === void 0 ? {} : { fetch: options.fetch },
|
|
1414
|
+
token: options.githubToken
|
|
1415
|
+
});
|
|
1416
|
+
const findings = [];
|
|
1417
|
+
for (const dirName of dirs) {
|
|
1418
|
+
const context = await contextFor(options, dirName, published, checkGithubUser);
|
|
1419
|
+
for (const rule of RULES) for (const finding of await rule.run(context)) findings.push({
|
|
1420
|
+
...finding,
|
|
1421
|
+
extensionId: dirName,
|
|
1422
|
+
ruleId: rule.id
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
return findings;
|
|
1426
|
+
};
|
|
1427
|
+
const listRules = () => RULES.map((rule) => `${rule.id} ${rule.title}`);
|
|
1428
|
+
|
|
1429
|
+
//#endregion
|
|
1430
|
+
//#region packages/extension-tools/src/catalog/index-file.ts
|
|
1431
|
+
const INDEX_FILE = "index.json";
|
|
1432
|
+
const orderFile = (file) => ({
|
|
1433
|
+
path: file.path,
|
|
1434
|
+
size: file.size,
|
|
1435
|
+
sha256: file.sha256
|
|
1436
|
+
});
|
|
1437
|
+
const byPath = (a, b) => compareText(a.path, b.path);
|
|
1438
|
+
const sameFiles = (a, b) => JSON.stringify([...a].sort(byPath).map(orderFile)) === JSON.stringify([...b].sort(byPath).map(orderFile));
|
|
1439
|
+
const orderVersion = (version) => ({
|
|
1440
|
+
version: version.version,
|
|
1441
|
+
apiVersion: version.apiVersion,
|
|
1442
|
+
minAppVersion: version.minAppVersion,
|
|
1443
|
+
permissions: [...version.permissions],
|
|
1444
|
+
publishedAt: version.publishedAt,
|
|
1445
|
+
baseUrl: version.baseUrl,
|
|
1446
|
+
files: [...version.files].sort(byPath).map(orderFile)
|
|
1447
|
+
});
|
|
1448
|
+
const orderEntry = (entry) => ({
|
|
1449
|
+
id: entry.id,
|
|
1450
|
+
name: entry.name,
|
|
1451
|
+
description: entry.description,
|
|
1452
|
+
author: entry.author,
|
|
1453
|
+
source: entry.source,
|
|
1454
|
+
platforms: [...entry.platforms],
|
|
1455
|
+
contributes: {
|
|
1456
|
+
exerciseTypes: [...entry.contributes.exerciseTypes],
|
|
1457
|
+
themes: [...entry.contributes.themes],
|
|
1458
|
+
markdownRenderers: [...entry.contributes.markdownRenderers],
|
|
1459
|
+
gradePolicies: [...entry.contributes.gradePolicies]
|
|
1460
|
+
},
|
|
1461
|
+
versions: entry.versions.map(orderVersion)
|
|
1462
|
+
});
|
|
1463
|
+
const byId = (a, b) => compareText(a.id, b.id);
|
|
1464
|
+
const newestFirst = (versions) => [...versions].sort((a, b) => compareSemver(b.version, a.version)).slice(0, 5);
|
|
1465
|
+
/** Собирает индекс в стабильном порядке ключей; бросает `BuildError`, если он не проходит `parseIndex`. */
|
|
1466
|
+
const assembleIndex = (parts) => {
|
|
1467
|
+
const candidate = {
|
|
1468
|
+
schemaVersion: 1,
|
|
1469
|
+
generatedAt: parts.generatedAt,
|
|
1470
|
+
extensions: [...parts.extensions].sort(byId).map(orderEntry),
|
|
1471
|
+
revoked: parts.revoked.map((item) => ({
|
|
1472
|
+
id: item.id,
|
|
1473
|
+
versions: item.versions,
|
|
1474
|
+
reason: item.reason
|
|
1475
|
+
}))
|
|
1476
|
+
};
|
|
1477
|
+
try {
|
|
1478
|
+
parseIndex(candidate);
|
|
1479
|
+
return candidate;
|
|
1480
|
+
} catch (error) {
|
|
1481
|
+
throw new BuildError(`resulting index is invalid: ${error instanceof Error ? error.message : String(error)}`, INDEX_FILE);
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
const writeIndexAtomically = async (file, index) => {
|
|
1485
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
1486
|
+
await writeFile(temporary, `${JSON.stringify(index, null, 2)}\n`);
|
|
1487
|
+
await rename(temporary, file);
|
|
1488
|
+
};
|
|
1489
|
+
const contentOf = (index) => {
|
|
1490
|
+
const { extensions, revoked } = assembleIndex({
|
|
1491
|
+
generatedAt: index.generatedAt,
|
|
1492
|
+
extensions: index.extensions,
|
|
1493
|
+
revoked: index.revoked
|
|
1494
|
+
});
|
|
1495
|
+
return JSON.stringify({
|
|
1496
|
+
extensions,
|
|
1497
|
+
revoked
|
|
1498
|
+
});
|
|
1499
|
+
};
|
|
1500
|
+
/** Совпадают ли записи и список отзыва (без `generatedAt`). */
|
|
1501
|
+
const hasSameContent = (current, next) => current !== null && contentOf(current) === contentOf(next);
|
|
1502
|
+
|
|
1503
|
+
//#endregion
|
|
1504
|
+
//#region packages/extension-tools/src/catalog/build.ts
|
|
1505
|
+
const DEFAULT_SOURCE_BASE = "https://github.com/dolphy-app/dolphy-extensions/tree/main/extensions";
|
|
1506
|
+
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
1507
|
+
const README = "README.md";
|
|
1508
|
+
const copyReadme = async (srcDir, dir, id) => {
|
|
1509
|
+
const text = await readFile(path.join(srcDir, README), "utf8").catch(() => "");
|
|
1510
|
+
if (text.trim() === "") throw new BuildError(`${README} is missing or empty`, id);
|
|
1511
|
+
await writeFile(path.join(dir, README), text);
|
|
1512
|
+
};
|
|
1513
|
+
const fileProblems = (files) => {
|
|
1514
|
+
const problems = files.filter((file) => !isSafeCatalogPath(file.path)).map((file) => `'${file.path}' is not allowed (extensions ${CATALOG_FILE_EXTENSIONS.join("|")}, safe path segments)`);
|
|
1515
|
+
if (files.length > 50) problems.push(`${files.length} files exceed the limit of ${50}`);
|
|
1516
|
+
const total = files.reduce((sum, file) => sum + file.size, 0);
|
|
1517
|
+
if (total > 1e7) problems.push(`${total} bytes exceed the limit of ${MAX_TOTAL_BYTES}`);
|
|
1518
|
+
return problems;
|
|
1519
|
+
};
|
|
1520
|
+
const readBuiltManifest = async (dir) => {
|
|
1521
|
+
const parsed = parseManifest(JSON.parse(await readFile(path.join(dir, "extension.json"), "utf8")));
|
|
1522
|
+
if (!parsed.ok) throw new BuildError(parsed.message, dir);
|
|
1523
|
+
return parsed.manifest;
|
|
1524
|
+
};
|
|
1525
|
+
const stage = async (options, id, scratch) => {
|
|
1526
|
+
const srcDir = path.resolve(options.src, id);
|
|
1527
|
+
const built = await buildExtension({
|
|
1528
|
+
root: srcDir,
|
|
1529
|
+
outDir: scratch
|
|
1530
|
+
});
|
|
1531
|
+
if (built.id !== id) throw new BuildError(`directory name '${id}' does not match manifest id '${built.id}'`, id);
|
|
1532
|
+
await copyReadme(srcDir, built.dir, id);
|
|
1533
|
+
const tree = await readTree(built.dir);
|
|
1534
|
+
const files = await hashTree(built.dir, tree.files);
|
|
1535
|
+
const problems = [...tree.symlinks.map((link) => `'${link}' is a symbolic link`), ...fileProblems(files)];
|
|
1536
|
+
if (problems.length > 0) throw new BuildError(problems.join("; "), id);
|
|
1537
|
+
return {
|
|
1538
|
+
id,
|
|
1539
|
+
dir: built.dir,
|
|
1540
|
+
manifest: await readBuiltManifest(built.dir),
|
|
1541
|
+
files: files.map(({ path: file, size, sha256 }) => ({
|
|
1542
|
+
path: file,
|
|
1543
|
+
size,
|
|
1544
|
+
sha256
|
|
1545
|
+
}))
|
|
1546
|
+
};
|
|
1547
|
+
};
|
|
1548
|
+
const requirePublication = (staged) => {
|
|
1549
|
+
const { name, description, author } = staged.manifest;
|
|
1550
|
+
const missing = Object.entries({
|
|
1551
|
+
name,
|
|
1552
|
+
description,
|
|
1553
|
+
author
|
|
1554
|
+
}).filter(([, value]) => value === null || value.trim() === "").map(([key]) => key);
|
|
1555
|
+
if (missing.length > 0) throw new BuildError(`manifest lacks publication metadata: ${missing.join(", ")}`, staged.id);
|
|
1556
|
+
};
|
|
1557
|
+
const versionDir = (out, staged) => path.join(out, "extensions", staged.id, staged.manifest.version);
|
|
1558
|
+
const baseUrlOf = (staged) => `extensions/${staged.id}/${staged.manifest.version}/`;
|
|
1559
|
+
const existingFiles = async (dir) => {
|
|
1560
|
+
if (await stat(dir).catch(() => null) === null) return null;
|
|
1561
|
+
const tree = await readTree(dir);
|
|
1562
|
+
if (tree.symlinks.length > 0) return [];
|
|
1563
|
+
return (await hashTree(dir, tree.files)).map(({ path: file, size, sha256 }) => ({
|
|
1564
|
+
path: file,
|
|
1565
|
+
size,
|
|
1566
|
+
sha256
|
|
1567
|
+
}));
|
|
1568
|
+
};
|
|
1569
|
+
const immutabilityError = (staged) => new BuildError(`version ${staged.manifest.version} is already published with different content; published versions never change`, staged.id);
|
|
1570
|
+
const newRecord = (staged, publishedAt) => ({
|
|
1571
|
+
version: staged.manifest.version,
|
|
1572
|
+
apiVersion: staged.manifest.apiVersion,
|
|
1573
|
+
minAppVersion: staged.manifest.minAppVersion,
|
|
1574
|
+
permissions: [...staged.manifest.permissions],
|
|
1575
|
+
publishedAt,
|
|
1576
|
+
baseUrl: baseUrlOf(staged),
|
|
1577
|
+
files: staged.files
|
|
1578
|
+
});
|
|
1579
|
+
const plan = async (staged, out, previous, publishedAt) => {
|
|
1580
|
+
const onDisk = await existingFiles(versionDir(out, staged));
|
|
1581
|
+
const recorded = previous?.versions.find((item) => item.version === staged.manifest.version);
|
|
1582
|
+
if (onDisk !== null && !sameFiles(onDisk, staged.files)) throw immutabilityError(staged);
|
|
1583
|
+
if (recorded !== void 0 && !sameFiles(recorded.files, staged.files)) throw immutabilityError(staged);
|
|
1584
|
+
const isPublished = onDisk !== null && recorded !== void 0;
|
|
1585
|
+
return {
|
|
1586
|
+
staged,
|
|
1587
|
+
record: recorded ?? newRecord(staged, publishedAt),
|
|
1588
|
+
status: isPublished ? "unchanged" : "published",
|
|
1589
|
+
shouldWrite: onDisk === null
|
|
1590
|
+
};
|
|
1591
|
+
};
|
|
1592
|
+
const entryOf = (item, previous, sourceBase) => {
|
|
1593
|
+
const { staged, record } = item;
|
|
1594
|
+
const { manifest } = staged;
|
|
1595
|
+
const others = (previous?.versions ?? []).filter((version) => version.version !== record.version);
|
|
1596
|
+
return {
|
|
1597
|
+
id: staged.id,
|
|
1598
|
+
name: manifest.name ?? "",
|
|
1599
|
+
description: manifest.description ?? "",
|
|
1600
|
+
author: manifest.author ?? "",
|
|
1601
|
+
source: `${sourceBase.replace(/\/+$/, "")}/${staged.id}`,
|
|
1602
|
+
platforms: [...manifest.platforms],
|
|
1603
|
+
contributes: {
|
|
1604
|
+
exerciseTypes: manifest.contributes.exerciseTypes.map((type) => type.id),
|
|
1605
|
+
themes: manifest.contributes.themes.map((theme) => theme.id),
|
|
1606
|
+
markdownRenderers: manifest.contributes.markdownRenderers.map((renderer) => renderer.language),
|
|
1607
|
+
gradePolicies: manifest.contributes.gradePolicies.map((policy) => policy.id)
|
|
1608
|
+
},
|
|
1609
|
+
versions: newestFirst([record, ...others])
|
|
1610
|
+
};
|
|
1611
|
+
};
|
|
1612
|
+
const loadRevoked = async (file, previous) => {
|
|
1613
|
+
if (file === void 0) return previous?.revoked ?? [];
|
|
1614
|
+
let raw;
|
|
1615
|
+
try {
|
|
1616
|
+
raw = JSON.parse(await readFile(file, "utf8"));
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
throw new BuildError(`revoked list is unreadable: ${errorText(error)}`, file);
|
|
1619
|
+
}
|
|
1620
|
+
if (!Array.isArray(raw)) throw new BuildError("revoked list must be a JSON array", file);
|
|
1621
|
+
return raw;
|
|
1622
|
+
};
|
|
1623
|
+
const writeVersion = async (item, out) => {
|
|
1624
|
+
const target = versionDir(out, item.staged);
|
|
1625
|
+
const parent = path.dirname(target);
|
|
1626
|
+
await mkdir(parent, { recursive: true });
|
|
1627
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
1628
|
+
await rm(temporary, {
|
|
1629
|
+
recursive: true,
|
|
1630
|
+
force: true
|
|
1631
|
+
});
|
|
1632
|
+
await cp(item.staged.dir, temporary, { recursive: true });
|
|
1633
|
+
await rename(temporary, target);
|
|
1634
|
+
};
|
|
1635
|
+
const resultOf = (item) => ({
|
|
1636
|
+
id: item.staged.id,
|
|
1637
|
+
version: item.staged.manifest.version,
|
|
1638
|
+
status: item.status,
|
|
1639
|
+
files: item.staged.files.length,
|
|
1640
|
+
bytes: item.staged.files.reduce((sum, file) => sum + file.size, 0)
|
|
1641
|
+
});
|
|
1642
|
+
const formatPublishResult = (result) => result.status === "unchanged" ? `unchanged ${result.id}@${result.version}` : `published ${result.id}@${result.version} (${result.files} files, ${result.bytes} bytes)`;
|
|
1643
|
+
/** Собирает версии расширений и обновляет `index.json`; при ошибке на диск ничего не пишется. */
|
|
1644
|
+
const buildCatalog = async (options) => {
|
|
1645
|
+
const out = path.resolve(options.out);
|
|
1646
|
+
const now = (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
|
|
1647
|
+
const publishedAt = options.publishedAt ?? now;
|
|
1648
|
+
const sourceBase = options.sourceBase ?? "https://github.com/dolphy-app/dolphy-extensions/tree/main/extensions";
|
|
1649
|
+
const previous = await loadIndexFile(path.resolve(options.previousIndex ?? path.join(out, "index.json")));
|
|
1650
|
+
const revoked = await loadRevoked(options.revoked, previous);
|
|
1651
|
+
const scratch = await mkdtemp(path.join(tmpdir(), "dolphy-catalog-"));
|
|
1652
|
+
try {
|
|
1653
|
+
const plans = [];
|
|
1654
|
+
const entries = new Map((previous?.extensions ?? []).map((entry) => [entry.id, entry]));
|
|
1655
|
+
for (const id of options.ids) {
|
|
1656
|
+
const staged = await stage(options, id, scratch);
|
|
1657
|
+
requirePublication(staged);
|
|
1658
|
+
const before = entries.get(id);
|
|
1659
|
+
const item = await plan(staged, out, before, publishedAt);
|
|
1660
|
+
plans.push(item);
|
|
1661
|
+
entries.set(id, entryOf(item, before, sourceBase));
|
|
1662
|
+
}
|
|
1663
|
+
const index = assembleIndex({
|
|
1664
|
+
generatedAt: now,
|
|
1665
|
+
extensions: [...entries.values()],
|
|
1666
|
+
revoked
|
|
1667
|
+
});
|
|
1668
|
+
for (const item of plans) if (item.shouldWrite) await writeVersion(item, out);
|
|
1669
|
+
await mkdir(out, { recursive: true });
|
|
1670
|
+
const indexFile = path.join(out, INDEX_FILE);
|
|
1671
|
+
if (!hasSameContent(await loadIndexFile(indexFile), index)) await writeIndexAtomically(indexFile, index);
|
|
1672
|
+
return plans.map(resultOf);
|
|
1673
|
+
} finally {
|
|
1674
|
+
await rm(scratch, {
|
|
1675
|
+
recursive: true,
|
|
1676
|
+
force: true
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
const formatReindexResult = (result) => `reindexed (${result.extensions} extensions, ${result.revoked} revoked)${result.changed ? "" : " — no changes"}`;
|
|
1681
|
+
/** Перезаписывает `revoked` и `generatedAt` существующего индекса; записи расширений не меняются. */
|
|
1682
|
+
const reindexCatalog = async (options) => {
|
|
1683
|
+
const out = path.resolve(options.out);
|
|
1684
|
+
const source = path.resolve(options.previousIndex ?? path.join(out, "index.json"));
|
|
1685
|
+
const previous = await loadIndexFile(source);
|
|
1686
|
+
if (previous === null) throw new CatalogUsageError(`nothing to reindex: ${source} does not exist`, source);
|
|
1687
|
+
const index = assembleIndex({
|
|
1688
|
+
generatedAt: options.publishedAt ?? (options.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
1689
|
+
extensions: previous.extensions,
|
|
1690
|
+
revoked: await loadRevoked(options.revoked, previous)
|
|
1691
|
+
});
|
|
1692
|
+
const indexFile = path.join(out, INDEX_FILE);
|
|
1693
|
+
const changed = !hasSameContent(await loadIndexFile(indexFile), index);
|
|
1694
|
+
if (changed) {
|
|
1695
|
+
await mkdir(out, { recursive: true });
|
|
1696
|
+
await writeIndexAtomically(indexFile, index);
|
|
1697
|
+
}
|
|
1698
|
+
return {
|
|
1699
|
+
extensions: index.extensions.length,
|
|
1700
|
+
revoked: index.revoked.length,
|
|
1701
|
+
changed
|
|
1702
|
+
};
|
|
1703
|
+
};
|
|
1704
|
+
|
|
1705
|
+
//#endregion
|
|
1706
|
+
//#region packages/extension-tools/src/catalog/cli.ts
|
|
1707
|
+
const CATALOG_SYNOPSIS = ` dolphy-ext catalog check <extensionsDir> [--ids a,b]
|
|
1708
|
+
[--published-index <path>] [--max-app-version <x.y.z>]
|
|
1709
|
+
[--skip-github-check] [--list-rules]
|
|
1710
|
+
dolphy-ext catalog build --src <extensionsDir> --ids a,b --out <siteDir>
|
|
1711
|
+
[--previous-index <path>] [--revoked <path>]
|
|
1712
|
+
[--source-base <url>] [--published-at <iso>]
|
|
1713
|
+
dolphy-ext catalog build --reindex --out <siteDir>
|
|
1714
|
+
[--previous-index <path>] [--revoked <path>]
|
|
1715
|
+
[--published-at <iso>]
|
|
1716
|
+
`;
|
|
1717
|
+
const CATALOG_HELP = ` catalog check проверить исходники расширений каталога; строки
|
|
1718
|
+
«error|warning <id> <RULE-ID> <поле>: <сообщение>»,
|
|
1719
|
+
код 1 при наличии error
|
|
1720
|
+
catalog build собрать версии в <siteDir>/extensions/<id>/<version>/ и
|
|
1721
|
+
обновить <siteDir>/index.json (опубликованные версии
|
|
1722
|
+
неизменны)
|
|
1723
|
+
catalog build --reindex
|
|
1724
|
+
только заменить revoked и generatedAt в существующем
|
|
1725
|
+
<siteDir>/index.json (--src и --ids не нужны)
|
|
1726
|
+
|
|
1727
|
+
--ids a,b только эти расширения (check: по умолчанию все)
|
|
1728
|
+
--published-index <p> index.json опубликованного каталога (нет файла —
|
|
1729
|
+
ничего не опубликовано)
|
|
1730
|
+
--max-app-version <v> minAppVersion не должен быть новее
|
|
1731
|
+
--skip-github-check не проверять автора через api.github.com
|
|
1732
|
+
(токен — переменная GITHUB_TOKEN)
|
|
1733
|
+
--list-rules вывести правила check
|
|
1734
|
+
--src <dir> каталог с проектами <dir>/<id>
|
|
1735
|
+
--out <dir> корень сайта
|
|
1736
|
+
--previous-index <p> исходный индекс (по умолчанию <out>/index.json)
|
|
1737
|
+
--revoked <p> JSON-массив {id, versions, reason}
|
|
1738
|
+
--source-base <url> основа поля source записи индекса
|
|
1739
|
+
--published-at <iso> publishedAt новых версий (по умолчанию сейчас);
|
|
1740
|
+
с --reindex — generatedAt
|
|
1741
|
+
`;
|
|
1742
|
+
const VALUE_FLAGS = {
|
|
1743
|
+
check: [
|
|
1744
|
+
"--ids",
|
|
1745
|
+
"--published-index",
|
|
1746
|
+
"--max-app-version"
|
|
1747
|
+
],
|
|
1748
|
+
build: [
|
|
1749
|
+
"--src",
|
|
1750
|
+
"--ids",
|
|
1751
|
+
"--out",
|
|
1752
|
+
"--previous-index",
|
|
1753
|
+
"--revoked",
|
|
1754
|
+
"--source-base",
|
|
1755
|
+
"--published-at"
|
|
1756
|
+
]
|
|
1757
|
+
};
|
|
1758
|
+
const SWITCHES = {
|
|
1759
|
+
check: ["--skip-github-check", "--list-rules"],
|
|
1760
|
+
build: ["--reindex"]
|
|
1761
|
+
};
|
|
1762
|
+
const readFlags = (mode, args) => {
|
|
1763
|
+
const flags = {};
|
|
1764
|
+
const positional = [];
|
|
1765
|
+
const valued = VALUE_FLAGS[mode];
|
|
1766
|
+
const switches = SWITCHES[mode];
|
|
1767
|
+
for (let i = 0; i < args.length; i++) {
|
|
1768
|
+
const arg = args[i];
|
|
1769
|
+
if (switches.includes(arg)) flags[arg] = true;
|
|
1770
|
+
else if (valued.includes(arg)) {
|
|
1771
|
+
const value = args[++i];
|
|
1772
|
+
if (value === void 0) return `${arg} требует значение`;
|
|
1773
|
+
flags[arg] = value;
|
|
1774
|
+
} else if (arg.startsWith("-")) return `неизвестный флаг: ${arg}`;
|
|
1775
|
+
else positional.push(arg);
|
|
1776
|
+
}
|
|
1777
|
+
return {
|
|
1778
|
+
flags,
|
|
1779
|
+
positional
|
|
1780
|
+
};
|
|
1781
|
+
};
|
|
1782
|
+
const text = (flags, name) => {
|
|
1783
|
+
const value = flags[name];
|
|
1784
|
+
return typeof value === "string" ? value : void 0;
|
|
1785
|
+
};
|
|
1786
|
+
const idsOf = (flags) => {
|
|
1787
|
+
const value = text(flags, "--ids");
|
|
1788
|
+
if (value === void 0) return value;
|
|
1789
|
+
return [...new Set(value.split(",").map((id) => id.trim()).filter(Boolean))];
|
|
1790
|
+
};
|
|
1791
|
+
const parseCheck = (args) => {
|
|
1792
|
+
const read = readFlags("check", args);
|
|
1793
|
+
if (typeof read === "string") return { usageError: read };
|
|
1794
|
+
const { flags, positional } = read;
|
|
1795
|
+
const maxAppVersion = text(flags, "--max-app-version");
|
|
1796
|
+
if (maxAppVersion !== void 0 && !isSemver(maxAppVersion)) return { usageError: "--max-app-version: нужен semver x.y.z" };
|
|
1797
|
+
const listRulesFlag = flags["--list-rules"] === true;
|
|
1798
|
+
if (positional.length > 1) return { usageError: `лишние аргументы: ${positional.slice(1).join(" ")}` };
|
|
1799
|
+
if (positional[0] === void 0 && !listRulesFlag) return { usageError: "catalog check: не указан каталог расширений" };
|
|
1800
|
+
return {
|
|
1801
|
+
command: "check",
|
|
1802
|
+
dir: positional[0] ?? null,
|
|
1803
|
+
ids: idsOf(flags),
|
|
1804
|
+
publishedIndex: text(flags, "--published-index"),
|
|
1805
|
+
maxAppVersion,
|
|
1806
|
+
skipGithubCheck: flags["--skip-github-check"] === true,
|
|
1807
|
+
listRules: listRulesFlag
|
|
1808
|
+
};
|
|
1809
|
+
};
|
|
1810
|
+
const REINDEX_FORBIDDEN = [
|
|
1811
|
+
"--src",
|
|
1812
|
+
"--ids",
|
|
1813
|
+
"--source-base"
|
|
1814
|
+
];
|
|
1815
|
+
const parseReindex = (flags) => {
|
|
1816
|
+
const forbidden = REINDEX_FORBIDDEN.find((name) => name in flags);
|
|
1817
|
+
if (forbidden !== void 0) return { usageError: `catalog build --reindex: ${forbidden} не нужен` };
|
|
1818
|
+
const out = text(flags, "--out");
|
|
1819
|
+
if (out === void 0) return { usageError: "catalog build: нужен --out" };
|
|
1820
|
+
return {
|
|
1821
|
+
command: "reindex",
|
|
1822
|
+
out,
|
|
1823
|
+
previousIndex: text(flags, "--previous-index"),
|
|
1824
|
+
revoked: text(flags, "--revoked"),
|
|
1825
|
+
publishedAt: text(flags, "--published-at")
|
|
1826
|
+
};
|
|
1827
|
+
};
|
|
1828
|
+
const parseBuild$1 = (args) => {
|
|
1829
|
+
const read = readFlags("build", args);
|
|
1830
|
+
if (typeof read === "string") return { usageError: read };
|
|
1831
|
+
const { flags, positional } = read;
|
|
1832
|
+
if (positional.length > 0) return { usageError: `лишние аргументы: ${positional.join(" ")}` };
|
|
1833
|
+
if (flags["--reindex"] === true) return parseReindex(flags);
|
|
1834
|
+
const src = text(flags, "--src");
|
|
1835
|
+
const out = text(flags, "--out");
|
|
1836
|
+
const ids = idsOf(flags);
|
|
1837
|
+
if (src === void 0) return { usageError: "catalog build: нужен --src" };
|
|
1838
|
+
if (out === void 0) return { usageError: "catalog build: нужен --out" };
|
|
1839
|
+
if (ids === void 0 || ids.length === 0) return { usageError: "catalog build: нужен --ids" };
|
|
1840
|
+
return {
|
|
1841
|
+
command: "build",
|
|
1842
|
+
src,
|
|
1843
|
+
ids,
|
|
1844
|
+
out,
|
|
1845
|
+
previousIndex: text(flags, "--previous-index"),
|
|
1846
|
+
revoked: text(flags, "--revoked"),
|
|
1847
|
+
sourceBase: text(flags, "--source-base"),
|
|
1848
|
+
publishedAt: text(flags, "--published-at")
|
|
1849
|
+
};
|
|
1850
|
+
};
|
|
1851
|
+
const parseCatalogArgs = (args) => {
|
|
1852
|
+
const [sub, ...rest] = args;
|
|
1853
|
+
if (sub === "check") return parseCheck(rest);
|
|
1854
|
+
if (sub === "build") return parseBuild$1(rest);
|
|
1855
|
+
return { usageError: sub === void 0 ? "catalog: не указана подкоманда (check|build)" : `catalog: неизвестная подкоманда: ${sub}` };
|
|
1856
|
+
};
|
|
1857
|
+
const runCheck = async (parsed, io, deps) => {
|
|
1858
|
+
if (parsed.listRules) {
|
|
1859
|
+
for (const line of listRules()) io.stdout(`${line}\n`);
|
|
1860
|
+
return 0;
|
|
1861
|
+
}
|
|
1862
|
+
const findings = await checkCatalog({
|
|
1863
|
+
extensionsDir: path.resolve(parsed.dir),
|
|
1864
|
+
...parsed.ids === void 0 ? {} : { ids: parsed.ids },
|
|
1865
|
+
...parsed.publishedIndex === void 0 ? {} : { publishedIndex: path.resolve(parsed.publishedIndex) },
|
|
1866
|
+
...parsed.maxAppVersion === void 0 ? {} : { maxAppVersion: parsed.maxAppVersion },
|
|
1867
|
+
skipGithubCheck: parsed.skipGithubCheck,
|
|
1868
|
+
githubToken: deps.env?.GITHUB_TOKEN,
|
|
1869
|
+
...deps.fetch === void 0 ? {} : { fetch: deps.fetch }
|
|
1870
|
+
});
|
|
1871
|
+
for (const finding of findings) io.stdout(`${formatFinding(finding)}\n`);
|
|
1872
|
+
return hasErrors(findings) ? 1 : 0;
|
|
1873
|
+
};
|
|
1874
|
+
const runReindex = async (parsed, io, deps) => {
|
|
1875
|
+
const result = await reindexCatalog({
|
|
1876
|
+
out: parsed.out,
|
|
1877
|
+
...parsed.previousIndex === void 0 ? {} : { previousIndex: parsed.previousIndex },
|
|
1878
|
+
...parsed.revoked === void 0 ? {} : { revoked: parsed.revoked },
|
|
1879
|
+
...parsed.publishedAt === void 0 ? {} : { publishedAt: parsed.publishedAt },
|
|
1880
|
+
...deps.now === void 0 ? {} : { now: deps.now }
|
|
1881
|
+
});
|
|
1882
|
+
io.stdout(`${formatReindexResult(result)}\n`);
|
|
1883
|
+
return 0;
|
|
1884
|
+
};
|
|
1885
|
+
const runBuild$1 = async (parsed, io, deps) => {
|
|
1886
|
+
const results = await buildCatalog({
|
|
1887
|
+
src: parsed.src,
|
|
1888
|
+
ids: parsed.ids,
|
|
1889
|
+
out: parsed.out,
|
|
1890
|
+
...parsed.previousIndex === void 0 ? {} : { previousIndex: parsed.previousIndex },
|
|
1891
|
+
...parsed.revoked === void 0 ? {} : { revoked: parsed.revoked },
|
|
1892
|
+
...parsed.sourceBase === void 0 ? {} : { sourceBase: parsed.sourceBase },
|
|
1893
|
+
...parsed.publishedAt === void 0 ? {} : { publishedAt: parsed.publishedAt },
|
|
1894
|
+
...deps.now === void 0 ? {} : { now: deps.now }
|
|
1895
|
+
});
|
|
1896
|
+
for (const result of results) io.stdout(`${formatPublishResult(result)}\n`);
|
|
1897
|
+
return 0;
|
|
1898
|
+
};
|
|
1899
|
+
/** Код выхода: 0, 1 (проблемы), 2 (аргументы/окружение). */
|
|
1900
|
+
const runCatalog = async (parsed, io, deps = {}) => {
|
|
1901
|
+
try {
|
|
1902
|
+
if (parsed.command === "check") return await runCheck(parsed, io, deps);
|
|
1903
|
+
if (parsed.command === "reindex") return await runReindex(parsed, io, deps);
|
|
1904
|
+
return await runBuild$1(parsed, io, deps);
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
if (!(error instanceof BuildError)) throw error;
|
|
1907
|
+
io.stderr(`error ${error.subject}: ${error.message}\n`);
|
|
1908
|
+
return error instanceof CatalogUsageError ? 2 : 1;
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
|
|
1912
|
+
//#endregion
|
|
1913
|
+
//#region packages/extension-tools/src/cli/run.ts
|
|
1914
|
+
const EXIT_OK = 0;
|
|
1915
|
+
const EXIT_PROBLEMS = 1;
|
|
1916
|
+
const EXIT_USAGE = 2;
|
|
1917
|
+
const USAGE = `usage: dolphy-ext build [dir] [--out <dir>] [--watch]
|
|
1918
|
+
dolphy-ext validate <dir>
|
|
1919
|
+
${CATALOG_SYNOPSIS}
|
|
1920
|
+
build [dir] собрать расширение из проекта (по умолчанию — текущий каталог)
|
|
1921
|
+
в <dir>/dist-ext/<id>
|
|
1922
|
+
validate <dir> проверить каталог собранного расширения (extension.json,
|
|
1923
|
+
схемы, main и renderer)
|
|
1924
|
+
${CATALOG_HELP}
|
|
1925
|
+
--out <dir> корень вывода (расширение кладётся в <dir>/<id>)
|
|
1926
|
+
--watch пересобирать бандлы при изменении исходников
|
|
1927
|
+
--help эта справка
|
|
1928
|
+
`;
|
|
1929
|
+
const parseBuild = (args) => {
|
|
1930
|
+
const positional = [];
|
|
1931
|
+
let out;
|
|
1932
|
+
let watch = false;
|
|
1933
|
+
for (let i = 0; i < args.length; i++) {
|
|
1934
|
+
const arg = args[i];
|
|
1935
|
+
if (arg === "--watch") watch = true;
|
|
1936
|
+
else if (arg === "--out") {
|
|
1937
|
+
out = args[++i];
|
|
1938
|
+
if (out === void 0) return { usageError: "--out требует путь" };
|
|
1939
|
+
} else if (arg.startsWith("-")) return { usageError: `неизвестный флаг: ${arg}` };
|
|
1940
|
+
else positional.push(arg);
|
|
1941
|
+
}
|
|
1942
|
+
if (positional.length > 1) return { usageError: `лишние аргументы: ${positional.slice(1).join(" ")}` };
|
|
1943
|
+
return {
|
|
1944
|
+
command: "build",
|
|
1945
|
+
dir: positional[0] ?? ".",
|
|
1946
|
+
out,
|
|
1947
|
+
watch
|
|
1948
|
+
};
|
|
1949
|
+
};
|
|
1950
|
+
const parseValidate = (args) => {
|
|
1951
|
+
const flag = args.find((arg) => arg.startsWith("-"));
|
|
1952
|
+
if (flag !== void 0) return { usageError: `неизвестный флаг: ${flag}` };
|
|
1953
|
+
const [dir, ...extra] = args;
|
|
1954
|
+
if (dir === void 0) return { usageError: "validate: не указан каталог" };
|
|
1955
|
+
if (extra.length > 0) return { usageError: `лишние аргументы: ${extra.join(" ")}` };
|
|
1956
|
+
return {
|
|
1957
|
+
command: "validate",
|
|
1958
|
+
dir
|
|
1959
|
+
};
|
|
1960
|
+
};
|
|
1961
|
+
const parseArgs = (argv) => {
|
|
1962
|
+
if (argv.includes("--help") || argv.includes("-h")) return { help: true };
|
|
1963
|
+
const [command, ...rest] = argv;
|
|
1964
|
+
if (command === "build") return parseBuild(rest);
|
|
1965
|
+
if (command === "validate") return parseValidate(rest);
|
|
1966
|
+
if (command === "catalog") return {
|
|
1967
|
+
command: "catalog",
|
|
1968
|
+
args: rest
|
|
1969
|
+
};
|
|
1970
|
+
return { usageError: command === void 0 ? "не указана команда" : `неизвестная команда: ${command}` };
|
|
1971
|
+
};
|
|
1972
|
+
const waitForSignal = () => new Promise((resolve) => {
|
|
1973
|
+
process.once("SIGINT", resolve);
|
|
1974
|
+
process.once("SIGTERM", resolve);
|
|
1975
|
+
});
|
|
1976
|
+
const summary = ({ id, dir, files }) => `built ${id} -> ${dir} (${files.length} files)\n`;
|
|
1977
|
+
const reportBuildError = (io, error) => {
|
|
1978
|
+
if (!(error instanceof BuildError)) throw error;
|
|
1979
|
+
io.stderr(`error ${error.subject}: ${error.message}\n`);
|
|
1980
|
+
return 1;
|
|
1981
|
+
};
|
|
1982
|
+
const runBuild = async (parsed, io, deps) => {
|
|
1983
|
+
const options = {
|
|
1984
|
+
root: path.resolve(parsed.dir),
|
|
1985
|
+
...parsed.out === void 0 ? {} : { outDir: path.resolve(parsed.out) },
|
|
1986
|
+
logger: {
|
|
1987
|
+
info: (message) => io.stdout(`${message}\n`),
|
|
1988
|
+
error: (message) => io.stderr(`${message}\n`)
|
|
1989
|
+
}
|
|
1990
|
+
};
|
|
1991
|
+
try {
|
|
1992
|
+
if (!parsed.watch) {
|
|
1993
|
+
io.stdout(summary(await buildExtension(options)));
|
|
1994
|
+
return 0;
|
|
1995
|
+
}
|
|
1996
|
+
const handle = await watchExtension(options);
|
|
1997
|
+
io.stdout(summary(handle.result));
|
|
1998
|
+
io.stdout("watching for changes...\n");
|
|
1999
|
+
try {
|
|
2000
|
+
await (deps.waitForExit ?? waitForSignal)();
|
|
2001
|
+
} finally {
|
|
2002
|
+
await handle.close();
|
|
2003
|
+
}
|
|
2004
|
+
return 0;
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
return reportBuildError(io, error);
|
|
2007
|
+
}
|
|
2008
|
+
};
|
|
2009
|
+
const runValidate = async (dir, io) => {
|
|
2010
|
+
const root = path.resolve(dir);
|
|
2011
|
+
const { ok, problems } = await validateExtension(root);
|
|
2012
|
+
if (ok) {
|
|
2013
|
+
io.stdout(`${root}: ok\n`);
|
|
2014
|
+
return 0;
|
|
2015
|
+
}
|
|
2016
|
+
for (const problem of problems) io.stderr(`error ${root}: ${problem}\n`);
|
|
2017
|
+
return 1;
|
|
2018
|
+
};
|
|
2019
|
+
/** `dolphy-ext build|validate|catalog`; `argv` без `node` и имени скрипта. */
|
|
2020
|
+
const runCli = async (argv, io, deps = {}) => {
|
|
2021
|
+
const parsed = parseArgs(argv);
|
|
2022
|
+
if ("help" in parsed) {
|
|
2023
|
+
io.stdout(USAGE);
|
|
2024
|
+
return 0;
|
|
2025
|
+
}
|
|
2026
|
+
if ("usageError" in parsed) {
|
|
2027
|
+
io.stderr(`${parsed.usageError}\n${USAGE}`);
|
|
2028
|
+
return 2;
|
|
2029
|
+
}
|
|
2030
|
+
if (parsed.command === "catalog") {
|
|
2031
|
+
const catalog = parseCatalogArgs(parsed.args);
|
|
2032
|
+
if ("usageError" in catalog) {
|
|
2033
|
+
io.stderr(`${catalog.usageError}\n${USAGE}`);
|
|
2034
|
+
return 2;
|
|
2035
|
+
}
|
|
2036
|
+
return runCatalog(catalog, io, deps);
|
|
2037
|
+
}
|
|
2038
|
+
if (parsed.command === "validate") return runValidate(parsed.dir, io);
|
|
2039
|
+
return runBuild(parsed, io, deps);
|
|
2040
|
+
};
|
|
2041
|
+
|
|
2042
|
+
//#endregion
|
|
2043
|
+
//#region packages/extension-tools/src/cli/main.ts
|
|
2044
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
2045
|
+
stdout: (text) => process.stdout.write(text),
|
|
2046
|
+
stderr: (text) => process.stderr.write(text)
|
|
2047
|
+
}, { env: process.env });
|
|
2048
|
+
|
|
2049
|
+
//#endregion
|
|
2050
|
+
export { };
|