agileflow 4.0.0-alpha.30 → 4.0.0-alpha.32
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/package.json
CHANGED
|
@@ -1341,6 +1341,31 @@ async function maybeOfferAutoRestore(prefs) {
|
|
|
1341
1341
|
async function launch(sub, nameArg, _options) {
|
|
1342
1342
|
try {
|
|
1343
1343
|
if (sub === "new") {
|
|
1344
|
+
// Alt+n triggers `agileflow launch new --prompt` (in a fresh
|
|
1345
|
+
// tmux window with a real TTY) so we can use Clack to read the
|
|
1346
|
+
// worktree name safely. Avoids the shell-injection vulnerability
|
|
1347
|
+
// where tmux's command-prompt substitutes %% into a run-shell
|
|
1348
|
+
// command BEFORE shell parsing, allowing `name"; rm -rf ~; #`
|
|
1349
|
+
// to execute arbitrary commands.
|
|
1350
|
+
const wantPrompt = (process.argv || []).includes("--prompt");
|
|
1351
|
+
if (!nameArg && wantPrompt) {
|
|
1352
|
+
const promptName = await prompts.text({
|
|
1353
|
+
message: "Worktree name (Esc cancels):",
|
|
1354
|
+
validate: (v) => {
|
|
1355
|
+
if (!v) return "name required";
|
|
1356
|
+
if (!/^[A-Za-z0-9._-]+$/.test(v)) {
|
|
1357
|
+
return "letters, digits, dot, underscore, hyphen only";
|
|
1358
|
+
}
|
|
1359
|
+
return undefined;
|
|
1360
|
+
},
|
|
1361
|
+
});
|
|
1362
|
+
if (prompts.isCancel(promptName)) {
|
|
1363
|
+
prompts.cancel("Cancelled.");
|
|
1364
|
+
process.exit(0);
|
|
1365
|
+
}
|
|
1366
|
+
await runNew(String(promptName));
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1344
1369
|
await runNew(nameArg);
|
|
1345
1370
|
return;
|
|
1346
1371
|
}
|
|
@@ -309,6 +309,12 @@ function clearOlderThan(maxAgeMs, home) {
|
|
|
309
309
|
const log = loadLog(home);
|
|
310
310
|
const cutoff = Date.now() - maxAgeMs;
|
|
311
311
|
let removed = 0;
|
|
312
|
+
// Collect keys to delete in a separate pass — mutating an object
|
|
313
|
+
// during Object.entries iteration works in current V8 (entries
|
|
314
|
+
// returns a snapshot of keys) but is fragile and silently breaks
|
|
315
|
+
// if a maintainer swaps to `for (const k in log.sessions)`.
|
|
316
|
+
/** @type {string[]} */
|
|
317
|
+
const toDelete = [];
|
|
312
318
|
for (const [sessionName, list] of Object.entries(log.sessions)) {
|
|
313
319
|
const kept = list.filter((e) => {
|
|
314
320
|
const t = Date.parse(e.closedAt || "");
|
|
@@ -316,9 +322,13 @@ function clearOlderThan(maxAgeMs, home) {
|
|
|
316
322
|
return t >= cutoff;
|
|
317
323
|
});
|
|
318
324
|
removed += list.length - kept.length;
|
|
319
|
-
if (kept.length === 0)
|
|
320
|
-
|
|
325
|
+
if (kept.length === 0) {
|
|
326
|
+
toDelete.push(sessionName);
|
|
327
|
+
} else {
|
|
328
|
+
log.sessions[sessionName] = kept;
|
|
329
|
+
}
|
|
321
330
|
}
|
|
331
|
+
for (const k of toDelete) delete log.sessions[k];
|
|
322
332
|
if (removed > 0) writeLog(log, home);
|
|
323
333
|
return removed;
|
|
324
334
|
});
|
|
@@ -213,16 +213,34 @@ function runRestoreInner(opts) {
|
|
|
213
213
|
? entry.wrapperWindowIndex
|
|
214
214
|
: 0;
|
|
215
215
|
let replayed = 0;
|
|
216
|
+
let replayFailures = 0;
|
|
216
217
|
for (const w of sorted) {
|
|
217
218
|
if (w.index === wrapperIdx) continue;
|
|
218
219
|
if (!existsSync(w.cwd)) continue;
|
|
219
220
|
const args = ["new-window", "-t", entry.name, "-c", w.cwd];
|
|
220
221
|
if (w.name) args.push("-n", w.name);
|
|
221
|
-
runner.runSync(args);
|
|
222
|
-
|
|
222
|
+
const r = runner.runSync(args);
|
|
223
|
+
// Only count tabs that actually got created. tmux can refuse
|
|
224
|
+
// new-window on resource limits, permission errors, or if the
|
|
225
|
+
// session got killed between createSession and this call —
|
|
226
|
+
// surface those so the user knows the restore was partial,
|
|
227
|
+
// instead of a misleading green-check log.
|
|
228
|
+
if (r.status === 0) {
|
|
229
|
+
replayed++;
|
|
230
|
+
} else {
|
|
231
|
+
replayFailures++;
|
|
232
|
+
log(
|
|
233
|
+
`agileflow launch: failed to replay tab ${
|
|
234
|
+
w.name || `(index ${w.index})`
|
|
235
|
+
} for ${entry.name} — ${(r.stderr || "unknown").trim()}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
223
238
|
}
|
|
224
|
-
if (replayed > 0) {
|
|
225
|
-
log(
|
|
239
|
+
if (replayed > 0 || replayFailures > 0) {
|
|
240
|
+
log(
|
|
241
|
+
`agileflow launch: replayed ${replayed} tab(s) for ${entry.name}` +
|
|
242
|
+
(replayFailures > 0 ? ` (${replayFailures} failed)` : ""),
|
|
243
|
+
);
|
|
226
244
|
}
|
|
227
245
|
}
|
|
228
246
|
// Install hooks AFTER replay so the new-window calls above don't
|
|
@@ -486,18 +486,18 @@ const KEYBIND_PRESET_BINDINGS = {
|
|
|
486
486
|
hint: "Alt+s → spawn a same-dir parallel session",
|
|
487
487
|
},
|
|
488
488
|
{
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
//
|
|
489
|
+
// Open a fresh tmux window and run the agileflow CLI with the
|
|
490
|
+
// `--prompt` flag. The CLI uses Clack to read the worktree name
|
|
491
|
+
// from a real TTY — no shell substitution involved.
|
|
492
|
+
//
|
|
493
|
+
// Previous design used `command-prompt -p '...' "run-shell
|
|
494
|
+
// '%AGILEFLOW% launch new \"%%\"'"`, which substituted %% INTO
|
|
495
|
+
// the run-shell argument BEFORE shell parsing. A user typing
|
|
496
|
+
// `name"; rm -rf ~; #` would get arbitrary shell execution.
|
|
497
|
+
// The new design routes input through stdin to a Node process
|
|
498
|
+
// that never touches a shell, so injection is impossible.
|
|
494
499
|
key: "M-n",
|
|
495
|
-
action: [
|
|
496
|
-
"command-prompt",
|
|
497
|
-
"-p",
|
|
498
|
-
"worktree name (esc to cancel):",
|
|
499
|
-
"run-shell '%AGILEFLOW% launch new \"%%\"'",
|
|
500
|
-
],
|
|
500
|
+
action: ["new-window", "%AGILEFLOW% launch new --prompt"],
|
|
501
501
|
hint: "Alt+n → prompt for a name, create a worktree, spawn there",
|
|
502
502
|
},
|
|
503
503
|
// v3-equivalent tab (tmux window) keybinds. Living in a separate
|