freestyle-sync 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # Freestyle Sync
2
2
 
3
- Sync your current directory, it's dependencies, and your agent context into a VM.
3
+ Sync your current directory, it's dependencies, and your agent context into a VM. VM snapshots ensure only changes are uploaded.
4
4
 
5
5
  ```
6
6
  npx freestyle-sync
7
7
  ```
8
8
 
9
+ Configure complex sync behavior using plugins.
9
10
  ```ts
10
11
  import { defineConfig } from "freestyle-sync";
11
12
  import { claudeAgentPlugin } from "@freestyle-sync/agent-claude";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freestyle-sync",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "src/main.ts",
6
6
  "exports": {
@@ -33,11 +33,13 @@ export function copilotAgentPlugin() {
33
33
  return [];
34
34
  },
35
35
  async beforeOpenRemoteEditor({ vm, vmId, scheme, remoteWorkspaceUri, contextCandidates, utils }) {
36
+ if (!isSupportedEditorScheme(scheme)) return [];
36
37
  const result = await preseedLocalCopilotForRemoteUri(vm, vmId, contextCandidates, scheme, remoteWorkspaceUri, utils);
37
38
  if (result.status === "copied") return [`Prepared Copilot sidebar state for ${scheme === "cursor" ? "Cursor" : "VS Code"}.`];
38
39
  return [];
39
40
  },
40
41
  async afterOpenRemoteEditor({ vm, vmId, scheme, remoteWorkspaceUri, contextCandidates, utils }) {
42
+ if (!isSupportedEditorScheme(scheme)) return [];
41
43
  const result = await syncLocalCopilotForRemoteUri(vm, vmId, contextCandidates, remoteWorkspaceUri, utils);
42
44
  if (result.status === "copied") return [`Copied Copilot sidebar state to opened ${scheme === "cursor" ? "Cursor" : "VS Code"} workspace.`];
43
45
  if (result.status === "pending") return ["Copilot sidebar state: opened the editor, but the workspace storage bucket was not created yet. Reload the window after it finishes connecting, then rerun vmpush if needed."];
@@ -166,7 +168,7 @@ async function syncLocalCopilotForRemoteUri(vm: RemoteVm, vmId: string, contextC
166
168
  return { status: "pending", count: 0 };
167
169
  }
168
170
 
169
- async function preseedLocalCopilotForRemoteUri(vm: RemoteVm, vmId: string, contextCandidates: ContextCandidate[], scheme: "vscode" | "cursor", remoteWorkspaceUri: string, utils: PushvmPluginUtils): Promise<CopyResult> {
171
+ async function preseedLocalCopilotForRemoteUri(vm: RemoteVm, vmId: string, contextCandidates: ContextCandidate[], scheme: string, remoteWorkspaceUri: string, utils: PushvmPluginUtils): Promise<CopyResult> {
170
172
  const sourceCopilotDir = selectedCurrentCopilotSource(contextCandidates);
171
173
  if (!sourceCopilotDir || !(await exists(sourceCopilotDir))) return { status: "skipped", count: 0 };
172
174
 
@@ -455,17 +457,25 @@ async function ensureWorkspaceStateDb(stateDb: string) {
455
457
  await execFileAsync("sqlite3", [stateDb, "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);"]);
456
458
  }
457
459
 
458
- function codeUserRootsForScheme(home: string, scheme: "vscode" | "cursor") {
459
- if (scheme === "cursor") {
460
- return [
461
- path.join(home, "Library", "Application Support", "Cursor", "User"),
462
- path.join(home, ".config", "Cursor", "User"),
463
- ];
460
+ function codeUserRootsForScheme(home: string, scheme: string) {
461
+ switch (scheme) {
462
+ case "cursor":
463
+ return [
464
+ path.join(home, "Library", "Application Support", "Cursor", "User"),
465
+ path.join(home, ".config", "Cursor", "User"),
466
+ ];
467
+ case "vscode":
468
+ return [
469
+ path.join(home, "Library", "Application Support", "Code", "User"),
470
+ path.join(home, ".config", "Code", "User"),
471
+ ];
472
+ default:
473
+ return [];
464
474
  }
465
- return [
466
- path.join(home, "Library", "Application Support", "Code", "User"),
467
- path.join(home, ".config", "Code", "User"),
468
- ];
475
+ }
476
+
477
+ function isSupportedEditorScheme(scheme: string): scheme is "vscode" | "cursor" {
478
+ return scheme === "vscode" || scheme === "cursor";
469
479
  }
470
480
 
471
481
  function codeUserRoots(home: string) {
package/src/main.ts CHANGED
@@ -17,6 +17,9 @@ const execFileAsync = promisify(execFile);
17
17
  const CACHE_VERSION = 1;
18
18
  const PLUGIN_PREFERENCES_VERSION = 1;
19
19
  const ARCHIVE_CHUNK_CHARS = 1024 * 1024;
20
+ const MS_PER_SECOND = 1000;
21
+ const USE_UNICODE_OUTPUT = process.stdout.isTTY && (process.env.TERM !== "dumb" || Boolean(process.env.TERM_PROGRAM));
22
+ const USE_STYLED_OUTPUT = process.stdout.isTTY && process.env.NO_COLOR !== "1";
20
23
  let config: PushvmConfig = { plugins: [] };
21
24
  let plugins = config.plugins;
22
25
  const pluginUtils: PushvmPluginUtils = {
@@ -79,14 +82,33 @@ type SyncResult = {
79
82
  class Progress {
80
83
  private current = 0;
81
84
  private readonly total: number;
85
+ private currentStepStartedAt = 0;
86
+ private currentStepMessage?: string;
82
87
 
83
88
  constructor(total: number) {
84
89
  this.total = total;
85
90
  }
86
91
 
87
92
  step(message: string) {
93
+ if (this.currentStepMessage) {
94
+ this.printCompletion();
95
+ }
88
96
  this.current += 1;
89
- console.log(`[${this.current}/${this.total}] ${message}`);
97
+ this.currentStepMessage = message;
98
+ this.currentStepStartedAt = Date.now();
99
+ console.log(`${accent(symbol("➜", ">"))} ${bold(`[${this.current}/${this.total}]`)} ${message}`);
100
+ }
101
+
102
+ finish() {
103
+ if (!this.currentStepMessage) return;
104
+ this.printCompletion();
105
+ this.currentStepMessage = undefined;
106
+ }
107
+
108
+ private printCompletion() {
109
+ if (!this.currentStepMessage) return;
110
+ const elapsed = Date.now() - this.currentStepStartedAt;
111
+ console.log(`${success(symbol("✔", "*"))} ${dim(`[${this.current}/${this.total}]`)} ${this.currentStepMessage} ${dim(formatDuration(elapsed))}`);
90
112
  }
91
113
  }
92
114
 
@@ -99,6 +121,7 @@ if (isDirectCliExecution()) {
99
121
 
100
122
  async function main() {
101
123
  const options = await parseArgs(process.argv.slice(2));
124
+ printHeading("vmpush");
102
125
  config = await loadConfig(options.projectRoot);
103
126
  plugins = config.plugins;
104
127
  const pluginPreferences = await updatePluginPreferences(options);
@@ -107,12 +130,53 @@ async function main() {
107
130
  printPlugins(pluginPreferences, options);
108
131
  return;
109
132
  }
110
- const progress = new Progress(options.dryRun ? 2 : options.install ? 12 : 11);
111
133
 
112
134
  if (options.dryRun) {
113
135
  console.log("vmpush dry run");
114
136
  }
115
137
 
138
+ if (options.skipSync) {
139
+ const progress = new Progress(3);
140
+ progress.step("Reading sync cache");
141
+ const cache = await readCache(options.cachePath, options);
142
+
143
+ if (!options.vmId && !cache.snapshotId) {
144
+ 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.");
145
+ }
146
+
147
+ const source = options.vmId ? `existing VM ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : "a new VM";
148
+ console.log(`Skipping sync: creating VM from ${source}`);
149
+ console.log(`Remote project: ${options.remoteProjectDir}`);
150
+
151
+ if (options.dryRun) {
152
+ return;
153
+ }
154
+
155
+ progress.step("Preparing Freestyle VM");
156
+ const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
157
+ console.log(`Using VM: ${vmId}`);
158
+
159
+ progress.step(`Running post-sync plugins for ${vmId}`);
160
+ const contextCandidates = await discoverPluginContextCandidates(options);
161
+ const postSyncMessages = await runAfterSyncPlugins(vm, vmId, options, contextCandidates);
162
+
163
+ console.log("");
164
+ console.log(`VM ready: ${vmId}`);
165
+ console.log(`Project: ${options.remoteProjectDir}`);
166
+ if (cache.snapshotId) {
167
+ console.log(`Snapshot cache: ${cache.snapshotId}`);
168
+ }
169
+ for (const message of postSyncMessages) console.log(message);
170
+ console.log(`SSH: npx freestyle vm ssh ${vmId}`);
171
+ if (options.autoSsh) {
172
+ const connected = await runConnectPlugins(vm, vmId, options, contextCandidates);
173
+ if (!connected) await sshIntoVm(vmId);
174
+ }
175
+ return;
176
+ }
177
+
178
+ const progress = new Progress(options.dryRun ? 2 : options.install ? 12 : 11);
179
+
116
180
  progress.step("Scanning project files");
117
181
  const cache = await readCache(options.cachePath, options);
118
182
  const base = cacheBaseForSync(options, cache);
@@ -132,6 +196,7 @@ async function main() {
132
196
  printPlan(options, projectChanges, contextChanges, envExports, cache);
133
197
 
134
198
  if (options.dryRun) {
199
+ progress.finish();
135
200
  return;
136
201
  }
137
202
 
@@ -162,59 +227,69 @@ async function main() {
162
227
  await runInstall(vm, options.projectRoot, options.remoteProjectDir);
163
228
  }
164
229
 
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, {
230
+ const buildSyncCache = (snapshotOverride?: { snapshotId: string }): CacheFile => ({
187
231
  version: CACHE_VERSION,
188
232
  projectRoot: options.projectRoot,
189
233
  remoteProjectDir: options.remoteProjectDir,
190
234
  vmId,
191
- snapshotId,
235
+ snapshotId: snapshotOverride ? snapshotOverride.snapshotId : cache.snapshotId,
192
236
  projectFiles: projectCurrent,
193
237
  contextFiles: contextCurrent,
194
- snapshotProjectFiles,
195
- snapshotContextFiles,
238
+ snapshotProjectFiles: snapshotOverride ? projectCurrent : cache.snapshotProjectFiles,
239
+ snapshotContextFiles: snapshotOverride ? contextCurrent : cache.snapshotContextFiles,
196
240
  envHash,
197
- snapshotEnvHash,
241
+ snapshotEnvHash: snapshotOverride ? envHash : cache.snapshotEnvHash,
198
242
  updatedAt: new Date().toISOString(),
199
243
  });
200
244
 
245
+ progress.step("Saving local sync cache");
246
+ await writeCache(options.cachePath, buildSyncCache());
247
+
248
+ let snapshotPromise: Promise<string | null> | null = null;
249
+ if (options.snapshot) {
250
+ progress.step(`Creating snapshot cache for ${vmId} in background`);
251
+ snapshotPromise = (async () => {
252
+ await runBeforeSnapshotPlugins(vm, vmId, options);
253
+ try {
254
+ const snapshot = await vm.snapshot({ name: `vmpush-${path.basename(options.projectRoot)}-${Date.now()}` });
255
+ return snapshot.snapshotId;
256
+ } catch (error) {
257
+ console.warn(`Snapshot cache skipped: ${error instanceof Error ? error.message : String(error)}`);
258
+ return null;
259
+ }
260
+ })();
261
+ } else {
262
+ progress.step("Skipping snapshot cache");
263
+ }
264
+
201
265
  progress.step(`Running post-sync plugins for ${vmId}`);
202
266
  const postSyncMessages = await runAfterSyncPlugins(vm, vmId, options, contextCandidates);
267
+ progress.finish();
203
268
 
204
269
  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}`);
270
+ console.log(`${success(symbol("✓", "*"))} ${bold(`VM ready: ${vmId}`)}`);
271
+ console.log(`${dim("Remote project:")} ${options.remoteProjectDir}`);
272
+ console.log(`${dim("Project files:")} ${projectResult.uploaded} uploaded, ${projectResult.removed} removed, ${projectResult.unchanged} unchanged`);
273
+ console.log(`${dim("Context files:")} ${contextResult.uploaded} uploaded, ${contextResult.unchanged} unchanged`);
274
+ if (snapshotPromise) {
275
+ console.log(`${dim("Snapshot cache:")} creating in background`);
276
+ } else if (cache.snapshotId) {
277
+ console.log(`${dim("Snapshot cache:")} ${cache.snapshotId}`);
211
278
  }
212
279
  for (const message of postSyncMessages) console.log(message);
213
- console.log(`SSH: npx freestyle vm ssh ${vmId}`);
280
+ console.log(`${accent(symbol("➜", ">"))} ${bold(`SSH: npx freestyle vm ssh ${vmId}`)}`);
214
281
  if (options.autoSsh) {
215
282
  const connected = await runConnectPlugins(vm, vmId, options, contextCandidates);
216
283
  if (!connected) await sshIntoVm(vmId);
217
284
  }
285
+
286
+ if (snapshotPromise) {
287
+ const newSnapshotId = await snapshotPromise;
288
+ if (newSnapshotId) {
289
+ await writeCache(options.cachePath, buildSyncCache({ snapshotId: newSnapshotId }));
290
+ console.log(`Snapshot cache saved: ${newSnapshotId}`);
291
+ }
292
+ }
218
293
  }
219
294
 
220
295
  function isDirectCliExecution() {
@@ -298,6 +373,7 @@ async function parseArgs(args: string[]): Promise<CliOptions> {
298
373
  includeGitDir: true,
299
374
  includeAllCopilotWorkspaces: false,
300
375
  snapshot: true,
376
+ skipSync: false,
301
377
  install: false,
302
378
  autoSsh: true,
303
379
  envKeys: [],
@@ -331,6 +407,8 @@ async function parseArgs(args: string[]): Promise<CliOptions> {
331
407
  options.includeAllCopilotWorkspaces = true;
332
408
  } else if (arg === "--no-snapshot" || arg === "--skip-snapshot") {
333
409
  options.snapshot = false;
410
+ } else if (arg === "--skip-sync") {
411
+ options.skipSync = true;
334
412
  } else if (arg === "--install") {
335
413
  options.install = true;
336
414
  } else if (arg === "--no-ssh") {
@@ -400,6 +478,8 @@ Options:
400
478
  --all-copilot-workspaces Include Copilot chat state for every VS Code workspace.
401
479
  --no-snapshot, --skip-snapshot
402
480
  Do not snapshot the VM after sync.
481
+ --skip-sync Create a VM from the last cached snapshot without syncing current
482
+ files. Requires a prior sync with snapshotting enabled, or --vm-id.
403
483
  --dry-run Show what would sync without creating or changing a VM.
404
484
  -y, --yes Deprecated; accepted for compatibility.
405
485
  -h, --help Show this help.
@@ -750,20 +830,62 @@ function printPlan(
750
830
  cache: CacheFile,
751
831
  ) {
752
832
  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`);
833
+ console.log("");
834
+ console.log(`${bold("Sync plan")}`);
835
+ console.log(`${dim(" Source:")} ${source}`);
836
+ console.log(`${dim(" Local:")} ${options.projectRoot}`);
837
+ console.log(`${dim(" Remote:")} ${options.remoteProjectDir}`);
838
+ console.log(`${dim(" Project files:")} ${projectChanges.changed.length} changed, ${projectChanges.removed.length} removed, ${projectChanges.unchanged} unchanged`);
839
+ console.log(`${dim(" Context files:")} ${contextChanges.changed.length} changed, ${contextChanges.removed.length} removed, ${contextChanges.unchanged} unchanged`);
840
+ console.log(`${dim(" Estimated upload:")} ${formatBytes(totalEntrySize(projectChanges.changed))} project, ${formatBytes(totalEntrySize(contextChanges.changed))} context`);
758
841
  if (Object.keys(envExports).length > 0) {
759
- console.log(`Environment exports: ${Object.keys(envExports).length}`);
842
+ console.log(`${dim(" Environment exports:")} ${Object.keys(envExports).length}`);
760
843
  }
844
+ console.log("");
761
845
  }
762
846
 
763
847
  function totalEntrySize(entries: LocalEntry[]) {
764
848
  return entries.reduce((total, entry) => total + entry.size, 0);
765
849
  }
766
850
 
851
+ function printHeading(name: string) {
852
+ console.log(`${bold(name)} ${dim(`${symbol("→", "-")} Freestyle sync`)}`);
853
+ console.log("");
854
+ }
855
+
856
+ function formatDuration(milliseconds: number) {
857
+ if (milliseconds < MS_PER_SECOND) {
858
+ const rounded = Math.round(milliseconds);
859
+ return `${rounded === 0 && milliseconds > 0 ? 1 : rounded}ms`;
860
+ }
861
+ return `${(milliseconds / MS_PER_SECOND).toFixed(1)}s`;
862
+ }
863
+
864
+ function symbol(unicode: string, ascii: string) {
865
+ return USE_UNICODE_OUTPUT ? unicode : ascii;
866
+ }
867
+
868
+ function accent(text: string) {
869
+ return color(36, text);
870
+ }
871
+
872
+ function success(text: string) {
873
+ return color(32, text);
874
+ }
875
+
876
+ function dim(text: string) {
877
+ return color(2, text);
878
+ }
879
+
880
+ function bold(text: string) {
881
+ return color(1, text);
882
+ }
883
+
884
+ function color(code: number, text: string) {
885
+ if (!USE_STYLED_OUTPUT) return text;
886
+ return `\u001b[${code}m${text}\u001b[0m`;
887
+ }
888
+
767
889
  async function getOrCreateVm(options: CliOptions, snapshotId?: string) {
768
890
  if (options.vmId) {
769
891
  const { vm } = await freestyle.vms.get({ vmId: options.vmId });
@@ -1049,7 +1171,7 @@ async function runBeforeOpenRemoteEditorPlugins(
1049
1171
  vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
1050
1172
  vmId: string,
1051
1173
  options: CliOptions,
1052
- scheme: "vscode" | "cursor",
1174
+ scheme: string,
1053
1175
  remoteWorkspaceUri: string,
1054
1176
  contextCandidates: ContextCandidate[],
1055
1177
  ) {
@@ -1073,7 +1195,7 @@ async function runAfterOpenRemoteEditorPlugins(
1073
1195
  vm: Awaited<ReturnType<typeof getOrCreateVm>>["vm"],
1074
1196
  vmId: string,
1075
1197
  options: CliOptions,
1076
- scheme: "vscode" | "cursor",
1198
+ scheme: string,
1077
1199
  remoteWorkspaceUri: string,
1078
1200
  contextCandidates: ContextCandidate[],
1079
1201
  ) {
package/src/plugin-api.ts CHANGED
@@ -16,6 +16,7 @@ export type CliOptions = {
16
16
  includeGitDir: boolean;
17
17
  includeAllCopilotWorkspaces: boolean;
18
18
  snapshot: boolean;
19
+ skipSync: boolean;
19
20
  install: boolean;
20
21
  autoSsh: boolean;
21
22
  envKeys: string[];
@@ -68,15 +69,17 @@ export type RemoteHookContext = PushvmPluginContext & {
68
69
  vmId: string;
69
70
  };
70
71
 
72
+ export type EditorScheme = string;
73
+
71
74
  export type EditorHookContext = RemoteHookContext & {
72
- scheme: "vscode" | "cursor";
75
+ scheme: EditorScheme;
73
76
  remoteWorkspaceUri: string;
74
77
  };
75
78
 
76
79
  export type ConnectHookContext = RemoteHookContext & {
77
80
  contextCandidates: ContextCandidate[];
78
- runBeforeOpenRemoteEditor(args: { scheme: "vscode" | "cursor"; remoteWorkspaceUri: string }): Promise<string[]>;
79
- runAfterOpenRemoteEditor(args: { scheme: "vscode" | "cursor"; remoteWorkspaceUri: string }): Promise<string[]>;
81
+ runBeforeOpenRemoteEditor(args: { scheme: EditorScheme; remoteWorkspaceUri: string }): Promise<string[]>;
82
+ runAfterOpenRemoteEditor(args: { scheme: EditorScheme; remoteWorkspaceUri: string }): Promise<string[]>;
80
83
  };
81
84
 
82
85
  export type PushvmPlugin = {