freestyle-sync 0.1.0

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 (39) hide show
  1. package/PUBLISHING.md +3 -0
  2. package/README.md +47 -0
  3. package/freestyle-sync.config.ts +38 -0
  4. package/package.json +28 -0
  5. package/plugins/agent-claude/package.json +8 -0
  6. package/plugins/agent-claude/src/index.ts +113 -0
  7. package/plugins/agent-codex/package.json +8 -0
  8. package/plugins/agent-codex/src/index.ts +69 -0
  9. package/plugins/agent-copilot/package.json +8 -0
  10. package/plugins/agent-copilot/src/index.ts +542 -0
  11. package/plugins/auth-aws/package.json +8 -0
  12. package/plugins/auth-aws/src/index.ts +23 -0
  13. package/plugins/auth-azure/package.json +8 -0
  14. package/plugins/auth-azure/src/index.ts +23 -0
  15. package/plugins/auth-docker/package.json +8 -0
  16. package/plugins/auth-docker/src/index.ts +23 -0
  17. package/plugins/auth-env/package.json +8 -0
  18. package/plugins/auth-env/src/index.ts +35 -0
  19. package/plugins/auth-gcloud/package.json +8 -0
  20. package/plugins/auth-gcloud/src/index.ts +23 -0
  21. package/plugins/auth-git/package.json +8 -0
  22. package/plugins/auth-git/src/index.ts +43 -0
  23. package/plugins/auth-github-cli/package.json +8 -0
  24. package/plugins/auth-github-cli/src/index.ts +33 -0
  25. package/plugins/auth-npm/package.json +8 -0
  26. package/plugins/auth-npm/src/index.ts +32 -0
  27. package/plugins/auth-ssh/package.json +8 -0
  28. package/plugins/auth-ssh/src/index.ts +36 -0
  29. package/plugins/auth-yarn/package.json +8 -0
  30. package/plugins/auth-yarn/src/index.ts +19 -0
  31. package/plugins/node-npm/package.json +8 -0
  32. package/plugins/node-npm/src/index.ts +390 -0
  33. package/plugins/shell-history/package.json +8 -0
  34. package/plugins/shell-history/src/index.ts +64 -0
  35. package/plugins/vscode/package.json +8 -0
  36. package/plugins/vscode/src/index.ts +162 -0
  37. package/src/main.ts +1136 -0
  38. package/src/plugin-api.ts +107 -0
  39. package/tsconfig.json +18 -0
package/src/main.ts ADDED
@@ -0,0 +1,1136 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config"
3
+ import { createHash } from "node:crypto";
4
+ import { createReadStream } from "node:fs";
5
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import path from "node:path";
8
+ import { execFile, spawn } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+ import { freestyle } from "freestyle";
11
+ import config from "../freestyle-sync.config.ts";
12
+ import type { CliOptions, ContextCandidate, PushvmPluginUtils, RemoteVm } from "./plugin-api.ts";
13
+ export * from "./plugin-api.ts";
14
+
15
+ const execFileAsync = promisify(execFile);
16
+
17
+ const CACHE_VERSION = 1;
18
+ const PLUGIN_PREFERENCES_VERSION = 1;
19
+ const ARCHIVE_CHUNK_CHARS = 1024 * 1024;
20
+ let plugins = config.plugins;
21
+ const pluginUtils: PushvmPluginUtils = {
22
+ checkedExec,
23
+ createTar,
24
+ uploadArchiveInChunks,
25
+ execFileAsync,
26
+ shellQuote,
27
+ md5,
28
+ delay,
29
+ };
30
+
31
+ type FileKind = "file" | "symlink";
32
+
33
+ type FileDigest = {
34
+ hash: string;
35
+ kind: FileKind;
36
+ mode: number;
37
+ size: number;
38
+ };
39
+
40
+ type LocalEntry = FileDigest & {
41
+ absolutePath: string;
42
+ relativePath: string;
43
+ };
44
+
45
+ type CacheFile = {
46
+ version: number;
47
+ projectRoot: string;
48
+ remoteProjectDir: string;
49
+ vmId?: string;
50
+ snapshotId?: string;
51
+ projectFiles: Record<string, FileDigest>;
52
+ contextFiles: Record<string, FileDigest>;
53
+ snapshotProjectFiles?: Record<string, FileDigest>;
54
+ snapshotContextFiles?: Record<string, FileDigest>;
55
+ envHash?: string;
56
+ snapshotEnvHash?: string;
57
+ updatedAt?: string;
58
+ };
59
+
60
+ type PluginPreferences = {
61
+ version: number;
62
+ disabledPlugins: string[];
63
+ updatedAt?: string;
64
+ };
65
+
66
+ type ContextEntry = LocalEntry & {
67
+ remotePath: string;
68
+ label: string;
69
+ sensitive: boolean;
70
+ };
71
+
72
+ type SyncResult = {
73
+ uploaded: number;
74
+ removed: number;
75
+ unchanged: number;
76
+ };
77
+
78
+ class Progress {
79
+ private current = 0;
80
+ private readonly total: number;
81
+
82
+ constructor(total: number) {
83
+ this.total = total;
84
+ }
85
+
86
+ step(message: string) {
87
+ this.current += 1;
88
+ console.log(`[${this.current}/${this.total}] ${message}`);
89
+ }
90
+ }
91
+
92
+ main().catch((error) => {
93
+ console.error(`vmpush: ${error instanceof Error ? error.message : String(error)}`);
94
+ process.exitCode = 1;
95
+ });
96
+
97
+ async function main() {
98
+ const options = await parseArgs(process.argv.slice(2));
99
+ const pluginPreferences = await updatePluginPreferences(options);
100
+ plugins = activePlugins(pluginPreferences, options);
101
+ if (options.listPlugins) {
102
+ printPlugins(pluginPreferences, options);
103
+ return;
104
+ }
105
+ const progress = new Progress(options.dryRun ? 2 : options.install ? 12 : 11);
106
+
107
+ if (options.dryRun) {
108
+ console.log("vmpush dry run");
109
+ }
110
+
111
+ progress.step("Scanning project files");
112
+ const cache = await readCache(options.cachePath, options);
113
+ const base = cacheBaseForSync(options, cache);
114
+ const projectEntries = await scanProject(options.projectRoot, options.includeGitDir);
115
+ const projectCurrent = digestMap(projectEntries);
116
+ const projectChanges = diffEntries(projectEntries, base.projectFiles);
117
+
118
+ progress.step("Detecting auth and agent context");
119
+ let envExports = collectPluginEnvironment(options);
120
+ let contextCandidates = await discoverPluginContextCandidates(options);
121
+ const envHash = hashString(renderEnvFile(envExports));
122
+
123
+ const contextEntries = await scanContextCandidates(contextCandidates);
124
+ const contextCurrent = digestMapByRemotePath(contextEntries);
125
+ const contextChanges = diffContextEntries(contextEntries, base.contextFiles);
126
+
127
+ printPlan(options, projectChanges, contextChanges, envExports, cache);
128
+
129
+ if (options.dryRun) {
130
+ return;
131
+ }
132
+
133
+ progress.step("Preparing Freestyle VM");
134
+ const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
135
+ console.log(`Uploading to VM: ${vmId}`);
136
+
137
+ progress.step(`Uploading project changes to ${vmId}`);
138
+ await ensureRemoteBase(vm, options.remoteProjectDir);
139
+ const projectResult = await syncProject(vm, vmId, options, projectChanges);
140
+
141
+ progress.step(`Running remote fixups on ${vmId}`);
142
+ await runRemoteFixups(vm, vmId, options);
143
+
144
+ progress.step(`Uploading auth/context changes to ${vmId}`);
145
+ const contextResult = await syncContext(vm, vmId, contextChanges);
146
+ await runAfterContextSyncPlugins(vm, vmId, options, contextChanges.changed.map((entry) => entry.remotePath));
147
+
148
+ progress.step(`Installing environment files on ${vmId}`);
149
+ await installEnvironment(vm, envExports, envHash, base.envHash);
150
+
151
+ progress.step(`Writing resume metadata on ${vmId}`);
152
+ await writeRemoteResumeFiles(vm, options, vmId, envExports);
153
+ await hardenRemoteRoot(vm);
154
+
155
+ if (options.install) {
156
+ progress.step(`Running install command on ${vmId}`);
157
+ await runInstall(vm, options.projectRoot, options.remoteProjectDir);
158
+ }
159
+
160
+ let snapshotId = cache.snapshotId;
161
+ let snapshotProjectFiles = cache.snapshotProjectFiles;
162
+ let snapshotContextFiles = cache.snapshotContextFiles;
163
+ let snapshotEnvHash = cache.snapshotEnvHash;
164
+ if (options.snapshot) {
165
+ progress.step(`Creating snapshot cache for ${vmId}`);
166
+ await runBeforeSnapshotPlugins(vm, vmId, options);
167
+ try {
168
+ const snapshot = await vm.snapshot({ name: `vmpush-${path.basename(options.projectRoot)}-${Date.now()}` });
169
+ snapshotId = snapshot.snapshotId;
170
+ snapshotProjectFiles = projectCurrent;
171
+ snapshotContextFiles = contextCurrent;
172
+ snapshotEnvHash = envHash;
173
+ } catch (error) {
174
+ console.warn(`Snapshot cache skipped: ${error instanceof Error ? error.message : String(error)}`);
175
+ }
176
+ } else {
177
+ progress.step("Skipping snapshot cache");
178
+ }
179
+
180
+ progress.step("Saving local sync cache");
181
+ await writeCache(options.cachePath, {
182
+ version: CACHE_VERSION,
183
+ projectRoot: options.projectRoot,
184
+ remoteProjectDir: options.remoteProjectDir,
185
+ vmId,
186
+ snapshotId,
187
+ projectFiles: projectCurrent,
188
+ contextFiles: contextCurrent,
189
+ snapshotProjectFiles,
190
+ snapshotContextFiles,
191
+ envHash,
192
+ snapshotEnvHash,
193
+ updatedAt: new Date().toISOString(),
194
+ });
195
+
196
+ progress.step(`Running post-sync plugins for ${vmId}`);
197
+ const postSyncMessages = await runAfterSyncPlugins(vm, vmId, options, contextCandidates);
198
+
199
+ console.log("");
200
+ console.log(`VM ready: ${vmId}`);
201
+ console.log(`Project: ${options.remoteProjectDir}`);
202
+ console.log(`Project files: ${projectResult.uploaded} uploaded, ${projectResult.removed} removed, ${projectResult.unchanged} unchanged`);
203
+ console.log(`Context files: ${contextResult.uploaded} uploaded, ${contextResult.unchanged} unchanged`);
204
+ if (snapshotId) {
205
+ console.log(`Snapshot cache: ${snapshotId}`);
206
+ }
207
+ for (const message of postSyncMessages) console.log(message);
208
+ console.log(`SSH: npx freestyle vm ssh ${vmId}`);
209
+ if (options.autoSsh) {
210
+ const connected = await runConnectPlugins(vm, vmId, options, contextCandidates);
211
+ if (!connected) await sshIntoVm(vmId);
212
+ }
213
+ }
214
+
215
+ async function parseArgs(args: string[]): Promise<CliOptions> {
216
+ const options: CliOptions = {
217
+ projectRoot: process.cwd(),
218
+ cachePath: "",
219
+ remoteProjectDir: "",
220
+ name: "",
221
+ yes: false,
222
+ dryRun: false,
223
+ disablePlugins: [],
224
+ enablePlugins: [],
225
+ resetPluginPrefs: false,
226
+ listPlugins: false,
227
+ includeAuth: true,
228
+ includeAgentContext: true,
229
+ includeGitDir: true,
230
+ includeAllCopilotWorkspaces: false,
231
+ snapshot: true,
232
+ install: false,
233
+ autoSsh: true,
234
+ envKeys: [],
235
+ };
236
+
237
+ const positional: string[] = [];
238
+ for (let index = 0; index < args.length; index += 1) {
239
+ const arg = args[index];
240
+ if (arg === "--help" || arg === "-h") {
241
+ printHelp();
242
+ process.exit(0);
243
+ } else if (arg === "--yes" || arg === "-y") {
244
+ options.yes = true;
245
+ } else if (arg === "--dry-run") {
246
+ options.dryRun = true;
247
+ } else if (arg === "--disable-plugin") {
248
+ options.disablePlugins.push(readOptionValue(args, ++index, arg));
249
+ } else if (arg === "--enable-plugin") {
250
+ options.enablePlugins.push(readOptionValue(args, ++index, arg));
251
+ } else if (arg === "--reset-plugin-prefs" || arg === "--reset-context-prefs") {
252
+ options.resetPluginPrefs = true;
253
+ } else if (arg === "--list-plugins") {
254
+ options.listPlugins = true;
255
+ } else if (arg === "--no-auth") {
256
+ options.includeAuth = false;
257
+ } else if (arg === "--no-agent-context") {
258
+ options.includeAgentContext = false;
259
+ } else if (arg === "--no-git-dir") {
260
+ options.includeGitDir = false;
261
+ } else if (arg === "--all-copilot-workspaces") {
262
+ options.includeAllCopilotWorkspaces = true;
263
+ } else if (arg === "--no-snapshot" || arg === "--skip-snapshot") {
264
+ options.snapshot = false;
265
+ } else if (arg === "--install") {
266
+ options.install = true;
267
+ } else if (arg === "--no-ssh") {
268
+ options.autoSsh = false;
269
+ } else if (arg === "--vm-id") {
270
+ options.vmId = readOptionValue(args, ++index, arg);
271
+ } else if (arg === "--name") {
272
+ options.name = readOptionValue(args, ++index, arg);
273
+ } else if (arg === "--remote-dir") {
274
+ options.remoteProjectDir = normalizeRemotePath(readOptionValue(args, ++index, arg));
275
+ } else if (arg === "--cache") {
276
+ options.cachePath = path.resolve(readOptionValue(args, ++index, arg));
277
+ } else if (arg === "--idle-timeout") {
278
+ options.idleTimeoutSeconds = Number(readOptionValue(args, ++index, arg));
279
+ if (!Number.isInteger(options.idleTimeoutSeconds) || options.idleTimeoutSeconds < 1) {
280
+ throw new Error("--idle-timeout must be a positive integer");
281
+ }
282
+ } else if (arg === "--include-env") {
283
+ options.envKeys.push(readOptionValue(args, ++index, arg));
284
+ } else if (arg.startsWith("--")) {
285
+ throw new Error(`unknown option: ${arg}`);
286
+ } else {
287
+ positional.push(arg);
288
+ }
289
+ }
290
+
291
+ if (positional.length > 1) {
292
+ throw new Error("expected at most one project path");
293
+ }
294
+
295
+ options.projectRoot = path.resolve(positional[0] ?? options.projectRoot);
296
+ const projectStats = await stat(options.projectRoot).catch(() => null);
297
+ if (!projectStats?.isDirectory()) {
298
+ throw new Error(`project path is not a directory: ${options.projectRoot}`);
299
+ }
300
+
301
+ const projectName = sanitizeName(path.basename(options.projectRoot));
302
+ options.name ||= `vmpush-${projectName}`;
303
+ options.remoteProjectDir ||= defaultRemoteProjectDir(options.projectRoot);
304
+ options.cachePath ||= path.join(options.projectRoot, ".freestyle-sync", "cache.json");
305
+
306
+ return options;
307
+ }
308
+
309
+ function printHelp() {
310
+ console.log(`vmpush uploads the current project into a Freestyle VM.
311
+
312
+ Usage:
313
+ vmpush [project-dir] [options]
314
+
315
+ Options:
316
+ --vm-id <id> Sync into an existing Freestyle VM.
317
+ --name <name> Name for a newly created VM. Defaults to vmpush-<project>.
318
+ --remote-dir <path> Remote project directory. Defaults to the local absolute path.
319
+ --cache <path> Snapshot/hash cache path. Defaults to .freestyle-sync/cache.json.
320
+ --include-env <name> Always copy an environment variable. Repeatable.
321
+ --disable-plugin <name> Disable a plugin for this project and save the preference. Repeatable.
322
+ --enable-plugin <name> Re-enable a previously disabled plugin and save the preference. Repeatable.
323
+ --reset-plugin-prefs Clear saved plugin enable/disable preferences.
324
+ --list-plugins Show configured plugins and whether they are enabled.
325
+ --install Run detected dependency install command after sync.
326
+ --no-ssh Do not automatically open VS Code/Cursor or SSH after sync.
327
+ --idle-timeout <seconds> Set VM idle timeout when creating/starting.
328
+ --no-auth Disable auth plugins for this run.
329
+ --no-agent-context Disable agent context plugins for this run.
330
+ --no-git-dir Exclude the local .git directory from project sync.
331
+ --all-copilot-workspaces Include Copilot chat state for every VS Code workspace.
332
+ --no-snapshot, --skip-snapshot
333
+ Do not snapshot the VM after sync.
334
+ --dry-run Show what would sync without creating or changing a VM.
335
+ -y, --yes Deprecated; accepted for compatibility.
336
+ -h, --help Show this help.
337
+ `);
338
+ }
339
+
340
+ function readOptionValue(args: string[], index: number, option: string) {
341
+ const value = args[index];
342
+ if (!value || value.startsWith("--")) {
343
+ throw new Error(`${option} requires a value`);
344
+ }
345
+ return value;
346
+ }
347
+
348
+ async function readCache(cachePath: string, options: CliOptions): Promise<CacheFile> {
349
+ try {
350
+ const parsed = JSON.parse(await readFile(cachePath, "utf8")) as CacheFile;
351
+ if (parsed.version === CACHE_VERSION && parsed.projectRoot === options.projectRoot && parsed.remoteProjectDir === options.remoteProjectDir) {
352
+ return {
353
+ ...parsed,
354
+ projectFiles: parsed.projectFiles ?? {},
355
+ contextFiles: parsed.contextFiles ?? {},
356
+ snapshotProjectFiles: parsed.snapshotProjectFiles,
357
+ snapshotContextFiles: parsed.snapshotContextFiles,
358
+ };
359
+ }
360
+ } catch (error) {
361
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
362
+ console.warn(`Ignoring unreadable cache ${cachePath}: ${error instanceof Error ? error.message : String(error)}`);
363
+ }
364
+ }
365
+
366
+ return {
367
+ version: CACHE_VERSION,
368
+ projectRoot: options.projectRoot,
369
+ remoteProjectDir: options.remoteProjectDir,
370
+ projectFiles: {},
371
+ contextFiles: {},
372
+ };
373
+ }
374
+
375
+ function cacheBaseForSync(options: CliOptions, cache: CacheFile): { projectFiles: Record<string, FileDigest>; contextFiles: Record<string, FileDigest>; envHash?: string } {
376
+ if (options.vmId && options.vmId === cache.vmId) {
377
+ return {
378
+ projectFiles: cache.projectFiles,
379
+ contextFiles: cache.contextFiles,
380
+ envHash: cache.envHash,
381
+ };
382
+ }
383
+
384
+ if (!options.vmId && cache.snapshotId) {
385
+ return {
386
+ projectFiles: cache.snapshotProjectFiles ?? {},
387
+ contextFiles: cache.snapshotContextFiles ?? {},
388
+ envHash: cache.snapshotEnvHash,
389
+ };
390
+ }
391
+
392
+ return { projectFiles: {}, contextFiles: {} };
393
+ }
394
+
395
+ async function writeCache(cachePath: string, cache: CacheFile) {
396
+ await mkdir(path.dirname(cachePath), { recursive: true });
397
+ await writeFile(cachePath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
398
+ }
399
+
400
+ async function scanProject(projectRoot: string, includeGitDir: boolean): Promise<LocalEntry[]> {
401
+ const entries: LocalEntry[] = [];
402
+ await walk(projectRoot, "", entries, {
403
+ skipDirectory(relativePath, name) {
404
+ if (!includeGitDir && relativePath === ".git") return true;
405
+ return false;
406
+ },
407
+ });
408
+ return entries.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
409
+ }
410
+
411
+ async function walk(
412
+ root: string,
413
+ relativePath: string,
414
+ entries: LocalEntry[],
415
+ options: { skipDirectory(relativePath: string, name: string): boolean },
416
+ ) {
417
+ const absolutePath = path.join(root, relativePath);
418
+ const dir = await import("node:fs/promises").then((fs) => fs.readdir(absolutePath, { withFileTypes: true }));
419
+
420
+ for (const dirent of dir) {
421
+ const childRelativePath = relativePath ? path.join(relativePath, dirent.name) : dirent.name;
422
+ const normalizedRelativePath = toPosix(childRelativePath);
423
+ const childAbsolutePath = path.join(root, childRelativePath);
424
+
425
+ if (dirent.isDirectory()) {
426
+ if (options.skipDirectory(normalizedRelativePath, dirent.name)) {
427
+ continue;
428
+ }
429
+ await walk(root, childRelativePath, entries, options);
430
+ continue;
431
+ }
432
+
433
+ if (dirent.isFile() || dirent.isSymbolicLink()) {
434
+ entries.push(await digestEntry(childAbsolutePath, normalizedRelativePath));
435
+ }
436
+ }
437
+ }
438
+
439
+ async function digestEntry(absolutePath: string, relativePath: string): Promise<LocalEntry> {
440
+ const stats = await import("node:fs/promises").then((fs) => fs.lstat(absolutePath));
441
+ if (stats.isSymbolicLink()) {
442
+ const target = await import("node:fs/promises").then((fs) => fs.readlink(absolutePath));
443
+ return {
444
+ absolutePath,
445
+ relativePath,
446
+ kind: "symlink",
447
+ mode: stats.mode,
448
+ size: target.length,
449
+ hash: hashString(`symlink:${target}`),
450
+ };
451
+ }
452
+
453
+ return {
454
+ absolutePath,
455
+ relativePath,
456
+ kind: "file",
457
+ mode: stats.mode,
458
+ size: stats.size,
459
+ hash: await hashFile(absolutePath),
460
+ };
461
+ }
462
+
463
+ function digestMap(entries: LocalEntry[]): Record<string, FileDigest> {
464
+ return Object.fromEntries(entries.map((entry) => [entry.relativePath, stripLocal(entry)]));
465
+ }
466
+
467
+ function digestMapByRemotePath(entries: ContextEntry[]): Record<string, FileDigest> {
468
+ return Object.fromEntries(entries.map((entry) => [entry.remotePath, stripLocal(entry)]));
469
+ }
470
+
471
+ function stripLocal(entry: LocalEntry): FileDigest {
472
+ return {
473
+ hash: entry.hash,
474
+ kind: entry.kind,
475
+ mode: entry.mode,
476
+ size: entry.size,
477
+ };
478
+ }
479
+
480
+ function diffEntries(entries: LocalEntry[], previous: Record<string, FileDigest>) {
481
+ const changed = entries.filter((entry) => previous[entry.relativePath]?.hash !== entry.hash);
482
+ const currentKeys = new Set(entries.map((entry) => entry.relativePath));
483
+ const removed = Object.keys(previous).filter((relativePath) => !currentKeys.has(relativePath));
484
+ return { changed, removed, unchanged: entries.length - changed.length };
485
+ }
486
+
487
+ function diffContextEntries(entries: ContextEntry[], previous: Record<string, FileDigest>) {
488
+ const changed = entries.filter((entry) => previous[entry.remotePath]?.hash !== entry.hash);
489
+ const currentKeys = new Set(entries.map((entry) => entry.remotePath));
490
+ const removed = Object.keys(previous).filter((remotePath) => !currentKeys.has(remotePath) && !isProtectedRemotePath(remotePath));
491
+ return { changed, removed, unchanged: entries.length - changed.length };
492
+ }
493
+
494
+ function collectPluginEnvironment(options: CliOptions) {
495
+ const env: Record<string, string> = {};
496
+ for (const plugin of plugins) {
497
+ Object.assign(env, plugin.collectEnvironment?.({ options, utils: pluginUtils }) ?? {});
498
+ }
499
+ return env;
500
+ }
501
+
502
+ async function discoverPluginContextCandidates(options: CliOptions) {
503
+ const candidates: ContextCandidate[] = [];
504
+ for (const plugin of plugins) {
505
+ const discovered = await plugin.discoverContextCandidates?.({ options, utils: pluginUtils });
506
+ if (discovered) candidates.push(...discovered);
507
+ }
508
+ return dedupeContextCandidates(candidates);
509
+ }
510
+
511
+ function shouldSkipContextDirectory(relativePath: string, name: string) {
512
+ return plugins.some((plugin) => plugin.shouldSkipContextDirectory?.(relativePath, name));
513
+ }
514
+
515
+ function isProtectedRemotePath(remotePath: string) {
516
+ return plugins.some((plugin) => plugin.isProtectedRemotePath?.(remotePath));
517
+ }
518
+
519
+ function dedupeContextCandidates(candidates: ContextCandidate[]) {
520
+ const seen = new Set<string>();
521
+ return candidates.filter((candidate) => {
522
+ const key = `${candidate.source}\0${candidate.remoteRoot}`;
523
+ if (seen.has(key)) return false;
524
+ seen.add(key);
525
+ return true;
526
+ });
527
+ }
528
+
529
+ async function scanContextCandidates(candidates: ContextCandidate[]): Promise<ContextEntry[]> {
530
+ const entries: ContextEntry[] = [];
531
+ for (const candidate of candidates) {
532
+ const stats = await import("node:fs/promises").then((fs) => fs.lstat(candidate.source));
533
+ if (stats.isFile()) {
534
+ const local = await digestEntry(candidate.source, path.basename(candidate.source));
535
+ entries.push({
536
+ ...local,
537
+ remotePath: candidate.remoteRoot,
538
+ label: candidate.label,
539
+ sensitive: candidate.sensitive,
540
+ });
541
+ } else if (stats.isDirectory()) {
542
+ const candidateEntries: LocalEntry[] = [];
543
+ await walk(candidate.source, "", candidateEntries, {
544
+ skipDirectory(relativePath, name) {
545
+ return shouldSkipContextDirectory(relativePath, name);
546
+ },
547
+ });
548
+ for (const local of candidateEntries) {
549
+ if (local.kind === "symlink") continue;
550
+ const remotePath = `${candidate.remoteRoot}/${local.relativePath}`;
551
+ if (isProtectedRemotePath(remotePath)) continue;
552
+ entries.push({
553
+ ...local,
554
+ remotePath,
555
+ label: candidate.label,
556
+ sensitive: candidate.sensitive,
557
+ });
558
+ }
559
+ }
560
+ }
561
+ return entries.sort((left, right) => left.remotePath.localeCompare(right.remotePath));
562
+ }
563
+
564
+ async function updatePluginPreferences(options: CliOptions): Promise<PluginPreferences> {
565
+ const preferencesPath = getPluginPreferencesPath(options);
566
+ const preferences = options.resetPluginPrefs ? emptyPluginPreferences() : await readPluginPreferences(preferencesPath);
567
+ let changed = options.resetPluginPrefs;
568
+ const disabledPlugins = new Set<string>();
569
+
570
+ for (const savedName of preferences.disabledPlugins) {
571
+ const canonicalName = maybeResolvePluginSelector(savedName);
572
+ disabledPlugins.add(canonicalName ?? savedName);
573
+ if (canonicalName && canonicalName !== savedName) changed = true;
574
+ }
575
+
576
+ for (const selector of options.enablePlugins) {
577
+ const name = resolvePluginSelector(selector);
578
+ if (disabledPlugins.delete(name)) changed = true;
579
+ }
580
+
581
+ for (const selector of options.disablePlugins) {
582
+ const name = resolvePluginSelector(selector);
583
+ if (!disabledPlugins.has(name)) {
584
+ disabledPlugins.add(name);
585
+ changed = true;
586
+ }
587
+ }
588
+
589
+ const next = { version: PLUGIN_PREFERENCES_VERSION, disabledPlugins: [...disabledPlugins].sort(), updatedAt: preferences.updatedAt };
590
+ if (changed) await writePluginPreferences(preferencesPath, next);
591
+ return next;
592
+ }
593
+
594
+ function activePlugins(preferences: PluginPreferences, options: CliOptions) {
595
+ const disabled = new Set(preferences.disabledPlugins);
596
+ return config.plugins.filter((plugin) => {
597
+ if (disabled.has(plugin.name)) return false;
598
+ if (!options.includeAuth && plugin.name.startsWith("@freestyle-sync/auth-")) return false;
599
+ if (!options.includeAgentContext && plugin.name.startsWith("@freestyle-sync/agent-")) return false;
600
+ return true;
601
+ });
602
+ }
603
+
604
+ function printPlugins(preferences: PluginPreferences, options: CliOptions) {
605
+ const enabled = new Set(activePlugins(preferences, options).map((plugin) => plugin.name));
606
+ console.log(`Plugin preferences: ${getPluginPreferencesPath(options)}`);
607
+ for (const plugin of config.plugins) {
608
+ const runtimeDisabled = (!options.includeAuth && plugin.name.startsWith("@freestyle-sync/auth-")) || (!options.includeAgentContext && plugin.name.startsWith("@freestyle-sync/agent-"));
609
+ const savedDisabled = preferences.disabledPlugins.includes(plugin.name);
610
+ const status = enabled.has(plugin.name) ? "enabled" : savedDisabled ? "disabled (saved)" : runtimeDisabled ? "disabled (this run)" : "disabled";
611
+ console.log(`${status.padEnd(19)} ${plugin.name}`);
612
+ }
613
+ }
614
+
615
+ function resolvePluginSelector(selector: string) {
616
+ const match = maybeResolvePluginSelector(selector);
617
+ if (match) return match;
618
+ const matches = config.plugins.filter((plugin) => pluginSelectorAliases(plugin.name).includes(selector));
619
+ if (matches.length > 1) throw new Error(`ambiguous plugin selector ${selector}: ${matches.map((plugin) => plugin.name).join(", ")}`);
620
+ throw new Error(`unknown plugin ${selector}. Run --list-plugins to see configured plugins.`);
621
+ }
622
+
623
+ function maybeResolvePluginSelector(selector: string) {
624
+ const matches = config.plugins.filter((plugin) => pluginSelectorAliases(plugin.name).includes(selector));
625
+ if (matches.length === 1) return matches[0].name;
626
+ if (matches.length > 1) throw new Error(`ambiguous plugin selector ${selector}: ${matches.map((plugin) => plugin.name).join(", ")}`);
627
+ return undefined;
628
+ }
629
+
630
+ function pluginSelectorAliases(name: string) {
631
+ const aliases = new Set([name]);
632
+ const withoutScope = name.split("/").pop();
633
+ if (withoutScope) aliases.add(withoutScope);
634
+ if (name.startsWith("@freestyle-sync/") && withoutScope) {
635
+ aliases.add(`@freestyle/sync-plugin-${withoutScope}`);
636
+ aliases.add(`@pushvm/plugin-${withoutScope}`);
637
+ aliases.add(`sync-plugin-${withoutScope}`);
638
+ aliases.add(`plugin-${withoutScope}`);
639
+ }
640
+ if (withoutScope?.startsWith("sync-plugin-")) aliases.add(withoutScope.slice("sync-plugin-".length));
641
+ if (withoutScope?.startsWith("plugin-")) aliases.add(withoutScope.slice("plugin-".length));
642
+ return [...aliases];
643
+ }
644
+
645
+ function emptyPluginPreferences(): PluginPreferences {
646
+ return { version: PLUGIN_PREFERENCES_VERSION, disabledPlugins: [] };
647
+ }
648
+
649
+ function getPluginPreferencesPath(options: CliOptions) {
650
+ return path.join(path.dirname(options.cachePath), "plugin-preferences.json");
651
+ }
652
+
653
+ async function readPluginPreferences(preferencesPath: string): Promise<PluginPreferences> {
654
+ try {
655
+ const parsed = JSON.parse(await readFile(preferencesPath, "utf8")) as PluginPreferences;
656
+ if (parsed.version === PLUGIN_PREFERENCES_VERSION) {
657
+ return {
658
+ version: PLUGIN_PREFERENCES_VERSION,
659
+ disabledPlugins: Array.isArray(parsed.disabledPlugins) ? parsed.disabledPlugins.filter((name) => typeof name === "string") : [],
660
+ updatedAt: parsed.updatedAt,
661
+ };
662
+ }
663
+ } catch (error) {
664
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
665
+ console.warn(`Ignoring unreadable plugin preferences ${preferencesPath}: ${error instanceof Error ? error.message : String(error)}`);
666
+ }
667
+ }
668
+ return emptyPluginPreferences();
669
+ }
670
+
671
+ async function writePluginPreferences(preferencesPath: string, preferences: PluginPreferences) {
672
+ await mkdir(path.dirname(preferencesPath), { recursive: true });
673
+ await writeFile(preferencesPath, `${JSON.stringify({ ...preferences, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
674
+ }
675
+
676
+ function printPlan(
677
+ options: CliOptions,
678
+ projectChanges: ReturnType<typeof diffEntries>,
679
+ contextChanges: ReturnType<typeof diffContextEntries>,
680
+ envExports: Record<string, string>,
681
+ cache: CacheFile,
682
+ ) {
683
+ const source = options.vmId ? `existing VM ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : "a new VM";
684
+ console.log(`Syncing ${options.projectRoot} to ${source}`);
685
+ console.log(`Remote project: ${options.remoteProjectDir}`);
686
+ console.log(`Project files: ${projectChanges.changed.length} changed, ${projectChanges.removed.length} removed, ${projectChanges.unchanged} unchanged`);
687
+ console.log(`Context files: ${contextChanges.changed.length} changed, ${contextChanges.removed.length} removed, ${contextChanges.unchanged} unchanged`);
688
+ console.log(`Estimated upload: ${formatBytes(totalEntrySize(projectChanges.changed))} project, ${formatBytes(totalEntrySize(contextChanges.changed))} context`);
689
+ if (Object.keys(envExports).length > 0) {
690
+ console.log(`Environment exports: ${Object.keys(envExports).length}`);
691
+ }
692
+ }
693
+
694
+ function totalEntrySize(entries: LocalEntry[]) {
695
+ return entries.reduce((total, entry) => total + entry.size, 0);
696
+ }
697
+
698
+ async function getOrCreateVm(options: CliOptions, snapshotId?: string) {
699
+ if (options.vmId) {
700
+ const { vm } = await freestyle.vms.get({ vmId: options.vmId });
701
+ await vm.start(options.idleTimeoutSeconds ? { idleTimeoutSeconds: options.idleTimeoutSeconds } : undefined);
702
+ return { vm, vmId: options.vmId };
703
+ }
704
+
705
+ console.log(snapshotId ? `Creating VM from snapshot ${snapshotId}...` : "Creating Freestyle VM...");
706
+ const result = await freestyle.vms.create({
707
+ name: options.name,
708
+ snapshotId: snapshotId ?? undefined,
709
+ idleTimeoutSeconds: options.idleTimeoutSeconds,
710
+ });
711
+ return { vm: result.vm, vmId: result.vmId };
712
+ }
713
+
714
+ async function ensureRemoteBase(vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"], remoteProjectDir: string) {
715
+ await checkedExec(vm, `mkdir -p ${shellQuote(remoteProjectDir)} /root/.freestyle-sync`);
716
+ }
717
+
718
+ async function syncProject(
719
+ vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
720
+ vmId: string,
721
+ options: CliOptions,
722
+ changes: ReturnType<typeof diffEntries>,
723
+ ): Promise<SyncResult> {
724
+ if (changes.changed.length > 0) {
725
+ console.log(`VM ${vmId}: uploading ${changes.changed.length} changed project files...`);
726
+ const archive = await createProjectArchive(options.projectRoot, changes.changed);
727
+ try {
728
+ await uploadArchiveInChunks(vm, vmId, archive, "/tmp/vmpush-project.tgz", "project");
729
+ await checkedExec(vm, `mkdir -p ${shellQuote(options.remoteProjectDir)} && tar --no-same-owner --no-same-permissions -xzf /tmp/vmpush-project.tgz -C ${shellQuote(options.remoteProjectDir)} && rm -f /tmp/vmpush-project.tgz`);
730
+ } finally {
731
+ await rm(path.dirname(archive), { recursive: true, force: true });
732
+ }
733
+ }
734
+
735
+ if (changes.removed.length > 0) {
736
+ console.log(`VM ${vmId}: removing ${changes.removed.length} deleted project files...`);
737
+ const removeList = Buffer.from(changes.removed.join("\0") + "\0");
738
+ await vm.fs.writeFile("/tmp/vmpush-remove-list", removeList);
739
+ await checkedExec(vm, `cd ${shellQuote(options.remoteProjectDir)} && xargs -0 rm -f -- < /tmp/vmpush-remove-list && rm -f /tmp/vmpush-remove-list`);
740
+ }
741
+
742
+ return {
743
+ uploaded: changes.changed.length,
744
+ removed: changes.removed.length,
745
+ unchanged: changes.unchanged,
746
+ };
747
+ }
748
+
749
+ async function runRemoteFixups(vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"], vmId: string, options: CliOptions) {
750
+ for (const plugin of plugins) {
751
+ if (!plugin.afterProjectSync) continue;
752
+ try {
753
+ await plugin.afterProjectSync({ vm: vm as RemoteVm, vmId, options, utils: pluginUtils });
754
+ } catch (error) {
755
+ console.warn(`Remote fixup ${plugin.name} skipped: ${error instanceof Error ? error.message : String(error)}`);
756
+ }
757
+ }
758
+ }
759
+
760
+ async function runAfterContextSyncPlugins(
761
+ vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
762
+ vmId: string,
763
+ options: CliOptions,
764
+ changedRemotePaths: string[],
765
+ ) {
766
+ for (const plugin of plugins) {
767
+ if (!plugin.afterContextSync) continue;
768
+ await plugin.afterContextSync({ vm: vm as RemoteVm, vmId, options, utils: pluginUtils, changedRemotePaths });
769
+ }
770
+ }
771
+
772
+ async function runAfterSyncPlugins(
773
+ vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
774
+ vmId: string,
775
+ options: CliOptions,
776
+ contextCandidates: ContextCandidate[],
777
+ ) {
778
+ const messages: string[] = [];
779
+ for (const plugin of plugins) {
780
+ const result = await plugin.afterSync?.({ vm: vm as RemoteVm, vmId, options, utils: pluginUtils, contextCandidates });
781
+ if (result) messages.push(...result);
782
+ }
783
+ return messages;
784
+ }
785
+
786
+ async function runBeforeSnapshotPlugins(vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"], vmId: string, options: CliOptions) {
787
+ for (const plugin of plugins) {
788
+ if (!plugin.beforeSnapshot) continue;
789
+ try {
790
+ await plugin.beforeSnapshot({ vm: vm as RemoteVm, vmId, options, utils: pluginUtils });
791
+ } catch (error) {
792
+ console.warn(`Snapshot preparation ${plugin.name} skipped: ${error instanceof Error ? error.message : String(error)}`);
793
+ }
794
+ }
795
+ }
796
+
797
+ async function syncContext(vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"], vmId: string, changes: ReturnType<typeof diffContextEntries>): Promise<SyncResult> {
798
+ if (changes.removed.length > 0) {
799
+ console.log(`VM ${vmId}: removing ${changes.removed.length} deleted auth/context files...`);
800
+ await checkedExec(vm, changes.removed.map((remotePath) => `rm -f -- ${shellQuote(remotePath)}`).join("\n"));
801
+ }
802
+
803
+ if (changes.changed.length === 0) {
804
+ return { uploaded: 0, removed: changes.removed.length, unchanged: changes.unchanged };
805
+ }
806
+
807
+ console.log(`VM ${vmId}: uploading ${changes.changed.length} changed auth/context files...`);
808
+ const archive = await createContextArchive(changes.changed);
809
+ try {
810
+ await uploadArchiveInChunks(vm, vmId, archive, "/tmp/vmpush-context.tgz", "context");
811
+ await checkedExec(vm, "tar --no-same-owner --no-same-permissions -xzf /tmp/vmpush-context.tgz -C / && rm -f /tmp/vmpush-context.tgz");
812
+ } finally {
813
+ await rm(path.dirname(archive), { recursive: true, force: true });
814
+ }
815
+ return { uploaded: changes.changed.length, removed: changes.removed.length, unchanged: changes.unchanged };
816
+ }
817
+
818
+ async function installEnvironment(
819
+ vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
820
+ envExports: Record<string, string>,
821
+ envHash: string,
822
+ previousHash?: string,
823
+ ) {
824
+ if (Object.keys(envExports).length === 0 || envHash === previousHash) return;
825
+ await checkedExec(vm, "mkdir -p /root/.freestyle-sync");
826
+ await vm.fs.writeTextFile("/root/.freestyle-sync/env.sh", renderEnvFile(envExports));
827
+ await checkedExec(
828
+ vm,
829
+ "chmod 700 /root/.freestyle-sync && chmod 600 /root/.freestyle-sync/env.sh && grep -qxF 'test -f /root/.freestyle-sync/env.sh && . /root/.freestyle-sync/env.sh' /root/.profile || printf '\n%s\n' 'test -f /root/.freestyle-sync/env.sh && . /root/.freestyle-sync/env.sh' >> /root/.profile",
830
+ );
831
+ }
832
+
833
+ async function writeRemoteResumeFiles(
834
+ vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
835
+ options: CliOptions,
836
+ vmId: string,
837
+ envExports: Record<string, string>,
838
+ ) {
839
+ const manifest = {
840
+ vmId,
841
+ project: options.remoteProjectDir,
842
+ syncedAt: new Date().toISOString(),
843
+ env: Object.keys(envExports).sort(),
844
+ installCommand: await detectInstallCommand(options.projectRoot),
845
+ };
846
+ await checkedExec(vm, `mkdir -p ${shellQuote(`${options.remoteProjectDir}/.freestyle-sync`)} /root/.freestyle-sync`);
847
+ await vm.fs.writeTextFile(`${options.remoteProjectDir}/.freestyle-sync/remote.json`, `${JSON.stringify(manifest, null, 2)}\n`);
848
+ await vm.fs.writeTextFile(
849
+ "/root/.freestyle-sync/resume.sh",
850
+ `#!/usr/bin/env bash
851
+ set -euo pipefail
852
+ test -f /root/.freestyle-sync/env.sh && . /root/.freestyle-sync/env.sh
853
+ cd ${shellQuote(options.remoteProjectDir)}
854
+ exec "$SHELL" -l
855
+ `,
856
+ );
857
+ await vm.fs.writeTextFile("/root/.freestyle-sync/profile.sh", renderRemoteProfile(options.remoteProjectDir));
858
+ await checkedExec(
859
+ vm,
860
+ "chmod 700 /root/.freestyle-sync && chmod +x /root/.freestyle-sync/resume.sh && chmod 600 /root/.freestyle-sync/profile.sh && grep -qxF 'test -f /root/.freestyle-sync/profile.sh && . /root/.freestyle-sync/profile.sh' /root/.profile || printf '\n%s\n' 'test -f /root/.freestyle-sync/profile.sh && . /root/.freestyle-sync/profile.sh' >> /root/.profile",
861
+ );
862
+ }
863
+
864
+ function renderRemoteProfile(remoteProjectDir: string) {
865
+ return `# Generated by freestyle-sync. Loaded by /root/.profile.
866
+ test -f /root/.freestyle-sync/env.sh && . /root/.freestyle-sync/env.sh
867
+ if [ -d ${shellQuote(remoteProjectDir)} ]; then
868
+ cd ${shellQuote(remoteProjectDir)}
869
+ fi
870
+ `;
871
+ }
872
+
873
+ async function runInstall(vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"], projectRoot: string, remoteProjectDir: string) {
874
+ const installCommand = await detectInstallCommand(projectRoot);
875
+ if (!installCommand) {
876
+ console.log("No dependency install command detected.");
877
+ return;
878
+ }
879
+ console.log(`Running install: ${installCommand}`);
880
+ await checkedExec(vm, `cd ${shellQuote(remoteProjectDir)} && ${installCommand}`, 20 * 60 * 1000);
881
+ }
882
+
883
+ async function detectInstallCommand(projectRoot: string) {
884
+ if (await exists(path.join(projectRoot, "pnpm-lock.yaml"))) return "corepack enable && pnpm install";
885
+ if (await exists(path.join(projectRoot, "yarn.lock"))) return "corepack enable && yarn install";
886
+ if (await exists(path.join(projectRoot, "package-lock.json"))) return "npm install";
887
+ if (await exists(path.join(projectRoot, "requirements.txt"))) return "python3 -m pip install -r requirements.txt";
888
+ if (await exists(path.join(projectRoot, "pyproject.toml"))) return "python3 -m pip install -e .";
889
+ if (await exists(path.join(projectRoot, "Cargo.toml"))) return "cargo fetch";
890
+ if (await exists(path.join(projectRoot, "go.mod"))) return "go mod download";
891
+ return undefined;
892
+ }
893
+
894
+ async function createProjectArchive(projectRoot: string, entries: LocalEntry[]) {
895
+ const tempDir = await mkdtemp(path.join(tmpdir(), "vmpush-"));
896
+ const listPath = path.join(tempDir, "files.list");
897
+ const archivePath = path.join(tempDir, "project.tgz");
898
+ await writeFile(listPath, Buffer.from(entries.map((entry) => entry.relativePath).join("\0") + "\0"));
899
+ await createTar(["--null", "--no-xattrs", "-T", listPath, "-czf", archivePath, "-C", projectRoot]);
900
+ return archivePath;
901
+ }
902
+
903
+ async function createContextArchive(entries: ContextEntry[]) {
904
+ const tempDir = await mkdtemp(path.join(tmpdir(), "vmpush-context-"));
905
+ const stagingDir = path.join(tempDir, "staging");
906
+ const archivePath = path.join(tempDir, "context.tgz");
907
+ await mkdir(stagingDir, { recursive: true });
908
+
909
+ for (const entry of entries) {
910
+ const relativeRemotePath = entry.remotePath.replace(/^\/+/, "");
911
+ const destination = path.join(stagingDir, ...relativeRemotePath.split("/"));
912
+ await mkdir(path.dirname(destination), { recursive: true });
913
+ await writeFile(destination, await readFile(entry.absolutePath));
914
+ }
915
+
916
+ await createTar(["--no-xattrs", "-czf", archivePath, "-C", stagingDir, "."]);
917
+ return archivePath;
918
+ }
919
+
920
+ async function createTar(args: string[]) {
921
+ await execFileAsync("tar", args, {
922
+ env: {
923
+ ...process.env,
924
+ COPYFILE_DISABLE: "1",
925
+ },
926
+ });
927
+ }
928
+
929
+ async function uploadArchiveInChunks(
930
+ vm: RemoteVm,
931
+ vmId: string,
932
+ archivePath: string,
933
+ remoteArchivePath: string,
934
+ label: string,
935
+ ) {
936
+ const archive = await readFile(archivePath);
937
+ const encoded = archive.toString("base64");
938
+ const chunkCount = Math.max(1, Math.ceil(encoded.length / ARCHIVE_CHUNK_CHARS));
939
+ const chunkDir = `/tmp/vmpush-${label}-${Date.now()}.chunks`;
940
+
941
+ console.log(`VM ${vmId}: streaming ${formatBytes(archive.length)} ${label} archive in ${chunkCount} chunk${chunkCount === 1 ? "" : "s"}...`);
942
+ await checkedExec(vm, `rm -rf ${shellQuote(chunkDir)} && mkdir -p ${shellQuote(chunkDir)}`);
943
+
944
+ const width = String(chunkCount - 1).length;
945
+ for (let index = 0; index < chunkCount; index += 1) {
946
+ const start = index * ARCHIVE_CHUNK_CHARS;
947
+ const chunk = encoded.slice(start, start + ARCHIVE_CHUNK_CHARS);
948
+ const chunkName = `${String(index).padStart(width, "0")}.b64`;
949
+ await vm.fs.writeTextFile(`${chunkDir}/${chunkName}`, chunk);
950
+ if (chunkCount > 1 && ((index + 1) % 10 === 0 || index + 1 === chunkCount)) {
951
+ console.log(`VM ${vmId}: uploaded ${index + 1}/${chunkCount} ${label} archive chunks`);
952
+ }
953
+ }
954
+
955
+ await checkedExec(
956
+ vm,
957
+ `cat ${shellQuote(chunkDir)}/*.b64 | base64 -d > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`,
958
+ );
959
+ }
960
+
961
+ async function mkdirRemote(vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"], directories: string[]) {
962
+ for (const chunk of chunkArray(directories, 50)) {
963
+ await checkedExec(vm, `mkdir -p ${chunk.map(shellQuote).join(" ")}`);
964
+ }
965
+ }
966
+
967
+ async function hardenRemoteRoot(vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"]): Promise<void> {
968
+ await checkedExec(vm, "chown root:root /root && chmod 700 /root");
969
+ }
970
+
971
+ async function checkedExec(vm: RemoteVm, command: string, timeoutMs?: number) {
972
+ const result = await vm.exec({ command, timeoutMs });
973
+ if (result.statusCode && result.statusCode !== 0) {
974
+ throw new Error(`remote command failed (${result.statusCode}): ${command}\n${result.stderr ?? result.stdout ?? ""}`);
975
+ }
976
+ return result;
977
+ }
978
+
979
+ async function runBeforeOpenRemoteEditorPlugins(
980
+ vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
981
+ vmId: string,
982
+ options: CliOptions,
983
+ scheme: "vscode" | "cursor",
984
+ remoteWorkspaceUri: string,
985
+ contextCandidates: ContextCandidate[],
986
+ ) {
987
+ const messages: string[] = [];
988
+ for (const plugin of plugins) {
989
+ const result = await plugin.beforeOpenRemoteEditor?.({
990
+ vm: vm as RemoteVm,
991
+ vmId,
992
+ options,
993
+ utils: pluginUtils,
994
+ scheme,
995
+ remoteWorkspaceUri,
996
+ contextCandidates,
997
+ });
998
+ if (result) messages.push(...result);
999
+ }
1000
+ return messages;
1001
+ }
1002
+
1003
+ async function runAfterOpenRemoteEditorPlugins(
1004
+ vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
1005
+ vmId: string,
1006
+ options: CliOptions,
1007
+ scheme: "vscode" | "cursor",
1008
+ remoteWorkspaceUri: string,
1009
+ contextCandidates: ContextCandidate[],
1010
+ ) {
1011
+ const messages: string[] = [];
1012
+ for (const plugin of plugins) {
1013
+ const result = await plugin.afterOpenRemoteEditor?.({
1014
+ vm: vm as RemoteVm,
1015
+ vmId,
1016
+ options,
1017
+ utils: pluginUtils,
1018
+ scheme,
1019
+ remoteWorkspaceUri,
1020
+ contextCandidates,
1021
+ });
1022
+ if (result) messages.push(...result);
1023
+ }
1024
+ return messages;
1025
+ }
1026
+
1027
+ async function runConnectPlugins(
1028
+ vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
1029
+ vmId: string,
1030
+ options: CliOptions,
1031
+ contextCandidates: ContextCandidate[],
1032
+ ) {
1033
+ for (const plugin of plugins) {
1034
+ const handled = await plugin.connect?.({
1035
+ vm: vm as RemoteVm,
1036
+ vmId,
1037
+ options,
1038
+ utils: pluginUtils,
1039
+ contextCandidates,
1040
+ runBeforeOpenRemoteEditor: ({ scheme, remoteWorkspaceUri }) => runBeforeOpenRemoteEditorPlugins(vm, vmId, options, scheme, remoteWorkspaceUri, contextCandidates),
1041
+ runAfterOpenRemoteEditor: ({ scheme, remoteWorkspaceUri }) => runAfterOpenRemoteEditorPlugins(vm, vmId, options, scheme, remoteWorkspaceUri, contextCandidates),
1042
+ });
1043
+ if (handled) return true;
1044
+ }
1045
+ return false;
1046
+ }
1047
+
1048
+ async function sshIntoVm(vmId: string) {
1049
+ console.log(`Connecting to VM ${vmId}...`);
1050
+ const exitCode = await new Promise<number | null>((resolve, reject) => {
1051
+ const child = spawn("npx", ["freestyle", "vm", "ssh", vmId], { stdio: "inherit" });
1052
+ child.on("error", reject);
1053
+ child.on("exit", (code) => resolve(code));
1054
+ });
1055
+ if (exitCode && exitCode !== 0) {
1056
+ throw new Error(`ssh exited with status ${exitCode}`);
1057
+ }
1058
+ }
1059
+
1060
+ async function hashFile(filePath: string): Promise<string> {
1061
+ const hash = createHash("sha256");
1062
+ await new Promise<void>((resolve, reject) => {
1063
+ const stream = createReadStream(filePath);
1064
+ stream.on("data", (chunk) => hash.update(chunk));
1065
+ stream.on("error", reject);
1066
+ stream.on("end", resolve);
1067
+ });
1068
+ return hash.digest("hex");
1069
+ }
1070
+
1071
+ function hashString(value: string): string {
1072
+ return createHash("sha256").update(value).digest("hex");
1073
+ }
1074
+
1075
+ function md5(value: string): string {
1076
+ return createHash("md5").update(value).digest("hex");
1077
+ }
1078
+
1079
+ function renderEnvFile(envExports: Record<string, string>) {
1080
+ const lines = ["# Generated by vmpush. Contains local auth tokens."];
1081
+ for (const key of Object.keys(envExports).sort()) {
1082
+ lines.push(`export ${key}=${shellQuote(envExports[key])}`);
1083
+ }
1084
+ return `${lines.join("\n")}\n`;
1085
+ }
1086
+
1087
+ function shellQuote(value: string): string {
1088
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
1089
+ }
1090
+
1091
+ function normalizeRemotePath(value: string): string {
1092
+ const normalized = path.posix.normalize(value.replace(/\\/g, "/"));
1093
+ if (!normalized.startsWith("/")) {
1094
+ throw new Error("--remote-dir must be an absolute VM path");
1095
+ }
1096
+ return normalized;
1097
+ }
1098
+
1099
+ function defaultRemoteProjectDir(projectRoot: string): string {
1100
+ return normalizeRemotePath(toPosix(projectRoot));
1101
+ }
1102
+
1103
+ function sanitizeName(value: string): string {
1104
+ return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1105
+ }
1106
+
1107
+ function toPosix(value: string) {
1108
+ return value.split(path.sep).join(path.posix.sep);
1109
+ }
1110
+
1111
+ function formatBytes(bytes: number) {
1112
+ if (bytes < 1024) return `${bytes} B`;
1113
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1114
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
1115
+ }
1116
+
1117
+ async function delay(ms: number) {
1118
+ await new Promise((resolve) => setTimeout(resolve, ms));
1119
+ }
1120
+
1121
+ async function exists(filePath: string) {
1122
+ try {
1123
+ await stat(filePath);
1124
+ return true;
1125
+ } catch {
1126
+ return false;
1127
+ }
1128
+ }
1129
+
1130
+ function chunkArray<T>(items: T[], size: number): T[][] {
1131
+ const chunks: T[][] = [];
1132
+ for (let index = 0; index < items.length; index += size) {
1133
+ chunks.push(items.slice(index, index + size));
1134
+ }
1135
+ return chunks;
1136
+ }