force-agent 0.0.1 → 0.6.1
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/README.md +15 -9
- package/bin/force.cjs +228 -0
- package/package.json +42 -6
- package/postinstall.mjs +53 -0
package/README.md
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
# force-agent
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
install
|
|
3
|
+
```sh
|
|
4
|
+
npx -y force-agent@latest web # start the server and print how to open the web UI
|
|
5
|
+
npm i -g force-agent # install the command
|
|
6
|
+
force service start # run it in the background
|
|
7
|
+
```
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
README will say so.
|
|
9
|
+
Version 0.6.1.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
The command is a small Node shim. It resolves the platform binary from the
|
|
12
|
+
matching `@force-agent/cli-<os>-<arch>` optional dependency at run time, so an
|
|
13
|
+
install with `--ignore-scripts` works exactly like one without. Set
|
|
14
|
+
`FORCE_BIN_PATH` to run a binary from somewhere else (`LABHARNESS_BIN_PATH`,
|
|
15
|
+
`LABFY_BIN_PATH`, `POWER_BIN_PATH` and `OPENCODE_BIN_PATH` are still honored, in
|
|
16
|
+
that order).
|
|
13
17
|
|
|
14
|
-
|
|
18
|
+
Published targets: linux-x64, linux-arm64, darwin-arm64, windows-x64.
|
|
19
|
+
|
|
20
|
+
https://labfy.dev
|
package/bin/force.cjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// force-agent overlay: the npm `bin` target for the `force-agent` distribution package.
|
|
3
|
+
// The command is `force`; the package that carries it is `force-agent`.
|
|
4
|
+
//
|
|
5
|
+
// Why a Node shim instead of pointing `bin` straight at the executable:
|
|
6
|
+
//
|
|
7
|
+
// 1. npm generates the Windows `.cmd`/`.ps1` wrappers from the shebang of the
|
|
8
|
+
// bin target at LINK time, which happens BEFORE `postinstall` runs. Upstream
|
|
9
|
+
// ships a placeholder shell script at `bin/<name>.exe` and has postinstall
|
|
10
|
+
// overwrite it with the real binary; the wrappers npm already wrote are then
|
|
11
|
+
// wrong-ish by luck rather than by design, and `npm install --ignore-scripts`
|
|
12
|
+
// leaves the placeholder in place, so the command prints an error forever.
|
|
13
|
+
// 2. This file has a real `#!/usr/bin/env node` shebang, so the wrappers npm
|
|
14
|
+
// writes are correct on every platform and never need rewriting.
|
|
15
|
+
// 3. Resolution happens at RUNTIME, from the optional dependency that npm
|
|
16
|
+
// already installed. `--ignore-scripts` changes nothing.
|
|
17
|
+
//
|
|
18
|
+
// `postinstall.mjs` is a pure optimization (it hardlinks the platform binary next
|
|
19
|
+
// to this file so the resolve walk is skipped). It must never be a requirement.
|
|
20
|
+
|
|
21
|
+
"use strict"
|
|
22
|
+
|
|
23
|
+
const childProcess = require("child_process")
|
|
24
|
+
const fs = require("fs")
|
|
25
|
+
const os = require("os")
|
|
26
|
+
const path = require("path")
|
|
27
|
+
|
|
28
|
+
const command = "force"
|
|
29
|
+
const scope = "@force-agent"
|
|
30
|
+
// Every platform package this distribution publishes. Keep in sync with the
|
|
31
|
+
// `allTargets` list in packages/cli/script/build.ts.
|
|
32
|
+
const published = ["linux-x64", "linux-arm64", "darwin-arm64", "windows-x64"]
|
|
33
|
+
|
|
34
|
+
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
|
|
35
|
+
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
|
36
|
+
const executable = platform === "windows" ? command + ".exe" : command
|
|
37
|
+
const scriptDir = (() => {
|
|
38
|
+
try {
|
|
39
|
+
return path.dirname(fs.realpathSync(__filename))
|
|
40
|
+
} catch {
|
|
41
|
+
return __dirname
|
|
42
|
+
}
|
|
43
|
+
})()
|
|
44
|
+
|
|
45
|
+
function isMusl() {
|
|
46
|
+
if (platform !== "linux") return false
|
|
47
|
+
try {
|
|
48
|
+
if (fs.existsSync("/etc/alpine-release")) return true
|
|
49
|
+
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
|
50
|
+
return ((result.stdout || "") + (result.stderr || "")).toLowerCase().includes("musl")
|
|
51
|
+
} catch {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Candidate order: the exact host slug first, then any slug whose binaries this
|
|
57
|
+
// host can still execute. Slugs that are not published are dropped, so adding a
|
|
58
|
+
// target to `published` is all a new variant needs.
|
|
59
|
+
function candidates() {
|
|
60
|
+
const musl = isMusl()
|
|
61
|
+
const order = [
|
|
62
|
+
musl ? platform + "-" + arch + "-musl" : undefined,
|
|
63
|
+
platform + "-" + arch,
|
|
64
|
+
// Node reports x64 when it is itself running under Rosetta on Apple silicon;
|
|
65
|
+
// the arm64 build is the native one and spawns fine from a translated parent.
|
|
66
|
+
platform === "darwin" && arch === "x64" ? "darwin-arm64" : undefined,
|
|
67
|
+
// Windows on ARM executes x64 images through its built-in emulation layer.
|
|
68
|
+
platform === "windows" && arch === "arm64" ? "windows-x64" : undefined,
|
|
69
|
+
]
|
|
70
|
+
return order
|
|
71
|
+
.filter((slug, index) => slug !== undefined && published.includes(slug) && order.indexOf(slug) === index)
|
|
72
|
+
.map((slug) => scope + "/cli-" + slug)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Resolution 1: the package as Node itself resolves it. Handles hoisting,
|
|
76
|
+
// nesting, pnpm's symlinked store and a global install root in one call.
|
|
77
|
+
function resolveByRequire(name) {
|
|
78
|
+
try {
|
|
79
|
+
const manifest = require.resolve(name + "/package.json", { paths: [scriptDir, process.cwd()] })
|
|
80
|
+
return path.join(path.dirname(manifest), "bin", executable)
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Resolution 2: walk node_modules upward. Covers layouts where the platform
|
|
87
|
+
// package is present on disk but unreachable from this file's resolution paths
|
|
88
|
+
// (a package published without the dependency edge, a manual drop-in).
|
|
89
|
+
function resolveByWalk(names, startDir) {
|
|
90
|
+
let current = startDir
|
|
91
|
+
for (;;) {
|
|
92
|
+
for (const name of names) {
|
|
93
|
+
const candidate = path.join(current, "node_modules", ...name.split("/"), "bin", executable)
|
|
94
|
+
if (fs.existsSync(candidate)) return candidate
|
|
95
|
+
}
|
|
96
|
+
const parent = path.dirname(current)
|
|
97
|
+
if (parent === current) return undefined
|
|
98
|
+
current = parent
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The branded override, most specific first: the current FORCE_AGENT_ brand, then each
|
|
103
|
+
// earlier one — LABHARNESS_, LABFY_, POWER_ — then upstream's OPENCODE_. Dropping
|
|
104
|
+
// any fallback would silently ignore an override an existing install relies on.
|
|
105
|
+
const binPathKeys = [
|
|
106
|
+
"FORCE_AGENT_BIN_PATH",
|
|
107
|
+
"LABHARNESS_BIN_PATH",
|
|
108
|
+
"LABFY_BIN_PATH",
|
|
109
|
+
"POWER_BIN_PATH",
|
|
110
|
+
"OPENCODE_BIN_PATH",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
function binPathOverride() {
|
|
114
|
+
for (const key of binPathKeys) {
|
|
115
|
+
const value = process.env[key]
|
|
116
|
+
if (value) return { key: key, value: value }
|
|
117
|
+
}
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolve(names) {
|
|
122
|
+
const override = binPathOverride()
|
|
123
|
+
if (override) {
|
|
124
|
+
if (!fs.existsSync(override.value)) fail(override.key + " points at a missing file: " + override.value)
|
|
125
|
+
return override.value
|
|
126
|
+
}
|
|
127
|
+
// Written by the optional postinstall hardlink. Never required.
|
|
128
|
+
const cached = path.join(scriptDir, "." + command + (platform === "windows" ? ".exe" : ""))
|
|
129
|
+
if (fs.existsSync(cached)) return cached
|
|
130
|
+
for (const name of names) {
|
|
131
|
+
const resolved = resolveByRequire(name)
|
|
132
|
+
if (resolved && fs.existsSync(resolved)) return resolved
|
|
133
|
+
}
|
|
134
|
+
return resolveByWalk(names, scriptDir) || resolveByWalk(names, process.cwd())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function fail(message) {
|
|
138
|
+
process.stderr.write(message + "\n")
|
|
139
|
+
process.exit(1)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const forwarded = platform === "windows" ? ["SIGINT", "SIGTERM", "SIGHUP"] : ["SIGINT", "SIGTERM", "SIGHUP", "SIGUSR1"]
|
|
143
|
+
|
|
144
|
+
// Self-update restart: the server installs the new version, then exits with
|
|
145
|
+
// this code and the shim re-resolves the binary (the package on disk is the new
|
|
146
|
+
// one by now) and runs it again with the same argv. Bounded so a build that
|
|
147
|
+
// keeps asking to restart cannot loop forever: at most `restartLimit` restarts,
|
|
148
|
+
// and a restarted process that asks again within `restartMinUptimeMs` is a
|
|
149
|
+
// crash loop, not an update.
|
|
150
|
+
const restartCode = 75
|
|
151
|
+
const restartLimit = 3
|
|
152
|
+
const restartMinUptimeMs = 5000
|
|
153
|
+
let restarts = 0
|
|
154
|
+
|
|
155
|
+
function run(target) {
|
|
156
|
+
const started = Date.now()
|
|
157
|
+
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
|
|
158
|
+
child.on("error", (error) => {
|
|
159
|
+
// npm can land the tarball without the executable bit on some filesystems;
|
|
160
|
+
// the upstream distribution relied on postinstall to chmod, and this one
|
|
161
|
+
// does not run postinstall at all when scripts are disabled.
|
|
162
|
+
if (error && error.code === "EACCES") {
|
|
163
|
+
try {
|
|
164
|
+
fs.chmodSync(target, 0o755)
|
|
165
|
+
return run(target)
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
fail(error && error.message ? error.message : String(error))
|
|
169
|
+
})
|
|
170
|
+
const handlers = {}
|
|
171
|
+
for (const signal of forwarded) {
|
|
172
|
+
handlers[signal] = () => {
|
|
173
|
+
try {
|
|
174
|
+
child.kill(signal)
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
process.on(signal, handlers[signal])
|
|
178
|
+
}
|
|
179
|
+
child.on("exit", (code, signal) => {
|
|
180
|
+
for (const name of forwarded) process.removeListener(name, handlers[name])
|
|
181
|
+
if (signal) return process.kill(process.pid, signal)
|
|
182
|
+
if (code === restartCode) {
|
|
183
|
+
const uptime = Date.now() - started
|
|
184
|
+
if (restarts > 0 && uptime < restartMinUptimeMs)
|
|
185
|
+
fail(
|
|
186
|
+
"force asked to restart again " +
|
|
187
|
+
uptime +
|
|
188
|
+
" ms after it was restarted; not restarting (crash loop). Start it again by hand.",
|
|
189
|
+
)
|
|
190
|
+
if (restarts >= restartLimit)
|
|
191
|
+
fail(
|
|
192
|
+
"force asked to restart " + (restarts + 1) + " times in a row; not restarting. Start it again by hand.",
|
|
193
|
+
)
|
|
194
|
+
restarts += 1
|
|
195
|
+
const next = resolve(names)
|
|
196
|
+
if (!next)
|
|
197
|
+
fail("force could not find its platform binary after the update. Reinstall it with `npm i -g force-agent`.")
|
|
198
|
+
return run(next)
|
|
199
|
+
}
|
|
200
|
+
process.exit(typeof code === "number" ? code : 0)
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const names = candidates()
|
|
205
|
+
if (names.length === 0)
|
|
206
|
+
fail(
|
|
207
|
+
"force-agent does not publish a build for " +
|
|
208
|
+
platform +
|
|
209
|
+
"-" +
|
|
210
|
+
arch +
|
|
211
|
+
(isMusl() ? " (musl libc)" : "") +
|
|
212
|
+
". Published targets: " +
|
|
213
|
+
published.join(", ") +
|
|
214
|
+
".",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const resolved = resolve(names)
|
|
218
|
+
if (!resolved)
|
|
219
|
+
fail(
|
|
220
|
+
"force could not find its platform binary. Install " +
|
|
221
|
+
names.map((name) => JSON.stringify(name)).join(" or ") +
|
|
222
|
+
", or point FORCE_AGENT_BIN_PATH at the executable." +
|
|
223
|
+
(isMusl()
|
|
224
|
+
? "\nThis host uses musl libc (Alpine). force-agent publishes glibc builds only; run it on a glibc image or install gcompat."
|
|
225
|
+
: ""),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
run(resolved)
|
package/package.json
CHANGED
|
@@ -1,14 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "force-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.1",
|
|
4
|
+
"description": "Force Agent CLI: a coding agent that runs as a background service with a web UI.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"author": "Labfy (https://labfy.dev)",
|
|
7
6
|
"homepage": "https://labfy.dev",
|
|
8
7
|
"repository": {
|
|
9
8
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/
|
|
9
|
+
"url": "git+https://github.com/force-agent/force-agent.git"
|
|
11
10
|
},
|
|
12
|
-
"
|
|
13
|
-
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/force-agent/force-agent/issues"
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"force": "./bin/force.cjs"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"postinstall": "node ./postinstall.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin",
|
|
22
|
+
"postinstall.mjs",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"os": [
|
|
29
|
+
"darwin",
|
|
30
|
+
"linux",
|
|
31
|
+
"win32"
|
|
32
|
+
],
|
|
33
|
+
"cpu": [
|
|
34
|
+
"arm64",
|
|
35
|
+
"x64"
|
|
36
|
+
],
|
|
37
|
+
"keywords": [
|
|
38
|
+
"ai",
|
|
39
|
+
"agent",
|
|
40
|
+
"cli",
|
|
41
|
+
"coding-agent",
|
|
42
|
+
"force-agent"
|
|
43
|
+
],
|
|
44
|
+
"optionalDependencies": {
|
|
45
|
+
"@force-agent/cli-darwin-arm64": "0.6.1",
|
|
46
|
+
"@force-agent/cli-linux-arm64": "0.6.1",
|
|
47
|
+
"@force-agent/cli-linux-x64": "0.6.1",
|
|
48
|
+
"@force-agent/cli-windows-x64": "0.6.1"
|
|
49
|
+
}
|
|
14
50
|
}
|
package/postinstall.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// force-agent overlay: OPTIONAL install-time optimization for the `force-agent`
|
|
3
|
+
// distribution package.
|
|
4
|
+
//
|
|
5
|
+
// It hardlinks (or copies) the platform binary next to the shim as
|
|
6
|
+
// `bin/.force[.exe]` so `bin/force.cjs` can skip the resolve walk.
|
|
7
|
+
// Everything here is best effort: `bin/force.cjs` resolves the binary at
|
|
8
|
+
// runtime on its own, so this script must NEVER fail an install. It always
|
|
9
|
+
// exits 0, and it never touches the `bin` target npm has already linked.
|
|
10
|
+
|
|
11
|
+
import childProcess from "node:child_process"
|
|
12
|
+
import fs from "node:fs"
|
|
13
|
+
import os from "node:os"
|
|
14
|
+
import path from "node:path"
|
|
15
|
+
import { createRequire } from "node:module"
|
|
16
|
+
import { fileURLToPath } from "node:url"
|
|
17
|
+
|
|
18
|
+
const directory = path.dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const require = createRequire(import.meta.url)
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"))
|
|
23
|
+
const command = Object.keys(manifest.bin ?? {})[0]
|
|
24
|
+
const dependencies = manifest.optionalDependencies ?? {}
|
|
25
|
+
if (command) {
|
|
26
|
+
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
|
|
27
|
+
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
|
|
28
|
+
const executable = platform === "windows" ? `${command}.exe` : command
|
|
29
|
+
const target = path.join(directory, "bin", `.${executable}`)
|
|
30
|
+
const names = Object.keys(dependencies).filter((name) => name.endsWith(`-${platform}-${arch}`))
|
|
31
|
+
for (const name of names) {
|
|
32
|
+
try {
|
|
33
|
+
const source = path.join(path.dirname(require.resolve(`${name}/package.json`)), "bin", executable)
|
|
34
|
+
if (!fs.existsSync(source)) continue
|
|
35
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
36
|
+
if (fs.existsSync(target)) fs.rmSync(target, { force: true })
|
|
37
|
+
try {
|
|
38
|
+
fs.linkSync(source, target)
|
|
39
|
+
} catch {
|
|
40
|
+
fs.copyFileSync(source, target)
|
|
41
|
+
}
|
|
42
|
+
fs.chmodSync(target, 0o755)
|
|
43
|
+
const check = childProcess.spawnSync(target, ["--version"], { stdio: "ignore", windowsHide: true })
|
|
44
|
+
if (check.status === 0) break
|
|
45
|
+
fs.rmSync(target, { force: true })
|
|
46
|
+
} catch {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Deliberately silent: the runtime shim is the contract, this is the shortcut.
|
|
53
|
+
}
|