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/bin/atris.js +19 -1
- package/commands/business.js +244 -2
- package/commands/context-sync.js +7 -5
- package/commands/pull.js +127 -24
- package/commands/push.js +137 -22
- package/commands/setup.js +178 -0
- package/commands/workspace-clean.js +249 -0
- package/lib/manifest.js +5 -3
- package/lib/section-merge.js +196 -0
- package/package.json +1 -1
- package/utils/api.js +17 -2
- package/utils/update-check.js +11 -11
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
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
-
//
|
|
216
|
+
// Handle conflicts: try section-level merge first, then force, then flag
|
|
149
217
|
const conflictPaths = [];
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
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
|
}
|