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.
Files changed (54) hide show
  1. package/README.md +124 -34
  2. package/atris/CLAUDE.md +5 -1
  3. package/atris/atris.md +4 -0
  4. package/atris/features/README.md +24 -0
  5. package/atris/skills/autopilot/SKILL.md +74 -75
  6. package/atris/skills/endgame/SKILL.md +179 -0
  7. package/atris/skills/flow/SKILL.md +121 -0
  8. package/atris/skills/improve/SKILL.md +84 -0
  9. package/atris/skills/loop/SKILL.md +72 -0
  10. package/atris/skills/wiki/SKILL.md +61 -0
  11. package/atris/team/executor/MEMBER.md +10 -4
  12. package/atris/team/navigator/MEMBER.md +2 -0
  13. package/atris/team/validator/MEMBER.md +8 -5
  14. package/atris.md +33 -0
  15. package/bin/atris.js +210 -41
  16. package/commands/activate.js +28 -2
  17. package/commands/align.js +720 -0
  18. package/commands/auth.js +75 -2
  19. package/commands/autopilot.js +1213 -270
  20. package/commands/browse.js +100 -0
  21. package/commands/business.js +785 -12
  22. package/commands/clean.js +107 -2
  23. package/commands/computer.js +429 -0
  24. package/commands/context-sync.js +78 -8
  25. package/commands/experiments.js +351 -0
  26. package/commands/feedback.js +150 -0
  27. package/commands/fleet.js +395 -0
  28. package/commands/fork.js +127 -0
  29. package/commands/init.js +50 -1
  30. package/commands/learn.js +407 -0
  31. package/commands/lifecycle.js +94 -0
  32. package/commands/loop.js +114 -0
  33. package/commands/publish.js +129 -0
  34. package/commands/pull.js +434 -48
  35. package/commands/push.js +312 -164
  36. package/commands/review.js +149 -0
  37. package/commands/run.js +76 -43
  38. package/commands/serve.js +360 -0
  39. package/commands/setup.js +1 -1
  40. package/commands/soul.js +381 -0
  41. package/commands/status.js +119 -1
  42. package/commands/sync.js +147 -1
  43. package/commands/terminal.js +201 -0
  44. package/commands/wiki.js +376 -0
  45. package/commands/workflow.js +191 -74
  46. package/commands/workspace-clean.js +3 -3
  47. package/lib/endstate.js +259 -0
  48. package/lib/learnings.js +235 -0
  49. package/lib/manifest.js +1 -0
  50. package/lib/todo.js +9 -5
  51. package/lib/wiki.js +578 -0
  52. package/package.json +2 -2
  53. package/utils/api.js +48 -36
  54. 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, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, SKIP_DIRS } = require('../lib/manifest');
7
+ const { loadManifest, saveManifest, buildManifest, computeLocalHashes } = require('../lib/manifest');
8
+ const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
7
9
 
8
10
  async function pushAtris() {
9
- const slug = process.argv[3];
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 <business-slug> [--from <path>] [--force]');
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('Options:');
17
- console.log(' --from <path> Push from a custom directory');
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('Examples:');
21
- console.log(' atris push pallet Push from atris/pallet/ or ./pallet/');
22
- console.log(' atris push pallet --from ./my-dir/ Push from a custom directory');
23
- console.log(' atris push pallet --force Override conflicts');
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
- const creds = loadCredentials();
30
- if (!creds || !creds.token) {
31
- console.error('Not logged in. Run: atris login');
32
- process.exit(1);
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
- sourceDir = atrisDir;
45
- } else if (fs.existsSync(cwdDir)) {
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(`Expected: atris/${slug}/ or ./${slug}/`);
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 ID
83
+ // Resolve business — always refresh from API
61
84
  let businessId, workspaceId, businessName, resolvedSlug;
62
85
  const businesses = loadBusinesses();
63
-
64
- if (businesses[slug]) {
65
- businessId = businesses[slug].business_id;
66
- workspaceId = businesses[slug].workspace_id;
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
- console.error(`Business "${slug}" has no workspace.`);
99
- process.exit(1);
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 (last sync state)
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
- // 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 }
121
- );
122
-
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 };
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
- // Three-way compare
136
- const diff = threeWayCompare(localFiles, remoteFiles, manifest);
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
- // 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(/^\//, ''));
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: p, content });
160
- } catch {
161
- // skip
162
- }
239
+ filesToPush.push({ path: filePath, content });
240
+ } catch {}
163
241
  }
164
- } else {
165
- for (const p of diff.conflicts) {
166
- conflictPaths.push(p);
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
- console.log('');
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 && conflictPaths.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
- 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
- }
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
- 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}`);
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(` Push failed: ${msg}`);
317
+ console.error(`\n Push failed: ${result.errorMessage || result.error || result.status}`);
318
+ process.exit(1);
199
319
  }
200
- process.exit(1);
320
+ } else {
321
+ pushed = filesToPush.length;
201
322
  }
323
+ }
202
324
 
203
- // Display results
204
- for (const p of diff.toPush) {
205
- console.log(` \u2191 ${p.replace(/^\//, '')} pushing your changes`);
206
- pushed++;
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
- for (const p of diff.newLocal) {
209
- console.log(` + ${p.replace(/^\//, '')} new file`);
210
- pushed++;
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 (force) {
213
- for (const p of diff.conflicts) {
214
- console.log(` ! ${p.replace(/^\//, '')} overwritten (--force)`);
215
- pushed++;
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
- // Show conflicts
221
- for (const p of conflictPaths) {
222
- console.log(` \u26A0 ${p.replace(/^\//, '')} CONFLICT \u2014 skipped (use --force to override)`);
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
- // Show unchanged
226
- if (diff.unchanged.length > 0) {
227
- // Don't list them all, just count
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 (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(', ')}.`);
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
- // 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;
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
- // 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
- }
405
+ for (const filePath of deletedConfirmed) {
406
+ delete updatedFiles[filePath];
258
407
  }
259
- const newManifest = buildManifest(mergedFiles, commitHash);
260
- saveManifest(resolvedSlug || slug, newManifest);
408
+ saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null));
261
409
  }
262
410
 
263
411
  module.exports = { pushAtris };