atris 3.14.0 → 3.15.11

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/push.js CHANGED
@@ -9,6 +9,86 @@ 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
+
12
92
  async function pushAtris() {
13
93
  const elapsedMs = startTimer();
14
94
  let slug = process.argv[3];
@@ -27,7 +107,7 @@ async function pushAtris() {
27
107
  }
28
108
 
29
109
  if (!slug || slug === '--help') {
30
- console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force]');
110
+ console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force] [--delete]');
31
111
  console.log('');
32
112
  console.log(' Push requires a fresh pull. If cloud has changed since your last pull,');
33
113
  console.log(' the push will be blocked until you run `atris pull`. Use --force to override.');
@@ -36,11 +116,14 @@ async function pushAtris() {
36
116
  console.log(' atris push pallet Push pallet/ or atris/pallet/');
37
117
  console.log(' atris push pallet --only team/nate Only push files in team/nate/');
38
118
  console.log(' atris push --force Bypass freshness check (force-push, may overwrite cloud changes)');
119
+ console.log(' atris push --delete Allow cloud deletes shown by --dry-run');
39
120
  process.exit(0);
40
121
  }
41
122
 
42
123
  const force = process.argv.includes('--force');
43
124
  const dryRun = process.argv.includes('--dry-run');
125
+ const allowDelete = process.argv.includes('--delete');
126
+ const allowCrossRootManifest = process.argv.includes('--allow-cross-root-manifest');
44
127
 
45
128
  // Parse --only
46
129
  let onlyRaw = null;
@@ -50,7 +133,7 @@ async function pushAtris() {
50
133
  const oi = process.argv.indexOf('--only');
51
134
  if (oi !== -1 && process.argv[oi + 1] && !process.argv[oi + 1].startsWith('-')) onlyRaw = process.argv[oi + 1];
52
135
  }
53
- const onlyPrefixes = onlyRaw
136
+ let onlyPrefixes = onlyRaw
54
137
  ? onlyRaw.split(',').map(p => {
55
138
  const wikiPrefix = normalizeWikiOnlyPrefix(p);
56
139
  if (wikiPrefix) return `/${wikiPrefix.replace(/^\//, '')}`;
@@ -64,22 +147,11 @@ async function pushAtris() {
64
147
  if (!creds || !creds.token) { console.error('Not logged in. Run: atris login'); process.exit(1); }
65
148
 
66
149
  // 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
- }
150
+ const sourceDir = resolvePushSourceDir({ slug });
151
+ if (!sourceDir) {
152
+ console.error(`No local folder found for "${slug}".`);
153
+ console.error('Run from inside a pulled folder, or: atris push pallet --from ./path');
154
+ process.exit(1);
83
155
  }
84
156
 
85
157
  if (!fs.existsSync(sourceDir)) { console.error(`Source not found: ${sourceDir}`); process.exit(1); }
@@ -87,6 +159,10 @@ async function pushAtris() {
87
159
  // Refuse to walk/upload dangerous paths ($HOME, /, /Users, system dirs).
88
160
  assertSafeWorkspaceRoot(sourceDir, { slug, op: 'push from' });
89
161
 
162
+ if (!onlyPrefixes && isBusinessWorkspaceRoot(sourceDir)) {
163
+ onlyPrefixes = ['/atris/'];
164
+ }
165
+
90
166
  // Resolve business — always refresh from API
91
167
  let businessId, workspaceId, businessName, resolvedSlug;
92
168
  const businesses = loadBusinesses();
@@ -145,6 +221,24 @@ async function pushAtris() {
145
221
  const manifest = loadManifest(resolvedSlug || slug);
146
222
  const localFiles = computeLocalHashes(sourceDir);
147
223
 
224
+ if (manifest && manifest.workspace_root && !allowCrossRootManifest) {
225
+ const manifestRoot = canonicalWorkspaceRoot(manifest.workspace_root);
226
+ const currentRoot = canonicalWorkspaceRoot(sourceDir);
227
+ if (manifestRoot !== currentRoot) {
228
+ console.log('');
229
+ console.log(' ✗ This folder has not been pulled since the current sync manifest was created.');
230
+ console.log('');
231
+ console.log(` Manifest folder: ${manifest.workspace_root}`);
232
+ console.log(` Current folder: ${sourceDir}`);
233
+ console.log('');
234
+ console.log(' To sync safely, run these from the folder you want to push:');
235
+ console.log(` atris pull ${resolvedSlug || slug} --keep-local --timeout 120`);
236
+ console.log(` atris push ${resolvedSlug || slug} --dry-run`);
237
+ console.log(` atris push ${resolvedSlug || slug}`);
238
+ process.exit(1);
239
+ }
240
+ }
241
+
148
242
  if (Object.keys(localFiles).length === 0) {
149
243
  console.log(`\nNo files to push from ${sourceDir}`);
150
244
  return;
@@ -240,34 +334,12 @@ async function pushAtris() {
240
334
  // Compare local hashes to manifest — NO server call needed
241
335
  // Files where local hash differs from manifest = changed locally
242
336
  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);
337
+ const { filesToPush, deletedPaths, unchangedCount } = buildPushChangePlan({
338
+ localFiles,
339
+ baseFiles,
340
+ onlyPrefixes,
341
+ readFileContent: (filePath) => fs.readFileSync(path.join(sourceDir, filePath.replace(/^\//, '')), 'utf8'),
342
+ });
271
343
 
272
344
  if (filesToPush.length === 0 && deletedPaths.length === 0) {
273
345
  console.log('\n Already up to date.\n');
@@ -290,9 +362,28 @@ async function pushAtris() {
290
362
  if (deletedPaths.length > 0) parts.push(`${deletedPaths.length} would be deleted`);
291
363
  if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
292
364
  console.log(`\n ${parts.join(', ')}. (--dry-run, nothing sent)\n`);
365
+ if (deletedPaths.length > 0) {
366
+ console.log(' Deletes require an explicit real push with --delete.');
367
+ console.log(' If these deletes are unexpected, run `atris pull --keep-local` first.\n');
368
+ }
293
369
  return;
294
370
  }
295
371
 
372
+ if (deletedPaths.length > 0 && !allowDelete) {
373
+ console.log('');
374
+ console.log(` ✗ Refusing to delete ${deletedPaths.length} cloud file${deletedPaths.length === 1 ? '' : 's'} without --delete.`);
375
+ console.log('');
376
+ console.log(' Preview first:');
377
+ console.log(` atris push ${resolvedSlug || slug} --dry-run`);
378
+ console.log('');
379
+ console.log(' If the deletes are intentional:');
380
+ console.log(` atris push ${resolvedSlug || slug} --delete`);
381
+ console.log('');
382
+ console.log(' If the deletes are surprising, pull cloud truth first:');
383
+ console.log(` atris pull ${resolvedSlug || slug} --keep-local --timeout 120`);
384
+ process.exit(1);
385
+ }
386
+
296
387
  let pushed = 0;
297
388
  let deleted = 0;
298
389
  let skipped = [];
@@ -526,7 +617,7 @@ async function pushAtris() {
526
617
  for (const filePath of deletedConfirmed) {
527
618
  delete updatedFiles[filePath];
528
619
  }
529
- saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null));
620
+ saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null, { workspaceRoot: sourceDir }));
530
621
 
531
622
  // Telemetry — outcome reflects actual run quality, not just exit-code-zero.
532
623
  // Partial delete failures or rate-limit retries mean the run was NOT a clean win;
@@ -558,4 +649,11 @@ async function pushAtris() {
558
649
  });
559
650
  }
560
651
 
561
- module.exports = { pushAtris };
652
+ module.exports = {
653
+ pushAtris,
654
+ buildPushChangePlan,
655
+ resolvePushSourceDir,
656
+ canonicalWorkspaceRoot,
657
+ basenameOfManifestPath,
658
+ isBusinessWorkspaceRoot,
659
+ };