atris 2.6.1 → 2.6.2
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 +65 -17
- package/commands/push.js +8 -4
- 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 +9 -1
- package/utils/update-check.js +11 -11
package/bin/atris.js
CHANGED
|
@@ -211,6 +211,7 @@ function showHelp() {
|
|
|
211
211
|
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
212
212
|
console.log('');
|
|
213
213
|
console.log('Setup:');
|
|
214
|
+
console.log(' setup - Guided first-time setup (login, pick business, pull)');
|
|
214
215
|
console.log(' init - Initialize Atris in current project');
|
|
215
216
|
console.log(' update - Update local files to latest version');
|
|
216
217
|
console.log(' upgrade - Install latest Atris from npm');
|
|
@@ -246,6 +247,14 @@ function showHelp() {
|
|
|
246
247
|
console.log('');
|
|
247
248
|
console.log('Sync:');
|
|
248
249
|
console.log(' pull - Pull journals + member data from cloud');
|
|
250
|
+
console.log(' clean-workspace <slug> - Analyze & remove junk files from a workspace (alias: cw)');
|
|
251
|
+
console.log('');
|
|
252
|
+
console.log('Business:');
|
|
253
|
+
console.log(' business add <slug> - Connect a business');
|
|
254
|
+
console.log(' business list - Show connected businesses');
|
|
255
|
+
console.log(' business remove <slug> - Disconnect a business');
|
|
256
|
+
console.log(' business health <slug> - Health report (members, workspace, issues)');
|
|
257
|
+
console.log(' business audit - One-line health summary of all businesses');
|
|
249
258
|
console.log('');
|
|
250
259
|
console.log('Cloud & agents:');
|
|
251
260
|
console.log(' console - Start/attach always-on coding console (tmux daemon)');
|
|
@@ -387,7 +396,7 @@ const { planAtris: planCmd, doAtris: doCmd, reviewAtris: reviewCmd } = require('
|
|
|
387
396
|
const knownCommands = ['init', 'log', 'status', 'analytics', 'visualize', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review',
|
|
388
397
|
'activate', 'agent', 'chat', 'console', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
389
398
|
'clean', 'verify', 'search', 'skill', 'member', 'plugin', 'experiments', 'pull', 'push', 'business', 'sync',
|
|
390
|
-
'gmail', 'calendar', 'twitter', 'slack', 'integrations'];
|
|
399
|
+
'gmail', 'calendar', 'twitter', 'slack', 'integrations', 'setup', 'clean-workspace', 'cw'];
|
|
391
400
|
|
|
392
401
|
// Check if command is an atris.md spec file - triggers welcome visualization
|
|
393
402
|
function isSpecFile(cmd) {
|
|
@@ -958,6 +967,11 @@ if (command === 'init') {
|
|
|
958
967
|
require('../commands/business').businessCommand(subcommand, ...args)
|
|
959
968
|
.then(() => process.exit(0))
|
|
960
969
|
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
970
|
+
} else if (command === 'clean-workspace' || command === 'cw') {
|
|
971
|
+
const { cleanWorkspace } = require('../commands/workspace-clean');
|
|
972
|
+
cleanWorkspace()
|
|
973
|
+
.then(() => process.exit(0))
|
|
974
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
961
975
|
} else if (command === 'plugin') {
|
|
962
976
|
const subcommand = process.argv[3] || 'build';
|
|
963
977
|
const args = process.argv.slice(4);
|
|
@@ -966,6 +980,10 @@ if (command === 'init') {
|
|
|
966
980
|
const subcommand = process.argv[3];
|
|
967
981
|
const args = process.argv.slice(4);
|
|
968
982
|
require('../commands/experiments').experimentsCommand(subcommand, ...args);
|
|
983
|
+
} else if (command === 'setup') {
|
|
984
|
+
require('../commands/setup').setupAtris()
|
|
985
|
+
.then(() => process.exit(0))
|
|
986
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
969
987
|
} else {
|
|
970
988
|
console.log(`Unknown command: ${command}`);
|
|
971
989
|
console.log('Run "atris help" to see available commands');
|
package/commands/business.js
CHANGED
|
@@ -111,6 +111,242 @@ async function removeBusiness(slug) {
|
|
|
111
111
|
console.log(`\nRemoved "${name}"`);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Resolve a slug to a business ID using local cache or API lookup
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
async function resolveSlug(slug, creds) {
|
|
118
|
+
// Check local cache first
|
|
119
|
+
const businesses = loadBusinesses();
|
|
120
|
+
if (businesses[slug]) {
|
|
121
|
+
return businesses[slug];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Try by-slug endpoint
|
|
125
|
+
const result = await apiRequestJson(`/businesses/by-slug/${slug}/`, {
|
|
126
|
+
method: 'GET',
|
|
127
|
+
token: creds.token,
|
|
128
|
+
});
|
|
129
|
+
if (result.ok && result.data) {
|
|
130
|
+
return { business_id: result.data.id, workspace_id: result.data.workspace_id, name: result.data.name, slug: result.data.slug };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fallback: list all and match
|
|
134
|
+
const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
|
|
135
|
+
if (listResult.ok && Array.isArray(listResult.data)) {
|
|
136
|
+
const match = listResult.data.find(b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase());
|
|
137
|
+
if (match) {
|
|
138
|
+
return { business_id: match.id, workspace_id: match.workspace_id, name: match.name, slug: match.slug };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Helper: format relative time
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
function relativeTime(dateStr) {
|
|
149
|
+
if (!dateStr) return 'unknown';
|
|
150
|
+
const diff = Date.now() - new Date(dateStr).getTime();
|
|
151
|
+
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
152
|
+
if (days <= 0) return 'today';
|
|
153
|
+
if (days === 1) return '1d ago';
|
|
154
|
+
return `${days}d ago`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Helper: activity bar
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
function activityBar(daysSinceActive, width = 10) {
|
|
161
|
+
const filled = Math.max(0, Math.min(width, width - Math.floor(daysSinceActive / 3)));
|
|
162
|
+
return '\u2501'.repeat(filled) + '\u2591'.repeat(width - filled);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// atris business health <slug>
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
async function businessHealth(slug) {
|
|
169
|
+
if (!slug) {
|
|
170
|
+
console.error('Usage: atris business health <slug>');
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const creds = loadCredentials();
|
|
175
|
+
if (!creds || !creds.token) {
|
|
176
|
+
console.error('Not logged in. Run: atris login');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const biz = await resolveSlug(slug, creds);
|
|
181
|
+
if (!biz) {
|
|
182
|
+
console.error(`Business "${slug}" not found.`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const bizId = biz.business_id;
|
|
187
|
+
const wsId = biz.workspace_id;
|
|
188
|
+
|
|
189
|
+
// Fetch dashboard and workspace snapshot in parallel
|
|
190
|
+
const fetchOpts = { method: 'GET', token: creds.token, timeoutMs: 120000 };
|
|
191
|
+
const [dashResult, wsResult] = await Promise.all([
|
|
192
|
+
apiRequestJson(`/businesses/${bizId}/dashboard/`, fetchOpts),
|
|
193
|
+
wsId
|
|
194
|
+
? apiRequestJson(`/businesses/${bizId}/workspaces/${wsId}/snapshot?include_content=false`, fetchOpts)
|
|
195
|
+
: Promise.resolve({ ok: false }),
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
const dashboard = dashResult.ok ? dashResult.data : null;
|
|
199
|
+
const workspace = wsResult.ok ? wsResult.data : null;
|
|
200
|
+
|
|
201
|
+
const name = dashboard?.business?.name || biz.name || slug;
|
|
202
|
+
|
|
203
|
+
console.log('');
|
|
204
|
+
console.log(`Business Health: ${name}`);
|
|
205
|
+
console.log('\u2501'.repeat(26 + name.length));
|
|
206
|
+
console.log('');
|
|
207
|
+
|
|
208
|
+
// Workspace stats
|
|
209
|
+
const files = workspace?.files || [];
|
|
210
|
+
const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
|
|
211
|
+
const fileSizeStr = totalSize > 1024 ? `${Math.round(totalSize / 1024)}KB` : `${totalSize}B`;
|
|
212
|
+
console.log(` Workspace: ${files.length} files, ${fileSizeStr}`);
|
|
213
|
+
|
|
214
|
+
// Members
|
|
215
|
+
const members = dashboard?.roster?.members || dashboard?.members || dashboard?.business?.members || [];
|
|
216
|
+
const humanMembers = members.filter(m => !m.is_agent && m.role !== 'agent');
|
|
217
|
+
const agentMembers = members.filter(m => m.is_agent || m.role === 'agent');
|
|
218
|
+
const memberCountStr = members.length > 0
|
|
219
|
+
? `${members.length} (${humanMembers.length} human, ${agentMembers.length} agent)`
|
|
220
|
+
: `${members.length}`;
|
|
221
|
+
console.log(` Members: ${memberCountStr}`);
|
|
222
|
+
|
|
223
|
+
// Apps
|
|
224
|
+
const apps = dashboard?.business?.apps || dashboard?.apps || [];
|
|
225
|
+
console.log(` Apps: ${Array.isArray(apps) ? apps.length : 0}`);
|
|
226
|
+
|
|
227
|
+
// Status
|
|
228
|
+
const status = dashboard?.business?.status || dashboard?.status || 'unknown';
|
|
229
|
+
console.log(` Status: ${status}`);
|
|
230
|
+
|
|
231
|
+
// Member activity
|
|
232
|
+
if (members.length > 0) {
|
|
233
|
+
console.log('');
|
|
234
|
+
console.log(' Member Activity:');
|
|
235
|
+
for (const m of members) {
|
|
236
|
+
const memberName = m.display_name || m.name || m.email || 'Unknown';
|
|
237
|
+
const role = m.role || 'member';
|
|
238
|
+
const lastActive = m.atris?.last_active || m.last_active || m.last_login || m.joined_at || m.created_at;
|
|
239
|
+
const daysSince = lastActive ? Math.floor((Date.now() - new Date(lastActive).getTime()) / (1000 * 60 * 60 * 24)) : 999;
|
|
240
|
+
const bar = activityBar(daysSince);
|
|
241
|
+
const label = daysSince <= 1 ? 'active' : `last active ${relativeTime(lastActive)}`;
|
|
242
|
+
console.log(` ${memberName.padEnd(18)} ${role.padEnd(8)} ${bar} ${label}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Workspace breakdown by directory
|
|
247
|
+
if (files.length > 0) {
|
|
248
|
+
console.log('');
|
|
249
|
+
console.log(' Workspace Breakdown:');
|
|
250
|
+
const dirSizes = {};
|
|
251
|
+
for (const f of files) {
|
|
252
|
+
const filePath = f.path || f.name || '';
|
|
253
|
+
const dir = filePath.includes('/') ? filePath.split('/')[0] + '/' : '/';
|
|
254
|
+
dirSizes[dir] = (dirSizes[dir] || 0) + (f.size || 0);
|
|
255
|
+
}
|
|
256
|
+
const maxDirSize = Math.max(...Object.values(dirSizes), 1);
|
|
257
|
+
const sortedDirs = Object.entries(dirSizes).sort((a, b) => b[1] - a[1]);
|
|
258
|
+
for (const [dir, size] of sortedDirs) {
|
|
259
|
+
const sizeStr = size > 1024 ? `${Math.round(size / 1024)}KB` : `${size}B`;
|
|
260
|
+
const barLen = Math.max(1, Math.round((size / maxDirSize) * 10));
|
|
261
|
+
console.log(` ${dir.padEnd(12)} ${sizeStr.padStart(5)} ${'█'.repeat(barLen)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Issues
|
|
266
|
+
console.log('');
|
|
267
|
+
console.log(' Issues:');
|
|
268
|
+
let hasIssues = false;
|
|
269
|
+
const humanMembers2 = members.filter(m => m.role !== 'agent');
|
|
270
|
+
for (const m of humanMembers2) {
|
|
271
|
+
const lastActive = m.atris?.last_active || m.last_active || m.last_login || m.joined_at || m.created_at;
|
|
272
|
+
const daysSince = lastActive ? Math.floor((Date.now() - new Date(lastActive).getTime()) / (1000 * 60 * 60 * 24)) : 999;
|
|
273
|
+
if (daysSince >= 30) {
|
|
274
|
+
const memberName = m.display_name || m.name || m.email || 'Unknown';
|
|
275
|
+
console.log(` \u26A0 ${memberName} inactive for ${daysSince}+ days`);
|
|
276
|
+
hasIssues = true;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Check for workspace bloat (arbitrary threshold: >500KB or >100 files)
|
|
281
|
+
if (totalSize > 500 * 1024) {
|
|
282
|
+
console.log(` \u26A0 Workspace large (${fileSizeStr})`);
|
|
283
|
+
hasIssues = true;
|
|
284
|
+
}
|
|
285
|
+
if (files.length > 100) {
|
|
286
|
+
console.log(` \u26A0 Workspace has ${files.length} files (consider cleanup)`);
|
|
287
|
+
hasIssues = true;
|
|
288
|
+
}
|
|
289
|
+
if (!hasIssues) {
|
|
290
|
+
console.log(' \u2713 Workspace clean (no bloat detected)');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log('');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// atris business audit
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
async function businessAudit() {
|
|
300
|
+
const creds = loadCredentials();
|
|
301
|
+
if (!creds || !creds.token) {
|
|
302
|
+
console.error('Not logged in. Run: atris login');
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
|
|
307
|
+
if (!listResult.ok || !Array.isArray(listResult.data)) {
|
|
308
|
+
console.error(`Failed to fetch businesses: ${listResult.error || 'unknown error'}`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const businesses = listResult.data;
|
|
313
|
+
|
|
314
|
+
console.log('');
|
|
315
|
+
console.log('Business Audit');
|
|
316
|
+
console.log('\u2501'.repeat(14));
|
|
317
|
+
console.log('');
|
|
318
|
+
|
|
319
|
+
for (const biz of businesses) {
|
|
320
|
+
const name = biz.name || biz.slug || 'Unknown';
|
|
321
|
+
const memberCount = typeof biz.member_count === 'number' ? biz.member_count : (Array.isArray(biz.members) ? biz.members.length : 0);
|
|
322
|
+
const appCount = typeof biz.app_count === 'number' ? biz.app_count : (Array.isArray(biz.apps) ? biz.apps.length : 0);
|
|
323
|
+
|
|
324
|
+
// Determine activity status
|
|
325
|
+
const status = biz.status || 'unknown';
|
|
326
|
+
const isActive = status === 'active' || (memberCount > 1 && appCount > 0);
|
|
327
|
+
const hasContent = memberCount > 1 || appCount > 0;
|
|
328
|
+
|
|
329
|
+
let icon, activityLabel;
|
|
330
|
+
if (isActive) {
|
|
331
|
+
icon = '\u2713';
|
|
332
|
+
activityLabel = appCount > 0 ? 'active' : 'idle';
|
|
333
|
+
} else if (hasContent) {
|
|
334
|
+
icon = '\u26A0';
|
|
335
|
+
activityLabel = 'inactive';
|
|
336
|
+
} else {
|
|
337
|
+
icon = '\u25CB';
|
|
338
|
+
activityLabel = 'inactive';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const memberStr = memberCount === 1 ? '1 member' : `${memberCount} members`;
|
|
342
|
+
const appStr = appCount === 1 ? '1 app' : `${appCount} apps`;
|
|
343
|
+
|
|
344
|
+
console.log(` ${icon} ${name.padEnd(16)} ${memberStr.padEnd(12)} ${appStr.padEnd(8)} ${activityLabel}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
console.log('');
|
|
348
|
+
}
|
|
349
|
+
|
|
114
350
|
async function businessCommand(subcommand, ...args) {
|
|
115
351
|
switch (subcommand) {
|
|
116
352
|
case 'add':
|
|
@@ -124,9 +360,15 @@ async function businessCommand(subcommand, ...args) {
|
|
|
124
360
|
case 'rm':
|
|
125
361
|
await removeBusiness(args[0]);
|
|
126
362
|
break;
|
|
363
|
+
case 'health':
|
|
364
|
+
await businessHealth(args[0]);
|
|
365
|
+
break;
|
|
366
|
+
case 'audit':
|
|
367
|
+
await businessAudit();
|
|
368
|
+
break;
|
|
127
369
|
default:
|
|
128
|
-
console.log('Usage: atris business <add|list|remove> [slug]');
|
|
370
|
+
console.log('Usage: atris business <add|list|remove|health|audit> [slug]');
|
|
129
371
|
}
|
|
130
372
|
}
|
|
131
373
|
|
|
132
|
-
module.exports = { businessCommand, loadBusinesses, saveBusinesses, getBusinessConfigPath };
|
|
374
|
+
module.exports = { businessCommand, businessHealth, businessAudit, loadBusinesses, saveBusinesses, getBusinessConfigPath };
|
package/commands/context-sync.js
CHANGED
|
@@ -73,10 +73,10 @@ async function businessStatus(slug) {
|
|
|
73
73
|
if (fs.existsSync(atrisDir)) localDir = atrisDir;
|
|
74
74
|
else if (fs.existsSync(cwdDir)) localDir = cwdDir;
|
|
75
75
|
|
|
76
|
-
// Get remote snapshot (
|
|
76
|
+
// Get remote snapshot with content (needed for reliable hash computation)
|
|
77
77
|
const result = await apiRequestJson(
|
|
78
|
-
`/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/snapshot?include_content=
|
|
79
|
-
{ method: 'GET', token: biz.token }
|
|
78
|
+
`/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/snapshot?include_content=true`,
|
|
79
|
+
{ method: 'GET', token: biz.token, timeoutMs: 120000 }
|
|
80
80
|
);
|
|
81
81
|
|
|
82
82
|
if (!result.ok) {
|
|
@@ -89,9 +89,11 @@ async function businessStatus(slug) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
const remoteFiles = {};
|
|
92
|
+
const crypto = require('crypto');
|
|
92
93
|
for (const file of (result.data.files || [])) {
|
|
93
|
-
if (file.path && !file.binary) {
|
|
94
|
-
|
|
94
|
+
if (file.path && !file.binary && file.content != null) {
|
|
95
|
+
const rawBytes = Buffer.from(file.content, 'utf-8');
|
|
96
|
+
remoteFiles[file.path] = { hash: crypto.createHash('sha256').update(rawBytes).digest('hex'), size: rawBytes.length };
|
|
95
97
|
}
|
|
96
98
|
}
|
|
97
99
|
|
package/commands/pull.js
CHANGED
|
@@ -13,7 +13,7 @@ async function pullAtris() {
|
|
|
13
13
|
const arg = process.argv[3];
|
|
14
14
|
|
|
15
15
|
// If a business name is given, do a business pull
|
|
16
|
-
if (arg && arg !== '--help') {
|
|
16
|
+
if (arg && arg !== '--help' && !arg.startsWith('--')) {
|
|
17
17
|
return pullBusiness(arg);
|
|
18
18
|
}
|
|
19
19
|
|
|
@@ -80,6 +80,23 @@ async function pullBusiness(slug) {
|
|
|
80
80
|
|
|
81
81
|
const force = process.argv.includes('--force');
|
|
82
82
|
|
|
83
|
+
// Parse --only flag: comma-separated directory prefixes to filter
|
|
84
|
+
const onlyArg = process.argv.find(a => a.startsWith('--only='));
|
|
85
|
+
const onlyPrefixes = onlyArg
|
|
86
|
+
? onlyArg.slice('--only='.length).split(',').map(p => {
|
|
87
|
+
// Normalize: strip leading slash, ensure trailing slash for dirs
|
|
88
|
+
let norm = p.replace(/^\//, '');
|
|
89
|
+
if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/';
|
|
90
|
+
return norm;
|
|
91
|
+
}).filter(Boolean)
|
|
92
|
+
: null;
|
|
93
|
+
|
|
94
|
+
// Parse --timeout flag: override default 120s timeout
|
|
95
|
+
const timeoutArg = process.argv.find(a => a.startsWith('--timeout='));
|
|
96
|
+
const timeoutMs = timeoutArg
|
|
97
|
+
? parseInt(timeoutArg.slice('--timeout='.length), 10) * 1000
|
|
98
|
+
: 120000;
|
|
99
|
+
|
|
83
100
|
// Determine output directory
|
|
84
101
|
const intoIdx = process.argv.indexOf('--into');
|
|
85
102
|
let outputDir;
|
|
@@ -94,21 +111,23 @@ async function pullBusiness(slug) {
|
|
|
94
111
|
}
|
|
95
112
|
}
|
|
96
113
|
|
|
97
|
-
// Resolve business ID —
|
|
114
|
+
// Resolve business ID — always refresh from API to avoid stale workspace_id
|
|
98
115
|
let businessId, workspaceId, businessName, resolvedSlug;
|
|
99
116
|
const businesses = loadBusinesses();
|
|
100
117
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
|
|
119
|
+
if (!listResult.ok) {
|
|
120
|
+
// Fall back to local cache if API fails
|
|
121
|
+
if (businesses[slug]) {
|
|
122
|
+
businessId = businesses[slug].business_id;
|
|
123
|
+
workspaceId = businesses[slug].workspace_id;
|
|
124
|
+
businessName = businesses[slug].name || slug;
|
|
125
|
+
resolvedSlug = businesses[slug].slug || slug;
|
|
126
|
+
} else {
|
|
109
127
|
console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
|
|
110
128
|
process.exit(1);
|
|
111
129
|
}
|
|
130
|
+
} else {
|
|
112
131
|
const match = (listResult.data || []).find(
|
|
113
132
|
b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
|
|
114
133
|
);
|
|
@@ -121,6 +140,7 @@ async function pullBusiness(slug) {
|
|
|
121
140
|
businessName = match.name;
|
|
122
141
|
resolvedSlug = match.slug;
|
|
123
142
|
|
|
143
|
+
// Update local cache
|
|
124
144
|
businesses[slug] = {
|
|
125
145
|
business_id: businessId,
|
|
126
146
|
workspace_id: workspaceId,
|
|
@@ -143,16 +163,19 @@ async function pullBusiness(slug) {
|
|
|
143
163
|
|
|
144
164
|
console.log('');
|
|
145
165
|
console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
|
|
166
|
+
console.log(' Fetching workspace...');
|
|
146
167
|
|
|
147
168
|
// Get remote snapshot (large workspaces can take 60s+)
|
|
148
169
|
const result = await apiRequestJson(
|
|
149
170
|
`/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
|
|
150
|
-
{ method: 'GET', token: creds.token, timeoutMs
|
|
171
|
+
{ method: 'GET', token: creds.token, timeoutMs }
|
|
151
172
|
);
|
|
152
173
|
|
|
153
174
|
if (!result.ok) {
|
|
154
|
-
const msg = result.errorMessage || `HTTP ${result.status}`;
|
|
155
|
-
if (result.status ===
|
|
175
|
+
const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
|
|
176
|
+
if (result.status === 0 || (typeof msg === 'string' && msg.toLowerCase().includes('timeout'))) {
|
|
177
|
+
console.error(`\n Workspace is taking too long to respond. Try: atris pull ${slug} --timeout=120`);
|
|
178
|
+
} else if (result.status === 409) {
|
|
156
179
|
console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
|
|
157
180
|
} else if (result.status === 403) {
|
|
158
181
|
console.error(`\n Access denied. You're not a member of "${slug}".`);
|
|
@@ -164,18 +187,39 @@ async function pullBusiness(slug) {
|
|
|
164
187
|
process.exit(1);
|
|
165
188
|
}
|
|
166
189
|
|
|
167
|
-
|
|
190
|
+
let files = result.data.files || [];
|
|
168
191
|
if (files.length === 0) {
|
|
169
192
|
console.log(' Workspace is empty.');
|
|
170
193
|
return;
|
|
171
194
|
}
|
|
172
195
|
|
|
196
|
+
console.log(` Processing ${files.length} files...`);
|
|
197
|
+
|
|
198
|
+
// Apply --only filter if specified
|
|
199
|
+
if (onlyPrefixes) {
|
|
200
|
+
files = files.filter(file => {
|
|
201
|
+
if (!file.path) return false;
|
|
202
|
+
const rel = file.path.replace(/^\//, '');
|
|
203
|
+
return onlyPrefixes.some(prefix => rel.startsWith(prefix));
|
|
204
|
+
});
|
|
205
|
+
if (files.length === 0) {
|
|
206
|
+
console.log(` No files matched --only filter: ${onlyPrefixes.join(', ')}`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
console.log(` Filtered to ${files.length} files matching: ${onlyPrefixes.join(', ')}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
173
212
|
// Build remote file map {path: {hash, size, content}}
|
|
174
213
|
const remoteFiles = {};
|
|
175
214
|
const remoteContent = {};
|
|
176
215
|
for (const file of files) {
|
|
177
216
|
if (!file.path || file.binary || file.content === null || file.content === undefined) continue;
|
|
178
|
-
|
|
217
|
+
// Skip empty files (deleted files that were blanked out)
|
|
218
|
+
if (file.content === '') continue;
|
|
219
|
+
// Compute hash from content bytes (matches computeLocalHashes raw byte hashing)
|
|
220
|
+
const crypto = require('crypto');
|
|
221
|
+
const rawBytes = Buffer.from(file.content, 'utf-8');
|
|
222
|
+
remoteFiles[file.path] = { hash: crypto.createHash('sha256').update(rawBytes).digest('hex'), size: rawBytes.length };
|
|
179
223
|
remoteContent[file.path] = file.content;
|
|
180
224
|
}
|
|
181
225
|
|
|
@@ -265,8 +309,12 @@ async function pullBusiness(slug) {
|
|
|
265
309
|
// Git might not be initialized yet — that's fine
|
|
266
310
|
}
|
|
267
311
|
|
|
268
|
-
// Save manifest
|
|
269
|
-
|
|
312
|
+
// Save manifest — when using --only, merge into existing manifest to avoid data loss
|
|
313
|
+
let manifestFiles = remoteFiles;
|
|
314
|
+
if (onlyPrefixes && manifest && manifest.files) {
|
|
315
|
+
manifestFiles = { ...manifest.files, ...remoteFiles };
|
|
316
|
+
}
|
|
317
|
+
const newManifest = buildManifest(manifestFiles, commitHash);
|
|
270
318
|
saveManifest(resolvedSlug || slug, newManifest);
|
|
271
319
|
}
|
|
272
320
|
|
package/commands/push.js
CHANGED
|
@@ -114,16 +114,20 @@ async function pushAtris() {
|
|
|
114
114
|
console.log('');
|
|
115
115
|
console.log(`Pushing to ${businessName}...`);
|
|
116
116
|
|
|
117
|
+
// Get snapshot with content to compute reliable hashes (server hash may differ)
|
|
117
118
|
const snapshotResult = await apiRequestJson(
|
|
118
|
-
`/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=
|
|
119
|
-
{ method: 'GET', token: creds.token }
|
|
119
|
+
`/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
|
|
120
|
+
{ method: 'GET', token: creds.token, timeoutMs: 120000 }
|
|
120
121
|
);
|
|
121
122
|
|
|
122
123
|
let remoteFiles = {};
|
|
123
124
|
if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) {
|
|
124
125
|
for (const file of snapshotResult.data.files) {
|
|
125
|
-
if (file.path && !file.binary) {
|
|
126
|
-
|
|
126
|
+
if (file.path && !file.binary && file.content != null) {
|
|
127
|
+
// Compute hash from content (matches how computeLocalHashes works on raw bytes)
|
|
128
|
+
const rawBytes = Buffer.from(file.content, 'utf-8');
|
|
129
|
+
const hash = require('crypto').createHash('sha256').update(rawBytes).digest('hex');
|
|
130
|
+
remoteFiles[file.path] = { hash, size: rawBytes.length };
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
133
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section-level three-way merge for structured markdown files.
|
|
3
|
+
*
|
|
4
|
+
* Parses markdown into sections (split on ## headers + YAML frontmatter).
|
|
5
|
+
* Merges non-conflicting section changes. Flags same-section conflicts.
|
|
6
|
+
*
|
|
7
|
+
* This is what makes us better than git for context files.
|
|
8
|
+
* Git merges by line. We merge by section.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse a markdown document into sections.
|
|
13
|
+
* Returns: { __frontmatter__: string, __header__: string, sections: [{name, content}] }
|
|
14
|
+
*/
|
|
15
|
+
function parseSections(content) {
|
|
16
|
+
if (!content) return { frontmatter: '', header: '', sections: [] };
|
|
17
|
+
|
|
18
|
+
const lines = content.split('\n');
|
|
19
|
+
let frontmatter = '';
|
|
20
|
+
let header = '';
|
|
21
|
+
const sections = [];
|
|
22
|
+
let current = null;
|
|
23
|
+
let inFrontmatter = false;
|
|
24
|
+
let frontmatterDone = false;
|
|
25
|
+
let headerLines = [];
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
const line = lines[i];
|
|
29
|
+
|
|
30
|
+
// YAML frontmatter
|
|
31
|
+
if (i === 0 && line.trim() === '---') {
|
|
32
|
+
inFrontmatter = true;
|
|
33
|
+
headerLines.push(line);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (inFrontmatter) {
|
|
37
|
+
headerLines.push(line);
|
|
38
|
+
if (line.trim() === '---') {
|
|
39
|
+
inFrontmatter = false;
|
|
40
|
+
frontmatterDone = true;
|
|
41
|
+
frontmatter = headerLines.join('\n');
|
|
42
|
+
headerLines = [];
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Section headers
|
|
48
|
+
if (line.startsWith('## ')) {
|
|
49
|
+
// Save previous section or header
|
|
50
|
+
if (current) {
|
|
51
|
+
sections.push(current);
|
|
52
|
+
} else if (headerLines.length > 0) {
|
|
53
|
+
header = headerLines.join('\n');
|
|
54
|
+
}
|
|
55
|
+
current = { name: line.substring(3).trim(), content: line };
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Content belongs to current section or header
|
|
60
|
+
if (current) {
|
|
61
|
+
current.content += '\n' + line;
|
|
62
|
+
} else {
|
|
63
|
+
headerLines.push(line);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Save last section or header
|
|
68
|
+
if (current) {
|
|
69
|
+
sections.push(current);
|
|
70
|
+
} else if (headerLines.length > 0 && !header) {
|
|
71
|
+
header = headerLines.join('\n');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { frontmatter, header, sections };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reconstruct a markdown document from parsed sections.
|
|
79
|
+
*/
|
|
80
|
+
function reconstructDocument(parsed) {
|
|
81
|
+
const parts = [];
|
|
82
|
+
if (parsed.frontmatter) parts.push(parsed.frontmatter);
|
|
83
|
+
if (parsed.header) parts.push(parsed.header);
|
|
84
|
+
for (const section of parsed.sections) {
|
|
85
|
+
parts.push(section.content);
|
|
86
|
+
}
|
|
87
|
+
return parts.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Three-way section merge.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} base - Common ancestor content
|
|
94
|
+
* @param {string} local - Your version
|
|
95
|
+
* @param {string} remote - Their version
|
|
96
|
+
* @returns {{ merged: string|null, conflicts: [{section, local, remote}] }}
|
|
97
|
+
*
|
|
98
|
+
* If merged is non-null, the merge succeeded (conflicts array is empty).
|
|
99
|
+
* If merged is null, there are conflicts that need manual resolution.
|
|
100
|
+
*/
|
|
101
|
+
function sectionMerge(base, local, remote) {
|
|
102
|
+
const baseParsed = parseSections(base);
|
|
103
|
+
const localParsed = parseSections(local);
|
|
104
|
+
const remoteParsed = parseSections(remote);
|
|
105
|
+
|
|
106
|
+
const conflicts = [];
|
|
107
|
+
|
|
108
|
+
// Merge frontmatter (field-by-field if both changed, otherwise take the changed one)
|
|
109
|
+
let mergedFrontmatter = baseParsed.frontmatter;
|
|
110
|
+
if (localParsed.frontmatter !== baseParsed.frontmatter && remoteParsed.frontmatter === baseParsed.frontmatter) {
|
|
111
|
+
mergedFrontmatter = localParsed.frontmatter;
|
|
112
|
+
} else if (remoteParsed.frontmatter !== baseParsed.frontmatter && localParsed.frontmatter === baseParsed.frontmatter) {
|
|
113
|
+
mergedFrontmatter = remoteParsed.frontmatter;
|
|
114
|
+
} else if (localParsed.frontmatter !== remoteParsed.frontmatter && localParsed.frontmatter !== baseParsed.frontmatter) {
|
|
115
|
+
conflicts.push({ section: 'frontmatter', local: localParsed.frontmatter, remote: remoteParsed.frontmatter });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Merge header
|
|
119
|
+
let mergedHeader = baseParsed.header;
|
|
120
|
+
if (localParsed.header !== baseParsed.header && remoteParsed.header === baseParsed.header) {
|
|
121
|
+
mergedHeader = localParsed.header;
|
|
122
|
+
} else if (remoteParsed.header !== baseParsed.header && localParsed.header === baseParsed.header) {
|
|
123
|
+
mergedHeader = remoteParsed.header;
|
|
124
|
+
} else if (localParsed.header !== remoteParsed.header && localParsed.header !== baseParsed.header) {
|
|
125
|
+
conflicts.push({ section: 'header', local: localParsed.header, remote: remoteParsed.header });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Build section maps
|
|
129
|
+
const baseMap = {};
|
|
130
|
+
for (const s of baseParsed.sections) baseMap[s.name] = s.content;
|
|
131
|
+
const localMap = {};
|
|
132
|
+
for (const s of localParsed.sections) localMap[s.name] = s.content;
|
|
133
|
+
const remoteMap = {};
|
|
134
|
+
for (const s of remoteParsed.sections) remoteMap[s.name] = s.content;
|
|
135
|
+
|
|
136
|
+
// Get all section names preserving order (base order, then new sections)
|
|
137
|
+
const allNames = [];
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
for (const s of baseParsed.sections) { allNames.push(s.name); seen.add(s.name); }
|
|
140
|
+
for (const s of localParsed.sections) { if (!seen.has(s.name)) { allNames.push(s.name); seen.add(s.name); } }
|
|
141
|
+
for (const s of remoteParsed.sections) { if (!seen.has(s.name)) { allNames.push(s.name); seen.add(s.name); } }
|
|
142
|
+
|
|
143
|
+
// Merge each section
|
|
144
|
+
const mergedSections = [];
|
|
145
|
+
for (const name of allNames) {
|
|
146
|
+
const b = baseMap[name] || null;
|
|
147
|
+
const l = localMap[name] || null;
|
|
148
|
+
const r = remoteMap[name] || null;
|
|
149
|
+
|
|
150
|
+
if (l === r) {
|
|
151
|
+
// Both same — take either (or null = both deleted)
|
|
152
|
+
if (l !== null) mergedSections.push({ name, content: l });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (b === null) {
|
|
157
|
+
// New section — exists in one or both
|
|
158
|
+
if (l && !r) { mergedSections.push({ name, content: l }); continue; }
|
|
159
|
+
if (r && !l) { mergedSections.push({ name, content: r }); continue; }
|
|
160
|
+
// Both added same-named section with different content
|
|
161
|
+
conflicts.push({ section: name, local: l, remote: r });
|
|
162
|
+
mergedSections.push({ name, content: l }); // default to local
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const localChanged = l !== b;
|
|
167
|
+
const remoteChanged = r !== b;
|
|
168
|
+
|
|
169
|
+
if (!localChanged && remoteChanged) {
|
|
170
|
+
if (r !== null) mergedSections.push({ name, content: r });
|
|
171
|
+
// else: remote deleted it, local didn't change → accept deletion
|
|
172
|
+
} else if (localChanged && !remoteChanged) {
|
|
173
|
+
if (l !== null) mergedSections.push({ name, content: l });
|
|
174
|
+
// else: local deleted it, remote didn't change → accept deletion
|
|
175
|
+
} else {
|
|
176
|
+
// Both changed the same section → conflict
|
|
177
|
+
conflicts.push({ section: name, local: l, remote: r });
|
|
178
|
+
if (l !== null) mergedSections.push({ name, content: l }); // default to local
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (conflicts.length > 0) {
|
|
183
|
+
return { merged: null, conflicts };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Reconstruct
|
|
187
|
+
const merged = reconstructDocument({
|
|
188
|
+
frontmatter: mergedFrontmatter,
|
|
189
|
+
header: mergedHeader,
|
|
190
|
+
sections: mergedSections,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { merged, conflicts: [] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { parseSections, reconstructDocument, sectionMerge };
|
package/package.json
CHANGED
package/utils/api.js
CHANGED
|
@@ -41,7 +41,7 @@ function httpRequest(urlString, options) {
|
|
|
41
41
|
const parsed = new URL(urlString);
|
|
42
42
|
const isHttps = parsed.protocol === 'https:';
|
|
43
43
|
const transport = isHttps ? https : http;
|
|
44
|
-
const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs :
|
|
44
|
+
const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 30000;
|
|
45
45
|
|
|
46
46
|
const requestOptions = {
|
|
47
47
|
method: options.method || 'GET',
|
|
@@ -52,6 +52,13 @@ function httpRequest(urlString, options) {
|
|
|
52
52
|
};
|
|
53
53
|
|
|
54
54
|
const req = transport.request(requestOptions, (res) => {
|
|
55
|
+
// Follow redirects (301, 302, 307, 308)
|
|
56
|
+
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
57
|
+
const redirectUrl = new URL(res.headers.location, urlString).toString();
|
|
58
|
+
resolve(httpRequest(redirectUrl, options));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
55
62
|
const chunks = [];
|
|
56
63
|
res.on('data', (chunk) => chunks.push(chunk));
|
|
57
64
|
res.on('end', () => {
|
|
@@ -111,6 +118,7 @@ async function apiRequestJson(pathname, options = {}) {
|
|
|
111
118
|
method: options.method || 'GET',
|
|
112
119
|
headers,
|
|
113
120
|
body: bodyPayload,
|
|
121
|
+
timeoutMs: options.timeoutMs,
|
|
114
122
|
});
|
|
115
123
|
|
|
116
124
|
const text = result.body.toString('utf8');
|
package/utils/update-check.js
CHANGED
|
@@ -4,8 +4,9 @@ const path = require('path');
|
|
|
4
4
|
const os = require('os');
|
|
5
5
|
|
|
6
6
|
const PACKAGE_NAME = 'atris';
|
|
7
|
-
const CHECK_INTERVAL_MS =
|
|
8
|
-
const
|
|
7
|
+
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
8
|
+
const ATRIS_DIR = path.join(os.homedir(), '.atris');
|
|
9
|
+
const CACHE_FILE = path.join(ATRIS_DIR, '.update-check');
|
|
9
10
|
|
|
10
11
|
function getInstalledVersion() {
|
|
11
12
|
try {
|
|
@@ -34,6 +35,10 @@ function getCacheData() {
|
|
|
34
35
|
|
|
35
36
|
function saveCacheData(latestVersion) {
|
|
36
37
|
try {
|
|
38
|
+
// Ensure ~/.atris/ exists
|
|
39
|
+
if (!fs.existsSync(ATRIS_DIR)) {
|
|
40
|
+
fs.mkdirSync(ATRIS_DIR, { recursive: true });
|
|
41
|
+
}
|
|
37
42
|
const data = {
|
|
38
43
|
lastCheck: new Date().toISOString(),
|
|
39
44
|
latestVersion: latestVersion,
|
|
@@ -164,15 +169,10 @@ async function checkForUpdates(force = false) {
|
|
|
164
169
|
function showUpdateNotification(updateInfo) {
|
|
165
170
|
if (!updateInfo || !updateInfo.needsUpdate) return;
|
|
166
171
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
console.log(
|
|
171
|
-
if (updateInfo.fromCache) {
|
|
172
|
-
console.log(` (checking npm registry...)`);
|
|
173
|
-
}
|
|
174
|
-
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
175
|
-
console.log('');
|
|
172
|
+
// Single yellow warning line — non-intrusive
|
|
173
|
+
const yellow = '\x1b[33m';
|
|
174
|
+
const reset = '\x1b[0m';
|
|
175
|
+
console.log(`${yellow}Update available: ${updateInfo.installed} → ${updateInfo.latest}. Run: npm install -g atris${reset}`);
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
function autoUpdate(updateInfo) {
|