atris 3.15.0 → 3.15.12

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/commands/pull.js CHANGED
@@ -11,6 +11,13 @@ const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocal
11
11
  const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
12
12
  const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
13
13
  const { resolveSafeOutputDir } = require('../lib/workspace-safety');
14
+ const {
15
+ buildConflictReviewPacket,
16
+ readBaseContent,
17
+ removeBaseContents,
18
+ writeBaseContents,
19
+ writeConflictReviewPacket,
20
+ } = require('../lib/company-brain-sync');
14
21
 
15
22
  function pruneEmptyParentDirs(filePath, stopDir) {
16
23
  let current = path.dirname(filePath);
@@ -26,11 +33,50 @@ function pruneEmptyParentDirs(filePath, stopDir) {
26
33
  }
27
34
  }
28
35
 
36
+ function isBusinessWorkspaceRoot(dir) {
37
+ return fs.existsSync(path.join(dir, '.atris', 'business.json')) && fs.existsSync(path.join(dir, 'atris'));
38
+ }
39
+
40
+ function syncTimestamp() {
41
+ return new Date().toISOString().replace(/[:.]/g, '-');
42
+ }
43
+
44
+ function buildPullConflictReviewPacket(outputDir, conflictChanges, remoteContents = {}, timestamp = syncTimestamp()) {
45
+ const baseContents = {};
46
+ const localContents = {};
47
+ const normalizedRemoteContents = {};
48
+
49
+ for (const change of conflictChanges) {
50
+ const p = change.path;
51
+ const localPath = path.join(outputDir, p.replace(/^\//, ''));
52
+ try {
53
+ localContents[p] = fs.readFileSync(localPath, 'utf8');
54
+ } catch {
55
+ localContents[p] = '';
56
+ }
57
+
58
+ const baseContent = readBaseContent(outputDir, p);
59
+ if (baseContent !== null) baseContents[p] = baseContent;
60
+
61
+ normalizedRemoteContents[p] = Object.prototype.hasOwnProperty.call(remoteContents, p)
62
+ ? remoteContents[p]
63
+ : '';
64
+ }
65
+
66
+ return buildConflictReviewPacket({
67
+ plan: { changes: conflictChanges },
68
+ baseContents,
69
+ localContents,
70
+ remoteContents: normalizedRemoteContents,
71
+ timestamp,
72
+ });
73
+ }
74
+
29
75
  async function pullAtris() {
30
76
  let arg = process.argv[3];
31
77
 
32
78
  if (arg === '--help') {
33
- console.log('Usage: atris pull [business] [--into <path>] [--only <prefix>] [--keep-local] [--timeout <seconds>]');
79
+ console.log('Usage: atris pull [business] [--into <path>] [--only <prefix>] [--keep-local] [--timeout <seconds>] [--dry-run] [--no-manifest]');
34
80
  console.log('');
35
81
  console.log(' Pull is force-overwrite by default. Cloud is the source of truth.');
36
82
  console.log(' Local files that conflict with cloud are replaced by the cloud version.');
@@ -40,6 +86,8 @@ async function pullAtris() {
40
86
  console.log(' atris pull doordash --into /tmp/doordash');
41
87
  console.log(' atris pull doordash --only atris/wiki/');
42
88
  console.log(' atris pull --keep-local Preserve conflicting local edits as .remote files (legacy)');
89
+ console.log(' atris pull --dry-run Preview pull changes without writing local files');
90
+ console.log(' atris pull --no-manifest Pull for inspection without changing this machine\'s sync anchor');
43
91
  return;
44
92
  }
45
93
 
@@ -127,6 +175,9 @@ async function pullBusiness(slug) {
127
175
  // --keep-local opts back into the legacy three-way merge with .remote conflict files.
128
176
  // --force is still accepted as an alias for the default for muscle-memory.
129
177
  const force = !process.argv.includes('--keep-local');
178
+ const failOnConflict = process.argv.includes('--fail-on-conflict');
179
+ const dryRun = process.argv.includes('--dry-run');
180
+ const noManifest = process.argv.includes('--no-manifest');
130
181
 
131
182
  // Parse --only flag: comma-separated directory prefixes to filter
132
183
  // Supports both --only=team/,context/ and --only team/,context/
@@ -140,7 +191,7 @@ async function pullBusiness(slug) {
140
191
  onlyRaw = process.argv[onlyIdx + 1];
141
192
  }
142
193
  }
143
- const onlyPrefixes = onlyRaw
194
+ let onlyPrefixes = onlyRaw
144
195
  ? onlyRaw.split(',').map(p => {
145
196
  let norm = p.replace(/^\//, '');
146
197
  const wikiPrefix = normalizeWikiOnlyPrefix(norm);
@@ -202,6 +253,10 @@ async function pullBusiness(slug) {
202
253
  // for a stray cwd to cause atris to delete user files.
203
254
  ({ dir: outputDir } = resolveSafeOutputDir(outputDir, { slug, op: 'pull into' }));
204
255
 
256
+ if (!onlyPrefixes && isBusinessWorkspaceRoot(outputDir)) {
257
+ onlyPrefixes = ['atris/'];
258
+ }
259
+
205
260
  // Resolve business ID — always refresh from API to avoid stale workspace_id
206
261
  let businessId, workspaceId, businessName, resolvedSlug;
207
262
  let localSlug = slug;
@@ -283,6 +338,14 @@ async function pullBusiness(slug) {
283
338
  // Load manifest (last sync state)
284
339
  const manifest = loadManifest(resolvedSlug || slug);
285
340
  const timeSince = manifest ? _timeSince(manifest.last_sync) : null;
341
+ const localFilesBeforePull = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
342
+ const manifestRootMatchesOutput = !manifest || !manifest.workspace_root || (() => {
343
+ try {
344
+ return fs.realpathSync(manifest.workspace_root) === fs.realpathSync(outputDir);
345
+ } catch {
346
+ return path.resolve(manifest.workspace_root || '') === path.resolve(outputDir);
347
+ }
348
+ })();
286
349
 
287
350
  console.log('');
288
351
  console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
@@ -297,7 +360,12 @@ async function pullBusiness(slug) {
297
360
  }, 250);
298
361
 
299
362
  // Smart pull: if we have a manifest (not first sync), fetch hashes first, then only changed content
300
- const hasManifest = manifest && manifest.files && Object.keys(manifest.files).length > 0 && !force;
363
+ const hasManifest = manifest
364
+ && manifest.files
365
+ && Object.keys(manifest.files).length > 0
366
+ && Object.keys(localFilesBeforePull).length > 0
367
+ && manifestRootMatchesOutput
368
+ && !force;
301
369
  let result;
302
370
 
303
371
  const pathsParam = onlyPrefixes ? `&paths=${encodeURIComponent(onlyPrefixes.map(p => p.replace(/\/$/, '')).join(','))}` : '';
@@ -419,6 +487,19 @@ async function pullBusiness(slug) {
419
487
  let files = result.data.files || [];
420
488
  if (files.length === 0) {
421
489
  console.log(' Workspace is empty.');
490
+ const inScopeLocalBeforePull = Object.keys(localFilesBeforePull).filter((p) => {
491
+ if (!onlyPrefixes) return true;
492
+ const rel = p.replace(/^\//, '');
493
+ return onlyPrefixes.some((pref) => rel.startsWith(pref));
494
+ }).length;
495
+ if (!force && inScopeLocalBeforePull > 0) {
496
+ console.error('');
497
+ console.error(' Pull stopped: cloud returned zero files while local has in-scope content.');
498
+ console.error(' This usually means the snapshot endpoint is unhealthy or still warming.');
499
+ console.error(' No local files or sync manifest were changed.');
500
+ await emit('status_unknown', { error_detail: 'empty snapshot with local in-scope content' });
501
+ process.exit(1);
502
+ }
422
503
  // Don't early-return in force mode: we still need to fall through to the
423
504
  // mirror sweep so a genuinely-emptied cloud can clear local files. The
424
505
  // sweep itself has a safety guard that refuses to wipe local content
@@ -483,7 +564,7 @@ async function pullBusiness(slug) {
483
564
  }
484
565
 
485
566
  // Compute local file hashes
486
- const localFiles = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
567
+ const localFiles = localFilesBeforePull;
487
568
 
488
569
  // If output dir is empty (fresh clone) or --force, treat as first sync — pull everything
489
570
  const effectiveManifest = (Object.keys(localFiles).length === 0 || force) ? null : manifest;
@@ -491,11 +572,38 @@ async function pullBusiness(slug) {
491
572
  // Three-way compare
492
573
  const diff = threeWayCompare(localFiles, remoteFiles, effectiveManifest);
493
574
 
575
+ if (dryRun) {
576
+ console.log('');
577
+ for (const p of [...diff.toPull, ...diff.newRemote]) {
578
+ const label = diff.newRemote.includes(p) ? 'new on computer' : 'updated on computer';
579
+ const icon = diff.newRemote.includes(p) ? '+' : '\u2193';
580
+ console.log(` ${icon} ${p.replace(/^\//, '')} ${label} (dry run)`);
581
+ }
582
+ for (const p of diff.conflicts) {
583
+ console.log(` ! ${p.replace(/^\//, '')} conflict (dry run)`);
584
+ }
585
+ for (const p of diff.deletedRemote) {
586
+ console.log(` - ${p.replace(/^\//, '')} deleted on computer (dry run)`);
587
+ }
588
+
589
+ const parts = [];
590
+ const pullCount = diff.toPull.length + diff.newRemote.length;
591
+ if (pullCount > 0) parts.push(`${pullCount} would be pulled`);
592
+ if (diff.deletedRemote.length > 0) parts.push(`${diff.deletedRemote.length} local file${diff.deletedRemote.length === 1 ? '' : 's'} would be removed`);
593
+ if (diff.unchanged.length > 0) parts.push(`${diff.unchanged.length} unchanged`);
594
+ if (diff.conflicts.length > 0) parts.push(`${diff.conflicts.length} conflict${diff.conflicts.length === 1 ? '' : 's'}`);
595
+ if (parts.length === 0) parts.push('no changes');
596
+ console.log(`\n ${parts.join(', ')}. (--dry-run, nothing written)\n`);
597
+ return;
598
+ }
599
+
494
600
  // Apply changes
495
601
  let pulled = 0;
496
602
  let deleted = 0;
497
603
  let conflictCount = 0;
498
604
  let unchangedCount = diff.unchanged.length;
605
+ const conflictChanges = [];
606
+ const conflictRemoteContents = {};
499
607
 
500
608
  console.log('');
501
609
 
@@ -526,6 +634,8 @@ async function pullBusiness(slug) {
526
634
  } else {
527
635
  // Save remote version alongside local
528
636
  const content = remoteContent[p];
637
+ conflictRemoteContents[p] = content || '';
638
+ conflictChanges.push({ path: p, status: 'conflict_updated', action: 'review' });
529
639
  if (content || content === '') {
530
640
  const localPath = path.join(outputDir, p.replace(/^\//, '') + '.remote');
531
641
  fs.mkdirSync(path.dirname(localPath), { recursive: true });
@@ -553,6 +663,8 @@ async function pullBusiness(slug) {
553
663
  deleted++;
554
664
  } else {
555
665
  console.log(` \u26A0 ${p.replace(/^\//, '')} deleted on computer, but you changed it locally`);
666
+ conflictRemoteContents[p] = '';
667
+ conflictChanges.push({ path: p, status: 'conflict_remote_deleted_local_updated', action: 'review' });
556
668
  conflictCount++;
557
669
  }
558
670
  }
@@ -640,6 +752,22 @@ async function pullBusiness(slug) {
640
752
  if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
641
753
  if (conflictCount > 0) parts.push(`${conflictCount} conflict${conflictCount > 1 ? 's' : ''}`);
642
754
  if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
755
+ if (failOnConflict && conflictCount > 0) {
756
+ const timestamp = syncTimestamp();
757
+ const packet = buildPullConflictReviewPacket(outputDir, conflictChanges, conflictRemoteContents, timestamp);
758
+ writeConflictReviewPacket(outputDir, packet);
759
+ console.log('');
760
+ console.log(` Sync paused: ${conflictCount} conflict${conflictCount === 1 ? '' : 's'} need review before publishing.`);
761
+ console.log(` Review packet: .atris/sync/conflicts/${timestamp}/summary.md`);
762
+ console.log(' Resolve the conflict, then run sync again.');
763
+ process.exit(2);
764
+ }
765
+ if (remoteFiles['atris/now.md'] || remoteFiles['/atris/now.md']) {
766
+ const nowLocal = path.join(outputDir, 'atris', 'now.md');
767
+ if (fs.existsSync(nowLocal)) {
768
+ console.log(' now: atris/now.md is current.');
769
+ }
770
+ }
643
771
 
644
772
  // Get current commit hash from remote (for manifest)
645
773
  let commitHash = null;
@@ -693,10 +821,16 @@ async function pullBusiness(slug) {
693
821
  }
694
822
  manifestFiles = merged;
695
823
  }
696
- const newManifest = buildManifest(manifestFiles, commitHash);
697
- saveManifest(resolvedSlug || slug, newManifest);
824
+ if (!noManifest) {
825
+ const newManifest = buildManifest(manifestFiles, commitHash, { workspaceRoot: outputDir });
826
+ saveManifest(resolvedSlug || slug, newManifest);
827
+ writeBaseContents(outputDir, remoteContent);
828
+ removeBaseContents(outputDir, diff.deletedRemote);
829
+ }
698
830
 
699
- // Save business config in the output dir so push/status work without args
831
+ // Save business config in the output dir so push/status work without args.
832
+ // Inspection pulls should not re-bind the global sync anchor, but the pulled
833
+ // folder still benefits from a local business marker for navigation.
700
834
  const atrisDir = path.join(outputDir, '.atris');
701
835
  fs.mkdirSync(atrisDir, { recursive: true });
702
836
  fs.writeFileSync(path.join(atrisDir, 'business.json'), JSON.stringify({
@@ -901,4 +1035,4 @@ async function pullMemberJournal(token, agentId, memberName, memberDir) {
901
1035
  return synced;
902
1036
  }
903
1037
 
904
- module.exports = { pullAtris };
1038
+ module.exports = { buildPullConflictReviewPacket, pullAtris };
package/commands/push.js CHANGED
@@ -9,6 +9,92 @@ const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
9
9
  const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
10
10
  const { assertSafeWorkspaceRoot } = require('../lib/workspace-safety');
11
11
 
12
+ function resolvePushSourceDir({ slug, argv = process.argv, cwd = process.cwd() }) {
13
+ const fromIdx = argv.indexOf('--from');
14
+ if (fromIdx !== -1 && argv[fromIdx + 1]) {
15
+ return path.resolve(cwd, argv[fromIdx + 1]);
16
+ }
17
+
18
+ if (fs.existsSync(path.join(cwd, '.atris', 'business.json'))) {
19
+ // A pulled business folder is the workspace root. Its cloud paths include
20
+ // the top-level `atris/` directory, so pushing from `<business>/atris`
21
+ // strips that prefix and makes a force push look like "delete atris/* and
22
+ // recreate everything at root". Keep the business folder itself as root.
23
+ return cwd;
24
+ }
25
+
26
+ const atrisDir = path.join(cwd, 'atris', slug);
27
+ const cwdDir = path.join(cwd, slug);
28
+ if (fs.existsSync(atrisDir)) return atrisDir;
29
+ if (fs.existsSync(cwdDir)) return cwdDir;
30
+
31
+ return null;
32
+ }
33
+
34
+ function canonicalWorkspaceRoot(dir) {
35
+ try {
36
+ return fs.realpathSync(dir);
37
+ } catch {
38
+ return path.resolve(dir);
39
+ }
40
+ }
41
+
42
+ function basenameOfManifestPath(filePath) {
43
+ const cleaned = String(filePath || '').replace(/\/+$/, '');
44
+ const idx = cleaned.lastIndexOf('/');
45
+ return idx === -1 ? cleaned : cleaned.slice(idx + 1);
46
+ }
47
+
48
+ function isBusinessWorkspaceRoot(dir) {
49
+ return fs.existsSync(path.join(dir, '.atris', 'business.json')) && fs.existsSync(path.join(dir, 'atris'));
50
+ }
51
+
52
+ function pathInScope(filePath, onlyPrefixes) {
53
+ if (!onlyPrefixes) return true;
54
+ return onlyPrefixes.some(prefix => filePath.startsWith(prefix));
55
+ }
56
+
57
+ function buildPushChangePlan({
58
+ localFiles = {},
59
+ baseFiles = {},
60
+ onlyPrefixes = null,
61
+ readFileContent,
62
+ } = {}) {
63
+ const filesToPush = [];
64
+ const deletedPaths = [];
65
+
66
+ for (const [filePath, fileInfo] of Object.entries(localFiles)) {
67
+ if (!pathInScope(filePath, onlyPrefixes)) continue;
68
+ const baseHash = baseFiles[filePath] ? baseFiles[filePath].hash : null;
69
+ if (!baseHash || fileInfo.hash !== baseHash) {
70
+ try {
71
+ const content = readFileContent ? readFileContent(filePath) : '';
72
+ filesToPush.push({ path: filePath, content });
73
+ } catch {
74
+ // File moved or became unreadable while planning; skip this cycle.
75
+ }
76
+ }
77
+ }
78
+
79
+ for (const filePath of Object.keys(baseFiles)) {
80
+ if (!pathInScope(filePath, onlyPrefixes)) continue;
81
+ if (basenameOfManifestPath(filePath).startsWith('.')) continue;
82
+ if (!localFiles[filePath]) {
83
+ deletedPaths.push(filePath);
84
+ }
85
+ }
86
+
87
+ const filteredLocalCount = Object.keys(localFiles).filter(filePath => pathInScope(filePath, onlyPrefixes)).length;
88
+ const unchangedCount = Math.max(0, filteredLocalCount - filesToPush.length);
89
+ return { filesToPush, deletedPaths, unchangedCount };
90
+ }
91
+
92
+ function shouldRetrySyncIndividually(result, filesToPush) {
93
+ if (!result || result.ok) return false;
94
+ if (!Array.isArray(filesToPush) || filesToPush.length <= 1) return false;
95
+ return result.status !== 403 && result.status !== 409;
96
+ }
97
+
12
98
  async function pushAtris() {
13
99
  const elapsedMs = startTimer();
14
100
  let slug = process.argv[3];
@@ -27,7 +113,7 @@ async function pushAtris() {
27
113
  }
28
114
 
29
115
  if (!slug || slug === '--help') {
30
- console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force]');
116
+ console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force] [--delete]');
31
117
  console.log('');
32
118
  console.log(' Push requires a fresh pull. If cloud has changed since your last pull,');
33
119
  console.log(' the push will be blocked until you run `atris pull`. Use --force to override.');
@@ -36,11 +122,14 @@ async function pushAtris() {
36
122
  console.log(' atris push pallet Push pallet/ or atris/pallet/');
37
123
  console.log(' atris push pallet --only team/nate Only push files in team/nate/');
38
124
  console.log(' atris push --force Bypass freshness check (force-push, may overwrite cloud changes)');
125
+ console.log(' atris push --delete Allow cloud deletes shown by --dry-run');
39
126
  process.exit(0);
40
127
  }
41
128
 
42
129
  const force = process.argv.includes('--force');
43
130
  const dryRun = process.argv.includes('--dry-run');
131
+ const allowDelete = process.argv.includes('--delete');
132
+ const allowCrossRootManifest = process.argv.includes('--allow-cross-root-manifest');
44
133
 
45
134
  // Parse --only
46
135
  let onlyRaw = null;
@@ -50,7 +139,7 @@ async function pushAtris() {
50
139
  const oi = process.argv.indexOf('--only');
51
140
  if (oi !== -1 && process.argv[oi + 1] && !process.argv[oi + 1].startsWith('-')) onlyRaw = process.argv[oi + 1];
52
141
  }
53
- const onlyPrefixes = onlyRaw
142
+ let onlyPrefixes = onlyRaw
54
143
  ? onlyRaw.split(',').map(p => {
55
144
  const wikiPrefix = normalizeWikiOnlyPrefix(p);
56
145
  if (wikiPrefix) return `/${wikiPrefix.replace(/^\//, '')}`;
@@ -64,22 +153,11 @@ async function pushAtris() {
64
153
  if (!creds || !creds.token) { console.error('Not logged in. Run: atris login'); process.exit(1); }
65
154
 
66
155
  // Determine source directory
67
- const fromIdx = process.argv.indexOf('--from');
68
- let sourceDir;
69
- if (fromIdx !== -1 && process.argv[fromIdx + 1]) {
70
- sourceDir = path.resolve(process.argv[fromIdx + 1]);
71
- } else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
72
- sourceDir = process.cwd();
73
- } else {
74
- const atrisDir = path.join(process.cwd(), 'atris', slug);
75
- const cwdDir = path.join(process.cwd(), slug);
76
- if (fs.existsSync(atrisDir)) sourceDir = atrisDir;
77
- else if (fs.existsSync(cwdDir)) sourceDir = cwdDir;
78
- else {
79
- console.error(`No local folder found for "${slug}".`);
80
- console.error('Run from inside a pulled folder, or: atris push pallet --from ./path');
81
- process.exit(1);
82
- }
156
+ const sourceDir = resolvePushSourceDir({ slug });
157
+ if (!sourceDir) {
158
+ console.error(`No local folder found for "${slug}".`);
159
+ console.error('Run from inside a pulled folder, or: atris push pallet --from ./path');
160
+ process.exit(1);
83
161
  }
84
162
 
85
163
  if (!fs.existsSync(sourceDir)) { console.error(`Source not found: ${sourceDir}`); process.exit(1); }
@@ -87,6 +165,10 @@ async function pushAtris() {
87
165
  // Refuse to walk/upload dangerous paths ($HOME, /, /Users, system dirs).
88
166
  assertSafeWorkspaceRoot(sourceDir, { slug, op: 'push from' });
89
167
 
168
+ if (!onlyPrefixes && isBusinessWorkspaceRoot(sourceDir)) {
169
+ onlyPrefixes = ['/atris/'];
170
+ }
171
+
90
172
  // Resolve business — always refresh from API
91
173
  let businessId, workspaceId, businessName, resolvedSlug;
92
174
  const businesses = loadBusinesses();
@@ -145,6 +227,24 @@ async function pushAtris() {
145
227
  const manifest = loadManifest(resolvedSlug || slug);
146
228
  const localFiles = computeLocalHashes(sourceDir);
147
229
 
230
+ if (manifest && manifest.workspace_root && !allowCrossRootManifest) {
231
+ const manifestRoot = canonicalWorkspaceRoot(manifest.workspace_root);
232
+ const currentRoot = canonicalWorkspaceRoot(sourceDir);
233
+ if (manifestRoot !== currentRoot) {
234
+ console.log('');
235
+ console.log(' ✗ This folder has not been pulled since the current sync manifest was created.');
236
+ console.log('');
237
+ console.log(` Manifest folder: ${manifest.workspace_root}`);
238
+ console.log(` Current folder: ${sourceDir}`);
239
+ console.log('');
240
+ console.log(' To sync safely, run these from the folder you want to push:');
241
+ console.log(` atris pull ${resolvedSlug || slug} --keep-local --timeout 120`);
242
+ console.log(` atris push ${resolvedSlug || slug} --dry-run`);
243
+ console.log(` atris push ${resolvedSlug || slug}`);
244
+ process.exit(1);
245
+ }
246
+ }
247
+
148
248
  if (Object.keys(localFiles).length === 0) {
149
249
  console.log(`\nNo files to push from ${sourceDir}`);
150
250
  return;
@@ -240,38 +340,16 @@ async function pushAtris() {
240
340
  // Compare local hashes to manifest — NO server call needed
241
341
  // Files where local hash differs from manifest = changed locally
242
342
  const baseFiles = (manifest && manifest.files) ? manifest.files : {};
243
- const filesToPush = [];
244
- const deletedPaths = [];
245
-
246
- for (const [filePath, fileInfo] of Object.entries(localFiles)) {
247
- if (onlyPrefixes && !onlyPrefixes.some(p => filePath.startsWith(p))) continue;
248
- const baseHash = baseFiles[filePath] ? baseFiles[filePath].hash : null;
249
- if (!baseHash || fileInfo.hash !== baseHash) {
250
- // Changed or new — push it
251
- const localPath = path.join(sourceDir, filePath.replace(/^\//, ''));
252
- try {
253
- const content = fs.readFileSync(localPath, 'utf8');
254
- filesToPush.push({ path: filePath, content });
255
- } catch {}
256
- }
257
- }
258
-
259
- for (const filePath of Object.keys(baseFiles)) {
260
- if (onlyPrefixes && !onlyPrefixes.some(p => filePath.startsWith(p))) continue;
261
- if (!localFiles[filePath]) {
262
- deletedPaths.push(filePath);
263
- }
264
- }
265
-
266
- const filteredLocalCount = Object.keys(localFiles).filter(filePath => {
267
- if (!onlyPrefixes) return true;
268
- return onlyPrefixes.some(prefix => filePath.startsWith(prefix));
269
- }).length;
270
- const unchangedCount = Math.max(0, filteredLocalCount - filesToPush.length);
343
+ const { filesToPush, deletedPaths, unchangedCount } = buildPushChangePlan({
344
+ localFiles,
345
+ baseFiles,
346
+ onlyPrefixes,
347
+ readFileContent: (filePath) => fs.readFileSync(path.join(sourceDir, filePath.replace(/^\//, '')), 'utf8'),
348
+ });
271
349
 
272
350
  if (filesToPush.length === 0 && deletedPaths.length === 0) {
273
351
  console.log('\n Already up to date.\n');
274
- await emit('success', { files_unchanged: filteredLocalCount });
352
+ await emit('success', { files_unchanged: unchangedCount });
275
353
  return;
276
354
  }
277
355
 
@@ -290,9 +368,28 @@ async function pushAtris() {
290
368
  if (deletedPaths.length > 0) parts.push(`${deletedPaths.length} would be deleted`);
291
369
  if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
292
370
  console.log(`\n ${parts.join(', ')}. (--dry-run, nothing sent)\n`);
371
+ if (deletedPaths.length > 0) {
372
+ console.log(' Deletes require an explicit real push with --delete.');
373
+ console.log(' If these deletes are unexpected, run `atris pull --keep-local` first.\n');
374
+ }
293
375
  return;
294
376
  }
295
377
 
378
+ if (deletedPaths.length > 0 && !allowDelete) {
379
+ console.log('');
380
+ console.log(` ✗ Refusing to delete ${deletedPaths.length} cloud file${deletedPaths.length === 1 ? '' : 's'} without --delete.`);
381
+ console.log('');
382
+ console.log(' Preview first:');
383
+ console.log(` atris push ${resolvedSlug || slug} --dry-run`);
384
+ console.log('');
385
+ console.log(' If the deletes are intentional:');
386
+ console.log(` atris push ${resolvedSlug || slug} --delete`);
387
+ console.log('');
388
+ console.log(' If the deletes are surprising, pull cloud truth first:');
389
+ console.log(` atris pull ${resolvedSlug || slug} --keep-local --timeout 120`);
390
+ process.exit(1);
391
+ }
392
+
296
393
  let pushed = 0;
297
394
  let deleted = 0;
298
395
  let skipped = [];
@@ -307,6 +404,7 @@ async function pushAtris() {
307
404
  let failedToLand = [];
308
405
  const landedPaths = new Set();
309
406
  let result = { ok: true };
407
+ let batchFailureDetail = null;
310
408
 
311
409
  // Server-canonical path format for the /sync endpoint: NO leading slash.
312
410
  // The warm runner's _safe_path rejects `/atris/...` with "Absolute path
@@ -319,6 +417,10 @@ async function pushAtris() {
319
417
  return s.startsWith('/') ? s : `/${s}`;
320
418
  };
321
419
  const wireFiles = (files) => files.map((f) => ({ path: toWirePath(f.path), content: f.content }));
420
+ const syncFiles = (files) => apiRequestJson(
421
+ `/business/${businessId}/workspaces/${workspaceId}/sync`,
422
+ { method: 'POST', token: creds.token, body: { files: wireFiles(files) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
423
+ );
322
424
 
323
425
  // Inspect per-file results from a /sync response. Treat "written" and
324
426
  // "unchanged" as success; everything else (including missing-from-results,
@@ -360,10 +462,7 @@ async function pushAtris() {
360
462
 
361
463
  if (filesToPush.length > 0) {
362
464
  // Push files to server (strip leading slash — server requires workspace-relative paths)
363
- result = await apiRequestJson(
364
- `/business/${businessId}/workspaces/${workspaceId}/sync`,
365
- { method: 'POST', token: creds.token, body: { files: wireFiles(filesToPush) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
366
- );
465
+ result = await syncFiles(filesToPush);
367
466
 
368
467
  if (!result.ok) {
369
468
  if (result.status === 403) {
@@ -378,10 +477,7 @@ async function pushAtris() {
378
477
  skipped = filesToPush.filter(f => !f.path.startsWith('/team/') && !f.path.startsWith('/journal/'));
379
478
 
380
479
  if (allowed.length > 0) {
381
- const retry = await apiRequestJson(
382
- `/business/${businessId}/workspaces/${workspaceId}/sync`,
383
- { method: 'POST', token: creds.token, body: { files: wireFiles(allowed) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
384
- );
480
+ const retry = await syncFiles(allowed);
385
481
  if (retry.ok) {
386
482
  recordSyncResults(allowed, retry);
387
483
  pushed = landedPaths.size;
@@ -399,6 +495,23 @@ async function pushAtris() {
399
495
  console.error('\n Computer is sleeping. Wake it first.');
400
496
  await emit('cold_wake', { error_detail: 'computer sleeping (409)' });
401
497
  process.exit(1);
498
+ } else if (shouldRetrySyncIndividually(result, filesToPush)) {
499
+ batchFailureDetail = `${result.status || 'unknown'}: ${result.errorMessage || result.error || 'batch sync failed'}`;
500
+ console.log('');
501
+ console.log(` Batch push failed (${result.errorMessage || result.error || result.status}). Retrying one file at a time...`);
502
+ for (const f of filesToPush) {
503
+ const single = await syncFiles([f]);
504
+ if (single.ok) {
505
+ recordSyncResults([f], single);
506
+ } else {
507
+ failedToLand.push({
508
+ path: f.path,
509
+ status: single.status || 'error',
510
+ error: single.errorMessage || single.error || '',
511
+ });
512
+ }
513
+ }
514
+ pushed = landedPaths.size;
402
515
  } else {
403
516
  console.error(`\n Push failed: ${result.errorMessage || result.error || result.status}`);
404
517
  await emit('status_unknown', { error_detail: `sync status ${result.status}` });
@@ -526,7 +639,7 @@ async function pushAtris() {
526
639
  for (const filePath of deletedConfirmed) {
527
640
  delete updatedFiles[filePath];
528
641
  }
529
- saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null));
642
+ saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null, { workspaceRoot: sourceDir }));
530
643
 
531
644
  // Telemetry — outcome reflects actual run quality, not just exit-code-zero.
532
645
  // Partial delete failures or rate-limit retries mean the run was NOT a clean win;
@@ -540,6 +653,9 @@ async function pushAtris() {
540
653
  } else if (deleteFailed.length > 0) {
541
654
  finalOutcome = 'status_unknown';
542
655
  finalDetail = `${deleteFailed.length} delete(s) failed (statuses: ${[...new Set(deleteFailed.map(f => f.status))].join(',')})`;
656
+ } else if (batchFailureDetail) {
657
+ finalOutcome = 'status_unknown';
658
+ finalDetail = `batch sync failed but individual retry landed all files (${batchFailureDetail})`;
543
659
  } else if (_rateLimitedDeletes > 0) {
544
660
  finalOutcome = 'rate_limited';
545
661
  finalDetail = `${_rateLimitedDeletes} delete(s) hit 429 (recovered)`;
@@ -558,4 +674,12 @@ async function pushAtris() {
558
674
  });
559
675
  }
560
676
 
561
- module.exports = { pushAtris };
677
+ module.exports = {
678
+ pushAtris,
679
+ buildPushChangePlan,
680
+ shouldRetrySyncIndividually,
681
+ resolvePushSourceDir,
682
+ canonicalWorkspaceRoot,
683
+ basenameOfManifestPath,
684
+ isBusinessWorkspaceRoot,
685
+ };