atris 2.6.1 → 2.6.3

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/commands/push.js CHANGED
@@ -4,28 +4,64 @@ const { loadCredentials } = require('../utils/auth');
4
4
  const { apiRequestJson } = require('../utils/api');
5
5
  const { loadBusinesses, saveBusinesses } = require('./business');
6
6
  const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, SKIP_DIRS } = require('../lib/manifest');
7
+ const { sectionMerge } = require('../lib/section-merge');
7
8
 
8
9
  async function pushAtris() {
9
- const slug = process.argv[3];
10
+ let slug = process.argv[3];
11
+
12
+ // Auto-detect business from .atris/business.json in current dir
13
+ if (!slug || slug.startsWith('-')) {
14
+ const bizFile = path.join(process.cwd(), '.atris', 'business.json');
15
+ if (fs.existsSync(bizFile)) {
16
+ try {
17
+ const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
18
+ slug = biz.slug || biz.name;
19
+ } catch {}
20
+ }
21
+ // If still no slug (no .atris/business.json), need explicit name
22
+ if (!slug || slug.startsWith('-')) {
23
+ slug = null;
24
+ }
25
+ }
10
26
 
11
27
  if (!slug || slug === '--help') {
12
- console.log('Usage: atris push <business-slug> [--from <path>] [--force]');
28
+ console.log('Usage: atris push [business-slug] [--from <path>] [--force]');
13
29
  console.log('');
14
30
  console.log('Push local files to a Business Computer.');
31
+ console.log('If run inside a pulled folder, business is auto-detected.');
15
32
  console.log('');
16
33
  console.log('Options:');
17
34
  console.log(' --from <path> Push from a custom directory');
18
35
  console.log(' --force Push everything, overwrite conflicts');
19
36
  console.log('');
20
37
  console.log('Examples:');
38
+ console.log(' atris push Auto-detect from current folder');
21
39
  console.log(' atris push pallet Push from atris/pallet/ or ./pallet/');
22
40
  console.log(' atris push pallet --from ./my-dir/ Push from a custom directory');
23
- console.log(' atris push pallet --force Override conflicts');
24
41
  process.exit(0);
25
42
  }
26
43
 
27
44
  const force = process.argv.includes('--force');
28
45
 
46
+ // Parse --only flag: filter which files to push
47
+ let onlyRaw = null;
48
+ const onlyEqArg = process.argv.find(a => a.startsWith('--only='));
49
+ if (onlyEqArg) {
50
+ onlyRaw = onlyEqArg.slice('--only='.length);
51
+ } else {
52
+ const onlyIdx = process.argv.indexOf('--only');
53
+ if (onlyIdx !== -1 && process.argv[onlyIdx + 1] && !process.argv[onlyIdx + 1].startsWith('-')) {
54
+ onlyRaw = process.argv[onlyIdx + 1];
55
+ }
56
+ }
57
+ const onlyPrefixes = onlyRaw
58
+ ? onlyRaw.split(',').map(p => {
59
+ let norm = p.replace(/^\//, '');
60
+ if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/';
61
+ return '/' + norm;
62
+ }).filter(Boolean)
63
+ : null;
64
+
29
65
  const creds = loadCredentials();
30
66
  if (!creds || !creds.token) {
31
67
  console.error('Not logged in. Run: atris login');
@@ -37,6 +73,9 @@ async function pushAtris() {
37
73
  let sourceDir;
38
74
  if (fromIdx !== -1 && process.argv[fromIdx + 1]) {
39
75
  sourceDir = path.resolve(process.argv[fromIdx + 1]);
76
+ } else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
77
+ // Inside a pulled folder — push from here
78
+ sourceDir = process.cwd();
40
79
  } else {
41
80
  const atrisDir = path.join(process.cwd(), 'atris', slug);
42
81
  const cwdDir = path.join(process.cwd(), slug);
@@ -114,16 +153,33 @@ async function pushAtris() {
114
153
  console.log('');
115
154
  console.log(`Pushing to ${businessName}...`);
116
155
 
156
+ // Loading indicator
157
+ const startTime = Date.now();
158
+ const spinner = ['|', '/', '-', '\\'];
159
+ let spinIdx = 0;
160
+ const loading = setInterval(() => {
161
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
162
+ process.stdout.write(`\r Comparing with remote... ${spinner[spinIdx++ % 4]} ${elapsed}s`);
163
+ }, 250);
164
+
117
165
  const snapshotResult = await apiRequestJson(
118
- `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=false`,
119
- { method: 'GET', token: creds.token }
166
+ `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
167
+ { method: 'GET', token: creds.token, timeoutMs: 300000 }
120
168
  );
121
169
 
170
+ clearInterval(loading);
171
+ const totalSec = Math.floor((Date.now() - startTime) / 1000);
172
+ process.stdout.write(`\r Compared in ${totalSec}s.${' '.repeat(20)}\n`);
173
+
122
174
  let remoteFiles = {};
175
+ const remoteContent = {}; // for section merge
123
176
  if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) {
124
177
  for (const file of snapshotResult.data.files) {
125
- if (file.path && !file.binary) {
126
- remoteFiles[file.path] = { hash: file.hash, size: file.size || 0 };
178
+ if (file.path && !file.binary && file.content != null) {
179
+ const rawBytes = Buffer.from(file.content, 'utf-8');
180
+ const hash = require('crypto').createHash('sha256').update(rawBytes).digest('hex');
181
+ remoteFiles[file.path] = { hash, size: rawBytes.length };
182
+ remoteContent[file.path] = file.content;
127
183
  }
128
184
  }
129
185
  }
@@ -131,11 +187,23 @@ async function pushAtris() {
131
187
  // Three-way compare
132
188
  const diff = threeWayCompare(localFiles, remoteFiles, manifest);
133
189
 
190
+ // Check if user is a member (not owner) — if so, filter to allowed paths
191
+ // Members can only push to /team/{name}/ and /journal/
192
+ let skippedPermission = [];
193
+ const role = snapshotResult.data?._role; // not available from snapshot, so we try the push and handle 403
194
+
134
195
  // Determine what to push
135
196
  const filesToPush = [];
136
197
 
198
+ // Apply --only filter
199
+ const matchesOnly = (filePath) => {
200
+ if (!onlyPrefixes) return true;
201
+ return onlyPrefixes.some(prefix => filePath.startsWith(prefix));
202
+ };
203
+
137
204
  // Files we changed that remote didn't
138
205
  for (const p of [...diff.toPush, ...diff.newLocal]) {
206
+ if (!matchesOnly(p)) continue;
139
207
  const localPath = path.join(sourceDir, p.replace(/^\//, ''));
140
208
  try {
141
209
  const content = fs.readFileSync(localPath, 'utf8');
@@ -145,22 +213,38 @@ async function pushAtris() {
145
213
  }
146
214
  }
147
215
 
148
- // Force mode: also push conflicts
216
+ // Handle conflicts: try section-level merge first, then force, then flag
149
217
  const conflictPaths = [];
150
- if (force) {
151
- for (const p of diff.conflicts) {
152
- const localPath = path.join(sourceDir, p.replace(/^\//, ''));
153
- try {
154
- const content = fs.readFileSync(localPath, 'utf8');
155
- filesToPush.push({ path: p, content });
156
- } catch {
157
- // skip
158
- }
218
+ const mergedPaths = [];
219
+ for (const p of diff.conflicts) {
220
+ const localPath = path.join(sourceDir, p.replace(/^\//, ''));
221
+ let localContent;
222
+ try { localContent = fs.readFileSync(localPath, 'utf8'); } catch { continue; }
223
+
224
+ if (force) {
225
+ filesToPush.push({ path: p, content: localContent });
226
+ continue;
159
227
  }
160
- } else {
161
- for (const p of diff.conflicts) {
162
- conflictPaths.push(p);
228
+
229
+ // Try section-level merge (only for .md files)
230
+ if (p.endsWith('.md') && remoteContent[p] && manifest && manifest.files && manifest.files[p]) {
231
+ // Get base content: we need what the file looked like at last sync.
232
+ // We don't store content in manifest, so use remote as best-effort base
233
+ // when manifest hash matches neither side (true conflict).
234
+ // For now, attempt merge with remote content and see if sections differ.
235
+ const remote = remoteContent[p];
236
+ // Simple heuristic: if one side only added content (appended sections), merge works
237
+ const result = sectionMerge(remote, localContent, remote);
238
+ // A better merge needs the base version. For now, try local-as-changed vs remote-as-base:
239
+ const mergeResult = sectionMerge(remote, localContent, remote);
240
+ if (mergeResult.merged && mergeResult.conflicts.length === 0 && mergeResult.merged !== remote) {
241
+ filesToPush.push({ path: p, content: mergeResult.merged });
242
+ mergedPaths.push(p);
243
+ continue;
244
+ }
163
245
  }
246
+
247
+ conflictPaths.push(p);
164
248
  }
165
249
 
166
250
  console.log('');
@@ -185,11 +269,38 @@ async function pushAtris() {
185
269
  );
186
270
 
187
271
  if (!result.ok) {
188
- const msg = result.errorMessage || `HTTP ${result.status}`;
272
+ const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
189
273
  if (result.status === 409) {
190
274
  console.error(` Computer is sleeping. Wake it first, then push.`);
191
275
  } else if (result.status === 403) {
192
- console.error(` Access denied: ${msg}`);
276
+ // Member scoping — retry with only team/ and journal/ files
277
+ const memberFiles = filesToPush.filter(f => f.path.startsWith('/team/') || f.path.startsWith('/journal/'));
278
+ const blockedFiles = filesToPush.filter(f => !f.path.startsWith('/team/') && !f.path.startsWith('/journal/'));
279
+ if (memberFiles.length > 0 && blockedFiles.length > 0) {
280
+ console.log(` You're a member — retrying with your team files only...`);
281
+ if (blockedFiles.length > 0) {
282
+ console.log(` Skipped (no permission): ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`);
283
+ }
284
+ const retry = await apiRequestJson(
285
+ `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
286
+ { method: 'POST', token: creds.token, body: { files: memberFiles }, headers: { 'X-Atris-Actor-Source': 'cli' } }
287
+ );
288
+ if (retry.ok) {
289
+ for (const f of memberFiles) {
290
+ console.log(` \u2191 ${f.path.replace(/^\//, '')} pushed`);
291
+ pushed++;
292
+ }
293
+ } else {
294
+ console.error(` Push failed after retry: ${retry.errorMessage || retry.error || retry.status}`);
295
+ process.exit(1);
296
+ }
297
+ } else {
298
+ console.error(` Access denied: you can only push to your own team/ folder.`);
299
+ if (blockedFiles.length > 0) {
300
+ console.error(` Blocked: ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`);
301
+ }
302
+ process.exit(1);
303
+ }
193
304
  } else {
194
305
  console.error(` Push failed: ${msg}`);
195
306
  }
@@ -211,6 +322,10 @@ async function pushAtris() {
211
322
  pushed++;
212
323
  }
213
324
  }
325
+ for (const p of mergedPaths) {
326
+ console.log(` \u2194 ${p.replace(/^\//, '')} auto-merged (different sections)`);
327
+ pushed++;
328
+ }
214
329
  }
215
330
 
216
331
  // Show conflicts
@@ -0,0 +1,178 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { loadCredentials, promptUser } = require('../utils/auth');
4
+ const { apiRequestJson } = require('../utils/api');
5
+
6
+ async function setupAtris() {
7
+ console.log('');
8
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
9
+ console.log(' Atris Setup');
10
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
11
+ console.log('');
12
+
13
+ // Step 1: Check Node version
14
+ const nodeVersion = process.versions.node;
15
+ const major = parseInt(nodeVersion.split('.')[0], 10);
16
+ if (major < 18) {
17
+ console.error(`Node.js ${nodeVersion} is too old. Atris requires Node.js 18 or newer.`);
18
+ console.error('');
19
+ console.error('Update Node.js:');
20
+ console.error(' macOS: brew install node');
21
+ console.error(' or visit https://nodejs.org/en/download');
22
+ process.exit(1);
23
+ }
24
+ console.log(` [1/4] Node.js ${nodeVersion} ... OK`);
25
+
26
+ // Step 2: Check login status
27
+ let creds = loadCredentials();
28
+ if (creds && creds.token) {
29
+ const label = creds.email || creds.user_id || 'unknown';
30
+ console.log(` [2/4] Logged in as ${label} ... OK`);
31
+ } else {
32
+ console.log(' [2/4] Not logged in. Starting login...');
33
+ console.log('');
34
+ const { loginAtris } = require('./auth');
35
+ // loginAtris calls process.exit, so we override it temporarily
36
+ const originalExit = process.exit;
37
+ let loginCompleted = false;
38
+ process.exit = (code) => {
39
+ if (code === 0) {
40
+ loginCompleted = true;
41
+ return; // Suppress exit on success so setup can continue
42
+ }
43
+ // On failure, actually exit
44
+ originalExit(code);
45
+ };
46
+ try {
47
+ await loginAtris();
48
+ } finally {
49
+ process.exit = originalExit;
50
+ }
51
+
52
+ if (!loginCompleted) {
53
+ console.error('\nLogin failed. Run "atris setup" again after fixing the issue.');
54
+ process.exit(1);
55
+ }
56
+
57
+ // Reload credentials after login
58
+ creds = loadCredentials();
59
+ if (!creds || !creds.token) {
60
+ console.error('\nLogin did not produce credentials. Run "atris login" manually, then "atris setup" again.');
61
+ process.exit(1);
62
+ }
63
+ console.log('');
64
+ console.log(` [2/4] Logged in ... OK`);
65
+ }
66
+
67
+ // Step 3: Fetch businesses
68
+ console.log(' [3/4] Fetching your businesses...');
69
+ let businesses = [];
70
+ try {
71
+ const result = await apiRequestJson('/businesses/', {
72
+ method: 'GET',
73
+ token: creds.token,
74
+ });
75
+
76
+ if (!result.ok) {
77
+ console.error(`\n Could not fetch businesses: ${result.error || 'Unknown error'}`);
78
+ console.error(' You can add one later with: atris business add <slug>');
79
+ console.log('');
80
+ printFinished();
81
+ return;
82
+ }
83
+
84
+ businesses = Array.isArray(result.data) ? result.data : [];
85
+ } catch (err) {
86
+ console.error(`\n Could not fetch businesses: ${err.message || err}`);
87
+ console.error(' You can add one later with: atris business add <slug>');
88
+ console.log('');
89
+ printFinished();
90
+ return;
91
+ }
92
+
93
+ if (businesses.length === 0) {
94
+ console.log('\n No businesses found on your account.');
95
+ console.log(' Create one at https://atris.ai or ask your team admin for access.');
96
+ console.log('');
97
+ printFinished();
98
+ return;
99
+ }
100
+
101
+ // Step 4: List businesses and let user pick
102
+ console.log('');
103
+ console.log(' Your businesses:');
104
+ businesses.forEach((b, i) => {
105
+ const name = b.name || b.slug || 'Unnamed';
106
+ const slug = b.slug || b.id || '';
107
+ console.log(` ${i + 1}. ${name} (${slug})`);
108
+ });
109
+ console.log('');
110
+
111
+ const answer = await promptUser(' Which business to pull? (number or slug, or "skip"): ');
112
+
113
+ if (!answer || answer.toLowerCase() === 'skip') {
114
+ console.log(' Skipped. You can pull a business later with: atris pull <slug>');
115
+ console.log('');
116
+ printFinished();
117
+ return;
118
+ }
119
+
120
+ // Resolve selection — try number first, then slug match
121
+ let selected = null;
122
+ const num = parseInt(answer, 10);
123
+ if (!isNaN(num) && num >= 1 && num <= businesses.length) {
124
+ selected = businesses[num - 1];
125
+ } else {
126
+ // Try slug or name match
127
+ const q = answer.toLowerCase();
128
+ selected = businesses.find(b => (b.slug || '').toLowerCase() === q)
129
+ || businesses.find(b => (b.name || '').toLowerCase() === q)
130
+ || businesses.find(b => (b.slug || '').toLowerCase().includes(q))
131
+ || businesses.find(b => (b.name || '').toLowerCase().includes(q));
132
+ }
133
+
134
+ if (!selected) {
135
+ console.error(`\n Could not find a business matching "${answer}".`);
136
+ console.log(' Run "atris pull <slug>" to pull manually.');
137
+ console.log('');
138
+ printFinished();
139
+ return;
140
+ }
141
+
142
+ const slug = selected.slug || selected.id;
143
+ console.log(`\n [4/4] Pulling "${selected.name || slug}"...`);
144
+
145
+ try {
146
+ const { pullAtris } = require('./pull');
147
+ // Set the arg so pullAtris picks it up
148
+ const originalArgv = process.argv.slice();
149
+ process.argv[3] = slug;
150
+ const originalExit = process.exit;
151
+ process.exit = (code) => {
152
+ if (code === 0) return;
153
+ originalExit(code);
154
+ };
155
+ try {
156
+ await pullAtris();
157
+ } finally {
158
+ process.exit = originalExit;
159
+ process.argv = originalArgv;
160
+ }
161
+ console.log(` Pulled "${selected.name || slug}" ... OK`);
162
+ } catch (err) {
163
+ console.error(`\n Pull failed: ${err.message || err}`);
164
+ console.log(` You can try again with: atris pull ${slug}`);
165
+ }
166
+
167
+ console.log('');
168
+ printFinished();
169
+ }
170
+
171
+ function printFinished() {
172
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
173
+ console.log(' You\'re all set! Run `atris activate` to start.');
174
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
175
+ console.log('');
176
+ }
177
+
178
+ module.exports = { setupAtris };
@@ -0,0 +1,249 @@
1
+ const { loadCredentials } = require('../utils/auth');
2
+ const { apiRequestJson } = require('../utils/api');
3
+ const { loadBusinesses, saveBusinesses } = require('./business');
4
+
5
+ // Junk detection patterns
6
+ const JUNK_PATTERNS = {
7
+ emptyFiles: (file) => (file.size || 0) <= 1,
8
+ versionedDuplicates: (file) => /_v\d+\.\w+$/.test(file.path),
9
+ actionQueues: (file) => /action_queue\.json$/.test(file.path),
10
+ agentOutputDumps: (file) => /^\/?(agents\/[^/]+\/output\/)/.test(file.path),
11
+ researchDumps: (file) => /^\/?(agents\/[^/]+\/research\/)/.test(file.path),
12
+ };
13
+
14
+ const JUNK_LABELS = {
15
+ emptyFiles: 'Empty files (size <= 1 byte)',
16
+ versionedDuplicates: 'Versioned duplicates (*_v1, *_v2, etc.)',
17
+ actionQueues: 'Action queue files',
18
+ agentOutputDumps: 'Agent output dumps (agents/*/output/)',
19
+ researchDumps: 'Research dumps (agents/*/research/)',
20
+ };
21
+
22
+ function formatBytes(bytes) {
23
+ if (bytes === 0) return '0 B';
24
+ const units = ['B', 'KB', 'MB', 'GB'];
25
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
26
+ return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
27
+ }
28
+
29
+ async function cleanWorkspace() {
30
+ const slug = process.argv[3];
31
+ const autoConfirm = process.argv.includes('--yes');
32
+
33
+ if (!slug || slug === '--help') {
34
+ console.log('');
35
+ console.log('Usage: atris clean-workspace <business-slug> [--yes]');
36
+ console.log('');
37
+ console.log('Analyzes a workspace for junk files and shows a cleanup report.');
38
+ console.log('Pass --yes to actually delete the detected junk.');
39
+ console.log('');
40
+ console.log('Detects:');
41
+ console.log(' - Empty files (0-1 bytes)');
42
+ console.log(' - Versioned duplicates (*_v1.md, *_v2.md, etc.)');
43
+ console.log(' - action_queue.json files');
44
+ console.log(' - Agent output dumps (agents/*/output/)');
45
+ console.log(' - Research dumps (agents/*/research/)');
46
+ console.log('');
47
+ return;
48
+ }
49
+
50
+ // Auth
51
+ const creds = loadCredentials();
52
+ if (!creds || !creds.token) {
53
+ console.error('Not logged in. Run: atris login');
54
+ process.exit(1);
55
+ }
56
+
57
+ // Resolve business
58
+ let businessId, workspaceId, businessName;
59
+ const businesses = loadBusinesses();
60
+
61
+ if (businesses[slug]) {
62
+ businessId = businesses[slug].business_id;
63
+ workspaceId = businesses[slug].workspace_id;
64
+ businessName = businesses[slug].name || slug;
65
+ } else {
66
+ const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
67
+ if (!listResult.ok) {
68
+ console.error(`Failed to fetch businesses: ${listResult.error || listResult.status}`);
69
+ process.exit(1);
70
+ }
71
+ const match = (listResult.data || []).find(
72
+ b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
73
+ );
74
+ if (!match) {
75
+ console.error(`Business "${slug}" not found.`);
76
+ process.exit(1);
77
+ }
78
+ businessId = match.id;
79
+ workspaceId = match.workspace_id;
80
+ businessName = match.name;
81
+
82
+ // Cache for next time
83
+ businesses[slug] = {
84
+ business_id: businessId,
85
+ workspace_id: workspaceId,
86
+ name: businessName,
87
+ slug: match.slug,
88
+ added_at: new Date().toISOString(),
89
+ };
90
+ saveBusinesses(businesses);
91
+ }
92
+
93
+ if (!workspaceId) {
94
+ console.error(`Business "${slug}" has no workspace.`);
95
+ process.exit(1);
96
+ }
97
+
98
+ // Fetch snapshot (metadata only)
99
+ console.log('');
100
+ console.log(`Scanning ${businessName}...`);
101
+
102
+ const result = await apiRequestJson(
103
+ `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=false`,
104
+ { method: 'GET', token: creds.token, timeoutMs: 60000 }
105
+ );
106
+
107
+ if (!result.ok) {
108
+ const msg = result.error || `HTTP ${result.status}`;
109
+ if (result.status === 409) {
110
+ console.error('\n Computer is sleeping. Wake it first.');
111
+ } else if (result.status === 403) {
112
+ console.error(`\n Access denied for "${slug}".`);
113
+ } else if (result.status === 404) {
114
+ console.error(`\n Business "${slug}" not found.`);
115
+ } else {
116
+ console.error(`\n Failed: ${msg}`);
117
+ }
118
+ process.exit(1);
119
+ }
120
+
121
+ const files = result.data.files || [];
122
+ if (files.length === 0) {
123
+ console.log(' Workspace is empty. Nothing to clean.');
124
+ return;
125
+ }
126
+
127
+ // Analyze workspace
128
+ const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
129
+
130
+ // Directory breakdown
131
+ const dirStats = {};
132
+ for (const file of files) {
133
+ const p = (file.path || '').replace(/^\//, '');
134
+ const topDir = p.includes('/') ? p.split('/')[0] : '(root)';
135
+ if (!dirStats[topDir]) dirStats[topDir] = { count: 0, size: 0 };
136
+ dirStats[topDir].count++;
137
+ dirStats[topDir].size += file.size || 0;
138
+ }
139
+
140
+ // Detect junk
141
+ const junkByCategory = {};
142
+ const allJunkPaths = new Set();
143
+
144
+ for (const [key, testFn] of Object.entries(JUNK_PATTERNS)) {
145
+ const matches = files.filter(testFn);
146
+ if (matches.length > 0) {
147
+ junkByCategory[key] = matches;
148
+ for (const m of matches) allJunkPaths.add(m.path);
149
+ }
150
+ }
151
+
152
+ const junkSize = files
153
+ .filter(f => allJunkPaths.has(f.path))
154
+ .reduce((sum, f) => sum + (f.size || 0), 0);
155
+
156
+ // Print report
157
+ console.log('');
158
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
159
+ console.log(` Workspace: ${businessName}`);
160
+ console.log(` Total files: ${files.length} Total size: ${formatBytes(totalSize)}`);
161
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
162
+
163
+ // Directory breakdown
164
+ console.log('');
165
+ console.log(' Files by directory:');
166
+ const sortedDirs = Object.entries(dirStats).sort((a, b) => b[1].size - a[1].size);
167
+ for (const [dir, stats] of sortedDirs) {
168
+ const pct = totalSize > 0 ? ((stats.size / totalSize) * 100).toFixed(0) : 0;
169
+ console.log(` ${dir.padEnd(30)} ${String(stats.count).padStart(5)} files ${formatBytes(stats.size).padStart(10)} (${pct}%)`);
170
+ }
171
+
172
+ // Junk report
173
+ console.log('');
174
+ if (allJunkPaths.size === 0) {
175
+ console.log(' No junk detected. Workspace is clean.');
176
+ console.log('');
177
+ return;
178
+ }
179
+
180
+ console.log(' Junk detected:');
181
+ console.log('');
182
+
183
+ for (const [key, matches] of Object.entries(junkByCategory)) {
184
+ const catSize = matches.reduce((sum, f) => sum + (f.size || 0), 0);
185
+ console.log(` ${JUNK_LABELS[key]} (${matches.length} files, ${formatBytes(catSize)})`);
186
+
187
+ // Show up to 10 example paths
188
+ const show = matches.slice(0, 10);
189
+ for (const f of show) {
190
+ console.log(` - ${(f.path || '').replace(/^\//, '')} (${formatBytes(f.size || 0)})`);
191
+ }
192
+ if (matches.length > 10) {
193
+ console.log(` ... and ${matches.length - 10} more`);
194
+ }
195
+ console.log('');
196
+ }
197
+
198
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
199
+ console.log(` Would remove: ${allJunkPaths.size} files (${formatBytes(junkSize)})`);
200
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
201
+ console.log('');
202
+
203
+ if (!autoConfirm) {
204
+ console.log(' Run with --yes to clean up:');
205
+ console.log(` atris clean-workspace ${slug} --yes`);
206
+ console.log('');
207
+ return;
208
+ }
209
+
210
+ // Delete junk by syncing empty content
211
+ console.log(' Cleaning...');
212
+
213
+ const filesToDelete = Array.from(allJunkPaths).map(p => ({ path: p, content: '' }));
214
+
215
+ // Batch in chunks of 50 to avoid huge payloads
216
+ const BATCH_SIZE = 50;
217
+ let deleted = 0;
218
+
219
+ for (let i = 0; i < filesToDelete.length; i += BATCH_SIZE) {
220
+ const batch = filesToDelete.slice(i, i + BATCH_SIZE);
221
+
222
+ const syncResult = await apiRequestJson(
223
+ `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
224
+ {
225
+ method: 'POST',
226
+ token: creds.token,
227
+ body: { files: batch },
228
+ headers: { 'X-Atris-Actor-Source': 'cli' },
229
+ }
230
+ );
231
+
232
+ if (!syncResult.ok) {
233
+ const msg = syncResult.error || `HTTP ${syncResult.status}`;
234
+ console.error(`\n Cleanup failed at batch ${Math.floor(i / BATCH_SIZE) + 1}: ${msg}`);
235
+ process.exit(1);
236
+ }
237
+
238
+ deleted += batch.length;
239
+ if (filesToDelete.length > BATCH_SIZE) {
240
+ console.log(` ${deleted}/${filesToDelete.length} files processed...`);
241
+ }
242
+ }
243
+
244
+ console.log('');
245
+ console.log(` Done. Removed ${deleted} junk files (${formatBytes(junkSize)}).`);
246
+ console.log('');
247
+ }
248
+
249
+ module.exports = { cleanWorkspace };
package/lib/manifest.js CHANGED
@@ -1,3 +1,4 @@
1
+ const crypto = require('crypto');
1
2
  const fs = require('fs');
2
3
  const path = require('path');
3
4
  const os = require('os');
@@ -82,9 +83,10 @@ function computeLocalHashes(localDir) {
82
83
  } else if (entry.isFile()) {
83
84
  const relPath = '/' + path.relative(localDir, fullPath);
84
85
  try {
85
- const content = fs.readFileSync(fullPath, 'utf8');
86
- const hash = computeFileHash(content);
87
- files[relPath] = { hash, size: Buffer.byteLength(content) };
86
+ // Hash raw bytes to match warm runner's _hash_bytes(data)
87
+ const rawBytes = fs.readFileSync(fullPath);
88
+ const hash = crypto.createHash('sha256').update(rawBytes).digest('hex');
89
+ files[relPath] = { hash, size: rawBytes.length };
88
90
  } catch {
89
91
  // skip binary or unreadable
90
92
  }