freestyle-sync 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,46 +1,12 @@
1
1
  # Freestyle Sync
2
2
 
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.
3
+ Offload any development environment to a VM with 1 command. Authentication, agent context, and dependencies are automatically handled.
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
-
17
- Use the SDK when embedding sync inside another CLI/app (no required `freestyle-sync.config.ts` or cache file):
18
-
19
- ```ts
20
- import { defineConfig, sync, type SyncCache } from "freestyle-sync";
21
- import { gitAuthPlugin } from "@freestyle-sync/auth-git";
22
- import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
23
-
24
- let cache: SyncCache | undefined;
25
-
26
- const result = await sync({
27
- config: defineConfig({
28
- plugins: [gitAuthPlugin(), nodeNpmPlugin()],
29
- }),
30
- options: {
31
- projectRoot: process.cwd(),
32
- autoSsh: false,
33
- },
34
- cache,
35
- onCacheUpdate(nextCache) {
36
- cache = nextCache;
37
- },
38
- });
39
-
40
- console.log("synced runtime", result.vmId);
41
- ```
42
-
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.
9
+ On it's first run, freestyle-sync creates `freestyle-sync.config.mjs` and configures a large set of default plugins. Plugins help sync parts of your development environment that require more complex logic than just copying the current directory's files.
44
10
 
45
11
  ```js
46
12
  import { defineConfig } from "freestyle-sync";
@@ -57,8 +23,11 @@ import { githubCliAuthPlugin } from "@freestyle-sync/auth-github-cli";
57
23
  import { npmAuthPlugin } from "@freestyle-sync/auth-npm";
58
24
  import { sshAuthPlugin } from "@freestyle-sync/auth-ssh";
59
25
  import { yarnAuthPlugin } from "@freestyle-sync/auth-yarn";
26
+ import { nextjsPlugin } from "@freestyle-sync/nextjs";
60
27
  import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
28
+ import { rustPlugin } from "@freestyle-sync/rust";
61
29
  import { shellHistoryPlugin } from "@freestyle-sync/shell-history";
30
+ import { vitePlugin } from "@freestyle-sync/vite";
62
31
 
63
32
  export default defineConfig({
64
33
  plugins: [
@@ -73,10 +42,70 @@ export default defineConfig({
73
42
  azureAuthPlugin(),
74
43
  gcloudAuthPlugin(),
75
44
  nodeNpmPlugin(),
45
+ nextjsPlugin(),
46
+ vitePlugin(),
47
+ rustPlugin(),
76
48
  claudeAgentPlugin(),
77
49
  codexAgentPlugin(),
78
50
  copilotAgentPlugin(),
79
51
  shellHistoryPlugin(),
80
52
  ],
81
53
  });
82
- ```
54
+ ```
55
+
56
+ You 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.
57
+
58
+ ```js
59
+ export default defineConfig({
60
+ sync: {
61
+ exclude: ["dist", ".turbo"],
62
+ include: [
63
+ { source: "../shared-config", target: "shared-config" },
64
+ ],
65
+ }
66
+ });
67
+ ```
68
+
69
+ The default Node/framework/runtime plugins avoid uploading generated dependency and build output folders:
70
+
71
+ ```js
72
+ nodeNpmPlugin() // excludes node_modules and installs frozen dependencies remotely
73
+ nextjsPlugin() // excludes .next
74
+ vitePlugin() // excludes dist, build, and .vite
75
+ rustPlugin() // excludes target, installs Rust/Cargo, and runs cargo fetch
76
+ ```
77
+
78
+ You can keep uploading those generated folders when needed:
79
+
80
+ ```js
81
+ nodeNpmPlugin({ syncNodeModules: true })
82
+ nextjsPlugin({ syncNextDir: true })
83
+ vitePlugin({ syncBuildOutput: true })
84
+ rustPlugin({ syncTargetDir: true })
85
+ ```
86
+
87
+ If you're integrating freestyle-sync into your own toolchain, you can use it as an sdk and take full control over how configuration and cache is stored.
88
+
89
+ ```ts
90
+ import { defineConfig, sync, type SyncCache } from "freestyle-sync";
91
+ import { gitAuthPlugin } from "@freestyle-sync/auth-git";
92
+ import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
93
+
94
+ let cache: SyncCache | undefined;
95
+
96
+ const result = await sync({
97
+ config: defineConfig({
98
+ plugins: [gitAuthPlugin(), nodeNpmPlugin()],
99
+ }),
100
+ options: {
101
+ projectRoot: process.cwd(),
102
+ autoSsh: false,
103
+ },
104
+ cache,
105
+ onCacheUpdate(nextCache) {
106
+ cache = nextCache;
107
+ },
108
+ });
109
+
110
+ console.log("synced runtime", result.vmId);
111
+ ```
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import type { CliOptions, PushvmConfig, VmProviderName } from "./plugin-api.ts";
4
+ export * from "./plugin-api.ts";
5
+ type FileKind = "file" | "symlink";
6
+ type FileDigest = {
7
+ hash: string;
8
+ kind: FileKind;
9
+ mode: number;
10
+ size: number;
11
+ };
12
+ type CacheFile = {
13
+ version: number;
14
+ projectRoot: string;
15
+ remoteProjectDir: string;
16
+ provider?: VmProviderName;
17
+ vmId?: string;
18
+ snapshotId?: string;
19
+ projectFiles: Record<string, FileDigest>;
20
+ contextFiles: Record<string, FileDigest>;
21
+ snapshotProjectFiles?: Record<string, FileDigest>;
22
+ snapshotContextFiles?: Record<string, FileDigest>;
23
+ envHash?: string;
24
+ snapshotEnvHash?: string;
25
+ updatedAt?: string;
26
+ };
27
+ type PluginPreferences = {
28
+ version: number;
29
+ disabledPlugins: string[];
30
+ updatedAt?: string;
31
+ };
32
+ export type SyncCache = CacheFile;
33
+ export type SyncPluginPreferences = PluginPreferences;
34
+ export type SyncSdkOptions = {
35
+ config: PushvmConfig;
36
+ options?: Partial<CliOptions>;
37
+ cache?: SyncCache;
38
+ pluginPreferences?: SyncPluginPreferences;
39
+ onCacheUpdate?(cache: SyncCache): Promise<void> | void;
40
+ };
41
+ export type SyncSdkResult = {
42
+ vmId?: string;
43
+ cache: SyncCache;
44
+ snapshotId?: string;
45
+ };
46
+ export declare function sync(sdkOptions: SyncSdkOptions): Promise<SyncSdkResult>;
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";
@@ -47,8 +47,11 @@ const DEFAULT_CONFIG_DEPENDENCIES = [
47
47
  { name: "@freestyle-sync/auth-npm", spec: "@freestyle-sync/auth-npm@latest" },
48
48
  { name: "@freestyle-sync/auth-ssh", spec: "@freestyle-sync/auth-ssh@latest" },
49
49
  { name: "@freestyle-sync/auth-yarn", spec: "@freestyle-sync/auth-yarn@latest" },
50
+ { name: "@freestyle-sync/nextjs", spec: "@freestyle-sync/nextjs@latest" },
50
51
  { name: "@freestyle-sync/node-npm", spec: "@freestyle-sync/node-npm@latest" },
52
+ { name: "@freestyle-sync/rust", spec: "@freestyle-sync/rust@latest" },
51
53
  { name: "@freestyle-sync/shell-history", spec: "@freestyle-sync/shell-history@latest" },
54
+ { name: "@freestyle-sync/vite", spec: "@freestyle-sync/vite@latest" },
52
55
  ];
53
56
  const pluginUtils = {
54
57
  checkedExec,
@@ -182,7 +185,8 @@ export async function sync(sdkOptions) {
182
185
  progress.step("Scanning project files");
183
186
  const cache = currentCache;
184
187
  const base = cacheBaseForSync(options, cache);
185
- const projectEntries = await scanProject(options.projectRoot, options.includeGitDir);
188
+ const projectSyncConfig = await resolveProjectSyncConfig(options);
189
+ const projectEntries = await scanProject(options.projectRoot, options.includeGitDir, projectSyncConfig);
186
190
  const projectCurrent = digestMap(projectEntries);
187
191
  const projectChanges = diffEntries(projectEntries, base.projectFiles);
188
192
  progress.step("Detecting auth and agent context");
@@ -374,8 +378,11 @@ import { githubCliAuthPlugin } from "@freestyle-sync/auth-github-cli";
374
378
  import { npmAuthPlugin } from "@freestyle-sync/auth-npm";
375
379
  import { sshAuthPlugin } from "@freestyle-sync/auth-ssh";
376
380
  import { yarnAuthPlugin } from "@freestyle-sync/auth-yarn";
381
+ import { nextjsPlugin } from "@freestyle-sync/nextjs";
377
382
  import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
383
+ import { rustPlugin } from "@freestyle-sync/rust";
378
384
  import { shellHistoryPlugin } from "@freestyle-sync/shell-history";
385
+ import { vitePlugin } from "@freestyle-sync/vite";
379
386
  import { defineConfig } from "freestyle-sync";
380
387
 
381
388
  export default defineConfig({
@@ -391,6 +398,9 @@ export default defineConfig({
391
398
  azureAuthPlugin(),
392
399
  gcloudAuthPlugin(),
393
400
  nodeNpmPlugin(),
401
+ nextjsPlugin(),
402
+ vitePlugin(),
403
+ rustPlugin(),
394
404
  claudeAgentPlugin(),
395
405
  codexAgentPlugin(),
396
406
  copilotAgentPlugin(),
@@ -413,8 +423,11 @@ import { githubCliAuthPlugin } from "@freestyle-sync/auth-github-cli";
413
423
  import { npmAuthPlugin } from "@freestyle-sync/auth-npm";
414
424
  import { sshAuthPlugin } from "@freestyle-sync/auth-ssh";
415
425
  import { yarnAuthPlugin } from "@freestyle-sync/auth-yarn";
426
+ import { nextjsPlugin } from "@freestyle-sync/nextjs";
416
427
  import { nodeNpmPlugin } from "@freestyle-sync/node-npm";
428
+ import { rustPlugin } from "@freestyle-sync/rust";
417
429
  import { shellHistoryPlugin } from "@freestyle-sync/shell-history";
430
+ import { vitePlugin } from "@freestyle-sync/vite";
418
431
  import { defineConfig } from "freestyle-sync";
419
432
 
420
433
  export default defineConfig({
@@ -430,6 +443,9 @@ export default defineConfig({
430
443
  azureAuthPlugin(),
431
444
  gcloudAuthPlugin(),
432
445
  nodeNpmPlugin(),
446
+ nextjsPlugin(),
447
+ vitePlugin(),
448
+ rustPlugin(),
433
449
  claudeAgentPlugin(),
434
450
  codexAgentPlugin(),
435
451
  copilotAgentPlugin(),
@@ -719,23 +735,92 @@ async function writeCache(cachePath, cache) {
719
735
  await mkdir(path.dirname(cachePath), { recursive: true });
720
736
  await writeFile(cachePath, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
721
737
  }
722
- async function scanProject(projectRoot, includeGitDir) {
723
- const entries = [];
724
- await walk(projectRoot, "", entries, {
738
+ async function resolveProjectSyncConfig(options) {
739
+ const configs = [];
740
+ if (config.sync)
741
+ configs.push(config.sync);
742
+ for (const plugin of plugins) {
743
+ const pluginConfig = await plugin.configureProjectSync?.({ options, utils: pluginUtils });
744
+ if (pluginConfig)
745
+ configs.push(pluginConfig);
746
+ }
747
+ return {
748
+ exclude: configs.flatMap((projectConfig) => projectConfig.exclude ?? []).map(normalizeProjectPattern),
749
+ include: configs.flatMap((projectConfig) => normalizeProjectIncludes(options.projectRoot, projectConfig.include ?? [])),
750
+ };
751
+ }
752
+ function normalizeProjectIncludes(projectRoot, includes) {
753
+ return includes.map((include) => {
754
+ const source = typeof include === "string" ? include : include.source;
755
+ const target = typeof include === "string" ? undefined : include.target;
756
+ const resolvedSource = path.resolve(projectRoot, source);
757
+ return {
758
+ source: resolvedSource,
759
+ target: normalizeProjectPattern(target ?? path.basename(resolvedSource)),
760
+ };
761
+ });
762
+ }
763
+ function normalizeProjectPattern(value) {
764
+ const normalized = path.posix.normalize(value.replace(/\\/g, "/")).replace(/^\/+/, "").replace(/\/+$/, "");
765
+ if (!normalized || normalized === ".") {
766
+ throw new Error("project sync paths must not be empty");
767
+ }
768
+ if (normalized === ".." || normalized.startsWith("../")) {
769
+ throw new Error(`project sync paths must stay inside the remote project: ${value}`);
770
+ }
771
+ return normalized;
772
+ }
773
+ async function scanProject(projectRoot, includeGitDir, syncConfig) {
774
+ const entriesByPath = new Map();
775
+ const rootEntries = [];
776
+ await walk(projectRoot, "", rootEntries, {
725
777
  skipDirectory(relativePath, name) {
726
778
  if (!includeGitDir && relativePath === ".git")
727
779
  return true;
728
- return false;
780
+ return shouldSkipProjectDirectory(relativePath, name, syncConfig.exclude);
729
781
  },
730
782
  });
731
- return entries.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
783
+ upsertProjectEntries(entriesByPath, rootEntries);
784
+ for (const include of syncConfig.include) {
785
+ upsertProjectEntries(entriesByPath, await scanProjectInclude(include, syncConfig.exclude));
786
+ }
787
+ return Array.from(entriesByPath.values()).sort((left, right) => left.relativePath.localeCompare(right.relativePath));
788
+ }
789
+ async function scanProjectInclude(include, exclude) {
790
+ const stats = await lstat(include.source).catch((error) => {
791
+ throw new Error(`project sync include does not exist: ${include.source} (${error instanceof Error ? error.message : String(error)})`);
792
+ });
793
+ if (stats.isFile() || stats.isSymbolicLink()) {
794
+ return [await digestEntry(include.source, include.target)];
795
+ }
796
+ if (!stats.isDirectory()) {
797
+ return [];
798
+ }
799
+ const entries = [];
800
+ await walk(include.source, "", entries, {
801
+ mapRelativePath(relativePath) {
802
+ return `${include.target}/${relativePath}`;
803
+ },
804
+ skipDirectory(relativePath, name) {
805
+ return shouldSkipProjectDirectory(relativePath, name, exclude);
806
+ },
807
+ });
808
+ return entries;
809
+ }
810
+ function upsertProjectEntries(entriesByPath, entries) {
811
+ for (const entry of entries)
812
+ entriesByPath.set(entry.relativePath, entry);
813
+ }
814
+ function shouldSkipProjectDirectory(relativePath, name, exclude) {
815
+ return exclude.some((excludedPath) => relativePath === excludedPath || name === excludedPath);
732
816
  }
733
817
  async function walk(root, relativePath, entries, options) {
734
818
  const absolutePath = path.join(root, relativePath);
735
819
  const dir = await import("node:fs/promises").then((fs) => fs.readdir(absolutePath, { withFileTypes: true }));
736
820
  for (const dirent of dir) {
737
821
  const childRelativePath = relativePath ? path.join(relativePath, dirent.name) : dirent.name;
738
- const normalizedRelativePath = toPosix(childRelativePath);
822
+ const localRelativePath = toPosix(childRelativePath);
823
+ const normalizedRelativePath = options.mapRelativePath?.(localRelativePath) ?? localRelativePath;
739
824
  const childAbsolutePath = path.join(root, childRelativePath);
740
825
  if (dirent.isDirectory()) {
741
826
  if (options.skipDirectory(normalizedRelativePath, dirent.name)) {
@@ -1387,7 +1472,7 @@ async function ensureRemoteBase(vm, remoteProjectDir) {
1387
1472
  async function syncProject(vm, vmId, options, changes) {
1388
1473
  if (changes.changed.length > 0) {
1389
1474
  console.log(`VM ${vmId}: uploading ${changes.changed.length} changed project files...`);
1390
- const archive = await createProjectArchive(options.projectRoot, changes.changed);
1475
+ const archive = await createProjectArchive(changes.changed);
1391
1476
  try {
1392
1477
  await uploadArchiveInChunks(vm, vmId, archive, "/tmp/freestyle-sync-project.tgz", "project");
1393
1478
  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`);
@@ -1511,12 +1596,14 @@ async function runInstall(vm, projectRoot, remoteProjectDir) {
1511
1596
  await checkedExec(vm, `cd ${shellQuote(remoteProjectDir)} && ${installCommand}`, 20 * 60 * 1000);
1512
1597
  }
1513
1598
  async function detectInstallCommand(projectRoot) {
1599
+ if (await exists(path.join(projectRoot, "bun.lock")) || await exists(path.join(projectRoot, "bun.lockb")))
1600
+ return "bun install --frozen-lockfile";
1514
1601
  if (await exists(path.join(projectRoot, "pnpm-lock.yaml")))
1515
- return "corepack enable && pnpm install";
1602
+ return "corepack enable && pnpm install --frozen-lockfile";
1516
1603
  if (await exists(path.join(projectRoot, "yarn.lock")))
1517
- return "corepack enable && yarn install";
1518
- if (await exists(path.join(projectRoot, "package-lock.json")))
1519
- return "npm install";
1604
+ return "corepack enable && yarn_version=$(yarn --version) && case \"$yarn_version\" in 1.*) yarn install --frozen-lockfile ;; *) yarn install --immutable ;; esac";
1605
+ if (await exists(path.join(projectRoot, "package-lock.json")) || await exists(path.join(projectRoot, "npm-shrinkwrap.json")))
1606
+ return "npm ci";
1520
1607
  if (await exists(path.join(projectRoot, "requirements.txt")))
1521
1608
  return "python3 -m pip install -r requirements.txt";
1522
1609
  if (await exists(path.join(projectRoot, "pyproject.toml")))
@@ -1527,12 +1614,23 @@ async function detectInstallCommand(projectRoot) {
1527
1614
  return "go mod download";
1528
1615
  return undefined;
1529
1616
  }
1530
- async function createProjectArchive(projectRoot, entries) {
1617
+ async function createProjectArchive(entries) {
1531
1618
  const tempDir = await mkdtemp(path.join(tmpdir(), "freestyle-sync-"));
1532
- const listPath = path.join(tempDir, "files.list");
1619
+ const stagingDir = path.join(tempDir, "staging");
1533
1620
  const archivePath = path.join(tempDir, "project.tgz");
1534
- await writeFile(listPath, Buffer.from(entries.map((entry) => entry.relativePath).join("\0") + "\0"));
1535
- await createTar(["--null", "--no-xattrs", "-T", listPath, "-czf", archivePath, "-C", projectRoot]);
1621
+ await mkdir(stagingDir, { recursive: true });
1622
+ for (const entry of entries) {
1623
+ const destination = path.join(stagingDir, ...entry.relativePath.split("/"));
1624
+ await mkdir(path.dirname(destination), { recursive: true });
1625
+ if (entry.kind === "symlink") {
1626
+ await symlink(await readlink(entry.absolutePath), destination);
1627
+ }
1628
+ else {
1629
+ await copyFile(entry.absolutePath, destination);
1630
+ await chmod(destination, entry.mode);
1631
+ }
1632
+ }
1633
+ await createTar(["--no-xattrs", "-czf", archivePath, "-C", stagingDir, "."]);
1536
1634
  return archivePath;
1537
1635
  }
1538
1636
  async function createContextArchive(entries) {
@@ -1567,11 +1665,13 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
1567
1665
  const width = String(chunkCount - 1).length;
1568
1666
  const canRenderInlineProgress = process.stdout.isTTY;
1569
1667
  const logEvery = Math.max(1, Math.ceil(chunkCount / 4));
1668
+ const chunkManifest = [];
1570
1669
  let index = 0;
1571
1670
  for await (const chunk of createReadStream(archivePath, { highWaterMark: ARCHIVE_CHUNK_BYTES })) {
1572
- const chunkName = `${String(index).padStart(width, "0")}.chunk.b64`;
1671
+ const chunkName = `${String(index).padStart(width, "0")}.chunk`;
1573
1672
  const content = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1574
- await vm.fs.writeTextFile(`${chunkDir}/${chunkName}`, content.toString("base64"));
1673
+ chunkManifest.push(`${chunkName} ${content.length}`);
1674
+ await vm.fs.writeFile(`${chunkDir}/${chunkName}`, content);
1575
1675
  const uploadedChunks = index + 1;
1576
1676
  if (chunkCount > 1) {
1577
1677
  const progressMessage = `VM ${vmId}: uploaded ${uploadedChunks}/${chunkCount} ${label} archive chunks`;
@@ -1587,14 +1687,63 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
1587
1687
  }
1588
1688
  index += 1;
1589
1689
  }
1690
+ if (archiveSize > 0) {
1691
+ await vm.fs.writeTextFile(`${chunkDir}/manifest`, `${chunkManifest.join("\n")}\n`);
1692
+ }
1590
1693
  await checkedExec(vm, archiveSize === 0
1591
1694
  ? `: > ${shellQuote(remoteArchivePath)} && rm -rf ${shellQuote(chunkDir)}`
1592
1695
  : [
1593
1696
  "set -e",
1594
- `rm -f ${shellQuote(remoteArchivePath)}`,
1595
- `for chunk in ${shellQuote(chunkDir)}/*.chunk.b64; do`,
1596
- ` base64 -d "$chunk" >> ${shellQuote(remoteArchivePath)}`,
1697
+ `chunk_dir=${shellQuote(chunkDir)}`,
1698
+ `remote_archive=${shellQuote(remoteArchivePath)}`,
1699
+ `manifest="$chunk_dir/manifest"`,
1700
+ "deadline=$(( $(date +%s) + 60 ))",
1701
+ "while :; do",
1702
+ " validation_error=",
1703
+ " validated_chunks=0",
1704
+ " if [ ! -f \"$manifest\" ]; then",
1705
+ " validation_error='missing chunk manifest'",
1706
+ " else",
1707
+ " actual_chunks=$(find \"$chunk_dir\" -maxdepth 1 -type f -name '*.chunk' | wc -l | tr -d '[:space:]')",
1708
+ ` if [ "$actual_chunks" != ${shellQuote(String(chunkCount))} ]; then`,
1709
+ ` validation_error="expected ${chunkCount} chunks, found $actual_chunks"`,
1710
+ " else",
1711
+ " while IFS=' ' read -r chunk_name expected_size; do",
1712
+ " [ -n \"$chunk_name\" ] || continue",
1713
+ " chunk_path=\"$chunk_dir/$chunk_name\"",
1714
+ " if [ ! -f \"$chunk_path\" ]; then",
1715
+ " validation_error=\"missing chunk $chunk_name\"",
1716
+ " break",
1717
+ " fi",
1718
+ " actual_chunk_size=$(wc -c < \"$chunk_path\" | tr -d '[:space:]')",
1719
+ " if [ \"$actual_chunk_size\" != \"$expected_size\" ]; then",
1720
+ " validation_error=\"chunk $chunk_name expected $expected_size bytes, got $actual_chunk_size\"",
1721
+ " break",
1722
+ " fi",
1723
+ " validated_chunks=$((validated_chunks + 1))",
1724
+ " done < \"$manifest\"",
1725
+ ` if [ "$validated_chunks" != ${shellQuote(String(chunkCount))} ] && [ -z "$validation_error" ]; then`,
1726
+ ` validation_error="expected ${chunkCount} manifest entries, found $validated_chunks"`,
1727
+ " fi",
1728
+ " fi",
1729
+ " fi",
1730
+ " if [ -z \"$validation_error\" ]; then",
1731
+ " break",
1732
+ " fi",
1733
+ " if [ \"$(date +%s)\" -ge \"$deadline\" ]; then",
1734
+ ` rm -f ${shellQuote(remoteArchivePath)}`,
1735
+ ` rm -rf ${shellQuote(chunkDir)}`,
1736
+ ` echo ${shellQuote(`archive chunk validation failed for ${label}`)} >&2`,
1737
+ " echo \"$validation_error\" >&2",
1738
+ " exit 1",
1739
+ " fi",
1740
+ " sleep 1",
1597
1741
  "done",
1742
+ `rm -f ${shellQuote(remoteArchivePath)}`,
1743
+ "while IFS=' ' read -r chunk_name expected_size; do",
1744
+ " [ -n \"$chunk_name\" ] || continue",
1745
+ " cat \"$chunk_dir/$chunk_name\" >> \"$remote_archive\"",
1746
+ "done < \"$manifest\"",
1598
1747
  `actual_size=$(wc -c < ${shellQuote(remoteArchivePath)} | tr -d '[:space:]')`,
1599
1748
  `actual_hash=$(sha256sum ${shellQuote(remoteArchivePath)} | awk '{print $1}')`,
1600
1749
  `if [ "$actual_size" != ${shellQuote(String(archiveSize))} ] || [ "$actual_hash" != ${shellQuote(archiveHash)} ]; then`,
@@ -1605,7 +1754,7 @@ async function uploadArchiveInChunks(vm, vmId, archivePath, remoteArchivePath, l
1605
1754
  " exit 1",
1606
1755
  "fi",
1607
1756
  `rm -rf ${shellQuote(chunkDir)}`,
1608
- ].join("\n"));
1757
+ ].join("\n"), 5 * 60 * MS_PER_SECOND);
1609
1758
  }
1610
1759
  async function mkdirRemote(vm, directories) {
1611
1760
  for (const chunk of chunkArray(directories, 50)) {
@@ -0,0 +1,127 @@
1
+ export type VmProviderName = "freestyle" | "apple-container";
2
+ export type CliOptions = {
3
+ projectRoot: string;
4
+ cachePath: string;
5
+ remoteProjectDir: string;
6
+ provider: VmProviderName;
7
+ name: string;
8
+ vmId?: string;
9
+ appleContainerImage: string;
10
+ idleTimeoutSeconds?: number;
11
+ yes: boolean;
12
+ dryRun: boolean;
13
+ disablePlugins: string[];
14
+ enablePlugins: string[];
15
+ resetPluginPrefs: boolean;
16
+ listPlugins: boolean;
17
+ includeAuth: boolean;
18
+ includeAgentContext: boolean;
19
+ includeGitDir: boolean;
20
+ includeAllCopilotWorkspaces: boolean;
21
+ snapshot: boolean;
22
+ skipSync: boolean;
23
+ install: boolean;
24
+ autoSsh: boolean;
25
+ envKeys: string[];
26
+ };
27
+ export type ContextCandidate = {
28
+ source: string;
29
+ remoteRoot: string;
30
+ label: string;
31
+ sensitive: boolean;
32
+ preferenceKey: string;
33
+ promptLabel: string;
34
+ };
35
+ export type RemoteVm = {
36
+ exec(args: {
37
+ command: string;
38
+ timeoutMs?: number;
39
+ }): Promise<{
40
+ stdout?: string | null;
41
+ stderr?: string | null;
42
+ statusCode?: number | null;
43
+ }>;
44
+ fs: {
45
+ writeFile(path: string, content: Buffer): Promise<void>;
46
+ writeTextFile(path: string, content: string): Promise<void>;
47
+ };
48
+ };
49
+ export type UploadArchiveInChunks = (vm: RemoteVm, vmId: string, archivePath: string, remoteArchivePath: string, label: string) => Promise<void>;
50
+ export type ExecFileAsync = (file: string, args: string[]) => Promise<{
51
+ stdout: string;
52
+ stderr: string;
53
+ }>;
54
+ export type PushvmPluginUtils = {
55
+ checkedExec(vm: RemoteVm, command: string, timeoutMs?: number): Promise<{
56
+ stdout?: string | null;
57
+ stderr?: string | null;
58
+ statusCode?: number | null;
59
+ }>;
60
+ createTar(args: string[]): Promise<void>;
61
+ uploadArchiveInChunks: UploadArchiveInChunks;
62
+ execFileAsync: ExecFileAsync;
63
+ shellQuote(value: string): string;
64
+ md5(value: string): string;
65
+ delay(ms: number): Promise<void>;
66
+ };
67
+ export type PushvmPluginContext = {
68
+ options: CliOptions;
69
+ utils: PushvmPluginUtils;
70
+ };
71
+ export type RemoteHookContext = PushvmPluginContext & {
72
+ vm: RemoteVm;
73
+ vmId: string;
74
+ };
75
+ export type EditorScheme = string;
76
+ export type EditorHookContext = RemoteHookContext & {
77
+ scheme: EditorScheme;
78
+ remoteWorkspaceUri: string;
79
+ };
80
+ export type ConnectHookContext = RemoteHookContext & {
81
+ contextCandidates: ContextCandidate[];
82
+ runBeforeOpenRemoteEditor(args: {
83
+ scheme: EditorScheme;
84
+ remoteWorkspaceUri: string;
85
+ }): Promise<string[]>;
86
+ runAfterOpenRemoteEditor(args: {
87
+ scheme: EditorScheme;
88
+ remoteWorkspaceUri: string;
89
+ }): Promise<string[]>;
90
+ };
91
+ export type ProjectSyncInclude = string | {
92
+ source: string;
93
+ target?: string;
94
+ };
95
+ export type ProjectSyncConfig = {
96
+ exclude?: string[];
97
+ include?: ProjectSyncInclude[];
98
+ };
99
+ export type PushvmPlugin = {
100
+ name: string;
101
+ collectEnvironment?(context: PushvmPluginContext): Record<string, string>;
102
+ discoverContextCandidates?(context: PushvmPluginContext): Promise<ContextCandidate[]> | ContextCandidate[];
103
+ configureProjectSync?(context: PushvmPluginContext): Promise<ProjectSyncConfig | void> | ProjectSyncConfig | void;
104
+ shouldSkipContextDirectory?(relativePath: string, name: string): boolean;
105
+ isProtectedRemotePath?(remotePath: string): boolean;
106
+ afterProjectSync?(context: RemoteHookContext): Promise<void> | void;
107
+ afterContextSync?(context: RemoteHookContext & {
108
+ changedRemotePaths: string[];
109
+ }): Promise<void> | void;
110
+ beforeSnapshot?(context: RemoteHookContext): Promise<void> | void;
111
+ afterSync?(context: RemoteHookContext & {
112
+ contextCandidates: ContextCandidate[];
113
+ }): Promise<string[] | void> | string[] | void;
114
+ connect?(context: ConnectHookContext): Promise<boolean | void> | boolean | void;
115
+ beforeOpenRemoteEditor?(context: EditorHookContext & {
116
+ contextCandidates: ContextCandidate[];
117
+ }): Promise<string[] | void> | string[] | void;
118
+ afterOpenRemoteEditor?(context: EditorHookContext & {
119
+ contextCandidates: ContextCandidate[];
120
+ }): Promise<string[] | void> | string[] | void;
121
+ };
122
+ export type PushvmConfig = {
123
+ plugins: PushvmPlugin[];
124
+ sync?: ProjectSyncConfig;
125
+ };
126
+ export declare function definePlugin(plugin: PushvmPlugin): PushvmPlugin;
127
+ export declare function defineConfig(config: PushvmConfig): PushvmConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freestyle-sync",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "main": "dist/src/main.js",
6
6
  "exports": {
@@ -25,7 +25,9 @@
25
25
  "publish": "npm run check && npm run build && npm run publish:workspaces && npm run publish:root",
26
26
  "publish:root": "node scripts/publish-root.mjs",
27
27
  "publish:workspaces": "node scripts/publish-workspaces.mjs",
28
- "start": "node src/main.ts"
28
+ "start": "node src/main.ts",
29
+ "version:patch": "node scripts/version-workspaces.mjs patch",
30
+ "version:workspaces": "node scripts/version-workspaces.mjs"
29
31
  },
30
32
  "dependencies": {
31
33
  "dotenv": "^17.4.2",