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/AGENTS.md +35 -4
- package/README.md +3 -3
- package/atris/atris.md +38 -13
- package/atris/features/company-brain-sync/build.md +140 -0
- package/atris/features/company-brain-sync/idea.md +52 -0
- package/atris/features/company-brain-sync/validate.md +250 -0
- package/atris/skills/imessage/SKILL.md +44 -0
- package/bin/atris.js +44 -4
- package/commands/align.js +1 -1
- package/commands/brain.js +840 -0
- package/commands/business-sync.js +716 -0
- package/commands/init.js +15 -3
- package/commands/integrations.js +136 -0
- package/commands/live.js +28 -6
- package/commands/now.js +263 -0
- package/commands/pull.js +142 -8
- package/commands/push.js +181 -57
- package/commands/task.js +1658 -18
- package/commands/wiki.js +40 -1
- package/lib/company-brain-sync.js +178 -0
- package/lib/manifest.js +2 -1
- package/lib/task-db.js +271 -4
- package/lib/todo-fallback.js +34 -9
- package/lib/wiki.js +84 -4
- package/package.json +12 -2
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
697
|
-
|
|
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
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 = {
|
|
677
|
+
module.exports = {
|
|
678
|
+
pushAtris,
|
|
679
|
+
buildPushChangePlan,
|
|
680
|
+
shouldRetrySyncIndividually,
|
|
681
|
+
resolvePushSourceDir,
|
|
682
|
+
canonicalWorkspaceRoot,
|
|
683
|
+
basenameOfManifestPath,
|
|
684
|
+
isBusinessWorkspaceRoot,
|
|
685
|
+
};
|