atris 2.6.3 → 3.0.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/README.md +124 -34
- package/atris/CLAUDE.md +5 -1
- package/atris/atris.md +4 -0
- package/atris/features/README.md +24 -0
- package/atris/skills/autopilot/SKILL.md +74 -75
- package/atris/skills/endgame/SKILL.md +179 -0
- package/atris/skills/flow/SKILL.md +121 -0
- package/atris/skills/improve/SKILL.md +84 -0
- package/atris/skills/loop/SKILL.md +72 -0
- package/atris/skills/wiki/SKILL.md +61 -0
- package/atris/team/executor/MEMBER.md +10 -4
- package/atris/team/navigator/MEMBER.md +2 -0
- package/atris/team/validator/MEMBER.md +8 -5
- package/atris.md +33 -0
- package/bin/atris.js +210 -41
- package/commands/activate.js +28 -2
- package/commands/align.js +720 -0
- package/commands/auth.js +75 -2
- package/commands/autopilot.js +1213 -270
- package/commands/browse.js +100 -0
- package/commands/business.js +785 -12
- package/commands/clean.js +107 -2
- package/commands/computer.js +429 -0
- package/commands/context-sync.js +78 -8
- package/commands/experiments.js +351 -0
- package/commands/feedback.js +150 -0
- package/commands/fleet.js +395 -0
- package/commands/fork.js +127 -0
- package/commands/init.js +50 -1
- package/commands/learn.js +407 -0
- package/commands/lifecycle.js +94 -0
- package/commands/loop.js +114 -0
- package/commands/publish.js +129 -0
- package/commands/pull.js +369 -38
- package/commands/push.js +283 -246
- package/commands/review.js +149 -0
- package/commands/run.js +76 -43
- package/commands/serve.js +360 -0
- package/commands/setup.js +1 -1
- package/commands/soul.js +381 -0
- package/commands/status.js +119 -1
- package/commands/sync.js +147 -1
- package/commands/terminal.js +201 -0
- package/commands/wiki.js +376 -0
- package/commands/workflow.js +191 -74
- package/commands/workspace-clean.js +3 -3
- package/lib/endstate.js +259 -0
- package/lib/learnings.js +235 -0
- package/lib/manifest.js +1 -0
- package/lib/todo.js +9 -5
- package/lib/wiki.js +578 -0
- package/package.json +2 -2
- package/utils/api.js +40 -35
- package/utils/auth.js +1 -0
package/commands/push.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const { loadCredentials } = require('../utils/auth');
|
|
4
5
|
const { apiRequestJson } = require('../utils/api');
|
|
5
6
|
const { loadBusinesses, saveBusinesses } = require('./business');
|
|
6
|
-
const { loadManifest, saveManifest,
|
|
7
|
-
const {
|
|
7
|
+
const { loadManifest, saveManifest, buildManifest, computeLocalHashes } = require('../lib/manifest');
|
|
8
|
+
const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
|
|
8
9
|
|
|
9
10
|
async function pushAtris() {
|
|
10
11
|
let slug = process.argv[3];
|
|
@@ -18,55 +19,45 @@ async function pushAtris() {
|
|
|
18
19
|
slug = biz.slug || biz.name;
|
|
19
20
|
} catch {}
|
|
20
21
|
}
|
|
21
|
-
|
|
22
|
-
if (!slug || slug.startsWith('-')) {
|
|
23
|
-
slug = null;
|
|
24
|
-
}
|
|
22
|
+
if (!slug || slug.startsWith('-')) slug = null;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
if (!slug || slug === '--help') {
|
|
28
|
-
console.log('Usage: atris push [business
|
|
29
|
-
console.log('');
|
|
30
|
-
console.log('Push local files to a Business Computer.');
|
|
31
|
-
console.log('If run inside a pulled folder, business is auto-detected.');
|
|
26
|
+
console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force]');
|
|
32
27
|
console.log('');
|
|
33
|
-
console.log('
|
|
34
|
-
console.log('
|
|
35
|
-
console.log(' --force Push everything, overwrite conflicts');
|
|
28
|
+
console.log(' Push requires a fresh pull. If cloud has changed since your last pull,');
|
|
29
|
+
console.log(' the push will be blocked until you run `atris pull`. Use --force to override.');
|
|
36
30
|
console.log('');
|
|
37
|
-
console.log('
|
|
38
|
-
console.log(' atris push
|
|
39
|
-
console.log(' atris push pallet
|
|
40
|
-
console.log(' atris push
|
|
31
|
+
console.log(' atris push Push from current folder (auto-detect business)');
|
|
32
|
+
console.log(' atris push pallet Push pallet/ or atris/pallet/');
|
|
33
|
+
console.log(' atris push pallet --only team/nate Only push files in team/nate/');
|
|
34
|
+
console.log(' atris push --force Bypass freshness check (force-push, may overwrite cloud changes)');
|
|
41
35
|
process.exit(0);
|
|
42
36
|
}
|
|
43
37
|
|
|
44
38
|
const force = process.argv.includes('--force');
|
|
39
|
+
const dryRun = process.argv.includes('--dry-run');
|
|
45
40
|
|
|
46
|
-
// Parse --only
|
|
41
|
+
// Parse --only
|
|
47
42
|
let onlyRaw = null;
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (onlyIdx !== -1 && process.argv[onlyIdx + 1] && !process.argv[onlyIdx + 1].startsWith('-')) {
|
|
54
|
-
onlyRaw = process.argv[onlyIdx + 1];
|
|
55
|
-
}
|
|
43
|
+
const onlyEq = process.argv.find(a => a.startsWith('--only='));
|
|
44
|
+
if (onlyEq) onlyRaw = onlyEq.slice(7);
|
|
45
|
+
else {
|
|
46
|
+
const oi = process.argv.indexOf('--only');
|
|
47
|
+
if (oi !== -1 && process.argv[oi + 1] && !process.argv[oi + 1].startsWith('-')) onlyRaw = process.argv[oi + 1];
|
|
56
48
|
}
|
|
57
49
|
const onlyPrefixes = onlyRaw
|
|
58
50
|
? onlyRaw.split(',').map(p => {
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
const wikiPrefix = normalizeWikiOnlyPrefix(p);
|
|
52
|
+
if (wikiPrefix) return `/${wikiPrefix.replace(/^\//, '')}`;
|
|
53
|
+
let n = '/' + p.replace(/^\//, '');
|
|
54
|
+
if (!n.endsWith('/') && !n.includes('.')) n += '/';
|
|
55
|
+
return n;
|
|
56
|
+
})
|
|
63
57
|
: null;
|
|
64
58
|
|
|
65
59
|
const creds = loadCredentials();
|
|
66
|
-
if (!creds || !creds.token) {
|
|
67
|
-
console.error('Not logged in. Run: atris login');
|
|
68
|
-
process.exit(1);
|
|
69
|
-
}
|
|
60
|
+
if (!creds || !creds.token) { console.error('Not logged in. Run: atris login'); process.exit(1); }
|
|
70
61
|
|
|
71
62
|
// Determine source directory
|
|
72
63
|
const fromIdx = process.argv.indexOf('--from');
|
|
@@ -74,74 +65,71 @@ async function pushAtris() {
|
|
|
74
65
|
if (fromIdx !== -1 && process.argv[fromIdx + 1]) {
|
|
75
66
|
sourceDir = path.resolve(process.argv[fromIdx + 1]);
|
|
76
67
|
} else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
|
|
77
|
-
// Inside a pulled folder — push from here
|
|
78
68
|
sourceDir = process.cwd();
|
|
79
69
|
} else {
|
|
80
70
|
const atrisDir = path.join(process.cwd(), 'atris', slug);
|
|
81
71
|
const cwdDir = path.join(process.cwd(), slug);
|
|
82
|
-
if (fs.existsSync(atrisDir))
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
sourceDir = cwdDir;
|
|
86
|
-
} else {
|
|
72
|
+
if (fs.existsSync(atrisDir)) sourceDir = atrisDir;
|
|
73
|
+
else if (fs.existsSync(cwdDir)) sourceDir = cwdDir;
|
|
74
|
+
else {
|
|
87
75
|
console.error(`No local folder found for "${slug}".`);
|
|
88
|
-
console.error(
|
|
89
|
-
console.error('Or specify: atris push pallet --from ./path/to/folder');
|
|
76
|
+
console.error('Run from inside a pulled folder, or: atris push pallet --from ./path');
|
|
90
77
|
process.exit(1);
|
|
91
78
|
}
|
|
92
79
|
}
|
|
93
80
|
|
|
94
|
-
if (!fs.existsSync(sourceDir)) {
|
|
95
|
-
console.error(`Source directory not found: ${sourceDir}`);
|
|
96
|
-
process.exit(1);
|
|
97
|
-
}
|
|
81
|
+
if (!fs.existsSync(sourceDir)) { console.error(`Source not found: ${sourceDir}`); process.exit(1); }
|
|
98
82
|
|
|
99
|
-
// Resolve business
|
|
83
|
+
// Resolve business — always refresh from API
|
|
100
84
|
let businessId, workspaceId, businessName, resolvedSlug;
|
|
101
85
|
const businesses = loadBusinesses();
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
businessName = businesses[slug].name || slug;
|
|
107
|
-
resolvedSlug = businesses[slug].slug || slug;
|
|
108
|
-
} else {
|
|
109
|
-
const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
|
|
110
|
-
if (!listResult.ok) {
|
|
111
|
-
console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
|
|
112
|
-
process.exit(1);
|
|
113
|
-
}
|
|
114
|
-
const match = (listResult.data || []).find(
|
|
115
|
-
b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
|
|
116
|
-
);
|
|
117
|
-
if (!match) {
|
|
118
|
-
console.error(`Business "${slug}" not found.`);
|
|
119
|
-
process.exit(1);
|
|
120
|
-
}
|
|
86
|
+
const listResult = await apiRequestJson('/business/', { method: 'GET', token: creds.token });
|
|
87
|
+
if (listResult.ok) {
|
|
88
|
+
const match = (listResult.data || []).find(b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase());
|
|
89
|
+
if (!match) { console.error(`Business "${slug}" not found.`); process.exit(1); }
|
|
121
90
|
businessId = match.id;
|
|
122
91
|
workspaceId = match.workspace_id;
|
|
123
92
|
businessName = match.name;
|
|
124
93
|
resolvedSlug = match.slug;
|
|
125
|
-
|
|
126
|
-
businesses[slug] = {
|
|
127
|
-
business_id: businessId,
|
|
128
|
-
workspace_id: workspaceId,
|
|
129
|
-
name: businessName,
|
|
130
|
-
slug: match.slug,
|
|
131
|
-
added_at: new Date().toISOString(),
|
|
132
|
-
};
|
|
94
|
+
businesses[slug] = { business_id: businessId, workspace_id: workspaceId, name: businessName, slug: match.slug, added_at: new Date().toISOString() };
|
|
133
95
|
saveBusinesses(businesses);
|
|
96
|
+
} else if (businesses[slug]) {
|
|
97
|
+
businessId = businesses[slug].business_id;
|
|
98
|
+
workspaceId = businesses[slug].workspace_id;
|
|
99
|
+
businessName = businesses[slug].name || slug;
|
|
100
|
+
resolvedSlug = businesses[slug].slug || slug;
|
|
101
|
+
} else {
|
|
102
|
+
console.error(`Failed to reach API and no cached business for "${slug}".`);
|
|
103
|
+
process.exit(1);
|
|
134
104
|
}
|
|
135
105
|
|
|
136
|
-
if (!workspaceId) {
|
|
137
|
-
|
|
138
|
-
|
|
106
|
+
if (!workspaceId) { console.error(`Business "${slug}" has no workspace.`); process.exit(1); }
|
|
107
|
+
|
|
108
|
+
// Auto-wake the EC2 computer if --auto-wake is set, otherwise check status and warn.
|
|
109
|
+
// Without this, push silently routes to agent_files cache when computer is asleep
|
|
110
|
+
// (the silent fallback footgun from tonight's debugging).
|
|
111
|
+
const autoWake = process.argv.includes('--auto-wake');
|
|
112
|
+
if (autoWake) {
|
|
113
|
+
const statusResult = await apiRequestJson(`/business/${businessId}/ai-computer/status`, { method: 'GET', token: creds.token });
|
|
114
|
+
const computerStatus = statusResult.ok && statusResult.data ? statusResult.data.status : 'unknown';
|
|
115
|
+
if (computerStatus !== 'running' || !(statusResult.data && statusResult.data.endpoint)) {
|
|
116
|
+
process.stdout.write(' Waking EC2 computer... ');
|
|
117
|
+
await apiRequestJson(`/business/${businessId}/ai-computer/wake`, { method: 'POST', token: creds.token });
|
|
118
|
+
const wakeStart = Date.now();
|
|
119
|
+
while (Date.now() - wakeStart < 90000) {
|
|
120
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
121
|
+
const s = await apiRequestJson(`/business/${businessId}/ai-computer/status`, { method: 'GET', token: creds.token });
|
|
122
|
+
if (s.ok && s.data && s.data.status === 'running' && s.data.endpoint) {
|
|
123
|
+
const elapsed = Math.floor((Date.now() - wakeStart) / 1000);
|
|
124
|
+
console.log(`awake (${elapsed}s)`);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
139
129
|
}
|
|
140
130
|
|
|
141
|
-
// Load manifest
|
|
131
|
+
// Load manifest and compute local hashes
|
|
142
132
|
const manifest = loadManifest(resolvedSlug || slug);
|
|
143
|
-
|
|
144
|
-
// Compute local file hashes
|
|
145
133
|
const localFiles = computeLocalHashes(sourceDir);
|
|
146
134
|
|
|
147
135
|
if (Object.keys(localFiles).length === 0) {
|
|
@@ -149,226 +137,275 @@ async function pushAtris() {
|
|
|
149
137
|
return;
|
|
150
138
|
}
|
|
151
139
|
|
|
152
|
-
// Get remote snapshot for three-way compare
|
|
153
140
|
console.log('');
|
|
154
141
|
console.log(`Pushing to ${businessName}...`);
|
|
155
142
|
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
143
|
+
// ───────────────────────────────────────────────────────────────────
|
|
144
|
+
// FRESHNESS CHECK — pull-before-push enforcement.
|
|
145
|
+
// ───────────────────────────────────────────────────────────────────
|
|
146
|
+
// Compare cloud's current state to our local manifest. If cloud has any
|
|
147
|
+
// file the manifest doesn't know about, OR a file with a different hash
|
|
148
|
+
// than what we last pulled, the user is out of date and MUST pull first.
|
|
149
|
+
// This prevents stale local state from clobbering fresh cloud changes —
|
|
150
|
+
// the "lagging version push" footgun. Use --force to bypass (e.g., for
|
|
151
|
+
// genuine local-canonical pushes like align --hard).
|
|
152
|
+
if (!force) {
|
|
153
|
+
process.stdout.write(' Checking cloud freshness... ');
|
|
154
|
+
const snapshotResult = await apiRequestJson(
|
|
155
|
+
`/business/${businessId}/workspaces/${workspaceId}/snapshot?include_content=false`,
|
|
156
|
+
{ method: 'GET', token: creds.token, timeoutMs: 60000 }
|
|
157
|
+
);
|
|
158
|
+
if (snapshotResult.ok && snapshotResult.data && Array.isArray(snapshotResult.data.files)) {
|
|
159
|
+
const cloudHashes = {};
|
|
160
|
+
for (const f of snapshotResult.data.files) {
|
|
161
|
+
if (f.path && f.hash) cloudHashes[f.path] = f.hash;
|
|
162
|
+
}
|
|
163
|
+
const manifestFiles = (manifest && manifest.files) || {};
|
|
164
|
+
const driftFiles = [];
|
|
165
|
+
// Direction 1: cloud has files the manifest doesn't know about, OR
|
|
166
|
+
// cloud's hash differs from what we last pulled (someone changed it).
|
|
167
|
+
for (const [p, hash] of Object.entries(cloudHashes)) {
|
|
168
|
+
// Apply --only filter to drift detection too: if user is scoping the
|
|
169
|
+
// push to a subtree, only block on drift inside that subtree.
|
|
170
|
+
if (onlyPrefixes && !onlyPrefixes.some((pref) => p.startsWith(pref))) continue;
|
|
171
|
+
const manifestEntry = manifestFiles[p];
|
|
172
|
+
if (!manifestEntry || manifestEntry.hash !== hash) {
|
|
173
|
+
driftFiles.push(p);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Direction 2: manifest has files the cloud no longer has (someone
|
|
177
|
+
// deleted them). Without this check, we'd silently re-push deleted
|
|
178
|
+
// files on the next push, undoing the deletion.
|
|
179
|
+
//
|
|
180
|
+
// CAVEAT: the warm runner's snapshot endpoint deliberately hides certain
|
|
181
|
+
// basenames (CLAUDE.md, .* dotfiles, node_modules, __pycache__, .git) —
|
|
182
|
+
// see ecs_warm_runner.py _snapshot_dir. They CAN exist on cloud but
|
|
183
|
+
// never appear in cloudHashes. Skip them in the missing-side check or
|
|
184
|
+
// every CLAUDE.md push will be flagged as drift forever.
|
|
185
|
+
const SERVER_HIDDEN_BASENAMES = new Set(['CLAUDE.md']);
|
|
186
|
+
const cloudPathSet = new Set(Object.keys(cloudHashes));
|
|
187
|
+
for (const p of Object.keys(manifestFiles)) {
|
|
188
|
+
if (onlyPrefixes && !onlyPrefixes.some((pref) => p.startsWith(pref))) continue;
|
|
189
|
+
const idx = p.lastIndexOf('/');
|
|
190
|
+
const base = idx === -1 ? p : p.slice(idx + 1);
|
|
191
|
+
if (SERVER_HIDDEN_BASENAMES.has(base)) continue;
|
|
192
|
+
if (!cloudPathSet.has(p)) {
|
|
193
|
+
driftFiles.push(p);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (driftFiles.length > 0) {
|
|
197
|
+
console.log(`drift detected (${driftFiles.length} file${driftFiles.length === 1 ? '' : 's'})`);
|
|
198
|
+
console.log('');
|
|
199
|
+
console.log(` ✗ Cloud has changed since your last pull. Refusing to push stale state.`);
|
|
200
|
+
console.log('');
|
|
201
|
+
console.log(' Files that differ on cloud:');
|
|
202
|
+
driftFiles.slice(0, 8).forEach((p) => console.log(` ~ ${p.replace(/^\//, '')}`));
|
|
203
|
+
if (driftFiles.length > 8) console.log(` ... +${driftFiles.length - 8} more`);
|
|
204
|
+
console.log('');
|
|
205
|
+
console.log(' Run `atris pull` first, then push your changes.');
|
|
206
|
+
console.log(' To override (force-push, may clobber cloud edits): atris push --force');
|
|
207
|
+
process.exit(1);
|
|
183
208
|
}
|
|
209
|
+
console.log('fresh');
|
|
210
|
+
} else {
|
|
211
|
+
// Snapshot fetch failed — fail closed. The whole point of the freshness
|
|
212
|
+
// check is to prevent accidental stale pushes; if we can't verify cloud
|
|
213
|
+
// state, we don't push. Use --force to bypass when you know what you're
|
|
214
|
+
// doing (e.g., the workspace is genuinely unhealthy and you have a clean
|
|
215
|
+
// local copy you need to recover from).
|
|
216
|
+
console.log(`failed (status ${snapshotResult.status || 'unknown'})`);
|
|
217
|
+
console.log('');
|
|
218
|
+
console.log(' ✗ Could not verify cloud freshness. Refusing to push.');
|
|
219
|
+
console.log(' The workspace may be unreachable or the snapshot endpoint is broken.');
|
|
220
|
+
console.log(' To bypass and force-push anyway: atris push --force');
|
|
221
|
+
process.exit(1);
|
|
184
222
|
}
|
|
185
223
|
}
|
|
186
224
|
|
|
187
|
-
//
|
|
188
|
-
|
|
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
|
-
|
|
195
|
-
// Determine what to push
|
|
225
|
+
// Compare local hashes to manifest — NO server call needed
|
|
226
|
+
// Files where local hash differs from manifest = changed locally
|
|
227
|
+
const baseFiles = (manifest && manifest.files) ? manifest.files : {};
|
|
196
228
|
const filesToPush = [];
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
if (!onlyPrefixes)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const content = fs.readFileSync(localPath, 'utf8');
|
|
210
|
-
filesToPush.push({ path: p, content });
|
|
211
|
-
} catch {
|
|
212
|
-
// skip
|
|
229
|
+
const deletedPaths = [];
|
|
230
|
+
|
|
231
|
+
for (const [filePath, fileInfo] of Object.entries(localFiles)) {
|
|
232
|
+
if (onlyPrefixes && !onlyPrefixes.some(p => filePath.startsWith(p))) continue;
|
|
233
|
+
const baseHash = baseFiles[filePath] ? baseFiles[filePath].hash : null;
|
|
234
|
+
if (!baseHash || fileInfo.hash !== baseHash) {
|
|
235
|
+
// Changed or new — push it
|
|
236
|
+
const localPath = path.join(sourceDir, filePath.replace(/^\//, ''));
|
|
237
|
+
try {
|
|
238
|
+
const content = fs.readFileSync(localPath, 'utf8');
|
|
239
|
+
filesToPush.push({ path: filePath, content });
|
|
240
|
+
} catch {}
|
|
213
241
|
}
|
|
214
242
|
}
|
|
215
243
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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;
|
|
244
|
+
for (const filePath of Object.keys(baseFiles)) {
|
|
245
|
+
if (onlyPrefixes && !onlyPrefixes.some(p => filePath.startsWith(p))) continue;
|
|
246
|
+
if (!localFiles[filePath]) {
|
|
247
|
+
deletedPaths.push(filePath);
|
|
227
248
|
}
|
|
249
|
+
}
|
|
228
250
|
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
}
|
|
245
|
-
}
|
|
251
|
+
const filteredLocalCount = Object.keys(localFiles).filter(filePath => {
|
|
252
|
+
if (!onlyPrefixes) return true;
|
|
253
|
+
return onlyPrefixes.some(prefix => filePath.startsWith(prefix));
|
|
254
|
+
}).length;
|
|
255
|
+
const unchangedCount = Math.max(0, filteredLocalCount - filesToPush.length);
|
|
246
256
|
|
|
247
|
-
|
|
257
|
+
if (filesToPush.length === 0 && deletedPaths.length === 0) {
|
|
258
|
+
console.log('\n Already up to date.\n');
|
|
259
|
+
return;
|
|
248
260
|
}
|
|
249
261
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (filesToPush.length === 0 && conflictPaths.length === 0) {
|
|
253
|
-
console.log(' Already up to date.');
|
|
262
|
+
// Dry run — show what would be pushed without pushing
|
|
263
|
+
if (dryRun) {
|
|
254
264
|
console.log('');
|
|
265
|
+
for (const f of filesToPush) {
|
|
266
|
+
const isNew = !baseFiles[f.path];
|
|
267
|
+
console.log(` ${isNew ? '+' : '\u2191'} ${f.path.replace(/^\//, '')} ${isNew ? 'new file' : 'updated'} (dry run)`);
|
|
268
|
+
}
|
|
269
|
+
for (const filePath of deletedPaths) {
|
|
270
|
+
console.log(` x ${filePath.replace(/^\//, '')} deleted (dry run)`);
|
|
271
|
+
}
|
|
272
|
+
const parts = [];
|
|
273
|
+
if (filesToPush.length > 0) parts.push(`${filesToPush.length} would be pushed`);
|
|
274
|
+
if (deletedPaths.length > 0) parts.push(`${deletedPaths.length} would be deleted`);
|
|
275
|
+
if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
|
|
276
|
+
console.log(`\n ${parts.join(', ')}. (--dry-run, nothing sent)\n`);
|
|
255
277
|
return;
|
|
256
278
|
}
|
|
257
279
|
|
|
258
|
-
// Push the files
|
|
259
280
|
let pushed = 0;
|
|
281
|
+
let deleted = 0;
|
|
282
|
+
let skipped = [];
|
|
283
|
+
let result = { ok: true };
|
|
284
|
+
|
|
260
285
|
if (filesToPush.length > 0) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
{
|
|
264
|
-
|
|
265
|
-
token: creds.token,
|
|
266
|
-
body: { files: filesToPush },
|
|
267
|
-
headers: { 'X-Atris-Actor-Source': 'cli' },
|
|
268
|
-
}
|
|
286
|
+
// Push files to server
|
|
287
|
+
result = await apiRequestJson(
|
|
288
|
+
`/business/${businessId}/workspaces/${workspaceId}/sync`,
|
|
289
|
+
{ method: 'POST', token: creds.token, body: { files: filesToPush }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
269
290
|
);
|
|
270
291
|
|
|
271
292
|
if (!result.ok) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
}
|
|
293
|
+
if (result.status === 403) {
|
|
294
|
+
// Permission denied — retry with only team/ and journal/ files
|
|
295
|
+
const allowed = filesToPush.filter(f => f.path.startsWith('/team/') || f.path.startsWith('/journal/'));
|
|
296
|
+
skipped = filesToPush.filter(f => !f.path.startsWith('/team/') && !f.path.startsWith('/journal/'));
|
|
297
|
+
|
|
298
|
+
if (allowed.length > 0) {
|
|
284
299
|
const retry = await apiRequestJson(
|
|
285
|
-
`/
|
|
286
|
-
{ method: 'POST', token: creds.token, body: { files:
|
|
300
|
+
`/business/${businessId}/workspaces/${workspaceId}/sync`,
|
|
301
|
+
{ method: 'POST', token: creds.token, body: { files: allowed }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
287
302
|
);
|
|
288
303
|
if (retry.ok) {
|
|
289
|
-
|
|
290
|
-
console.log(` \u2191 ${f.path.replace(/^\//, '')} pushed`);
|
|
291
|
-
pushed++;
|
|
292
|
-
}
|
|
304
|
+
pushed = allowed.length;
|
|
293
305
|
} else {
|
|
294
|
-
console.error(
|
|
306
|
+
console.error(`\n Push failed: ${retry.errorMessage || retry.error || retry.status}`);
|
|
295
307
|
process.exit(1);
|
|
296
308
|
}
|
|
297
309
|
} else {
|
|
298
|
-
console.error(
|
|
299
|
-
if (blockedFiles.length > 0) {
|
|
300
|
-
console.error(` Blocked: ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`);
|
|
301
|
-
}
|
|
310
|
+
console.error('\n Access denied: you can only push to your team/ folder.');
|
|
302
311
|
process.exit(1);
|
|
303
312
|
}
|
|
313
|
+
} else if (result.status === 409) {
|
|
314
|
+
console.error('\n Computer is sleeping. Wake it first.');
|
|
315
|
+
process.exit(1);
|
|
304
316
|
} else {
|
|
305
|
-
console.error(
|
|
317
|
+
console.error(`\n Push failed: ${result.errorMessage || result.error || result.status}`);
|
|
318
|
+
process.exit(1);
|
|
306
319
|
}
|
|
307
|
-
|
|
320
|
+
} else {
|
|
321
|
+
pushed = filesToPush.length;
|
|
308
322
|
}
|
|
323
|
+
}
|
|
309
324
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
325
|
+
// Delete loop — throttled, 429-aware, tracks per-file success/failure.
|
|
326
|
+
// Earlier bug: bulk deletes hit rate limit (60/min default) at request 60,
|
|
327
|
+
// then process.exit'd, leaving partial state and a manifest that thought
|
|
328
|
+
// everything was deleted. New behavior:
|
|
329
|
+
// - 600ms throttle between deletes (≈100/min, safe under default rate limit)
|
|
330
|
+
// - 429 → wait 20s, retry once
|
|
331
|
+
// - 404 → counted as success (file already gone)
|
|
332
|
+
// - other failures → collected, reported at end, do NOT exit
|
|
333
|
+
// - manifest update only counts confirmed-deleted paths
|
|
334
|
+
const deletedConfirmed = [];
|
|
335
|
+
const deleteFailed = [];
|
|
336
|
+
for (let i = 0; i < deletedPaths.length; i++) {
|
|
337
|
+
const filePath = deletedPaths[i];
|
|
338
|
+
if (i > 0) {
|
|
339
|
+
// sleep 600ms between deletes
|
|
340
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
314
341
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
342
|
+
let deleteResult = await apiRequestJson(
|
|
343
|
+
`/business/${businessId}/workspaces/${workspaceId}/file?path=${encodeURIComponent(filePath)}`,
|
|
344
|
+
{ method: 'DELETE', token: creds.token }
|
|
345
|
+
);
|
|
346
|
+
if (deleteResult.status === 429) {
|
|
347
|
+
// Rate limit — wait 20s, retry once
|
|
348
|
+
await new Promise((r) => setTimeout(r, 20000));
|
|
349
|
+
deleteResult = await apiRequestJson(
|
|
350
|
+
`/business/${businessId}/workspaces/${workspaceId}/file?path=${encodeURIComponent(filePath)}`,
|
|
351
|
+
{ method: 'DELETE', token: creds.token }
|
|
352
|
+
);
|
|
318
353
|
}
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
354
|
+
if (deleteResult.ok || deleteResult.status === 404) {
|
|
355
|
+
deletedConfirmed.push(filePath);
|
|
356
|
+
deleted++;
|
|
357
|
+
} else {
|
|
358
|
+
deleteFailed.push({ path: filePath, status: deleteResult.status, error: deleteResult.error });
|
|
324
359
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
360
|
+
// Show progress for large batches
|
|
361
|
+
if (deletedPaths.length > 20 && (i + 1) % 20 === 0) {
|
|
362
|
+
console.log(` [delete ${i + 1}/${deletedPaths.length}] ${deletedConfirmed.length} ok, ${deleteFailed.length} failed`);
|
|
328
363
|
}
|
|
329
364
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
console.log(`
|
|
365
|
+
if (deleteFailed.length > 0) {
|
|
366
|
+
console.log('');
|
|
367
|
+
console.log(` ⚠ ${deleteFailed.length} delete(s) failed (NOT marked as deleted in manifest):`);
|
|
368
|
+
deleteFailed.slice(0, 10).forEach((f) => console.log(` ${f.status} ${f.path.replace(/^\//, '')}`));
|
|
369
|
+
if (deleteFailed.length > 10) console.log(` ... +${deleteFailed.length - 10} more`);
|
|
334
370
|
}
|
|
335
371
|
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
372
|
+
// Display results
|
|
373
|
+
console.log('');
|
|
374
|
+
for (const f of filesToPush) {
|
|
375
|
+
if (skipped.includes(f)) continue;
|
|
376
|
+
const isNew = !baseFiles[f.path];
|
|
377
|
+
console.log(` ${isNew ? '+' : '\u2191'} ${f.path.replace(/^\//, '')} ${isNew ? 'new file' : 'updated'}`);
|
|
378
|
+
}
|
|
379
|
+
for (const f of skipped) {
|
|
380
|
+
console.log(` - ${f.path.replace(/^\//, '')} skipped (no permission)`);
|
|
381
|
+
}
|
|
382
|
+
// Only print confirmed deletes (not failed ones — they were reported above)
|
|
383
|
+
for (const filePath of deletedConfirmed) {
|
|
384
|
+
console.log(` x ${filePath.replace(/^\//, '')} deleted`);
|
|
339
385
|
}
|
|
340
386
|
|
|
341
387
|
// Summary
|
|
342
388
|
console.log('');
|
|
343
389
|
const parts = [];
|
|
344
390
|
if (pushed > 0) parts.push(`${pushed} pushed`);
|
|
345
|
-
if (
|
|
346
|
-
if (
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
commitHash = headResult.data.commit;
|
|
391
|
+
if (deleted > 0) parts.push(`${deleted} deleted`);
|
|
392
|
+
if (deleteFailed.length > 0) parts.push(`${deleteFailed.length} delete failed`);
|
|
393
|
+
if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
|
|
394
|
+
if (skipped.length > 0) parts.push(`${skipped.length} skipped`);
|
|
395
|
+
console.log(` ${parts.join(', ')}.`);
|
|
396
|
+
|
|
397
|
+
// Update manifest — mark pushed files with their new hash, drop ONLY confirmed deletes.
|
|
398
|
+
// Failed deletes stay in the manifest so the next push will retry them.
|
|
399
|
+
const updatedFiles = { ...baseFiles };
|
|
400
|
+
for (const f of filesToPush) {
|
|
401
|
+
if (!skipped.includes(f)) {
|
|
402
|
+
updatedFiles[f.path] = localFiles[f.path];
|
|
358
403
|
}
|
|
359
|
-
} catch {
|
|
360
|
-
// Git might not be initialized yet
|
|
361
404
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
const mergedFiles = { ...remoteFiles };
|
|
365
|
-
for (const p of Object.keys(localFiles)) {
|
|
366
|
-
if (filesToPush.some(f => f.path === p)) {
|
|
367
|
-
mergedFiles[p] = localFiles[p]; // we pushed this, so our hash is now the truth
|
|
368
|
-
}
|
|
405
|
+
for (const filePath of deletedConfirmed) {
|
|
406
|
+
delete updatedFiles[filePath];
|
|
369
407
|
}
|
|
370
|
-
|
|
371
|
-
saveManifest(resolvedSlug || slug, newManifest);
|
|
408
|
+
saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null));
|
|
372
409
|
}
|
|
373
410
|
|
|
374
411
|
module.exports = { pushAtris };
|