freestyle-sync 0.1.4 → 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.
package/README.md CHANGED
@@ -6,8 +6,35 @@ Sync your current directory, it's dependencies, and your agent context into a VM
6
6
  npx freestyle-sync
7
7
  ```
8
8
 
9
- Configure complex sync behavior using plugins.
9
+ Use the SDK when embedding sync inside another CLI/app (no required `freestyle-sync.config.ts` or cache file):
10
+
10
11
  ```ts
12
+ import { defineConfig, sync, type SyncCache } from "freestyle-sync";
13
+ import { gitAuthPlugin } from "@freestyle-sync/auth-git";
14
+ import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
15
+
16
+ let cache: SyncCache | undefined;
17
+
18
+ const result = await sync({
19
+ config: defineConfig({
20
+ plugins: [gitAuthPlugin(), nodeNpmPlugin()],
21
+ }),
22
+ options: {
23
+ projectRoot: process.cwd(),
24
+ autoSsh: false,
25
+ },
26
+ cache,
27
+ onCacheUpdate(nextCache) {
28
+ cache = nextCache;
29
+ },
30
+ });
31
+
32
+ console.log("synced VM", result.vmId);
33
+ ```
34
+
35
+ freestyle-sync creates `freestyle-sync.config.mjs` on first run and installs the config dependencies into your project as dev dependencies. Remove the generated config or dependencies if you want to reset it later.
36
+
37
+ ```js
11
38
  import { defineConfig } from "freestyle-sync";
12
39
  import { claudeAgentPlugin } from "@freestyle-sync/agent-claude";
13
40
  import { codexAgentPlugin } from "@freestyle-sync/agent-codex";
@@ -44,5 +71,4 @@ export default defineConfig({
44
71
  shellHistoryPlugin(),
45
72
  ],
46
73
  });
47
-
48
74
  ```
package/dist/src/main.js CHANGED
@@ -19,14 +19,31 @@ import { promisify } from "node:util";
19
19
  import { freestyle } from "freestyle";
20
20
  export * from "./plugin-api.js";
21
21
  const execFileAsync = promisify(execFile);
22
+ const CLI_NAME = "freestyle-sync";
22
23
  const CACHE_VERSION = 1;
23
24
  const PLUGIN_PREFERENCES_VERSION = 1;
24
25
  const ARCHIVE_CHUNK_CHARS = 1024 * 1024;
25
26
  const MS_PER_SECOND = 1000;
26
27
  const USE_UNICODE_OUTPUT = process.stdout.isTTY && (process.env.TERM !== "dumb" || Boolean(process.env.TERM_PROGRAM));
27
28
  const USE_STYLED_OUTPUT = process.stdout.isTTY && process.env.NO_COLOR !== "1";
28
- let config = { plugins: [] };
29
- let plugins = config.plugins;
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
+ ];
30
47
  const pluginUtils = {
31
48
  checkedExec,
32
49
  createTar,
@@ -36,6 +53,8 @@ const pluginUtils = {
36
53
  md5,
37
54
  delay,
38
55
  };
56
+ let config = { plugins: [] };
57
+ let plugins = config.plugins;
39
58
  class Progress {
40
59
  current = 0;
41
60
  total;
@@ -68,39 +87,66 @@ class Progress {
68
87
  }
69
88
  if (isDirectCliExecution()) {
70
89
  main().catch((error) => {
71
- console.error(`vmpush: ${error instanceof Error ? error.message : String(error)}`);
90
+ console.error(`${CLI_NAME}: ${error instanceof Error ? error.message : String(error)}`);
72
91
  process.exitCode = 1;
73
92
  });
74
93
  }
75
94
  async function main() {
76
95
  const options = await parseArgs(process.argv.slice(2));
77
- printHeading("vmpush");
78
- 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;
79
106
  plugins = config.plugins;
80
- const pluginPreferences = await updatePluginPreferences(options);
107
+ const pluginPreferences = await updatePluginPreferences(options, sdkOptions.pluginPreferences);
81
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
+ };
82
121
  if (options.listPlugins) {
83
122
  printPlugins(pluginPreferences, options);
84
- return;
123
+ return {
124
+ cache: currentCache,
125
+ snapshotId: currentCache.snapshotId,
126
+ };
85
127
  }
86
128
  if (options.dryRun) {
87
- console.log("vmpush dry run");
129
+ console.log(`${CLI_NAME} dry run`);
88
130
  }
89
131
  if (options.skipSync) {
90
132
  const progress = new Progress(3);
91
133
  progress.step("Reading sync cache");
92
- const cache = await readCache(options.cachePath, options);
134
+ const cache = currentCache;
93
135
  if (!options.vmId && !cache.snapshotId) {
94
- console.warn("vmpush --skip-sync: no cached snapshot found. A new empty VM will be created. Run without --skip-sync first to create a snapshot.");
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.`);
95
137
  }
96
138
  const source = options.vmId ? `existing VM ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : "a new VM";
97
139
  console.log(`Skipping sync: creating VM from ${source}`);
98
140
  console.log(`Remote project: ${options.remoteProjectDir}`);
99
141
  if (options.dryRun) {
100
- return;
142
+ return {
143
+ cache: currentCache,
144
+ snapshotId: currentCache.snapshotId,
145
+ };
101
146
  }
102
147
  progress.step("Preparing Freestyle VM");
103
148
  const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
149
+ resultVmId = vmId;
104
150
  console.log(`Using VM: ${vmId}`);
105
151
  progress.step(`Running post-sync plugins for ${vmId}`);
106
152
  const contextCandidates = await discoverPluginContextCandidates(options);
@@ -119,11 +165,15 @@ async function main() {
119
165
  if (!connected)
120
166
  await sshIntoVm(vmId);
121
167
  }
122
- return;
168
+ return {
169
+ vmId,
170
+ cache: currentCache,
171
+ snapshotId: currentCache.snapshotId,
172
+ };
123
173
  }
124
174
  const progress = new Progress(options.dryRun ? 2 : options.install ? 12 : 11);
125
175
  progress.step("Scanning project files");
126
- const cache = await readCache(options.cachePath, options);
176
+ const cache = currentCache;
127
177
  const base = cacheBaseForSync(options, cache);
128
178
  const projectEntries = await scanProject(options.projectRoot, options.includeGitDir);
129
179
  const projectCurrent = digestMap(projectEntries);
@@ -138,10 +188,14 @@ async function main() {
138
188
  printPlan(options, projectChanges, contextChanges, envExports, cache);
139
189
  if (options.dryRun) {
140
190
  progress.finish();
141
- return;
191
+ return {
192
+ cache: currentCache,
193
+ snapshotId: currentCache.snapshotId,
194
+ };
142
195
  }
143
196
  progress.step("Preparing Freestyle VM");
144
197
  const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
198
+ resultVmId = vmId;
145
199
  console.log(`Uploading to VM: ${vmId}`);
146
200
  progress.step(`Uploading project changes to ${vmId}`);
147
201
  await ensureRemoteBase(vm, options.remoteProjectDir);
@@ -175,14 +229,14 @@ async function main() {
175
229
  updatedAt: new Date().toISOString(),
176
230
  });
177
231
  progress.step("Saving local sync cache");
178
- await writeCache(options.cachePath, buildSyncCache());
232
+ await saveCache(buildSyncCache());
179
233
  let snapshotPromise = null;
180
234
  if (options.snapshot) {
181
235
  progress.step(`Creating snapshot cache for ${vmId} in background`);
182
236
  snapshotPromise = (async () => {
183
237
  await runBeforeSnapshotPlugins(vm, vmId, options);
184
238
  try {
185
- 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()}` });
186
240
  return snapshot.snapshotId;
187
241
  }
188
242
  catch (error) {
@@ -219,10 +273,15 @@ async function main() {
219
273
  if (snapshotPromise) {
220
274
  const newSnapshotId = await snapshotPromise;
221
275
  if (newSnapshotId) {
222
- await writeCache(options.cachePath, buildSyncCache({ snapshotId: newSnapshotId }));
276
+ await saveCache(buildSyncCache({ snapshotId: newSnapshotId }));
223
277
  console.log(`Snapshot cache saved: ${newSnapshotId}`);
224
278
  }
225
279
  }
280
+ return {
281
+ vmId: resultVmId,
282
+ cache: currentCache,
283
+ snapshotId: currentCache.snapshotId,
284
+ };
226
285
  }
227
286
  function isDirectCliExecution() {
228
287
  if (!process.argv[1])
@@ -235,11 +294,7 @@ function isDirectCliExecution() {
235
294
  }
236
295
  }
237
296
  async function loadConfig(projectRoot) {
238
- const configPath = path.join(projectRoot, "freestyle-sync.config.ts");
239
- if (!(await exists(configPath))) {
240
- await writeFile(configPath, renderDefaultConfig(), "utf8");
241
- console.log(`Created ${path.relative(process.cwd(), configPath) || path.basename(configPath)}`);
242
- }
297
+ const configPath = await resolveConfigPath(projectRoot);
243
298
  const imported = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
244
299
  const loaded = imported.default;
245
300
  if (!loaded || !Array.isArray(loaded.plugins)) {
@@ -247,6 +302,90 @@ async function loadConfig(projectRoot) {
247
302
  }
248
303
  return loaded;
249
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";
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
+ }
250
389
  function renderDefaultConfig() {
251
390
  return `import { claudeAgentPlugin } from "@freestyle-sync/agent-claude";
252
391
  import { codexAgentPlugin } from "@freestyle-sync/agent-codex";
@@ -286,28 +425,43 @@ export default defineConfig({
286
425
  });
287
426
  `;
288
427
  }
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
+ }
289
463
  async function parseArgs(args) {
290
- const options = {
291
- projectRoot: process.cwd(),
292
- cachePath: "",
293
- remoteProjectDir: "",
294
- name: "",
295
- yes: false,
296
- dryRun: false,
297
- disablePlugins: [],
298
- enablePlugins: [],
299
- resetPluginPrefs: false,
300
- listPlugins: false,
301
- includeAuth: true,
302
- includeAgentContext: true,
303
- includeGitDir: true,
304
- includeAllCopilotWorkspaces: false,
305
- snapshot: true,
306
- skipSync: false,
307
- install: false,
308
- autoSsh: true,
309
- envKeys: [],
310
- };
464
+ const options = defaultCliOptions();
311
465
  const positional = [];
312
466
  for (let index = 0; index < args.length; index += 1) {
313
467
  const arg = args[index];
@@ -389,25 +543,65 @@ async function parseArgs(args) {
389
543
  throw new Error("expected at most one project path");
390
544
  }
391
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) {
392
582
  const projectStats = await stat(options.projectRoot).catch(() => null);
393
583
  if (!projectStats?.isDirectory()) {
394
584
  throw new Error(`project path is not a directory: ${options.projectRoot}`);
395
585
  }
396
586
  const projectName = sanitizeName(path.basename(options.projectRoot));
397
- options.name ||= `vmpush-${projectName}`;
398
- options.remoteProjectDir ||= defaultRemoteProjectDir(options.projectRoot);
399
- options.cachePath ||= path.join(options.projectRoot, ".freestyle-sync", "cache.json");
400
- 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
+ };
401
595
  }
402
596
  function printHelp() {
403
- console.log(`vmpush uploads the current project into a Freestyle VM.
597
+ console.log(`${CLI_NAME} uploads the current project into a Freestyle VM.
404
598
 
405
599
  Usage:
406
- vmpush [project-dir] [options]
600
+ ${CLI_NAME} [project-dir] [options]
407
601
 
408
602
  Options:
409
603
  --vm-id <id> Sync into an existing Freestyle VM.
410
- --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>.
411
605
  --remote-dir <path> Remote project directory. Defaults to the local absolute path.
412
606
  --cache <path> Snapshot/hash cache path. Defaults to .freestyle-sync/cache.json.
413
607
  --include-env <name> Always copy an environment variable. Repeatable.
@@ -464,6 +658,18 @@ async function readCache(cachePath, options) {
464
658
  contextFiles: {},
465
659
  };
466
660
  }
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
+ }
467
673
  function cacheBaseForSync(options, cache) {
468
674
  if (options.vmId && options.vmId === cache.vmId) {
469
675
  return {
@@ -632,10 +838,13 @@ async function scanContextCandidates(candidates) {
632
838
  }
633
839
  return entries.sort((left, right) => left.remotePath.localeCompare(right.remotePath));
634
840
  }
635
- async function updatePluginPreferences(options) {
841
+ async function updatePluginPreferences(options, providedPreferences) {
636
842
  const preferencesPath = getPluginPreferencesPath(options);
637
- const preferences = options.resetPluginPrefs ? emptyPluginPreferences() : await readPluginPreferences(preferencesPath);
638
- let changed = options.resetPluginPrefs;
843
+ const usingProvidedPreferences = Boolean(providedPreferences);
844
+ const preferences = options.resetPluginPrefs
845
+ ? emptyPluginPreferences()
846
+ : normalizePluginPreferences(providedPreferences ?? await readPluginPreferences(preferencesPath));
847
+ let changed = options.resetPluginPrefs && !usingProvidedPreferences;
639
848
  const disabledPlugins = new Set();
640
849
  for (const savedName of preferences.disabledPlugins) {
641
850
  const canonicalName = maybeResolvePluginSelector(savedName);
@@ -656,7 +865,7 @@ async function updatePluginPreferences(options) {
656
865
  }
657
866
  }
658
867
  const next = { version: PLUGIN_PREFERENCES_VERSION, disabledPlugins: [...disabledPlugins].sort(), updatedAt: preferences.updatedAt };
659
- if (changed)
868
+ if (changed && !usingProvidedPreferences)
660
869
  await writePluginPreferences(preferencesPath, next);
661
870
  return next;
662
871
  }
@@ -719,6 +928,13 @@ function pluginSelectorAliases(name) {
719
928
  function emptyPluginPreferences() {
720
929
  return { version: PLUGIN_PREFERENCES_VERSION, disabledPlugins: [] };
721
930
  }
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
+ }
722
938
  function getPluginPreferencesPath(options) {
723
939
  return path.join(path.dirname(options.cachePath), "plugin-preferences.json");
724
940
  }
@@ -726,11 +942,7 @@ async function readPluginPreferences(preferencesPath) {
726
942
  try {
727
943
  const parsed = JSON.parse(await readFile(preferencesPath, "utf8"));
728
944
  if (parsed.version === PLUGIN_PREFERENCES_VERSION) {
729
- return {
730
- version: PLUGIN_PREFERENCES_VERSION,
731
- disabledPlugins: Array.isArray(parsed.disabledPlugins) ? parsed.disabledPlugins.filter((name) => typeof name === "string") : [],
732
- updatedAt: parsed.updatedAt,
733
- };
945
+ return normalizePluginPreferences(parsed);
734
946
  }
735
947
  }
736
948
  catch (error) {
@@ -815,8 +1027,8 @@ async function syncProject(vm, vmId, options, changes) {
815
1027
  console.log(`VM ${vmId}: uploading ${changes.changed.length} changed project files...`);
816
1028
  const archive = await createProjectArchive(options.projectRoot, changes.changed);
817
1029
  try {
818
- await uploadArchiveInChunks(vm, vmId, archive, "/tmp/vmpush-project.tgz", "project");
819
- await checkedExec(vm, `mkdir -p ${shellQuote(options.remoteProjectDir)} && tar --no-same-owner --no-same-permissions -xzf /tmp/vmpush-project.tgz -C ${shellQuote(options.remoteProjectDir)} && rm -f /tmp/vmpush-project.tgz`);
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`);
820
1032
  }
821
1033
  finally {
822
1034
  await rm(path.dirname(archive), { recursive: true, force: true });
@@ -825,8 +1037,8 @@ async function syncProject(vm, vmId, options, changes) {
825
1037
  if (changes.removed.length > 0) {
826
1038
  console.log(`VM ${vmId}: removing ${changes.removed.length} deleted project files...`);
827
1039
  const removeList = Buffer.from(changes.removed.join("\0") + "\0");
828
- await vm.fs.writeFile("/tmp/vmpush-remove-list", removeList);
829
- 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`);
830
1042
  }
831
1043
  return {
832
1044
  uploaded: changes.changed.length,
@@ -885,8 +1097,8 @@ async function syncContext(vm, vmId, changes) {
885
1097
  console.log(`VM ${vmId}: uploading ${changes.changed.length} changed auth/context files...`);
886
1098
  const archive = await createContextArchive(changes.changed);
887
1099
  try {
888
- await uploadArchiveInChunks(vm, vmId, archive, "/tmp/vmpush-context.tgz", "context");
889
- await checkedExec(vm, "tar --no-same-owner --no-same-permissions -xzf /tmp/vmpush-context.tgz -C / && rm -f /tmp/vmpush-context.tgz");
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");
890
1102
  }
891
1103
  finally {
892
1104
  await rm(path.dirname(archive), { recursive: true, force: true });
@@ -954,7 +1166,7 @@ async function detectInstallCommand(projectRoot) {
954
1166
  return undefined;
955
1167
  }
956
1168
  async function createProjectArchive(projectRoot, entries) {
957
- const tempDir = await mkdtemp(path.join(tmpdir(), "vmpush-"));
1169
+ const tempDir = await mkdtemp(path.join(tmpdir(), "freestyle-sync-"));
958
1170
  const listPath = path.join(tempDir, "files.list");
959
1171
  const archivePath = path.join(tempDir, "project.tgz");
960
1172
  await writeFile(listPath, Buffer.from(entries.map((entry) => entry.relativePath).join("\0") + "\0"));
@@ -962,7 +1174,7 @@ async function createProjectArchive(projectRoot, entries) {
962
1174
  return archivePath;
963
1175
  }
964
1176
  async function createContextArchive(entries) {
965
- const tempDir = await mkdtemp(path.join(tmpdir(), "vmpush-context-"));
1177
+ const tempDir = await mkdtemp(path.join(tmpdir(), "freestyle-sync-context-"));
966
1178
  const stagingDir = path.join(tempDir, "staging");
967
1179
  const archivePath = path.join(tempDir, "context.tgz");
968
1180
  await mkdir(stagingDir, { recursive: true });
@@ -987,17 +1199,29 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
987
1199
  const archive = await readFile(archivePath);
988
1200
  const encoded = archive.toString("base64");
989
1201
  const chunkCount = Math.max(1, Math.ceil(encoded.length / ARCHIVE_CHUNK_CHARS));
990
- const chunkDir = `/tmp/vmpush-${label}-${Date.now()}.chunks`;
1202
+ const chunkDir = `/tmp/freestyle-sync-${label}-${Date.now()}.chunks`;
991
1203
  console.log(`VM ${vmId}: streaming ${formatBytes(archive.length)} ${label} archive in ${chunkCount} chunk${chunkCount === 1 ? "" : "s"}...`);
992
1204
  await checkedExec(vm, `rm -rf ${shellQuote(chunkDir)} && mkdir -p ${shellQuote(chunkDir)}`);
993
1205
  const width = String(chunkCount - 1).length;
1206
+ const canRenderInlineProgress = process.stdout.isTTY;
1207
+ const logEvery = Math.max(1, Math.ceil(chunkCount / 4));
994
1208
  for (let index = 0; index < chunkCount; index += 1) {
995
1209
  const start = index * ARCHIVE_CHUNK_CHARS;
996
1210
  const chunk = encoded.slice(start, start + ARCHIVE_CHUNK_CHARS);
997
1211
  const chunkName = `${String(index).padStart(width, "0")}.b64`;
998
1212
  await vm.fs.writeTextFile(`${chunkDir}/${chunkName}`, chunk);
999
- if (chunkCount > 1 && ((index + 1) % 10 === 0 || index + 1 === chunkCount)) {
1000
- 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
+ }
1001
1225
  }
1002
1226
  }
1003
1227
  await checkedExec(vm, `cat ${shellQuote(chunkDir)}/*.b64 | base64 -d > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`);
@@ -1095,7 +1319,7 @@ function md5(value) {
1095
1319
  return createHash("md5").update(value).digest("hex");
1096
1320
  }
1097
1321
  function renderEnvFile(envExports) {
1098
- const lines = ["# Generated by vmpush. Contains local auth tokens."];
1322
+ const lines = [`# Generated by ${CLI_NAME}. Contains local auth tokens.`];
1099
1323
  for (const key of Object.keys(envExports).sort()) {
1100
1324
  lines.push(`export ${key}=${shellQuote(envExports[key])}`);
1101
1325
  }
@@ -1115,7 +1339,28 @@ function defaultRemoteProjectDir(projectRoot) {
1115
1339
  return normalizeRemotePath(toPosix(projectRoot));
1116
1340
  }
1117
1341
  function sanitizeName(value) {
1118
- return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
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";
1119
1364
  }
1120
1365
  function toPosix(value) {
1121
1366
  return value.split(path.sep).join(path.posix.sep);