javi-forge 1.38.4 → 1.38.6

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.
@@ -17,7 +17,9 @@ export declare function runPluginList(onStep: StepCallback): Promise<void>;
17
17
  /**
18
18
  * Search the remote plugin registry.
19
19
  */
20
- export declare function runPluginSearch(query: string | undefined, onStep: StepCallback): Promise<void>;
20
+ export declare function runPluginSearch(query: string | undefined, onStep: StepCallback, options?: {
21
+ signal?: AbortSignal;
22
+ }): Promise<void>;
21
23
  /**
22
24
  * Validate a local plugin directory.
23
25
  */
@@ -58,20 +58,24 @@ export async function runPluginList(onStep) {
58
58
  /**
59
59
  * Search the remote plugin registry.
60
60
  */
61
- export async function runPluginSearch(query, onStep) {
61
+ export async function runPluginSearch(query, onStep, options = {}) {
62
62
  const stepId = "plugin-search";
63
63
  report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "running");
64
- const results = await searchRegistry(query);
65
- if (results.length === 0) {
66
- report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", query
67
- ? `no plugins matching "${query}"`
68
- : "registry empty or unreachable");
64
+ const results = await searchRegistry(query, options);
65
+ if (results.status === "cancelled") {
66
+ report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "error", "registry search cancelled");
67
+ }
68
+ else if (results.status === "unavailable") {
69
+ report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "error", "registry unavailable");
70
+ }
71
+ else if (results.entries.length === 0) {
72
+ report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", query ? `no plugins matching "${query}"` : "registry empty");
69
73
  }
70
74
  else {
71
- const summary = results
75
+ const summary = results.entries
72
76
  .map((p) => `${p.id} — ${p.description}`)
73
77
  .join("\n ");
74
- report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", `${results.length} results:\n ${summary}`);
78
+ report(onStep, stepId, `Search plugins${query ? `: ${query}` : ""}`, "done", `${results.entries.length} results:\n ${summary}`);
75
79
  }
76
80
  }
77
81
  /**
@@ -1,4 +1,12 @@
1
1
  import type { InstalledPlugin, PluginRegistryEntry, PluginSyncResult, PluginValidationResult } from "../types/index.js";
2
+ export type RegistrySearchResult = {
3
+ status: "success";
4
+ entries: PluginRegistryEntry[];
5
+ } | {
6
+ status: "unavailable";
7
+ } | {
8
+ status: "cancelled";
9
+ };
2
10
  /**
3
11
  * Validate a plugin directory structure and manifest.
4
12
  */
@@ -38,8 +46,14 @@ export declare function removePlugin(name: string, options?: {
38
46
  export declare function listInstalledPlugins(): Promise<InstalledPlugin[]>;
39
47
  /**
40
48
  * Fetch the remote plugin registry and optionally filter by query.
49
+ *
50
+ * A registry that cannot be read or validated is deliberately distinct from a
51
+ * valid registry with no matches. Callers need that distinction to avoid
52
+ * presenting a network failure as a successful empty search.
41
53
  */
42
- export declare function searchRegistry(query?: string): Promise<PluginRegistryEntry[]>;
54
+ export declare function searchRegistry(query?: string, options?: {
55
+ signal?: AbortSignal;
56
+ }): Promise<RegistrySearchResult>;
43
57
  /**
44
58
  * Detect installed plugins in a project's .javi-forge/plugins/ directory.
45
59
  * Returns an array of plugin names (sorted alphabetically).
@@ -8,6 +8,7 @@ import { evaluateCoverageGate, scanFailureMessage, } from "./skill-install-gate.
8
8
  import { scanSkillsWithCoverage } from "./skill-scanner.js";
9
9
  const KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
10
10
  const SEMVER_RE = /^\d+\.\d+\.\d+$/;
11
+ const REGISTRY_REQUEST_TIMEOUT_MS = 10_000;
11
12
  // ── Validation ──────────────────────────────────────────────────────────────
12
13
  /**
13
14
  * Validate a plugin directory structure and manifest.
@@ -251,26 +252,90 @@ export async function listInstalledPlugins() {
251
252
  }
252
253
  /**
253
254
  * Fetch the remote plugin registry and optionally filter by query.
255
+ *
256
+ * A registry that cannot be read or validated is deliberately distinct from a
257
+ * valid registry with no matches. Callers need that distinction to avoid
258
+ * presenting a network failure as a successful empty search.
254
259
  */
255
- export async function searchRegistry(query) {
260
+ export async function searchRegistry(query, options = {}) {
261
+ const { signal: callerSignal } = options;
262
+ if (callerSignal?.aborted)
263
+ return { status: "cancelled" };
264
+ const controller = new AbortController();
265
+ let timedOut = false;
266
+ let callerCancelled = false;
267
+ let resolveDeadline;
268
+ const deadline = new Promise((resolve) => {
269
+ resolveDeadline = resolve;
270
+ });
271
+ const timeout = setTimeout(() => {
272
+ timedOut = true;
273
+ controller.abort();
274
+ resolveDeadline();
275
+ }, REGISTRY_REQUEST_TIMEOUT_MS);
276
+ const cancelFromCaller = () => {
277
+ callerCancelled = true;
278
+ controller.abort();
279
+ resolveDeadline();
280
+ };
281
+ callerSignal?.addEventListener("abort", cancelFromCaller, { once: true });
256
282
  try {
257
- const response = await fetch(PLUGIN_REGISTRY_URL);
258
- if (!response.ok) {
259
- return [];
260
- }
261
- const registry = (await response.json());
262
- let plugins = registry.plugins ?? [];
263
- if (query) {
264
- const q = query.toLowerCase();
265
- plugins = plugins.filter((p) => p.id.toLowerCase().includes(q) ||
266
- p.description.toLowerCase().includes(q) ||
267
- p.tags.some((t) => t.toLowerCase().includes(q)));
268
- }
269
- return plugins;
283
+ const response = await Promise.race([
284
+ Promise.resolve().then(() => fetch(PLUGIN_REGISTRY_URL, { signal: controller.signal })),
285
+ deadline,
286
+ ]);
287
+ if (callerCancelled || callerSignal?.aborted)
288
+ return { status: "cancelled" };
289
+ if (timedOut || !response || !response.ok)
290
+ return { status: "unavailable" };
291
+ const body = await Promise.race([response.json(), deadline]);
292
+ if (callerCancelled || callerSignal?.aborted)
293
+ return { status: "cancelled" };
294
+ if (timedOut || !isPluginRegistry(body))
295
+ return { status: "unavailable" };
296
+ const entries = query
297
+ ? filterRegistryEntries(body.plugins, query)
298
+ : body.plugins;
299
+ return { status: "success", entries };
270
300
  }
271
301
  catch {
272
- return [];
302
+ return callerCancelled || callerSignal?.aborted
303
+ ? { status: "cancelled" }
304
+ : { status: "unavailable" };
273
305
  }
306
+ finally {
307
+ clearTimeout(timeout);
308
+ callerSignal?.removeEventListener("abort", cancelFromCaller);
309
+ }
310
+ }
311
+ function filterRegistryEntries(plugins, query) {
312
+ const normalizedQuery = query.toLowerCase();
313
+ return plugins.filter((plugin) => plugin.id.toLowerCase().includes(normalizedQuery) ||
314
+ plugin.description.toLowerCase().includes(normalizedQuery) ||
315
+ plugin.tags.some((tag) => tag.toLowerCase().includes(normalizedQuery)));
316
+ }
317
+ function isPluginRegistry(value) {
318
+ if (!isRecord(value))
319
+ return false;
320
+ return (typeof value.version === "string" &&
321
+ typeof value.updatedAt === "string" &&
322
+ Array.isArray(value.plugins) &&
323
+ value.plugins.every(isPluginRegistryEntry));
324
+ }
325
+ function isPluginRegistryEntry(value) {
326
+ if (!isRecord(value))
327
+ return false;
328
+ return (typeof value.id === "string" &&
329
+ typeof value.repository === "string" &&
330
+ typeof value.description === "string" &&
331
+ Array.isArray(value.tags) &&
332
+ value.tags.every((tag) => typeof tag === "string") &&
333
+ (value.stars === undefined ||
334
+ (typeof value.stars === "number" && Number.isFinite(value.stars))) &&
335
+ (value.updatedAt === undefined || typeof value.updatedAt === "string"));
336
+ }
337
+ function isRecord(value) {
338
+ return typeof value === "object" && value !== null;
274
339
  }
275
340
  // ── Sync ───────────────────────────────────────────────────────────────
276
341
  /**
package/dist/ui/Plugin.js CHANGED
@@ -19,6 +19,15 @@ const STATUS_COLOR = {
19
19
  export default function Plugin({ action, target, dryRun, codex = false, force = false, }) {
20
20
  const [steps, setSteps] = useState([]);
21
21
  const [done, setDone] = useState(false);
22
+ const handleTerminalSignal = (signal, signalController) => {
23
+ if (signalController.signal.aborted)
24
+ return;
25
+ signalController.abort();
26
+ if (signal === "SIGINT")
27
+ process.exitCode = 130;
28
+ if (signal === "SIGTERM")
29
+ process.exitCode = 143;
30
+ };
22
31
  const onStep = (step) => {
23
32
  setSteps((prev) => {
24
33
  const idx = prev.findIndex((s) => s.id === step.id);
@@ -31,6 +40,17 @@ export default function Plugin({ action, target, dryRun, codex = false, force =
31
40
  });
32
41
  };
33
42
  useEffect(() => {
43
+ const controller = action === "search" ? new AbortController() : undefined;
44
+ const sigintHandler = controller === undefined
45
+ ? undefined
46
+ : () => handleTerminalSignal("SIGINT", controller);
47
+ const sigtermHandler = controller === undefined
48
+ ? undefined
49
+ : () => handleTerminalSignal("SIGTERM", controller);
50
+ if (action === "search" && sigintHandler && sigtermHandler) {
51
+ process.on("SIGINT", sigintHandler);
52
+ process.on("SIGTERM", sigtermHandler);
53
+ }
34
54
  const run = async () => {
35
55
  try {
36
56
  switch (action) {
@@ -62,7 +82,9 @@ export default function Plugin({ action, target, dryRun, codex = false, force =
62
82
  await runPluginList(onStep);
63
83
  break;
64
84
  case "search":
65
- await runPluginSearch(target, onStep);
85
+ await runPluginSearch(target, onStep, {
86
+ signal: controller?.signal,
87
+ });
66
88
  break;
67
89
  case "validate":
68
90
  if (!target) {
@@ -129,6 +151,14 @@ export default function Plugin({ action, target, dryRun, codex = false, force =
129
151
  setDone(true);
130
152
  };
131
153
  run();
154
+ return () => {
155
+ if (sigintHandler) {
156
+ process.removeListener("SIGINT", sigintHandler);
157
+ }
158
+ if (sigtermHandler) {
159
+ process.removeListener("SIGTERM", sigtermHandler);
160
+ }
161
+ };
132
162
  }, [action, target, dryRun, force]);
133
163
  return (React.createElement(Box, { flexDirection: "column", padding: 1 },
134
164
  React.createElement(Box, { marginBottom: 1 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.38.4",
3
+ "version": "1.38.6",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {