feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the Artifact tool's hook), pinned workspace files, skills and plugins, each kept as a snapshot; shared ones appear in a Hub and a skill or plugin installs into the viewer's own account (server/lib/artifacts.js, /api/artifacts, docs/plans/artifacts/). - Workspace extra folders: clone one or more related repos beside the workspace and add them in one step (POST /api/lanes/:id/sources/clone). - A pane that ended by itself while tmux kept running is no longer offered for resume (tmux.serverStartedAt in reconcileOrphans). - Changes panel: a folder that is not a git repo opens on Files, and its files open from there. - Plus the parallel sessions' work in this tree: File Explorer button in the panel header, user delete, RSA SSH keys, chat/terminal and pet fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -192,6 +192,54 @@ describe("POST /api/lanes/clone", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/lanes/:id/sources/clone", () => {
|
||||
it("clones beside the workspace folder and adds it as an extra folder", async () => {
|
||||
const home = path.join(WORK, "app");
|
||||
fs.mkdirSync(home);
|
||||
const made = await request("POST", "/api/lanes/ensure", { cwd: home });
|
||||
assert.ok([200, 201].includes(made.status), JSON.stringify(made.body));
|
||||
const id = made.body.lane.id;
|
||||
|
||||
const r = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||
url: "https://forge.test/o/r.git",
|
||||
name: "lib",
|
||||
// Ignored: the clone always lands beside the workspace folder.
|
||||
parent: path.join(ROOT, "elsewhere"),
|
||||
});
|
||||
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||
const target = path.join(WORK, "lib");
|
||||
assert.equal(fs.realpathSync(r.body.path), fs.realpathSync(target));
|
||||
assert.equal(fs.readFileSync(path.join(target, "README.md"), "utf8"), "hi\n");
|
||||
const folders = r.body.sources.filter((s) => s.kind === "folder");
|
||||
assert.deepEqual(
|
||||
folders.map((f) => fs.realpathSync(f.path)),
|
||||
[fs.realpathSync(target)]
|
||||
);
|
||||
|
||||
const again = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||
url: "https://forge.test/o/r.git",
|
||||
name: "lib",
|
||||
});
|
||||
assert.equal(again.status, 409);
|
||||
assert.equal(again.body.error.code, "EEXISTS");
|
||||
|
||||
const login = await request("POST", `/api/lanes/${id}/sources/clone`, {
|
||||
url: "https://private.test/o/r.git",
|
||||
name: "private-lib",
|
||||
});
|
||||
assert.equal(login.status, 409);
|
||||
assert.equal(login.body.error.code, "ECREDENTIALS");
|
||||
assert.equal(login.body.error.auth.host, "private.test");
|
||||
});
|
||||
|
||||
it("answers 404 for a workspace that does not exist", async () => {
|
||||
const r = await request("POST", "/api/lanes/99999/sources/clone", {
|
||||
url: "https://forge.test/o/r.git",
|
||||
});
|
||||
assert.equal(r.status, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/me/git", () => {
|
||||
it("makes a key once, returns only its public half, and deletes it", async () => {
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
@@ -219,6 +267,29 @@ describe("/api/me/git", () => {
|
||||
assert.equal(del.body.ssh, null);
|
||||
});
|
||||
|
||||
it("makes an RSA 4096 key for hosts that refuse ed25519 (AWS CodeCommit), one key at a time", async () => {
|
||||
await request("POST", "/api/me/git/ssh-key", {});
|
||||
const rsa = await request("POST", "/api/me/git/ssh-key", { replace: true, type: "rsa" });
|
||||
assert.equal(rsa.status, 201, JSON.stringify(rsa.body));
|
||||
assert.equal(rsa.body.ssh.type, "rsa");
|
||||
assert.match(rsa.body.ssh.publicKey, /^ssh-rsa AAAAB3NzaC1yc2E/);
|
||||
assert.ok(!JSON.stringify(rsa.body).includes("PRIVATE"));
|
||||
const dir = path.join(ROOT, ".claude", "agenthub-ssh");
|
||||
assert.equal(fs.statSync(path.join(dir, "id_rsa")).mode & 0o777, 0o600);
|
||||
assert.ok(!fs.existsSync(path.join(dir, "id_ed25519")), "the ed25519 key was replaced");
|
||||
const bits = execFileSync("ssh-keygen", ["-lf", path.join(dir, "id_rsa.pub")], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.match(bits, /^4096 .*\(RSA\)/);
|
||||
assert.equal(
|
||||
(await request("POST", "/api/me/git/ssh-key", { replace: true, type: "dsa" })).status,
|
||||
201
|
||||
);
|
||||
// An unknown type falls back to the default (the route only passes rsa through).
|
||||
assert.equal((await request("GET", "/api/me/git")).body.ssh.type, "ed25519");
|
||||
await request("DELETE", "/api/me/git/ssh-key");
|
||||
});
|
||||
|
||||
it("reads and sets the commit author, refusing a bad one", async () => {
|
||||
const r0 = await request("GET", "/api/me/git");
|
||||
assert.deepEqual(r0.body.author, { name: null, email: null });
|
||||
@@ -282,10 +353,10 @@ describe("git-identity, member account (S3, stubbed)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the key and the logins in the member's own home", () => {
|
||||
it("keeps the key and the logins in the member's own home", async () => {
|
||||
const account = identity.accountForDir(path.join(HOME, "proj"));
|
||||
assert.equal(account.kind, "member");
|
||||
identity.generateSshKey(account);
|
||||
await identity.generateSshKey(account);
|
||||
const key = path.join(HOME, ".config", "agenthub", "ssh", "id_ed25519");
|
||||
assert.equal(fs.statSync(key).mode & 0o777, 0o600);
|
||||
identity.saveHttps(account, { host: "gitlab.test", username: "an", password: "p@ss/1" });
|
||||
|
||||
Reference in New Issue
Block a user