@notegen/plugin-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +349 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +3 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +398 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/lib/archive.d.ts +17 -0
- package/dist/lib/archive.js +257 -0
- package/dist/lib/constants.d.ts +16 -0
- package/dist/lib/constants.js +16 -0
- package/dist/lib/diagnostics.d.ts +25 -0
- package/dist/lib/diagnostics.js +49 -0
- package/dist/lib/files.d.ts +30 -0
- package/dist/lib/files.js +396 -0
- package/dist/lib/integrity.d.ts +20 -0
- package/dist/lib/integrity.js +162 -0
- package/dist/lib/manifest.d.ts +13 -0
- package/dist/lib/manifest.js +645 -0
- package/dist/lib/package.d.ts +21 -0
- package/dist/lib/package.js +229 -0
- package/dist/lib/path-rules.d.ts +23 -0
- package/dist/lib/path-rules.js +153 -0
- package/dist/lib/project.d.ts +21 -0
- package/dist/lib/project.js +209 -0
- package/dist/lib/scaffold.d.ts +22 -0
- package/dist/lib/scaffold.js +230 -0
- package/dist/lib/signing.d.ts +26 -0
- package/dist/lib/signing.js +181 -0
- package/dist/lib/strict-json.d.ts +26 -0
- package/dist/lib/strict-json.js +210 -0
- package/dist/lib/tasks.d.ts +70 -0
- package/dist/lib/tasks.js +241 -0
- package/dist/lib/watch.d.ts +6 -0
- package/dist/lib/watch.js +63 -0
- package/package.json +64 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { decodePackageSignature, assertPackageSignature } from './signing.js';
|
|
4
|
+
import { MAX_ARCHIVE_ENTRIES, MAX_ENTRY_BYTES, MAX_UNCOMPRESSED_BYTES, } from './constants.js';
|
|
5
|
+
import { MAX_SIGNATURE_FILE_BYTES, parseIntegrityManifest, } from './integrity.js';
|
|
6
|
+
import { parsePluginManifest } from './manifest.js';
|
|
7
|
+
import { parseStrictJson, isJsonObject } from './strict-json.js';
|
|
8
|
+
import { assertDirectory, assertInside } from './files.js';
|
|
9
|
+
import { diagnostic, DiagnosticError, fail } from './diagnostics.js';
|
|
10
|
+
import { assertUniquePackagePaths, validatePackagePath } from './path-rules.js';
|
|
11
|
+
function requiredFile(files, path) {
|
|
12
|
+
const bytes = files.get(path);
|
|
13
|
+
if (!bytes)
|
|
14
|
+
fail('package.missing-file', `Plugin package is missing ${path}`, path);
|
|
15
|
+
return bytes;
|
|
16
|
+
}
|
|
17
|
+
function decodeUtf8(bytes, path) {
|
|
18
|
+
try {
|
|
19
|
+
const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
20
|
+
if (value.includes('\0'))
|
|
21
|
+
fail('package.nul-byte', `${path} contains a NUL byte`, path);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error instanceof DiagnosticError)
|
|
26
|
+
throw error;
|
|
27
|
+
fail('package.invalid-utf8', `${path} must contain valid UTF-8`, path);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function validateOptionalPackageJson(files) {
|
|
31
|
+
const bytes = files.get('package.json');
|
|
32
|
+
if (!bytes)
|
|
33
|
+
return;
|
|
34
|
+
const value = parseStrictJson(bytes, 'package.json');
|
|
35
|
+
if (!isJsonObject(value))
|
|
36
|
+
return;
|
|
37
|
+
const scripts = value.scripts;
|
|
38
|
+
if (!isJsonObject(scripts))
|
|
39
|
+
return;
|
|
40
|
+
for (const name of ['preinstall', 'install', 'postinstall']) {
|
|
41
|
+
if (Object.hasOwn(scripts, name)) {
|
|
42
|
+
fail('package.install-script', `Plugin packages must not contain the ${name} lifecycle script`, `package.json#scripts.${name}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function validatePackageFiles(files, options = {}) {
|
|
47
|
+
const pluginBytes = requiredFile(files, 'plugin.json');
|
|
48
|
+
const integrityBytes = requiredFile(files, 'integrity.json');
|
|
49
|
+
const manifestValue = parseStrictJson(pluginBytes, 'plugin.json');
|
|
50
|
+
const integrityValue = parseStrictJson(integrityBytes, 'integrity.json');
|
|
51
|
+
const integrity = parseIntegrityManifest(integrityBytes, files);
|
|
52
|
+
const manifest = parsePluginManifest(pluginBytes, {
|
|
53
|
+
files,
|
|
54
|
+
...(options.apiVersion === undefined ? {} : { apiVersion: options.apiVersion }),
|
|
55
|
+
...(options.appVersion === undefined ? {} : { appVersion: options.appVersion }),
|
|
56
|
+
});
|
|
57
|
+
validateOptionalPackageJson(files);
|
|
58
|
+
const signatureBytes = files.get('signature.sig');
|
|
59
|
+
const signature = signatureBytes ? decodeUtf8(signatureBytes, 'signature.sig') : undefined;
|
|
60
|
+
if (signature !== undefined)
|
|
61
|
+
decodePackageSignature(signature);
|
|
62
|
+
if (options.requireSignature && signature === undefined) {
|
|
63
|
+
fail('signature.missing', 'Signed plugin package is missing signature.sig', 'signature.sig');
|
|
64
|
+
}
|
|
65
|
+
if (options.publicKey !== undefined && signature === undefined) {
|
|
66
|
+
fail('signature.missing', 'A public key was provided but the package has no signature.sig', 'signature.sig');
|
|
67
|
+
}
|
|
68
|
+
if (options.publicKey !== undefined && signature !== undefined) {
|
|
69
|
+
assertPackageSignature(manifestValue, integrityValue, signature, options.publicKey);
|
|
70
|
+
}
|
|
71
|
+
return Object.freeze({
|
|
72
|
+
manifest,
|
|
73
|
+
manifestValue,
|
|
74
|
+
integrity,
|
|
75
|
+
integrityValue,
|
|
76
|
+
files,
|
|
77
|
+
signature,
|
|
78
|
+
signatureVerified: options.publicKey !== undefined,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function readSafePackageFile(root, relativePath, maximumBytes = MAX_ENTRY_BYTES) {
|
|
82
|
+
const segments = relativePath.split('/');
|
|
83
|
+
let current = root;
|
|
84
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
85
|
+
current = join(current, segments[index] ?? '');
|
|
86
|
+
let metadata;
|
|
87
|
+
try {
|
|
88
|
+
metadata = await lstat(current);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
throw new DiagnosticError(diagnostic({
|
|
92
|
+
code: 'package.missing-file',
|
|
93
|
+
message: `Plugin package is missing ${relativePath}`,
|
|
94
|
+
path: current,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
if (metadata.isSymbolicLink()) {
|
|
98
|
+
throw new DiagnosticError(diagnostic({
|
|
99
|
+
code: 'package.symlink',
|
|
100
|
+
message: 'Plugin package paths cannot contain symbolic links',
|
|
101
|
+
path: current,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
const final = index === segments.length - 1;
|
|
105
|
+
if (final ? !metadata.isFile() : !metadata.isDirectory()) {
|
|
106
|
+
throw new DiagnosticError(diagnostic({
|
|
107
|
+
code: 'package.file-type',
|
|
108
|
+
message: final ? 'Package payload must be a regular file' : 'Package path parent must be a directory',
|
|
109
|
+
path: current,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
if (final && (metadata.mode & 0o111) !== 0) {
|
|
113
|
+
throw new DiagnosticError(diagnostic({
|
|
114
|
+
code: 'package.executable',
|
|
115
|
+
message: 'Executable package files are not allowed',
|
|
116
|
+
path: current,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
if (final && metadata.size > maximumBytes) {
|
|
120
|
+
throw new DiagnosticError(diagnostic({
|
|
121
|
+
code: 'package.file-too-large',
|
|
122
|
+
message: `Package file exceeds ${maximumBytes} bytes`,
|
|
123
|
+
path: current,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
assertInside(root, current, 'Package file');
|
|
128
|
+
const bytes = await readFile(current);
|
|
129
|
+
if (bytes.byteLength > maximumBytes) {
|
|
130
|
+
fail('package.file-too-large', `Package file exceeds ${maximumBytes} bytes`, current);
|
|
131
|
+
}
|
|
132
|
+
return bytes;
|
|
133
|
+
}
|
|
134
|
+
export async function readPackageDirectory(directory) {
|
|
135
|
+
const root = resolve(directory);
|
|
136
|
+
await assertDirectory(root, 'Plugin package directory');
|
|
137
|
+
const manifestBytes = await readSafePackageFile(root, 'plugin.json');
|
|
138
|
+
const integrityBytes = await readSafePackageFile(root, 'integrity.json');
|
|
139
|
+
const integrity = parseIntegrityManifest(integrityBytes);
|
|
140
|
+
const files = new Map([
|
|
141
|
+
['plugin.json', manifestBytes],
|
|
142
|
+
['integrity.json', integrityBytes],
|
|
143
|
+
]);
|
|
144
|
+
for (const item of integrity.files) {
|
|
145
|
+
if (!files.has(item.path)) {
|
|
146
|
+
files.set(item.path, await readSafePackageFile(root, item.path));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const signaturePath = join(root, 'signature.sig');
|
|
150
|
+
let hasSignature = false;
|
|
151
|
+
try {
|
|
152
|
+
await lstat(signaturePath);
|
|
153
|
+
hasSignature = true;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
const cause = error;
|
|
157
|
+
if (cause.code !== 'ENOENT') {
|
|
158
|
+
throw new DiagnosticError(diagnostic({
|
|
159
|
+
code: 'package.signature-inspection-failed',
|
|
160
|
+
message: 'Unable to inspect development plugin signature.sig',
|
|
161
|
+
path: signaturePath,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (hasSignature) {
|
|
166
|
+
files.set('signature.sig', await readSafePackageFile(root, 'signature.sig', MAX_SIGNATURE_FILE_BYTES));
|
|
167
|
+
}
|
|
168
|
+
return files;
|
|
169
|
+
}
|
|
170
|
+
/** Reads every entry in a complete package directory, unlike development import. */
|
|
171
|
+
export async function readCompletePackageDirectory(directory) {
|
|
172
|
+
const root = resolve(directory);
|
|
173
|
+
await assertDirectory(root, 'Complete plugin package directory');
|
|
174
|
+
const files = new Map();
|
|
175
|
+
const entries = [];
|
|
176
|
+
let totalBytes = 0;
|
|
177
|
+
const visit = async (relativeDirectory) => {
|
|
178
|
+
const current = relativeDirectory === ''
|
|
179
|
+
? root
|
|
180
|
+
: join(root, ...relativeDirectory.split('/'));
|
|
181
|
+
const names = await readdir(current);
|
|
182
|
+
names.sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
183
|
+
for (const name of names) {
|
|
184
|
+
const relativePath = relativeDirectory === '' ? name : `${relativeDirectory}/${name}`;
|
|
185
|
+
const path = join(current, name);
|
|
186
|
+
const metadata = await lstat(path);
|
|
187
|
+
if (metadata.isSymbolicLink()) {
|
|
188
|
+
fail('package.symlink', 'Complete package directories cannot contain symbolic links', path);
|
|
189
|
+
}
|
|
190
|
+
const directoryEntry = metadata.isDirectory();
|
|
191
|
+
if (!directoryEntry && !metadata.isFile()) {
|
|
192
|
+
fail('package.file-type', 'Complete package directories cannot contain special files', path);
|
|
193
|
+
}
|
|
194
|
+
const packagePath = validatePackagePath(relativePath, {
|
|
195
|
+
directory: directoryEntry,
|
|
196
|
+
label: relativePath,
|
|
197
|
+
});
|
|
198
|
+
entries.push({ path: packagePath, directory: directoryEntry });
|
|
199
|
+
if (entries.length > MAX_ARCHIVE_ENTRIES) {
|
|
200
|
+
fail('package.too-many-entries', `Package exceeds the ${MAX_ARCHIVE_ENTRIES}-entry limit`, path);
|
|
201
|
+
}
|
|
202
|
+
assertUniquePackagePaths(entries);
|
|
203
|
+
if (directoryEntry) {
|
|
204
|
+
await visit(packagePath);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if ((metadata.mode & 0o111) !== 0) {
|
|
208
|
+
fail('package.executable', 'Executable package files are not allowed', path);
|
|
209
|
+
}
|
|
210
|
+
const maximum = packagePath === 'signature.sig'
|
|
211
|
+
? MAX_SIGNATURE_FILE_BYTES
|
|
212
|
+
: MAX_ENTRY_BYTES;
|
|
213
|
+
if (metadata.size > maximum) {
|
|
214
|
+
fail('package.file-too-large', `Package file exceeds ${maximum} bytes`, path);
|
|
215
|
+
}
|
|
216
|
+
const bytes = await readFile(path);
|
|
217
|
+
if (bytes.byteLength !== metadata.size || bytes.byteLength > maximum) {
|
|
218
|
+
fail('package.changed', 'Package file changed while it was being read', path);
|
|
219
|
+
}
|
|
220
|
+
totalBytes += bytes.byteLength;
|
|
221
|
+
if (totalBytes > MAX_UNCOMPRESSED_BYTES) {
|
|
222
|
+
fail('package.too-large', 'Package exceeds the 50 MiB uncompressed limit', path);
|
|
223
|
+
}
|
|
224
|
+
files.set(packagePath, bytes);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
await visit('');
|
|
228
|
+
return files;
|
|
229
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const MAX_PACKAGE_PATH_BYTES = 1024;
|
|
2
|
+
export declare const MAX_PACKAGE_SEGMENT_BYTES = 240;
|
|
3
|
+
export declare const MAX_PACKAGE_PATH_DEPTH = 12;
|
|
4
|
+
export interface PackagePathOptions {
|
|
5
|
+
readonly directory?: boolean;
|
|
6
|
+
readonly label?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface PackagePathEntry {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly directory?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function utf8ByteLength(value: string): number;
|
|
13
|
+
export declare function hasControlCharacter(value: string): boolean;
|
|
14
|
+
export declare function packagePathCollisionKey(path: string): string;
|
|
15
|
+
export declare function isForbiddenPackageFilePath(path: string): boolean;
|
|
16
|
+
/** Validates and returns the canonical forward-slash package path. */
|
|
17
|
+
export declare function validatePackagePath(raw: string, options?: PackagePathOptions): string;
|
|
18
|
+
/**
|
|
19
|
+
* Enforces exact and case/NFC collision rules as well as the file-as-parent
|
|
20
|
+
* prohibition used by the desktop host.
|
|
21
|
+
*/
|
|
22
|
+
export declare function assertUniquePackagePaths(entries: readonly PackagePathEntry[]): void;
|
|
23
|
+
export declare function countPackageEntries(filePaths: readonly string[]): number;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { fail } from './diagnostics.js';
|
|
2
|
+
export const MAX_PACKAGE_PATH_BYTES = 1_024;
|
|
3
|
+
export const MAX_PACKAGE_SEGMENT_BYTES = 240;
|
|
4
|
+
export const MAX_PACKAGE_PATH_DEPTH = 12;
|
|
5
|
+
const RESERVED_SEGMENTS = new Set([
|
|
6
|
+
'.notegen',
|
|
7
|
+
'node_modules',
|
|
8
|
+
'.git',
|
|
9
|
+
'.hg',
|
|
10
|
+
'.svn',
|
|
11
|
+
'.cache',
|
|
12
|
+
]);
|
|
13
|
+
const FORBIDDEN_FILE_SUFFIXES = Object.freeze([
|
|
14
|
+
'.exe', '.dll', '.dylib', '.so', '.node', '.wasm', '.msi', '.dmg', '.pkg',
|
|
15
|
+
'.deb', '.rpm', '.apk', '.ipa', '.app', '.jar', '.class', '.bat', '.cmd',
|
|
16
|
+
'.ps1', '.sh', '.map', '.pem', '.p12', '.pfx',
|
|
17
|
+
]);
|
|
18
|
+
const INVALID_PORTABLE_CHARACTERS = /[<>:"|?*]/u;
|
|
19
|
+
const CONTROL_CHARACTER = /\p{Cc}/u;
|
|
20
|
+
export function utf8ByteLength(value) {
|
|
21
|
+
return Buffer.byteLength(value, 'utf8');
|
|
22
|
+
}
|
|
23
|
+
export function hasControlCharacter(value) {
|
|
24
|
+
return CONTROL_CHARACTER.test(value);
|
|
25
|
+
}
|
|
26
|
+
function asciiUppercase(value) {
|
|
27
|
+
return value.replace(/[a-z]/g, (character) => character.toUpperCase());
|
|
28
|
+
}
|
|
29
|
+
function isWindowsReservedName(segment) {
|
|
30
|
+
const stem = asciiUppercase(segment.replace(/[. ]+$/u, '').split('.')[0] ?? '');
|
|
31
|
+
return stem === 'CON'
|
|
32
|
+
|| stem === 'PRN'
|
|
33
|
+
|| stem === 'AUX'
|
|
34
|
+
|| stem === 'NUL'
|
|
35
|
+
|| stem === 'CLOCK$'
|
|
36
|
+
|| stem === 'CONIN$'
|
|
37
|
+
|| stem === 'CONOUT$'
|
|
38
|
+
|| /^(?:COM|LPT)(?:[1-9¹²³])$(?![\s\S])/u.test(stem);
|
|
39
|
+
}
|
|
40
|
+
export function packagePathCollisionKey(path) {
|
|
41
|
+
return path.toLowerCase().normalize('NFC');
|
|
42
|
+
}
|
|
43
|
+
export function isForbiddenPackageFilePath(path) {
|
|
44
|
+
const lower = path.toLowerCase();
|
|
45
|
+
return FORBIDDEN_FILE_SUFFIXES.some((suffix) => lower.endsWith(suffix));
|
|
46
|
+
}
|
|
47
|
+
/** Validates and returns the canonical forward-slash package path. */
|
|
48
|
+
export function validatePackagePath(raw, options = {}) {
|
|
49
|
+
const label = options.label ?? 'package path';
|
|
50
|
+
const directory = options.directory === true;
|
|
51
|
+
if (raw.length === 0
|
|
52
|
+
|| raw.startsWith('/')
|
|
53
|
+
|| raw.startsWith('\\')
|
|
54
|
+
|| raw.includes('\\')
|
|
55
|
+
|| hasControlCharacter(raw)) {
|
|
56
|
+
fail('path.unsafe', `${label} is not a safe relative package path`, label);
|
|
57
|
+
}
|
|
58
|
+
let path = raw;
|
|
59
|
+
if (directory) {
|
|
60
|
+
if (path.endsWith('/'))
|
|
61
|
+
path = path.slice(0, -1);
|
|
62
|
+
}
|
|
63
|
+
else if (path.endsWith('/')) {
|
|
64
|
+
fail('path.file-trailing-slash', `${label} is a file path and must not end in /`, label);
|
|
65
|
+
}
|
|
66
|
+
if (path.length === 0
|
|
67
|
+
|| utf8ByteLength(path) > MAX_PACKAGE_PATH_BYTES
|
|
68
|
+
|| path.includes('//')) {
|
|
69
|
+
fail('path.unsafe', `${label} is empty, duplicated, or too long`, label);
|
|
70
|
+
}
|
|
71
|
+
const segments = path.split('/');
|
|
72
|
+
if (segments.length > MAX_PACKAGE_PATH_DEPTH) {
|
|
73
|
+
fail('path.too-deep', `${label} exceeds the ${MAX_PACKAGE_PATH_DEPTH}-segment nesting limit`, label);
|
|
74
|
+
}
|
|
75
|
+
for (const segment of segments) {
|
|
76
|
+
if (segment.length === 0
|
|
77
|
+
|| utf8ByteLength(segment) > MAX_PACKAGE_SEGMENT_BYTES
|
|
78
|
+
|| segment === '.'
|
|
79
|
+
|| segment === '..'
|
|
80
|
+
|| segment.normalize('NFC') !== segment
|
|
81
|
+
|| segment.endsWith('.')
|
|
82
|
+
|| segment.endsWith(' ')
|
|
83
|
+
|| INVALID_PORTABLE_CHARACTERS.test(segment)
|
|
84
|
+
|| isWindowsReservedName(segment)) {
|
|
85
|
+
fail('path.non-portable', `${label} contains a non-canonical or non-portable segment`, label);
|
|
86
|
+
}
|
|
87
|
+
const lower = segment.toLowerCase();
|
|
88
|
+
if (RESERVED_SEGMENTS.has(lower)
|
|
89
|
+
|| lower === '.env'
|
|
90
|
+
|| lower.startsWith('.env.')) {
|
|
91
|
+
fail('path.reserved', `${label} contains a reserved or sensitive segment`, label);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!directory && isForbiddenPackageFilePath(path)) {
|
|
95
|
+
fail('path.forbidden-file', `${label} has an executable, native, or sensitive suffix`, label);
|
|
96
|
+
}
|
|
97
|
+
return path;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Enforces exact and case/NFC collision rules as well as the file-as-parent
|
|
101
|
+
* prohibition used by the desktop host.
|
|
102
|
+
*/
|
|
103
|
+
export function assertUniquePackagePaths(entries) {
|
|
104
|
+
const exact = new Map();
|
|
105
|
+
const folded = new Map();
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const directory = entry.directory === true;
|
|
108
|
+
const path = validatePackagePath(entry.path, { directory, label: entry.path });
|
|
109
|
+
if (exact.has(path)) {
|
|
110
|
+
fail('path.duplicate', `Package contains the path more than once: ${path}`, path);
|
|
111
|
+
}
|
|
112
|
+
exact.set(path, directory);
|
|
113
|
+
// ZIPs need not contain directory entries. Check their implicit parents as
|
|
114
|
+
// well, otherwise A/x.json and a/y.json pass here but cannot be installed
|
|
115
|
+
// consistently on case-sensitive and case-insensitive filesystems.
|
|
116
|
+
const segments = path.split('/');
|
|
117
|
+
for (let index = 1; index <= segments.length; index += 1) {
|
|
118
|
+
const expandedPath = segments.slice(0, index).join('/');
|
|
119
|
+
const collisionKey = packagePathCollisionKey(expandedPath);
|
|
120
|
+
const previous = folded.get(collisionKey);
|
|
121
|
+
if (previous !== undefined && previous !== expandedPath) {
|
|
122
|
+
fail('path.case-collision', `Package paths collide by case or Unicode normalization: ${previous}, ${expandedPath}`, path);
|
|
123
|
+
}
|
|
124
|
+
folded.set(collisionKey, expandedPath);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const files = new Set([...exact.entries()]
|
|
128
|
+
.filter(([, directory]) => !directory)
|
|
129
|
+
.map(([path]) => path));
|
|
130
|
+
for (const [path, directory] of exact) {
|
|
131
|
+
const segments = path.split('/');
|
|
132
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
133
|
+
const parent = segments.slice(0, index).join('/');
|
|
134
|
+
if (files.has(parent)) {
|
|
135
|
+
fail('path.file-as-parent', `Package file is also used as a directory: ${parent}`, path);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (directory && files.has(path)) {
|
|
139
|
+
fail('path.file-directory-collision', `Package path is both a file and directory: ${path}`, path);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function countPackageEntries(filePaths) {
|
|
144
|
+
const directories = new Set();
|
|
145
|
+
for (const raw of filePaths) {
|
|
146
|
+
const path = validatePackagePath(raw, { label: raw });
|
|
147
|
+
const segments = path.split('/');
|
|
148
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
149
|
+
directories.add(segments.slice(0, index).join('/'));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return filePaths.length + directories.size;
|
|
153
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PluginManifestV1 } from '@notegen/plugin-api';
|
|
2
|
+
import { type ValidatedPluginPackage } from './package.js';
|
|
3
|
+
export interface BuildPluginProjectOptions {
|
|
4
|
+
readonly directory?: string;
|
|
5
|
+
readonly apiVersion?: string;
|
|
6
|
+
readonly appVersion?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface BuiltPluginProject {
|
|
9
|
+
readonly projectDirectory: string;
|
|
10
|
+
readonly outputDirectory: string;
|
|
11
|
+
readonly sourcePath: string;
|
|
12
|
+
readonly manifest: PluginManifestV1;
|
|
13
|
+
readonly package: ValidatedPluginPackage;
|
|
14
|
+
}
|
|
15
|
+
export interface ValidatedPluginProjectSource {
|
|
16
|
+
readonly projectDirectory: string;
|
|
17
|
+
readonly sourcePath: string;
|
|
18
|
+
readonly manifest: PluginManifestV1;
|
|
19
|
+
}
|
|
20
|
+
export declare function buildPluginProject(options?: BuildPluginProjectOptions): Promise<BuiltPluginProject>;
|
|
21
|
+
export declare function validatePluginProjectSource(options?: BuildPluginProjectOptions): Promise<ValidatedPluginProjectSource>;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { build as esbuild } from 'esbuild';
|
|
4
|
+
import { init, parse } from 'es-module-lexer';
|
|
5
|
+
import { DEVELOPMENT_OUTPUT_DIRECTORY } from './constants.js';
|
|
6
|
+
import { diagnostic, DiagnosticError, fail } from './diagnostics.js';
|
|
7
|
+
import { assertDirectory, assertInside, assertNoSymlinkComponents, assertRegularFile, replaceDirectoryAtomically, writeFileExclusive, } from './files.js';
|
|
8
|
+
import { createIntegrityManifest, serializeIntegrityManifest, } from './integrity.js';
|
|
9
|
+
import { parsePluginManifest } from './manifest.js';
|
|
10
|
+
import { validatePackageFiles } from './package.js';
|
|
11
|
+
import { validatePackagePath } from './path-rules.js';
|
|
12
|
+
import { isJsonObject, parseStrictJson } from './strict-json.js';
|
|
13
|
+
async function readProjectConfiguration(projectDirectory) {
|
|
14
|
+
const packageJsonPath = join(projectDirectory, 'package.json');
|
|
15
|
+
let value;
|
|
16
|
+
try {
|
|
17
|
+
value = parseStrictJson(await readFile(packageJsonPath), 'package.json');
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
const cause = error;
|
|
21
|
+
if (cause.code === 'ENOENT')
|
|
22
|
+
return { source: 'src/main.ts' };
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
if (!isJsonObject(value))
|
|
26
|
+
fail('project.invalid-package-json', 'package.json must be an object', packageJsonPath);
|
|
27
|
+
const notegen = value.notegen;
|
|
28
|
+
if (notegen === undefined)
|
|
29
|
+
return { source: 'src/main.ts' };
|
|
30
|
+
if (!isJsonObject(notegen)) {
|
|
31
|
+
fail('project.invalid-config', 'package.json notegen must be an object', 'package.json#notegen');
|
|
32
|
+
}
|
|
33
|
+
const unknown = Object.keys(notegen).find((key) => key !== 'source');
|
|
34
|
+
if (unknown !== undefined) {
|
|
35
|
+
fail('project.unknown-config', `Unknown NoteGen project option ${unknown}`, `package.json#notegen.${unknown}`);
|
|
36
|
+
}
|
|
37
|
+
if (typeof notegen.source !== 'string') {
|
|
38
|
+
fail('project.invalid-source', 'package.json notegen.source must be a string', 'package.json#notegen.source');
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
source: validatePackagePath(notegen.source, { label: 'package.json#notegen.source' }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function readProjectPayloadFile(projectDirectory, packagePath) {
|
|
45
|
+
const path = resolve(projectDirectory, ...packagePath.split('/'));
|
|
46
|
+
assertInside(projectDirectory, path, 'Project payload');
|
|
47
|
+
await assertNoSymlinkComponents(projectDirectory, path, 'Project payload');
|
|
48
|
+
await assertRegularFile(path, `Plugin payload ${packagePath}`);
|
|
49
|
+
return readFile(path);
|
|
50
|
+
}
|
|
51
|
+
async function collectUsageFiles(projectDirectory, files) {
|
|
52
|
+
const names = (await readdir(projectDirectory)).filter(name => /^USAGE(?:\.[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*)?\.md$/.test(name));
|
|
53
|
+
if (names.length > 50)
|
|
54
|
+
fail('project.usage-limit', 'At most 50 usage translations are allowed');
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
const path = join(projectDirectory, name);
|
|
57
|
+
await assertNoSymlinkComponents(projectDirectory, path, 'Usage guide');
|
|
58
|
+
if ((await stat(path)).size > 131_072)
|
|
59
|
+
fail('project.usage-limit', 'Usage guide exceeds 128 KiB', name);
|
|
60
|
+
const bytes = await readProjectPayloadFile(projectDirectory, name);
|
|
61
|
+
if (bytes.length > 131_072)
|
|
62
|
+
fail('project.usage-limit', 'Usage guide exceeds 128 KiB', name);
|
|
63
|
+
try {
|
|
64
|
+
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
fail('project.invalid-usage', 'Usage guide must be UTF-8 Markdown', name);
|
|
68
|
+
}
|
|
69
|
+
files.set(name, bytes);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function assertSelfContainedModule(source) {
|
|
73
|
+
const parsed = parse(source);
|
|
74
|
+
const imports = parsed[0];
|
|
75
|
+
const exports = parsed[1];
|
|
76
|
+
if (imports.some((item) => item.type !== 'import-meta')) {
|
|
77
|
+
fail('build.residual-import', 'Bundled entry still contains a static or dynamic module import', 'dist/main.js', 'Bundle every dependency into the entry; NoteGen does not resolve plugin imports at runtime.');
|
|
78
|
+
}
|
|
79
|
+
const names = new Set(exports.flatMap((item) => 'name' in item ? [item.name] : []));
|
|
80
|
+
if (!names.has('activate')) {
|
|
81
|
+
fail('build.missing-activate', 'Plugin entry must export a named activate function', 'dist/main.js');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function bundleEntry(sourcePath, entryPath) {
|
|
85
|
+
let result;
|
|
86
|
+
try {
|
|
87
|
+
result = await esbuild({
|
|
88
|
+
entryPoints: [sourcePath],
|
|
89
|
+
bundle: true,
|
|
90
|
+
write: false,
|
|
91
|
+
format: 'esm',
|
|
92
|
+
platform: 'neutral',
|
|
93
|
+
target: ['es2022'],
|
|
94
|
+
charset: 'utf8',
|
|
95
|
+
legalComments: 'none',
|
|
96
|
+
sourcemap: false,
|
|
97
|
+
treeShaking: true,
|
|
98
|
+
splitting: false,
|
|
99
|
+
mainFields: ['module', 'main'],
|
|
100
|
+
conditions: ['import', 'module', 'default'],
|
|
101
|
+
outfile: entryPath,
|
|
102
|
+
logLevel: 'silent',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
const message = error instanceof Error ? error.message : 'esbuild failed';
|
|
107
|
+
throw new DiagnosticError(diagnostic({
|
|
108
|
+
code: 'build.failed',
|
|
109
|
+
message,
|
|
110
|
+
path: sourcePath,
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
const javascript = result.outputFiles.filter((file) => file.path.endsWith('.js'));
|
|
114
|
+
if (result.outputFiles.length !== 1 || javascript.length !== 1) {
|
|
115
|
+
fail('build.multiple-outputs', 'Plugin build must produce exactly one JavaScript file', sourcePath, 'Inline imported assets or remove loaders that create additional files.');
|
|
116
|
+
}
|
|
117
|
+
const bytes = Buffer.from(javascript[0]?.contents ?? new Uint8Array());
|
|
118
|
+
if (bytes.byteLength === 0)
|
|
119
|
+
fail('build.empty-entry', 'Plugin entry build is empty', sourcePath);
|
|
120
|
+
if (bytes.byteLength > 5 * 1_048_576) {
|
|
121
|
+
fail('build.entry-too-large', 'Plugin entry exceeds the 5 MiB limit', sourcePath);
|
|
122
|
+
}
|
|
123
|
+
await init();
|
|
124
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
125
|
+
assertSelfContainedModule(decoded);
|
|
126
|
+
return bytes;
|
|
127
|
+
}
|
|
128
|
+
export async function buildPluginProject(options = {}) {
|
|
129
|
+
const projectDirectory = resolve(options.directory ?? process.cwd());
|
|
130
|
+
await assertDirectory(projectDirectory, 'Plugin project');
|
|
131
|
+
const manifestBytes = await readProjectPayloadFile(projectDirectory, 'plugin.json');
|
|
132
|
+
const manifest = parsePluginManifest(manifestBytes, {
|
|
133
|
+
...(options.apiVersion === undefined ? {} : { apiVersion: options.apiVersion }),
|
|
134
|
+
...(options.appVersion === undefined ? {} : { appVersion: options.appVersion }),
|
|
135
|
+
});
|
|
136
|
+
const configuration = await readProjectConfiguration(projectDirectory);
|
|
137
|
+
const sourcePath = resolve(projectDirectory, ...configuration.source.split('/'));
|
|
138
|
+
assertInside(projectDirectory, sourcePath, 'Plugin source');
|
|
139
|
+
await assertNoSymlinkComponents(projectDirectory, sourcePath, 'Plugin source');
|
|
140
|
+
await assertRegularFile(sourcePath, 'Plugin source entry');
|
|
141
|
+
const entry = validatePackagePath(manifest.entry, { label: '$.entry' });
|
|
142
|
+
const bundle = await bundleEntry(sourcePath, entry);
|
|
143
|
+
const files = new Map([
|
|
144
|
+
['plugin.json', manifestBytes],
|
|
145
|
+
[entry, bundle],
|
|
146
|
+
]);
|
|
147
|
+
for (const localePath of Object.values(manifest.locales ?? {})) {
|
|
148
|
+
if (!files.has(localePath)) {
|
|
149
|
+
files.set(localePath, await readProjectPayloadFile(projectDirectory, localePath));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
await collectUsageFiles(projectDirectory, files);
|
|
153
|
+
const integrity = createIntegrityManifest(files);
|
|
154
|
+
files.set('integrity.json', Buffer.from(serializeIntegrityManifest(integrity), 'utf8'));
|
|
155
|
+
const validated = validatePackageFiles(files, {
|
|
156
|
+
...(options.apiVersion === undefined ? {} : { apiVersion: options.apiVersion }),
|
|
157
|
+
...(options.appVersion === undefined ? {} : { appVersion: options.appVersion }),
|
|
158
|
+
});
|
|
159
|
+
const outputDirectory = join(projectDirectory, DEVELOPMENT_OUTPUT_DIRECTORY);
|
|
160
|
+
assertInside(projectDirectory, outputDirectory, 'Development output');
|
|
161
|
+
await assertNoSymlinkComponents(projectDirectory, outputDirectory, 'Development output');
|
|
162
|
+
await replaceDirectoryAtomically(outputDirectory, async (temporary) => {
|
|
163
|
+
for (const [path, bytes] of files) {
|
|
164
|
+
await writeFileExclusive(join(temporary, ...path.split('/')), bytes, 0o644);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
projectDirectory,
|
|
169
|
+
outputDirectory,
|
|
170
|
+
sourcePath,
|
|
171
|
+
manifest,
|
|
172
|
+
package: validated,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
export async function validatePluginProjectSource(options = {}) {
|
|
176
|
+
const projectDirectory = resolve(options.directory ?? process.cwd());
|
|
177
|
+
await assertDirectory(projectDirectory, 'Plugin project');
|
|
178
|
+
const manifestBytes = await readProjectPayloadFile(projectDirectory, 'plugin.json');
|
|
179
|
+
const manifest = parsePluginManifest(manifestBytes, {
|
|
180
|
+
...(options.apiVersion === undefined ? {} : { apiVersion: options.apiVersion }),
|
|
181
|
+
...(options.appVersion === undefined ? {} : { appVersion: options.appVersion }),
|
|
182
|
+
});
|
|
183
|
+
const configuration = await readProjectConfiguration(projectDirectory);
|
|
184
|
+
const sourcePath = resolve(projectDirectory, ...configuration.source.split('/'));
|
|
185
|
+
assertInside(projectDirectory, sourcePath, 'Plugin source');
|
|
186
|
+
await assertNoSymlinkComponents(projectDirectory, sourcePath, 'Plugin source');
|
|
187
|
+
await assertRegularFile(sourcePath, 'Plugin source entry');
|
|
188
|
+
// Source preflight cannot require the configured built entry yet. Supply a
|
|
189
|
+
// harmless placeholder for that one package file so the authoritative
|
|
190
|
+
// manifest validator can still parse locale payloads and verify that the
|
|
191
|
+
// default locale covers every contribution reference.
|
|
192
|
+
const sourceValidationFiles = new Map([
|
|
193
|
+
[manifest.entry, Buffer.from('// source-project preflight placeholder\n', 'utf8')],
|
|
194
|
+
]);
|
|
195
|
+
for (const localePath of Object.values(manifest.locales ?? {})) {
|
|
196
|
+
sourceValidationFiles.set(localePath, await readProjectPayloadFile(projectDirectory, localePath));
|
|
197
|
+
}
|
|
198
|
+
await collectUsageFiles(projectDirectory, sourceValidationFiles);
|
|
199
|
+
const validatedManifest = parsePluginManifest(manifestBytes, {
|
|
200
|
+
files: sourceValidationFiles,
|
|
201
|
+
...(options.apiVersion === undefined ? {} : { apiVersion: options.apiVersion }),
|
|
202
|
+
...(options.appVersion === undefined ? {} : { appVersion: options.appVersion }),
|
|
203
|
+
});
|
|
204
|
+
return Object.freeze({
|
|
205
|
+
projectDirectory,
|
|
206
|
+
sourcePath,
|
|
207
|
+
manifest: validatedManifest,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type PluginTemplate = 'command' | 'editor-statistics';
|
|
2
|
+
export type PackageManager = 'pnpm' | 'npm';
|
|
3
|
+
export interface CreatePluginProjectOptions {
|
|
4
|
+
readonly directory: string;
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly description?: string;
|
|
8
|
+
readonly template?: PluginTemplate;
|
|
9
|
+
readonly minAppVersion?: string;
|
|
10
|
+
readonly apiVersion?: string;
|
|
11
|
+
readonly packageManager?: PackageManager;
|
|
12
|
+
readonly install?: boolean;
|
|
13
|
+
/** Receives package-manager stdout and stderr; defaults to inherited stdio. */
|
|
14
|
+
readonly installOutput?: Pick<NodeJS.WritableStream, 'write'>;
|
|
15
|
+
}
|
|
16
|
+
export interface CreatedPluginProject {
|
|
17
|
+
readonly directory: string;
|
|
18
|
+
readonly files: readonly string[];
|
|
19
|
+
readonly packageManager: PackageManager;
|
|
20
|
+
readonly installed: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function createPluginProject(options: CreatePluginProjectOptions): Promise<CreatedPluginProject>;
|