-
+
+ );
+}
+
+/** Architectural drafting blueprint illustration for recursive dependency split cascades */
+function CascadesDraftingIllustration() {
+ return (
+
+ );
+}
+
+/** Architectural drafting blueprint illustration for RFC-8785 cryptographic audit receipts */
+function ReceiptsDraftingIllustration() {
+ return (
+
+ );
+}
+
+/** General blueprint drafting drawing for other states */
+function DefaultDraftingIllustration() {
+ return (
+
+ );
+}
+
+export type EmptyIllustrationVariant = "streams" | "cascades" | "receipts" | "default";
+
+export function Empty({
+ icon,
+ title,
+ variant,
+ children,
+}: {
+ icon?: string;
+ title: string;
+ variant?: EmptyIllustrationVariant;
+ children?: ReactNode;
+}) {
+ const resolvedVariant: EmptyIllustrationVariant =
+ variant ??
+ (icon === "water_drop"
+ ? "streams"
+ : icon === "account_tree"
+ ? "cascades"
+ : icon === "receipt_long" || icon === "receipt" || icon === "account_balance_wallet"
+ ? "receipts"
+ : "default");
+
+ return (
+
+
+ {resolvedVariant === "streams" && }
+ {resolvedVariant === "cascades" && }
+ {resolvedVariant === "receipts" && }
+ {resolvedVariant === "default" && }
+
{title}
{children &&
{children}
}
diff --git a/frontend/src/lib/__tests__/cli.test.ts b/frontend/src/lib/__tests__/cli.test.ts
new file mode 100644
index 0000000..6ffa856
--- /dev/null
+++ b/frontend/src/lib/__tests__/cli.test.ts
@@ -0,0 +1,214 @@
+import { describe, it, expect } from "vitest";
+import { toStellarCliCommand, formatCliValue } from "../cli";
+import { sv } from "../soroban";
+
+const ROUTER_ID = "CA45PGMYO5CNRPCMW3THPKI26PK2CU3JU4XTJZJ3HZ4KLEFRKUQXUTWK";
+const SPLITTER_ID = "CA4U6VUIFA33SQA5QCQYMFNEQTNN6XV43EAZ67IKXOB434V5GI5KB2SY";
+const ADDR_A = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";
+const ADDR_B = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
+const TOKEN_ADDR = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
+
+describe("toStellarCliCommand", () => {
+ describe("StreamRouter methods", () => {
+ it("generates CLI command for create_stream", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "create_stream",
+ args: [
+ sv.addr(ADDR_A),
+ sv.addr(ADDR_B),
+ sv.addr(TOKEN_ADDR),
+ sv.i128(100000n),
+ sv.i128(5000000n),
+ ],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- create_stream --sender ${ADDR_A} --recipient ${ADDR_B} --token ${TOKEN_ADDR} --rate_per_sec 100000 --deposit 5000000`
+ );
+ });
+
+ it("generates CLI command for withdraw", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "withdraw",
+ args: [sv.u64(12)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- withdraw --id 12`
+ );
+ });
+
+ it("generates CLI command for withdraw_all", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "withdraw_all",
+ args: [sv.addr(ADDR_B)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- withdraw_all --recipient ${ADDR_B}`
+ );
+ });
+
+ it("generates CLI command for top_up", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "top_up",
+ args: [sv.u64(12), sv.i128(2000000n)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- top_up --id 12 --amount 2000000`
+ );
+ });
+
+ it("generates CLI command for cancel", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "cancel",
+ args: [sv.u64(12)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- cancel --id 12`
+ );
+ });
+
+ it("generates CLI command for withdrawable", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "withdrawable",
+ args: [sv.u64(12)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- withdrawable --id 12`
+ );
+ });
+
+ it("generates CLI command for get_stream", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "get_stream",
+ args: [sv.u64(12)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- get_stream --id 12`
+ );
+ });
+
+ it("generates CLI command for streams_by_sender", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "streams_by_sender",
+ args: [sv.addr(ADDR_A)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- streams_by_sender --sender ${ADDR_A}`
+ );
+ });
+
+ it("generates CLI command for streams_by_recipient", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "streams_by_recipient",
+ args: [sv.addr(ADDR_B)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- streams_by_recipient --recipient ${ADDR_B}`
+ );
+ });
+
+ it("generates CLI command for stream_count", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "stream_count",
+ args: [],
+ });
+ expect(cmd).toBe(`stellar contract invoke --id ${ROUTER_ID} -- stream_count`);
+ });
+ });
+
+ describe("CascadeSplitter methods", () => {
+ it("generates CLI command for set_splits", () => {
+ const splits = [
+ { recipient: ADDR_B, bps: 5000, label: "serde" },
+ ];
+ const cmd = toStellarCliCommand({
+ contractId: SPLITTER_ID,
+ method: "set_splits",
+ args: [sv.addr(ADDR_A), sv.splits(splits)],
+ });
+ expect(cmd).toContain(`stellar contract invoke --id ${SPLITTER_ID} -- set_splits --owner ${ADDR_A} --splits `);
+ expect(cmd).toContain('"bps":5000');
+ expect(cmd).toContain('"label":"serde"');
+ expect(cmd).toContain(`"recipient":"${ADDR_B}"`);
+ });
+
+ it("generates CLI command for get_splits", () => {
+ const cmd = toStellarCliCommand({
+ contractId: SPLITTER_ID,
+ method: "get_splits",
+ args: [sv.addr(ADDR_A)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${SPLITTER_ID} -- get_splits --owner ${ADDR_A}`
+ );
+ });
+
+ it("generates CLI command for preview", () => {
+ const cmd = toStellarCliCommand({
+ contractId: SPLITTER_ID,
+ method: "preview",
+ args: [sv.addr(ADDR_A), sv.i128(10000000n)],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${SPLITTER_ID} -- preview --owner ${ADDR_A} --amount 10000000`
+ );
+ });
+
+ it("generates CLI command for distribute", () => {
+ const cmd = toStellarCliCommand({
+ contractId: SPLITTER_ID,
+ method: "distribute",
+ args: [
+ sv.addr(ADDR_A),
+ sv.addr(ADDR_B),
+ sv.addr(TOKEN_ADDR),
+ sv.i128(10000000n),
+ ],
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${SPLITTER_ID} -- distribute --from ${ADDR_A} --owner ${ADDR_B} --token ${TOKEN_ADDR} --amount 10000000`
+ );
+ });
+ });
+
+ describe("Flags and formatting options", () => {
+ it("includes --network and --source when supplied", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "withdraw",
+ args: [sv.u64(99)],
+ source: ADDR_A,
+ network: "testnet",
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} --source ${ADDR_A} --network testnet -- withdraw --id 99`
+ );
+ });
+
+ it("accepts plain record objects as arguments", () => {
+ const cmd = toStellarCliCommand({
+ contractId: ROUTER_ID,
+ method: "top_up",
+ args: { id: 10, amount: "5000" },
+ });
+ expect(cmd).toBe(
+ `stellar contract invoke --id ${ROUTER_ID} -- top_up --id 10 --amount 5000`
+ );
+ });
+
+ it("safely escapes shell values with spaces", () => {
+ expect(formatCliValue("hello world")).toBe("'hello world'");
+ expect(formatCliValue(123)).toBe("123");
+ expect(formatCliValue(456n)).toBe("456");
+ });
+ });
+});
diff --git a/frontend/src/lib/__tests__/focus.test.ts b/frontend/src/lib/__tests__/focus.test.ts
new file mode 100644
index 0000000..becb129
--- /dev/null
+++ b/frontend/src/lib/__tests__/focus.test.ts
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi } from "vitest";
+import { handleFocusTrap } from "../focus";
+
+describe("handleFocusTrap", () => {
+ function createMockContainer(elements: { tag: string; id: string; disabled?: boolean }[]) {
+ const container = {
+ contains: vi.fn((el) => elements.some((e) => e.id === el?.id)),
+ querySelectorAll: vi.fn((_selector: string) => {
+ return elements.map((item) => ({
+ id: item.id,
+ tagName: item.tag.toUpperCase(),
+ focus: vi.fn(),
+ getAttribute: vi.fn((attr: string) => (attr === "disabled" && item.disabled ? "" : null)),
+ style: {},
+ }));
+ }),
+ } as unknown as HTMLElement;
+ return container;
+ }
+
+ it("ignores non-Tab key events", () => {
+ const container = createMockContainer([{ tag: "button", id: "btn1" }]);
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Enter", shiftKey: false, preventDefault },
+ activeElement: null,
+ });
+
+ expect(handled).toBe(false);
+ expect(preventDefault).not.toHaveBeenCalled();
+ });
+
+ it("wraps from last to first on Tab", () => {
+ const elements = [
+ { id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ { id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ ];
+ const container = {
+ contains: vi.fn(() => true),
+ querySelectorAll: vi.fn(() => elements),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: false, preventDefault },
+ activeElement: elements[1] as any, // Last element
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(elements[0].focus).toHaveBeenCalled();
+ });
+
+ it("wraps from first to last on Shift+Tab", () => {
+ const elements = [
+ { id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ { id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ ];
+ const container = {
+ contains: vi.fn(() => true),
+ querySelectorAll: vi.fn(() => elements),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: true, preventDefault },
+ activeElement: elements[0] as any, // First element
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(elements[1].focus).toHaveBeenCalled();
+ });
+
+ it("focuses first element if focus is outside container on Tab", () => {
+ const elements = [
+ { id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ { id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ ];
+ const container = {
+ contains: vi.fn(() => false),
+ querySelectorAll: vi.fn(() => elements),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: false, preventDefault },
+ activeElement: { id: "outside" } as any,
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(elements[0].focus).toHaveBeenCalled();
+ });
+
+ it("prevents default if no focusable elements are present", () => {
+ const container = {
+ contains: vi.fn(() => true),
+ querySelectorAll: vi.fn(() => []),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: false, preventDefault },
+ activeElement: null,
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/lib/__tests__/manifest.test.ts b/frontend/src/lib/__tests__/manifest.test.ts
index bc26051..aa7827a 100644
--- a/frontend/src/lib/__tests__/manifest.test.ts
+++ b/frontend/src/lib/__tests__/manifest.test.ts
@@ -1,9 +1,17 @@
import { describe, expect, it } from "vitest";
-import { equalWeights, parseManifest } from "../manifest";
+import {
+ equalWeights,
+ MAX_SPLIT_ROWS,
+ parseCargoLock,
+ parseCargoToml,
+ parseManifest,
+ parsePackageJson,
+ parsePackageLockJson,
+} from "../manifest";
describe("parseCargoToml", () => {
it("reads all common dependency forms", () => {
- const m = parseManifest(`
+ const raw = `
[package]
name = "demo"
@@ -21,7 +29,8 @@ proptest = "1"
[target.'cfg(unix)'.dependencies]
libc = "0.2" # comment
-`);
+`;
+ const m = parseCargoToml(raw);
expect(m.name).toBe("demo");
const byName = Object.fromEntries(m.deps.map((d) => [d.name, d]));
expect(byName.serde.version).toBe("1.0");
@@ -36,17 +45,246 @@ libc = "0.2" # comment
describe("parsePackageJson", () => {
it("reads deps, peer and dev deps without duplicates", () => {
- const m = parseManifest(
- JSON.stringify({ name: "x", dependencies: { a: "^1" }, peerDependencies: { b: "2" }, devDependencies: { a: "1", c: "3" } }),
- );
+ const raw = JSON.stringify({
+ name: "x",
+ dependencies: { a: "^1" },
+ peerDependencies: { b: "2" },
+ devDependencies: { a: "1", c: "3" },
+ });
+ const m = parsePackageJson(raw);
expect(m.ecosystem).toBe("npm");
expect(m.deps.map((d) => `${d.name}:${d.kind}`)).toEqual(["a:normal", "b:peer", "c:dev"]);
});
+
it("errors on invalid json", () => {
expect(() => parseManifest("{ nope", "package.json")).toThrow(/valid JSON/);
});
});
+describe("parseCargoLock", () => {
+ const sampleCargoLock = `
+version = 3
+
+[[package]]
+name = "my-crate"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "soroban-sdk 22.0.1",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.197"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.197"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "soroban-sdk"
+version = "22.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+
+[[package]]
+name = "quote"
+version = "1.0.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+
+[[package]]
+name = "syn"
+version = "2.0.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+`;
+
+ it("parses direct dependencies only by default", () => {
+ const m = parseCargoLock(sampleCargoLock, { includeTransitive: false });
+ expect(m.ecosystem).toBe("cargo");
+ expect(m.name).toBe("my-crate");
+ expect(m.deps.map((d) => d.name)).toEqual(["serde", "soroban-sdk"]);
+ expect(m.deps.find((d) => d.name === "serde")?.version).toBe("1.0.197");
+ });
+
+ it("parses transitive dependencies when requested", () => {
+ const m = parseCargoLock(sampleCargoLock, { includeTransitive: true });
+ expect(m.ecosystem).toBe("cargo");
+ const names = m.deps.map((d) => d.name);
+ expect(names).toContain("serde");
+ expect(names).toContain("serde_derive");
+ expect(names).toContain("proc-macro2");
+ expect(names).toContain("quote");
+ expect(names).toContain("syn");
+ expect(names).not.toContain("my-crate");
+ });
+
+ it("caps results at MAX_SPLIT_ROWS (32) and de-duplicates", () => {
+ let bigLock = `
+[[package]]
+name = "root"
+version = "0.1.0"
+dependencies = []
+`;
+ for (let i = 0; i < 50; i++) {
+ bigLock += `
+[[package]]
+name = "dep-${i}"
+version = "1.0.${i}"
+source = "registry+https://..."
+`;
+ }
+ // Duplicate one
+ bigLock += `
+[[package]]
+name = "dep-0"
+version = "1.0.99"
+source = "registry+https://..."
+`;
+
+ const m = parseCargoLock(bigLock, { includeTransitive: true });
+ expect(m.deps.length).toBe(MAX_SPLIT_ROWS);
+ expect(m.deps.length).toBeLessThanOrEqual(32);
+ const seen = new Set(m.deps.map((d) => d.name));
+ expect(seen.size).toBe(m.deps.length);
+ });
+
+ it("routes through parseManifest with filename detection", () => {
+ const m = parseManifest(sampleCargoLock, "Cargo.lock", { includeTransitive: true });
+ expect(m.ecosystem).toBe("cargo");
+ expect(m.deps.length).toBeGreaterThan(2);
+ });
+});
+
+describe("parsePackageLockJson", () => {
+ const sampleV3Lock = JSON.stringify({
+ name: "flowpulse-frontend",
+ version: "0.1.0",
+ lockfileVersion: 3,
+ packages: {
+ "": {
+ name: "flowpulse-frontend",
+ version: "0.1.0",
+ dependencies: {
+ "@stellar/stellar-sdk": "^17.4.0",
+ react: "^19.3.1",
+ },
+ devDependencies: {
+ typescript: "~5.9.3",
+ },
+ },
+ "node_modules/@stellar/stellar-sdk": {
+ version: "17.4.0",
+ dependencies: {
+ axios: "^1.7.0",
+ eventsource: "^2.0.2",
+ },
+ },
+ "node_modules/axios": {
+ version: "1.7.9",
+ },
+ "node_modules/eventsource": {
+ version: "2.0.2",
+ },
+ "node_modules/react": {
+ version: "19.3.1",
+ },
+ "node_modules/typescript": {
+ version: "5.9.3",
+ dev: true,
+ },
+ },
+ });
+
+ it("extracts direct dependencies from v3 lockfile when includeTransitive is false", () => {
+ const m = parsePackageLockJson(sampleV3Lock, { includeTransitive: false });
+ expect(m.ecosystem).toBe("npm");
+ expect(m.name).toBe("flowpulse-frontend");
+ const names = m.deps.map((d) => d.name);
+ expect(names).toEqual(["@stellar/stellar-sdk", "react", "typescript"]);
+ const ts = m.deps.find((d) => d.name === "typescript");
+ expect(ts?.kind).toBe("dev");
+ });
+
+ it("extracts transitive dependencies from v3 lockfile when includeTransitive is true", () => {
+ const m = parsePackageLockJson(sampleV3Lock, { includeTransitive: true });
+ expect(m.ecosystem).toBe("npm");
+ const names = m.deps.map((d) => d.name);
+ expect(names).toContain("@stellar/stellar-sdk");
+ expect(names).toContain("axios");
+ expect(names).toContain("eventsource");
+ expect(names).toContain("react");
+ expect(names).toContain("typescript");
+ expect(names).not.toContain("flowpulse-frontend");
+ });
+
+ it("extracts transitive dependencies from v1 lockfile", () => {
+ const sampleV1Lock = JSON.stringify({
+ name: "v1-app",
+ version: "1.0.0",
+ lockfileVersion: 1,
+ dependencies: {
+ express: {
+ version: "4.19.2",
+ dependencies: {
+ accepts: {
+ version: "1.3.8",
+ dependencies: {
+ "mime-types": {
+ version: "2.1.35",
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const direct = parsePackageLockJson(sampleV1Lock, { includeTransitive: false });
+ expect(direct.deps.map((d) => d.name)).toEqual(["express"]);
+
+ const transitive = parsePackageLockJson(sampleV1Lock, { includeTransitive: true });
+ expect(transitive.deps.map((d) => d.name)).toEqual(["express", "accepts", "mime-types"]);
+ });
+
+ it("caps at maxRows and handles nested node_modules paths correctly", () => {
+ const sampleNested = JSON.stringify({
+ name: "nested-app",
+ lockfileVersion: 2,
+ packages: {
+ "": { name: "nested-app" },
+ "node_modules/a": { version: "1.0.0" },
+ "node_modules/a/node_modules/b": { version: "2.0.0" },
+ "node_modules/@scope/pkg": { version: "3.0.0" },
+ },
+ });
+
+ const m = parsePackageLockJson(sampleNested, { includeTransitive: true, maxRows: 2 });
+ expect(m.deps.length).toBe(2);
+ expect(m.deps[0].name).toBe("a");
+ expect(m.deps[1].name).toBe("b");
+ });
+
+ it("routes through parseManifest with package-lock.json", () => {
+ const m = parseManifest(sampleV3Lock, "package-lock.json", { includeTransitive: true });
+ expect(m.ecosystem).toBe("npm");
+ expect(m.deps.map((d) => d.name)).toContain("axios");
+ });
+});
+
describe("equalWeights", () => {
it("sums exactly to the target", () => {
const w = equalWeights(7, 9000);
diff --git a/frontend/src/lib/cli.ts b/frontend/src/lib/cli.ts
new file mode 100644
index 0000000..1b6d432
--- /dev/null
+++ b/frontend/src/lib/cli.ts
@@ -0,0 +1,94 @@
+import { scValToNative, xdr } from "@stellar/stellar-sdk";
+
+export const METHOD_SIGNATURES: Record
= {
+ // StreamRouter
+ create_stream: ["sender", "recipient", "token", "rate_per_sec", "deposit"],
+ withdraw: ["id"],
+ withdraw_all: ["recipient"],
+ top_up: ["id", "amount"],
+ cancel: ["id"],
+ withdrawable: ["id"],
+ get_stream: ["id"],
+ streams_by_sender: ["sender"],
+ streams_by_recipient: ["recipient"],
+ stream_count: [],
+
+ // CascadeSplitter
+ set_splits: ["owner", "splits"],
+ get_splits: ["owner"],
+ preview: ["owner", "amount"],
+ distribute: ["from", "owner", "token", "amount"],
+};
+
+export interface FormatCliOptions {
+ contractId: string;
+ method: string;
+ args?: xdr.ScVal[] | Record | Array;
+ network?: string;
+ source?: string;
+}
+
+/**
+ * Format a value into a safe shell argument representation for Stellar CLI.
+ */
+export function formatCliValue(val: unknown): string {
+ if (typeof val === "bigint" || typeof val === "number") {
+ return val.toString();
+ }
+ if (typeof val === "string") {
+ // If it contains spaces or quotes, wrap in quotes
+ if (/[\s"']/.test(val)) {
+ return `'${val.replace(/'/g, "'\\''")}'`;
+ }
+ return val;
+ }
+ if (typeof val === "boolean") {
+ return val ? "true" : "false";
+ }
+ // Array or object (e.g. splits struct or JSON payload)
+ const json = JSON.stringify(val);
+ return `'${json.replace(/'/g, "'\\''")}'`;
+}
+
+/**
+ * Generate the equivalent `stellar contract invoke` CLI command for a Soroban call.
+ */
+export function toStellarCliCommand({
+ contractId,
+ method,
+ args = [],
+ network,
+ source,
+}: FormatCliOptions): string {
+ const parts: string[] = ["stellar", "contract", "invoke", "--id", contractId];
+
+ if (source) {
+ parts.push("--source", source);
+ }
+ if (network) {
+ parts.push("--network", network);
+ }
+
+ parts.push("--", method);
+
+ let namedArgs: Record = {};
+
+ if (Array.isArray(args)) {
+ const signature = METHOD_SIGNATURES[method];
+ args.forEach((arg, index) => {
+ // Decode ScVal if applicable
+ const nativeVal =
+ arg instanceof xdr.ScVal ? scValToNative(arg as xdr.ScVal) : arg;
+ const paramName = signature && signature[index] ? signature[index] : `arg${index + 1}`;
+ namedArgs[paramName] = nativeVal;
+ });
+ } else if (args && typeof args === "object") {
+ namedArgs = args as Record;
+ }
+
+ for (const [key, val] of Object.entries(namedArgs)) {
+ parts.push(`--${key}`, formatCliValue(val));
+ }
+
+ return parts.join(" ");
+}
diff --git a/frontend/src/lib/focus.ts b/frontend/src/lib/focus.ts
new file mode 100644
index 0000000..2d6c0e7
--- /dev/null
+++ b/frontend/src/lib/focus.ts
@@ -0,0 +1,53 @@
+export const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+export interface FocusTrapOptions {
+ container: HTMLElement;
+ event: { key: string; shiftKey: boolean; preventDefault: () => void };
+ activeElement: Element | null;
+}
+
+/**
+ * Handles Tab and Shift+Tab key navigation to keep focus trapped within the container.
+ * Returns true if the event was handled (trapped) and default prevented.
+ */
+export function handleFocusTrap({
+ container,
+ event,
+ activeElement,
+}: FocusTrapOptions): boolean {
+ if (event.key !== "Tab") return false;
+
+ const focusables = Array.from(
+ container.querySelectorAll(FOCUSABLE_SELECTOR)
+ ).filter((el) => {
+ // In JSDOM / headless, offsetParent might be null, so check display/visibility if needed
+ const style = (el as any).style;
+ if (style && (style.display === "none" || style.visibility === "hidden")) return false;
+ return true;
+ });
+
+ if (focusables.length === 0) {
+ event.preventDefault();
+ return true;
+ }
+
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+
+ if (event.shiftKey) {
+ if (activeElement === first || !container.contains(activeElement)) {
+ event.preventDefault();
+ last.focus();
+ return true;
+ }
+ } else {
+ if (activeElement === last || !container.contains(activeElement)) {
+ event.preventDefault();
+ first.focus();
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/frontend/src/lib/manifest.ts b/frontend/src/lib/manifest.ts
index 306d01f..b4e4d68 100644
--- a/frontend/src/lib/manifest.ts
+++ b/frontend/src/lib/manifest.ts
@@ -1,8 +1,10 @@
/**
* Dependency manifest parsing for split tables.
- * Supports Cargo.toml, package.json, and Cargo.lock / package-lock.json (top-level only).
+ * Supports Cargo.toml, package.json, Cargo.lock, and package-lock.json (with transitive dependencies option).
*/
+export const MAX_SPLIT_ROWS = 32;
+
export type Ecosystem = "cargo" | "npm";
export interface Dependency {
@@ -18,9 +20,34 @@ export interface Manifest {
deps: Dependency[];
}
-export function parseManifest(text: string, filename = ""): Manifest {
+export interface ParseManifestOptions {
+ includeTransitive?: boolean;
+ maxRows?: number;
+}
+
+export function parseManifest(text: string, filename = "", options?: ParseManifestOptions): Manifest {
const t = text.trim();
- if (filename.endsWith(".json") || t.startsWith("{")) return parsePackageJson(t);
+ const lowerFile = filename.toLowerCase();
+
+ if (lowerFile.endsWith("cargo.lock") || (lowerFile.endsWith(".lock") && t.includes("[[package]]"))) {
+ return parseCargoLock(t, options);
+ }
+
+ if (
+ lowerFile.endsWith("package-lock.json") ||
+ (lowerFile.endsWith(".json") && t.includes('"lockfileVersion"'))
+ ) {
+ return parsePackageLockJson(t, options);
+ }
+
+ if (lowerFile.endsWith(".json") || t.startsWith("{")) {
+ return parsePackageJson(t);
+ }
+
+ if (t.includes("[[package]]") && !t.includes("[package]") && !t.includes("[workspace]")) {
+ return parseCargoLock(t, options);
+ }
+
return parseCargoToml(t);
}
@@ -50,6 +77,245 @@ export function parsePackageJson(text: string): Manifest {
return { ecosystem: "npm", name: typeof j.name === "string" ? j.name : undefined, deps };
}
+/**
+ * Parses Cargo.lock and optionally includes transitive dependencies.
+ * De-duplicates and caps at maxRows (default MAX_SPLIT_ROWS = 32).
+ */
+export function parseCargoLock(text: string, options?: ParseManifestOptions): Manifest {
+ const includeTransitive = options?.includeTransitive ?? false;
+ const maxRows = options?.maxRows ?? MAX_SPLIT_ROWS;
+
+ interface RawPackage {
+ name: string;
+ version: string;
+ source?: string;
+ dependencies: string[];
+ }
+
+ const packages: RawPackage[] = [];
+ let current: Partial | null = null;
+ let inDependencies = false;
+
+ for (const raw of text.split(/\r?\n/)) {
+ const line = raw.replace(/\s+#.*$/, "").trim();
+ if (!line || line.startsWith("#")) continue;
+
+ if (line === "[[package]]") {
+ if (current && current.name && current.version) {
+ packages.push({
+ name: current.name,
+ version: current.version,
+ source: current.source,
+ dependencies: current.dependencies ?? [],
+ });
+ }
+ current = { dependencies: [] };
+ inDependencies = false;
+ continue;
+ }
+
+ if (!current) continue;
+
+ if (inDependencies) {
+ if (line === "]") {
+ inDependencies = false;
+ continue;
+ }
+ const depMatch = line.match(/^"([^"]+)"/);
+ if (depMatch) {
+ const depRaw = depMatch[1].trim();
+ const depName = depRaw.split(" ")[0];
+ current.dependencies!.push(depName);
+ }
+ continue;
+ }
+
+ const kv = line.match(/^("?)([A-Za-z0-9_\-]+)\1\s*=\s*(.+)$/);
+ if (!kv) continue;
+ const key = kv[2];
+ const val = kv[3].trim();
+
+ if (key === "name") {
+ current.name = unquote(val);
+ } else if (key === "version") {
+ current.version = unquote(val);
+ } else if (key === "source") {
+ current.source = unquote(val);
+ } else if (key === "dependencies") {
+ if (val === "[") {
+ inDependencies = true;
+ } else if (val.startsWith("[") && val.endsWith("]")) {
+ const inner = val.slice(1, -1).trim();
+ if (inner) {
+ const list = inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
+ current.dependencies = list.map((d) => d.split(" ")[0]);
+ }
+ }
+ }
+ }
+
+ if (current && current.name && current.version) {
+ packages.push({
+ name: current.name,
+ version: current.version,
+ source: current.source,
+ dependencies: current.dependencies ?? [],
+ });
+ }
+
+ if (packages.length === 0) {
+ return { ecosystem: "cargo", deps: [] };
+ }
+
+ // Root packages are local packages without external source specification
+ const rootPackages = packages.filter((p) => !p.source);
+ const primaryRoot = rootPackages[0] ?? packages[0];
+ const rootNames = new Set(rootPackages.map((p) => p.name));
+
+ const deps: Dependency[] = [];
+ const seen = new Set();
+
+ const pushDep = (name: string, version: string) => {
+ if (seen.has(name) || rootNames.has(name)) return;
+ seen.add(name);
+ deps.push({
+ name,
+ version,
+ ecosystem: "cargo",
+ kind: "normal",
+ });
+ };
+
+ if (!includeTransitive) {
+ // Only direct dependencies of the root packages
+ const directDeps = new Set();
+ for (const r of rootPackages) {
+ for (const d of r.dependencies) {
+ directDeps.add(d);
+ }
+ }
+ for (const pkg of packages) {
+ if (directDeps.has(pkg.name)) {
+ pushDep(pkg.name, pkg.version);
+ if (deps.length >= maxRows) break;
+ }
+ }
+ } else {
+ // All unique transitive dependencies across the entire lock graph
+ for (const pkg of packages) {
+ pushDep(pkg.name, pkg.version);
+ if (deps.length >= maxRows) break;
+ }
+ }
+
+ return {
+ ecosystem: "cargo",
+ name: primaryRoot?.name,
+ deps: deps.slice(0, maxRows),
+ };
+}
+
+/**
+ * Parses package-lock.json (v1, v2, v3 formats) and optionally includes transitive dependencies.
+ * De-duplicates and caps at maxRows (default MAX_SPLIT_ROWS = 32).
+ */
+export function parsePackageLockJson(text: string, options?: ParseManifestOptions): Manifest {
+ const includeTransitive = options?.includeTransitive ?? false;
+ const maxRows = options?.maxRows ?? MAX_SPLIT_ROWS;
+
+ let j: Record;
+ try {
+ j = JSON.parse(text);
+ } catch {
+ throw new Error("package-lock.json is not valid JSON");
+ }
+
+ const rootName = typeof j.name === "string" ? j.name : undefined;
+ const deps: Dependency[] = [];
+ const seen = new Set();
+
+ const pushDep = (name: string, version: string, kind: Dependency["kind"] = "normal") => {
+ if (!name || seen.has(name) || name === rootName) return;
+ seen.add(name);
+ deps.push({
+ name,
+ version: String(version || "*"),
+ ecosystem: "npm",
+ kind,
+ });
+ };
+
+ // Modern npm lockfile format: v2 / v3 using "packages"
+ if (j.packages && typeof j.packages === "object") {
+ const pkgs = j.packages as Record>;
+ const rootPkg = pkgs[""] || {};
+ const directDeps = (rootPkg.dependencies || {}) as Record;
+ const directDevDeps = (rootPkg.devDependencies || {}) as Record;
+ const directPeerDeps = (rootPkg.peerDependencies || {}) as Record;
+
+ if (!includeTransitive) {
+ for (const [name, reqVer] of Object.entries(directDeps)) {
+ const resolved = pkgs[`node_modules/${name}`]?.version;
+ pushDep(name, String(resolved || reqVer), "normal");
+ if (deps.length >= maxRows) break;
+ }
+ for (const [name, reqVer] of Object.entries(directPeerDeps)) {
+ if (deps.length >= maxRows) break;
+ const resolved = pkgs[`node_modules/${name}`]?.version;
+ pushDep(name, String(resolved || reqVer), "peer");
+ }
+ for (const [name, reqVer] of Object.entries(directDevDeps)) {
+ if (deps.length >= maxRows) break;
+ const resolved = pkgs[`node_modules/${name}`]?.version;
+ pushDep(name, String(resolved || reqVer), "dev");
+ }
+ } else {
+ // Collect all packages from node_modules/*
+ for (const [pathKey, val] of Object.entries(pkgs)) {
+ if (!pathKey || pathKey === "" || !val || typeof val !== "object") continue;
+ const lastIndex = pathKey.lastIndexOf("node_modules/");
+ if (lastIndex === -1) continue;
+ const rawPkgName = pathKey.slice(lastIndex + "node_modules/".length);
+ if (!rawPkgName) continue;
+
+ const isDev = Boolean(val.dev);
+ const isPeer = Boolean(val.peer);
+ const kind: Dependency["kind"] = isPeer ? "peer" : isDev ? "dev" : "normal";
+ const ver = typeof val.version === "string" ? val.version : "*";
+
+ pushDep(rawPkgName, ver, kind);
+ if (deps.length >= maxRows) break;
+ }
+ }
+ } else if (j.dependencies && typeof j.dependencies === "object") {
+ // Legacy npm lockfile format: v1
+ const depsTree = j.dependencies as Record>;
+
+ const walkV1 = (tree: Record>) => {
+ for (const [name, node] of Object.entries(tree)) {
+ if (!node || typeof node !== "object") continue;
+ const ver = typeof node.version === "string" ? node.version : "*";
+ const isDev = Boolean(node.dev);
+ pushDep(name, ver, isDev ? "dev" : "normal");
+ if (deps.length >= maxRows) return;
+
+ if (includeTransitive && node.dependencies && typeof node.dependencies === "object") {
+ walkV1(node.dependencies as Record>);
+ if (deps.length >= maxRows) return;
+ }
+ }
+ };
+
+ walkV1(depsTree);
+ }
+
+ return {
+ ecosystem: "npm",
+ name: rootName,
+ deps: deps.slice(0, maxRows),
+ };
+}
+
/**
* Minimal TOML reader covering the dependency tables used in practice:
* [dependencies] foo = "1" | foo = { version = "1", ... } | foo.workspace = true