freestyle-sync 0.1.2 → 0.1.4

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