shadow-claw 1.52.0 → 1.52.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/.well-known/mcp/server-card.json +1 -1
- package/.well-known/mcp.json +1 -1
- package/README.md +9 -9
- package/dist/cli/build/build.js +1 -1
- package/dist/cli/{chunk-DYq-orHK.js → chunk-BgTv9w0K.js} +1 -1
- package/dist/cli/{chunk-C6jii2Fl.js → chunk-d5hIhj8t.js} +1 -1
- package/dist/cli/cli.js +1 -1
- package/dist/lib/components/index.js +1 -1
- package/dist/lib/components/shadow-claw-pages.js +1 -1
- package/dist/lib/index.js +1 -1
- package/dist/lib/{shadow-claw-pages-kA2qa-4B.js → shadow-claw-pages-CjHS50HN.js} +2 -2
- package/dist/lib/{shadow-claw-pages-kA2qa-4B.js.map → shadow-claw-pages-CjHS50HN.js.map} +1 -1
- package/dist/public/.well-known/mcp/server-card.json +1 -1
- package/dist/public/.well-known/mcp.json +1 -1
- package/dist/public/AGENTS.md +1 -1
- package/dist/public/README.md +9 -9
- package/dist/public/docs/README.md +1 -1
- package/dist/public/docs/architecture/orchestrator.md +2 -2
- package/dist/public/docs/architecture/overview.md +3 -3
- package/dist/public/docs/architecture/worker-protocol.md +29 -29
- package/dist/public/docs/example/article/index.html +1 -1
- package/dist/public/docs/example/article.html +1 -1
- package/dist/public/docs/publishing/index.html +1 -1
- package/dist/public/docs/publishing.html +1 -1
- package/dist/public/docs/skill-creator/index.html +1 -1
- package/dist/public/docs/skill-creator.html +1 -1
- package/dist/public/docs/subsystems/local-models.md +1 -1
- package/dist/public/docs/subsystems/providers.md +11 -4
- package/dist/public/e2e/README.md +2 -2
- package/dist/public/index.html +1 -1
- package/dist/public/index.js +1 -1
- package/dist/public/llms.txt +1 -1
- package/dist/public/main/index.html +1 -1
- package/dist/public/main/memory/index.html +1 -1
- package/dist/public/main/memory.html +1 -1
- package/dist/public/main.html +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-Ba2ZFE9m.js → shadow-claw-T9oPpqxT.js} +1 -1
- package/dist/public/{shadow-claw-pages-Cy6T0G9y.js → shadow-claw-pages-IW_LNEL_.js} +1 -1
- package/package.json +1 -1
- package/src/cli/build/build.test.ts +95 -0
- package/src/cli/build/build.ts +5 -1
- package/src/cli/commands/agent.test.ts +134 -0
- package/src/cli/commands/backup.test.ts +85 -0
- package/src/cli/commands/clients.test.ts +32 -0
- package/src/cli/commands/mcp.test.ts +260 -1
- package/src/cli/commands/send-file.test.ts +73 -0
- package/src/cli/commands/send.test.ts +58 -0
- package/src/cli/commands/tasks.test.ts +78 -0
- package/src/cli/commands/webrtc-listen.test.ts +74 -0
- package/src/cli/tools/assert-version-bump.test.ts +30 -1
- package/src/cli/tools/file-search-replace.test.ts +33 -1
- package/src/cli/tools/patch-service-worker-trusted-types.test.ts +24 -1
- package/src/cli/tools/touch-nojekyll.test.ts +12 -0
- package/src/cli/tools/version.test.ts +109 -5
- package/src/cli/utils/agent-core.test.ts +40 -0
- package/src/cli/utils/control-client.test.ts +239 -2
- package/src/cli/utils/load-workspace-config.test.ts +63 -0
- package/src/cli/utils/local-models.test.ts +98 -0
- package/src/cli/utils/progress-bar.test.ts +41 -2
- package/src/cli/utils/resolve-cache-dir.test.ts +61 -0
- package/src/cli/utils/resolve-project-root.test.ts +17 -0
- package/src/cli/utils/stdin.test.ts +38 -0
- package/src/cli/utils/stdio.test.ts +51 -1
- package/src/cli/utils/suppress-warnings.test.ts +9 -0
- package/src/cli/utils/webrtc-control-client.test.ts +569 -0
- package/src/common/utils/frontmatter.test.ts +117 -0
- package/src/components/common/help/providers.test.ts +67 -0
- package/src/components/common/shadow-claw-provider-model-picker/shadow-claw-provider-model-picker.test.ts +121 -0
- package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +131 -0
- package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +130 -0
- package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +12 -0
- package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +328 -0
- package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +530 -8
- package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +459 -0
- package/src/components/settings/shadow-claw-networking/shadow-claw-networking.test.ts +88 -0
- package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +92 -0
- package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +127 -0
- package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +173 -0
- package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +62 -0
- package/src/components/settings/shadow-claw-webvm/shadow-claw-webvm.test.ts +104 -0
- package/src/components/shadow-claw/shadow-claw.test.ts +112 -0
- package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +109 -0
- package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +361 -0
- package/src/components/shadow-claw-dialog/shadow-claw-dialog.test.ts +76 -0
- package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +307 -0
- package/src/components/shadow-claw-files/shadow-claw-files.test.ts +398 -1
- package/src/components/shadow-claw-pages/shadow-claw-pages.test.ts +85 -0
- package/src/components/shadow-claw-pages/shadow-claw-pages.ts +2 -1
- package/src/components/shadow-claw-pdf-viewer/shadow-claw-pdf-viewer.test.ts +229 -0
- package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +233 -0
- package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +223 -1
- package/src/components/shadow-claw-toast/shadow-claw-toast.test.ts +79 -0
- package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +229 -1
- package/src/server/logger.test.ts +57 -0
- package/src/server/peer.test.ts +47 -0
- package/src/server/routes/gemini.test.ts +244 -0
- package/src/server/server-peer.test.ts +245 -11
- package/src/server/services/llamafile-manager.test.ts +668 -0
- package/src/share-target/pending-shares.test.ts +90 -0
- package/src/shell/fs.test.ts +125 -0
- package/src/shell/shell.test.ts +8 -0
- package/src/subsystems/git/credentials.test.ts +260 -0
- package/src/subsystems/git/git.test.ts +64 -0
- package/src/subsystems/providers/executeNativeAiTask.test.ts +78 -0
- package/src/subsystems/providers/utils/parseLocalModelToolCall.test.ts +57 -0
- package/src/worker/tools/builtin-ai/builtin-ai.test.ts +88 -0
- package/src/worker/tools/email/email.test.ts +358 -0
- package/src/worker/tools/email/utils/resolveIntegrationEmailAuth.test.ts +120 -0
- package/src/worker/tools/email/utils/resolveUniqueAttachmentPath.test.ts +80 -0
- package/src/worker/tools/fetch-file/fetch-file.test.ts +111 -0
- package/src/worker/tools/fetch-url/fetch-url.test.ts +295 -0
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
2
5
|
import express from "express";
|
|
3
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
describe,
|
|
8
|
+
it,
|
|
9
|
+
expect,
|
|
10
|
+
beforeEach,
|
|
11
|
+
afterEach,
|
|
12
|
+
jest,
|
|
13
|
+
} from "@jest/globals";
|
|
4
14
|
import {
|
|
5
15
|
openClientStore,
|
|
6
16
|
closeClientStore,
|
|
7
17
|
} from "../../server/client-registry.js";
|
|
8
18
|
import { createControlPlane } from "../../server/control-plane.js";
|
|
9
19
|
import { registerBackupRoutes } from "../../server/routes/backup.js";
|
|
10
|
-
import { CliControlClient } from "./control-client.js";
|
|
20
|
+
import { CliControlClient, resolveControlToken } from "./control-client.js";
|
|
11
21
|
|
|
12
22
|
describe("src/cli/utils/control-client.ts", () => {
|
|
13
23
|
let app: any;
|
|
@@ -39,6 +49,27 @@ describe("src/cli/utils/control-client.ts", () => {
|
|
|
39
49
|
res.json({ sent: 1, failed: 0, received: req.body });
|
|
40
50
|
});
|
|
41
51
|
|
|
52
|
+
app.post("/api/control/command", (req: any, res: any) => {
|
|
53
|
+
res.json({ ok: true, result: "command-executed", body: req.body });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.delete("/api/backup/:id", (req: any, res: any) => {
|
|
57
|
+
res.json({ ok: true, deleted: req.params.id });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
app.get("/push/subscriptions", (_req: any, res: any) => {
|
|
61
|
+
res.json([
|
|
62
|
+
{ client_id: "client-sub-1", device_label: "Device 1" },
|
|
63
|
+
{ client_id: "client-sub-2" },
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
app.get("/push/clients", (_req: any, res: any) => {
|
|
68
|
+
res.json({
|
|
69
|
+
clients: [{ clientId: "client-push-1", deviceLabel: "Push 1" }],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
42
73
|
await new Promise<void>((resolve) => {
|
|
43
74
|
server.listen(0, "127.0.0.1", () => {
|
|
44
75
|
port = server.address().port;
|
|
@@ -149,4 +180,210 @@ describe("src/cli/utils/control-client.ts", () => {
|
|
|
149
180
|
expect(Array.isArray(clients)).toBe(true);
|
|
150
181
|
expect(client.token).toBe(token);
|
|
151
182
|
});
|
|
183
|
+
|
|
184
|
+
it("deletes a backup via deleteBackup", async () => {
|
|
185
|
+
const client = new CliControlClient({
|
|
186
|
+
host: "127.0.0.1",
|
|
187
|
+
port,
|
|
188
|
+
token,
|
|
189
|
+
});
|
|
190
|
+
const res = await client.deleteBackup("backup-123", "client-1");
|
|
191
|
+
expect(res).toEqual({ success: true, deleted: "backup-123" });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("lists push subscriptions and clients", async () => {
|
|
195
|
+
const client = new CliControlClient({
|
|
196
|
+
host: "127.0.0.1",
|
|
197
|
+
port,
|
|
198
|
+
token,
|
|
199
|
+
});
|
|
200
|
+
const subs = await client.listPushSubscriptions();
|
|
201
|
+
expect(subs).toHaveLength(2);
|
|
202
|
+
|
|
203
|
+
const pushClients = await client.listPushClients();
|
|
204
|
+
expect(pushClients).toHaveLength(1);
|
|
205
|
+
expect(pushClients[0].clientId).toBe("client-push-1");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("sends commands and files via sendCommand and sendFile", async () => {
|
|
209
|
+
const client = new CliControlClient({
|
|
210
|
+
host: "127.0.0.1",
|
|
211
|
+
port,
|
|
212
|
+
token,
|
|
213
|
+
});
|
|
214
|
+
await expect(
|
|
215
|
+
client.sendCommand("client-1", "ping", { foo: "bar" }),
|
|
216
|
+
).rejects.toThrow("Client not connected: client-1");
|
|
217
|
+
|
|
218
|
+
// sendFile error: file not found
|
|
219
|
+
await expect(
|
|
220
|
+
client.sendFile("client-1", "/non/existent/file.txt"),
|
|
221
|
+
).rejects.toThrow("File not found");
|
|
222
|
+
|
|
223
|
+
// sendFile error: directory
|
|
224
|
+
await expect(client.sendFile("client-1", os.tmpdir())).rejects.toThrow(
|
|
225
|
+
"Cannot send directory",
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// sendFile success attempt
|
|
229
|
+
const tmpFile = path.join(os.tmpdir(), `test-file-${Date.now()}.txt`);
|
|
230
|
+
fs.writeFileSync(tmpFile, "hello shadow-claw", "utf8");
|
|
231
|
+
try {
|
|
232
|
+
await expect(
|
|
233
|
+
client.sendFile("client-1", tmpFile, { prompt: "Read this" }),
|
|
234
|
+
).rejects.toThrow("Client not connected: client-1");
|
|
235
|
+
} finally {
|
|
236
|
+
fs.unlinkSync(tmpFile);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("supports WebRTC transport delegation and fallbacks", async () => {
|
|
241
|
+
const client = new CliControlClient({
|
|
242
|
+
host: "127.0.0.1",
|
|
243
|
+
port,
|
|
244
|
+
token,
|
|
245
|
+
transport: "webrtc",
|
|
246
|
+
});
|
|
247
|
+
expect(client._webrtcClient).not.toBeNull();
|
|
248
|
+
|
|
249
|
+
// 1. listClients with webrtc clients
|
|
250
|
+
client._webrtcClient!.listClients = jest
|
|
251
|
+
.fn()
|
|
252
|
+
.mockResolvedValue([
|
|
253
|
+
{ clientId: "client-peer-ok", peerId: "peer-ok-id" },
|
|
254
|
+
{ clientId: "client-no-peer" },
|
|
255
|
+
]);
|
|
256
|
+
client._webrtcClient!.sendCommand = jest
|
|
257
|
+
.fn()
|
|
258
|
+
.mockResolvedValue({ ok: true, webrtc: true });
|
|
259
|
+
|
|
260
|
+
const clients = await client.listClients();
|
|
261
|
+
expect(clients[0].clientId).toBe("client-peer-ok");
|
|
262
|
+
|
|
263
|
+
// 2. sendCommand with matching peerId
|
|
264
|
+
const cmdRes = await client.sendCommand(
|
|
265
|
+
"client-peer-ok",
|
|
266
|
+
"test-action",
|
|
267
|
+
{},
|
|
268
|
+
);
|
|
269
|
+
expect(cmdRes).toEqual({ ok: true, webrtc: true });
|
|
270
|
+
expect(client._webrtcClient!.sendCommand).toHaveBeenCalledWith(
|
|
271
|
+
"peer-ok-id",
|
|
272
|
+
"test-action",
|
|
273
|
+
{},
|
|
274
|
+
30000,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// 3. sendCommand with matching client that has NO peerId (triggers warn)
|
|
278
|
+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
|
|
279
|
+
try {
|
|
280
|
+
await client.sendCommand("client-no-peer", "test-action", {});
|
|
281
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
282
|
+
expect.stringContaining("has no PeerJS peer ID registered"),
|
|
283
|
+
);
|
|
284
|
+
} finally {
|
|
285
|
+
warnSpy.mockRestore();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 4. listClients fallback to control plane when webrtc listClients returns empty
|
|
289
|
+
client._webrtcClient!.listClients = jest.fn().mockResolvedValue([]);
|
|
290
|
+
const cpClients = await client.listClients();
|
|
291
|
+
expect(Array.isArray(cpClients)).toBe(true);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("resolves control token from cacheDir and custom options", () => {
|
|
295
|
+
expect(resolveControlToken("custom-tok-123")).toBe("custom-tok-123");
|
|
296
|
+
|
|
297
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctrl-tok-test-"));
|
|
298
|
+
try {
|
|
299
|
+
const tokFile = path.join(tempDir, "control-token.json");
|
|
300
|
+
fs.writeFileSync(
|
|
301
|
+
tokFile,
|
|
302
|
+
JSON.stringify({ token: "cached-file-tok" }),
|
|
303
|
+
"utf8",
|
|
304
|
+
);
|
|
305
|
+
const resolved = resolveControlToken(undefined, tempDir);
|
|
306
|
+
expect(resolved).toBe("cached-file-tok");
|
|
307
|
+
} finally {
|
|
308
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("falls back to listPushSubscriptions when /push/clients fails", async () => {
|
|
313
|
+
const client = new CliControlClient({
|
|
314
|
+
host: "127.0.0.1",
|
|
315
|
+
port,
|
|
316
|
+
token,
|
|
317
|
+
});
|
|
318
|
+
jest
|
|
319
|
+
.spyOn(client as any, "_request")
|
|
320
|
+
.mockImplementation(async (opts: any) => {
|
|
321
|
+
if (opts.path === "/push/clients") {
|
|
322
|
+
throw new Error("404 Not Found");
|
|
323
|
+
}
|
|
324
|
+
if (opts.path === "/push/subscriptions") {
|
|
325
|
+
return [
|
|
326
|
+
{ client_id: "client-sub-1", device_label: "Device 1" },
|
|
327
|
+
{ client_id: "client-sub-1", device_label: "Device 1 duplicate" },
|
|
328
|
+
{ id: 42, device_label: "Device ID Only" },
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
return {};
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const pushClients = await client.listPushClients();
|
|
335
|
+
expect(pushClients).toHaveLength(2);
|
|
336
|
+
expect(pushClients[0]).toEqual({
|
|
337
|
+
clientId: "client-sub-1",
|
|
338
|
+
deviceLabel: "Device 1",
|
|
339
|
+
});
|
|
340
|
+
expect(pushClients[1]).toEqual({
|
|
341
|
+
clientId: "42",
|
|
342
|
+
deviceLabel: "Device ID Only",
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("falls back to sqlite push-subscriptions.db when both endpoints fail", async () => {
|
|
347
|
+
const tempDir = fs.mkdtempSync(
|
|
348
|
+
path.join(os.tmpdir(), "control-client-sqlite-"),
|
|
349
|
+
);
|
|
350
|
+
try {
|
|
351
|
+
const sqlite = (process as any).getBuiltinModule?.("node:sqlite");
|
|
352
|
+
if (sqlite && sqlite.DatabaseSync) {
|
|
353
|
+
const dbFile = path.join(tempDir, "push-subscriptions.db");
|
|
354
|
+
const db = new sqlite.DatabaseSync(dbFile);
|
|
355
|
+
db.exec(
|
|
356
|
+
"CREATE TABLE subscriptions (id INTEGER PRIMARY KEY, client_id TEXT, device_label TEXT, created_at INTEGER);",
|
|
357
|
+
);
|
|
358
|
+
db.prepare(
|
|
359
|
+
"INSERT INTO subscriptions (id, client_id, device_label, created_at) VALUES (?, ?, ?, ?)",
|
|
360
|
+
).run(1, "sql-client-1", "Desk 1", Date.now());
|
|
361
|
+
db.prepare(
|
|
362
|
+
"INSERT INTO subscriptions (id, client_id, device_label, created_at) VALUES (?, ?, ?, ?)",
|
|
363
|
+
).run(2, "", "No CID", Date.now());
|
|
364
|
+
db.close();
|
|
365
|
+
|
|
366
|
+
const client = new CliControlClient({
|
|
367
|
+
host: "127.0.0.1",
|
|
368
|
+
port,
|
|
369
|
+
token,
|
|
370
|
+
cacheDir: tempDir,
|
|
371
|
+
});
|
|
372
|
+
jest
|
|
373
|
+
.spyOn(client as any, "_request")
|
|
374
|
+
.mockRejectedValue(new Error("Network failure"));
|
|
375
|
+
|
|
376
|
+
const clients = await client.listPushClients();
|
|
377
|
+
expect(clients.length).toBe(2);
|
|
378
|
+
const c1 = clients.find((c) => c.clientId === "sql-client-1");
|
|
379
|
+
expect(c1).toBeDefined();
|
|
380
|
+
expect(c1?.deviceLabel).toBe("Desk 1");
|
|
381
|
+
const c2 = clients.find((c) => c.clientId === "2");
|
|
382
|
+
expect(c2).toBeDefined();
|
|
383
|
+
expect(c2?.deviceLabel).toBe("No CID");
|
|
384
|
+
}
|
|
385
|
+
} finally {
|
|
386
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
});
|
|
152
389
|
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { loadWorkspaceConfig } from "./load-workspace-config.js";
|
|
2
|
+
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
describe("load-workspace-config", () => {
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
tmpDir = await mkdtemp(path.join(os.tmpdir(), "sc-cfg-test-"));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns null when no config exists", async () => {
|
|
18
|
+
const res = await loadWorkspaceConfig(tmpDir);
|
|
19
|
+
expect(res).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("loads shadow-claw.config.json from workspace", async () => {
|
|
23
|
+
const cfgPath = path.join(tmpDir, "shadow-claw.config.json");
|
|
24
|
+
await writeFile(cfgPath, JSON.stringify({ model: "test-model" }), "utf8");
|
|
25
|
+
|
|
26
|
+
const res = await loadWorkspaceConfig(tmpDir);
|
|
27
|
+
expect(res).not.toBeNull();
|
|
28
|
+
expect(res?.configPath).toBe(cfgPath);
|
|
29
|
+
expect(res?.config.model).toBe("test-model");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("loads config from contentRoot", async () => {
|
|
33
|
+
const contentDir = path.join(tmpDir, "content");
|
|
34
|
+
await mkdir(contentDir, { recursive: true });
|
|
35
|
+
const cfgPath = path.join(contentDir, "site-config.json");
|
|
36
|
+
await writeFile(cfgPath, JSON.stringify({ siteName: "My Site" }), "utf8");
|
|
37
|
+
|
|
38
|
+
const res = await loadWorkspaceConfig(undefined, contentDir);
|
|
39
|
+
expect(res).not.toBeNull();
|
|
40
|
+
expect(res?.configPath).toBe(cfgPath);
|
|
41
|
+
expect((res?.config as any).siteName).toBe("My Site");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("checks parent directory when workspace is .cache", async () => {
|
|
45
|
+
const cacheDir = path.join(tmpDir, ".cache");
|
|
46
|
+
await mkdir(cacheDir, { recursive: true });
|
|
47
|
+
const cfgPath = path.join(tmpDir, "shadowclaw.config.json");
|
|
48
|
+
await writeFile(cfgPath, JSON.stringify({ provider: "local" }), "utf8");
|
|
49
|
+
|
|
50
|
+
const res = await loadWorkspaceConfig(cacheDir);
|
|
51
|
+
expect(res).not.toBeNull();
|
|
52
|
+
expect(res?.configPath).toBe(cfgPath);
|
|
53
|
+
expect(res?.config.provider).toBe("local");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("handles malformed JSON gracefully", async () => {
|
|
57
|
+
const cfgPath = path.join(tmpDir, "shadow-claw.config.json");
|
|
58
|
+
await writeFile(cfgPath, "{ malformed json", "utf8");
|
|
59
|
+
|
|
60
|
+
const res = await loadWorkspaceConfig(tmpDir);
|
|
61
|
+
expect(res).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1242,5 +1242,103 @@ describe("local-models", () => {
|
|
|
1242
1242
|
globalThis.fetch = originalFetch;
|
|
1243
1243
|
}
|
|
1244
1244
|
});
|
|
1245
|
+
|
|
1246
|
+
it("allows selecting llamafile options 5 through 10", async () => {
|
|
1247
|
+
for (const choice of ["5", "6", "7", "8", "9", "10"]) {
|
|
1248
|
+
const mockStdin = new PassThrough();
|
|
1249
|
+
const mockStdout = new Writable({
|
|
1250
|
+
write(chunk, _encoding, callback) {
|
|
1251
|
+
const str = chunk.toString();
|
|
1252
|
+
if (str.includes("Enter choice [1-12]")) {
|
|
1253
|
+
setImmediate(() => mockStdin.write(`${choice}\n`));
|
|
1254
|
+
} else if (str.includes("Download model weights now?")) {
|
|
1255
|
+
setImmediate(() => mockStdin.write("n\n"));
|
|
1256
|
+
}
|
|
1257
|
+
callback();
|
|
1258
|
+
},
|
|
1259
|
+
});
|
|
1260
|
+
const result = await promptForAgentModel({
|
|
1261
|
+
workspaceDir: tmpDir,
|
|
1262
|
+
contentRoot: tmpDir,
|
|
1263
|
+
cacheDir: tmpDir,
|
|
1264
|
+
stdin: mockStdin,
|
|
1265
|
+
stdout: mockStdout,
|
|
1266
|
+
isTTY: true,
|
|
1267
|
+
});
|
|
1268
|
+
expect(result.providerId).toBe("llamafile");
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
it("allows direct entry of agentfile, llamafile, openrouter, and onnx models at the main prompt", async () => {
|
|
1273
|
+
const inputs = [
|
|
1274
|
+
{ in: "helper.agentfile", p: "agentfile", m: "helper.agentfile" },
|
|
1275
|
+
{ in: "worker.llamafile", p: "llamafile", m: "worker.llamafile" },
|
|
1276
|
+
{ in: "meta-llama/llama-3", p: "openrouter", m: "meta-llama/llama-3" },
|
|
1277
|
+
{
|
|
1278
|
+
in: "custom-onnx-model",
|
|
1279
|
+
p: "transformers_js_local",
|
|
1280
|
+
m: "custom-onnx-model",
|
|
1281
|
+
},
|
|
1282
|
+
];
|
|
1283
|
+
|
|
1284
|
+
for (const item of inputs) {
|
|
1285
|
+
const mockStdin = new PassThrough();
|
|
1286
|
+
const mockStdout = new Writable({
|
|
1287
|
+
write(chunk, _encoding, callback) {
|
|
1288
|
+
const str = chunk.toString();
|
|
1289
|
+
if (str.includes("Enter choice [1-12]")) {
|
|
1290
|
+
setImmediate(() => mockStdin.write(`${item.in}\n`));
|
|
1291
|
+
} else if (str.includes("Download model weights now?")) {
|
|
1292
|
+
setImmediate(() => mockStdin.write("n\n"));
|
|
1293
|
+
}
|
|
1294
|
+
callback();
|
|
1295
|
+
},
|
|
1296
|
+
});
|
|
1297
|
+
const result = await promptForAgentModel({
|
|
1298
|
+
workspaceDir: tmpDir,
|
|
1299
|
+
contentRoot: tmpDir,
|
|
1300
|
+
cacheDir: tmpDir,
|
|
1301
|
+
stdin: mockStdin,
|
|
1302
|
+
stdout: mockStdout,
|
|
1303
|
+
isTTY: true,
|
|
1304
|
+
});
|
|
1305
|
+
expect(result.providerId).toBe(item.p);
|
|
1306
|
+
expect(result.model).toBe(item.m);
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
it("tests isLlamafileLocallyCached and isAgentfileLocallyCached paths", () => {
|
|
1311
|
+
const realFile = path.join(tmpDir, "model.llamafile");
|
|
1312
|
+
fs.writeFileSync(realFile, "binary content", "utf8");
|
|
1313
|
+
|
|
1314
|
+
expect(isLlamafileLocallyCached(realFile)).toBe(true);
|
|
1315
|
+
expect(isLlamafileLocallyCached(realFile, tmpDir)).toBe(true);
|
|
1316
|
+
|
|
1317
|
+
const agentFile = path.join(tmpDir, "model.agentfile");
|
|
1318
|
+
fs.writeFileSync(agentFile, "agent content", "utf8");
|
|
1319
|
+
|
|
1320
|
+
expect(isAgentfileLocallyCached(agentFile)).toBe(true);
|
|
1321
|
+
expect(isAgentfileLocallyCached(agentFile, tmpDir)).toBe(true);
|
|
1322
|
+
|
|
1323
|
+
// Part file returns false
|
|
1324
|
+
const cacheDir = getLlamafileCacheDir(tmpDir);
|
|
1325
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
1326
|
+
fs.writeFileSync(
|
|
1327
|
+
path.join(cacheDir, "test.llamafile.part"),
|
|
1328
|
+
"incomplete",
|
|
1329
|
+
"utf8",
|
|
1330
|
+
);
|
|
1331
|
+
expect(isLlamafileLocallyCached("test.llamafile", tmpDir)).toBe(false);
|
|
1332
|
+
|
|
1333
|
+
// Cache dirs with and without custom dir
|
|
1334
|
+
expect(getLlamafileCacheDir()).toContain("llamafile");
|
|
1335
|
+
expect(getAgentfileCacheDir()).toContain("agentfile");
|
|
1336
|
+
expect(getLlamafileCacheDir(tmpDir)).toBe(
|
|
1337
|
+
path.resolve(tmpDir, "llamafile"),
|
|
1338
|
+
);
|
|
1339
|
+
expect(getAgentfileCacheDir(tmpDir)).toBe(
|
|
1340
|
+
path.resolve(tmpDir, "agentfile"),
|
|
1341
|
+
);
|
|
1342
|
+
});
|
|
1245
1343
|
});
|
|
1246
1344
|
});
|
|
@@ -205,9 +205,48 @@ describe("progress-bar", () => {
|
|
|
205
205
|
total: 1000,
|
|
206
206
|
});
|
|
207
207
|
bar.update({ status: "done", file: "model.onnx" });
|
|
208
|
-
bar.finish();
|
|
209
|
-
|
|
210
208
|
expect(output).toBe("");
|
|
211
209
|
});
|
|
210
|
+
|
|
211
|
+
it("handles failure message with prior files in TTY mode", () => {
|
|
212
|
+
let output = "";
|
|
213
|
+
const mockStream = {
|
|
214
|
+
isTTY: true,
|
|
215
|
+
write: (str: string) => {
|
|
216
|
+
output += str;
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const bar = createCliProgressBar({
|
|
221
|
+
modelId: "bad-model",
|
|
222
|
+
stream: mockStream,
|
|
223
|
+
isTTY: true,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
bar.update({ status: "progress", file: "part.bin", progress: 10 });
|
|
227
|
+
bar.fail("Custom failure occurred");
|
|
228
|
+
expect(output).toContain("Custom failure occurred");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("handles done status directly in non-TTY mode without prior entry and formatBytes negative decimals", () => {
|
|
232
|
+
let output = "";
|
|
233
|
+
const mockStream = {
|
|
234
|
+
isTTY: false,
|
|
235
|
+
write: (str: string) => {
|
|
236
|
+
output += str;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const bar = createCliProgressBar({
|
|
241
|
+
modelId: "quick-model",
|
|
242
|
+
stream: mockStream,
|
|
243
|
+
isTTY: false,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
bar.update({ status: "done", file: "direct-done.bin" });
|
|
247
|
+
expect(output).toContain("[Download] Finished file: direct-done.bin");
|
|
248
|
+
|
|
249
|
+
expect(formatBytes(1536, -1)).toBe("2 KB");
|
|
250
|
+
});
|
|
212
251
|
});
|
|
213
252
|
});
|
|
@@ -96,6 +96,39 @@ describe("src/cli/utils/resolve-cache-dir.ts", () => {
|
|
|
96
96
|
expect(resolved).toBe(path.resolve(tempDir, ".legacy-cache"));
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
it("checks pages/site-config.json and shadowclaw.config.json variants and handles malformed JSON", async () => {
|
|
100
|
+
// Malformed JSON should be ignored
|
|
101
|
+
const badConfigPath = path.join(tempDir, "shadow-claw-config.json");
|
|
102
|
+
await fs.writeFile(badConfigPath, "{ bad json", "utf8");
|
|
103
|
+
expect(readConfigFileCacheDir(tempDir)).toBeNull();
|
|
104
|
+
await fs.unlink(badConfigPath);
|
|
105
|
+
|
|
106
|
+
// shadowclaw.config.json variant
|
|
107
|
+
const shadowclawPath = path.join(tempDir, "shadowclaw.config.json");
|
|
108
|
+
await fs.writeFile(
|
|
109
|
+
shadowclawPath,
|
|
110
|
+
JSON.stringify({ cacheDir: "sc-variant-cache" }),
|
|
111
|
+
"utf8",
|
|
112
|
+
);
|
|
113
|
+
expect(readConfigFileCacheDir(tempDir)).toBe(
|
|
114
|
+
path.resolve(tempDir, "sc-variant-cache"),
|
|
115
|
+
);
|
|
116
|
+
await fs.unlink(shadowclawPath);
|
|
117
|
+
|
|
118
|
+
// pages/site-config.json variant
|
|
119
|
+
const pagesDir = path.join(tempDir, "pages");
|
|
120
|
+
await fs.mkdir(pagesDir, { recursive: true });
|
|
121
|
+
const pagesSiteConfig = path.join(pagesDir, "site-config.json");
|
|
122
|
+
await fs.writeFile(
|
|
123
|
+
pagesSiteConfig,
|
|
124
|
+
JSON.stringify({ cacheDir: "pages-sub-cache" }),
|
|
125
|
+
"utf8",
|
|
126
|
+
);
|
|
127
|
+
expect(readConfigFileCacheDir(tempDir)).toBe(
|
|
128
|
+
path.resolve(tempDir, "pages-sub-cache"),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
99
132
|
it("respects explicit --tmp option", async () => {
|
|
100
133
|
const result = await resolveCacheDir({
|
|
101
134
|
contentRoot: tempDir,
|
|
@@ -366,4 +399,32 @@ describe("src/cli/utils/resolve-cache-dir.ts", () => {
|
|
|
366
399
|
expect(exitCode).toBe(130);
|
|
367
400
|
expect(output).toContain("Operation cancelled.");
|
|
368
401
|
});
|
|
402
|
+
|
|
403
|
+
it("handles explicit databaseDir ending in /database and non-/database", async () => {
|
|
404
|
+
const res1 = await resolveCacheDir({
|
|
405
|
+
contentRoot: tempDir,
|
|
406
|
+
databaseDir: "my-cache/database",
|
|
407
|
+
});
|
|
408
|
+
expect(res1.databaseDir).toBe(path.resolve(tempDir, "my-cache/database"));
|
|
409
|
+
expect(res1.cacheDir).toBe(path.resolve(tempDir, "my-cache"));
|
|
410
|
+
|
|
411
|
+
const res2 = await resolveCacheDir({
|
|
412
|
+
contentRoot: tempDir,
|
|
413
|
+
databaseDir: "custom-db-dir",
|
|
414
|
+
});
|
|
415
|
+
expect(res2.databaseDir).toBe(path.resolve(tempDir, "custom-db-dir"));
|
|
416
|
+
expect(res2.cacheDir).toBe(path.resolve(tempDir));
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("reads server.cacheDir from site-config.json", async () => {
|
|
420
|
+
const cfgPath = path.join(tempDir, "site-config.json");
|
|
421
|
+
await fs.writeFile(
|
|
422
|
+
cfgPath,
|
|
423
|
+
JSON.stringify({ server: { cacheDir: "server-cache" } }),
|
|
424
|
+
"utf8",
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const res = await resolveCacheDir({ contentRoot: tempDir });
|
|
428
|
+
expect(res.cacheDir).toBe(path.resolve(tempDir, "server-cache"));
|
|
429
|
+
});
|
|
369
430
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getProjectRoot } from "./resolve-project-root.js";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
describe("resolve-project-root", () => {
|
|
5
|
+
it("resolves the shadow-claw project root from current module url", () => {
|
|
6
|
+
const root = getProjectRoot();
|
|
7
|
+
expect(root).toContain("shadow-claw");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("throws when shadow-claw package.json cannot be found", () => {
|
|
11
|
+
expect(() => {
|
|
12
|
+
getProjectRoot(pathToFileURL("/").href);
|
|
13
|
+
}).toThrow(
|
|
14
|
+
"Could not find shadow-claw project root containing package.json",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -94,6 +94,44 @@ describe("src/cli/utils/stdin.ts - readStdin", () => {
|
|
|
94
94
|
expect(result).toBe(json);
|
|
95
95
|
expect(JSON.parse(result!)).toEqual({ text: "hello", tone: "happy" });
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
it("resolves null on timeoutMs expiration and destroys stream", async () => {
|
|
99
|
+
const stream = new Readable({ read() {} });
|
|
100
|
+
const result = await readStdin({ stdin: stream, timeoutMs: 50 });
|
|
101
|
+
expect(result).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("handles Buffer chunks, timer cleanup on end or error, and default process.stdin", async () => {
|
|
105
|
+
const stream = new Readable({
|
|
106
|
+
read() {
|
|
107
|
+
this.push(Buffer.from("buffer data"));
|
|
108
|
+
this.push(null);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
(stream as any).setEncoding = null;
|
|
112
|
+
(stream as any).isTTY = false;
|
|
113
|
+
const res = await readStdin({ stdin: stream, timeoutMs: 5000 });
|
|
114
|
+
expect(res).toBe("buffer data");
|
|
115
|
+
|
|
116
|
+
const errStream = new Readable({
|
|
117
|
+
read() {
|
|
118
|
+
this.emit("error", new Error("with timer error"));
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
(errStream as any).isTTY = false;
|
|
122
|
+
await expect(
|
|
123
|
+
readStdin({ stdin: errStream, timeoutMs: 5000 }),
|
|
124
|
+
).rejects.toThrow("with timer error");
|
|
125
|
+
|
|
126
|
+
const origIsTTY = process.stdin.isTTY;
|
|
127
|
+
try {
|
|
128
|
+
(process.stdin as any).isTTY = true;
|
|
129
|
+
const defRes = await readStdin();
|
|
130
|
+
expect(defRes).toBeNull();
|
|
131
|
+
} finally {
|
|
132
|
+
(process.stdin as any).isTTY = origIsTTY;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
97
135
|
});
|
|
98
136
|
|
|
99
137
|
describe("src/cli/utils/stdin.ts - mapTextToToolInput", () => {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
it,
|
|
4
|
+
expect,
|
|
5
|
+
beforeEach,
|
|
6
|
+
afterEach,
|
|
7
|
+
jest,
|
|
8
|
+
} from "@jest/globals";
|
|
2
9
|
import { Readable } from "node:stream";
|
|
3
10
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
4
11
|
import { tmpdir } from "node:os";
|
|
@@ -55,6 +62,29 @@ describe("src/cli/utils/stdio.ts", () => {
|
|
|
55
62
|
const result = await readStdin({ stdin: mockStdin, force: true });
|
|
56
63
|
expect(result).toBe("forced stream content");
|
|
57
64
|
});
|
|
65
|
+
|
|
66
|
+
it("returns null on stream error", async () => {
|
|
67
|
+
const mockStdin = new Readable({
|
|
68
|
+
read() {
|
|
69
|
+
this.emit("error", new Error("stream read failure"));
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
(mockStdin as any).isTTY = false;
|
|
73
|
+
const result = await readStdin({ stdin: mockStdin });
|
|
74
|
+
expect(result).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("resolves immediately when stream readableEnded is already true", async () => {
|
|
78
|
+
const mockStdin = new Readable({
|
|
79
|
+
read() {
|
|
80
|
+
this.push(null);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
(mockStdin as any).isTTY = false;
|
|
84
|
+
Object.defineProperty(mockStdin, "readableEnded", { get: () => true });
|
|
85
|
+
const result = await readStdin({ stdin: mockStdin });
|
|
86
|
+
expect(result).toBe("");
|
|
87
|
+
});
|
|
58
88
|
});
|
|
59
89
|
|
|
60
90
|
describe("readInputOrStdin", () => {
|
|
@@ -130,5 +160,25 @@ describe("src/cli/utils/stdio.ts", () => {
|
|
|
130
160
|
const content = await readFile(targetFile, "utf8");
|
|
131
161
|
expect(JSON.parse(content)).toEqual({ success: true, count: 42 });
|
|
132
162
|
});
|
|
163
|
+
|
|
164
|
+
it("logs string and JSON to console when output is not specified and quiet is false", async () => {
|
|
165
|
+
const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
|
|
166
|
+
try {
|
|
167
|
+
await writeOutput("console text output");
|
|
168
|
+
expect(logSpy).toHaveBeenCalledWith("console text output");
|
|
169
|
+
|
|
170
|
+
await writeOutput({ count: 1 });
|
|
171
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
172
|
+
JSON.stringify({ count: 1 }, null, 2),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// When quiet is true, does not log
|
|
176
|
+
logSpy.mockClear();
|
|
177
|
+
await writeOutput("quiet text", { quiet: true });
|
|
178
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
179
|
+
} finally {
|
|
180
|
+
logSpy.mockRestore();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
133
183
|
});
|
|
134
184
|
});
|