atris 3.72.5 → 3.73.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.
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ // Zen for every repo you keep, in one run. The list comes from the command
4
+ // line or ~/.atris/zen.json ({"repos": ["~/code/app", ...]}). A repo whose
5
+ // unsaved edits touch live app data or secrets is skipped whole, because a
6
+ // person should look at it first. Each repo gets one line, then one shared
7
+ // list of what only a person can do.
8
+
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { DEFAULT_TEAM_STATE, dirtyEntries, untrackedFiles } = require('./zen');
13
+ const { applyZen } = require('./zen-apply');
14
+
15
+ const LIVE_DATA = DEFAULT_TEAM_STATE.filter((p) => p.startsWith('*.')).map((p) => p.slice(1));
16
+ const SECRET = /(^|\/)(\.env(\..+)?|id_rsa[^/]*|[^/]*\.(pem|key|p12)|[^/]*(secret|credential)[^/]*)$/i;
17
+
18
+ function fleetFile() {
19
+ return process.env.ATRIS_ZEN_FLEET_FILE || path.join(os.homedir(), '.atris', 'zen.json');
20
+ }
21
+
22
+ function expand(p) {
23
+ return path.resolve(p.replace(/^~(?=$|\/)/, os.homedir()));
24
+ }
25
+
26
+ function fleetRepos(paths = []) {
27
+ if (paths.length) return paths.map(expand);
28
+ try {
29
+ const listed = JSON.parse(fs.readFileSync(fleetFile(), 'utf8')).repos;
30
+ return Array.isArray(listed) ? listed.filter((p) => typeof p === 'string').map(expand) : [];
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ // Unsaved edits that look like live app data or secrets.
37
+ function riskyEdits(root) {
38
+ return dirtyEntries(root)
39
+ .flatMap((e) => (e.xy === '??' && e.path.endsWith('/') ? untrackedFiles(root, e.path) : [e.path]))
40
+ .filter((p) => LIVE_DATA.some((ext) => p.endsWith(ext)) || SECRET.test(p));
41
+ }
42
+
43
+ function home(p) {
44
+ const dir = os.homedir();
45
+ return p === dir || p.startsWith(dir + path.sep) ? `~${p.slice(dir.length)}` : p;
46
+ }
47
+
48
+ function zenOne(repo, fetch) {
49
+ const name = path.basename(repo);
50
+ if (!fs.existsSync(repo)) return { repo, name, state: 'missing', text: `not found at ${home(repo)}`, yours: [] };
51
+ const risky = riskyEdits(repo);
52
+ if (risky.length) {
53
+ const shown = risky.slice(0, 3).join(', ') + (risky.length > 3 ? ` and ${risky.length - 3} more` : '');
54
+ return { repo, name, state: 'skipped', text: `skipped, unsaved live data or secrets: ${shown}`, yours: [] };
55
+ }
56
+ let result;
57
+ try {
58
+ result = applyZen({ cwd: repo, fetch });
59
+ } catch (err) {
60
+ return { repo, name, state: 'failed', text: `zen stopped: ${String(err.message || err).split('\n')[0]}`, yours: [] };
61
+ }
62
+ if (!result.ok) return { repo, name, state: 'failed', text: `can't start, ${result.error}`, yours: [] };
63
+ const did = [...result.done, ...(result.workspace?.done || [])];
64
+ let text = result.already ? 'already zen' : did.join(', ') || 'zen';
65
+ if (!result.zen) text += `; still out of place: ${result.remaining.join(', ')}`;
66
+ // Where your copy went and what was left alone matter as much as what was
67
+ // done, so they ride along under the repo line. Side copy paths stay out to
68
+ // keep the pane small; plain atris zen in that repo lists them.
69
+ const more = [...result.notes, ...result.left.filter((line) => !/^\s/.test(line))];
70
+ return { repo, name, state: result.zen ? 'zen' : 'almost', text, more, yours: result.workspace?.yours || [] };
71
+ }
72
+
73
+ function fleetZen({ repos, fetch = true } = {}) {
74
+ return repos.map((repo) => zenOne(repo, fetch));
75
+ }
76
+
77
+ function printFleet(results) {
78
+ const width = Math.max(...results.map((r) => r.name.length));
79
+ console.log('');
80
+ console.log(' atris zen, every repo');
81
+ console.log('');
82
+ for (const r of results) {
83
+ console.log(` ${r.name.padEnd(width)} ${r.text}`);
84
+ for (const line of r.more || []) console.log(` ${''.padEnd(width)} ${line}`);
85
+ }
86
+ const yours = results.flatMap((r) => r.yours.map((item) => ({ text: `${r.name}: ${item.text}`, cmd: item.cmd })));
87
+ if (yours.length) {
88
+ console.log('');
89
+ console.log(' for you');
90
+ const w = Math.max(...yours.map((item) => item.text.length));
91
+ for (const item of yours) console.log(` ${item.text.padEnd(w)} ${item.cmd}`);
92
+ }
93
+ const calm = results.filter((r) => r.state === 'zen').length;
94
+ const skipped = results.filter((r) => r.state === 'skipped').length;
95
+ console.log('');
96
+ console.log(` ${calm} of ${results.length} repos zen${skipped ? `, ${skipped} skipped for you to look at` : ''}.`);
97
+ console.log('');
98
+ }
99
+
100
+ module.exports = { fleetFile, fleetRepos, fleetZen, printFleet, riskyEdits };
package/lib/zen.js CHANGED
@@ -91,6 +91,14 @@ function dirtyPaths(root) {
91
91
  return dirtyEntries(root).map((entry) => entry.path);
92
92
  }
93
93
 
94
+ // Changed files with untracked folders opened up, so a live database inside
95
+ // a new folder is seen as live data, not as part of a stray folder.
96
+ function dirtyFiles(root) {
97
+ return dirtyEntries(root).flatMap((entry) => (entry.xy === '??' && entry.path.endsWith('/')
98
+ ? untrackedFiles(root, entry.path)
99
+ : [entry.path]));
100
+ }
101
+
94
102
  function listWorktrees(root) {
95
103
  const out = gitOut(['worktree', 'list', '--porcelain'], root) || '';
96
104
  const worktrees = [];
@@ -111,14 +119,41 @@ function listWorktrees(root) {
111
119
  return worktrees;
112
120
  }
113
121
 
122
+ function expandHome(text) {
123
+ const home = os.homedir();
124
+ return text.replace(/\$\{?HOME\}?/g, home).replace(/(^|[\s"'=:])~\//g, `$1${home}/`);
125
+ }
126
+
127
+ // A job often runs a script that names the side copy, not the copy itself,
128
+ // so the scripts a schedule runs are read too, one level down. Logs are left
129
+ // out, and so is anything large or binary.
130
+ function withScripts(text) {
131
+ const seen = new Set();
132
+ const parts = [text];
133
+ for (const [file] of expandHome(text).matchAll(/\/[^\s"'<>;|&()]+/g)) {
134
+ if (seen.has(file) || file.endsWith('.log')) continue;
135
+ seen.add(file);
136
+ try {
137
+ const stat = fs.statSync(file);
138
+ if (!stat.isFile() || stat.size > 256 * 1024) continue;
139
+ const body = fs.readFileSync(file, 'utf8');
140
+ if (!body.includes('\0')) parts.push(body);
141
+ } catch {
142
+ // Not a readable file.
143
+ }
144
+ }
145
+ return parts.join('\n');
146
+ }
147
+
114
148
  // Text of the jobs this machine runs on a schedule: the user crontab and
115
- // launchd agents. A side copy named there is in use even when it looks idle.
149
+ // launchd agents, plus the scripts they run. A side copy named there is in
150
+ // use even when it looks idle.
116
151
  // ATRIS_ZEN_SCHEDULE_FILE points at a file to read instead (tests).
117
152
  function scheduleText() {
118
153
  const override = process.env.ATRIS_ZEN_SCHEDULE_FILE;
119
154
  if (override) {
120
155
  try {
121
- return fs.readFileSync(override, 'utf8');
156
+ return withScripts(fs.readFileSync(override, 'utf8'));
122
157
  } catch {
123
158
  return '';
124
159
  }
@@ -134,13 +169,12 @@ function scheduleText() {
134
169
  } catch {
135
170
  // No launchd agents.
136
171
  }
137
- return parts.join('\n');
172
+ return withScripts(parts.join('\n'));
138
173
  }
139
174
 
140
175
  function scheduledCopies(worktrees, text = scheduleText()) {
141
176
  if (!text) return new Set();
142
- const home = os.homedir();
143
- const expanded = text.replace(/\$\{?HOME\}?/g, home).replace(/(^|[\s"'=:])~\//g, `$1${home}/`);
177
+ const expanded = expandHome(text);
144
178
  const used = new Set();
145
179
  for (const wt of worktrees) {
146
180
  const at = path.resolve(wt.path);
@@ -279,7 +313,7 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
279
313
  }
280
314
 
281
315
  const teamState = readTeamState(root);
282
- const dirty = dirtyPaths(root);
316
+ const dirty = dirtyFiles(root);
283
317
  const stray = dirty.filter((file) => !isTeamState(file, teamState));
284
318
  const teamFiles = dirty.length - stray.length;
285
319
  if (stray.length) {
@@ -403,6 +437,7 @@ module.exports = {
403
437
  dirtyEntries,
404
438
  gitOut,
405
439
  isTeamState,
440
+ dirtyFiles,
406
441
  listWorktrees,
407
442
  readTeamState,
408
443
  scheduledCopies,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.72.5",
3
+ "version": "3.73.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {