atris 3.13.0 → 3.15.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.
@@ -33,10 +33,292 @@ function loadBusinesses() {
33
33
  try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; }
34
34
  }
35
35
 
36
+ function businessAliases(business) {
37
+ const aliases = business?.aliases || business?.config?.aliases || [];
38
+ return Array.isArray(aliases) ? aliases : [];
39
+ }
40
+
41
+ function businessMatchesSlug(business, slug, { includeName = false } = {}) {
42
+ if (!business || !slug) return false;
43
+ const wanted = String(slug).toLowerCase();
44
+ const canonical = String(business.slug || '').toLowerCase();
45
+ const aliases = businessAliases(business).map((alias) => String(alias).toLowerCase());
46
+ if (canonical === wanted || aliases.includes(wanted)) return true;
47
+ return includeName && String(business.name || '').toLowerCase() === wanted;
48
+ }
49
+
36
50
  function saveBusinesses(data) {
37
51
  fs.writeFileSync(getBusinessConfigPath(), JSON.stringify(data, null, 2));
38
52
  }
39
53
 
54
+ function buildBusinessCacheEntry(business, localSlug, existing = {}) {
55
+ const aliases = businessAliases(business);
56
+ const entry = {
57
+ business_id: business.id || business.business_id,
58
+ workspace_id: business.workspace_id,
59
+ name: business.name || localSlug,
60
+ slug: localSlug || business.slug,
61
+ added_at: existing.added_at || new Date().toISOString(),
62
+ };
63
+ if (business.slug && business.slug !== entry.slug) entry.canonical_slug = business.slug;
64
+ else if (business.slug) entry.canonical_slug = business.slug;
65
+ if (aliases.length > 0) entry.aliases = aliases;
66
+ return entry;
67
+ }
68
+
69
+ function readBusinessFolderBindings(rootDir = path.join(os.homedir(), 'arena', 'atris-business')) {
70
+ const skip = new Set([
71
+ '.git', '.DS_Store', 'archive', 'archives', '_archive', 'bench', 'deals',
72
+ 'node_modules', 'shelf', '_shelf', 'templates',
73
+ ]);
74
+ if (!fs.existsSync(rootDir)) return [];
75
+
76
+ return fs.readdirSync(rootDir, { withFileTypes: true })
77
+ .filter((entry) => !entry.name.startsWith('.') && !skip.has(entry.name))
78
+ .map((entry) => {
79
+ const fullPath = path.join(rootDir, entry.name);
80
+ let isDirectory = entry.isDirectory();
81
+ const isSymlink = entry.isSymbolicLink();
82
+ let symlinkTarget = null;
83
+ if (isSymlink) {
84
+ try {
85
+ symlinkTarget = fs.readlinkSync(fullPath);
86
+ isDirectory = fs.statSync(fullPath).isDirectory();
87
+ } catch {
88
+ isDirectory = false;
89
+ }
90
+ }
91
+ if (!isDirectory) return null;
92
+
93
+ const businessJsonPath = path.join(fullPath, '.atris', 'business.json');
94
+ const atrisDir = path.join(fullPath, 'atris');
95
+ const binding = {
96
+ name: entry.name,
97
+ path: fullPath,
98
+ isSymlink,
99
+ symlinkTarget,
100
+ hasAtris: fs.existsSync(atrisDir) && fs.statSync(atrisDir).isDirectory(),
101
+ hasBusinessJson: fs.existsSync(businessJsonPath),
102
+ businessJsonPath,
103
+ meta: null,
104
+ error: null,
105
+ };
106
+
107
+ if (binding.hasBusinessJson) {
108
+ try {
109
+ binding.meta = JSON.parse(fs.readFileSync(businessJsonPath, 'utf8'));
110
+ } catch (err) {
111
+ binding.error = err.message || 'invalid JSON';
112
+ }
113
+ }
114
+ return binding;
115
+ })
116
+ .filter(Boolean);
117
+ }
118
+
119
+ function analyzeBusinessDoctor({ cache = {}, cloudBusinesses = [], folderBindings = [] } = {}) {
120
+ const issues = [];
121
+ const cacheUpdates = {};
122
+ const activeById = new Map();
123
+ const activeBySlug = new Map();
124
+ const realFolderIds = new Map();
125
+
126
+ for (const business of cloudBusinesses || []) {
127
+ const id = business.id || business.business_id;
128
+ if (!id) continue;
129
+ activeById.set(id, business);
130
+ if (business.slug) {
131
+ const lower = String(business.slug).toLowerCase();
132
+ if (activeBySlug.has(lower)) {
133
+ issues.push({
134
+ level: 'fail',
135
+ code: 'duplicate-active-slug',
136
+ subject: business.slug,
137
+ message: `multiple active cloud businesses use slug ${business.slug}`,
138
+ });
139
+ }
140
+ activeBySlug.set(lower, business);
141
+ }
142
+ }
143
+
144
+ const findActiveMatch = (key, entry = {}) => {
145
+ if (entry.business_id && activeById.has(entry.business_id)) return activeById.get(entry.business_id);
146
+ const candidates = [key, entry.slug, entry.canonical_slug, entry.name].filter(Boolean);
147
+ return cloudBusinesses.find((business) =>
148
+ candidates.some((candidate) => businessMatchesSlug(business, candidate, { includeName: true }))
149
+ ) || null;
150
+ };
151
+
152
+ for (const [key, entry] of Object.entries(cache || {})) {
153
+ const active = findActiveMatch(key, entry);
154
+ if (!active) {
155
+ issues.push({
156
+ level: 'fail',
157
+ code: 'stale-cache',
158
+ subject: key,
159
+ message: `${key} points at a deleted/inaccessible business (${entry.business_id || 'missing id'})`,
160
+ });
161
+ continue;
162
+ }
163
+
164
+ if (entry.business_id && entry.business_id !== active.id) {
165
+ issues.push({
166
+ level: 'fail',
167
+ code: 'stale-cache-repoint',
168
+ subject: key,
169
+ message: `${key} points at ${entry.business_id}; active cloud row is ${active.id}`,
170
+ fixable: true,
171
+ });
172
+ cacheUpdates[key] = buildBusinessCacheEntry(active, key, entry);
173
+ }
174
+
175
+ if (!businessMatchesSlug({ ...active, aliases: businessAliases(active) }, key, { includeName: true }) && key !== active.slug) {
176
+ issues.push({
177
+ level: 'warn',
178
+ code: 'cache-key-not-alias',
179
+ subject: key,
180
+ message: `${key} is cached but is not the canonical slug, alias, or name for ${active.slug}`,
181
+ });
182
+ }
183
+ }
184
+
185
+ for (const business of cloudBusinesses || []) {
186
+ const canonicalKey = business.slug;
187
+ if (canonicalKey && (!cache[canonicalKey] || cache[canonicalKey].business_id !== business.id)) {
188
+ issues.push({
189
+ level: 'warn',
190
+ code: 'missing-canonical-cache',
191
+ subject: canonicalKey,
192
+ message: `${canonicalKey} is active in cloud but missing/stale in local cache`,
193
+ fixable: true,
194
+ });
195
+ cacheUpdates[canonicalKey] = buildBusinessCacheEntry(business, canonicalKey, cache[canonicalKey]);
196
+ }
197
+
198
+ for (const alias of businessAliases(business)) {
199
+ if (!cache[alias] || cache[alias].business_id !== business.id) {
200
+ issues.push({
201
+ level: 'warn',
202
+ code: 'missing-alias-cache',
203
+ subject: alias,
204
+ message: `${alias} is an active alias for ${business.slug} but missing/stale in local cache`,
205
+ fixable: true,
206
+ });
207
+ cacheUpdates[alias] = buildBusinessCacheEntry(business, alias, cache[alias]);
208
+ }
209
+ }
210
+ }
211
+
212
+ for (const binding of folderBindings || []) {
213
+ if (binding.error) {
214
+ issues.push({
215
+ level: 'fail',
216
+ code: 'invalid-business-json',
217
+ subject: binding.name,
218
+ message: `${binding.name}/.atris/business.json is invalid: ${binding.error}`,
219
+ });
220
+ continue;
221
+ }
222
+
223
+ if (!binding.hasBusinessJson) {
224
+ if (binding.hasAtris && !binding.isSymlink) {
225
+ issues.push({
226
+ level: 'warn',
227
+ code: 'folder-unbound',
228
+ subject: binding.name,
229
+ message: `${binding.name} has atris/ but no .atris/business.json`,
230
+ });
231
+ }
232
+ continue;
233
+ }
234
+
235
+ const meta = binding.meta || {};
236
+ const active = findActiveMatch(binding.name, {
237
+ business_id: meta.business_id,
238
+ slug: meta.slug,
239
+ canonical_slug: meta.canonical_slug,
240
+ name: meta.name,
241
+ });
242
+
243
+ if (!active) {
244
+ issues.push({
245
+ level: 'fail',
246
+ code: 'stale-folder-binding',
247
+ subject: binding.name,
248
+ message: `${binding.name} is bound to deleted/inaccessible business ${meta.business_id || meta.slug || 'unknown'}`,
249
+ });
250
+ continue;
251
+ }
252
+
253
+ if (meta.business_id && meta.business_id !== active.id) {
254
+ issues.push({
255
+ level: 'fail',
256
+ code: 'folder-id-mismatch',
257
+ subject: binding.name,
258
+ message: `${binding.name} points at ${meta.business_id}; active cloud row is ${active.id}`,
259
+ });
260
+ }
261
+
262
+ if (meta.slug && !businessMatchesSlug(active, meta.slug, { includeName: true })) {
263
+ issues.push({
264
+ level: 'fail',
265
+ code: 'folder-slug-mismatch',
266
+ subject: binding.name,
267
+ message: `${binding.name} uses slug ${meta.slug}, which is not ${active.slug} or an alias`,
268
+ });
269
+ }
270
+
271
+ if (!businessMatchesSlug(active, binding.name, { includeName: false }) && !binding.isSymlink) {
272
+ issues.push({
273
+ level: 'warn',
274
+ code: 'folder-name-not-slug-or-alias',
275
+ subject: binding.name,
276
+ message: `${binding.name} is not a canonical slug or alias for ${active.slug}`,
277
+ });
278
+ }
279
+
280
+ if (!binding.isSymlink) {
281
+ const existing = realFolderIds.get(active.id) || [];
282
+ existing.push(binding.name);
283
+ realFolderIds.set(active.id, existing);
284
+ }
285
+
286
+ const cacheKey = meta.slug || binding.name;
287
+ if (!cache[cacheKey] || cache[cacheKey].business_id !== active.id) {
288
+ issues.push({
289
+ level: 'warn',
290
+ code: 'folder-cache-missing',
291
+ subject: cacheKey,
292
+ message: `${binding.name} is bound locally but ${cacheKey} is missing/stale in local cache`,
293
+ fixable: true,
294
+ });
295
+ cacheUpdates[cacheKey] = buildBusinessCacheEntry(active, cacheKey, cache[cacheKey]);
296
+ }
297
+ }
298
+
299
+ for (const [businessId, names] of realFolderIds.entries()) {
300
+ if (names.length > 1) {
301
+ issues.push({
302
+ level: 'fail',
303
+ code: 'duplicate-real-folders',
304
+ subject: businessId,
305
+ message: `business ${businessId} has multiple real folders: ${names.join(', ')}`,
306
+ });
307
+ }
308
+ }
309
+
310
+ return {
311
+ issues,
312
+ cacheUpdates,
313
+ stats: {
314
+ cache_entries: Object.keys(cache || {}).length,
315
+ cloud_active: cloudBusinesses.length,
316
+ folders: folderBindings.length,
317
+ fixable_cache_entries: Object.keys(cacheUpdates).length,
318
+ },
319
+ };
320
+ }
321
+
40
322
  function parseCreateBusinessFlags(flags, cwd = process.cwd()) {
41
323
  const options = {
42
324
  description: '',
@@ -797,7 +1079,7 @@ async function findExistingBusinessBySlug(slug, token) {
797
1079
  return { id: local[slug].business_id, name: local[slug].name, slug, source: 'local' };
798
1080
  }
799
1081
  for (const v of Object.values(local)) {
800
- if (v && v.slug === slug) {
1082
+ if (businessMatchesSlug(v, slug)) {
801
1083
  return { id: v.business_id, name: v.name, slug, source: 'local' };
802
1084
  }
803
1085
  }
@@ -815,7 +1097,7 @@ async function findExistingBusinessBySlug(slug, token) {
815
1097
 
816
1098
  const list = await apiRequestJson('/business/', { method: 'GET', token });
817
1099
  if (list.ok && Array.isArray(list.data)) {
818
- const match = list.data.find(b => b && b.slug === slug);
1100
+ const match = list.data.find(b => businessMatchesSlug(b, slug));
819
1101
  if (match) return { id: match.id, name: match.name, slug: match.slug, source: 'cloud' };
820
1102
  }
821
1103
 
@@ -848,7 +1130,7 @@ async function addBusiness(slug) {
848
1130
  // Try listing all and matching
849
1131
  const listResult = await apiRequestJson('/business/', { method: 'GET', token: creds.token });
850
1132
  if (listResult.ok && Array.isArray(listResult.data)) {
851
- const match = listResult.data.find(b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase());
1133
+ const match = listResult.data.find(b => businessMatchesSlug(b, slug, { includeName: true }));
852
1134
  if (match) {
853
1135
  const businesses = loadBusinesses();
854
1136
  businesses[slug] = {
@@ -1099,7 +1381,7 @@ async function resolveSlug(slug, creds) {
1099
1381
  // Fallback: list all and match
1100
1382
  const listResult = await apiRequestJson('/business/', { method: 'GET', token: creds.token });
1101
1383
  if (listResult.ok && Array.isArray(listResult.data)) {
1102
- const match = listResult.data.find(b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase());
1384
+ const match = listResult.data.find(b => businessMatchesSlug(b, slug, { includeName: true }));
1103
1385
  if (match) {
1104
1386
  return { business_id: match.id, workspace_id: match.workspace_id, name: match.name, slug: match.slug };
1105
1387
  }
@@ -1313,6 +1595,103 @@ async function businessAudit() {
1313
1595
  console.log('');
1314
1596
  }
1315
1597
 
1598
+ function parseBusinessDoctorOptions(args = []) {
1599
+ const options = {
1600
+ fix: args.includes('--fix'),
1601
+ json: args.includes('--json'),
1602
+ root: path.join(os.homedir(), 'arena', 'atris-business'),
1603
+ };
1604
+ const rootIdx = args.indexOf('--root');
1605
+ if (rootIdx !== -1 && args[rootIdx + 1]) {
1606
+ options.root = path.resolve(args[rootIdx + 1]);
1607
+ }
1608
+ return options;
1609
+ }
1610
+
1611
+ function printBusinessDoctorHelp() {
1612
+ console.log('Usage: atris business doctor [--fix] [--root <dir>] [--json]');
1613
+ console.log('');
1614
+ console.log('Checks cloud-active businesses against:');
1615
+ console.log(' - ~/.atris/businesses.json');
1616
+ console.log(' - ~/arena/atris-business/*/.atris/business.json');
1617
+ console.log(' - canonical slug + alias bindings');
1618
+ console.log('');
1619
+ console.log('--fix rewrites only safe local cache entries. It does not rename folders or touch cloud data.');
1620
+ }
1621
+
1622
+ async function businessDoctor(...args) {
1623
+ if (args.some(isHelpToken)) {
1624
+ printBusinessDoctorHelp();
1625
+ return;
1626
+ }
1627
+
1628
+ const options = parseBusinessDoctorOptions(args);
1629
+ const creds = loadCredentials();
1630
+ if (!creds || !creds.token) {
1631
+ console.error('Not logged in. Run: atris login');
1632
+ process.exit(1);
1633
+ }
1634
+
1635
+ const listResult = await apiRequestJson('/business/', { method: 'GET', token: creds.token });
1636
+ if (!listResult.ok || !Array.isArray(listResult.data)) {
1637
+ console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.error || listResult.status || 'unknown error'}`);
1638
+ process.exit(1);
1639
+ }
1640
+
1641
+ let cache = loadBusinesses();
1642
+ const folderBindings = readBusinessFolderBindings(options.root);
1643
+ let analysis = analyzeBusinessDoctor({
1644
+ cache,
1645
+ cloudBusinesses: listResult.data,
1646
+ folderBindings,
1647
+ });
1648
+
1649
+ const cacheUpdateKeys = Object.keys(analysis.cacheUpdates);
1650
+ let fixed = [];
1651
+ if (options.fix && cacheUpdateKeys.length > 0) {
1652
+ cache = { ...cache, ...analysis.cacheUpdates };
1653
+ saveBusinesses(cache);
1654
+ fixed = cacheUpdateKeys;
1655
+ analysis = analyzeBusinessDoctor({
1656
+ cache,
1657
+ cloudBusinesses: listResult.data,
1658
+ folderBindings,
1659
+ });
1660
+ }
1661
+
1662
+ if (options.json) {
1663
+ console.log(JSON.stringify({
1664
+ root: options.root,
1665
+ stats: analysis.stats,
1666
+ fixed,
1667
+ issues: analysis.issues,
1668
+ }, null, 2));
1669
+ } else {
1670
+ console.log('');
1671
+ console.log('Business Doctor');
1672
+ console.log('---------------');
1673
+ console.log(`cloud active: ${analysis.stats.cloud_active}`);
1674
+ console.log(`cache entries: ${analysis.stats.cache_entries}`);
1675
+ console.log(`folders scanned: ${analysis.stats.folders}`);
1676
+ if (fixed.length > 0) console.log(`fixed cache entries: ${fixed.join(', ')}`);
1677
+ console.log('');
1678
+
1679
+ if (analysis.issues.length === 0) {
1680
+ console.log('OK no business binding drift found.');
1681
+ } else {
1682
+ for (const issue of analysis.issues) {
1683
+ const label = issue.level === 'fail' ? 'FAIL' : 'WARN';
1684
+ const fixHint = issue.fixable ? ' (run with --fix)' : '';
1685
+ console.log(`${label} ${issue.code}: ${issue.message}${fixHint}`);
1686
+ }
1687
+ }
1688
+ console.log('');
1689
+ }
1690
+
1691
+ const failures = analysis.issues.filter((issue) => issue.level === 'fail');
1692
+ if (failures.length > 0) process.exitCode = 1;
1693
+ }
1694
+
1316
1695
  async function createBusinessInternal(name, flags = [], mode = 'auto') {
1317
1696
  if (!name || isHelpToken(name) || String(name).startsWith('-')) {
1318
1697
  console.error('Usage: atris business create <name> [--description "..."] [--workspace] [--here|--root <dir>]');
@@ -1778,7 +2157,7 @@ async function deployBusiness(slug) {
1778
2157
  // Try to find by slug in cloud
1779
2158
  const listResult = await apiRequestJson('/business/', { method: 'GET', token: creds.token });
1780
2159
  if (listResult.ok && Array.isArray(listResult.data)) {
1781
- const match = listResult.data.find(b => b.slug === slug);
2160
+ const match = listResult.data.find(b => businessMatchesSlug(b, slug));
1782
2161
  if (match) {
1783
2162
  bizConfig = { business_id: match.id, workspace_id: match.workspace_id, name: match.name, slug: match.slug };
1784
2163
  businesses[slug] = { ...bizConfig, added_at: new Date().toISOString() };
@@ -1933,6 +2312,7 @@ function printBusinessHelp() {
1933
2312
  console.log(' status <slug> Quick status check');
1934
2313
  console.log(' health [slug] Full health dashboard');
1935
2314
  console.log(' audit Audit all businesses');
2315
+ console.log(' doctor [--fix] Find stale business cache, alias, and folder bindings');
1936
2316
  console.log(' connect <service> Connect a skill/integration');
1937
2317
  console.log(' notify <mode> Set notification mode (digest/silent/push)');
1938
2318
  console.log(' deploy <slug> Push local business to cloud');
@@ -1951,7 +2331,7 @@ async function businessCommand(subcommand, ...args) {
1951
2331
  printBusinessHelp();
1952
2332
  return;
1953
2333
  }
1954
- if (args.length > 0 && isHelpToken(args[0])) {
2334
+ if (args.length > 0 && isHelpToken(args[0]) && subcommand !== 'doctor') {
1955
2335
  printBusinessHelp();
1956
2336
  return;
1957
2337
  }
@@ -2001,6 +2381,9 @@ async function businessCommand(subcommand, ...args) {
2001
2381
  case 'audit':
2002
2382
  await businessAudit();
2003
2383
  break;
2384
+ case 'doctor':
2385
+ await businessDoctor(...args);
2386
+ break;
2004
2387
  case 'connect':
2005
2388
  await connectService(args[0], ...args.slice(1));
2006
2389
  break;
@@ -2036,10 +2419,14 @@ module.exports = {
2036
2419
  businessCommand,
2037
2420
  businessHealth,
2038
2421
  businessAudit,
2422
+ businessDoctor,
2039
2423
  businessTeam,
2040
2424
  loadBusinesses,
2041
2425
  saveBusinesses,
2042
2426
  getBusinessConfigPath,
2427
+ businessMatchesSlug,
2428
+ analyzeBusinessDoctor,
2429
+ readBusinessFolderBindings,
2043
2430
  createCanonicalBusinessWorkspace,
2044
2431
  initBusinessWorkspace,
2045
2432
  onboardBusiness,