freestyle-sync 0.1.4 → 0.1.7

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/dist/src/main.js CHANGED
@@ -11,22 +11,44 @@ import "dotenv/config";
11
11
  import { createHash } from "node:crypto";
12
12
  import { createReadStream, realpathSync } from "node:fs";
13
13
  import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
14
- import { tmpdir } from "node:os";
14
+ import { homedir, tmpdir } from "node:os";
15
15
  import path from "node:path";
16
16
  import { fileURLToPath, pathToFileURL } from "node:url";
17
17
  import { execFile, spawn } from "node:child_process";
18
18
  import { promisify } from "node:util";
19
- import { freestyle } from "freestyle";
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;
27
+ const DEFAULT_FREESTYLE_API_URL = "https://api.freestyle.sh";
28
+ const DEFAULT_STACK_API_URL = "https://api.stack-auth.com";
29
+ const DEFAULT_STACK_PROJECT_ID = "0edf478c-f123-46fb-818f-34c0024a9f35";
30
+ const DEFAULT_STACK_PUBLISHABLE_CLIENT_KEY = "pck_h2aft7g9pqjzrkdnzs199h1may5wjtdtdxeex7m2wzp1r";
31
+ const STACK_REFRESH_TOKEN_ENV_KEY = "FREESTYLE_STACK_REFRESH_TOKEN";
26
32
  const USE_UNICODE_OUTPUT = process.stdout.isTTY && (process.env.TERM !== "dumb" || Boolean(process.env.TERM_PROGRAM));
27
33
  const USE_STYLED_OUTPUT = process.stdout.isTTY && process.env.NO_COLOR !== "1";
28
- let config = { plugins: [] };
29
- let plugins = config.plugins;
34
+ const DEFAULT_CONFIG_DEPENDENCIES = [
35
+ { name: "freestyle-sync", spec: "freestyle-sync@latest" },
36
+ { name: "@freestyle-sync/agent-claude", spec: "@freestyle-sync/agent-claude@latest" },
37
+ { name: "@freestyle-sync/agent-codex", spec: "@freestyle-sync/agent-codex@latest" },
38
+ { name: "@freestyle-sync/agent-copilot", spec: "@freestyle-sync/agent-copilot@latest" },
39
+ { name: "@freestyle-sync/auth-aws", spec: "@freestyle-sync/auth-aws@latest" },
40
+ { name: "@freestyle-sync/auth-azure", spec: "@freestyle-sync/auth-azure@latest" },
41
+ { name: "@freestyle-sync/auth-docker", spec: "@freestyle-sync/auth-docker@latest" },
42
+ { name: "@freestyle-sync/auth-env", spec: "@freestyle-sync/auth-env@latest" },
43
+ { name: "@freestyle-sync/auth-gcloud", spec: "@freestyle-sync/auth-gcloud@latest" },
44
+ { name: "@freestyle-sync/auth-git", spec: "@freestyle-sync/auth-git@latest" },
45
+ { name: "@freestyle-sync/auth-github-cli", spec: "@freestyle-sync/auth-github-cli@latest" },
46
+ { name: "@freestyle-sync/auth-npm", spec: "@freestyle-sync/auth-npm@latest" },
47
+ { name: "@freestyle-sync/auth-ssh", spec: "@freestyle-sync/auth-ssh@latest" },
48
+ { name: "@freestyle-sync/auth-yarn", spec: "@freestyle-sync/auth-yarn@latest" },
49
+ { name: "@freestyle-sync/node-npm", spec: "@freestyle-sync/node-npm@latest" },
50
+ { name: "@freestyle-sync/shell-history", spec: "@freestyle-sync/shell-history@latest" },
51
+ ];
30
52
  const pluginUtils = {
31
53
  checkedExec,
32
54
  createTar,
@@ -36,6 +58,8 @@ const pluginUtils = {
36
58
  md5,
37
59
  delay,
38
60
  };
61
+ let config = { plugins: [] };
62
+ let plugins = config.plugins;
39
63
  class Progress {
40
64
  current = 0;
41
65
  total;
@@ -68,39 +92,66 @@ class Progress {
68
92
  }
69
93
  if (isDirectCliExecution()) {
70
94
  main().catch((error) => {
71
- console.error(`vmpush: ${error instanceof Error ? error.message : String(error)}`);
95
+ console.error(`${CLI_NAME}: ${error instanceof Error ? error.message : String(error)}`);
72
96
  process.exitCode = 1;
73
97
  });
74
98
  }
75
99
  async function main() {
76
100
  const options = await parseArgs(process.argv.slice(2));
77
- printHeading("vmpush");
78
- config = await loadConfig(options.projectRoot);
101
+ const loadedConfig = await loadConfig(options.projectRoot);
102
+ await sync({
103
+ config: loadedConfig,
104
+ options,
105
+ });
106
+ }
107
+ export async function sync(sdkOptions) {
108
+ const options = await resolveCliOptions(sdkOptions.options);
109
+ printHeading(CLI_NAME);
110
+ config = sdkOptions.config;
79
111
  plugins = config.plugins;
80
- const pluginPreferences = await updatePluginPreferences(options);
112
+ const pluginPreferences = await updatePluginPreferences(options, sdkOptions.pluginPreferences);
81
113
  plugins = activePlugins(pluginPreferences, options);
114
+ let currentCache = sdkOptions.cache ? normalizeCache(sdkOptions.cache, options) : await readCache(options.cachePath, options);
115
+ let resultVmId;
116
+ const saveCache = async (nextCache) => {
117
+ currentCache = nextCache;
118
+ if (sdkOptions.onCacheUpdate) {
119
+ await sdkOptions.onCacheUpdate(nextCache);
120
+ return;
121
+ }
122
+ if (!sdkOptions.cache) {
123
+ await writeCache(options.cachePath, nextCache);
124
+ }
125
+ };
82
126
  if (options.listPlugins) {
83
127
  printPlugins(pluginPreferences, options);
84
- return;
128
+ return {
129
+ cache: currentCache,
130
+ snapshotId: currentCache.snapshotId,
131
+ };
85
132
  }
86
133
  if (options.dryRun) {
87
- console.log("vmpush dry run");
134
+ console.log(`${CLI_NAME} dry run`);
88
135
  }
89
136
  if (options.skipSync) {
90
137
  const progress = new Progress(3);
91
138
  progress.step("Reading sync cache");
92
- const cache = await readCache(options.cachePath, options);
139
+ const cache = currentCache;
93
140
  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.");
141
+ 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
142
  }
96
143
  const source = options.vmId ? `existing VM ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : "a new VM";
97
144
  console.log(`Skipping sync: creating VM from ${source}`);
98
145
  console.log(`Remote project: ${options.remoteProjectDir}`);
99
146
  if (options.dryRun) {
100
- return;
147
+ return {
148
+ cache: currentCache,
149
+ snapshotId: currentCache.snapshotId,
150
+ };
101
151
  }
102
152
  progress.step("Preparing Freestyle VM");
103
153
  const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
154
+ resultVmId = vmId;
104
155
  console.log(`Using VM: ${vmId}`);
105
156
  progress.step(`Running post-sync plugins for ${vmId}`);
106
157
  const contextCandidates = await discoverPluginContextCandidates(options);
@@ -119,11 +170,15 @@ async function main() {
119
170
  if (!connected)
120
171
  await sshIntoVm(vmId);
121
172
  }
122
- return;
173
+ return {
174
+ vmId,
175
+ cache: currentCache,
176
+ snapshotId: currentCache.snapshotId,
177
+ };
123
178
  }
124
179
  const progress = new Progress(options.dryRun ? 2 : options.install ? 12 : 11);
125
180
  progress.step("Scanning project files");
126
- const cache = await readCache(options.cachePath, options);
181
+ const cache = currentCache;
127
182
  const base = cacheBaseForSync(options, cache);
128
183
  const projectEntries = await scanProject(options.projectRoot, options.includeGitDir);
129
184
  const projectCurrent = digestMap(projectEntries);
@@ -138,10 +193,14 @@ async function main() {
138
193
  printPlan(options, projectChanges, contextChanges, envExports, cache);
139
194
  if (options.dryRun) {
140
195
  progress.finish();
141
- return;
196
+ return {
197
+ cache: currentCache,
198
+ snapshotId: currentCache.snapshotId,
199
+ };
142
200
  }
143
201
  progress.step("Preparing Freestyle VM");
144
202
  const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
203
+ resultVmId = vmId;
145
204
  console.log(`Uploading to VM: ${vmId}`);
146
205
  progress.step(`Uploading project changes to ${vmId}`);
147
206
  await ensureRemoteBase(vm, options.remoteProjectDir);
@@ -175,14 +234,14 @@ async function main() {
175
234
  updatedAt: new Date().toISOString(),
176
235
  });
177
236
  progress.step("Saving local sync cache");
178
- await writeCache(options.cachePath, buildSyncCache());
237
+ await saveCache(buildSyncCache());
179
238
  let snapshotPromise = null;
180
239
  if (options.snapshot) {
181
240
  progress.step(`Creating snapshot cache for ${vmId} in background`);
182
241
  snapshotPromise = (async () => {
183
242
  await runBeforeSnapshotPlugins(vm, vmId, options);
184
243
  try {
185
- const snapshot = await vm.snapshot({ name: `vmpush-${path.basename(options.projectRoot)}-${Date.now()}` });
244
+ const snapshot = await vm.snapshot({ name: `${CLI_NAME}-${path.basename(options.projectRoot)}-${Date.now()}` });
186
245
  return snapshot.snapshotId;
187
246
  }
188
247
  catch (error) {
@@ -219,10 +278,15 @@ async function main() {
219
278
  if (snapshotPromise) {
220
279
  const newSnapshotId = await snapshotPromise;
221
280
  if (newSnapshotId) {
222
- await writeCache(options.cachePath, buildSyncCache({ snapshotId: newSnapshotId }));
281
+ await saveCache(buildSyncCache({ snapshotId: newSnapshotId }));
223
282
  console.log(`Snapshot cache saved: ${newSnapshotId}`);
224
283
  }
225
284
  }
285
+ return {
286
+ vmId: resultVmId,
287
+ cache: currentCache,
288
+ snapshotId: currentCache.snapshotId,
289
+ };
226
290
  }
227
291
  function isDirectCliExecution() {
228
292
  if (!process.argv[1])
@@ -235,11 +299,7 @@ function isDirectCliExecution() {
235
299
  }
236
300
  }
237
301
  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
- }
302
+ const configPath = await resolveConfigPath(projectRoot);
243
303
  const imported = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
244
304
  const loaded = imported.default;
245
305
  if (!loaded || !Array.isArray(loaded.plugins)) {
@@ -247,6 +307,90 @@ async function loadConfig(projectRoot) {
247
307
  }
248
308
  return loaded;
249
309
  }
310
+ async function resolveConfigPath(projectRoot) {
311
+ const jsConfigPath = path.join(projectRoot, "freestyle-sync.config.mjs");
312
+ const tsConfigPath = path.join(projectRoot, "freestyle-sync.config.ts");
313
+ if (await exists(jsConfigPath)) {
314
+ if (await migrateBundledDefaultConfig(jsConfigPath)) {
315
+ await ensureDefaultConfigDependencies(projectRoot);
316
+ }
317
+ else if (await isDefaultGeneratedConfig(jsConfigPath)) {
318
+ await ensureDefaultConfigDependencies(projectRoot);
319
+ }
320
+ return jsConfigPath;
321
+ }
322
+ if (await exists(tsConfigPath)) {
323
+ if (await migrateLegacyDefaultConfig(tsConfigPath, jsConfigPath)) {
324
+ await ensureDefaultConfigDependencies(projectRoot);
325
+ return jsConfigPath;
326
+ }
327
+ return tsConfigPath;
328
+ }
329
+ await writeFile(jsConfigPath, renderDefaultConfig(), "utf8");
330
+ console.log(`Created ${path.relative(process.cwd(), jsConfigPath) || path.basename(jsConfigPath)}`);
331
+ await ensureDefaultConfigDependencies(projectRoot);
332
+ return jsConfigPath;
333
+ }
334
+ async function isDefaultGeneratedConfig(configPath) {
335
+ const contents = await readFile(configPath, "utf8").catch(() => "");
336
+ return contents.trim() === renderDefaultConfig().trim();
337
+ }
338
+ async function migrateBundledDefaultConfig(configPath) {
339
+ const contents = await readFile(configPath, "utf8").catch(() => "");
340
+ if (contents.trim() !== renderBundledDefaultConfig().trim())
341
+ return false;
342
+ await writeFile(configPath, renderDefaultConfig(), "utf8");
343
+ console.log(`Updated ${path.relative(process.cwd(), configPath) || path.basename(configPath)} to install plugin packages`);
344
+ return true;
345
+ }
346
+ async function migrateLegacyDefaultConfig(tsConfigPath, jsConfigPath) {
347
+ const contents = await readFile(tsConfigPath, "utf8").catch(() => "");
348
+ if (contents.trim() !== renderLegacyDefaultConfig().trim())
349
+ return false;
350
+ await writeFile(jsConfigPath, renderDefaultConfig(), "utf8");
351
+ await rm(tsConfigPath);
352
+ console.log(`Updated ${path.relative(process.cwd(), jsConfigPath) || path.basename(jsConfigPath)} to install plugin packages`);
353
+ return true;
354
+ }
355
+ function renderLegacyDefaultConfig() {
356
+ return `import { claudeAgentPlugin } from "@freestyle-sync/agent-claude";
357
+ import { codexAgentPlugin } from "@freestyle-sync/agent-codex";
358
+ import { copilotAgentPlugin } from "@freestyle-sync/agent-copilot";
359
+ import { awsAuthPlugin } from "@freestyle-sync/auth-aws";
360
+ import { azureAuthPlugin } from "@freestyle-sync/auth-azure";
361
+ import { dockerAuthPlugin } from "@freestyle-sync/auth-docker";
362
+ import { envAuthPlugin } from "@freestyle-sync/auth-env";
363
+ import { gcloudAuthPlugin } from "@freestyle-sync/auth-gcloud";
364
+ import { gitAuthPlugin } from "@freestyle-sync/auth-git";
365
+ import { githubCliAuthPlugin } from "@freestyle-sync/auth-github-cli";
366
+ import { npmAuthPlugin } from "@freestyle-sync/auth-npm";
367
+ import { sshAuthPlugin } from "@freestyle-sync/auth-ssh";
368
+ import { yarnAuthPlugin } from "@freestyle-sync/auth-yarn";
369
+ import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
370
+ import { shellHistoryPlugin } from "@freestyle-sync/shell-history";
371
+ import { defineConfig } from "freestyle-sync";
372
+
373
+ export default defineConfig({
374
+ plugins: [
375
+ envAuthPlugin(),
376
+ gitAuthPlugin(),
377
+ sshAuthPlugin(),
378
+ githubCliAuthPlugin(),
379
+ npmAuthPlugin(),
380
+ yarnAuthPlugin(),
381
+ dockerAuthPlugin(),
382
+ awsAuthPlugin(),
383
+ azureAuthPlugin(),
384
+ gcloudAuthPlugin(),
385
+ nodeNpmPlugin(),
386
+ claudeAgentPlugin(),
387
+ codexAgentPlugin(),
388
+ copilotAgentPlugin(),
389
+ shellHistoryPlugin(),
390
+ ],
391
+ });
392
+ `;
393
+ }
250
394
  function renderDefaultConfig() {
251
395
  return `import { claudeAgentPlugin } from "@freestyle-sync/agent-claude";
252
396
  import { codexAgentPlugin } from "@freestyle-sync/agent-codex";
@@ -286,28 +430,43 @@ export default defineConfig({
286
430
  });
287
431
  `;
288
432
  }
433
+ function renderBundledDefaultConfig() {
434
+ return `export default {
435
+ plugins: "default",
436
+ };
437
+ `;
438
+ }
439
+ async function ensureDefaultConfigDependencies(projectRoot) {
440
+ const missing = [];
441
+ for (const dependency of DEFAULT_CONFIG_DEPENDENCIES) {
442
+ if (!(await isDependencyInstalled(projectRoot, dependency.name))) {
443
+ missing.push(dependency.spec);
444
+ }
445
+ }
446
+ if (missing.length === 0)
447
+ return;
448
+ const { command, args } = await dependencyInstallCommand(projectRoot, missing);
449
+ console.log(`Installing freestyle-sync config dependencies with ${command}...`);
450
+ try {
451
+ await execFileAsync(command, args, { cwd: projectRoot });
452
+ }
453
+ catch (error) {
454
+ const details = error && typeof error === "object" && "stderr" in error ? String(error.stderr ?? "") : String(error);
455
+ throw new Error(`failed to install freestyle-sync config dependencies: ${details.trim() || String(error)}`);
456
+ }
457
+ }
458
+ async function isDependencyInstalled(projectRoot, name) {
459
+ return exists(path.join(projectRoot, "node_modules", ...name.split("/"), "package.json"));
460
+ }
461
+ async function dependencyInstallCommand(projectRoot, specs) {
462
+ if (await exists(path.join(projectRoot, "pnpm-lock.yaml")))
463
+ return { command: "pnpm", args: ["add", "-D", ...specs] };
464
+ if (await exists(path.join(projectRoot, "yarn.lock")))
465
+ return { command: "yarn", args: ["add", "-D", ...specs] };
466
+ return { command: "npm", args: ["install", "--save-dev", ...specs] };
467
+ }
289
468
  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
- };
469
+ const options = defaultCliOptions();
311
470
  const positional = [];
312
471
  for (let index = 0; index < args.length; index += 1) {
313
472
  const arg = args[index];
@@ -389,25 +548,65 @@ async function parseArgs(args) {
389
548
  throw new Error("expected at most one project path");
390
549
  }
391
550
  options.projectRoot = path.resolve(positional[0] ?? options.projectRoot);
551
+ return finalizeCliOptions(options);
552
+ }
553
+ async function resolveCliOptions(overrides) {
554
+ return finalizeCliOptions({
555
+ ...defaultCliOptions(),
556
+ ...overrides,
557
+ projectRoot: path.resolve(overrides?.projectRoot ?? process.cwd()),
558
+ disablePlugins: overrides?.disablePlugins ? [...overrides.disablePlugins] : [],
559
+ enablePlugins: overrides?.enablePlugins ? [...overrides.enablePlugins] : [],
560
+ envKeys: overrides?.envKeys ? [...overrides.envKeys] : [],
561
+ });
562
+ }
563
+ function defaultCliOptions() {
564
+ return {
565
+ projectRoot: process.cwd(),
566
+ cachePath: "",
567
+ remoteProjectDir: "",
568
+ name: "",
569
+ yes: false,
570
+ dryRun: false,
571
+ disablePlugins: [],
572
+ enablePlugins: [],
573
+ resetPluginPrefs: false,
574
+ listPlugins: false,
575
+ includeAuth: true,
576
+ includeAgentContext: true,
577
+ includeGitDir: true,
578
+ includeAllCopilotWorkspaces: false,
579
+ snapshot: true,
580
+ skipSync: false,
581
+ install: false,
582
+ autoSsh: true,
583
+ envKeys: [],
584
+ };
585
+ }
586
+ async function finalizeCliOptions(options) {
392
587
  const projectStats = await stat(options.projectRoot).catch(() => null);
393
588
  if (!projectStats?.isDirectory()) {
394
589
  throw new Error(`project path is not a directory: ${options.projectRoot}`);
395
590
  }
396
591
  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;
592
+ const remoteProjectDir = options.remoteProjectDir ? normalizeRemotePath(options.remoteProjectDir) : defaultRemoteProjectDir(options.projectRoot);
593
+ const cachePath = options.cachePath ? path.resolve(options.cachePath) : path.join(options.projectRoot, ".freestyle-sync", "cache.json");
594
+ return {
595
+ ...options,
596
+ name: options.name || `${CLI_NAME}-${projectName}`,
597
+ remoteProjectDir,
598
+ cachePath,
599
+ };
401
600
  }
402
601
  function printHelp() {
403
- console.log(`vmpush uploads the current project into a Freestyle VM.
602
+ console.log(`${CLI_NAME} uploads the current project into a Freestyle VM.
404
603
 
405
604
  Usage:
406
- vmpush [project-dir] [options]
605
+ ${CLI_NAME} [project-dir] [options]
407
606
 
408
607
  Options:
409
608
  --vm-id <id> Sync into an existing Freestyle VM.
410
- --name <name> Name for a newly created VM. Defaults to vmpush-<project>.
609
+ --name <name> Name for a newly created VM. Defaults to ${CLI_NAME}-<project>.
411
610
  --remote-dir <path> Remote project directory. Defaults to the local absolute path.
412
611
  --cache <path> Snapshot/hash cache path. Defaults to .freestyle-sync/cache.json.
413
612
  --include-env <name> Always copy an environment variable. Repeatable.
@@ -464,6 +663,18 @@ async function readCache(cachePath, options) {
464
663
  contextFiles: {},
465
664
  };
466
665
  }
666
+ function normalizeCache(cache, options) {
667
+ return {
668
+ ...cache,
669
+ version: CACHE_VERSION,
670
+ projectRoot: options.projectRoot,
671
+ remoteProjectDir: options.remoteProjectDir,
672
+ projectFiles: cache.projectFiles ?? {},
673
+ contextFiles: cache.contextFiles ?? {},
674
+ snapshotProjectFiles: cache.snapshotProjectFiles ?? {},
675
+ snapshotContextFiles: cache.snapshotContextFiles ?? {},
676
+ };
677
+ }
467
678
  function cacheBaseForSync(options, cache) {
468
679
  if (options.vmId && options.vmId === cache.vmId) {
469
680
  return {
@@ -632,10 +843,13 @@ async function scanContextCandidates(candidates) {
632
843
  }
633
844
  return entries.sort((left, right) => left.remotePath.localeCompare(right.remotePath));
634
845
  }
635
- async function updatePluginPreferences(options) {
846
+ async function updatePluginPreferences(options, providedPreferences) {
636
847
  const preferencesPath = getPluginPreferencesPath(options);
637
- const preferences = options.resetPluginPrefs ? emptyPluginPreferences() : await readPluginPreferences(preferencesPath);
638
- let changed = options.resetPluginPrefs;
848
+ const usingProvidedPreferences = Boolean(providedPreferences);
849
+ const preferences = options.resetPluginPrefs
850
+ ? emptyPluginPreferences()
851
+ : normalizePluginPreferences(providedPreferences ?? await readPluginPreferences(preferencesPath));
852
+ let changed = options.resetPluginPrefs && !usingProvidedPreferences;
639
853
  const disabledPlugins = new Set();
640
854
  for (const savedName of preferences.disabledPlugins) {
641
855
  const canonicalName = maybeResolvePluginSelector(savedName);
@@ -656,7 +870,7 @@ async function updatePluginPreferences(options) {
656
870
  }
657
871
  }
658
872
  const next = { version: PLUGIN_PREFERENCES_VERSION, disabledPlugins: [...disabledPlugins].sort(), updatedAt: preferences.updatedAt };
659
- if (changed)
873
+ if (changed && !usingProvidedPreferences)
660
874
  await writePluginPreferences(preferencesPath, next);
661
875
  return next;
662
876
  }
@@ -719,6 +933,13 @@ function pluginSelectorAliases(name) {
719
933
  function emptyPluginPreferences() {
720
934
  return { version: PLUGIN_PREFERENCES_VERSION, disabledPlugins: [] };
721
935
  }
936
+ function normalizePluginPreferences(preferences) {
937
+ return {
938
+ version: PLUGIN_PREFERENCES_VERSION,
939
+ disabledPlugins: Array.isArray(preferences.disabledPlugins) ? preferences.disabledPlugins.filter((name) => typeof name === "string") : [],
940
+ updatedAt: preferences.updatedAt,
941
+ };
942
+ }
722
943
  function getPluginPreferencesPath(options) {
723
944
  return path.join(path.dirname(options.cachePath), "plugin-preferences.json");
724
945
  }
@@ -726,11 +947,7 @@ async function readPluginPreferences(preferencesPath) {
726
947
  try {
727
948
  const parsed = JSON.parse(await readFile(preferencesPath, "utf8"));
728
949
  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
- };
950
+ return normalizePluginPreferences(parsed);
734
951
  }
735
952
  }
736
953
  catch (error) {
@@ -793,7 +1010,198 @@ function color(code, text) {
793
1010
  return text;
794
1011
  return `\u001b[${code}m${text}\u001b[0m`;
795
1012
  }
1013
+ async function getFreestyleClient() {
1014
+ const baseUrl = process.env.FREESTYLE_API_URL || DEFAULT_FREESTYLE_API_URL;
1015
+ if (process.env.FREESTYLE_API_KEY) {
1016
+ return new Freestyle({ apiKey: process.env.FREESTYLE_API_KEY, baseUrl });
1017
+ }
1018
+ const config = resolveStackConfig();
1019
+ let auth = await readStoredFreestyleAuth(config);
1020
+ if (!auth?.refreshToken && !readRefreshTokenFromEnv()) {
1021
+ await runFreestyleLogin();
1022
+ auth = await readStoredFreestyleAuth(config);
1023
+ }
1024
+ let refreshed = await refreshStackAccessToken(config, readRefreshTokenFromEnv() ?? auth?.refreshToken);
1025
+ if (!refreshed) {
1026
+ await runFreestyleLogin(["--force"]);
1027
+ auth = await readStoredFreestyleAuth(config);
1028
+ refreshed = await refreshStackAccessToken(config, readRefreshTokenFromEnv() ?? auth?.refreshToken);
1029
+ }
1030
+ if (!refreshed) {
1031
+ throw new Error("Freestyle authentication failed. Run `npx freestyle login` or set FREESTYLE_API_KEY.");
1032
+ }
1033
+ if (refreshed.refreshToken && refreshed.refreshToken !== auth?.refreshToken) {
1034
+ auth = await updateStoredFreestyleRefreshToken(config, auth, refreshed.refreshToken);
1035
+ }
1036
+ let teamId = process.env.FREESTYLE_TEAM_ID || auth?.defaultTeamId;
1037
+ if (!teamId) {
1038
+ await runFreestyleLogin();
1039
+ auth = await readStoredFreestyleAuth(config);
1040
+ teamId = process.env.FREESTYLE_TEAM_ID || auth?.defaultTeamId;
1041
+ }
1042
+ if (!teamId) {
1043
+ throw new Error("No Freestyle team selected. Run `npx freestyle login` to configure a default team, or set FREESTYLE_TEAM_ID.");
1044
+ }
1045
+ return new Freestyle({
1046
+ apiKey: "placeholder",
1047
+ baseUrl,
1048
+ fetch: createFreestyleProxyFetch(refreshed.accessToken, teamId),
1049
+ });
1050
+ }
1051
+ function resolveStackConfig() {
1052
+ return {
1053
+ stackApiUrl: (process.env.FREESTYLE_STACK_API_URL || DEFAULT_STACK_API_URL).replace(/\/+$/, ""),
1054
+ projectId: process.env.FREESTYLE_STACK_PROJECT_ID || process.env.NEXT_PUBLIC_STACK_PROJECT_ID || process.env.VITE_STACK_PROJECT_ID || DEFAULT_STACK_PROJECT_ID,
1055
+ publishableClientKey: process.env.FREESTYLE_STACK_PUBLISHABLE_CLIENT_KEY || process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY || process.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY || DEFAULT_STACK_PUBLISHABLE_CLIENT_KEY,
1056
+ authFilePath: process.env.FREESTYLE_STACK_AUTH_FILE || path.join(homedir(), ".freestyle", "stack-auth.json"),
1057
+ };
1058
+ }
1059
+ function readRefreshTokenFromEnv() {
1060
+ const refreshToken = process.env[STACK_REFRESH_TOKEN_ENV_KEY]?.trim();
1061
+ return refreshToken ? refreshToken : undefined;
1062
+ }
1063
+ async function readStoredFreestyleAuth(config) {
1064
+ try {
1065
+ const parsed = JSON.parse(await readFile(config.authFilePath, "utf8"));
1066
+ if (typeof parsed.refreshToken !== "string" || parsed.refreshToken.length === 0)
1067
+ return undefined;
1068
+ return {
1069
+ refreshToken: parsed.refreshToken,
1070
+ defaultTeamId: typeof parsed.defaultTeamId === "string" ? parsed.defaultTeamId : undefined,
1071
+ };
1072
+ }
1073
+ catch (error) {
1074
+ if (error.code === "ENOENT")
1075
+ return undefined;
1076
+ return undefined;
1077
+ }
1078
+ }
1079
+ async function updateStoredFreestyleRefreshToken(config, auth, refreshToken) {
1080
+ const next = {
1081
+ refreshToken,
1082
+ updatedAt: Date.now(),
1083
+ defaultTeamId: auth?.defaultTeamId,
1084
+ };
1085
+ await mkdir(path.dirname(config.authFilePath), { recursive: true });
1086
+ await writeFile(config.authFilePath, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
1087
+ return {
1088
+ refreshToken,
1089
+ defaultTeamId: auth?.defaultTeamId,
1090
+ };
1091
+ }
1092
+ async function refreshStackAccessToken(config, refreshToken) {
1093
+ if (!refreshToken)
1094
+ return undefined;
1095
+ const response = await fetch(`${config.stackApiUrl}/api/v1/auth/sessions/current/refresh`, {
1096
+ method: "POST",
1097
+ headers: {
1098
+ ...stackClientHeaders(config),
1099
+ "x-stack-refresh-token": refreshToken,
1100
+ },
1101
+ body: "{}",
1102
+ });
1103
+ if (!response.ok)
1104
+ return undefined;
1105
+ const data = await response.json();
1106
+ if (typeof data.access_token !== "string")
1107
+ return undefined;
1108
+ return {
1109
+ accessToken: data.access_token,
1110
+ refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : undefined,
1111
+ };
1112
+ }
1113
+ function stackClientHeaders(config) {
1114
+ return {
1115
+ "Content-Type": "application/json",
1116
+ "x-stack-project-id": config.projectId,
1117
+ "x-stack-access-type": "client",
1118
+ "x-stack-publishable-client-key": config.publishableClientKey,
1119
+ };
1120
+ }
1121
+ function createFreestyleProxyFetch(accessToken, teamId) {
1122
+ const dashboardApiUrl = (process.env.FREESTYLE_DASHBOARD_URL || "https://dash.freestyle.sh").replace(/\/+$/, "");
1123
+ return async (url, init) => {
1124
+ const urlObject = typeof url === "string" ? new URL(url) : url instanceof URL ? url : new URL(url.url);
1125
+ const requestPath = `${urlObject.pathname}${urlObject.search}`;
1126
+ const proxyResponse = await fetch(`${dashboardApiUrl}/api/proxy/request`, {
1127
+ method: "POST",
1128
+ headers: { "Content-Type": "application/json" },
1129
+ body: JSON.stringify({
1130
+ data: {
1131
+ accessToken,
1132
+ teamId,
1133
+ path: requestPath.startsWith("/") ? requestPath.slice(1) : requestPath,
1134
+ method: init?.method || "GET",
1135
+ headers: init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : {},
1136
+ body: init?.body ? init.body.toString() : undefined,
1137
+ },
1138
+ }),
1139
+ });
1140
+ if (!proxyResponse.ok) {
1141
+ const body = await proxyResponse.text();
1142
+ const normalizedError = normalizeFreestyleProxyError(body, proxyResponse.status);
1143
+ return new Response(normalizedError.body, {
1144
+ status: proxyResponse.status,
1145
+ statusText: proxyResponse.statusText,
1146
+ headers: { "Content-Type": normalizedError.contentType },
1147
+ });
1148
+ }
1149
+ const data = await proxyResponse.json();
1150
+ return new Response(JSON.stringify(data), {
1151
+ status: 200,
1152
+ headers: { "Content-Type": "application/json" },
1153
+ });
1154
+ };
1155
+ }
1156
+ function normalizeFreestyleProxyError(errorText, status) {
1157
+ const fallbackCode = status === 400 ? "BAD_REQUEST" : status === 401 ? "UNAUTHORIZED_ERROR" : status === 403 ? "FORBIDDEN" : "INTERNAL_ERROR";
1158
+ try {
1159
+ const parsed = JSON.parse(errorText);
1160
+ if (typeof parsed.code === "string" && typeof parsed.message === "string") {
1161
+ return { body: JSON.stringify(parsed), contentType: "application/json" };
1162
+ }
1163
+ const message = [parsed.error, parsed.message, parsed.reason].find((value) => typeof value === "string" && value.length > 0);
1164
+ if (message) {
1165
+ return {
1166
+ body: JSON.stringify(freestyleErrorBody(fallbackCode, message)),
1167
+ contentType: "application/json",
1168
+ };
1169
+ }
1170
+ }
1171
+ catch {
1172
+ }
1173
+ return {
1174
+ body: JSON.stringify(freestyleErrorBody(fallbackCode, errorText || "Request failed")),
1175
+ contentType: "application/json",
1176
+ };
1177
+ }
1178
+ function freestyleErrorBody(code, message) {
1179
+ if (code === "UNAUTHORIZED_ERROR") {
1180
+ return { code, message, route: "/api/proxy/request", reason: message };
1181
+ }
1182
+ return { code, message };
1183
+ }
1184
+ async function runFreestyleLogin(extraArgs = []) {
1185
+ console.log("No Freestyle API key found. Opening Freestyle login...");
1186
+ const cliPath = path.join(path.dirname(fileURLToPath(import.meta.resolve("freestyle"))), "cli.mjs");
1187
+ await new Promise((resolve, reject) => {
1188
+ const child = spawn(process.execPath, [cliPath, "login", ...extraArgs], {
1189
+ cwd: process.cwd(),
1190
+ stdio: "inherit",
1191
+ env: process.env,
1192
+ });
1193
+ child.on("error", reject);
1194
+ child.on("exit", (code, signal) => {
1195
+ if (code === 0) {
1196
+ resolve();
1197
+ return;
1198
+ }
1199
+ reject(new Error(signal ? `freestyle login was interrupted by ${signal}` : `freestyle login failed with exit code ${code ?? "unknown"}`));
1200
+ });
1201
+ });
1202
+ }
796
1203
  async function getOrCreateVm(options, snapshotId) {
1204
+ const freestyle = await getFreestyleClient();
797
1205
  if (options.vmId) {
798
1206
  const { vm } = await freestyle.vms.get({ vmId: options.vmId });
799
1207
  await vm.start(options.idleTimeoutSeconds ? { idleTimeoutSeconds: options.idleTimeoutSeconds } : undefined);
@@ -815,8 +1223,8 @@ async function syncProject(vm, vmId, options, changes) {
815
1223
  console.log(`VM ${vmId}: uploading ${changes.changed.length} changed project files...`);
816
1224
  const archive = await createProjectArchive(options.projectRoot, changes.changed);
817
1225
  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`);
1226
+ await uploadArchiveInChunks(vm, vmId, archive, "/tmp/freestyle-sync-project.tgz", "project");
1227
+ 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
1228
  }
821
1229
  finally {
822
1230
  await rm(path.dirname(archive), { recursive: true, force: true });
@@ -825,8 +1233,8 @@ async function syncProject(vm, vmId, options, changes) {
825
1233
  if (changes.removed.length > 0) {
826
1234
  console.log(`VM ${vmId}: removing ${changes.removed.length} deleted project files...`);
827
1235
  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`);
1236
+ await vm.fs.writeFile("/tmp/freestyle-sync-remove-list", removeList);
1237
+ await checkedExec(vm, `cd ${shellQuote(options.remoteProjectDir)} && xargs -0 rm -f -- < /tmp/freestyle-sync-remove-list && rm -f /tmp/freestyle-sync-remove-list`);
830
1238
  }
831
1239
  return {
832
1240
  uploaded: changes.changed.length,
@@ -885,8 +1293,8 @@ async function syncContext(vm, vmId, changes) {
885
1293
  console.log(`VM ${vmId}: uploading ${changes.changed.length} changed auth/context files...`);
886
1294
  const archive = await createContextArchive(changes.changed);
887
1295
  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");
1296
+ await uploadArchiveInChunks(vm, vmId, archive, "/tmp/freestyle-sync-context.tgz", "context");
1297
+ 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
1298
  }
891
1299
  finally {
892
1300
  await rm(path.dirname(archive), { recursive: true, force: true });
@@ -954,7 +1362,7 @@ async function detectInstallCommand(projectRoot) {
954
1362
  return undefined;
955
1363
  }
956
1364
  async function createProjectArchive(projectRoot, entries) {
957
- const tempDir = await mkdtemp(path.join(tmpdir(), "vmpush-"));
1365
+ const tempDir = await mkdtemp(path.join(tmpdir(), "freestyle-sync-"));
958
1366
  const listPath = path.join(tempDir, "files.list");
959
1367
  const archivePath = path.join(tempDir, "project.tgz");
960
1368
  await writeFile(listPath, Buffer.from(entries.map((entry) => entry.relativePath).join("\0") + "\0"));
@@ -962,7 +1370,7 @@ async function createProjectArchive(projectRoot, entries) {
962
1370
  return archivePath;
963
1371
  }
964
1372
  async function createContextArchive(entries) {
965
- const tempDir = await mkdtemp(path.join(tmpdir(), "vmpush-context-"));
1373
+ const tempDir = await mkdtemp(path.join(tmpdir(), "freestyle-sync-context-"));
966
1374
  const stagingDir = path.join(tempDir, "staging");
967
1375
  const archivePath = path.join(tempDir, "context.tgz");
968
1376
  await mkdir(stagingDir, { recursive: true });
@@ -987,17 +1395,29 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
987
1395
  const archive = await readFile(archivePath);
988
1396
  const encoded = archive.toString("base64");
989
1397
  const chunkCount = Math.max(1, Math.ceil(encoded.length / ARCHIVE_CHUNK_CHARS));
990
- const chunkDir = `/tmp/vmpush-${label}-${Date.now()}.chunks`;
1398
+ const chunkDir = `/tmp/freestyle-sync-${label}-${Date.now()}.chunks`;
991
1399
  console.log(`VM ${vmId}: streaming ${formatBytes(archive.length)} ${label} archive in ${chunkCount} chunk${chunkCount === 1 ? "" : "s"}...`);
992
1400
  await checkedExec(vm, `rm -rf ${shellQuote(chunkDir)} && mkdir -p ${shellQuote(chunkDir)}`);
993
1401
  const width = String(chunkCount - 1).length;
1402
+ const canRenderInlineProgress = process.stdout.isTTY;
1403
+ const logEvery = Math.max(1, Math.ceil(chunkCount / 4));
994
1404
  for (let index = 0; index < chunkCount; index += 1) {
995
1405
  const start = index * ARCHIVE_CHUNK_CHARS;
996
1406
  const chunk = encoded.slice(start, start + ARCHIVE_CHUNK_CHARS);
997
1407
  const chunkName = `${String(index).padStart(width, "0")}.b64`;
998
1408
  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`);
1409
+ const uploadedChunks = index + 1;
1410
+ if (chunkCount > 1) {
1411
+ const progressMessage = `VM ${vmId}: uploaded ${uploadedChunks}/${chunkCount} ${label} archive chunks`;
1412
+ if (canRenderInlineProgress) {
1413
+ process.stdout.write(`\r${progressMessage}`);
1414
+ if (uploadedChunks === chunkCount) {
1415
+ process.stdout.write("\n");
1416
+ }
1417
+ }
1418
+ else if (uploadedChunks === 1 || uploadedChunks % logEvery === 0 || uploadedChunks === chunkCount) {
1419
+ console.log(progressMessage);
1420
+ }
1001
1421
  }
1002
1422
  }
1003
1423
  await checkedExec(vm, `cat ${shellQuote(chunkDir)}/*.b64 | base64 -d > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`);
@@ -1095,7 +1515,7 @@ function md5(value) {
1095
1515
  return createHash("md5").update(value).digest("hex");
1096
1516
  }
1097
1517
  function renderEnvFile(envExports) {
1098
- const lines = ["# Generated by vmpush. Contains local auth tokens."];
1518
+ const lines = [`# Generated by ${CLI_NAME}. Contains local auth tokens.`];
1099
1519
  for (const key of Object.keys(envExports).sort()) {
1100
1520
  lines.push(`export ${key}=${shellQuote(envExports[key])}`);
1101
1521
  }
@@ -1115,7 +1535,28 @@ function defaultRemoteProjectDir(projectRoot) {
1115
1535
  return normalizeRemotePath(toPosix(projectRoot));
1116
1536
  }
1117
1537
  function sanitizeName(value) {
1118
- return value.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1538
+ const lower = value.toLowerCase();
1539
+ let normalized = "";
1540
+ let lastWasDash = false;
1541
+ for (const character of lower) {
1542
+ const isAlpha = character >= "a" && character <= "z";
1543
+ const isDigit = character >= "0" && character <= "9";
1544
+ const isAllowedPunctuation = character === "." || character === "_" || character === "-";
1545
+ if (isAlpha || isDigit || isAllowedPunctuation) {
1546
+ normalized += character;
1547
+ lastWasDash = false;
1548
+ continue;
1549
+ }
1550
+ if (!lastWasDash) {
1551
+ normalized += "-";
1552
+ lastWasDash = true;
1553
+ }
1554
+ }
1555
+ while (normalized.startsWith("-"))
1556
+ normalized = normalized.slice(1);
1557
+ while (normalized.endsWith("-"))
1558
+ normalized = normalized.slice(0, -1);
1559
+ return normalized || "project";
1119
1560
  }
1120
1561
  function toPosix(value) {
1121
1562
  return value.split(path.sep).join(path.posix.sep);