@foldspace_npm/harness 0.1.31 → 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.
Files changed (41) hide show
  1. package/CLAUDE.md +27 -8
  2. package/README.md +25 -2
  3. package/bin/ask.mjs +16 -145
  4. package/bin/attach.mjs +2 -0
  5. package/bin/cli.mjs +22 -0
  6. package/bin/inject.mjs +2 -14
  7. package/bin/record.mjs +412 -0
  8. package/bin/share.mjs +63 -0
  9. package/package.json +1 -1
  10. package/src/attach-daemon.mjs +18 -0
  11. package/src/badge-auto.mjs +38 -1
  12. package/src/cdp-client.mjs +7 -1
  13. package/src/chrome-binary.mjs +28 -0
  14. package/src/cli-help.mjs +1 -0
  15. package/src/cli-registry.mjs +97 -3
  16. package/src/init.mjs +170 -16
  17. package/src/panel-driver.mjs +283 -0
  18. package/src/session-events.mjs +1 -0
  19. package/src/upgrade.mjs +87 -1
  20. package/src/video/README.md +29 -0
  21. package/src/video/assets/foldspace-mark.svg +27 -0
  22. package/src/video/assets/logo-dark.svg +68 -0
  23. package/src/video/assets/logo-light.svg +56 -0
  24. package/src/video/brand.mjs +46 -0
  25. package/src/video/compose.mjs +476 -0
  26. package/src/video/composer/composer.mjs +554 -0
  27. package/src/video/composer/index.html +13 -0
  28. package/src/video/fonts/OFL.txt +93 -0
  29. package/src/video/fonts/Poppins-ExtraBold.woff2 +0 -0
  30. package/src/video/fonts/Poppins-Regular.woff2 +0 -0
  31. package/src/video/fonts/Poppins-SemiBold.woff2 +0 -0
  32. package/src/video/recorder.mjs +331 -0
  33. package/src/video/scenes.mjs +253 -0
  34. package/src/video/share.mjs +284 -0
  35. package/src/video/timeline.mjs +726 -0
  36. package/src/video/vendor/mp4-muxer.LICENSE +21 -0
  37. package/src/video/vendor/mp4-muxer.min.mjs +5 -0
  38. package/templates/agent-starter/README.md +5 -0
  39. package/templates/agent-starter/claude-settings.json +1 -0
  40. package/templates/agent-starter/gitignore +3 -0
  41. package/templates/agent-starter/skills/share-a-video/SKILL.md +271 -0
@@ -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
+ }