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 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');
@@ -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 };
@@ -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 (hashes only)
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=false`,
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
- remoteFiles[file.path] = { hash: file.hash, size: file.size || 0 };
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
- const arg = process.argv[3];
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 — check local config first, then API
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
- if (businesses[slug]) {
102
- businessId = businesses[slug].business_id;
103
- workspaceId = businesses[slug].workspace_id;
104
- businessName = businesses[slug].name || slug;
105
- resolvedSlug = businesses[slug].slug || slug;
106
- } else {
107
- const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
108
- if (!listResult.ok) {
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
- // Get remote snapshot (large workspaces can take 60s+)
148
- const result = await apiRequestJson(
149
- `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
150
- { method: 'GET', token: creds.token, timeoutMs: 120000 }
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 === 409) {
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
- const files = result.data.files || [];
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
- remoteFiles[file.path] = { hash: file.hash || computeFileHash(file.content), size: file.size || 0 };
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 baseFiles = (manifest && manifest.files) ? manifest.files : {};
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
- const newManifest = buildManifest(remoteFiles, commitHash);
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