atris 3.5.0 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/atris.js +16 -7
- package/commands/align.js +1 -0
- package/commands/autopilot.js +473 -16
- package/commands/brainstorm.js +7 -5
- package/commands/clean.js +19 -3
- package/commands/computer.js +43 -0
- package/commands/integrations.js +14 -9
- package/commands/pull.js +46 -10
- package/commands/push.js +110 -10
- package/commands/workflow.js +24 -9
- package/lib/manifest.js +3 -0
- package/lib/workspace-safety.js +87 -0
- package/package.json +1 -1
package/commands/brainstorm.js
CHANGED
|
@@ -3,7 +3,7 @@ const path = require('path');
|
|
|
3
3
|
const readline = require('readline');
|
|
4
4
|
const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/journal');
|
|
5
5
|
const { loadConfig } = require('../utils/config');
|
|
6
|
-
const { loadCredentials } = require('../utils/auth');
|
|
6
|
+
const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
|
|
7
7
|
const { apiRequestJson } = require('../utils/api');
|
|
8
8
|
const { planAtris, doAtris, reviewAtris } = require('./workflow');
|
|
9
9
|
|
|
@@ -58,8 +58,9 @@ async function brainstormAtris() {
|
|
|
58
58
|
// Optional: fetch journal context from backend (for hints only)
|
|
59
59
|
let remoteJournalContext = '';
|
|
60
60
|
const config = loadConfig();
|
|
61
|
-
const
|
|
62
|
-
|
|
61
|
+
const ensured1 = await ensureValidCredentials(apiRequestJson);
|
|
62
|
+
const credentials = ensured1.error ? null : ensured1.credentials;
|
|
63
|
+
|
|
63
64
|
if (useCloudJournal && config.agent_id && credentials && credentials.token) {
|
|
64
65
|
try {
|
|
65
66
|
console.log('📖 Fetching latest journal entry from AtrisOS...');
|
|
@@ -623,8 +624,9 @@ async function autopilotAtris(initialIdea = null) {
|
|
|
623
624
|
// Try to fetch latest journal entry from backend
|
|
624
625
|
let journalContext = '';
|
|
625
626
|
const config = loadConfig();
|
|
626
|
-
const
|
|
627
|
-
|
|
627
|
+
const ensured2 = await ensureValidCredentials(apiRequestJson);
|
|
628
|
+
const credentials = ensured2.error ? null : ensured2.credentials;
|
|
629
|
+
|
|
628
630
|
if (config.agent_id && credentials && credentials.token) {
|
|
629
631
|
try {
|
|
630
632
|
const journalResult = await apiRequestJson(`/agents/${config.agent_id}/journal/today`, {
|
package/commands/clean.js
CHANGED
|
@@ -197,6 +197,15 @@ function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
|
|
|
197
197
|
// Required delimiter [(,[,—,-] prevents bleeding into adjacent refs on same line
|
|
198
198
|
const refPattern = /(`?)([a-zA-Z0-9_\-./\\]+\.(js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml)):(\d+)(?:-(\d+))?(`?)([^\S\n]*[\(\[—\-][^\S\n]*([^)\]\n]+))?/g;
|
|
199
199
|
|
|
200
|
+
// Function Inventory form: `symbolName()` → `file:line[-line]`
|
|
201
|
+
// Pre-scan to build a (file:line) → symbol map so refs with the symbol BEFORE them still verify.
|
|
202
|
+
const preRefSymbols = {};
|
|
203
|
+
const preRefPattern = /`([a-zA-Z_][a-zA-Z0-9_]*)\s*\(?\s*\)?`\s*(?:→|->)\s*`([a-zA-Z0-9_\-./\\]+\.(?:js|ts|py|go|rs|rb|java|c|cpp|h|hpp|md|json|yaml|yml)):(\d+)(?:-\d+)?`/g;
|
|
204
|
+
let preMatch;
|
|
205
|
+
while ((preMatch = preRefPattern.exec(mapContent)) !== null) {
|
|
206
|
+
preRefSymbols[`${preMatch[2]}:${preMatch[3]}`] = preMatch[1];
|
|
207
|
+
}
|
|
208
|
+
|
|
200
209
|
// Cache file reads
|
|
201
210
|
const fileCache = {};
|
|
202
211
|
function readFileCached(filePath) {
|
|
@@ -228,7 +237,8 @@ function healBrokenMapRefs(cwd, atrisDir, dryRun = false) {
|
|
|
228
237
|
continue;
|
|
229
238
|
}
|
|
230
239
|
|
|
231
|
-
|
|
240
|
+
let symbol = extractSymbol(contextPart);
|
|
241
|
+
if (!symbol) symbol = preRefSymbols[`${filePath}:${startLine}`] || null;
|
|
232
242
|
|
|
233
243
|
// Check if reference is still accurate
|
|
234
244
|
const outOfBounds = startLine > file.lines.length || (endLine && endLine > file.lines.length);
|
|
@@ -445,9 +455,15 @@ function checkPageStaleness(cwd, filePath) {
|
|
|
445
455
|
.map(line => line.replace(/^\s+-\s+/, '').trim())
|
|
446
456
|
.filter(Boolean);
|
|
447
457
|
|
|
448
|
-
// Check each source's mtime against last_compiled
|
|
458
|
+
// Check each source's mtime against last_compiled.
|
|
459
|
+
// Source entries can include a line range and/or a parenthesized annotation,
|
|
460
|
+
// e.g. "bin/atris.js:199-340 (showHelp function)" — strip both before stat.
|
|
449
461
|
for (const source of sources) {
|
|
450
|
-
const
|
|
462
|
+
const filePart = source
|
|
463
|
+
.replace(/\s*\([^)]*\)\s*$/, '') // drop trailing "(annotation)"
|
|
464
|
+
.replace(/:\d+(-\d+)?$/, '') // drop trailing ":N" or ":N-M"
|
|
465
|
+
.trim();
|
|
466
|
+
const sourcePath = path.isAbsolute(filePart) ? filePart : path.join(cwd, filePart);
|
|
451
467
|
try {
|
|
452
468
|
const stat = fs.statSync(sourcePath);
|
|
453
469
|
if (stat.mtime > compiledDate) {
|
package/commands/computer.js
CHANGED
|
@@ -693,6 +693,36 @@ async function resolveBusinessContext(token) {
|
|
|
693
693
|
return null;
|
|
694
694
|
}
|
|
695
695
|
|
|
696
|
+
async function resolveBusinessContextBySlug(token, slug) {
|
|
697
|
+
if (!slug) return null;
|
|
698
|
+
|
|
699
|
+
const businesses = loadBusinesses();
|
|
700
|
+
const list = await apiRequestJson('/business/', { method: 'GET', token });
|
|
701
|
+
if (list.ok) {
|
|
702
|
+
const match = (list.data || []).find(
|
|
703
|
+
(b) => b.slug === slug || (b.name || '').toLowerCase() === slug.toLowerCase()
|
|
704
|
+
);
|
|
705
|
+
if (match) {
|
|
706
|
+
businesses[match.slug || slug] = {
|
|
707
|
+
business_id: match.id,
|
|
708
|
+
workspace_id: match.workspace_id,
|
|
709
|
+
name: match.name,
|
|
710
|
+
slug: match.slug,
|
|
711
|
+
added_at: new Date().toISOString(),
|
|
712
|
+
};
|
|
713
|
+
saveBusinesses(businesses);
|
|
714
|
+
return {
|
|
715
|
+
slug: match.slug,
|
|
716
|
+
businessId: match.id,
|
|
717
|
+
workspaceId: match.workspace_id,
|
|
718
|
+
businessName: match.name || match.slug,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
|
|
696
726
|
function shellQuote(value) {
|
|
697
727
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
698
728
|
}
|
|
@@ -2288,6 +2318,7 @@ async function runComputer() {
|
|
|
2288
2318
|
console.log(' local-byo Open LOCAL BYO Claude mode; Anthropic tokens, no cloud audit');
|
|
2289
2319
|
console.log(' --cloud Open CLOUD workspace mode in the bound business workspace');
|
|
2290
2320
|
console.log(' cloud Open CLOUD workspace mode in the bound business workspace');
|
|
2321
|
+
console.log(' codeops Open Atris CodeOps cloud workspace if your account has access');
|
|
2291
2322
|
console.log(' --worker Cloud worker override: claude | openai');
|
|
2292
2323
|
console.log(' --model Cloud model override');
|
|
2293
2324
|
console.log(' claude|codex Legacy local console backends');
|
|
@@ -2319,6 +2350,7 @@ async function runComputer() {
|
|
|
2319
2350
|
console.log(' atris computer --cloud');
|
|
2320
2351
|
console.log(' atris computer --cloud --worker openai --model gpt-5.4');
|
|
2321
2352
|
console.log(' atris computer cloud');
|
|
2353
|
+
console.log(' atris computer codeops');
|
|
2322
2354
|
console.log(' atris computer status');
|
|
2323
2355
|
console.log(' atris computer wake');
|
|
2324
2356
|
console.log(' atris computer run "ls -la /workspace"');
|
|
@@ -2331,6 +2363,17 @@ async function runComputer() {
|
|
|
2331
2363
|
const token = getToken();
|
|
2332
2364
|
const ctx = await resolveBusinessContext(token);
|
|
2333
2365
|
|
|
2366
|
+
if (sub === 'codeops') {
|
|
2367
|
+
const codeopsCtx = await resolveBusinessContextBySlug(token, 'atris-codeops');
|
|
2368
|
+
if (!codeopsCtx) {
|
|
2369
|
+
console.error('Atris CodeOps is not available for this account.');
|
|
2370
|
+
console.error('Ask an Atris CodeOps admin to add you to the atris-codeops business.');
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
await computerChat(token, codeopsCtx, { worker: 'claude', ...cloudOptions });
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2334
2377
|
if (sub === '--cloud' || sub === 'cloud') {
|
|
2335
2378
|
await computerChat(token, ctx, cloudOptions);
|
|
2336
2379
|
return;
|
package/commands/integrations.js
CHANGED
|
@@ -10,20 +10,25 @@
|
|
|
10
10
|
* atris slack channels - List Slack channels
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
const { loadCredentials } = require('../utils/auth');
|
|
13
|
+
const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
|
|
14
14
|
const { apiRequestJson } = require('../utils/api');
|
|
15
15
|
|
|
16
|
-
function getAuth() {
|
|
17
|
-
const
|
|
16
|
+
async function getAuth() {
|
|
17
|
+
const ensured = await ensureValidCredentials(apiRequestJson);
|
|
18
|
+
const creds = ensured.error ? null : ensured.credentials;
|
|
18
19
|
if (!creds || !creds.token) {
|
|
19
|
-
|
|
20
|
+
if (ensured.error && ensured.error !== 'not_logged_in') {
|
|
21
|
+
console.error(`Authentication failed: ${ensured.detail || ensured.error}. Run: atris login`);
|
|
22
|
+
} else {
|
|
23
|
+
console.error('Not logged in. Run: atris login');
|
|
24
|
+
}
|
|
20
25
|
process.exit(1);
|
|
21
26
|
}
|
|
22
27
|
return { token: creds.token, email: creds.email || 'unknown' };
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
async function getAuthToken() {
|
|
26
|
-
return getAuth().token;
|
|
31
|
+
return (await getAuth()).token;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
// ============================================================================
|
|
@@ -31,7 +36,7 @@ async function getAuthToken() {
|
|
|
31
36
|
// ============================================================================
|
|
32
37
|
|
|
33
38
|
async function gmailInbox(options = {}) {
|
|
34
|
-
const { token, email } = getAuth();
|
|
39
|
+
const { token, email } = await getAuth();
|
|
35
40
|
const limit = options.limit || 10;
|
|
36
41
|
|
|
37
42
|
console.log('📬 Fetching inbox...\n');
|
|
@@ -129,7 +134,7 @@ async function gmailCommand(subcommand, ...args) {
|
|
|
129
134
|
// ============================================================================
|
|
130
135
|
|
|
131
136
|
async function calendarToday() {
|
|
132
|
-
const { token, email } = getAuth();
|
|
137
|
+
const { token, email } = await getAuth();
|
|
133
138
|
|
|
134
139
|
console.log('📅 Today\'s events:\n');
|
|
135
140
|
|
|
@@ -224,7 +229,7 @@ async function twitterPost(text) {
|
|
|
224
229
|
|
|
225
230
|
if (!result.ok) {
|
|
226
231
|
if (result.status === 400 || result.status === 401) {
|
|
227
|
-
const { email } = getAuth();
|
|
232
|
+
const { email } = await getAuth();
|
|
228
233
|
console.error(`Twitter not connected for ${email}.`);
|
|
229
234
|
console.error('Connect at: https://atris.ai/dashboard/settings');
|
|
230
235
|
console.error(`Make sure you're signed in as ${email} on the web.`);
|
|
@@ -268,7 +273,7 @@ async function slackChannels() {
|
|
|
268
273
|
|
|
269
274
|
if (!result.ok) {
|
|
270
275
|
if (result.status === 400 || result.status === 401) {
|
|
271
|
-
const { email } = getAuth();
|
|
276
|
+
const { email } = await getAuth();
|
|
272
277
|
console.error(`Slack not connected for ${email}.`);
|
|
273
278
|
console.error('Connect at: https://atris.ai/dashboard/settings');
|
|
274
279
|
console.error(`Make sure you're signed in as ${email} on the web.`);
|
package/commands/pull.js
CHANGED
|
@@ -10,6 +10,7 @@ const { loadBusinesses } = require('./business');
|
|
|
10
10
|
const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
|
|
11
11
|
const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
|
|
12
12
|
const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
|
|
13
|
+
const { resolveSafeOutputDir } = require('../lib/workspace-safety');
|
|
13
14
|
|
|
14
15
|
function pruneEmptyParentDirs(filePath, stopDir) {
|
|
15
16
|
let current = path.dirname(filePath);
|
|
@@ -163,22 +164,44 @@ async function pullBusiness(slug) {
|
|
|
163
164
|
}
|
|
164
165
|
const timeoutMs = timeoutSec * 1000;
|
|
165
166
|
|
|
166
|
-
// Determine output directory
|
|
167
|
+
// Determine output directory.
|
|
168
|
+
//
|
|
169
|
+
// We only reuse the current working directory when we can prove it's the
|
|
170
|
+
// correct workspace for THIS business — i.e. it has a `.atris/business.json`
|
|
171
|
+
// whose slug matches `slug`. Any other signal (a stray `atris/` folder, a
|
|
172
|
+
// business.json for a different business, etc.) is NOT enough: pulling
|
|
173
|
+
// atris-labs-1 on top of a pallet workspace would mix two businesses into
|
|
174
|
+
// one directory and write pallet's manifest over atris-labs-1's (or vice
|
|
175
|
+
// versa), causing the next sync to do strange things.
|
|
176
|
+
//
|
|
177
|
+
// Fallback: create a fresh ./{slug}/ subdir. Always safe — even if cwd is
|
|
178
|
+
// $HOME or /tmp, we land in a dedicated subfolder.
|
|
167
179
|
const intoIdx = process.argv.indexOf('--into');
|
|
168
180
|
let outputDir;
|
|
169
181
|
if (intoIdx !== -1 && process.argv[intoIdx + 1]) {
|
|
170
182
|
outputDir = path.resolve(process.argv[intoIdx + 1]);
|
|
171
|
-
} else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
|
|
172
|
-
// Inside a pulled workspace — pull into current dir (no nesting)
|
|
173
|
-
outputDir = process.cwd();
|
|
174
|
-
} else if (fs.existsSync(path.join(process.cwd(), 'atris')) && fs.statSync(path.join(process.cwd(), 'atris')).isDirectory()) {
|
|
175
|
-
// Inside an atris init'd workspace — merge business into current dir
|
|
176
|
-
outputDir = process.cwd();
|
|
177
183
|
} else {
|
|
178
|
-
|
|
179
|
-
|
|
184
|
+
const bizFile = path.join(process.cwd(), '.atris', 'business.json');
|
|
185
|
+
let cwdMatchesSlug = false;
|
|
186
|
+
if (fs.existsSync(bizFile)) {
|
|
187
|
+
try {
|
|
188
|
+
const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
|
|
189
|
+
const cwdSlug = biz.slug || biz.name;
|
|
190
|
+
cwdMatchesSlug = cwdSlug === slug;
|
|
191
|
+
} catch {
|
|
192
|
+
// Corrupt business.json — ignore, treat as no match.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
outputDir = cwdMatchesSlug ? process.cwd() : path.join(process.cwd(), slug);
|
|
180
196
|
}
|
|
181
197
|
|
|
198
|
+
// Auto-relocate if the resolved outputDir is dangerous ($HOME, /, /Users,
|
|
199
|
+
// system dirs, reserved home folders). Non-technical users shouldn't have
|
|
200
|
+
// to know about mkdir/cd — atris picks a safe subdir and tells them.
|
|
201
|
+
// Paired with the manifest-scoped sweep below, this makes it impossible
|
|
202
|
+
// for a stray cwd to cause atris to delete user files.
|
|
203
|
+
({ dir: outputDir } = resolveSafeOutputDir(outputDir, { slug, op: 'pull into' }));
|
|
204
|
+
|
|
182
205
|
// Resolve business ID — always refresh from API to avoid stale workspace_id
|
|
183
206
|
let businessId, workspaceId, businessName, resolvedSlug;
|
|
184
207
|
const businesses = loadBusinesses();
|
|
@@ -564,14 +587,27 @@ async function pullBusiness(slug) {
|
|
|
564
587
|
}
|
|
565
588
|
if (force) {
|
|
566
589
|
const remotePathSet = new Set(Object.keys(remoteFiles));
|
|
590
|
+
|
|
591
|
+
// SAFETY: only sweep files atris previously wrote (recorded in the
|
|
592
|
+
// manifest). Local-only files that were never on cloud are the user's
|
|
593
|
+
// own — atris didn't put them there, atris doesn't delete them. This
|
|
594
|
+
// makes it impossible for a stray cwd (e.g. $HOME) to cause atris to
|
|
595
|
+
// wipe ~/Library, ~/Downloads, etc.
|
|
596
|
+
//
|
|
597
|
+
// If there's no prior manifest (first pull), there's nothing to sweep —
|
|
598
|
+
// threeWayCompare already handled newRemote/conflicts/newLocal, and we
|
|
599
|
+
// have no basis for claiming ownership of any local-only file.
|
|
600
|
+
const managedPaths = manifest && manifest.files ? Object.keys(manifest.files) : [];
|
|
601
|
+
const sweepCandidates = managedPaths.filter(isInScope).filter((p) => localFiles[p]);
|
|
567
602
|
const inScopeLocal = Object.keys(localFiles).filter(isInScope);
|
|
603
|
+
|
|
568
604
|
if (remotePathSet.size === 0 && inScopeLocal.length > 0) {
|
|
569
605
|
console.log('');
|
|
570
606
|
console.log(' ⚠ Cloud reported zero files but local has in-scope content. Refusing to sweep.');
|
|
571
607
|
console.log(' This usually means the snapshot endpoint glitched. Try again,');
|
|
572
608
|
console.log(' or run `atris align --hard` if you really want to nuke local.');
|
|
573
609
|
} else {
|
|
574
|
-
for (const p of
|
|
610
|
+
for (const p of sweepCandidates) {
|
|
575
611
|
if (remotePathSet.has(p)) continue;
|
|
576
612
|
if (SERVER_HIDDEN_BASENAMES.has(basename(p))) continue;
|
|
577
613
|
const localPath = path.join(outputDir, p.replace(/^\//, ''));
|
package/commands/push.js
CHANGED
|
@@ -7,6 +7,7 @@ const { loadBusinesses, saveBusinesses } = require('./business');
|
|
|
7
7
|
const { loadManifest, saveManifest, buildManifest, computeLocalHashes } = require('../lib/manifest');
|
|
8
8
|
const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
|
|
9
9
|
const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
|
|
10
|
+
const { assertSafeWorkspaceRoot } = require('../lib/workspace-safety');
|
|
10
11
|
|
|
11
12
|
async function pushAtris() {
|
|
12
13
|
const elapsedMs = startTimer();
|
|
@@ -83,6 +84,9 @@ async function pushAtris() {
|
|
|
83
84
|
|
|
84
85
|
if (!fs.existsSync(sourceDir)) { console.error(`Source not found: ${sourceDir}`); process.exit(1); }
|
|
85
86
|
|
|
87
|
+
// Refuse to walk/upload dangerous paths ($HOME, /, /Users, system dirs).
|
|
88
|
+
assertSafeWorkspaceRoot(sourceDir, { slug, op: 'push from' });
|
|
89
|
+
|
|
86
90
|
// Resolve business — always refresh from API
|
|
87
91
|
let businessId, workspaceId, businessName, resolvedSlug;
|
|
88
92
|
const businesses = loadBusinesses();
|
|
@@ -292,13 +296,73 @@ async function pushAtris() {
|
|
|
292
296
|
let pushed = 0;
|
|
293
297
|
let deleted = 0;
|
|
294
298
|
let skipped = [];
|
|
299
|
+
// Files we sent that the server did not confirm as written/unchanged.
|
|
300
|
+
// The /sync endpoint can silently drop files (role-based filters on the
|
|
301
|
+
// business workspace route, path-rejection inside the warm runner, etc.)
|
|
302
|
+
// and still return HTTP 200. If we don't cross-check per-file results,
|
|
303
|
+
// the CLI prints "Pushed" for files that never actually landed — and
|
|
304
|
+
// the manifest records a hash that makes the next push skip them too,
|
|
305
|
+
// losing them permanently. `failedToLand` collects those casualties so
|
|
306
|
+
// we can warn the user AND keep them out of the manifest update.
|
|
307
|
+
let failedToLand = [];
|
|
308
|
+
const landedPaths = new Set();
|
|
295
309
|
let result = { ok: true };
|
|
296
310
|
|
|
311
|
+
// Server-canonical path format for the /sync endpoint: NO leading slash.
|
|
312
|
+
// The warm runner's _safe_path rejects `/atris/...` with "Absolute path
|
|
313
|
+
// outside workspace" (it only accepts paths under `/workspace/...`). All
|
|
314
|
+
// our internal bookkeeping uses a leading slash (manifest keys, localFiles
|
|
315
|
+
// keys, snapshot response paths), so we strip only at the wire.
|
|
316
|
+
const toWirePath = (p) => (p || '').replace(/^\/+/, '');
|
|
317
|
+
const fromWirePath = (p) => {
|
|
318
|
+
const s = String(p || '');
|
|
319
|
+
return s.startsWith('/') ? s : `/${s}`;
|
|
320
|
+
};
|
|
321
|
+
const wireFiles = (files) => files.map((f) => ({ path: toWirePath(f.path), content: f.content }));
|
|
322
|
+
|
|
323
|
+
// Inspect per-file results from a /sync response. Treat "written" and
|
|
324
|
+
// "unchanged" as success; everything else (including missing-from-results,
|
|
325
|
+
// which is how silent server-side drops look) is a failure.
|
|
326
|
+
const recordSyncResults = (sentFiles, response) => {
|
|
327
|
+
const resultsArr = response && response.data && Array.isArray(response.data.results)
|
|
328
|
+
? response.data.results
|
|
329
|
+
: null;
|
|
330
|
+
const seen = new Set();
|
|
331
|
+
if (resultsArr) {
|
|
332
|
+
for (const r of resultsArr) {
|
|
333
|
+
if (!r || !r.path) continue;
|
|
334
|
+
const status = String(r.status || '').toLowerCase();
|
|
335
|
+
const canonical = fromWirePath(r.path);
|
|
336
|
+
if (status === 'written' || status === 'unchanged') {
|
|
337
|
+
landedPaths.add(canonical);
|
|
338
|
+
seen.add(canonical);
|
|
339
|
+
} else {
|
|
340
|
+
failedToLand.push({ path: canonical, status: status || 'error', error: r.error || '' });
|
|
341
|
+
seen.add(canonical);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
// No results array in response — old server. Best-effort: assume
|
|
346
|
+
// everything sent landed. This preserves existing behavior when
|
|
347
|
+
// talking to a server that doesn't return per-file status.
|
|
348
|
+
for (const f of sentFiles) {
|
|
349
|
+
landedPaths.add(f.path);
|
|
350
|
+
seen.add(f.path);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Any file we sent that the server didn't mention = silently dropped.
|
|
354
|
+
for (const f of sentFiles) {
|
|
355
|
+
if (!seen.has(f.path)) {
|
|
356
|
+
failedToLand.push({ path: f.path, status: 'dropped', error: 'server did not confirm write' });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
297
361
|
if (filesToPush.length > 0) {
|
|
298
|
-
// Push files to server
|
|
362
|
+
// Push files to server (strip leading slash — server requires workspace-relative paths)
|
|
299
363
|
result = await apiRequestJson(
|
|
300
364
|
`/business/${businessId}/workspaces/${workspaceId}/sync`,
|
|
301
|
-
{ method: 'POST', token: creds.token, body: { files: filesToPush }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
365
|
+
{ method: 'POST', token: creds.token, body: { files: wireFiles(filesToPush) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
302
366
|
);
|
|
303
367
|
|
|
304
368
|
if (!result.ok) {
|
|
@@ -310,10 +374,11 @@ async function pushAtris() {
|
|
|
310
374
|
if (allowed.length > 0) {
|
|
311
375
|
const retry = await apiRequestJson(
|
|
312
376
|
`/business/${businessId}/workspaces/${workspaceId}/sync`,
|
|
313
|
-
{ method: 'POST', token: creds.token, body: { files: allowed }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
377
|
+
{ method: 'POST', token: creds.token, body: { files: wireFiles(allowed) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
|
|
314
378
|
);
|
|
315
379
|
if (retry.ok) {
|
|
316
|
-
|
|
380
|
+
recordSyncResults(allowed, retry);
|
|
381
|
+
pushed = landedPaths.size;
|
|
317
382
|
} else {
|
|
318
383
|
console.error(`\n Push failed: ${retry.errorMessage || retry.error || retry.status}`);
|
|
319
384
|
await emit('access_denied', { error_detail: `403 retry failed: ${retry.status}` });
|
|
@@ -334,7 +399,8 @@ async function pushAtris() {
|
|
|
334
399
|
process.exit(1);
|
|
335
400
|
}
|
|
336
401
|
} else {
|
|
337
|
-
|
|
402
|
+
recordSyncResults(filesToPush, result);
|
|
403
|
+
pushed = landedPaths.size;
|
|
338
404
|
}
|
|
339
405
|
}
|
|
340
406
|
|
|
@@ -387,10 +453,15 @@ async function pushAtris() {
|
|
|
387
453
|
if (deleteFailed.length > 10) console.log(` ... +${deleteFailed.length - 10} more`);
|
|
388
454
|
}
|
|
389
455
|
|
|
390
|
-
// Display results
|
|
456
|
+
// Display results — only for files the server confirmed as landed.
|
|
457
|
+
// A non-technical user seeing "+ atris/ideas/foo.md new file" naturally
|
|
458
|
+
// assumes foo.md is on cloud. So we only print that line when the server
|
|
459
|
+
// actually confirmed it via per-file status. Anything else goes into the
|
|
460
|
+
// loud failure block below.
|
|
391
461
|
console.log('');
|
|
392
462
|
for (const f of filesToPush) {
|
|
393
463
|
if (skipped.includes(f)) continue;
|
|
464
|
+
if (!landedPaths.has(f.path)) continue;
|
|
394
465
|
const isNew = !baseFiles[f.path];
|
|
395
466
|
console.log(` ${isNew ? '+' : '\u2191'} ${f.path.replace(/^\//, '')} ${isNew ? 'new file' : 'updated'}`);
|
|
396
467
|
}
|
|
@@ -402,6 +473,26 @@ async function pushAtris() {
|
|
|
402
473
|
console.log(` x ${filePath.replace(/^\//, '')} deleted`);
|
|
403
474
|
}
|
|
404
475
|
|
|
476
|
+
// Loud failure block — files the server silently dropped or rejected.
|
|
477
|
+
// These did NOT land on cloud even though the HTTP call returned 200.
|
|
478
|
+
if (failedToLand.length > 0) {
|
|
479
|
+
console.log('');
|
|
480
|
+
console.log(` ⚠ ${failedToLand.length} file(s) did NOT land on cloud (server returned 200 but`);
|
|
481
|
+
console.log(` dropped or rejected these files):`);
|
|
482
|
+
const shown = failedToLand.slice(0, 15);
|
|
483
|
+
for (const f of shown) {
|
|
484
|
+
const detail = f.error ? ` — ${f.error}` : ` (${f.status})`;
|
|
485
|
+
console.log(` ✗ ${f.path.replace(/^\//, '')}${detail}`);
|
|
486
|
+
}
|
|
487
|
+
if (failedToLand.length > shown.length) {
|
|
488
|
+
console.log(` ... +${failedToLand.length - shown.length} more`);
|
|
489
|
+
}
|
|
490
|
+
console.log('');
|
|
491
|
+
console.log(' Common causes: path is outside the workspace (e.g. absolute /Users/... path),');
|
|
492
|
+
console.log(' your role lacks write permission for that folder, or warm runner returned an error.');
|
|
493
|
+
console.log(' These files will appear as drift on your next push so you can retry.');
|
|
494
|
+
}
|
|
495
|
+
|
|
405
496
|
// Summary
|
|
406
497
|
console.log('');
|
|
407
498
|
const parts = [];
|
|
@@ -414,11 +505,17 @@ async function pushAtris() {
|
|
|
414
505
|
|
|
415
506
|
// Update manifest — mark pushed files with their new hash, drop ONLY confirmed deletes.
|
|
416
507
|
// Failed deletes stay in the manifest so the next push will retry them.
|
|
508
|
+
//
|
|
509
|
+
// CRITICAL: only record manifest entries for files the server confirmed as
|
|
510
|
+
// landed (landedPaths). If we recorded the local hash for a file that the
|
|
511
|
+
// server silently dropped, the next push would compare local==manifest and
|
|
512
|
+
// skip it — the file would never land. Keeping it OUT of the manifest means
|
|
513
|
+
// the next push sees it as new/changed and retries automatically.
|
|
417
514
|
const updatedFiles = { ...baseFiles };
|
|
418
515
|
for (const f of filesToPush) {
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
|
|
516
|
+
if (skipped.includes(f)) continue;
|
|
517
|
+
if (!landedPaths.has(f.path)) continue;
|
|
518
|
+
updatedFiles[f.path] = localFiles[f.path];
|
|
422
519
|
}
|
|
423
520
|
for (const filePath of deletedConfirmed) {
|
|
424
521
|
delete updatedFiles[filePath];
|
|
@@ -431,7 +528,10 @@ async function pushAtris() {
|
|
|
431
528
|
const bytesChanged = filesToPush.reduce((acc, f) => acc + (f.content ? Buffer.byteLength(f.content, 'utf8') : 0), 0);
|
|
432
529
|
let finalOutcome;
|
|
433
530
|
let finalDetail;
|
|
434
|
-
if (
|
|
531
|
+
if (failedToLand.length > 0) {
|
|
532
|
+
finalOutcome = 'status_unknown';
|
|
533
|
+
finalDetail = `${failedToLand.length} file(s) silently dropped by server (statuses: ${[...new Set(failedToLand.map(f => f.status))].join(',')})`;
|
|
534
|
+
} else if (deleteFailed.length > 0) {
|
|
435
535
|
finalOutcome = 'status_unknown';
|
|
436
536
|
finalDetail = `${deleteFailed.length} delete(s) failed (statuses: ${[...new Set(deleteFailed.map(f => f.status))].join(',')})`;
|
|
437
537
|
} else if (_rateLimitedDeletes > 0) {
|
package/commands/workflow.js
CHANGED
|
@@ -43,7 +43,8 @@ function printWorkflowBrief(lines) {
|
|
|
43
43
|
|
|
44
44
|
async function planAtris(userInput = null) {
|
|
45
45
|
const { loadConfig } = require('../utils/config');
|
|
46
|
-
const { loadCredentials } = require('../utils/auth');
|
|
46
|
+
const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
|
|
47
|
+
const { apiRequestJson } = require('../utils/api');
|
|
47
48
|
const { executeCodeExecution } = require('../utils/claude_sdk');
|
|
48
49
|
const args = process.argv.slice(3);
|
|
49
50
|
const executeFlag = args.includes('--execute');
|
|
@@ -243,10 +244,14 @@ async function planAtris(userInput = null) {
|
|
|
243
244
|
if (!config.agent_id) {
|
|
244
245
|
throw new Error('No agent selected. Run "atris agent" first.');
|
|
245
246
|
}
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
247
|
+
const ensured = await ensureValidCredentials(apiRequestJson);
|
|
248
|
+
if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
|
|
248
249
|
throw new Error('Not logged in. Run "atris login" first.');
|
|
249
250
|
}
|
|
251
|
+
if (ensured.error) {
|
|
252
|
+
throw new Error(`Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
|
|
253
|
+
}
|
|
254
|
+
const credentials = ensured.credentials;
|
|
250
255
|
|
|
251
256
|
// Build system prompt
|
|
252
257
|
let systemPrompt = '';
|
|
@@ -362,7 +367,8 @@ async function planAtris(userInput = null) {
|
|
|
362
367
|
|
|
363
368
|
async function doAtris() {
|
|
364
369
|
const { loadConfig } = require('../utils/config');
|
|
365
|
-
const { loadCredentials } = require('../utils/auth');
|
|
370
|
+
const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
|
|
371
|
+
const { apiRequestJson } = require('../utils/api');
|
|
366
372
|
const { executeCodeExecution } = require('../utils/claude_sdk');
|
|
367
373
|
const args = process.argv.slice(3);
|
|
368
374
|
const executeFlag = args.includes('--execute');
|
|
@@ -603,10 +609,14 @@ async function doAtris() {
|
|
|
603
609
|
if (!config.agent_id) {
|
|
604
610
|
throw new Error('No agent selected. Run "atris agent" first.');
|
|
605
611
|
}
|
|
606
|
-
const
|
|
607
|
-
if (
|
|
612
|
+
const ensured = await ensureValidCredentials(apiRequestJson);
|
|
613
|
+
if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
|
|
608
614
|
throw new Error('Not logged in. Run "atris login" first.');
|
|
609
615
|
}
|
|
616
|
+
if (ensured.error) {
|
|
617
|
+
throw new Error(`Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
|
|
618
|
+
}
|
|
619
|
+
const credentials = ensured.credentials;
|
|
610
620
|
|
|
611
621
|
// Build system prompt
|
|
612
622
|
let systemPrompt = '';
|
|
@@ -704,7 +714,8 @@ async function doAtris() {
|
|
|
704
714
|
|
|
705
715
|
async function reviewAtris() {
|
|
706
716
|
const { loadConfig } = require('../utils/config');
|
|
707
|
-
const { loadCredentials } = require('../utils/auth');
|
|
717
|
+
const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
|
|
718
|
+
const { apiRequestJson } = require('../utils/api');
|
|
708
719
|
const { executeCodeExecution } = require('../utils/claude_sdk');
|
|
709
720
|
const args = process.argv.slice(3);
|
|
710
721
|
const executeFlag = args.includes('--execute');
|
|
@@ -959,10 +970,14 @@ async function reviewAtris() {
|
|
|
959
970
|
if (!config.agent_id) {
|
|
960
971
|
throw new Error('No agent selected. Run "atris agent" first.');
|
|
961
972
|
}
|
|
962
|
-
const
|
|
963
|
-
if (
|
|
973
|
+
const ensured = await ensureValidCredentials(apiRequestJson);
|
|
974
|
+
if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
|
|
964
975
|
throw new Error('Not logged in. Run "atris login" first.');
|
|
965
976
|
}
|
|
977
|
+
if (ensured.error) {
|
|
978
|
+
throw new Error(`Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
|
|
979
|
+
}
|
|
980
|
+
const credentials = ensured.credentials;
|
|
966
981
|
|
|
967
982
|
// Build system prompt
|
|
968
983
|
let systemPrompt = '';
|
package/lib/manifest.js
CHANGED
|
@@ -62,6 +62,9 @@ function buildManifest(files, commitHash) {
|
|
|
62
62
|
const SKIP_DIRS = new Set([
|
|
63
63
|
'node_modules', '__pycache__', '.git', 'venv', '.venv',
|
|
64
64
|
'lost+found', '.cache', '.atris',
|
|
65
|
+
// Defense-in-depth: macOS/system dirs that should never be scanned as
|
|
66
|
+
// part of any atris workspace, even if outputDir is accidentally wide.
|
|
67
|
+
'Library', 'Applications', 'System',
|
|
65
68
|
]);
|
|
66
69
|
|
|
67
70
|
function computeLocalHashes(localDir) {
|