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 CHANGED
@@ -1,14 +1,20 @@
1
1
  # force-agent
2
2
 
3
- **This package is a placeholder.** The name is reserved for an upcoming coding
4
- agent by [Labfy](https://labfy.dev). It ships no code and does nothing if you
5
- install it.
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
- There is no release to use yet. When there is one, it will land here and this
8
- README will say so.
9
+ Version 0.6.1.
9
10
 
10
- Meanwhile, the agent we do ship is [labharness](https://github.com/labfydev/labharness)
11
- a coding agent that runs as a service on your machine and that you reach through
12
- the browser.
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
- MIT.
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.0.1",
4
- "description": "Name reserved for an upcoming coding agent by Labfy. Not functional yet.",
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/labfydev/force-agent.git"
9
+ "url": "git+https://github.com/force-agent/force-agent.git"
11
10
  },
12
- "keywords": ["agent", "coding-agent", "placeholder"],
13
- "files": ["README.md"]
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
  }
@@ -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
+ }