atris 2.6.0 → 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.
Files changed (46) hide show
  1. package/GETTING_STARTED.md +2 -2
  2. package/atris/GETTING_STARTED.md +2 -2
  3. package/bin/atris.js +35 -4
  4. package/commands/business.js +244 -2
  5. package/commands/context-sync.js +228 -0
  6. package/commands/pull.js +176 -50
  7. package/commands/push.js +154 -61
  8. package/commands/setup.js +178 -0
  9. package/commands/workspace-clean.js +249 -0
  10. package/lib/manifest.js +224 -0
  11. package/lib/section-merge.js +196 -0
  12. package/package.json +9 -4
  13. package/utils/api.js +9 -1
  14. package/utils/update-check.js +11 -11
  15. package/AGENT.md +0 -35
  16. package/atris/experiments/README.md +0 -118
  17. package/atris/experiments/_examples/smoke-keep-revert/README.md +0 -45
  18. package/atris/experiments/_examples/smoke-keep-revert/candidate.py +0 -8
  19. package/atris/experiments/_examples/smoke-keep-revert/loop.py +0 -129
  20. package/atris/experiments/_examples/smoke-keep-revert/measure.py +0 -47
  21. package/atris/experiments/_examples/smoke-keep-revert/program.md +0 -3
  22. package/atris/experiments/_examples/smoke-keep-revert/proposals/bad_patch.py +0 -19
  23. package/atris/experiments/_examples/smoke-keep-revert/proposals/fix_patch.py +0 -22
  24. package/atris/experiments/_examples/smoke-keep-revert/reset.py +0 -21
  25. package/atris/experiments/_examples/smoke-keep-revert/results.tsv +0 -5
  26. package/atris/experiments/_examples/smoke-keep-revert/visual.svg +0 -52
  27. package/atris/experiments/_fixtures/invalid/BadName/loop.py +0 -1
  28. package/atris/experiments/_fixtures/invalid/BadName/program.md +0 -3
  29. package/atris/experiments/_fixtures/invalid/BadName/results.tsv +0 -1
  30. package/atris/experiments/_fixtures/invalid/bloated-context/loop.py +0 -1
  31. package/atris/experiments/_fixtures/invalid/bloated-context/measure.py +0 -1
  32. package/atris/experiments/_fixtures/invalid/bloated-context/program.md +0 -6
  33. package/atris/experiments/_fixtures/invalid/bloated-context/results.tsv +0 -1
  34. package/atris/experiments/_fixtures/valid/good-experiment/loop.py +0 -1
  35. package/atris/experiments/_fixtures/valid/good-experiment/measure.py +0 -1
  36. package/atris/experiments/_fixtures/valid/good-experiment/program.md +0 -3
  37. package/atris/experiments/_fixtures/valid/good-experiment/results.tsv +0 -1
  38. package/atris/experiments/_template/pack/loop.py +0 -3
  39. package/atris/experiments/_template/pack/measure.py +0 -13
  40. package/atris/experiments/_template/pack/program.md +0 -3
  41. package/atris/experiments/_template/pack/reset.py +0 -3
  42. package/atris/experiments/_template/pack/results.tsv +0 -1
  43. package/atris/experiments/benchmark_runtime.py +0 -81
  44. package/atris/experiments/benchmark_validate.py +0 -70
  45. package/atris/experiments/validate.py +0 -92
  46. package/atris/team/navigator/journal/2026-02-23.md +0 -6
package/commands/pull.js CHANGED
@@ -7,12 +7,13 @@ const { loadConfig } = require('../utils/config');
7
7
  const { getLogPath } = require('../lib/file-ops');
8
8
  const { parseJournalSections, mergeSections, reconstructJournal } = require('../lib/journal');
9
9
  const { loadBusinesses } = require('./business');
10
+ const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
10
11
 
11
12
  async function pullAtris() {
12
13
  const arg = process.argv[3];
13
14
 
14
15
  // If a business name is given, do a business pull
15
- if (arg && arg !== '--help') {
16
+ if (arg && arg !== '--help' && !arg.startsWith('--')) {
16
17
  return pullBusiness(arg);
17
18
  }
18
19
 
@@ -77,13 +78,31 @@ async function pullBusiness(slug) {
77
78
  process.exit(1);
78
79
  }
79
80
 
81
+ const force = process.argv.includes('--force');
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
+
80
100
  // Determine output directory
81
101
  const intoIdx = process.argv.indexOf('--into');
82
102
  let outputDir;
83
103
  if (intoIdx !== -1 && process.argv[intoIdx + 1]) {
84
104
  outputDir = path.resolve(process.argv[intoIdx + 1]);
85
105
  } else {
86
- // Default: atris/{slug}/ in current directory, or just {slug}/ if no atris/ folder
87
106
  const atrisDir = path.join(process.cwd(), 'atris');
88
107
  if (fs.existsSync(atrisDir)) {
89
108
  outputDir = path.join(atrisDir, slug);
@@ -92,21 +111,23 @@ async function pullBusiness(slug) {
92
111
  }
93
112
  }
94
113
 
95
- // Resolve business ID — check local config first, then API
96
- let businessId, workspaceId, businessName;
114
+ // Resolve business ID — always refresh from API to avoid stale workspace_id
115
+ let businessId, workspaceId, businessName, resolvedSlug;
97
116
  const businesses = loadBusinesses();
98
117
 
99
- if (businesses[slug]) {
100
- businessId = businesses[slug].business_id;
101
- workspaceId = businesses[slug].workspace_id;
102
- businessName = businesses[slug].name || slug;
103
- } else {
104
- // Try to find by slug via API
105
- const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
106
- if (!listResult.ok) {
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 {
107
127
  console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
108
128
  process.exit(1);
109
129
  }
130
+ } else {
110
131
  const match = (listResult.data || []).find(
111
132
  b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
112
133
  );
@@ -117,8 +138,9 @@ async function pullBusiness(slug) {
117
138
  businessId = match.id;
118
139
  workspaceId = match.workspace_id;
119
140
  businessName = match.name;
141
+ resolvedSlug = match.slug;
120
142
 
121
- // Auto-save for next time
143
+ // Update local cache
122
144
  businesses[slug] = {
123
145
  business_id: businessId,
124
146
  workspace_id: workspaceId,
@@ -135,74 +157,178 @@ async function pullBusiness(slug) {
135
157
  process.exit(1);
136
158
  }
137
159
 
160
+ // Load manifest (last sync state)
161
+ const manifest = loadManifest(resolvedSlug || slug);
162
+ const timeSince = manifest ? _timeSince(manifest.last_sync) : null;
163
+
138
164
  console.log('');
139
- console.log(`Pulling ${businessName}...`);
165
+ console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
166
+ console.log(' Fetching workspace...');
140
167
 
141
- // Snapshot one API call gets everything
168
+ // Get remote snapshot (large workspaces can take 60s+)
142
169
  const result = await apiRequestJson(
143
170
  `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
144
- { method: 'GET', token: creds.token }
171
+ { method: 'GET', token: creds.token, timeoutMs }
145
172
  );
146
173
 
147
174
  if (!result.ok) {
148
- const msg = result.errorMessage || `HTTP ${result.status}`;
149
- if (result.status === 409) {
150
- console.error(`\nComputer is sleeping. Wake it first, then pull again.`);
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) {
179
+ console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
151
180
  } else if (result.status === 403) {
152
- console.error(`\nAccess denied. You're not a member of "${slug}".`);
181
+ console.error(`\n Access denied. You're not a member of "${slug}".`);
153
182
  } else if (result.status === 404) {
154
- console.error(`\nBusiness "${slug}" not found.`);
183
+ console.error(`\n Business "${slug}" not found.`);
155
184
  } else {
156
- console.error(`\nPull failed: ${msg}`);
185
+ console.error(`\n Pull failed: ${msg}`);
157
186
  }
158
187
  process.exit(1);
159
188
  }
160
189
 
161
- const files = result.data.files || [];
190
+ let files = result.data.files || [];
162
191
  if (files.length === 0) {
163
192
  console.log(' Workspace is empty.');
164
193
  return;
165
194
  }
166
195
 
167
- // Write files to local directory
168
- let written = 0;
169
- let skipped = 0;
196
+ console.log(` Processing ${files.length} files...`);
170
197
 
171
- for (const file of files) {
172
- if (!file.path || file.content === null || file.content === undefined) {
173
- skipped++;
174
- continue;
175
- }
176
- if (file.binary) {
177
- skipped++;
178
- continue;
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;
179
208
  }
209
+ console.log(` Filtered to ${files.length} files matching: ${onlyPrefixes.join(', ')}`);
210
+ }
211
+
212
+ // Build remote file map {path: {hash, size, content}}
213
+ const remoteFiles = {};
214
+ const remoteContent = {};
215
+ for (const file of files) {
216
+ if (!file.path || file.binary || file.content === null || file.content === undefined) continue;
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 };
223
+ remoteContent[file.path] = file.content;
224
+ }
225
+
226
+ // Compute local file hashes
227
+ const localFiles = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
228
+
229
+ // Three-way compare
230
+ const baseFiles = (manifest && manifest.files) ? manifest.files : {};
231
+ const diff = threeWayCompare(localFiles, remoteFiles, manifest);
232
+
233
+ // Apply changes
234
+ let pulled = 0;
235
+ let conflictCount = 0;
236
+ let unchangedCount = diff.unchanged.length;
180
237
 
181
- const localPath = path.join(outputDir, file.path.replace(/^\//, ''));
182
- const localDir = path.dirname(localPath);
238
+ console.log('');
239
+
240
+ // Pull files that changed remotely (and we didn't change locally)
241
+ for (const p of [...diff.toPull, ...diff.newRemote]) {
242
+ const content = remoteContent[p];
243
+ if (!content && content !== '') continue;
244
+ const localPath = path.join(outputDir, p.replace(/^\//, ''));
245
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
246
+ fs.writeFileSync(localPath, content);
247
+ const label = diff.newRemote.includes(p) ? 'new on computer' : 'updated on computer';
248
+ const icon = diff.newRemote.includes(p) ? '+' : '\u2193';
249
+ console.log(` ${icon} ${p.replace(/^\//, '')} ${label}`);
250
+ pulled++;
251
+ }
183
252
 
184
- // Check if unchanged
185
- if (fs.existsSync(localPath)) {
186
- const existing = fs.readFileSync(localPath, 'utf8');
187
- if (existing === file.content) {
188
- skipped++;
189
- continue;
253
+ // Handle conflicts
254
+ for (const p of diff.conflicts) {
255
+ if (force) {
256
+ // Force mode: pull remote version, overwrite local
257
+ const content = remoteContent[p];
258
+ if (!content && content !== '') continue;
259
+ const localPath = path.join(outputDir, p.replace(/^\//, ''));
260
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
261
+ fs.writeFileSync(localPath, content);
262
+ console.log(` ! ${p.replace(/^\//, '')} overwritten (--force)`);
263
+ pulled++;
264
+ } else {
265
+ // Save remote version alongside local
266
+ const content = remoteContent[p];
267
+ if (content || content === '') {
268
+ const localPath = path.join(outputDir, p.replace(/^\//, '') + '.remote');
269
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
270
+ fs.writeFileSync(localPath, content);
190
271
  }
272
+ console.log(` \u26A0 ${p.replace(/^\//, '')} CONFLICT \u2014 both you and the computer changed this`);
273
+ console.log(` \u2192 Remote version saved as ${p.replace(/^\//, '')}.remote`);
274
+ conflictCount++;
191
275
  }
276
+ }
192
277
 
193
- fs.mkdirSync(localDir, { recursive: true });
194
- fs.writeFileSync(localPath, file.content);
195
- written++;
278
+ // Warn about remote deletions
279
+ for (const p of diff.deletedRemote) {
280
+ console.log(` - ${p.replace(/^\//, '')} deleted on computer`);
196
281
  }
197
282
 
283
+ // Show unchanged
284
+ if (unchangedCount > 0 && pulled === 0 && conflictCount === 0 && diff.deletedRemote.length === 0) {
285
+ console.log(' Already up to date.');
286
+ }
287
+
288
+ // Summary
198
289
  console.log('');
199
- if (written > 0) {
200
- console.log(` ${written} file${written > 1 ? 's' : ''} pulled to ${outputDir}`);
290
+ const parts = [];
291
+ if (pulled > 0) parts.push(`${pulled} pulled`);
292
+ if (diff.newRemote.length > 0 && !parts.some(p => p.includes('pulled'))) parts.push(`${diff.newRemote.length} new`);
293
+ if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
294
+ if (conflictCount > 0) parts.push(`${conflictCount} conflict${conflictCount > 1 ? 's' : ''}`);
295
+ if (diff.deletedRemote.length > 0) parts.push(`${diff.deletedRemote.length} deleted remotely`);
296
+ if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
297
+
298
+ // Get current commit hash from remote (for manifest)
299
+ let commitHash = null;
300
+ try {
301
+ const headResult = await apiRequestJson(
302
+ `/businesses/${businessId}/workspaces/${workspaceId}/git/head`,
303
+ { method: 'GET', token: creds.token }
304
+ );
305
+ if (headResult.ok && headResult.data && headResult.data.commit) {
306
+ commitHash = headResult.data.commit;
307
+ }
308
+ } catch {
309
+ // Git might not be initialized yet — that's fine
201
310
  }
202
- if (skipped > 0) {
203
- console.log(` ${skipped} unchanged`);
311
+
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 };
204
316
  }
205
- console.log(`\n Total: ${files.length} files (${result.data.total_size} bytes)`);
317
+ const newManifest = buildManifest(manifestFiles, commitHash);
318
+ saveManifest(resolvedSlug || slug, newManifest);
319
+ }
320
+
321
+
322
+ function _timeSince(isoString) {
323
+ if (!isoString) return null;
324
+ const diff = Date.now() - new Date(isoString).getTime();
325
+ const mins = Math.floor(diff / 60000);
326
+ if (mins < 1) return 'just now';
327
+ if (mins < 60) return `${mins}m ago`;
328
+ const hours = Math.floor(mins / 60);
329
+ if (hours < 24) return `${hours}h ago`;
330
+ const days = Math.floor(hours / 24);
331
+ return `${days}d ago`;
206
332
  }
207
333
 
208
334
 
package/commands/push.js CHANGED
@@ -3,21 +3,29 @@ const path = require('path');
3
3
  const { loadCredentials } = require('../utils/auth');
4
4
  const { apiRequestJson } = require('../utils/api');
5
5
  const { loadBusinesses, saveBusinesses } = require('./business');
6
+ const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, SKIP_DIRS } = require('../lib/manifest');
6
7
 
7
8
  async function pushAtris() {
8
9
  const slug = process.argv[3];
9
10
 
10
11
  if (!slug || slug === '--help') {
11
- console.log('Usage: atris push <business-slug> [--from <path>]');
12
+ console.log('Usage: atris push <business-slug> [--from <path>] [--force]');
12
13
  console.log('');
13
14
  console.log('Push local files to a Business Computer.');
14
15
  console.log('');
16
+ console.log('Options:');
17
+ console.log(' --from <path> Push from a custom directory');
18
+ console.log(' --force Push everything, overwrite conflicts');
19
+ console.log('');
15
20
  console.log('Examples:');
16
21
  console.log(' atris push pallet Push from atris/pallet/ or ./pallet/');
17
22
  console.log(' atris push pallet --from ./my-dir/ Push from a custom directory');
23
+ console.log(' atris push pallet --force Override conflicts');
18
24
  process.exit(0);
19
25
  }
20
26
 
27
+ const force = process.argv.includes('--force');
28
+
21
29
  const creds = loadCredentials();
22
30
  if (!creds || !creds.token) {
23
31
  console.error('Not logged in. Run: atris login');
@@ -50,15 +58,15 @@ async function pushAtris() {
50
58
  }
51
59
 
52
60
  // Resolve business ID
53
- let businessId, workspaceId, businessName;
61
+ let businessId, workspaceId, businessName, resolvedSlug;
54
62
  const businesses = loadBusinesses();
55
63
 
56
64
  if (businesses[slug]) {
57
65
  businessId = businesses[slug].business_id;
58
66
  workspaceId = businesses[slug].workspace_id;
59
67
  businessName = businesses[slug].name || slug;
68
+ resolvedSlug = businesses[slug].slug || slug;
60
69
  } else {
61
- // Try to find by slug via API
62
70
  const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
63
71
  if (!listResult.ok) {
64
72
  console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
@@ -74,8 +82,8 @@ async function pushAtris() {
74
82
  businessId = match.id;
75
83
  workspaceId = match.workspace_id;
76
84
  businessName = match.name;
85
+ resolvedSlug = match.slug;
77
86
 
78
- // Auto-save
79
87
  businesses[slug] = {
80
88
  business_id: businessId,
81
89
  workspace_id: workspaceId,
@@ -91,80 +99,165 @@ async function pushAtris() {
91
99
  process.exit(1);
92
100
  }
93
101
 
94
- // Walk local directory and collect files
95
- const files = [];
96
- const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git', 'venv', '.venv', 'lost+found', '.cache']);
97
-
98
- function walkDir(dir) {
99
- const entries = fs.readdirSync(dir, { withFileTypes: true });
100
- for (const entry of entries) {
101
- if (entry.name.startsWith('.')) continue;
102
- const fullPath = path.join(dir, entry.name);
103
-
104
- if (entry.isDirectory()) {
105
- if (SKIP_DIRS.has(entry.name)) continue;
106
- walkDir(fullPath);
107
- } else if (entry.isFile()) {
108
- const relPath = '/' + path.relative(sourceDir, fullPath);
109
- try {
110
- const content = fs.readFileSync(fullPath, 'utf8');
111
- files.push({ path: relPath, content });
112
- } catch {
113
- // Skip binary files
114
- }
115
- }
116
- }
117
- }
102
+ // Load manifest (last sync state)
103
+ const manifest = loadManifest(resolvedSlug || slug);
118
104
 
119
- walkDir(sourceDir);
105
+ // Compute local file hashes
106
+ const localFiles = computeLocalHashes(sourceDir);
120
107
 
121
- if (files.length === 0) {
108
+ if (Object.keys(localFiles).length === 0) {
122
109
  console.log(`\nNo files to push from ${sourceDir}`);
123
110
  return;
124
111
  }
125
112
 
113
+ // Get remote snapshot for three-way compare
126
114
  console.log('');
127
- console.log(`Pushing ${files.length} files to ${businessName}...`);
128
-
129
- // Sync one API call pushes everything
130
- const result = await apiRequestJson(
131
- `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
132
- {
133
- method: 'POST',
134
- token: creds.token,
135
- body: { files },
136
- }
115
+ console.log(`Pushing to ${businessName}...`);
116
+
117
+ // Get snapshot with content to compute reliable hashes (server hash may differ)
118
+ const snapshotResult = await apiRequestJson(
119
+ `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
120
+ { method: 'GET', token: creds.token, timeoutMs: 120000 }
137
121
  );
138
122
 
139
- if (!result.ok) {
140
- const msg = result.errorMessage || `HTTP ${result.status}`;
141
- if (result.status === 409) {
142
- console.error(`\nComputer is sleeping. Wake it first, then push.`);
143
- } else if (result.status === 403) {
144
- console.error(`\nAccess denied: ${msg}`);
145
- } else {
146
- console.error(`\nPush failed: ${msg}`);
123
+ let remoteFiles = {};
124
+ if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) {
125
+ for (const file of snapshotResult.data.files) {
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 };
131
+ }
147
132
  }
148
- process.exit(1);
149
133
  }
150
134
 
151
- const data = result.data;
152
- console.log('');
153
- if (data.written > 0) {
154
- console.log(` ${data.written} file${data.written > 1 ? 's' : ''} written`);
135
+ // Three-way compare
136
+ const diff = threeWayCompare(localFiles, remoteFiles, manifest);
137
+
138
+ // Determine what to push
139
+ const filesToPush = [];
140
+
141
+ // Files we changed that remote didn't
142
+ for (const p of [...diff.toPush, ...diff.newLocal]) {
143
+ const localPath = path.join(sourceDir, p.replace(/^\//, ''));
144
+ try {
145
+ const content = fs.readFileSync(localPath, 'utf8');
146
+ filesToPush.push({ path: p, content });
147
+ } catch {
148
+ // skip
149
+ }
150
+ }
151
+
152
+ // Force mode: also push conflicts
153
+ 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
+ }
163
+ }
164
+ } else {
165
+ for (const p of diff.conflicts) {
166
+ conflictPaths.push(p);
167
+ }
155
168
  }
156
- if (data.unchanged > 0) {
157
- console.log(` ${data.unchanged} unchanged`);
169
+
170
+ console.log('');
171
+
172
+ if (filesToPush.length === 0 && conflictPaths.length === 0) {
173
+ console.log(' Already up to date.');
174
+ console.log('');
175
+ return;
158
176
  }
159
- if (data.errors > 0) {
160
- console.log(` ${data.errors} error${data.errors > 1 ? 's' : ''}`);
161
- for (const r of (data.results || [])) {
162
- if (r.status === 'error') {
163
- console.log(` ${r.path}: ${r.error}`);
177
+
178
+ // Push the files
179
+ let pushed = 0;
180
+ if (filesToPush.length > 0) {
181
+ const result = await apiRequestJson(
182
+ `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
183
+ {
184
+ method: 'POST',
185
+ token: creds.token,
186
+ body: { files: filesToPush },
187
+ headers: { 'X-Atris-Actor-Source': 'cli' },
188
+ }
189
+ );
190
+
191
+ if (!result.ok) {
192
+ const msg = result.errorMessage || `HTTP ${result.status}`;
193
+ if (result.status === 409) {
194
+ console.error(` Computer is sleeping. Wake it first, then push.`);
195
+ } else if (result.status === 403) {
196
+ console.error(` Access denied: ${msg}`);
197
+ } else {
198
+ console.error(` Push failed: ${msg}`);
199
+ }
200
+ process.exit(1);
201
+ }
202
+
203
+ // Display results
204
+ for (const p of diff.toPush) {
205
+ console.log(` \u2191 ${p.replace(/^\//, '')} pushing your changes`);
206
+ pushed++;
207
+ }
208
+ for (const p of diff.newLocal) {
209
+ console.log(` + ${p.replace(/^\//, '')} new file`);
210
+ pushed++;
211
+ }
212
+ if (force) {
213
+ for (const p of diff.conflicts) {
214
+ console.log(` ! ${p.replace(/^\//, '')} overwritten (--force)`);
215
+ pushed++;
164
216
  }
165
217
  }
166
218
  }
167
- console.log(`\n Synced to ${businessName}.`);
219
+
220
+ // Show conflicts
221
+ for (const p of conflictPaths) {
222
+ console.log(` \u26A0 ${p.replace(/^\//, '')} CONFLICT \u2014 skipped (use --force to override)`);
223
+ }
224
+
225
+ // Show unchanged
226
+ if (diff.unchanged.length > 0) {
227
+ // Don't list them all, just count
228
+ }
229
+
230
+ // Summary
231
+ console.log('');
232
+ const parts = [];
233
+ if (pushed > 0) parts.push(`${pushed} pushed`);
234
+ if (diff.unchanged.length > 0) parts.push(`${diff.unchanged.length} unchanged`);
235
+ if (conflictPaths.length > 0) parts.push(`${conflictPaths.length} conflict${conflictPaths.length > 1 ? 's' : ''}`);
236
+ if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
237
+
238
+ // Get commit hash after push
239
+ let commitHash = null;
240
+ try {
241
+ const headResult = await apiRequestJson(
242
+ `/businesses/${businessId}/workspaces/${workspaceId}/git/head`,
243
+ { method: 'GET', token: creds.token }
244
+ );
245
+ if (headResult.ok && headResult.data && headResult.data.commit) {
246
+ commitHash = headResult.data.commit;
247
+ }
248
+ } catch {
249
+ // Git might not be initialized yet
250
+ }
251
+
252
+ // Update manifest with new state (merge local + remote)
253
+ const mergedFiles = { ...remoteFiles };
254
+ for (const p of Object.keys(localFiles)) {
255
+ if (filesToPush.some(f => f.path === p)) {
256
+ mergedFiles[p] = localFiles[p]; // we pushed this, so our hash is now the truth
257
+ }
258
+ }
259
+ const newManifest = buildManifest(mergedFiles, commitHash);
260
+ saveManifest(resolvedSlug || slug, newManifest);
168
261
  }
169
262
 
170
263
  module.exports = { pushAtris };