@ulinkly/setup
Advanced tools
+7
-3
@@ -9,3 +9,3 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync } from "node:fs"; | ||
| command: "npx", | ||
| args: ["-y", "@ulinkly/mcp-server@0.1.7"], | ||
| args: ["-y", "@ulinkly/mcp-server@0.1.12"], | ||
| }; | ||
@@ -43,4 +43,8 @@ | ||
| if (!config.mcpServers) config.mcpServers = {}; | ||
| } catch { | ||
| // File doesn't exist — start fresh | ||
| } catch (err) { | ||
| if (err.code === "ENOENT") { | ||
| // File doesn't exist — start fresh | ||
| } else { | ||
| console.warn(` Warning: could not parse ${redactHome(configPath)}, starting fresh`); | ||
| } | ||
| } | ||
@@ -47,0 +51,0 @@ |
+5
-4
@@ -7,3 +7,3 @@ #!/usr/bin/env node | ||
| import { fileURLToPath } from "node:url"; | ||
| import { execSync } from "node:child_process"; | ||
| import { execFileSync } from "node:child_process"; | ||
| import { commandExists, writeMcpConfig, copySkill } from "./lib.mjs"; | ||
@@ -53,4 +53,5 @@ | ||
| try { | ||
| execSync( | ||
| "claude plugin marketplace add FlywheelStudio/ulink-ai-setup", | ||
| execFileSync( | ||
| "claude", | ||
| ["plugin", "marketplace", "add", "FlywheelStudio/ulink-ai-setup"], | ||
| { stdio: "inherit" } | ||
@@ -63,3 +64,3 @@ ); | ||
| console.log(" Installing plugin..."); | ||
| execSync("claude plugin install ulink-onboarding@ulink", { | ||
| execFileSync("claude", ["plugin", "install", "ulink-onboarding@ulink"], { | ||
| stdio: "inherit", | ||
@@ -66,0 +67,0 @@ }); |
+2
-2
| { | ||
| "name": "@ulinkly/setup", | ||
| "version": "0.1.5", | ||
| "version": "0.1.6", | ||
| "description": "Set up ULink deep linking with your AI coding assistant (Claude Code, Cursor, Antigravity)", | ||
@@ -10,3 +10,3 @@ "type": "module", | ||
| "files": [ | ||
| "bin/", | ||
| "bin/*.mjs", | ||
| "skills/" | ||
@@ -13,0 +13,0 @@ ], |
+2
-2
@@ -34,3 +34,3 @@ # ULink AI Setup | ||
| "command": "npx", | ||
| "args": ["-y", "@ulinkly/mcp-server@latest"] | ||
| "args": ["-y", "@ulinkly/mcp-server@0.1.12"] | ||
| } | ||
@@ -54,3 +54,3 @@ } | ||
| "command": "npx", | ||
| "args": ["-y", "@ulinkly/mcp-server@latest"] | ||
| "args": ["-y", "@ulinkly/mcp-server@0.1.12"] | ||
| } | ||
@@ -57,0 +57,0 @@ } |
@@ -42,3 +42,3 @@ --- | ||
| "command": "npx", | ||
| "args": ["-y", "@ulinkly/mcp-server@0.1.7"] | ||
| "args": ["-y", "@ulinkly/mcp-server@0.1.12"] | ||
| } | ||
@@ -55,3 +55,3 @@ } | ||
| "command": "npx", | ||
| "args": ["-y", "@ulinkly/mcp-server@0.1.7"] | ||
| "args": ["-y", "@ulinkly/mcp-server@0.1.12"] | ||
| } | ||
@@ -136,3 +136,3 @@ } | ||
| - Project name (suggest based on detected app name) | ||
| - Default fallback URL (the URL users see if deep linking fails) | ||
| - Default fallback URL (the URL users see if deep linking fails) — **must be HTTPS** (validate it starts with `https://` and is a well-formed URL; reject `javascript:`, `data:`, `file:`, and `http://` schemes) | ||
| - **One project** — Show it and ask the user to confirm. | ||
@@ -139,0 +139,0 @@ - **Multiple projects** — List them all (name, slug, creation date) and ask the user to select one. |
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { commandExists, writeMcpConfig, copySkill, MCP_ENTRY } from "../lib.mjs"; | ||
| // ── Mock node built-ins ───────────────────────────────────────────── | ||
| vi.mock("node:fs", () => ({ | ||
| existsSync: vi.fn(), | ||
| readFileSync: vi.fn(), | ||
| writeFileSync: vi.fn(), | ||
| mkdirSync: vi.fn(), | ||
| cpSync: vi.fn(), | ||
| })); | ||
| vi.mock("node:child_process", () => ({ | ||
| execFileSync: vi.fn(), | ||
| })); | ||
| vi.mock("node:os", () => ({ | ||
| homedir: vi.fn(() => "/mock-home"), | ||
| })); | ||
| import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync } from "node:fs"; | ||
| import { execFileSync } from "node:child_process"; | ||
| beforeEach(() => { | ||
| vi.spyOn(console, "log").mockImplementation(() => {}); | ||
| }); | ||
| // ── MCP_ENTRY ─────────────────────────────────────────────────────── | ||
| describe("MCP_ENTRY", () => { | ||
| it("has a pinned version (not @latest)", () => { | ||
| expect(MCP_ENTRY).toEqual({ | ||
| command: "npx", | ||
| args: ["-y", expect.stringMatching(/^@ulinkly\/mcp-server@\d+\.\d+\.\d+$/)], | ||
| }); | ||
| // Must NOT use @latest — supply chain risk | ||
| expect(MCP_ENTRY.args[1]).not.toContain("@latest"); | ||
| }); | ||
| }); | ||
| // ── commandExists ─────────────────────────────────────────────────── | ||
| describe("commandExists", () => { | ||
| it("returns true for allowed commands when execFileSync succeeds", () => { | ||
| execFileSync.mockReturnValue(Buffer.from("")); | ||
| expect(commandExists("node")).toBe(true); | ||
| // Should use execFileSync (no shell) not execSync | ||
| expect(execFileSync).toHaveBeenCalledWith( | ||
| "which", | ||
| ["node"], | ||
| { stdio: "ignore" } | ||
| ); | ||
| }); | ||
| it("returns false when execFileSync throws", () => { | ||
| execFileSync.mockImplementation(() => { | ||
| throw new Error("not found"); | ||
| }); | ||
| expect(commandExists("ulink")).toBe(false); | ||
| }); | ||
| it("rejects commands not in the allowlist (prevents injection)", () => { | ||
| // These should be rejected without even calling execFileSync | ||
| expect(commandExists("rm")).toBe(false); | ||
| expect(commandExists("cat")).toBe(false); | ||
| expect(commandExists("; rm -rf /")).toBe(false); | ||
| expect(commandExists("node; echo pwned")).toBe(false); | ||
| expect(commandExists("")).toBe(false); | ||
| // execFileSync should NOT have been called for disallowed commands | ||
| expect(execFileSync).not.toHaveBeenCalled(); | ||
| }); | ||
| it("allows all expected commands", () => { | ||
| execFileSync.mockReturnValue(Buffer.from("")); | ||
| const allowed = ["ulink", "node", "npx", "npm", "flutter", "xcodebuild", "keytool", "curl"]; | ||
| for (const cmd of allowed) { | ||
| expect(commandExists(cmd)).toBe(true); | ||
| } | ||
| }); | ||
| }); | ||
| // ── writeMcpConfig ────────────────────────────────────────────────── | ||
| describe("writeMcpConfig", () => { | ||
| it("creates a new config when file does not exist", () => { | ||
| readFileSync.mockImplementation(() => { | ||
| throw new Error("ENOENT"); | ||
| }); | ||
| writeMcpConfig("/tmp/test/mcp.json"); | ||
| expect(mkdirSync).toHaveBeenCalledWith("/tmp/test", { recursive: true }); | ||
| expect(writeFileSync).toHaveBeenCalledWith( | ||
| "/tmp/test/mcp.json", | ||
| JSON.stringify({ mcpServers: { ulink: MCP_ENTRY } }, null, 2) + "\n" | ||
| ); | ||
| }); | ||
| it("merges into an existing config preserving other servers", () => { | ||
| const existing = { | ||
| mcpServers: { other: { command: "other-cmd", args: [] } }, | ||
| extraKey: true, | ||
| }; | ||
| readFileSync.mockReturnValue(JSON.stringify(existing)); | ||
| writeMcpConfig("/mock-home/.cursor/mcp.json"); | ||
| const written = JSON.parse(writeFileSync.mock.calls[0][1]); | ||
| expect(written.mcpServers.other).toEqual({ command: "other-cmd", args: [] }); | ||
| expect(written.mcpServers.ulink).toEqual(MCP_ENTRY); | ||
| expect(written.extraKey).toBe(true); | ||
| }); | ||
| it("redacts home directory in log output", () => { | ||
| readFileSync.mockImplementation(() => { | ||
| throw new Error("ENOENT"); | ||
| }); | ||
| writeMcpConfig("/mock-home/.cursor/mcp.json"); | ||
| // Should log with ~ instead of full home path | ||
| expect(console.log).toHaveBeenCalledWith( | ||
| " MCP config written to ~/.cursor/mcp.json" | ||
| ); | ||
| }); | ||
| it("overwrites existing ulink entry and logs updating message", () => { | ||
| const existing = { | ||
| mcpServers: { ulink: { command: "old", args: [] } }, | ||
| }; | ||
| readFileSync.mockReturnValue(JSON.stringify(existing)); | ||
| writeMcpConfig("/tmp/mcp.json"); | ||
| expect(console.log).toHaveBeenCalledWith( | ||
| " MCP server already configured, updating..." | ||
| ); | ||
| const written = JSON.parse(writeFileSync.mock.calls[0][1]); | ||
| expect(written.mcpServers.ulink).toEqual(MCP_ENTRY); | ||
| }); | ||
| it("creates parent directories recursively", () => { | ||
| readFileSync.mockImplementation(() => { | ||
| throw new Error("ENOENT"); | ||
| }); | ||
| writeMcpConfig("/deep/nested/dir/mcp.json"); | ||
| expect(mkdirSync).toHaveBeenCalledWith("/deep/nested/dir", { recursive: true }); | ||
| }); | ||
| it("adds mcpServers key if existing config lacks it", () => { | ||
| readFileSync.mockReturnValue(JSON.stringify({ someOther: "data" })); | ||
| writeMcpConfig("/tmp/mcp.json"); | ||
| const written = JSON.parse(writeFileSync.mock.calls[0][1]); | ||
| expect(written.mcpServers).toBeDefined(); | ||
| expect(written.mcpServers.ulink).toEqual(MCP_ENTRY); | ||
| expect(written.someOther).toBe("data"); | ||
| }); | ||
| }); | ||
| // ── copySkill ─────────────────────────────────────────────────────── | ||
| describe("copySkill", () => { | ||
| it("copies recursively when source exists", () => { | ||
| existsSync.mockReturnValue(true); | ||
| copySkill("/mock-home/skills", "/source/skills"); | ||
| expect(mkdirSync).toHaveBeenCalledWith("/mock-home/skills", { recursive: true }); | ||
| expect(cpSync).toHaveBeenCalledWith("/source/skills", "/mock-home/skills", { | ||
| recursive: true, | ||
| }); | ||
| // Should redact home directory in log output | ||
| expect(console.log).toHaveBeenCalledWith( | ||
| " Skill installed to ~/skills" | ||
| ); | ||
| }); | ||
| it("creates destination directory before copying", () => { | ||
| existsSync.mockReturnValue(true); | ||
| copySkill("/new/dir/skills", "/source/skills"); | ||
| // mkdirSync should be called before cpSync | ||
| const mkdirOrder = mkdirSync.mock.invocationCallOrder[0]; | ||
| const cpOrder = cpSync.mock.invocationCallOrder[0]; | ||
| expect(mkdirOrder).toBeLessThan(cpOrder); | ||
| }); | ||
| it("logs warning and skips when source is missing", () => { | ||
| existsSync.mockReturnValue(false); | ||
| copySkill("/dest/skills", "/missing/source"); | ||
| expect(console.log).toHaveBeenCalledWith( | ||
| " Warning: skill source not found, skipping skill install" | ||
| ); | ||
| expect(mkdirSync).not.toHaveBeenCalled(); | ||
| expect(cpSync).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
2
-33.33%2
-33.33%31645
-17.56%5
-16.67%276
-36.41%