@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.
@@ -0,0 +1,230 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { lstat, mkdir, readdir, rm, rmdir } from 'node:fs/promises';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { diagnostic, DiagnosticError } from './diagnostics.js';
5
+ import { pathExists, writeFileExclusive } from './files.js';
6
+ import { parsePluginManifest } from './manifest.js';
7
+ function json(value) {
8
+ return `${JSON.stringify(value, null, 2)}\n`;
9
+ }
10
+ function projectSlug(directory) {
11
+ const normalized = basename(resolve(directory))
12
+ .normalize('NFKD')
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .replace(/^-+|-+$/g, '');
16
+ return normalized || 'notegen-plugin';
17
+ }
18
+ function commandSource(id) {
19
+ const commandId = `${id}.hello`;
20
+ return `import type { PluginActivate } from '@notegen/plugin-api'\n\nexport const activate: PluginActivate = async (context) => {\n context.commands.handle('${commandId}', async () => {\n await context.ui.showNotice('Hello from ${id}')\n })\n}\n`;
21
+ }
22
+ function editorStatisticsSource(id) {
23
+ return `import type { ActiveEditorContext, PluginActivate } from '@notegen/plugin-api'\n\nfunction formatSize(editor: ActiveEditorContext | null): string {\n if (!editor) return 'No active editor'\n return editor.size.lines + ' lines · ' + editor.size.utf16Length + ' characters'\n}\n\nexport const activate: PluginActivate = async (context) => {\n const statusId = '${id}.status'\n const update = async () => {\n const editor = await context.editor.getActiveEditor()\n await context.ui.statusBar.update(statusId, {\n visible: editor !== null,\n text: formatSize(editor),\n accessibleLabel: formatSize(editor),\n })\n }\n\n context.editor.onDidChangeActiveEditor(update)\n context.editor.onDidChangeContent(update)\n await update()\n}\n`;
24
+ }
25
+ function templateFiles(options) {
26
+ const template = options.template ?? 'command';
27
+ const packageManager = options.packageManager ?? 'pnpm';
28
+ const source = template === 'editor-statistics'
29
+ ? editorStatisticsSource(options.id)
30
+ : commandSource(options.id);
31
+ const commandId = `${options.id}.hello`;
32
+ const manifest = template === 'editor-statistics'
33
+ ? {
34
+ manifestVersion: 1,
35
+ id: options.id,
36
+ name: options.name,
37
+ description: options.description ?? 'Show statistics for the active NoteGen editor.',
38
+ version: '0.1.0',
39
+ apiVersion: options.apiVersion ?? '^0.1.0',
40
+ minAppVersion: options.minAppVersion ?? '0.37.0',
41
+ platforms: ['desktop'],
42
+ entry: 'dist/main.js',
43
+ activationEvents: ['onEditor:markdown'],
44
+ permissions: {
45
+ 'editor.read': {
46
+ scope: 'active-editor',
47
+ description: 'Read the active editor size to display live statistics.',
48
+ },
49
+ },
50
+ contributes: {
51
+ statusBar: [{ id: `${options.id}.status`, alignment: 'right' }],
52
+ },
53
+ license: 'MIT',
54
+ }
55
+ : {
56
+ manifestVersion: 1,
57
+ id: options.id,
58
+ name: options.name,
59
+ description: options.description ?? 'A NoteGen command plugin.',
60
+ version: '0.1.0',
61
+ apiVersion: options.apiVersion ?? '^0.1.0',
62
+ minAppVersion: options.minAppVersion ?? '0.37.0',
63
+ platforms: ['desktop'],
64
+ entry: 'dist/main.js',
65
+ activationEvents: [`onCommand:${commandId}`],
66
+ permissions: {},
67
+ contributes: {
68
+ commands: [{
69
+ id: commandId,
70
+ title: 'Say hello',
71
+ description: 'Show a notice from this plugin.',
72
+ }],
73
+ },
74
+ license: 'MIT',
75
+ };
76
+ const scripts = {
77
+ dev: 'notegen-plugin dev',
78
+ build: 'tsc -p tsconfig.json --noEmit && notegen-plugin build',
79
+ validate: 'tsc -p tsconfig.json --noEmit && notegen-plugin validate',
80
+ pack: 'notegen-plugin pack',
81
+ };
82
+ return new Map([
83
+ ['.gitignore', 'node_modules/\n.notegen/\n'],
84
+ ['plugin.json', json(manifest)],
85
+ ['package.json', json({
86
+ name: projectSlug(options.directory),
87
+ version: '0.1.0',
88
+ private: true,
89
+ type: 'module',
90
+ scripts,
91
+ notegen: { source: 'src/main.ts' },
92
+ devDependencies: {
93
+ '@notegen/plugin-api': '^0.1.0',
94
+ '@notegen/plugin-cli': '^0.1.0',
95
+ typescript: '^5.8.3',
96
+ },
97
+ packageManager: packageManager === 'pnpm' ? 'pnpm@10.20.0' : undefined,
98
+ })],
99
+ ['tsconfig.json', json({
100
+ compilerOptions: {
101
+ target: 'ES2022',
102
+ module: 'ESNext',
103
+ moduleResolution: 'Bundler',
104
+ lib: ['ES2022'],
105
+ strict: true,
106
+ noEmit: true,
107
+ isolatedModules: true,
108
+ verbatimModuleSyntax: true,
109
+ skipLibCheck: true,
110
+ },
111
+ include: ['src/**/*.ts'],
112
+ })],
113
+ ['src/main.ts', source],
114
+ ]);
115
+ }
116
+ function generatedParentDirectories(paths) {
117
+ const directories = new Set();
118
+ for (const path of paths) {
119
+ const segments = path.split('/');
120
+ segments.pop();
121
+ while (segments.length > 0) {
122
+ directories.add(segments.join('/'));
123
+ segments.pop();
124
+ }
125
+ }
126
+ return [...directories].sort((left, right) => (right.split('/').length - left.split('/').length));
127
+ }
128
+ async function runInstall(directory, packageManager, output) {
129
+ await new Promise((resolvePromise, reject) => {
130
+ const child = spawn(packageManager, ['install'], {
131
+ cwd: directory,
132
+ stdio: output === undefined ? 'inherit' : ['inherit', 'pipe', 'pipe'],
133
+ shell: process.platform === 'win32',
134
+ });
135
+ if (output !== undefined) {
136
+ child.stdout?.on('data', (chunk) => output.write(chunk));
137
+ child.stderr?.on('data', (chunk) => output.write(chunk));
138
+ }
139
+ child.once('error', reject);
140
+ child.once('exit', (code, signal) => {
141
+ if (code === 0) {
142
+ resolvePromise();
143
+ return;
144
+ }
145
+ reject(new DiagnosticError(diagnostic({
146
+ code: 'create.install_failed',
147
+ message: signal
148
+ ? `${packageManager} install was interrupted by ${signal}`
149
+ : `${packageManager} install exited with code ${code ?? 'unknown'}`,
150
+ path: directory,
151
+ })));
152
+ });
153
+ });
154
+ }
155
+ export async function createPluginProject(options) {
156
+ if (options.template !== undefined && !['command', 'editor-statistics'].includes(options.template)) {
157
+ throw new DiagnosticError(diagnostic({
158
+ code: 'create.invalid-template',
159
+ message: `Unsupported plugin template: ${options.template}`,
160
+ }));
161
+ }
162
+ if (options.packageManager !== undefined && !['pnpm', 'npm'].includes(options.packageManager)) {
163
+ throw new DiagnosticError(diagnostic({
164
+ code: 'create.invalid-package-manager',
165
+ message: `Unsupported package manager: ${options.packageManager}`,
166
+ }));
167
+ }
168
+ const files = templateFiles(options);
169
+ const manifestSource = files.get('plugin.json');
170
+ if (manifestSource === undefined) {
171
+ throw new DiagnosticError(diagnostic({
172
+ code: 'create.template-invalid',
173
+ message: 'Plugin template did not produce plugin.json',
174
+ }));
175
+ }
176
+ parsePluginManifest(manifestSource);
177
+ const directory = resolve(options.directory);
178
+ const existed = await pathExists(directory);
179
+ if (existed) {
180
+ const metadata = await lstat(directory);
181
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
182
+ throw new DiagnosticError(diagnostic({
183
+ code: 'create.target_invalid',
184
+ message: 'The project target must be a real directory',
185
+ path: directory,
186
+ }));
187
+ }
188
+ const children = await readdir(directory);
189
+ if (children.length > 0) {
190
+ throw new DiagnosticError(diagnostic({
191
+ code: 'create.target_not_empty',
192
+ message: 'Refusing to create a plugin in a non-empty directory',
193
+ path: directory,
194
+ }));
195
+ }
196
+ }
197
+ else {
198
+ await mkdir(directory, { recursive: true });
199
+ }
200
+ const written = [];
201
+ try {
202
+ for (const [path, contents] of files) {
203
+ await writeFileExclusive(join(directory, path), contents);
204
+ written.push(path);
205
+ }
206
+ }
207
+ catch (error) {
208
+ for (const path of written.reverse()) {
209
+ await rm(join(directory, path), { force: true }).catch(() => undefined);
210
+ }
211
+ if (!existed) {
212
+ await rm(directory, { recursive: true, force: true }).catch(() => undefined);
213
+ }
214
+ else {
215
+ for (const path of generatedParentDirectories(files.keys())) {
216
+ await rmdir(join(directory, ...path.split('/'))).catch(() => undefined);
217
+ }
218
+ }
219
+ throw error;
220
+ }
221
+ const packageManager = options.packageManager ?? 'pnpm';
222
+ if (options.install)
223
+ await runInstall(directory, packageManager, options.installOutput);
224
+ return Object.freeze({
225
+ directory,
226
+ files: Object.freeze([...files.keys()]),
227
+ packageManager,
228
+ installed: options.install === true,
229
+ });
230
+ }
@@ -0,0 +1,26 @@
1
+ import { KeyObject } from 'node:crypto';
2
+ export declare const ED25519_PUBLIC_KEY_BYTES = 32;
3
+ export declare const ED25519_SIGNATURE_BYTES = 64;
4
+ export type PrivateKeySource = string | Uint8Array | KeyObject;
5
+ export type PublicKeySource = string | KeyObject;
6
+ export interface PrivateKeyOptions {
7
+ readonly passphrase?: string | Uint8Array;
8
+ }
9
+ export interface GeneratedPublisherKeyPair {
10
+ /** PKCS#8 PEM. It is encrypted when a passphrase is supplied. */
11
+ readonly privateKeyPem: string;
12
+ /** Raw 32-byte Ed25519 public key encoded with standard padded Base64. */
13
+ readonly publicKey: string;
14
+ /** Stable SDK convention; the host treats keyId as signed marketplace metadata. */
15
+ readonly keyId: string;
16
+ }
17
+ export declare function canonicalJsonBytes(value: unknown): Buffer;
18
+ export declare function createPackageSignatureMessage(manifestValue: unknown, integrityValue: unknown): Buffer;
19
+ export declare function decodePublisherPublicKey(value: string): Buffer;
20
+ export declare function decodePackageSignature(value: string): Buffer;
21
+ export declare function publisherPublicKeyFromPrivate(privateKey: PrivateKeySource, options?: PrivateKeyOptions): string;
22
+ export declare function publisherKeyId(publicKey: PublicKeySource): string;
23
+ export declare function generatePublisherKeyPair(passphrase?: string | Uint8Array): GeneratedPublisherKeyPair;
24
+ export declare function signPackage(manifestValue: unknown, integrityValue: unknown, privateKey: PrivateKeySource, options?: PrivateKeyOptions): string;
25
+ export declare function verifyPackageSignature(manifestValue: unknown, integrityValue: unknown, signatureBase64: string, publicKey: PublicKeySource): boolean;
26
+ export declare function assertPackageSignature(manifestValue: unknown, integrityValue: unknown, signatureBase64: string, publicKey: PublicKeySource): void;
@@ -0,0 +1,181 @@
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, KeyObject, sign as nodeSign, verify as nodeVerify, } from 'node:crypto';
2
+ import canonicalize from 'canonicalize';
3
+ import { fail } from './diagnostics.js';
4
+ const canonicalizeJson = canonicalize;
5
+ const PACKAGE_SIGNATURE_DOMAIN = Buffer.from('NOTEGEN_PLUGIN_SIGNATURE_V1\0', 'utf8');
6
+ export const ED25519_PUBLIC_KEY_BYTES = 32;
7
+ export const ED25519_SIGNATURE_BYTES = 64;
8
+ function lengthFrame(length) {
9
+ if (!Number.isSafeInteger(length) || length < 0) {
10
+ fail('signature.invalid-length', 'Signature message length is outside the supported range');
11
+ }
12
+ const bytes = Buffer.alloc(8);
13
+ bytes.writeBigUInt64BE(BigInt(length));
14
+ return bytes;
15
+ }
16
+ export function canonicalJsonBytes(value) {
17
+ let serialized;
18
+ try {
19
+ serialized = canonicalizeJson(value);
20
+ }
21
+ catch (error) {
22
+ const message = error instanceof Error ? error.message : 'value is not canonicalizable';
23
+ fail('signature.invalid-json', `Unable to canonicalize signed JSON: ${message}`);
24
+ }
25
+ if (typeof serialized !== 'string') {
26
+ fail('signature.invalid-json', 'Signed value is not valid canonical JSON');
27
+ }
28
+ return Buffer.from(serialized, 'utf8');
29
+ }
30
+ export function createPackageSignatureMessage(manifestValue, integrityValue) {
31
+ const manifest = canonicalJsonBytes(manifestValue);
32
+ const integrity = canonicalJsonBytes(integrityValue);
33
+ return Buffer.concat([
34
+ PACKAGE_SIGNATURE_DOMAIN,
35
+ lengthFrame(manifest.byteLength),
36
+ manifest,
37
+ lengthFrame(integrity.byteLength),
38
+ integrity,
39
+ ]);
40
+ }
41
+ function decodeBase64(value, expectedBytes, label) {
42
+ const compact = [...value]
43
+ .filter((character) => !/\p{White_Space}/u.test(character))
44
+ .join('');
45
+ let decoded;
46
+ const paddedStandard = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$(?![\s\S])/u;
47
+ const unpaddedStandard = /^[A-Za-z0-9+/]*$(?![\s\S])/u;
48
+ const unpaddedUrlSafe = /^[A-Za-z0-9_-]*$(?![\s\S])/u;
49
+ if (paddedStandard.test(compact) && compact.length % 4 === 0) {
50
+ const candidate = Buffer.from(compact, 'base64');
51
+ if (candidate.toString('base64') === compact)
52
+ decoded = candidate;
53
+ }
54
+ else if (unpaddedStandard.test(compact) && compact.length % 4 !== 1) {
55
+ const candidate = Buffer.from(compact, 'base64');
56
+ if (candidate.toString('base64').replace(/=+$/u, '') === compact)
57
+ decoded = candidate;
58
+ }
59
+ else if (unpaddedUrlSafe.test(compact) && compact.length % 4 !== 1) {
60
+ const candidate = Buffer.from(compact, 'base64url');
61
+ if (candidate.toString('base64url') === compact)
62
+ decoded = candidate;
63
+ }
64
+ if (!decoded)
65
+ fail('signature.invalid-base64', `${label} is not valid Base64`, label);
66
+ if (decoded.byteLength !== expectedBytes) {
67
+ fail('signature.invalid-length', `${label} must decode to ${expectedBytes} bytes`, label);
68
+ }
69
+ return decoded;
70
+ }
71
+ export function decodePublisherPublicKey(value) {
72
+ return decodeBase64(value, ED25519_PUBLIC_KEY_BYTES, 'publisher public key');
73
+ }
74
+ export function decodePackageSignature(value) {
75
+ return decodeBase64(value, ED25519_SIGNATURE_BYTES, 'signature.sig');
76
+ }
77
+ function publicKeyObject(source) {
78
+ if (typeof source !== 'string') {
79
+ if (source.type !== 'public' || source.asymmetricKeyType !== 'ed25519') {
80
+ fail('signature.invalid-public-key', 'Publisher public key must be an Ed25519 public key');
81
+ }
82
+ return source;
83
+ }
84
+ const raw = decodePublisherPublicKey(source);
85
+ try {
86
+ return createPublicKey({
87
+ key: {
88
+ kty: 'OKP',
89
+ crv: 'Ed25519',
90
+ x: raw.toString('base64url'),
91
+ },
92
+ format: 'jwk',
93
+ });
94
+ }
95
+ catch {
96
+ fail('signature.invalid-public-key', 'Unable to import the Ed25519 publisher public key');
97
+ }
98
+ }
99
+ function privateKeyObject(source, options = {}) {
100
+ let key;
101
+ try {
102
+ key = source instanceof KeyObject
103
+ ? source
104
+ : createPrivateKey({
105
+ key: typeof source === 'string' ? source : Buffer.from(source),
106
+ format: 'pem',
107
+ ...(options.passphrase === undefined ? {} : {
108
+ passphrase: typeof options.passphrase === 'string' ? options.passphrase : Buffer.from(options.passphrase),
109
+ }),
110
+ });
111
+ }
112
+ catch {
113
+ fail('signature.invalid-private-key', 'Unable to import the PKCS#8 publisher private key');
114
+ }
115
+ if (key.type !== 'private' || key.asymmetricKeyType !== 'ed25519') {
116
+ fail('signature.invalid-private-key', 'Publisher private key must be an Ed25519 private key');
117
+ }
118
+ return key;
119
+ }
120
+ function rawPublicKey(key) {
121
+ let exported;
122
+ try {
123
+ exported = key.export({ format: 'jwk' });
124
+ }
125
+ catch {
126
+ fail('signature.invalid-public-key', 'Unable to export the Ed25519 publisher public key');
127
+ }
128
+ if (exported.kty !== 'OKP' || exported.crv !== 'Ed25519' || typeof exported.x !== 'string') {
129
+ fail('signature.invalid-public-key', 'Publisher public key is not raw Ed25519 key material');
130
+ }
131
+ const raw = Buffer.from(exported.x, 'base64url');
132
+ if (raw.byteLength !== ED25519_PUBLIC_KEY_BYTES) {
133
+ fail('signature.invalid-public-key', 'Publisher public key must contain 32 raw bytes');
134
+ }
135
+ return raw;
136
+ }
137
+ export function publisherPublicKeyFromPrivate(privateKey, options = {}) {
138
+ const publicKey = createPublicKey(privateKeyObject(privateKey, options));
139
+ return rawPublicKey(publicKey).toString('base64');
140
+ }
141
+ export function publisherKeyId(publicKey) {
142
+ const raw = typeof publicKey === 'string'
143
+ ? decodePublisherPublicKey(publicKey)
144
+ : rawPublicKey(publicKeyObject(publicKey));
145
+ return `ed25519-${createHash('sha256').update(raw).digest('hex')}`;
146
+ }
147
+ export function generatePublisherKeyPair(passphrase) {
148
+ const generated = generateKeyPairSync('ed25519');
149
+ const exportedPrivate = passphrase === undefined
150
+ ? generated.privateKey.export({ type: 'pkcs8', format: 'pem' })
151
+ : generated.privateKey.export({
152
+ type: 'pkcs8',
153
+ format: 'pem',
154
+ cipher: 'aes-256-cbc',
155
+ passphrase: typeof passphrase === 'string' ? passphrase : Buffer.from(passphrase),
156
+ });
157
+ const publicKey = rawPublicKey(generated.publicKey).toString('base64');
158
+ return {
159
+ privateKeyPem: exportedPrivate.toString(),
160
+ publicKey,
161
+ keyId: publisherKeyId(publicKey),
162
+ };
163
+ }
164
+ export function signPackage(manifestValue, integrityValue, privateKey, options = {}) {
165
+ const key = privateKeyObject(privateKey, options);
166
+ const signature = nodeSign(null, createPackageSignatureMessage(manifestValue, integrityValue), key);
167
+ if (signature.byteLength !== ED25519_SIGNATURE_BYTES) {
168
+ fail('signature.invalid-length', 'Ed25519 signer returned an unexpected signature length');
169
+ }
170
+ return signature.toString('base64');
171
+ }
172
+ export function verifyPackageSignature(manifestValue, integrityValue, signatureBase64, publicKey) {
173
+ const signature = decodePackageSignature(signatureBase64);
174
+ const key = publicKeyObject(publicKey);
175
+ return nodeVerify(null, createPackageSignatureMessage(manifestValue, integrityValue), key, signature);
176
+ }
177
+ export function assertPackageSignature(manifestValue, integrityValue, signatureBase64, publicKey) {
178
+ if (!verifyPackageSignature(manifestValue, integrityValue, signatureBase64, publicKey)) {
179
+ fail('signature.verification-failed', 'Ed25519 package signature verification failed', 'signature.sig');
180
+ }
181
+ }
@@ -0,0 +1,26 @@
1
+ export type JsonObject = Record<string, unknown>;
2
+ export interface StrictJsonDocument<T = unknown> {
3
+ readonly text: string;
4
+ readonly value: T;
5
+ }
6
+ type JsonContainer = Record<string, unknown> | readonly unknown[];
7
+ /**
8
+ * Ensures a parsed JSON number used by an integer schema field was written as
9
+ * an integer token. This mirrors serde's u32/i32/u64 decoding, which rejects
10
+ * otherwise equivalent floating-point forms such as `1.0` and `1e0`.
11
+ */
12
+ export declare function assertJsonIntegerToken(container: JsonContainer, key: string | number, path: string, options?: {
13
+ readonly signed?: boolean;
14
+ }): void;
15
+ /**
16
+ * Parses standard JSON while rejecting comments, trailing commas, BOMs, trailing
17
+ * values, invalid UTF-8, and duplicate keys at every object depth.
18
+ *
19
+ * The returned value preserves the fields supplied by the author. Callers must
20
+ * not add schema defaults before passing it to package signing.
21
+ */
22
+ export declare function parseStrictJsonDocument(input: string | Uint8Array, label?: string): StrictJsonDocument;
23
+ export declare function parseStrictJson(input: string | Uint8Array, label?: string): unknown;
24
+ export declare function isJsonObject(value: unknown): value is JsonObject;
25
+ export declare function parseStrictJsonObject(input: string | Uint8Array, label?: string): JsonObject;
26
+ export {};
@@ -0,0 +1,210 @@
1
+ import { getNodeValue, parseTree, printParseErrorCode, } from 'jsonc-parser';
2
+ import { fail } from './diagnostics.js';
3
+ const integerNumberToken = /^(?:0|[1-9]\d*)$(?![\s\S])/u;
4
+ const signedIntegerNumberToken = /^(?:0|[1-9]\d*|-[1-9]\d*)$(?![\s\S])/u;
5
+ const numberTokens = new WeakMap();
6
+ function decodeJsonInput(input, label) {
7
+ if (typeof input === 'string')
8
+ return input;
9
+ try {
10
+ // Keeping a BOM in the decoded result lets us reject it just like serde_json.
11
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(input);
12
+ }
13
+ catch {
14
+ fail('json.invalid-utf8', `${label} must be valid UTF-8`, label);
15
+ }
16
+ }
17
+ function lineAndColumn(text, offset) {
18
+ let line = 1;
19
+ let column = 1;
20
+ for (let index = 0; index < offset && index < text.length; index += 1) {
21
+ if (text.charCodeAt(index) === 0x0a) {
22
+ line += 1;
23
+ column = 1;
24
+ }
25
+ else {
26
+ column += 1;
27
+ }
28
+ }
29
+ return `${line}:${column}`;
30
+ }
31
+ function propertyPath(segments) {
32
+ let output = '$';
33
+ for (const segment of segments) {
34
+ if (typeof segment === 'number') {
35
+ output += `[${segment}]`;
36
+ }
37
+ else if (/^[A-Za-z_$][A-Za-z0-9_$]*$(?![\s\S])/.test(segment)) {
38
+ output += `.${segment}`;
39
+ }
40
+ else {
41
+ output += `[${JSON.stringify(segment)}]`;
42
+ }
43
+ }
44
+ return output;
45
+ }
46
+ function assertNoDuplicateKeys(node, text, label, path = []) {
47
+ if (node.type === 'object') {
48
+ const keys = new Set();
49
+ for (const property of node.children ?? []) {
50
+ const keyNode = property.children?.[0];
51
+ const valueNode = property.children?.[1];
52
+ if (!keyNode || typeof keyNode.value !== 'string' || !valueNode)
53
+ continue;
54
+ const key = keyNode.value;
55
+ if (keys.has(key)) {
56
+ fail('json.duplicate-key', `${label} contains the duplicate object key ${JSON.stringify(key)}`, `${propertyPath([...path, key])} (${lineAndColumn(text, keyNode.offset)})`);
57
+ }
58
+ keys.add(key);
59
+ assertNoDuplicateKeys(valueNode, text, label, [...path, key]);
60
+ }
61
+ return;
62
+ }
63
+ if (node.type === 'array') {
64
+ for (const [index, child] of (node.children ?? []).entries()) {
65
+ assertNoDuplicateKeys(child, text, label, [...path, index]);
66
+ }
67
+ }
68
+ }
69
+ function assertValidSyntax(text, label) {
70
+ if (text.startsWith('\uFEFF')) {
71
+ fail('json.bom', `${label} must not start with a UTF-8 byte-order mark`, label);
72
+ }
73
+ const errors = [];
74
+ const root = parseTree(text, errors, {
75
+ allowEmptyContent: false,
76
+ allowTrailingComma: false,
77
+ disallowComments: true,
78
+ });
79
+ const first = errors[0];
80
+ if (!root || first) {
81
+ const message = first
82
+ ? printParseErrorCode(first.error)
83
+ : 'JSON document has no root value';
84
+ const location = first ? lineAndColumn(text, first.offset) : '1:1';
85
+ fail('json.invalid-syntax', `${label} is not valid strict JSON: ${message}`, `${label}:${location}`);
86
+ }
87
+ return root;
88
+ }
89
+ function hasLoneSurrogate(value) {
90
+ for (let index = 0; index < value.length; index += 1) {
91
+ const unit = value.charCodeAt(index);
92
+ if (unit >= 0xd800 && unit <= 0xdbff) {
93
+ const next = value.charCodeAt(index + 1);
94
+ if (!(next >= 0xdc00 && next <= 0xdfff))
95
+ return true;
96
+ index += 1;
97
+ }
98
+ else if (unit >= 0xdc00 && unit <= 0xdfff) {
99
+ return true;
100
+ }
101
+ }
102
+ return false;
103
+ }
104
+ function assertCompatibleJsonValue(value, label, path = []) {
105
+ if (typeof value === 'number') {
106
+ if (!Number.isFinite(value)) {
107
+ fail('json.number-out-of-range', `${label} contains a number outside the JSON range`, propertyPath(path));
108
+ }
109
+ return;
110
+ }
111
+ if (typeof value === 'string') {
112
+ if (hasLoneSurrogate(value)) {
113
+ fail('json.invalid-unicode', `${label} contains an unpaired UTF-16 surrogate`, propertyPath(path));
114
+ }
115
+ return;
116
+ }
117
+ if (Array.isArray(value)) {
118
+ for (const [index, child] of value.entries()) {
119
+ assertCompatibleJsonValue(child, label, [...path, index]);
120
+ }
121
+ return;
122
+ }
123
+ if (typeof value === 'object' && value !== null) {
124
+ for (const [key, child] of Object.entries(value)) {
125
+ if (hasLoneSurrogate(key)) {
126
+ fail('json.invalid-unicode', `${label} contains an object key with an unpaired UTF-16 surrogate`, propertyPath([...path, key]));
127
+ }
128
+ assertCompatibleJsonValue(child, label, [...path, key]);
129
+ }
130
+ }
131
+ }
132
+ function indexNumberTokens(node, value, text) {
133
+ if (node.type !== 'object' && node.type !== 'array')
134
+ return;
135
+ if (typeof value !== 'object' || value === null)
136
+ return;
137
+ const direct = new Map();
138
+ if (node.type === 'object' && !Array.isArray(value)) {
139
+ const objectValue = value;
140
+ for (const property of node.children ?? []) {
141
+ const keyNode = property.children?.[0];
142
+ const valueNode = property.children?.[1];
143
+ if (!keyNode || typeof keyNode.value !== 'string' || !valueNode)
144
+ continue;
145
+ const key = keyNode.value;
146
+ if (valueNode.type === 'number') {
147
+ direct.set(key, text.slice(valueNode.offset, valueNode.offset + valueNode.length));
148
+ }
149
+ else {
150
+ indexNumberTokens(valueNode, objectValue[key], text);
151
+ }
152
+ }
153
+ }
154
+ else if (node.type === 'array' && Array.isArray(value)) {
155
+ for (const [index, child] of (node.children ?? []).entries()) {
156
+ if (child.type === 'number') {
157
+ direct.set(index, text.slice(child.offset, child.offset + child.length));
158
+ }
159
+ else {
160
+ indexNumberTokens(child, value[index], text);
161
+ }
162
+ }
163
+ }
164
+ numberTokens.set(value, direct);
165
+ }
166
+ /**
167
+ * Ensures a parsed JSON number used by an integer schema field was written as
168
+ * an integer token. This mirrors serde's u32/i32/u64 decoding, which rejects
169
+ * otherwise equivalent floating-point forms such as `1.0` and `1e0`.
170
+ */
171
+ export function assertJsonIntegerToken(container, key, path, options = {}) {
172
+ const token = numberTokens.get(container)?.get(key);
173
+ if (token === undefined)
174
+ return;
175
+ const valid = (options.signed ? signedIntegerNumberToken : integerNumberToken).test(token);
176
+ if (!valid) {
177
+ fail('json.expected-integer-token', `${path} must use an integer JSON token without a fraction, exponent, or negative zero`, path);
178
+ }
179
+ }
180
+ /**
181
+ * Parses standard JSON while rejecting comments, trailing commas, BOMs, trailing
182
+ * values, invalid UTF-8, and duplicate keys at every object depth.
183
+ *
184
+ * The returned value preserves the fields supplied by the author. Callers must
185
+ * not add schema defaults before passing it to package signing.
186
+ */
187
+ export function parseStrictJsonDocument(input, label = 'JSON') {
188
+ const text = decodeJsonInput(input, label);
189
+ const root = assertValidSyntax(text, label);
190
+ assertNoDuplicateKeys(root, text, label);
191
+ // parseTree has already applied the strict syntax policy. getNodeValue keeps
192
+ // the same JSON value without a schema-driven normalize/reserialize step.
193
+ const value = getNodeValue(root);
194
+ assertCompatibleJsonValue(value, label);
195
+ indexNumberTokens(root, value, text);
196
+ return { text, value };
197
+ }
198
+ export function parseStrictJson(input, label = 'JSON') {
199
+ return parseStrictJsonDocument(input, label).value;
200
+ }
201
+ export function isJsonObject(value) {
202
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
203
+ }
204
+ export function parseStrictJsonObject(input, label = 'JSON') {
205
+ const value = parseStrictJson(input, label);
206
+ if (!isJsonObject(value)) {
207
+ fail('json.expected-object', `${label} must contain a JSON object`, label);
208
+ }
209
+ return value;
210
+ }