@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.
@@ -0,0 +1,38 @@
1
+ export interface PrepareFileViewerOfflineOptions {
2
+ projectRoot: string;
3
+ directory: string;
4
+ registry: string;
5
+ concurrency?: number;
6
+ }
7
+ export interface FileViewerOfflineManifestFile {
8
+ packageName: string;
9
+ version: string;
10
+ dependencies: Record<string, string>;
11
+ size: number;
12
+ integrity: string;
13
+ }
14
+ export interface FileViewerOfflineManifest {
15
+ schemaVersion: 1;
16
+ release: string;
17
+ createdAt: string;
18
+ registry: string;
19
+ roots: Array<{
20
+ packageName: string;
21
+ version: string;
22
+ }>;
23
+ files: Record<string, FileViewerOfflineManifestFile>;
24
+ boundary: {
25
+ included: string;
26
+ excluded: string;
27
+ };
28
+ }
29
+ export declare const isFileViewerOfflinePackage: (packageName: string) => boolean;
30
+ export declare const fileViewerOfflineTarballFilename: (packageName: string, version: string) => string;
31
+ export declare function prepareFileViewerOfflineDirectory(requiredPackages: readonly {
32
+ packageName: string;
33
+ version: string;
34
+ }[], options: PrepareFileViewerOfflineOptions): Promise<{
35
+ directory: string;
36
+ reused: boolean;
37
+ manifest: FileViewerOfflineManifest;
38
+ }>;
@@ -0,0 +1,264 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { normalizeFileViewerRegistryUrl } from './url-security.js';
7
+ const internalUnscopedPackages = new Set([
8
+ 'create-file-viewer',
9
+ 'file-viewer-cli',
10
+ 'file-viewer-copy-assets',
11
+ 'msdoc-viewer',
12
+ 'styled-exceljs',
13
+ ]);
14
+ export const isFileViewerOfflinePackage = (packageName) => packageName.startsWith('@file-viewer/') || internalUnscopedPackages.has(packageName);
15
+ const hasUnsafeOfflineFilenameCharacter = (value) => {
16
+ for (const character of value) {
17
+ const code = character.charCodeAt(0);
18
+ if (code <= 0x1f || code === 0x7f || character === '\\')
19
+ return true;
20
+ }
21
+ return false;
22
+ };
23
+ export const fileViewerOfflineTarballFilename = (packageName, version) => {
24
+ if (!packageName || !version || hasUnsafeOfflineFilenameCharacter(`${packageName}${version}`)) {
25
+ throw new Error('Offline package name and version must be safe non-empty values.');
26
+ }
27
+ return `${encodeURIComponent(packageName)}-${encodeURIComponent(version)}.tgz`;
28
+ };
29
+ const runNpmView = (spec, registry) => new Promise((resolvePromise, reject) => {
30
+ const args = ['view', spec, 'name', 'version', 'dependencies', 'optionalDependencies', '--json'];
31
+ const child = spawn('npm', args, {
32
+ shell: false,
33
+ env: {
34
+ ...process.env,
35
+ npm_config_registry: registry,
36
+ npm_config_audit: 'false',
37
+ npm_config_fund: 'false',
38
+ npm_config_loglevel: 'error',
39
+ },
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ });
42
+ let stdout = '';
43
+ let stderr = '';
44
+ child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
45
+ child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
46
+ child.once('error', reject);
47
+ child.once('close', status => {
48
+ if (status !== 0) {
49
+ reject(new Error(`npm view ${spec} failed with status ${String(status)}: ${stderr || stdout}`));
50
+ return;
51
+ }
52
+ try {
53
+ const parsed = JSON.parse(stdout);
54
+ if (!parsed.name || !parsed.version)
55
+ throw new Error('registry metadata has no name/version.');
56
+ resolvePromise({
57
+ name: parsed.name,
58
+ version: parsed.version,
59
+ dependencies: { ...parsed.dependencies, ...parsed.optionalDependencies },
60
+ });
61
+ }
62
+ catch (error) {
63
+ reject(new Error(`npm view ${spec} returned invalid JSON: ${error.message}`));
64
+ }
65
+ });
66
+ });
67
+ const runNpmPack = (spec, destination, registry) => new Promise((resolvePromise, reject) => {
68
+ const args = ['pack', '--json', '--ignore-scripts', '--pack-destination', destination, spec];
69
+ const child = spawn('npm', args, {
70
+ shell: false,
71
+ env: {
72
+ ...process.env,
73
+ npm_config_registry: registry,
74
+ npm_config_audit: 'false',
75
+ npm_config_fund: 'false',
76
+ npm_config_loglevel: 'error',
77
+ },
78
+ stdio: ['ignore', 'pipe', 'pipe'],
79
+ });
80
+ let stdout = '';
81
+ let stderr = '';
82
+ child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
83
+ child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
84
+ child.once('error', reject);
85
+ child.once('close', status => {
86
+ if (status !== 0) {
87
+ reject(new Error(`npm pack ${spec} failed with status ${String(status)}: ${stderr || stdout}`));
88
+ return;
89
+ }
90
+ try {
91
+ const result = JSON.parse(stdout);
92
+ const packed = result.at(-1);
93
+ const filename = packed?.filename;
94
+ if (!filename || filename.includes('/') || filename.includes('\\'))
95
+ throw new Error('npm pack returned an unsafe filename.');
96
+ if (!packed?.name || !packed.version)
97
+ throw new Error('npm pack returned no package name/version.');
98
+ resolvePromise({ filename, name: packed.name, version: packed.version });
99
+ }
100
+ catch (error) {
101
+ reject(new Error(`npm pack ${spec} returned invalid JSON: ${error.message}`));
102
+ }
103
+ });
104
+ });
105
+ const normalizeDependencySpec = (value) => value.replace(/^workspace:/, '');
106
+ const satisfiesRequestedVersion = (version, rawSpec) => {
107
+ const spec = normalizeDependencySpec(rawSpec);
108
+ if (version === spec)
109
+ return true;
110
+ const actualMatch = version.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
111
+ const rangeMatch = spec.match(/^\^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
112
+ if (!actualMatch || !rangeMatch)
113
+ return false;
114
+ const actual = actualMatch.slice(1, 4).map(Number);
115
+ const minimum = rangeMatch.slice(1, 4).map(Number);
116
+ const atLeastMinimum = actual[0] > minimum[0] ||
117
+ (actual[0] === minimum[0] && (actual[1] > minimum[1] ||
118
+ (actual[1] === minimum[1] && actual[2] >= minimum[2])));
119
+ if (!atLeastMinimum)
120
+ return false;
121
+ if (minimum[0] > 0)
122
+ return actual[0] === minimum[0];
123
+ if (minimum[1] > 0)
124
+ return actual[0] === 0 && actual[1] === minimum[1];
125
+ return actual[0] === 0 && actual[1] === 0 && actual[2] === minimum[2];
126
+ };
127
+ const verifyExistingDirectory = async (directory, roots, registry) => {
128
+ const path = join(directory, 'file-viewer-offline-manifest.json');
129
+ try {
130
+ const manifest = JSON.parse(await readFile(path, 'utf8'));
131
+ if (manifest.schemaVersion !== 1 || manifest.registry !== registry || !manifest.files || !Array.isArray(manifest.roots))
132
+ return null;
133
+ if (JSON.stringify(manifest.roots) !== JSON.stringify(roots))
134
+ return null;
135
+ const expectedEntries = new Set(['file-viewer-offline-manifest.json', ...Object.keys(manifest.files)]);
136
+ const actualEntries = await readdir(directory);
137
+ if (actualEntries.some(entry => !expectedEntries.has(entry)) || expectedEntries.size !== actualEntries.length)
138
+ return null;
139
+ for (const [filename, item] of Object.entries(manifest.files)) {
140
+ if (!filename.endsWith('.tgz') || filename.includes('/') || filename.includes('\\'))
141
+ return null;
142
+ if (!item.packageName || !item.version || !item.dependencies || Array.isArray(item.dependencies))
143
+ return null;
144
+ const content = await readFile(join(directory, filename));
145
+ if (content.byteLength !== item.size || `sha512-${createHash('sha512').update(content).digest('base64')}` !== item.integrity)
146
+ return null;
147
+ }
148
+ return manifest;
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ };
154
+ export async function prepareFileViewerOfflineDirectory(requiredPackages, options) {
155
+ const registry = normalizeFileViewerRegistryUrl(options.registry);
156
+ const concurrency = options.concurrency ?? 4;
157
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 8)
158
+ throw new Error('prepare concurrency must be an integer from 1 to 8.');
159
+ const roots = [...requiredPackages]
160
+ .sort((left, right) => left.packageName.localeCompare(right.packageName))
161
+ .map(item => ({ packageName: item.packageName, version: item.version }));
162
+ const directory = resolve(options.projectRoot, options.directory);
163
+ if (existsSync(directory)) {
164
+ const existing = await verifyExistingDirectory(directory, roots, registry);
165
+ if (existing)
166
+ return { directory, reused: true, manifest: existing };
167
+ throw new Error(`Refusing to replace non-matching offline directory ${directory}. Choose an empty path.`);
168
+ }
169
+ await mkdir(dirname(directory), { recursive: true });
170
+ const staging = await mkdtemp(join(dirname(directory), '.file-viewer-offline-'));
171
+ const queued = new Map();
172
+ const requestedSpecs = new Map();
173
+ const completed = new Map();
174
+ for (const item of roots) {
175
+ queued.set(item.packageName, item.version);
176
+ requestedSpecs.set(item.packageName, new Set([item.version]));
177
+ }
178
+ try {
179
+ while (queued.size) {
180
+ const batch = [...queued.entries()].slice(0, concurrency);
181
+ batch.forEach(([packageName]) => queued.delete(packageName));
182
+ const results = await Promise.all(batch.map(async ([packageName, requested]) => {
183
+ const manifest = await runNpmView(`${packageName}@${normalizeDependencySpec(requested)}`, registry);
184
+ if (manifest.name !== packageName)
185
+ throw new Error(`Registry returned ${manifest.name} for ${packageName}.`);
186
+ const packDirectory = await mkdtemp(join(staging, '.file-viewer-pack-'));
187
+ try {
188
+ const packed = await runNpmPack(`${packageName}@${manifest.version}`, packDirectory, registry);
189
+ if (packed.name !== manifest.name || packed.version !== manifest.version) {
190
+ throw new Error(`Registry packed ${packed.name}@${packed.version}; expected ${manifest.name}@${manifest.version}.`);
191
+ }
192
+ const filename = fileViewerOfflineTarballFilename(packed.name, packed.version);
193
+ const destination = join(staging, filename);
194
+ if (existsSync(destination)) {
195
+ throw new Error(`Offline tarball filename collision for ${packed.name}@${packed.version}.`);
196
+ }
197
+ await rename(join(packDirectory, packed.filename), destination);
198
+ return { packageName, filename, manifest };
199
+ }
200
+ finally {
201
+ await rm(packDirectory, { recursive: true, force: true });
202
+ }
203
+ }));
204
+ for (const result of results) {
205
+ const requests = requestedSpecs.get(result.packageName) ?? new Set();
206
+ const incompatible = [...requests].filter(spec => !satisfiesRequestedVersion(result.manifest.version, spec));
207
+ if (incompatible.length) {
208
+ throw new Error(`Offline closure resolved ${result.packageName}@${result.manifest.version}, which does not satisfy ${incompatible.join(', ')}.`);
209
+ }
210
+ const previous = completed.get(result.packageName);
211
+ if (previous && previous.manifest.version !== result.manifest.version) {
212
+ throw new Error(`Offline closure resolved conflicting versions for ${result.packageName}.`);
213
+ }
214
+ completed.set(result.packageName, { filename: result.filename, manifest: result.manifest });
215
+ }
216
+ for (const result of results) {
217
+ for (const [dependencyName, dependencySpec] of Object.entries(result.manifest.dependencies)) {
218
+ if (!isFileViewerOfflinePackage(dependencyName))
219
+ continue;
220
+ const requests = requestedSpecs.get(dependencyName) ?? new Set();
221
+ requests.add(dependencySpec);
222
+ requestedSpecs.set(dependencyName, requests);
223
+ const completedDependency = completed.get(dependencyName);
224
+ if (completedDependency && !satisfiesRequestedVersion(completedDependency.manifest.version, dependencySpec)) {
225
+ throw new Error(`Offline closure has conflicting version requirements for ${dependencyName}: ${[...requests].join(', ')}.`);
226
+ }
227
+ if (!completedDependency && !queued.has(dependencyName))
228
+ queued.set(dependencyName, dependencySpec);
229
+ }
230
+ }
231
+ }
232
+ const files = {};
233
+ for (const { filename, manifest } of [...completed.values()].sort((left, right) => left.manifest.name.localeCompare(right.manifest.name))) {
234
+ const content = await readFile(join(staging, filename));
235
+ files[filename] = {
236
+ packageName: manifest.name,
237
+ version: manifest.version,
238
+ dependencies: manifest.dependencies,
239
+ size: content.byteLength,
240
+ integrity: `sha512-${createHash('sha512').update(content).digest('base64')}`,
241
+ };
242
+ }
243
+ const release = roots.find(item => item.packageName === '@file-viewer/core')?.version ?? roots[0]?.version ?? 'unknown';
244
+ const offlineManifest = {
245
+ schemaVersion: 1,
246
+ release,
247
+ createdAt: new Date().toISOString(),
248
+ registry,
249
+ roots,
250
+ files,
251
+ boundary: {
252
+ included: 'Exact File Viewer-owned runtime package closure selected by this profile.',
253
+ excluded: 'Third-party framework and renderer dependencies; use a package-manager cache for an air-gapped install.',
254
+ },
255
+ };
256
+ await writeFile(join(staging, 'file-viewer-offline-manifest.json'), `${JSON.stringify(offlineManifest, null, 2)}\n`, 'utf8');
257
+ await rename(staging, directory);
258
+ return { directory, reused: false, manifest: offlineManifest };
259
+ }
260
+ catch (error) {
261
+ await rm(staging, { recursive: true, force: true });
262
+ throw error;
263
+ }
264
+ }
@@ -0,0 +1,20 @@
1
+ export type FileViewerBuildSystem = 'vite' | 'vue-cli' | 'webpack' | 'next' | 'nuxt' | 'unknown';
2
+ export interface FileViewerProjectAdapterInspection {
3
+ schemaVersion: 1;
4
+ buildSystem: FileViewerBuildSystem;
5
+ safeAutomaticConfiguration: boolean;
6
+ failClosed: boolean;
7
+ assetTarget?: string;
8
+ publicDirectory?: string;
9
+ selectedConfigPath?: string;
10
+ configPaths: string[];
11
+ relevantScripts: Array<{
12
+ name: string;
13
+ command: string;
14
+ }>;
15
+ manualSteps: string[];
16
+ warnings: string[];
17
+ }
18
+ export declare function inspectFileViewerProjectAdapter(projectRoot: string): Promise<FileViewerProjectAdapterInspection>;
19
+ export declare function assertFileViewerProjectAdapterCanWrite(inspection: FileViewerProjectAdapterInspection): FileViewerProjectAdapterInspection;
20
+ export declare function readDeclaredPackageManagerVersion(projectRoot: string, manager: string): string | undefined;