@gitlawb/zero 0.0.0 → 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gitlawb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,8 +1,369 @@
1
- # @gitlawb/zero
1
+ <p align="center">
2
+ <img src="docs/assets/zero-logo.png" alt="Zero" width="385">
3
+ </p>
2
4
 
3
- This version (0.0.0) is a placeholder that reserves the package name and
4
- enables npm trusted publishing for automated releases.
5
+ <p align="center"><strong>A terminal coding agent you own.</strong></p>
5
6
 
6
- **Zero** — a terminal coding agent you own — ships here starting with v0.1.0.
7
+ <p align="center">
8
+ <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
9
+ <img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
10
+ <img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
11
+ <br>
12
+ <strong>English</strong> | <a href="README_ZH.md">中文</a>
13
+ </p>
7
14
 
8
- Until then, see [github.com/Gitlawb/zero](https://github.com/Gitlawb/zero).
15
+ Zero is an AI coding agent for your local terminal. It can inspect a repository,
16
+ edit files, run commands, use browser/terminal helpers, and keep durable local
17
+ sessions while you choose the model and the permission level.
18
+
19
+ ```bash
20
+ zero
21
+ zero exec "fix the failing test in ./pkg"
22
+ zero exec --output-format stream-json < turns.jsonl
23
+ ```
24
+
25
+ ## Why Zero
26
+
27
+ - **Use the model you want.** Bring OpenAI, Anthropic, Gemini, Groq, OpenRouter,
28
+ DeepSeek, Mistral, xAI, Qwen, Kimi, GitHub Models, Ollama, LM Studio, or any
29
+ OpenAI-/Anthropic-compatible endpoint.
30
+ - **Stay in control.** File writes, shell commands, network access, and
31
+ out-of-workspace writes go through Zero's permission and sandbox policy.
32
+ - **Works in the terminal.** The TUI has model/provider pickers, image input,
33
+ slash commands, live plan/tool rendering, scrollback, themes, and resume/fork
34
+ support.
35
+ - **Works without the TUI.** `zero exec` is scriptable, supports text/JSON/
36
+ stream-JSON I/O, isolated worktrees, spec-first runs, and meaningful exit
37
+ codes for CI.
38
+ - **Keeps context local.** Sessions are stored on disk, searchable, resumable,
39
+ and never uploaded as telemetry by Zero.
40
+ - **Extensible when you need it.** Use MCP servers, skills, plugins, hooks, and
41
+ specialist subagents from the same CLI.
42
+
43
+ ## Install
44
+
45
+ ### npm
46
+
47
+ ```bash
48
+ npm install -g @gitlawb/zero
49
+ zero
50
+ ```
51
+
52
+ The npm package installs a small wrapper plus the matching Zero binary for your
53
+ platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
54
+ arm64.
55
+
56
+ ### Bun
57
+
58
+ Bun does not run dependency lifecycle scripts by default, so the `postinstall`
59
+ that fetches the Zero binary is skipped and the first run fails with
60
+ `No native binary found next to the npm wrapper`.
61
+
62
+ The simplest fix is to trust the package after installing, which runs the
63
+ blocked postinstall. This works for project and global installs:
64
+
65
+ ```bash
66
+ # project install
67
+ bun add @gitlawb/zero
68
+ bun pm trust @gitlawb/zero
69
+
70
+ # global install
71
+ bun add -g @gitlawb/zero
72
+ bun pm -g trust @gitlawb/zero
73
+ ```
74
+
75
+ Alternatives: allow the postinstall up front by adding
76
+ `"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
77
+ before `bun add`, or run the installer manually
78
+ (`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
79
+ that do not have `bun pm trust`.
80
+
81
+ ### Install scripts
82
+
83
+ Linux/macOS:
84
+
85
+ ```bash
86
+ curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
87
+ ```
88
+
89
+ Windows PowerShell:
90
+
91
+ ```powershell
92
+ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
93
+ ```
94
+
95
+ ### From source
96
+
97
+ Source builds require Go 1.25+.
98
+
99
+ ```bash
100
+ git clone https://github.com/Gitlawb/zero.git
101
+ cd zero
102
+ go run ./cmd/zero
103
+ ```
104
+
105
+ Release installers and the npm wrapper require published GitHub Release assets.
106
+ If you are testing before the first public release, build from source:
107
+
108
+ ```bash
109
+ go build -o zero ./cmd/zero
110
+ ```
111
+
112
+ On Linux, build the sandbox helper too if you want native sandboxing:
113
+
114
+ ```bash
115
+ go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
116
+ go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
117
+ ```
118
+
119
+ Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
120
+ (`~/.local/bin` is a good default). macOS does not need an extra helper binary.
121
+ Windows source builds can use the main `zero.exe` as their sandbox helper; release
122
+ archives still ship standalone Windows helper executables.
123
+
124
+ More install details: [docs/INSTALL.md](docs/INSTALL.md).
125
+
126
+ ## First Run
127
+
128
+ Start the TUI:
129
+
130
+ ```bash
131
+ zero
132
+ ```
133
+
134
+ The setup wizard helps you pick a provider and model. You can also configure
135
+ providers from the command line:
136
+
137
+ ```bash
138
+ zero setup
139
+ zero providers list
140
+ zero models list
141
+ zero doctor
142
+ ```
143
+
144
+ For API providers, set the matching environment variable before setup or enter
145
+ the key in the wizard:
146
+
147
+ ```bash
148
+ export OPENAI_API_KEY=sk-...
149
+ export ANTHROPIC_API_KEY=...
150
+ export GEMINI_API_KEY=...
151
+ export LONGCAT_API_KEY=...
152
+ ```
153
+
154
+ To configure Meituan LongCat (LongCat-2.0) directly, run:
155
+
156
+ ```bash
157
+ zero providers setup longcat --set-active
158
+ ```
159
+
160
+ For local models, run Ollama or LM Studio and then use `zero setup` or
161
+ `zero providers detect`.
162
+
163
+ ## Daily Use
164
+
165
+ ### Interactive TUI
166
+
167
+ ```bash
168
+ zero
169
+ ```
170
+
171
+ Useful controls:
172
+
173
+ | Control | Action |
174
+ |---|---|
175
+ | `Enter` | send the prompt |
176
+ | `/` | open slash-command suggestions |
177
+ | `Shift+Tab` | cycle permission mode |
178
+ | `Ctrl+B` | show/hide the sidebar |
179
+ | `Ctrl+C` | cancel or exit |
180
+
181
+ Common slash commands:
182
+
183
+ | Command | Purpose |
184
+ |---|---|
185
+ | `/model`, `/provider` | switch the active model/provider |
186
+ | `/spec`, `/plan` | draft and review a plan before building |
187
+ | `/image` | attach an image for vision-capable models |
188
+ | `/resume`, `/rewind` | continue or roll back local sessions |
189
+ | `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
190
+ | `/compact`, `/context` | manage context usage |
191
+ | `/permissions`, `/tools` | inspect available tools and policy |
192
+ | `/add-dir` | allow an extra write directory for this session |
193
+ | `/theme`, `/doctor`, `/config` | adjust appearance and inspect setup |
194
+
195
+ ### Headless `exec`
196
+
197
+ ```bash
198
+ zero exec "explain internal/agent/loop.go"
199
+ zero exec --model claude-sonnet-4.5 "refactor the config loader"
200
+ zero exec --use-spec "add rate limiting to the API client"
201
+ zero exec --worktree "try the migration in an isolated worktree"
202
+ zero exec --resume
203
+ zero exec --fork <session-id> "try the other approach"
204
+ ```
205
+
206
+ Programmatic use:
207
+
208
+ ```bash
209
+ zero exec --input-format stream-json --output-format stream-json < turns.jsonl
210
+ ```
211
+
212
+ The stream-JSON contract is documented in
213
+ [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md).
214
+
215
+ ## Safety Model
216
+
217
+ Zero is designed to make side effects visible.
218
+
219
+ - Workspace reads are allowed by default.
220
+ - File writes are limited to the workspace unless you grant another directory.
221
+ - Shell commands, network access, destructive commands, and elevated actions are
222
+ permission-gated.
223
+ - `--add-dir <path>` and `/add-dir <path>` grant additional write roots without
224
+ giving the agent the whole filesystem.
225
+ - Unsafe/autonomous modes are explicit opt-ins.
226
+ - Secrets are redacted from tool output and logs where Zero controls the surface.
227
+
228
+ Example:
229
+
230
+ ```bash
231
+ zero --add-dir ../docs-site
232
+ zero exec --add-dir ../shared "update both repos"
233
+ ```
234
+
235
+ Sandbox behavior can be inspected with:
236
+
237
+ ```bash
238
+ zero sandbox policy
239
+ zero sandbox grants list
240
+ ```
241
+
242
+ ## Web And Local Control
243
+
244
+ Zero includes local file/search/edit/shell tools, `web_fetch` for public URLs,
245
+ and MCP support for additional tools.
246
+
247
+ For local dev servers, use shell commands such as `curl` through `exec_command`
248
+ so the normal sandbox and permission policy applies. Long-running commands stay
249
+ attached to a background terminal session and can be listed or stopped from the
250
+ TUI.
251
+
252
+ The npm package also includes browser and terminal helper packages used by local
253
+ browser/terminal tools. Source builds can use the same helpers when they are on
254
+ `PATH` or configured in Zero's local-control settings.
255
+
256
+ ## Common Commands
257
+
258
+ ```text
259
+ zero interactive TUI
260
+ zero exec one-shot or scripted agent run
261
+ zero setup first-run provider setup
262
+ zero auth OAuth/login helpers for supported providers
263
+ zero models model registry and capabilities
264
+ zero providers provider profiles and detection
265
+ zero doctor setup, key, and connectivity checks
266
+ zero context context-budget report
267
+ zero repo-map deterministic repository map
268
+ zero repo-info local repository summary
269
+ zero search | find search local session history
270
+ zero sessions inspect, resume, fork, and rewind sessions
271
+ zero spec manage spec-mode drafts
272
+ zero specialist manage specialist subagents
273
+ zero skills manage markdown instruction skills
274
+ zero plugins manage plugins
275
+ zero hooks manage lifecycle hooks
276
+ zero mcp manage MCP servers and tools
277
+ zero serve --mcp expose Zero tools over MCP stdio
278
+ zero sandbox inspect sandbox policy and grants
279
+ zero worktrees prepare isolated git worktrees
280
+ zero verify detect and run local verification checks
281
+ zero changes inspect and commit local git changes
282
+ zero usage token usage and estimated cost
283
+ zero cron scheduled agent jobs
284
+ zero update check for newer releases
285
+ ```
286
+
287
+ ## Extending Zero
288
+
289
+ ### Project and personal instructions
290
+
291
+ Zero appends project-specific guidance to the system prompt from the first
292
+ `AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
293
+ the git root down to your current working directory (checked in that order
294
+ per directory). Files are injected general-to-specific, capped at 8 KiB per
295
+ file and 32 KiB total.
296
+
297
+ A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
298
+ (`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
299
+ `%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
300
+
301
+ ### Plugins
302
+
303
+ Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
304
+ scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
305
+ `config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
306
+ (project scope — resolved from the current working directory, not the repo
307
+ root), and managed with `zero plugins`. A manifest can declare:
308
+
309
+ - `tools` — custom tools (`command`, `args`, `inputSchema`, and a
310
+ `permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
311
+ auto-approval is enabled)
312
+ - `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
313
+ `sessionEnd`
314
+ - `prompts` and `skills` — additional prompt/skill files
315
+
316
+ MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
317
+ the same extension points and can also be wired up outside of a plugin
318
+ manifest.
319
+
320
+ ## Appearance And Accessibility
321
+
322
+ | Control | Effect |
323
+ |---|---|
324
+ | `NO_COLOR=<anything>` | disables color output |
325
+ | `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
326
+ | `--theme <name>` | selects the TUI theme from the CLI (same names) |
327
+ | `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
328
+ | `ZERO_NO_FADE=1` | disables streaming fade animation |
329
+
330
+ Meaning does not rely on color alone; diffs, permissions, and statuses also use
331
+ text or glyph markers.
332
+
333
+ ## Development
334
+
335
+ ```bash
336
+ go test ./...
337
+ go run ./cmd/zero-release build
338
+ go run ./cmd/zero-release smoke
339
+ go run ./cmd/zero-perf-bench
340
+ ```
341
+
342
+ Cross-compile examples:
343
+
344
+ ```bash
345
+ go run ./cmd/zero-release build --goos linux --goarch amd64
346
+ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero.exe
347
+ ```
348
+
349
+ ## Documentation
350
+
351
+ - [Install](docs/INSTALL.md)
352
+ - [Update flow](docs/UPDATE.md)
353
+ - [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
354
+ - [Specialists](docs/SPECIALISTS.md)
355
+ - [GitHub Action](docs/GITHUB_ACTION.md)
356
+ - [Benchmarks](docs/BENCHMARK.md)
357
+ - [Performance](docs/PERFORMANCE.md)
358
+ - [Agent evals](docs/AGENT_EVALS.md)
359
+
360
+ ## Contributing
361
+
362
+ Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
363
+ relevant tests, and open a focused pull request.
364
+
365
+ Security reports should follow [SECURITY.md](SECURITY.md).
366
+
367
+ ## License
368
+
369
+ Zero is released under the [MIT License](LICENSE).
package/bin/zero.js ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawnSync } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ function zeroBinaryName(platform = process.platform) {
9
+ return platform === 'win32' ? 'zero.exe' : 'zero';
10
+ }
11
+
12
+ function helperShimNames(name, platform = process.platform) {
13
+ if (platform === 'win32') {
14
+ return [`${name}.cmd`, `${name}.exe`, name];
15
+ }
16
+ return [name];
17
+ }
18
+
19
+ function commandForShim(path, platform = process.platform) {
20
+ if (platform === 'win32' && path.toLowerCase().endsWith('.cmd')) {
21
+ return {
22
+ command: process.env.ComSpec || 'cmd.exe',
23
+ prefixArgs: ['/d', '/s', '/c', `"${path.replace(/"/g, '""')}"`],
24
+ };
25
+ }
26
+ return { command: path, prefixArgs: [] };
27
+ }
28
+
29
+ function resolveHelper(packageRoot, name) {
30
+ const binDir = join(packageRoot, 'node_modules', '.bin');
31
+ for (const shimName of helperShimNames(name)) {
32
+ const candidate = join(binDir, shimName);
33
+ if (!existsSync(candidate)) continue;
34
+ return {
35
+ ...commandForShim(candidate),
36
+ pathPrepend: [binDir],
37
+ };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function localControlHelperManifest(packageRoot) {
43
+ const helpers = {};
44
+ for (const name of ['agent-browser', 'tuistory']) {
45
+ const helper = resolveHelper(packageRoot, name);
46
+ if (helper) helpers[name] = helper;
47
+ }
48
+ if (Object.keys(helpers).length === 0) return '';
49
+ return JSON.stringify({ version: 1, helpers });
50
+ }
51
+
52
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
53
+ const nativePath = join(packageRoot, zeroBinaryName());
54
+ const localControlHelpers = localControlHelperManifest(packageRoot);
55
+
56
+ if (!existsSync(nativePath)) {
57
+ const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
58
+ const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
59
+ console.error(
60
+ '[zero] No native binary found next to the npm wrapper.\n' +
61
+ 'The platform binary is fetched at install time by a postinstall script,\n' +
62
+ 'which did not run (or was skipped) for this install.\n' +
63
+ '\n' +
64
+ 'Fix it now by running the installer manually:\n' +
65
+ ` node "${postinstallScript}"\n` +
66
+ '\n' +
67
+ (ranByBun
68
+ ? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
69
+ 'by default. Trust the package to run the blocked postinstall:\n' +
70
+ ' bun pm trust @gitlawb/zero (project install)\n' +
71
+ ' bun pm -g trust @gitlawb/zero (global install)\n' +
72
+ 'On Bun versions without `bun pm trust`, add\n' +
73
+ ' "trustedDependencies": ["@gitlawb/zero"]\n' +
74
+ 'to your project package.json and reinstall.\n' +
75
+ '\n'
76
+ : '') +
77
+ 'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
78
+ '(go run ./cmd/zero, requires Go 1.25+).',
79
+ );
80
+ process.exit(1);
81
+ }
82
+
83
+ const env = { ...process.env };
84
+ if (localControlHelpers) {
85
+ env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
86
+ } else {
87
+ delete env.ZERO_LOCAL_CONTROL_HELPERS;
88
+ }
89
+
90
+ const child = spawnSync(nativePath, process.argv.slice(2), {
91
+ stdio: 'inherit',
92
+ env,
93
+ });
94
+
95
+ if (child.error) {
96
+ console.error(`[zero] Failed to launch wrapper target: ${child.error.message}`);
97
+ process.exit(1);
98
+ }
99
+
100
+ if (child.signal) {
101
+ process.kill(process.pid, child.signal);
102
+ }
103
+
104
+ process.exit(child.status ?? 1);
package/package.json CHANGED
@@ -1,11 +1,43 @@
1
1
  {
2
2
  "name": "@gitlawb/zero",
3
- "version": "0.0.0",
4
- "description": "Placeholder for Zero, a terminal coding agent. The real package ships with v0.1.0 — see https://github.com/Gitlawb/zero.",
3
+ "version": "0.2.0",
4
+ "description": "Zero a fast, multi-provider terminal coding agent.",
5
+ "type": "module",
6
+ "module": "bin/zero.js",
7
+ "bin": {
8
+ "zero": "bin/zero.js"
9
+ },
10
+ "scripts": {
11
+ "postinstall": "node scripts/postinstall.mjs"
12
+ },
13
+ "files": [
14
+ "bin/zero.js",
15
+ "scripts/postinstall.mjs"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "dependencies": {
21
+ "agent-browser": "^0.30.1",
22
+ "tuistory": "^0.10.0"
23
+ },
24
+ "os": [
25
+ "linux",
26
+ "darwin",
27
+ "win32",
28
+ "android"
29
+ ],
30
+ "cpu": [
31
+ "x64",
32
+ "arm64"
33
+ ],
5
34
  "repository": {
6
35
  "type": "git",
7
36
  "url": "git+https://github.com/Gitlawb/zero.git"
8
37
  },
9
38
  "homepage": "https://github.com/Gitlawb/zero#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/Gitlawb/zero/issues"
41
+ },
10
42
  "license": "MIT"
11
43
  }
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ // postinstall: fetch the prebuilt `zero` binary for this platform from the
3
+ // matching GitHub Release and place it next to bin/zero.js, which execs it.
4
+ //
5
+ // Mirrors scripts/install.sh and scripts/install.ps1. The asset-name scheme is
6
+ // the source of truth in internal/release/release.go:
7
+ // zero-v{version}-{linux|macos|windows}-{x64|arm64}.{tar.gz|zip} (+ .sha256)
8
+ //
9
+ // Safety: HTTPS-only download from the pinned repo, SHA-256 verification against
10
+ // the release's own .sha256, and extraction that never trusts archive paths —
11
+ // we extract to a temp dir and copy only known binary basenames into place, so a
12
+ // crafted archive cannot write outside the package (no zip-slip).
13
+ //
14
+ // Env overrides (testing / mirrors / locked-down installs):
15
+ // ZERO_SKIP_DOWNLOAD=1 skip entirely, exit 0 (wrapper will guide if run)
16
+ // ZERO_INSTALL_DRY_RUN=1 print the resolved plan as JSON, no network, exit 0
17
+ // ZERO_INSTALL_PLATFORM=… override process.platform (linux|darwin|win32|android)
18
+ // ZERO_INSTALL_ARCH=… override process.arch (x64|arm64)
19
+ // ZERO_REPO=owner/name override the GitHub repo (default Gitlawb/zero)
20
+ // ZERO_GITHUB_BASE_URL=… override the download host (default https://github.com)
21
+
22
+ import {
23
+ chmodSync,
24
+ copyFileSync,
25
+ existsSync,
26
+ mkdirSync,
27
+ mkdtempSync,
28
+ readdirSync,
29
+ readFileSync,
30
+ rmSync,
31
+ statSync,
32
+ writeFileSync,
33
+ } from 'node:fs';
34
+ import { createHash } from 'node:crypto';
35
+ import { tmpdir } from 'node:os';
36
+ import { dirname, join } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { spawnSync } from 'node:child_process';
39
+
40
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
41
+ const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
42
+ const VERSION = pkg.version;
43
+
44
+ const REPO = process.env.ZERO_REPO || 'Gitlawb/zero';
45
+ const BASE = (process.env.ZERO_GITHUB_BASE_URL || 'https://github.com').replace(/\/+$/, '');
46
+ // The .sha256 is fetched from the same origin as the archive, so TLS authenticity
47
+ // is the only real integrity control — require https unless explicitly overridden
48
+ // for local testing (e.g. a localhost mirror in tests).
49
+ const ALLOW_INSECURE = process.env.ZERO_ALLOW_INSECURE_DOWNLOAD === '1';
50
+ const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
51
+
52
+ function fail(message) {
53
+ console.error(`[zero] ${message}`);
54
+ process.exit(1);
55
+ }
56
+
57
+ function warnSkip(message) {
58
+ // Exit 0 so an unsupported platform or an opt-out does not break `npm install`;
59
+ // bin/zero.js reports a clear "no native binary" message if the user runs zero.
60
+ console.error(`[zero] ${message}`);
61
+ process.exit(0);
62
+ }
63
+
64
+ // Maps mirror internal/release/release.go (ReleasePlatform / ReleaseArch).
65
+ function resolvePlatform(p) {
66
+ switch (p) {
67
+ case 'linux':
68
+ case 'android':
69
+ return 'linux';
70
+ case 'darwin':
71
+ return 'macos';
72
+ case 'win32':
73
+ return 'windows';
74
+ default:
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function resolveArch(a) {
80
+ switch (a) {
81
+ case 'x64':
82
+ return 'x64';
83
+ case 'arm64':
84
+ return 'arm64';
85
+ default:
86
+ return null;
87
+ }
88
+ }
89
+
90
+ if (process.env.ZERO_SKIP_DOWNLOAD === '1') {
91
+ warnSkip('ZERO_SKIP_DOWNLOAD=1 set — skipping native binary download.');
92
+ }
93
+
94
+ const rawPlatform = process.env.ZERO_INSTALL_PLATFORM || process.platform;
95
+ const rawArch = process.env.ZERO_INSTALL_ARCH || process.arch;
96
+ const platform = resolvePlatform(rawPlatform);
97
+ const arch = resolveArch(rawArch);
98
+
99
+ if (!platform || !arch) {
100
+ warnSkip(
101
+ `no prebuilt binary for ${rawPlatform}/${rawArch}. Build from source: ` +
102
+ `https://github.com/${REPO} (go run ./cmd/zero).`,
103
+ );
104
+ }
105
+
106
+ // The release matrix builds linux/macos {x64,arm64} and windows-x64 — there is no
107
+ // windows-arm64 artifact. (win32, arm64) otherwise resolves to a valid
108
+ // platform/arch, so it would proceed to download a non-existent asset and hard
109
+ // -fail npm install on a 404. Guard it explicitly to skip gracefully; Windows on
110
+ // ARM runs the x64 build under emulation, so the x64 package is the fallback.
111
+ if (platform === 'windows' && arch === 'arm64') {
112
+ warnSkip(
113
+ `no prebuilt binary for windows-arm64 (the windows-x64 build runs under ` +
114
+ `emulation on Windows on ARM). Build from source or install the x64 package: ` +
115
+ `https://github.com/${REPO} (go run ./cmd/zero).`,
116
+ );
117
+ }
118
+
119
+ const ext = platform === 'windows' ? 'zip' : 'tar.gz';
120
+ const binaryName = platform === 'windows' ? 'zero.exe' : 'zero';
121
+ const tag = `v${VERSION}`;
122
+ const assetName = `zero-v${VERSION}-${platform}-${arch}.${ext}`;
123
+ const assetUrl = `${BASE}/${REPO}/releases/download/${tag}/${assetName}`;
124
+ const checksumUrl = `${assetUrl}.sha256`;
125
+
126
+ // Extra binaries the archive may carry; copied when present so the platform
127
+ // sandbox finds its adjacent helpers (tier-1 discovery), but never required.
128
+ const optionalBinaries =
129
+ platform === 'windows'
130
+ ? ['zero-windows-command-runner.exe', 'zero-windows-sandbox-setup.exe']
131
+ : platform === 'linux'
132
+ ? ['zero-linux-sandbox', 'zero-seccomp']
133
+ : [];
134
+
135
+ if (process.env.ZERO_INSTALL_DRY_RUN === '1') {
136
+ process.stdout.write(
137
+ JSON.stringify({
138
+ version: VERSION,
139
+ platform,
140
+ arch,
141
+ tag,
142
+ assetName,
143
+ assetUrl,
144
+ checksumUrl,
145
+ binaryName,
146
+ optionalBinaries,
147
+ }) + '\n',
148
+ );
149
+ process.exit(0);
150
+ }
151
+
152
+ // Idempotent: skip if the binary for this exact version is already in place.
153
+ const markerPath = join(packageRoot, '.zero-binary-version');
154
+ const installedBinary = join(packageRoot, binaryName);
155
+ if (
156
+ existsSync(installedBinary) &&
157
+ existsSync(markerPath) &&
158
+ readFileSync(markerPath, 'utf8').trim() === VERSION
159
+ ) {
160
+ process.exit(0);
161
+ }
162
+
163
+ async function download(url) {
164
+ if (!ALLOW_INSECURE && !url.startsWith('https://')) {
165
+ fail(
166
+ `refusing insecure download origin for ${url}: only https:// is allowed ` +
167
+ `(set ZERO_ALLOW_INSECURE_DOWNLOAD=1 to override for local testing).`,
168
+ );
169
+ }
170
+ let response;
171
+ try {
172
+ response = await fetch(url, { redirect: 'follow' });
173
+ } catch (error) {
174
+ fail(`download failed for ${url}: ${error.message}`);
175
+ }
176
+ // Catch an https->http downgrade across redirects: the verified bytes must
177
+ // still have arrived over TLS for the same-origin checksum to mean anything.
178
+ if (!ALLOW_INSECURE && response.url && !response.url.startsWith('https://')) {
179
+ fail(`refusing insecure redirect to ${response.url}`);
180
+ }
181
+ if (!response.ok) {
182
+ fail(
183
+ `download failed (HTTP ${response.status}) for ${url} (release tag ${tag}). ` +
184
+ `If no release exists yet, or the tag and package.json version disagree, ` +
185
+ `build from source: https://github.com/${REPO} (go run ./cmd/zero).`,
186
+ );
187
+ }
188
+ const declared = Number(response.headers.get('content-length') || 0);
189
+ if (declared && declared > MAX_DOWNLOAD_BYTES) {
190
+ fail(`refusing oversized download (${declared} bytes) from ${url}`);
191
+ }
192
+ const buffer = Buffer.from(await response.arrayBuffer());
193
+ if (buffer.length > MAX_DOWNLOAD_BYTES) {
194
+ fail(`download from ${url} exceeded ${MAX_DOWNLOAD_BYTES} bytes`);
195
+ }
196
+ return buffer;
197
+ }
198
+
199
+ // Parse a sha256sum-style file. Anchored per line, and when the line carries a
200
+ // filename field it must equal the requested asset — mirrors the Go
201
+ // ParseSHA256Checksum so a checksum file cannot misattribute a digest.
202
+ function parseSha256(text, wantName) {
203
+ for (const rawLine of text.split(/\r?\n/)) {
204
+ const line = rawLine.trim();
205
+ if (!line) continue;
206
+ const match = line.match(/^([a-fA-F0-9]{64})(?:\s+\*?(.+))?$/);
207
+ if (!match) continue;
208
+ const [, hex, name] = match;
209
+ if (!name || name.trim() === wantName) {
210
+ return hex.toLowerCase();
211
+ }
212
+ }
213
+ fail(`could not find a SHA-256 digest for ${wantName} in ${wantName}.sha256`);
214
+ }
215
+
216
+ function findByBasename(dir, name) {
217
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
218
+ const full = join(dir, entry.name);
219
+ if (entry.isDirectory()) {
220
+ const nested = findByBasename(full, name);
221
+ if (nested) return nested;
222
+ } else if (entry.name === name) {
223
+ return full;
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ // extract runs from `workDir` and passes RELATIVE names so no Windows drive
230
+ // letter (e.g. C:\) appears in tar's archive argument — GNU tar otherwise reads
231
+ // `C:foo` as a remote `host:path` and fails. archiveName and destName are both
232
+ // relative to workDir.
233
+ function extract(workDir, archiveName, destName) {
234
+ const opts = { stdio: 'inherit', cwd: workDir };
235
+ if (ext === 'zip') {
236
+ // Windows 10+ ships bsdtar as tar.exe, which extracts zips; fall back to
237
+ // PowerShell Expand-Archive if tar is unavailable or cannot read the zip.
238
+ const viaTar = spawnSync('tar', ['-xf', archiveName, '-C', destName], opts);
239
+ if (viaTar.status === 0) return;
240
+ const absArchive = join(workDir, archiveName);
241
+ const absDest = join(workDir, destName);
242
+ const viaPwsh = spawnSync(
243
+ 'powershell',
244
+ [
245
+ '-NoProfile',
246
+ '-NonInteractive',
247
+ '-Command',
248
+ `Expand-Archive -LiteralPath '${absArchive.replace(/'/g, "''")}' ` +
249
+ `-DestinationPath '${absDest.replace(/'/g, "''")}' -Force`,
250
+ ],
251
+ { stdio: 'inherit' },
252
+ );
253
+ if (viaPwsh.status !== 0) fail(`failed to extract ${assetName}`);
254
+ return;
255
+ }
256
+ const viaTar = spawnSync('tar', ['-xzf', archiveName, '-C', destName], opts);
257
+ if (viaTar.status !== 0) fail(`failed to extract ${assetName} (tar exited ${viaTar.status})`);
258
+ }
259
+
260
+ async function main() {
261
+ const archiveBuffer = await download(assetUrl);
262
+ const checksumText = (await download(checksumUrl)).toString('utf8');
263
+ const expected = parseSha256(checksumText, assetName);
264
+ const actual = createHash('sha256').update(archiveBuffer).digest('hex');
265
+ if (actual !== expected) {
266
+ fail(`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`);
267
+ }
268
+
269
+ const tempDir = mkdtempSync(join(tmpdir(), 'zero-install-'));
270
+ try {
271
+ const archivePath = join(tempDir, assetName);
272
+ writeFileSync(archivePath, archiveBuffer);
273
+ const extractDir = join(tempDir, 'extracted');
274
+ mkdirSync(extractDir);
275
+ extract(tempDir, assetName, 'extracted');
276
+
277
+ // Copy only known basenames into the package root. We never honor
278
+ // archive-relative paths, so a crafted entry cannot escape the package.
279
+ const primarySource = findByBasename(extractDir, binaryName);
280
+ if (!primarySource) fail(`archive ${assetName} did not contain ${binaryName}`);
281
+ copyFileSync(primarySource, installedBinary);
282
+ if (platform !== 'windows') chmodSync(installedBinary, 0o755);
283
+
284
+ for (const name of optionalBinaries) {
285
+ const source = findByBasename(extractDir, name);
286
+ if (!source) {
287
+ console.error(
288
+ `[zero] note: optional helper ${name} was not in ${assetName}; ` +
289
+ `the sandbox may run with reduced isolation.`,
290
+ );
291
+ continue;
292
+ }
293
+ const dest = join(packageRoot, name);
294
+ copyFileSync(source, dest);
295
+ if (platform !== 'windows') chmodSync(dest, 0o755);
296
+ }
297
+
298
+ writeFileSync(markerPath, VERSION + '\n');
299
+ const sizeKb = Math.round(statSync(installedBinary).size / 1024);
300
+ console.error(`[zero] installed ${binaryName} ${VERSION} (${platform}-${arch}, ${sizeKb} KB).`);
301
+ } finally {
302
+ rmSync(tempDir, { recursive: true, force: true });
303
+ }
304
+ }
305
+
306
+ main().catch((error) => fail(error.message));