+74
-19
@@ -13,4 +13,17 @@ const fs = require('fs'); | ||
| async function pullAtris() { | ||
| const arg = process.argv[3]; | ||
| let arg = process.argv[3]; | ||
| // Auto-detect business from .atris/business.json in current dir | ||
| if (!arg || arg.startsWith('--')) { | ||
| const bizFile = path.join(process.cwd(), '.atris', 'business.json'); | ||
| if (fs.existsSync(bizFile)) { | ||
| try { | ||
| const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8')); | ||
| if (biz.slug || biz.name) { | ||
| return pullBusiness(biz.slug || biz.name); | ||
| } | ||
| } catch {} | ||
| } | ||
| } | ||
| // If a business name is given, do a business pull | ||
@@ -85,6 +98,15 @@ if (arg && arg !== '--help' && !arg.startsWith('--')) { | ||
| // Parse --only flag: comma-separated directory prefixes to filter | ||
| const onlyArg = process.argv.find(a => a.startsWith('--only=')); | ||
| const onlyPrefixes = onlyArg | ||
| ? onlyArg.slice('--only='.length).split(',').map(p => { | ||
| // Normalize: strip leading slash, ensure trailing slash for dirs | ||
| // Supports both --only=team/,context/ and --only team/,context/ | ||
| let onlyRaw = null; | ||
| const onlyEqArg = process.argv.find(a => a.startsWith('--only=')); | ||
| if (onlyEqArg) { | ||
| onlyRaw = onlyEqArg.slice('--only='.length); | ||
| } else { | ||
| const onlyIdx = process.argv.indexOf('--only'); | ||
| if (onlyIdx !== -1 && process.argv[onlyIdx + 1] && !process.argv[onlyIdx + 1].startsWith('-')) { | ||
| onlyRaw = process.argv[onlyIdx + 1]; | ||
| } | ||
| } | ||
| const onlyPrefixes = onlyRaw | ||
| ? onlyRaw.split(',').map(p => { | ||
| let norm = p.replace(/^\//, ''); | ||
@@ -96,7 +118,15 @@ if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/'; | ||
| // Parse --timeout flag: override default 120s timeout | ||
| const timeoutArg = process.argv.find(a => a.startsWith('--timeout=')); | ||
| const timeoutMs = timeoutArg | ||
| ? parseInt(timeoutArg.slice('--timeout='.length), 10) * 1000 | ||
| : 120000; | ||
| // Parse --timeout flag: override default 300s timeout | ||
| // Supports both --timeout=60 and --timeout 60 | ||
| let timeoutSec = 300; | ||
| const timeoutEqArg = process.argv.find(a => a.startsWith('--timeout=')); | ||
| if (timeoutEqArg) { | ||
| timeoutSec = parseInt(timeoutEqArg.slice('--timeout='.length), 10); | ||
| } else { | ||
| const timeoutIdx = process.argv.indexOf('--timeout'); | ||
| if (timeoutIdx !== -1 && process.argv[timeoutIdx + 1]) { | ||
| timeoutSec = parseInt(process.argv[timeoutIdx + 1], 10); | ||
| } | ||
| } | ||
| const timeoutMs = timeoutSec * 1000; | ||
@@ -169,14 +199,27 @@ // Determine output directory | ||
| console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : '')); | ||
| console.log(' Fetching workspace...'); | ||
| // Get remote snapshot (large workspaces can take 60s+) | ||
| const result = await apiRequestJson( | ||
| `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`, | ||
| { method: 'GET', token: creds.token, timeoutMs } | ||
| ); | ||
| // Loading indicator with elapsed time | ||
| const startTime = Date.now(); | ||
| const spinner = ['|', '/', '-', '\\']; | ||
| let spinIdx = 0; | ||
| const loading = setInterval(() => { | ||
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | ||
| process.stdout.write(`\r Fetching workspace... ${spinner[spinIdx++ % 4]} ${elapsed}s`); | ||
| }, 250); | ||
| // Get remote snapshot — pass --only prefixes to server for faster response | ||
| let snapshotUrl = `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`; | ||
| if (onlyPrefixes) { | ||
| snapshotUrl += `&paths=${encodeURIComponent(onlyPrefixes.map(p => p.replace(/\/$/, '')).join(','))}`; | ||
| } | ||
| const result = await apiRequestJson(snapshotUrl, { method: 'GET', token: creds.token, timeoutMs }); | ||
| clearInterval(loading); | ||
| const totalSec = Math.floor((Date.now() - startTime) / 1000); | ||
| process.stdout.write(`\r Fetched in ${totalSec}s.${' '.repeat(20)}\n`); | ||
| if (!result.ok) { | ||
| const msg = result.errorMessage || result.error || `HTTP ${result.status}`; | ||
| if (result.status === 0 || (typeof msg === 'string' && msg.toLowerCase().includes('timeout'))) { | ||
| console.error(`\n Workspace is taking too long to respond. Try: atris pull ${slug} --timeout=120`); | ||
| console.error(`\n Workspace timed out (large workspaces can take 60s+). Try: atris pull ${slug} --timeout=600`); | ||
| } else if (result.status === 409) { | ||
@@ -233,5 +276,7 @@ console.error(`\n Computer is sleeping. Wake it first, then pull again.`); | ||
| // If output dir is empty (fresh clone) or --force, treat as first sync — pull everything | ||
| const effectiveManifest = (Object.keys(localFiles).length === 0 || force) ? null : manifest; | ||
| // Three-way compare | ||
| const baseFiles = (manifest && manifest.files) ? manifest.files : {}; | ||
| const diff = threeWayCompare(localFiles, remoteFiles, manifest); | ||
| const diff = threeWayCompare(localFiles, remoteFiles, effectiveManifest); | ||
@@ -324,2 +369,12 @@ // Apply changes | ||
| saveManifest(resolvedSlug || slug, newManifest); | ||
| // Save business config in the output dir so push/status work without args | ||
| const atrisDir = path.join(outputDir, '.atris'); | ||
| fs.mkdirSync(atrisDir, { recursive: true }); | ||
| fs.writeFileSync(path.join(atrisDir, 'business.json'), JSON.stringify({ | ||
| slug: resolvedSlug || slug, | ||
| business_id: businessId, | ||
| workspace_id: workspaceId, | ||
| name: businessName, | ||
| }, null, 2)); | ||
| } | ||
@@ -326,0 +381,0 @@ |
+132
-21
@@ -7,10 +7,27 @@ const fs = require('fs'); | ||
| const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, SKIP_DIRS } = require('../lib/manifest'); | ||
| const { sectionMerge } = require('../lib/section-merge'); | ||
| async function pushAtris() { | ||
| const slug = process.argv[3]; | ||
| let slug = process.argv[3]; | ||
| // Auto-detect business from .atris/business.json in current dir | ||
| if (!slug || slug.startsWith('-')) { | ||
| const bizFile = path.join(process.cwd(), '.atris', 'business.json'); | ||
| if (fs.existsSync(bizFile)) { | ||
| try { | ||
| const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8')); | ||
| slug = biz.slug || biz.name; | ||
| } catch {} | ||
| } | ||
| // If still no slug (no .atris/business.json), need explicit name | ||
| if (!slug || slug.startsWith('-')) { | ||
| slug = null; | ||
| } | ||
| } | ||
| if (!slug || slug === '--help') { | ||
| console.log('Usage: atris push <business-slug> [--from <path>] [--force]'); | ||
| console.log('Usage: atris push [business-slug] [--from <path>] [--force]'); | ||
| console.log(''); | ||
| console.log('Push local files to a Business Computer.'); | ||
| console.log('If run inside a pulled folder, business is auto-detected.'); | ||
| console.log(''); | ||
@@ -22,5 +39,5 @@ console.log('Options:'); | ||
| console.log('Examples:'); | ||
| console.log(' atris push Auto-detect from current folder'); | ||
| console.log(' atris push pallet Push from atris/pallet/ or ./pallet/'); | ||
| console.log(' atris push pallet --from ./my-dir/ Push from a custom directory'); | ||
| console.log(' atris push pallet --force Override conflicts'); | ||
| process.exit(0); | ||
@@ -31,2 +48,21 @@ } | ||
| // Parse --only flag: filter which files to push | ||
| let onlyRaw = null; | ||
| const onlyEqArg = process.argv.find(a => a.startsWith('--only=')); | ||
| if (onlyEqArg) { | ||
| onlyRaw = onlyEqArg.slice('--only='.length); | ||
| } else { | ||
| const onlyIdx = process.argv.indexOf('--only'); | ||
| if (onlyIdx !== -1 && process.argv[onlyIdx + 1] && !process.argv[onlyIdx + 1].startsWith('-')) { | ||
| onlyRaw = process.argv[onlyIdx + 1]; | ||
| } | ||
| } | ||
| const onlyPrefixes = onlyRaw | ||
| ? onlyRaw.split(',').map(p => { | ||
| let norm = p.replace(/^\//, ''); | ||
| if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/'; | ||
| return '/' + norm; | ||
| }).filter(Boolean) | ||
| : null; | ||
| const creds = loadCredentials(); | ||
@@ -43,2 +79,5 @@ if (!creds || !creds.token) { | ||
| sourceDir = path.resolve(process.argv[fromIdx + 1]); | ||
| } else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) { | ||
| // Inside a pulled folder — push from here | ||
| sourceDir = process.cwd(); | ||
| } else { | ||
@@ -121,16 +160,29 @@ const atrisDir = path.join(process.cwd(), 'atris', slug); | ||
| // Get snapshot with content to compute reliable hashes (server hash may differ) | ||
| // Loading indicator | ||
| const startTime = Date.now(); | ||
| const spinner = ['|', '/', '-', '\\']; | ||
| let spinIdx = 0; | ||
| const loading = setInterval(() => { | ||
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | ||
| process.stdout.write(`\r Comparing with remote... ${spinner[spinIdx++ % 4]} ${elapsed}s`); | ||
| }, 250); | ||
| const snapshotResult = await apiRequestJson( | ||
| `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`, | ||
| { method: 'GET', token: creds.token, timeoutMs: 120000 } | ||
| { method: 'GET', token: creds.token, timeoutMs: 300000 } | ||
| ); | ||
| clearInterval(loading); | ||
| const totalSec = Math.floor((Date.now() - startTime) / 1000); | ||
| process.stdout.write(`\r Compared in ${totalSec}s.${' '.repeat(20)}\n`); | ||
| let remoteFiles = {}; | ||
| const remoteContent = {}; // for section merge | ||
| if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) { | ||
| for (const file of snapshotResult.data.files) { | ||
| if (file.path && !file.binary && file.content != null) { | ||
| // Compute hash from content (matches how computeLocalHashes works on raw bytes) | ||
| const rawBytes = Buffer.from(file.content, 'utf-8'); | ||
| const hash = require('crypto').createHash('sha256').update(rawBytes).digest('hex'); | ||
| remoteFiles[file.path] = { hash, size: rawBytes.length }; | ||
| remoteContent[file.path] = file.content; | ||
| } | ||
@@ -143,7 +195,19 @@ } | ||
| // Check if user is a member (not owner) — if so, filter to allowed paths | ||
| // Members can only push to /team/{name}/ and /journal/ | ||
| let skippedPermission = []; | ||
| const role = snapshotResult.data?._role; // not available from snapshot, so we try the push and handle 403 | ||
| // Determine what to push | ||
| const filesToPush = []; | ||
| // Apply --only filter | ||
| const matchesOnly = (filePath) => { | ||
| if (!onlyPrefixes) return true; | ||
| return onlyPrefixes.some(prefix => filePath.startsWith(prefix)); | ||
| }; | ||
| // Files we changed that remote didn't | ||
| for (const p of [...diff.toPush, ...diff.newLocal]) { | ||
| if (!matchesOnly(p)) continue; | ||
| const localPath = path.join(sourceDir, p.replace(/^\//, '')); | ||
@@ -158,18 +222,34 @@ try { | ||
| // Force mode: also push conflicts | ||
| // Handle conflicts: try section-level merge first, then force, then flag | ||
| const conflictPaths = []; | ||
| if (force) { | ||
| for (const p of diff.conflicts) { | ||
| const localPath = path.join(sourceDir, p.replace(/^\//, '')); | ||
| try { | ||
| const content = fs.readFileSync(localPath, 'utf8'); | ||
| filesToPush.push({ path: p, content }); | ||
| } catch { | ||
| // skip | ||
| const mergedPaths = []; | ||
| for (const p of diff.conflicts) { | ||
| const localPath = path.join(sourceDir, p.replace(/^\//, '')); | ||
| let localContent; | ||
| try { localContent = fs.readFileSync(localPath, 'utf8'); } catch { continue; } | ||
| if (force) { | ||
| filesToPush.push({ path: p, content: localContent }); | ||
| continue; | ||
| } | ||
| // Try section-level merge (only for .md files) | ||
| if (p.endsWith('.md') && remoteContent[p] && manifest && manifest.files && manifest.files[p]) { | ||
| // Get base content: we need what the file looked like at last sync. | ||
| // We don't store content in manifest, so use remote as best-effort base | ||
| // when manifest hash matches neither side (true conflict). | ||
| // For now, attempt merge with remote content and see if sections differ. | ||
| const remote = remoteContent[p]; | ||
| // Simple heuristic: if one side only added content (appended sections), merge works | ||
| const result = sectionMerge(remote, localContent, remote); | ||
| // A better merge needs the base version. For now, try local-as-changed vs remote-as-base: | ||
| const mergeResult = sectionMerge(remote, localContent, remote); | ||
| if (mergeResult.merged && mergeResult.conflicts.length === 0 && mergeResult.merged !== remote) { | ||
| filesToPush.push({ path: p, content: mergeResult.merged }); | ||
| mergedPaths.push(p); | ||
| continue; | ||
| } | ||
| } | ||
| } else { | ||
| for (const p of diff.conflicts) { | ||
| conflictPaths.push(p); | ||
| } | ||
| conflictPaths.push(p); | ||
| } | ||
@@ -199,7 +279,34 @@ | ||
| if (!result.ok) { | ||
| const msg = result.errorMessage || `HTTP ${result.status}`; | ||
| const msg = result.errorMessage || result.error || `HTTP ${result.status}`; | ||
| if (result.status === 409) { | ||
| console.error(` Computer is sleeping. Wake it first, then push.`); | ||
| } else if (result.status === 403) { | ||
| console.error(` Access denied: ${msg}`); | ||
| // Member scoping — retry with only team/ and journal/ files | ||
| const memberFiles = filesToPush.filter(f => f.path.startsWith('/team/') || f.path.startsWith('/journal/')); | ||
| const blockedFiles = filesToPush.filter(f => !f.path.startsWith('/team/') && !f.path.startsWith('/journal/')); | ||
| if (memberFiles.length > 0 && blockedFiles.length > 0) { | ||
| console.log(` You're a member — retrying with your team files only...`); | ||
| if (blockedFiles.length > 0) { | ||
| console.log(` Skipped (no permission): ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`); | ||
| } | ||
| const retry = await apiRequestJson( | ||
| `/businesses/${businessId}/workspaces/${workspaceId}/sync`, | ||
| { method: 'POST', token: creds.token, body: { files: memberFiles }, headers: { 'X-Atris-Actor-Source': 'cli' } } | ||
| ); | ||
| if (retry.ok) { | ||
| for (const f of memberFiles) { | ||
| console.log(` \u2191 ${f.path.replace(/^\//, '')} pushed`); | ||
| pushed++; | ||
| } | ||
| } else { | ||
| console.error(` Push failed after retry: ${retry.errorMessage || retry.error || retry.status}`); | ||
| process.exit(1); | ||
| } | ||
| } else { | ||
| console.error(` Access denied: you can only push to your own team/ folder.`); | ||
| if (blockedFiles.length > 0) { | ||
| console.error(` Blocked: ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| } else { | ||
@@ -226,2 +333,6 @@ console.error(` Push failed: ${msg}`); | ||
| } | ||
| for (const p of mergedPaths) { | ||
| console.log(` \u2194 ${p.replace(/^\//, '')} auto-merged (different sections)`); | ||
| pushed++; | ||
| } | ||
| } | ||
@@ -228,0 +339,0 @@ |
+1
-1
| { | ||
| "name": "atris", | ||
| "version": "2.6.2", | ||
| "version": "2.6.3", | ||
| "description": "atrisDev (atris dev) - CLI for AI coding agents. Works with Claude Code, Cursor, Windsurf. Make any codebase AI-navigable.", | ||
@@ -5,0 +5,0 @@ "main": "bin/atris.js", |
+8
-1
@@ -74,7 +74,14 @@ const https = require('https'); | ||
| req.on('error', reject); | ||
| // Socket idle timeout (fires if no data received for this duration) | ||
| if (timeoutMs > 0) { | ||
| req.setTimeout(timeoutMs, () => { | ||
| req.destroy(new Error('Request timeout')); | ||
| req.destroy(new Error(`Request timeout after ${Math.round(timeoutMs / 1000)}s — try --timeout=300`)); | ||
| }); | ||
| } | ||
| // Hard deadline — kill request after 2x the timeout regardless of activity | ||
| const hardDeadline = timeoutMs > 0 | ||
| ? setTimeout(() => { req.destroy(new Error(`Hard deadline exceeded (${Math.round(timeoutMs * 2 / 1000)}s)`)); }, timeoutMs * 2) | ||
| : null; | ||
| // Clear hard deadline when response completes | ||
| req.on('close', () => { if (hardDeadline) clearTimeout(hardDeadline); }); | ||
@@ -81,0 +88,0 @@ if (options.body) { |
Network access
Supply chain riskThis module accesses the network.
Found 3 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 14 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 3 instances in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 14 instances in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
890308
0.88%13808
1.16%