@vfrey/vfrey 1.0.0-beta.0 → 1.0.0-beta.1
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/package.json +2 -2
- package/skills/vfrey-product/SKILL.md +3 -3
- package/src/api/client.ts +79 -0
- package/src/api/cloud-files.ts +197 -0
- package/src/api/cloud-runs.ts +75 -0
- package/src/api/cloud.ts +413 -0
- package/src/api/types.ts +68 -0
- package/src/cli.ts +4 -3
- package/src/cloud/bundle.ts +70 -0
- package/src/cloud/cloudrun-executor.ts +237 -0
- package/src/cloud/executor.ts +5 -5
- package/src/cloud/gcs-sign.ts +76 -0
- package/src/cloud/remote-job.ts +67 -0
- package/src/cloud/render-job.ts +4 -0
- package/src/help.ts +20 -1
- package/src/project/marker.ts +6 -1
- package/src/project/templates.ts +2 -2
- package/src/tick-report.ts +1 -1
package/src/api/cloud.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// `vfrey cloud link | push | pull | status` (spec 019 B): sync a local project with a hosted one in the signed-in org.
|
|
2
|
+
// Every call goes through ApiClient; the token never leaves it, and is never sent to the storage host.
|
|
3
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync, renameSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { Readable, Transform } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import { ApiFailure, clean, type ApiClient } from "./client.js";
|
|
10
|
+
import { ApiConfigError, apiHost, resolveApiUrl } from "./config.js";
|
|
11
|
+
import type { ApiIo } from "./commands.js";
|
|
12
|
+
import { openSession } from "./topics.js";
|
|
13
|
+
import { collectRuns } from "./cloud-runs.js";
|
|
14
|
+
import {
|
|
15
|
+
MAX_FILES_PER_REQUEST,
|
|
16
|
+
MAX_PUSH_BYTES,
|
|
17
|
+
classify,
|
|
18
|
+
conflictPath,
|
|
19
|
+
formatSize,
|
|
20
|
+
readState,
|
|
21
|
+
safeTarget,
|
|
22
|
+
scanProject,
|
|
23
|
+
sha256File,
|
|
24
|
+
writeState,
|
|
25
|
+
type LocalFile,
|
|
26
|
+
type Skipped,
|
|
27
|
+
type SyncState,
|
|
28
|
+
} from "./cloud-files.js";
|
|
29
|
+
import type { CloudFileDecl, CloudFileView, CloudPullFile } from "./types.js";
|
|
30
|
+
import { resolveHome } from "../home.js";
|
|
31
|
+
import { findProject } from "../project/discover.js";
|
|
32
|
+
import { readMarker, writeMarker, type Project } from "../project/marker.js";
|
|
33
|
+
|
|
34
|
+
export type CloudIo = ApiIo & { cwd?: string };
|
|
35
|
+
|
|
36
|
+
const USAGE = [
|
|
37
|
+
"usage: npx vfrey cloud link [--json]",
|
|
38
|
+
" npx vfrey cloud push [--videos] [--dry-run] [--json]",
|
|
39
|
+
" npx vfrey cloud pull [--json]",
|
|
40
|
+
" npx vfrey cloud status [--json]",
|
|
41
|
+
].join("\n");
|
|
42
|
+
|
|
43
|
+
function refuse(io: CloudIo, message: string, fix: string): number {
|
|
44
|
+
io.stderr(`✗ ${message}\nFix: ${fix}\n`);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function failure(io: CloudIo, error: unknown): number {
|
|
49
|
+
if (error instanceof ApiFailure || error instanceof ApiConfigError) return refuse(io, error.message, error.fix);
|
|
50
|
+
return refuse(io, error instanceof Error ? clean(error.message) : "Something went wrong.", "run `npx vfrey doctor` and check the line above.");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The server's answers about this project and role, in the person's words. */
|
|
54
|
+
function cloudFailure(error: unknown): unknown {
|
|
55
|
+
if (!(error instanceof ApiFailure)) return error;
|
|
56
|
+
if (error.status === 404) return new ApiFailure("The cloud does not know this project in your current org.", "run `npx vfrey cloud link` (it makes or finds the project in the org you are signed in to).", 404, error.code);
|
|
57
|
+
if (error.status === 403) return new ApiFailure("Your role in this org cannot do that. Pushing and pulling need a member, admin or owner; reviewers can read.", "ask an owner or admin, or switch org with `npx vfrey org use`.", 403, error.code);
|
|
58
|
+
if (error.status === 402 || error.status === 401 || error.status === undefined || error.code === "limit_reached") return error;
|
|
59
|
+
if (error.status === 409) return new ApiFailure(error.message, "run `npx vfrey cloud push` again. Only what changed is sent.", 409, error.code);
|
|
60
|
+
if (error.status === 503) return new ApiFailure("Cloud storage is not set up on this vfrey server yet.", "try again later. Nothing was changed.", 503, error.code);
|
|
61
|
+
return error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseArgs(args: string[], bools: string[]): { flags: Set<string>; api?: string; positionals: string[]; error?: string } {
|
|
65
|
+
const flags = new Set<string>();
|
|
66
|
+
const positionals: string[] = [];
|
|
67
|
+
let api: string | undefined;
|
|
68
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
69
|
+
const arg = args[i]!;
|
|
70
|
+
if (!arg.startsWith("--")) {
|
|
71
|
+
positionals.push(arg);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const eq = arg.indexOf("=");
|
|
75
|
+
const name = eq > 0 ? arg.slice(0, eq) : arg;
|
|
76
|
+
if (bools.includes(name)) flags.add(name);
|
|
77
|
+
else if (name === "--api") {
|
|
78
|
+
const value = eq > 0 ? arg.slice(eq + 1) : args[i + 1] && !args[i + 1]!.startsWith("--") ? args[(i += 1)]! : "";
|
|
79
|
+
if (!value.trim()) return { flags, positionals, error: "--api needs a value." };
|
|
80
|
+
api = value;
|
|
81
|
+
} else return { flags, positionals, error: `Unknown option "${arg}".` };
|
|
82
|
+
}
|
|
83
|
+
return { flags, positionals, ...(api ? { api } : {}) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const json = (v: unknown): string => JSON.stringify(v, null, 2) + "\n";
|
|
87
|
+
|
|
88
|
+
interface Ctx {
|
|
89
|
+
project: Project;
|
|
90
|
+
client: ApiClient;
|
|
91
|
+
host: string;
|
|
92
|
+
state: SyncState;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requireProject(io: CloudIo): Project | number {
|
|
96
|
+
const project = findProject(io.cwd ?? process.cwd());
|
|
97
|
+
if (!project) return refuse(io, "This folder is not inside a vfrey project.", "cd into your project folder, or start one with `npx vfrey init`.");
|
|
98
|
+
return project;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function open(io: CloudIo, project: Project, api?: string, needLink = true): Ctx | number {
|
|
102
|
+
const base = resolveApiUrl(io.env, api ?? null);
|
|
103
|
+
const client = openSession({ env: io.env, ...(io.homedir ? { homedir: io.homedir } : {}), ...(io.platform ? { platform: io.platform } : {}), ...(io.run ? { run: io.run } : {}), ...(io.fetch ? { fetch: io.fetch } : {}), ...(io.timeoutMs ? { timeoutMs: io.timeoutMs } : {}) }, api);
|
|
104
|
+
if (needLink && !project.cloudProjectId) return refuse(io, `${project.name} is not linked to a cloud project yet.`, "run `npx vfrey cloud link`.");
|
|
105
|
+
return { project, client, host: apiHost(base!), state: readState(project.root, project.cloudProjectId ?? "") };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const now = (io: CloudIo): Date => new Date(io.now ? io.now() : Date.now());
|
|
109
|
+
|
|
110
|
+
function skippedNote(skipped: Skipped[]): string {
|
|
111
|
+
if (skipped.length === 0) return "";
|
|
112
|
+
const shown = skipped.slice(0, 5).map((s) => ` ${clean(s.path)} (${clean(s.reason)})`);
|
|
113
|
+
return `Not synced: ${skipped.length} file${skipped.length === 1 ? "" : "s"}\n${shown.join("\n")}${skipped.length > 5 ? `\n and ${skipped.length - 5} more` : ""}\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------- link ----------
|
|
117
|
+
|
|
118
|
+
async function link(io: CloudIo, args: string[]): Promise<number> {
|
|
119
|
+
const p = parseArgs(args, ["--json"]);
|
|
120
|
+
if (p.error || p.positionals.length) return refuse(io, p.error ?? `Unexpected "${p.positionals[0]}".`, USAGE);
|
|
121
|
+
const project = requireProject(io);
|
|
122
|
+
if (typeof project === "number") return project;
|
|
123
|
+
const ctx = open(io, project, p.api, false);
|
|
124
|
+
if (typeof ctx === "number") return ctx;
|
|
125
|
+
const { project: cloud } = await ctx.client.cloudLink({ localProjectId: project.id, name: project.name });
|
|
126
|
+
if (!cloud || typeof cloud.id !== "string") throw new ApiFailure("The vfrey server sent an answer this version does not understand.", "update vfrey with `npx vfrey update`.");
|
|
127
|
+
const changed = project.cloudProjectId !== cloud.id;
|
|
128
|
+
if (changed) writeMarker(project.root, { ...readMarker(project.root), cloudProjectId: cloud.id });
|
|
129
|
+
if (p.flags.has("--json")) io.stdout(json({ linked: true, cloudProjectId: cloud.id, name: cloud.name, created: Boolean(cloud.created), host: ctx.host }));
|
|
130
|
+
else io.stdout(`✓ ${changed ? "Linked" : "Already linked:"} ${clean(project.name)} to cloud project ${clean(cloud.id)} on ${ctx.host}${cloud.created ? " (new)" : ""}.\nNext: \`npx vfrey cloud push\` sends scenes, scripts and run details. Add --videos for the finished videos.\n`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------- push ----------
|
|
135
|
+
|
|
136
|
+
const decl = (f: LocalFile): CloudFileDecl => ({ path: f.path, size: f.size, sha256: f.sha256, modifiedAt: f.mtime.toISOString() });
|
|
137
|
+
|
|
138
|
+
function batches(files: LocalFile[]): LocalFile[][] {
|
|
139
|
+
const out: LocalFile[][] = [];
|
|
140
|
+
let cur: LocalFile[] = [];
|
|
141
|
+
let bytes = 0;
|
|
142
|
+
for (const f of files) {
|
|
143
|
+
if (cur.length && (cur.length >= MAX_FILES_PER_REQUEST || bytes + f.size > MAX_PUSH_BYTES)) {
|
|
144
|
+
out.push(cur);
|
|
145
|
+
cur = [];
|
|
146
|
+
bytes = 0;
|
|
147
|
+
}
|
|
148
|
+
cur.push(f);
|
|
149
|
+
bytes += f.size;
|
|
150
|
+
}
|
|
151
|
+
if (cur.length) out.push(cur);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function push(io: CloudIo, args: string[]): Promise<number> {
|
|
156
|
+
const p = parseArgs(args, ["--json", "--videos", "--dry-run"]);
|
|
157
|
+
if (p.error || p.positionals.length) return refuse(io, p.error ?? `Unexpected "${p.positionals[0]}".`, USAGE);
|
|
158
|
+
const project = requireProject(io);
|
|
159
|
+
if (typeof project === "number") return project;
|
|
160
|
+
const ctx = open(io, project, p.api);
|
|
161
|
+
if (typeof ctx === "number") return ctx;
|
|
162
|
+
const id = project.cloudProjectId!;
|
|
163
|
+
const dry = p.flags.has("--dry-run");
|
|
164
|
+
const withVideos = p.flags.has("--videos");
|
|
165
|
+
|
|
166
|
+
const { files: local, skipped } = await scanProject(project.root, { videos: withVideos });
|
|
167
|
+
const cloud = new Map((await ctx.client.cloudFiles(id)).files.map((f) => [f.path, f]));
|
|
168
|
+
const toPush: LocalFile[] = [];
|
|
169
|
+
const conflicts: string[] = [];
|
|
170
|
+
const unchanged: string[] = [];
|
|
171
|
+
for (const f of local) {
|
|
172
|
+
const verdict = classify(f.sha256, cloud.get(f.path)?.sha256, ctx.state.files[f.path]);
|
|
173
|
+
if (verdict === "same") unchanged.push(f.path);
|
|
174
|
+
else if (verdict === "conflict" || verdict === "pull") conflicts.push(f.path);
|
|
175
|
+
else toPush.push(f);
|
|
176
|
+
}
|
|
177
|
+
const bytes = toPush.reduce((n, f) => n + f.size, 0);
|
|
178
|
+
const videoPaths = new Set<string>([...local.filter((f) => f.kind === "video" && !conflicts.includes(f.path)).map((f) => f.path), ...[...cloud.values()].filter((f) => f.kind === "video").map((f) => f.path)]);
|
|
179
|
+
const runs = collectRuns(resolveHome(io.env, io.homedir ?? os.homedir(), project.root).dataDir, project.root, videoPaths);
|
|
180
|
+
|
|
181
|
+
if (dry) {
|
|
182
|
+
const report = { dryRun: true, wouldPush: toPush.map((f) => f.path), bytes, unchanged: unchanged.length, conflicts, skipped, runs: runs.length };
|
|
183
|
+
if (p.flags.has("--json")) io.stdout(json(report));
|
|
184
|
+
else {
|
|
185
|
+
io.stdout(`Dry run for ${clean(project.name)}: nothing was uploaded.\nWould push ${toPush.length} file${toPush.length === 1 ? "" : "s"} (${formatSize(bytes)}); ${unchanged.length} unchanged; ${runs.length} run${runs.length === 1 ? "" : "s"} of metadata.\n`);
|
|
186
|
+
for (const f of toPush.slice(0, 20)) io.stdout(` + ${clean(f.path)} (${formatSize(f.size)})\n`);
|
|
187
|
+
if (toPush.length > 20) io.stdout(` and ${toPush.length - 20} more\n`);
|
|
188
|
+
if (!withVideos) io.stdout("Videos stay on this machine unless you add --videos.\n");
|
|
189
|
+
io.stdout(skippedNote(skipped));
|
|
190
|
+
if (conflicts.length) io.stdout(`${conflicts.length} file${conflicts.length === 1 ? " differs" : "s differ"} in the cloud and would not be pushed: ${conflicts.slice(0, 5).map((x) => clean(x)).join(", ")}. Run \`npx vfrey cloud pull\` first.\n`);
|
|
191
|
+
}
|
|
192
|
+
return conflicts.length ? 1 : 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const pushed: string[] = [];
|
|
196
|
+
const failed: Array<{ path: string; reason: string }> = [];
|
|
197
|
+
for (const batch of batches(toPush)) {
|
|
198
|
+
const plan = await ctx.client.cloudPlan(id, batch.map(decl));
|
|
199
|
+
const byPath = new Map(batch.map((f) => [f.path, f]));
|
|
200
|
+
const sent: LocalFile[] = [];
|
|
201
|
+
for (const u of plan.uploads) {
|
|
202
|
+
const f = byPath.get(u.path);
|
|
203
|
+
if (!f) throw new ApiFailure("The server asked for a file that was not in this push.", "run the command again; if it repeats, update vfrey with `npx vfrey update`.");
|
|
204
|
+
const st = statSync(f.abs);
|
|
205
|
+
if (st.size !== f.size || st.mtime.getTime() !== f.mtime.getTime()) {
|
|
206
|
+
failed.push({ path: f.path, reason: "changed while pushing; run push again" });
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
await ctx.client.transfer("PUT", u.url, { headers: u.headers ?? {}, body: Readable.toWeb(createReadStream(f.abs)) as unknown as BodyInit, length: f.size });
|
|
210
|
+
sent.push(f);
|
|
211
|
+
}
|
|
212
|
+
for (const path_ of plan.unchanged) {
|
|
213
|
+
const f = byPath.get(path_);
|
|
214
|
+
if (f) ctx.state.files[f.path] = f.sha256;
|
|
215
|
+
}
|
|
216
|
+
if (sent.length) {
|
|
217
|
+
const answer = await ctx.client.cloudConfirm(id, sent.map(decl));
|
|
218
|
+
for (const path_ of answer.confirmed) {
|
|
219
|
+
pushed.push(path_);
|
|
220
|
+
ctx.state.files[path_] = byPath.get(path_)!.sha256;
|
|
221
|
+
}
|
|
222
|
+
failed.push(...answer.failed);
|
|
223
|
+
}
|
|
224
|
+
writeState(project.root, ctx.state);
|
|
225
|
+
}
|
|
226
|
+
for (const path_ of unchanged) ctx.state.files[path_] = local.find((f) => f.path === path_)!.sha256;
|
|
227
|
+
writeState(project.root, ctx.state);
|
|
228
|
+
|
|
229
|
+
let runCount = 0;
|
|
230
|
+
for (let i = 0; i < runs.length; i += 100) {
|
|
231
|
+
runCount += (await ctx.client.cloudPushRuns(id, runs.slice(i, i + 100))).runs;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const ok = failed.length === 0 && conflicts.length === 0;
|
|
235
|
+
if (p.flags.has("--json")) io.stdout(json({ pushed, bytes, unchanged: unchanged.length, conflicts, failed, skipped, runs: runCount }));
|
|
236
|
+
else {
|
|
237
|
+
io.stdout(`${ok ? "✓" : "!"} Pushed ${pushed.length} file${pushed.length === 1 ? "" : "s"} (${formatSize(toPush.filter((f) => pushed.includes(f.path)).reduce((n, f) => n + f.size, 0))}); ${unchanged.length} unchanged; ${runCount} run${runCount === 1 ? "" : "s"} synced.\n`);
|
|
238
|
+
if (!withVideos) io.stdout("Videos stay on this machine unless you add --videos.\n");
|
|
239
|
+
io.stdout(skippedNote(skipped));
|
|
240
|
+
}
|
|
241
|
+
if (failed.length) io.stderr(`✗ ${failed.length} file${failed.length === 1 ? " was" : "s were"} not recorded: ${failed.slice(0, 5).map((f) => `${clean(f.path)} (${clean(f.reason)})`).join(", ")}.\nFix: run \`npx vfrey cloud push\` again. Only what changed is sent.\n`);
|
|
242
|
+
if (conflicts.length) io.stderr(`✗ ${conflicts.length} file${conflicts.length === 1 ? " is" : "s are"} different in the cloud (changed there since your last sync), so ${conflicts.length === 1 ? "it was" : "they were"} not pushed: ${conflicts.slice(0, 5).map((x) => clean(x)).join(", ")}.\nFix: run \`npx vfrey cloud pull\`. The newest copy wins and the other is kept as <file>.conflict-<time>.\n`);
|
|
243
|
+
return ok ? 0 : 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------- pull ----------
|
|
247
|
+
|
|
248
|
+
async function download(client: ApiClient, file: CloudPullFile, dest: string): Promise<void> {
|
|
249
|
+
const response = await client.transfer("GET", file.url, { timeoutMs: 600_000 });
|
|
250
|
+
if (!response.body) throw new ApiFailure(`Storage sent no data for ${clean(file.path)}.`, "run `npx vfrey cloud pull` again.");
|
|
251
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
252
|
+
const tmp = `${dest}.vfrey-tmp`;
|
|
253
|
+
const hash = createHash("sha256");
|
|
254
|
+
let size = 0;
|
|
255
|
+
try {
|
|
256
|
+
await pipeline(
|
|
257
|
+
Readable.fromWeb(response.body as never),
|
|
258
|
+
new Transform({
|
|
259
|
+
transform(chunk: Buffer, _enc, cb) {
|
|
260
|
+
hash.update(chunk);
|
|
261
|
+
size += chunk.length;
|
|
262
|
+
cb(null, chunk);
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
createWriteStream(tmp),
|
|
266
|
+
);
|
|
267
|
+
if (size !== file.size || hash.digest("hex") !== file.sha256) {
|
|
268
|
+
throw new ApiFailure(`${clean(file.path)} arrived damaged (it does not match its hash).`, "run `npx vfrey cloud pull` again.");
|
|
269
|
+
}
|
|
270
|
+
renameSync(tmp, dest);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
rmSync(tmp, { force: true });
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
const at = new Date(file.modifiedAt);
|
|
276
|
+
if (!Number.isNaN(at.getTime())) utimesSync(dest, at, at);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface PullResult {
|
|
280
|
+
written: string[];
|
|
281
|
+
conflicts: Array<{ path: string; kept: string; winner: "cloud" | "local" }>;
|
|
282
|
+
refused: Array<{ path: string; reason: string }>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function pull(io: CloudIo, args: string[]): Promise<number> {
|
|
286
|
+
const p = parseArgs(args, ["--json"]);
|
|
287
|
+
if (p.error || p.positionals.length) return refuse(io, p.error ?? `Unexpected "${p.positionals[0]}".`, USAGE);
|
|
288
|
+
const project = requireProject(io);
|
|
289
|
+
if (typeof project === "number") return project;
|
|
290
|
+
const ctx = open(io, project, p.api);
|
|
291
|
+
if (typeof ctx === "number") return ctx;
|
|
292
|
+
const id = project.cloudProjectId!;
|
|
293
|
+
const result: PullResult = { written: [], conflicts: [], refused: [] };
|
|
294
|
+
let bytes = 0;
|
|
295
|
+
let cursor = ctx.state.cursor;
|
|
296
|
+
for (let page = 0; page < 1000; page += 1) {
|
|
297
|
+
const answer = await ctx.client.cloudPull(id, { since: cursor });
|
|
298
|
+
if (!answer || !Array.isArray(answer.files)) throw new ApiFailure("The vfrey server sent an answer this version does not understand.", "update vfrey with `npx vfrey update`.");
|
|
299
|
+
for (const f of answer.files) {
|
|
300
|
+
const target = safeTarget(project.root, f.path);
|
|
301
|
+
if (!target.ok) {
|
|
302
|
+
result.refused.push({ path: String(f.path).slice(0, 80), reason: target.reason });
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const have = existsSync(target.abs);
|
|
306
|
+
const base = ctx.state.files[f.path];
|
|
307
|
+
if (have) {
|
|
308
|
+
const st = statSync(target.abs);
|
|
309
|
+
if (!st.isFile()) {
|
|
310
|
+
result.refused.push({ path: f.path, reason: "not a regular file here" });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const here = await sha256File(target.abs);
|
|
314
|
+
if (here === f.sha256) {
|
|
315
|
+
ctx.state.files[f.path] = f.sha256;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const verdict = classify(here, f.sha256, base);
|
|
319
|
+
if (verdict === "conflict") {
|
|
320
|
+
const cloudNewer = new Date(f.modifiedAt).getTime() >= st.mtimeMs;
|
|
321
|
+
const kept = conflictPath(target.abs, now(io));
|
|
322
|
+
if (cloudNewer) {
|
|
323
|
+
renameSync(target.abs, kept);
|
|
324
|
+
await download(ctx.client, f, target.abs);
|
|
325
|
+
} else {
|
|
326
|
+
await download(ctx.client, f, kept);
|
|
327
|
+
}
|
|
328
|
+
result.conflicts.push({ path: f.path, kept: path.relative(project.root, kept).split(path.sep).join("/"), winner: cloudNewer ? "cloud" : "local" });
|
|
329
|
+
ctx.state.files[f.path] = f.sha256;
|
|
330
|
+
bytes += f.size;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// Only the cloud changed since this machine last synced: take it.
|
|
334
|
+
}
|
|
335
|
+
await download(ctx.client, f, target.abs);
|
|
336
|
+
ctx.state.files[f.path] = f.sha256;
|
|
337
|
+
result.written.push(f.path);
|
|
338
|
+
bytes += f.size;
|
|
339
|
+
}
|
|
340
|
+
const next = answer.cursor ?? cursor;
|
|
341
|
+
if (answer.hasMore && next === cursor && answer.files.length === 0) throw new ApiFailure("The server's pull cursor did not move.", "try again in a moment.");
|
|
342
|
+
cursor = next;
|
|
343
|
+
ctx.state.cursor = cursor;
|
|
344
|
+
writeState(project.root, ctx.state);
|
|
345
|
+
if (!answer.hasMore) break;
|
|
346
|
+
}
|
|
347
|
+
if (p.flags.has("--json")) io.stdout(json({ pulled: result.written, bytes, conflicts: result.conflicts, refused: result.refused }));
|
|
348
|
+
else {
|
|
349
|
+
const total = result.written.length + result.conflicts.length;
|
|
350
|
+
io.stdout(`✓ Pulled ${total} file${total === 1 ? "" : "s"} (${formatSize(bytes)}) from ${ctx.host}.${total === 0 ? " Everything here is already up to date." : ""}\n`);
|
|
351
|
+
for (const f of result.written.slice(0, 20)) io.stdout(` ${clean(f)}\n`);
|
|
352
|
+
for (const c of result.conflicts) io.stdout(` ${clean(c.path)}: changed in both places. The ${c.winner === "cloud" ? "cloud" : "local"} copy is newer and wins; the other is kept as ${clean(c.kept)}.\n`);
|
|
353
|
+
}
|
|
354
|
+
if (result.refused.length) {
|
|
355
|
+
io.stderr(`✗ ${result.refused.length} file${result.refused.length === 1 ? " was" : "s were"} not written: ${result.refused.slice(0, 5).map((r) => `${clean(r.path)} (${clean(r.reason)})`).join(", ")}.\nFix: these paths are not safe to write into a project; nothing outside scenes/, scripts/ and videos/ is ever written.\n`);
|
|
356
|
+
return 1;
|
|
357
|
+
}
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---------- status ----------
|
|
362
|
+
|
|
363
|
+
async function status(io: CloudIo, args: string[]): Promise<number> {
|
|
364
|
+
const p = parseArgs(args, ["--json"]);
|
|
365
|
+
if (p.error || p.positionals.length) return refuse(io, p.error ?? `Unexpected "${p.positionals[0]}".`, USAGE);
|
|
366
|
+
const project = requireProject(io);
|
|
367
|
+
if (typeof project === "number") return project;
|
|
368
|
+
if (!project.cloudProjectId) {
|
|
369
|
+
if (p.flags.has("--json")) io.stdout(json({ linked: false, name: project.name }));
|
|
370
|
+
else io.stdout(`${clean(project.name)} is not linked to a cloud project.\nLink it: \`npx vfrey cloud link\`.\n`);
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
const ctx = open(io, project, p.api);
|
|
374
|
+
if (typeof ctx === "number") return ctx;
|
|
375
|
+
const { files: local } = await scanProject(project.root, { videos: false });
|
|
376
|
+
const cloudAll = (await ctx.client.cloudFiles(project.cloudProjectId)).files;
|
|
377
|
+
const cloud = new Map<string, CloudFileView>(cloudAll.filter((f) => f.kind !== "video" && f.kind !== "transcript").map((f) => [f.path, f]));
|
|
378
|
+
const counts = { same: 0, push: [] as string[], pull: [] as string[], conflict: [] as string[] };
|
|
379
|
+
const seen = new Set<string>();
|
|
380
|
+
for (const f of local) {
|
|
381
|
+
seen.add(f.path);
|
|
382
|
+
const v = classify(f.sha256, cloud.get(f.path)?.sha256, ctx.state.files[f.path]);
|
|
383
|
+
if (v === "same") counts.same += 1;
|
|
384
|
+
else if (v === "new" || v === "push") counts.push.push(f.path);
|
|
385
|
+
else if (v === "pull") counts.pull.push(f.path);
|
|
386
|
+
else counts.conflict.push(f.path);
|
|
387
|
+
}
|
|
388
|
+
for (const c of cloud.keys()) if (!seen.has(c)) counts.pull.push(c);
|
|
389
|
+
const videos = cloudAll.filter((f) => f.kind === "video").length;
|
|
390
|
+
const report = { linked: true, name: project.name, cloudProjectId: project.cloudProjectId, host: ctx.host, inSync: counts.same, toPush: counts.push, toPull: counts.pull, conflicts: counts.conflict, videosInCloud: videos, lastPullCursor: ctx.state.cursor };
|
|
391
|
+
if (p.flags.has("--json")) io.stdout(json(report));
|
|
392
|
+
else {
|
|
393
|
+
const line = (label: string, list: string[]) => (list.length ? `${label}: ${list.length} (${list.slice(0, 3).map((x) => clean(x)).join(", ")}${list.length > 3 ? ", ..." : ""})\n` : "");
|
|
394
|
+
io.stdout(`${clean(project.name)} is linked to ${clean(project.cloudProjectId)} on ${ctx.host}.\nIn sync: ${counts.same} file${counts.same === 1 ? "" : "s"}.\n${line("To push", counts.push)}${line("To pull", counts.pull)}${line("Changed in both places", counts.conflict)}Videos in the cloud: ${videos}.\n`);
|
|
395
|
+
if (counts.push.length === 0 && counts.pull.length === 0 && counts.conflict.length === 0) io.stdout("Scenes and scripts match the cloud.\n");
|
|
396
|
+
else io.stdout(`Next: ${counts.conflict.length || counts.pull.length ? "`npx vfrey cloud pull`" : ""}${(counts.conflict.length || counts.pull.length) && counts.push.length ? " then " : ""}${counts.push.length ? "`npx vfrey cloud push`" : ""}.\n`);
|
|
397
|
+
}
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export async function runCloud(args: string[], io: CloudIo): Promise<number> {
|
|
402
|
+
const sub = args[0];
|
|
403
|
+
const rest = args.slice(1);
|
|
404
|
+
try {
|
|
405
|
+
if (sub === "link") return await link(io, rest);
|
|
406
|
+
if (sub === "push") return await push(io, rest);
|
|
407
|
+
if (sub === "pull") return await pull(io, rest);
|
|
408
|
+
if (sub === "status") return await status(io, rest);
|
|
409
|
+
return refuse(io, sub ? `Unknown cloud command "${sub}".` : "Say what to do in the cloud.", USAGE);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
return failure(io, cloudFailure(error));
|
|
412
|
+
}
|
|
413
|
+
}
|
package/src/api/types.ts
CHANGED
|
@@ -203,3 +203,71 @@ export interface OrgSwitchResponse {
|
|
|
203
203
|
token_type: "bearer";
|
|
204
204
|
org: { id: string; name: string; role: string };
|
|
205
205
|
}
|
|
206
|
+
|
|
207
|
+
// ---------- Hosted projects and sync (spec 019 B). The console's REST shapes. ----------
|
|
208
|
+
|
|
209
|
+
export interface CloudProjectView {
|
|
210
|
+
id: string;
|
|
211
|
+
localProjectId: string;
|
|
212
|
+
name: string;
|
|
213
|
+
created?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface CloudFileView {
|
|
217
|
+
path: string;
|
|
218
|
+
kind: string;
|
|
219
|
+
size: number;
|
|
220
|
+
sha256: string;
|
|
221
|
+
modifiedAt: string;
|
|
222
|
+
updatedAt: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** One file the CLI declares: path relative to the project root, size, sha256 and the file's own modified time. */
|
|
226
|
+
export interface CloudFileDecl {
|
|
227
|
+
path: string;
|
|
228
|
+
size: number;
|
|
229
|
+
sha256: string;
|
|
230
|
+
modifiedAt: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface CloudSignedUpload {
|
|
234
|
+
path: string;
|
|
235
|
+
method: string;
|
|
236
|
+
url: string;
|
|
237
|
+
headers?: Record<string, string>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface CloudPlanResponse {
|
|
241
|
+
unchanged: string[];
|
|
242
|
+
uploads: CloudSignedUpload[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface CloudConfirmResponse {
|
|
246
|
+
confirmed: string[];
|
|
247
|
+
failed: Array<{ path: string; reason: string }>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface CloudPullFile extends CloudFileView {
|
|
251
|
+
url: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface CloudPullResponse {
|
|
255
|
+
files: CloudPullFile[];
|
|
256
|
+
cursor: string | null;
|
|
257
|
+
hasMore: boolean;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The run summary a push sends: metadata, stages, checks and a publications summary. Never a token or a secret. */
|
|
261
|
+
export interface CloudRunBody {
|
|
262
|
+
localRunId: string;
|
|
263
|
+
title: string | null;
|
|
264
|
+
status: string;
|
|
265
|
+
stage: string | null;
|
|
266
|
+
startedAt: string | null;
|
|
267
|
+
finishedAt: string | null;
|
|
268
|
+
durationSec: number | null;
|
|
269
|
+
videoPath: string | null;
|
|
270
|
+
stages: Array<{ stage: string; status: string; at?: string }>;
|
|
271
|
+
checks: Array<{ id: string; phase?: string; severity?: string; status: string; message?: string; fix?: string | null; attempt?: number }>;
|
|
272
|
+
publications: Array<{ platform: string; externalId: string | null; publishedAt: string | null; title: string | null }>;
|
|
273
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -33,13 +33,14 @@ import { runLogin, runLogout, runOrg, runWhoami } from "./api/commands.js";
|
|
|
33
33
|
import { runTopics } from "./api/topics.js";
|
|
34
34
|
import { runCreators } from "./api/creators.js";
|
|
35
35
|
import { runMail } from "./api/mail.js";
|
|
36
|
+
import { runCloud } from "./api/cloud.js";
|
|
36
37
|
import { checkScene, SceneError } from "./scene/scene.js";
|
|
37
38
|
import { writeNow } from "./sync-write.js";
|
|
38
39
|
import { defaultRender, tick, tickJobs, type Runtime } from "./pipeline/run.js";
|
|
39
40
|
import { Store } from "./queue/store.js";
|
|
40
41
|
import { attachRunUrls, buildTickReport, formatTickReport } from "./tick-report.js";
|
|
41
42
|
|
|
42
|
-
const COMMANDS = ["doctor", "tick", "worker", "engage", "analytics", "render", "engine", "dashboard", "dev", "push", "update", "desk", "skill", "scene", "init", "videos", "projects", "open", "service", "login", "logout", "whoami", "org", "topics", "creators", "mail"];
|
|
43
|
+
const COMMANDS = ["doctor", "tick", "worker", "engage", "analytics", "render", "engine", "dashboard", "dev", "push", "update", "desk", "skill", "scene", "init", "videos", "projects", "open", "service", "login", "logout", "whoami", "org", "topics", "creators", "mail", "cloud"];
|
|
43
44
|
|
|
44
45
|
const command = process.argv[2] ?? "doctor";
|
|
45
46
|
|
|
@@ -84,9 +85,9 @@ async function main(): Promise<void> {
|
|
|
84
85
|
const notice = projectNotice(home);
|
|
85
86
|
if (notice) process.stderr.write(notice);
|
|
86
87
|
}
|
|
87
|
-
if (command === "login" || command === "logout" || command === "whoami" || command === "org" || command === "topics" || command === "creators" || command === "mail") {
|
|
88
|
+
if (command === "login" || command === "logout" || command === "whoami" || command === "org" || command === "topics" || command === "creators" || command === "mail" || command === "cloud") {
|
|
88
89
|
const io = { env: process.env, stdout: (text: string) => writeNow(1, text), stderr: (text: string) => writeNow(2, text) };
|
|
89
|
-
process.exitCode = await (command === "login" ? runLogin(rest, io) : command === "logout" ? runLogout(rest, io) : command === "org" ? runOrg(rest, io) : command === "topics" ? runTopics(rest, io) : command === "creators" ? runCreators(rest, io) : command === "mail" ? runMail(rest, io) : runWhoami(rest, io));
|
|
90
|
+
process.exitCode = await (command === "login" ? runLogin(rest, io) : command === "logout" ? runLogout(rest, io) : command === "org" ? runOrg(rest, io) : command === "topics" ? runTopics(rest, io) : command === "creators" ? runCreators(rest, io) : command === "mail" ? runMail(rest, io) : command === "cloud" ? runCloud(rest, { ...io, cwd: process.cwd() }) : runWhoami(rest, io));
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
93
|
if (command === "open") {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { lstat, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
4
|
+
import { insidePath, JobError } from "./job.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A job's input folder as ONE object (spec 019, section C2). The cloud executor uploads it to a private bucket and the
|
|
8
|
+
* job container downloads it through a signed URL, so the container needs no permission on the bucket at all.
|
|
9
|
+
*
|
|
10
|
+
* The format is gzip over JSON: `{ version: 1, files: [{ path, data }] }`, `data` base64. It carries regular files only.
|
|
11
|
+
* A symlink or any other entry is refused when packing, and every path is re-checked (relative, no `..`) when unpacking.
|
|
12
|
+
*/
|
|
13
|
+
export const BUNDLE_VERSION = 1;
|
|
14
|
+
export const BUNDLE_LIMITS = { files: 5000, totalBytes: 512 * 1024 * 1024 } as const;
|
|
15
|
+
|
|
16
|
+
interface BundleFile {
|
|
17
|
+
path: string;
|
|
18
|
+
data: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function walk(root: string, rel: string, out: string[]): Promise<void> {
|
|
22
|
+
for (const entry of await readdir(path.join(root, rel), { withFileTypes: true })) {
|
|
23
|
+
const next = rel ? `${rel}/${entry.name}` : entry.name;
|
|
24
|
+
const info = await lstat(path.join(root, next));
|
|
25
|
+
if (info.isSymbolicLink()) throw new JobError(`"${next}" is a symbolic link; a job folder holds regular files only.`, "job_symlink");
|
|
26
|
+
if (info.isDirectory()) await walk(root, next, out);
|
|
27
|
+
else if (info.isFile()) out.push(next);
|
|
28
|
+
else throw new JobError(`"${next}" is not a regular file.`, "job_invalid");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Packs a folder into one gzip buffer. */
|
|
33
|
+
export async function packFolder(root: string): Promise<Buffer> {
|
|
34
|
+
const names: string[] = [];
|
|
35
|
+
await walk(root, "", names);
|
|
36
|
+
if (names.length > BUNDLE_LIMITS.files) throw new JobError(`The job folder holds ${names.length} files; the limit is ${BUNDLE_LIMITS.files}.`, "job_too_large");
|
|
37
|
+
let total = 0;
|
|
38
|
+
const files: BundleFile[] = [];
|
|
39
|
+
for (const name of names.sort()) {
|
|
40
|
+
const data = await readFile(path.join(root, name));
|
|
41
|
+
total += data.length;
|
|
42
|
+
if (total > BUNDLE_LIMITS.totalBytes) throw new JobError("The job folder is larger than 512 MB.", "job_too_large");
|
|
43
|
+
files.push({ path: name, data: data.toString("base64") });
|
|
44
|
+
}
|
|
45
|
+
return gzipSync(Buffer.from(JSON.stringify({ version: BUNDLE_VERSION, files })));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Unpacks a bundle into an (empty or new) folder. Throws a JobError for anything that is not a plain, in-folder file. */
|
|
49
|
+
export async function unpackBundle(bundle: Buffer, dest: string): Promise<number> {
|
|
50
|
+
let parsed: { version?: unknown; files?: unknown };
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(gunzipSync(bundle, { maxOutputLength: BUNDLE_LIMITS.totalBytes * 2 }).toString("utf8"));
|
|
53
|
+
} catch {
|
|
54
|
+
throw new JobError("The input bundle could not be read.", "job_unreadable");
|
|
55
|
+
}
|
|
56
|
+
if (parsed.version !== BUNDLE_VERSION || !Array.isArray(parsed.files)) throw new JobError("The input bundle has the wrong version.", "job_unreadable");
|
|
57
|
+
if (parsed.files.length > BUNDLE_LIMITS.files) throw new JobError("The input bundle holds too many files.", "job_too_large");
|
|
58
|
+
await mkdir(dest, { recursive: true });
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
for (const file of parsed.files as BundleFile[]) {
|
|
61
|
+
if (!file || typeof file.data !== "string") throw new JobError("The input bundle has a malformed entry.", "job_unreadable");
|
|
62
|
+
const rel = insidePath(file.path, "bundle path");
|
|
63
|
+
if (rel === "." || seen.has(rel)) throw new JobError(`The input bundle has a bad or repeated path "${file.path}".`, "job_invalid");
|
|
64
|
+
seen.add(rel);
|
|
65
|
+
const target = path.join(dest, rel);
|
|
66
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
67
|
+
await writeFile(target, Buffer.from(file.data, "base64"), { flag: "wx", mode: 0o644 });
|
|
68
|
+
}
|
|
69
|
+
return seen.size;
|
|
70
|
+
}
|