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.
- package/GETTING_STARTED.md +2 -2
- package/atris/GETTING_STARTED.md +2 -2
- package/bin/atris.js +35 -4
- package/commands/business.js +244 -2
- package/commands/context-sync.js +228 -0
- package/commands/pull.js +176 -50
- package/commands/push.js +154 -61
- package/commands/setup.js +178 -0
- package/commands/workspace-clean.js +249 -0
- package/lib/manifest.js +224 -0
- package/lib/section-merge.js +196 -0
- package/package.json +9 -4
- package/utils/api.js +9 -1
- package/utils/update-check.js +11 -11
- package/AGENT.md +0 -35
- package/atris/experiments/README.md +0 -118
- package/atris/experiments/_examples/smoke-keep-revert/README.md +0 -45
- package/atris/experiments/_examples/smoke-keep-revert/candidate.py +0 -8
- package/atris/experiments/_examples/smoke-keep-revert/loop.py +0 -129
- package/atris/experiments/_examples/smoke-keep-revert/measure.py +0 -47
- package/atris/experiments/_examples/smoke-keep-revert/program.md +0 -3
- package/atris/experiments/_examples/smoke-keep-revert/proposals/bad_patch.py +0 -19
- package/atris/experiments/_examples/smoke-keep-revert/proposals/fix_patch.py +0 -22
- package/atris/experiments/_examples/smoke-keep-revert/reset.py +0 -21
- package/atris/experiments/_examples/smoke-keep-revert/results.tsv +0 -5
- package/atris/experiments/_examples/smoke-keep-revert/visual.svg +0 -52
- package/atris/experiments/_fixtures/invalid/BadName/loop.py +0 -1
- package/atris/experiments/_fixtures/invalid/BadName/program.md +0 -3
- package/atris/experiments/_fixtures/invalid/BadName/results.tsv +0 -1
- package/atris/experiments/_fixtures/invalid/bloated-context/loop.py +0 -1
- package/atris/experiments/_fixtures/invalid/bloated-context/measure.py +0 -1
- package/atris/experiments/_fixtures/invalid/bloated-context/program.md +0 -6
- package/atris/experiments/_fixtures/invalid/bloated-context/results.tsv +0 -1
- package/atris/experiments/_fixtures/valid/good-experiment/loop.py +0 -1
- package/atris/experiments/_fixtures/valid/good-experiment/measure.py +0 -1
- package/atris/experiments/_fixtures/valid/good-experiment/program.md +0 -3
- package/atris/experiments/_fixtures/valid/good-experiment/results.tsv +0 -1
- package/atris/experiments/_template/pack/loop.py +0 -3
- package/atris/experiments/_template/pack/measure.py +0 -13
- package/atris/experiments/_template/pack/program.md +0 -3
- package/atris/experiments/_template/pack/reset.py +0 -3
- package/atris/experiments/_template/pack/results.tsv +0 -1
- package/atris/experiments/benchmark_runtime.py +0 -81
- package/atris/experiments/benchmark_validate.py +0 -70
- package/atris/experiments/validate.py +0 -92
- 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 —
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 ===
|
|
150
|
-
console.error(`\
|
|
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(`\
|
|
181
|
+
console.error(`\n Access denied. You're not a member of "${slug}".`);
|
|
153
182
|
} else if (result.status === 404) {
|
|
154
|
-
console.error(`\
|
|
183
|
+
console.error(`\n Business "${slug}" not found.`);
|
|
155
184
|
} else {
|
|
156
|
-
console.error(`\
|
|
185
|
+
console.error(`\n Pull failed: ${msg}`);
|
|
157
186
|
}
|
|
158
187
|
process.exit(1);
|
|
159
188
|
}
|
|
160
189
|
|
|
161
|
-
|
|
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
|
-
|
|
168
|
-
let written = 0;
|
|
169
|
-
let skipped = 0;
|
|
196
|
+
console.log(` Processing ${files.length} files...`);
|
|
170
197
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
95
|
-
const
|
|
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
|
-
|
|
105
|
+
// Compute local file hashes
|
|
106
|
+
const localFiles = computeLocalHashes(sourceDir);
|
|
120
107
|
|
|
121
|
-
if (
|
|
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
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
const
|
|
131
|
-
`/businesses/${businessId}/workspaces/${workspaceId}/
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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 };
|