fix: v2.3.1 — correct version, say why a plugin update failed, gitignored files in Files
- v2.3.0's tree reported 2.2.1, so every install saw an update forever; package.json, the lockfile, openapi.yaml, the CLAUDE.md marker and the snapshot now read 2.3.1 - A failed plugin-update step records the tail of the command's stdout and stderr plus its exit code or signal (failureText); plugin-bootstrap.js prints its refusal on stdout, which the verdict used to drop. Update all uses the same text - updates.test.js never applies an update: it skips when the checkout is behind a release (apply ran npm run setup and pruned dev dependencies) - Files tree lists gitignored files and folders, named in orange (addIgnored in git-diff.js); change-tree rows indent in whole steps
This commit is contained in:
@@ -248,6 +248,7 @@ function TreeNodes({
|
|||||||
type="button"
|
type="button"
|
||||||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||||||
data-tree-kept={isKept || undefined}
|
data-tree-kept={isKept || undefined}
|
||||||
|
data-tree-ignored={e.ignored || undefined}
|
||||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||||
@@ -255,11 +256,13 @@ function TreeNodes({
|
|||||||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||||||
isKept
|
isKept
|
||||||
? "bg-kept/10 text-kept"
|
? "bg-kept/10 text-kept"
|
||||||
: mark
|
: e.ignored
|
||||||
? mark.tone
|
? "text-kept"
|
||||||
: e.changed
|
: mark
|
||||||
? "text-status-warning"
|
? mark.tone
|
||||||
: "text-fg-primary"
|
: e.changed
|
||||||
|
? "text-status-warning"
|
||||||
|
: "text-fg-primary"
|
||||||
} ${active === e.path ? "!bg-surface-4" : ""}`}
|
} ${active === e.path ? "!bg-surface-4" : ""}`}
|
||||||
>
|
>
|
||||||
{e.dir ? (
|
{e.dir ? (
|
||||||
@@ -495,7 +498,9 @@ function ChangeRow({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className="group flex items-baseline gap-1"
|
className="group flex items-baseline gap-1"
|
||||||
style={depth ? { paddingLeft: `${depth * 0.9}rem` } : undefined}
|
// A file has no chevron: reserve its 1rem so the letter lines up
|
||||||
|
// with a sibling folder's icon, one step inside the parent folder.
|
||||||
|
style={{ paddingLeft: `${depth + 1}rem` }}
|
||||||
onContextMenu={(e) => onMenu(e, f)}
|
onContextMenu={(e) => onMenu(e, f)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -571,10 +576,7 @@ function ChangeTreeNodes({
|
|||||||
const open = !collapsed.has(key);
|
const open = !collapsed.has(key);
|
||||||
return (
|
return (
|
||||||
<li key={d.path}>
|
<li key={d.path}>
|
||||||
<div
|
<div className="group flex items-center gap-1" style={{ paddingLeft: `${depth}rem` }}>
|
||||||
className="group flex items-center gap-1"
|
|
||||||
style={{ paddingLeft: `${depth * 0.9}rem` }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
|||||||
@@ -2764,6 +2764,8 @@ export interface LaneTreeEntry {
|
|||||||
status?: string;
|
status?: string;
|
||||||
/** A folder with a changed or untracked file anywhere under it. */
|
/** A folder with a changed or untracked file anywhere under it. */
|
||||||
changed?: true;
|
changed?: true;
|
||||||
|
/** Ignored by git (`.gitignore`) — drawn orange in the Files tree. */
|
||||||
|
ignored?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One row of a rendered diff — both gutters already resolved server-side. */
|
/** One row of a rendered diff — both gutters already resolved server-side. */
|
||||||
|
|||||||
@@ -6913,7 +6913,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<p
|
<p
|
||||||
class="text-[10px] text-status-warning/90 mt-1"
|
class="text-[10px] text-status-warning/90 mt-1"
|
||||||
>
|
>
|
||||||
UI build v2.2.1 (rebuild client to match)
|
UI build v2.3.1 (rebuild client to match)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.2.1
|
version: 2.3.1
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.2.1",
|
"version": "2.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.2.1",
|
"version": "2.3.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.2.1",
|
"version": "2.3.1",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const fs = require("fs");
|
|||||||
const { rotateIfLarge } = require("./log-rotate");
|
const { rotateIfLarge } = require("./log-rotate");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFileSync, spawnSync } = require("child_process");
|
const { execFileSync, spawnSync } = require("child_process");
|
||||||
|
// What a failed command printed — plugin-bootstrap.js says why on STDOUT.
|
||||||
|
const { failureText } = require("./plugin-update-helper");
|
||||||
|
|
||||||
const [, , runtimeDirArg, planJson] = process.argv;
|
const [, , runtimeDirArg, planJson] = process.argv;
|
||||||
|
|
||||||
@@ -229,7 +231,7 @@ function main() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A marketplace that can't be refreshed (offline, auth) does not stop the
|
// A marketplace that can't be refreshed (offline, auth) does not stop the
|
||||||
// batch: the plugins may still have updates from the marketplaces that did.
|
// batch: the plugins may still have updates from the marketplaces that did.
|
||||||
finishStep(mktStep, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
finishStep(mktStep, "failed", { error: failureText(err).slice(0, 2000) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const self = plan.selfPlugin;
|
const self = plan.selfPlugin;
|
||||||
@@ -243,7 +245,7 @@ function main() {
|
|||||||
run(claude, ["plugin", "update", id, "-y"], 600_000);
|
run(claude, ["plugin", "update", id, "-y"], 600_000);
|
||||||
finishStep(step, "ok");
|
finishStep(step, "ok");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
finishStep(step, "failed", { error: failureText(err).slice(0, 2000) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +255,7 @@ function main() {
|
|||||||
try {
|
try {
|
||||||
run(claude, ["plugin", "update", selfId, "-y"], 600_000);
|
run(claude, ["plugin", "update", selfId, "-y"], 600_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
finishStep(step, "failed", { error: String(err.stderr || err.message).slice(0, 2000) });
|
finishStep(step, "failed", { error: failureText(err).slice(0, 2000) });
|
||||||
reloadPanes();
|
reloadPanes();
|
||||||
state.status = "failed";
|
state.status = "failed";
|
||||||
state.finished_at = new Date().toISOString();
|
state.finished_at = new Date().toISOString();
|
||||||
@@ -281,7 +283,7 @@ function main() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`bootstrap failed: ${err.message}`);
|
log(`bootstrap failed: ${err.message}`);
|
||||||
state.status = "failed";
|
state.status = "failed";
|
||||||
state.bootstrap_error = String(err.stderr || err.message).slice(0, 2000);
|
state.bootstrap_error = failureText(err).slice(0, 2000);
|
||||||
state.note = "the old server was stopped; run /agenthub-update to recover";
|
state.note = "the old server was stopped; run /agenthub-update to recover";
|
||||||
flush();
|
flush();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@
|
|||||||
* runtime directory (outside the plugin cache, so it survives the swap) and the
|
* runtime directory (outside the plugin cache, so it survives the swap) and the
|
||||||
* new server reports it back as `last_plugin_update`. Without that, a failed
|
* new server reports it back as `last_plugin_update`. Without that, a failed
|
||||||
* chain would look identical to a slow one: the tab would poll a server that
|
* chain would look identical to a slow one: the tab would poll a server that
|
||||||
* never comes back and say nothing about why.
|
* never comes back and say nothing about why. A failed step's verdict carries
|
||||||
|
* what the command printed (`failureText`): `plugin-bootstrap.js` says why it
|
||||||
|
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
|
||||||
|
* only "Command failed: <argv>".
|
||||||
*
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -35,8 +38,10 @@ const { execFileSync, spawnSync } = require("child_process");
|
|||||||
|
|
||||||
const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
|
const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
|
||||||
|
|
||||||
const RESULT_FILE = path.join(runtimeDirArg, "plugin-update.json");
|
// `|| ""`: required by a test (for `failureText`) there is no argv, and only
|
||||||
const LOG_FILE = path.join(runtimeDirArg, "plugin-update.log");
|
// main() — which a require never runs — writes these.
|
||||||
|
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
|
||||||
|
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
|
||||||
|
|
||||||
// Names come from the local cache PATH, never from an HTTP body, but they still
|
// Names come from the local cache PATH, never from an HTTP body, but they still
|
||||||
// reach an argv — so they are re-validated here rather than trusted across the
|
// reach an argv — so they are re-validated here rather than trusted across the
|
||||||
@@ -120,14 +125,44 @@ function run(cmd, args, timeout) {
|
|||||||
|
|
||||||
function runOnce(cmd, args, timeout) {
|
function runOnce(cmd, args, timeout) {
|
||||||
log(`$ ${cmd} ${args.join(" ")}`);
|
log(`$ ${cmd} ${args.join(" ")}`);
|
||||||
const out = execFileSync(cmd, args, {
|
try {
|
||||||
encoding: "utf8",
|
const out = execFileSync(cmd, args, {
|
||||||
timeout,
|
encoding: "utf8",
|
||||||
maxBuffer: 10_000_000,
|
timeout,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
maxBuffer: 10_000_000,
|
||||||
});
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
if (out) log(out.trim().slice(0, 4000));
|
});
|
||||||
return out;
|
if (out) log(out.trim().slice(0, 4000));
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
log(`failed (${howItEnded(err)})`);
|
||||||
|
for (const stream of [err.stdout, err.stderr]) {
|
||||||
|
if (stream) log(String(stream).trim().slice(-4000));
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function howItEnded(err) {
|
||||||
|
if (err.signal) return `killed by ${err.signal}`;
|
||||||
|
return err.status != null ? `exit ${err.status}` : err.code || "did not run";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed command's verdict text: the last lines it printed on stdout and
|
||||||
|
* stderr, then how it ended. Falls back to execFileSync's message ("Command
|
||||||
|
* failed: …", or ENOENT) only when the command printed nothing.
|
||||||
|
*/
|
||||||
|
function failureText(err) {
|
||||||
|
const tail = (s) =>
|
||||||
|
String(s || "")
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.slice(-5)
|
||||||
|
.join("\n");
|
||||||
|
const said = [tail(err.stdout), tail(err.stderr)].filter(Boolean);
|
||||||
|
if (!said.length) return `${String(err.message || err).split("\n")[0]} (${howItEnded(err)})`;
|
||||||
|
return [...said, `(${howItEnded(err)})`].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
@@ -149,14 +184,14 @@ function main() {
|
|||||||
try {
|
try {
|
||||||
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
record({ ok: false, step: "marketplace_update", error: String(err.stderr || err.message) });
|
record({ ok: false, step: "marketplace_update", error: failureText(err) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
record({ ok: false, step: "plugin_update", error: String(err.stderr || err.message) });
|
record({ ok: false, step: "plugin_update", error: failureText(err) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +236,7 @@ function main() {
|
|||||||
record({
|
record({
|
||||||
ok: false,
|
ok: false,
|
||||||
step: "bootstrap",
|
step: "bootstrap",
|
||||||
error: String(err.stderr || err.message),
|
error: failureText(err),
|
||||||
plugin_root: newRoot,
|
plugin_root: newRoot,
|
||||||
note: "the old server was stopped; run /agenthub-update to recover",
|
note: "the old server was stopped; run /agenthub-update to recover",
|
||||||
});
|
});
|
||||||
@@ -211,10 +246,14 @@ function main() {
|
|||||||
record({ ok: true, step: "done", plugin_root: newRoot });
|
record({ ok: true, step: "done", plugin_root: newRoot });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
module.exports = { failureText };
|
||||||
main();
|
|
||||||
} catch (err) {
|
if (require.main === module) {
|
||||||
// A helper that dies without a verdict is the failure mode this file exists
|
try {
|
||||||
// to prevent, so even an unexpected throw gets recorded.
|
main();
|
||||||
record({ ok: false, step: "unexpected", error: err.message || String(err) });
|
} catch (err) {
|
||||||
|
// A helper that dies without a verdict is the failure mode this file exists
|
||||||
|
// to prevent, so even an unexpected throw gets recorded.
|
||||||
|
record({ ok: false, step: "unexpected", error: err.message || String(err) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -530,7 +530,7 @@ Request body shape:
|
|||||||
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
| `GET` | `/api/lanes/:id/git/file?path=&rev=` | One file's whole content at `work` (realpath-confined to the workspace), `HEAD` or a commit-ish. 1 MB cap. `400 EBADPATH`/`EBADREV`/`ENOFILE`. |
|
||||||
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
|
| `PUT` | `/api/lanes/:id/git/file` | Same-origin guarded save from the whole-file editor: `{path, content, baseSha}`. An existing text file within the 1 MB cap, over exactly the version opened (`baseSha` = the read's `sha`, else `409 ESTALE`); `403 ESECRET`, `400 EBADPATH`/`ENOFILE`/`EBADCONTENT`/`ETOOBIG`/`EBINARY`. |
|
||||||
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
| `GET` | `/api/lanes/:id/git/ls?q=` | Tracked paths matching a substring, for quick-open. `git ls-files`, so ignored files never appear. Capped at 500. |
|
||||||
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. `400 EBADPATH` for a path escaping the workspace. |
|
| `GET` | `/api/lanes/:id/git/tree?path=` | One directory level of tracked + untracked-not-ignored files, folded; changed files carry their porcelain `status`, folders holding one `changed: true`. What git ignores at that level (read off the disk, `.git` excepted — also inside an ignored folder) is listed too with `ignored: true`; `tree-all` adds them per level but gives an ignored folder no level of its own. `400 EBADPATH` for a path escaping the workspace. |
|
||||||
| `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. |
|
| `GET` | `/api/lanes/:id/git/branches` | The workspace repo's own local branches + worktrees, for the branch switcher. The repo comes from the workspace row, never the query. |
|
||||||
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
|
| `GET` | `/api/git-credentials` | AgentHub's own git logins (one per host, shared by every user), `hasToken` only — never a password. |
|
||||||
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
|
| `POST` | `/api/git-credentials` | Same-origin guarded save/replace: `{protocol, host, username, password}`. `400 EBADHOST`/`EBADCRED`. |
|
||||||
|
|||||||
@@ -463,20 +463,32 @@ describe("listCommits / readFileAt / listPaths / listTree", () => {
|
|||||||
|
|
||||||
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
|
it("lists untracked-but-not-ignored files and marks what changed, folders included", async () => {
|
||||||
write(LANE, "src/main.ts", "m\n");
|
write(LANE, "src/main.ts", "m\n");
|
||||||
write(LANE, ".gitignore", "*.log\n");
|
write(LANE, ".gitignore", "*.log\nvendor/\n");
|
||||||
gc(LANE, "add", "-A");
|
gc(LANE, "add", "-A");
|
||||||
gc(LANE, "commit", "-qm", "src");
|
gc(LANE, "commit", "-qm", "src");
|
||||||
write(LANE, "a.txt", "modified\n");
|
write(LANE, "a.txt", "modified\n");
|
||||||
write(LANE, "new.txt", "fresh\n");
|
write(LANE, "new.txt", "fresh\n");
|
||||||
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
|
write(LANE, "gen/deep/out.txt", "wholly untracked folder\n");
|
||||||
write(LANE, "noise.log", "ignored\n");
|
write(LANE, "noise.log", "ignored\n");
|
||||||
|
write(LANE, "vendor/lib/x.js", "ignored folder\n");
|
||||||
|
|
||||||
const root = await listTree(LANE, {});
|
const root = await listTree(LANE, {});
|
||||||
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
|
const by = Object.fromEntries(root.entries.map((e) => [e.name, e]));
|
||||||
assert.equal(by["a.txt"].status, " M");
|
assert.equal(by["a.txt"].status, " M");
|
||||||
assert.equal(by["new.txt"].status, "??");
|
assert.equal(by["new.txt"].status, "??");
|
||||||
assert.equal(by["keep.txt"].status, undefined);
|
assert.equal(by["keep.txt"].status, undefined);
|
||||||
assert.equal(by["noise.log"], undefined, "an ignored file stays out of the tree");
|
assert.equal(by["noise.log"].ignored, true, "an ignored file is listed, marked");
|
||||||
|
assert.equal(by.vendor.ignored, true);
|
||||||
|
assert.equal(by.vendor.dir, true);
|
||||||
|
assert.equal(by[".git"], undefined, "git's own dir is never listed");
|
||||||
|
assert.equal(by["a.txt"].ignored, undefined);
|
||||||
|
assert.equal(by.src.ignored, undefined);
|
||||||
|
// Inside an ignored folder, which `ls-files --ignored` cannot list.
|
||||||
|
const lib = await listTree(LANE, { subdir: "vendor/lib" });
|
||||||
|
assert.deepEqual(
|
||||||
|
lib.entries.map((e) => [e.name, e.ignored]),
|
||||||
|
[["x.js", true]]
|
||||||
|
);
|
||||||
assert.equal(by.gen.changed, true);
|
assert.equal(by.gen.changed, true);
|
||||||
assert.equal(by.src.changed, undefined);
|
assert.equal(by.src.changed, undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -169,3 +169,36 @@ describe("plugin-update-helper name guard", () => {
|
|||||||
assert.ok(!Number.isNaN(Date.parse(v.finished_at)));
|
assert.ok(!Number.isNaN(Date.parse(v.finished_at)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("plugin-update-helper failureText", () => {
|
||||||
|
// Required, not spawned: the helper only runs main() as a script.
|
||||||
|
const { failureText } = require("../../scripts/plugin-update-helper");
|
||||||
|
|
||||||
|
it("names why the bootstrap refused, which it prints on stdout", () => {
|
||||||
|
const err = Object.assign(new Error("Command failed: node plugin-bootstrap.js --force"), {
|
||||||
|
status: 1,
|
||||||
|
stdout: "installing…\nbootstrap: locked\n",
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
assert.equal(failureText(err), "installing…\nbootstrap: locked\n(exit 1)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the last lines, stdout then stderr", () => {
|
||||||
|
const err = Object.assign(new Error("x"), {
|
||||||
|
status: 2,
|
||||||
|
stdout: ["1", "2", "3", "4", "5", "6", "7"].join("\n"),
|
||||||
|
stderr: "boom",
|
||||||
|
});
|
||||||
|
assert.equal(failureText(err), "3\n4\n5\n6\n7\nboom\n(exit 2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a killed command was killed, and falls back to the message when nothing was printed", () => {
|
||||||
|
const err = Object.assign(new Error("Command failed: node b.js --force\n"), {
|
||||||
|
status: null,
|
||||||
|
signal: "SIGTERM",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
});
|
||||||
|
assert.equal(failureText(err), "Command failed: node b.js --force (killed by SIGTERM)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -91,7 +91,17 @@ describe("POST /api/updates/check", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /api/updates/apply", () => {
|
describe("POST /api/updates/apply", () => {
|
||||||
it("declines with a reason instead of pulling/restarting when there is nothing to apply", async () => {
|
it("declines with a reason instead of pulling/restarting when there is nothing to apply", async (t) => {
|
||||||
|
// Never let this test APPLY anything. When this checkout really is behind
|
||||||
|
// a release (or reports a version older than the newest tag), the route
|
||||||
|
// fast-forwards the repo, runs `npm run setup` — which under the shell's
|
||||||
|
// NODE_ENV=production prunes every dev dependency — and SIGTERMs the
|
||||||
|
// process to restart it. That happened once; ask first and stand down.
|
||||||
|
const status = await httpFetch("/api/updates/status");
|
||||||
|
if (status.body.update_available) {
|
||||||
|
t.skip(`this checkout is behind ${status.body.latest_release}; not applying it in a test`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The test repo checkout has no update pending (or isn't fast-forwardable
|
// The test repo checkout has no update pending (or isn't fast-forwardable
|
||||||
// from a bare test run), so this exercises the decline path only — it must
|
// from a bare test run), so this exercises the decline path only — it must
|
||||||
// never touch the working tree or process in a test run.
|
// never touch the working tree or process in a test run.
|
||||||
|
|||||||
+47
-5
@@ -857,11 +857,45 @@ async function treeStatus(dir) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds to one level what git does not list there — ignored files and folders
|
||||||
|
* (and an empty folder, which git never lists either) — marked `ignored`,
|
||||||
|
* read off the disk because `ls-files --ignored` cannot list INSIDE an
|
||||||
|
* ignored folder. Returns the folder names it added.
|
||||||
|
*/
|
||||||
|
async function addIgnored(dir, sub, dirNames, files) {
|
||||||
|
const added = new Set();
|
||||||
|
let dirents;
|
||||||
|
try {
|
||||||
|
const root = await fs.promises.realpath(dir);
|
||||||
|
const real = await fs.promises.realpath(path.join(root, sub));
|
||||||
|
const r = path.relative(root, real);
|
||||||
|
// A symlinked folder pointing out of the workspace lists nothing.
|
||||||
|
if (r === ".." || r.startsWith(`..${path.sep}`) || path.isAbsolute(r)) return added;
|
||||||
|
dirents = await fs.promises.readdir(real, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
const prefix = sub ? `${sub}/` : "";
|
||||||
|
const listed = new Set(files.map((f) => f.name));
|
||||||
|
for (const d of dirents) {
|
||||||
|
if (d.name === ".git" || dirNames.has(d.name) || listed.has(d.name)) continue;
|
||||||
|
if (d.isDirectory()) {
|
||||||
|
dirNames.add(d.name);
|
||||||
|
added.add(d.name);
|
||||||
|
} else {
|
||||||
|
files.push({ name: d.name, path: `${prefix}${d.name}`, dir: false, ignored: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
/** A folded level's entries, each carrying its status (see `treeStatus`). */
|
/** A folded level's entries, each carrying its status (see `treeStatus`). */
|
||||||
function treeEntries(prefix, dirNames, files, st) {
|
function treeEntries(prefix, dirNames, files, st, ignoredDirs = new Set()) {
|
||||||
return [
|
return [
|
||||||
...[...dirNames].sort().map((name) => {
|
...[...dirNames].sort().map((name) => {
|
||||||
const p = `${prefix}${name}`;
|
const p = `${prefix}${name}`;
|
||||||
|
if (ignoredDirs.has(name)) return { name, path: p, dir: true, ignored: true };
|
||||||
return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) };
|
return { name, path: p, dir: true, ...(st.dirChanged(p) ? { changed: true } : {}) };
|
||||||
}),
|
}),
|
||||||
...files
|
...files
|
||||||
@@ -875,7 +909,8 @@ function treeEntries(prefix, dirNames, files, st) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One directory level of the working tree, for the file explorer: tracked
|
* One directory level of the working tree, for the file explorer: tracked
|
||||||
* files plus untracked-but-not-ignored ones (`TREE_LS`), folded into
|
* files plus untracked-but-not-ignored ones (`TREE_LS`) and, marked
|
||||||
|
* `ignored`, what git ignores there (`addIgnored`), folded into
|
||||||
* directories at this level — a tree that lists a repo's every file at once is
|
* directories at this level — a tree that lists a repo's every file at once is
|
||||||
* both slow to render and not what "expand a folder" means. A changed file
|
* both slow to render and not what "expand a folder" means. A changed file
|
||||||
* carries its porcelain `status`, a folder holding one `changed: true`.
|
* carries its porcelain `status`, a folder holding one `changed: true`.
|
||||||
@@ -898,7 +933,8 @@ async function listTree(dir, { subdir = "" } = {}) {
|
|||||||
if (slash === -1) files.push({ name: rest, path: p, dir: false });
|
if (slash === -1) files.push({ name: rest, path: p, dir: false });
|
||||||
else dirs.add(rest.slice(0, slash));
|
else dirs.add(rest.slice(0, slash));
|
||||||
}
|
}
|
||||||
const entries = treeEntries(prefix, dirs, files, st);
|
const ignored = await addIgnored(dir, clean, dirs, files);
|
||||||
|
const entries = treeEntries(prefix, dirs, files, st, ignored);
|
||||||
return {
|
return {
|
||||||
path: clean,
|
path: clean,
|
||||||
entries: entries.slice(0, MAX_PATHS),
|
entries: entries.slice(0, MAX_PATHS),
|
||||||
@@ -995,7 +1031,7 @@ function onlyBlock(hunk, block, reverse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every level of the explorer tree (tracked + untracked-not-ignored) from ONE `git ls-files`, keyed by directory
|
* Every level of the explorer tree (tracked + untracked-not-ignored, plus each level's ignored entries) from ONE `git ls-files`, keyed by directory
|
||||||
* path ("" is the root), each level shaped and capped exactly like
|
* path ("" is the root), each level shaped and capped exactly like
|
||||||
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
|
* `listTree`'s. "Expand all folders" used to be one request — one ls-files —
|
||||||
* per folder, which on a large repo is thousands of git processes.
|
* per folder, which on a large repo is thousands of git processes.
|
||||||
@@ -1019,8 +1055,14 @@ async function listTreeAll(dir) {
|
|||||||
}
|
}
|
||||||
const levels = {};
|
const levels = {};
|
||||||
let truncated = cut;
|
let truncated = cut;
|
||||||
|
// An ignored folder gets no level of its own: it stays shut (node_modules).
|
||||||
|
const ignored = new Map(
|
||||||
|
await Promise.all(
|
||||||
|
[...byDir].map(async ([d, l]) => [d, await addIgnored(dir, d, l.dirs, l.files)])
|
||||||
|
)
|
||||||
|
);
|
||||||
for (const [d, { dirs, files }] of byDir) {
|
for (const [d, { dirs, files }] of byDir) {
|
||||||
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st);
|
const entries = treeEntries(d ? `${d}/` : "", dirs, files, st, ignored.get(d));
|
||||||
if (entries.length > MAX_PATHS) truncated = true;
|
if (entries.length > MAX_PATHS) truncated = true;
|
||||||
levels[d] = entries.slice(0, MAX_PATHS);
|
levels[d] = entries.slice(0, MAX_PATHS);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user