atris 2.6.0 → 2.6.1
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 +16 -3
- package/commands/context-sync.js +226 -0
- package/commands/pull.js +118 -40
- package/commands/push.js +150 -61
- package/lib/manifest.js +222 -0
- package/package.json +9 -4
- 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/GETTING_STARTED.md
CHANGED
|
@@ -164,8 +164,8 @@ This syncs your local `atris.md` and agent templates to the latest version. Re-r
|
|
|
164
164
|
## Need Help?
|
|
165
165
|
|
|
166
166
|
- **Full spec:** Read `atris.md` for technical details
|
|
167
|
-
- **Issues:** https://github.com/atrislabs/atris
|
|
168
|
-
- **Docs:** https://github.com/atrislabs/atris
|
|
167
|
+
- **Issues:** https://github.com/atrislabs/atris/issues
|
|
168
|
+
- **Docs:** https://github.com/atrislabs/atris
|
|
169
169
|
|
|
170
170
|
---
|
|
171
171
|
|
package/atris/GETTING_STARTED.md
CHANGED
|
@@ -164,8 +164,8 @@ This syncs your local `atris.md` and agent templates to the latest version. Re-r
|
|
|
164
164
|
## Need Help?
|
|
165
165
|
|
|
166
166
|
- **Full spec:** Read `atris.md` for technical details
|
|
167
|
-
- **Issues:** https://github.com/atrislabs/atris
|
|
168
|
-
- **Docs:** https://github.com/atrislabs/atris
|
|
167
|
+
- **Issues:** https://github.com/atrislabs/atris/issues
|
|
168
|
+
- **Docs:** https://github.com/atrislabs/atris
|
|
169
169
|
|
|
170
170
|
---
|
|
171
171
|
|
package/bin/atris.js
CHANGED
|
@@ -698,6 +698,11 @@ if (command === 'init') {
|
|
|
698
698
|
console.error(`✗ Log sync failed: ${error.message || error}`);
|
|
699
699
|
process.exit(1);
|
|
700
700
|
});
|
|
701
|
+
} else if (subcommand && subcommand !== '--help' && !subcommand.startsWith('-')) {
|
|
702
|
+
// Business log: atris log <business-slug>
|
|
703
|
+
require('../commands/context-sync').businessLog(subcommand)
|
|
704
|
+
.then(() => process.exit(0))
|
|
705
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
701
706
|
} else {
|
|
702
707
|
logCmd();
|
|
703
708
|
}
|
|
@@ -876,9 +881,17 @@ if (command === 'init') {
|
|
|
876
881
|
process.exit(1);
|
|
877
882
|
});
|
|
878
883
|
} else if (command === 'status') {
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
884
|
+
const subcommand = process.argv[3];
|
|
885
|
+
if (subcommand && !subcommand.startsWith('-')) {
|
|
886
|
+
// Business status: atris status <business-slug>
|
|
887
|
+
require('../commands/context-sync').businessStatus(subcommand)
|
|
888
|
+
.then(() => process.exit(0))
|
|
889
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
890
|
+
} else {
|
|
891
|
+
const isQuick = process.argv.includes('--quick') || process.argv.includes('-q');
|
|
892
|
+
const isJson = process.argv.includes('--json');
|
|
893
|
+
statusCmd(isQuick, isJson);
|
|
894
|
+
}
|
|
882
895
|
} else if (command === 'analytics') {
|
|
883
896
|
require('../commands/analytics').analyticsAtris();
|
|
884
897
|
} else if (command === 'clean') {
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { loadCredentials } = require('../utils/auth');
|
|
4
|
+
const { apiRequestJson } = require('../utils/api');
|
|
5
|
+
const { loadBusinesses } = require('./business');
|
|
6
|
+
const { loadManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a business slug to its IDs. Shared helper.
|
|
10
|
+
*/
|
|
11
|
+
async function resolveBusiness(slug) {
|
|
12
|
+
const creds = loadCredentials();
|
|
13
|
+
if (!creds || !creds.token) {
|
|
14
|
+
console.error('Not logged in. Run: atris login');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const businesses = loadBusinesses();
|
|
19
|
+
if (businesses[slug]) {
|
|
20
|
+
return {
|
|
21
|
+
businessId: businesses[slug].business_id,
|
|
22
|
+
workspaceId: businesses[slug].workspace_id,
|
|
23
|
+
name: businesses[slug].name || slug,
|
|
24
|
+
slug: businesses[slug].slug || slug,
|
|
25
|
+
token: creds.token,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
|
|
30
|
+
if (!listResult.ok) {
|
|
31
|
+
console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const match = (listResult.data || []).find(
|
|
35
|
+
b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
|
|
36
|
+
);
|
|
37
|
+
if (!match) {
|
|
38
|
+
console.error(`Business "${slug}" not found.`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
businessId: match.id,
|
|
43
|
+
workspaceId: match.workspace_id,
|
|
44
|
+
name: match.name,
|
|
45
|
+
slug: match.slug,
|
|
46
|
+
token: creds.token,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* atris status <business-slug>
|
|
53
|
+
* Shows what's different locally vs remote without transferring.
|
|
54
|
+
*/
|
|
55
|
+
async function businessStatus(slug) {
|
|
56
|
+
const biz = await resolveBusiness(slug);
|
|
57
|
+
|
|
58
|
+
if (!biz.workspaceId) {
|
|
59
|
+
console.error(`Business "${slug}" has no workspace.`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const manifest = loadManifest(biz.slug);
|
|
64
|
+
const timeSince = manifest ? _timeSince(manifest.last_sync) : null;
|
|
65
|
+
|
|
66
|
+
console.log('');
|
|
67
|
+
console.log(`${biz.name}` + (timeSince ? ` \u2014 last synced ${timeSince}` : ' \u2014 never synced'));
|
|
68
|
+
|
|
69
|
+
// Determine local directory
|
|
70
|
+
const atrisDir = path.join(process.cwd(), 'atris', slug);
|
|
71
|
+
const cwdDir = path.join(process.cwd(), slug);
|
|
72
|
+
let localDir = null;
|
|
73
|
+
if (fs.existsSync(atrisDir)) localDir = atrisDir;
|
|
74
|
+
else if (fs.existsSync(cwdDir)) localDir = cwdDir;
|
|
75
|
+
|
|
76
|
+
// Get remote snapshot (hashes only)
|
|
77
|
+
const result = await apiRequestJson(
|
|
78
|
+
`/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/snapshot?include_content=false`,
|
|
79
|
+
{ method: 'GET', token: biz.token }
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!result.ok) {
|
|
83
|
+
if (result.status === 409) {
|
|
84
|
+
console.log(' Computer is sleeping. Wake it first.');
|
|
85
|
+
} else {
|
|
86
|
+
console.error(` Failed to get remote state: ${result.errorMessage || result.status}`);
|
|
87
|
+
}
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const remoteFiles = {};
|
|
92
|
+
for (const file of (result.data.files || [])) {
|
|
93
|
+
if (file.path && !file.binary) {
|
|
94
|
+
remoteFiles[file.path] = { hash: file.hash, size: file.size || 0 };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const localFiles = localDir ? computeLocalHashes(localDir) : {};
|
|
99
|
+
const diff = threeWayCompare(localFiles, remoteFiles, manifest);
|
|
100
|
+
|
|
101
|
+
console.log('');
|
|
102
|
+
|
|
103
|
+
// You changed
|
|
104
|
+
const youChanged = [...diff.toPush, ...diff.newLocal];
|
|
105
|
+
if (youChanged.length > 0) {
|
|
106
|
+
console.log(' You changed:');
|
|
107
|
+
for (const p of youChanged) {
|
|
108
|
+
const label = diff.newLocal.includes(p) ? '(new)' : '';
|
|
109
|
+
console.log(` ${p.replace(/^\//, '')} ${label}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Computer changed
|
|
114
|
+
const computerChanged = [...diff.toPull, ...diff.newRemote];
|
|
115
|
+
if (computerChanged.length > 0) {
|
|
116
|
+
console.log(' Computer changed:');
|
|
117
|
+
for (const p of computerChanged) {
|
|
118
|
+
const label = diff.newRemote.includes(p) ? '(new)' : '';
|
|
119
|
+
console.log(` ${p.replace(/^\//, '')} ${label}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Conflicts
|
|
124
|
+
if (diff.conflicts.length > 0) {
|
|
125
|
+
console.log(' Conflicts:');
|
|
126
|
+
for (const p of diff.conflicts) {
|
|
127
|
+
console.log(` ${p.replace(/^\//, '')}`);
|
|
128
|
+
}
|
|
129
|
+
} else if (youChanged.length > 0 || computerChanged.length > 0) {
|
|
130
|
+
console.log(' Conflicts: (none)');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Remote deletions
|
|
134
|
+
if (diff.deletedRemote.length > 0) {
|
|
135
|
+
console.log(' Deleted on computer:');
|
|
136
|
+
for (const p of diff.deletedRemote) {
|
|
137
|
+
console.log(` ${p.replace(/^\//, '')}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (youChanged.length === 0 && computerChanged.length === 0 && diff.conflicts.length === 0) {
|
|
142
|
+
if (!localDir) {
|
|
143
|
+
console.log(' No local copy found. Run: atris pull ' + slug);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(' Everything up to date.');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log('');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* atris log <business-slug>
|
|
155
|
+
* Shows human-readable commit history.
|
|
156
|
+
*/
|
|
157
|
+
async function businessLog(slug) {
|
|
158
|
+
const biz = await resolveBusiness(slug);
|
|
159
|
+
|
|
160
|
+
if (!biz.workspaceId) {
|
|
161
|
+
console.error(`Business "${slug}" has no workspace.`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const limit = 20;
|
|
166
|
+
const pathFilter = process.argv.includes('--path') ? process.argv[process.argv.indexOf('--path') + 1] : null;
|
|
167
|
+
|
|
168
|
+
const params = `limit=${limit}` + (pathFilter ? `&path=${encodeURIComponent(pathFilter)}` : '');
|
|
169
|
+
const result = await apiRequestJson(
|
|
170
|
+
`/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/git/log?${params}`,
|
|
171
|
+
{ method: 'GET', token: biz.token }
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
if (!result.ok) {
|
|
175
|
+
if (result.status === 409) {
|
|
176
|
+
console.log('\n Computer is sleeping. Wake it first.\n');
|
|
177
|
+
} else {
|
|
178
|
+
console.error(`\n Failed to get history: ${result.errorMessage || result.status}\n`);
|
|
179
|
+
}
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const commits = result.data.commits || [];
|
|
184
|
+
if (commits.length === 0) {
|
|
185
|
+
console.log(`\n ${biz.name} \u2014 no history yet.\n`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log(`\n ${biz.name} \u2014 history\n`);
|
|
190
|
+
|
|
191
|
+
for (const commit of commits) {
|
|
192
|
+
const date = _timeSince(commit.date) || commit.date;
|
|
193
|
+
const msg = commit.message || '';
|
|
194
|
+
|
|
195
|
+
// Parse actor from message format "actor/name: message"
|
|
196
|
+
const colonIdx = msg.indexOf(': ');
|
|
197
|
+
let actor = '';
|
|
198
|
+
let description = msg;
|
|
199
|
+
if (colonIdx > 0 && colonIdx < 40) {
|
|
200
|
+
actor = msg.substring(0, colonIdx);
|
|
201
|
+
description = msg.substring(colonIdx + 2);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const actorDisplay = actor ? ` ${actor}` : '';
|
|
205
|
+
console.log(` ${date.padEnd(12)} ${actorDisplay}`);
|
|
206
|
+
console.log(` ${description}`);
|
|
207
|
+
console.log('');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
function _timeSince(isoString) {
|
|
213
|
+
if (!isoString) return null;
|
|
214
|
+
const diff = Date.now() - new Date(isoString).getTime();
|
|
215
|
+
if (diff < 0) return 'just now';
|
|
216
|
+
const mins = Math.floor(diff / 60000);
|
|
217
|
+
if (mins < 1) return 'just now';
|
|
218
|
+
if (mins < 60) return `${mins}m ago`;
|
|
219
|
+
const hours = Math.floor(mins / 60);
|
|
220
|
+
if (hours < 24) return `${hours}h ago`;
|
|
221
|
+
const days = Math.floor(hours / 24);
|
|
222
|
+
return `${days}d ago`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
module.exports = { businessStatus, businessLog };
|
package/commands/pull.js
CHANGED
|
@@ -7,6 +7,7 @@ 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];
|
|
@@ -77,13 +78,14 @@ async function pullBusiness(slug) {
|
|
|
77
78
|
process.exit(1);
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
const force = process.argv.includes('--force');
|
|
82
|
+
|
|
80
83
|
// Determine output directory
|
|
81
84
|
const intoIdx = process.argv.indexOf('--into');
|
|
82
85
|
let outputDir;
|
|
83
86
|
if (intoIdx !== -1 && process.argv[intoIdx + 1]) {
|
|
84
87
|
outputDir = path.resolve(process.argv[intoIdx + 1]);
|
|
85
88
|
} else {
|
|
86
|
-
// Default: atris/{slug}/ in current directory, or just {slug}/ if no atris/ folder
|
|
87
89
|
const atrisDir = path.join(process.cwd(), 'atris');
|
|
88
90
|
if (fs.existsSync(atrisDir)) {
|
|
89
91
|
outputDir = path.join(atrisDir, slug);
|
|
@@ -93,15 +95,15 @@ async function pullBusiness(slug) {
|
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
// Resolve business ID — check local config first, then API
|
|
96
|
-
let businessId, workspaceId, businessName;
|
|
98
|
+
let businessId, workspaceId, businessName, resolvedSlug;
|
|
97
99
|
const businesses = loadBusinesses();
|
|
98
100
|
|
|
99
101
|
if (businesses[slug]) {
|
|
100
102
|
businessId = businesses[slug].business_id;
|
|
101
103
|
workspaceId = businesses[slug].workspace_id;
|
|
102
104
|
businessName = businesses[slug].name || slug;
|
|
105
|
+
resolvedSlug = businesses[slug].slug || slug;
|
|
103
106
|
} else {
|
|
104
|
-
// Try to find by slug via API
|
|
105
107
|
const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
|
|
106
108
|
if (!listResult.ok) {
|
|
107
109
|
console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
|
|
@@ -117,8 +119,8 @@ async function pullBusiness(slug) {
|
|
|
117
119
|
businessId = match.id;
|
|
118
120
|
workspaceId = match.workspace_id;
|
|
119
121
|
businessName = match.name;
|
|
122
|
+
resolvedSlug = match.slug;
|
|
120
123
|
|
|
121
|
-
// Auto-save for next time
|
|
122
124
|
businesses[slug] = {
|
|
123
125
|
business_id: businessId,
|
|
124
126
|
workspace_id: workspaceId,
|
|
@@ -135,25 +137,29 @@ async function pullBusiness(slug) {
|
|
|
135
137
|
process.exit(1);
|
|
136
138
|
}
|
|
137
139
|
|
|
140
|
+
// Load manifest (last sync state)
|
|
141
|
+
const manifest = loadManifest(resolvedSlug || slug);
|
|
142
|
+
const timeSince = manifest ? _timeSince(manifest.last_sync) : null;
|
|
143
|
+
|
|
138
144
|
console.log('');
|
|
139
|
-
console.log(`Pulling ${businessName}...`);
|
|
145
|
+
console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
|
|
140
146
|
|
|
141
|
-
//
|
|
147
|
+
// Get remote snapshot (large workspaces can take 60s+)
|
|
142
148
|
const result = await apiRequestJson(
|
|
143
149
|
`/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
|
|
144
|
-
{ method: 'GET', token: creds.token }
|
|
150
|
+
{ method: 'GET', token: creds.token, timeoutMs: 120000 }
|
|
145
151
|
);
|
|
146
152
|
|
|
147
153
|
if (!result.ok) {
|
|
148
154
|
const msg = result.errorMessage || `HTTP ${result.status}`;
|
|
149
155
|
if (result.status === 409) {
|
|
150
|
-
console.error(`\
|
|
156
|
+
console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
|
|
151
157
|
} else if (result.status === 403) {
|
|
152
|
-
console.error(`\
|
|
158
|
+
console.error(`\n Access denied. You're not a member of "${slug}".`);
|
|
153
159
|
} else if (result.status === 404) {
|
|
154
|
-
console.error(`\
|
|
160
|
+
console.error(`\n Business "${slug}" not found.`);
|
|
155
161
|
} else {
|
|
156
|
-
console.error(`\
|
|
162
|
+
console.error(`\n Pull failed: ${msg}`);
|
|
157
163
|
}
|
|
158
164
|
process.exit(1);
|
|
159
165
|
}
|
|
@@ -164,45 +170,117 @@ async function pullBusiness(slug) {
|
|
|
164
170
|
return;
|
|
165
171
|
}
|
|
166
172
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
173
|
+
// Build remote file map {path: {hash, size, content}}
|
|
174
|
+
const remoteFiles = {};
|
|
175
|
+
const remoteContent = {};
|
|
171
176
|
for (const file of files) {
|
|
172
|
-
if (!file.path || file.content === null || file.content === undefined)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
177
|
+
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 };
|
|
179
|
+
remoteContent[file.path] = file.content;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Compute local file hashes
|
|
183
|
+
const localFiles = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
|
|
180
184
|
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
// Three-way compare
|
|
186
|
+
const baseFiles = (manifest && manifest.files) ? manifest.files : {};
|
|
187
|
+
const diff = threeWayCompare(localFiles, remoteFiles, manifest);
|
|
188
|
+
|
|
189
|
+
// Apply changes
|
|
190
|
+
let pulled = 0;
|
|
191
|
+
let conflictCount = 0;
|
|
192
|
+
let unchangedCount = diff.unchanged.length;
|
|
193
|
+
|
|
194
|
+
console.log('');
|
|
195
|
+
|
|
196
|
+
// Pull files that changed remotely (and we didn't change locally)
|
|
197
|
+
for (const p of [...diff.toPull, ...diff.newRemote]) {
|
|
198
|
+
const content = remoteContent[p];
|
|
199
|
+
if (!content && content !== '') continue;
|
|
200
|
+
const localPath = path.join(outputDir, p.replace(/^\//, ''));
|
|
201
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
202
|
+
fs.writeFileSync(localPath, content);
|
|
203
|
+
const label = diff.newRemote.includes(p) ? 'new on computer' : 'updated on computer';
|
|
204
|
+
const icon = diff.newRemote.includes(p) ? '+' : '\u2193';
|
|
205
|
+
console.log(` ${icon} ${p.replace(/^\//, '')} ${label}`);
|
|
206
|
+
pulled++;
|
|
207
|
+
}
|
|
183
208
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
209
|
+
// Handle conflicts
|
|
210
|
+
for (const p of diff.conflicts) {
|
|
211
|
+
if (force) {
|
|
212
|
+
// Force mode: pull remote version, overwrite local
|
|
213
|
+
const content = remoteContent[p];
|
|
214
|
+
if (!content && content !== '') continue;
|
|
215
|
+
const localPath = path.join(outputDir, p.replace(/^\//, ''));
|
|
216
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
217
|
+
fs.writeFileSync(localPath, content);
|
|
218
|
+
console.log(` ! ${p.replace(/^\//, '')} overwritten (--force)`);
|
|
219
|
+
pulled++;
|
|
220
|
+
} else {
|
|
221
|
+
// Save remote version alongside local
|
|
222
|
+
const content = remoteContent[p];
|
|
223
|
+
if (content || content === '') {
|
|
224
|
+
const localPath = path.join(outputDir, p.replace(/^\//, '') + '.remote');
|
|
225
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
226
|
+
fs.writeFileSync(localPath, content);
|
|
190
227
|
}
|
|
228
|
+
console.log(` \u26A0 ${p.replace(/^\//, '')} CONFLICT \u2014 both you and the computer changed this`);
|
|
229
|
+
console.log(` \u2192 Remote version saved as ${p.replace(/^\//, '')}.remote`);
|
|
230
|
+
conflictCount++;
|
|
191
231
|
}
|
|
232
|
+
}
|
|
192
233
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
234
|
+
// Warn about remote deletions
|
|
235
|
+
for (const p of diff.deletedRemote) {
|
|
236
|
+
console.log(` - ${p.replace(/^\//, '')} deleted on computer`);
|
|
196
237
|
}
|
|
197
238
|
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
console.log(
|
|
239
|
+
// Show unchanged
|
|
240
|
+
if (unchangedCount > 0 && pulled === 0 && conflictCount === 0 && diff.deletedRemote.length === 0) {
|
|
241
|
+
console.log(' Already up to date.');
|
|
201
242
|
}
|
|
202
|
-
|
|
203
|
-
|
|
243
|
+
|
|
244
|
+
// Summary
|
|
245
|
+
console.log('');
|
|
246
|
+
const parts = [];
|
|
247
|
+
if (pulled > 0) parts.push(`${pulled} pulled`);
|
|
248
|
+
if (diff.newRemote.length > 0 && !parts.some(p => p.includes('pulled'))) parts.push(`${diff.newRemote.length} new`);
|
|
249
|
+
if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
|
|
250
|
+
if (conflictCount > 0) parts.push(`${conflictCount} conflict${conflictCount > 1 ? 's' : ''}`);
|
|
251
|
+
if (diff.deletedRemote.length > 0) parts.push(`${diff.deletedRemote.length} deleted remotely`);
|
|
252
|
+
if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
|
|
253
|
+
|
|
254
|
+
// Get current commit hash from remote (for manifest)
|
|
255
|
+
let commitHash = null;
|
|
256
|
+
try {
|
|
257
|
+
const headResult = await apiRequestJson(
|
|
258
|
+
`/businesses/${businessId}/workspaces/${workspaceId}/git/head`,
|
|
259
|
+
{ method: 'GET', token: creds.token }
|
|
260
|
+
);
|
|
261
|
+
if (headResult.ok && headResult.data && headResult.data.commit) {
|
|
262
|
+
commitHash = headResult.data.commit;
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
// Git might not be initialized yet — that's fine
|
|
204
266
|
}
|
|
205
|
-
|
|
267
|
+
|
|
268
|
+
// Save manifest
|
|
269
|
+
const newManifest = buildManifest(remoteFiles, commitHash);
|
|
270
|
+
saveManifest(resolvedSlug || slug, newManifest);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
function _timeSince(isoString) {
|
|
275
|
+
if (!isoString) return null;
|
|
276
|
+
const diff = Date.now() - new Date(isoString).getTime();
|
|
277
|
+
const mins = Math.floor(diff / 60000);
|
|
278
|
+
if (mins < 1) return 'just now';
|
|
279
|
+
if (mins < 60) return `${mins}m ago`;
|
|
280
|
+
const hours = Math.floor(mins / 60);
|
|
281
|
+
if (hours < 24) return `${hours}h ago`;
|
|
282
|
+
const days = Math.floor(hours / 24);
|
|
283
|
+
return `${days}d ago`;
|
|
206
284
|
}
|
|
207
285
|
|
|
208
286
|
|