freestyle-sync 0.1.8 → 0.1.10

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 (3) hide show
  1. package/README.md +33 -3
  2. package/dist/src/main.js +324 -70
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -1,11 +1,19 @@
1
1
  # Freestyle Sync
2
2
 
3
- Sync your current directory, it's dependencies, and your agent context into a VM. VM snapshots ensure only changes are uploaded.
3
+ Sync your current directory, it's dependencies, and your agent context into a remote runtime. Freestyle VMs support snapshots so only changes are uploaded on later runs.
4
4
 
5
5
  ```
6
6
  npx freestyle-sync
7
7
  ```
8
8
 
9
+ Freestyle VMs are the default provider. You can sync into a local Apple container on macOS with the Apple `container` CLI installed:
10
+
11
+ ```sh
12
+ npx freestyle-sync --provider apple-container --apple-container-image ubuntu:24.04
13
+ ```
14
+
15
+ Use `--vm-id <container-name-or-id>` to reuse an existing Apple container. Snapshot caching is only available with the Freestyle provider.
16
+
9
17
  Use the SDK when embedding sync inside another CLI/app (no required `freestyle-sync.config.ts` or cache file):
10
18
 
11
19
  ```ts
@@ -29,7 +37,7 @@ const result = await sync({
29
37
  },
30
38
  });
31
39
 
32
- console.log("synced VM", result.vmId);
40
+ console.log("synced runtime", result.vmId);
33
41
  ```
34
42
 
35
43
  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.
@@ -71,4 +79,26 @@ export default defineConfig({
71
79
  shellHistoryPlugin(),
72
80
  ],
73
81
  });
74
- ```
82
+ ```
83
+
84
+ Project sync can exclude folders and include additional local paths. Include paths may point outside the project directory; they are copied into the remote project at the configured relative target.
85
+
86
+ ```js
87
+ export default defineConfig({
88
+ sync: {
89
+ exclude: ["dist", ".turbo"],
90
+ include: [
91
+ { source: "../shared-config", target: "shared-config" },
92
+ ],
93
+ },
94
+ plugins: [
95
+ nodeNpmPlugin(),
96
+ ],
97
+ });
98
+ ```
99
+
100
+ `nodeNpmPlugin()` excludes `node_modules` from upload by default and installs dependencies on the remote runtime using the project lockfile: `pnpm install --frozen-lockfile`, Yarn `--frozen-lockfile`/`--immutable`, or `npm ci`. To keep the previous behavior of syncing `node_modules` and only repairing native/workspace packages remotely, use:
101
+
102
+ ```js
103
+ nodeNpmPlugin({ syncNodeModules: true })
104
+ ```
package/dist/src/main.js CHANGED
@@ -10,7 +10,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
10
10
  import "dotenv/config";
11
11
  import { createHash } from "node:crypto";
12
12
  import { createReadStream, realpathSync } from "node:fs";
13
- import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
13
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from "node:fs/promises";
14
14
  import { homedir, tmpdir } from "node:os";
15
15
  import path from "node:path";
16
16
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -22,8 +22,9 @@ const execFileAsync = promisify(execFile);
22
22
  const CLI_NAME = "freestyle-sync";
23
23
  const CACHE_VERSION = 1;
24
24
  const PLUGIN_PREFERENCES_VERSION = 1;
25
- const ARCHIVE_CHUNK_BYTES = 1024 * 1024;
25
+ const ARCHIVE_CHUNK_BYTES = 512 * 1024;
26
26
  const MS_PER_SECOND = 1000;
27
+ const DEFAULT_APPLE_CONTAINER_IMAGE = "ubuntu:24.04";
27
28
  const DEFAULT_FREESTYLE_API_URL = "https://api.freestyle.sh";
28
29
  const DEFAULT_STACK_API_URL = "https://api.stack-auth.com";
29
30
  const DEFAULT_STACK_PROJECT_ID = "0edf478c-f123-46fb-818f-34c0024a9f35";
@@ -138,9 +139,10 @@ export async function sync(sdkOptions) {
138
139
  progress.step("Reading sync cache");
139
140
  const cache = currentCache;
140
141
  if (!options.vmId && !cache.snapshotId) {
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.`);
142
+ console.warn(`${CLI_NAME} --skip-sync: no cached snapshot found. A new empty runtime will be created. Run without --skip-sync first to create a snapshot.`);
142
143
  }
143
- const source = options.vmId ? `existing VM ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : "a new VM";
144
+ const provider = resolveVmProvider(options.provider);
145
+ const source = options.vmId ? `existing ${provider.runtimeLabel} ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : `a new ${provider.runtimeLabel}`;
144
146
  console.log(`Skipping sync: creating VM from ${source}`);
145
147
  console.log(`Remote project: ${options.remoteProjectDir}`);
146
148
  if (options.dryRun) {
@@ -149,26 +151,26 @@ export async function sync(sdkOptions) {
149
151
  snapshotId: currentCache.snapshotId,
150
152
  };
151
153
  }
152
- progress.step("Preparing Freestyle VM");
153
- const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
154
+ progress.step(`Preparing ${provider.displayName}`);
155
+ const { vm, vmId, provider: runtimeProvider } = await getOrCreateVm(options, cache.snapshotId);
154
156
  resultVmId = vmId;
155
- console.log(`Using VM: ${vmId}`);
157
+ console.log(`Using ${runtimeProvider.runtimeLabel}: ${vmId}`);
156
158
  progress.step(`Running post-sync plugins for ${vmId}`);
157
159
  const contextCandidates = await discoverPluginContextCandidates(options);
158
160
  const postSyncMessages = await runAfterSyncPlugins(vm, vmId, options, contextCandidates);
159
161
  console.log("");
160
- console.log(`VM ready: ${vmId}`);
162
+ console.log(`${capitalize(runtimeProvider.runtimeLabel)} ready: ${vmId}`);
161
163
  console.log(`Project: ${options.remoteProjectDir}`);
162
164
  if (cache.snapshotId) {
163
165
  console.log(`Snapshot cache: ${cache.snapshotId}`);
164
166
  }
165
167
  for (const message of postSyncMessages)
166
168
  console.log(message);
167
- console.log(`SSH: npx freestyle vm ssh ${vmId}`);
169
+ console.log(`${capitalize(runtimeProvider.runtimeLabel)} access: ${runtimeProvider.connectionHint(vmId)}`);
168
170
  if (options.autoSsh) {
169
171
  const connected = await runConnectPlugins(vm, vmId, options, contextCandidates);
170
172
  if (!connected)
171
- await sshIntoVm(vmId);
173
+ await connectToVm(runtimeProvider, vmId);
172
174
  }
173
175
  return {
174
176
  vmId,
@@ -180,7 +182,8 @@ export async function sync(sdkOptions) {
180
182
  progress.step("Scanning project files");
181
183
  const cache = currentCache;
182
184
  const base = cacheBaseForSync(options, cache);
183
- const projectEntries = await scanProject(options.projectRoot, options.includeGitDir);
185
+ const projectSyncConfig = await resolveProjectSyncConfig(options);
186
+ const projectEntries = await scanProject(options.projectRoot, options.includeGitDir, projectSyncConfig);
184
187
  const projectCurrent = digestMap(projectEntries);
185
188
  const projectChanges = diffEntries(projectEntries, base.projectFiles);
186
189
  progress.step("Detecting auth and agent context");
@@ -198,10 +201,11 @@ export async function sync(sdkOptions) {
198
201
  snapshotId: currentCache.snapshotId,
199
202
  };
200
203
  }
201
- progress.step("Preparing Freestyle VM");
202
- const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
204
+ const provider = resolveVmProvider(options.provider);
205
+ progress.step(`Preparing ${provider.displayName}`);
206
+ const { vm, vmId, provider: runtimeProvider } = await getOrCreateVm(options, cache.snapshotId);
203
207
  resultVmId = vmId;
204
- console.log(`Uploading to VM: ${vmId}`);
208
+ console.log(`Uploading to ${runtimeProvider.runtimeLabel}: ${vmId}`);
205
209
  progress.step(`Uploading project changes to ${vmId}`);
206
210
  await ensureRemoteBase(vm, options.remoteProjectDir);
207
211
  const projectResult = await syncProject(vm, vmId, options, projectChanges);
@@ -223,6 +227,7 @@ export async function sync(sdkOptions) {
223
227
  version: CACHE_VERSION,
224
228
  projectRoot: options.projectRoot,
225
229
  remoteProjectDir: options.remoteProjectDir,
230
+ provider: options.provider,
226
231
  vmId,
227
232
  snapshotId: snapshotOverride ? snapshotOverride.snapshotId : cache.snapshotId,
228
233
  projectFiles: projectCurrent,
@@ -236,12 +241,13 @@ export async function sync(sdkOptions) {
236
241
  progress.step("Saving local sync cache");
237
242
  await saveCache(buildSyncCache());
238
243
  let snapshotPromise = null;
239
- if (options.snapshot) {
244
+ const createSnapshot = vm.snapshot;
245
+ if (options.snapshot && createSnapshot) {
240
246
  progress.step(`Creating snapshot cache for ${vmId} in background`);
241
247
  snapshotPromise = (async () => {
242
248
  await runBeforeSnapshotPlugins(vm, vmId, options);
243
249
  try {
244
- const snapshot = await vm.snapshot({ name: `${CLI_NAME}-${path.basename(options.projectRoot)}-${Date.now()}` });
250
+ const snapshot = await createSnapshot({ name: `${CLI_NAME}-${path.basename(options.projectRoot)}-${Date.now()}` });
245
251
  return snapshot.snapshotId;
246
252
  }
247
253
  catch (error) {
@@ -250,6 +256,9 @@ export async function sync(sdkOptions) {
250
256
  }
251
257
  })();
252
258
  }
259
+ else if (options.snapshot) {
260
+ progress.step(`Skipping snapshot cache for ${runtimeProvider.displayName}`);
261
+ }
253
262
  else {
254
263
  progress.step("Skipping snapshot cache");
255
264
  }
@@ -257,7 +266,7 @@ export async function sync(sdkOptions) {
257
266
  const postSyncMessages = await runAfterSyncPlugins(vm, vmId, options, contextCandidates);
258
267
  progress.finish();
259
268
  console.log("");
260
- console.log(`${success(symbol("✓", "*"))} ${bold(`VM ready: ${vmId}`)}`);
269
+ console.log(`${success(symbol("✓", "*"))} ${bold(`${capitalize(runtimeProvider.runtimeLabel)} ready: ${vmId}`)}`);
261
270
  console.log(`${dim("Remote project:")} ${options.remoteProjectDir}`);
262
271
  console.log(`${dim("Project files:")} ${projectResult.uploaded} uploaded, ${projectResult.removed} removed, ${projectResult.unchanged} unchanged`);
263
272
  console.log(`${dim("Context files:")} ${contextResult.uploaded} uploaded, ${contextResult.unchanged} unchanged`);
@@ -269,11 +278,11 @@ export async function sync(sdkOptions) {
269
278
  }
270
279
  for (const message of postSyncMessages)
271
280
  console.log(message);
272
- console.log(`${accent(symbol("➜", ">"))} ${bold(`SSH: npx freestyle vm ssh ${vmId}`)}`);
281
+ console.log(`${accent(symbol("➜", ">"))} ${bold(`${capitalize(runtimeProvider.runtimeLabel)} access: ${runtimeProvider.connectionHint(vmId)}`)}`);
273
282
  if (options.autoSsh) {
274
283
  const connected = await runConnectPlugins(vm, vmId, options, contextCandidates);
275
284
  if (!connected)
276
- await sshIntoVm(vmId);
285
+ await connectToVm(runtimeProvider, vmId);
277
286
  }
278
287
  if (snapshotPromise) {
279
288
  const newSnapshotId = await snapshotPromise;
@@ -516,9 +525,15 @@ async function parseArgs(args) {
516
525
  else if (arg === "--no-ssh") {
517
526
  options.autoSsh = false;
518
527
  }
528
+ else if (arg === "--provider" || arg === "--vm-provider") {
529
+ options.provider = parseVmProvider(readOptionValue(args, ++index, arg));
530
+ }
519
531
  else if (arg === "--vm-id") {
520
532
  options.vmId = readOptionValue(args, ++index, arg);
521
533
  }
534
+ else if (arg === "--apple-container-image" || arg === "--container-image") {
535
+ options.appleContainerImage = readOptionValue(args, ++index, arg);
536
+ }
522
537
  else if (arg === "--name") {
523
538
  options.name = readOptionValue(args, ++index, arg);
524
539
  }
@@ -565,7 +580,9 @@ function defaultCliOptions() {
565
580
  projectRoot: process.cwd(),
566
581
  cachePath: "",
567
582
  remoteProjectDir: "",
583
+ provider: "freestyle",
568
584
  name: "",
585
+ appleContainerImage: DEFAULT_APPLE_CONTAINER_IMAGE,
569
586
  yes: false,
570
587
  dryRun: false,
571
588
  disablePlugins: [],
@@ -599,14 +616,17 @@ async function finalizeCliOptions(options) {
599
616
  };
600
617
  }
601
618
  function printHelp() {
602
- console.log(`${CLI_NAME} uploads the current project into a Freestyle VM.
619
+ console.log(`${CLI_NAME} uploads the current project into a remote runtime.
603
620
 
604
621
  Usage:
605
622
  ${CLI_NAME} [project-dir] [options]
606
623
 
607
624
  Options:
608
- --vm-id <id> Sync into an existing Freestyle VM.
609
- --name <name> Name for a newly created VM. Defaults to ${CLI_NAME}-<project>.
625
+ --provider <name> Runtime provider: freestyle or apple-container. Defaults to freestyle.
626
+ --vm-id <id> Sync into an existing provider runtime.
627
+ --apple-container-image <image>
628
+ Apple Containers image. Defaults to ${DEFAULT_APPLE_CONTAINER_IMAGE}.
629
+ --name <name> Name for a newly created runtime. Defaults to ${CLI_NAME}-<project>.
610
630
  --remote-dir <path> Remote project directory. Defaults to the local absolute path.
611
631
  --cache <path> Snapshot/hash cache path. Defaults to .freestyle-sync/cache.json.
612
632
  --include-env <name> Always copy an environment variable. Repeatable.
@@ -616,16 +636,16 @@ Options:
616
636
  --list-plugins Show configured plugins and whether they are enabled.
617
637
  --install Run detected dependency install command after sync.
618
638
  --no-ssh Do not automatically open VS Code/Cursor or SSH after sync.
619
- --idle-timeout <seconds> Set VM idle timeout when creating/starting.
639
+ --idle-timeout <seconds> Set runtime idle timeout when supported.
620
640
  --no-auth Disable auth plugins for this run.
621
641
  --no-agent-context Disable agent context plugins for this run.
622
642
  --no-git-dir Exclude the local .git directory from project sync.
623
643
  --all-copilot-workspaces Include Copilot chat state for every VS Code workspace.
624
644
  --no-snapshot, --skip-snapshot
625
- Do not snapshot the VM after sync.
626
- --skip-sync Create a VM from the last cached snapshot without syncing current
627
- files. Requires a prior sync with snapshotting enabled, or --vm-id.
628
- --dry-run Show what would sync without creating or changing a VM.
645
+ Do not snapshot the runtime after sync.
646
+ --skip-sync Create a runtime from the last cached snapshot without syncing current
647
+ files. Requires a provider that supports snapshots, or --vm-id.
648
+ --dry-run Show what would sync without creating or changing a runtime.
629
649
  -y, --yes Deprecated; accepted for compatibility.
630
650
  -h, --help Show this help.
631
651
  `);
@@ -640,9 +660,11 @@ function readOptionValue(args, index, option) {
640
660
  async function readCache(cachePath, options) {
641
661
  try {
642
662
  const parsed = JSON.parse(await readFile(cachePath, "utf8"));
643
- if (parsed.version === CACHE_VERSION && parsed.projectRoot === options.projectRoot && parsed.remoteProjectDir === options.remoteProjectDir) {
663
+ const provider = parsed.provider ?? "freestyle";
664
+ if (parsed.version === CACHE_VERSION && parsed.projectRoot === options.projectRoot && parsed.remoteProjectDir === options.remoteProjectDir && provider === options.provider) {
644
665
  return {
645
666
  ...parsed,
667
+ provider,
646
668
  projectFiles: parsed.projectFiles ?? {},
647
669
  contextFiles: parsed.contextFiles ?? {},
648
670
  snapshotProjectFiles: parsed.snapshotProjectFiles,
@@ -659,6 +681,7 @@ async function readCache(cachePath, options) {
659
681
  version: CACHE_VERSION,
660
682
  projectRoot: options.projectRoot,
661
683
  remoteProjectDir: options.remoteProjectDir,
684
+ provider: options.provider,
662
685
  projectFiles: {},
663
686
  contextFiles: {},
664
687
  };
@@ -669,6 +692,7 @@ function normalizeCache(cache, options) {
669
692
  version: CACHE_VERSION,
670
693
  projectRoot: options.projectRoot,
671
694
  remoteProjectDir: options.remoteProjectDir,
695
+ provider: options.provider,
672
696
  projectFiles: cache.projectFiles ?? {},
673
697
  contextFiles: cache.contextFiles ?? {},
674
698
  snapshotProjectFiles: cache.snapshotProjectFiles ?? {},
@@ -696,23 +720,92 @@ async function writeCache(cachePath, cache) {
696
720
  await mkdir(path.dirname(cachePath), { recursive: true });
697
721
  await writeFile(cachePath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
698
722
  }
699
- async function scanProject(projectRoot, includeGitDir) {
700
- const entries = [];
701
- await walk(projectRoot, "", entries, {
723
+ async function resolveProjectSyncConfig(options) {
724
+ const configs = [];
725
+ if (config.sync)
726
+ configs.push(config.sync);
727
+ for (const plugin of plugins) {
728
+ const pluginConfig = await plugin.configureProjectSync?.({ options, utils: pluginUtils });
729
+ if (pluginConfig)
730
+ configs.push(pluginConfig);
731
+ }
732
+ return {
733
+ exclude: configs.flatMap((projectConfig) => projectConfig.exclude ?? []).map(normalizeProjectPattern),
734
+ include: configs.flatMap((projectConfig) => normalizeProjectIncludes(options.projectRoot, projectConfig.include ?? [])),
735
+ };
736
+ }
737
+ function normalizeProjectIncludes(projectRoot, includes) {
738
+ return includes.map((include) => {
739
+ const source = typeof include === "string" ? include : include.source;
740
+ const target = typeof include === "string" ? undefined : include.target;
741
+ const resolvedSource = path.resolve(projectRoot, source);
742
+ return {
743
+ source: resolvedSource,
744
+ target: normalizeProjectPattern(target ?? path.basename(resolvedSource)),
745
+ };
746
+ });
747
+ }
748
+ function normalizeProjectPattern(value) {
749
+ const normalized = path.posix.normalize(value.replace(/\\/g, "/")).replace(/^\/+/, "").replace(/\/+$/, "");
750
+ if (!normalized || normalized === ".") {
751
+ throw new Error("project sync paths must not be empty");
752
+ }
753
+ if (normalized === ".." || normalized.startsWith("../")) {
754
+ throw new Error(`project sync paths must stay inside the remote project: ${value}`);
755
+ }
756
+ return normalized;
757
+ }
758
+ async function scanProject(projectRoot, includeGitDir, syncConfig) {
759
+ const entriesByPath = new Map();
760
+ const rootEntries = [];
761
+ await walk(projectRoot, "", rootEntries, {
702
762
  skipDirectory(relativePath, name) {
703
763
  if (!includeGitDir && relativePath === ".git")
704
764
  return true;
705
- return false;
765
+ return shouldSkipProjectDirectory(relativePath, name, syncConfig.exclude);
706
766
  },
707
767
  });
708
- return entries.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
768
+ upsertProjectEntries(entriesByPath, rootEntries);
769
+ for (const include of syncConfig.include) {
770
+ upsertProjectEntries(entriesByPath, await scanProjectInclude(include, syncConfig.exclude));
771
+ }
772
+ return Array.from(entriesByPath.values()).sort((left, right) => left.relativePath.localeCompare(right.relativePath));
773
+ }
774
+ async function scanProjectInclude(include, exclude) {
775
+ const stats = await lstat(include.source).catch((error) => {
776
+ throw new Error(`project sync include does not exist: ${include.source} (${error instanceof Error ? error.message : String(error)})`);
777
+ });
778
+ if (stats.isFile() || stats.isSymbolicLink()) {
779
+ return [await digestEntry(include.source, include.target)];
780
+ }
781
+ if (!stats.isDirectory()) {
782
+ return [];
783
+ }
784
+ const entries = [];
785
+ await walk(include.source, "", entries, {
786
+ mapRelativePath(relativePath) {
787
+ return `${include.target}/${relativePath}`;
788
+ },
789
+ skipDirectory(relativePath, name) {
790
+ return shouldSkipProjectDirectory(relativePath, name, exclude);
791
+ },
792
+ });
793
+ return entries;
794
+ }
795
+ function upsertProjectEntries(entriesByPath, entries) {
796
+ for (const entry of entries)
797
+ entriesByPath.set(entry.relativePath, entry);
798
+ }
799
+ function shouldSkipProjectDirectory(relativePath, name, exclude) {
800
+ return exclude.some((excludedPath) => relativePath === excludedPath || name === excludedPath);
709
801
  }
710
802
  async function walk(root, relativePath, entries, options) {
711
803
  const absolutePath = path.join(root, relativePath);
712
804
  const dir = await import("node:fs/promises").then((fs) => fs.readdir(absolutePath, { withFileTypes: true }));
713
805
  for (const dirent of dir) {
714
806
  const childRelativePath = relativePath ? path.join(relativePath, dirent.name) : dirent.name;
715
- const normalizedRelativePath = toPosix(childRelativePath);
807
+ const localRelativePath = toPosix(childRelativePath);
808
+ const normalizedRelativePath = options.mapRelativePath?.(localRelativePath) ?? localRelativePath;
716
809
  const childAbsolutePath = path.join(root, childRelativePath);
717
810
  if (dirent.isDirectory()) {
718
811
  if (options.skipDirectory(normalizedRelativePath, dirent.name)) {
@@ -962,9 +1055,11 @@ async function writePluginPreferences(preferencesPath, preferences) {
962
1055
  await writeFile(preferencesPath, `${JSON.stringify({ ...preferences, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
963
1056
  }
964
1057
  function printPlan(options, projectChanges, contextChanges, envExports, cache) {
965
- const source = options.vmId ? `existing VM ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : "a new VM";
1058
+ const provider = resolveVmProvider(options.provider);
1059
+ const source = options.vmId ? `existing ${provider.runtimeLabel} ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : `a new ${provider.runtimeLabel}`;
966
1060
  console.log("");
967
1061
  console.log(`${bold("Sync plan")}`);
1062
+ console.log(`${dim(" Provider:")} ${provider.displayName}`);
968
1063
  console.log(`${dim(" Source:")} ${source}`);
969
1064
  console.log(`${dim(" Local:")} ${options.projectRoot}`);
970
1065
  console.log(`${dim(" Remote:")} ${options.remoteProjectDir}`);
@@ -1200,20 +1295,161 @@ async function runFreestyleLogin(extraArgs = []) {
1200
1295
  });
1201
1296
  });
1202
1297
  }
1298
+ function parseVmProvider(value) {
1299
+ if (value === "freestyle" || value === "apple-container")
1300
+ return value;
1301
+ throw new Error(`unknown provider: ${value}. Expected freestyle or apple-container.`);
1302
+ }
1303
+ function resolveVmProvider(provider) {
1304
+ if (provider === "apple-container")
1305
+ return appleContainerProvider;
1306
+ return freestyleVmProvider;
1307
+ }
1308
+ const freestyleVmProvider = {
1309
+ name: "freestyle",
1310
+ displayName: "Freestyle VM",
1311
+ runtimeLabel: "VM",
1312
+ async prepare(options, snapshotId) {
1313
+ const freestyle = await getFreestyleClient();
1314
+ if (options.vmId) {
1315
+ const { vm } = await freestyle.vms.get({ vmId: options.vmId });
1316
+ await vm.start(options.idleTimeoutSeconds ? { idleTimeoutSeconds: options.idleTimeoutSeconds } : undefined);
1317
+ return { vm: vm, vmId: options.vmId };
1318
+ }
1319
+ console.log(snapshotId ? `Creating VM from snapshot ${snapshotId}...` : "Creating Freestyle VM...");
1320
+ const result = await freestyle.vms.create({
1321
+ name: options.name,
1322
+ snapshotId: snapshotId ?? undefined,
1323
+ idleTimeoutSeconds: options.idleTimeoutSeconds,
1324
+ });
1325
+ return { vm: result.vm, vmId: result.vmId };
1326
+ },
1327
+ connectionHint(vmId) {
1328
+ return `npx freestyle vm ssh ${vmId}`;
1329
+ },
1330
+ async connect(vmId) {
1331
+ console.log(`Connecting to VM ${vmId}...`);
1332
+ await spawnInherited("npx", ["freestyle", "vm", "ssh", vmId], "ssh exited with status");
1333
+ },
1334
+ };
1335
+ const appleContainerProvider = {
1336
+ name: "apple-container",
1337
+ displayName: "Apple container",
1338
+ runtimeLabel: "container",
1339
+ async prepare(options) {
1340
+ if (options.vmId) {
1341
+ await startAppleContainer(options.vmId);
1342
+ return { vm: new AppleContainerVm(options.vmId), vmId: options.vmId };
1343
+ }
1344
+ console.log(`Creating Apple container ${options.name} from ${options.appleContainerImage}...`);
1345
+ const result = await execFileAsync("container", ["run", "--detach", "--name", options.name, options.appleContainerImage, "sleep", "infinity"]);
1346
+ const vmId = result.stdout.trim() || options.name;
1347
+ return { vm: new AppleContainerVm(vmId), vmId };
1348
+ },
1349
+ connectionHint(vmId) {
1350
+ return `container exec --interactive --tty ${vmId} sh -l`;
1351
+ },
1352
+ async connect(vmId) {
1353
+ console.log(`Connecting to container ${vmId}...`);
1354
+ await spawnInherited("container", ["exec", "--interactive", "--tty", vmId, "sh", "-l"], "container shell exited with status");
1355
+ },
1356
+ };
1203
1357
  async function getOrCreateVm(options, snapshotId) {
1204
- const freestyle = await getFreestyleClient();
1205
- if (options.vmId) {
1206
- const { vm } = await freestyle.vms.get({ vmId: options.vmId });
1207
- await vm.start(options.idleTimeoutSeconds ? { idleTimeoutSeconds: options.idleTimeoutSeconds } : undefined);
1208
- return { vm, vmId: options.vmId };
1209
- }
1210
- console.log(snapshotId ? `Creating VM from snapshot ${snapshotId}...` : "Creating Freestyle VM...");
1211
- const result = await freestyle.vms.create({
1212
- name: options.name,
1213
- snapshotId: snapshotId ?? undefined,
1214
- idleTimeoutSeconds: options.idleTimeoutSeconds,
1358
+ const provider = resolveVmProvider(options.provider);
1359
+ if (snapshotId && provider.name !== "freestyle") {
1360
+ console.warn(`${provider.displayName} does not support Freestyle snapshots. Creating a fresh ${provider.runtimeLabel}.`);
1361
+ }
1362
+ const runtime = await provider.prepare(options, provider.name === "freestyle" ? snapshotId : undefined);
1363
+ return { ...runtime, provider };
1364
+ }
1365
+ async function startAppleContainer(containerId) {
1366
+ const result = await execFileResult("container", ["start", containerId]);
1367
+ if (result.statusCode !== 0 && !/already running/i.test(result.stderr || result.stdout || "")) {
1368
+ throw new Error(`failed to start Apple container ${containerId}: ${result.stderr || result.stdout || `exit ${result.statusCode}`}`);
1369
+ }
1370
+ }
1371
+ class AppleContainerVm {
1372
+ containerId;
1373
+ fs = {
1374
+ writeFile: (remotePath, content) => this.writeFile(remotePath, content),
1375
+ writeTextFile: (remotePath, content) => this.writeFile(remotePath, Buffer.from(content)),
1376
+ };
1377
+ constructor(containerId) {
1378
+ this.containerId = containerId;
1379
+ }
1380
+ exec(args) {
1381
+ return execFileResult("container", ["exec", this.containerId, "sh", "-lc", args.command], { timeoutMs: args.timeoutMs });
1382
+ }
1383
+ async writeFile(remotePath, content) {
1384
+ const directory = path.posix.dirname(remotePath);
1385
+ await checkedContainerExec(this.containerId, `mkdir -p ${shellQuote(directory)} && cat > ${shellQuote(remotePath)}`, content);
1386
+ }
1387
+ }
1388
+ async function checkedContainerExec(containerId, command, input) {
1389
+ const result = await execFileResult("container", ["exec", "--interactive", containerId, "sh", "-lc", command], { input });
1390
+ if (result.statusCode && result.statusCode !== 0) {
1391
+ throw new Error(`container command failed (${result.statusCode}): ${command}\n${result.stderr || result.stdout || ""}`);
1392
+ }
1393
+ }
1394
+ async function execFileResult(file, args, options = {}) {
1395
+ return new Promise((resolve, reject) => {
1396
+ const child = spawn(file, args, { stdio: [options.input ? "pipe" : "ignore", "pipe", "pipe"] });
1397
+ const stdout = [];
1398
+ const stderr = [];
1399
+ let settled = false;
1400
+ let timedOut = false;
1401
+ const timer = options.timeoutMs
1402
+ ? setTimeout(() => {
1403
+ timedOut = true;
1404
+ child.kill("SIGTERM");
1405
+ }, options.timeoutMs)
1406
+ : undefined;
1407
+ child.stdout?.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
1408
+ child.stderr?.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
1409
+ child.on("error", (error) => {
1410
+ if (settled)
1411
+ return;
1412
+ settled = true;
1413
+ if (timer)
1414
+ clearTimeout(timer);
1415
+ reject(error);
1416
+ });
1417
+ child.on("exit", (code) => {
1418
+ if (settled)
1419
+ return;
1420
+ settled = true;
1421
+ if (timer)
1422
+ clearTimeout(timer);
1423
+ resolve({
1424
+ stdout: Buffer.concat(stdout).toString("utf8"),
1425
+ stderr: Buffer.concat(stderr).toString("utf8"),
1426
+ statusCode: timedOut ? 124 : code ?? 1,
1427
+ });
1428
+ });
1429
+ if (options.input) {
1430
+ child.stdin?.end(options.input);
1431
+ }
1432
+ });
1433
+ }
1434
+ async function spawnInherited(file, args, errorPrefix) {
1435
+ const exitCode = await new Promise((resolve, reject) => {
1436
+ const child = spawn(file, args, { stdio: "inherit" });
1437
+ child.on("error", reject);
1438
+ child.on("exit", (code) => resolve(code));
1215
1439
  });
1216
- return { vm: result.vm, vmId: result.vmId };
1440
+ if (exitCode && exitCode !== 0) {
1441
+ throw new Error(`${errorPrefix} ${exitCode}`);
1442
+ }
1443
+ }
1444
+ async function connectToVm(provider, vmId) {
1445
+ if (!provider.connect) {
1446
+ console.log(`Connect with: ${provider.connectionHint(vmId)}`);
1447
+ return;
1448
+ }
1449
+ await provider.connect(vmId);
1450
+ }
1451
+ function capitalize(value) {
1452
+ return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`;
1217
1453
  }
1218
1454
  async function ensureRemoteBase(vm, remoteProjectDir) {
1219
1455
  await checkedExec(vm, `mkdir -p ${shellQuote(remoteProjectDir)} /root/.freestyle-sync`);
@@ -1221,7 +1457,7 @@ async function ensureRemoteBase(vm, remoteProjectDir) {
1221
1457
  async function syncProject(vm, vmId, options, changes) {
1222
1458
  if (changes.changed.length > 0) {
1223
1459
  console.log(`VM ${vmId}: uploading ${changes.changed.length} changed project files...`);
1224
- const archive = await createProjectArchive(options.projectRoot, changes.changed);
1460
+ const archive = await createProjectArchive(changes.changed);
1225
1461
  try {
1226
1462
  await uploadArchiveInChunks(vm, vmId, archive, "/tmp/freestyle-sync-project.tgz", "project");
1227
1463
  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`);
@@ -1346,11 +1582,11 @@ async function runInstall(vm, projectRoot, remoteProjectDir) {
1346
1582
  }
1347
1583
  async function detectInstallCommand(projectRoot) {
1348
1584
  if (await exists(path.join(projectRoot, "pnpm-lock.yaml")))
1349
- return "corepack enable && pnpm install";
1585
+ return "corepack enable && pnpm install --frozen-lockfile";
1350
1586
  if (await exists(path.join(projectRoot, "yarn.lock")))
1351
- return "corepack enable && yarn install";
1352
- if (await exists(path.join(projectRoot, "package-lock.json")))
1353
- return "npm install";
1587
+ return "corepack enable && yarn_version=$(yarn --version) && case \"$yarn_version\" in 1.*) yarn install --frozen-lockfile ;; *) yarn install --immutable ;; esac";
1588
+ if (await exists(path.join(projectRoot, "package-lock.json")) || await exists(path.join(projectRoot, "npm-shrinkwrap.json")))
1589
+ return "npm ci";
1354
1590
  if (await exists(path.join(projectRoot, "requirements.txt")))
1355
1591
  return "python3 -m pip install -r requirements.txt";
1356
1592
  if (await exists(path.join(projectRoot, "pyproject.toml")))
@@ -1361,12 +1597,23 @@ async function detectInstallCommand(projectRoot) {
1361
1597
  return "go mod download";
1362
1598
  return undefined;
1363
1599
  }
1364
- async function createProjectArchive(projectRoot, entries) {
1600
+ async function createProjectArchive(entries) {
1365
1601
  const tempDir = await mkdtemp(path.join(tmpdir(), "freestyle-sync-"));
1366
- const listPath = path.join(tempDir, "files.list");
1602
+ const stagingDir = path.join(tempDir, "staging");
1367
1603
  const archivePath = path.join(tempDir, "project.tgz");
1368
- await writeFile(listPath, Buffer.from(entries.map((entry) => entry.relativePath).join("\0") + "\0"));
1369
- await createTar(["--null", "--no-xattrs", "-T", listPath, "-czf", archivePath, "-C", projectRoot]);
1604
+ await mkdir(stagingDir, { recursive: true });
1605
+ for (const entry of entries) {
1606
+ const destination = path.join(stagingDir, ...entry.relativePath.split("/"));
1607
+ await mkdir(path.dirname(destination), { recursive: true });
1608
+ if (entry.kind === "symlink") {
1609
+ await symlink(await readlink(entry.absolutePath), destination);
1610
+ }
1611
+ else {
1612
+ await copyFile(entry.absolutePath, destination);
1613
+ await chmod(destination, entry.mode);
1614
+ }
1615
+ }
1616
+ await createTar(["--no-xattrs", "-czf", archivePath, "-C", stagingDir, "."]);
1370
1617
  return archivePath;
1371
1618
  }
1372
1619
  async function createContextArchive(entries) {
@@ -1393,6 +1640,7 @@ async function createTar(args) {
1393
1640
  }
1394
1641
  async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, label) {
1395
1642
  const archiveSize = (await stat(archivePath)).size;
1643
+ const archiveHash = await hashFile(archivePath);
1396
1644
  const chunkCount = Math.max(1, Math.ceil(archiveSize / ARCHIVE_CHUNK_BYTES));
1397
1645
  const chunkDir = `/tmp/freestyle-sync-${label}-${Date.now()}.chunks`;
1398
1646
  console.log(`VM ${vmId}: streaming ${formatBytes(archiveSize)} ${label} archive in ${chunkCount} chunk${chunkCount === 1 ? "" : "s"}...`);
@@ -1402,8 +1650,9 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
1402
1650
  const logEvery = Math.max(1, Math.ceil(chunkCount / 4));
1403
1651
  let index = 0;
1404
1652
  for await (const chunk of createReadStream(archivePath, { highWaterMark: ARCHIVE_CHUNK_BYTES })) {
1405
- const chunkName = `${String(index).padStart(width, "0")}.chunk`;
1406
- await vm.fs.writeFile(`${chunkDir}/${chunkName}`, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1653
+ const chunkName = `${String(index).padStart(width, "0")}.chunk.b64`;
1654
+ const content = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1655
+ await vm.fs.writeTextFile(`${chunkDir}/${chunkName}`, content.toString("base64"));
1407
1656
  const uploadedChunks = index + 1;
1408
1657
  if (chunkCount > 1) {
1409
1658
  const progressMessage = `VM ${vmId}: uploaded ${uploadedChunks}/${chunkCount} ${label} archive chunks`;
@@ -1421,7 +1670,23 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
1421
1670
  }
1422
1671
  await checkedExec(vm, archiveSize === 0
1423
1672
  ? `: > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`
1424
- : `cat ${shellQuote(chunkDir)}/*.chunk > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`);
1673
+ : [
1674
+ "set -e",
1675
+ `rm -f ${shellQuote(remoteArchivePath)}`,
1676
+ `for chunk in ${shellQuote(chunkDir)}/*.chunk.b64; do`,
1677
+ ` base64 -d "$chunk" >> ${shellQuote(remoteArchivePath)}`,
1678
+ "done",
1679
+ `actual_size=$(wc -c < ${shellQuote(remoteArchivePath)} | tr -d '[:space:]')`,
1680
+ `actual_hash=$(sha256sum ${shellQuote(remoteArchivePath)} | awk '{print $1}')`,
1681
+ `if [ "$actual_size" != ${shellQuote(String(archiveSize))} ] || [ "$actual_hash" != ${shellQuote(archiveHash)} ]; then`,
1682
+ ` rm -f ${shellQuote(remoteArchivePath)}`,
1683
+ ` rm -rf ${shellQuote(chunkDir)}`,
1684
+ ` echo ${shellQuote(`archive integrity check failed for ${label}: expected ${archiveSize} bytes/${archiveHash}`)} >&2`,
1685
+ ` echo "got $actual_size bytes/$actual_hash" >&2`,
1686
+ " exit 1",
1687
+ "fi",
1688
+ `rm -rf ${shellQuote(chunkDir)}`,
1689
+ ].join("\n"));
1425
1690
  }
1426
1691
  async function mkdirRemote(vm, directories) {
1427
1692
  for (const chunk of chunkArray(directories, 50)) {
@@ -1488,17 +1753,6 @@ async function runConnectPlugins(vm, vmId, options, contextCandidates) {
1488
1753
  }
1489
1754
  return false;
1490
1755
  }
1491
- async function sshIntoVm(vmId) {
1492
- console.log(`Connecting to VM ${vmId}...`);
1493
- const exitCode = await new Promise((resolve, reject) => {
1494
- const child = spawn("npx", ["freestyle", "vm", "ssh", vmId], { stdio: "inherit" });
1495
- child.on("error", reject);
1496
- child.on("exit", (code) => resolve(code));
1497
- });
1498
- if (exitCode && exitCode !== 0) {
1499
- throw new Error(`ssh exited with status ${exitCode}`);
1500
- }
1501
- }
1502
1756
  async function hashFile(filePath) {
1503
1757
  const hash = createHash("sha256");
1504
1758
  await new Promise((resolve, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freestyle-sync",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "main": "dist/src/main.js",
6
6
  "exports": {
@@ -22,7 +22,8 @@
22
22
  "build": "rm -rf dist plugins/*/dist && tsc -p tsconfig.json && node scripts/prepare-plugin-packages.mjs",
23
23
  "check": "tsc --noEmit -p tsconfig.json",
24
24
  "prepack": "npm run build",
25
- "publish": "npm run check && npm run build && npm run publish:workspaces && npm publish",
25
+ "publish": "npm run check && npm run build && npm run publish:workspaces && npm run publish:root",
26
+ "publish:root": "node scripts/publish-root.mjs",
26
27
  "publish:workspaces": "node scripts/publish-workspaces.mjs",
27
28
  "start": "node src/main.ts"
28
29
  },