@file-viewer/cli 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1665 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { createRequire } from 'node:module';
5
+ import { access, lstat, mkdir, readFile, readdir, realpath, rename, writeFile } from 'node:fs/promises';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { readFileViewerAssetLedger, uninstallFileViewerCapabilityAssetPack, verifyFileViewerCapabilityAssetReceipt, verifyFileViewerAssetState } from '@file-viewer/asset-installer';
9
+ import { normalizeFileViewerAssetBaseUrl, normalizeFileViewerRegistryUrl } from './url-security.js';
10
+ import { detectYarnGeneration } from './carrier-command.js';
11
+ export * from './offline.js';
12
+ export { normalizeFileViewerRegistryUrl } from './url-security.js';
13
+ export const DEFAULT_FILE_VIEWER_CONFIG = 'file-viewer.config.json';
14
+ const allowedFrameworks = new Set([
15
+ 'web',
16
+ 'vue3',
17
+ 'vue2.7',
18
+ 'vue2.6',
19
+ 'react',
20
+ 'react-legacy',
21
+ 'svelte',
22
+ 'jquery'
23
+ ]);
24
+ const allowedProfiles = new Set([
25
+ 'standard',
26
+ 'lite',
27
+ 'office',
28
+ 'engineering',
29
+ 'all',
30
+ 'full',
31
+ 'custom'
32
+ ]);
33
+ const allowedPackageManagers = new Set(['pnpm', 'npm', 'yarn', 'bun']);
34
+ const normalizeList = (values = []) => [
35
+ ...new Set(values
36
+ .flatMap((value) => value.split(','))
37
+ .map((value) => value.trim().toLowerCase().replace(/^\./, ''))
38
+ .filter(Boolean))
39
+ ].sort();
40
+ const normalizeProjectRelativePath = (value, label) => {
41
+ const normalized = String(value || '')
42
+ .trim()
43
+ .replace(/\\/g, '/')
44
+ .replace(/^\.\//, '');
45
+ if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..')) {
46
+ throw new Error(`${label} must be a contained project-relative path.`);
47
+ }
48
+ return normalized;
49
+ };
50
+ export const inferFileViewerLocalAssetBaseUrl = (assetTarget, publicDirectory) => {
51
+ const target = normalizeProjectRelativePath(assetTarget, 'assetTarget');
52
+ const roots = publicDirectory
53
+ ? [normalizeProjectRelativePath(publicDirectory, 'publicDirectory')]
54
+ : ['public', 'static'];
55
+ for (const root of roots) {
56
+ if (!target.startsWith(`${root}/`))
57
+ continue;
58
+ const publicPath = target.slice(root.length + 1);
59
+ if (!publicPath)
60
+ return undefined;
61
+ return normalizeFileViewerAssetBaseUrl(`/${publicPath}/`);
62
+ }
63
+ return undefined;
64
+ };
65
+ const normalizeInstallSource = (source) => {
66
+ if (!source)
67
+ return undefined;
68
+ if (source.concurrency !== undefined &&
69
+ (!Number.isInteger(source.concurrency) || source.concurrency < 1 || source.concurrency > 32)) {
70
+ throw new Error('Install concurrency must be an integer from 1 to 32.');
71
+ }
72
+ const cacheDir = source.cacheDir?.trim();
73
+ // eslint-disable-next-line no-control-regex -- reject controls before resolving a local path
74
+ if (cacheDir && /[\u0000-\u001f\u007f]/.test(cacheDir))
75
+ throw new Error('Invalid cache directory.');
76
+ if (source.kind === 'registry') {
77
+ return {
78
+ kind: 'registry',
79
+ ...(source.registry ? { registry: normalizeFileViewerRegistryUrl(source.registry) } : {}),
80
+ ...(cacheDir ? { cacheDir } : {}),
81
+ ...(source.concurrency ? { concurrency: source.concurrency } : {})
82
+ };
83
+ }
84
+ const directory = source.directory?.trim();
85
+ // eslint-disable-next-line no-control-regex -- reject controls before resolving a local path
86
+ if (!directory || /[\u0000-\u001f\u007f]/.test(directory))
87
+ throw new Error('Invalid offline directory.');
88
+ return {
89
+ kind: 'offline-directory',
90
+ directory,
91
+ ...(cacheDir ? { cacheDir } : {}),
92
+ ...(source.concurrency ? { concurrency: source.concurrency } : {})
93
+ };
94
+ };
95
+ const resolveContainedProjectFile = (projectRoot, value, label) => resolve(projectRoot, normalizeProjectRelativePath(value, label));
96
+ const stableConfig = (config) => ({
97
+ schemaVersion: 1,
98
+ framework: config.framework,
99
+ profile: config.profile,
100
+ formats: normalizeList(config.formats),
101
+ capabilities: normalizeList(config.capabilities),
102
+ assetTarget: normalizeProjectRelativePath(config.assetTarget, 'assetTarget'),
103
+ generatedModule: normalizeProjectRelativePath(config.generatedModule, 'generatedModule'),
104
+ ...(config.entry ? { entry: normalizeProjectRelativePath(config.entry, 'entry') } : {}),
105
+ ...(config.packageManager ? { packageManager: config.packageManager } : {}),
106
+ ...(config.packageManagerVersion ? { packageManagerVersion: config.packageManagerVersion } : {}),
107
+ ...(config.managedPackages?.length
108
+ ? { managedPackages: normalizeList(config.managedPackages) }
109
+ : {}),
110
+ ...(config.locale ? { locale: config.locale } : {}),
111
+ ...(config.source ? { source: normalizeInstallSource(config.source) } : {}),
112
+ ...(config.frameworkVersion ? { frameworkVersion: config.frameworkVersion } : {}),
113
+ ...(config.assetBaseUrl
114
+ ? { assetBaseUrl: normalizeFileViewerAssetBaseUrl(config.assetBaseUrl) }
115
+ : {})
116
+ });
117
+ export async function loadFileViewerCliCatalog() {
118
+ const raw = await readFile(new URL('../catalog/catalog.json', import.meta.url), 'utf8');
119
+ const catalog = JSON.parse(raw);
120
+ if (catalog.schemaVersion !== 1 || !Array.isArray(catalog.capabilities)) {
121
+ throw new Error('The bundled File Viewer capability catalog is invalid.');
122
+ }
123
+ return catalog;
124
+ }
125
+ export async function listFileViewerCapabilities() {
126
+ const catalog = await loadFileViewerCliCatalog();
127
+ return {
128
+ schemaVersion: 1,
129
+ coreVersion: catalog.core.version,
130
+ capabilities: [...catalog.capabilities]
131
+ .sort((left, right) => left.id.localeCompare(right.id))
132
+ .map((capability) => ({
133
+ id: capability.id,
134
+ packageSpec: `${capability.packageName}@${capability.version}`,
135
+ formats: [...capability.formats].sort(),
136
+ rendererIds: [...capability.rendererIds].sort(),
137
+ weight: capability.weight,
138
+ license: capability.license,
139
+ profiles: [...capability.profiles].sort(),
140
+ availability: capability.profiles.length
141
+ ? capability.profiles.slice().sort().join(',')
142
+ : 'explicit opt-in',
143
+ assetPackageSpec: capability.assets.packageName && capability.assets.packageVersion
144
+ ? `${capability.assets.packageName}@${capability.assets.packageVersion}`
145
+ : null
146
+ }))
147
+ };
148
+ }
149
+ export function renderFileViewerCapabilityList(value) {
150
+ const lines = [`File Viewer capabilities (catalog ${value.coreVersion})`];
151
+ for (const capability of value.capabilities) {
152
+ lines.push([
153
+ capability.id,
154
+ `formats=${capability.formats.join(',') || '-'}`,
155
+ `weight=${capability.weight}`,
156
+ `license=${capability.license.spdx}/${capability.license.policy}`,
157
+ `profile=${capability.availability}`,
158
+ `assets=${capability.assetPackageSpec ?? 'none'}`
159
+ ].join(' | '));
160
+ }
161
+ return lines.join('\n');
162
+ }
163
+ export function normalizeFileViewerConfig(input = {}) {
164
+ const framework = input.framework ?? 'web';
165
+ const profile = input.profile ?? 'standard';
166
+ if (!allowedFrameworks.has(framework)) {
167
+ throw new Error(`Unknown framework "${framework}".`);
168
+ }
169
+ if (!allowedProfiles.has(profile)) {
170
+ throw new Error(`Unknown profile "${profile}".`);
171
+ }
172
+ if (input.packageManager && !allowedPackageManagers.has(input.packageManager)) {
173
+ throw new Error(`Unknown package manager "${input.packageManager}".`);
174
+ }
175
+ if (input.packageManagerVersion !== undefined &&
176
+ !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(input.packageManagerVersion)) {
177
+ throw new Error('packageManagerVersion must be an exact semantic version.');
178
+ }
179
+ return stableConfig({
180
+ schemaVersion: 1,
181
+ framework,
182
+ profile,
183
+ formats: input.formats ?? [],
184
+ capabilities: input.capabilities ?? [],
185
+ assetTarget: input.assetTarget ?? 'public/file-viewer',
186
+ generatedModule: input.generatedModule ?? 'file-viewer.generated.mjs',
187
+ entry: input.entry,
188
+ packageManager: input.packageManager,
189
+ packageManagerVersion: input.packageManagerVersion,
190
+ managedPackages: input.managedPackages,
191
+ locale: input.locale,
192
+ source: input.source,
193
+ frameworkVersion: input.frameworkVersion,
194
+ assetBaseUrl: input.assetBaseUrl
195
+ });
196
+ }
197
+ export async function readFileViewerProjectConfig(projectRoot, configFile = DEFAULT_FILE_VIEWER_CONFIG, optional = false) {
198
+ const configPath = resolveContainedProjectFile(projectRoot, configFile, 'configFile');
199
+ try {
200
+ const raw = await readFile(configPath, 'utf8');
201
+ const parsed = JSON.parse(raw);
202
+ if (parsed.schemaVersion !== 1) {
203
+ throw new Error(`Unsupported File Viewer config schemaVersion ${String(parsed.schemaVersion)}.`);
204
+ }
205
+ return normalizeFileViewerConfig(parsed);
206
+ }
207
+ catch (error) {
208
+ const code = error.code;
209
+ if (optional && code === 'ENOENT')
210
+ return null;
211
+ throw error;
212
+ }
213
+ }
214
+ function capabilityForToken(token, catalog) {
215
+ const exactId = catalog.capabilities.find((capability) => capability.id === token);
216
+ if (exactId)
217
+ return exactId;
218
+ const matches = catalog.capabilities.filter((capability) => capability.rendererIds.includes(token) || capability.formats.includes(token));
219
+ if (matches.length > 1) {
220
+ throw new Error(`"${token}" matches multiple capabilities: ${matches.map((item) => item.id).join(', ')}.`);
221
+ }
222
+ return matches[0] ?? null;
223
+ }
224
+ export function detectPackageManager(projectRoot) {
225
+ try {
226
+ const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
227
+ const declared = packageJson.packageManager
228
+ ?.match(/^(pnpm|npm|yarn|bun)@/i)?.[1]
229
+ ?.toLowerCase();
230
+ if (declared && allowedPackageManagers.has(declared))
231
+ return declared;
232
+ }
233
+ catch {
234
+ // Lockfile detection below remains the compatibility fallback.
235
+ }
236
+ const candidates = [
237
+ ['pnpm-lock.yaml', 'pnpm'],
238
+ ['yarn.lock', 'yarn'],
239
+ ['bun.lockb', 'bun'],
240
+ ['bun.lock', 'bun'],
241
+ ['package-lock.json', 'npm']
242
+ ];
243
+ const detected = candidates.filter(([filename]) => existsSync(join(projectRoot, filename)));
244
+ const managers = [...new Set(detected.map(([, manager]) => manager))];
245
+ if (managers.length > 1) {
246
+ throw new Error(`Multiple package-manager lockfiles were found (${detected.map(([filename]) => filename).join(', ')}). Pass --package-manager explicitly or set an exact packageManager field in package.json.`);
247
+ }
248
+ if (managers[0])
249
+ return managers[0];
250
+ return 'npm';
251
+ }
252
+ function installStep(manager, packages, cwd, source, packageManagerVersion) {
253
+ const concurrency = source?.concurrency && source.concurrency > 0 ? String(source.concurrency) : null;
254
+ const cacheDir = source?.cacheDir ? resolve(cwd, source.cacheDir) : null;
255
+ const registry = source?.kind === 'registry' ? source.registry : undefined;
256
+ const extra = [];
257
+ const env = {};
258
+ if (source?.kind === 'offline-directory' && (manager === 'pnpm' || manager === 'npm'))
259
+ extra.push('--offline');
260
+ if (registry && manager !== 'yarn')
261
+ extra.push('--registry', registry);
262
+ if (registry && manager === 'yarn') {
263
+ // Berry only accepts registry configuration through its environment. Keep
264
+ // these values for both generations, then add Classic's explicit flag once
265
+ // the project generation is known below.
266
+ env.npm_config_registry = registry;
267
+ env.YARN_NPM_REGISTRY_SERVER = registry;
268
+ }
269
+ if (manager === 'pnpm') {
270
+ if (cacheDir)
271
+ extra.push('--store-dir', cacheDir);
272
+ if (concurrency)
273
+ extra.push('--network-concurrency', concurrency);
274
+ return {
275
+ id: 'install',
276
+ kind: 'install',
277
+ command: 'pnpm',
278
+ args: ['add', '--save-exact', '--ignore-scripts', ...extra, ...packages],
279
+ cwd,
280
+ ...(Object.keys(env).length ? { env } : {})
281
+ };
282
+ }
283
+ if (manager === 'yarn') {
284
+ const effectiveYarnVersion = packageManagerVersion ?? configuredYarnVersion(cwd);
285
+ const declaredMajor = Number(effectiveYarnVersion?.match(/^(\d+)/)?.[1] ?? Number.NaN);
286
+ const generation = Number.isFinite(declaredMajor)
287
+ ? declaredMajor >= 2
288
+ ? 'berry'
289
+ : 'classic'
290
+ : detectYarnGeneration(cwd);
291
+ if (generation === 'berry') {
292
+ if (!effectiveYarnVersion) {
293
+ throw new Error('Yarn Berry was detected but its exact version could not be determined. Add packageManager: "yarn@<exact-version>", configure a versioned .yarn/releases/yarn-<version>.cjs yarnPath, or pass --package-manager-version.');
294
+ }
295
+ env.YARN_ENABLE_SCRIPTS = 'false';
296
+ if (cacheDir) {
297
+ env.YARN_ENABLE_GLOBAL_CACHE = 'true';
298
+ env.YARN_GLOBAL_FOLDER = cacheDir;
299
+ }
300
+ if (concurrency) {
301
+ env.YARN_NETWORK_CONCURRENCY = concurrency;
302
+ }
303
+ if (source?.kind === 'offline-directory')
304
+ env.YARN_ENABLE_NETWORK = 'false';
305
+ }
306
+ else {
307
+ // Yarn Classic does not reliably honor the Berry environment setting and
308
+ // can otherwise fall back to a user's global registry or proxy.
309
+ if (registry) {
310
+ extra.push('--registry', registry);
311
+ const registryHost = new URL(registry).hostname;
312
+ const isLoopbackRegistry = registryHost === 'localhost' ||
313
+ registryHost === '[::1]' ||
314
+ /^127(?:\.\d{1,3}){3}$/.test(registryHost);
315
+ if (isLoopbackRegistry) {
316
+ // Yarn Classic treats npm_config_proxy as authoritative even when
317
+ // NO_PROXY contains the loopback host. A loopback registry must never
318
+ // leave the machine, so bypass inherited proxies for this child only.
319
+ for (const name of [
320
+ 'npm_config_proxy',
321
+ 'npm_config_https_proxy',
322
+ 'HTTP_PROXY',
323
+ 'HTTPS_PROXY',
324
+ 'ALL_PROXY',
325
+ 'http_proxy',
326
+ 'https_proxy',
327
+ 'all_proxy'
328
+ ])
329
+ env[name] = '';
330
+ const noProxy = [registryHost.replace(/^\[|\]$/g, ''), '127.0.0.1', 'localhost', '::1']
331
+ .filter((value, index, values) => values.indexOf(value) === index)
332
+ .join(',');
333
+ env.NO_PROXY = noProxy;
334
+ env.no_proxy = noProxy;
335
+ }
336
+ }
337
+ extra.push('--ignore-scripts');
338
+ if (cacheDir)
339
+ env.YARN_CACHE_FOLDER = cacheDir;
340
+ if (concurrency)
341
+ extra.push('--network-concurrency', concurrency);
342
+ if (source?.kind === 'offline-directory')
343
+ extra.push('--offline');
344
+ }
345
+ return {
346
+ id: 'install',
347
+ kind: 'install',
348
+ command: 'yarn',
349
+ args: ['add', '--exact', ...extra, ...packages],
350
+ cwd,
351
+ ...(effectiveYarnVersion ? { expectedExecutableVersion: effectiveYarnVersion } : {}),
352
+ ...(Object.keys(env).length ? { env } : {})
353
+ };
354
+ }
355
+ if (manager === 'bun') {
356
+ if (cacheDir)
357
+ extra.push('--cache-dir', cacheDir);
358
+ if (concurrency)
359
+ extra.push('--network-concurrency', concurrency, '--concurrent-scripts', concurrency);
360
+ return {
361
+ id: 'install',
362
+ kind: 'install',
363
+ command: 'bun',
364
+ args: ['add', '--exact', '--ignore-scripts', ...extra, ...packages],
365
+ cwd
366
+ };
367
+ }
368
+ if (cacheDir)
369
+ extra.push('--cache', cacheDir);
370
+ if (concurrency)
371
+ extra.push('--maxsockets', concurrency);
372
+ return {
373
+ id: 'install',
374
+ kind: 'install',
375
+ command: 'npm',
376
+ args: ['install', '--save-exact', '--ignore-scripts', ...extra, ...packages],
377
+ cwd
378
+ };
379
+ }
380
+ export async function resolveFileViewerOfflinePackages(required, directoryInput, projectRoot) {
381
+ const directory = resolve(projectRoot, directoryInput);
382
+ const directoryDetails = await lstat(directory);
383
+ if (!directoryDetails.isDirectory() || directoryDetails.isSymbolicLink())
384
+ throw new Error(`Offline directory is not a regular directory: ${directory}.`);
385
+ const physicalDirectory = await realpath(directory);
386
+ const archives = (await readdir(physicalDirectory)).filter((file) => file.endsWith('.tgz')).sort();
387
+ const integrityPath = join(physicalDirectory, 'file-viewer-offline-manifest.json');
388
+ const integrityDetails = await lstat(integrityPath);
389
+ if (!integrityDetails.isFile() || integrityDetails.isSymbolicLink())
390
+ throw new Error(`Offline manifest is not a regular file: ${integrityPath}.`);
391
+ const integrityManifest = JSON.parse(await readFile(integrityPath, 'utf8').catch((error) => {
392
+ if (error.code === 'ENOENT')
393
+ throw new Error(`Offline directory requires ${integrityPath} with sha512 integrity for every tarball.`);
394
+ throw error;
395
+ }));
396
+ if (integrityManifest.schemaVersion !== 1 ||
397
+ !integrityManifest.files ||
398
+ Array.isArray(integrityManifest.files)) {
399
+ throw new Error(`Invalid offline integrity manifest ${integrityPath}.`);
400
+ }
401
+ const byPackage = new Map();
402
+ const isContained = (root, path) => {
403
+ const relation = relative(root, path);
404
+ return relation === '' || (!relation.startsWith('..') && !isAbsolute(relation));
405
+ };
406
+ const inspectTarballManifest = (path) => {
407
+ const result = spawnSync('tar', ['-xOf', path, 'package/package.json'], {
408
+ encoding: 'utf8',
409
+ shell: false,
410
+ maxBuffer: 2 * 1024 * 1024
411
+ });
412
+ if (result.status !== 0)
413
+ throw new Error(`Could not inspect offline tarball ${basename(path)}: ${String(result.stderr || result.error?.message || '').trim()}`);
414
+ try {
415
+ return JSON.parse(result.stdout);
416
+ }
417
+ catch (error) {
418
+ throw new Error(`Offline tarball ${basename(path)} has invalid package/package.json: ${error.message}`, { cause: error });
419
+ }
420
+ };
421
+ const stableDependencies = (value = {}) => Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)));
422
+ const satisfiesOfflineSpec = (version, rawSpec) => {
423
+ const spec = rawSpec.replace(/^workspace:/, '');
424
+ if (version === spec)
425
+ return true;
426
+ const versionMatch = version.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
427
+ const rangeMatch = spec.match(/^\^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
428
+ if (!versionMatch || !rangeMatch)
429
+ return false;
430
+ const actual = versionMatch.slice(1, 4).map(Number);
431
+ const minimum = rangeMatch.slice(1, 4).map(Number);
432
+ const atLeastMinimum = actual[0] > minimum[0] ||
433
+ (actual[0] === minimum[0] &&
434
+ (actual[1] > minimum[1] || (actual[1] === minimum[1] && actual[2] >= minimum[2])));
435
+ if (!atLeastMinimum)
436
+ return false;
437
+ if (minimum[0] > 0)
438
+ return actual[0] === minimum[0];
439
+ if (minimum[1] > 0)
440
+ return actual[0] === 0 && actual[1] === minimum[1];
441
+ return actual[0] === 0 && actual[1] === 0 && actual[2] === minimum[2];
442
+ };
443
+ for (const archive of archives) {
444
+ const unresolvedPath = join(physicalDirectory, archive);
445
+ const details = await lstat(unresolvedPath);
446
+ if (!details.isFile() || details.isSymbolicLink())
447
+ throw new Error(`Offline tarball is not a regular file: ${archive}.`);
448
+ const path = await realpath(unresolvedPath);
449
+ if (!isContained(physicalDirectory, path))
450
+ throw new Error(`Offline tarball escapes its manifest directory: ${archive}.`);
451
+ const declared = integrityManifest.files[archive];
452
+ const expectedIntegrity = declared?.integrity;
453
+ const actualIntegrity = `sha512-${createHash('sha512')
454
+ .update(await readFile(path))
455
+ .digest('base64')}`;
456
+ if (!expectedIntegrity || expectedIntegrity !== actualIntegrity)
457
+ throw new Error(`Offline tarball integrity mismatch: ${archive}.`);
458
+ if (!declared?.packageName ||
459
+ !declared.version ||
460
+ !declared.dependencies ||
461
+ Array.isArray(declared.dependencies)) {
462
+ throw new Error(`Offline manifest metadata is incomplete for ${archive}. Regenerate it with file-viewer prepare.`);
463
+ }
464
+ const packed = inspectTarballManifest(path);
465
+ if (packed.name !== declared.packageName || packed.version !== declared.version) {
466
+ throw new Error(`Offline tarball identity mismatch: ${archive} contains ${String(packed.name)}@${String(packed.version)}, manifest declares ${declared.packageName}@${declared.version}.`);
467
+ }
468
+ const packedDependencies = stableDependencies({
469
+ ...packed.dependencies,
470
+ ...packed.optionalDependencies
471
+ });
472
+ if (JSON.stringify(packedDependencies) !==
473
+ JSON.stringify(stableDependencies(declared.dependencies))) {
474
+ throw new Error(`Offline tarball dependency metadata mismatch: ${archive}.`);
475
+ }
476
+ if (byPackage.has(declared.packageName))
477
+ throw new Error(`Offline directory has duplicate package ${declared.packageName}.`);
478
+ byPackage.set(declared.packageName, {
479
+ version: declared.version,
480
+ path,
481
+ dependencies: declared.dependencies
482
+ });
483
+ }
484
+ const queue = [...required];
485
+ const closure = new Map();
486
+ while (queue.length) {
487
+ const item = queue.shift();
488
+ if (closure.has(item.packageName)) {
489
+ if (!satisfiesOfflineSpec(closure.get(item.packageName).version, item.version))
490
+ throw new Error(`Offline closure has conflicting versions for ${item.packageName}.`);
491
+ continue;
492
+ }
493
+ const archive = byPackage.get(item.packageName);
494
+ if (!archive)
495
+ throw new Error(`Offline directory is missing ${item.packageName}@${item.version}.`);
496
+ if (!satisfiesOfflineSpec(archive.version, item.version))
497
+ throw new Error(`Offline ${item.packageName} is ${archive.version}; expected ${item.version}.`);
498
+ closure.set(item.packageName, archive);
499
+ for (const [packageName, rawVersion] of Object.entries(archive.dependencies)) {
500
+ if (!packageName.startsWith('@file-viewer/') &&
501
+ !['file-viewer-copy-assets', 'msdoc-viewer', 'styled-exceljs'].includes(packageName))
502
+ continue;
503
+ const version = rawVersion.replace(/^workspace:/, '');
504
+ if (!/^\^?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) {
505
+ throw new Error(`Offline dependency ${item.packageName} -> ${packageName} must use an exact or compatible caret version; found ${rawVersion}.`);
506
+ }
507
+ queue.push({ packageName, version });
508
+ }
509
+ }
510
+ return [...closure.entries()]
511
+ .sort(([left], [right]) => left.localeCompare(right))
512
+ .map(([packageName, item]) => ({ packageName, ...item }));
513
+ }
514
+ async function resolvePackageSpecs(required, source, projectRoot) {
515
+ if (source?.kind !== 'offline-directory')
516
+ return required.map((item) => `${item.packageName}@${item.version}`);
517
+ return (await resolveFileViewerOfflinePackages(required, source.directory, projectRoot)).map((item) => item.path);
518
+ }
519
+ function packageBinaryStep(bin, args, cwd, packageName, packageVersion) {
520
+ return {
521
+ command: bin,
522
+ args,
523
+ cwd,
524
+ executableOwner: { packageName, packageVersion, bin }
525
+ };
526
+ }
527
+ const shellQuote = (value) => /^[a-zA-Z0-9_@./,:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
528
+ const displayCommand = (command, args) => [command, ...args.map(shellQuote)].join(' ');
529
+ const configuredYarnVersion = (projectRoot) => {
530
+ const collect = (value) => {
531
+ const matches = new Set();
532
+ for (const match of value.matchAll(/yarn-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\.cjs/g))
533
+ matches.add(match[1]);
534
+ return matches;
535
+ };
536
+ const yarnConfig = join(projectRoot, '.yarnrc.yml');
537
+ if (existsSync(yarnConfig)) {
538
+ const yarnPath = readFileSync(yarnConfig, 'utf8').match(/^\s*yarnPath\s*:\s*["']?([^"'#\r\n]+)["']?\s*(?:#.*)?$/m)?.[1];
539
+ if (yarnPath) {
540
+ const configured = collect(yarnPath.trim());
541
+ return configured.size === 1 ? [...configured][0] : undefined;
542
+ }
543
+ }
544
+ const releases = join(projectRoot, '.yarn', 'releases');
545
+ const matches = new Set();
546
+ if (existsSync(releases)) {
547
+ for (const filename of readdirSync(releases)) {
548
+ for (const version of collect(filename))
549
+ matches.add(version);
550
+ }
551
+ }
552
+ return matches.size === 1 ? [...matches][0] : undefined;
553
+ };
554
+ const applicationImportStatement = (projectRoot, generatedModule, entry) => {
555
+ if (!entry)
556
+ return `Import ${generatedModule} from your application entry.`;
557
+ const entryPath = resolve(projectRoot, normalizeProjectRelativePath(entry, 'entry'));
558
+ const generatedPath = resolve(projectRoot, normalizeProjectRelativePath(generatedModule, 'generatedModule'));
559
+ let relation = relative(dirname(entryPath), generatedPath).replace(/\\/g, '/');
560
+ if (!relation.startsWith('.'))
561
+ relation = `./${relation}`;
562
+ return `import ${JSON.stringify(relation)};`;
563
+ };
564
+ export async function createFileViewerInstallPlan(configInput, options = {}) {
565
+ const config = normalizeFileViewerConfig(configInput);
566
+ const catalog = await loadFileViewerCliCatalog();
567
+ const effectiveAssetTarget = normalizeProjectRelativePath(options.assetTarget ?? config.assetTarget, 'assetTarget');
568
+ const frameworkEntry = catalog.frameworkOverrides?.[config.profile]?.[config.framework] ??
569
+ catalog.frameworks[config.framework];
570
+ if (!frameworkEntry)
571
+ throw new Error(`No component package is registered for ${config.framework}.`);
572
+ const profile = config.profile === 'custom' || config.profile === 'full'
573
+ ? null
574
+ : catalog.profiles.find((candidate) => candidate.id === config.profile);
575
+ if (config.profile !== 'custom' && config.profile !== 'full' && !profile) {
576
+ throw new Error(`Profile ${config.profile} is not available in this CLI catalog.`);
577
+ }
578
+ const requested = normalizeList([...config.formats, ...config.capabilities]);
579
+ if (config.profile === 'custom' && requested.length === 0) {
580
+ throw new Error('The custom profile requires at least one --format or --capability. No files were written.');
581
+ }
582
+ const explicitlySelectedCapabilities = requested.map((token) => {
583
+ const capability = capabilityForToken(token, catalog);
584
+ if (!capability) {
585
+ const candidates = catalog.capabilities
586
+ .flatMap((item) => [item.id, ...item.rendererIds, ...item.formats])
587
+ .filter((candidate) => candidate.includes(token) || token.includes(candidate))
588
+ .slice(0, 5);
589
+ const suggestion = candidates.length ? ` Did you mean ${candidates.join(', ')}?` : '';
590
+ throw new Error(`No File Viewer capability provides "${token}".${suggestion} Run file-viewer list to inspect valid tokens.`);
591
+ }
592
+ return capability;
593
+ });
594
+ const selectedCapabilities = config.profile === 'full'
595
+ ? [
596
+ ...new Map([
597
+ ...catalog.capabilities.filter((capability) => (catalog.legacyFull?.excludedFutureCapabilities ?? []).includes(capability.id)),
598
+ ...explicitlySelectedCapabilities.filter((capability) => (catalog.legacyFull?.excludedFutureCapabilities ?? []).includes(capability.id))
599
+ ].map((capability) => [capability.packageName, capability])).values()
600
+ ]
601
+ : explicitlySelectedCapabilities;
602
+ const profilePackages = new Set(profile?.capabilityPackages ?? []);
603
+ const capabilityPackages = [
604
+ ...new Set(selectedCapabilities
605
+ .map((capability) => capability.packageName)
606
+ .filter((packageName) => !profilePackages.has(packageName)))
607
+ ].sort();
608
+ const projectRoot = options.projectRoot ?? process.cwd();
609
+ const manager = options.packageManager ?? config.packageManager ?? detectPackageManager(projectRoot);
610
+ if (!allowedPackageManagers.has(manager)) {
611
+ throw new Error(`Unknown package manager "${manager}".`);
612
+ }
613
+ const profileCapabilities = profile
614
+ ? catalog.capabilities.filter((capability) => profilePackages.has(capability.packageName))
615
+ : [];
616
+ const activeCapabilities = [
617
+ ...new Map([...profileCapabilities, ...selectedCapabilities].map((capability) => [
618
+ capability.packageName,
619
+ capability
620
+ ])).values()
621
+ ];
622
+ const legacyFullCapabilities = config.profile === 'full'
623
+ ? catalog.capabilities.filter((capability) => !(catalog.legacyFull?.excludedFutureCapabilities ?? []).includes(capability.id))
624
+ : [];
625
+ const reportingCapabilities = [
626
+ ...new Map([...legacyFullCapabilities, ...activeCapabilities].map((capability) => [
627
+ capability.packageName,
628
+ capability
629
+ ])).values()
630
+ ];
631
+ const assetGroups = new Map();
632
+ const missingAssetRendererIds = [];
633
+ for (const capability of activeCapabilities) {
634
+ const assets = capability.assets;
635
+ if (!assets.rendererIds.length)
636
+ continue;
637
+ if (!assets.packageName ||
638
+ !assets.bin ||
639
+ !assets.target ||
640
+ !assets.copyMode ||
641
+ !assets.copyGroups ||
642
+ !assets.receiptFilename) {
643
+ missingAssetRendererIds.push(...assets.rendererIds);
644
+ continue;
645
+ }
646
+ if (assets.packageName === 'file-viewer-copy-assets' || assets.copyMode === 'renderer-groups') {
647
+ throw new Error(`Capability ${capability.id} still uses the legacy aggregate asset carrier; v3 requires an independent owner.`);
648
+ }
649
+ const target = effectiveAssetTarget;
650
+ if (!assets.packageVersion || !assets.installerPackageVersion) {
651
+ throw new Error(`Capability ${capability.id} is missing frozen asset package versions.`);
652
+ }
653
+ const key = `${assets.packageName}\u0000${target}\u0000${assets.copyMode}`;
654
+ const group = assetGroups.get(key) ?? {
655
+ packageName: assets.packageName,
656
+ packageVersion: assets.packageVersion,
657
+ installerPackageName: assets.installerPackageName ?? assets.packageName,
658
+ installerPackageVersion: assets.installerPackageVersion,
659
+ bin: assets.bin,
660
+ target,
661
+ copyMode: assets.copyMode,
662
+ receiptFilename: assets.receiptFilename,
663
+ copyGroups: new Set(),
664
+ rendererIds: new Set()
665
+ };
666
+ assets.copyGroups.forEach((item) => group.copyGroups.add(item));
667
+ assets.rendererIds.forEach((item) => group.rendererIds.add(item));
668
+ assetGroups.set(key, group);
669
+ }
670
+ const assetPackages = [
671
+ ...new Set([...assetGroups.values()].map((group) => group.packageName))
672
+ ].sort();
673
+ const versionByPackage = new Map();
674
+ const requirePackage = (packageName, version) => {
675
+ const existing = versionByPackage.get(packageName);
676
+ if (existing && existing !== version)
677
+ throw new Error(`Catalog version conflict for ${packageName}: ${existing} vs ${version}.`);
678
+ versionByPackage.set(packageName, version);
679
+ };
680
+ requirePackage(catalog.core.packageName, catalog.core.version);
681
+ requirePackage(frameworkEntry.packageName, frameworkEntry.version);
682
+ if (profile)
683
+ requirePackage(profile.packageName, profile.version);
684
+ for (const capability of selectedCapabilities)
685
+ requirePackage(capability.packageName, capability.version);
686
+ for (const group of assetGroups.values()) {
687
+ requirePackage(group.packageName, group.packageVersion);
688
+ requirePackage(group.installerPackageName, group.installerPackageVersion);
689
+ }
690
+ const packages = [...versionByPackage.keys()];
691
+ const requiredPackages = packages.map((packageName) => ({
692
+ packageName,
693
+ version: versionByPackage.get(packageName)
694
+ }));
695
+ const packageSpecs = await resolvePackageSpecs(requiredPackages, config.source, projectRoot);
696
+ const assetRendererIds = [
697
+ ...new Set([...assetGroups.values()].flatMap((group) => [...group.rendererIds]))
698
+ ].sort();
699
+ const install = installStep(manager, packageSpecs, projectRoot, config.source, config.packageManagerVersion);
700
+ const assetSteps = [...assetGroups.values()]
701
+ .sort((left, right) => left.packageName.localeCompare(right.packageName))
702
+ .map((group) => {
703
+ const copyGroups = [...group.copyGroups].sort();
704
+ const binArgs = [group.target];
705
+ const binary = packageBinaryStep(group.bin, binArgs, projectRoot, group.installerPackageName, group.installerPackageVersion);
706
+ return {
707
+ id: `assets:${group.packageName}`,
708
+ kind: 'assets',
709
+ ...binary,
710
+ assetOwner: {
711
+ packageName: group.packageName,
712
+ packageVersion: group.packageVersion,
713
+ target: group.target,
714
+ copyGroups,
715
+ receiptFilename: group.receiptFilename,
716
+ expectedProfileManifestSha256: group.packageName === profile?.assetPackageName
717
+ ? profile?.profileManifestSha256
718
+ : undefined
719
+ }
720
+ };
721
+ });
722
+ if (config.profile === 'full') {
723
+ const binary = packageBinaryStep('file-viewer-copy-assets', [effectiveAssetTarget], projectRoot, config.framework === 'web' ? frameworkEntry.packageName : 'file-viewer-copy-assets', config.framework === 'web' ? frameworkEntry.version : catalog.core.version);
724
+ assetSteps.push({ id: 'assets:file-viewer-copy-assets', kind: 'assets', ...binary });
725
+ }
726
+ const licenseNotices = new Map();
727
+ for (const capability of reportingCapabilities) {
728
+ licenseNotices.set(capability.packageName, {
729
+ packageName: capability.packageName,
730
+ spdx: capability.license.spdx,
731
+ policy: capability.license.policy
732
+ });
733
+ for (const notice of capability.license.notices ?? []) {
734
+ licenseNotices.set(notice.packageName, {
735
+ packageName: notice.packageName,
736
+ spdx: notice.spdx,
737
+ policy: 'transitive-notice'
738
+ });
739
+ }
740
+ }
741
+ const assetCommands = assetSteps.map((step) => displayCommand(step.command, step.args));
742
+ return {
743
+ schemaVersion: 1,
744
+ framework: config.framework,
745
+ profile: config.profile,
746
+ packageManager: manager,
747
+ ...(config.packageManagerVersion
748
+ ? { packageManagerVersion: config.packageManagerVersion }
749
+ : {}),
750
+ packages,
751
+ packageSpecs,
752
+ requiredPackages,
753
+ capabilityPackages,
754
+ heavyCapabilities: [
755
+ ...new Set(reportingCapabilities
756
+ .filter((capability) => capability.weight === 'heavy')
757
+ .map((capability) => capability.id))
758
+ ].sort(),
759
+ licenseNotices: [...licenseNotices.values()].sort((left, right) => left.packageName.localeCompare(right.packageName)),
760
+ assetPackages: config.profile === 'full'
761
+ ? [config.framework === 'web' ? frameworkEntry.packageName : 'file-viewer-copy-assets']
762
+ : assetPackages,
763
+ assetRendererIds,
764
+ missingAssetRendererIds: [...new Set(missingAssetRendererIds)].sort(),
765
+ estimates: requested.length ? null : (profile?.estimates ?? null),
766
+ ...(config.profile === 'full' && catalog.legacyFull?.referenceColdInstall
767
+ ? { legacyFullEstimate: catalog.legacyFull.referenceColdInstall }
768
+ : {}),
769
+ steps: [install, ...assetSteps],
770
+ command: displayCommand(install.command, install.args),
771
+ assetCommands,
772
+ assetCommand: assetCommands.join('\n'),
773
+ assetTarget: effectiveAssetTarget,
774
+ generatedModule: config.generatedModule,
775
+ integrationImport: applicationImportStatement(projectRoot, config.generatedModule, config.entry)
776
+ };
777
+ }
778
+ export function executeFileViewerPlanStep(step, options = {}) {
779
+ if (!options.confirmed)
780
+ return { executed: false, step, status: null };
781
+ const runner = options.runner ?? spawnSync;
782
+ let command = step.command;
783
+ let commandArgs = step.args;
784
+ const environment = step.env ? { ...process.env, ...step.env } : process.env;
785
+ if (step.expectedExecutableVersion) {
786
+ const probe = runner(command, ['--version'], {
787
+ cwd: step.cwd,
788
+ encoding: 'utf8',
789
+ shell: false,
790
+ env: environment
791
+ });
792
+ if (probe.error)
793
+ throw probe.error;
794
+ const actual = String(probe.stdout ?? '').trim();
795
+ if (probe.status !== 0 || actual !== step.expectedExecutableVersion) {
796
+ throw new Error(`${command} resolved to ${actual || `status ${String(probe.status)}`}; expected exact ${step.expectedExecutableVersion}. Activate that package-manager version (for example with Corepack) before continuing.`);
797
+ }
798
+ }
799
+ if (step.executableOwner) {
800
+ const require = createRequire(resolve(step.cwd, 'package.json'));
801
+ let packagePath;
802
+ try {
803
+ packagePath = require.resolve(`${step.executableOwner.packageName}/package.json`);
804
+ }
805
+ catch (error) {
806
+ throw new Error(`Required local asset executable ${step.executableOwner.packageName}@${step.executableOwner.packageVersion} is not installed; refusing registry fallback. ${error.message}`, { cause: error });
807
+ }
808
+ const manifest = JSON.parse(readFileSync(packagePath, 'utf8'));
809
+ if (manifest.version !== step.executableOwner.packageVersion) {
810
+ throw new Error(`Local asset executable ${step.executableOwner.packageName} is ${String(manifest.version)}; expected ${step.executableOwner.packageVersion}.`);
811
+ }
812
+ const relativeBin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[step.executableOwner.bin];
813
+ if (!relativeBin) {
814
+ throw new Error(`${step.executableOwner.packageName}@${step.executableOwner.packageVersion} does not declare ${step.executableOwner.bin}.`);
815
+ }
816
+ const packageRoot = realpathSync(dirname(packagePath));
817
+ const executable = realpathSync(resolve(packageRoot, relativeBin));
818
+ const relation = relative(packageRoot, executable);
819
+ if (!relation || relation.startsWith('..') || isAbsolute(relation))
820
+ throw new Error(`Local asset executable ${step.executableOwner.bin} escapes its owner package.`);
821
+ if (!statSync(executable).isFile())
822
+ throw new Error(`Local asset executable ${step.executableOwner.bin} is not a regular file.`);
823
+ command = process.execPath;
824
+ commandArgs = [executable, ...step.args];
825
+ }
826
+ const result = runner(command, commandArgs, {
827
+ cwd: step.cwd,
828
+ stdio: 'inherit',
829
+ shell: false,
830
+ env: environment
831
+ });
832
+ if (result.error)
833
+ throw result.error;
834
+ if (result.status !== 0) {
835
+ throw new Error(`${displayCommand(command, commandArgs)} exited with status ${String(result.status)}.`);
836
+ }
837
+ return { executed: true, step, status: result.status };
838
+ }
839
+ export async function reconcileFileViewerAssetOwners(projectRoot, plan) {
840
+ const targets = new Map();
841
+ for (const step of plan.steps.filter((step) => step.kind === 'assets' && step.assetOwner)) {
842
+ const target = step.assetOwner.target;
843
+ const owners = targets.get(target) ?? new Set();
844
+ owners.add(step.assetOwner.packageName);
845
+ targets.set(target, owners);
846
+ }
847
+ if (!targets.size)
848
+ targets.set(plan.assetTarget, new Set());
849
+ const removed = [];
850
+ for (const [target, plannedOwners] of targets) {
851
+ const targetDir = resolve(projectRoot, target);
852
+ const ledger = await readFileViewerAssetLedger(targetDir);
853
+ const installedOwners = new Map();
854
+ for (const entry of ledger.entries)
855
+ installedOwners.set(entry.ownerPackage, entry.receiptFilename);
856
+ for (const path of ledger.paths) {
857
+ for (const owner of path.owners)
858
+ installedOwners.set(owner.packageName, owner.receiptFilename);
859
+ }
860
+ for (const [packageName, receiptFilename] of installedOwners) {
861
+ if (plannedOwners.has(packageName))
862
+ continue;
863
+ if (packageName === 'file-viewer-copy-assets') {
864
+ continue;
865
+ }
866
+ if (!packageName.startsWith('@file-viewer/assets-'))
867
+ continue;
868
+ const result = await uninstallFileViewerCapabilityAssetPack(targetDir, packageName, receiptFilename);
869
+ if (result.changed)
870
+ removed.push({ packageName, receiptFilename, removedFiles: result.removedFiles });
871
+ }
872
+ }
873
+ return removed;
874
+ }
875
+ export async function reconcileFileViewerManagedDependencies(projectRoot, configInput, plan, options = {}) {
876
+ const config = normalizeFileViewerConfig(configInput);
877
+ const manifestPath = resolve(projectRoot, 'package.json');
878
+ const previousText = await readFile(manifestPath, 'utf8');
879
+ const manifest = JSON.parse(previousText);
880
+ const dependencyFields = ['dependencies', 'devDependencies', 'optionalDependencies'];
881
+ const declaredBefore = new Set(dependencyFields.flatMap((field) => Object.keys(manifest[field] ?? {})));
882
+ const required = new Set(plan.requiredPackages.map((item) => item.packageName));
883
+ const catalog = await loadFileViewerCliCatalog();
884
+ const heavyPackages = new Set(catalog.capabilities
885
+ .filter((capability) => capability.weight === 'heavy')
886
+ .flatMap((capability) => [
887
+ capability.packageName,
888
+ capability.assets.packageName,
889
+ capability.assets.installerPackageName
890
+ ])
891
+ .filter((packageName) => Boolean(packageName)));
892
+ const previousManaged = new Set(config.managedPackages ?? []);
893
+ const obsoleteHeavyPackages = [...previousManaged]
894
+ .filter((packageName) => heavyPackages.has(packageName) && !required.has(packageName))
895
+ .sort();
896
+ const removedDeclarations = [];
897
+ for (const packageName of obsoleteHeavyPackages) {
898
+ for (const field of dependencyFields) {
899
+ if (!manifest[field]?.[packageName])
900
+ continue;
901
+ delete manifest[field][packageName];
902
+ removedDeclarations.push(packageName);
903
+ }
904
+ }
905
+ const managedPackages = [
906
+ ...new Set([
907
+ ...[...previousManaged].filter((packageName) => required.has(packageName) && !obsoleteHeavyPackages.includes(packageName)),
908
+ ...[...required].filter((packageName) => !declaredBefore.has(packageName))
909
+ ])
910
+ ].sort();
911
+ const indent = previousText.match(/\n([ \t]+)"/)?.[1] ?? ' ';
912
+ const newline = previousText.includes('\r\n') ? '\r\n' : '\n';
913
+ const nextText = `${JSON.stringify(manifest, null, indent).replace(/\n/g, newline)}${newline}`;
914
+ const manifestChanged = removedDeclarations.length > 0 && previousText !== nextText;
915
+ if (options.write && manifestChanged)
916
+ await atomicWriteText(manifestPath, nextText);
917
+ return {
918
+ obsoleteHeavyPackages,
919
+ removedDeclarations: [...new Set(removedDeclarations)].sort(),
920
+ managedPackages,
921
+ manifestChanged,
922
+ manifestWritten: Boolean(options.write && manifestChanged),
923
+ config: stableConfig({ ...config, managedPackages })
924
+ };
925
+ }
926
+ const atomicWriteText = async (path, content) => {
927
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
928
+ await writeFile(temporary, content, 'utf8');
929
+ await rename(temporary, path);
930
+ };
931
+ export async function initializeFileViewerProject(projectRoot, input, options = {}) {
932
+ const config = normalizeFileViewerConfig(input);
933
+ const configPath = resolveContainedProjectFile(projectRoot, options.configFile ?? DEFAULT_FILE_VIEWER_CONFIG, 'configFile');
934
+ const content = `${JSON.stringify(config, null, 2)}\n`;
935
+ let existing = null;
936
+ try {
937
+ existing = await readFile(configPath, 'utf8');
938
+ }
939
+ catch (error) {
940
+ if (error.code !== 'ENOENT')
941
+ throw error;
942
+ }
943
+ if (existing !== null && existing !== content && !options.force) {
944
+ throw new Error(`${basename(configPath)} already exists with different settings; use --force to replace it.`);
945
+ }
946
+ const changed = existing !== content;
947
+ if (options.write && changed) {
948
+ await mkdir(dirname(configPath), { recursive: true });
949
+ await atomicWriteText(configPath, content);
950
+ }
951
+ return { changed, written: Boolean(options.write && changed), configPath, config };
952
+ }
953
+ function createQuickstartSamplePdf() {
954
+ const stream = 'BT\n/F1 24 Tf\n72 720 Td\n(File Viewer quickstart) Tj\nET\n';
955
+ const objects = [
956
+ '<< /Type /Catalog /Pages 2 0 R >>',
957
+ '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
958
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
959
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
960
+ `<< /Length ${Buffer.byteLength(stream, 'ascii')} >>\nstream\n${stream}endstream`
961
+ ];
962
+ let pdf = '%PDF-1.4\n% File Viewer quickstart\n';
963
+ const offsets = [];
964
+ for (const [index, object] of objects.entries()) {
965
+ offsets.push(Buffer.byteLength(pdf, 'ascii'));
966
+ pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
967
+ }
968
+ const xrefOffset = Buffer.byteLength(pdf, 'ascii');
969
+ const entries = offsets.map((offset) => `${String(offset).padStart(10, '0')} 00000 n `).join('\n');
970
+ return `${pdf}xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${entries}\ntrailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
971
+ }
972
+ const quickstartSamples = {
973
+ pdf: () => ({ filename: 'sample.pdf', content: createQuickstartSamplePdf(), rendererId: 'pdf' }),
974
+ image: () => ({
975
+ filename: 'sample.svg',
976
+ rendererId: 'image',
977
+ content: '<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540"><defs><linearGradient id="g" x2="1" y2="1"><stop stop-color="#2563eb"/><stop offset="1" stop-color="#22c55e"/></linearGradient></defs><rect width="960" height="540" rx="32" fill="url(#g)"/><circle cx="160" cy="270" r="82" fill="white" opacity=".9"/><path d="M125 270h70M160 235v70" stroke="#2563eb" stroke-width="18" stroke-linecap="round"/><text x="280" y="250" fill="white" font-family="system-ui,sans-serif" font-size="58" font-weight="700">File Viewer</text><text x="282" y="315" fill="white" opacity=".86" font-family="system-ui,sans-serif" font-size="28">Lightweight quickstart</text></svg>\n'
978
+ }),
979
+ text: () => ({
980
+ filename: 'sample.md',
981
+ rendererId: 'code',
982
+ content: '# File Viewer quickstart\n\nThis Markdown file is rendered by the lightweight text renderer.\n'
983
+ }),
984
+ model: () => ({
985
+ filename: 'sample.obj',
986
+ rendererId: 'model',
987
+ content: '# File Viewer quickstart triangle\no Quickstart\nv -1 -0.7 0\nv 1 -0.7 0\nv 0 1 0\nf 1 2 3\n'
988
+ }),
989
+ geo: () => ({
990
+ filename: 'sample.geojson',
991
+ rendererId: 'geo',
992
+ content: '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"File Viewer"},"geometry":{"type":"Polygon","coordinates":[[[116.30,39.85],[116.50,39.85],[116.50,40.00],[116.30,40.00],[116.30,39.85]]]}}]}\n'
993
+ }),
994
+ cad: () => ({
995
+ filename: 'sample.dxf',
996
+ rendererId: 'cad',
997
+ content: '0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n30\n0\n11\n100\n21\n100\n31\n0\n0\nCIRCLE\n8\n0\n10\n50\n20\n50\n30\n0\n40\n25\n0\nENDSEC\n0\nEOF\n'
998
+ }),
999
+ drawing: () => ({
1000
+ filename: 'sample.mmd',
1001
+ rendererId: 'drawing',
1002
+ content: 'graph LR\n Upload[Choose a file] --> Preview[Preview locally]\n Preview --> Done[Keep data private]\n'
1003
+ }),
1004
+ email: () => ({
1005
+ filename: 'sample.eml',
1006
+ rendererId: 'email',
1007
+ content: 'From: admin@flyfish.dev\r\nTo: developer@example.test\r\nSubject: File Viewer quickstart\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nThis message is rendered locally by File Viewer.\r\n'
1008
+ }),
1009
+ spreadsheet: () => ({
1010
+ filename: 'sample.csv',
1011
+ rendererId: 'spreadsheet-openxml',
1012
+ content: 'Feature,Status\nBrowser-native,Ready\nOffline assets,Ready\n'
1013
+ }),
1014
+ rtf: () => ({
1015
+ filename: 'sample.rtf',
1016
+ rendererId: 'office-word-binary',
1017
+ content: '{\\rtf1\\ansi\\deff0 {\\fonttbl {\\f0 Arial;}}\\f0\\fs36 File Viewer quickstart\\par\\fs24 Rendered locally.}\n'
1018
+ })
1019
+ };
1020
+ const sampleKindForCapability = (capability) => {
1021
+ const byId = {
1022
+ pdf: 'pdf',
1023
+ 'pdf-identity-font-repair': 'pdf',
1024
+ image: 'image',
1025
+ text: 'text',
1026
+ 'text-tools': 'text',
1027
+ model: 'model',
1028
+ geo: 'geo',
1029
+ cad: 'cad',
1030
+ drawing: 'drawing',
1031
+ 'mermaid-markdown': 'drawing',
1032
+ 'drawio-official': 'drawing',
1033
+ email: 'email',
1034
+ spreadsheet: 'spreadsheet',
1035
+ rtf: 'rtf'
1036
+ };
1037
+ return byId[capability.id] ?? null;
1038
+ };
1039
+ function selectQuickstartSample(config, catalog) {
1040
+ if (['standard', 'office', 'all', 'full'].includes(config.profile))
1041
+ return quickstartSamples.pdf();
1042
+ if (config.profile === 'lite')
1043
+ return quickstartSamples.image();
1044
+ if (config.profile === 'engineering')
1045
+ return quickstartSamples.model();
1046
+ const tokens = normalizeList([...config.formats, ...config.capabilities]);
1047
+ if (!tokens.length) {
1048
+ throw new Error('The custom profile requires at least one --format or --capability. No files were written.');
1049
+ }
1050
+ const capabilities = tokens
1051
+ .map((token) => capabilityForToken(token, catalog))
1052
+ .filter(Boolean);
1053
+ for (const capability of capabilities) {
1054
+ const kind = sampleKindForCapability(capability);
1055
+ if (kind && quickstartSamples[kind]) {
1056
+ const sample = quickstartSamples[kind]();
1057
+ const sampleExtension = sample.filename.split('.').at(-1) ?? '';
1058
+ if (tokens.includes(capability.id) || tokens.includes(sampleExtension))
1059
+ return sample;
1060
+ }
1061
+ }
1062
+ const capability = capabilities[0];
1063
+ if (!capability)
1064
+ throw new Error(`No File Viewer capability provides "${tokens[0]}".`);
1065
+ return {
1066
+ filename: '',
1067
+ content: '',
1068
+ rendererId: capability.rendererIds[0] ?? capability.id,
1069
+ picker: {
1070
+ capabilityId: capability.id,
1071
+ accept: capability.formats.map((format) => `.${format}`).join(',')
1072
+ }
1073
+ };
1074
+ }
1075
+ export async function scaffoldFileViewerQuickstart(projectRoot, input, options = {}) {
1076
+ const config = normalizeFileViewerConfig(input);
1077
+ const catalog = await loadFileViewerCliCatalog();
1078
+ const frameworkPackage = (config.profile === 'full'
1079
+ ? catalog.frameworkOverrides?.full?.[config.framework]
1080
+ : catalog.frameworks[config.framework])?.packageName;
1081
+ if (!frameworkPackage)
1082
+ throw new Error(`No package is available for ${config.framework}/${config.profile}.`);
1083
+ const generatedRelation = relative('src', config.generatedModule).replace(/\\/g, '/');
1084
+ const generatedImport = generatedRelation.startsWith('.')
1085
+ ? generatedRelation
1086
+ : `./${generatedRelation}`;
1087
+ const template = catalog.frameworkTemplates?.[config.framework];
1088
+ if (!template)
1089
+ throw new Error(`The CLI catalog has no validated ${config.framework} project template.`);
1090
+ const frameworkVersion = config.frameworkVersion ?? template.defaultVersion;
1091
+ const selectedTemplate = template.validatedVersions[frameworkVersion];
1092
+ if (!selectedTemplate)
1093
+ throw new Error(`Unsupported ${config.framework} version ${frameworkVersion}. Validated versions: ${Object.keys(template.validatedVersions).join(', ')}.`);
1094
+ const sample = selectQuickstartSample(config, catalog);
1095
+ const sampleUrl = `/${sample.filename}`;
1096
+ const fixtureModuleByFramework = {
1097
+ web: `import ${JSON.stringify(generatedImport)};\nimport { defineFileViewerElement } from ${JSON.stringify(frameworkPackage)};\ndefineFileViewerElement();\nconst viewer = document.createElement('flyfish-file-viewer');\nviewer.setAttribute('src', ${JSON.stringify(sampleUrl)});\nviewer.setAttribute('filename', ${JSON.stringify(sample.filename)});\nviewer.style.cssText = 'display:block;height:100vh';\ndocument.querySelector('#app').append(viewer);\n`,
1098
+ vue3: `import ${JSON.stringify(generatedImport)};\nimport { createApp, h } from 'vue';\nimport { FileViewer } from ${JSON.stringify(frameworkPackage)};\ncreateApp({ render: () => h(FileViewer, { url: ${JSON.stringify(sampleUrl)}, filename: ${JSON.stringify(sample.filename)} }) }).mount('#app');\n`,
1099
+ 'vue2.7': `import Vue from 'vue';\nimport ${JSON.stringify(generatedImport)};\nimport { FileViewer } from ${JSON.stringify(frameworkPackage)};\nnew Vue({ render: h => h(FileViewer, { props: { url: ${JSON.stringify(sampleUrl)}, filename: ${JSON.stringify(sample.filename)} } }) }).$mount('#app');\n`,
1100
+ 'vue2.6': `import Vue from 'vue';\nimport ${JSON.stringify(generatedImport)};\nimport { FileViewer } from ${JSON.stringify(frameworkPackage)};\nnew Vue({ render: h => h(FileViewer, { props: { url: ${JSON.stringify(sampleUrl)}, filename: ${JSON.stringify(sample.filename)} } }) }).$mount('#app');\n`,
1101
+ react: `import ${JSON.stringify(generatedImport)};\nimport React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport { FileViewer } from ${JSON.stringify(frameworkPackage)};\ncreateRoot(document.querySelector('#app')).render(React.createElement(FileViewer, { url: ${JSON.stringify(sampleUrl)}, filename: ${JSON.stringify(sample.filename)} }));\n`,
1102
+ 'react-legacy': `import ${JSON.stringify(generatedImport)};\nimport React from 'react';\nimport ReactDOM from 'react-dom';\nimport { FileViewerLegacy } from ${JSON.stringify(frameworkPackage)};\nReactDOM.render(React.createElement(FileViewerLegacy, { url: ${JSON.stringify(sampleUrl)}, filename: ${JSON.stringify(sample.filename)} }), document.querySelector('#app'));\n`,
1103
+ svelte: selectedTemplate.templateVariant === 'svelte-classic'
1104
+ ? `import ${JSON.stringify(generatedImport)};\nimport App from './App.svelte';\nnew App({ target: document.querySelector('#app') });\n`
1105
+ : `import ${JSON.stringify(generatedImport)};\nimport { mount } from 'svelte';\nimport App from './App.svelte';\nmount(App, { target: document.querySelector('#app') });\n`,
1106
+ jquery: `import ${JSON.stringify(generatedImport)};\nimport $ from 'jquery';\nimport installFileViewer from ${JSON.stringify(frameworkPackage)};\ninstallFileViewer($);\n$('#app').fileViewer({ url: ${JSON.stringify(sampleUrl)}, filename: ${JSON.stringify(sample.filename)} });\n`
1107
+ };
1108
+ const pickerLabel = `Choose a ${sample.picker?.capabilityId ?? 'supported'} file`;
1109
+ const pickerAccept = sample.picker?.accept ?? '';
1110
+ const pickerModuleByFramework = {
1111
+ web: `import ${JSON.stringify(generatedImport)};\nimport { defineFileViewerElement } from ${JSON.stringify(frameworkPackage)};\ndefineFileViewerElement();\nconst root = document.querySelector('#app');\nconst input = document.createElement('input');\ninput.type = 'file';\ninput.accept = ${JSON.stringify(pickerAccept)};\ninput.setAttribute('aria-label', ${JSON.stringify(pickerLabel)});\nconst viewer = document.createElement('flyfish-file-viewer');\nviewer.style.cssText = 'display:block;height:calc(100vh - 44px)';\ninput.addEventListener('change', () => { const file = input.files?.[0]; if (file) { viewer.file = file; viewer.filename = file.name; } });\nroot.append(input, viewer);\n`,
1112
+ vue3: `import ${JSON.stringify(generatedImport)};\nimport { createApp, h, ref } from 'vue';\nimport { FileViewer } from ${JSON.stringify(frameworkPackage)};\ncreateApp({ setup() { const file = ref(null); return () => h('div', { style: 'height:100vh' }, [h('input', { type: 'file', accept: ${JSON.stringify(pickerAccept)}, 'aria-label': ${JSON.stringify(pickerLabel)}, onChange: event => { file.value = event.target.files?.[0] || null; } }), h(FileViewer, { file: file.value, filename: file.value?.name, style: { height: 'calc(100vh - 44px)' } })]); } }).mount('#app');\n`,
1113
+ 'vue2.7': `import Vue from 'vue';\nimport ${JSON.stringify(generatedImport)};\nimport { FileViewer } from ${JSON.stringify(frameworkPackage)};\nnew Vue({ data: () => ({ selectedFile: null }), render(h) { return h('div', { style: { height: '100vh' } }, [h('input', { attrs: { type: 'file', accept: ${JSON.stringify(pickerAccept)}, 'aria-label': ${JSON.stringify(pickerLabel)} }, on: { change: event => { this.selectedFile = event.target.files?.[0] || null; } } }), h(FileViewer, { props: { file: this.selectedFile, filename: this.selectedFile?.name }, style: { height: 'calc(100vh - 44px)' } })]); } }).$mount('#app');\n`,
1114
+ 'vue2.6': `import Vue from 'vue';\nimport ${JSON.stringify(generatedImport)};\nimport { FileViewer } from ${JSON.stringify(frameworkPackage)};\nnew Vue({ data: () => ({ selectedFile: null }), render(h) { return h('div', { style: { height: '100vh' } }, [h('input', { attrs: { type: 'file', accept: ${JSON.stringify(pickerAccept)}, 'aria-label': ${JSON.stringify(pickerLabel)} }, on: { change: event => { this.selectedFile = event.target.files?.[0] || null; } } }), h(FileViewer, { props: { file: this.selectedFile, filename: this.selectedFile?.name }, style: { height: 'calc(100vh - 44px)' } })]); } }).$mount('#app');\n`,
1115
+ react: `import ${JSON.stringify(generatedImport)};\nimport React, { useState } from 'react';\nimport { createRoot } from 'react-dom/client';\nimport { FileViewer } from ${JSON.stringify(frameworkPackage)};\nfunction App() { const [file, setFile] = useState(null); return React.createElement('div', { style: { height: '100vh' } }, React.createElement('input', { type: 'file', accept: ${JSON.stringify(pickerAccept)}, 'aria-label': ${JSON.stringify(pickerLabel)}, onChange: event => setFile(event.target.files?.[0] || null) }), React.createElement(FileViewer, { file, filename: file?.name, style: { height: 'calc(100vh - 44px)' } })); }\ncreateRoot(document.querySelector('#app')).render(React.createElement(App));\n`,
1116
+ 'react-legacy': `import ${JSON.stringify(generatedImport)};\nimport React, { useState } from 'react';\nimport ReactDOM from 'react-dom';\nimport { FileViewerLegacy } from ${JSON.stringify(frameworkPackage)};\nfunction App() { const [file, setFile] = useState(null); return React.createElement('div', { style: { height: '100vh' } }, React.createElement('input', { type: 'file', accept: ${JSON.stringify(pickerAccept)}, 'aria-label': ${JSON.stringify(pickerLabel)}, onChange: event => setFile(event.target.files?.[0] || null) }), React.createElement(FileViewerLegacy, { file, filename: file?.name, style: { height: 'calc(100vh - 44px)' } })); }\nReactDOM.render(React.createElement(App), document.querySelector('#app'));\n`,
1117
+ svelte: fixtureModuleByFramework.svelte,
1118
+ jquery: `import ${JSON.stringify(generatedImport)};\nimport $ from 'jquery';\nimport installFileViewer from ${JSON.stringify(frameworkPackage)};\ninstallFileViewer($);\nconst input = $('<input>', { type: 'file', accept: ${JSON.stringify(pickerAccept)}, 'aria-label': ${JSON.stringify(pickerLabel)} });\ninput.on('change', event => { const file = event.target.files?.[0]; if (file) $('#app').fileViewer({ file, filename: file.name }); });\n$('body').prepend(input);\n`
1119
+ };
1120
+ const moduleByFramework = sample.picker ? pickerModuleByFramework : fixtureModuleByFramework;
1121
+ const devDependencies = {
1122
+ vite: selectedTemplate.viteVersion,
1123
+ ...(selectedTemplate.vitePluginSvelteVersion
1124
+ ? { '@sveltejs/vite-plugin-svelte': selectedTemplate.vitePluginSvelteVersion }
1125
+ : {})
1126
+ };
1127
+ const files = new Map([
1128
+ [
1129
+ 'package.json',
1130
+ `${JSON.stringify({
1131
+ private: true,
1132
+ type: 'module',
1133
+ ...(config.packageManager && config.packageManagerVersion
1134
+ ? { packageManager: `${config.packageManager}@${config.packageManagerVersion}` }
1135
+ : {}),
1136
+ scripts: { dev: 'vite', build: 'vite build' },
1137
+ dependencies: selectedTemplate.runtimeDependencies,
1138
+ devDependencies
1139
+ }, null, 2)}\n`
1140
+ ],
1141
+ [
1142
+ 'index.html',
1143
+ '<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>File Viewer</title></head><body style="margin:0"><div id="app" style="height:100vh"></div><script type="module" src="/src/main.mjs"></script></body></html>\n'
1144
+ ],
1145
+ ...(sample.picker ? [] : [[`public/${sample.filename}`, sample.content]]),
1146
+ ['src/main.mjs', moduleByFramework[config.framework]],
1147
+ ...(config.framework === 'svelte'
1148
+ ? [
1149
+ [
1150
+ 'src/App.svelte',
1151
+ sample.picker
1152
+ ? `<script>\n import { fileViewer } from ${JSON.stringify(`${frameworkPackage}/action`)};\n let selectedFile = null;\n</script>\n\n<input type="file" accept=${JSON.stringify(pickerAccept)} aria-label=${JSON.stringify(pickerLabel)} on:change={event => selectedFile = event.currentTarget.files?.[0] || null} />\n<div use:fileViewer={{ file: selectedFile, filename: selectedFile?.name }}></div>\n\n<style>\n div { height: calc(100vh - 44px); }\n</style>\n`
1153
+ : `<script>\n import { fileViewer } from ${JSON.stringify(`${frameworkPackage}/action`)};\n</script>\n\n<div use:fileViewer={{ url: ${JSON.stringify(sampleUrl)}, filename: ${JSON.stringify(sample.filename)} }}></div>\n\n<style>\n div { height: 100vh; }\n</style>\n`
1154
+ ]
1155
+ ]
1156
+ : []),
1157
+ ...(config.framework === 'svelte'
1158
+ ? [
1159
+ [
1160
+ 'vite.config.mjs',
1161
+ `import { defineConfig } from 'vite';\nimport { svelte } from '@sveltejs/vite-plugin-svelte';\nexport default defineConfig({ plugins: [svelte()] });\n`
1162
+ ]
1163
+ ]
1164
+ : []),
1165
+ ...(config.packageManager === 'yarn' &&
1166
+ Number(config.packageManagerVersion?.match(/^(\d+)/)?.[1] ?? 0) >= 2
1167
+ ? [['.yarnrc.yml', 'nodeLinker: node-modules\nenableGlobalCache: true\n']]
1168
+ : [])
1169
+ ]);
1170
+ const conflicts = [];
1171
+ for (const [relativePath, content] of files) {
1172
+ const previous = await readFile(resolve(projectRoot, relativePath), 'utf8').catch((error) => {
1173
+ if (error.code === 'ENOENT')
1174
+ return null;
1175
+ throw error;
1176
+ });
1177
+ if (previous !== null && previous !== content && !options.force)
1178
+ conflicts.push(relativePath);
1179
+ }
1180
+ if (conflicts.length) {
1181
+ throw new Error(`Scaffold conflicts with existing files: ${conflicts.join(', ')}. No files were written; use --force to replace only these scaffold-owned files.`);
1182
+ }
1183
+ const results = [];
1184
+ for (const [relativePath, content] of files) {
1185
+ const path = resolve(projectRoot, relativePath);
1186
+ const previous = await readFile(path, 'utf8').catch((error) => {
1187
+ if (error.code === 'ENOENT')
1188
+ return null;
1189
+ throw error;
1190
+ });
1191
+ const changed = previous !== content;
1192
+ if (options.write && changed) {
1193
+ await mkdir(dirname(path), { recursive: true });
1194
+ await atomicWriteText(path, content);
1195
+ }
1196
+ results.push({ path, changed, written: Boolean(options.write && changed) });
1197
+ }
1198
+ return { frameworkPackage, frameworkVersion, sample, files: results };
1199
+ }
1200
+ const applicationEntryCandidates = [
1201
+ 'src/main.ts',
1202
+ 'src/main.tsx',
1203
+ 'src/main.js',
1204
+ 'src/main.jsx',
1205
+ 'src/main.mjs',
1206
+ 'src/index.ts',
1207
+ 'src/index.tsx',
1208
+ 'src/index.js',
1209
+ 'src/index.jsx'
1210
+ ];
1211
+ export const listFileViewerApplicationEntries = (projectRoot) => applicationEntryCandidates.filter((candidate) => existsSync(resolve(projectRoot, candidate)));
1212
+ const managedEntryMarker = '// file-viewer:generated-integration';
1213
+ function removeManagedEntryBlock(content) {
1214
+ const newline = content.includes('\r\n') ? '\r\n' : '\n';
1215
+ const lines = content.split(/\r?\n/);
1216
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
1217
+ if (lines[index].trim() !== managedEntryMarker)
1218
+ continue;
1219
+ lines.splice(index, 1);
1220
+ const following = lines[index] ?? '';
1221
+ const managedImport = following.match(/^\s*import\s+(['"])([^'"]+)\1\s*;?\s*$/);
1222
+ if (managedImport && /(?:file-viewer|generated)/i.test(managedImport[2]))
1223
+ lines.splice(index, 1);
1224
+ }
1225
+ return lines.join(newline);
1226
+ }
1227
+ function applicationPrologueOffset(content) {
1228
+ let offset = content.charCodeAt(0) === 0xfeff ? 1 : 0;
1229
+ if (content.startsWith('#!', offset)) {
1230
+ const newline = content.indexOf('\n', offset);
1231
+ offset = newline === -1 ? content.length : newline + 1;
1232
+ }
1233
+ const skipTrivia = (start) => {
1234
+ let cursor = start;
1235
+ while (cursor < content.length) {
1236
+ if (/\s/.test(content[cursor])) {
1237
+ cursor += 1;
1238
+ continue;
1239
+ }
1240
+ if (content.startsWith('//', cursor)) {
1241
+ const newline = content.indexOf('\n', cursor + 2);
1242
+ cursor = newline === -1 ? content.length : newline + 1;
1243
+ continue;
1244
+ }
1245
+ if (content.startsWith('/*', cursor)) {
1246
+ const close = content.indexOf('*/', cursor + 2);
1247
+ cursor = close === -1 ? content.length : close + 2;
1248
+ continue;
1249
+ }
1250
+ break;
1251
+ }
1252
+ return cursor;
1253
+ };
1254
+ let cursor = skipTrivia(offset);
1255
+ let insertionOffset = cursor;
1256
+ while (cursor < content.length) {
1257
+ const lineEnd = content.indexOf('\n', cursor);
1258
+ const end = lineEnd === -1 ? content.length : lineEnd + 1;
1259
+ const line = content.slice(cursor, lineEnd === -1 ? content.length : lineEnd).replace(/\r$/, '');
1260
+ if (!/^(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*;?\s*(?:\/\/.*)?$/.test(line))
1261
+ break;
1262
+ cursor = skipTrivia(end);
1263
+ insertionOffset = cursor;
1264
+ }
1265
+ return insertionOffset;
1266
+ }
1267
+ export async function patchFileViewerApplicationEntry(projectRoot, generatedModule, options = {}) {
1268
+ const availableEntries = listFileViewerApplicationEntries(projectRoot);
1269
+ const entry = options.entry
1270
+ ? normalizeProjectRelativePath(options.entry, 'entry')
1271
+ : availableEntries.length === 1
1272
+ ? availableEntries[0]
1273
+ : null;
1274
+ if (!options.entry && availableEntries.length > 1) {
1275
+ throw new Error(`Multiple application entries were found (${availableEntries.join(', ')}). Pass --entry explicitly.`);
1276
+ }
1277
+ if (!entry)
1278
+ return {
1279
+ entry: null,
1280
+ availableEntries,
1281
+ changed: false,
1282
+ written: false,
1283
+ warning: `No supported application entry was found. Import ./${generatedModule} from your application entry.`
1284
+ };
1285
+ const entryPath = resolve(projectRoot, entry);
1286
+ if (!existsSync(entryPath))
1287
+ throw new Error(`Application entry ${entry} does not exist.`);
1288
+ const generatedPath = resolve(projectRoot, normalizeProjectRelativePath(generatedModule, 'generatedModule'));
1289
+ let relation = relative(dirname(entryPath), generatedPath).replace(/\\/g, '/');
1290
+ if (!relation.startsWith('.'))
1291
+ relation = `./${relation}`;
1292
+ const statement = `import ${JSON.stringify(relation)};`;
1293
+ const previous = await readFile(entryPath, 'utf8');
1294
+ const withoutManagedBlock = removeManagedEntryBlock(previous);
1295
+ let content = withoutManagedBlock;
1296
+ if (!withoutManagedBlock.includes(statement)) {
1297
+ const newline = previous.includes('\r\n') ? '\r\n' : '\n';
1298
+ const offset = applicationPrologueOffset(withoutManagedBlock);
1299
+ const separator = offset > 0 && withoutManagedBlock[offset - 1] !== '\n' ? newline : '';
1300
+ const block = `${managedEntryMarker}${newline}${statement}${newline}`;
1301
+ content = `${withoutManagedBlock.slice(0, offset)}${separator}${block}${withoutManagedBlock.slice(offset)}`;
1302
+ }
1303
+ const changed = content !== previous;
1304
+ if (options.write && changed)
1305
+ await atomicWriteText(entryPath, content);
1306
+ return {
1307
+ entry,
1308
+ availableEntries,
1309
+ entryPath,
1310
+ generatedModule,
1311
+ statement,
1312
+ changed,
1313
+ written: Boolean(options.write && changed),
1314
+ content
1315
+ };
1316
+ }
1317
+ export async function generateFileViewerIntegrationModule(projectRoot, input, options = {}) {
1318
+ const config = normalizeFileViewerConfig(input);
1319
+ const catalog = await loadFileViewerCliCatalog();
1320
+ const profile = config.profile === 'custom' || config.profile === 'full'
1321
+ ? null
1322
+ : (catalog.profiles.find((candidate) => candidate.id === config.profile) ?? null);
1323
+ if (config.profile !== 'custom' && config.profile !== 'full' && !profile)
1324
+ throw new Error(`Unknown generated profile ${config.profile}.`);
1325
+ const explicit = normalizeList([...config.formats, ...config.capabilities]).map((token) => {
1326
+ const capability = capabilityForToken(token, catalog);
1327
+ if (!capability)
1328
+ throw new Error(`No File Viewer capability provides "${token}".`);
1329
+ return capability;
1330
+ });
1331
+ const selectedSource = config.profile === 'full'
1332
+ ? [
1333
+ ...catalog.capabilities.filter((capability) => (catalog.legacyFull?.excludedFutureCapabilities ?? []).includes(capability.id)),
1334
+ ...explicit.filter((capability) => (catalog.legacyFull?.excludedFutureCapabilities ?? []).includes(capability.id))
1335
+ ]
1336
+ : explicit;
1337
+ const selected = [
1338
+ ...new Map(selectedSource.map((capability) => [capability.packageName, capability])).values()
1339
+ ].filter((capability) => !profile?.capabilityPackages.includes(capability.packageName));
1340
+ const imports = [
1341
+ "import { registerFileViewerAutoRendererPreset, setDefaultFileViewerAssetBaseUrl } from '@file-viewer/core';"
1342
+ ];
1343
+ const rendererNames = [];
1344
+ if (profile) {
1345
+ imports.push(`import fileViewerProfilePreset from ${JSON.stringify(profile.packageName)};`);
1346
+ }
1347
+ else if (config.profile === 'full') {
1348
+ const fullPackage = catalog.frameworkOverrides?.full?.[config.framework];
1349
+ if (!fullPackage)
1350
+ throw new Error(`No legacy-compatible full package exists for ${config.framework}.`);
1351
+ const presetEntry = config.framework === 'svelte' ? `${fullPackage.packageName}/action` : fullPackage.packageName;
1352
+ imports.push(`import { fileViewerFullPreset as fileViewerProfilePreset } from ${JSON.stringify(presetEntry)};`);
1353
+ }
1354
+ let rendererIndex = 0;
1355
+ for (const capability of selected) {
1356
+ const activation = capability.activation;
1357
+ if (!activation)
1358
+ throw new Error(`Capability ${capability.id} has no activation contract.`);
1359
+ if (activation.kind === 'renderer-export') {
1360
+ if (!activation.export)
1361
+ throw new Error(`Renderer capability ${capability.id} has no activation export.`);
1362
+ const local = `fileViewerRenderer${rendererIndex++}`;
1363
+ imports.push(`import { ${activation.export} as ${local} } from ${JSON.stringify(activation.import)};`);
1364
+ rendererNames.push(local);
1365
+ }
1366
+ else {
1367
+ imports.push(`import ${JSON.stringify(activation.import)};`);
1368
+ }
1369
+ }
1370
+ const profileRenderers = profile || config.profile === 'full' ? '...fileViewerProfilePreset.renderers,' : '';
1371
+ const generatedAssetBaseUrl = config.assetBaseUrl ?? inferFileViewerLocalAssetBaseUrl(config.assetTarget);
1372
+ const content = `${[
1373
+ '// Generated by @file-viewer/cli. Edit file-viewer.config.json and regenerate instead of editing this file.',
1374
+ ...imports,
1375
+ ...(generatedAssetBaseUrl
1376
+ ? [`setDefaultFileViewerAssetBaseUrl(${JSON.stringify(generatedAssetBaseUrl)});`]
1377
+ : []),
1378
+ '',
1379
+ 'export const fileViewerGeneratedPreset = {',
1380
+ " id: 'file-viewer-generated',",
1381
+ " label: 'Generated File Viewer integration',",
1382
+ ` renderers: [${profileRenderers}${rendererNames.join(',')}],`,
1383
+ '};',
1384
+ "registerFileViewerAutoRendererPreset(fileViewerGeneratedPreset, { id: 'generated', packageName: '@file-viewer/cli' });",
1385
+ 'export default fileViewerGeneratedPreset;',
1386
+ ''
1387
+ ].join('\n')}`;
1388
+ const relativeOutput = normalizeProjectRelativePath(options.output ?? config.generatedModule, 'generatedModule');
1389
+ const outputPath = resolve(projectRoot, relativeOutput);
1390
+ const relation = relative(resolve(projectRoot), outputPath);
1391
+ if (!relation || relation.startsWith('..'))
1392
+ throw new Error('generatedModule escapes the project root.');
1393
+ const previous = await readFile(outputPath, 'utf8').catch((error) => {
1394
+ if (error.code === 'ENOENT')
1395
+ return null;
1396
+ throw error;
1397
+ });
1398
+ const generatedMarker = '// Generated by @file-viewer/cli.';
1399
+ if (previous !== null &&
1400
+ previous !== content &&
1401
+ !previous.startsWith(generatedMarker) &&
1402
+ !options.force) {
1403
+ throw new Error(`Refusing to overwrite user-owned generatedModule ${relativeOutput}; choose another --output or use --force.`);
1404
+ }
1405
+ const changed = previous !== content;
1406
+ if (options.write && changed) {
1407
+ await mkdir(dirname(outputPath), { recursive: true });
1408
+ await atomicWriteText(outputPath, content);
1409
+ }
1410
+ return {
1411
+ outputPath,
1412
+ generatedModule: relativeOutput,
1413
+ content,
1414
+ changed,
1415
+ written: Boolean(options.write && changed),
1416
+ importStatement: applicationImportStatement(projectRoot, relativeOutput, config.entry),
1417
+ selectedCapabilities: selected.map((capability) => capability.id)
1418
+ };
1419
+ }
1420
+ export async function updateFileViewerProjectSelection(projectRoot, tokenInput, action, options = {}) {
1421
+ const configFile = options.configFile ?? DEFAULT_FILE_VIEWER_CONFIG;
1422
+ const configPath = resolveContainedProjectFile(projectRoot, configFile, 'configFile');
1423
+ const config = await readFileViewerProjectConfig(projectRoot, configFile);
1424
+ const catalog = await loadFileViewerCliCatalog();
1425
+ const token = normalizeList([tokenInput])[0];
1426
+ if (!token)
1427
+ throw new Error('A format or capability token is required.');
1428
+ const capability = capabilityForToken(token, catalog);
1429
+ if (!capability) {
1430
+ const candidates = catalog.capabilities
1431
+ .flatMap((item) => [item.id, ...item.formats, ...item.rendererIds])
1432
+ .filter((candidate) => candidate.includes(token) || token.includes(candidate))
1433
+ .slice(0, 5);
1434
+ throw new Error(`Unknown File Viewer token "${token}".${candidates.length ? ` Try ${candidates.join(', ')}.` : ''} Run file-viewer list to inspect valid tokens.`);
1435
+ }
1436
+ const profile = config.profile === 'custom' || config.profile === 'full'
1437
+ ? null
1438
+ : (catalog.profiles.find((item) => item.id === config.profile) ?? null);
1439
+ const isCapabilityId = capability.id === token || capability.rendererIds.includes(token);
1440
+ const field = isCapabilityId ? 'capabilities' : 'formats';
1441
+ const values = new Set(config[field]);
1442
+ const providedByProfile = Boolean(profile?.capabilityPackages.includes(capability.packageName) ||
1443
+ (config.profile === 'full' &&
1444
+ !(catalog.legacyFull?.excludedFutureCapabilities ?? []).includes(capability.id)));
1445
+ if (action === 'remove' && !values.has(token) && providedByProfile) {
1446
+ throw new Error(`Cannot remove "${token}" from the fixed ${config.profile} profile; choose custom or another profile.`);
1447
+ }
1448
+ if (action === 'add') {
1449
+ if (!providedByProfile)
1450
+ values.add(token);
1451
+ }
1452
+ else {
1453
+ values.delete(token);
1454
+ }
1455
+ const next = stableConfig({ ...config, [field]: [...values] });
1456
+ const content = `${JSON.stringify(next, null, 2)}\n`;
1457
+ const existing = await readFile(configPath, 'utf8');
1458
+ const changed = existing !== content;
1459
+ if (options.write && changed)
1460
+ await atomicWriteText(configPath, content);
1461
+ return {
1462
+ action,
1463
+ token,
1464
+ capability: capability.id,
1465
+ field,
1466
+ providedByProfile,
1467
+ changed,
1468
+ written: Boolean(options.write && changed),
1469
+ configPath,
1470
+ config: next
1471
+ };
1472
+ }
1473
+ const verifyAssetReceipt = async (projectRoot, owner) => {
1474
+ const targetDir = resolve(projectRoot, owner.target);
1475
+ const receiptPath = resolve(targetDir, owner.receiptFilename);
1476
+ const verification = await verifyFileViewerCapabilityAssetReceipt(targetDir, owner.receiptFilename);
1477
+ const errors = [...verification.errors];
1478
+ const receipt = verification.receipt;
1479
+ if (!receipt)
1480
+ return errors.length ? errors : [`Missing asset receipt ${receiptPath}.`];
1481
+ if (receipt.packageName !== owner.packageName ||
1482
+ receipt.packageVersion !== owner.packageVersion) {
1483
+ errors.push(`Asset receipt version is stale: expected ${owner.packageName}@${owner.packageVersion}.`);
1484
+ }
1485
+ if (owner.expectedProfileManifestSha256 &&
1486
+ receipt.profileManifestSha256 !== owner.expectedProfileManifestSha256) {
1487
+ errors.push(`Asset receipt profile manifest hash is stale: ${owner.packageName}.`);
1488
+ }
1489
+ if (receipt.copyGroups) {
1490
+ const installedGroups = new Set(receipt.copyGroups);
1491
+ const missing = owner.copyGroups.filter((group) => !installedGroups.has(group));
1492
+ if (missing.length)
1493
+ errors.push(`Receipt ${owner.receiptFilename} is missing groups: ${missing.join(', ')}.`);
1494
+ }
1495
+ try {
1496
+ const require = createRequire(resolve(projectRoot, 'package.json'));
1497
+ const packageJsonPath = require.resolve(`${owner.packageName}/package.json`);
1498
+ const packageRoot = dirname(packageJsonPath);
1499
+ const manifestCandidates = [
1500
+ resolve(packageRoot, 'viewer/file-viewer-asset-pack.json'),
1501
+ resolve(packageRoot, 'viewer/flyfish-viewer-assets.json')
1502
+ ];
1503
+ const found = [];
1504
+ for (const path of manifestCandidates) {
1505
+ try {
1506
+ found.push({ path, content: await readFile(path) });
1507
+ }
1508
+ catch (error) {
1509
+ if (error.code !== 'ENOENT')
1510
+ throw error;
1511
+ }
1512
+ }
1513
+ if (found.length !== 1) {
1514
+ errors.push(`Expected one installed asset manifest for ${owner.packageName}; found ${found.length}.`);
1515
+ }
1516
+ else {
1517
+ const installedManifestSha256 = createHash('sha256').update(found[0].content).digest('hex');
1518
+ if (installedManifestSha256 !== receipt.assetManifestSha256) {
1519
+ errors.push(`Asset receipt manifest hash is stale or forged: ${owner.packageName}.`);
1520
+ }
1521
+ const installedManifest = JSON.parse(found[0].content.toString('utf8'));
1522
+ if (installedManifest.packageName !== owner.packageName ||
1523
+ installedManifest.packageVersion !== owner.packageVersion) {
1524
+ errors.push(`Installed asset manifest version is stale: expected ${owner.packageName}@${owner.packageVersion}.`);
1525
+ }
1526
+ }
1527
+ }
1528
+ catch (error) {
1529
+ errors.push(`Could not verify installed asset manifest for ${owner.packageName}: ${error.message}`);
1530
+ }
1531
+ const ledger = await readFileViewerAssetLedger(targetDir);
1532
+ for (const file of receipt.files) {
1533
+ const ledgerPath = ledger.paths.find((item) => item.path === file.path && item.sha256 === file.sha256 && item.size === file.size);
1534
+ if (!ledgerPath?.owners.some((candidate) => candidate.packageName === owner.packageName &&
1535
+ candidate.receiptFilename === owner.receiptFilename)) {
1536
+ errors.push(`Asset ledger ownership is missing or stale: ${owner.packageName}:${file.path}.`);
1537
+ }
1538
+ }
1539
+ return errors;
1540
+ };
1541
+ export async function doctorFileViewerProject(projectRoot, configFile = DEFAULT_FILE_VIEWER_CONFIG, packageManager) {
1542
+ const configPath = resolveContainedProjectFile(projectRoot, configFile, 'configFile');
1543
+ const config = await readFileViewerProjectConfig(projectRoot, configFile);
1544
+ const plan = await createFileViewerInstallPlan(config, { projectRoot, packageManager });
1545
+ const errors = [];
1546
+ const warnings = [];
1547
+ let projectPackage = {};
1548
+ try {
1549
+ projectPackage = JSON.parse(await readFile(resolve(projectRoot, 'package.json'), 'utf8'));
1550
+ }
1551
+ catch (error) {
1552
+ errors.push(`Could not read ${resolve(projectRoot, 'package.json')}: ${error.message}`);
1553
+ }
1554
+ const installed = { ...projectPackage.dependencies, ...projectPackage.devDependencies };
1555
+ const require = createRequire(resolve(projectRoot, 'package.json'));
1556
+ const verifiedOfflinePackages = config?.source?.kind === 'offline-directory'
1557
+ ? new Map((await resolveFileViewerOfflinePackages(plan.requiredPackages, config.source.directory, projectRoot)).map((item) => [item.packageName, item]))
1558
+ : null;
1559
+ for (const required of plan.requiredPackages) {
1560
+ const declared = installed[required.packageName];
1561
+ if (!declared) {
1562
+ errors.push(`Missing dependency ${required.packageName}@${required.version}.`);
1563
+ continue;
1564
+ }
1565
+ const verifiedOfflinePackage = verifiedOfflinePackages?.get(required.packageName);
1566
+ if (verifiedOfflinePackage) {
1567
+ try {
1568
+ if (!declared.startsWith('file:'))
1569
+ throw new Error('the declaration is not a file reference');
1570
+ const rawPath = declared.startsWith('file://')
1571
+ ? fileURLToPath(declared)
1572
+ : declared.slice('file:'.length);
1573
+ const declaredPath = await realpath(resolve(projectRoot, rawPath));
1574
+ if (declaredPath !== verifiedOfflinePackage.path) {
1575
+ throw new Error(`it resolves to ${declaredPath}`);
1576
+ }
1577
+ }
1578
+ catch (error) {
1579
+ errors.push(`Dependency ${required.packageName} must reference its verified offline tarball for ${required.version}; found ${declared} (${error.message}).`);
1580
+ }
1581
+ }
1582
+ else if (declared !== required.version) {
1583
+ errors.push(`Dependency ${required.packageName} must be pinned to ${required.version}; found ${declared}.`);
1584
+ }
1585
+ try {
1586
+ const resolvedPackageJson = require.resolve(`${required.packageName}/package.json`);
1587
+ const resolved = JSON.parse(await readFile(resolvedPackageJson, 'utf8'));
1588
+ if (resolved.version !== required.version) {
1589
+ errors.push(`Resolved ${required.packageName}@${String(resolved.version)}; catalog requires ${required.version}.`);
1590
+ }
1591
+ }
1592
+ catch (error) {
1593
+ errors.push(`Could not resolve installed ${required.packageName}@${required.version}: ${error.message}`);
1594
+ }
1595
+ }
1596
+ if (plan.heavyCapabilities.length) {
1597
+ warnings.push(`Heavy capabilities are enabled explicitly: ${plan.heavyCapabilities.join(', ')}.`);
1598
+ }
1599
+ if (plan.missingAssetRendererIds.length) {
1600
+ errors.push(`Selected capabilities need separately owned assets: ${plan.missingAssetRendererIds.join(', ')}.`);
1601
+ }
1602
+ for (const step of plan.steps.filter((step) => step.kind === 'assets')) {
1603
+ if (!step.assetOwner) {
1604
+ if (plan.profile === 'full' && step.id === 'assets:file-viewer-copy-assets') {
1605
+ try {
1606
+ const receipt = JSON.parse(await readFile(resolve(projectRoot, plan.assetTarget, 'file-viewer-copy-assets.receipt.json'), 'utf8'));
1607
+ if (receipt.packageName !== 'file-viewer-copy-assets' || !Array.isArray(receipt.files))
1608
+ errors.push('Legacy full asset receipt is invalid.');
1609
+ }
1610
+ catch (error) {
1611
+ errors.push(`Missing legacy full asset receipt: ${error.message}`);
1612
+ }
1613
+ }
1614
+ else
1615
+ errors.push(`Asset step ${step.id} is missing owner metadata.`);
1616
+ continue;
1617
+ }
1618
+ errors.push(...(await verifyAssetReceipt(projectRoot, step.assetOwner)));
1619
+ }
1620
+ for (const target of [
1621
+ ...new Set(plan.steps
1622
+ .filter((step) => step.kind === 'assets' && step.assetOwner)
1623
+ .map((step) => step.assetOwner.target))
1624
+ ]) {
1625
+ const state = await verifyFileViewerAssetState(resolve(projectRoot, target));
1626
+ errors.push(...state.errors);
1627
+ }
1628
+ try {
1629
+ await access(configPath);
1630
+ }
1631
+ catch {
1632
+ errors.push(`Missing ${configPath}.`);
1633
+ }
1634
+ try {
1635
+ const expectedGenerated = await generateFileViewerIntegrationModule(projectRoot, config, {
1636
+ output: plan.generatedModule,
1637
+ force: true
1638
+ });
1639
+ const actualGenerated = await readFile(expectedGenerated.outputPath, 'utf8').catch((error) => {
1640
+ if (error.code === 'ENOENT')
1641
+ return null;
1642
+ throw error;
1643
+ });
1644
+ if (actualGenerated === null) {
1645
+ errors.push(`Missing generated integration module ${plan.generatedModule}.`);
1646
+ }
1647
+ else if (actualGenerated !== expectedGenerated.content) {
1648
+ errors.push(`Generated integration module ${plan.generatedModule} does not match file-viewer.config.json.`);
1649
+ }
1650
+ }
1651
+ catch (error) {
1652
+ errors.push(`Could not verify generated integration module: ${error.message}`);
1653
+ }
1654
+ try {
1655
+ const entryIntegration = await patchFileViewerApplicationEntry(projectRoot, plan.generatedModule, { entry: config.entry });
1656
+ if (entryIntegration.warning)
1657
+ errors.push(entryIntegration.warning);
1658
+ else if (entryIntegration.changed)
1659
+ errors.push(`Application entry ${entryIntegration.entry} does not import ${plan.generatedModule}.`);
1660
+ }
1661
+ catch (error) {
1662
+ errors.push(`Could not verify application entry integration: ${error.message}`);
1663
+ }
1664
+ return { ok: errors.length === 0, configPath, errors, warnings, plan };
1665
+ }