convene-cli 1.13.0 → 1.13.2
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/dist/api.js +5 -3
- package/dist/catalog/index.js +15 -5
- package/dist/catalog/manifest.js +30 -0
- package/dist/commands/auth.js +24 -13
- package/dist/commands/fetch.js +4 -1
- package/dist/commands/gate-push.js +37 -9
- package/dist/commands/guard.js +123 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -207,9 +207,11 @@ class ConveneApi {
|
|
|
207
207
|
}
|
|
208
208
|
/**
|
|
209
209
|
* GET /catalog — the canonical best-practices catalog (PUBLIC, no tenant data).
|
|
210
|
-
* The
|
|
211
|
-
*
|
|
212
|
-
*
|
|
210
|
+
* The server returns a release ENVELOPE `{ version, contentHash, catalog }`; the
|
|
211
|
+
* actual Catalog lives under `.catalog` (loadCatalog unwraps it). The CLI prefers
|
|
212
|
+
* this live read but is fully fail-soft: on any non-ok / network error the caller
|
|
213
|
+
* falls back to the bundled offline mirror. Bounded by a short timeout — never
|
|
214
|
+
* the 10s default.
|
|
213
215
|
*/
|
|
214
216
|
getCatalog(timeoutMs) {
|
|
215
217
|
return this.request('GET', '/catalog', { timeoutMs });
|
package/dist/catalog/index.js
CHANGED
|
@@ -6,16 +6,26 @@ const catalog_generated_1 = require("./catalog.generated");
|
|
|
6
6
|
Object.defineProperty(exports, "CATALOG", { enumerable: true, get: function () { return catalog_generated_1.CATALOG; } });
|
|
7
7
|
Object.defineProperty(exports, "CATALOG_VERSION", { enumerable: true, get: function () { return catalog_generated_1.CATALOG_VERSION; } });
|
|
8
8
|
/**
|
|
9
|
-
* Load the catalog, preferring the live server copy when `api` is given.
|
|
10
|
-
*
|
|
11
|
-
* back
|
|
9
|
+
* Load the catalog, preferring the live server copy when `api` is given. Unwraps
|
|
10
|
+
* the server's release envelope ({ version, contentHash, catalog }) and tolerates
|
|
11
|
+
* a bare Catalog for back-compat. Any failure (no client, non-ok status, network
|
|
12
|
+
* error, empty/malformed body) silently falls back to the bundled mirror — this
|
|
13
|
+
* never throws.
|
|
12
14
|
*/
|
|
13
15
|
async function loadCatalog(api, timeoutMs = 5_000) {
|
|
14
16
|
if (api) {
|
|
15
17
|
try {
|
|
16
18
|
const res = await api.getCatalog(timeoutMs);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
// The server wraps the catalog in a release envelope
|
|
20
|
+
// ({ version, contentHash, catalog }); the Catalog (with `practices`) lives
|
|
21
|
+
// one level down under `.catalog`. Unwrap that, but also tolerate a bare
|
|
22
|
+
// Catalog for back-compat with a differently-shaped/older server. VALIDATE
|
|
23
|
+
// the UNWRAPPED object — the envelope's top-level `version` matches a bare
|
|
24
|
+
// Catalog's, so guarding on the inner `practices` is what tells them apart.
|
|
25
|
+
const body = res.json;
|
|
26
|
+
const cat = body && 'catalog' in body ? body.catalog : body;
|
|
27
|
+
if (res.ok && cat && Array.isArray(cat.practices) && typeof cat.version === 'string') {
|
|
28
|
+
return { catalog: cat, source: 'live' };
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
31
|
catch {
|
package/dist/catalog/manifest.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.semverLt = semverLt;
|
|
4
4
|
exports.bumpClass = bumpClass;
|
|
5
5
|
exports.compareToCatalog = compareToCatalog;
|
|
6
|
+
exports.catalogBehindLine = catalogBehindLine;
|
|
7
|
+
exports.bestPracticesFreshnessLine = bestPracticesFreshnessLine;
|
|
6
8
|
/**
|
|
7
9
|
* Strict SemVer less-than over the dotted numeric core (pre-release/build
|
|
8
10
|
* metadata ignored — the catalog uses plain X.Y.Z). Missing components read as
|
|
@@ -69,3 +71,31 @@ function compareToCatalog(manifest, catalog) {
|
|
|
69
71
|
unknownIds,
|
|
70
72
|
};
|
|
71
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* The canonical "your repo trails the catalog" nudge, shared by `convene doctor`
|
|
76
|
+
* and the `convene fetch` channel nudge so the SAME sentence ALWAYS means the
|
|
77
|
+
* same thing: `serverVersion` is the SERVER-published catalog version
|
|
78
|
+
* (authoritative), `repoVersion` is what this repo adopted. Never call this with
|
|
79
|
+
* a bundled-mirror version — a stale binary's bundled version is not "available"
|
|
80
|
+
* on the server (see `bestPracticesFreshnessLine`'s offline branch for that).
|
|
81
|
+
*/
|
|
82
|
+
function catalogBehindLine(serverVersion, repoVersion) {
|
|
83
|
+
return `server catalog v${serverVersion} available (repo adopted v${repoVersion}) — run \`convene update\``;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The `convene doctor` best-practices freshness line, given a catalog comparison
|
|
87
|
+
* and WHERE the catalog came from. Server-truthful by construction: it only makes
|
|
88
|
+
* an "available"/"up to date" claim when the comparison was against the LIVE
|
|
89
|
+
* server catalog. When the server was unreachable the comparison is against the
|
|
90
|
+
* bundled mirror baked into this binary — which may trail OR lead the server — so
|
|
91
|
+
* it refuses to claim either and just states both versions + that the server is
|
|
92
|
+
* unknown. Pure (no stdout/network) so it unit-tests directly.
|
|
93
|
+
*/
|
|
94
|
+
function bestPracticesFreshnessLine(cmp, source) {
|
|
95
|
+
if (source === 'live') {
|
|
96
|
+
return cmp.behind
|
|
97
|
+
? catalogBehindLine(cmp.catalogVersion, cmp.repoVersion)
|
|
98
|
+
: `best practices up to date with server catalog v${cmp.repoVersion}`;
|
|
99
|
+
}
|
|
100
|
+
return `bundled catalog v${cmp.catalogVersion} (offline — server version unknown) vs repo adopted v${cmp.repoVersion}`;
|
|
101
|
+
}
|
package/dist/commands/auth.js
CHANGED
|
@@ -636,22 +636,35 @@ async function doctor(opts) {
|
|
|
636
636
|
for (const c of checks) {
|
|
637
637
|
process.stdout.write(`${c.ok ? '✓' : '✗'} ${c.name.padEnd(8)} ${c.detail}\n`);
|
|
638
638
|
}
|
|
639
|
-
// Best practices (
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
639
|
+
// Best practices (advisory): adopted-practice inventory + whether the repo
|
|
640
|
+
// trails the catalog. Fail-soft — never throws and never alters the doctor exit
|
|
641
|
+
// code. Resolve the catalog SERVER-TRUTHFULLY first (prefer the live published
|
|
642
|
+
// catalog, fall back to the bundled mirror) — the same `loadCatalog` resolver
|
|
643
|
+
// `convene update` uses — so the "available" line reflects what the SERVER
|
|
644
|
+
// publishes, not the version baked into this binary. doctor is already async +
|
|
645
|
+
// already does network I/O (api.me above), so a live fetch here costs nothing
|
|
646
|
+
// extra; loadCatalog is fail-soft and tags its source, so an unreachable server
|
|
647
|
+
// simply yields the bundled mirror, labelled honestly as offline.
|
|
648
|
+
const { catalog: bpCatalog, source: bpSource } = await (0, catalog_1.loadCatalog)(cfg.apiKey ? new api_1.ConveneApi(cfg.baseUrl, cfg.apiKey) : null);
|
|
649
|
+
reportBestPractices(top, proj?.slug ?? null, bpCatalog, bpSource);
|
|
643
650
|
if (!checks.every((c) => c.ok))
|
|
644
651
|
process.exitCode = 1;
|
|
645
652
|
}
|
|
646
653
|
/**
|
|
647
|
-
* Print doctor's "Best practices" section.
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
654
|
+
* Print doctor's "Best practices" section. Diffs the repo manifest against the
|
|
655
|
+
* RESOLVED catalog (`catalog`/`source` come from loadCatalog — live-preferred,
|
|
656
|
+
* bundled fallback). Purely informational — it never throws (a malformed manifest
|
|
657
|
+
* is swallowed) and never sets the exit code.
|
|
658
|
+
* - manifest present → server-truthful freshness line + each adopted practice + unknowns.
|
|
651
659
|
* - on the bus but no manifest → a single nudge to adopt some.
|
|
652
660
|
* - not on the bus → nothing.
|
|
661
|
+
* The freshness wording is server-truthful: an "available"/"up to date" claim is
|
|
662
|
+
* made only when `source === 'live'`; an unreachable server yields the honest
|
|
663
|
+
* "bundled catalog … (offline — server version unknown)" line (see
|
|
664
|
+
* `bestPracticesFreshnessLine`). The same `catalogBehindLine` phrasing is shared
|
|
665
|
+
* with the `convene fetch` nudge so the sentence never means two different things.
|
|
653
666
|
*/
|
|
654
|
-
function reportBestPractices(top, slug) {
|
|
667
|
+
function reportBestPractices(top, slug, catalog, source) {
|
|
655
668
|
try {
|
|
656
669
|
const manifest = (0, config_1.loadManifest)(top);
|
|
657
670
|
if (!manifest) {
|
|
@@ -660,10 +673,8 @@ function reportBestPractices(top, slug) {
|
|
|
660
673
|
}
|
|
661
674
|
return;
|
|
662
675
|
}
|
|
663
|
-
const cmp = (0, manifest_1.compareToCatalog)(manifest,
|
|
664
|
-
process.stdout.write(cmp
|
|
665
|
-
? `· catalog v${cmp.catalogVersion} available (repo on v${cmp.repoVersion}) — run \`convene update\`\n`
|
|
666
|
-
: `· best practices up to date at v${cmp.repoVersion}\n`);
|
|
676
|
+
const cmp = (0, manifest_1.compareToCatalog)(manifest, catalog);
|
|
677
|
+
process.stdout.write(`· ${(0, manifest_1.bestPracticesFreshnessLine)(cmp, source)}\n`);
|
|
667
678
|
for (const p of cmp.adopted) {
|
|
668
679
|
const flag = p.outdated ? ` (outdated → v${p.catalogVersion})` : '';
|
|
669
680
|
process.stdout.write(` ${p.id} @ ${p.manifestVersion} [${p.level}]${flag}\n`);
|
package/dist/commands/fetch.js
CHANGED
|
@@ -105,7 +105,10 @@ function catalogBehindNudge(top) {
|
|
|
105
105
|
return null;
|
|
106
106
|
if (!(0, manifest_1.semverLt)(manifest.catalogVersion, live))
|
|
107
107
|
return null;
|
|
108
|
-
|
|
108
|
+
// Shared phrasing with `convene doctor` — `live` is the SERVER version (the
|
|
109
|
+
// cache is populated from api.getCatalog), so the "server catalog vX" wording
|
|
110
|
+
// is accurate here and means the SAME thing in both surfaces.
|
|
111
|
+
return `convene: ${(0, manifest_1.catalogBehindLine)(live, manifest.catalogVersion)}`;
|
|
109
112
|
}
|
|
110
113
|
catch {
|
|
111
114
|
return null;
|
|
@@ -129,7 +129,23 @@ function loudOpen(systemMessage) {
|
|
|
129
129
|
}
|
|
130
130
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
131
131
|
async function run(opts) {
|
|
132
|
-
|
|
132
|
+
// Read the PreToolUse / pre-push payload ONCE up front. stdin can only be read
|
|
133
|
+
// once, so everything downstream reuses `payloadRaw`. For the Claude Code
|
|
134
|
+
// PreToolUse path we need it to resolve the directory the push ACTUALLY runs in
|
|
135
|
+
// BEFORE resolving the repo — a release is routinely pushed from a SEPARATE
|
|
136
|
+
// worktree, and gating the SESSION-ROOT checkout instead false-blocks it (bug
|
|
137
|
+
// deff9ab9).
|
|
138
|
+
const payloadRaw = opts.stdin ? await readStdin(1500) : null;
|
|
139
|
+
const trimmed = (payloadRaw ?? '').trim();
|
|
140
|
+
const isHookPayload = trimmed.startsWith('{');
|
|
141
|
+
// Effective push directory: the PreToolUse `cwd` (reflects a persisted `cd`)
|
|
142
|
+
// overridden by a leading in-command `cd`; process.cwd() otherwise — which is
|
|
143
|
+
// also correct for the real git pre-push hook, where cwd already IS the pushed
|
|
144
|
+
// worktree.
|
|
145
|
+
const pushCwd = isHookPayload
|
|
146
|
+
? (0, guard_1.resolvePushCwd)(payloadRaw, (0, guard_1.commandFromPayload)(payloadRaw))
|
|
147
|
+
: { dir: process.cwd(), indeterminate: false };
|
|
148
|
+
const top = (0, git_1.gitToplevel)(pushCwd.dir);
|
|
133
149
|
if (!top)
|
|
134
150
|
return 0; // not a git repo → no-op
|
|
135
151
|
const cwd = top;
|
|
@@ -143,15 +159,14 @@ async function run(opts) {
|
|
|
143
159
|
// Determine the ref(s) being pushed.
|
|
144
160
|
let refs;
|
|
145
161
|
if (opts.stdin) {
|
|
146
|
-
|
|
147
|
-
const trimmed = (stdin ?? '').trim();
|
|
148
|
-
if (trimmed.startsWith('{')) {
|
|
162
|
+
if (isHookPayload) {
|
|
149
163
|
// Claude Code PreToolUse/PostToolUse payload (JSON). Gate ONLY a real
|
|
150
164
|
// `git push` — classify the command with the SAME anchored classifier as
|
|
151
165
|
// `guard`, so an ordinary Bash command (even one whose ARGS contain
|
|
152
166
|
// "deploy"/"release"/a ref name) is a zero-network no-op and NEVER claims a
|
|
153
|
-
// lane. A bare `git push` (no refspec) falls back to the current branch
|
|
154
|
-
|
|
167
|
+
// lane. A bare `git push` (no refspec) falls back to the current branch (of
|
|
168
|
+
// the RESOLVED push worktree).
|
|
169
|
+
const cls = (0, guard_1.classifyCommand)((0, guard_1.commandFromPayload)(payloadRaw));
|
|
155
170
|
if (cls.kind === 'push') {
|
|
156
171
|
const cb = (0, git_1.currentBranch)(cwd);
|
|
157
172
|
refs = cls.refs.length ? cls.refs : cb ? [`refs/heads/${cb}`] : [];
|
|
@@ -162,7 +177,7 @@ async function run(opts) {
|
|
|
162
177
|
}
|
|
163
178
|
else {
|
|
164
179
|
// git pre-push hook context: real refspecs arrive on stdin.
|
|
165
|
-
refs =
|
|
180
|
+
refs = payloadRaw ? parseRefsFromStdin(payloadRaw) : [];
|
|
166
181
|
if (!refs.length) {
|
|
167
182
|
const b = (0, git_1.currentBranch)(cwd);
|
|
168
183
|
refs = b ? [`refs/heads/${b}`] : [];
|
|
@@ -280,13 +295,26 @@ async function run(opts) {
|
|
|
280
295
|
}
|
|
281
296
|
// 200 → we hold the lane (claimed fresh or self-reclaimed). Now the COMPAT gate.
|
|
282
297
|
const compat = await compatP;
|
|
283
|
-
if (compat.behind) {
|
|
284
|
-
// Behind HEAD after a fresh fetch
|
|
298
|
+
if (compat.behind && !pushCwd.indeterminate) {
|
|
299
|
+
// Behind HEAD after a fresh fetch, against the RESOLVED push worktree →
|
|
300
|
+
// CONFIRMED positive → release + hard block.
|
|
285
301
|
await api.laneRelease(slug, lane, NET_TIMEOUT_MS).catch(() => undefined);
|
|
286
302
|
blockReason(`convene: BLOCKED — HEAD is behind origin/${ref.replace(/^refs\/heads\//, '')} after fetch. ` +
|
|
287
303
|
`Run \`git pull --rebase\` then push again.`);
|
|
288
304
|
return 2;
|
|
289
305
|
}
|
|
306
|
+
if (compat.behind && pushCwd.indeterminate) {
|
|
307
|
+
// A dynamic in-command `cd` meant we could NOT resolve the pushed worktree, so
|
|
308
|
+
// this "behind" verdict may have come from an unrelated checkout. The lane IS
|
|
309
|
+
// held (the claim succeeded); keep it and ALLOW — degrade only the LOCAL
|
|
310
|
+
// freshness check to fail-open-loud rather than risk a false block (bug
|
|
311
|
+
// deff9ab9). NOTE: the committed git pre-push hook only RE-gates freshness when
|
|
312
|
+
// `convene.blockingPush` is enabled (it otherwise just posts a status), so this
|
|
313
|
+
// is a genuine skip of the freshness check, not a deferral to another gate.
|
|
314
|
+
loudOpen(`convene: deploy lane ${lane} claimed; could not resolve the push worktree from the ` +
|
|
315
|
+
`command, so the behind-HEAD freshness check was SKIPPED — proceeding UNGATED on freshness. ` +
|
|
316
|
+
`Confirm the branch is current before relying on this deploy.`);
|
|
317
|
+
}
|
|
290
318
|
// Also honor a directed halt even when the lane was free (defense in depth).
|
|
291
319
|
const halt = await directedHaltFor(api, slug, ref);
|
|
292
320
|
if (halt) {
|
package/dist/commands/guard.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.classifyPushRefs = classifyPushRefs;
|
|
4
7
|
exports.classifyCommand = classifyCommand;
|
|
5
8
|
exports.commandFromPayload = commandFromPayload;
|
|
9
|
+
exports.cwdFromPayload = cwdFromPayload;
|
|
10
|
+
exports.resolvePushCwd = resolvePushCwd;
|
|
6
11
|
exports.guard = guard;
|
|
7
12
|
/**
|
|
8
13
|
* `convene guard` (WP9) — the PreToolUse halt+lane gate for Bash commands. Wired
|
|
@@ -30,6 +35,7 @@ exports.guard = guard;
|
|
|
30
35
|
* (b) a CONFIRMED held lane for a DEPLOY command (different instance).
|
|
31
36
|
* A SOFT held-lane conflict (foreign live lane, no directed halt) → 'ask'.
|
|
32
37
|
*/
|
|
38
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
33
39
|
const git_1 = require("../git");
|
|
34
40
|
const config_1 = require("../config");
|
|
35
41
|
const cache_1 = require("../cache");
|
|
@@ -167,6 +173,123 @@ function commandFromPayload(raw) {
|
|
|
167
173
|
return '';
|
|
168
174
|
}
|
|
169
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Pull the top-level `cwd` (the directory the tool will run in) out of a Claude
|
|
178
|
+
* Code PreToolUse payload, or null if absent/unparseable. Claude Code stamps this
|
|
179
|
+
* with the session's CURRENT working directory, which already reflects any `cd`
|
|
180
|
+
* that PERSISTED from a prior Bash call (within the project / additional dirs).
|
|
181
|
+
*/
|
|
182
|
+
function cwdFromPayload(raw) {
|
|
183
|
+
if (!raw)
|
|
184
|
+
return null;
|
|
185
|
+
try {
|
|
186
|
+
const j = JSON.parse(raw);
|
|
187
|
+
const c = j?.cwd;
|
|
188
|
+
return typeof c === 'string' && c.trim() ? c.trim() : null;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function isPush(w) {
|
|
195
|
+
return w[0] === 'git' && w[1] === 'push';
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Resolve a `cd`/`pushd` target to an absolute dir against `from`, or report it
|
|
199
|
+
* unresolvable. Strips cd option flags (`--`, `-L`, `-P`, …); rejects `-` (OLDPWD)
|
|
200
|
+
* and any dynamic/quoted/glob/`~`/escaped path — only a single literal path is safe.
|
|
201
|
+
*/
|
|
202
|
+
function resolveCdTarget(args, from) {
|
|
203
|
+
let rest = args;
|
|
204
|
+
while (rest.length && (rest[0] === '--' || /^-[A-Za-z]+$/.test(rest[0])))
|
|
205
|
+
rest = rest.slice(1);
|
|
206
|
+
const target = rest[0];
|
|
207
|
+
if (!target || target === '-' || rest.length > 1 || /[$*?`"'~\\]/.test(target))
|
|
208
|
+
return null;
|
|
209
|
+
return node_path_1.default.isAbsolute(target) ? target : node_path_1.default.resolve(from, target);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Resolve the directory a gated `git push` will run in, for the PreToolUse gate.
|
|
213
|
+
*
|
|
214
|
+
* The base is the PreToolUse payload's `cwd` (the session's current dir, already
|
|
215
|
+
* reflecting a PERSISTED `cd`), falling back to `process.cwd()` — which is also
|
|
216
|
+
* the right answer for the real git pre-push hook path, where the process cwd IS
|
|
217
|
+
* the pushed worktree. But the hook fires BEFORE the command runs, so a `cd`
|
|
218
|
+
* performed INSIDE the gated command — `(cd ../repo-release; git push origin main)`
|
|
219
|
+
* or `cd ../wt && git push` — is NOT yet reflected in `cwd`. We therefore also walk
|
|
220
|
+
* the command and apply a leading `cd`/`pushd` that precedes the push.
|
|
221
|
+
*
|
|
222
|
+
* Shell-faithful enough for the real cases: a `(` opens a SUBSHELL scope (a `cd`
|
|
223
|
+
* inside it that `)` closes BEFORE the push does not persist — `(cd X); git push`
|
|
224
|
+
* runs in the base, `(cd X; git push)` runs in X); a `cd` whose `||`/pipe RHS IS
|
|
225
|
+
* the push does not apply (the push then runs in the base); anything we cannot
|
|
226
|
+
* resolve statically (a `$var`/glob/quoted target, a bare `popd`, an over-long
|
|
227
|
+
* command) flags `indeterminate` so the caller degrades to fail-open-loud rather
|
|
228
|
+
* than trusting a guessed dir.
|
|
229
|
+
*
|
|
230
|
+
* Why this matters (bug deff9ab9): a release is routinely pushed from a SEPARATE
|
|
231
|
+
* git worktree — the one-worktree-per-session pattern Convene itself recommends.
|
|
232
|
+
* Resolving the repo from the SESSION-ROOT checkout instead of the pushed worktree
|
|
233
|
+
* made the behind-HEAD gate compare against a stale, unrelated HEAD and false-block
|
|
234
|
+
* the push.
|
|
235
|
+
*/
|
|
236
|
+
function resolvePushCwd(raw, command, fallback = process.cwd()) {
|
|
237
|
+
const base = cwdFromPayload(raw) ?? fallback;
|
|
238
|
+
const cmd = (command ?? '').trim();
|
|
239
|
+
if (!cmd)
|
|
240
|
+
return { dir: base, indeterminate: false };
|
|
241
|
+
// An absurdly long command is pathological (and could waste work pre-watchdog) —
|
|
242
|
+
// do not try to parse it; fail-open on the local freshness check.
|
|
243
|
+
if (cmd.length > 8192)
|
|
244
|
+
return { dir: base, indeterminate: true };
|
|
245
|
+
// Split on shell connectors, KEEPING them. `||` must precede `|` in the
|
|
246
|
+
// alternation so a single pipe never shadows it.
|
|
247
|
+
const parts = cmd.split(/(\s*&&\s*|\s*\|\|\s*|\s*;\s*|\s*\|\s*|\n)/);
|
|
248
|
+
const segs = [];
|
|
249
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
250
|
+
const rawSeg = parts[i] ?? '';
|
|
251
|
+
const open = (rawSeg.match(/^\s*\(+/)?.[0].match(/\(/g) || []).length;
|
|
252
|
+
const close = (rawSeg.match(/\)+\s*$/)?.[0].match(/\)/g) || []).length;
|
|
253
|
+
const seg = rawSeg.replace(/^[\s(]+/, '').replace(/[\s)]+$/, '');
|
|
254
|
+
const words = seg.split(/\s+/).filter(Boolean);
|
|
255
|
+
let k = 0;
|
|
256
|
+
while (k < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[k]) || /^(sudo|command|exec|time|env)$/.test(words[k])))
|
|
257
|
+
k++;
|
|
258
|
+
segs.push({ open, close, words: words.slice(k), conn: (parts[i + 1] ?? '').trim() });
|
|
259
|
+
}
|
|
260
|
+
let dir = base;
|
|
261
|
+
let indeterminate = false;
|
|
262
|
+
const scope = []; // saved dirs at each OPEN `(` — restored when it `)` closes
|
|
263
|
+
for (let idx = 0; idx < segs.length; idx++) {
|
|
264
|
+
const s = segs[idx];
|
|
265
|
+
for (let o = 0; o < s.open; o++)
|
|
266
|
+
scope.push(dir); // enter subshell(s)
|
|
267
|
+
const w = s.words;
|
|
268
|
+
if (isPush(w))
|
|
269
|
+
break; // reached the push — `dir` is its cwd (still inside any open subshell)
|
|
270
|
+
if (w[0] === 'cd' || w[0] === 'pushd') {
|
|
271
|
+
// A pipe RHS, or a `||` RHS that IS the push, means the push does NOT run in
|
|
272
|
+
// this cd's dir (cd in a pipe is a subshell; `cd X || git push` only pushes
|
|
273
|
+
// when the cd FAILED) — leave the dir untouched. A `cd X || handler; … push`
|
|
274
|
+
// still applies (its success persists to the later push).
|
|
275
|
+
const pushIsRhs = (s.conn === '||' || s.conn === '|') && isPush(segs[idx + 1]?.words ?? []);
|
|
276
|
+
if (s.conn !== '|' && !pushIsRhs) {
|
|
277
|
+
const resolved = resolveCdTarget(w.slice(1), dir);
|
|
278
|
+
if (resolved === null)
|
|
279
|
+
indeterminate = true;
|
|
280
|
+
else
|
|
281
|
+
dir = resolved;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else if (w[0] === 'popd') {
|
|
285
|
+
indeterminate = true; // we do not model the pushd/popd stack
|
|
286
|
+
}
|
|
287
|
+
for (let c = 0; c < s.close; c++)
|
|
288
|
+
if (scope.length)
|
|
289
|
+
dir = scope.pop(); // exit subshell(s)
|
|
290
|
+
}
|
|
291
|
+
return { dir, indeterminate };
|
|
292
|
+
}
|
|
170
293
|
function emitJson(obj) {
|
|
171
294
|
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
172
295
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "convene-cli",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.2",
|
|
4
4
|
"description": "Convene CLI — AI development coordination bus client + UserPromptSubmit hook. Install: npm i -g convene-cli; then `convene setup`.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://convene.live",
|