@nurix/etna 0.19.64 → 0.19.66
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/etna.js +155 -557
- package/data/harness-index.json +1 -1
- package/data/harnesses/apollo/CLAUDE.md +1 -1
- package/data/harnesses/apollo/agents/maintenance.md +1 -0
- package/data/harnesses/apollo/harness.json +2 -2
- package/data/harnesses/apollo/hooks/approved-packages.md +2 -0
- package/data/harnesses/apollo/rules/changelog.md +2 -0
- package/data/harnesses/apollo/skills/documentation/SKILL.md +1 -0
- package/data/harnesses/apollo/skills/documentation/references/frontmatter.md +1 -1
- package/data/harnesses/apollo/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/apollo/skills/documentation/scripts/doc-lint.mjs +1 -1
- package/data/harnesses/apollo/skills/documentation/templates/Readme.md +1 -0
- package/data/harnesses/apollo/skills/documentation/templates/behavioral-checklist.md +2 -0
- package/data/harnesses/apollo/skills/documentation/templates/changelog-entry.md +2 -2
- package/data/harnesses/apollo/skills/documentation/templates/feature.md +1 -0
- package/data/harnesses/apollo/skills/documentation/templates/implementation-map.md +42 -0
- package/data/harnesses/apollo/skills/testing/SKILL.md +5 -5
- package/data/harnesses/code/CLAUDE.md +1 -1
- package/data/harnesses/code/agents/maintenance.md +1 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/approved-packages.md +2 -0
- package/data/harnesses/code/hooks/check-completion-integrity.mjs +8 -0
- package/data/harnesses/code/hooks/compliance.mjs +138 -27
- package/data/harnesses/code/hooks/lib/ledger.mjs +31 -0
- package/data/harnesses/code/hooks/lib/session-log.mjs +75 -0
- package/data/harnesses/code/rules/changelog.md +2 -0
- package/data/harnesses/code/skills/documentation/SKILL.md +1 -0
- package/data/harnesses/code/skills/documentation/references/frontmatter.md +1 -1
- package/data/harnesses/code/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +1 -1
- package/data/harnesses/code/skills/documentation/templates/Readme.md +1 -0
- package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +2 -0
- package/data/harnesses/code/skills/documentation/templates/changelog-entry.md +2 -2
- package/data/harnesses/code/skills/documentation/templates/feature.md +1 -0
- package/data/harnesses/code/skills/documentation/templates/implementation-map.md +42 -0
- package/data/harnesses/code/skills/testing/SKILL.md +5 -5
- package/data/harnesses/delivery/agents/maintenance.md +1 -0
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/rules/changelog.md +2 -0
- package/data/harnesses/delivery/skills/documentation/SKILL.md +1 -0
- package/data/harnesses/delivery/skills/documentation/references/frontmatter.md +1 -1
- package/data/harnesses/delivery/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +1 -1
- package/data/harnesses/delivery/skills/documentation/templates/Readme.md +1 -0
- package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +2 -0
- package/data/harnesses/delivery/skills/documentation/templates/changelog-entry.md +2 -2
- package/data/harnesses/delivery/skills/documentation/templates/feature.md +1 -0
- package/data/harnesses/delivery/skills/documentation/templates/implementation-map.md +42 -0
- package/data/harnesses/etna/agents/maintenance.md +1 -0
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/rules/changelog.md +2 -0
- package/data/harnesses/etna/skills/documentation/SKILL.md +1 -0
- package/data/harnesses/etna/skills/documentation/references/frontmatter.md +1 -1
- package/data/harnesses/etna/skills/documentation/references/style-guide.md +34 -1
- package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +1 -1
- package/data/harnesses/etna/skills/documentation/templates/Readme.md +1 -0
- package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +2 -0
- package/data/harnesses/etna/skills/documentation/templates/changelog-entry.md +2 -2
- package/data/harnesses/etna/skills/documentation/templates/feature.md +1 -0
- package/data/harnesses/etna/skills/documentation/templates/implementation-map.md +42 -0
- package/lib/install/materialize.mjs +402 -0
- package/lib/install/plan.mjs +271 -0
- package/package.json +2 -2
package/bin/etna.js
CHANGED
|
@@ -14,559 +14,157 @@
|
|
|
14
14
|
// Harnesses are addressed by their harness/<folder> name; several harnesses may
|
|
15
15
|
// share one plugin.
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
existsSync,
|
|
19
|
-
mkdirSync,
|
|
20
|
-
readFileSync,
|
|
21
|
-
writeFileSync,
|
|
22
|
-
renameSync,
|
|
23
|
-
cpSync,
|
|
24
|
-
readdirSync,
|
|
25
|
-
lstatSync,
|
|
26
|
-
rmSync,
|
|
27
|
-
} from 'node:fs';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
28
18
|
import { join, dirname, resolve } from 'node:path';
|
|
29
19
|
import { fileURLToPath } from 'node:url';
|
|
30
20
|
import { spawnSync } from 'node:child_process';
|
|
31
21
|
import * as p from '@clack/prompts';
|
|
32
|
-
import {
|
|
22
|
+
import { materializeHarness, readJson as readJsonOrThrow } from '../lib/install/materialize.mjs';
|
|
23
|
+
|
|
24
|
+
// WHERE A HARNESS'S FILES LAND is not decided here — `lib/install/materialize.mjs` owns that
|
|
25
|
+
// mapping, because the Apollo push ships the same tree and the two must never diverge. This
|
|
26
|
+
// file owns the CLI around it: argument parsing, the interactive pick, and the reporting.
|
|
33
27
|
|
|
34
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
35
29
|
const pkgRoot = resolve(__dirname, '..');
|
|
36
30
|
const dataDir = join(pkgRoot, 'data');
|
|
37
31
|
|
|
38
|
-
// Subfolders bundled inside each harness payload that map 1:1 onto
|
|
39
|
-
// <project>/.claude/<name>/. Order matches Claude Code's own folder layout.
|
|
40
|
-
// hooks/ carries the hook scripts + lib/ (and hooks.json, copied harmlessly —
|
|
41
|
-
// the settings merge reads it from the bundle, not the project copy).
|
|
42
|
-
const PLUGIN_SUBDIRS = ['skills', 'agents', 'rules', 'scripts', 'hooks'];
|
|
43
|
-
|
|
44
32
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
45
33
|
|
|
46
34
|
// True when both stdin and stdout are a terminal — clack prompts can't resolve
|
|
47
35
|
// otherwise (CI, piped stdin), so the no-arg pick must fall back to an error.
|
|
48
36
|
function isInteractive() {
|
|
49
|
-
|
|
37
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
50
38
|
}
|
|
51
39
|
|
|
52
40
|
// Boxed list of installable harnesses (name + description), for --help and error paths.
|
|
53
41
|
function printHarnessList(harnesses) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
42
|
+
const entries = Object.entries(harnesses);
|
|
43
|
+
if (!entries.length) {
|
|
44
|
+
p.log.warn('No harnesses are bundled. Run `npm run build:data` from the package root.');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const width = Math.max(...entries.map(([folder]) => folder.length));
|
|
48
|
+
const lines = entries.map(
|
|
49
|
+
([folder, meta]) =>
|
|
50
|
+
`${folder.padEnd(width)}${meta?.description ? ` ${meta.description}` : ''}`,
|
|
51
|
+
);
|
|
52
|
+
p.note(lines.join('\n'), 'Available harnesses');
|
|
64
53
|
}
|
|
65
54
|
|
|
66
55
|
function loadHarnessIndex() {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
56
|
+
const path = join(dataDir, 'harness-index.json');
|
|
57
|
+
if (!existsSync(path)) {
|
|
58
|
+
die('harness-index.json missing — run `npm run build:data` from the package root.');
|
|
59
|
+
}
|
|
60
|
+
return readJson(path);
|
|
72
61
|
}
|
|
73
62
|
|
|
74
63
|
function die(msg, code = 1) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function isSymlink(path) {
|
|
80
|
-
try {
|
|
81
|
-
return lstatSync(path).isSymbolicLink();
|
|
82
|
-
} catch {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Move <path> to <path>.bak.<timestamp> if it exists. Returns the backup path, or null.
|
|
88
|
-
function backupIfExists(path) {
|
|
89
|
-
if (!existsSync(path) && !isSymlink(path)) return null;
|
|
90
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
91
|
-
const backup = `${path}.bak.${ts}`;
|
|
92
|
-
renameSync(path, backup);
|
|
93
|
-
return backup;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Copy a directory to <path>.bak.<timestamp>, leaving the original in place. Returns the
|
|
97
|
-
// backup path, or null. Used for .claude/, which is refreshed in place (not overwritten
|
|
98
|
-
// wholesale), so the live tree must survive for the in-place update — snapshot, don't move.
|
|
99
|
-
function snapshotDirIfExists(path) {
|
|
100
|
-
if (!existsSync(path)) return null;
|
|
101
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
102
|
-
const backup = `${path}.bak.${ts}`;
|
|
103
|
-
cpSync(path, backup, { recursive: true });
|
|
104
|
-
return backup;
|
|
64
|
+
p.log.error(msg);
|
|
65
|
+
process.exit(code);
|
|
105
66
|
}
|
|
106
67
|
|
|
68
|
+
// The shared reader throws so it can serve the CI push too; the CLI turns that into its own
|
|
69
|
+
// one-line death rather than a stack trace.
|
|
107
70
|
function readJson(path) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 2-space indent matches Claude Code's own writes to settings.json.
|
|
117
|
-
function writeJson(path, value) {
|
|
118
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
119
|
-
writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Copy the harness's bundled .claude/<subdir>/ contents into <project>/.claude/<subdir>/,
|
|
123
|
-
// merging with whatever's already there. Existing same-named entries are overwritten so
|
|
124
|
-
// re-running `etna` always refreshes harness-provided content. Returns count copied.
|
|
125
|
-
function copyHarnessSubdir(bundledSubdir, projectSubdir) {
|
|
126
|
-
if (!existsSync(bundledSubdir)) return 0;
|
|
127
|
-
mkdirSync(projectSubdir, { recursive: true });
|
|
128
|
-
let copied = 0;
|
|
129
|
-
for (const entry of readdirSync(bundledSubdir, { withFileTypes: true })) {
|
|
130
|
-
const src = join(bundledSubdir, entry.name);
|
|
131
|
-
const dest = join(projectSubdir, entry.name);
|
|
132
|
-
cpSync(src, dest, { recursive: true, force: true });
|
|
133
|
-
copied += 1;
|
|
134
|
-
}
|
|
135
|
-
return copied;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Clean up any leftover marketplace/enabledPlugins entries from a previous 0.1.x
|
|
139
|
-
// install. After 0.2.0 we don't use the marketplace flow at all — leaving stale
|
|
140
|
-
// entries causes Claude Code to also resolve the plugin from the global cache,
|
|
141
|
-
// double-loading the same skills under a different namespace.
|
|
142
|
-
function migrateFromPluginInstall(targetDir, pluginName) {
|
|
143
|
-
const removed = [];
|
|
144
|
-
const projectSettingsPath = join(targetDir, '.claude', 'settings.json');
|
|
145
|
-
if (existsSync(projectSettingsPath)) {
|
|
146
|
-
const s = readJson(projectSettingsPath);
|
|
147
|
-
if (s.enabledPlugins) {
|
|
148
|
-
const keys = Object.keys(s.enabledPlugins).filter((k) =>
|
|
149
|
-
k.startsWith(`${pluginName}@`),
|
|
150
|
-
);
|
|
151
|
-
for (const k of keys) {
|
|
152
|
-
delete s.enabledPlugins[k];
|
|
153
|
-
removed.push(`enabledPlugins["${k}"] in ${projectSettingsPath}`);
|
|
154
|
-
}
|
|
155
|
-
if (Object.keys(s.enabledPlugins).length === 0) delete s.enabledPlugins;
|
|
156
|
-
writeJson(projectSettingsPath, s);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return removed;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Hook scripts shipped by earlier harness versions (the legacy memory-ledger tier) and since retired.
|
|
163
|
-
// mergeHooks is additive, so an upgrade must explicitly remove both the stale settings.json
|
|
164
|
-
// entries pointing at these and the leftover script files they reference.
|
|
165
|
-
const RETIRED_HOOKS = [
|
|
166
|
-
'session-stop.js',
|
|
167
|
-
'post-tool-failure.js',
|
|
168
|
-
'post-commit.js',
|
|
169
|
-
'generate-ownership-index.mjs', // folded into awareness.mjs — unretired, the index would inject twice
|
|
170
|
-
];
|
|
171
|
-
|
|
172
|
-
// Remove settings.json hook entries and .claude/hooks/ files for retired hook scripts.
|
|
173
|
-
// Bundle-aware: a name on the retired list is skipped while THIS bundle still ships it —
|
|
174
|
-
// several harnesses share the list, and they retire the same script at different versions.
|
|
175
|
-
function retireOldHooks(targetDir, bundledHarnessDir) {
|
|
176
|
-
const removed = [];
|
|
177
|
-
const bundledHooksDir = join(bundledHarnessDir, 'hooks');
|
|
178
|
-
const stillShipped = new Set(
|
|
179
|
-
existsSync(bundledHooksDir)
|
|
180
|
-
? readdirSync(bundledHooksDir, { withFileTypes: true })
|
|
181
|
-
.filter((e) => e.isFile())
|
|
182
|
-
.map((e) => e.name)
|
|
183
|
-
: [],
|
|
184
|
-
);
|
|
185
|
-
const retired = RETIRED_HOOKS.filter((name) => !stillShipped.has(name));
|
|
186
|
-
const settingsPath = join(targetDir, '.claude', 'settings.json');
|
|
187
|
-
if (existsSync(settingsPath)) {
|
|
188
|
-
const settings = readJson(settingsPath);
|
|
189
|
-
const isRetired = (h) =>
|
|
190
|
-
retired.some((name) => (h.command || '').includes(`/hooks/${name}`));
|
|
191
|
-
let pruned = 0;
|
|
192
|
-
for (const [event, groups] of Object.entries(settings.hooks || {})) {
|
|
193
|
-
for (const group of groups) {
|
|
194
|
-
const before = (group.hooks || []).length;
|
|
195
|
-
group.hooks = (group.hooks || []).filter((h) => !isRetired(h));
|
|
196
|
-
pruned += before - group.hooks.length;
|
|
197
|
-
}
|
|
198
|
-
settings.hooks[event] = groups.filter((g) => (g.hooks || []).length > 0);
|
|
199
|
-
if (settings.hooks[event].length === 0) delete settings.hooks[event];
|
|
200
|
-
}
|
|
201
|
-
if (pruned > 0) {
|
|
202
|
-
writeJson(settingsPath, settings);
|
|
203
|
-
removed.push(`${pruned} retired hook entr${pruned === 1 ? 'y' : 'ies'} in settings.json`);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
for (const name of retired) {
|
|
207
|
-
const file = join(targetDir, '.claude', 'hooks', name);
|
|
208
|
-
if (existsSync(file)) {
|
|
209
|
-
rmSync(file, { force: true });
|
|
210
|
-
removed.push(`stale hook script ${name}`);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
return removed;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Component subdirs whose membership is tracked so a removed-from-the-harness component
|
|
217
|
-
// can be pruned on refresh. Hooks are excluded — retireOldHooks owns them (it must also
|
|
218
|
-
// prune the matching settings.json entry, which a name-only diff can't reach).
|
|
219
|
-
const PRUNABLE_SUBDIRS = ['skills', 'agents', 'rules', 'scripts'];
|
|
220
|
-
|
|
221
|
-
// The set of component names the bundle ships, per prunable subdir — the manifest of what
|
|
222
|
-
// THIS harness owns. Written into the installed harness.json so the next refresh can diff.
|
|
223
|
-
function bundledOwned(bundledHarnessDir) {
|
|
224
|
-
const owned = {};
|
|
225
|
-
for (const sub of PRUNABLE_SUBDIRS) {
|
|
226
|
-
const dir = join(bundledHarnessDir, sub);
|
|
227
|
-
owned[sub] = existsSync(dir) ? readdirSync(dir).sort() : [];
|
|
228
|
-
}
|
|
229
|
-
return owned;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Read the `owned` manifest the PREVIOUS install recorded in the project's harness.json.
|
|
233
|
-
// Absent (first install, or pre-pruning version) ⇒ {} ⇒ nothing is pruned this round.
|
|
234
|
-
function readInstalledOwned(targetDir) {
|
|
235
|
-
const path = join(targetDir, '.claude', 'harness.json');
|
|
236
|
-
if (!existsSync(path)) return {};
|
|
237
|
-
const owned = readJson(path).owned;
|
|
238
|
-
return owned && typeof owned === 'object' ? owned : {};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Remove vendored components a prior harness version owned but this one no longer ships
|
|
242
|
-
// (e.g. a retired agent like harness-manager). Only names present in the PRIOR `owned`
|
|
243
|
-
// manifest are eligible, so a project's own components — never in that manifest — survive.
|
|
244
|
-
function pruneOrphanedComponents(targetDir, priorOwned, newOwned) {
|
|
245
|
-
const removed = [];
|
|
246
|
-
for (const sub of PRUNABLE_SUBDIRS) {
|
|
247
|
-
const had = priorOwned[sub] || [];
|
|
248
|
-
const has = new Set(newOwned[sub] || []);
|
|
249
|
-
for (const name of had) {
|
|
250
|
-
if (has.has(name)) continue;
|
|
251
|
-
const target = join(targetDir, '.claude', sub, name);
|
|
252
|
-
if (existsSync(target)) {
|
|
253
|
-
rmSync(target, { recursive: true, force: true });
|
|
254
|
-
removed.push(`orphaned ${sub.replace(/s$/, '')} ${name}`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
return removed;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Every file inside dir, as paths relative to dir.
|
|
262
|
-
function listFilesRecursive(dir, prefix = '') {
|
|
263
|
-
const out = [];
|
|
264
|
-
if (!existsSync(dir)) return out;
|
|
265
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
266
|
-
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
267
|
-
if (entry.isDirectory()) out.push(...listFilesRecursive(join(dir, entry.name), rel));
|
|
268
|
-
else out.push(rel);
|
|
269
|
-
}
|
|
270
|
-
return out;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Remove directories left empty by a prune, depth-first.
|
|
274
|
-
function removeEmptyDirs(dir) {
|
|
275
|
-
if (!existsSync(dir)) return;
|
|
276
|
-
for (const entry of readdirSync(dir, { withFileTypes: true }))
|
|
277
|
-
if (entry.isDirectory()) removeEmptyDirs(join(dir, entry.name));
|
|
278
|
-
if (readdirSync(dir).length === 0) rmSync(dir, { recursive: true, force: true });
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Remove files inside an installed component that this bundle no longer ships. copyHarnessSubdir
|
|
282
|
-
// merges, so a component that SURVIVES an upgrade while shedding a file keeps the stale copy
|
|
283
|
-
// forever — that is how a retired reference file goes on being read as live doctrine. Scoped to
|
|
284
|
-
// components the bundle still ships (a project's own components are never in that set, so they
|
|
285
|
-
// are never touched), and needs no prior manifest, so one refresh clears orphans left by any
|
|
286
|
-
// earlier version. Whole components that disappear are pruneOrphanedComponents' job.
|
|
287
|
-
function pruneStaleComponentFiles(bundledSubdir, projectSubdir) {
|
|
288
|
-
const removed = [];
|
|
289
|
-
if (!existsSync(bundledSubdir)) return removed;
|
|
290
|
-
for (const entry of readdirSync(bundledSubdir, { withFileTypes: true })) {
|
|
291
|
-
if (!entry.isDirectory()) continue;
|
|
292
|
-
const shipped = new Set(listFilesRecursive(join(bundledSubdir, entry.name)));
|
|
293
|
-
const installedDir = join(projectSubdir, entry.name);
|
|
294
|
-
for (const rel of listFilesRecursive(installedDir)) {
|
|
295
|
-
if (shipped.has(rel)) continue;
|
|
296
|
-
rmSync(join(installedDir, rel), { force: true });
|
|
297
|
-
removed.push(`${entry.name}/${rel}`);
|
|
298
|
-
}
|
|
299
|
-
removeEmptyDirs(installedDir);
|
|
300
|
-
}
|
|
301
|
-
return removed;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// True if an event's matcher-group already carries a hook with this command string.
|
|
305
|
-
function groupHasCommand(group, command) {
|
|
306
|
-
return (group.hooks || []).some((h) => h.command === command);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Deep-merge the bundle's hooks.json object into <project>/.claude/settings.json under
|
|
310
|
-
// "hooks", preserving every existing key. Idempotent: for each event, an incoming entry
|
|
311
|
-
// merges into the matcher-group of the same matcher, skipping any hook whose command
|
|
312
|
-
// already exists there; a new matcher creates a new group. Returns count of hooks added.
|
|
313
|
-
function mergeHooks(targetDir, bundledHooksJson) {
|
|
314
|
-
if (!existsSync(bundledHooksJson)) return 0;
|
|
315
|
-
const bundled = readJson(bundledHooksJson);
|
|
316
|
-
const incoming = bundled.hooks || {};
|
|
317
|
-
const settingsPath = join(targetDir, '.claude', 'settings.json');
|
|
318
|
-
const settings = readJson(settingsPath);
|
|
319
|
-
settings.hooks = settings.hooks || {};
|
|
320
|
-
let added = 0;
|
|
321
|
-
// Harness-shipped permission defaults (e.g. Edit on docs/** flows without prompts —
|
|
322
|
-
// the deny hooks still hard-block frozen/protected paths underneath). Additive, idempotent.
|
|
323
|
-
for (const rule of bundled.permissions?.allow || []) {
|
|
324
|
-
settings.permissions = settings.permissions || {};
|
|
325
|
-
const allowList = (settings.permissions.allow = settings.permissions.allow || []);
|
|
326
|
-
if (!allowList.includes(rule)) {
|
|
327
|
-
allowList.push(rule);
|
|
328
|
-
added += 1;
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
for (const [event, groups] of Object.entries(incoming)) {
|
|
332
|
-
const existingGroups = (settings.hooks[event] = settings.hooks[event] || []);
|
|
333
|
-
for (const group of groups) {
|
|
334
|
-
const match = existingGroups.find((g) => g.matcher === group.matcher);
|
|
335
|
-
if (!match) {
|
|
336
|
-
existingGroups.push(group);
|
|
337
|
-
added += (group.hooks || []).length;
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
match.hooks = match.hooks || [];
|
|
341
|
-
for (const hook of group.hooks || []) {
|
|
342
|
-
if (groupHasCommand(match, hook.command)) continue;
|
|
343
|
-
match.hooks.push(hook);
|
|
344
|
-
added += 1;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
if (added > 0) writeJson(settingsPath, settings);
|
|
349
|
-
return added;
|
|
71
|
+
try {
|
|
72
|
+
return readJsonOrThrow(path);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
die(e.message);
|
|
75
|
+
}
|
|
350
76
|
}
|
|
351
77
|
|
|
352
78
|
// Resolve the harness to install: the --name value, or — in a TTY — an interactive pick
|
|
353
79
|
// showing each harness's description. Returns the chosen folder, or null on cancel.
|
|
354
80
|
async function resolveHarnessName(name, harnesses) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
81
|
+
const names = Object.keys(harnesses);
|
|
82
|
+
if (name !== null) {
|
|
83
|
+
if (!name || !harnesses[name]) {
|
|
84
|
+
p.log.error(name ? `Unknown harness '${name}'.` : 'Empty --name value.');
|
|
85
|
+
printHarnessList(harnesses);
|
|
86
|
+
p.outro('Pass --name=<harness> with one of the names above.');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
return name;
|
|
90
|
+
}
|
|
91
|
+
if (!isInteractive()) {
|
|
92
|
+
p.log.error('No --name given, and not running in an interactive terminal.');
|
|
93
|
+
printHarnessList(harnesses);
|
|
94
|
+
p.outro('Re-run as `npx @nurix/etna --name=<harness>`.');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const choice = await p.select({
|
|
98
|
+
message: 'Which harness do you want to install here?',
|
|
99
|
+
options: names.map((folder) => ({
|
|
100
|
+
value: folder,
|
|
101
|
+
label: folder,
|
|
102
|
+
hint: harnesses[folder]?.description || undefined,
|
|
103
|
+
})),
|
|
104
|
+
});
|
|
105
|
+
if (p.isCancel(choice)) {
|
|
106
|
+
p.cancel('Cancelled — nothing installed.');
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return choice;
|
|
384
110
|
}
|
|
385
111
|
|
|
386
112
|
// Copy one harness's bundled payload into the current directory. All file ops run
|
|
387
113
|
// inside a spinner; the per-item detail + any warnings are emitted after it stops.
|
|
388
114
|
async function installHarness(harnessName, harnesses) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const installed = [];
|
|
412
|
-
const notices = [];
|
|
413
|
-
const warnings = [];
|
|
414
|
-
|
|
415
|
-
// CLAUDE.md ships frontmatter-free, so it installs as a verbatim copy — no stripping.
|
|
416
|
-
// It's harness-owned and overwritten in place. If the project is a git repo the old copy
|
|
417
|
-
// is recoverable from history, so we skip the backup; if it isn't, there's no safety net,
|
|
418
|
-
// so we back it up and tell the user. (Backup applies to CLAUDE.md only.)
|
|
419
|
-
const claudeExisted = existsSync(claudeMdDest) || isSymlink(claudeMdDest);
|
|
420
|
-
const projectHasGit = existsSync(join(targetDir, '.git'));
|
|
421
|
-
if (claudeExisted) {
|
|
422
|
-
if (projectHasGit) {
|
|
423
|
-
notices.push('Overwrote existing CLAUDE.md (tracked by git — `git checkout CLAUDE.md` to restore).');
|
|
424
|
-
} else {
|
|
425
|
-
const claudeBackup = backupIfExists(claudeMdDest);
|
|
426
|
-
warnings.push(
|
|
427
|
-
`No .git here, so no version-control safety net — backed up existing CLAUDE.md → ${claudeBackup}`,
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
cpSync(bundledClaudeMd, claudeMdDest, { force: true });
|
|
432
|
-
installed.push('CLAUDE.md → ./');
|
|
433
|
-
|
|
434
|
-
// Same safety net for the .claude/ tree, mirroring the CLAUDE.md rule above. The tree is
|
|
435
|
-
// refreshed in place — owned files overwritten, orphaned owned components pruned — so a
|
|
436
|
-
// user's edits to owned files and any pruned component are the loss surface. A git repo
|
|
437
|
-
// recovers them from history, so we skip the backup; without git there's no net, so we
|
|
438
|
-
// snapshot the whole tree first and tell the user. (Snapshot, not move: the live tree has
|
|
439
|
-
// to stay for the in-place refresh below.)
|
|
440
|
-
const claudeDir = join(targetDir, '.claude');
|
|
441
|
-
if (existsSync(claudeDir)) {
|
|
442
|
-
if (projectHasGit) {
|
|
443
|
-
notices.push('Refreshed existing .claude/ in place (tracked by git — `git checkout .claude` to restore).');
|
|
444
|
-
} else {
|
|
445
|
-
const claudeDirBackup = snapshotDirIfExists(claudeDir);
|
|
446
|
-
if (claudeDirBackup) {
|
|
447
|
-
warnings.push(
|
|
448
|
-
`No .git here, so no version-control safety net — snapshotted existing .claude/ → ${claudeDirBackup}`,
|
|
449
|
-
);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// What a prior install recorded as harness-owned — read before we overwrite the marker,
|
|
455
|
-
// so we can prune anything this bundle has since dropped.
|
|
456
|
-
const priorOwned = readInstalledOwned(targetDir);
|
|
457
|
-
|
|
458
|
-
for (const sub of PLUGIN_SUBDIRS) {
|
|
459
|
-
const count = copyHarnessSubdir(join(bundledHarnessDir, sub), join(targetDir, '.claude', sub));
|
|
460
|
-
if (count > 0) installed.push(`${count} ${sub} → .claude/${sub}/`);
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// Prune components a prior harness version owned but this bundle no longer ships (e.g. a
|
|
464
|
-
// retired agent). Only prior-owned names are eligible, so the user's own files survive.
|
|
465
|
-
const newOwned = bundledOwned(bundledHarnessDir);
|
|
466
|
-
for (const r of pruneOrphanedComponents(targetDir, priorOwned, newOwned)) notices.push(`removed ${r}`);
|
|
467
|
-
|
|
468
|
-
// Then prune stale files INSIDE components the bundle still ships — the case the
|
|
469
|
-
// name-level diff above cannot see, since the component itself is still present.
|
|
470
|
-
for (const sub of PRUNABLE_SUBDIRS) {
|
|
471
|
-
const stale = pruneStaleComponentFiles(
|
|
472
|
-
join(bundledHarnessDir, sub),
|
|
473
|
-
join(targetDir, '.claude', sub),
|
|
474
|
-
);
|
|
475
|
-
for (const r of stale) notices.push(`removed stale ${sub.replace(/s$/, '')} file ${r}`);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// Dual-runtime: derive the repo's Codex artifacts (AGENTS.md, .agents/skills/,
|
|
479
|
-
// .codex/agents/, .codex/hooks.json) from the just-installed Claude tree + bundle, so the
|
|
480
|
-
// same project works under Codex too. Pure-derived — no MCP, no settings/config.toml
|
|
481
|
-
// (owner decision). See docs/features/codex-support.md. The `codex` owned-list lets a
|
|
482
|
-
// future version prune a Codex target it stops shipping.
|
|
483
|
-
const codex = projectCodexArtifacts({
|
|
484
|
-
targetDir,
|
|
485
|
-
bundledHarnessDir,
|
|
486
|
-
bundledClaudeMd,
|
|
487
|
-
projectHasGit,
|
|
488
|
-
priorCodexOwned: priorOwned.codex || [],
|
|
115
|
+
const bundledHarnessDir = join(dataDir, 'harnesses', harnessName);
|
|
116
|
+
const targetDir = process.cwd();
|
|
117
|
+
|
|
118
|
+
// Bundled harness marker — names the version for the banner. Everything else it carries
|
|
119
|
+
// (the `files` and `seeds` lists) is read by materializeHarness, which owns placement.
|
|
120
|
+
const harnessMeta = readJson(join(bundledHarnessDir, 'harness.json'));
|
|
121
|
+
const versionTag = harnessMeta.version ? ` v${harnessMeta.version}` : '';
|
|
122
|
+
// The underlying plugin name — may differ from the harness name, and several harnesses may
|
|
123
|
+
// share it. Used only for legacy marketplace-entry cleanup.
|
|
124
|
+
const pluginName = harnessMeta.name || harnesses[harnessName]?.plugin || harnessName;
|
|
125
|
+
|
|
126
|
+
const spin = p.spinner();
|
|
127
|
+
spin.start(`Installing ${harnessName}${versionTag} into ${targetDir}`);
|
|
128
|
+
|
|
129
|
+
let result;
|
|
130
|
+
try {
|
|
131
|
+
result = materializeHarness({
|
|
132
|
+
bundledHarnessDir,
|
|
133
|
+
targetDir,
|
|
134
|
+
pluginName,
|
|
135
|
+
projectHasGit: existsSync(join(targetDir, '.git')),
|
|
489
136
|
});
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
// Write the harness marker — plus the `owned` manifest the next refresh diffs against —
|
|
496
|
-
// so the maintenance agent (baseline mode) can resolve the installed version.
|
|
497
|
-
if (existsSync(bundledHarnessJson)) {
|
|
498
|
-
const harnessJsonDest = join(targetDir, '.claude', 'harness.json');
|
|
499
|
-
mkdirSync(dirname(harnessJsonDest), { recursive: true });
|
|
500
|
-
const marker = readJson(bundledHarnessJson);
|
|
501
|
-
marker.owned = newOwned;
|
|
502
|
-
writeJson(harnessJsonDest, marker);
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Verbatim project-root files bundled outside the plugin route (.prettierrc.json,
|
|
506
|
-
// .vscode/settings.json, …), listed in harness.json's `files`. They land at the project
|
|
507
|
-
// root at their relative path — NOT under .claude/ — and overwrite unconditionally with no
|
|
508
|
-
// backup: the harness owns this config, and re-running etna refreshes it.
|
|
509
|
-
for (const rel of harnessMeta.files || []) {
|
|
510
|
-
const src = join(bundledHarnessDir, rel);
|
|
511
|
-
if (!existsSync(src)) continue;
|
|
512
|
-
const dest = join(targetDir, rel);
|
|
513
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
514
|
-
cpSync(src, dest, { recursive: true, force: true });
|
|
515
|
-
installed.push(`${rel} → ./${rel}`);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Seed files listed in harness.json's `seeds` (e.g. a pre-baked docs/ scaffold). Same
|
|
519
|
-
// project-root landing as `files`, opposite ownership: a seed is written only when its
|
|
520
|
-
// path is absent. Once present it is the project's living copy — a refresh never
|
|
521
|
-
// overwrites it and never prunes it.
|
|
522
|
-
for (const rel of harnessMeta.seeds || []) {
|
|
523
|
-
const src = join(bundledHarnessDir, rel);
|
|
524
|
-
if (!existsSync(src)) continue;
|
|
525
|
-
const dest = join(targetDir, rel);
|
|
526
|
-
if (existsSync(dest)) continue;
|
|
527
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
528
|
-
cpSync(src, dest, { recursive: true });
|
|
529
|
-
installed.push(`${rel} → ./${rel} (seeded)`);
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Retire hooks earlier harness versions shipped before merging, so the additive merge
|
|
533
|
-
// never resurrects an entry pointing at a script this bundle no longer carries.
|
|
534
|
-
for (const r of retireOldHooks(targetDir, bundledHarnessDir)) notices.push(`removed ${r}`);
|
|
535
|
-
|
|
536
|
-
// Merge the bundle's hooks.json into the project's settings.json (idempotent).
|
|
537
|
-
const added = mergeHooks(targetDir, join(bundledHarnessDir, 'hooks', 'hooks.json'));
|
|
538
|
-
if (added > 0) installed.push(`${added} hook(s) → .claude/settings.json`);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
spin.stop(`Could not install ${harnessName}`);
|
|
139
|
+
die(error.message);
|
|
140
|
+
}
|
|
539
141
|
|
|
540
|
-
|
|
541
|
-
notices.push(`removed stale ${entry}`);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
spin.stop(`Installed ${harnessName}${versionTag}`);
|
|
142
|
+
spin.stop(`Installed ${harnessName}${versionTag}`);
|
|
545
143
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
144
|
+
p.note(result.installed.join('\n'), 'Written');
|
|
145
|
+
for (const notice of result.notices) p.log.info(notice);
|
|
146
|
+
for (const warning of result.warnings) p.log.warn(warning);
|
|
549
147
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
148
|
+
p.outro(
|
|
149
|
+
`${harnessName}${versionTag} ready in ${targetDir}. ` +
|
|
150
|
+
`Re-run \`npx @nurix/etna --name=${harnessName}\` anytime to refresh harness content.`,
|
|
151
|
+
);
|
|
554
152
|
}
|
|
555
153
|
|
|
556
154
|
// Parse argv. `--name=<harness>` / `--name <harness>` selects a harness; -h/--help shows
|
|
557
155
|
// help; any other bare token is the dropped positional form (a breaking change in 0.18.0).
|
|
558
156
|
function parseArgs(args) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
157
|
+
let name = null;
|
|
158
|
+
let help = false;
|
|
159
|
+
let positional = null;
|
|
160
|
+
for (let i = 0; i < args.length; i++) {
|
|
161
|
+
const a = args[i];
|
|
162
|
+
if (a === '-h' || a === '--help') help = true;
|
|
163
|
+
else if (a.startsWith('--name=')) name = a.slice('--name='.length);
|
|
164
|
+
else if (a === '--name') name = args[++i] ?? '';
|
|
165
|
+
else if (!a.startsWith('-') && positional === null) positional = a;
|
|
166
|
+
}
|
|
167
|
+
return { name, help, positional };
|
|
570
168
|
}
|
|
571
169
|
|
|
572
170
|
// npx caches aggressively: a bare `npx @nurix/etna` can re-run a STALE cached copy
|
|
@@ -582,62 +180,62 @@ function parseArgs(args) {
|
|
|
582
180
|
// falls through to running THIS copy. @nurix/etna publishes with public access, so the
|
|
583
181
|
// `npm view` and the re-exec `npx` both resolve unauthenticated — no npm token needed.
|
|
584
182
|
function selfUpdateOrReexec() {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
183
|
+
if (process.env.ETNA_SELFUPDATED || process.env.ETNA_NO_SELFUPDATE) return;
|
|
184
|
+
const here = readJson(join(pkgRoot, 'package.json')).version;
|
|
185
|
+
if (!here) return;
|
|
186
|
+
const view = spawnSync('npm', ['view', '@nurix/etna', 'version', '--prefer-online'], {
|
|
187
|
+
encoding: 'utf8',
|
|
188
|
+
});
|
|
189
|
+
const latest = view.status === 0 ? (view.stdout || '').trim() : null;
|
|
190
|
+
if (!latest || latest === here) return; // current, offline, or unauthed → run this copy
|
|
191
|
+
const child = spawnSync(
|
|
192
|
+
'npx',
|
|
193
|
+
['-y', '--prefer-online', `@nurix/etna@${latest}`, ...process.argv.slice(2)],
|
|
194
|
+
{ stdio: 'inherit', env: { ...process.env, ETNA_SELFUPDATED: '1' } },
|
|
195
|
+
);
|
|
196
|
+
if (!child.error) process.exit(child.status ?? 0); // re-exec ran → hand off complete
|
|
197
|
+
// npx unavailable (child.error) → fall through and run this (older) copy.
|
|
600
198
|
}
|
|
601
199
|
|
|
602
200
|
async function main(args) {
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (help) {
|
|
608
|
-
p.intro('@nurix/etna — Nurix harness installer');
|
|
609
|
-
p.log.message(
|
|
610
|
-
'Usage:\n' +
|
|
611
|
-
' npx @nurix/etna pick a harness from the menu\n' +
|
|
612
|
-
' npx @nurix/etna --name=<harness> install that harness directly',
|
|
613
|
-
);
|
|
614
|
-
printHarnessList(harnesses);
|
|
615
|
-
p.outro('No args → interactive menu. --name=<harness> → direct install.');
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
201
|
+
const idx = loadHarnessIndex();
|
|
202
|
+
const harnesses = idx.harnesses || {};
|
|
203
|
+
const { name, help, positional } = parseArgs(args);
|
|
618
204
|
|
|
205
|
+
if (help) {
|
|
619
206
|
p.intro('@nurix/etna — Nurix harness installer');
|
|
207
|
+
p.log.message(
|
|
208
|
+
'Usage:\n' +
|
|
209
|
+
' npx @nurix/etna pick a harness from the menu\n' +
|
|
210
|
+
' npx @nurix/etna --name=<harness> install that harness directly',
|
|
211
|
+
);
|
|
212
|
+
printHarnessList(harnesses);
|
|
213
|
+
p.outro('No args → interactive menu. --name=<harness> → direct install.');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
p.intro('@nurix/etna — Nurix harness installer');
|
|
218
|
+
|
|
219
|
+
// Breaking change (0.18.0): the positional form `etna <harness>` was replaced by
|
|
220
|
+
// `--name=<harness>`. Fail loudly with the new syntax rather than silently misfiring.
|
|
221
|
+
if (positional && name === null) {
|
|
222
|
+
p.log.error(
|
|
223
|
+
`Positional arguments are no longer supported. Install with \`npx @nurix/etna --name=${positional}\`.`,
|
|
224
|
+
);
|
|
225
|
+
printHarnessList(harnesses);
|
|
226
|
+
p.outro('Use --name=<harness> to install, or no args to pick from the menu.');
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
620
229
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
`Positional arguments are no longer supported. Install with \`npx @nurix/etna --name=${positional}\`.`,
|
|
626
|
-
);
|
|
627
|
-
printHarnessList(harnesses);
|
|
628
|
-
p.outro('Use --name=<harness> to install, or no args to pick from the menu.');
|
|
629
|
-
process.exit(1);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
const boot = p.spinner();
|
|
633
|
-
boot.start('Starting up');
|
|
634
|
-
await sleep(2000);
|
|
635
|
-
boot.stop('Ready');
|
|
230
|
+
const boot = p.spinner();
|
|
231
|
+
boot.start('Starting up');
|
|
232
|
+
await sleep(2000);
|
|
233
|
+
boot.stop('Ready');
|
|
636
234
|
|
|
637
|
-
|
|
638
|
-
|
|
235
|
+
const harnessName = await resolveHarnessName(name, harnesses);
|
|
236
|
+
if (!harnessName) return; // user cancelled the interactive pick
|
|
639
237
|
|
|
640
|
-
|
|
238
|
+
await installHarness(harnessName, harnesses);
|
|
641
239
|
}
|
|
642
240
|
|
|
643
241
|
// Before anything else: if a newer @nurix/etna is published, re-exec it (so a stale
|
|
@@ -645,6 +243,6 @@ async function main(args) {
|
|
|
645
243
|
selfUpdateOrReexec();
|
|
646
244
|
|
|
647
245
|
main(process.argv.slice(2)).catch((e) => {
|
|
648
|
-
|
|
649
|
-
|
|
246
|
+
p.log.error(e?.stack || String(e));
|
|
247
|
+
process.exit(1);
|
|
650
248
|
});
|