@palettelab/cli 0.3.44 → 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
@@ -342,6 +342,18 @@ async def save_report(ctx: PluginContext = Depends(get_plugin_context)):
342
342
  key="reports/summary.json",
343
343
  )
344
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:]}
345
357
  ```
346
358
 
347
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,
@@ -759,12 +759,28 @@ saved = await ctx.storage.upload_file(
759
759
  key="reports/summary.json",
760
760
  )
761
761
 
762
- session = await ctx.storage.create_resumable_upload(
763
- "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",
764
767
  "video/mp4",
765
- size=video_size,
766
768
  key="videos/video.mp4",
769
+ chunk_size=8 * 1024 * 1024,
770
+ on_progress=lambda event: progress.append(event),
767
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
+ )
768
784
  ```
769
785
 
770
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}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palettelab/cli",
3
- "version": "0.3.44",
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"