@worca/app 0.0.1
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/README.md +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// src/core/pipeline-delete.mjs
|
|
2
|
+
// ARCHIVE a finished pipeline: reclaim everything that costs disk — its store
|
|
3
|
+
// folder, its shared plan/review markdown (resolved EXACTLY via the artifacts
|
|
4
|
+
// index, no more baseName guessing), its detached run root, and its local branch
|
|
5
|
+
// + worktree — then SOFT-DELETE the DB row by stamping archived_at. The remote
|
|
6
|
+
// branch is never touched. Best-effort on git: filesystem store removal always
|
|
7
|
+
// proceeds; git failures are reported as warnings, not thrown.
|
|
8
|
+
//
|
|
9
|
+
// The pipelines row is PERMANENT: it is the run's statistical record (cost,
|
|
10
|
+
// duration, status), so it is never DELETEd and no FK ON DELETE CASCADE fires.
|
|
11
|
+
// Its steps/events/clarify/reviews children stay for the same reason. The only
|
|
12
|
+
// child rows removed are `artifacts` — that index points at the exact files this
|
|
13
|
+
// function just unlinked, so leaving it would strand dead pointers.
|
|
14
|
+
// List and count reads filter on `archived_at IS NULL`; by-id reads still resolve.
|
|
15
|
+
|
|
16
|
+
import { rm, mkdir } from 'node:fs/promises';
|
|
17
|
+
import { existsSync } from 'node:fs';
|
|
18
|
+
import { join, isAbsolute } from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { projectKey, projectStorePath } from './store.mjs';
|
|
21
|
+
import {
|
|
22
|
+
listArtifacts, readPipelineByKey, persistPrState, retainedWorkFor,
|
|
23
|
+
recordArtifact, appendAudit, findRunDir,
|
|
24
|
+
} from './artifacts.mjs';
|
|
25
|
+
import { worcaHome } from './projects.mjs';
|
|
26
|
+
import { getDb, tx } from './db.mjs';
|
|
27
|
+
import { removeWorktree, snapshotWorktreePatch } from './worktree.mjs';
|
|
28
|
+
import {
|
|
29
|
+
rmGuarded, readRunManifest, rescueModifiedMounts, scanStrayEntries, copyRunManifestTo,
|
|
30
|
+
} from './run-manifest.mjs';
|
|
31
|
+
import { branchExists, hasGh, findPrForBranch } from './git-info.mjs';
|
|
32
|
+
import { retainedWorkPatchName } from './results.mjs';
|
|
33
|
+
|
|
34
|
+
// Statuses for which deletion is refused (the entry is or may be live).
|
|
35
|
+
const ACTIVE = new Set(['running', 'starting', 'created', 'pausing']);
|
|
36
|
+
function err(message, code) { return Object.assign(new Error(message), { code }); }
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the pipelines row for a store key + (short id | run-dir basename). Mirrors
|
|
40
|
+
* artifacts.lookupPipelineRow's WHERE logic: exact id first, then the 8-hex id parsed
|
|
41
|
+
* from a run-dir basename. A workspace key ("workspaces/<wk>") filters on workspace_key.
|
|
42
|
+
*/
|
|
43
|
+
function lookupRow(storeKey, id) {
|
|
44
|
+
const isWs = typeof storeKey === 'string' && storeKey.startsWith('workspaces/');
|
|
45
|
+
const col = isWs ? 'workspace_key' : 'project_key';
|
|
46
|
+
const val = isWs ? storeKey.slice('workspaces/'.length) : storeKey;
|
|
47
|
+
let row = getDb().prepare(`SELECT * FROM pipelines WHERE ${col} = ? AND id = ?`).get(val, id);
|
|
48
|
+
if (row) return row;
|
|
49
|
+
const m = /-([0-9a-f]{8})$/i.exec(String(id));
|
|
50
|
+
if (m) row = getDb().prepare(`SELECT * FROM pipelines WHERE ${col} = ? AND id = ?`).get(val, m[1].toLowerCase());
|
|
51
|
+
return row || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolve an indexed artifact's absolute path. The artifacts index encodes scope by
|
|
56
|
+
* convention (recordArtifact / orchestrator._artifact): plans/ and reviews/ are
|
|
57
|
+
* store-root-relative (the shared markdown, a sibling of pipelines/); everything else
|
|
58
|
+
* (prompt.md, manual-tests-checklist.md, webui-review-cycleN.md, extras/*) is
|
|
59
|
+
* pipeline-dir-relative.
|
|
60
|
+
*/
|
|
61
|
+
function artifactAbsPath(relPath, pipelineDir, storeRootDir) {
|
|
62
|
+
if (isAbsolute(relPath)) return relPath;
|
|
63
|
+
if (relPath.startsWith('plans/') || relPath.startsWith('reviews/')) return join(storeRootDir, relPath);
|
|
64
|
+
return join(pipelineDir, relPath);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{ projectDir?:string, key?:string, workspaceKey?:string, id:string }} args
|
|
69
|
+
* @returns {Promise<null | { ok, id, archived, pipelineDir, planFiles, reviewFiles, branch, worktree, warnings }
|
|
70
|
+
* | { ok, id, alreadyArchived, warnings }>}
|
|
71
|
+
* null => no pipeline with that id (404). Throws err(code:'RUNNING'|'BAD_REQUEST') for guards.
|
|
72
|
+
* An already-archived row short-circuits to { alreadyArchived: true } — the FS was
|
|
73
|
+
* reclaimed by the first call, so a second pass has nothing to do (idempotent).
|
|
74
|
+
*
|
|
75
|
+
* When `workspaceKey` is set the pipeline lives in the workspace store
|
|
76
|
+
* (store/workspaces/<workspaceKey>/); branch/worktree cleanup iterates the
|
|
77
|
+
* per-project `state.branches` map (each entry {feature,worktreeDir} keyed by
|
|
78
|
+
* projectKey) against the matching `state.projects[].projectDir`, instead of the
|
|
79
|
+
* single scalar `state.branch`. The state is reconstructed from the DB row by the
|
|
80
|
+
* same reader history uses. Result warnings[] aggregates per-project failures.
|
|
81
|
+
*/
|
|
82
|
+
export async function archivePipeline({ projectDir = null, key = null, workspaceKey = null, id } = {}) {
|
|
83
|
+
if (!id || typeof id !== 'string') throw err('id is required', 'BAD_REQUEST');
|
|
84
|
+
|
|
85
|
+
// Resolve the store key ONCE so the run dir and the shared plan/review files are
|
|
86
|
+
// always read from the same store root. A workspace pipeline lives under the
|
|
87
|
+
// literal "workspaces/<workspaceKey>" segment (projectStorePath joins it under
|
|
88
|
+
// storeRoot()), so the dir/plan/review resolution below is reused as-is.
|
|
89
|
+
const storeKey = workspaceKey
|
|
90
|
+
? `workspaces/${workspaceKey}`
|
|
91
|
+
: (key || (projectDir ? projectKey(projectDir) : null));
|
|
92
|
+
if (!storeKey) throw err('projectKey, projectDir or workspaceKey is required', 'BAD_REQUEST');
|
|
93
|
+
|
|
94
|
+
const row = lookupRow(storeKey, id);
|
|
95
|
+
if (!row) return null;
|
|
96
|
+
if (ACTIVE.has(String(row.status || '').toLowerCase())) {
|
|
97
|
+
throw err('cannot delete a running pipeline', 'RUNNING');
|
|
98
|
+
}
|
|
99
|
+
// Idempotent: the first archive already reclaimed the FS. Re-running the unlink /
|
|
100
|
+
// worktree / rm passes would only produce noise (and warnings) over gone paths.
|
|
101
|
+
if (row.archived_at) {
|
|
102
|
+
return { ok: true, id: row.id, alreadyArchived: true, warnings: [] };
|
|
103
|
+
}
|
|
104
|
+
// UI state is advisory. Enforce the no-data-loss rule here as well, because a
|
|
105
|
+
// stale client or a direct API caller could otherwise force-remove the very
|
|
106
|
+
// checkout that was retained after its commit failed.
|
|
107
|
+
if (retainedWorkFor(row)) {
|
|
108
|
+
throw err('cannot archive while retained uncommitted work exists; recover it or discard the worktree first', 'RETAINED_WORKTREE');
|
|
109
|
+
}
|
|
110
|
+
// The run root is the one thing archive destroys that can still hold retained
|
|
111
|
+
// checkouts (detached members live at runs/<id>/repos/<key>). Resolve it ONCE,
|
|
112
|
+
// here, for the guards below AND the 2b) removal further down.
|
|
113
|
+
const runRoot = join(worcaHome(), 'runs', row.id);
|
|
114
|
+
// Fail CLOSED on unreadable retention metadata — but ONLY while the run root
|
|
115
|
+
// still exists. With it gone, archive's per-member cleanup is already a no-op
|
|
116
|
+
// for an unparseable column (rowToState yields branches:{} / a null branch), so
|
|
117
|
+
// refusing forever would only wedge the row: discard cannot clear it
|
|
118
|
+
// (retainedWorkFor returns null) and the UI hides the Discard button. Naming
|
|
119
|
+
// the path keeps the refusal hand-clearable.
|
|
120
|
+
const unreadable = (col) => {
|
|
121
|
+
if (typeof col !== 'string' || !col.trim()) return false;
|
|
122
|
+
// A JSON *string* branch ("worca/foo") is a SUPPORTED legacy shape
|
|
123
|
+
// (rowToHistoryEntry reads it) — it carries no retention, so it is readable.
|
|
124
|
+
try { const p = JSON.parse(col); return p === null || (typeof p !== 'object' && typeof p !== 'string'); }
|
|
125
|
+
catch { return true; }
|
|
126
|
+
};
|
|
127
|
+
const metaUnreadable = row.target === 'workspace' ? unreadable(row.workspace_meta) : unreadable(row.branch);
|
|
128
|
+
if (metaUnreadable && existsSync(runRoot)) {
|
|
129
|
+
throw err(
|
|
130
|
+
`cannot archive: retention metadata is unreadable, so retained uncommitted work inside ${runRoot} cannot be ruled out — inspect and remove that run root, then archive again`,
|
|
131
|
+
'RETAINED_WORKTREE',
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
// The DB stamp is teardown's LAST best-effort write; the run.json retain block
|
|
135
|
+
// is written earlier. Trust either representation (the sweep already does).
|
|
136
|
+
if (existsSync(runRoot)) {
|
|
137
|
+
const guardManifest = await readRunManifest(runRoot);
|
|
138
|
+
const members = Array.isArray(guardManifest?.retain?.members) ? guardManifest.retain.members : [];
|
|
139
|
+
if (members.some((m) => m?.worktreeDir && existsSync(m.worktreeDir))) {
|
|
140
|
+
throw err('cannot archive while retained uncommitted work exists; recover it or discard the worktree first', 'RETAINED_WORKTREE');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Reconstruct state (branch/branches/projects) via the same reader history uses.
|
|
145
|
+
const { state } = (await readPipelineByKey(storeKey, row.id)) || { state: null };
|
|
146
|
+
|
|
147
|
+
// The real on-disk run dir (markdown + extras live here). Resolve by the -<id> suffix.
|
|
148
|
+
const storeRootDir = projectStorePath(storeKey);
|
|
149
|
+
const pipelinesDir = join(storeRootDir, 'pipelines');
|
|
150
|
+
const runDir = await findRunDir(pipelinesDir, row.id);
|
|
151
|
+
|
|
152
|
+
const report = {
|
|
153
|
+
ok: true, id: row.id, archived: true, pipelineDir: runDir,
|
|
154
|
+
planFiles: [], reviewFiles: [], branch: null, worktree: null, runRoot: null, warnings: [],
|
|
155
|
+
};
|
|
156
|
+
if (metaUnreadable) {
|
|
157
|
+
report.warnings.push('retention metadata was unreadable; per-member branch/worktree cleanup was skipped');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Final PR observation while the branch still exists (spec §6.8.3). Single-
|
|
161
|
+
// project runs only: workspace rows carry a branches MAP (state.branches) —
|
|
162
|
+
// their per-project PR facts are left to prior enrichment passes (accepted
|
|
163
|
+
// limitation). A merge after archive is never observed — also accepted.
|
|
164
|
+
try {
|
|
165
|
+
const branch = state?.branch?.feature;
|
|
166
|
+
if (branch && state?.projectDir && await hasGh()) {
|
|
167
|
+
const pr = await findPrForBranch({ projectDir: state.projectDir, head: branch });
|
|
168
|
+
if (pr) persistPrState(row.id, pr);
|
|
169
|
+
}
|
|
170
|
+
} catch (e) {
|
|
171
|
+
// Named `e`, not `err`: `err` is this module's error-factory helper and a catch
|
|
172
|
+
// parameter would shadow it for the whole block.
|
|
173
|
+
report.warnings.push(`pr refresh failed: ${e?.message || e}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 1) Unlink the EXACT indexed markdown (no baseName-derivation). Pipeline-local
|
|
177
|
+
// artifacts (prompt/extras/checklist/webui) live INSIDE runDir and are cleared
|
|
178
|
+
// by the rm(runDir) below; only the shared store-rooted plan/review md need an
|
|
179
|
+
// explicit unlink here.
|
|
180
|
+
const arts = await listArtifacts(row.id);
|
|
181
|
+
for (const a of arts) {
|
|
182
|
+
const abs = artifactAbsPath(a.relPath, runDir || join(pipelinesDir, row.id), storeRootDir);
|
|
183
|
+
if (!abs || (runDir && abs.startsWith(runDir))) continue; // pipeline-local handled by rm(runDir)
|
|
184
|
+
try {
|
|
185
|
+
if (existsSync(abs)) { await rm(abs, { force: true }); }
|
|
186
|
+
if (a.kind === 'plan') report.planFiles.push(abs);
|
|
187
|
+
else if (a.kind === 'review') report.reviewFiles.push(abs);
|
|
188
|
+
} catch { /* best-effort */ }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 2) Local branch(es) + worktree(s) (remote untouched). Best-effort. Reads the
|
|
192
|
+
// reconstructed state (branch / branches / projects from the row's JSON columns).
|
|
193
|
+
if (workspaceKey || state?.target === 'workspace') {
|
|
194
|
+
// Per-project: iterate state.branches keyed by projectKey, cleaning each
|
|
195
|
+
// member's worktree+branch in its OWN repo (state.projects[].projectDir).
|
|
196
|
+
const branches = state?.branches && typeof state.branches === 'object' ? state.branches : {};
|
|
197
|
+
const projects = Array.isArray(state?.projects) ? state.projects : [];
|
|
198
|
+
const dirByKey = new Map(projects.map((p) => [p.projectKey, p.projectDir]));
|
|
199
|
+
for (const [pk, br] of Object.entries(branches)) {
|
|
200
|
+
const repoDir = dirByKey.get(pk) || null;
|
|
201
|
+
const feature = br?.feature || null;
|
|
202
|
+
const wt = br?.worktreeDir || null;
|
|
203
|
+
if (!repoDir || (!feature && !wt)) continue;
|
|
204
|
+
const liveWt = wt && existsSync(wt) ? wt : null;
|
|
205
|
+
const liveBranch = feature && (await branchExists(repoDir, feature)) ? feature : null;
|
|
206
|
+
if (!liveWt && !liveBranch) continue;
|
|
207
|
+
const res = await removeWorktree({ projectDir: repoDir, worktreeDir: liveWt, branch: liveBranch, force: true });
|
|
208
|
+
for (const stp of res.steps.filter((x) => !x.ok)) {
|
|
209
|
+
report.warnings.push(`${pk}: ${stp.step}: ${stp.stderr || 'failed'}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
const repoDir = state?.projectDir || projectDir || null;
|
|
214
|
+
const feature = state?.branch?.feature || null;
|
|
215
|
+
const wt = state?.branch?.worktreeDir || null;
|
|
216
|
+
if (repoDir && (feature || wt)) {
|
|
217
|
+
const liveWt = wt && existsSync(wt) ? wt : null; // skip already-removed worktrees
|
|
218
|
+
const liveBranch = feature && (await branchExists(repoDir, feature)) ? feature : null; // skip merged/deleted
|
|
219
|
+
if (liveWt || liveBranch) {
|
|
220
|
+
const res = await removeWorktree({ projectDir: repoDir, worktreeDir: liveWt, branch: liveBranch, force: true });
|
|
221
|
+
report.branch = liveBranch;
|
|
222
|
+
report.worktree = liveWt;
|
|
223
|
+
for (const stp of res.steps.filter((x) => !x.ok)) {
|
|
224
|
+
report.warnings.push(`${stp.step}: ${stp.stderr || 'failed'}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 2b) The detached run root, under the same §8.13 assertions rmGuarded enforces
|
|
231
|
+
// everywhere (`<worcaHome>/runs/` prefix + basename === pipelineId). Without
|
|
232
|
+
// this, archiving a paused or interrupted detached run from the UI would leave
|
|
233
|
+
// the generated CLAUDE.md, mcp.json, run.json, the workspace skill mount and
|
|
234
|
+
// the emptied repos/ shell on disk permanently. Archiving KEEPS the pipelines
|
|
235
|
+
// row, so the boot sweep can still resolve this id — but the run root is gone
|
|
236
|
+
// and unreclaimable-by-id later, so it must be removed here, not deferred.
|
|
237
|
+
// A legacy run simply has no such dir, so this is a no-op there.
|
|
238
|
+
if (existsSync(runRoot)) {
|
|
239
|
+
const res = await rmGuarded(runRoot, { worcaHome: worcaHome(), pipelineId: row.id });
|
|
240
|
+
if (res.removed) report.runRoot = runRoot;
|
|
241
|
+
else report.warnings.push(`run-root: ${res.reason || 'removal refused'}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 3) The run folder itself (prompt.md, pipeline.md header, extras/, any
|
|
245
|
+
// pipeline-local md). Everything else lives inside it.
|
|
246
|
+
if (runDir) await rm(runDir, { recursive: true, force: true });
|
|
247
|
+
|
|
248
|
+
// 4) Soft delete: the run's statistical record is permanent (spec §6.7). The
|
|
249
|
+
// FS was reclaimed above; the row now only carries history. The artifacts
|
|
250
|
+
// INDEX rows are deleted explicitly — their files were just unlinked, and
|
|
251
|
+
// the CASCADE that used to clear them no longer fires (no row DELETE).
|
|
252
|
+
// pipeline_steps and the other children stay: stats fallback sums need them.
|
|
253
|
+
tx(() => {
|
|
254
|
+
getDb().prepare('DELETE FROM artifacts WHERE pipeline_id = ?').run(row.id);
|
|
255
|
+
getDb().prepare('UPDATE pipelines SET archived_at = ? WHERE id = ?')
|
|
256
|
+
.run(new Date().toISOString(), row.id);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return report;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Explicitly reclaim worktrees retained after a teardown commit failure while
|
|
264
|
+
* preserving the pipeline row and artifact directory. Every live checkout is
|
|
265
|
+
* snapshotted first; any snapshot failure aborts before removal.
|
|
266
|
+
*/
|
|
267
|
+
export async function discardRetainedWorktrees({ projectDir = null, key = null, workspaceKey = null, id } = {}) {
|
|
268
|
+
if (!id || typeof id !== 'string') throw err('id is required', 'BAD_REQUEST');
|
|
269
|
+
const storeKey = workspaceKey
|
|
270
|
+
? `workspaces/${workspaceKey}`
|
|
271
|
+
: (key || (projectDir ? projectKey(projectDir) : null));
|
|
272
|
+
if (!storeKey) throw err('projectKey, projectDir or workspaceKey is required', 'BAD_REQUEST');
|
|
273
|
+
|
|
274
|
+
const row = lookupRow(storeKey, id);
|
|
275
|
+
if (!row) return null;
|
|
276
|
+
if (ACTIVE.has(String(row.status || '').toLowerCase())) {
|
|
277
|
+
throw err('cannot discard a running pipeline worktree', 'RUNNING');
|
|
278
|
+
}
|
|
279
|
+
const runRoot = join(worcaHome(), 'runs', row.id);
|
|
280
|
+
let retained = retainedWorkFor(row);
|
|
281
|
+
if (!retained && existsSync(runRoot)) {
|
|
282
|
+
// The DB stamp is teardown's LAST best-effort write and can be lost (F2's
|
|
283
|
+
// crash window). The run.json retain ledger is written earlier — honor it so
|
|
284
|
+
// manifest-only retention is still discardable instead of a permanent wedge
|
|
285
|
+
// (archive refuses it; this is the only exit). existsSync keeps it
|
|
286
|
+
// self-clearing, same contract as retainedWorkFor.
|
|
287
|
+
const manifest = await readRunManifest(runRoot);
|
|
288
|
+
const live = (Array.isArray(manifest?.retain?.members) ? manifest.retain.members : [])
|
|
289
|
+
.filter((m) => m?.worktreeDir && existsSync(m.worktreeDir));
|
|
290
|
+
if (live.length) retained = { reason: manifest.retain.reason || 'unknown', members: live };
|
|
291
|
+
}
|
|
292
|
+
if (!retained) {
|
|
293
|
+
return { ok: true, id: row.id, discarded: false, remaining: 0, worktrees: [], patches: [], runRoot: null, warnings: [] };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const { state } = (await readPipelineByKey(storeKey, row.id)) || { state: null };
|
|
297
|
+
if (!state) throw err('pipeline state is unavailable', 'BAD_REQUEST');
|
|
298
|
+
const storeRootDir = projectStorePath(storeKey);
|
|
299
|
+
const runDir = await findRunDir(join(storeRootDir, 'pipelines'), row.id);
|
|
300
|
+
if (!runDir) {
|
|
301
|
+
throw err('cannot discard retained work: the pipeline directory needed for the recovery patch is missing', 'SNAPSHOT_FAILED');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const projects = Array.isArray(state.projects) ? state.projects : [];
|
|
305
|
+
const dirByKey = new Map(projects.map((p) => [p.projectKey, p.projectDir]));
|
|
306
|
+
const targets = retained.members.map((member) => ({
|
|
307
|
+
...member,
|
|
308
|
+
projectDir: state.target === 'workspace'
|
|
309
|
+
? (dirByKey.get(member.projectKey) || null)
|
|
310
|
+
: (state.projectDir || projectDir || null),
|
|
311
|
+
}));
|
|
312
|
+
if (targets.some((t) => !t.projectDir)) {
|
|
313
|
+
throw err('cannot discard retained work: a member repository could not be resolved', 'SNAPSHOT_FAILED');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Snapshot ALL members before deleting ANY member, straight to their final
|
|
317
|
+
// files. This prevents a later snapshot failure from leaving a half-discarded
|
|
318
|
+
// workspace, without ever holding a whole patch in memory.
|
|
319
|
+
await mkdir(runDir, { recursive: true });
|
|
320
|
+
const patches = [];
|
|
321
|
+
for (const target of targets) {
|
|
322
|
+
const name = retainedWorkPatchName(state.target === 'workspace' ? (target.projectKey || 'member') : null);
|
|
323
|
+
const snap = await snapshotWorktreePatch(target.worktreeDir, join(runDir, name));
|
|
324
|
+
if (!snap.ok) {
|
|
325
|
+
throw err(
|
|
326
|
+
`cannot save recovery patch for ${target.projectKey || target.worktreeDir}: git ${snap.step} failed: ${snap.message}`,
|
|
327
|
+
'SNAPSHOT_FAILED',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (snap.file) { // a clean tree yields no patch — nothing to record
|
|
331
|
+
recordArtifact(row.id, 'retained-work-patch', name);
|
|
332
|
+
patches.push(snap.file);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const report = {
|
|
337
|
+
ok: true, id: row.id, discarded: false, remaining: 0, worktrees: [], patches,
|
|
338
|
+
runRoot: null, warnings: [],
|
|
339
|
+
};
|
|
340
|
+
if (existsSync(runRoot)) {
|
|
341
|
+
const manifest = await readRunManifest(runRoot);
|
|
342
|
+
for (const [scope, entries] of Object.entries(manifest?.injectedPaths || {})) {
|
|
343
|
+
const baseDir = scope === 'runRoot'
|
|
344
|
+
? runRoot
|
|
345
|
+
: targets.find((t) => t.projectKey === scope)?.worktreeDir || join(runRoot, 'repos', scope);
|
|
346
|
+
const warnings = await rescueModifiedMounts({
|
|
347
|
+
baseDir, entries, pipelineDir: runDir, scope, pipelineId: row.id,
|
|
348
|
+
});
|
|
349
|
+
report.warnings.push(...warnings);
|
|
350
|
+
}
|
|
351
|
+
report.warnings.push(...await scanStrayEntries({ runRoot, pipelineDir: runDir }));
|
|
352
|
+
await copyRunManifestTo(runRoot, runDir);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const clearedKeys = [];
|
|
356
|
+
for (const target of targets) {
|
|
357
|
+
const result = await removeWorktree({
|
|
358
|
+
projectDir: target.projectDir, worktreeDir: target.worktreeDir, branch: null, force: true,
|
|
359
|
+
});
|
|
360
|
+
for (const step of result.steps.filter((s) => !s.ok)) {
|
|
361
|
+
report.warnings.push(`${target.projectKey || 'project'}: ${step.step}: ${step.stderr || 'failed'}`);
|
|
362
|
+
}
|
|
363
|
+
if (!existsSync(target.worktreeDir)) {
|
|
364
|
+
report.worktrees.push(target.worktreeDir);
|
|
365
|
+
clearedKeys.push(target.projectKey ?? null);
|
|
366
|
+
} else {
|
|
367
|
+
report.remaining += 1;
|
|
368
|
+
report.warnings.push(`${target.projectKey || 'project'}: worktree still exists at ${target.worktreeDir}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
report.discarded = report.remaining === 0;
|
|
372
|
+
|
|
373
|
+
// Targeted update: clear ONLY the retention stamps. A full writeState would
|
|
374
|
+
// restamp updated_at (the stats terminal-write proxy), NULL resume_point, and
|
|
375
|
+
// rewrite pipeline_steps — none of which a checkout reclaim may touch.
|
|
376
|
+
if (clearedKeys.length) {
|
|
377
|
+
const parse = (t) => { try { return JSON.parse(t); } catch { return undefined; } };
|
|
378
|
+
const clear = (br) => { delete br.commitFailed; br.worktreeRemoved = true; br.branchKept = true; };
|
|
379
|
+
tx(() => {
|
|
380
|
+
const fresh = getDb().prepare('SELECT branch, workspace_meta FROM pipelines WHERE id = ?').get(row.id);
|
|
381
|
+
if (state.target === 'workspace') {
|
|
382
|
+
const wm = typeof fresh?.workspace_meta === 'string' ? parse(fresh.workspace_meta) : fresh?.workspace_meta;
|
|
383
|
+
// NEVER write a rebuilt {} here: workspace_meta carries the whole §5.2
|
|
384
|
+
// superset (projects/projectKeys/checkpointRefs/runRootMode). Unreadable
|
|
385
|
+
// or branch-less meta is reported, not overwritten.
|
|
386
|
+
if (!wm || typeof wm !== 'object' || !wm.branches || typeof wm.branches !== 'object') {
|
|
387
|
+
// A NULL column is the manifest-only case (Task 11) — nothing to clear,
|
|
388
|
+
// nothing to warn about. Warn only when a non-null column is corrupt.
|
|
389
|
+
if (fresh?.workspace_meta != null) {
|
|
390
|
+
report.warnings.push('retention stamp not cleared: workspace metadata is unreadable');
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
let touched = false;
|
|
395
|
+
for (const k of clearedKeys) { const br = wm.branches[k]; if (br) { clear(br); touched = true; } }
|
|
396
|
+
if (touched) {
|
|
397
|
+
getDb().prepare('UPDATE pipelines SET workspace_meta = ? WHERE id = ?')
|
|
398
|
+
.run(JSON.stringify(wm), row.id);
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
const br = typeof fresh?.branch === 'string' ? parse(fresh.branch) : fresh?.branch;
|
|
402
|
+
if (!br || typeof br !== 'object') {
|
|
403
|
+
if (fresh?.branch != null) { // NULL column = manifest-only discard: silent skip
|
|
404
|
+
report.warnings.push('retention stamp not cleared: branch metadata is unreadable');
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
clear(br);
|
|
409
|
+
getDb().prepare('UPDATE pipelines SET branch = ? WHERE id = ?').run(JSON.stringify(br), row.id);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (existsSync(runRoot) && report.remaining === 0) {
|
|
414
|
+
const removal = await rmGuarded(runRoot, { worcaHome: worcaHome(), pipelineId: row.id });
|
|
415
|
+
if (removal.removed) report.runRoot = runRoot;
|
|
416
|
+
else report.warnings.push(`run-root: ${removal.reason || 'removal refused'}`);
|
|
417
|
+
}
|
|
418
|
+
await appendAudit(runDir,
|
|
419
|
+
`Discarded ${report.worktrees.length} retained worktree(s) after saving recovery patch(es): ` +
|
|
420
|
+
patches.map((p) => `\`${p}\``).join(', ')).catch(() => {});
|
|
421
|
+
return report;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Compat alias. The server now calls `archivePipeline` directly; this name is
|
|
425
|
+
// kept for the pre-existing suites (test/pipeline-delete.test.mjs,
|
|
426
|
+
// test/persist-roundtrip.test.mjs) that assert the FS/branch reclamation half
|
|
427
|
+
// under the old name — which archive still performs in full.
|
|
428
|
+
export { archivePipeline as deletePipeline };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/core/plugin-api.mjs
|
|
2
|
+
// Host plugin API versions (plugin spec §10). Integers, bumped on breaking
|
|
3
|
+
// change ONLY. WORCA_PLUGIN_API is the current/max API; WORCA_PLUGIN_APIS lists
|
|
4
|
+
// every API this host still satisfies, so old manifests (e.g. ">=1 <2") keep
|
|
5
|
+
// installing after a bump. Checked against manifests' engines.worca-cc-api at
|
|
6
|
+
// install AND at load (plugin-manifest.mjs apiSatisfies). Kept in its own
|
|
7
|
+
// dependency-free module so the shim child (Task 11) can import it without the
|
|
8
|
+
// core graph.
|
|
9
|
+
//
|
|
10
|
+
// API 2 adds the chatChannels contribution + persistent channel worker
|
|
11
|
+
// protocol; the task-source connector contract is unchanged between 1 and 2.
|
|
12
|
+
export const WORCA_PLUGIN_API = 2;
|
|
13
|
+
export const WORCA_PLUGIN_APIS = [1, 2];
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/core/plugin-config.mjs
|
|
2
|
+
// Per-plugin settings/secrets/state under <pluginDir>/data (spec §5, §7.6).
|
|
3
|
+
// Secrets: data/secrets.json, mode 0600, atomic temp+rename (settings.mjs:89-92
|
|
4
|
+
// idiom), {"$env":"VAR"} indirection resolved at READ time only — stored
|
|
5
|
+
// verbatim so the value never touches disk. Explicitly NOT in worca-cc.db.
|
|
6
|
+
// All functions are sync (contract; callers are the shim + server routes).
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, chmodSync } from 'node:fs';
|
|
9
|
+
import { join, dirname } from 'node:path';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { pluginDataDir } from './plugins-lock.mjs';
|
|
12
|
+
|
|
13
|
+
function readJson(file) {
|
|
14
|
+
try {
|
|
15
|
+
const v = JSON.parse(readFileSync(file, 'utf8'));
|
|
16
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function writeJsonAtomic(file, obj, { mode } = {}) {
|
|
23
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
24
|
+
const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
|
|
25
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', mode !== undefined ? { mode } : { encoding: 'utf8' });
|
|
26
|
+
if (mode !== undefined) chmodSync(tmp, mode); // umask-proof: mode is exact
|
|
27
|
+
renameSync(tmp, file);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isEnvRef = (v) => !!v && typeof v === 'object' && !Array.isArray(v) && typeof v.$env === 'string';
|
|
31
|
+
/** The exact redaction marker redactedConfig emits — must never be persisted. */
|
|
32
|
+
const isSetMarker = (v) => !!v && typeof v === 'object' && v.set === true && Object.keys(v).length === 1;
|
|
33
|
+
|
|
34
|
+
function files(name) {
|
|
35
|
+
const dir = pluginDataDir(name);
|
|
36
|
+
return { config: join(dir, 'config.json'), secrets: join(dir, 'secrets.json'), state: join(dir, 'state.json') };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Merged config.json + secrets.json (secrets win), schema defaults applied,
|
|
40
|
+
* {"$env":"VAR"} resolved (unset env -> null). */
|
|
41
|
+
export function readPluginConfig(name, configSchema = []) {
|
|
42
|
+
const f = files(name);
|
|
43
|
+
const raw = { ...readJson(f.config), ...readJson(f.secrets) };
|
|
44
|
+
const out = {};
|
|
45
|
+
for (const field of configSchema) {
|
|
46
|
+
let v = raw[field.key];
|
|
47
|
+
if (v === undefined || v === null || v === '') v = field.default ?? null;
|
|
48
|
+
if (isEnvRef(v)) v = process.env[v.$env] ?? null;
|
|
49
|
+
out[field.key] = v;
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Route values by schema: secret:true -> secrets.json (0600), else config.json.
|
|
55
|
+
* undefined / redaction-marker values keep the prior stored value; null clears.
|
|
56
|
+
* $env refs are stored verbatim. Both writes are temp+rename atomic. */
|
|
57
|
+
export function writePluginConfig(name, configSchema = [], values = {}) {
|
|
58
|
+
const f = files(name);
|
|
59
|
+
const config = readJson(f.config);
|
|
60
|
+
const secrets = readJson(f.secrets);
|
|
61
|
+
const secretKeys = new Set(configSchema.filter((x) => x && x.secret).map((x) => x.key));
|
|
62
|
+
for (const [k, v] of Object.entries(values && typeof values === 'object' ? values : {})) {
|
|
63
|
+
if (v === undefined || isSetMarker(v)) continue; // absent / echoed marker -> keep prior
|
|
64
|
+
const bucket = secretKeys.has(k) ? secrets : config;
|
|
65
|
+
const other = secretKeys.has(k) ? config : secrets;
|
|
66
|
+
delete other[k]; // field migrated buckets across schema versions
|
|
67
|
+
if (v === null) delete bucket[k];
|
|
68
|
+
else bucket[k] = v;
|
|
69
|
+
}
|
|
70
|
+
writeJsonAtomic(f.config, config);
|
|
71
|
+
writeJsonAtomic(f.secrets, secrets, { mode: 0o600 });
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** UI echo shape: secrets -> { set: true|false } markers, non-secrets verbatim
|
|
76
|
+
* (with defaults). Secret VALUES never reach the browser after save (§7.6). */
|
|
77
|
+
export function redactedConfig(name, configSchema = []) {
|
|
78
|
+
const f = files(name);
|
|
79
|
+
const config = readJson(f.config);
|
|
80
|
+
const secrets = readJson(f.secrets);
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const field of configSchema) {
|
|
83
|
+
if (field.secret) out[field.key] = { set: secrets[field.key] !== undefined };
|
|
84
|
+
else out[field.key] = config[field.key] ?? field.default ?? null;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Connector KV (cursors, etags) — host-persisted ctx.state backing (§7.1). */
|
|
90
|
+
export function readPluginState(name) {
|
|
91
|
+
return readJson(files(name).state);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Shallow-merge patch into state.json, atomic write. Returns the new state. */
|
|
95
|
+
export function writePluginState(name, patch = {}) {
|
|
96
|
+
const f = files(name);
|
|
97
|
+
const next = { ...readJson(f.state), ...(patch && typeof patch === 'object' ? patch : {}) };
|
|
98
|
+
writeJsonAtomic(f.state, next);
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/core/plugin-inventory.mjs
|
|
2
|
+
// Consent inventory for a NOT-YET-INSTALLED plugin (spec §4.8): git-archive the
|
|
3
|
+
// pinned SHA from the bare cache into a throwaway temp dir, buildInstallInventory
|
|
4
|
+
// it, delete it. Nothing lands under ~/.worca-cc/plugins and no plugin code runs.
|
|
5
|
+
// Shared by the marketplace snapshot sync (marketplaces.mjs) and the web server.
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
10
|
+
import { rmSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { repoCacheDir } from './plugin-repo.mjs';
|
|
14
|
+
import { buildInstallInventory } from './plugin-store.mjs';
|
|
15
|
+
import { findEscapingSymlinks } from './plugin-manifest.mjs';
|
|
16
|
+
|
|
17
|
+
const execFileP = promisify(execFile);
|
|
18
|
+
const defaultExec = (cmd, args, opts = {}) =>
|
|
19
|
+
execFileP(cmd, args, {
|
|
20
|
+
maxBuffer: 16 * 1024 * 1024, timeout: 120_000, killSignal: 'SIGKILL',
|
|
21
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, ...opts,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export async function inventoryFromCache(repoUrl, sha, subdir, { exec = defaultExec } = {}) {
|
|
25
|
+
const tmp = await mkdtemp(join(tmpdir(), 'worca-cc-consent-'));
|
|
26
|
+
try {
|
|
27
|
+
const tar = join(tmp, 'x.tar');
|
|
28
|
+
// `--` before the subdir positional so a dir like `-x`/`--output=…` can never
|
|
29
|
+
// be parsed as a git option (defense layer 2; the parser rejects it too).
|
|
30
|
+
await exec('git', ['--git-dir', repoCacheDir(repoUrl), 'archive', '--format=tar', '-o', tar,
|
|
31
|
+
...(subdir ? [sha, '--', subdir] : [sha])]);
|
|
32
|
+
await exec('tar', ['-xf', tar, '-C', tmp,
|
|
33
|
+
...(subdir ? ['--strip-components', String(subdir.split('/').length)] : [])]);
|
|
34
|
+
await rm(tar, { force: true });
|
|
35
|
+
// Defense-in-depth for the consent display: strip any symlink that escapes the
|
|
36
|
+
// export root before inventorying (the tmp dir is deleted in finally regardless).
|
|
37
|
+
for (const rel of findEscapingSymlinks(tmp)) rmSync(join(tmp, rel), { force: true });
|
|
38
|
+
const inv = buildInstallInventory(tmp);
|
|
39
|
+
// buildInstallInventory bakes the throwaway export dir into setup commands
|
|
40
|
+
// (e.g. `npm ci --prefix <tmp>`); this inventory is PERSISTED in
|
|
41
|
+
// marketplaces.json and shown in the consent modal long after `tmp` is gone,
|
|
42
|
+
// so replace the tmp path with a stable placeholder.
|
|
43
|
+
if (Array.isArray(inv.setupCommands)) {
|
|
44
|
+
inv.setupCommands = inv.setupCommands.map((c) => String(c).split(tmp).join('<plugin-dir>'));
|
|
45
|
+
}
|
|
46
|
+
return inv;
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(tmp, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|