Skip to content

Commit 969f2ea

Browse files
MajorTalclaude
andcommitted
feat(sdk,cli,mcp): migrate assets to unified-apply substrate (v2.1)
Routes r.assets.put through /content/v1/plans → S3 PUT → apply commit; removes legacy /storage/v1/uploads* methods (gone in gateway v1.48). Adds assets.uploadDir (Node), assets spec to ReleaseSpec/deploy-manifest, and assets_put MCP tool wired into the deploy tool's apply flow. Updates schemas, docs, and integration tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5b0ddf2 commit 969f2ea

20 files changed

Lines changed: 1416 additions & 1320 deletions

cli/lib/assets.mjs

Lines changed: 26 additions & 210 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,13 @@
1919
*/
2020

2121
import {
22-
createReadStream,
2322
statSync,
2423
readFileSync,
25-
writeFileSync,
2624
mkdirSync,
27-
chmodSync,
2825
existsSync,
29-
unlinkSync,
30-
readdirSync,
3126
createWriteStream,
3227
} from "node:fs";
33-
import { createHash } from "node:crypto";
34-
import { basename, dirname, join, resolve as resolvePath } from "node:path";
35-
import { homedir } from "node:os";
28+
import { basename, dirname, resolve as resolvePath } from "node:path";
3629
import { pipeline } from "node:stream/promises";
3730

3831
import { resolveProjectId } from "./config.mjs";
@@ -55,10 +48,7 @@ Options:
5548
--key <dest> Destination key (put only; defaults to file basename)
5649
--content-type <mime> MIME override for blob put (defaults to extension inference)
5750
--private Upload as private (not served by CDN; apikey required to read)
58-
--immutable Adds a content-hash suffix to the URL so overwrites produce distinct URLs.
59-
Requires computing SHA-256 over the file (CLI does this automatically).
60-
--concurrency N Concurrent part PUTs (default 4)
61-
--no-resume Start fresh; ignore any cached state
51+
--immutable Append a content-hash suffix to the URL so overwrites produce distinct URLs.
6252
--json NDJSON progress events (for agent consumption)
6353
--prefix <p> Prefix filter (ls only)
6454
--limit <n> Max results (ls only; default 100, max 1000)
@@ -72,6 +62,11 @@ Examples:
7262
run402 assets ls --project prj_abc123 --prefix images/
7363
run402 assets rm images/logo.png --project prj_abc123
7464
run402 assets sign images/logo.png --project prj_abc123 --ttl 600
65+
66+
Note: as of v2.1.0, the CLI delegates to sdk.assets.put which routes through
67+
the unified-apply hero. The pre-v2.1.0 --concurrency and --no-resume flags
68+
are still accepted for backward compatibility but are ignored; resume
69+
semantics now live at the apply-plan level (24h plan TTL).
7570
`;
7671

7772
const SUB_HELP = {
@@ -89,15 +84,13 @@ Options:
8984
--content-type <mime> MIME override; defaults to inferring from the destination key extension
9085
--private Upload as private (not served by CDN; apikey required to read)
9186
--immutable Append content-hash suffix so overwrites produce distinct URLs
92-
--concurrency N Concurrent part PUTs for multipart uploads (default 4)
93-
--no-resume Ignore any cached resumable-upload state and start fresh
9487
--json Emit NDJSON progress events on stdout (for agent consumption)
9588
9689
Examples:
9790
run402 assets put ./artifact.tgz --project prj_abc123
9891
run402 assets put ./dist/**/*.png --project prj_abc123 --key assets/
9992
run402 assets put ./asset --project prj_abc123 --key assets/logo --content-type image/svg+xml
100-
run402 assets put huge.bin --project prj_abc123 --immutable --concurrency 8
93+
run402 assets put huge.bin --project prj_abc123 --immutable
10194
`,
10295
get: `run402 assets get — Download a blob by key
10396
@@ -185,10 +178,6 @@ Examples:
185178
`,
186179
};
187180

188-
function uploadStateDir() {
189-
return join(homedir(), ".run402", "uploads");
190-
}
191-
192181
function die(msg, exit_code = 1) {
193182
fail({ code: "BAD_USAGE", message: msg, exit_code });
194183
}
@@ -255,173 +244,34 @@ function parseContentTypeFlag(name, value) {
255244
return raw;
256245
}
257246

258-
async function sha256File(filePath) {
259-
const h = createHash("sha256");
260-
const stream = createReadStream(filePath);
261-
for await (const chunk of stream) h.update(chunk);
262-
return h.digest("hex");
263-
}
264-
265-
function sha256BufferHexAndBase64(body) {
266-
const digest = createHash("sha256").update(body).digest();
267-
return {
268-
hex: digest.toString("hex"),
269-
base64: digest.toString("base64"),
270-
};
271-
}
272-
273-
function checksumHeadersForPresignedUrl(url, checksumBase64) {
274-
let urlHasChecksum = false;
275-
try {
276-
urlHasChecksum = new URL(url).searchParams.has("x-amz-checksum-sha256");
277-
} catch {
278-
urlHasChecksum = false;
279-
}
280-
return urlHasChecksum ? {} : { "x-amz-checksum-sha256": checksumBase64 };
281-
}
282-
283-
function loadState(uploadId) {
284-
const path = join(uploadStateDir(), `${uploadId}.json`);
285-
if (!existsSync(path)) return null;
286-
try { return JSON.parse(readFileSync(path, "utf8")); }
287-
catch { return null; }
288-
}
289-
290-
function saveState(state) {
291-
const dir = uploadStateDir();
292-
mkdirSync(dir, { recursive: true, mode: 0o700 });
293-
chmodSync(dir, 0o700);
294-
const diskState = { ...state };
295-
delete diskState.parts;
296-
const path = join(dir, `${state.upload_id}.json`);
297-
writeFileSync(path, JSON.stringify(diskState, null, 2), { mode: 0o600 });
298-
chmodSync(path, 0o600);
299-
}
300-
301-
function removeState(uploadId) {
302-
const path = join(uploadStateDir(), `${uploadId}.json`);
303-
if (existsSync(path)) unlinkSync(path);
304-
}
305-
306-
function fileFingerprint(stat) {
307-
return {
308-
file_size: stat.size,
309-
file_mtime_ms: stat.mtimeMs,
310-
};
311-
}
312-
313-
function stateMatchesFile(state, fingerprint) {
314-
return state.file_size === fingerprint.file_size &&
315-
typeof state.file_mtime_ms === "number" &&
316-
state.file_mtime_ms === fingerprint.file_mtime_ms;
317-
}
318-
319-
function findResumableStateForFile(projectId, localPath, key, fingerprint) {
320-
const dir = uploadStateDir();
321-
if (!existsSync(dir)) return null;
322-
for (const f of readdirSync(dir)) {
323-
if (!f.endsWith(".json")) continue;
324-
try {
325-
const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
326-
if (s.project_id === projectId && s.local_path === localPath && s.key === key) {
327-
if (stateMatchesFile(s, fingerprint)) return s;
328-
removeState(s.upload_id);
329-
}
330-
} catch { /* ignore */ }
331-
}
332-
return null;
333-
}
334-
335247
// ---------------------------------------------------------------------------
336248
// put
337249
// ---------------------------------------------------------------------------
338250

339251
async function putOne(projectId, filePath, opts) {
340252
const stat = statSync(filePath);
341-
const size = stat.size;
342-
const fingerprint = fileFingerprint(stat);
343-
const destKey = computeDestKey(filePath, opts.key);
344-
const absLocal = resolvePath(filePath);
345-
346-
const sha256 = await sha256File(filePath);
347-
348-
// Attempt to resume
349-
let state = opts.resume
350-
? findResumableStateForFile(projectId, absLocal, destKey, fingerprint)
351-
: null;
352-
let initRes;
353-
if (state) {
354-
// Re-poll the session; if it's still active, resume. Otherwise start fresh.
355-
const poll = await getSdk().assets.getUploadSession(projectId, state.upload_id);
356-
if (poll.status === "active") {
357-
log(opts, { event: "resume", upload_id: state.upload_id, key: destKey });
358-
initRes = {
359-
upload_id: state.upload_id,
360-
mode: poll.mode ?? state.mode,
361-
parts: poll.parts ?? state.parts ?? [],
362-
part_count: poll.part_count ?? state.part_count,
363-
part_size_bytes: poll.part_size_bytes ?? state.part_size_bytes,
364-
};
365-
} else {
366-
removeState(state.upload_id);
367-
state = null;
368-
}
253+
if (!stat.isFile()) {
254+
die(`Not a regular file: ${filePath}`);
369255
}
256+
const destKey = computeDestKey(filePath, opts.key);
370257

371-
if (!state) {
372-
initRes = await getSdk().assets.initUploadSession(projectId, {
373-
key: destKey,
374-
size_bytes: size,
375-
content_type: opts.contentType ?? guessContentType(destKey),
376-
visibility: opts.private ? "private" : "public",
377-
immutable: opts.immutable,
378-
sha256,
379-
});
380-
state = {
381-
upload_id: initRes.upload_id,
382-
project_id: projectId,
383-
local_path: absLocal,
384-
key: destKey,
385-
mode: initRes.mode,
386-
part_size_bytes: initRes.part_size_bytes,
387-
part_count: initRes.part_count,
388-
parts: initRes.parts,
389-
parts_done: {},
390-
sha256,
391-
...fingerprint,
392-
started_at: new Date().toISOString(),
393-
};
394-
if (opts.resume) saveState(state);
395-
}
396-
397-
// Upload parts with concurrency limit. For single-PUT mode part_count=1 and
398-
// this loop runs once.
399-
const etags = Array(initRes.part_count);
400-
for (const pn of Object.keys(state.parts_done || {})) {
401-
const pd = state.parts_done[pn];
402-
// Legacy resume state stored just the etag string; new code stores
403-
// { etag, sha256 }. Normalize on load.
404-
etags[parseInt(pn, 10) - 1] = typeof pd === "string" ? { etag: pd, sha256: undefined } : pd;
405-
}
406-
407-
const todo = initRes.parts.filter((p) => !(state.parts_done || {})[String(p.part_number)]);
408-
await withConcurrency(todo, opts.concurrency, async (part) => {
409-
const { etag, sha256: partSha256 } = await putPart(filePath, part);
410-
etags[part.part_number - 1] = { etag, sha256: partSha256 };
411-
state.parts_done[String(part.part_number)] = { etag, sha256: partSha256 };
412-
if (opts.resume) saveState(state);
413-
log(opts, { event: "part", upload_id: state.upload_id, part_number: part.part_number, etag, sha256: partSha256 });
414-
});
415-
416-
// Complete
417-
const body = initRes.mode === "multipart"
418-
? { parts: etags.map((e, i) => ({ part_number: i + 1, etag: e.etag, sha256: e.sha256 })) }
419-
: {};
420-
const result = await getSdk().assets.completeUploadSession(projectId, state.upload_id, body, {
258+
// v2.1.0: the legacy /storage/v1/uploads* session API is gone. The CLI
259+
// now delegates to `sdk.assets.put`, which routes through the
260+
// unified-apply hero (apply/v1/plans -> content/v1/plans -> S3 PUT ->
261+
// commit).
262+
//
263+
// Trade-off vs v2.0.x: resumable uploads via persisted state under
264+
// ~/.run402/uploads/ are no longer supported. Resume semantics now live
265+
// at the apply-plan level (24h plan TTL); a future CLI redesign can
266+
// expose that. The --concurrency and --no-resume flags are accepted but
267+
// ignored — the SDK upload paths handle parallelism internally.
268+
log(opts, { event: "start", key: destKey, size_bytes: stat.size });
269+
const bytes = new Uint8Array(readFileSync(filePath));
270+
const result = await getSdk().assets.put(projectId, destKey, { bytes }, {
421271
contentType: opts.contentType ?? guessContentType(destKey),
272+
visibility: opts.private ? "private" : "public",
273+
immutable: opts.immutable,
422274
});
423-
424-
removeState(state.upload_id);
425275
log(opts, { event: "done", ...result });
426276
return result;
427277
}
@@ -432,40 +282,6 @@ function computeDestKey(filePath, keyOpt) {
432282
return keyOpt;
433283
}
434284

435-
async function putPart(filePath, part) {
436-
const start = part.byte_start ?? 0;
437-
const end = part.byte_end ?? (statSync(filePath).size - 1);
438-
const stream = createReadStream(filePath, { start, end });
439-
const chunks = [];
440-
for await (const c of stream) chunks.push(c);
441-
const body = Buffer.concat(chunks);
442-
const checksum = sha256BufferHexAndBase64(body);
443-
444-
const res = await fetch(part.url, {
445-
method: "PUT",
446-
headers: checksumHeadersForPresignedUrl(part.url, checksum.base64),
447-
body,
448-
});
449-
if (!res.ok) {
450-
const errBody = await res.text().catch(() => "");
451-
throw new Error(`Part ${part.part_number} PUT failed: ${res.status} ${res.statusText}${errBody ? " — " + errBody.slice(0, 200) : ""}`);
452-
}
453-
const etag = res.headers.get("etag") ?? "";
454-
return { etag, sha256: checksum.hex };
455-
}
456-
457-
async function withConcurrency(items, limit, worker) {
458-
let index = 0;
459-
const workerCount = Math.min(limit, items.length);
460-
async function runWorker() {
461-
while (index < items.length) {
462-
const item = items[index++];
463-
await worker(item);
464-
}
465-
}
466-
await Promise.all(Array.from({ length: workerCount }, runWorker));
467-
}
468-
469285
async function put(projectId, argv) {
470286
const opts = parseArgs(argv);
471287
opts.project = opts.project || projectId;

cli/llms-cli.txt

Lines changed: 19 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -859,7 +859,7 @@ Other AssetRef fields for advanced use:
859859

860860
**Agent loop pattern (mutable URL only):** if you must use a stable mutable URL across re-uploads, `run402 cdn wait-fresh <mutable-url> --sha <new-sha>` blocks until the CDN serves the new SHA. **Don't call wait-fresh on immutable URLs** — they're correct from the moment of upload.
861861

862-
Resume: state is persisted to `~/.run402/uploads/<upload_id>.json` with restrictive permissions (directory 0700, files 0600). The state is bound to project, local path, destination key, size, and mtime, and it does not persist presigned part URLs. Ctrl-C mid-upload and re-run the same `blob put` command — it picks up where it left off when the local file still matches. Pass `--no-resume` to start fresh.
862+
Resume: removed in v2.1.0. The CLI now delegates to `sdk.assets.put`, which routes through the unified-apply hero (`apply/v1/plans → content/v1/plans → S3 PUT → commit`). The `--concurrency` and `--no-resume` flags are accepted for backward compatibility but ignored — resume semantics now live at the apply-plan level (24h plan TTL).
863863

864864
Private blobs (`--private`): no CDN URL returned; read via authenticated gateway path `GET /storage/v1/blob/<key>` with apikey, or via `run402 assets sign` for time-boxed external sharing.
865865

@@ -870,45 +870,28 @@ Content-Type: `blob put` infers MIME from the destination key extension. Use `--
870870
#### Uploading from Node/agent code
871871

872872
```javascript
873-
import { run402 } from "@run402/sdk/node";
873+
import { run402, dir } from "@run402/sdk/node";
874874

875875
const client = run402();
876876

877-
// 1. Init through the SDK. The gateway returns presigned part URLs.
878-
const init = await client.assets.initUploadSession(projectId, {
879-
key: "uploads/report.pdf",
880-
size_bytes: buf.length,
881-
content_type: "application/pdf",
882-
sha256: objectSha256Hex,
883-
});
884-
885-
// 2. PUT each part URL (bytes go direct to S3, NOT through the gateway).
886-
// Send x-amz-checksum-sha256 unless the presigned URL already encodes it.
887-
const etags = [];
888-
for (const part of init.parts) {
889-
const body = buf.slice(part.byte_start, part.byte_end + 1);
890-
const partSha256Hex = sha256Hex(body);
891-
const partChecksumBase64 = sha256Base64(body);
892-
const headers = new URL(part.url).searchParams.has("x-amz-checksum-sha256")
893-
? {}
894-
: { "x-amz-checksum-sha256": partChecksumBase64 };
895-
const res = await fetch(part.url, {
896-
method: "PUT",
897-
headers,
898-
body,
899-
});
900-
etags.push({ part_number: part.part_number, etag: res.headers.get("etag"), sha256: partSha256Hex });
901-
}
902-
903-
// 3. Complete through the SDK.
904-
const asset = await client.assets.completeUploadSession(
905-
projectId,
906-
init.upload_id,
907-
{ parts: etags },
908-
{ contentType: "application/pdf" },
909-
);
910-
877+
// Single key — bytes/string source.
878+
const asset = await client.assets.put(projectId, "uploads/report.pdf", { bytes });
911879
// asset.cdnUrl is the preferred content-addressed URL for public blobs.
880+
881+
// Whole directory in one apply (atomic with site/functions/secrets if combined).
882+
const manifest = await client.assets.uploadDir("./assets", {
883+
project: projectId,
884+
prefix: "static/",
885+
});
886+
console.log(manifest.byKey["static/logo.png"].cdn_url);
887+
888+
// Or as part of a release apply (assets promote inside the same activation
889+
// transaction that flips live_release_id).
890+
await client.deploy.apply({
891+
project: projectId,
892+
assets: { put: [{ key: "static/logo.png", source: bytes }] },
893+
site: dir("./dist"),
894+
});
912895
```
913896

914897
### sites

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "run402",
3-
"version": "2.0.1",
3+
"version": "2.1.0",
44
"description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
55
"type": "module",
66
"bin": {

0 commit comments

Comments
 (0)