@olonjs/cli 3.0.89 → 3.0.90

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.
@@ -1641,7 +1641,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1641
1641
  "@tiptap/extension-link": "^2.11.5",
1642
1642
  "@tiptap/react": "^2.11.5",
1643
1643
  "@tiptap/starter-kit": "^2.11.5",
1644
- "@olonjs/core": "^1.0.77",
1644
+ "@olonjs/core": "^1.0.78",
1645
1645
  "clsx": "^2.1.1",
1646
1646
  "lucide-react": "^0.474.0",
1647
1647
  "react": "^19.0.0",
@@ -2612,6 +2612,9 @@ const TENANT_ID = 'alpha';
2612
2612
  const filePages = getFilePages();
2613
2613
  const fileSiteConfig = siteData as unknown as SiteConfig;
2614
2614
  const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024;
2615
+ const ASSET_UPLOAD_MAX_RETRIES = 2;
2616
+ const ASSET_UPLOAD_TIMEOUT_MS = 20_000;
2617
+ const ALLOWED_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif']);
2615
2618
 
2616
2619
  interface CloudSaveUiState {
2617
2620
  isOpen: boolean;
@@ -2918,14 +2921,39 @@ function App() {
2918
2921
  [isCloudMode, CLOUD_API_URL]
2919
2922
  );
2920
2923
 
2921
- useEffect(() => {
2922
- // In Cloud mode, listing assets might be different or disabled for MVP
2923
- // For now, we keep the local fetch which will fail gracefully on Vercel (404)
2924
+ const loadAssetsManifest = useCallback(async (): Promise<void> => {
2925
+ if (isCloudMode && CLOUD_API_URL && CLOUD_API_KEY) {
2926
+ const apiBases = cloudApiCandidates.length > 0 ? cloudApiCandidates : [normalizeApiBase(CLOUD_API_URL)];
2927
+ for (const apiBase of apiBases) {
2928
+ try {
2929
+ const res = await fetch(`${apiBase}/assets/list?limit=200`, {
2930
+ method: 'GET',
2931
+ headers: {
2932
+ Authorization: `Bearer ${CLOUD_API_KEY}`,
2933
+ },
2934
+ });
2935
+ const body = (await res.json().catch(() => ({}))) as { items?: LibraryImageEntry[] };
2936
+ if (!res.ok) continue;
2937
+ const items = Array.isArray(body.items) ? body.items : [];
2938
+ setAssetsManifest(items);
2939
+ return;
2940
+ } catch {
2941
+ // try next candidate
2942
+ }
2943
+ }
2944
+ setAssetsManifest([]);
2945
+ return;
2946
+ }
2947
+
2924
2948
  fetch('/api/list-assets')
2925
2949
  .then((r) => (r.ok ? r.json() : []))
2926
2950
  .then((list: LibraryImageEntry[]) => setAssetsManifest(Array.isArray(list) ? list : []))
2927
2951
  .catch(() => setAssetsManifest([]));
2928
- }, []);
2952
+ }, [isCloudMode, CLOUD_API_URL, CLOUD_API_KEY, cloudApiCandidates]);
2953
+
2954
+ useEffect(() => {
2955
+ void loadAssetsManifest();
2956
+ }, [loadAssetsManifest]);
2929
2957
 
2930
2958
  useEffect(() => {
2931
2959
  return () => {
@@ -3278,14 +3306,60 @@ function App() {
3278
3306
  assetsBaseUrl: '/assets',
3279
3307
  assetsManifest,
3280
3308
  async onAssetUpload(file: File): Promise<string> {
3281
- // Note: Asset upload in Cloud Mode requires the R2 Bridge (Next Step in Roadmap)
3282
- // For now, this works in Local Mode.
3283
3309
  if (!file.type.startsWith('image/')) throw new Error('Invalid file type.');
3310
+ if (!ALLOWED_IMAGE_MIME_TYPES.has(file.type)) {
3311
+ throw new Error('Unsupported image format. Allowed: jpeg, png, webp, gif, avif.');
3312
+ }
3284
3313
  if (file.size > MAX_UPLOAD_SIZE_BYTES) throw new Error(`File too large. Max ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB.`);
3285
-
3314
+
3315
+ if (isCloudMode && CLOUD_API_URL && CLOUD_API_KEY) {
3316
+ const apiBases = cloudApiCandidates.length > 0 ? cloudApiCandidates : [normalizeApiBase(CLOUD_API_URL)];
3317
+ let lastError: Error | null = null;
3318
+ for (const apiBase of apiBases) {
3319
+ for (let attempt = 0; attempt <= ASSET_UPLOAD_MAX_RETRIES; attempt += 1) {
3320
+ try {
3321
+ const formData = new FormData();
3322
+ formData.append('file', file);
3323
+ formData.append('filename', file.name);
3324
+ const controller = new AbortController();
3325
+ const timeout = window.setTimeout(() => controller.abort(), ASSET_UPLOAD_TIMEOUT_MS);
3326
+ const res = await fetch(`${apiBase}/assets/upload`, {
3327
+ method: 'POST',
3328
+ headers: {
3329
+ Authorization: `Bearer ${CLOUD_API_KEY}`,
3330
+ 'X-Correlation-Id': crypto.randomUUID(),
3331
+ },
3332
+ body: formData,
3333
+ signal: controller.signal,
3334
+ }).finally(() => window.clearTimeout(timeout));
3335
+ const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string; code?: string };
3336
+ if (res.ok && typeof body.url === 'string') {
3337
+ await loadAssetsManifest().catch(() => undefined);
3338
+ return body.url;
3339
+ }
3340
+ lastError = new Error(body.error || body.code || `Cloud upload failed: ${res.status}`);
3341
+ if (isRetryableStatus(res.status) && attempt < ASSET_UPLOAD_MAX_RETRIES) {
3342
+ await sleep(backoffDelayMs(attempt));
3343
+ continue;
3344
+ }
3345
+ break;
3346
+ } catch (error: unknown) {
3347
+ const message = error instanceof Error ? error.message : 'Cloud upload failed.';
3348
+ lastError = new Error(message);
3349
+ if (attempt < ASSET_UPLOAD_MAX_RETRIES) {
3350
+ await sleep(backoffDelayMs(attempt));
3351
+ continue;
3352
+ }
3353
+ break;
3354
+ }
3355
+ }
3356
+ }
3357
+ throw lastError ?? new Error('Cloud upload failed.');
3358
+ }
3359
+
3286
3360
  const base64 = await new Promise<string>((resolve, reject) => {
3287
3361
  const reader = new FileReader();
3288
- reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '');
3362
+ reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '');
3289
3363
  reader.onerror = () => reject(reader.error);
3290
3364
  reader.readAsDataURL(file);
3291
3365
  });
@@ -3295,10 +3369,10 @@ function App() {
3295
3369
  headers: { 'Content-Type': 'application/json' },
3296
3370
  body: JSON.stringify({ filename: file.name, mimeType: file.type || undefined, data: base64 }),
3297
3371
  });
3298
-
3299
3372
  const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
3300
3373
  if (!res.ok) throw new Error(body.error || `Upload failed: ${res.status}`);
3301
3374
  if (typeof body.url !== 'string') throw new Error('Invalid server response: missing url');
3375
+ await loadAssetsManifest().catch(() => undefined);
3302
3376
  return body.url;
3303
3377
  },
3304
3378
  },
@@ -596,7 +596,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
596
596
  "@tiptap/extension-link": "^2.11.5",
597
597
  "@tiptap/react": "^2.11.5",
598
598
  "@tiptap/starter-kit": "^2.11.5",
599
- "@olonjs/core": "^1.0.77",
599
+ "@olonjs/core": "^1.0.78",
600
600
  "clsx": "^2.1.1",
601
601
  "lucide-react": "^0.474.0",
602
602
  "react": "^19.0.0",
@@ -957,6 +957,9 @@ const TENANT_ID = 'santamamma'; // 🌿 SantaMamma Agriturismo
957
957
  const filePages = getFilePages();
958
958
  const fileSiteConfig = siteData as unknown as SiteConfig;
959
959
  const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024;
960
+ const ASSET_UPLOAD_MAX_RETRIES = 2;
961
+ const ASSET_UPLOAD_TIMEOUT_MS = 20_000;
962
+ const ALLOWED_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif']);
960
963
 
961
964
  interface CloudSaveUiState {
962
965
  isOpen: boolean;
@@ -980,6 +983,30 @@ function stepProgress(doneSteps: StepId[]): number {
980
983
  return Math.round((doneSteps.length / DEPLOY_STEPS.length) * 100);
981
984
  }
982
985
 
986
+ function normalizeApiBase(raw: string): string {
987
+ return raw.trim().replace(/\/+$/, '');
988
+ }
989
+
990
+ function buildUploadEndpoint(raw: string): string {
991
+ const base = normalizeApiBase(raw);
992
+ const withApi = /\/api\/v1$/i.test(base) ? base : `${base}/api/v1`;
993
+ return `${withApi}/assets/upload`;
994
+ }
995
+
996
+ function isRetryableStatus(status: number): boolean {
997
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
998
+ }
999
+
1000
+ function backoffDelayMs(attempt: number): number {
1001
+ const base = 250 * Math.pow(2, attempt);
1002
+ const jitter = Math.floor(Math.random() * 120);
1003
+ return base + jitter;
1004
+ }
1005
+
1006
+ function sleep(ms: number): Promise<void> {
1007
+ return new Promise((resolve) => setTimeout(resolve, ms));
1008
+ }
1009
+
983
1010
  function App() {
984
1011
  const [{ pages, siteConfig }] = useState(getInitialData);
985
1012
  const [assetsManifest, setAssetsManifest] = useState<LibraryImageEntry[]>([]);
@@ -988,12 +1015,36 @@ function App() {
988
1015
  const pendingCloudSave = useRef<{ state: ProjectState; slug: string } | null>(null);
989
1016
  const isCloudMode = Boolean(CLOUD_API_URL && CLOUD_API_KEY);
990
1017
 
991
- useEffect(() => {
1018
+ const loadAssetsManifest = useCallback(async (): Promise<void> => {
1019
+ if (isCloudMode && CLOUD_API_URL && CLOUD_API_KEY) {
1020
+ try {
1021
+ const res = await fetch(`${buildUploadEndpoint(CLOUD_API_URL).replace(/\/upload$/, '/list')}?limit=200`, {
1022
+ method: 'GET',
1023
+ headers: {
1024
+ Authorization: `Bearer ${CLOUD_API_KEY}`,
1025
+ },
1026
+ });
1027
+ const body = (await res.json().catch(() => ({}))) as { items?: LibraryImageEntry[] };
1028
+ if (res.ok) {
1029
+ setAssetsManifest(Array.isArray(body.items) ? body.items : []);
1030
+ return;
1031
+ }
1032
+ } catch {
1033
+ // fallback to empty
1034
+ }
1035
+ setAssetsManifest([]);
1036
+ return;
1037
+ }
1038
+
992
1039
  fetch('/api/list-assets')
993
1040
  .then((r) => (r.ok ? r.json() : []))
994
1041
  .then((list: LibraryImageEntry[]) => setAssetsManifest(Array.isArray(list) ? list : []))
995
1042
  .catch(() => setAssetsManifest([]));
996
- }, []);
1043
+ }, [isCloudMode, CLOUD_API_URL, CLOUD_API_KEY]);
1044
+
1045
+ useEffect(() => {
1046
+ void loadAssetsManifest();
1047
+ }, [loadAssetsManifest]);
997
1048
 
998
1049
  useEffect(() => {
999
1050
  return () => { activeCloudSaveController.current?.abort(); };
@@ -1104,7 +1155,53 @@ function App() {
1104
1155
  assetsManifest,
1105
1156
  async onAssetUpload(file: File): Promise<string> {
1106
1157
  if (!file.type.startsWith('image/')) throw new Error('Invalid file type.');
1158
+ if (!ALLOWED_IMAGE_MIME_TYPES.has(file.type)) {
1159
+ throw new Error('Unsupported image format. Allowed: jpeg, png, webp, gif, avif.');
1160
+ }
1107
1161
  if (file.size > MAX_UPLOAD_SIZE_BYTES) throw new Error(`File too large. Max ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB.`);
1162
+
1163
+ if (isCloudMode && CLOUD_API_URL && CLOUD_API_KEY) {
1164
+ let lastError: Error | null = null;
1165
+ for (let attempt = 0; attempt <= ASSET_UPLOAD_MAX_RETRIES; attempt += 1) {
1166
+ try {
1167
+ const formData = new FormData();
1168
+ formData.append('file', file);
1169
+ formData.append('filename', file.name);
1170
+ const controller = new AbortController();
1171
+ const timeout = window.setTimeout(() => controller.abort(), ASSET_UPLOAD_TIMEOUT_MS);
1172
+ const res = await fetch(buildUploadEndpoint(CLOUD_API_URL), {
1173
+ method: 'POST',
1174
+ headers: {
1175
+ Authorization: `Bearer ${CLOUD_API_KEY}`,
1176
+ 'X-Correlation-Id': crypto.randomUUID(),
1177
+ },
1178
+ body: formData,
1179
+ signal: controller.signal,
1180
+ }).finally(() => window.clearTimeout(timeout));
1181
+ const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string; code?: string };
1182
+ if (res.ok && typeof body.url === 'string') {
1183
+ await loadAssetsManifest().catch(() => undefined);
1184
+ return body.url;
1185
+ }
1186
+ lastError = new Error(body.error || body.code || `Upload failed: ${res.status}`);
1187
+ if (isRetryableStatus(res.status) && attempt < ASSET_UPLOAD_MAX_RETRIES) {
1188
+ await sleep(backoffDelayMs(attempt));
1189
+ continue;
1190
+ }
1191
+ break;
1192
+ } catch (error: unknown) {
1193
+ const message = error instanceof Error ? error.message : 'Cloud upload failed.';
1194
+ lastError = new Error(message);
1195
+ if (attempt < ASSET_UPLOAD_MAX_RETRIES) {
1196
+ await sleep(backoffDelayMs(attempt));
1197
+ continue;
1198
+ }
1199
+ break;
1200
+ }
1201
+ }
1202
+ throw lastError ?? new Error('Cloud upload failed.');
1203
+ }
1204
+
1108
1205
  const base64 = await new Promise<string>((resolve, reject) => {
1109
1206
  const reader = new FileReader();
1110
1207
  reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '');
@@ -1119,6 +1216,7 @@ function App() {
1119
1216
  const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
1120
1217
  if (!res.ok) throw new Error(body.error || `Upload failed: ${res.status}`);
1121
1218
  if (typeof body.url !== 'string') throw new Error('Invalid server response: missing url');
1219
+ await loadAssetsManifest().catch(() => undefined);
1122
1220
  return body.url;
1123
1221
  },
1124
1222
  },
@@ -1641,7 +1641,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1641
1641
  "@tiptap/extension-link": "^2.11.5",
1642
1642
  "@tiptap/react": "^2.11.5",
1643
1643
  "@tiptap/starter-kit": "^2.11.5",
1644
- "@olonjs/core": "^1.0.77",
1644
+ "@olonjs/core": "^1.0.78",
1645
1645
  "clsx": "^2.1.1",
1646
1646
  "lucide-react": "^0.474.0",
1647
1647
  "react": "^19.0.0",
@@ -2612,6 +2612,9 @@ const TENANT_ID = 'alpha';
2612
2612
  const filePages = getFilePages();
2613
2613
  const fileSiteConfig = siteData as unknown as SiteConfig;
2614
2614
  const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024;
2615
+ const ASSET_UPLOAD_MAX_RETRIES = 2;
2616
+ const ASSET_UPLOAD_TIMEOUT_MS = 20_000;
2617
+ const ALLOWED_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif']);
2615
2618
 
2616
2619
  interface CloudSaveUiState {
2617
2620
  isOpen: boolean;
@@ -2918,14 +2921,39 @@ function App() {
2918
2921
  [isCloudMode, CLOUD_API_URL]
2919
2922
  );
2920
2923
 
2921
- useEffect(() => {
2922
- // In Cloud mode, listing assets might be different or disabled for MVP
2923
- // For now, we keep the local fetch which will fail gracefully on Vercel (404)
2924
+ const loadAssetsManifest = useCallback(async (): Promise<void> => {
2925
+ if (isCloudMode && CLOUD_API_URL && CLOUD_API_KEY) {
2926
+ const apiBases = cloudApiCandidates.length > 0 ? cloudApiCandidates : [normalizeApiBase(CLOUD_API_URL)];
2927
+ for (const apiBase of apiBases) {
2928
+ try {
2929
+ const res = await fetch(`${apiBase}/assets/list?limit=200`, {
2930
+ method: 'GET',
2931
+ headers: {
2932
+ Authorization: `Bearer ${CLOUD_API_KEY}`,
2933
+ },
2934
+ });
2935
+ const body = (await res.json().catch(() => ({}))) as { items?: LibraryImageEntry[] };
2936
+ if (!res.ok) continue;
2937
+ const items = Array.isArray(body.items) ? body.items : [];
2938
+ setAssetsManifest(items);
2939
+ return;
2940
+ } catch {
2941
+ // try next candidate
2942
+ }
2943
+ }
2944
+ setAssetsManifest([]);
2945
+ return;
2946
+ }
2947
+
2924
2948
  fetch('/api/list-assets')
2925
2949
  .then((r) => (r.ok ? r.json() : []))
2926
2950
  .then((list: LibraryImageEntry[]) => setAssetsManifest(Array.isArray(list) ? list : []))
2927
2951
  .catch(() => setAssetsManifest([]));
2928
- }, []);
2952
+ }, [isCloudMode, CLOUD_API_URL, CLOUD_API_KEY, cloudApiCandidates]);
2953
+
2954
+ useEffect(() => {
2955
+ void loadAssetsManifest();
2956
+ }, [loadAssetsManifest]);
2929
2957
 
2930
2958
  useEffect(() => {
2931
2959
  return () => {
@@ -3278,14 +3306,60 @@ function App() {
3278
3306
  assetsBaseUrl: '/assets',
3279
3307
  assetsManifest,
3280
3308
  async onAssetUpload(file: File): Promise<string> {
3281
- // Note: Asset upload in Cloud Mode requires the R2 Bridge (Next Step in Roadmap)
3282
- // For now, this works in Local Mode.
3283
3309
  if (!file.type.startsWith('image/')) throw new Error('Invalid file type.');
3310
+ if (!ALLOWED_IMAGE_MIME_TYPES.has(file.type)) {
3311
+ throw new Error('Unsupported image format. Allowed: jpeg, png, webp, gif, avif.');
3312
+ }
3284
3313
  if (file.size > MAX_UPLOAD_SIZE_BYTES) throw new Error(`File too large. Max ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB.`);
3285
-
3314
+
3315
+ if (isCloudMode && CLOUD_API_URL && CLOUD_API_KEY) {
3316
+ const apiBases = cloudApiCandidates.length > 0 ? cloudApiCandidates : [normalizeApiBase(CLOUD_API_URL)];
3317
+ let lastError: Error | null = null;
3318
+ for (const apiBase of apiBases) {
3319
+ for (let attempt = 0; attempt <= ASSET_UPLOAD_MAX_RETRIES; attempt += 1) {
3320
+ try {
3321
+ const formData = new FormData();
3322
+ formData.append('file', file);
3323
+ formData.append('filename', file.name);
3324
+ const controller = new AbortController();
3325
+ const timeout = window.setTimeout(() => controller.abort(), ASSET_UPLOAD_TIMEOUT_MS);
3326
+ const res = await fetch(`${apiBase}/assets/upload`, {
3327
+ method: 'POST',
3328
+ headers: {
3329
+ Authorization: `Bearer ${CLOUD_API_KEY}`,
3330
+ 'X-Correlation-Id': crypto.randomUUID(),
3331
+ },
3332
+ body: formData,
3333
+ signal: controller.signal,
3334
+ }).finally(() => window.clearTimeout(timeout));
3335
+ const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string; code?: string };
3336
+ if (res.ok && typeof body.url === 'string') {
3337
+ await loadAssetsManifest().catch(() => undefined);
3338
+ return body.url;
3339
+ }
3340
+ lastError = new Error(body.error || body.code || `Cloud upload failed: ${res.status}`);
3341
+ if (isRetryableStatus(res.status) && attempt < ASSET_UPLOAD_MAX_RETRIES) {
3342
+ await sleep(backoffDelayMs(attempt));
3343
+ continue;
3344
+ }
3345
+ break;
3346
+ } catch (error: unknown) {
3347
+ const message = error instanceof Error ? error.message : 'Cloud upload failed.';
3348
+ lastError = new Error(message);
3349
+ if (attempt < ASSET_UPLOAD_MAX_RETRIES) {
3350
+ await sleep(backoffDelayMs(attempt));
3351
+ continue;
3352
+ }
3353
+ break;
3354
+ }
3355
+ }
3356
+ }
3357
+ throw lastError ?? new Error('Cloud upload failed.');
3358
+ }
3359
+
3286
3360
  const base64 = await new Promise<string>((resolve, reject) => {
3287
3361
  const reader = new FileReader();
3288
- reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '');
3362
+ reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '');
3289
3363
  reader.onerror = () => reject(reader.error);
3290
3364
  reader.readAsDataURL(file);
3291
3365
  });
@@ -3295,10 +3369,10 @@ function App() {
3295
3369
  headers: { 'Content-Type': 'application/json' },
3296
3370
  body: JSON.stringify({ filename: file.name, mimeType: file.type || undefined, data: base64 }),
3297
3371
  });
3298
-
3299
3372
  const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
3300
3373
  if (!res.ok) throw new Error(body.error || `Upload failed: ${res.status}`);
3301
3374
  if (typeof body.url !== 'string') throw new Error('Invalid server response: missing url');
3375
+ await loadAssetsManifest().catch(() => undefined);
3302
3376
  return body.url;
3303
3377
  },
3304
3378
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olonjs/cli",
3
- "version": "3.0.89",
3
+ "version": "3.0.90",
4
4
  "description": "The Sovereign CLI Engine for OlonJS.",
5
5
  "type": "module",
6
6
  "bin": {