@whatalo/cli-kit 1.0.2 → 1.1.1

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,159 @@
1
+ import { WhataloApiClient } from '../http/index.js';
2
+ import { EventEmitter } from 'node:events';
3
+ import '../types-DunvRQ0f.js';
4
+
5
+ /**
6
+ * BundleBuilder — runs the user-defined build command and collects output files.
7
+ *
8
+ * Uses `spawn` (not `exec`) to avoid shell injection. The build command string
9
+ * is split on whitespace to produce [cmd, ...args].
10
+ *
11
+ * Size limit: 10 MB total across all output files.
12
+ */
13
+ /** Represents a single file collected from the build output directory */
14
+ interface BuildFile {
15
+ /** Path relative to the output directory root (e.g. "assets/main.js") */
16
+ relativePath: string;
17
+ /** Absolute path on disk */
18
+ absolutePath: string;
19
+ /** File size in bytes */
20
+ size: number;
21
+ }
22
+ /** Result returned by BundleBuilder.build() */
23
+ interface BuildResult {
24
+ /** All files found in the output directory */
25
+ files: BuildFile[];
26
+ /** Sum of all file sizes in bytes */
27
+ totalSize: number;
28
+ /** Wall-clock time the build took in milliseconds */
29
+ duration: number;
30
+ }
31
+ /** Formats bytes as a human-readable string (e.g. "1.4 MB") */
32
+ declare function formatSize(bytes: number): string;
33
+ declare class BundleBuilder {
34
+ /**
35
+ * Runs the user-defined build command and collects the output files.
36
+ *
37
+ * @param buildCommand - Command string from config (e.g. "pnpm build")
38
+ * @param outputDir - Build output directory (relative or absolute)
39
+ * @param cwd - Working directory for the command (defaults to process.cwd())
40
+ * @throws If the build exits with a non-zero code or if total size exceeds 10 MB
41
+ */
42
+ build(buildCommand: string, outputDir: string, cwd?: string): Promise<BuildResult>;
43
+ /**
44
+ * Ensures index.html uses relative asset URLs so bundles work under
45
+ * session-scoped CDN paths like /dev-bundles/{sessionId}/.
46
+ */
47
+ private validateIndexHtmlForCdn;
48
+ /**
49
+ * Spawns the build process and waits for it to finish.
50
+ * Captures stderr for error reporting on non-zero exit.
51
+ */
52
+ private runBuildProcess;
53
+ }
54
+
55
+ /**
56
+ * BundleUploader — requests presigned PUT URLs from the API and uploads
57
+ * all build output files to R2/S3 in parallel.
58
+ *
59
+ * Strategy:
60
+ * - Uses Promise.allSettled so partial failures don't abort the whole upload.
61
+ * - index.html is considered the critical entry point; if it fails, the
62
+ * entire upload is treated as failed (the CDN bundle would be unusable).
63
+ * - Content-Type is derived from the file extension.
64
+ * - Native fetch is used for PUT uploads — no extra dependencies.
65
+ */
66
+
67
+ /** Result of a single file upload attempt */
68
+ interface FileUploadResult {
69
+ path: string;
70
+ success: boolean;
71
+ error?: string;
72
+ }
73
+ declare class BundleUploader {
74
+ /**
75
+ * Requests presigned URLs and uploads all files to the CDN in parallel.
76
+ *
77
+ * @param apiClient - Authenticated API client instance
78
+ * @param sessionId - Development session ID (used in the API path)
79
+ * @param files - List of files from BundleBuilder.build()
80
+ * @returns The public CDN base URL for the uploaded bundle
81
+ * @throws If the API request fails or if index.html upload fails
82
+ */
83
+ upload(apiClient: WhataloApiClient, sessionId: string, files: BuildFile[]): Promise<string>;
84
+ /**
85
+ * Uploads a single file to its presigned URL via HTTP PUT.
86
+ * Sets Content-Type and Content-Length headers as required by S3/R2.
87
+ */
88
+ private uploadSingleFile;
89
+ /**
90
+ * Checks upload results and returns paths of any files that failed.
91
+ * Used by the caller for logging warnings.
92
+ */
93
+ static getFailedPaths(files: BuildFile[], results: PromiseSettledResult<FileUploadResult>[]): string[];
94
+ }
95
+
96
+ /**
97
+ * BundleWatcher — watches a source directory for file changes and triggers
98
+ * a rebuild callback using the SerialBatchProcessor pattern.
99
+ *
100
+ * Key guarantees:
101
+ * - Debounce: 300ms after the last detected change before triggering rebuild.
102
+ * - Serial execution: if a rebuild is already in progress, the next one is
103
+ * queued (not dropped) and runs immediately after the current one finishes.
104
+ * - At most ONE pending rebuild is queued at a time (further changes during
105
+ * an in-progress rebuild extend the debounce, not stack more rebuilds).
106
+ * - Emits typed events for integration with the dev console output.
107
+ */
108
+
109
+ interface RebuildStartEvent {
110
+ /** Path of the file that triggered the rebuild (relative to watched dir) */
111
+ changedFile: string;
112
+ }
113
+ interface RebuildCompleteEvent {
114
+ /** Wall-clock duration of the rebuild in milliseconds */
115
+ duration: number;
116
+ }
117
+ interface RebuildErrorEvent {
118
+ error: Error;
119
+ }
120
+ /** Typed events emitted by BundleWatcher */
121
+ interface BundleWatcherEvents {
122
+ "rebuild:start": (event: RebuildStartEvent) => void;
123
+ "rebuild:complete": (event: RebuildCompleteEvent) => void;
124
+ "rebuild:error": (event: RebuildErrorEvent) => void;
125
+ }
126
+ declare class BundleWatcher extends EventEmitter {
127
+ private watcher;
128
+ private debounceTimer;
129
+ private isRebuilding;
130
+ private pendingRebuild;
131
+ private pendingChangedFile;
132
+ private lastChangedFile;
133
+ /** Typed override for EventEmitter.on */
134
+ on<K extends keyof BundleWatcherEvents>(event: K, listener: BundleWatcherEvents[K]): this;
135
+ /** Typed override for EventEmitter.emit */
136
+ emit<K extends keyof BundleWatcherEvents>(event: K, ...args: Parameters<BundleWatcherEvents[K]>): boolean;
137
+ /**
138
+ * Starts watching `srcDir` for changes.
139
+ * Calls `onRebuild` after each debounced change, serially.
140
+ *
141
+ * @param srcDir - Directory to watch recursively
142
+ * @param onRebuild - Async callback invoked on each change batch
143
+ */
144
+ start(srcDir: string, onRebuild: () => Promise<void>): void;
145
+ /** Stops watching and clears all pending timers */
146
+ stop(): void;
147
+ /**
148
+ * Schedules a rebuild after the debounce window.
149
+ * If a rebuild is already in progress, marks a pending rebuild instead.
150
+ */
151
+ private scheduleRebuild;
152
+ /**
153
+ * Executes the rebuild callback, then runs any pending rebuild that
154
+ * accumulated while this one was in progress.
155
+ */
156
+ private executeRebuild;
157
+ }
158
+
159
+ export { type BuildFile, type BuildResult, BundleBuilder, BundleUploader, BundleWatcher, type BundleWatcherEvents, type RebuildCompleteEvent, type RebuildErrorEvent, type RebuildStartEvent, formatSize };
@@ -0,0 +1,398 @@
1
+ // src/bundle/builder.ts
2
+ import { spawn } from "child_process";
3
+ import { readFile, readdir, stat } from "fs/promises";
4
+ import path from "path";
5
+ var MAX_BUNDLE_SIZE_BYTES = 10 * 1024 * 1024;
6
+ async function collectFiles(dir, rootDir) {
7
+ const entries = await readdir(dir, { withFileTypes: true });
8
+ const results = [];
9
+ const resolvedRoot = path.resolve(rootDir);
10
+ for (const entry of entries) {
11
+ const absolutePath = path.join(dir, entry.name);
12
+ const resolvedPath = path.resolve(absolutePath);
13
+ if (!resolvedPath.startsWith(resolvedRoot)) {
14
+ continue;
15
+ }
16
+ if (entry.isDirectory()) {
17
+ const nested = await collectFiles(absolutePath, rootDir);
18
+ results.push(...nested);
19
+ } else if (entry.isFile()) {
20
+ const fileStat = await stat(absolutePath);
21
+ const relativePath = path.relative(rootDir, absolutePath);
22
+ if (relativePath.startsWith("..")) continue;
23
+ results.push({
24
+ relativePath,
25
+ absolutePath,
26
+ size: fileStat.size
27
+ });
28
+ }
29
+ }
30
+ return results;
31
+ }
32
+ function formatSize(bytes) {
33
+ if (bytes < 1024) return `${bytes} B`;
34
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
35
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
36
+ }
37
+ var BundleBuilder = class {
38
+ /**
39
+ * Runs the user-defined build command and collects the output files.
40
+ *
41
+ * @param buildCommand - Command string from config (e.g. "pnpm build")
42
+ * @param outputDir - Build output directory (relative or absolute)
43
+ * @param cwd - Working directory for the command (defaults to process.cwd())
44
+ * @throws If the build exits with a non-zero code or if total size exceeds 10 MB
45
+ */
46
+ async build(buildCommand, outputDir, cwd = process.cwd()) {
47
+ const startTime = Date.now();
48
+ const parts = buildCommand.trim().split(/\s+/);
49
+ const [cmd, ...args] = parts;
50
+ if (!cmd) {
51
+ throw new Error("build_command is empty in whatalo.app.toml");
52
+ }
53
+ await this.runBuildProcess(cmd, args, cwd);
54
+ const absoluteOutputDir = path.isAbsolute(outputDir) ? outputDir : path.join(cwd, outputDir);
55
+ let files;
56
+ try {
57
+ files = await collectFiles(absoluteOutputDir, absoluteOutputDir);
58
+ } catch (err) {
59
+ const message = err instanceof Error ? err.message : String(err);
60
+ throw new Error(
61
+ `Build output directory not found at "${absoluteOutputDir}": ${message}`
62
+ );
63
+ }
64
+ if (files.length === 0) {
65
+ throw new Error(
66
+ `Build output directory "${absoluteOutputDir}" is empty after build.`
67
+ );
68
+ }
69
+ await this.validateIndexHtmlForCdn(files);
70
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
71
+ if (totalSize > MAX_BUNDLE_SIZE_BYTES) {
72
+ throw new Error(
73
+ `Bundle size ${formatSize(totalSize)} exceeds the 10 MB limit. Reduce your build output or use --no-cdn to skip CDN upload.`
74
+ );
75
+ }
76
+ const duration = Date.now() - startTime;
77
+ return { files, totalSize, duration };
78
+ }
79
+ /**
80
+ * Ensures index.html uses relative asset URLs so bundles work under
81
+ * session-scoped CDN paths like /dev-bundles/{sessionId}/.
82
+ */
83
+ async validateIndexHtmlForCdn(files) {
84
+ const indexFile = files.find((file) => file.relativePath === "index.html");
85
+ if (!indexFile) {
86
+ return;
87
+ }
88
+ const html = await readFile(indexFile.absolutePath, "utf-8");
89
+ const hasRootAbsoluteAssetRefs = /(?:src|href)=["']\/assets\//i.test(html) || /(?:src|href)=["']\/[a-z0-9._-]+\//i.test(html);
90
+ if (hasRootAbsoluteAssetRefs) {
91
+ throw new Error(
92
+ "Build output uses root-absolute asset paths in index.html. CDN dev bundles are served from a subpath, so assets must be relative. If you use Vite, set `base: './'` in vite.config.ts and rebuild."
93
+ );
94
+ }
95
+ }
96
+ /**
97
+ * Spawns the build process and waits for it to finish.
98
+ * Captures stderr for error reporting on non-zero exit.
99
+ */
100
+ runBuildProcess(cmd, args, cwd) {
101
+ return new Promise((resolve, reject) => {
102
+ const proc = spawn(cmd, args, {
103
+ cwd,
104
+ stdio: ["ignore", "pipe", "pipe"],
105
+ // Never use shell: true — avoids injection
106
+ shell: false
107
+ });
108
+ let stderr = "";
109
+ let stdout = "";
110
+ if (proc.stdout) {
111
+ proc.stdout.on("data", (chunk) => {
112
+ stdout += chunk.toString("utf-8");
113
+ });
114
+ }
115
+ if (proc.stderr) {
116
+ proc.stderr.on("data", (chunk) => {
117
+ stderr += chunk.toString("utf-8");
118
+ });
119
+ }
120
+ proc.on("error", (err) => {
121
+ reject(
122
+ new Error(
123
+ `Failed to start build command "${cmd}": ${err.message}. Is the command installed and in PATH?`
124
+ )
125
+ );
126
+ });
127
+ proc.on("close", (code) => {
128
+ if (code === 0) {
129
+ resolve();
130
+ return;
131
+ }
132
+ const output = (stderr || stdout).trim();
133
+ reject(
134
+ new Error(
135
+ `Build failed with exit code ${code ?? "unknown"}.` + (output ? `
136
+
137
+ ${output}` : "")
138
+ )
139
+ );
140
+ });
141
+ });
142
+ }
143
+ };
144
+
145
+ // src/bundle/uploader.ts
146
+ import { readFile as readFile2 } from "fs/promises";
147
+ var UPLOAD_TIMEOUT_MS = 6e4;
148
+ var CONTENT_TYPE_MAP = {
149
+ ".html": "text/html",
150
+ ".htm": "text/html",
151
+ ".css": "text/css",
152
+ ".js": "application/javascript",
153
+ ".mjs": "application/javascript",
154
+ ".cjs": "application/javascript",
155
+ ".ts": "application/javascript",
156
+ ".json": "application/json",
157
+ ".svg": "image/svg+xml",
158
+ ".png": "image/png",
159
+ ".jpg": "image/jpeg",
160
+ ".jpeg": "image/jpeg",
161
+ ".gif": "image/gif",
162
+ ".webp": "image/webp",
163
+ ".ico": "image/x-icon",
164
+ ".woff": "font/woff",
165
+ ".woff2": "font/woff2",
166
+ ".ttf": "font/ttf",
167
+ ".eot": "application/vnd.ms-fontobject",
168
+ ".txt": "text/plain",
169
+ ".xml": "application/xml",
170
+ ".map": "application/json",
171
+ ".webmanifest": "application/manifest+json"
172
+ };
173
+ function getContentType(filePath) {
174
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
175
+ return CONTENT_TYPE_MAP[ext] ?? "application/octet-stream";
176
+ }
177
+ var BundleUploader = class {
178
+ /**
179
+ * Requests presigned URLs and uploads all files to the CDN in parallel.
180
+ *
181
+ * @param apiClient - Authenticated API client instance
182
+ * @param sessionId - Development session ID (used in the API path)
183
+ * @param files - List of files from BundleBuilder.build()
184
+ * @returns The public CDN base URL for the uploaded bundle
185
+ * @throws If the API request fails or if index.html upload fails
186
+ */
187
+ async upload(apiClient, sessionId, files) {
188
+ for (const f of files) {
189
+ if (f.relativePath.includes("..") || f.relativePath.startsWith("/")) {
190
+ throw new Error(`Invalid file path: ${f.relativePath}`);
191
+ }
192
+ }
193
+ const payload = {
194
+ files: files.map((f) => ({ path: f.relativePath, size: f.size }))
195
+ };
196
+ const { urls, publicBaseUrl } = await apiClient.post(
197
+ `/api/dev-sessions/${sessionId}/upload-urls`,
198
+ payload
199
+ );
200
+ const uploadPromises = files.map(
201
+ (file) => this.uploadSingleFile(file, urls[file.relativePath])
202
+ );
203
+ const results = await Promise.allSettled(uploadPromises);
204
+ const fileResults = results.map((result, index) => {
205
+ if (result.status === "fulfilled") {
206
+ return result.value;
207
+ }
208
+ const filePath = files[index]?.relativePath ?? `file[${index}]`;
209
+ return {
210
+ path: filePath,
211
+ success: false,
212
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
213
+ };
214
+ });
215
+ const failed = fileResults.filter((r) => !r.success);
216
+ const indexFailed = failed.some(
217
+ (r) => r.path === "index.html" || r.path.endsWith("/index.html")
218
+ );
219
+ if (indexFailed) {
220
+ const indexError = failed.find(
221
+ (r) => r.path === "index.html" || r.path.endsWith("/index.html")
222
+ );
223
+ throw new Error(
224
+ `Critical upload failure: index.html could not be uploaded. ${indexError?.error ?? ""}`
225
+ );
226
+ }
227
+ for (const failure of failed) {
228
+ void failure;
229
+ }
230
+ return publicBaseUrl;
231
+ }
232
+ /**
233
+ * Uploads a single file to its presigned URL via HTTP PUT.
234
+ * Sets Content-Type and Content-Length headers as required by S3/R2.
235
+ */
236
+ async uploadSingleFile(file, presignedUrl) {
237
+ if (!presignedUrl) {
238
+ return {
239
+ path: file.relativePath,
240
+ success: false,
241
+ error: "No presigned URL returned from API"
242
+ };
243
+ }
244
+ try {
245
+ const body = await readFile2(file.absolutePath);
246
+ const contentType = getContentType(file.relativePath);
247
+ const response = await fetch(presignedUrl, {
248
+ method: "PUT",
249
+ headers: {
250
+ "Content-Type": contentType,
251
+ "Content-Length": String(file.size)
252
+ },
253
+ body,
254
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
255
+ });
256
+ if (!response.ok) {
257
+ const text = await response.text().catch(() => "");
258
+ return {
259
+ path: file.relativePath,
260
+ success: false,
261
+ error: `HTTP ${response.status}: ${text.slice(0, 200)}`
262
+ };
263
+ }
264
+ return { path: file.relativePath, success: true };
265
+ } catch (err) {
266
+ return {
267
+ path: file.relativePath,
268
+ success: false,
269
+ error: err instanceof Error ? err.message : String(err)
270
+ };
271
+ }
272
+ }
273
+ /**
274
+ * Checks upload results and returns paths of any files that failed.
275
+ * Used by the caller for logging warnings.
276
+ */
277
+ static getFailedPaths(files, results) {
278
+ return results.map((r, i) => ({
279
+ path: files[i]?.relativePath ?? `file[${i}]`,
280
+ ok: r.status === "fulfilled" && r.value.success
281
+ })).filter((r) => !r.ok).map((r) => r.path);
282
+ }
283
+ };
284
+
285
+ // src/bundle/watcher.ts
286
+ import { watch } from "fs";
287
+ import { EventEmitter } from "events";
288
+ import path2 from "path";
289
+ var DEBOUNCE_MS = 300;
290
+ var BundleWatcher = class extends EventEmitter {
291
+ watcher = null;
292
+ debounceTimer = null;
293
+ isRebuilding = false;
294
+ pendingRebuild = false;
295
+ pendingChangedFile = "";
296
+ lastChangedFile = "";
297
+ /** Typed override for EventEmitter.on */
298
+ on(event, listener) {
299
+ return super.on(event, listener);
300
+ }
301
+ /** Typed override for EventEmitter.emit */
302
+ emit(event, ...args) {
303
+ return super.emit(event, ...args);
304
+ }
305
+ /**
306
+ * Starts watching `srcDir` for changes.
307
+ * Calls `onRebuild` after each debounced change, serially.
308
+ *
309
+ * @param srcDir - Directory to watch recursively
310
+ * @param onRebuild - Async callback invoked on each change batch
311
+ */
312
+ start(srcDir, onRebuild) {
313
+ if (this.watcher) {
314
+ throw new Error("BundleWatcher is already running. Call stop() first.");
315
+ }
316
+ try {
317
+ this.watcher = watch(srcDir, { recursive: true }, (_, filename) => {
318
+ const changedFile = filename ? path2.normalize(filename) : "(unknown file)";
319
+ this.scheduleRebuild(changedFile, onRebuild);
320
+ });
321
+ this.watcher.on("error", (err) => {
322
+ this.emit("rebuild:error", { error: err });
323
+ });
324
+ } catch (err) {
325
+ throw new Error(
326
+ `Cannot watch directory "${srcDir}": ${err instanceof Error ? err.message : String(err)}`
327
+ );
328
+ }
329
+ }
330
+ /** Stops watching and clears all pending timers */
331
+ stop() {
332
+ if (this.debounceTimer) {
333
+ clearTimeout(this.debounceTimer);
334
+ this.debounceTimer = null;
335
+ }
336
+ if (this.watcher) {
337
+ this.watcher.close();
338
+ this.watcher = null;
339
+ }
340
+ this.isRebuilding = false;
341
+ this.pendingRebuild = false;
342
+ this.pendingChangedFile = "";
343
+ this.lastChangedFile = "";
344
+ }
345
+ /**
346
+ * Schedules a rebuild after the debounce window.
347
+ * If a rebuild is already in progress, marks a pending rebuild instead.
348
+ */
349
+ scheduleRebuild(changedFile, onRebuild) {
350
+ this.lastChangedFile = changedFile;
351
+ if (this.debounceTimer) {
352
+ clearTimeout(this.debounceTimer);
353
+ }
354
+ if (this.isRebuilding) {
355
+ this.pendingRebuild = true;
356
+ this.pendingChangedFile = changedFile;
357
+ return;
358
+ }
359
+ this.debounceTimer = setTimeout(() => {
360
+ this.debounceTimer = null;
361
+ void this.executeRebuild(this.lastChangedFile, onRebuild);
362
+ }, DEBOUNCE_MS);
363
+ }
364
+ /**
365
+ * Executes the rebuild callback, then runs any pending rebuild that
366
+ * accumulated while this one was in progress.
367
+ */
368
+ async executeRebuild(changedFile, onRebuild) {
369
+ this.isRebuilding = true;
370
+ const startTime = Date.now();
371
+ this.emit("rebuild:start", { changedFile });
372
+ try {
373
+ await onRebuild();
374
+ const duration = Date.now() - startTime;
375
+ this.emit("rebuild:complete", { duration });
376
+ } catch (err) {
377
+ const error = err instanceof Error ? err : new Error(String(err));
378
+ this.emit("rebuild:error", { error });
379
+ } finally {
380
+ this.isRebuilding = false;
381
+ if (this.pendingRebuild) {
382
+ this.pendingRebuild = false;
383
+ const nextFile = this.pendingChangedFile;
384
+ this.pendingChangedFile = "";
385
+ this.debounceTimer = setTimeout(() => {
386
+ this.debounceTimer = null;
387
+ void this.executeRebuild(nextFile, onRebuild);
388
+ }, DEBOUNCE_MS);
389
+ }
390
+ }
391
+ }
392
+ };
393
+ export {
394
+ BundleBuilder,
395
+ BundleUploader,
396
+ BundleWatcher,
397
+ formatSize
398
+ };
@@ -134,7 +134,9 @@ var pluginConfigSchema = import_zod.z.object({
134
134
  }).optional(),
135
135
  dev: import_zod.z.object({
136
136
  port: import_zod.z.number().int("Port must be an integer").min(1024, "Port must be >= 1024").max(65535, "Port must be <= 65535").default(5173),
137
- store: import_zod.z.string().optional()
137
+ store: import_zod.z.string().optional(),
138
+ portal_url: import_zod.z.string().url("portal_url must be a valid URL").optional(),
139
+ app_url: import_zod.z.string().url("app_url must be a valid URL").optional()
138
140
  }).optional()
139
141
  });
140
142
  function validateConfig(raw) {
@@ -1,4 +1,4 @@
1
- export { C as CONFIG_FILE_NAME, E as EnvEntry, R as REQUIRED_FIELDS, W as WhataloAppConfig, p as parseEnvFile, r as readConfig, u as updateEnvFile, w as writeConfig } from '../env-file-KvUHlLtI.cjs';
1
+ export { C as CONFIG_FILE_NAME, E as EnvEntry, R as REQUIRED_FIELDS, W as WhataloAppConfig, p as parseEnvFile, r as readConfig, u as updateEnvFile, w as writeConfig } from '../env-file-Qh_6c5K2.cjs';
2
2
  import { z } from 'zod';
3
3
 
4
4
  /**
@@ -24,6 +24,8 @@ declare const pluginConfigSchema: z.ZodObject<{
24
24
  dev: z.ZodOptional<z.ZodObject<{
25
25
  port: z.ZodDefault<z.ZodNumber>;
26
26
  store: z.ZodOptional<z.ZodString>;
27
+ portal_url: z.ZodOptional<z.ZodString>;
28
+ app_url: z.ZodOptional<z.ZodString>;
27
29
  }, z.core.$strip>>;
28
30
  }, z.core.$strip>;
29
31
  type PluginConfigSchema = z.infer<typeof pluginConfigSchema>;
@@ -1,4 +1,4 @@
1
- export { C as CONFIG_FILE_NAME, E as EnvEntry, R as REQUIRED_FIELDS, W as WhataloAppConfig, p as parseEnvFile, r as readConfig, u as updateEnvFile, w as writeConfig } from '../env-file-KvUHlLtI.js';
1
+ export { C as CONFIG_FILE_NAME, E as EnvEntry, R as REQUIRED_FIELDS, W as WhataloAppConfig, p as parseEnvFile, r as readConfig, u as updateEnvFile, w as writeConfig } from '../env-file-Qh_6c5K2.js';
2
2
  import { z } from 'zod';
3
3
 
4
4
  /**
@@ -24,6 +24,8 @@ declare const pluginConfigSchema: z.ZodObject<{
24
24
  dev: z.ZodOptional<z.ZodObject<{
25
25
  port: z.ZodDefault<z.ZodNumber>;
26
26
  store: z.ZodOptional<z.ZodString>;
27
+ portal_url: z.ZodOptional<z.ZodString>;
28
+ app_url: z.ZodOptional<z.ZodString>;
27
29
  }, z.core.$strip>>;
28
30
  }, z.core.$strip>;
29
31
  type PluginConfigSchema = z.infer<typeof pluginConfigSchema>;
@@ -90,7 +90,9 @@ var pluginConfigSchema = z.object({
90
90
  }).optional(),
91
91
  dev: z.object({
92
92
  port: z.number().int("Port must be an integer").min(1024, "Port must be >= 1024").max(65535, "Port must be <= 65535").default(5173),
93
- store: z.string().optional()
93
+ store: z.string().optional(),
94
+ portal_url: z.string().url("portal_url must be a valid URL").optional(),
95
+ app_url: z.string().url("app_url must be a valid URL").optional()
94
96
  }).optional()
95
97
  });
96
98
  function validateConfig(raw) {
@@ -22,6 +22,10 @@ interface WhataloAppConfig {
22
22
  dev: {
23
23
  /** Local dev server port */
24
24
  port: number;
25
+ /** Developer Portal URL override (e.g. http://localhost:3002 for local testing) */
26
+ portal_url?: string;
27
+ /** Main Whatalo app URL override for preview links (e.g. http://localhost:3000 for local testing) */
28
+ app_url?: string;
25
29
  };
26
30
  }
27
31
  /** Required fields checked during config read */
@@ -22,6 +22,10 @@ interface WhataloAppConfig {
22
22
  dev: {
23
23
  /** Local dev server port */
24
24
  port: number;
25
+ /** Developer Portal URL override (e.g. http://localhost:3002 for local testing) */
26
+ portal_url?: string;
27
+ /** Main Whatalo app URL override for preview links (e.g. http://localhost:3000 for local testing) */
28
+ app_url?: string;
25
29
  };
26
30
  }
27
31
  /** Required fields checked during config read */