@deneb-ui/cli 2.0.34 → 2.0.36

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,176 @@
1
+ import AdmZip from 'adm-zip';
2
+
3
+ export const TEMPLATE_ARCHIVE_LIMITS = {
4
+ maxEntries: 2_000,
5
+ maxEntryUncompressedBytes: 25 * 1024 * 1024,
6
+ maxTotalUncompressedBytes: 200 * 1024 * 1024,
7
+ maxCompressionRatio: 100,
8
+ } as const;
9
+
10
+ const FORBIDDEN_DIRECTORY_SEGMENTS = new Set([
11
+ '.cache',
12
+ '.git',
13
+ '.next',
14
+ '.npm',
15
+ '.pnpm-store',
16
+ '.turbo',
17
+ '__macosx',
18
+ 'node_modules',
19
+ ]);
20
+
21
+ const FORBIDDEN_GENERATED_DIRECTORIES = new Set([
22
+ 'build',
23
+ 'coverage',
24
+ 'dist',
25
+ 'out',
26
+ ]);
27
+
28
+ export type TemplatePackageArchiveInspection = {
29
+ entries: string[];
30
+ forbiddenEntries: string[];
31
+ };
32
+
33
+ /** Returns the same source-only inventory decision used by upload and the CLI. */
34
+ export function inspectTemplatePackageArchive(
35
+ buffer: Buffer,
36
+ ): TemplatePackageArchiveInspection {
37
+ let entries: ReturnType<AdmZip['getEntries']>;
38
+
39
+ try {
40
+ entries = new AdmZip(buffer).getEntries();
41
+ } catch {
42
+ throw new Error('Template package is not a valid ZIP archive.');
43
+ }
44
+
45
+ const forbiddenEntries = new Set<string>();
46
+ const entryNames: string[] = [];
47
+ let totalUncompressedBytes = 0;
48
+
49
+ if (entries.length > TEMPLATE_ARCHIVE_LIMITS.maxEntries) {
50
+ throw new Error(
51
+ `Template ZIP contains too many entries (${entries.length}; maximum ${TEMPLATE_ARCHIVE_LIMITS.maxEntries}).`,
52
+ );
53
+ }
54
+
55
+ for (const entry of entries) {
56
+ const normalizedName = normalizeTemplatePackageEntryName(entry.entryName);
57
+ entryNames.push(normalizedName);
58
+
59
+ if (isUnsafeTemplatePackageEntryName(normalizedName)) {
60
+ throw new Error(
61
+ `Template ZIP contains an unsafe entry: ${entry.entryName || '(empty path)'}.`,
62
+ );
63
+ }
64
+
65
+ if (!entry.isDirectory) {
66
+ const uncompressedBytes = Number(entry.header.size ?? 0);
67
+ const compressedBytes = Number(entry.header.compressedSize ?? 0);
68
+ if (
69
+ !Number.isSafeInteger(uncompressedBytes) ||
70
+ uncompressedBytes < 0 ||
71
+ uncompressedBytes > TEMPLATE_ARCHIVE_LIMITS.maxEntryUncompressedBytes
72
+ ) {
73
+ throw new Error(
74
+ `Template ZIP entry is too large after extraction: ${normalizedName}.`,
75
+ );
76
+ }
77
+
78
+ totalUncompressedBytes += uncompressedBytes;
79
+ if (
80
+ totalUncompressedBytes >
81
+ TEMPLATE_ARCHIVE_LIMITS.maxTotalUncompressedBytes
82
+ ) {
83
+ throw new Error(
84
+ `Template ZIP expands beyond the ${TEMPLATE_ARCHIVE_LIMITS.maxTotalUncompressedBytes / (1024 * 1024)} MB limit.`,
85
+ );
86
+ }
87
+
88
+ if (
89
+ uncompressedBytes > 1024 * 1024 &&
90
+ uncompressedBytes / Math.max(1, compressedBytes) >
91
+ TEMPLATE_ARCHIVE_LIMITS.maxCompressionRatio
92
+ ) {
93
+ throw new Error(
94
+ `Template ZIP entry has an unsafe compression ratio: ${normalizedName}.`,
95
+ );
96
+ }
97
+ }
98
+
99
+ if (!entry.isDirectory && isForbiddenTemplatePackagePath(normalizedName)) {
100
+ forbiddenEntries.add(normalizedName);
101
+ } else if (
102
+ entry.isDirectory &&
103
+ isForbiddenTemplatePackagePath(normalizedName)
104
+ ) {
105
+ forbiddenEntries.add(normalizedName);
106
+ }
107
+ }
108
+
109
+ return {
110
+ entries: entryNames,
111
+ forbiddenEntries: [...forbiddenEntries],
112
+ };
113
+ }
114
+
115
+ export function formatTemplatePackageArchivePolicyError(
116
+ forbiddenEntries: readonly string[],
117
+ ) {
118
+ const examples = forbiddenEntries.slice(0, 8);
119
+ const remaining = forbiddenEntries.length - examples.length;
120
+ return [
121
+ 'Template ZIP contains generated, cached, secret, log, or nested archive files that must be removed.',
122
+ `Remove: ${examples.join(', ')}${remaining > 0 ? `, and ${remaining} more` : ''}.`,
123
+ 'Create a clean source-only ZIP and upload it again.',
124
+ ].join(' ');
125
+ }
126
+
127
+ export function normalizeTemplatePackageEntryName(entryName: string) {
128
+ return entryName.replace(/\\/g, '/').replace(/^\.\//, '');
129
+ }
130
+
131
+ export function isUnsafeTemplatePackageEntryName(normalizedName: string) {
132
+ const segments = getSegments(normalizedName);
133
+ return (
134
+ !normalizedName ||
135
+ normalizedName.includes('\0') ||
136
+ normalizedName.startsWith('/') ||
137
+ /^[a-z]:\//i.test(normalizedName) ||
138
+ segments.includes('..')
139
+ );
140
+ }
141
+
142
+ export function isForbiddenTemplatePackagePath(entryName: string) {
143
+ const normalizedName = normalizeTemplatePackageEntryName(entryName);
144
+ const segments = getSegments(normalizedName);
145
+ const fileName = segments.at(-1) ?? '';
146
+ const hasForbiddenDirectory = segments.some((segment) =>
147
+ FORBIDDEN_DIRECTORY_SEGMENTS.has(segment),
148
+ );
149
+ const hasYarnCache = segments[0] === '.yarn' && segments[1] === 'cache';
150
+ const hasGeneratedDirectory = segments.some((segment) =>
151
+ FORBIDDEN_GENERATED_DIRECTORIES.has(segment),
152
+ );
153
+ const isEnvironmentFile = fileName === '.env' || fileName.startsWith('.env.');
154
+ const isNestedArchive = fileName.endsWith('.zip');
155
+ const isLogFile = fileName.endsWith('.log');
156
+ const isBuildInfo = fileName.endsWith('.tsbuildinfo');
157
+ const isOsMetadata = fileName === '.ds_store' || fileName === 'thumbs.db';
158
+
159
+ return (
160
+ hasForbiddenDirectory ||
161
+ hasYarnCache ||
162
+ hasGeneratedDirectory ||
163
+ isEnvironmentFile ||
164
+ isNestedArchive ||
165
+ isLogFile ||
166
+ isBuildInfo ||
167
+ isOsMetadata
168
+ );
169
+ }
170
+
171
+ function getSegments(normalizedName: string) {
172
+ return normalizedName
173
+ .split('/')
174
+ .map((segment) => segment.trim().toLowerCase())
175
+ .filter(Boolean);
176
+ }
@@ -0,0 +1,272 @@
1
+ import {
2
+ findDuplicateTemplateEditorPaths,
3
+ normalizeTemplateEditorSchema,
4
+ type TemplateEditorSchema,
5
+ } from './template-editor-schema';
6
+ import type { TemplateVisualEditingConfig } from './template-visual-edit-contract';
7
+ import { parseThemeSchema, type ThemeSchema } from './visual-customization';
8
+
9
+ export type TemplatePackageManifest = {
10
+ framework: 'nextjs-static-export';
11
+ version: number;
12
+ siteDataFile: string;
13
+ contentDefaults?: Record<string, unknown> | null;
14
+ editorSchema?: TemplateEditorSchema | null;
15
+ pages?: Array<{
16
+ id: string;
17
+ label: string;
18
+ route?: string;
19
+ required?: boolean;
20
+ description?: string;
21
+ }>;
22
+ visualEditing?: TemplateVisualEditingConfig | null;
23
+ themeSchema?: ThemeSchema;
24
+ colorPalette?: Array<{
25
+ color: string;
26
+ usageCount: number;
27
+ }>;
28
+ outputDirectory?: string;
29
+ installCommand?: string;
30
+ buildCommand?: string;
31
+ basePathEnvVar?: string;
32
+ publicSiteUrlEnvVar?: string;
33
+ renderedContentPathsByPage?: Record<string, string[]>;
34
+ };
35
+
36
+ type ErrorFactory = (message: string) => Error;
37
+
38
+ const defaultErrorFactory: ErrorFactory = (message) => new Error(message);
39
+ const ALLOWED_INSTALL_COMMANDS = new Set([
40
+ 'npm install',
41
+ 'npm ci',
42
+ 'pnpm install',
43
+ 'pnpm install --frozen-lockfile',
44
+ 'yarn install',
45
+ 'yarn install --frozen-lockfile',
46
+ ]);
47
+ const ALLOWED_BUILD_COMMANDS = new Set([
48
+ 'npm run build',
49
+ 'pnpm run build',
50
+ 'yarn build',
51
+ 'yarn run build',
52
+ ]);
53
+
54
+ /**
55
+ * Canonical parser shared by server-side package intake and the downloadable
56
+ * developer preflight CLI. Keeping the parser here prevents a package from
57
+ * passing locally with manifest rules that differ from upload validation.
58
+ */
59
+ export function normalizeTemplatePackageManifest(
60
+ input: unknown,
61
+ errorFactory: ErrorFactory = defaultErrorFactory,
62
+ ): TemplatePackageManifest {
63
+ const fail = (message: string): never => {
64
+ throw errorFactory(message);
65
+ };
66
+
67
+ if (!input || typeof input !== 'object') {
68
+ fail('Template manifest is missing or invalid.');
69
+ }
70
+
71
+ const candidate = input as Record<string, unknown>;
72
+
73
+ if (candidate.framework !== 'nextjs-static-export') {
74
+ fail('Template manifest framework must be "nextjs-static-export".');
75
+ }
76
+
77
+ if (
78
+ typeof candidate.siteDataFile !== 'string' ||
79
+ !candidate.siteDataFile.trim()
80
+ ) {
81
+ fail('Template manifest siteDataFile is required.');
82
+ }
83
+
84
+ const version =
85
+ typeof candidate.version === 'number' && Number.isFinite(candidate.version)
86
+ ? candidate.version
87
+ : 1;
88
+ const visualEditing = normalizeVisualEditingConfig(
89
+ candidate.visualEditing,
90
+ version,
91
+ errorFactory,
92
+ );
93
+ const editorSchema = normalizeTemplateEditorSchema(candidate.editorSchema);
94
+ const themeSchema = parseThemeSchema(candidate);
95
+ const colorPalette = Array.isArray(candidate.colorPalette)
96
+ ? candidate.colorPalette
97
+ .filter(
98
+ (entry): entry is Record<string, unknown> =>
99
+ Boolean(entry) && typeof entry === 'object',
100
+ )
101
+ .flatMap((entry) => {
102
+ const color =
103
+ typeof entry.color === 'string'
104
+ ? entry.color.trim().toLowerCase()
105
+ : '';
106
+ if (!/^#[0-9a-f]{6}$/.test(color)) return [];
107
+ const usageCount =
108
+ typeof entry.usageCount === 'number' &&
109
+ Number.isFinite(entry.usageCount)
110
+ ? Math.max(1, Math.floor(entry.usageCount))
111
+ : 1;
112
+ return [{ color, usageCount }];
113
+ })
114
+ .slice(0, 40)
115
+ : [];
116
+ const duplicateEditorPaths = findDuplicateTemplateEditorPaths(editorSchema);
117
+ if (duplicateEditorPaths.length > 0) {
118
+ const examples = duplicateEditorPaths
119
+ .slice(0, 5)
120
+ .map((duplicate) => `"${duplicate.path}"`)
121
+ .join(', ');
122
+ fail(
123
+ `Template editorSchema declares the same editable path in more than one place: ${examples}. Keep each primitive field and list in exactly one section; a dedicated nested section must not also be repeated inside its ancestor section.`,
124
+ );
125
+ }
126
+
127
+ const installCommand = normalizePackageCommand(
128
+ candidate.installCommand,
129
+ ALLOWED_INSTALL_COMMANDS,
130
+ 'installCommand',
131
+ fail,
132
+ );
133
+ const buildCommand = normalizePackageCommand(
134
+ candidate.buildCommand,
135
+ ALLOWED_BUILD_COMMANDS,
136
+ 'buildCommand',
137
+ fail,
138
+ );
139
+
140
+ return {
141
+ framework: 'nextjs-static-export',
142
+ version,
143
+ siteDataFile: (candidate.siteDataFile as string).trim(),
144
+ editorSchema,
145
+ pages: Array.isArray(candidate.pages)
146
+ ? candidate.pages
147
+ .filter(
148
+ (page): page is Record<string, unknown> =>
149
+ Boolean(page) && typeof page === 'object',
150
+ )
151
+ .map((page) => {
152
+ const id =
153
+ typeof page.id === 'string' && page.id.trim()
154
+ ? page.id.trim()
155
+ : null;
156
+ const label =
157
+ typeof page.label === 'string' && page.label.trim()
158
+ ? page.label.trim()
159
+ : null;
160
+
161
+ if (!id || !label) {
162
+ return fail(
163
+ 'Each template manifest page must include id and label.',
164
+ );
165
+ }
166
+
167
+ return {
168
+ id,
169
+ label,
170
+ route:
171
+ typeof page.route === 'string' && page.route.trim()
172
+ ? page.route.trim()
173
+ : undefined,
174
+ required:
175
+ typeof page.required === 'boolean' ? page.required : false,
176
+ description:
177
+ typeof page.description === 'string'
178
+ ? page.description.trim()
179
+ : undefined,
180
+ };
181
+ })
182
+ : undefined,
183
+ visualEditing,
184
+ ...(themeSchema ? { themeSchema } : {}),
185
+ ...(colorPalette.length > 0 ? { colorPalette } : {}),
186
+ outputDirectory:
187
+ typeof candidate.outputDirectory === 'string'
188
+ ? candidate.outputDirectory.trim()
189
+ : undefined,
190
+ installCommand,
191
+ buildCommand,
192
+ basePathEnvVar:
193
+ typeof candidate.basePathEnvVar === 'string'
194
+ ? candidate.basePathEnvVar.trim()
195
+ : 'NEXT_PUBLIC_SITE_BASE_PATH',
196
+ publicSiteUrlEnvVar:
197
+ typeof candidate.publicSiteUrlEnvVar === 'string'
198
+ ? candidate.publicSiteUrlEnvVar.trim()
199
+ : undefined,
200
+ };
201
+ }
202
+
203
+ function normalizePackageCommand(
204
+ input: unknown,
205
+ allowed: ReadonlySet<string>,
206
+ fieldName: string,
207
+ fail: (message: string) => never,
208
+ ) {
209
+ if (input === undefined || input === null || input === '') return undefined;
210
+ if (typeof input !== 'string') {
211
+ return fail(`Template manifest ${fieldName} must be a string.`);
212
+ }
213
+ const command = input.trim().replace(/\s+/g, ' ');
214
+ if (!allowed.has(command)) {
215
+ return fail(
216
+ `Template manifest ${fieldName} is not allowed. Use a standard npm, pnpm, or yarn install/build command.`,
217
+ );
218
+ }
219
+ return command;
220
+ }
221
+
222
+ function normalizeVisualEditingConfig(
223
+ input: unknown,
224
+ manifestVersion: number,
225
+ errorFactory: ErrorFactory,
226
+ ): TemplateVisualEditingConfig {
227
+ const fail = (message: string): never => {
228
+ throw errorFactory(message);
229
+ };
230
+
231
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
232
+ if (manifestVersion >= 2) {
233
+ fail(
234
+ 'Template manifest version 2 requires visualEditing.contractVersion 1 and visualEditing.mode "strict".',
235
+ );
236
+ }
237
+ return {
238
+ contractVersion: 1,
239
+ mode: 'legacy',
240
+ };
241
+ }
242
+
243
+ const candidate = input as Record<string, unknown>;
244
+ if (candidate.contractVersion !== 1) {
245
+ fail('Template visualEditing.contractVersion must be 1.');
246
+ }
247
+
248
+ const mode =
249
+ candidate.mode === 'strict' || candidate.mode === 'legacy'
250
+ ? candidate.mode
251
+ : null;
252
+ if (!mode) {
253
+ return fail('Template visualEditing.mode must be "strict" or "legacy".');
254
+ }
255
+ if (manifestVersion >= 2 && mode !== 'strict') {
256
+ fail('Template manifest version 2 requires visualEditing.mode "strict".');
257
+ }
258
+
259
+ const controlOnlyPaths = Array.isArray(candidate.controlOnlyPaths)
260
+ ? candidate.controlOnlyPaths
261
+ .filter((path): path is string => typeof path === 'string')
262
+ .map((path) => path.trim())
263
+ .filter(Boolean)
264
+ : [];
265
+
266
+ return {
267
+ contractVersion: 1,
268
+ mode,
269
+ controlOnlyPaths:
270
+ controlOnlyPaths.length > 0 ? controlOnlyPaths : undefined,
271
+ };
272
+ }