@foldspace_npm/harness 0.1.32 → 0.1.33
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/bin/share.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']
|
|
3
|
+
//
|
|
4
|
+
// inspect - sizes and checks the MP4 and its poster here; uploads nothing.
|
|
5
|
+
// Its video.bytes and poster.bytes go into the MCP tool
|
|
6
|
+
// create_video_share.
|
|
7
|
+
// upload - sends both files to Foldspace's video storage with the one-time
|
|
8
|
+
// targets create_video_share returned (its upload command), then
|
|
9
|
+
// the agent calls publish_video_share for the link.
|
|
10
|
+
//
|
|
11
|
+
// The CLI holds no Foldspace sign-in: creating and publishing a share are MCP
|
|
12
|
+
// tools, only the bytes go through here. The targets are never printed.
|
|
13
|
+
// Sharing is always the human's call: run upload only after they said yes.
|
|
14
|
+
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
import { parseArgs, printJson } from "../src/cdp-client.mjs";
|
|
18
|
+
import { recordEvent } from "../src/session-events.mjs";
|
|
19
|
+
import { resolveProjectDir } from "../src/upgrade.mjs";
|
|
20
|
+
import { ShareError, defaultVideo, inspectForShare, parseTargets, posterFor, resumableUpload } from "../src/video/share.mjs";
|
|
21
|
+
|
|
22
|
+
const { flags, rest } = parseArgs(process.argv.slice(2), ["poster", "targets"]);
|
|
23
|
+
const [action, videoArg] = rest;
|
|
24
|
+
const projectDir = path.resolve(resolveProjectDir());
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
if (action !== "inspect" && action !== "upload") {
|
|
28
|
+
throw new ShareError("USAGE", "Usage: foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']");
|
|
29
|
+
}
|
|
30
|
+
const videoPath = videoArg ? path.resolve(projectDir, videoArg) : defaultVideo(projectDir);
|
|
31
|
+
const posterPath = flags.poster ? path.resolve(projectDir, String(flags.poster)) : posterFor(videoPath);
|
|
32
|
+
const files = inspectForShare({ videoPath, posterPath });
|
|
33
|
+
|
|
34
|
+
if (action === "inspect") {
|
|
35
|
+
printJson({
|
|
36
|
+
ok: true,
|
|
37
|
+
video: { path: path.relative(projectDir, files.video.path), bytes: files.video.bytes, durationS: round(files.video.durationS) },
|
|
38
|
+
poster: { path: path.relative(projectDir, files.poster.path), bytes: files.poster.bytes },
|
|
39
|
+
next: "Only if the human agreed to share it: call create_video_share with a title, video_bytes and poster_bytes, then run the upload command it returns.",
|
|
40
|
+
});
|
|
41
|
+
} else {
|
|
42
|
+
if (!flags.targets) throw new ShareError("BAD_TARGETS", "--targets is required: run the upload command create_video_share returned.");
|
|
43
|
+
const targets = parseTargets(flags.targets);
|
|
44
|
+
await resumableUpload(targets.video, files.video.path, { label: "video" });
|
|
45
|
+
await resumableUpload(targets.poster, files.poster.path, { label: "poster" });
|
|
46
|
+
recordEvent(projectDir, "share-upload", { videoBytes: files.video.bytes });
|
|
47
|
+
printJson({
|
|
48
|
+
ok: true,
|
|
49
|
+
status: "uploaded",
|
|
50
|
+
videoBytes: files.video.bytes,
|
|
51
|
+
posterBytes: files.poster.bytes,
|
|
52
|
+
next: "Call publish_video_share with the share_id from create_video_share; it returns the public link.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const code = error instanceof ShareError ? error.code : "UNEXPECTED";
|
|
57
|
+
printJson({ ok: false, code, message: error instanceof Error ? error.message : String(error) });
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function round(seconds) {
|
|
62
|
+
return Math.round(seconds * 10) / 10;
|
|
63
|
+
}
|
package/package.json
CHANGED
package/src/cli-registry.mjs
CHANGED
|
@@ -490,6 +490,48 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
490
490
|
"To change a scene's capabilities or line: edit the scenes file, then `npx foldspace record --from <recordDir>`",
|
|
491
491
|
],
|
|
492
492
|
}),
|
|
493
|
+
Object.freeze({
|
|
494
|
+
name: "share",
|
|
495
|
+
entry: "share.mjs",
|
|
496
|
+
group: "share",
|
|
497
|
+
summary: "Size and check a video for a Foldspace share link, then upload it with the targets create_video_share returned",
|
|
498
|
+
usage: "foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']",
|
|
499
|
+
risk: "remote-upload",
|
|
500
|
+
environment: "node",
|
|
501
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
|
|
502
|
+
capabilities: ["video.upload"],
|
|
503
|
+
positionals: [
|
|
504
|
+
Object.freeze({
|
|
505
|
+
name: "action",
|
|
506
|
+
required: true,
|
|
507
|
+
description:
|
|
508
|
+
"inspect: size and check the MP4 and its poster here, uploading nothing | upload: send both to Foldspace's video storage with the targets create_video_share returned",
|
|
509
|
+
}),
|
|
510
|
+
Object.freeze({
|
|
511
|
+
name: "video",
|
|
512
|
+
required: false,
|
|
513
|
+
description: "The MP4; default the newest video/*.mp4 that `record` made",
|
|
514
|
+
}),
|
|
515
|
+
],
|
|
516
|
+
options: [
|
|
517
|
+
value("--poster", "file.jpg", "The JPEG poster", { default: "the MP4's name with .jpg" }),
|
|
518
|
+
value("--targets", "json", "upload only: the targets JSON exactly as the upload command from create_video_share has it"),
|
|
519
|
+
],
|
|
520
|
+
prerequisites: [
|
|
521
|
+
"An H.264 MP4 with its moov box first (what `record` makes) and a JPEG poster",
|
|
522
|
+
"upload: the human said yes to a public link, and the Foldspace MCP tool create_video_share returned the upload command (its targets last 30 minutes)",
|
|
523
|
+
],
|
|
524
|
+
effects: [
|
|
525
|
+
"inspect reads the two files and nothing else",
|
|
526
|
+
"upload sends the MP4 and the poster to Foldspace's video storage, and only there; it reads no Foldspace sign-in",
|
|
527
|
+
"Never prints the upload targets: each is a one-time credential for one file",
|
|
528
|
+
"Anyone with the link publish_video_share returns can watch the video, which shows real data from the account",
|
|
529
|
+
],
|
|
530
|
+
next: [
|
|
531
|
+
"After inspect, with the human's yes: create_video_share with a title, video_bytes and poster_bytes, then run its upload command",
|
|
532
|
+
"After upload: publish_video_share with the share_id; give the human the link",
|
|
533
|
+
],
|
|
534
|
+
}),
|
|
493
535
|
Object.freeze({
|
|
494
536
|
name: "deploy",
|
|
495
537
|
// Foldspace-internal: it uploads to Foldspace's own storage, which no
|
|
@@ -542,12 +584,14 @@ export const CAPABILITY_CATALOGUE = Object.freeze([
|
|
|
542
584
|
["page.evaluate", "Inspect bounded SDK and registration state"],
|
|
543
585
|
["page.mutate", "Prepare and restore a matching development page"],
|
|
544
586
|
["artifact.publish", "Write the built artifact to remote storage"],
|
|
587
|
+
["video.upload", "Upload a video and its poster for a Foldspace share link, with targets the Foldspace MCP issued"],
|
|
545
588
|
]);
|
|
546
589
|
|
|
547
590
|
export const RISK_CATALOGUE = Object.freeze([
|
|
548
591
|
["local-write", "Writes only inside the consuming project"],
|
|
549
592
|
["browser-session", "Launches or temporarily mutates a local browser session"],
|
|
550
593
|
["remote-publish", "Writes the action artifact to remote storage"],
|
|
594
|
+
["remote-upload", "Uploads files the human chose to share to Foldspace's video storage"],
|
|
551
595
|
]);
|
|
552
596
|
|
|
553
597
|
export const ENVIRONMENT_CATALOGUE = Object.freeze([
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// foldspace share - the bytes half of sharing a video by a Foldspace link.
|
|
2
|
+
//
|
|
3
|
+
// The CLI holds no Foldspace sign-in (the editor's MCP does), so sharing is
|
|
4
|
+
// three steps the agent takes, only after the human said yes:
|
|
5
|
+
// 1. `npx foldspace share inspect` - sizes and checks the MP4 and poster here
|
|
6
|
+
// 2. MCP `create_video_share` - returns one-time signed upload targets
|
|
7
|
+
// (`upload_command`), then `npx foldspace share upload` sends the files
|
|
8
|
+
// straight to Foldspace's video storage with them
|
|
9
|
+
// 3. MCP `publish_video_share` - the server checks the files and returns
|
|
10
|
+
// the public link
|
|
11
|
+
// Nothing here talks to Foldspace except the upload targets the MCP issued,
|
|
12
|
+
// and nothing prints them: a target is a bearer credential for one object.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
/** GCS requires every chunk but the last to be a multiple of 256 KiB. */
|
|
18
|
+
export const CHUNK_UNIT_BYTES = 256 * 1024;
|
|
19
|
+
export const DEFAULT_CHUNK_BYTES = 32 * CHUNK_UNIT_BYTES; // 8 MiB
|
|
20
|
+
const MAX_ATTEMPTS_PER_CHUNK = 4;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The only hosts an upload may go to: Google Cloud Storage, where Foldspace's video bucket is, and
|
|
24
|
+
* this machine for the local demo. A target anywhere else is refused, so a tampered or injected
|
|
25
|
+
* upload command cannot send the human's recording somewhere else.
|
|
26
|
+
*/
|
|
27
|
+
export function isAllowedUploadUrl(value) {
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(value);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (url.protocol === "https:" && url.hostname === "storage.googleapis.com") return true;
|
|
35
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Mirrors the server's caps (share-service SHARE_MAX_*): checked here so a too-big file fails before any upload. */
|
|
39
|
+
export const MAX_VIDEO_BYTES = 500 * 1024 * 1024;
|
|
40
|
+
export const MAX_POSTER_BYTES = 5 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
export class ShareError extends Error {
|
|
43
|
+
constructor(code, message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.code = code;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The newest video/*.mp4 that `record` made, with its poster beside it. */
|
|
50
|
+
export function defaultVideo(projectDir) {
|
|
51
|
+
const dir = path.join(projectDir, "video");
|
|
52
|
+
let names = [];
|
|
53
|
+
try {
|
|
54
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith(".mp4"));
|
|
55
|
+
} catch {
|
|
56
|
+
// no video folder
|
|
57
|
+
}
|
|
58
|
+
if (!names.length) {
|
|
59
|
+
throw new ShareError("NO_VIDEO", "No video/*.mp4 in this project. Make one with `npx foldspace record`, or pass the MP4's path.");
|
|
60
|
+
}
|
|
61
|
+
const newest = names
|
|
62
|
+
.map((name) => ({ name, mtime: fs.statSync(path.join(dir, name)).mtimeMs }))
|
|
63
|
+
.sort((a, b) => b.mtime - a.mtime)[0].name;
|
|
64
|
+
return path.join(dir, newest);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function posterFor(videoPath) {
|
|
68
|
+
return videoPath.replace(/\.mp4$/i, ".jpg");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sizes the two files and runs the same structural checks the server's publish
|
|
73
|
+
* check runs (MP4 with ftyp, moov before mdat; JPEG poster), so the agent hears
|
|
74
|
+
* "re-encode with faststart" before uploading 500 MB rather than after.
|
|
75
|
+
*/
|
|
76
|
+
export function inspectForShare({ videoPath, posterPath }) {
|
|
77
|
+
const video = statFile(videoPath, "NO_VIDEO", "video");
|
|
78
|
+
const poster = statFile(posterPath, "NO_POSTER", "poster");
|
|
79
|
+
if (video.bytes > MAX_VIDEO_BYTES) throw new ShareError("TOO_LARGE", `The video is ${mb(video.bytes)} MB; the limit is ${mb(MAX_VIDEO_BYTES)} MB.`);
|
|
80
|
+
if (poster.bytes > MAX_POSTER_BYTES) throw new ShareError("TOO_LARGE", `The poster is ${mb(poster.bytes)} MB; the limit is ${mb(MAX_POSTER_BYTES)} MB.`);
|
|
81
|
+
|
|
82
|
+
const head = readSlice(posterPath, 0, 3);
|
|
83
|
+
if (!(head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff)) {
|
|
84
|
+
throw new ShareError("NOT_JPEG", "The poster must be a JPEG image.");
|
|
85
|
+
}
|
|
86
|
+
const durationS = readMp4Duration(videoPath, video.bytes);
|
|
87
|
+
return { video: { ...video, durationS }, poster };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Walks top-level boxes: ftyp first, moov before mdat; returns mvhd duration in seconds. */
|
|
91
|
+
function readMp4Duration(file, size) {
|
|
92
|
+
const first = boxAt(file, 0, size);
|
|
93
|
+
if (!first || first.type !== "ftyp") throw new ShareError("NOT_MP4", "The video is not an MP4 file.");
|
|
94
|
+
let offset = first.size;
|
|
95
|
+
for (let i = 0; i < 64 && offset < size; i++) {
|
|
96
|
+
const box = boxAt(file, offset, size);
|
|
97
|
+
if (!box) throw new ShareError("NOT_MP4", "The MP4 is truncated or damaged.");
|
|
98
|
+
if (box.type === "mdat") {
|
|
99
|
+
throw new ShareError("NOT_FAST_START", "The MP4 is not fast-start (its moov box comes after the video data). Re-encode it with `-movflags +faststart`.");
|
|
100
|
+
}
|
|
101
|
+
if (box.type === "moov") {
|
|
102
|
+
const body = readSlice(file, offset + box.header, Math.min(box.size - box.header, 16 * 1024 * 1024));
|
|
103
|
+
return mvhdDurationSeconds(body);
|
|
104
|
+
}
|
|
105
|
+
offset += box.size;
|
|
106
|
+
}
|
|
107
|
+
throw new ShareError("NOT_MP4", "The MP4 has no moov box.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function mvhdDurationSeconds(moov) {
|
|
111
|
+
let offset = 0;
|
|
112
|
+
while (offset + 8 <= moov.length) {
|
|
113
|
+
const size = moov.readUInt32BE(offset);
|
|
114
|
+
const type = moov.toString("latin1", offset + 4, offset + 8);
|
|
115
|
+
if (size < 8) break;
|
|
116
|
+
if (type === "mvhd") {
|
|
117
|
+
const body = moov.subarray(offset + 8, offset + size);
|
|
118
|
+
if (body[0] === 1) return Number(body.readBigUInt64BE(24)) / body.readUInt32BE(20);
|
|
119
|
+
return body.readUInt32BE(16) / body.readUInt32BE(12);
|
|
120
|
+
}
|
|
121
|
+
offset += size;
|
|
122
|
+
}
|
|
123
|
+
throw new ShareError("NOT_MP4", "The MP4 has no movie header.");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function boxAt(file, offset, fileSize) {
|
|
127
|
+
if (offset + 8 > fileSize) return null;
|
|
128
|
+
const head = readSlice(file, offset, 16);
|
|
129
|
+
let size = head.readUInt32BE(0);
|
|
130
|
+
let header = 8;
|
|
131
|
+
if (size === 1) {
|
|
132
|
+
size = Number(head.readBigUInt64BE(8));
|
|
133
|
+
header = 16;
|
|
134
|
+
} else if (size === 0) {
|
|
135
|
+
size = fileSize - offset;
|
|
136
|
+
}
|
|
137
|
+
if (size < header || offset + size > fileSize) return null;
|
|
138
|
+
return { type: head.toString("latin1", 4, 8), size, header };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parses the `--targets` JSON that `create_video_share` put in the upload
|
|
143
|
+
* command: { video: {url, method, headers}, poster: {...} }.
|
|
144
|
+
*/
|
|
145
|
+
export function parseTargets(text) {
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(String(text || ""));
|
|
149
|
+
} catch {
|
|
150
|
+
throw new ShareError("BAD_TARGETS", "--targets is not valid JSON. Copy the upload command from create_video_share exactly.");
|
|
151
|
+
}
|
|
152
|
+
for (const name of ["video", "poster"]) {
|
|
153
|
+
const target = parsed?.[name];
|
|
154
|
+
if (!target || typeof target.url !== "string" || target.method !== "POST" || typeof target.headers !== "object") {
|
|
155
|
+
throw new ShareError("BAD_TARGETS", `--targets has no valid ${name} target. Copy the upload command from create_video_share exactly.`);
|
|
156
|
+
}
|
|
157
|
+
if (!isAllowedUploadUrl(target.url)) {
|
|
158
|
+
throw new ShareError("BAD_TARGETS", `The ${name} upload target is not Foldspace's video storage (https://storage.googleapis.com). Use only the command create_video_share returned.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return parsed;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* GCS resumable upload: POST the signed URL to start a session, then PUT the
|
|
166
|
+
* file in chunks with Content-Range. On a dropped connection it asks the
|
|
167
|
+
* session how much arrived (`bytes * /total`) and resumes from there.
|
|
168
|
+
* Errors name the HTTP status and GCS's error code, never the URL.
|
|
169
|
+
*/
|
|
170
|
+
export async function resumableUpload(target, filePath, { fetchImpl = fetch, chunkBytes = DEFAULT_CHUNK_BYTES, label = "file" } = {}) {
|
|
171
|
+
if (chunkBytes % CHUNK_UNIT_BYTES !== 0) throw new Error("chunkBytes must be a multiple of 256 KiB");
|
|
172
|
+
const total = fs.statSync(filePath).size;
|
|
173
|
+
if (total === 0) throw new ShareError("UPLOAD_FAILED", `The ${label} is empty.`);
|
|
174
|
+
// redirect: "manual" everywhere, never "error": GCS answers every unfinished chunk with 308 "Resume
|
|
175
|
+
// Incomplete", which fetch would turn into a network error. A 3xx that names a Location is a real
|
|
176
|
+
// redirect, and is refused (rejectRedirect): storage never sends an upload somewhere else.
|
|
177
|
+
const start = rejectRedirect(await fetchImpl(target.url, { method: "POST", headers: target.headers, body: "", redirect: "manual" }), label);
|
|
178
|
+
if (start.status !== 200 && start.status !== 201) {
|
|
179
|
+
throw new ShareError("UPLOAD_REFUSED", `Starting the ${label} upload failed: ${await describe(start)}. The upload targets last 30 minutes; if they expired, call create_video_share again.`);
|
|
180
|
+
}
|
|
181
|
+
const session = start.headers.get("location");
|
|
182
|
+
if (!session || !isAllowedUploadUrl(session)) throw new ShareError("UPLOAD_REFUSED", `Starting the ${label} upload returned no usable session.`);
|
|
183
|
+
|
|
184
|
+
let offset = 0;
|
|
185
|
+
let attempts = 0;
|
|
186
|
+
const handle = fs.openSync(filePath, "r");
|
|
187
|
+
const buffer = Buffer.alloc(Math.min(chunkBytes, total)); // one buffer, reused for every chunk
|
|
188
|
+
try {
|
|
189
|
+
while (offset < total) {
|
|
190
|
+
const end = Math.min(offset + chunkBytes, total) - 1;
|
|
191
|
+
const length = end - offset + 1;
|
|
192
|
+
fs.readSync(handle, buffer, 0, length, offset);
|
|
193
|
+
const chunk = buffer.subarray(0, length);
|
|
194
|
+
let response;
|
|
195
|
+
try {
|
|
196
|
+
response = await fetchImpl(session, {
|
|
197
|
+
method: "PUT",
|
|
198
|
+
headers: { "content-range": `bytes ${offset}-${end}/${total}` },
|
|
199
|
+
body: chunk,
|
|
200
|
+
redirect: "manual",
|
|
201
|
+
});
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (++attempts >= MAX_ATTEMPTS_PER_CHUNK) throw new ShareError("UPLOAD_FAILED", `The ${label} upload kept failing: ${error.message}`);
|
|
204
|
+
offset = await committedBytes(fetchImpl, session, total);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
rejectRedirect(response, label);
|
|
208
|
+
if (response.status === 200 || response.status === 201) return { bytes: total };
|
|
209
|
+
if (response.status === 308) {
|
|
210
|
+
// Progress resets the retry count; a 308 whose committed range did not move counts as a failed
|
|
211
|
+
// attempt, so a session that never advances ends instead of looping forever.
|
|
212
|
+
const next = rangeEnd(response.headers.get("range"));
|
|
213
|
+
if (next > offset) attempts = 0;
|
|
214
|
+
else if (++attempts >= MAX_ATTEMPTS_PER_CHUNK) throw new ShareError("UPLOAD_FAILED", `The ${label} upload stopped moving: storage kept the same ${next} bytes.`);
|
|
215
|
+
offset = next;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (response.status >= 500 && ++attempts < MAX_ATTEMPTS_PER_CHUNK) {
|
|
219
|
+
offset = await committedBytes(fetchImpl, session, total);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
throw new ShareError("UPLOAD_FAILED", `The ${label} upload failed: ${await describe(response)}.`);
|
|
223
|
+
}
|
|
224
|
+
} finally {
|
|
225
|
+
fs.closeSync(handle);
|
|
226
|
+
}
|
|
227
|
+
throw new ShareError("UPLOAD_FAILED", `The ${label} upload did not finish.`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function committedBytes(fetchImpl, session, total) {
|
|
231
|
+
const status = rejectRedirect(await fetchImpl(session, { method: "PUT", headers: { "content-range": `bytes */${total}` }, body: "", redirect: "manual" }), "file");
|
|
232
|
+
if (status.status === 200 || status.status === 201) return total;
|
|
233
|
+
return rangeEnd(status.headers.get("range"));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A 3xx with a Location is a redirect away from storage: refused. (GCS's 308 Resume Incomplete has none.) */
|
|
237
|
+
function rejectRedirect(response, label) {
|
|
238
|
+
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
239
|
+
throw new ShareError("UPLOAD_REFUSED", `The ${label} upload was redirected; uploads go only to Foldspace's video storage.`);
|
|
240
|
+
}
|
|
241
|
+
return response;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** "bytes=0-1048575" → 1048576 (the next byte to send); no header → 0. */
|
|
245
|
+
function rangeEnd(header) {
|
|
246
|
+
const match = /bytes=0-(\d+)/.exec(header || "");
|
|
247
|
+
return match ? Number(match[1]) + 1 : 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function describe(response) {
|
|
251
|
+
let code = "";
|
|
252
|
+
try {
|
|
253
|
+
const text = await response.text();
|
|
254
|
+
code = /<Code>([^<]{1,64})<\/Code>/.exec(text)?.[1] ?? "";
|
|
255
|
+
} catch {
|
|
256
|
+
// no body
|
|
257
|
+
}
|
|
258
|
+
return `HTTP ${response.status}${code ? ` ${code}` : ""}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function statFile(file, code, label) {
|
|
262
|
+
try {
|
|
263
|
+
const stat = fs.statSync(file);
|
|
264
|
+
if (!stat.isFile()) throw new Error("not a file");
|
|
265
|
+
return { path: file, bytes: stat.size };
|
|
266
|
+
} catch {
|
|
267
|
+
throw new ShareError(code, `No ${label} at ${file}.`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function readSlice(file, offset, length) {
|
|
272
|
+
const handle = fs.openSync(file, "r");
|
|
273
|
+
try {
|
|
274
|
+
const buffer = Buffer.alloc(length);
|
|
275
|
+
const read = fs.readSync(handle, buffer, 0, length, offset);
|
|
276
|
+
return buffer.subarray(0, read);
|
|
277
|
+
} finally {
|
|
278
|
+
fs.closeSync(handle);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function mb(bytes) {
|
|
283
|
+
return Math.round(bytes / (1024 * 1024));
|
|
284
|
+
}
|
|
@@ -22,7 +22,8 @@ nothing to set.
|
|
|
22
22
|
|
|
23
23
|
- **Real data from their account.** The signed-in app as it is: their records,
|
|
24
24
|
their numbers, their name wherever the app or the panel shows it. Nothing is
|
|
25
|
-
blurred, nothing is made up, nothing is uploaded
|
|
25
|
+
blurred, nothing is made up, and nothing is uploaded unless the human asks
|
|
26
|
+
for a share link (step 7).
|
|
26
27
|
- **Sharing it is their call** - whether, and where. Like a screen recording
|
|
27
28
|
they made themselves.
|
|
28
29
|
- Never create demo data for the video, never blur or crop parts of it, and
|
|
@@ -199,8 +200,43 @@ another question for that use case, prove it, record again. If there is a
|
|
|
199
200
|
goes is their call.
|
|
200
201
|
|
|
201
202
|
**Sharing:** upload the MP4 itself to LinkedIn or X - that is also how it
|
|
202
|
-
plays inline in the feed.
|
|
203
|
-
watch it
|
|
203
|
+
plays inline in the feed. Or a Foldspace share link (step 7): a page anyone
|
|
204
|
+
with the link can open to watch it, with a "Build yours" button, and an email
|
|
205
|
+
to them when it is watched.
|
|
206
|
+
|
|
207
|
+
## 7. A Foldspace share link - only when they ask for one
|
|
208
|
+
|
|
209
|
+
Offer it as one option next to "send me the file", never as the default.
|
|
210
|
+
The link is public: anyone who has it can watch, with no sign-in. So before
|
|
211
|
+
anything leaves this computer, ask once, as a choice: *"A share link is public
|
|
212
|
+
to anyone who has it, and the video shows [what is on screen]. Create one?"*
|
|
213
|
+
Only on a yes:
|
|
214
|
+
|
|
215
|
+
1. `npx foldspace share inspect` (the newest `video/*.mp4` and its poster, or
|
|
216
|
+
pass the path). It uploads nothing. If it says the MP4 is not fast-start,
|
|
217
|
+
that file did not come from `record`: say so and stop.
|
|
218
|
+
2. The Foldspace tool `create_video_share` with a title in their words (the
|
|
219
|
+
product and what the agent does, up to 200 characters), and `video_bytes`
|
|
220
|
+
and `poster_bytes` from step 1.
|
|
221
|
+
3. Run the upload command it returns, exactly - it carries one-time upload
|
|
222
|
+
targets for 30 minutes. Pass `upload` the same video path and `--poster`
|
|
223
|
+
you gave `inspect`, if any, so it uploads the files `inspect` sized. Never
|
|
224
|
+
paste the targets into chat, a file or a commit. In Codex (or any editor
|
|
225
|
+
that runs commands in a sandbox), this command needs the network: run it
|
|
226
|
+
outside the sandbox through the editor's own approval, with one plain line
|
|
227
|
+
on why.
|
|
228
|
+
4. When it prints `"status": "uploaded"`, the tool `publish_video_share` with
|
|
229
|
+
the `share_id`. It returns the link: give it to them as it is.
|
|
230
|
+
5. Tell them in one line that they get an email at the first view and a daily
|
|
231
|
+
summary on days it is watched, and that you can show the numbers
|
|
232
|
+
(`get_video_share_stats`) or delete the link (`delete_video_share`) whenever
|
|
233
|
+
they ask.
|
|
234
|
+
|
|
235
|
+
The tools refuse with a reason you pass on as it is: an EU account (share
|
|
236
|
+
links are not offered there yet - send the file instead), a cap (shares per
|
|
237
|
+
day, or a trial's monthly limit), or a sign-in by API key rather than as a
|
|
238
|
+
person. The CLI never signs in to Foldspace and never reaches the Foldspace
|
|
239
|
+
tools itself: steps 2 and 4 are tool calls you make.
|
|
204
240
|
|
|
205
241
|
The video holds real data, so it is never saved with the code: `record` writes
|
|
206
242
|
`video/.gitignore` (`*.mp4`, `*.jpg`) in every project. `video/scenes.json`
|