freestyle-sync 0.1.3 → 0.1.5

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