atris 2.6.2 → 3.0.0
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 +434 -48
- package/commands/push.js +312 -164
- 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 +48 -36
- package/utils/auth.js +1 -0
package/commands/push.js
CHANGED
|
@@ -1,108 +1,135 @@
|
|
|
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 { loadManifest, saveManifest, buildManifest, computeLocalHashes } = require('../lib/manifest');
|
|
8
|
+
const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
|
|
7
9
|
|
|
8
10
|
async function pushAtris() {
|
|
9
|
-
|
|
11
|
+
let slug = process.argv[3];
|
|
12
|
+
|
|
13
|
+
// Auto-detect business from .atris/business.json in current dir
|
|
14
|
+
if (!slug || slug.startsWith('-')) {
|
|
15
|
+
const bizFile = path.join(process.cwd(), '.atris', 'business.json');
|
|
16
|
+
if (fs.existsSync(bizFile)) {
|
|
17
|
+
try {
|
|
18
|
+
const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
|
|
19
|
+
slug = biz.slug || biz.name;
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
if (!slug || slug.startsWith('-')) slug = null;
|
|
23
|
+
}
|
|
10
24
|
|
|
11
25
|
if (!slug || slug === '--help') {
|
|
12
|
-
console.log('Usage: atris push
|
|
13
|
-
console.log('');
|
|
14
|
-
console.log('Push local files to a Business Computer.');
|
|
26
|
+
console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force]');
|
|
15
27
|
console.log('');
|
|
16
|
-
console.log('
|
|
17
|
-
console.log('
|
|
18
|
-
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.');
|
|
19
30
|
console.log('');
|
|
20
|
-
console.log('
|
|
21
|
-
console.log(' atris push pallet
|
|
22
|
-
console.log(' atris push pallet --
|
|
23
|
-
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)');
|
|
24
35
|
process.exit(0);
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
const force = process.argv.includes('--force');
|
|
39
|
+
const dryRun = process.argv.includes('--dry-run');
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
// Parse --only
|
|
42
|
+
let onlyRaw = null;
|
|
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];
|
|
33
48
|
}
|
|
49
|
+
const onlyPrefixes = onlyRaw
|
|
50
|
+
? onlyRaw.split(',').map(p => {
|
|
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
|
+
})
|
|
57
|
+
: null;
|
|
58
|
+
|
|
59
|
+
const creds = loadCredentials();
|
|
60
|
+
if (!creds || !creds.token) { console.error('Not logged in. Run: atris login'); process.exit(1); }
|
|
34
61
|
|
|
35
62
|
// Determine source directory
|
|
36
63
|
const fromIdx = process.argv.indexOf('--from');
|
|
37
64
|
let sourceDir;
|
|
38
65
|
if (fromIdx !== -1 && process.argv[fromIdx + 1]) {
|
|
39
66
|
sourceDir = path.resolve(process.argv[fromIdx + 1]);
|
|
67
|
+
} else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
|
|
68
|
+
sourceDir = process.cwd();
|
|
40
69
|
} else {
|
|
41
70
|
const atrisDir = path.join(process.cwd(), 'atris', slug);
|
|
42
71
|
const cwdDir = path.join(process.cwd(), slug);
|
|
43
|
-
if (fs.existsSync(atrisDir))
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
sourceDir = cwdDir;
|
|
47
|
-
} else {
|
|
72
|
+
if (fs.existsSync(atrisDir)) sourceDir = atrisDir;
|
|
73
|
+
else if (fs.existsSync(cwdDir)) sourceDir = cwdDir;
|
|
74
|
+
else {
|
|
48
75
|
console.error(`No local folder found for "${slug}".`);
|
|
49
|
-
console.error(
|
|
50
|
-
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');
|
|
51
77
|
process.exit(1);
|
|
52
78
|
}
|
|
53
79
|
}
|
|
54
80
|
|
|
55
|
-
if (!fs.existsSync(sourceDir)) {
|
|
56
|
-
console.error(`Source directory not found: ${sourceDir}`);
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
81
|
+
if (!fs.existsSync(sourceDir)) { console.error(`Source not found: ${sourceDir}`); process.exit(1); }
|
|
59
82
|
|
|
60
|
-
// Resolve business
|
|
83
|
+
// Resolve business — always refresh from API
|
|
61
84
|
let businessId, workspaceId, businessName, resolvedSlug;
|
|
62
85
|
const businesses = loadBusinesses();
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
businessName = businesses[slug].name || slug;
|
|
68
|
-
resolvedSlug = businesses[slug].slug || slug;
|
|
69
|
-
} else {
|
|
70
|
-
const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
|
|
71
|
-
if (!listResult.ok) {
|
|
72
|
-
console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
|
|
73
|
-
process.exit(1);
|
|
74
|
-
}
|
|
75
|
-
const match = (listResult.data || []).find(
|
|
76
|
-
b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
|
|
77
|
-
);
|
|
78
|
-
if (!match) {
|
|
79
|
-
console.error(`Business "${slug}" not found.`);
|
|
80
|
-
process.exit(1);
|
|
81
|
-
}
|
|
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); }
|
|
82
90
|
businessId = match.id;
|
|
83
91
|
workspaceId = match.workspace_id;
|
|
84
92
|
businessName = match.name;
|
|
85
93
|
resolvedSlug = match.slug;
|
|
86
|
-
|
|
87
|
-
businesses[slug] = {
|
|
88
|
-
business_id: businessId,
|
|
89
|
-
workspace_id: workspaceId,
|
|
90
|
-
name: businessName,
|
|
91
|
-
slug: match.slug,
|
|
92
|
-
added_at: new Date().toISOString(),
|
|
93
|
-
};
|
|
94
|
+
businesses[slug] = { business_id: businessId, workspace_id: workspaceId, name: businessName, slug: match.slug, added_at: new Date().toISOString() };
|
|
94
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);
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
if (!workspaceId) {
|
|
98
|
-
|
|
99
|
-
|
|
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
|
+
}
|
|
100
129
|
}
|
|
101
130
|
|
|
102
|
-
// Load manifest
|
|
131
|
+
// Load manifest and compute local hashes
|
|
103
132
|
const manifest = loadManifest(resolvedSlug || slug);
|
|
104
|
-
|
|
105
|
-
// Compute local file hashes
|
|
106
133
|
const localFiles = computeLocalHashes(sourceDir);
|
|
107
134
|
|
|
108
135
|
if (Object.keys(localFiles).length === 0) {
|
|
@@ -110,154 +137,275 @@ async function pushAtris() {
|
|
|
110
137
|
return;
|
|
111
138
|
}
|
|
112
139
|
|
|
113
|
-
// Get remote snapshot for three-way compare
|
|
114
140
|
console.log('');
|
|
115
141
|
console.log(`Pushing to ${businessName}...`);
|
|
116
142
|
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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;
|
|
131
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);
|
|
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);
|
|
132
222
|
}
|
|
133
223
|
}
|
|
134
224
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// 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 : {};
|
|
139
228
|
const filesToPush = [];
|
|
229
|
+
const deletedPaths = [];
|
|
140
230
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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(/^\//, ''));
|
|
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(/^\//, ''));
|
|
157
237
|
try {
|
|
158
238
|
const content = fs.readFileSync(localPath, 'utf8');
|
|
159
|
-
filesToPush.push({ path:
|
|
160
|
-
} catch {
|
|
161
|
-
// skip
|
|
162
|
-
}
|
|
239
|
+
filesToPush.push({ path: filePath, content });
|
|
240
|
+
} catch {}
|
|
163
241
|
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
242
|
+
}
|
|
243
|
+
|
|
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);
|
|
167
248
|
}
|
|
168
249
|
}
|
|
169
250
|
|
|
170
|
-
|
|
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);
|
|
171
256
|
|
|
172
|
-
if (filesToPush.length === 0 &&
|
|
173
|
-
console.log(' Already up to date
|
|
257
|
+
if (filesToPush.length === 0 && deletedPaths.length === 0) {
|
|
258
|
+
console.log('\n Already up to date.\n');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Dry run — show what would be pushed without pushing
|
|
263
|
+
if (dryRun) {
|
|
174
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`);
|
|
175
277
|
return;
|
|
176
278
|
}
|
|
177
279
|
|
|
178
|
-
// Push the files
|
|
179
280
|
let pushed = 0;
|
|
281
|
+
let deleted = 0;
|
|
282
|
+
let skipped = [];
|
|
283
|
+
let result = { ok: true };
|
|
284
|
+
|
|
180
285
|
if (filesToPush.length > 0) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
{
|
|
184
|
-
|
|
185
|
-
token: creds.token,
|
|
186
|
-
body: { files: filesToPush },
|
|
187
|
-
headers: { 'X-Atris-Actor-Source': 'cli' },
|
|
188
|
-
}
|
|
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' } }
|
|
189
290
|
);
|
|
190
291
|
|
|
191
292
|
if (!result.ok) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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) {
|
|
299
|
+
const retry = await apiRequestJson(
|
|
300
|
+
`/business/${businessId}/workspaces/${workspaceId}/sync`,
|
|
301
|
+
{ method: 'POST', token: creds.token, body: { files: allowed }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
302
|
+
);
|
|
303
|
+
if (retry.ok) {
|
|
304
|
+
pushed = allowed.length;
|
|
305
|
+
} else {
|
|
306
|
+
console.error(`\n Push failed: ${retry.errorMessage || retry.error || retry.status}`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
console.error('\n Access denied: you can only push to your team/ folder.');
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
} else if (result.status === 409) {
|
|
314
|
+
console.error('\n Computer is sleeping. Wake it first.');
|
|
315
|
+
process.exit(1);
|
|
197
316
|
} else {
|
|
198
|
-
console.error(
|
|
317
|
+
console.error(`\n Push failed: ${result.errorMessage || result.error || result.status}`);
|
|
318
|
+
process.exit(1);
|
|
199
319
|
}
|
|
200
|
-
|
|
320
|
+
} else {
|
|
321
|
+
pushed = filesToPush.length;
|
|
201
322
|
}
|
|
323
|
+
}
|
|
202
324
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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));
|
|
207
341
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
);
|
|
211
353
|
}
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
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 });
|
|
359
|
+
}
|
|
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`);
|
|
217
363
|
}
|
|
218
364
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
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`);
|
|
223
370
|
}
|
|
224
371
|
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
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`);
|
|
228
385
|
}
|
|
229
386
|
|
|
230
387
|
// Summary
|
|
231
388
|
console.log('');
|
|
232
389
|
const parts = [];
|
|
233
390
|
if (pushed > 0) parts.push(`${pushed} pushed`);
|
|
234
|
-
if (
|
|
235
|
-
if (
|
|
236
|
-
if (
|
|
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(', ')}.`);
|
|
237
396
|
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
);
|
|
245
|
-
if (headResult.ok && headResult.data && headResult.data.commit) {
|
|
246
|
-
commitHash = headResult.data.commit;
|
|
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];
|
|
247
403
|
}
|
|
248
|
-
} catch {
|
|
249
|
-
// Git might not be initialized yet
|
|
250
404
|
}
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
}
|
|
405
|
+
for (const filePath of deletedConfirmed) {
|
|
406
|
+
delete updatedFiles[filePath];
|
|
258
407
|
}
|
|
259
|
-
|
|
260
|
-
saveManifest(resolvedSlug || slug, newManifest);
|
|
408
|
+
saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null));
|
|
261
409
|
}
|
|
262
410
|
|
|
263
411
|
module.exports = { pushAtris };
|