freestyle-sync 0.1.8 → 0.1.9

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 +10 -2
  2. package/dist/src/main.js +226 -53
  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.
package/dist/src/main.js CHANGED
@@ -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,
@@ -198,10 +200,11 @@ export async function sync(sdkOptions) {
198
200
  snapshotId: currentCache.snapshotId,
199
201
  };
200
202
  }
201
- progress.step("Preparing Freestyle VM");
202
- const { vm, vmId } = await getOrCreateVm(options, cache.snapshotId);
203
+ const provider = resolveVmProvider(options.provider);
204
+ progress.step(`Preparing ${provider.displayName}`);
205
+ const { vm, vmId, provider: runtimeProvider } = await getOrCreateVm(options, cache.snapshotId);
203
206
  resultVmId = vmId;
204
- console.log(`Uploading to VM: ${vmId}`);
207
+ console.log(`Uploading to ${runtimeProvider.runtimeLabel}: ${vmId}`);
205
208
  progress.step(`Uploading project changes to ${vmId}`);
206
209
  await ensureRemoteBase(vm, options.remoteProjectDir);
207
210
  const projectResult = await syncProject(vm, vmId, options, projectChanges);
@@ -223,6 +226,7 @@ export async function sync(sdkOptions) {
223
226
  version: CACHE_VERSION,
224
227
  projectRoot: options.projectRoot,
225
228
  remoteProjectDir: options.remoteProjectDir,
229
+ provider: options.provider,
226
230
  vmId,
227
231
  snapshotId: snapshotOverride ? snapshotOverride.snapshotId : cache.snapshotId,
228
232
  projectFiles: projectCurrent,
@@ -236,12 +240,13 @@ export async function sync(sdkOptions) {
236
240
  progress.step("Saving local sync cache");
237
241
  await saveCache(buildSyncCache());
238
242
  let snapshotPromise = null;
239
- if (options.snapshot) {
243
+ const createSnapshot = vm.snapshot;
244
+ if (options.snapshot && createSnapshot) {
240
245
  progress.step(`Creating snapshot cache for ${vmId} in background`);
241
246
  snapshotPromise = (async () => {
242
247
  await runBeforeSnapshotPlugins(vm, vmId, options);
243
248
  try {
244
- const snapshot = await vm.snapshot({ name: `${CLI_NAME}-${path.basename(options.projectRoot)}-${Date.now()}` });
249
+ const snapshot = await createSnapshot({ name: `${CLI_NAME}-${path.basename(options.projectRoot)}-${Date.now()}` });
245
250
  return snapshot.snapshotId;
246
251
  }
247
252
  catch (error) {
@@ -250,6 +255,9 @@ export async function sync(sdkOptions) {
250
255
  }
251
256
  })();
252
257
  }
258
+ else if (options.snapshot) {
259
+ progress.step(`Skipping snapshot cache for ${runtimeProvider.displayName}`);
260
+ }
253
261
  else {
254
262
  progress.step("Skipping snapshot cache");
255
263
  }
@@ -257,7 +265,7 @@ export async function sync(sdkOptions) {
257
265
  const postSyncMessages = await runAfterSyncPlugins(vm, vmId, options, contextCandidates);
258
266
  progress.finish();
259
267
  console.log("");
260
- console.log(`${success(symbol("✓", "*"))} ${bold(`VM ready: ${vmId}`)}`);
268
+ console.log(`${success(symbol("✓", "*"))} ${bold(`${capitalize(runtimeProvider.runtimeLabel)} ready: ${vmId}`)}`);
261
269
  console.log(`${dim("Remote project:")} ${options.remoteProjectDir}`);
262
270
  console.log(`${dim("Project files:")} ${projectResult.uploaded} uploaded, ${projectResult.removed} removed, ${projectResult.unchanged} unchanged`);
263
271
  console.log(`${dim("Context files:")} ${contextResult.uploaded} uploaded, ${contextResult.unchanged} unchanged`);
@@ -269,11 +277,11 @@ export async function sync(sdkOptions) {
269
277
  }
270
278
  for (const message of postSyncMessages)
271
279
  console.log(message);
272
- console.log(`${accent(symbol("➜", ">"))} ${bold(`SSH: npx freestyle vm ssh ${vmId}`)}`);
280
+ console.log(`${accent(symbol("➜", ">"))} ${bold(`${capitalize(runtimeProvider.runtimeLabel)} access: ${runtimeProvider.connectionHint(vmId)}`)}`);
273
281
  if (options.autoSsh) {
274
282
  const connected = await runConnectPlugins(vm, vmId, options, contextCandidates);
275
283
  if (!connected)
276
- await sshIntoVm(vmId);
284
+ await connectToVm(runtimeProvider, vmId);
277
285
  }
278
286
  if (snapshotPromise) {
279
287
  const newSnapshotId = await snapshotPromise;
@@ -516,9 +524,15 @@ async function parseArgs(args) {
516
524
  else if (arg === "--no-ssh") {
517
525
  options.autoSsh = false;
518
526
  }
527
+ else if (arg === "--provider" || arg === "--vm-provider") {
528
+ options.provider = parseVmProvider(readOptionValue(args, ++index, arg));
529
+ }
519
530
  else if (arg === "--vm-id") {
520
531
  options.vmId = readOptionValue(args, ++index, arg);
521
532
  }
533
+ else if (arg === "--apple-container-image" || arg === "--container-image") {
534
+ options.appleContainerImage = readOptionValue(args, ++index, arg);
535
+ }
522
536
  else if (arg === "--name") {
523
537
  options.name = readOptionValue(args, ++index, arg);
524
538
  }
@@ -565,7 +579,9 @@ function defaultCliOptions() {
565
579
  projectRoot: process.cwd(),
566
580
  cachePath: "",
567
581
  remoteProjectDir: "",
582
+ provider: "freestyle",
568
583
  name: "",
584
+ appleContainerImage: DEFAULT_APPLE_CONTAINER_IMAGE,
569
585
  yes: false,
570
586
  dryRun: false,
571
587
  disablePlugins: [],
@@ -599,14 +615,17 @@ async function finalizeCliOptions(options) {
599
615
  };
600
616
  }
601
617
  function printHelp() {
602
- console.log(`${CLI_NAME} uploads the current project into a Freestyle VM.
618
+ console.log(`${CLI_NAME} uploads the current project into a remote runtime.
603
619
 
604
620
  Usage:
605
621
  ${CLI_NAME} [project-dir] [options]
606
622
 
607
623
  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>.
624
+ --provider <name> Runtime provider: freestyle or apple-container. Defaults to freestyle.
625
+ --vm-id <id> Sync into an existing provider runtime.
626
+ --apple-container-image <image>
627
+ Apple Containers image. Defaults to ${DEFAULT_APPLE_CONTAINER_IMAGE}.
628
+ --name <name> Name for a newly created runtime. Defaults to ${CLI_NAME}-<project>.
610
629
  --remote-dir <path> Remote project directory. Defaults to the local absolute path.
611
630
  --cache <path> Snapshot/hash cache path. Defaults to .freestyle-sync/cache.json.
612
631
  --include-env <name> Always copy an environment variable. Repeatable.
@@ -616,16 +635,16 @@ Options:
616
635
  --list-plugins Show configured plugins and whether they are enabled.
617
636
  --install Run detected dependency install command after sync.
618
637
  --no-ssh Do not automatically open VS Code/Cursor or SSH after sync.
619
- --idle-timeout <seconds> Set VM idle timeout when creating/starting.
638
+ --idle-timeout <seconds> Set runtime idle timeout when supported.
620
639
  --no-auth Disable auth plugins for this run.
621
640
  --no-agent-context Disable agent context plugins for this run.
622
641
  --no-git-dir Exclude the local .git directory from project sync.
623
642
  --all-copilot-workspaces Include Copilot chat state for every VS Code workspace.
624
643
  --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.
644
+ Do not snapshot the runtime after sync.
645
+ --skip-sync Create a runtime from the last cached snapshot without syncing current
646
+ files. Requires a provider that supports snapshots, or --vm-id.
647
+ --dry-run Show what would sync without creating or changing a runtime.
629
648
  -y, --yes Deprecated; accepted for compatibility.
630
649
  -h, --help Show this help.
631
650
  `);
@@ -640,9 +659,11 @@ function readOptionValue(args, index, option) {
640
659
  async function readCache(cachePath, options) {
641
660
  try {
642
661
  const parsed = JSON.parse(await readFile(cachePath, "utf8"));
643
- if (parsed.version === CACHE_VERSION && parsed.projectRoot === options.projectRoot && parsed.remoteProjectDir === options.remoteProjectDir) {
662
+ const provider = parsed.provider ?? "freestyle";
663
+ if (parsed.version === CACHE_VERSION && parsed.projectRoot === options.projectRoot && parsed.remoteProjectDir === options.remoteProjectDir && provider === options.provider) {
644
664
  return {
645
665
  ...parsed,
666
+ provider,
646
667
  projectFiles: parsed.projectFiles ?? {},
647
668
  contextFiles: parsed.contextFiles ?? {},
648
669
  snapshotProjectFiles: parsed.snapshotProjectFiles,
@@ -659,6 +680,7 @@ async function readCache(cachePath, options) {
659
680
  version: CACHE_VERSION,
660
681
  projectRoot: options.projectRoot,
661
682
  remoteProjectDir: options.remoteProjectDir,
683
+ provider: options.provider,
662
684
  projectFiles: {},
663
685
  contextFiles: {},
664
686
  };
@@ -669,6 +691,7 @@ function normalizeCache(cache, options) {
669
691
  version: CACHE_VERSION,
670
692
  projectRoot: options.projectRoot,
671
693
  remoteProjectDir: options.remoteProjectDir,
694
+ provider: options.provider,
672
695
  projectFiles: cache.projectFiles ?? {},
673
696
  contextFiles: cache.contextFiles ?? {},
674
697
  snapshotProjectFiles: cache.snapshotProjectFiles ?? {},
@@ -962,9 +985,11 @@ async function writePluginPreferences(preferencesPath, preferences) {
962
985
  await writeFile(preferencesPath, `${JSON.stringify({ ...preferences, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
963
986
  }
964
987
  function printPlan(options, projectChanges, contextChanges, envExports, cache) {
965
- const source = options.vmId ? `existing VM ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : "a new VM";
988
+ const provider = resolveVmProvider(options.provider);
989
+ const source = options.vmId ? `existing ${provider.runtimeLabel} ${options.vmId}` : cache.snapshotId ? `snapshot ${cache.snapshotId}` : `a new ${provider.runtimeLabel}`;
966
990
  console.log("");
967
991
  console.log(`${bold("Sync plan")}`);
992
+ console.log(`${dim(" Provider:")} ${provider.displayName}`);
968
993
  console.log(`${dim(" Source:")} ${source}`);
969
994
  console.log(`${dim(" Local:")} ${options.projectRoot}`);
970
995
  console.log(`${dim(" Remote:")} ${options.remoteProjectDir}`);
@@ -1200,20 +1225,161 @@ async function runFreestyleLogin(extraArgs = []) {
1200
1225
  });
1201
1226
  });
1202
1227
  }
1228
+ function parseVmProvider(value) {
1229
+ if (value === "freestyle" || value === "apple-container")
1230
+ return value;
1231
+ throw new Error(`unknown provider: ${value}. Expected freestyle or apple-container.`);
1232
+ }
1233
+ function resolveVmProvider(provider) {
1234
+ if (provider === "apple-container")
1235
+ return appleContainerProvider;
1236
+ return freestyleVmProvider;
1237
+ }
1238
+ const freestyleVmProvider = {
1239
+ name: "freestyle",
1240
+ displayName: "Freestyle VM",
1241
+ runtimeLabel: "VM",
1242
+ async prepare(options, snapshotId) {
1243
+ const freestyle = await getFreestyleClient();
1244
+ if (options.vmId) {
1245
+ const { vm } = await freestyle.vms.get({ vmId: options.vmId });
1246
+ await vm.start(options.idleTimeoutSeconds ? { idleTimeoutSeconds: options.idleTimeoutSeconds } : undefined);
1247
+ return { vm: vm, vmId: options.vmId };
1248
+ }
1249
+ console.log(snapshotId ? `Creating VM from snapshot ${snapshotId}...` : "Creating Freestyle VM...");
1250
+ const result = await freestyle.vms.create({
1251
+ name: options.name,
1252
+ snapshotId: snapshotId ?? undefined,
1253
+ idleTimeoutSeconds: options.idleTimeoutSeconds,
1254
+ });
1255
+ return { vm: result.vm, vmId: result.vmId };
1256
+ },
1257
+ connectionHint(vmId) {
1258
+ return `npx freestyle vm ssh ${vmId}`;
1259
+ },
1260
+ async connect(vmId) {
1261
+ console.log(`Connecting to VM ${vmId}...`);
1262
+ await spawnInherited("npx", ["freestyle", "vm", "ssh", vmId], "ssh exited with status");
1263
+ },
1264
+ };
1265
+ const appleContainerProvider = {
1266
+ name: "apple-container",
1267
+ displayName: "Apple container",
1268
+ runtimeLabel: "container",
1269
+ async prepare(options) {
1270
+ if (options.vmId) {
1271
+ await startAppleContainer(options.vmId);
1272
+ return { vm: new AppleContainerVm(options.vmId), vmId: options.vmId };
1273
+ }
1274
+ console.log(`Creating Apple container ${options.name} from ${options.appleContainerImage}...`);
1275
+ const result = await execFileAsync("container", ["run", "--detach", "--name", options.name, options.appleContainerImage, "sleep", "infinity"]);
1276
+ const vmId = result.stdout.trim() || options.name;
1277
+ return { vm: new AppleContainerVm(vmId), vmId };
1278
+ },
1279
+ connectionHint(vmId) {
1280
+ return `container exec --interactive --tty ${vmId} sh -l`;
1281
+ },
1282
+ async connect(vmId) {
1283
+ console.log(`Connecting to container ${vmId}...`);
1284
+ await spawnInherited("container", ["exec", "--interactive", "--tty", vmId, "sh", "-l"], "container shell exited with status");
1285
+ },
1286
+ };
1203
1287
  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,
1288
+ const provider = resolveVmProvider(options.provider);
1289
+ if (snapshotId && provider.name !== "freestyle") {
1290
+ console.warn(`${provider.displayName} does not support Freestyle snapshots. Creating a fresh ${provider.runtimeLabel}.`);
1291
+ }
1292
+ const runtime = await provider.prepare(options, provider.name === "freestyle" ? snapshotId : undefined);
1293
+ return { ...runtime, provider };
1294
+ }
1295
+ async function startAppleContainer(containerId) {
1296
+ const result = await execFileResult("container", ["start", containerId]);
1297
+ if (result.statusCode !== 0 && !/already running/i.test(result.stderr || result.stdout || "")) {
1298
+ throw new Error(`failed to start Apple container ${containerId}: ${result.stderr || result.stdout || `exit ${result.statusCode}`}`);
1299
+ }
1300
+ }
1301
+ class AppleContainerVm {
1302
+ containerId;
1303
+ fs = {
1304
+ writeFile: (remotePath, content) => this.writeFile(remotePath, content),
1305
+ writeTextFile: (remotePath, content) => this.writeFile(remotePath, Buffer.from(content)),
1306
+ };
1307
+ constructor(containerId) {
1308
+ this.containerId = containerId;
1309
+ }
1310
+ exec(args) {
1311
+ return execFileResult("container", ["exec", this.containerId, "sh", "-lc", args.command], { timeoutMs: args.timeoutMs });
1312
+ }
1313
+ async writeFile(remotePath, content) {
1314
+ const directory = path.posix.dirname(remotePath);
1315
+ await checkedContainerExec(this.containerId, `mkdir -p ${shellQuote(directory)} && cat > ${shellQuote(remotePath)}`, content);
1316
+ }
1317
+ }
1318
+ async function checkedContainerExec(containerId, command, input) {
1319
+ const result = await execFileResult("container", ["exec", "--interactive", containerId, "sh", "-lc", command], { input });
1320
+ if (result.statusCode && result.statusCode !== 0) {
1321
+ throw new Error(`container command failed (${result.statusCode}): ${command}\n${result.stderr || result.stdout || ""}`);
1322
+ }
1323
+ }
1324
+ async function execFileResult(file, args, options = {}) {
1325
+ return new Promise((resolve, reject) => {
1326
+ const child = spawn(file, args, { stdio: [options.input ? "pipe" : "ignore", "pipe", "pipe"] });
1327
+ const stdout = [];
1328
+ const stderr = [];
1329
+ let settled = false;
1330
+ let timedOut = false;
1331
+ const timer = options.timeoutMs
1332
+ ? setTimeout(() => {
1333
+ timedOut = true;
1334
+ child.kill("SIGTERM");
1335
+ }, options.timeoutMs)
1336
+ : undefined;
1337
+ child.stdout?.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
1338
+ child.stderr?.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
1339
+ child.on("error", (error) => {
1340
+ if (settled)
1341
+ return;
1342
+ settled = true;
1343
+ if (timer)
1344
+ clearTimeout(timer);
1345
+ reject(error);
1346
+ });
1347
+ child.on("exit", (code) => {
1348
+ if (settled)
1349
+ return;
1350
+ settled = true;
1351
+ if (timer)
1352
+ clearTimeout(timer);
1353
+ resolve({
1354
+ stdout: Buffer.concat(stdout).toString("utf8"),
1355
+ stderr: Buffer.concat(stderr).toString("utf8"),
1356
+ statusCode: timedOut ? 124 : code ?? 1,
1357
+ });
1358
+ });
1359
+ if (options.input) {
1360
+ child.stdin?.end(options.input);
1361
+ }
1215
1362
  });
1216
- return { vm: result.vm, vmId: result.vmId };
1363
+ }
1364
+ async function spawnInherited(file, args, errorPrefix) {
1365
+ const exitCode = await new Promise((resolve, reject) => {
1366
+ const child = spawn(file, args, { stdio: "inherit" });
1367
+ child.on("error", reject);
1368
+ child.on("exit", (code) => resolve(code));
1369
+ });
1370
+ if (exitCode && exitCode !== 0) {
1371
+ throw new Error(`${errorPrefix} ${exitCode}`);
1372
+ }
1373
+ }
1374
+ async function connectToVm(provider, vmId) {
1375
+ if (!provider.connect) {
1376
+ console.log(`Connect with: ${provider.connectionHint(vmId)}`);
1377
+ return;
1378
+ }
1379
+ await provider.connect(vmId);
1380
+ }
1381
+ function capitalize(value) {
1382
+ return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`;
1217
1383
  }
1218
1384
  async function ensureRemoteBase(vm, remoteProjectDir) {
1219
1385
  await checkedExec(vm, `mkdir -p ${shellQuote(remoteProjectDir)} /root/.freestyle-sync`);
@@ -1393,6 +1559,7 @@ async function createTar(args) {
1393
1559
  }
1394
1560
  async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, label) {
1395
1561
  const archiveSize = (await stat(archivePath)).size;
1562
+ const archiveHash = await hashFile(archivePath);
1396
1563
  const chunkCount = Math.max(1, Math.ceil(archiveSize / ARCHIVE_CHUNK_BYTES));
1397
1564
  const chunkDir = `/tmp/freestyle-sync-${label}-${Date.now()}.chunks`;
1398
1565
  console.log(`VM ${vmId}: streaming ${formatBytes(archiveSize)} ${label} archive in ${chunkCount} chunk${chunkCount === 1 ? "" : "s"}...`);
@@ -1402,8 +1569,9 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
1402
1569
  const logEvery = Math.max(1, Math.ceil(chunkCount / 4));
1403
1570
  let index = 0;
1404
1571
  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));
1572
+ const chunkName = `${String(index).padStart(width, "0")}.chunk.b64`;
1573
+ const content = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1574
+ await vm.fs.writeTextFile(`${chunkDir}/${chunkName}`, content.toString("base64"));
1407
1575
  const uploadedChunks = index + 1;
1408
1576
  if (chunkCount > 1) {
1409
1577
  const progressMessage = `VM ${vmId}: uploaded ${uploadedChunks}/${chunkCount} ${label} archive chunks`;
@@ -1421,7 +1589,23 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
1421
1589
  }
1422
1590
  await checkedExec(vm, archiveSize === 0
1423
1591
  ? `: > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`
1424
- : `cat ${shellQuote(chunkDir)}/*.chunk > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`);
1592
+ : [
1593
+ "set -e",
1594
+ `rm -f ${shellQuote(remoteArchivePath)}`,
1595
+ `for chunk in ${shellQuote(chunkDir)}/*.chunk.b64; do`,
1596
+ ` base64 -d "$chunk" >> ${shellQuote(remoteArchivePath)}`,
1597
+ "done",
1598
+ `actual_size=$(wc -c < ${shellQuote(remoteArchivePath)} | tr -d '[:space:]')`,
1599
+ `actual_hash=$(sha256sum ${shellQuote(remoteArchivePath)} | awk '{print $1}')`,
1600
+ `if [ "$actual_size" != ${shellQuote(String(archiveSize))} ] || [ "$actual_hash" != ${shellQuote(archiveHash)} ]; then`,
1601
+ ` rm -f ${shellQuote(remoteArchivePath)}`,
1602
+ ` rm -rf ${shellQuote(chunkDir)}`,
1603
+ ` echo ${shellQuote(`archive integrity check failed for ${label}: expected ${archiveSize} bytes/${archiveHash}`)} >&2`,
1604
+ ` echo "got $actual_size bytes/$actual_hash" >&2`,
1605
+ " exit 1",
1606
+ "fi",
1607
+ `rm -rf ${shellQuote(chunkDir)}`,
1608
+ ].join("\n"));
1425
1609
  }
1426
1610
  async function mkdirRemote(vm, directories) {
1427
1611
  for (const chunk of chunkArray(directories, 50)) {
@@ -1488,17 +1672,6 @@ async function runConnectPlugins(vm, vmId, options, contextCandidates) {
1488
1672
  }
1489
1673
  return false;
1490
1674
  }
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
1675
  async function hashFile(filePath) {
1503
1676
  const hash = createHash("sha256");
1504
1677
  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.9",
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
  },