atris 2.6.2 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/commands/pull.js CHANGED
@@ -10,7 +10,20 @@ 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
29
  if (arg && arg !== '--help' && !arg.startsWith('--')) {
@@ -81,21 +94,38 @@ async function pullBusiness(slug) {
81
94
  const force = process.argv.includes('--force');
82
95
 
83
96
  // 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
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 => {
88
110
  let norm = p.replace(/^\//, '');
89
111
  if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/';
90
112
  return norm;
91
113
  }).filter(Boolean)
92
114
  : null;
93
115
 
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;
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;
99
129
 
100
130
  // Determine output directory
101
131
  const intoIdx = process.argv.indexOf('--into');
@@ -163,18 +193,31 @@ async function pullBusiness(slug) {
163
193
 
164
194
  console.log('');
165
195
  console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
166
- console.log(' Fetching workspace...');
167
196
 
168
- // Get remote snapshot (large workspaces can take 60s+)
169
- const result = await apiRequestJson(
170
- `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
171
- { method: 'GET', token: creds.token, timeoutMs }
172
- );
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`);
173
216
 
174
217
  if (!result.ok) {
175
218
  const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
176
219
  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`);
220
+ console.error(`\n Workspace timed out (large workspaces can take 60s+). Try: atris pull ${slug} --timeout=600`);
178
221
  } else if (result.status === 409) {
179
222
  console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
180
223
  } else if (result.status === 403) {
@@ -226,9 +269,11 @@ async function pullBusiness(slug) {
226
269
  // Compute local file hashes
227
270
  const localFiles = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
228
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
+
229
275
  // Three-way compare
230
- const baseFiles = (manifest && manifest.files) ? manifest.files : {};
231
- const diff = threeWayCompare(localFiles, remoteFiles, manifest);
276
+ const diff = threeWayCompare(localFiles, remoteFiles, effectiveManifest);
232
277
 
233
278
  // Apply changes
234
279
  let pulled = 0;
@@ -316,6 +361,16 @@ async function pullBusiness(slug) {
316
361
  }
317
362
  const newManifest = buildManifest(manifestFiles, commitHash);
318
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));
319
374
  }
320
375
 
321
376
 
package/commands/push.js CHANGED
@@ -4,28 +4,64 @@ const { loadCredentials } = require('../utils/auth');
4
4
  const { apiRequestJson } = require('../utils/api');
5
5
  const { loadBusinesses, saveBusinesses } = require('./business');
6
6
  const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, SKIP_DIRS } = require('../lib/manifest');
7
+ const { sectionMerge } = require('../lib/section-merge');
7
8
 
8
9
  async function pushAtris() {
9
- const slug = process.argv[3];
10
+ let slug = process.argv[3];
11
+
12
+ // Auto-detect business from .atris/business.json in current dir
13
+ if (!slug || slug.startsWith('-')) {
14
+ const bizFile = path.join(process.cwd(), '.atris', 'business.json');
15
+ if (fs.existsSync(bizFile)) {
16
+ try {
17
+ const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
18
+ slug = biz.slug || biz.name;
19
+ } catch {}
20
+ }
21
+ // If still no slug (no .atris/business.json), need explicit name
22
+ if (!slug || slug.startsWith('-')) {
23
+ slug = null;
24
+ }
25
+ }
10
26
 
11
27
  if (!slug || slug === '--help') {
12
- console.log('Usage: atris push <business-slug> [--from <path>] [--force]');
28
+ console.log('Usage: atris push [business-slug] [--from <path>] [--force]');
13
29
  console.log('');
14
30
  console.log('Push local files to a Business Computer.');
31
+ console.log('If run inside a pulled folder, business is auto-detected.');
15
32
  console.log('');
16
33
  console.log('Options:');
17
34
  console.log(' --from <path> Push from a custom directory');
18
35
  console.log(' --force Push everything, overwrite conflicts');
19
36
  console.log('');
20
37
  console.log('Examples:');
38
+ console.log(' atris push Auto-detect from current folder');
21
39
  console.log(' atris push pallet Push from atris/pallet/ or ./pallet/');
22
40
  console.log(' atris push pallet --from ./my-dir/ Push from a custom directory');
23
- console.log(' atris push pallet --force Override conflicts');
24
41
  process.exit(0);
25
42
  }
26
43
 
27
44
  const force = process.argv.includes('--force');
28
45
 
46
+ // Parse --only flag: filter which files to push
47
+ let onlyRaw = null;
48
+ const onlyEqArg = process.argv.find(a => a.startsWith('--only='));
49
+ if (onlyEqArg) {
50
+ onlyRaw = onlyEqArg.slice('--only='.length);
51
+ } else {
52
+ const onlyIdx = process.argv.indexOf('--only');
53
+ if (onlyIdx !== -1 && process.argv[onlyIdx + 1] && !process.argv[onlyIdx + 1].startsWith('-')) {
54
+ onlyRaw = process.argv[onlyIdx + 1];
55
+ }
56
+ }
57
+ const onlyPrefixes = onlyRaw
58
+ ? onlyRaw.split(',').map(p => {
59
+ let norm = p.replace(/^\//, '');
60
+ if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/';
61
+ return '/' + norm;
62
+ }).filter(Boolean)
63
+ : null;
64
+
29
65
  const creds = loadCredentials();
30
66
  if (!creds || !creds.token) {
31
67
  console.error('Not logged in. Run: atris login');
@@ -37,6 +73,9 @@ async function pushAtris() {
37
73
  let sourceDir;
38
74
  if (fromIdx !== -1 && process.argv[fromIdx + 1]) {
39
75
  sourceDir = path.resolve(process.argv[fromIdx + 1]);
76
+ } else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
77
+ // Inside a pulled folder — push from here
78
+ sourceDir = process.cwd();
40
79
  } else {
41
80
  const atrisDir = path.join(process.cwd(), 'atris', slug);
42
81
  const cwdDir = path.join(process.cwd(), slug);
@@ -114,20 +153,33 @@ async function pushAtris() {
114
153
  console.log('');
115
154
  console.log(`Pushing to ${businessName}...`);
116
155
 
117
- // Get snapshot with content to compute reliable hashes (server hash may differ)
156
+ // Loading indicator
157
+ const startTime = Date.now();
158
+ const spinner = ['|', '/', '-', '\\'];
159
+ let spinIdx = 0;
160
+ const loading = setInterval(() => {
161
+ const elapsed = Math.floor((Date.now() - startTime) / 1000);
162
+ process.stdout.write(`\r Comparing with remote... ${spinner[spinIdx++ % 4]} ${elapsed}s`);
163
+ }, 250);
164
+
118
165
  const snapshotResult = await apiRequestJson(
119
166
  `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
120
- { method: 'GET', token: creds.token, timeoutMs: 120000 }
167
+ { method: 'GET', token: creds.token, timeoutMs: 300000 }
121
168
  );
122
169
 
170
+ clearInterval(loading);
171
+ const totalSec = Math.floor((Date.now() - startTime) / 1000);
172
+ process.stdout.write(`\r Compared in ${totalSec}s.${' '.repeat(20)}\n`);
173
+
123
174
  let remoteFiles = {};
175
+ const remoteContent = {}; // for section merge
124
176
  if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) {
125
177
  for (const file of snapshotResult.data.files) {
126
178
  if (file.path && !file.binary && file.content != null) {
127
- // Compute hash from content (matches how computeLocalHashes works on raw bytes)
128
179
  const rawBytes = Buffer.from(file.content, 'utf-8');
129
180
  const hash = require('crypto').createHash('sha256').update(rawBytes).digest('hex');
130
181
  remoteFiles[file.path] = { hash, size: rawBytes.length };
182
+ remoteContent[file.path] = file.content;
131
183
  }
132
184
  }
133
185
  }
@@ -135,11 +187,23 @@ async function pushAtris() {
135
187
  // Three-way compare
136
188
  const diff = threeWayCompare(localFiles, remoteFiles, manifest);
137
189
 
190
+ // Check if user is a member (not owner) — if so, filter to allowed paths
191
+ // Members can only push to /team/{name}/ and /journal/
192
+ let skippedPermission = [];
193
+ const role = snapshotResult.data?._role; // not available from snapshot, so we try the push and handle 403
194
+
138
195
  // Determine what to push
139
196
  const filesToPush = [];
140
197
 
198
+ // Apply --only filter
199
+ const matchesOnly = (filePath) => {
200
+ if (!onlyPrefixes) return true;
201
+ return onlyPrefixes.some(prefix => filePath.startsWith(prefix));
202
+ };
203
+
141
204
  // Files we changed that remote didn't
142
205
  for (const p of [...diff.toPush, ...diff.newLocal]) {
206
+ if (!matchesOnly(p)) continue;
143
207
  const localPath = path.join(sourceDir, p.replace(/^\//, ''));
144
208
  try {
145
209
  const content = fs.readFileSync(localPath, 'utf8');
@@ -149,22 +213,38 @@ async function pushAtris() {
149
213
  }
150
214
  }
151
215
 
152
- // Force mode: also push conflicts
216
+ // Handle conflicts: try section-level merge first, then force, then flag
153
217
  const conflictPaths = [];
154
- if (force) {
155
- for (const p of diff.conflicts) {
156
- const localPath = path.join(sourceDir, p.replace(/^\//, ''));
157
- try {
158
- const content = fs.readFileSync(localPath, 'utf8');
159
- filesToPush.push({ path: p, content });
160
- } catch {
161
- // skip
162
- }
218
+ const mergedPaths = [];
219
+ for (const p of diff.conflicts) {
220
+ const localPath = path.join(sourceDir, p.replace(/^\//, ''));
221
+ let localContent;
222
+ try { localContent = fs.readFileSync(localPath, 'utf8'); } catch { continue; }
223
+
224
+ if (force) {
225
+ filesToPush.push({ path: p, content: localContent });
226
+ continue;
163
227
  }
164
- } else {
165
- for (const p of diff.conflicts) {
166
- conflictPaths.push(p);
228
+
229
+ // Try section-level merge (only for .md files)
230
+ if (p.endsWith('.md') && remoteContent[p] && manifest && manifest.files && manifest.files[p]) {
231
+ // Get base content: we need what the file looked like at last sync.
232
+ // We don't store content in manifest, so use remote as best-effort base
233
+ // when manifest hash matches neither side (true conflict).
234
+ // For now, attempt merge with remote content and see if sections differ.
235
+ const remote = remoteContent[p];
236
+ // Simple heuristic: if one side only added content (appended sections), merge works
237
+ const result = sectionMerge(remote, localContent, remote);
238
+ // A better merge needs the base version. For now, try local-as-changed vs remote-as-base:
239
+ const mergeResult = sectionMerge(remote, localContent, remote);
240
+ if (mergeResult.merged && mergeResult.conflicts.length === 0 && mergeResult.merged !== remote) {
241
+ filesToPush.push({ path: p, content: mergeResult.merged });
242
+ mergedPaths.push(p);
243
+ continue;
244
+ }
167
245
  }
246
+
247
+ conflictPaths.push(p);
168
248
  }
169
249
 
170
250
  console.log('');
@@ -189,11 +269,38 @@ async function pushAtris() {
189
269
  );
190
270
 
191
271
  if (!result.ok) {
192
- const msg = result.errorMessage || `HTTP ${result.status}`;
272
+ const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
193
273
  if (result.status === 409) {
194
274
  console.error(` Computer is sleeping. Wake it first, then push.`);
195
275
  } else if (result.status === 403) {
196
- console.error(` Access denied: ${msg}`);
276
+ // Member scoping — retry with only team/ and journal/ files
277
+ const memberFiles = filesToPush.filter(f => f.path.startsWith('/team/') || f.path.startsWith('/journal/'));
278
+ const blockedFiles = filesToPush.filter(f => !f.path.startsWith('/team/') && !f.path.startsWith('/journal/'));
279
+ if (memberFiles.length > 0 && blockedFiles.length > 0) {
280
+ console.log(` You're a member — retrying with your team files only...`);
281
+ if (blockedFiles.length > 0) {
282
+ console.log(` Skipped (no permission): ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`);
283
+ }
284
+ const retry = await apiRequestJson(
285
+ `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
286
+ { method: 'POST', token: creds.token, body: { files: memberFiles }, headers: { 'X-Atris-Actor-Source': 'cli' } }
287
+ );
288
+ if (retry.ok) {
289
+ for (const f of memberFiles) {
290
+ console.log(` \u2191 ${f.path.replace(/^\//, '')} pushed`);
291
+ pushed++;
292
+ }
293
+ } else {
294
+ console.error(` Push failed after retry: ${retry.errorMessage || retry.error || retry.status}`);
295
+ process.exit(1);
296
+ }
297
+ } else {
298
+ console.error(` Access denied: you can only push to your own team/ folder.`);
299
+ if (blockedFiles.length > 0) {
300
+ console.error(` Blocked: ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`);
301
+ }
302
+ process.exit(1);
303
+ }
197
304
  } else {
198
305
  console.error(` Push failed: ${msg}`);
199
306
  }
@@ -215,6 +322,10 @@ async function pushAtris() {
215
322
  pushed++;
216
323
  }
217
324
  }
325
+ for (const p of mergedPaths) {
326
+ console.log(` \u2194 ${p.replace(/^\//, '')} auto-merged (different sections)`);
327
+ pushed++;
328
+ }
218
329
  }
219
330
 
220
331
  // Show conflicts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "2.6.2",
3
+ "version": "2.6.3",
4
4
  "description": "atrisDev (atris dev) - CLI for AI coding agents. Works with Claude Code, Cursor, Windsurf. Make any codebase AI-navigable.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
package/utils/api.js CHANGED
@@ -71,11 +71,18 @@ function httpRequest(urlString, options) {
71
71
  });
72
72
 
73
73
  req.on('error', reject);
74
+ // Socket idle timeout (fires if no data received for this duration)
74
75
  if (timeoutMs > 0) {
75
76
  req.setTimeout(timeoutMs, () => {
76
- req.destroy(new Error('Request timeout'));
77
+ req.destroy(new Error(`Request timeout after ${Math.round(timeoutMs / 1000)}s — try --timeout=300`));
77
78
  });
78
79
  }
80
+ // Hard deadline — kill request after 2x the timeout regardless of activity
81
+ const hardDeadline = timeoutMs > 0
82
+ ? setTimeout(() => { req.destroy(new Error(`Hard deadline exceeded (${Math.round(timeoutMs * 2 / 1000)}s)`)); }, timeoutMs * 2)
83
+ : null;
84
+ // Clear hard deadline when response completes
85
+ req.on('close', () => { if (hardDeadline) clearTimeout(hardDeadline); });
79
86
 
80
87
  if (options.body) {
81
88
  if (!req.hasHeader('Content-Length')) {