windows-development-sandbox 1.1.0 → 1.4.1

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 (222) hide show
  1. package/.sandbox-config.example.json +2 -1
  2. package/README.md +3 -2
  3. package/config.example.json +39 -5
  4. package/dist/cli/args.d.ts +3 -0
  5. package/dist/cli/args.js +3 -1
  6. package/dist/cli/boot-progress.d.ts +22 -0
  7. package/dist/cli/boot-progress.js +59 -0
  8. package/dist/cli/commands/accounts.js +2 -2
  9. package/dist/cli/commands/agent-commands.js +1 -1
  10. package/dist/cli/commands/context.js +2 -1
  11. package/dist/cli/commands/data/clean.js +6 -1
  12. package/dist/cli/commands/data/collect.js +3 -3
  13. package/dist/cli/commands/index.d.ts +1 -1
  14. package/dist/cli/commands/index.js +1 -1
  15. package/dist/cli/commands/launch.d.ts +5 -2
  16. package/dist/cli/commands/launch.js +113 -25
  17. package/dist/cli/commands/maintenance.d.ts +2 -0
  18. package/dist/cli/commands/maintenance.js +70 -4
  19. package/dist/cli/commands/project.d.ts +10 -6
  20. package/dist/cli/commands/project.js +42 -3
  21. package/dist/cli/commands/restart.js +17 -7
  22. package/dist/cli/commands/sessions.js +5 -4
  23. package/dist/cli/commands/user-config.js +1 -1
  24. package/dist/cli/index.js +6 -4
  25. package/dist/cli/manifest.js +6 -1
  26. package/dist/cli/menu/apps.d.ts +21 -0
  27. package/dist/cli/menu/apps.js +122 -0
  28. package/dist/cli/menu/command-form.d.ts +28 -0
  29. package/dist/cli/menu/command-form.js +219 -0
  30. package/dist/cli/menu/index.d.ts +26 -0
  31. package/dist/cli/menu/index.js +180 -0
  32. package/dist/cli/menu/settings/categories.d.ts +9 -0
  33. package/dist/cli/menu/settings/categories.js +28 -0
  34. package/dist/cli/menu/settings/config-file.d.ts +40 -0
  35. package/dist/cli/menu/settings/config-file.js +71 -0
  36. package/dist/cli/menu/settings/editors.d.ts +30 -0
  37. package/dist/cli/menu/settings/editors.js +305 -0
  38. package/dist/cli/menu/settings/index.d.ts +32 -0
  39. package/dist/cli/menu/settings/index.js +136 -0
  40. package/dist/cli/menu/settings/schema-node.d.ts +62 -0
  41. package/dist/cli/menu/settings/schema-node.js +104 -0
  42. package/dist/cli/{menu-multi.d.ts → menu/several.d.ts} +10 -2
  43. package/dist/cli/menu/several.js +102 -0
  44. package/dist/cli/progress.d.ts +15 -0
  45. package/dist/cli/progress.js +52 -0
  46. package/dist/cli/tui/app.d.ts +39 -0
  47. package/dist/cli/tui/app.js +52 -0
  48. package/dist/cli/tui/dialogs.d.ts +57 -0
  49. package/dist/cli/tui/dialogs.js +162 -0
  50. package/dist/cli/tui/frame.d.ts +18 -0
  51. package/dist/cli/tui/frame.js +27 -0
  52. package/dist/cli/tui/index.d.ts +7 -0
  53. package/dist/cli/tui/index.js +8 -0
  54. package/dist/cli/tui/list-screen.d.ts +101 -0
  55. package/dist/cli/tui/list-screen.js +299 -0
  56. package/dist/cli/tui/terminal.d.ts +37 -0
  57. package/dist/cli/tui/terminal.js +89 -0
  58. package/dist/cli/tui/text.d.ts +20 -0
  59. package/dist/cli/tui/text.js +105 -0
  60. package/dist/cli/tui/theme.d.ts +45 -0
  61. package/dist/cli/tui/theme.js +30 -0
  62. package/dist/core/config.js +10 -7
  63. package/dist/core/local-config.d.ts +31 -1
  64. package/dist/core/local-config.js +72 -7
  65. package/dist/core/project-policy.d.ts +50 -3
  66. package/dist/core/project-policy.js +90 -4
  67. package/dist/core/schema/apps.d.ts +40 -0
  68. package/dist/core/schema/apps.js +27 -0
  69. package/dist/core/schema/config.d.ts +69 -3
  70. package/dist/core/schema/config.js +18 -6
  71. package/dist/core/schema/profile.d.ts +5 -0
  72. package/dist/core/schema/profile.js +4 -2
  73. package/dist/core/schema/projects.d.ts +1 -0
  74. package/dist/core/schema/projects.js +1 -0
  75. package/dist/core/schema/sandbox.d.ts +29 -1
  76. package/dist/core/schema/sandbox.js +29 -1
  77. package/dist/core/schema/toolchains.d.ts +3 -0
  78. package/dist/core/schema/toolchains.js +6 -1
  79. package/dist/core/types.d.ts +24 -1
  80. package/dist/core/util.d.ts +2 -0
  81. package/dist/core/util.js +2 -0
  82. package/dist/core/workspace.d.ts +21 -4
  83. package/dist/core/workspace.js +99 -21
  84. package/dist/guest/apps/index.d.ts +10 -0
  85. package/dist/guest/apps/index.js +206 -0
  86. package/dist/guest/apps/resolvers.d.ts +8 -0
  87. package/dist/guest/apps/resolvers.js +90 -0
  88. package/dist/guest/audit-parse.d.ts +7 -0
  89. package/dist/guest/audit-parse.js +24 -0
  90. package/dist/guest/audit.d.ts +4 -0
  91. package/dist/guest/audit.js +47 -0
  92. package/dist/guest/daemon/relays.d.ts +1 -0
  93. package/dist/guest/daemon/relays.js +18 -0
  94. package/dist/guest/daemon.js +4 -0
  95. package/dist/guest/env.d.ts +7 -2
  96. package/dist/guest/env.js +17 -3
  97. package/dist/guest/helpers.d.ts +1 -1
  98. package/dist/guest/main.js +20 -3
  99. package/dist/guest/netproxy.d.ts +1 -0
  100. package/dist/guest/netproxy.js +100 -0
  101. package/dist/guest/ptyhost.js +14 -4
  102. package/dist/guest/setup/claude.d.ts +0 -4
  103. package/dist/guest/setup/claude.js +48 -2
  104. package/dist/guest/setup/network.d.ts +9 -0
  105. package/dist/guest/setup/network.js +97 -4
  106. package/dist/guest/setup/user.js +4 -0
  107. package/dist/guest/toolchains/index.js +29 -3
  108. package/dist/guest/toolchains/native/mesa.js +13 -3
  109. package/dist/guest/toolchains/plan.d.ts +3 -1
  110. package/dist/guest/toolchains/plan.js +53 -9
  111. package/dist/guest/toolchains/python.js +26 -2
  112. package/dist/guest/toolchains/vscode-profile.d.ts +30 -0
  113. package/dist/guest/toolchains/vscode-profile.js +69 -0
  114. package/dist/guest/toolchains/vscode.d.ts +17 -0
  115. package/dist/guest/toolchains/vscode.js +87 -1
  116. package/dist/host/attach.js +2 -1
  117. package/dist/host/audit/collect.d.ts +34 -0
  118. package/dist/host/audit/collect.js +126 -0
  119. package/dist/host/audit/log.d.ts +46 -0
  120. package/dist/host/audit/log.js +143 -0
  121. package/dist/host/channel.d.ts +2 -2
  122. package/dist/host/channel.js +3 -2
  123. package/dist/host/generate.d.ts +7 -1
  124. package/dist/host/generate.js +23 -6
  125. package/dist/host/join.d.ts +7 -0
  126. package/dist/host/join.js +23 -9
  127. package/dist/host/launch/mappings.d.ts +7 -2
  128. package/dist/host/launch/mappings.js +68 -18
  129. package/dist/host/launch/projects.d.ts +2 -0
  130. package/dist/host/launch/projects.js +11 -5
  131. package/dist/host/launch/stage.js +1 -1
  132. package/dist/host/motw.d.ts +17 -0
  133. package/dist/host/motw.js +74 -0
  134. package/dist/host/platform.js +3 -0
  135. package/dist/host/port-forward.d.ts +8 -0
  136. package/dist/host/port-forward.js +37 -0
  137. package/dist/host/projects.d.ts +25 -2
  138. package/dist/host/projects.js +54 -5
  139. package/dist/host/repo-guard.d.ts +10 -3
  140. package/dist/host/repo-guard.js +142 -14
  141. package/dist/host/stops.d.ts +67 -0
  142. package/dist/host/stops.js +126 -0
  143. package/dist/host/terminal.d.ts +8 -0
  144. package/dist/host/terminal.js +45 -1
  145. package/dist/host/workspace-view/index.d.ts +20 -0
  146. package/dist/host/workspace-view/index.js +53 -0
  147. package/dist/host/workspace-view/rules.d.ts +26 -0
  148. package/dist/host/workspace-view/rules.js +93 -0
  149. package/dist/host/workspace-view/view.d.ts +50 -0
  150. package/dist/host/workspace-view/view.js +281 -0
  151. package/dist/runtime/daemon.js +1387 -558
  152. package/dist/runtime/db.js +199 -14
  153. package/dist/runtime/main.js +3082 -631
  154. package/dist/runtime/netproxy.js +25948 -0
  155. package/dist/runtime/view.js +531 -148
  156. package/dist/selftest/audit.test.d.ts +2 -0
  157. package/dist/selftest/audit.test.js +57 -0
  158. package/dist/selftest/channel.test.js +45 -2
  159. package/dist/selftest/cli.test.js +47 -17
  160. package/dist/selftest/fixtures.d.ts +47 -1
  161. package/dist/selftest/fixtures.js +3 -1
  162. package/dist/selftest/generate.test.js +7 -2
  163. package/dist/selftest/index.js +6 -2
  164. package/dist/selftest/live-network.test.d.ts +2 -0
  165. package/dist/selftest/live-network.test.js +63 -0
  166. package/dist/selftest/live-terminal.test.d.ts +2 -0
  167. package/dist/selftest/live-terminal.test.js +169 -0
  168. package/dist/selftest/local-config.test.js +42 -4
  169. package/dist/selftest/mirror.test.js +36 -7
  170. package/dist/selftest/network.test.js +12 -0
  171. package/dist/selftest/projects.test.js +77 -8
  172. package/dist/selftest/repo-guard.test.js +59 -1
  173. package/dist/selftest/sync.test.js +50 -10
  174. package/dist/selftest/toolchains.test.js +30 -0
  175. package/dist/selftest/workspace-view.test.d.ts +2 -0
  176. package/dist/selftest/workspace-view.test.js +89 -0
  177. package/dist/shared/apps.d.ts +49 -0
  178. package/dist/shared/apps.js +114 -0
  179. package/dist/shared/console-input.d.ts +11 -0
  180. package/dist/shared/console-input.js +61 -0
  181. package/dist/shared/download.d.ts +3 -0
  182. package/dist/shared/download.js +25 -2
  183. package/dist/shared/guest-paths.d.ts +27 -0
  184. package/dist/shared/guest-paths.js +68 -0
  185. package/dist/shared/netpolicy.d.ts +27 -0
  186. package/dist/shared/netpolicy.js +51 -0
  187. package/dist/shared/projects.d.ts +27 -4
  188. package/dist/shared/projects.js +72 -13
  189. package/dist/shared/pty-client.d.ts +26 -4
  190. package/dist/shared/pty-client.js +204 -25
  191. package/dist/shared/rundir.d.ts +2 -0
  192. package/dist/shared/rundir.js +2 -0
  193. package/dist/shared/schema.d.ts +34 -0
  194. package/dist/shared/schema.js +14 -0
  195. package/dist/shared/system.d.ts +1 -0
  196. package/dist/shared/system.js +1 -0
  197. package/dist/sync/claude-mem.js +3 -2
  198. package/dist/sync/history.d.ts +6 -0
  199. package/dist/sync/history.js +26 -11
  200. package/dist/sync/watcher.js +78 -6
  201. package/package.json +2 -1
  202. package/sandbox/cargoshim.cs +56 -0
  203. package/sandbox/cpucap.cs +33 -0
  204. package/sandbox/cpucap.ps1 +4 -35
  205. package/sandbox/logon.cmd +6 -0
  206. package/sandbox/memlog.ps1 +34 -0
  207. package/sandbox/pagefile.cs +31 -0
  208. package/sandbox/pagefile.ps1 +21 -0
  209. package/sandbox/ptyhost.cs +162 -7
  210. package/schemas/config.schema.json +318 -3
  211. package/schemas/sandbox-config.schema.json +129 -14
  212. package/dist/cli/menu-multi.js +0 -82
  213. package/dist/cli/menu.d.ts +0 -14
  214. package/dist/cli/menu.js +0 -290
  215. package/dist/cli/settings/edit.d.ts +0 -13
  216. package/dist/cli/settings/edit.js +0 -19
  217. package/dist/cli/settings/index.d.ts +0 -8
  218. package/dist/cli/settings/index.js +0 -131
  219. package/dist/cli/settings/prompts.d.ts +0 -7
  220. package/dist/cli/settings/prompts.js +0 -48
  221. package/dist/cli/settings/schema.d.ts +0 -33
  222. package/dist/cli/settings/schema.js +0 -29
@@ -0,0 +1,20 @@
1
+ import type { Config } from '../../core/types.js';
2
+ import { type ViewOptions } from './view.js';
3
+ export { IgnoreRules } from './rules.js';
4
+ export { WorkspaceView, type SyncStats, type ViewOptions } from './view.js';
5
+ /** The project's .git as the sandbox sees it: next to the workspace (C:\Workspace\app -> C:\Workspace\app.git). */
6
+ export declare const guestGitDir: (guestWorkspace: string) => string;
7
+ export interface ViewPlan {
8
+ options: ViewOptions; /** The project's real .git folder, mapped at options.guestGitDir (null: none). */
9
+ hostGit: string | null;
10
+ note?: string;
11
+ }
12
+ /**
13
+ * The view for a launch, or null when the folder itself is mapped: workspaceFilter off, or a git worktree (its .git is
14
+ * a file pointing elsewhere, which a view cannot follow; then the folder is mapped as before and a note says so).
15
+ */
16
+ export declare const planView: (config: Config, storageRoot: string, workspaceHost: string) => ViewPlan | null;
17
+ /** Why a filtered launch maps the folder itself after all (for the launch notes). */
18
+ export declare const viewSkipped: (config: Config, workspaceHost: string) => string | null;
19
+ export declare const saveActiveViews: (storageRoot: string, dataPath: string, views: ViewOptions[]) => void;
20
+ export declare const readActiveViews: (storageRoot: string, dataPath: string) => ViewOptions[];
@@ -0,0 +1,53 @@
1
+ // workspaceFilter in practice: which view a launch maps instead of the project, the project's .git mapped beside it,
2
+ // and the host-only list of views the background watcher keeps in step while the sandbox runs.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { readValid, writeJsonAtomic } from '../../shared/rundir.js';
7
+ import { WorkspaceView } from './view.js';
8
+ export { IgnoreRules } from './rules.js';
9
+ export { WorkspaceView } from './view.js';
10
+ /** The project's .git as the sandbox sees it: next to the workspace (C:\Workspace\app -> C:\Workspace\app.git). */
11
+ export const guestGitDir = (guestWorkspace) => guestWorkspace.replace(/[\\/]+$/, '') + '.git';
12
+ /**
13
+ * The view for a launch, or null when the folder itself is mapped: workspaceFilter off, or a git worktree (its .git is
14
+ * a file pointing elsewhere, which a view cannot follow; then the folder is mapped as before and a note says so).
15
+ */
16
+ export const planView = (config, storageRoot, workspaceHost) => {
17
+ if (!config.workspaceFilter.enabled)
18
+ return null;
19
+ const git = path.join(workspaceHost, '.git');
20
+ let gitIsDir = false;
21
+ try {
22
+ const s = fs.lstatSync(git);
23
+ if (s.isFile())
24
+ return null;
25
+ gitIsDir = s.isDirectory() && !s.isSymbolicLink();
26
+ }
27
+ catch { /* no .git */ }
28
+ // git access "none": the agent gets no .git at all.
29
+ const hostGit = gitIsDir && config.provisioning.git.enabled && config.provisioning.git.access !== 'none' ? git : null;
30
+ return {
31
+ options: { ...WorkspaceView.pathsFor(storageRoot, workspaceHost), exclude: config.workspaceFilter.exclude, include: config.workspaceFilter.include, guestGitDir: hostGit ? guestGitDir(config.guestWorkspace) : null },
32
+ hostGit,
33
+ };
34
+ };
35
+ /** Why a filtered launch maps the folder itself after all (for the launch notes). */
36
+ export const viewSkipped = (config, workspaceHost) => {
37
+ if (!config.workspaceFilter.enabled)
38
+ return null;
39
+ try {
40
+ if (fs.lstatSync(path.join(workspaceHost, '.git')).isFile())
41
+ return 'workspaceFilter: ' + workspaceHost + ' is a git worktree (.git is a file); the folder is mapped as it is, git-ignored files included.';
42
+ }
43
+ catch { /* fine */ }
44
+ return null;
45
+ };
46
+ // ---- the views the watcher keeps in step (host-only, next to the sync state) ------------------------------------------
47
+ const ActiveView = z.object({
48
+ host: z.string().max(1024), view: z.string().max(1024), state: z.string().max(1024),
49
+ exclude: z.array(z.string().max(500)).max(500), include: z.array(z.string().max(500)).max(500), guestGitDir: z.string().max(1024).nullable(),
50
+ });
51
+ const activeFile = (storageRoot, dataPath) => path.join(storageRoot, 'sync', path.basename(dataPath) + '-views.json');
52
+ export const saveActiveViews = (storageRoot, dataPath, views) => writeJsonAtomic(activeFile(storageRoot, dataPath), views.map((v) => ({ host: v.host, view: v.view, state: v.state, exclude: v.exclude ?? [], include: v.include ?? [], guestGitDir: v.guestGitDir ?? null })));
53
+ export const readActiveViews = (storageRoot, dataPath) => readValid(z.array(ActiveView).max(64), activeFile(storageRoot, dataPath)) ?? [];
@@ -0,0 +1,26 @@
1
+ export declare class IgnoreRules {
2
+ private readonly root;
3
+ private readonly include;
4
+ /** One matcher per folder that has a .gitignore (null = the folder has none), by its path relative to the root. */
5
+ private readonly perFolder;
6
+ private readonly extra;
7
+ private readonly forced;
8
+ /** `root`: the project folder on this PC (its .gitignore files are the rules); `exclude` / `include`: your patterns. */
9
+ constructor(root: string, exclude?: string[], include?: string[]);
10
+ /** One of your include patterns lets this path in, whatever .gitignore says (even inside an ignored folder). */
11
+ isForced(rel: string): boolean;
12
+ /** Could one of your include patterns match something inside this ignored folder (so a walk must look inside)? */
13
+ mayIncludeInside(dir: string): boolean;
14
+ /** Forget the .gitignore files read so far (one of them changed). */
15
+ reset(): void;
16
+ private matcher;
17
+ /**
18
+ * Is this one entry ignored, given that its parent folder is not? `rel` uses / and has no leading slash. Your include
19
+ * patterns win over everything, your exclude patterns over .gitignore.
20
+ */
21
+ private ignoredHere;
22
+ /** Is `rel` (a file, or a folder with isDir) ignored, counting its folders: inside an ignored folder is ignored. */
23
+ isIgnored(rel: string, isDir?: boolean): boolean;
24
+ /** For a walk that already checked the parent folder: is this child ignored? */
25
+ isIgnoredChild(rel: string, isDir: boolean): boolean;
26
+ }
@@ -0,0 +1,93 @@
1
+ // Which files of a project git ignores, read the way git reads them: the .gitignore in every folder (deeper files win,
2
+ // `!pattern` lets a file back in), .git/info/exclude, and nothing inside an ignored folder ever comes back. Plus your
3
+ // own extra patterns (workspaceFilter.exclude) and exceptions (workspaceFilter.include). Bounded reads: a .gitignore
4
+ // over 256 KB is treated as empty.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import ignore from 'ignore';
8
+ const MAX_RULES_BYTES = 256 * 1024;
9
+ const readRules = (file) => {
10
+ try {
11
+ const stat = fs.lstatSync(file);
12
+ return stat.isFile() && stat.size <= MAX_RULES_BYTES ? fs.readFileSync(file, 'utf8') : '';
13
+ }
14
+ catch {
15
+ return '';
16
+ }
17
+ };
18
+ export class IgnoreRules {
19
+ root;
20
+ include;
21
+ /** One matcher per folder that has a .gitignore (null = the folder has none), by its path relative to the root. */
22
+ perFolder = new Map();
23
+ extra;
24
+ forced;
25
+ /** `root`: the project folder on this PC (its .gitignore files are the rules); `exclude` / `include`: your patterns. */
26
+ constructor(root, exclude = [], include = []) {
27
+ this.root = root;
28
+ this.include = include;
29
+ this.extra = ignore().add(exclude);
30
+ this.forced = ignore().add(include);
31
+ }
32
+ /** One of your include patterns lets this path in, whatever .gitignore says (even inside an ignored folder). */
33
+ isForced(rel) { return this.include.length > 0 && this.forced.ignores(rel); }
34
+ /** Could one of your include patterns match something inside this ignored folder (so a walk must look inside)? */
35
+ mayIncludeInside(dir) {
36
+ return this.include.some((p) => { const clean = p.replace(/^\//, ''); return clean.startsWith('**') || clean.startsWith(dir + '/'); });
37
+ }
38
+ /** Forget the .gitignore files read so far (one of them changed). */
39
+ reset() { this.perFolder.clear(); }
40
+ matcher(folder) {
41
+ if (this.perFolder.has(folder))
42
+ return this.perFolder.get(folder);
43
+ const dir = path.join(this.root, ...folder.split('/').filter(Boolean));
44
+ let text = readRules(path.join(dir, '.gitignore'));
45
+ if (folder === '')
46
+ text += '\n' + readRules(path.join(this.root, '.git', 'info', 'exclude'));
47
+ const matcher = text.trim() ? ignore().add(text) : null;
48
+ this.perFolder.set(folder, matcher);
49
+ return matcher;
50
+ }
51
+ /**
52
+ * Is this one entry ignored, given that its parent folder is not? `rel` uses / and has no leading slash. Your include
53
+ * patterns win over everything, your exclude patterns over .gitignore.
54
+ */
55
+ ignoredHere(rel, isDir) {
56
+ const probe = isDir ? rel + '/' : rel;
57
+ if (this.forced.ignores(probe))
58
+ return false;
59
+ if (this.extra.ignores(probe))
60
+ return true;
61
+ const parts = rel.split('/');
62
+ let ignored = false;
63
+ for (let i = 0; i < parts.length; i++) {
64
+ const folder = parts.slice(0, i).join('/');
65
+ const m = this.matcher(folder);
66
+ if (!m)
67
+ continue;
68
+ const below = parts.slice(i).join('/') + (isDir ? '/' : '');
69
+ const result = m.test(below);
70
+ if (result.ignored)
71
+ ignored = true;
72
+ else if (result.unignored)
73
+ ignored = false;
74
+ }
75
+ return ignored;
76
+ }
77
+ /** Is `rel` (a file, or a folder with isDir) ignored, counting its folders: inside an ignored folder is ignored. */
78
+ isIgnored(rel, isDir = false) {
79
+ const parts = rel.split('/').filter(Boolean);
80
+ if (parts[0] === '.git')
81
+ return true;
82
+ if (!isDir && this.isForced(parts.join('/')))
83
+ return false;
84
+ for (let i = 1; i < parts.length; i++)
85
+ if (this.ignoredHere(parts.slice(0, i).join('/'), true))
86
+ return true;
87
+ return this.ignoredHere(parts.join('/'), isDir);
88
+ }
89
+ /** For a walk that already checked the parent folder: is this child ignored? */
90
+ isIgnoredChild(rel, isDir) {
91
+ return rel.split('/')[0] === '.git' || this.ignoredHere(rel, isDir);
92
+ }
93
+ }
@@ -0,0 +1,50 @@
1
+ import { IgnoreRules } from './rules.js';
2
+ export interface ViewPaths {
3
+ /** The project on this PC. */
4
+ host: string;
5
+ /** Its filtered copy (mapped into the sandbox as the workspace). */
6
+ view: string;
7
+ /** Host-only: what both sides looked like after the last sync. */
8
+ state: string;
9
+ }
10
+ export interface ViewOptions extends ViewPaths {
11
+ exclude?: string[];
12
+ include?: string[];
13
+ /** The project's .git as the sandbox sees it (written into the view's .git file); null = the project has no .git. */
14
+ guestGitDir?: string | null;
15
+ note?: (message: string) => void;
16
+ }
17
+ export interface SyncStats {
18
+ toView: number;
19
+ toHost: number;
20
+ deleted: number;
21
+ conflicts: number;
22
+ skipped: number;
23
+ }
24
+ export declare class WorkspaceView {
25
+ private readonly o;
26
+ readonly rules: IgnoreRules;
27
+ private seen;
28
+ constructor(o: ViewOptions);
29
+ /** Where a project's view and its sync state live: <storage>\views\<id> and <storage>\views\<id>.json. */
30
+ static pathsFor(storageRoot: string, host: string): ViewPaths;
31
+ private abs;
32
+ /** Creates the view (with its .git file) and brings it fully in step with the project. */
33
+ prepare(): SyncStats;
34
+ /** Every file not ignored, on one side (bounded, links skipped, ignored folders not entered). */
35
+ private walk;
36
+ /** A full pass over both sides and everything synced before. */
37
+ syncAll(): SyncStats;
38
+ /** Paths that changed (from file events): files, or folders whose contents are walked. */
39
+ syncChanged(rels: Iterable<string>): SyncStats;
40
+ private syncPaths;
41
+ private syncOne;
42
+ /** Copies (keeping the file time) or deletes on the other side. */
43
+ private carry;
44
+ private pruneEmpty;
45
+ /**
46
+ * Keeps both sides in step while it runs: file events from both, batched every 300 ms, plus a full pass every
47
+ * `fullEveryMs` to catch anything an event missed. Returns a function that stops it.
48
+ */
49
+ watch(fullEveryMs?: number): () => void;
50
+ }
@@ -0,0 +1,281 @@
1
+ // A project as the sandbox sees it with workspaceFilter on: a copy of the project on this PC (in storage, outside the
2
+ // project) that holds only what git does not ignore, kept in step with the project both ways. The sandbox maps the view
3
+ // instead of the project, and the project's real .git separately (a "gitdir:" file in the view points at it), so git
4
+ // works as usual. What the sandbox creates that is ignored (node_modules, build output) stays in the view; what is
5
+ // ignored in your project (.env, your node_modules) never enters the sandbox.
6
+ //
7
+ // Each file is compared on both sides with what both looked like after the last sync (size + time): a change on one
8
+ // side is copied to the other, a deletion is repeated, and a file changed on both sides keeps yours in the project and
9
+ // the sandbox's next to it as <name>.sandbox-conflict-<time><ext>. Copies keep the file time, so an unchanged file is
10
+ // never copied again. Links are never followed or created on either side.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+ import { z } from 'zod';
15
+ import { readValid, writeJsonAtomic } from '../../shared/rundir.js';
16
+ import { IgnoreRules } from './rules.js';
17
+ const Manifest = z.record(z.string().max(1024), z.tuple([z.number(), z.number(), z.number(), z.number()]));
18
+ /** Most entries one walk looks at: a sandbox can create files faster than they are worth syncing. */
19
+ const MAX_ENTRIES = 300000;
20
+ const stamp = (file) => {
21
+ try {
22
+ const s = fs.lstatSync(file);
23
+ return s.isFile() ? [s.size, Math.round(s.mtimeMs)] : [-1, -1];
24
+ }
25
+ catch {
26
+ return [-1, -1];
27
+ }
28
+ };
29
+ const sameContent = (a, b) => {
30
+ const hash = (f) => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
31
+ try {
32
+ return fs.statSync(a).size === fs.statSync(b).size && hash(a) === hash(b);
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ };
38
+ /** A path from an event or the manifest: relative, forward slashes, no way out of the root. */
39
+ const safeRel = (rel) => {
40
+ const clean = rel.replace(/\\/g, '/').replace(/^\/+/, '');
41
+ if (!clean || clean.length > 1000 || clean.split('/').some((p) => p === '..' || p === '' || p.includes(':')))
42
+ return null;
43
+ return clean;
44
+ };
45
+ export class WorkspaceView {
46
+ o;
47
+ rules;
48
+ seen;
49
+ constructor(o) {
50
+ this.o = o;
51
+ this.rules = new IgnoreRules(o.host, o.exclude, o.include);
52
+ this.seen = readValid(Manifest, o.state) ?? {};
53
+ }
54
+ /** Where a project's view and its sync state live: <storage>\views\<id> and <storage>\views\<id>.json. */
55
+ static pathsFor(storageRoot, host) {
56
+ const id = path.basename(host).replace(/[^A-Za-z0-9._-]/g, '_') + '-' + crypto.createHash('sha256').update(path.resolve(host).toLowerCase()).digest('hex').slice(0, 8);
57
+ const view = path.join(storageRoot, 'views', id);
58
+ return { host, view, state: view + '.json' };
59
+ }
60
+ abs(root, rel) { return path.join(root, ...rel.split('/')); }
61
+ /** Creates the view (with its .git file) and brings it fully in step with the project. */
62
+ prepare() {
63
+ fs.mkdirSync(this.o.view, { recursive: true });
64
+ const gitFile = path.join(this.o.view, '.git');
65
+ if (this.o.guestGitDir)
66
+ fs.writeFileSync(gitFile, 'gitdir: ' + this.o.guestGitDir.replace(/\\/g, '/') + '\n');
67
+ else
68
+ fs.rmSync(gitFile, { force: true });
69
+ return this.syncAll();
70
+ }
71
+ /** Every file not ignored, on one side (bounded, links skipped, ignored folders not entered). */
72
+ walk(root, under = '') {
73
+ const files = [];
74
+ // An ignored folder is only entered when one of your include patterns could match inside it; then only files those
75
+ // patterns let in are taken.
76
+ const queue = [{ rel: under, ignored: under !== '' && this.rules.isIgnored(under, true) }];
77
+ let count = 0;
78
+ while (queue.length) {
79
+ const { rel, ignored } = queue.shift();
80
+ let entries;
81
+ try {
82
+ entries = fs.readdirSync(this.abs(root, rel), { withFileTypes: true });
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ for (const e of entries) {
88
+ if (++count > MAX_ENTRIES) {
89
+ this.o.note?.('stopped listing ' + root + ' after ' + MAX_ENTRIES + ' entries');
90
+ return files;
91
+ }
92
+ const child = rel ? rel + '/' + e.name : e.name;
93
+ if (e.isSymbolicLink() || child === '.git')
94
+ continue;
95
+ if (e.isDirectory()) {
96
+ const childIgnored = ignored || this.rules.isIgnoredChild(child, true);
97
+ if (!childIgnored || this.rules.mayIncludeInside(child))
98
+ queue.push({ rel: child, ignored: childIgnored });
99
+ }
100
+ else if (e.isFile() && ((!ignored && !this.rules.isIgnoredChild(child, false)) || this.rules.isForced(child)))
101
+ files.push(child);
102
+ }
103
+ }
104
+ return files;
105
+ }
106
+ /** A full pass over both sides and everything synced before. */
107
+ syncAll() {
108
+ this.rules.reset();
109
+ const all = new Set([...this.walk(this.o.host), ...this.walk(this.o.view), ...Object.keys(this.seen)]);
110
+ return this.syncPaths(all);
111
+ }
112
+ /** Paths that changed (from file events): files, or folders whose contents are walked. */
113
+ syncChanged(rels) {
114
+ const files = new Set();
115
+ for (const raw of rels) {
116
+ const rel = safeRel(raw);
117
+ if (!rel)
118
+ continue;
119
+ if (rel.endsWith('.gitignore')) {
120
+ this.rules.reset();
121
+ return this.syncAll();
122
+ }
123
+ const isDir = [this.o.host, this.o.view].some((root) => { try {
124
+ return fs.lstatSync(this.abs(root, rel)).isDirectory();
125
+ }
126
+ catch {
127
+ return false;
128
+ } });
129
+ if (isDir) {
130
+ if (this.rules.isIgnored(rel, true) && !this.rules.mayIncludeInside(rel))
131
+ continue;
132
+ for (const f of [...this.walk(this.o.host, rel), ...this.walk(this.o.view, rel)])
133
+ files.add(f);
134
+ for (const k of Object.keys(this.seen))
135
+ if (k.startsWith(rel + '/'))
136
+ files.add(k);
137
+ }
138
+ else
139
+ files.add(rel);
140
+ }
141
+ return this.syncPaths(files);
142
+ }
143
+ syncPaths(rels) {
144
+ const stats = { toView: 0, toHost: 0, deleted: 0, conflicts: 0, skipped: 0 };
145
+ for (const rel of rels) {
146
+ if (this.rules.isIgnored(rel)) {
147
+ delete this.seen[rel];
148
+ continue;
149
+ }
150
+ try {
151
+ this.syncOne(rel, stats);
152
+ }
153
+ catch (e) {
154
+ stats.skipped++;
155
+ this.o.note?.('could not sync ' + rel + ': ' + (e instanceof Error ? e.message : String(e)));
156
+ }
157
+ }
158
+ writeJsonAtomic(this.o.state, this.seen);
159
+ return stats;
160
+ }
161
+ syncOne(rel, stats) {
162
+ const hostFile = this.abs(this.o.host, rel);
163
+ const viewFile = this.abs(this.o.view, rel);
164
+ const [hs, hm] = stamp(hostFile);
165
+ const [vs, vm] = stamp(viewFile);
166
+ const before = this.seen[rel];
167
+ const record = () => { const h = stamp(hostFile); const v = stamp(viewFile); if (h[0] < 0 && v[0] < 0)
168
+ delete this.seen[rel];
169
+ else
170
+ this.seen[rel] = [h[0], h[1], v[0], v[1]]; };
171
+ if (hs < 0 && vs < 0) {
172
+ delete this.seen[rel];
173
+ return;
174
+ }
175
+ const hostChanged = !before || before[0] !== hs || before[1] !== hm;
176
+ const viewChanged = !before || before[2] !== vs || before[3] !== vm;
177
+ if (!hostChanged && !viewChanged)
178
+ return;
179
+ if (hostChanged && !viewChanged) {
180
+ this.carry(hostFile, viewFile, hs < 0, stats, 'toView');
181
+ record();
182
+ return;
183
+ }
184
+ if (viewChanged && !hostChanged) {
185
+ this.carry(viewFile, hostFile, vs < 0, stats, 'toHost');
186
+ record();
187
+ return;
188
+ }
189
+ // Changed on both sides (or never synced): the same content is no conflict.
190
+ if (hs >= 0 && vs >= 0 && sameContent(hostFile, viewFile)) {
191
+ record();
192
+ return;
193
+ }
194
+ if (hs < 0) {
195
+ this.carry(viewFile, hostFile, false, stats, 'toHost');
196
+ record();
197
+ return;
198
+ }
199
+ if (vs < 0) {
200
+ this.carry(hostFile, viewFile, false, stats, 'toView');
201
+ record();
202
+ return;
203
+ }
204
+ // Yours stays in the project; the sandbox's version is kept next to it, and the view gets yours.
205
+ const ext = path.extname(hostFile);
206
+ const conflict = hostFile.slice(0, hostFile.length - ext.length) + '.sandbox-conflict-' + new Date().toISOString().replace(/\D/g, '').slice(0, 14) + ext;
207
+ fs.copyFileSync(viewFile, conflict);
208
+ this.carry(hostFile, viewFile, false, stats, 'toView');
209
+ stats.conflicts++;
210
+ this.o.note?.('both sides changed ' + rel + ': kept yours, the sandbox\'s version is ' + path.basename(conflict));
211
+ record();
212
+ }
213
+ /** Copies (keeping the file time) or deletes on the other side. */
214
+ carry(from, to, deleted, stats, way) {
215
+ if (deleted) {
216
+ if (stamp(to)[0] < 0)
217
+ return;
218
+ fs.rmSync(to, { force: true });
219
+ this.pruneEmpty(path.dirname(to), way === 'toView' ? this.o.view : this.o.host);
220
+ stats.deleted++;
221
+ return;
222
+ }
223
+ // Never write through a link that is already there.
224
+ try {
225
+ if (fs.lstatSync(to).isSymbolicLink())
226
+ throw new Error('a link is in the way');
227
+ }
228
+ catch (e) {
229
+ if (e.code !== 'ENOENT')
230
+ throw e;
231
+ }
232
+ fs.mkdirSync(path.dirname(to), { recursive: true });
233
+ fs.copyFileSync(from, to);
234
+ const t = fs.statSync(from).mtime;
235
+ fs.utimesSync(to, t, t);
236
+ stats[way]++;
237
+ }
238
+ pruneEmpty(dir, root) {
239
+ for (let d = dir; d.length > root.length && d.startsWith(root); d = path.dirname(d)) {
240
+ try {
241
+ if (fs.readdirSync(d).length)
242
+ return;
243
+ fs.rmdirSync(d);
244
+ }
245
+ catch {
246
+ return;
247
+ }
248
+ }
249
+ }
250
+ /**
251
+ * Keeps both sides in step while it runs: file events from both, batched every 300 ms, plus a full pass every
252
+ * `fullEveryMs` to catch anything an event missed. Returns a function that stops it.
253
+ */
254
+ watch(fullEveryMs = 60000) {
255
+ const pending = new Set();
256
+ let timer;
257
+ const flush = () => {
258
+ timer = undefined;
259
+ const batch = [...pending];
260
+ pending.clear();
261
+ const s = this.syncChanged(batch);
262
+ if (s.toHost || s.toView || s.deleted || s.conflicts)
263
+ this.o.note?.('synced: ' + s.toView + ' in, ' + s.toHost + ' out, ' + s.deleted + ' deleted, ' + s.conflicts + ' conflicts');
264
+ };
265
+ const onEvent = (_event, file) => {
266
+ if (!file)
267
+ return;
268
+ pending.add(String(file));
269
+ timer ??= setTimeout(flush, 300);
270
+ };
271
+ const watchers = [this.o.host, this.o.view].map((dir) => fs.watch(dir, { recursive: true }, onEvent));
272
+ const full = setInterval(() => { try {
273
+ this.syncAll();
274
+ }
275
+ catch (e) {
276
+ this.o.note?.('full sync failed: ' + (e instanceof Error ? e.message : String(e)));
277
+ } }, fullEveryMs);
278
+ return () => { watchers.forEach((w) => w.close()); clearInterval(full); clearTimeout(timer); if (pending.size)
279
+ flush(); };
280
+ }
281
+ }