From 868f63d5567b452d335b749e6f08b091ddedaf8d Mon Sep 17 00:00:00 2001 From: root Date: Sat, 27 Jun 2026 08:53:55 +0000 Subject: [PATCH 1/3] tests(#468): 37 unit tests for cli/plugins-update.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export diffCatalogs, readLocalCatalog, writeLocalCatalog, downloadTarball, and extractPluginsFromTarball for direct testing. Test coverage: - diffCatalogs (7): null/empty local → all added, identical checksums → unchanged, stale checksum → changed, mixed add+change+unchanged - readLocalCatalog (6): missing file, valid JSON, invalid JSON, null/non-object parsed value, fs.readFileSync throws - writeLocalCatalog (4): creates dir when missing, skips mkdirSync when dir exists, writes to correct path, trailing newline - downloadTarball (5): spawnSync error, non-zero exit, missing archive, zero-byte archive, success path - extractPluginsFromTarball (7): creates destDir, correct tar args, tolerates exit 2, throws on exit 1, throws on spawnSync error, batches >200 into 2 calls, 200 uses 1 call - updatePlugins (8): check=true skips download, up-to-date skips download, force=true ignores local catalog, stale catalog triggers full update+write, tmpDir cleaned via finally even on error, remote_count in result, fetch failure throws, invalid catalog format throws Co-Authored-By: Claude Sonnet 4.6 --- __tests__/plugins-update.test.js | 448 +++++++++++++++++++++++++++++++ cli/plugins-update.js | 9 +- 2 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 __tests__/plugins-update.test.js diff --git a/__tests__/plugins-update.test.js b/__tests__/plugins-update.test.js new file mode 100644 index 000000000..2efa62168 --- /dev/null +++ b/__tests__/plugins-update.test.js @@ -0,0 +1,448 @@ +"use strict" + +const fs = require("fs") +const path = require("path") +const os = require("os") +const child_process = require("child_process") + +jest.mock("fs") +jest.mock("os", () => ({ + homedir: jest.fn(() => "/home/user"), + tmpdir: jest.fn(() => "/tmp"), +})) +jest.mock("child_process") +jest.mock("../cli/plugins-store", () => ({ + REMOTE_BUNDLED_DIR: "/home/user/.supercli/plugins/bundled", + REMOTE_CATALOG_FILE: "/home/user/.supercli/plugins/remote-catalog.json", +})) + +const { + diffCatalogs, + readLocalCatalog, + writeLocalCatalog, + downloadTarball, + extractPluginsFromTarball, + updatePlugins, +} = require("../cli/plugins-update") + +const CATALOG_FILE = "/home/user/.supercli/plugins/remote-catalog.json" +const BUNDLED_DIR = "/home/user/.supercli/plugins/bundled" + +beforeEach(() => { + jest.resetAllMocks() + os.homedir.mockReturnValue("/home/user") + os.tmpdir.mockReturnValue("/tmp") +}) + +// --------------------------------------------------------------------------- +// diffCatalogs — pure checksum comparison +// --------------------------------------------------------------------------- + +describe("diffCatalogs", () => { + const remote = { + plugins: [ + { name: "alpha", checksum: "aaa" }, + { name: "beta", checksum: "bbb" }, + { name: "gamma", checksum: "ccc" }, + ], + } + + test("null local → all remote plugins are added", () => { + const result = diffCatalogs(null, remote) + expect(result.added).toEqual(["alpha", "beta", "gamma"]) + expect(result.changed).toEqual([]) + expect(result.unchanged).toEqual([]) + }) + + test("empty local plugins array → all remote plugins are added", () => { + const result = diffCatalogs({ plugins: [] }, remote) + expect(result.added).toEqual(["alpha", "beta", "gamma"]) + expect(result.changed).toEqual([]) + expect(result.unchanged).toEqual([]) + }) + + test("local catalog with identical checksums → all unchanged", () => { + const local = { plugins: [...remote.plugins] } + const result = diffCatalogs(local, remote) + expect(result.added).toEqual([]) + expect(result.changed).toEqual([]) + expect(result.unchanged).toEqual(["alpha", "beta", "gamma"]) + }) + + test("stale checksum → plugin reported as changed", () => { + const local = { + plugins: [ + { name: "alpha", checksum: "OLD" }, + { name: "beta", checksum: "bbb" }, + { name: "gamma", checksum: "ccc" }, + ], + } + const result = diffCatalogs(local, remote) + expect(result.changed).toEqual(["alpha"]) + expect(result.unchanged).toEqual(["beta", "gamma"]) + expect(result.added).toEqual([]) + }) + + test("mix: one added, one changed, one unchanged", () => { + const local = { + plugins: [ + { name: "beta", checksum: "OLD" }, + { name: "gamma", checksum: "ccc" }, + ], + } + const result = diffCatalogs(local, remote) + expect(result.added).toEqual(["alpha"]) + expect(result.changed).toEqual(["beta"]) + expect(result.unchanged).toEqual(["gamma"]) + }) + + test("local with no plugins key → all remote plugins added", () => { + const result = diffCatalogs({}, remote) + expect(result.added).toEqual(["alpha", "beta", "gamma"]) + }) + + test("remote with empty plugins array → everything empty", () => { + const local = { plugins: [{ name: "alpha", checksum: "aaa" }] } + const result = diffCatalogs(local, { plugins: [] }) + expect(result.added).toEqual([]) + expect(result.changed).toEqual([]) + expect(result.unchanged).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// readLocalCatalog — reads catalog from disk +// --------------------------------------------------------------------------- + +describe("readLocalCatalog", () => { + test("returns null when catalog file does not exist", () => { + fs.existsSync.mockReturnValue(false) + expect(readLocalCatalog()).toBeNull() + expect(fs.readFileSync).not.toHaveBeenCalled() + }) + + test("returns parsed catalog when file exists and is valid", () => { + const catalog = { plugins: [{ name: "p1", checksum: "x" }] } + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue(JSON.stringify(catalog)) + expect(readLocalCatalog()).toEqual(catalog) + }) + + test("returns null when file contains invalid JSON", () => { + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue("NOT JSON {{{") + expect(readLocalCatalog()).toBeNull() + }) + + test("returns null when parsed value is null", () => { + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue("null") + expect(readLocalCatalog()).toBeNull() + }) + + test("returns null when parsed value is not an object (e.g. a number)", () => { + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue("42") + expect(readLocalCatalog()).toBeNull() + }) + + test("returns null when fs.readFileSync throws", () => { + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockImplementation(() => { throw new Error("EACCES") }) + expect(readLocalCatalog()).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// writeLocalCatalog — persists catalog to disk +// --------------------------------------------------------------------------- + +describe("writeLocalCatalog", () => { + const catalogDir = path.dirname(CATALOG_FILE) + const catalog = { plugins: [{ name: "p1", checksum: "abc" }] } + + test("creates directory when it does not exist", () => { + fs.existsSync.mockReturnValue(false) + writeLocalCatalog(catalog) + expect(fs.mkdirSync).toHaveBeenCalledWith(catalogDir, { recursive: true }) + }) + + test("does not call mkdirSync when directory already exists", () => { + fs.existsSync.mockReturnValue(true) + writeLocalCatalog(catalog) + expect(fs.mkdirSync).not.toHaveBeenCalled() + }) + + test("writes JSON to the catalog file path", () => { + fs.existsSync.mockReturnValue(true) + writeLocalCatalog(catalog) + expect(fs.writeFileSync).toHaveBeenCalledWith( + CATALOG_FILE, + expect.stringContaining('"name": "p1"') + ) + }) + + test("written JSON ends with a newline", () => { + fs.existsSync.mockReturnValue(true) + writeLocalCatalog(catalog) + const written = fs.writeFileSync.mock.calls[0][1] + expect(written.endsWith("\n")).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// downloadTarball — curls the archive into tmpDir +// --------------------------------------------------------------------------- + +describe("downloadTarball", () => { + const tmpDir = "/tmp/supercli-update-xyz" + const tarPath = path.join(tmpDir, "supercli-master.tar.gz") + + test("throws integration_error when spawnSync returns an error", () => { + child_process.spawnSync.mockReturnValue({ error: new Error("curl not found"), status: null }) + expect(() => downloadTarball(tmpDir)).toThrow("Failed to download plugin archive") + const err = (() => { try { downloadTarball(tmpDir) } catch (e) { return e } })() + expect(err.code).toBe(105) + expect(err.type).toBe("integration_error") + expect(err.recoverable).toBe(true) + }) + + test("throws when curl exits with non-zero status", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 7, stderr: "connection refused" }) + expect(() => downloadTarball(tmpDir)).toThrow("curl exited with status 7") + const err = (() => { try { downloadTarball(tmpDir) } catch (e) { return e } })() + expect(err.code).toBe(105) + }) + + test("throws when downloaded archive does not exist", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 0, stderr: "" }) + fs.existsSync.mockReturnValue(false) + expect(() => downloadTarball(tmpDir)).toThrow("Downloaded archive is empty") + }) + + test("throws when downloaded archive is zero bytes", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 0, stderr: "" }) + fs.existsSync.mockReturnValue(true) + fs.statSync.mockReturnValue({ size: 0 }) + expect(() => downloadTarball(tmpDir)).toThrow("Downloaded archive is empty") + }) + + test("returns tarPath on success", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 0, stderr: "" }) + fs.existsSync.mockReturnValue(true) + fs.statSync.mockReturnValue({ size: 12345 }) + expect(downloadTarball(tmpDir)).toBe(tarPath) + }) +}) + +// --------------------------------------------------------------------------- +// extractPluginsFromTarball — calls tar to unpack plugin dirs +// --------------------------------------------------------------------------- + +describe("extractPluginsFromTarball", () => { + const tarPath = "/tmp/supercli-master.tar.gz" + const destDir = BUNDLED_DIR + + beforeEach(() => { + fs.existsSync.mockReturnValue(false) // dest dir doesn't exist yet + }) + + test("creates destDir when it does not exist", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 0 }) + extractPluginsFromTarball(tarPath, ["plugin-a"], destDir) + expect(fs.mkdirSync).toHaveBeenCalledWith(destDir, { recursive: true }) + }) + + test("calls tar with correct args for a single plugin", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 0 }) + fs.existsSync.mockReturnValue(true) + extractPluginsFromTarball(tarPath, ["my-plugin"], destDir) + const call = child_process.spawnSync.mock.calls[0] + expect(call[0]).toBe("tar") + expect(call[1]).toContain("-xzf") + expect(call[1]).toContain(tarPath) + expect(call[1]).toContain("supercli-master/plugins/my-plugin") + }) + + test("tolerates tar exit code 2 (some paths not found in archive)", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 2, stderr: "not found" }) + fs.existsSync.mockReturnValue(true) + expect(() => extractPluginsFromTarball(tarPath, ["missing-plugin"], destDir)).not.toThrow() + }) + + test("throws integration_error when tar exits with status other than 0 or 2", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 1, stderr: "permission denied" }) + fs.existsSync.mockReturnValue(true) + expect(() => extractPluginsFromTarball(tarPath, ["p"], destDir)).toThrow("tar exited with status 1") + const err = (() => { try { extractPluginsFromTarball(tarPath, ["p"], destDir) } catch (e) { return e } })() + expect(err.code).toBe(105) + expect(err.type).toBe("integration_error") + }) + + test("throws when spawnSync returns an error object", () => { + child_process.spawnSync.mockReturnValue({ error: new Error("tar not found"), status: null }) + fs.existsSync.mockReturnValue(true) + expect(() => extractPluginsFromTarball(tarPath, ["p"], destDir)).toThrow("Failed to extract plugin archive") + }) + + test("batches >200 plugins into multiple spawnSync calls", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 0 }) + fs.existsSync.mockReturnValue(true) + const plugins = Array.from({ length: 201 }, (_, i) => `plugin-${i}`) + extractPluginsFromTarball(tarPath, plugins, destDir) + expect(child_process.spawnSync).toHaveBeenCalledTimes(2) + }) + + test("exactly 200 plugins uses a single spawnSync call", () => { + child_process.spawnSync.mockReturnValue({ error: null, status: 0 }) + fs.existsSync.mockReturnValue(true) + const plugins = Array.from({ length: 200 }, (_, i) => `plugin-${i}`) + extractPluginsFromTarball(tarPath, plugins, destDir) + expect(child_process.spawnSync).toHaveBeenCalledTimes(1) + }) +}) + +// --------------------------------------------------------------------------- +// updatePlugins — main orchestrator +// --------------------------------------------------------------------------- + +describe("updatePlugins", () => { + const remoteCatalog = { + plugins: [ + { name: "alpha", checksum: "aaa" }, + { name: "beta", checksum: "bbb" }, + ], + } + + function mockFetch(catalog, ok = true) { + global.fetch = jest.fn().mockResolvedValue({ + ok, + status: ok ? 200 : 500, + statusText: ok ? "OK" : "Internal Server Error", + json: jest.fn().mockResolvedValue(catalog), + }) + } + + afterEach(() => { + delete global.fetch + }) + + test("check=true returns result without downloading or writing", async () => { + mockFetch(remoteCatalog) + // local catalog has stale checksum → would trigger update without check + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue(JSON.stringify({ plugins: [{ name: "alpha", checksum: "OLD" }] })) + + const result = await updatePlugins({ check: true }) + + expect(result.check_only).toBe(true) + expect(result.changed).toBe(1) + expect(child_process.spawnSync).not.toHaveBeenCalled() + expect(fs.writeFileSync).not.toHaveBeenCalled() + }) + + test("up_to_date catalog returns without downloading", async () => { + mockFetch(remoteCatalog) + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue(JSON.stringify(remoteCatalog)) + + const result = await updatePlugins() + + expect(result.up_to_date).toBe(true) + expect(result.added).toBe(0) + expect(result.changed).toBe(0) + expect(child_process.spawnSync).not.toHaveBeenCalled() + }) + + test("force=true ignores local catalog and updates all plugins", async () => { + mockFetch(remoteCatalog) + // local catalog matches → normally would be unchanged + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue(JSON.stringify(remoteCatalog)) + fs.mkdtempSync = jest.fn().mockReturnValue("/tmp/supercli-update-abc") + child_process.spawnSync.mockReturnValue({ error: null, status: 0, stderr: "" }) + fs.statSync.mockReturnValue({ size: 99999 }) + fs.rmSync = jest.fn() + + const result = await updatePlugins({ force: true }) + + // force ignores local → all are added + expect(result.added).toBe(2) + expect(result.updated).toEqual(["alpha", "beta"]) + }) + + test("stale catalog triggers download, extract, and catalog write", async () => { + mockFetch(remoteCatalog) + // local has stale alpha checksum + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue( + JSON.stringify({ plugins: [{ name: "alpha", checksum: "OLD" }, { name: "beta", checksum: "bbb" }] }) + ) + fs.mkdtempSync = jest.fn().mockReturnValue("/tmp/supercli-update-abc") + child_process.spawnSync.mockReturnValue({ error: null, status: 0, stderr: "" }) + fs.statSync.mockReturnValue({ size: 9999 }) + fs.rmSync = jest.fn() + + const result = await updatePlugins() + + expect(result.changed).toBe(1) + expect(result.updated).toEqual(["alpha"]) + expect(result.extracted).toBe(1) + // catalog must be written with the new remote catalog + expect(fs.writeFileSync).toHaveBeenCalledWith( + CATALOG_FILE, + expect.stringContaining('"aaa"') + ) + }) + + test("tmpDir is cleaned up (rmSync) even when extraction throws", async () => { + mockFetch(remoteCatalog) + fs.existsSync.mockImplementation((p) => p !== CATALOG_FILE) + fs.readFileSync.mockReturnValue("{}") + fs.mkdtempSync = jest.fn().mockReturnValue("/tmp/supercli-update-fail") + // curl succeeds but tar fails + child_process.spawnSync + .mockReturnValueOnce({ error: null, status: 0, stderr: "" }) // curl + .mockReturnValueOnce({ error: null, status: 1, stderr: "bad archive" }) // tar + fs.statSync.mockReturnValue({ size: 1 }) + fs.rmSync = jest.fn() + + await expect(updatePlugins()).rejects.toThrow() + expect(fs.rmSync).toHaveBeenCalledWith( + "/tmp/supercli-update-fail", + { recursive: true, force: true } + ) + }) + + test("result includes remote_count matching remote catalog plugins length", async () => { + mockFetch(remoteCatalog) + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue(JSON.stringify(remoteCatalog)) + + const result = await updatePlugins() + + expect(result.remote_count).toBe(2) + }) + + test("throws when remote catalog fetch fails", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + }) + fs.existsSync.mockReturnValue(false) + await expect(updatePlugins()).rejects.toThrow("Failed to fetch plugin catalog") + const err = await updatePlugins().catch(e => e) + expect(err.code).toBe(105) + }) + + test("throws when remote catalog has invalid format", async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ plugins: "not-an-array" }), + }) + fs.existsSync.mockReturnValue(false) + await expect(updatePlugins()).rejects.toThrow("Invalid remote catalog format") + }) +}) diff --git a/cli/plugins-update.js b/cli/plugins-update.js index 32e320fc7..6bbe1270a 100644 --- a/cli/plugins-update.js +++ b/cli/plugins-update.js @@ -201,4 +201,11 @@ async function updatePlugins(options = {}) { return result } -module.exports = { updatePlugins } +module.exports = { + updatePlugins, + diffCatalogs, + readLocalCatalog, + writeLocalCatalog, + downloadTarball, + extractPluginsFromTarball, +} From b0971e585f60ea4d2e4cdf621180660f415979b5 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 27 Jun 2026 10:51:36 +0000 Subject: [PATCH 2/3] tests(#474): 50 unit tests for cli/commands-handler.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all four exported functions: - handleCommandsQuery: guard clauses (null config, invalid limit/offset), namespace/resource/action/query filters, pagination (limit, offset, auto-limit), _warning for truncated auto-limit, human mode (outputHumanTable), args * formatting, and exception catch → outputError code 110 - handleInspect: positional length guard, unknown command (code 92), full spec structure (side_effects, risk_level, input_schema required array, defaults), human mode console.log path - handleSchema: input_schema property/required mapping, type defaulting, output_schema default and override, empty-args edge case - handleNamespaceBrowse: namespace browse (1 positional), resource browse (2 positionals), unknown namespace/resource errors (code 92), human mode All 50 tests pass in 0.265s. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/commands-handler.test.js | 536 +++++++++++++++++++++++++++++ 1 file changed, 536 insertions(+) create mode 100644 __tests__/commands-handler.test.js diff --git a/__tests__/commands-handler.test.js b/__tests__/commands-handler.test.js new file mode 100644 index 000000000..5afa9827b --- /dev/null +++ b/__tests__/commands-handler.test.js @@ -0,0 +1,536 @@ +"use strict" + +const { + handleCommandsQuery, + handleInspect, + handleSchema, + handleNamespaceBrowse, +} = require("../cli/commands-handler") + +// ── helpers ────────────────────────────────────────────────────────────────── + +function makeIo(overrides = {}) { + return { + humanMode: false, + output: jest.fn(), + outputError: jest.fn(), + outputHumanTable: jest.fn(), + ...overrides, + } +} + +function makeConfig(commands = []) { + return { commands } +} + +function makeCmd(overrides = {}) { + return { + namespace: "ns", + resource: "res", + action: "act", + description: "desc", + adapter: "http", + args: [], + ...overrides, + } +} + +// ── handleCommandsQuery ─────────────────────────────────────────────────────── + +describe("handleCommandsQuery", () => { + describe("guard: invalid config", () => { + test("emits error code 110 when config is null", () => { + const io = makeIo() + handleCommandsQuery(null, {}, io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 110, type: "internal_error" }) + ) + expect(io.output).not.toHaveBeenCalled() + }) + + test("emits error code 110 when config.commands is missing", () => { + const io = makeIo() + handleCommandsQuery({}, {}, io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 110 }) + ) + }) + }) + + describe("guard: invalid --limit", () => { + test("rejects zero limit", () => { + const io = makeIo() + handleCommandsQuery(makeConfig(), { limit: 0 }, io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 85, type: "invalid_argument" }) + ) + }) + + test("rejects negative limit", () => { + const io = makeIo() + handleCommandsQuery(makeConfig(), { limit: -5 }, io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 85 }) + ) + }) + + test("rejects non-integer limit (float)", () => { + const io = makeIo() + handleCommandsQuery(makeConfig(), { limit: 2.5 }, io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 85 }) + ) + }) + + test("rejects non-numeric limit string", () => { + const io = makeIo() + handleCommandsQuery(makeConfig(), { limit: "abc" }, io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 85 }) + ) + }) + + test("accepts valid positive integer limit", () => { + const io = makeIo() + handleCommandsQuery(makeConfig([makeCmd()]), { limit: 5 }, io) + expect(io.outputError).not.toHaveBeenCalled() + expect(io.output).toHaveBeenCalled() + }) + }) + + describe("guard: invalid --offset", () => { + test("rejects negative offset", () => { + const io = makeIo() + handleCommandsQuery(makeConfig(), { offset: -1 }, io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 85, type: "invalid_argument" }) + ) + }) + + test("rejects float offset", () => { + const io = makeIo() + handleCommandsQuery(makeConfig(), { offset: 1.5 }, io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 85 }) + ) + }) + + test("accepts zero offset", () => { + const io = makeIo() + handleCommandsQuery(makeConfig([makeCmd()]), { offset: 0 }, io) + expect(io.outputError).not.toHaveBeenCalled() + }) + + test("accepts positive integer offset", () => { + const io = makeIo() + handleCommandsQuery(makeConfig([makeCmd()]), { offset: 1 }, io) + expect(io.outputError).not.toHaveBeenCalled() + }) + }) + + describe("filtering", () => { + const cmds = [ + makeCmd({ namespace: "git", resource: "repo", action: "clone", adapter: "shell", description: "clone a repo" }), + makeCmd({ namespace: "git", resource: "repo", action: "push", adapter: "shell", description: "push changes" }), + makeCmd({ namespace: "k8s", resource: "pod", action: "list", adapter: "http", description: "list pods" }), + ] + const config = makeConfig(cmds) + + test("no filters returns all commands", () => { + const io = makeIo() + handleCommandsQuery(config, { limit: 100 }, io) + const result = io.output.mock.calls[0][0] + expect(result.total).toBe(3) + }) + + test("--namespace filters by exact namespace (case-insensitive)", () => { + const io = makeIo() + handleCommandsQuery(config, { namespace: "GIT", limit: 100 }, io) + const result = io.output.mock.calls[0][0] + expect(result.total).toBe(2) + result.commands.forEach((c) => expect(c.namespace).toBe("git")) + }) + + test("--resource filters by exact resource", () => { + const io = makeIo() + handleCommandsQuery(config, { resource: "pod", limit: 100 }, io) + const result = io.output.mock.calls[0][0] + expect(result.total).toBe(1) + expect(result.commands[0].action).toBe("list") + }) + + test("--action filters by exact action", () => { + const io = makeIo() + handleCommandsQuery(config, { action: "clone", limit: 100 }, io) + const result = io.output.mock.calls[0][0] + expect(result.total).toBe(1) + }) + + test("--query filters by substring in command string", () => { + const io = makeIo() + handleCommandsQuery(config, { query: "push", limit: 100 }, io) + const result = io.output.mock.calls[0][0] + expect(result.total).toBe(1) + expect(result.commands[0].action).toBe("push") + }) + + test("--query matches against description", () => { + const io = makeIo() + handleCommandsQuery(config, { query: "pods", limit: 100 }, io) + const result = io.output.mock.calls[0][0] + expect(result.total).toBe(1) + expect(result.commands[0].namespace).toBe("k8s") + }) + + test("combined namespace + action filter narrows to one result", () => { + const io = makeIo() + handleCommandsQuery(config, { namespace: "git", action: "push", limit: 100 }, io) + const result = io.output.mock.calls[0][0] + expect(result.total).toBe(1) + }) + + test("filter with no match returns empty commands array", () => { + const io = makeIo() + handleCommandsQuery(config, { namespace: "does-not-exist", limit: 100 }, io) + const result = io.output.mock.calls[0][0] + expect(result.total).toBe(0) + expect(result.commands).toEqual([]) + }) + }) + + describe("pagination", () => { + const manyCommands = Array.from({ length: 10 }, (_, i) => + makeCmd({ namespace: "ns", resource: "res", action: `act${i}` }) + ) + const config = makeConfig(manyCommands) + + test("applies explicit limit", () => { + const io = makeIo() + handleCommandsQuery(config, { limit: 3 }, io) + const result = io.output.mock.calls[0][0] + expect(result.returned).toBe(3) + expect(result.commands).toHaveLength(3) + }) + + test("applies explicit offset", () => { + const io = makeIo() + handleCommandsQuery(config, { limit: 3, offset: 7 }, io) + const result = io.output.mock.calls[0][0] + expect(result.returned).toBe(3) + expect(result.offset).toBe(7) + }) + + test("offset beyond total returns empty", () => { + const io = makeIo() + handleCommandsQuery(config, { limit: 5, offset: 20 }, io) + const result = io.output.mock.calls[0][0] + expect(result.returned).toBe(0) + }) + + test("auto-limit of 50 applied in non-human mode when limit not set", () => { + const bigConfig = makeConfig( + Array.from({ length: 60 }, (_, i) => makeCmd({ action: `act${i}` })) + ) + const io = makeIo() + handleCommandsQuery(bigConfig, {}, io) + const result = io.output.mock.calls[0][0] + expect(result.returned).toBe(50) + expect(result._warning).toMatch(/--limit 50/) + }) + + test("no _warning when all results fit within auto-limit", () => { + const io = makeIo() + handleCommandsQuery(config, {}, io) // 10 cmds < 50 + const result = io.output.mock.calls[0][0] + expect(result._warning).toBeUndefined() + }) + + test("filters object reflects applied filter values", () => { + const io = makeIo() + handleCommandsQuery(makeConfig([makeCmd()]), { namespace: "ns", limit: 10 }, io) + const result = io.output.mock.calls[0][0] + expect(result.filters.namespace).toBe("ns") + expect(result.filters.limit).toBe(10) + }) + }) + + describe("human mode", () => { + test("calls outputHumanTable instead of output", () => { + const io = makeIo({ humanMode: true }) + handleCommandsQuery(makeConfig([makeCmd()]), {}, io) + expect(io.outputHumanTable).toHaveBeenCalled() + expect(io.output).not.toHaveBeenCalled() + }) + + test("no auto-limit in human mode", () => { + const bigConfig = makeConfig( + Array.from({ length: 60 }, (_, i) => makeCmd({ action: `act${i}` })) + ) + const io = makeIo({ humanMode: true }) + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}) + handleCommandsQuery(bigConfig, {}, io) + expect(io.outputHumanTable.mock.calls[0][0]).toHaveLength(60) + logSpy.mockRestore() + }) + }) + + describe("args formatting", () => { + test("required args are marked with * in output", () => { + const cmd = makeCmd({ + args: [ + { name: "url", required: true }, + { name: "token", required: false }, + ], + }) + const io = makeIo() + handleCommandsQuery(makeConfig([cmd]), { limit: 10 }, io) + const row = io.output.mock.calls[0][0].commands[0] + expect(row.args).toContain("--url*") + expect(row.args).toContain("--token") + expect(row.args).not.toMatch(/--token\*/) + }) + }) + + describe("exception handling", () => { + test("catches thrown errors and emits outputError code 110", () => { + const io = makeIo() + const badConfig = { + get commands() { throw new Error("boom") }, + } + handleCommandsQuery(badConfig, {}, io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 110, type: "internal_error" }) + ) + }) + }) +}) + +// ── handleInspect ───────────────────────────────────────────────────────────── + +describe("handleInspect", () => { + const cmd = makeCmd({ + namespace: "git", + resource: "repo", + action: "clone", + description: "clone a repo", + adapter: "shell", + adapterConfig: { cmd: "git clone" }, + mutation: true, + risk_level: "medium", + args: [ + { name: "url", type: "string", required: true }, + { name: "dir", type: "string", required: false }, + ], + }) + const config = makeConfig([cmd]) + + test("emits error code 85 when positional has fewer than 4 elements", () => { + const io = makeIo() + handleInspect(config, ["inspect", "git", "repo"], io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 85, type: "invalid_argument" }) + ) + }) + + test("emits error code 92 when command not found", () => { + const io = makeIo() + handleInspect(config, ["inspect", "git", "repo", "push"], io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 92, type: "resource_not_found" }) + ) + }) + + test("returns full spec in JSON mode", () => { + const io = makeIo() + handleInspect(config, ["inspect", "git", "repo", "clone"], io) + expect(io.output).toHaveBeenCalled() + const spec = io.output.mock.calls[0][0] + expect(spec.version).toBe("1.0") + expect(spec.command).toBe("git.repo.clone") + expect(spec.namespace).toBe("git") + expect(spec.resource).toBe("repo") + expect(spec.action).toBe("clone") + expect(spec.adapter).toBe("shell") + expect(spec.side_effects).toBe(true) + expect(spec.risk_level).toBe("medium") + }) + + test("input_schema required array contains required arg names", () => { + const io = makeIo() + handleInspect(config, ["inspect", "git", "repo", "clone"], io) + const spec = io.output.mock.calls[0][0] + expect(spec.input_schema.required).toEqual(["url"]) + expect(spec.input_schema.properties).toHaveProperty("url") + expect(spec.input_schema.properties).toHaveProperty("dir") + }) + + test("side_effects is false when mutation is falsy", () => { + const noMutCmd = makeCmd({ namespace: "git", resource: "repo", action: "clone" }) + const io = makeIo() + handleInspect(makeConfig([noMutCmd]), ["inspect", "git", "repo", "clone"], io) + expect(io.output.mock.calls[0][0].side_effects).toBe(false) + }) + + test("risk_level defaults to 'safe' when not set", () => { + const safeCmd = makeCmd({ namespace: "git", resource: "repo", action: "clone" }) + const io = makeIo() + handleInspect(makeConfig([safeCmd]), ["inspect", "git", "repo", "clone"], io) + expect(io.output.mock.calls[0][0].risk_level).toBe("safe") + }) + + test("human mode calls console.log and does NOT call output", () => { + const io = makeIo({ humanMode: true }) + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}) + handleInspect(config, ["inspect", "git", "repo", "clone"], io) + expect(logSpy).toHaveBeenCalled() + expect(io.output).not.toHaveBeenCalled() + logSpy.mockRestore() + }) +}) + +// ── handleSchema ────────────────────────────────────────────────────────────── + +describe("handleSchema", () => { + test("returns version 1.0 schema object", () => { + const io = makeIo() + const cmd = makeCmd({ + args: [{ name: "url", type: "string", required: true }], + output: { type: "array" }, + }) + handleSchema(cmd, ["git", "repo", "clone"], io) + const schema = io.output.mock.calls[0][0] + expect(schema.version).toBe("1.0") + expect(schema.command).toBe("git.repo.clone") + }) + + test("input_schema.properties maps arg names to types", () => { + const io = makeIo() + const cmd = makeCmd({ + args: [ + { name: "url", type: "string", required: true }, + { name: "depth", type: "number", required: false }, + ], + }) + handleSchema(cmd, ["ns", "res", "act"], io) + const { input_schema } = io.output.mock.calls[0][0] + expect(input_schema.properties.url).toEqual({ type: "string" }) + expect(input_schema.properties.depth).toEqual({ type: "number" }) + }) + + test("input_schema.required lists only required args", () => { + const io = makeIo() + const cmd = makeCmd({ + args: [ + { name: "url", type: "string", required: true }, + { name: "depth", type: "number", required: false }, + ], + }) + handleSchema(cmd, ["ns", "res", "act"], io) + expect(io.output.mock.calls[0][0].input_schema.required).toEqual(["url"]) + }) + + test("args default to type 'string' when not specified", () => { + const io = makeIo() + const cmd = makeCmd({ args: [{ name: "x" }] }) + handleSchema(cmd, ["ns", "res", "act"], io) + expect(io.output.mock.calls[0][0].input_schema.properties.x).toEqual({ type: "string" }) + }) + + test("output_schema defaults to { type: 'object' } when cmd.output is missing", () => { + const io = makeIo() + const cmd = makeCmd() + handleSchema(cmd, ["ns", "res", "act"], io) + expect(io.output.mock.calls[0][0].output_schema).toEqual({ type: "object" }) + }) + + test("uses cmd.output when provided", () => { + const io = makeIo() + const cmd = makeCmd({ output: { type: "array", items: { type: "string" } } }) + handleSchema(cmd, ["ns", "res", "act"], io) + expect(io.output.mock.calls[0][0].output_schema).toEqual({ type: "array", items: { type: "string" } }) + }) + + test("handles cmd with no args (empty input_schema)", () => { + const io = makeIo() + const cmd = makeCmd({ args: [] }) + handleSchema(cmd, ["ns", "res", "act"], io) + const { input_schema } = io.output.mock.calls[0][0] + expect(input_schema.properties).toEqual({}) + expect(input_schema.required).toEqual([]) + }) +}) + +// ── handleNamespaceBrowse ───────────────────────────────────────────────────── + +describe("handleNamespaceBrowse", () => { + const cmds = [ + makeCmd({ namespace: "git", resource: "repo", action: "clone" }), + makeCmd({ namespace: "git", resource: "repo", action: "push" }), + makeCmd({ namespace: "git", resource: "tag", action: "list" }), + makeCmd({ namespace: "k8s", resource: "pod", action: "get" }), + ] + const config = makeConfig(cmds) + + describe("positional length === 1 (namespace browse)", () => { + test("returns unique resources for a known namespace", () => { + const io = makeIo() + handleNamespaceBrowse(config, ["git"], io) + const result = io.output.mock.calls[0][0] + expect(result.namespace).toBe("git") + expect(result.resources).toEqual(expect.arrayContaining(["repo", "tag"])) + expect(result.resources).toHaveLength(2) + }) + + test("emits error code 92 for unknown namespace", () => { + const io = makeIo() + handleNamespaceBrowse(config, ["unknown"], io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 92, type: "resource_not_found" }) + ) + }) + + test("human mode logs to console and does NOT call output", () => { + const io = makeIo({ humanMode: true }) + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}) + handleNamespaceBrowse(config, ["git"], io) + expect(logSpy).toHaveBeenCalled() + expect(io.output).not.toHaveBeenCalled() + logSpy.mockRestore() + }) + }) + + describe("positional length === 2 (resource browse)", () => { + test("returns actions for a known namespace.resource", () => { + const io = makeIo() + handleNamespaceBrowse(config, ["git", "repo"], io) + const result = io.output.mock.calls[0][0] + expect(result.namespace).toBe("git") + expect(result.resource).toBe("repo") + expect(result.actions).toEqual(expect.arrayContaining(["clone", "push"])) + }) + + test("emits error code 92 for unknown resource", () => { + const io = makeIo() + handleNamespaceBrowse(config, ["git", "notaresource"], io) + expect(io.outputError).toHaveBeenCalledWith( + expect.objectContaining({ code: 92, type: "resource_not_found" }) + ) + }) + + test("human mode logs to console and does NOT call output", () => { + const io = makeIo({ humanMode: true }) + const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}) + handleNamespaceBrowse(config, ["git", "repo"], io) + expect(logSpy).toHaveBeenCalled() + expect(io.output).not.toHaveBeenCalled() + logSpy.mockRestore() + }) + + test("error suggestion references namespace when resource not found", () => { + const io = makeIo() + handleNamespaceBrowse(config, ["git", "notaresource"], io) + const err = io.outputError.mock.calls[0][0] + expect(err.suggestions[0]).toContain("git") + }) + }) +}) From e0e9b9399a82692b02114e8034a490770cad6eba Mon Sep 17 00:00:00 2001 From: root Date: Sat, 27 Jun 2026 12:51:53 +0000 Subject: [PATCH 3/3] tests(#476): add cli/env.js with 41 unit tests for env-var resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create cli/env.js to centralise all SUPERCLI_* and OPENAI_* env-var reads with explicit fallback defaults and a clear override-precedence contract. Getters: - getSupercliHome() → SUPERCLI_HOME || ~/.supercli - getServer() → SUPERCLI_SERVER || null - getApiKey() → SUPERCLI_API_KEY || null - getClientId() → SUPERCLI_CLIENT_ID trimmed || null (whitespace-only → null) - getOpenAiBaseUrl() → OPENAI_BASE_URL || null (doubles as hasLocalLLM gate) - getOpenAiModel() → OPENAI_MODEL || "gpt-3.5-turbo" - getOpenAiApiKey() → OPENAI_API_KEY || "dummy" - interpolateEnvPlaceholders(text) → expands ${VARNAME} in strings __tests__/env.test.js: 41 tests covering env-set, env-unset, empty-string fallback, whitespace-trim, truthy gating, non-string pass-through, adjacent and repeated placeholders, dollar-sign-no-braces not expanded, and unknown variable → empty string. Co-Authored-By: Claude Sonnet 4.6 --- __tests__/env.test.js | 304 ++++++++++++++++++++++++++++++++++++++++++ cli/env.js | 64 +++++++++ 2 files changed, 368 insertions(+) create mode 100644 __tests__/env.test.js create mode 100644 cli/env.js diff --git a/__tests__/env.test.js b/__tests__/env.test.js new file mode 100644 index 000000000..0a21ded2a --- /dev/null +++ b/__tests__/env.test.js @@ -0,0 +1,304 @@ +"use strict" + +const os = require("os") +const path = require("path") + +// Require the module fresh inside each test so that module-level constants +// (if any) do not cache a stale env. Jest caches require() across tests in the +// same file, which is fine here because every getter reads process.env at +// call-time rather than at require-time. +const { + getSupercliHome, + getServer, + getApiKey, + getClientId, + getOpenAiBaseUrl, + getOpenAiModel, + getOpenAiApiKey, + interpolateEnvPlaceholders, +} = require("../cli/env") + +// Snapshot of the original env vars we touch so we can restore them after each test. +const ENV_KEYS = [ + "SUPERCLI_HOME", + "SUPERCLI_SERVER", + "SUPERCLI_API_KEY", + "SUPERCLI_CLIENT_ID", + "OPENAI_BASE_URL", + "OPENAI_MODEL", + "OPENAI_API_KEY", +] + +let savedEnv = {} + +beforeEach(() => { + savedEnv = {} + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] + delete process.env[key] + } +}) + +afterEach(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key] + } else { + process.env[key] = savedEnv[key] + } + } +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getSupercliHome +// ────────────────────────────────────────────────────────────────────────────── + +describe("getSupercliHome", () => { + test("returns SUPERCLI_HOME when set", () => { + process.env.SUPERCLI_HOME = "/custom/supercli" + expect(getSupercliHome()).toBe("/custom/supercli") + }) + + test("falls back to ~/.supercli when env var is unset", () => { + const expected = path.join(os.homedir(), ".supercli") + expect(getSupercliHome()).toBe(expected) + }) + + test("falls back to default when SUPERCLI_HOME is empty string", () => { + process.env.SUPERCLI_HOME = "" + const expected = path.join(os.homedir(), ".supercli") + expect(getSupercliHome()).toBe(expected) + }) + + test("preserves trailing slash if caller set one", () => { + process.env.SUPERCLI_HOME = "/data/scli/" + expect(getSupercliHome()).toBe("/data/scli/") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getServer +// ────────────────────────────────────────────────────────────────────────────── + +describe("getServer", () => { + test("returns SUPERCLI_SERVER when set", () => { + process.env.SUPERCLI_SERVER = "https://panel.example.com" + expect(getServer()).toBe("https://panel.example.com") + }) + + test("returns null when SUPERCLI_SERVER is unset", () => { + expect(getServer()).toBeNull() + }) + + test("returns null when SUPERCLI_SERVER is empty string", () => { + process.env.SUPERCLI_SERVER = "" + expect(getServer()).toBeNull() + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getApiKey +// ────────────────────────────────────────────────────────────────────────────── + +describe("getApiKey", () => { + test("returns SUPERCLI_API_KEY when set", () => { + process.env.SUPERCLI_API_KEY = "sk-secret-token" + expect(getApiKey()).toBe("sk-secret-token") + }) + + test("returns null when SUPERCLI_API_KEY is unset", () => { + expect(getApiKey()).toBeNull() + }) + + test("returns null when SUPERCLI_API_KEY is empty string", () => { + process.env.SUPERCLI_API_KEY = "" + expect(getApiKey()).toBeNull() + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getClientId +// ────────────────────────────────────────────────────────────────────────────── + +describe("getClientId", () => { + test("returns trimmed SUPERCLI_CLIENT_ID when set", () => { + process.env.SUPERCLI_CLIENT_ID = " my-client-id " + expect(getClientId()).toBe("my-client-id") + }) + + test("returns the value without leading whitespace", () => { + process.env.SUPERCLI_CLIENT_ID = "\tabc123" + expect(getClientId()).toBe("abc123") + }) + + test("returns null when SUPERCLI_CLIENT_ID is unset", () => { + expect(getClientId()).toBeNull() + }) + + test("returns null when SUPERCLI_CLIENT_ID is empty string", () => { + process.env.SUPERCLI_CLIENT_ID = "" + expect(getClientId()).toBeNull() + }) + + test("returns null when SUPERCLI_CLIENT_ID is all whitespace", () => { + process.env.SUPERCLI_CLIENT_ID = " " + expect(getClientId()).toBeNull() + }) + + test("preserves internal whitespace in multi-word IDs", () => { + process.env.SUPERCLI_CLIENT_ID = " foo bar " + expect(getClientId()).toBe("foo bar") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getOpenAiBaseUrl +// ────────────────────────────────────────────────────────────────────────────── + +describe("getOpenAiBaseUrl", () => { + test("returns OPENAI_BASE_URL when set", () => { + process.env.OPENAI_BASE_URL = "http://localhost:11434/v1" + expect(getOpenAiBaseUrl()).toBe("http://localhost:11434/v1") + }) + + test("returns null when OPENAI_BASE_URL is unset (no local LLM)", () => { + expect(getOpenAiBaseUrl()).toBeNull() + }) + + test("returns null when OPENAI_BASE_URL is empty string", () => { + process.env.OPENAI_BASE_URL = "" + expect(getOpenAiBaseUrl()).toBeNull() + }) + + test("can serve as hasLocalLLM gate via truthy check", () => { + process.env.OPENAI_BASE_URL = "http://localhost:1234" + expect(!!getOpenAiBaseUrl()).toBe(true) + }) + + test("gate returns false when env var is missing", () => { + expect(!!getOpenAiBaseUrl()).toBe(false) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getOpenAiModel +// ────────────────────────────────────────────────────────────────────────────── + +describe("getOpenAiModel", () => { + test("returns OPENAI_MODEL when set", () => { + process.env.OPENAI_MODEL = "llama3.2" + expect(getOpenAiModel()).toBe("llama3.2") + }) + + test("falls back to gpt-3.5-turbo when OPENAI_MODEL is unset", () => { + expect(getOpenAiModel()).toBe("gpt-3.5-turbo") + }) + + test("falls back to default when OPENAI_MODEL is empty string", () => { + process.env.OPENAI_MODEL = "" + expect(getOpenAiModel()).toBe("gpt-3.5-turbo") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// getOpenAiApiKey +// ────────────────────────────────────────────────────────────────────────────── + +describe("getOpenAiApiKey", () => { + test("returns OPENAI_API_KEY when set", () => { + process.env.OPENAI_API_KEY = "sk-real-key" + expect(getOpenAiApiKey()).toBe("sk-real-key") + }) + + test("falls back to 'dummy' when OPENAI_API_KEY is unset", () => { + expect(getOpenAiApiKey()).toBe("dummy") + }) + + test("falls back to 'dummy' when OPENAI_API_KEY is empty string", () => { + process.env.OPENAI_API_KEY = "" + expect(getOpenAiApiKey()).toBe("dummy") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// interpolateEnvPlaceholders +// ────────────────────────────────────────────────────────────────────────────── + +describe("interpolateEnvPlaceholders", () => { + test("replaces ${VARNAME} with env var value", () => { + process.env.SUPERCLI_SERVER = "https://panel.example.com" + expect(interpolateEnvPlaceholders("url: ${SUPERCLI_SERVER}")).toBe( + "url: https://panel.example.com" + ) + }) + + test("replaces multiple placeholders in one string", () => { + process.env.SUPERCLI_SERVER = "https://example.com" + process.env.SUPERCLI_API_KEY = "tok" + expect( + interpolateEnvPlaceholders("${SUPERCLI_SERVER}/path?key=${SUPERCLI_API_KEY}") + ).toBe("https://example.com/path?key=tok") + }) + + test("replaces unknown placeholder with empty string", () => { + expect(interpolateEnvPlaceholders("hello ${DOES_NOT_EXIST_XYZ123}")).toBe("hello ") + }) + + test("leaves string unchanged when there are no placeholders", () => { + expect(interpolateEnvPlaceholders("no placeholders here")).toBe("no placeholders here") + }) + + test("returns empty string unchanged", () => { + expect(interpolateEnvPlaceholders("")).toBe("") + }) + + test("returns non-string values unchanged (number)", () => { + expect(interpolateEnvPlaceholders(42)).toBe(42) + }) + + test("returns non-string values unchanged (null)", () => { + expect(interpolateEnvPlaceholders(null)).toBeNull() + }) + + test("returns non-string values unchanged (undefined)", () => { + expect(interpolateEnvPlaceholders(undefined)).toBeUndefined() + }) + + test("returns non-string values unchanged (array)", () => { + const arr = ["a", "b"] + expect(interpolateEnvPlaceholders(arr)).toBe(arr) + }) + + test("only matches uppercase A-Z, 0-9, underscore patterns", () => { + // lowercase variable names must NOT be replaced + process.env.lowercase = "should-not-appear" + expect(interpolateEnvPlaceholders("${lowercase}")).toBe("${lowercase}") + delete process.env.lowercase + }) + + test("handles adjacent placeholders", () => { + process.env.SUPERCLI_HOME = "/home" + process.env.SUPERCLI_SERVER = "/srv" + expect(interpolateEnvPlaceholders("${SUPERCLI_HOME}${SUPERCLI_SERVER}")).toBe("/home/srv") + }) + + test("does not replace $VAR (without braces)", () => { + process.env.SUPERCLI_HOME = "should-not-appear" + expect(interpolateEnvPlaceholders("$SUPERCLI_HOME")).toBe("$SUPERCLI_HOME") + }) + + test("handles mixed set and unset placeholders", () => { + process.env.SUPERCLI_API_KEY = "key123" + expect( + interpolateEnvPlaceholders("${SUPERCLI_API_KEY}:${MISSING_VAR_ABC}") + ).toBe("key123:") + delete process.env.MISSING_VAR_ABC + }) + + test("same placeholder repeated twice gets same substitution", () => { + process.env.SUPERCLI_HOME = "/data" + expect( + interpolateEnvPlaceholders("${SUPERCLI_HOME} and ${SUPERCLI_HOME}") + ).toBe("/data and /data") + }) +}) diff --git a/cli/env.js b/cli/env.js new file mode 100644 index 000000000..7756d5afa --- /dev/null +++ b/cli/env.js @@ -0,0 +1,64 @@ +"use strict" + +const os = require("os") +const path = require("path") + +// SUPERCLI_HOME — base data directory; override via env, default to ~/.supercli +function getSupercliHome() { + return process.env.SUPERCLI_HOME || path.join(os.homedir(), ".supercli") +} + +// SUPERCLI_SERVER — remote panel URL; no default (null when unset) +function getServer() { + return process.env.SUPERCLI_SERVER || null +} + +// SUPERCLI_API_KEY — bearer token for panel auth; no default (null when unset) +function getApiKey() { + return process.env.SUPERCLI_API_KEY || null +} + +// SUPERCLI_CLIENT_ID — explicit override for the generated machine fingerprint +// Trims whitespace so accidental trailing spaces in env don't leak into hashes. +// Returns null when unset so callers can fall back to their own ID generation. +function getClientId() { + const raw = process.env.SUPERCLI_CLIENT_ID + if (!raw) return null + const trimmed = String(raw).trim() + return trimmed || null +} + +// OPENAI_BASE_URL — points to a local LLM that speaks the OpenAI HTTP API; +// null when unset (callers use this as the hasLocalLLM gate) +function getOpenAiBaseUrl() { + return process.env.OPENAI_BASE_URL || null +} + +// OPENAI_MODEL — model name sent to the local/remote LLM; default gpt-3.5-turbo +function getOpenAiModel() { + return process.env.OPENAI_MODEL || "gpt-3.5-turbo" +} + +// OPENAI_API_KEY — API key sent to the LLM endpoint; default "dummy" so that +// local LLMs (which ignore the key) work without extra env-var setup +function getOpenAiApiKey() { + return process.env.OPENAI_API_KEY || "dummy" +} + +// Expand ${VARNAME} placeholders inside a string using process.env. +// Unknown variables are replaced with "". Non-strings are returned unchanged. +function interpolateEnvPlaceholders(text) { + if (typeof text !== "string") return text + return text.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => process.env[name] || "") +} + +module.exports = { + getSupercliHome, + getServer, + getApiKey, + getClientId, + getOpenAiBaseUrl, + getOpenAiModel, + getOpenAiApiKey, + interpolateEnvPlaceholders, +}