@bybrawe/opencode-loop 0.5.16 → 0.5.18
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.
- package/CHANGELOG.md +22 -0
- package/README.md +36 -11
- package/agents/opencode-loop-local.md +10 -0
- package/commands/loop-ask.md +1 -0
- package/commands/loop-clear.md +1 -0
- package/commands/loop-cmd.md +1 -0
- package/commands/loop-command.md +1 -0
- package/commands/loop-compact.md +1 -0
- package/commands/loop-dev.md +1 -0
- package/commands/loop-doctor.md +1 -0
- package/commands/loop-export.md +1 -0
- package/commands/loop-goal-blocked.md +1 -0
- package/commands/loop-goal-clear.md +1 -0
- package/commands/loop-goal-done.md +1 -0
- package/commands/loop-goal-pause.md +1 -0
- package/commands/loop-goal-resume.md +1 -0
- package/commands/loop-goal-status.md +1 -0
- package/commands/loop-goal.md +1 -0
- package/commands/loop-help.md +1 -0
- package/commands/loop-init.md +1 -0
- package/commands/loop-logs.md +1 -0
- package/commands/loop-now.md +1 -0
- package/commands/loop-pause.md +1 -0
- package/commands/loop-progress.md +1 -0
- package/commands/loop-prompt.md +1 -0
- package/commands/loop-remove.md +1 -0
- package/commands/loop-resume.md +1 -0
- package/commands/loop-safe-dev.md +1 -0
- package/commands/loop-shell.md +1 -0
- package/commands/loop-status.md +1 -0
- package/commands/loop-stop.md +1 -0
- package/commands/loop-testfix.md +1 -0
- package/commands/loop.md +1 -0
- package/package.json +6 -5
- package/scripts/comprehensive-test.mjs +486 -0
- package/scripts/install-node.mjs +146 -4
- package/scripts/install-test.mjs +104 -0
- package/scripts/install.ps1 +6 -32
- package/scripts/install.sh +6 -34
- package/scripts/loopd-test.mjs +208 -0
- package/scripts/loopd.mjs +125 -41
- package/scripts/smoke-test.mjs +1 -1
- package/src/index.js +193 -52
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawn } from "node:child_process"
|
|
3
|
+
import { promises as fs } from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
9
|
+
const installer = path.join(root, "scripts", "install-node.mjs")
|
|
10
|
+
const packageVersion = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8")).version
|
|
11
|
+
const expectedPackageSpec = `@bybrawe/opencode-loop@${packageVersion}`
|
|
12
|
+
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loop-installer-"))
|
|
13
|
+
|
|
14
|
+
async function runInstaller(config, cliArgs = []) {
|
|
15
|
+
return await new Promise((resolve, reject) => {
|
|
16
|
+
const child = spawn(process.execPath, [installer, ...cliArgs], {
|
|
17
|
+
cwd: root,
|
|
18
|
+
env: { ...process.env, OPENCODE_CONFIG_DIR: config },
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
})
|
|
21
|
+
const stdout = []
|
|
22
|
+
const stderr = []
|
|
23
|
+
child.stdout.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
24
|
+
child.stderr.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
25
|
+
child.on("error", reject)
|
|
26
|
+
child.on("close", (code) => resolve({
|
|
27
|
+
code,
|
|
28
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
29
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
30
|
+
}))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function commandCount(config) {
|
|
35
|
+
return (await fs.readdir(path.join(config, "commands"))).filter((name) => name.endsWith(".md")).length
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function exists(target) {
|
|
39
|
+
try { await fs.access(target); return true } catch { return false }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const helpConfig = path.join(temporaryRoot, "help-must-not-install")
|
|
44
|
+
const helpResult = await runInstaller(helpConfig, ["--help"])
|
|
45
|
+
assert.equal(helpResult.code, 0, helpResult.stderr)
|
|
46
|
+
assert.match(helpResult.stdout, /OpenCode Loop installer/)
|
|
47
|
+
assert.equal(await exists(helpConfig), false, "--help must not mutate the OpenCode config directory")
|
|
48
|
+
|
|
49
|
+
const versionResult = await runInstaller(helpConfig, ["--version"])
|
|
50
|
+
assert.equal(versionResult.code, 0, versionResult.stderr)
|
|
51
|
+
assert.equal(versionResult.stdout.trim(), packageVersion)
|
|
52
|
+
assert.equal(await exists(helpConfig), false, "--version must not mutate the OpenCode config directory")
|
|
53
|
+
|
|
54
|
+
const local = path.join(temporaryRoot, "local")
|
|
55
|
+
const localResult = await runInstaller(local)
|
|
56
|
+
assert.equal(localResult.code, 0, localResult.stderr)
|
|
57
|
+
assert.equal(await exists(path.join(local, "plugins", "opencode-loop.ts")), true)
|
|
58
|
+
assert.equal(await commandCount(local), 30)
|
|
59
|
+
assert.equal(await exists(path.join(local, "agents", "opencode-loop-local.md")), true)
|
|
60
|
+
const localPackage = JSON.parse(await fs.readFile(path.join(local, "package.json"), "utf8"))
|
|
61
|
+
assert.equal(localPackage.dependencies["@opencode-ai/plugin"], ">=1.4.0")
|
|
62
|
+
|
|
63
|
+
const configured = path.join(temporaryRoot, "configured")
|
|
64
|
+
await fs.mkdir(path.join(configured, "plugins"), { recursive: true })
|
|
65
|
+
await fs.writeFile(path.join(configured, "opencode.json"), JSON.stringify({ plugin: ["@bybrawe/opencode-loop@latest"] }), "utf8")
|
|
66
|
+
await fs.writeFile(path.join(configured, "plugins", "opencode-loop.ts"), "duplicate", "utf8")
|
|
67
|
+
await fs.writeFile(path.join(configured, "plugins", "opencode-loop.js"), "legacy duplicate", "utf8")
|
|
68
|
+
const configuredResult = await runInstaller(configured)
|
|
69
|
+
assert.equal(configuredResult.code, 0, configuredResult.stderr)
|
|
70
|
+
assert.match(configuredResult.stdout, /removed the duplicate local plugin copy/i)
|
|
71
|
+
assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.ts")), false)
|
|
72
|
+
assert.equal(await exists(path.join(configured, "plugins", "opencode-loop.js")), false)
|
|
73
|
+
assert.equal(await commandCount(configured), 30)
|
|
74
|
+
assert.equal(await exists(path.join(configured, "agents", "opencode-loop-local.md")), true)
|
|
75
|
+
const configuredJson = JSON.parse(await fs.readFile(path.join(configured, "opencode.json"), "utf8"))
|
|
76
|
+
assert.deepEqual(configuredJson.plugin, [expectedPackageSpec], "the installer must bust OpenCode's stale package cache with an exact version spec")
|
|
77
|
+
|
|
78
|
+
const jsonc = path.join(temporaryRoot, "jsonc")
|
|
79
|
+
await fs.mkdir(jsonc, { recursive: true })
|
|
80
|
+
await fs.writeFile(path.join(jsonc, "opencode.jsonc"), `{
|
|
81
|
+
// A configured package is authoritative; a local copy would load twice.
|
|
82
|
+
"plugin": [
|
|
83
|
+
"other-plugin",
|
|
84
|
+
"@bybrawe/opencode-loop",
|
|
85
|
+
],
|
|
86
|
+
}`, "utf8")
|
|
87
|
+
const jsoncResult = await runInstaller(jsonc)
|
|
88
|
+
assert.equal(jsoncResult.code, 0, jsoncResult.stderr)
|
|
89
|
+
assert.equal(await exists(path.join(jsonc, "plugins", "opencode-loop.ts")), false)
|
|
90
|
+
const updatedJsonc = await fs.readFile(path.join(jsonc, "opencode.jsonc"), "utf8")
|
|
91
|
+
assert.match(updatedJsonc, /A configured package is authoritative/, "pinning must preserve JSONC comments")
|
|
92
|
+
assert.match(updatedJsonc, new RegExp(expectedPackageSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
|
|
93
|
+
|
|
94
|
+
const lookalike = path.join(temporaryRoot, "lookalike")
|
|
95
|
+
await fs.mkdir(lookalike, { recursive: true })
|
|
96
|
+
await fs.writeFile(path.join(lookalike, "opencode.json"), JSON.stringify({ plugin: ["@bybrawe/opencode-loop-extra"] }), "utf8")
|
|
97
|
+
const lookalikeResult = await runInstaller(lookalike)
|
|
98
|
+
assert.equal(lookalikeResult.code, 0, lookalikeResult.stderr)
|
|
99
|
+
assert.equal(await exists(path.join(lookalike, "plugins", "opencode-loop.ts")), true)
|
|
100
|
+
|
|
101
|
+
console.log("OpenCode Loop installer test passed")
|
|
102
|
+
} finally {
|
|
103
|
+
await fs.rm(temporaryRoot, { recursive: true, force: true })
|
|
104
|
+
}
|
package/scripts/install.ps1
CHANGED
|
@@ -1,36 +1,10 @@
|
|
|
1
1
|
$ErrorActionPreference = "Stop"
|
|
2
2
|
|
|
3
|
-
$ConfigDir = if ($env:OPENCODE_CONFIG_DIR) { $env:OPENCODE_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".config\opencode" }
|
|
4
|
-
$PluginDir = Join-Path $ConfigDir "plugins"
|
|
5
|
-
$CommandDir = Join-Path $ConfigDir "commands"
|
|
6
3
|
$Root = Split-Path -Parent $PSScriptRoot
|
|
4
|
+
$Installer = Join-Path $Root "scripts\install-node.mjs"
|
|
5
|
+
$Runtime = Get-Command node -ErrorAction SilentlyContinue
|
|
6
|
+
if (-not $Runtime) { $Runtime = Get-Command bun -ErrorAction SilentlyContinue }
|
|
7
|
+
if (-not $Runtime) { throw "OpenCode Loop source installation requires Node.js or Bun." }
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
$PackageFile = Join-Path $ConfigDir "package.json"
|
|
12
|
-
if (Test-Path $PackageFile) {
|
|
13
|
-
try {
|
|
14
|
-
$PackageJson = Get-Content -Raw -Path $PackageFile | ConvertFrom-Json
|
|
15
|
-
if (-not $PackageJson.dependencies) {
|
|
16
|
-
$PackageJson | Add-Member -MemberType NoteProperty -Name dependencies -Value ([pscustomobject]@{})
|
|
17
|
-
}
|
|
18
|
-
if (-not $PackageJson.dependencies.PSObject.Properties["@opencode-ai/plugin"]) {
|
|
19
|
-
$PackageJson.dependencies | Add-Member -MemberType NoteProperty -Name "@opencode-ai/plugin" -Value ">=1.4.0"
|
|
20
|
-
$PackageJson | ConvertTo-Json -Depth 20 | Set-Content -Path $PackageFile -Encoding UTF8
|
|
21
|
-
}
|
|
22
|
-
} catch {
|
|
23
|
-
Write-Warning "Could not update $PackageFile. Add '@opencode-ai/plugin': '>=1.4.0' if OpenCode cannot load the local plugin."
|
|
24
|
-
}
|
|
25
|
-
} else {
|
|
26
|
-
'{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' | Set-Content -Path $PackageFile -Encoding UTF8
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
Copy-Item -Force (Join-Path $Root "src\index.js") (Join-Path $PluginDir "opencode-loop.ts")
|
|
30
|
-
Remove-Item -Force -ErrorAction SilentlyContinue (Join-Path $PluginDir "opencode-loop.js")
|
|
31
|
-
Copy-Item -Force (Join-Path $Root "commands\*.md") $CommandDir
|
|
32
|
-
|
|
33
|
-
Write-Host "Installed OpenCode Loop plugin." -ForegroundColor Green
|
|
34
|
-
Write-Host "Plugin: $PluginDir\opencode-loop.ts"
|
|
35
|
-
Write-Host "Commands: $CommandDir\loop*.md"
|
|
36
|
-
Write-Host "Restart OpenCode, then run: /loop-help"
|
|
9
|
+
& $Runtime.Source $Installer
|
|
10
|
+
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
package/scripts/install.sh
CHANGED
|
@@ -1,42 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env sh
|
|
2
2
|
set -eu
|
|
3
3
|
|
|
4
|
-
CONFIG_DIR="${OPENCODE_CONFIG_DIR:-$HOME/.config/opencode}"
|
|
5
|
-
PLUGIN_DIR="$CONFIG_DIR/plugins"
|
|
6
|
-
COMMAND_DIR="$CONFIG_DIR/commands"
|
|
7
4
|
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
|
8
5
|
|
|
9
|
-
mkdir -p "$PLUGIN_DIR" "$COMMAND_DIR"
|
|
10
|
-
PACKAGE_FILE="$CONFIG_DIR/package.json"
|
|
11
6
|
if command -v node >/dev/null 2>&1; then
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
let pkg = {}
|
|
17
|
-
try {
|
|
18
|
-
pkg = JSON.parse(fs.readFileSync(file, "utf8"))
|
|
19
|
-
} catch (error) {
|
|
20
|
-
if (error.code !== "ENOENT") {
|
|
21
|
-
console.warn(`Could not update ${file}: ${error.message}`)
|
|
22
|
-
process.exit(0)
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) pkg = {}
|
|
26
|
-
if (!pkg.dependencies || typeof pkg.dependencies !== "object" || Array.isArray(pkg.dependencies)) pkg.dependencies = {}
|
|
27
|
-
if (!pkg.dependencies["@opencode-ai/plugin"]) {
|
|
28
|
-
pkg.dependencies["@opencode-ai/plugin"] = ">=1.4.0"
|
|
29
|
-
fs.writeFileSync(file, JSON.stringify(pkg, null, 2) + "\n")
|
|
30
|
-
}
|
|
31
|
-
NODE
|
|
32
|
-
elif [ ! -f "$PACKAGE_FILE" ]; then
|
|
33
|
-
printf '%s\n' '{"dependencies":{"@opencode-ai/plugin":">=1.4.0"}}' > "$PACKAGE_FILE"
|
|
7
|
+
exec node "$ROOT_DIR/scripts/install-node.mjs"
|
|
8
|
+
fi
|
|
9
|
+
if command -v bun >/dev/null 2>&1; then
|
|
10
|
+
exec bun "$ROOT_DIR/scripts/install-node.mjs"
|
|
34
11
|
fi
|
|
35
|
-
cp "$ROOT_DIR/src/index.js" "$PLUGIN_DIR/opencode-loop.ts"
|
|
36
|
-
rm -f "$PLUGIN_DIR/opencode-loop.js"
|
|
37
|
-
cp "$ROOT_DIR/commands"/*.md "$COMMAND_DIR/"
|
|
38
12
|
|
|
39
|
-
echo "
|
|
40
|
-
|
|
41
|
-
echo "Commands: $COMMAND_DIR/loop*.md"
|
|
42
|
-
echo "Restart OpenCode, then run: /loop-help"
|
|
13
|
+
echo "OpenCode Loop source installation requires Node.js or Bun." >&2
|
|
14
|
+
exit 1
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawn } from "node:child_process"
|
|
3
|
+
import { promises as fs } from "node:fs"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
|
|
8
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|
9
|
+
const loopd = path.join(root, "scripts", "loopd.mjs")
|
|
10
|
+
const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-loopd-test-"))
|
|
11
|
+
|
|
12
|
+
function runCli(cliArgs, env = {}) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn(process.execPath, [loopd, ...cliArgs], {
|
|
15
|
+
cwd: root,
|
|
16
|
+
env: { ...process.env, ...env },
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
})
|
|
19
|
+
const stdout = []
|
|
20
|
+
const stderr = []
|
|
21
|
+
child.stdout.on("data", (data) => stdout.push(Buffer.from(data)))
|
|
22
|
+
child.stderr.on("data", (data) => stderr.push(Buffer.from(data)))
|
|
23
|
+
child.on("error", reject)
|
|
24
|
+
child.on("close", (code, signal) => resolve({
|
|
25
|
+
code: code ?? (signal ? 1 : 0),
|
|
26
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
27
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
28
|
+
}))
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function makeFakeCommand(name) {
|
|
33
|
+
const script = path.join(temporaryRoot, `${name}.mjs`)
|
|
34
|
+
const log = path.join(temporaryRoot, `${name}.jsonl`)
|
|
35
|
+
await fs.writeFile(script, `
|
|
36
|
+
import fs from "node:fs"
|
|
37
|
+
const log = process.env.FAKE_COMMAND_LOG
|
|
38
|
+
let previous = []
|
|
39
|
+
try { previous = fs.readFileSync(log, "utf8").trim().split(/\\r?\\n/).filter(Boolean) } catch {}
|
|
40
|
+
fs.appendFileSync(log, JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }) + "\\n")
|
|
41
|
+
const codes = String(process.env.FAKE_EXIT_CODES || "0").split(",").map((value) => Number(value.trim()))
|
|
42
|
+
const code = codes[Math.min(previous.length, codes.length - 1)]
|
|
43
|
+
process.exit(Number.isInteger(code) ? code : 0)
|
|
44
|
+
`, "utf8")
|
|
45
|
+
|
|
46
|
+
if (process.platform === "win32") {
|
|
47
|
+
const command = path.join(temporaryRoot, `${name}.cmd`)
|
|
48
|
+
const escapeCmd = (value) => String(value).replace(/%/g, "%%")
|
|
49
|
+
await fs.writeFile(command, `@echo off\r\n"${escapeCmd(process.execPath)}" "${escapeCmd(script)}" %*\r\nexit /b %errorlevel%\r\n`, "utf8")
|
|
50
|
+
return { command, log }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const command = path.join(temporaryRoot, name)
|
|
54
|
+
await fs.writeFile(command, `#!/bin/sh\nexec "${process.execPath.replace(/"/g, '\\"')}" "${script.replace(/"/g, '\\"')}" "$@"\n`, { mode: 0o755 })
|
|
55
|
+
return { command, log }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readLog(log) {
|
|
59
|
+
try {
|
|
60
|
+
return (await fs.readFile(log, "utf8")).trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line))
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code === "ENOENT") return []
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const project = path.join(temporaryRoot, "project with spaces")
|
|
69
|
+
await fs.mkdir(project, { recursive: true })
|
|
70
|
+
const fakeOpenCode = await makeFakeCommand("fake-opencode")
|
|
71
|
+
|
|
72
|
+
const inlinePrompt = 'continue "quoted text" & keep | characters literal'
|
|
73
|
+
let result = await runCli([
|
|
74
|
+
"--project", project,
|
|
75
|
+
"--every", "0s",
|
|
76
|
+
"--max-runs", "1",
|
|
77
|
+
"--model", "opencode/nemotron-3-ultra-free",
|
|
78
|
+
"--agent", "build",
|
|
79
|
+
"--prompt", inlinePrompt,
|
|
80
|
+
], {
|
|
81
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
82
|
+
FAKE_COMMAND_LOG: fakeOpenCode.log,
|
|
83
|
+
})
|
|
84
|
+
assert.equal(result.code, 0, result.stderr)
|
|
85
|
+
let calls = await readLog(fakeOpenCode.log)
|
|
86
|
+
assert.equal(calls.length, 1)
|
|
87
|
+
assert.equal(path.resolve(calls[0].cwd), path.resolve(project))
|
|
88
|
+
assert.deepEqual(calls[0].args, [
|
|
89
|
+
"run", "--continue",
|
|
90
|
+
"--model", "opencode/nemotron-3-ultra-free",
|
|
91
|
+
"--agent", "build",
|
|
92
|
+
inlinePrompt,
|
|
93
|
+
], "loopd must preserve model, agent, quotes, and shell metacharacters as literal arguments")
|
|
94
|
+
|
|
95
|
+
const promptFile = path.join(project, "goal prompt.md")
|
|
96
|
+
await fs.writeFile(promptFile, "\uFEFFPrompt loaded from a BOM file.", "utf8")
|
|
97
|
+
result = await runCli([
|
|
98
|
+
"--project", project,
|
|
99
|
+
"--every", "0s",
|
|
100
|
+
"--max-runs", "1",
|
|
101
|
+
"--prompt-file", path.basename(promptFile),
|
|
102
|
+
], {
|
|
103
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
104
|
+
FAKE_COMMAND_LOG: fakeOpenCode.log,
|
|
105
|
+
})
|
|
106
|
+
assert.equal(result.code, 0, result.stderr)
|
|
107
|
+
calls = await readLog(fakeOpenCode.log)
|
|
108
|
+
assert.equal(calls.at(-1).args.at(-1), "Prompt loaded from a BOM file.")
|
|
109
|
+
|
|
110
|
+
const failureLog = path.join(temporaryRoot, "failure.jsonl")
|
|
111
|
+
result = await runCli([
|
|
112
|
+
"--project", project,
|
|
113
|
+
"--every", "0s",
|
|
114
|
+
"--max-runs", "1",
|
|
115
|
+
"--prompt", "fail once",
|
|
116
|
+
], {
|
|
117
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
118
|
+
FAKE_COMMAND_LOG: failureLog,
|
|
119
|
+
FAKE_EXIT_CODES: "7",
|
|
120
|
+
OPENCODE_LOOPD_FAILED_RUN_RETRY_MS: "0",
|
|
121
|
+
})
|
|
122
|
+
assert.equal(result.code, 7, "a finite daemon run must propagate the failing OpenCode exit code")
|
|
123
|
+
|
|
124
|
+
result = await runCli(["--project", project, "--max-runs", "not-a-number", "--prompt", "test"], {
|
|
125
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
126
|
+
FAKE_COMMAND_LOG: path.join(temporaryRoot, "invalid-count.jsonl"),
|
|
127
|
+
})
|
|
128
|
+
assert.equal(result.code, 1)
|
|
129
|
+
assert.match(result.stderr, /Invalid --max-runs value/)
|
|
130
|
+
|
|
131
|
+
result = await runCli(["--project", path.join(temporaryRoot, "missing-project"), "--max-runs", "1", "--prompt", "test"], {
|
|
132
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
133
|
+
FAKE_COMMAND_LOG: path.join(temporaryRoot, "missing-project.jsonl"),
|
|
134
|
+
})
|
|
135
|
+
assert.equal(result.code, 1)
|
|
136
|
+
assert.match(result.stderr, /Project directory does not exist/)
|
|
137
|
+
|
|
138
|
+
if (process.platform === "win32") {
|
|
139
|
+
const fakeTasks = await makeFakeCommand("fake-schtasks")
|
|
140
|
+
const taskName = "OpenCodeLoop-Test"
|
|
141
|
+
result = await runCli([
|
|
142
|
+
"install-task",
|
|
143
|
+
"--project", project,
|
|
144
|
+
"--every", "90s",
|
|
145
|
+
"--name", taskName,
|
|
146
|
+
"--prompt", "scheduled task prompt",
|
|
147
|
+
"--model", "opencode/nemotron-3-ultra-free",
|
|
148
|
+
"--agent", "build",
|
|
149
|
+
], {
|
|
150
|
+
SCHTASKS_BIN: fakeTasks.command,
|
|
151
|
+
FAKE_COMMAND_LOG: fakeTasks.log,
|
|
152
|
+
OPENCODE_BIN: fakeOpenCode.command,
|
|
153
|
+
OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "task-files"),
|
|
154
|
+
})
|
|
155
|
+
assert.equal(result.code, 0, result.stderr)
|
|
156
|
+
const taskCalls = await readLog(fakeTasks.log)
|
|
157
|
+
assert.equal(taskCalls.length, 1)
|
|
158
|
+
const taskArgs = taskCalls[0].args
|
|
159
|
+
assert.equal(taskArgs[0], "/Create")
|
|
160
|
+
assert.equal(taskArgs[taskArgs.indexOf("/MO") + 1], "2")
|
|
161
|
+
assert.equal(taskArgs[taskArgs.indexOf("/TN") + 1], taskName)
|
|
162
|
+
const taskCommand = taskArgs[taskArgs.indexOf("/TR") + 1]
|
|
163
|
+
assert.ok(taskCommand.length <= 261, "the Task Scheduler /TR command must stay within its documented limit")
|
|
164
|
+
assert.match(taskCommand, /^cmd\.exe \/d \/s \/c /)
|
|
165
|
+
const taskFiles = await fs.readdir(path.join(temporaryRoot, "task-files"))
|
|
166
|
+
const taskConfigFile = path.join(temporaryRoot, "task-files", taskFiles.find((name) => name.endsWith(".json")))
|
|
167
|
+
const taskConfig = JSON.parse(await fs.readFile(taskConfigFile, "utf8"))
|
|
168
|
+
assert.equal(taskConfig.project, project)
|
|
169
|
+
assert.equal(taskConfig.prompt, "scheduled task prompt")
|
|
170
|
+
assert.equal(taskConfig.model, "opencode/nemotron-3-ultra-free")
|
|
171
|
+
assert.equal(taskConfig.agent, "build")
|
|
172
|
+
assert.equal(taskConfig.opencodeBin, fakeOpenCode.command)
|
|
173
|
+
|
|
174
|
+
const taskRunLog = path.join(temporaryRoot, "task-run.jsonl")
|
|
175
|
+
result = await runCli(["task-run", "--config", taskConfigFile], {
|
|
176
|
+
FAKE_COMMAND_LOG: taskRunLog,
|
|
177
|
+
})
|
|
178
|
+
assert.equal(result.code, 0, result.stderr)
|
|
179
|
+
const taskRunCall = (await readLog(taskRunLog))[0]
|
|
180
|
+
assert.deepEqual(taskRunCall.args, [
|
|
181
|
+
"run", "--continue",
|
|
182
|
+
"--model", "opencode/nemotron-3-ultra-free",
|
|
183
|
+
"--agent", "build",
|
|
184
|
+
"scheduled task prompt",
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
result = await runCli(["uninstall-task", "--name", taskName], {
|
|
188
|
+
SCHTASKS_BIN: fakeTasks.command,
|
|
189
|
+
FAKE_COMMAND_LOG: fakeTasks.log,
|
|
190
|
+
OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "task-files"),
|
|
191
|
+
})
|
|
192
|
+
assert.equal(result.code, 0, result.stderr)
|
|
193
|
+
const uninstallArgs = (await readLog(fakeTasks.log)).at(-1).args
|
|
194
|
+
assert.deepEqual(uninstallArgs, ["/Delete", "/F", "/TN", taskName])
|
|
195
|
+
assert.deepEqual(await fs.readdir(path.join(temporaryRoot, "task-files")), [], "uninstall-task must remove its launcher and config")
|
|
196
|
+
|
|
197
|
+
result = await runCli(["install-task", "--project", project, "--name", taskName, "--prompt", "test"], {
|
|
198
|
+
SCHTASKS_BIN: path.join(temporaryRoot, "missing-schtasks.exe"),
|
|
199
|
+
OPENCODE_LOOPD_TASK_DIR: path.join(temporaryRoot, "missing-task-files"),
|
|
200
|
+
})
|
|
201
|
+
assert.equal(result.code, 1, "a missing Task Scheduler executable must not report success")
|
|
202
|
+
assert.match(result.stderr, /failed to start/i)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
console.log("OpenCode Loop daemon test passed")
|
|
206
|
+
} finally {
|
|
207
|
+
await fs.rm(temporaryRoot, { recursive: true, force: true })
|
|
208
|
+
}
|