@mod-computer/cli 0.1.0 → 0.2.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 (55) hide show
  1. package/README.md +72 -0
  2. package/dist/cli.bundle.js +24633 -13744
  3. package/dist/cli.bundle.js.map +4 -4
  4. package/dist/cli.js +23 -12
  5. package/dist/commands/add.js +245 -0
  6. package/dist/commands/auth.js +129 -21
  7. package/dist/commands/comment.js +568 -0
  8. package/dist/commands/diff.js +182 -0
  9. package/dist/commands/index.js +33 -3
  10. package/dist/commands/init.js +545 -326
  11. package/dist/commands/ls.js +135 -0
  12. package/dist/commands/members.js +687 -0
  13. package/dist/commands/mv.js +282 -0
  14. package/dist/commands/rm.js +257 -0
  15. package/dist/commands/status.js +273 -306
  16. package/dist/commands/sync.js +99 -75
  17. package/dist/commands/trace.js +1752 -0
  18. package/dist/commands/workspace.js +354 -330
  19. package/dist/config/features.js +8 -3
  20. package/dist/config/release-profiles/development.json +4 -1
  21. package/dist/config/release-profiles/mvp.json +4 -2
  22. package/dist/daemon/conflict-resolution.js +172 -0
  23. package/dist/daemon/content-hash.js +31 -0
  24. package/dist/daemon/file-sync.js +985 -0
  25. package/dist/daemon/index.js +203 -0
  26. package/dist/daemon/mime-types.js +166 -0
  27. package/dist/daemon/offline-queue.js +211 -0
  28. package/dist/daemon/path-utils.js +64 -0
  29. package/dist/daemon/share-policy.js +83 -0
  30. package/dist/daemon/wasm-errors.js +189 -0
  31. package/dist/daemon/worker.js +557 -0
  32. package/dist/daemon-worker.js +3 -2
  33. package/dist/errors/workspace-errors.js +48 -0
  34. package/dist/lib/auth-server.js +89 -26
  35. package/dist/lib/browser.js +1 -1
  36. package/dist/lib/diff.js +284 -0
  37. package/dist/lib/formatters.js +204 -0
  38. package/dist/lib/git.js +137 -0
  39. package/dist/lib/local-fs.js +201 -0
  40. package/dist/lib/prompts.js +56 -0
  41. package/dist/lib/storage.js +11 -1
  42. package/dist/lib/trace-formatters.js +314 -0
  43. package/dist/services/add-service.js +554 -0
  44. package/dist/services/add-validation.js +124 -0
  45. package/dist/services/mod-config.js +8 -2
  46. package/dist/services/modignore-service.js +2 -0
  47. package/dist/stores/use-workspaces-store.js +36 -14
  48. package/dist/types/add-types.js +99 -0
  49. package/dist/types/config.js +1 -1
  50. package/dist/types/workspace-connection.js +53 -2
  51. package/package.json +7 -5
  52. package/commands/execute.md +0 -156
  53. package/commands/overview.md +0 -233
  54. package/commands/review.md +0 -151
  55. package/commands/spec.md +0 -169
@@ -0,0 +1,137 @@
1
+ // glassware[type="implementation", id="impl-cli-git-utils--7e4f29ca", requirements="requirement-cli-git-app-1--f5a33b8c,requirement-cli-git-app-2--4f3b552c,requirement-cli-git-qual-1--98d1bacb,requirement-cli-git-qual-2--503addb9,requirement-cli-git-qual-3--5af20852,requirement-cli-git-qual-4--7c40baf1"]
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ /**
5
+ * Detect if a directory is inside a git repository.
6
+ * Walks up the directory tree looking for a .git directory.
7
+ */
8
+ // glassware[type="implementation", id="impl-cli-detect-git-repo--86f0b85f", specifications="specification-spec-is-git-repo--8d217687"]
9
+ export function detectGitRepo(directory) {
10
+ let currentDir = path.resolve(directory);
11
+ const root = path.parse(currentDir).root;
12
+ while (currentDir !== root) {
13
+ const gitDir = path.join(currentDir, '.git');
14
+ if (fs.existsSync(gitDir)) {
15
+ const headFile = path.join(gitDir, 'HEAD');
16
+ const branchResult = readCurrentBranchWithDetached(headFile);
17
+ return {
18
+ isGitRepo: true,
19
+ branch: branchResult.branch,
20
+ gitDir,
21
+ headFile,
22
+ isDetached: branchResult.isDetached,
23
+ };
24
+ }
25
+ currentDir = path.dirname(currentDir);
26
+ }
27
+ return {
28
+ isGitRepo: false,
29
+ branch: null,
30
+ gitDir: null,
31
+ headFile: null,
32
+ isDetached: false,
33
+ };
34
+ }
35
+ /**
36
+ * Read the current branch name from .git/HEAD with detached state info.
37
+ * Returns branch name and whether in detached HEAD state.
38
+ */
39
+ // glassware[type="implementation", id="impl-cli-read-branch-detached--42ec893d", specifications="specification-spec-detect-branch--76e466f2,specification-spec-detached-head--0fce4d44"]
40
+ export function readCurrentBranchWithDetached(headFile) {
41
+ try {
42
+ if (!fs.existsSync(headFile)) {
43
+ return { branch: null, isDetached: false };
44
+ }
45
+ const content = fs.readFileSync(headFile, 'utf8').trim();
46
+ // Check if it's a symbolic ref (normal branch)
47
+ // Format: "ref: refs/heads/branch-name"
48
+ if (content.startsWith('ref: refs/heads/')) {
49
+ return {
50
+ branch: content.replace('ref: refs/heads/', ''),
51
+ isDetached: false,
52
+ };
53
+ }
54
+ // Detached HEAD (commit hash) - return detached-{hash} format per spec
55
+ if (/^[0-9a-f]{40}$/i.test(content)) {
56
+ return {
57
+ branch: `detached-${content.substring(0, 7)}`,
58
+ isDetached: true,
59
+ };
60
+ }
61
+ return { branch: null, isDetached: false };
62
+ }
63
+ catch (error) {
64
+ return { branch: null, isDetached: false };
65
+ }
66
+ }
67
+ /**
68
+ * Read the current branch name from .git/HEAD.
69
+ * Returns null if detached HEAD or unable to read.
70
+ */
71
+ export function readCurrentBranch(headFile) {
72
+ return readCurrentBranchWithDetached(headFile).branch;
73
+ }
74
+ /**
75
+ * Get git branch for a directory.
76
+ * Convenience function that returns just the branch name or null.
77
+ */
78
+ export function getGitBranch(directory) {
79
+ const gitInfo = detectGitRepo(directory);
80
+ return gitInfo.branch;
81
+ }
82
+ /**
83
+ * Check if a path should be ignored based on git patterns.
84
+ * This is a simplified check for common patterns.
85
+ */
86
+ export function isGitIgnoredPath(relativePath) {
87
+ // Always ignore .git directory contents
88
+ if (relativePath.startsWith('.git/') || relativePath === '.git') {
89
+ return true;
90
+ }
91
+ // Ignore common patterns
92
+ const ignoredPatterns = [
93
+ /^node_modules\//,
94
+ /^\.git\//,
95
+ /^dist\//,
96
+ /^build\//,
97
+ /^\.next\//,
98
+ /^\.nuxt\//,
99
+ /^coverage\//,
100
+ /^\.cache\//,
101
+ /^\.automerge-data\//,
102
+ /\.log$/,
103
+ /\.lock$/,
104
+ /^\.DS_Store$/,
105
+ /^Thumbs\.db$/,
106
+ ];
107
+ return ignoredPatterns.some(pattern => pattern.test(relativePath));
108
+ }
109
+ /**
110
+ * Check if a git operation is in progress (rebase, merge, etc).
111
+ * When active git operations are in progress, syncing should be paused.
112
+ */
113
+ // glassware[type="implementation", id="impl-cli-git-operation-check--c31739c8", specifications="specification-spec-git-operations--addf801f"]
114
+ export function isGitOperationInProgress(directory) {
115
+ const gitInfo = detectGitRepo(directory);
116
+ if (!gitInfo.isGitRepo || !gitInfo.gitDir) {
117
+ return false;
118
+ }
119
+ const gitDir = gitInfo.gitDir;
120
+ // Check for various git operations in progress
121
+ const operationIndicators = [
122
+ path.join(gitDir, 'rebase-merge'), // git rebase in progress
123
+ path.join(gitDir, 'rebase-apply'), // git am or rebase --apply in progress
124
+ path.join(gitDir, 'MERGE_HEAD'), // git merge in progress
125
+ path.join(gitDir, 'CHERRY_PICK_HEAD'), // git cherry-pick in progress
126
+ path.join(gitDir, 'REVERT_HEAD'), // git revert in progress
127
+ path.join(gitDir, 'BISECT_LOG'), // git bisect in progress
128
+ ];
129
+ return operationIndicators.some(indicator => fs.existsSync(indicator));
130
+ }
131
+ /**
132
+ * Check if the .git directory exists and is valid.
133
+ */
134
+ export function isGitRepo(directory) {
135
+ const gitInfo = detectGitRepo(directory);
136
+ return gitInfo.isGitRepo;
137
+ }
@@ -0,0 +1,201 @@
1
+ // glassware[type="implementation", id="impl-cli-fd-local-fs--3a1efb54", requirements="requirement-cli-fd-local-read--bbd1aae6,requirement-cli-fd-local-delete--df1be435,requirement-cli-fd-local-move--022534b1"]
2
+ // spec: packages/mod-cli/specs/file-directory.md
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ /**
6
+ * Read local file content as string
7
+ */
8
+ // glassware[type="implementation", id="impl-cli-fd-local-read--99aa05fa", requirements="requirement-cli-fd-local-read--bbd1aae6"]
9
+ export async function readLocalFile(filePath) {
10
+ try {
11
+ const absolutePath = path.resolve(filePath);
12
+ const content = await fs.readFile(absolutePath, 'utf-8');
13
+ return content;
14
+ }
15
+ catch (error) {
16
+ if (error.code === 'ENOENT') {
17
+ return null;
18
+ }
19
+ throw error;
20
+ }
21
+ }
22
+ /**
23
+ * Check if local file exists
24
+ */
25
+ export async function localFileExists(filePath) {
26
+ try {
27
+ const absolutePath = path.resolve(filePath);
28
+ await fs.access(absolutePath);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ /**
36
+ * Get local file stats
37
+ */
38
+ export async function getLocalFileStats(filePath) {
39
+ try {
40
+ const absolutePath = path.resolve(filePath);
41
+ const stats = await fs.stat(absolutePath);
42
+ return {
43
+ size: stats.size,
44
+ mtime: stats.mtime,
45
+ };
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Delete local file with proper error handling
53
+ */
54
+ // glassware[type="implementation", id="impl-cli-fd-local-delete--74bf0b5b", requirements="requirement-cli-fd-local-delete--df1be435"]
55
+ export async function deleteLocalFile(filePath) {
56
+ try {
57
+ const absolutePath = path.resolve(filePath);
58
+ await fs.unlink(absolutePath);
59
+ return { success: true };
60
+ }
61
+ catch (error) {
62
+ if (error.code === 'ENOENT') {
63
+ return { success: true }; // Already deleted
64
+ }
65
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
66
+ return { success: false, error: `Permission denied: ${filePath}` };
67
+ }
68
+ return { success: false, error: error.message };
69
+ }
70
+ }
71
+ /**
72
+ * Delete local directory recursively
73
+ */
74
+ export async function deleteLocalDirectory(dirPath) {
75
+ try {
76
+ const absolutePath = path.resolve(dirPath);
77
+ await fs.rm(absolutePath, { recursive: true, force: true });
78
+ return { success: true };
79
+ }
80
+ catch (error) {
81
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
82
+ return { success: false, error: `Permission denied: ${dirPath}` };
83
+ }
84
+ return { success: false, error: error.message };
85
+ }
86
+ }
87
+ /**
88
+ * Move local file with cross-device fallback
89
+ */
90
+ // glassware[type="implementation", id="impl-cli-fd-local-move--692fd328", requirements="requirement-cli-fd-local-move--022534b1"]
91
+ export async function moveLocalFile(src, dest) {
92
+ try {
93
+ const absoluteSrc = path.resolve(src);
94
+ const absoluteDest = path.resolve(dest);
95
+ // Ensure destination directory exists
96
+ const destDir = path.dirname(absoluteDest);
97
+ await fs.mkdir(destDir, { recursive: true });
98
+ try {
99
+ await fs.rename(absoluteSrc, absoluteDest);
100
+ }
101
+ catch (err) {
102
+ if (err.code === 'EXDEV') {
103
+ // Cross-device: copy then delete
104
+ await fs.copyFile(absoluteSrc, absoluteDest);
105
+ await fs.unlink(absoluteSrc);
106
+ }
107
+ else {
108
+ throw err;
109
+ }
110
+ }
111
+ return { success: true };
112
+ }
113
+ catch (error) {
114
+ if (error.code === 'ENOENT') {
115
+ return { success: false, error: `File not found: ${src}` };
116
+ }
117
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
118
+ return { success: false, error: `Permission denied: ${src}` };
119
+ }
120
+ return { success: false, error: error.message };
121
+ }
122
+ }
123
+ /**
124
+ * List files in a local directory recursively
125
+ */
126
+ export async function listLocalFiles(dirPath, pattern) {
127
+ const files = [];
128
+ const absolutePath = path.resolve(dirPath);
129
+ async function walk(dir) {
130
+ try {
131
+ const entries = await fs.readdir(dir, { withFileTypes: true });
132
+ for (const entry of entries) {
133
+ const fullPath = path.join(dir, entry.name);
134
+ const relativePath = path.relative(absolutePath, fullPath);
135
+ if (entry.isDirectory()) {
136
+ await walk(fullPath);
137
+ }
138
+ else if (entry.isFile()) {
139
+ if (!pattern || matchGlob(relativePath, pattern)) {
140
+ files.push(relativePath);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ catch {
146
+ // Skip directories we can't read
147
+ }
148
+ }
149
+ await walk(absolutePath);
150
+ return files;
151
+ }
152
+ /**
153
+ * Simple glob matching (supports * and **)
154
+ */
155
+ export function matchGlob(filePath, pattern) {
156
+ // Normalize path separators
157
+ const normalizedPath = filePath.replace(/\\/g, '/');
158
+ const normalizedPattern = pattern.replace(/\\/g, '/');
159
+ // Handle patterns starting with **/ (should match any path depth including root)
160
+ if (normalizedPattern.startsWith('**/')) {
161
+ // **/ means "anywhere in path", so we match either:
162
+ // 1. The file directly at root (just matches the part after **/)
163
+ // 2. The file nested in directories (matches .* followed by / then the part after **/)
164
+ const rest = normalizedPattern.slice(3); // Remove **/
165
+ const restRegex = rest
166
+ .replace(/\./g, '\\.')
167
+ .replace(/\*\*/g, '.*')
168
+ .replace(/\*/g, '[^/]*');
169
+ const regex = new RegExp(`^(.*\\/)?${restRegex}$`);
170
+ return regex.test(normalizedPath);
171
+ }
172
+ // Convert glob to regex for other patterns
173
+ const regexPattern = normalizedPattern
174
+ .replace(/\./g, '\\.') // Escape dots
175
+ .replace(/\*\*/g, '.*') // ** matches anything
176
+ .replace(/\*/g, '[^/]*'); // * matches anything except /
177
+ const regex = new RegExp(`^${regexPattern}$`);
178
+ return regex.test(normalizedPath);
179
+ }
180
+ /**
181
+ * Check if a file appears to be binary
182
+ */
183
+ export async function isBinaryFile(filePath) {
184
+ try {
185
+ const absolutePath = path.resolve(filePath);
186
+ const buffer = Buffer.alloc(512);
187
+ const fd = await fs.open(absolutePath, 'r');
188
+ const { bytesRead } = await fd.read(buffer, 0, 512, 0);
189
+ await fd.close();
190
+ // Check for null bytes (common in binary files)
191
+ for (let i = 0; i < bytesRead; i++) {
192
+ if (buffer[i] === 0) {
193
+ return true;
194
+ }
195
+ }
196
+ return false;
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ }
@@ -0,0 +1,56 @@
1
+ // glassware[type="implementation", id="cli-prompts--bbf3cbc1", requirements="requirement-cli-init-ux-2--b69b045f,requirement-cli-init-ux-3--3dde4846,requirement-cli-init-ux-4--140d9249"]
2
+ import { select as inquirerSelect, input as inquirerInput, confirm as inquirerConfirm } from '@inquirer/prompts';
3
+ /**
4
+ * Display a selection prompt with arrow key navigation.
5
+ */
6
+ export async function select(question, options) {
7
+ const result = await inquirerSelect({
8
+ message: question,
9
+ choices: options.map(opt => ({
10
+ name: opt.label,
11
+ value: opt.value,
12
+ description: opt.description,
13
+ })),
14
+ });
15
+ return result;
16
+ }
17
+ /**
18
+ * Display a text input prompt and return the value.
19
+ */
20
+ export async function input(question, options) {
21
+ const result = await inquirerInput({
22
+ message: question,
23
+ default: options?.default,
24
+ validate: options?.validate
25
+ ? (value) => options.validate(value) ?? true
26
+ : undefined,
27
+ });
28
+ return result;
29
+ }
30
+ /**
31
+ * Display a yes/no confirmation prompt.
32
+ */
33
+ export async function confirm(question, options) {
34
+ const result = await inquirerConfirm({
35
+ message: question,
36
+ default: options?.default ?? true,
37
+ });
38
+ return result;
39
+ }
40
+ // glassware[type="implementation", id="impl-validate-workspace-name--6732de6e", requirements="requirement-cli-init-qual-2--4ba7ca9c"]
41
+ /**
42
+ * Validate a workspace name.
43
+ * Returns error message if invalid, null if valid.
44
+ */
45
+ export function validateWorkspaceName(name) {
46
+ if (!name || name.trim().length === 0) {
47
+ return 'Workspace name cannot be empty';
48
+ }
49
+ if (name.length > 100) {
50
+ return 'Workspace name must be 100 characters or less';
51
+ }
52
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9-_ ]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/.test(name.trim())) {
53
+ return 'Workspace name must start and end with alphanumeric characters';
54
+ }
55
+ return null;
56
+ }
@@ -1,4 +1,7 @@
1
- // glassware[type=implementation, id=cli-storage-utils, requirements=req-cli-storage-struct-1,req-cli-storage-app-1,req-cli-storage-app-2,req-cli-storage-app-3,req-cli-storage-conn-2,req-cli-storage-qual-1,req-cli-storage-qual-5]
1
+ // glassware[type="implementation", id="impl-cli-storage--44a11f2b", specifications="specification-spec-token-storage--26199fe8,specification-spec-config-permissions--18b20291,specification-spec-token-reuse--bfc74a74"]
2
+ // glassware[type="implementation", id="impl-cli-storage-distributed--54f8febe", specifications="specification-spec-storage-dir--54adedf5,specification-spec-create-storage-dir--29d5746d,specification-spec-nodefs-adapter--55a00910,specification-spec-storage-path-config--26888df2"]
3
+ // glassware[type="implementation", id="impl-cli-ws-storage--bb831726", requirements="requirement-cli-ws-storage-location--5691bb5c,requirement-cli-ws-storage-hash--940b982f,requirement-cli-ws-storage-save--e11dd16d,requirement-cli-ws-storage-get--fbbcc532,requirement-cli-ws-storage-remove--05267ecd,requirement-cli-ws-storage-list--f900c191,requirement-cli-ws-storage-atomic--732eb2e0,requirement-cli-ws-storage-permissions--68c455f2"]
4
+ // spec: packages/mod-cli/specs/workspaces.md
2
5
  import fs from 'fs';
3
6
  import path from 'path';
4
7
  import crypto from 'crypto';
@@ -71,6 +74,7 @@ export function writeConfig(config) {
71
74
  const configPath = getConfigPath();
72
75
  atomicWrite(configPath, JSON.stringify(config, null, 2), 0o600);
73
76
  }
77
+ // glassware[type="implementation", id="impl-cli-ws-storage-hash--7647b959", requirements="requirement-cli-ws-storage-hash--940b982f,requirement-cli-init-app-6--aa3c1ace"]
74
78
  /**
75
79
  * Get the path to a workspace connection file for a directory.
76
80
  * Uses SHA-256 hash of the absolute path (first 16 chars).
@@ -84,6 +88,7 @@ export function getWorkspaceConnectionPath(directoryPath) {
84
88
  .slice(0, 16);
85
89
  return path.join(getModDir(), 'workspaces', hash);
86
90
  }
91
+ // glassware[type="implementation", id="impl-cli-ws-storage-get--bce2619c", requirements="requirement-cli-ws-storage-get--fbbcc532"]
87
92
  /**
88
93
  * Read the workspace connection for a directory.
89
94
  * Returns null if no connection exists.
@@ -102,6 +107,7 @@ export function readWorkspaceConnection(directoryPath) {
102
107
  return null;
103
108
  }
104
109
  }
110
+ // glassware[type="implementation", id="impl-cli-ws-storage-save--352ee3cc", requirements="requirement-cli-ws-storage-save--e11dd16d,requirement-cli-ws-storage-atomic--732eb2e0,requirement-cli-ws-storage-permissions--68c455f2,requirement-cli-init-qual-3--fc1e8a03"]
105
111
  /**
106
112
  * Write a workspace connection for a directory atomically.
107
113
  */
@@ -110,6 +116,7 @@ export function writeWorkspaceConnection(directoryPath, connection) {
110
116
  const connectionPath = getWorkspaceConnectionPath(directoryPath);
111
117
  atomicWrite(connectionPath, JSON.stringify(connection, null, 2), 0o600);
112
118
  }
119
+ // glassware[type="implementation", id="impl-cli-ws-storage-remove--7923bc01", requirements="requirement-cli-ws-storage-remove--05267ecd"]
113
120
  /**
114
121
  * Delete the workspace connection for a directory.
115
122
  */
@@ -127,9 +134,12 @@ export function deleteWorkspaceConnection(directoryPath) {
127
134
  return false;
128
135
  }
129
136
  }
137
+ // glassware[type="implementation", id="impl-cli-ws-storage-list--00734c67", requirements="requirement-cli-ws-storage-list--f900c191,requirement-cli-init-data-0--d659ea61,requirement-cli-init-int-3--ada97918"]
130
138
  /**
131
139
  * List all workspace connections.
132
140
  * Returns array of connections, filtering out corrupted files.
141
+ * Workspace connections map local directories to workspaces (data-0).
142
+ * Connection visible to sync daemon for directory lookup (int-3).
133
143
  */
134
144
  export function listWorkspaceConnections() {
135
145
  const workspacesDir = path.join(getModDir(), 'workspaces');