pluidr 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +115 -49
  2. package/package.json +4 -1
  3. package/src/cli/commands/doctor.js +99 -0
  4. package/src/cli/commands/init.js +14 -7
  5. package/src/cli/commands/uninstall.js +67 -0
  6. package/src/cli/commands/update.js +22 -0
  7. package/src/cli/index.js +31 -1
  8. package/src/cli/wizard/selectModelTier.js +22 -31
  9. package/src/core/agentPromptWriter.js +2 -2
  10. package/src/core/agentPromptWriter.test.js +56 -0
  11. package/src/core/backup.js +35 -5
  12. package/src/core/backup.test.js +51 -0
  13. package/src/core/configBuilder.js +13 -0
  14. package/src/core/configBuilder.test.js +47 -0
  15. package/src/core/configWriter.js +7 -4
  16. package/src/core/configWriter.test.js +26 -0
  17. package/src/core/identityHeader.test.js +15 -0
  18. package/src/core/paths.js +4 -15
  19. package/src/core/paths.test.js +25 -0
  20. package/src/core/pluginWriter.js +12 -8
  21. package/src/core/pluginWriter.test.js +41 -0
  22. package/src/core/squeezeInstaller.js +141 -0
  23. package/src/core/squeezeInstaller.test.js +77 -0
  24. package/src/plugins/README.md +29 -15
  25. package/src/plugins/{parent-session.js → pluidr-flow.js} +1 -6
  26. package/src/plugins/pluidr-squeeze.js +56 -0
  27. package/src/templates/agent-prompts/coder.txt +32 -4
  28. package/src/templates/agent-prompts/composer.txt +415 -0
  29. package/src/templates/agent-prompts/debugger.txt +55 -14
  30. package/src/templates/agent-prompts/fixer.txt +7 -0
  31. package/src/templates/agent-prompts/hierarchy.txt +11 -8
  32. package/src/templates/agent-prompts/plan-checker.txt +5 -5
  33. package/src/templates/agent-prompts/plan-writer.txt +3 -3
  34. package/src/templates/agent-prompts/reporter.txt +0 -4
  35. package/src/templates/agent-prompts/researcher.txt +3 -3
  36. package/src/templates/agent-prompts/reviewer.txt +16 -4
  37. package/src/templates/agent-prompts/tester.txt +10 -1
  38. package/src/templates/agent-prompts/writer.txt +4 -4
  39. package/src/templates/model-defaults.json +38 -2
  40. package/src/templates/opencode.config.json +93 -67
  41. package/src/templates/rtk-checksums.json +7 -0
  42. package/src/templates/agent-prompts/builder.txt +0 -107
  43. package/src/templates/agent-prompts/explorer.txt +0 -53
  44. package/src/templates/agent-prompts/planner.txt +0 -126
@@ -0,0 +1,56 @@
1
+ import { describe, it, after } from "node:test"
2
+ import assert from "node:assert"
3
+ import { existsSync, mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { writeAgentPrompts } from "./agentPromptWriter.js"
7
+
8
+ describe("agentPromptWriter", () => {
9
+ const destDir = mkdtempSync(join(tmpdir(), "pluidr-apw-dest-"))
10
+
11
+ after(() => {
12
+ // Clean up any files written during tests
13
+ if (existsSync(destDir)) rmSync(destDir, { recursive: true })
14
+ })
15
+
16
+ it("prepends identity header to agent prompts", () => {
17
+ const tmpDir = mkdtempSync(join(tmpdir(), "apw-source-"))
18
+ const agentPromptsDir = join(tmpDir, "agent-prompts")
19
+ mkdirSync(agentPromptsDir, { recursive: true })
20
+
21
+ writeFileSync(join(agentPromptsDir, "coder.txt"), "test instructions", "utf-8")
22
+ writeFileSync(join(agentPromptsDir, "writer.txt"), "write docs", "utf-8")
23
+
24
+ writeAgentPrompts(tmpDir, destDir)
25
+
26
+ const coderContent = readFileSync(join(destDir, "coder.txt"), "utf-8")
27
+ const writerContent = readFileSync(join(destDir, "writer.txt"), "utf-8")
28
+
29
+ assert.ok(coderContent.startsWith('You are the "coder" agent.\n\n'),
30
+ "coder should have identity header")
31
+ assert.ok(writerContent.startsWith('You are the "writer" agent.\n\n'),
32
+ "writer should have identity header")
33
+ assert.ok(coderContent.includes("test instructions"),
34
+ "original content preserved in coder")
35
+ assert.ok(writerContent.includes("write docs"),
36
+ "original content preserved in writer")
37
+
38
+ rmSync(tmpDir, { recursive: true })
39
+ })
40
+
41
+ it("copies hierarchy.txt verbatim without identity header", () => {
42
+ const tmpDir = mkdtempSync(join(tmpdir(), "apw-source2-"))
43
+ const agentPromptsDir = join(tmpDir, "agent-prompts")
44
+ mkdirSync(agentPromptsDir, { recursive: true })
45
+
46
+ writeFileSync(join(agentPromptsDir, "hierarchy.txt"), "global reference rules", "utf-8")
47
+
48
+ writeAgentPrompts(tmpDir, destDir)
49
+
50
+ const hierarchyContent = readFileSync(join(destDir, "hierarchy.txt"), "utf-8")
51
+ assert.strictEqual(hierarchyContent, "global reference rules",
52
+ "hierarchy.txt should be copied verbatim without header")
53
+
54
+ rmSync(tmpDir, { recursive: true })
55
+ })
56
+ })
@@ -1,8 +1,38 @@
1
- import { existsSync, copyFileSync } from "node:fs"
2
- import { getConfigPath, getBackupPath } from "./paths.js"
1
+ import { existsSync, copyFileSync, readdirSync, unlinkSync } from "node:fs"
2
+ import { join, dirname } from "node:path"
3
+ import { getConfigPath } from "./paths.js"
3
4
 
4
- export function backupExistingConfig() {
5
- if (existsSync(getConfigPath())) {
6
- copyFileSync(getConfigPath(), getBackupPath())
5
+ const MAX_BACKUPS = 5
6
+
7
+ function timestamp() {
8
+ const d = new Date()
9
+ const pad = (n) => String(n).padStart(2, "0")
10
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
11
+ }
12
+
13
+ function rotateBackups(configPath) {
14
+ const dir = dirname(configPath)
15
+ const baseName = "opencode.jsonc.bak"
16
+ let backups = []
17
+
18
+ try {
19
+ backups = readdirSync(dir)
20
+ .filter((f) => f.startsWith(baseName + "."))
21
+ .map((f) => join(dir, f))
22
+ .sort()
23
+ } catch { /* dir doesn't exist yet */ }
24
+
25
+ while (backups.length >= MAX_BACKUPS) {
26
+ const oldest = backups.shift()
27
+ try { unlinkSync(oldest) } catch {}
7
28
  }
8
29
  }
30
+
31
+ export function backupExistingConfig(configPath) {
32
+ const resolvedPath = configPath || getConfigPath()
33
+ if (!existsSync(resolvedPath)) return
34
+
35
+ rotateBackups(resolvedPath)
36
+ const backupPath = `${resolvedPath}.bak.${timestamp()}`
37
+ copyFileSync(resolvedPath, backupPath)
38
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, before, after } from "node:test"
2
+ import assert from "node:assert"
3
+ import { existsSync, writeFileSync, readFileSync, rmSync, readdirSync, mkdirSync, mkdtempSync } from "node:fs"
4
+ import { join, dirname } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { backupExistingConfig } from "./backup.js"
7
+
8
+ describe("backup", () => {
9
+ const tempDir = mkdtempSync(join(tmpdir(), "pluidr-backup-"))
10
+ const configPath = join(tempDir, "opencode.jsonc")
11
+ const TEST_CONTENT = JSON.stringify({ test: true })
12
+
13
+ function findBackups() {
14
+ try {
15
+ return readdirSync(tempDir).filter((f) => f.startsWith("opencode.jsonc.bak."))
16
+ } catch { return [] }
17
+ }
18
+
19
+ before(() => {
20
+ mkdirSync(tempDir, { recursive: true })
21
+ })
22
+
23
+ after(() => {
24
+ if (existsSync(tempDir)) rmSync(tempDir, { recursive: true })
25
+ })
26
+
27
+ it("skips if no config exists (no throw)", () => {
28
+ // Ensure no config file exists in temp dir
29
+ if (existsSync(configPath)) rmSync(configPath)
30
+ for (const bak of findBackups()) {
31
+ rmSync(join(tempDir, bak))
32
+ }
33
+ backupExistingConfig(configPath)
34
+ assert.ok(true)
35
+ })
36
+
37
+ it("copies existing config to backup with timestamped name", () => {
38
+ // Write test config
39
+ writeFileSync(configPath, TEST_CONTENT, "utf-8")
40
+
41
+ backupExistingConfig(configPath)
42
+
43
+ const backups = findBackups()
44
+ assert.ok(backups.length > 0, "timestamped backup file should exist")
45
+ const newest = backups.sort().reverse()[0]
46
+ const newestPath = join(tempDir, newest)
47
+ assert.ok(newestPath.includes("opencode.jsonc.bak."), "backup should be timestamped")
48
+ const backupContent = JSON.parse(readFileSync(newestPath, "utf-8"))
49
+ assert.deepStrictEqual(backupContent, { test: true })
50
+ })
51
+ })
@@ -8,6 +8,19 @@ export function buildConfig(tierChoices, templatesDir) {
8
8
  const config = JSON.parse(readFileSync(configPath, "utf-8"))
9
9
  const modelDefaults = JSON.parse(readFileSync(defaultsPath, "utf-8"))
10
10
 
11
+ // Validate: every agent in model-defaults must have a key in config.agent
12
+ const defaultedAgents = new Set(
13
+ Object.values(modelDefaults).flatMap((tier) => tier.agents),
14
+ )
15
+ const missingAgents = [...defaultedAgents].filter(
16
+ (name) => !(name in config.agent),
17
+ )
18
+ if (missingAgents.length > 0) {
19
+ throw new Error(
20
+ `Template validation failed: agent(s) not found in opencode.config.json: ${missingAgents.join(", ")}`,
21
+ )
22
+ }
23
+
11
24
  for (const [tierKey, tier] of Object.entries(modelDefaults)) {
12
25
  const model = tierChoices[tierKey]
13
26
  for (const agentName of tier.agents) {
@@ -0,0 +1,47 @@
1
+ import { describe, it } from "node:test"
2
+ import assert from "node:assert"
3
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { buildConfig } from "./configBuilder.js"
7
+
8
+ describe("configBuilder", () => {
9
+ it("injects models into agent config per tier", () => {
10
+ const tmpDir = mkdtempSync(join(tmpdir(), "configbuilder-test-"))
11
+
12
+ const configJson = {
13
+ agent: {
14
+ composer: {},
15
+ coder: {},
16
+ },
17
+ }
18
+ const defaultsJson = {
19
+ reasoningHeavy: {
20
+ provider: "test",
21
+ model: "big-model",
22
+ agents: ["composer"],
23
+ },
24
+ fast: {
25
+ provider: "test",
26
+ model: "small-model",
27
+ agents: ["coder"],
28
+ },
29
+ }
30
+
31
+ writeFileSync(join(tmpDir, "opencode.config.json"), JSON.stringify(configJson), "utf-8")
32
+ writeFileSync(join(tmpDir, "model-defaults.json"), JSON.stringify(defaultsJson), "utf-8")
33
+
34
+ const result = buildConfig(
35
+ {
36
+ reasoningHeavy: "test/big-model",
37
+ fast: "test/small-model",
38
+ },
39
+ tmpDir,
40
+ )
41
+
42
+ assert.strictEqual(result.agent.composer.model, "test/big-model")
43
+ assert.strictEqual(result.agent.coder.model, "test/small-model")
44
+
45
+ rmSync(tmpDir, { recursive: true })
46
+ })
47
+ })
@@ -1,7 +1,10 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs"
2
- import { getConfigDir, getConfigPath } from "./paths.js"
2
+ import { join } from "node:path"
3
+ import { getConfigDir } from "./paths.js"
3
4
 
4
- export function writeConfig(configObject) {
5
- mkdirSync(getConfigDir(), { recursive: true })
6
- writeFileSync(getConfigPath(), JSON.stringify(configObject, null, 2), "utf-8")
5
+ export function writeConfig(configObject, targetDir) {
6
+ const baseDir = targetDir || getConfigDir()
7
+ const configPath = join(baseDir, "opencode.jsonc")
8
+ mkdirSync(baseDir, { recursive: true })
9
+ writeFileSync(configPath, JSON.stringify(configObject, null, 2), "utf-8")
7
10
  }
@@ -0,0 +1,26 @@
1
+ import { describe, it, after } from "node:test"
2
+ import assert from "node:assert"
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
4
+ import { join } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { writeConfig } from "./configWriter.js"
7
+
8
+ describe("configWriter", () => {
9
+ const tempDir = mkdtempSync(join(tmpdir(), "pluidr-configwriter-"))
10
+
11
+ after(() => {
12
+ if (existsSync(tempDir)) rmSync(tempDir, { recursive: true })
13
+ })
14
+
15
+ it("writes config as formatted JSON to the correct path", () => {
16
+ const configObj = { agent: { composer: { model: "test/model" } } }
17
+ const expected = JSON.stringify(configObj, null, 2)
18
+
19
+ writeConfig(configObj, tempDir)
20
+
21
+ const configPath = join(tempDir, "opencode.jsonc")
22
+ assert.ok(existsSync(configPath), "config file should exist")
23
+ const written = readFileSync(configPath, "utf-8")
24
+ assert.strictEqual(written, expected)
25
+ })
26
+ })
@@ -0,0 +1,15 @@
1
+ import { describe, it } from "node:test"
2
+ import assert from "node:assert"
3
+ import IDENTITY_HEADER from "./identityHeader.js"
4
+
5
+ describe("identityHeader", () => {
6
+ it('returns "You are the <name> agent.\\n\\n" format', () => {
7
+ const result = IDENTITY_HEADER("coder")
8
+ assert.strictEqual(result, 'You are the "coder" agent.\n\n')
9
+ })
10
+
11
+ it('handles multi-word agent names', () => {
12
+ const result = IDENTITY_HEADER("plan-writer")
13
+ assert.strictEqual(result, 'You are the "plan-writer" agent.\n\n')
14
+ })
15
+ })
package/src/core/paths.js CHANGED
@@ -3,18 +3,7 @@ import { join } from "node:path"
3
3
 
4
4
  const BASE = join(homedir(), ".config", "opencode")
5
5
 
6
- export function getConfigDir() {
7
- return BASE
8
- }
9
-
10
- export function getConfigPath() {
11
- return join(BASE, "opencode.jsonc")
12
- }
13
-
14
- export function getBackupPath() {
15
- return join(BASE, "opencode.jsonc.bak")
16
- }
17
-
18
- export function getPromptsDir() {
19
- return join(BASE, "prompts")
20
- }
6
+ export const getConfigDir = () => BASE
7
+ export const getConfigPath = () => join(BASE, "opencode.jsonc")
8
+ export const getBackupPath = () => join(BASE, "opencode.jsonc.bak")
9
+ export const getPromptsDir = () => join(BASE, "prompts")
@@ -0,0 +1,25 @@
1
+ import { describe, it } from "node:test"
2
+ import assert from "node:assert"
3
+ import { homedir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { getConfigDir, getConfigPath, getBackupPath, getPromptsDir } from "./paths.js"
6
+
7
+ const BASE = join(homedir(), ".config", "opencode")
8
+
9
+ describe("paths", () => {
10
+ it("getConfigDir returns ~/.config/opencode", () => {
11
+ assert.strictEqual(getConfigDir(), BASE)
12
+ })
13
+
14
+ it("getConfigPath returns opencode.jsonc path", () => {
15
+ assert.strictEqual(getConfigPath(), join(BASE, "opencode.jsonc"))
16
+ })
17
+
18
+ it("getBackupPath returns opencode.jsonc.bak path", () => {
19
+ assert.strictEqual(getBackupPath(), join(BASE, "opencode.jsonc.bak"))
20
+ })
21
+
22
+ it("getPromptsDir returns prompts path", () => {
23
+ assert.strictEqual(getPromptsDir(), join(BASE, "prompts"))
24
+ })
25
+ })
@@ -4,22 +4,26 @@ import { fileURLToPath } from "node:url"
4
4
  import { getConfigDir } from "./paths.js"
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url))
7
- const PLUGIN_NAME = "parent-session.js"
7
+ const PLUGINS = ["pluidr-flow.js", "pluidr-squeeze.js"]
8
8
  const PACKAGE_JSON = {
9
+ type: "module",
9
10
  dependencies: { "@opencode-ai/plugin": "^1.17.9" },
10
11
  }
11
12
 
12
- export function writePluginBundle() {
13
- const pluginsDir = join(getConfigDir(), "plugins")
13
+ export function writePluginBundle(targetDir) {
14
+ const baseDir = targetDir || getConfigDir()
15
+ const pluginsDir = join(baseDir, "plugins")
14
16
  mkdirSync(pluginsDir, { recursive: true })
15
17
 
16
- const sourcePath = resolve(__dirname, "../plugins", PLUGIN_NAME)
17
- copyFileSync(sourcePath, join(pluginsDir, PLUGIN_NAME))
18
+ for (const name of PLUGINS) {
19
+ const sourcePath = resolve(__dirname, "../plugins", name)
20
+ copyFileSync(sourcePath, join(pluginsDir, name))
21
+ }
18
22
  }
19
23
 
20
- export function writePluginPackageJson() {
21
- const packageJsonPath = join(getConfigDir(), "package.json")
22
- if (existsSync(packageJsonPath)) return
24
+ export function writePluginPackageJson(targetDir) {
25
+ const baseDir = targetDir || getConfigDir()
26
+ const packageJsonPath = join(baseDir, "package.json")
23
27
 
24
28
  writeFileSync(packageJsonPath, JSON.stringify(PACKAGE_JSON, null, 2), "utf-8")
25
29
  }
@@ -0,0 +1,41 @@
1
+ import { describe, it, after } from "node:test"
2
+ import assert from "node:assert"
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"
4
+ import { join, resolve, dirname } from "node:path"
5
+ import { tmpdir } from "node:os"
6
+ import { fileURLToPath } from "node:url"
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url))
9
+
10
+ describe("pluginWriter", () => {
11
+ const tempDir = mkdtempSync(join(tmpdir(), "pluidr-pluginwriter-"))
12
+
13
+ after(() => {
14
+ if (existsSync(tempDir)) rmSync(tempDir, { recursive: true })
15
+ })
16
+
17
+ it("copies plugin files and writes package.json", async () => {
18
+ const { writePluginBundle, writePluginPackageJson } = await import("./pluginWriter.js")
19
+
20
+ // Use temp dir as target — sandboxed, no real ~/.config/opencode writes
21
+ writePluginBundle(tempDir)
22
+
23
+ const pluginsDestDir = join(tempDir, "plugins")
24
+ const pluginFiles = ["pluidr-flow.js", "pluidr-squeeze.js"]
25
+ for (const f of pluginFiles) {
26
+ assert.ok(existsSync(join(pluginsDestDir, f)),
27
+ `plugin ${f} should be copied to plugins dir`)
28
+ }
29
+
30
+ // Clean plugins dir to test package.json separately
31
+ rmSync(pluginsDestDir, { recursive: true })
32
+
33
+ writePluginPackageJson(tempDir)
34
+
35
+ const pkgPath = join(tempDir, "package.json")
36
+ assert.ok(existsSync(pkgPath), "package.json should exist in temp dir")
37
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
38
+ assert.strictEqual(pkg.type, "module")
39
+ assert.ok(pkg.dependencies["@opencode-ai/plugin"])
40
+ })
41
+ })
@@ -0,0 +1,141 @@
1
+ import { execFileSync } from "node:child_process"
2
+ import { chmodSync, mkdirSync, existsSync, writeFileSync, unlinkSync, readFileSync } from "node:fs"
3
+ import { extname, join, resolve, dirname } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import { createHash } from "node:crypto"
6
+ import { getConfigDir } from "./paths.js"
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url))
9
+
10
+ const BIN_NAME = process.platform === "win32" ? "rtk.exe" : "rtk"
11
+ export const ASSETS = {
12
+ "darwin-arm64": "rtk-aarch64-apple-darwin.tar.gz",
13
+ "darwin-x64": "rtk-x86_64-apple-darwin.tar.gz",
14
+ "linux-x64": "rtk-x86_64-unknown-linux-musl.tar.gz",
15
+ "linux-arm64": "rtk-aarch64-unknown-linux-gnu.tar.gz",
16
+ "win32-x64": "rtk-x86_64-pc-windows-msvc.zip",
17
+ }
18
+ const RTK_RELEASE_BASE = "https://github.com/rtk-ai/rtk/releases/latest/download"
19
+
20
+ export function platformAsset() {
21
+ const key = `${process.platform}-${process.arch}`
22
+ return ASSETS[key] ?? null
23
+ }
24
+
25
+ export function validatePath(path) {
26
+ if (!/^[a-zA-Z0-9_\-\.\:\/\\]+$/.test(path)) {
27
+ throw new Error(`Invalid path: "${path}" contains unsafe characters`)
28
+ }
29
+ }
30
+
31
+ export function findRtkPath() {
32
+ try {
33
+ const [cmd, ...args] =
34
+ process.platform === "win32" ? ["where", "rtk"] : ["which", "rtk"]
35
+ return execFileSync(cmd, args, { stdio: "pipe" })
36
+ .toString()
37
+ .trim()
38
+ .split(/\r?\n/)[0]
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ function extract(archivePath, destDir) {
45
+ validatePath(archivePath)
46
+ validatePath(destDir)
47
+ const isZip = extname(archivePath) === ".zip"
48
+
49
+ if (isZip && process.platform === "win32") {
50
+ execFileSync("powershell", [
51
+ "-NoProfile",
52
+ "-Command",
53
+ "Expand-Archive",
54
+ "-LiteralPath", archivePath,
55
+ "-DestinationPath", destDir,
56
+ "-Force"
57
+ ], { stdio: "ignore" })
58
+ } else {
59
+ execFileSync("tar", ["-xzf", archivePath, "-C", destDir], { stdio: "ignore" })
60
+ }
61
+ }
62
+
63
+ function verifyChecksum(buffer, assetName) {
64
+ const checksumsPath = resolve(__dirname, "../templates/rtk-checksums.json")
65
+ let checksums
66
+ try {
67
+ checksums = JSON.parse(readFileSync(checksumsPath, "utf-8"))
68
+ } catch {
69
+ // No checksums file — skip verification
70
+ return
71
+ }
72
+
73
+ const expectedHash = checksums[assetName]
74
+ if (expectedHash === null || expectedHash === undefined) {
75
+ // Hash not yet populated for this platform — silently skip
76
+ return
77
+ }
78
+
79
+ const computed = createHash("sha256").update(buffer).digest("hex")
80
+ if (computed !== expectedHash) {
81
+ throw new Error(
82
+ `SHA256 mismatch for ${assetName}\n expected: ${expectedHash}\n computed: ${computed}`
83
+ )
84
+ }
85
+ }
86
+
87
+ export async function installSqueeze() {
88
+ let rtkPath = findRtkPath()
89
+
90
+ if (!rtkPath) {
91
+ // Not on PATH — download it to the managed location
92
+ const asset = platformAsset()
93
+ if (!asset) {
94
+ console.warn("⚠ Squeeze: unsupported platform — install rtk manually")
95
+ return
96
+ }
97
+
98
+ const binDir = join(getConfigDir(), "bin")
99
+ mkdirSync(binDir, { recursive: true })
100
+
101
+ const url = `${RTK_RELEASE_BASE}/${asset}`
102
+ const archivePath = join(binDir, asset)
103
+
104
+ try {
105
+ let response
106
+ for (let attempt = 1; attempt <= 3; attempt++) {
107
+ try {
108
+ response = await fetch(url)
109
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
110
+ break
111
+ } catch (err) {
112
+ if (attempt < 3) {
113
+ // Retry on any error (network, HTTP, etc.) with exponential backoff
114
+ const delay = Math.pow(2, attempt - 1) * 1000
115
+ await new Promise(r => setTimeout(r, delay))
116
+ continue
117
+ }
118
+ throw err
119
+ }
120
+ }
121
+
122
+ const buffer = Buffer.from(await response.arrayBuffer())
123
+ verifyChecksum(buffer, asset)
124
+ writeFileSync(archivePath, buffer)
125
+
126
+ extract(archivePath, binDir)
127
+
128
+ try { unlinkSync(archivePath) } catch {}
129
+
130
+ rtkPath = join(binDir, BIN_NAME)
131
+
132
+ if (process.platform !== "win32" && existsSync(rtkPath)) {
133
+ chmodSync(rtkPath, 0o755)
134
+ }
135
+ } catch {
136
+ try { unlinkSync(archivePath) } catch {}
137
+ console.warn("⚠ Squeeze: download failed after 3 attempts — install rtk manually")
138
+ return
139
+ }
140
+ }
141
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, it } from "node:test"
2
+ import assert from "node:assert"
3
+ import { platformAsset, validatePath, findRtkPath, ASSETS } from "./squeezeInstaller.js"
4
+
5
+ describe("platformAsset", () => {
6
+ it("returns a string or null for current platform", () => {
7
+ const result = platformAsset()
8
+ // On a supported platform: string; on unsupported: null
9
+ assert.ok(result === null || typeof result === "string")
10
+ })
11
+
12
+ it("returns known asset names for mapped platforms", () => {
13
+ // Verify the ASSETS map has entries for all known platform combos
14
+ const knownCombos = [
15
+ { plat: "darwin", arch: "arm64" },
16
+ { plat: "darwin", arch: "x64" },
17
+ { plat: "linux", arch: "x64" },
18
+ { plat: "linux", arch: "arm64" },
19
+ { plat: "win32", arch: "x64" },
20
+ ]
21
+ for (const { plat, arch } of knownCombos) {
22
+ const key = `${plat}-${arch}`
23
+ const asset = ASSETS[key]
24
+ assert.ok(asset, `ASSETS map should have entry for ${key}`)
25
+ assert.ok(asset.endsWith(".tar.gz") || asset.endsWith(".zip"),
26
+ `Asset "${asset}" should end with .tar.gz or .zip`)
27
+ }
28
+ })
29
+ })
30
+
31
+ describe("validatePath", () => {
32
+ it("accepts simple relative paths", () => {
33
+ assert.doesNotThrow(() => validatePath("foo/bar"))
34
+ })
35
+
36
+ it("accepts absolute POSIX paths", () => {
37
+ assert.doesNotThrow(() => validatePath("/home/user/.config"))
38
+ })
39
+
40
+ it("accepts absolute Windows paths", () => {
41
+ assert.doesNotThrow(() => validatePath("C:\\Users\\test"))
42
+ })
43
+
44
+ it("accepts paths with dots and dashes", () => {
45
+ assert.doesNotThrow(() => validatePath("./dir/file-name.ext"))
46
+ })
47
+
48
+ it("allows .. in paths (shell metacharacter check only, not path traversal)", () => {
49
+ assert.doesNotThrow(() => validatePath("../evil"))
50
+ })
51
+
52
+ it("rejects paths with semicolons", () => {
53
+ assert.throws(() => validatePath("foo;rm -rf /"), /unsafe characters/)
54
+ })
55
+
56
+ it("rejects paths with backticks", () => {
57
+ assert.throws(() => validatePath("foo`id`"), /unsafe characters/)
58
+ })
59
+
60
+ it("rejects paths with dollar signs", () => {
61
+ assert.throws(() => validatePath("foo$PATH"), /unsafe characters/)
62
+ })
63
+
64
+ it("rejects paths with pipe characters", () => {
65
+ assert.throws(() => validatePath("foo|bar"), /unsafe characters/)
66
+ })
67
+ })
68
+
69
+ describe("findRtkPath", () => {
70
+ it("returns null when rtk is not available on PATH", () => {
71
+ // This test is safe because if rtk IS on PATH, we still get a
72
+ // string back — but the test doesn't crash either way.
73
+ const result = findRtkPath()
74
+ // Either null (not on PATH) or a non-empty string (found)
75
+ assert.ok(result === null || typeof result === "string")
76
+ })
77
+ })