@palettelab/cli 0.3.43 → 0.3.45

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/README.md CHANGED
@@ -328,6 +328,8 @@ async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
328
328
  return {"room": room, "folder": folder, "bytes": len(content or b"")}
329
329
  ```
330
330
 
331
+ App storage is different from Data Room uploads. Use `ctx.storage` or `palette.storage` for app-owned files written directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` / `palette.dataRooms` only when the file should be managed as a Data Room document.
332
+
331
333
  Python backend app-storage example:
332
334
 
333
335
  ```python
@@ -340,6 +342,18 @@ async def save_report(ctx: PluginContext = Depends(get_plugin_context)):
340
342
  key="reports/summary.json",
341
343
  )
342
344
  return saved
345
+
346
+ @router.post("/exports", dependencies=[require_permission("reports:write")])
347
+ async def save_large_export(ctx: PluginContext = Depends(get_plugin_context)):
348
+ progress = []
349
+ saved = await ctx.storage.upload_file_path(
350
+ "/tmp/export.zip",
351
+ "application/zip",
352
+ key="exports/export.zip",
353
+ chunk_size=8 * 1024 * 1024,
354
+ on_progress=lambda event: progress.append(event),
355
+ )
356
+ return {"saved": saved, "progress": progress[-1:]}
343
357
  ```
344
358
 
345
359
  Frontend apps can use `createPaletteClient(platform).storage.upload(file, {
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import inspect
3
4
  import mimetypes
5
+ import os
4
6
  import re
5
7
  import uuid
6
8
  from dataclasses import dataclass
7
9
  from pathlib import Path
8
- from typing import Any
10
+ from typing import Any, BinaryIO, Callable
9
11
 
10
12
 
11
13
  def _slug_segment(value: str | None, fallback: str) -> str:
@@ -31,6 +33,48 @@ def _validate_relative_key(key: str) -> str:
31
33
  return "/".join(_sanitize_filename(part) for part in parts)
32
34
 
33
35
 
36
+ MIN_CHUNK_SIZE = 256 * 1024
37
+ DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024
38
+
39
+ ProgressCallback = Callable[[dict[str, Any]], Any]
40
+
41
+ def _normalize_chunk_size(value: int | None) -> int:
42
+ raw = max(value or DEFAULT_CHUNK_SIZE, MIN_CHUNK_SIZE)
43
+ return ((raw + MIN_CHUNK_SIZE - 1) // MIN_CHUNK_SIZE) * MIN_CHUNK_SIZE
44
+
45
+ def _infer_stream_size(stream: BinaryIO) -> int | None:
46
+ try:
47
+ current = stream.tell()
48
+ stream.seek(0, os.SEEK_END)
49
+ end = stream.tell()
50
+ stream.seek(current, os.SEEK_SET)
51
+ return max(0, end - current)
52
+ except Exception:
53
+ return None
54
+
55
+ async def _maybe_report_progress(
56
+ callback: ProgressCallback | None,
57
+ *,
58
+ loaded: int,
59
+ total: int,
60
+ chunk_index: int,
61
+ chunk_count: int,
62
+ state: str,
63
+ ) -> None:
64
+ if callback is None:
65
+ return
66
+ payload = {
67
+ "loaded": loaded,
68
+ "total": total,
69
+ "percentage": min(100, round((loaded / total) * 10000) / 100) if total > 0 else 100,
70
+ "chunk_index": chunk_index,
71
+ "chunk_count": chunk_count,
72
+ "state": state,
73
+ }
74
+ result = callback(payload)
75
+ if inspect.isawaitable(result):
76
+ await result
77
+
34
78
  def _content_type(filename: str | None, content_type: str | None) -> str:
35
79
  browser_ct = content_type or "application/octet-stream"
36
80
  if browser_ct == "application/octet-stream":
@@ -86,6 +130,101 @@ class LocalStorageService:
86
130
  "size": len(content),
87
131
  }
88
132
 
133
+ async def upload_file_stream(
134
+ self,
135
+ filename: str,
136
+ stream: BinaryIO,
137
+ content_type: str | None = None,
138
+ *,
139
+ size: int | None = None,
140
+ key: str | None = None,
141
+ chunk_size: int | None = None,
142
+ on_progress: ProgressCallback | None = None,
143
+ ) -> dict[str, Any]:
144
+ total = size if size is not None else _infer_stream_size(stream)
145
+ if total is None:
146
+ raise ValueError("size is required for non-seekable storage streams")
147
+ chunk_bytes = _normalize_chunk_size(chunk_size)
148
+ chunk_count = max(1, (total + chunk_bytes - 1) // chunk_bytes)
149
+ object_path = self.object_path(filename, key=key)
150
+ target = self._target(object_path)
151
+ target.parent.mkdir(parents=True, exist_ok=True)
152
+ loaded = 0
153
+ await _maybe_report_progress(
154
+ on_progress,
155
+ loaded=0,
156
+ total=total,
157
+ chunk_index=0,
158
+ chunk_count=chunk_count,
159
+ state="starting",
160
+ )
161
+ if total == 0:
162
+ target.write_bytes(b"")
163
+ await _maybe_report_progress(
164
+ on_progress,
165
+ loaded=0,
166
+ total=0,
167
+ chunk_index=0,
168
+ chunk_count=chunk_count,
169
+ state="complete",
170
+ )
171
+ return {
172
+ "bucket": "local",
173
+ "object_path": object_path,
174
+ "file_url": target.as_uri(),
175
+ "content_type": _content_type(filename, content_type),
176
+ "size": 0,
177
+ }
178
+ with target.open("wb") as out:
179
+ chunk_index = 0
180
+ while True:
181
+ chunk = stream.read(chunk_bytes)
182
+ if not chunk:
183
+ break
184
+ chunk_index += 1
185
+ out.write(chunk)
186
+ loaded += len(chunk)
187
+ await _maybe_report_progress(
188
+ on_progress,
189
+ loaded=loaded,
190
+ total=total,
191
+ chunk_index=chunk_index,
192
+ chunk_count=chunk_count,
193
+ state="complete" if loaded >= total else "uploading",
194
+ )
195
+ if loaded != total:
196
+ raise RuntimeError(f"storage upload incomplete: uploaded {loaded} of {total} bytes")
197
+ return {
198
+ "bucket": "local",
199
+ "object_path": object_path,
200
+ "file_url": target.as_uri(),
201
+ "content_type": _content_type(filename, content_type),
202
+ "size": loaded,
203
+ }
204
+
205
+ async def upload_file_path(
206
+ self,
207
+ path: str | Path,
208
+ content_type: str | None = None,
209
+ *,
210
+ filename: str | None = None,
211
+ key: str | None = None,
212
+ chunk_size: int | None = None,
213
+ on_progress: ProgressCallback | None = None,
214
+ ) -> dict[str, Any]:
215
+ source = Path(path)
216
+ resolved_filename = filename or source.name
217
+ with source.open("rb") as stream:
218
+ return await self.upload_file_stream(
219
+ resolved_filename,
220
+ stream,
221
+ content_type,
222
+ size=source.stat().st_size,
223
+ key=key,
224
+ chunk_size=chunk_size,
225
+ on_progress=on_progress,
226
+ )
227
+
89
228
  async def create_resumable_upload(
90
229
  self,
91
230
  filename: str,
@@ -740,6 +740,8 @@ Local mocks live in `.palette/app-services.local.json`:
740
740
  }
741
741
  ```
742
742
 
743
+ App storage is separate from Data Rooms. Use `ctx.storage` and `palette.storage` for app-owned files that go directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` or `palette.dataRooms` only when the file should be visible and governed as a Data Room document.
744
+
743
745
  Palette scopes storage the same way. Files written through `ctx.storage` or the
744
746
  frontend storage client live under:
745
747
 
@@ -757,12 +759,28 @@ saved = await ctx.storage.upload_file(
757
759
  key="reports/summary.json",
758
760
  )
759
761
 
760
- session = await ctx.storage.create_resumable_upload(
761
- "video.mp4",
762
+ # Large backend-generated files should stream from disk. This avoids loading
763
+ # GB-sized files into memory and reports chunk progress.
764
+ progress = []
765
+ saved = await ctx.storage.upload_file_path(
766
+ "/tmp/video.mp4",
762
767
  "video/mp4",
763
- size=video_size,
764
768
  key="videos/video.mp4",
769
+ chunk_size=8 * 1024 * 1024,
770
+ on_progress=lambda event: progress.append(event),
765
771
  )
772
+
773
+ # If you already have an open binary stream, use upload_file_stream(...).
774
+ # Non-seekable streams must pass size=.
775
+ with open("/tmp/video.mp4", "rb") as stream:
776
+ saved = await ctx.storage.upload_file_stream(
777
+ "video.mp4",
778
+ stream,
779
+ "video/mp4",
780
+ size=video_size,
781
+ key="videos/video.mp4",
782
+ on_progress=lambda event: progress.append(event),
783
+ )
766
784
  ```
767
785
 
768
786
  Frontend apps can use the browser SDK for chunked/resumable upload progress:
@@ -83,7 +83,7 @@ async function run(args, { cwd }) {
83
83
  loadLocalEnv(cwd)
84
84
  if (cloud) {
85
85
  const json = args.includes("--json")
86
- const publishArgs = []
86
+ const publishArgs = ["--publish-type", "preview"]
87
87
  if (flags.env) publishArgs.push("--env", flags.env)
88
88
  if (flags.yes) publishArgs.push("--yes")
89
89
  if (args.includes("--json")) publishArgs.push("--json")
@@ -165,6 +165,20 @@ function runPreflight(cwd, json) {
165
165
  process.exit(res.status || 1)
166
166
  }
167
167
 
168
+
169
+ function parsePublishType(argv) {
170
+ for (let i = 0; i < argv.length; i += 1) {
171
+ const arg = argv[i]
172
+ if (arg === "--preview") return "preview"
173
+ if (arg === "--release") return "release"
174
+ if (arg === "--publish-type") return argv[i + 1] === "preview" ? "preview" : "release"
175
+ if (arg.startsWith("--publish-type=")) {
176
+ return arg.slice("--publish-type=".length) === "preview" ? "preview" : "release"
177
+ }
178
+ }
179
+ return "release"
180
+ }
181
+
168
182
  function makeApi(env) {
169
183
  return async function api(pathname, { method = "GET", body, headers = {} } = {}) {
170
184
  const url = `${env.url}${pathname}`
@@ -266,6 +280,7 @@ async function run(argv, { cwd }) {
266
280
  }
267
281
 
268
282
  const manifest = loadManifest(cwd)
283
+ const publishType = parsePublishType(argv)
269
284
  loadLocalEnv(cwd)
270
285
  const errors = validateManifest(manifest)
271
286
  if (errors.length) {
@@ -310,6 +325,7 @@ async function run(argv, { cwd }) {
310
325
  plugin_id: manifest.id,
311
326
  version: manifest.version,
312
327
  bundle_sha256: backendSha,
328
+ publish_type: publishType,
313
329
  },
314
330
  })
315
331
 
@@ -334,6 +350,7 @@ async function run(argv, { cwd }) {
334
350
  bundle_path: signed.bundle_path,
335
351
  bundle_sha256: backendSha,
336
352
  manifest,
353
+ publish_type: publishType,
337
354
  environment: env.name,
338
355
  }
339
356
  if (Object.keys(pluginSecrets).length) publishBody.plugin_secrets = pluginSecrets
@@ -357,6 +374,7 @@ async function run(argv, { cwd }) {
357
374
  version: record.version,
358
375
  env: env.name,
359
376
  url: env.url,
377
+ publish_type: record.publish_type || publishType,
360
378
  preview_url: record.preview_url,
361
379
  preview_expires_at: record.preview_expires_at,
362
380
  published_at: new Date().toISOString(),
@@ -375,12 +393,16 @@ async function run(argv, { cwd }) {
375
393
  }
376
394
 
377
395
  console.log(
378
- `[pltt] published ${record.plugin_id}@${record.version} (status=${record.status})`,
396
+ `[pltt] ${publishType === "preview" ? "previewed" : "published"} ${record.plugin_id}@${record.version} (status=${record.status})`,
379
397
  )
380
398
  if (record.status === "active") {
381
399
  console.log(`[pltt] active on ${env.url}`)
400
+ } else if (record.status === "preview_ready") {
401
+ console.log(`[pltt] preview ready on ${env.url}`)
402
+ } else if (record.status === "preview_pending") {
403
+ console.log(`[pltt] awaiting superadmin preview approval on ${env.url}`)
382
404
  } else {
383
- console.log(`[pltt] awaiting superadmin review on ${env.url}`)
405
+ console.log(`[pltt] awaiting superadmin publish approval on ${env.url}`)
384
406
  }
385
407
  if (record.review_url && record.status !== "active") {
386
408
  console.log(`[pltt] review queue: ${record.review_url}`)
@@ -393,7 +415,7 @@ async function run(argv, { cwd }) {
393
415
  }
394
416
  if (record.status === "active") {
395
417
  console.log(`[pltt] live at ${env.url}${record.catalog_url}`)
396
- } else {
418
+ } else if (publishType === "release") {
397
419
  console.log(`[pltt] once approved, live at ${env.url}${record.catalog_url}`)
398
420
  }
399
421
  return record
@@ -63,6 +63,7 @@ async function run(argv, { cwd }) {
63
63
  }
64
64
 
65
65
  console.log(`[pltt] ${data.plugin_id}@${data.version}`)
66
+ if (data.publish_type) console.log(` type: ${data.publish_type}`)
66
67
  console.log(` status: ${data.status}`)
67
68
  if (data.review_decision) console.log(` decision: ${data.review_decision}`)
68
69
  if (data.review_reason) console.log(` reason: ${data.review_reason}`)
@@ -145,7 +145,7 @@ function ensurePythonEnv(cwd, devDir, manifest) {
145
145
  return venvPython
146
146
  }
147
147
 
148
- function writeBackendRunner(cwd, devDir, manifest, backendEntry) {
148
+ function writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort) {
149
149
  const runner = path.join(devDir, "backend_runner.py")
150
150
  const sdkPath = localBackendSdkPath()
151
151
  const databasePath = path.join(devDir, `${manifest.id}.sqlite3`)
@@ -159,10 +159,12 @@ import importlib.util
159
159
  import json
160
160
  import os
161
161
  import pathlib
162
+ import re
162
163
  import sys
164
+ import uuid
163
165
  from types import SimpleNamespace
164
166
 
165
- from fastapi import FastAPI, Request
167
+ from fastapi import FastAPI, HTTPException, Request, Response
166
168
  from fastapi.middleware.cors import CORSMiddleware
167
169
  from starlette.middleware.base import BaseHTTPMiddleware
168
170
 
@@ -175,6 +177,7 @@ DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///$
175
177
  DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
176
178
  DEV_CONNECTIONS = json.loads(${JSON.stringify(JSON.stringify(devConnections))})
177
179
  DEV_APP_MOCKS = json.loads(${JSON.stringify(JSON.stringify(devAppMocks))})
180
+ BACKEND_BASE = "http://127.0.0.1:${backendPort}"
178
181
 
179
182
  def _service_enabled(name: str) -> bool:
180
183
  services = MANIFEST.get("platform_services") or []
@@ -209,6 +212,34 @@ if _service_enabled("storage"):
209
212
  organization_name="Palette Dev",
210
213
  )
211
214
 
215
+ LOCAL_UPLOADS = {}
216
+ CONTENT_RANGE_RE = re.compile(r"^bytes (?P<start>\\d+)-(?P<end>\\d+)/(?P<total>\\d+)$")
217
+
218
+ def _local_storage_enabled():
219
+ return _service_enabled("storage") and DEV_STORAGE is not None
220
+
221
+ def _parse_content_range(value: str | None):
222
+ if not value:
223
+ raise HTTPException(status_code=411, detail="Content-Range header is required")
224
+ match = CONTENT_RANGE_RE.match(value)
225
+ if not match:
226
+ raise HTTPException(status_code=400, detail="Invalid Content-Range header")
227
+ start = int(match.group("start"))
228
+ end = int(match.group("end"))
229
+ total = int(match.group("total"))
230
+ if end < start or total < 0 or end >= total:
231
+ raise HTTPException(status_code=400, detail="Invalid Content-Range byte range")
232
+ return start, end, total
233
+
234
+ def _require_local_storage(plugin_id: str):
235
+ if plugin_id != MANIFEST.get("id", ""):
236
+ raise HTTPException(status_code=404, detail="App not found")
237
+ if not _local_storage_enabled():
238
+ raise HTTPException(status_code=403, detail='App must declare platform_services: ["storage"]')
239
+
240
+ def _api_url(path: str):
241
+ return f"{BACKEND_BASE}/api/v1{path}"
242
+
212
243
  class LocalAppInteropService:
213
244
  def service(self, service_id: str):
214
245
  return LocalAppServiceClient(service_id)
@@ -310,6 +341,84 @@ app.add_middleware(
310
341
  app.add_middleware(DevPluginContextMiddleware)
311
342
  app.include_router(router, prefix=f"/api/v1/plugins/{MANIFEST['id']}")
312
343
 
344
+ @app.post("/api/v1/app-storage/{plugin_id}/uploads")
345
+ async def create_local_app_storage_upload(plugin_id: str, request: Request):
346
+ _require_local_storage(plugin_id)
347
+ body = await request.json()
348
+ filename = body.get("filename") or "upload"
349
+ content_type = body.get("content_type") or "application/octet-stream"
350
+ size = int(body.get("size") or 0)
351
+ key = body.get("key")
352
+ chunk_size = int(body.get("chunk_size") or 8 * 1024 * 1024)
353
+ upload_id = uuid.uuid4().hex
354
+ object_path = DEV_STORAGE.object_path(filename, key=key)
355
+ temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
356
+ temp_path.parent.mkdir(parents=True, exist_ok=True)
357
+ temp_path.write_bytes(b"")
358
+ LOCAL_UPLOADS[upload_id] = {
359
+ "plugin_id": plugin_id,
360
+ "object_path": object_path,
361
+ "content_type": content_type,
362
+ "size": size,
363
+ "uploaded_bytes": 0,
364
+ "complete": False,
365
+ }
366
+ return {
367
+ "upload_id": upload_id,
368
+ "mode": "local_resumable",
369
+ "bucket": "local",
370
+ "object_path": object_path,
371
+ "file_url": DEV_STORAGE._target(object_path).as_uri(),
372
+ "upload_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/chunks"),
373
+ "status_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/status"),
374
+ "content_type": content_type,
375
+ "size": size,
376
+ "chunk_size": chunk_size,
377
+ }
378
+
379
+ @app.get("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/status")
380
+ async def local_app_storage_upload_status(plugin_id: str, upload_id: str):
381
+ _require_local_storage(plugin_id)
382
+ session = LOCAL_UPLOADS.get(upload_id)
383
+ if not session or session["plugin_id"] != plugin_id:
384
+ raise HTTPException(status_code=404, detail="Upload session not found")
385
+ return {
386
+ "upload_id": upload_id,
387
+ "object_path": session["object_path"],
388
+ "uploaded_bytes": session["uploaded_bytes"],
389
+ "size": session["size"],
390
+ "complete": session["complete"],
391
+ "file_url": DEV_STORAGE._target(session["object_path"]).as_uri(),
392
+ }
393
+
394
+ @app.put("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/chunks")
395
+ async def upload_local_app_storage_chunk(plugin_id: str, upload_id: str, request: Request):
396
+ _require_local_storage(plugin_id)
397
+ session = LOCAL_UPLOADS.get(upload_id)
398
+ if not session or session["plugin_id"] != plugin_id:
399
+ raise HTTPException(status_code=404, detail="Upload session not found")
400
+ if session["complete"]:
401
+ return Response(status_code=204)
402
+ start, end, total = _parse_content_range(request.headers.get("content-range"))
403
+ if total != session["size"]:
404
+ raise HTTPException(status_code=400, detail="Chunk total does not match upload size")
405
+ if start != session["uploaded_bytes"]:
406
+ raise HTTPException(status_code=409, detail=f"Expected chunk to start at byte {session['uploaded_bytes']}")
407
+ payload = await request.body()
408
+ if len(payload) != end - start + 1:
409
+ raise HTTPException(status_code=400, detail="Chunk size does not match Content-Range")
410
+ temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
411
+ with temp_path.open("ab") as fh:
412
+ fh.write(payload)
413
+ session["uploaded_bytes"] = end + 1
414
+ if session["uploaded_bytes"] >= session["size"]:
415
+ final_path = DEV_STORAGE._target(session["object_path"])
416
+ final_path.parent.mkdir(parents=True, exist_ok=True)
417
+ temp_path.replace(final_path)
418
+ session["complete"] = True
419
+ return Response(status_code=201)
420
+ return Response(status_code=308, headers={"Range": f"bytes=0-{session['uploaded_bytes'] - 1}"})
421
+
313
422
  @app.on_event("startup")
314
423
  async def create_local_database_tables():
315
424
  if engine is None:
@@ -328,7 +437,7 @@ function startBackend(cwd, devDir, manifest, backendPort) {
328
437
  if (!fs.existsSync(absEntry)) throw new Error(`backend entry not found: ${backendEntry}`)
329
438
 
330
439
  const python = ensurePythonEnv(cwd, devDir, manifest)
331
- const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry)
440
+ const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort)
332
441
  const sdkPath = localBackendSdkPath()
333
442
  const env = { ...process.env }
334
443
  if (sdkPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.43",
3
+ "version": "0.3.45",
4
4
  "description": "Developer CLI for building Palette platform plugins — no platform source access required.",
5
5
  "bin": {
6
6
  "pltt": "bin/pltt.js"