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/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
|
@@ -10,10 +10,23 @@ const { loadBusinesses } = require('./business');
|
|
|
10
10
|
const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
|
|
11
11
|
|
|
12
12
|
async function pullAtris() {
|
|
13
|
-
|
|
13
|
+
let arg = process.argv[3];
|
|
14
|
+
|
|
15
|
+
// Auto-detect business from .atris/business.json in current dir
|
|
16
|
+
if (!arg || arg.startsWith('--')) {
|
|
17
|
+
const bizFile = path.join(process.cwd(), '.atris', 'business.json');
|
|
18
|
+
if (fs.existsSync(bizFile)) {
|
|
19
|
+
try {
|
|
20
|
+
const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
|
|
21
|
+
if (biz.slug || biz.name) {
|
|
22
|
+
return pullBusiness(biz.slug || biz.name);
|
|
23
|
+
}
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
14
27
|
|
|
15
28
|
// If a business name is given, do a business pull
|
|
16
|
-
if (arg && arg !== '--help') {
|
|
29
|
+
if (arg && arg !== '--help' && !arg.startsWith('--')) {
|
|
17
30
|
return pullBusiness(arg);
|
|
18
31
|
}
|
|
19
32
|
|
|
@@ -80,6 +93,40 @@ async function pullBusiness(slug) {
|
|
|
80
93
|
|
|
81
94
|
const force = process.argv.includes('--force');
|
|
82
95
|
|
|
96
|
+
// Parse --only flag: comma-separated directory prefixes to filter
|
|
97
|
+
// Supports both --only=team/,context/ and --only team/,context/
|
|
98
|
+
let onlyRaw = null;
|
|
99
|
+
const onlyEqArg = process.argv.find(a => a.startsWith('--only='));
|
|
100
|
+
if (onlyEqArg) {
|
|
101
|
+
onlyRaw = onlyEqArg.slice('--only='.length);
|
|
102
|
+
} else {
|
|
103
|
+
const onlyIdx = process.argv.indexOf('--only');
|
|
104
|
+
if (onlyIdx !== -1 && process.argv[onlyIdx + 1] && !process.argv[onlyIdx + 1].startsWith('-')) {
|
|
105
|
+
onlyRaw = process.argv[onlyIdx + 1];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const onlyPrefixes = onlyRaw
|
|
109
|
+
? onlyRaw.split(',').map(p => {
|
|
110
|
+
let norm = p.replace(/^\//, '');
|
|
111
|
+
if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/';
|
|
112
|
+
return norm;
|
|
113
|
+
}).filter(Boolean)
|
|
114
|
+
: null;
|
|
115
|
+
|
|
116
|
+
// Parse --timeout flag: override default 300s timeout
|
|
117
|
+
// Supports both --timeout=60 and --timeout 60
|
|
118
|
+
let timeoutSec = 300;
|
|
119
|
+
const timeoutEqArg = process.argv.find(a => a.startsWith('--timeout='));
|
|
120
|
+
if (timeoutEqArg) {
|
|
121
|
+
timeoutSec = parseInt(timeoutEqArg.slice('--timeout='.length), 10);
|
|
122
|
+
} else {
|
|
123
|
+
const timeoutIdx = process.argv.indexOf('--timeout');
|
|
124
|
+
if (timeoutIdx !== -1 && process.argv[timeoutIdx + 1]) {
|
|
125
|
+
timeoutSec = parseInt(process.argv[timeoutIdx + 1], 10);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const timeoutMs = timeoutSec * 1000;
|
|
129
|
+
|
|
83
130
|
// Determine output directory
|
|
84
131
|
const intoIdx = process.argv.indexOf('--into');
|
|
85
132
|
let outputDir;
|
|
@@ -94,21 +141,23 @@ async function pullBusiness(slug) {
|
|
|
94
141
|
}
|
|
95
142
|
}
|
|
96
143
|
|
|
97
|
-
// Resolve business ID —
|
|
144
|
+
// Resolve business ID — always refresh from API to avoid stale workspace_id
|
|
98
145
|
let businessId, workspaceId, businessName, resolvedSlug;
|
|
99
146
|
const businesses = loadBusinesses();
|
|
100
147
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
148
|
+
const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
|
|
149
|
+
if (!listResult.ok) {
|
|
150
|
+
// Fall back to local cache if API fails
|
|
151
|
+
if (businesses[slug]) {
|
|
152
|
+
businessId = businesses[slug].business_id;
|
|
153
|
+
workspaceId = businesses[slug].workspace_id;
|
|
154
|
+
businessName = businesses[slug].name || slug;
|
|
155
|
+
resolvedSlug = businesses[slug].slug || slug;
|
|
156
|
+
} else {
|
|
109
157
|
console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
|
|
110
158
|
process.exit(1);
|
|
111
159
|
}
|
|
160
|
+
} else {
|
|
112
161
|
const match = (listResult.data || []).find(
|
|
113
162
|
b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
|
|
114
163
|
);
|
|
@@ -121,6 +170,7 @@ async function pullBusiness(slug) {
|
|
|
121
170
|
businessName = match.name;
|
|
122
171
|
resolvedSlug = match.slug;
|
|
123
172
|
|
|
173
|
+
// Update local cache
|
|
124
174
|
businesses[slug] = {
|
|
125
175
|
business_id: businessId,
|
|
126
176
|
workspace_id: workspaceId,
|
|
@@ -144,15 +194,31 @@ async function pullBusiness(slug) {
|
|
|
144
194
|
console.log('');
|
|
145
195
|
console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
|
|
146
196
|
|
|
147
|
-
//
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
)
|
|
197
|
+
// Loading indicator with elapsed time
|
|
198
|
+
const startTime = Date.now();
|
|
199
|
+
const spinner = ['|', '/', '-', '\\'];
|
|
200
|
+
let spinIdx = 0;
|
|
201
|
+
const loading = setInterval(() => {
|
|
202
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
203
|
+
process.stdout.write(`\r Fetching workspace... ${spinner[spinIdx++ % 4]} ${elapsed}s`);
|
|
204
|
+
}, 250);
|
|
205
|
+
|
|
206
|
+
// Get remote snapshot — pass --only prefixes to server for faster response
|
|
207
|
+
let snapshotUrl = `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`;
|
|
208
|
+
if (onlyPrefixes) {
|
|
209
|
+
snapshotUrl += `&paths=${encodeURIComponent(onlyPrefixes.map(p => p.replace(/\/$/, '')).join(','))}`;
|
|
210
|
+
}
|
|
211
|
+
const result = await apiRequestJson(snapshotUrl, { method: 'GET', token: creds.token, timeoutMs });
|
|
212
|
+
|
|
213
|
+
clearInterval(loading);
|
|
214
|
+
const totalSec = Math.floor((Date.now() - startTime) / 1000);
|
|
215
|
+
process.stdout.write(`\r Fetched in ${totalSec}s.${' '.repeat(20)}\n`);
|
|
152
216
|
|
|
153
217
|
if (!result.ok) {
|
|
154
|
-
const msg = result.errorMessage || `HTTP ${result.status}`;
|
|
155
|
-
if (result.status ===
|
|
218
|
+
const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
|
|
219
|
+
if (result.status === 0 || (typeof msg === 'string' && msg.toLowerCase().includes('timeout'))) {
|
|
220
|
+
console.error(`\n Workspace timed out (large workspaces can take 60s+). Try: atris pull ${slug} --timeout=600`);
|
|
221
|
+
} else if (result.status === 409) {
|
|
156
222
|
console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
|
|
157
223
|
} else if (result.status === 403) {
|
|
158
224
|
console.error(`\n Access denied. You're not a member of "${slug}".`);
|
|
@@ -164,27 +230,50 @@ async function pullBusiness(slug) {
|
|
|
164
230
|
process.exit(1);
|
|
165
231
|
}
|
|
166
232
|
|
|
167
|
-
|
|
233
|
+
let files = result.data.files || [];
|
|
168
234
|
if (files.length === 0) {
|
|
169
235
|
console.log(' Workspace is empty.');
|
|
170
236
|
return;
|
|
171
237
|
}
|
|
172
238
|
|
|
239
|
+
console.log(` Processing ${files.length} files...`);
|
|
240
|
+
|
|
241
|
+
// Apply --only filter if specified
|
|
242
|
+
if (onlyPrefixes) {
|
|
243
|
+
files = files.filter(file => {
|
|
244
|
+
if (!file.path) return false;
|
|
245
|
+
const rel = file.path.replace(/^\//, '');
|
|
246
|
+
return onlyPrefixes.some(prefix => rel.startsWith(prefix));
|
|
247
|
+
});
|
|
248
|
+
if (files.length === 0) {
|
|
249
|
+
console.log(` No files matched --only filter: ${onlyPrefixes.join(', ')}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
console.log(` Filtered to ${files.length} files matching: ${onlyPrefixes.join(', ')}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
173
255
|
// Build remote file map {path: {hash, size, content}}
|
|
174
256
|
const remoteFiles = {};
|
|
175
257
|
const remoteContent = {};
|
|
176
258
|
for (const file of files) {
|
|
177
259
|
if (!file.path || file.binary || file.content === null || file.content === undefined) continue;
|
|
178
|
-
|
|
260
|
+
// Skip empty files (deleted files that were blanked out)
|
|
261
|
+
if (file.content === '') continue;
|
|
262
|
+
// Compute hash from content bytes (matches computeLocalHashes raw byte hashing)
|
|
263
|
+
const crypto = require('crypto');
|
|
264
|
+
const rawBytes = Buffer.from(file.content, 'utf-8');
|
|
265
|
+
remoteFiles[file.path] = { hash: crypto.createHash('sha256').update(rawBytes).digest('hex'), size: rawBytes.length };
|
|
179
266
|
remoteContent[file.path] = file.content;
|
|
180
267
|
}
|
|
181
268
|
|
|
182
269
|
// Compute local file hashes
|
|
183
270
|
const localFiles = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
|
|
184
271
|
|
|
272
|
+
// If output dir is empty (fresh clone) or --force, treat as first sync — pull everything
|
|
273
|
+
const effectiveManifest = (Object.keys(localFiles).length === 0 || force) ? null : manifest;
|
|
274
|
+
|
|
185
275
|
// Three-way compare
|
|
186
|
-
const
|
|
187
|
-
const diff = threeWayCompare(localFiles, remoteFiles, manifest);
|
|
276
|
+
const diff = threeWayCompare(localFiles, remoteFiles, effectiveManifest);
|
|
188
277
|
|
|
189
278
|
// Apply changes
|
|
190
279
|
let pulled = 0;
|
|
@@ -265,9 +354,23 @@ async function pullBusiness(slug) {
|
|
|
265
354
|
// Git might not be initialized yet — that's fine
|
|
266
355
|
}
|
|
267
356
|
|
|
268
|
-
// Save manifest
|
|
269
|
-
|
|
357
|
+
// Save manifest — when using --only, merge into existing manifest to avoid data loss
|
|
358
|
+
let manifestFiles = remoteFiles;
|
|
359
|
+
if (onlyPrefixes && manifest && manifest.files) {
|
|
360
|
+
manifestFiles = { ...manifest.files, ...remoteFiles };
|
|
361
|
+
}
|
|
362
|
+
const newManifest = buildManifest(manifestFiles, commitHash);
|
|
270
363
|
saveManifest(resolvedSlug || slug, newManifest);
|
|
364
|
+
|
|
365
|
+
// Save business config in the output dir so push/status work without args
|
|
366
|
+
const atrisDir = path.join(outputDir, '.atris');
|
|
367
|
+
fs.mkdirSync(atrisDir, { recursive: true });
|
|
368
|
+
fs.writeFileSync(path.join(atrisDir, 'business.json'), JSON.stringify({
|
|
369
|
+
slug: resolvedSlug || slug,
|
|
370
|
+
business_id: businessId,
|
|
371
|
+
workspace_id: workspaceId,
|
|
372
|
+
name: businessName,
|
|
373
|
+
}, null, 2));
|
|
271
374
|
}
|
|
272
375
|
|
|
273
376
|
|