anon-pi 0.1.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/Dockerfile.pi +23 -0
- package/LICENSE +661 -0
- package/README.md +117 -0
- package/dist/anon-pi.d.ts +89 -0
- package/dist/anon-pi.d.ts.map +1 -0
- package/dist/anon-pi.js +192 -0
- package/dist/anon-pi.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +76 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/anon-pi.ts +277 -0
- package/src/cli.ts +99 -0
- package/src/index.ts +3 -0
package/src/anon-pi.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// anon-pi: the PURE logic (no process spawning, no interactive I/O) so every
|
|
2
|
+
// decision is unit-testable. cli.ts wires this to the real filesystem + spawn.
|
|
3
|
+
//
|
|
4
|
+
// What anon-pi does (settled design):
|
|
5
|
+
// - ALWAYS seed a per-workdir writable copy of the canonical anon-pi config
|
|
6
|
+
// (~/.config/anon-pi/agent) into a per-session dir keyed by the workdir, and
|
|
7
|
+
// mount THAT as the container's pi global (PI_CODING_AGENT_DIR). The
|
|
8
|
+
// canonical config is only ever READ (at seed time), never mounted, so the
|
|
9
|
+
// container cannot mutate it.
|
|
10
|
+
// - Mount the workdir separately at /work (pi's cwd; the user's files land on
|
|
11
|
+
// the host). A user-supplied /work/.pi/ override is just pi's own
|
|
12
|
+
// project-over-global layering; anon-pi neither creates nor requires it.
|
|
13
|
+
// - Open exactly ONE direct hole (--allow-direct <ANON_PI_LLM>) so pi can reach
|
|
14
|
+
// a local model while all other egress stays forced through the proxy.
|
|
15
|
+
// - NEVER auto-populate the canonical seed: if it is absent, error and tell the
|
|
16
|
+
// user to populate it (their anon accounts / chosen skills / a valid
|
|
17
|
+
// trust.json that trusts /work). anon-pi does not synthesize pi's trust.json.
|
|
18
|
+
// - Session identity = the ABSOLUTE workdir path (hashed). Same folder resumes
|
|
19
|
+
// the same session config+state; reseed is manual (delete the session dir).
|
|
20
|
+
|
|
21
|
+
import {createHash} from 'node:crypto';
|
|
22
|
+
import {homedir} from 'node:os';
|
|
23
|
+
import {isAbsolute, join, resolve} from 'node:path';
|
|
24
|
+
|
|
25
|
+
/** The container path the workdir is mounted at (pi's cwd). */
|
|
26
|
+
export const CONTAINER_WORKDIR = '/work';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The DEFAULT container path the seeded pi config is mounted at (the pi global).
|
|
30
|
+
* Absolute and image-independent: both podman (the -v target) and pi (the
|
|
31
|
+
* PI_CODING_AGENT_DIR env) agree on it with no home-resolution guessing. A user
|
|
32
|
+
* who knows their image's home can override it (ANON_PI_AGENT_MOUNT) to the
|
|
33
|
+
* standard `~/.pi/agent` location, e.g. /root/.pi/agent for a root image.
|
|
34
|
+
*/
|
|
35
|
+
export const DEFAULT_CONTAINER_AGENT_DIR = '/opt/pi-agent';
|
|
36
|
+
|
|
37
|
+
/** The pi env var that overrides its config dir (see pi config.ts getAgentDir). */
|
|
38
|
+
export const PI_AGENT_DIR_ENV = 'PI_CODING_AGENT_DIR';
|
|
39
|
+
|
|
40
|
+
/** Inputs resolved from the environment + argv, injected so this stays pure. */
|
|
41
|
+
export interface AnonPiEnv {
|
|
42
|
+
/** $HOME (or an override) used to derive default paths. */
|
|
43
|
+
home: string;
|
|
44
|
+
/** socks5h proxy URL. Default socks5h://127.0.0.1:9050. */
|
|
45
|
+
proxy?: string;
|
|
46
|
+
/** The anon-pi home dir. Default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi. */
|
|
47
|
+
anonPiHome?: string;
|
|
48
|
+
/** Override the canonical seed dir. Default <anonPiHome>/agent. */
|
|
49
|
+
configSeed?: string;
|
|
50
|
+
/** The container image that has `pi` on PATH. REQUIRED. */
|
|
51
|
+
image?: string;
|
|
52
|
+
/** The RFC1918/link-local IP[:port] of the local model. REQUIRED. */
|
|
53
|
+
llmDirect?: string;
|
|
54
|
+
/** XDG_CONFIG_HOME, if set (used to derive the default anon-pi home). */
|
|
55
|
+
xdgConfigHome?: string;
|
|
56
|
+
/**
|
|
57
|
+
* The ABSOLUTE container path to mount the seeded config at (and point pi's
|
|
58
|
+
* PI_CODING_AGENT_DIR at). Default /opt/pi-agent. Set it to your image's real
|
|
59
|
+
* `~/.pi/agent` (e.g. /root/.pi/agent) if you want the standard location.
|
|
60
|
+
* MUST be absolute: podman does not expand `~`, so a `~`-relative value would
|
|
61
|
+
* be mounted literally. Rejected if it starts with `~` or is not absolute.
|
|
62
|
+
*/
|
|
63
|
+
agentMount?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The fully-resolved run plan cli.ts executes. */
|
|
67
|
+
export interface RunPlan {
|
|
68
|
+
/** Absolute workdir on the host (mounted at /work). */
|
|
69
|
+
workdir: string;
|
|
70
|
+
/** The per-session seeded config dir on the host (mounted as the pi global). */
|
|
71
|
+
sessionAgentDir: string;
|
|
72
|
+
/** The canonical seed dir to copy FROM (read-only by convention). */
|
|
73
|
+
configSeed: string;
|
|
74
|
+
/** The absolute container path the session config is mounted at (== pi's config dir). */
|
|
75
|
+
agentMount: string;
|
|
76
|
+
/** True iff the session dir does not exist yet and must be seeded from configSeed. */
|
|
77
|
+
needsSeed: boolean;
|
|
78
|
+
/** The argv passed to `netcage` (after the `netcage` program name). */
|
|
79
|
+
netcageArgs: string[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEFAULT_PROXY = 'socks5h://127.0.0.1:9050';
|
|
83
|
+
|
|
84
|
+
/** A user-facing error whose message is meant to be printed verbatim (no stack). */
|
|
85
|
+
export class AnonPiError extends Error {}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the container agent-mount path: ANON_PI_AGENT_MOUNT or the default.
|
|
89
|
+
* It MUST be an absolute container path, because it is BOTH the podman `-v`
|
|
90
|
+
* target AND pi's PI_CODING_AGENT_DIR, and podman (unlike pi) does not expand
|
|
91
|
+
* `~`. A `~`-relative or relative value is rejected loudly rather than silently
|
|
92
|
+
* mounted at a literal `~` directory or a cwd-relative path.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveAgentMount(env: AnonPiEnv): string {
|
|
95
|
+
const raw =
|
|
96
|
+
env.agentMount && env.agentMount.trim() !== ''
|
|
97
|
+
? env.agentMount.trim()
|
|
98
|
+
: DEFAULT_CONTAINER_AGENT_DIR;
|
|
99
|
+
if (raw.startsWith('~')) {
|
|
100
|
+
throw new AnonPiError(
|
|
101
|
+
`anon-pi: ANON_PI_AGENT_MOUNT must be an ABSOLUTE container path, not \`${raw}\`. ` +
|
|
102
|
+
'podman does not expand `~`; use the concrete home, e.g. /root/.pi/agent.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (!raw.startsWith('/')) {
|
|
106
|
+
throw new AnonPiError(
|
|
107
|
+
`anon-pi: ANON_PI_AGENT_MOUNT must be an ABSOLUTE container path, not \`${raw}\` (it is both the mount target and pi's config dir).`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Resolve the anon-pi home dir (holds the canonical seed + per-session state). */
|
|
114
|
+
export function resolveAnonPiHome(env: AnonPiEnv): string {
|
|
115
|
+
if (env.anonPiHome) return resolve(env.anonPiHome);
|
|
116
|
+
const base =
|
|
117
|
+
env.xdgConfigHome && env.xdgConfigHome.trim() !== ''
|
|
118
|
+
? env.xdgConfigHome
|
|
119
|
+
: join(env.home, '.config');
|
|
120
|
+
return join(base, 'anon-pi');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The canonical seed dir (copied FROM, never mounted). */
|
|
124
|
+
export function resolveConfigSeed(env: AnonPiEnv): string {
|
|
125
|
+
if (env.configSeed) return resolve(env.configSeed);
|
|
126
|
+
return join(resolveAnonPiHome(env), 'agent');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Session id = a stable short hash of the ABSOLUTE workdir path, so re-running
|
|
131
|
+
* anon-pi on the same folder resumes the same session config+state, and moving
|
|
132
|
+
* the folder starts a new session (documented, accepted).
|
|
133
|
+
*/
|
|
134
|
+
export function sessionId(absWorkdir: string): string {
|
|
135
|
+
return createHash('sha256').update(absWorkdir).digest('hex').slice(0, 16);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The per-session seeded config dir on the host for a given workdir. */
|
|
139
|
+
export function sessionAgentDir(env: AnonPiEnv, absWorkdir: string): string {
|
|
140
|
+
return join(
|
|
141
|
+
resolveAnonPiHome(env),
|
|
142
|
+
'sessions',
|
|
143
|
+
sessionId(absWorkdir),
|
|
144
|
+
'agent',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build the run plan from the environment + the (optional) workdir arg. PURE: it
|
|
150
|
+
* resolves paths and composes the netcage argv, and reports whether a seed copy
|
|
151
|
+
* is needed, but performs NO filesystem writes or spawns. It THROWS AnonPiError
|
|
152
|
+
* for the two hard preconditions (missing image, missing llm) so the required
|
|
153
|
+
* inputs fail loud; the missing-SEED check is left to cli.ts (it needs a real
|
|
154
|
+
* `existsSync`), but `needsSeed` is derived from the injected `seedExists`.
|
|
155
|
+
*/
|
|
156
|
+
export function buildRunPlan(
|
|
157
|
+
env: AnonPiEnv,
|
|
158
|
+
workdirArg: string | undefined,
|
|
159
|
+
seedExists: (dir: string) => boolean,
|
|
160
|
+
sessionExists: (dir: string) => boolean,
|
|
161
|
+
): RunPlan {
|
|
162
|
+
if (!env.image || env.image.trim() === '') {
|
|
163
|
+
throw new AnonPiError(
|
|
164
|
+
'anon-pi: set ANON_PI_IMAGE to a container image that has `pi` on its PATH (e.g. ANON_PI_IMAGE=your/pi-image:tag).',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (!env.llmDirect || env.llmDirect.trim() === '') {
|
|
168
|
+
throw new AnonPiError(
|
|
169
|
+
'anon-pi: set ANON_PI_LLM to the RFC1918/link-local IP[:port] of the local model pi should reach directly (e.g. ANON_PI_LLM=192.168.1.150:8080). All other egress stays forced through the proxy.',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const home = env.home;
|
|
174
|
+
if (!home || home.trim() === '') {
|
|
175
|
+
throw new AnonPiError('anon-pi: could not resolve HOME.');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const raw =
|
|
179
|
+
workdirArg && workdirArg.trim() !== '' ? workdirArg : process.cwd();
|
|
180
|
+
const workdir = isAbsolute(raw) ? raw : resolve(raw);
|
|
181
|
+
|
|
182
|
+
const configSeed = resolveConfigSeed(env);
|
|
183
|
+
if (!seedExists(configSeed)) {
|
|
184
|
+
throw new AnonPiError(
|
|
185
|
+
`anon-pi: canonical config not found at ${configSeed}.\n` +
|
|
186
|
+
'anon-pi never populates it for you. Create it yourself with the pi config you want\n' +
|
|
187
|
+
'(anon accounts, chosen models/skills, and a trust.json that trusts /work), e.g.:\n' +
|
|
188
|
+
` mkdir -p ${configSeed}\n` +
|
|
189
|
+
` cp -a ~/.pi/agent/. ${configSeed}/ # then remove any identity you do not want anonymized\n` +
|
|
190
|
+
'See the README (Populating the seed) for the trust.json requirement.',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const sessionDir = sessionAgentDir(env, workdir);
|
|
195
|
+
const needsSeed = !sessionExists(sessionDir);
|
|
196
|
+
|
|
197
|
+
const agentMount = resolveAgentMount(env);
|
|
198
|
+
const proxy =
|
|
199
|
+
env.proxy && env.proxy.trim() !== '' ? env.proxy : DEFAULT_PROXY;
|
|
200
|
+
|
|
201
|
+
const netcageArgs = [
|
|
202
|
+
'run',
|
|
203
|
+
'--proxy',
|
|
204
|
+
proxy,
|
|
205
|
+
'--allow-direct',
|
|
206
|
+
env.llmDirect,
|
|
207
|
+
'-it',
|
|
208
|
+
'-v',
|
|
209
|
+
workdir, // netcage defaults a target-less -v to /work and cwd to /work
|
|
210
|
+
'-v',
|
|
211
|
+
`${sessionDir}:${agentMount}`,
|
|
212
|
+
'-e',
|
|
213
|
+
`${PI_AGENT_DIR_ENV}=${agentMount}`,
|
|
214
|
+
env.image,
|
|
215
|
+
'pi',
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
workdir,
|
|
220
|
+
sessionAgentDir: sessionDir,
|
|
221
|
+
configSeed,
|
|
222
|
+
agentMount,
|
|
223
|
+
needsSeed,
|
|
224
|
+
netcageArgs,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Read the AnonPiEnv from a process env map (kept separate so tests inject one). */
|
|
229
|
+
export function envFromProcess(
|
|
230
|
+
penv: Record<string, string | undefined>,
|
|
231
|
+
): AnonPiEnv {
|
|
232
|
+
return {
|
|
233
|
+
home: penv.HOME ?? homedir(),
|
|
234
|
+
proxy: penv.ANON_PI_PROXY,
|
|
235
|
+
anonPiHome: penv.ANON_PI_HOME,
|
|
236
|
+
configSeed: penv.ANON_PI_CONFIG,
|
|
237
|
+
image: penv.ANON_PI_IMAGE,
|
|
238
|
+
llmDirect: penv.ANON_PI_LLM,
|
|
239
|
+
xdgConfigHome: penv.XDG_CONFIG_HOME,
|
|
240
|
+
agentMount: penv.ANON_PI_AGENT_MOUNT,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The --help text (kept here so it is covered by the same module). */
|
|
245
|
+
export const HELP = `anon-pi - launch pi inside a netcage (anonymized egress + one direct local model)
|
|
246
|
+
|
|
247
|
+
USAGE
|
|
248
|
+
anon-pi [WORKDIR]
|
|
249
|
+
|
|
250
|
+
WORKDIR the host folder pi works in (mounted at /work). Defaults to the
|
|
251
|
+
current directory. The session config+state is keyed to this folder.
|
|
252
|
+
|
|
253
|
+
WHAT IT DOES
|
|
254
|
+
Seeds a per-workdir writable copy of your canonical anon-pi config into
|
|
255
|
+
~/.config/anon-pi/sessions/<hash>/agent, mounts it as pi's global config
|
|
256
|
+
(${PI_AGENT_DIR_ENV}=<mount>, default ${DEFAULT_CONTAINER_AGENT_DIR}), mounts WORKDIR at
|
|
257
|
+
${CONTAINER_WORKDIR}, opens ONE direct hole to your local model, and runs pi with all other
|
|
258
|
+
egress forced through the socks5h proxy, fail-closed. Requires \`netcage\`.
|
|
259
|
+
|
|
260
|
+
ENVIRONMENT
|
|
261
|
+
ANON_PI_IMAGE (required) image with \`pi\` on PATH
|
|
262
|
+
ANON_PI_LLM (required) RFC1918/link-local IP[:port] of the local model
|
|
263
|
+
ANON_PI_PROXY socks5h URL (default ${DEFAULT_PROXY})
|
|
264
|
+
ANON_PI_HOME anon-pi home (default $XDG_CONFIG_HOME/anon-pi or ~/.config/anon-pi)
|
|
265
|
+
ANON_PI_CONFIG canonical seed dir (default <ANON_PI_HOME>/agent)
|
|
266
|
+
ANON_PI_AGENT_MOUNT absolute container path for pi's config (default
|
|
267
|
+
${DEFAULT_CONTAINER_AGENT_DIR}; set to your image's ~/.pi/agent, e.g. /root/.pi/agent)
|
|
268
|
+
|
|
269
|
+
RESEED
|
|
270
|
+
Reseed is manual: delete the session dir, e.g.
|
|
271
|
+
rm -rf ~/.config/anon-pi/sessions/<hash>/agent
|
|
272
|
+
and the next run re-seeds it from the canonical config.
|
|
273
|
+
|
|
274
|
+
PLATFORM
|
|
275
|
+
Linux only (via netcage's netns/nft jail). On macOS/Windows it works only
|
|
276
|
+
inside a Linux VM, where --allow-direct to a LAN model is VM-boundary-sensitive.
|
|
277
|
+
`;
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// anon-pi CLI: resolve the run plan (pure), do the one filesystem side-effect
|
|
3
|
+
// (seed the session config if absent), then exec `netcage run ...` with inherited
|
|
4
|
+
// stdio so the interactive pi session (-it) passes through the terminal cleanly.
|
|
5
|
+
|
|
6
|
+
import {cpSync, existsSync, mkdirSync} from 'node:fs';
|
|
7
|
+
import {spawnSync} from 'node:child_process';
|
|
8
|
+
import {dirname} from 'node:path';
|
|
9
|
+
import {AnonPiError, buildRunPlan, envFromProcess, HELP} from './anon-pi.js';
|
|
10
|
+
|
|
11
|
+
function main(argv: string[]): number {
|
|
12
|
+
const args = argv.slice(2);
|
|
13
|
+
|
|
14
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
15
|
+
process.stdout.write(HELP);
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The only positional is the optional workdir. Reject stray flags so a typo
|
|
20
|
+
// (e.g. --allow-direct) is not silently swallowed: anon-pi owns the netcage
|
|
21
|
+
// argv, extra flags are not passed through.
|
|
22
|
+
const positionals = args.filter((a) => !a.startsWith('-'));
|
|
23
|
+
const flags = args.filter((a) => a.startsWith('-'));
|
|
24
|
+
if (flags.length > 0) {
|
|
25
|
+
process.stderr.write(
|
|
26
|
+
`anon-pi: unknown option(s): ${flags.join(' ')}\nRun \`anon-pi --help\`.\n`,
|
|
27
|
+
);
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
if (positionals.length > 1) {
|
|
31
|
+
process.stderr.write(
|
|
32
|
+
'anon-pi: too many arguments (expected at most one WORKDIR).\nRun `anon-pi --help`.\n',
|
|
33
|
+
);
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const env = envFromProcess(process.env);
|
|
38
|
+
|
|
39
|
+
let plan;
|
|
40
|
+
try {
|
|
41
|
+
plan = buildRunPlan(env, positionals[0], existsSync, existsSync);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (e instanceof AnonPiError) {
|
|
44
|
+
process.stderr.write(e.message + '\n');
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fail loud if netcage is not installed, before we mutate anything.
|
|
51
|
+
if (!hasNetcage()) {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
'anon-pi: `netcage` not found on PATH. anon-pi is a launcher for netcage; install it first\n' +
|
|
54
|
+
'(https://github.com/wighawag/netcage). Linux only.\n',
|
|
55
|
+
);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The one side-effect: seed the per-session config from the canonical seed the
|
|
60
|
+
// FIRST time this workdir is used. Reuse-if-present, seed-if-absent.
|
|
61
|
+
if (plan.needsSeed) {
|
|
62
|
+
mkdirSync(dirname(plan.sessionAgentDir), {recursive: true});
|
|
63
|
+
cpSync(plan.configSeed, plan.sessionAgentDir, {recursive: true});
|
|
64
|
+
process.stderr.write(
|
|
65
|
+
`anon-pi: seeded session config -> ${plan.sessionAgentDir}\n`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Ensure the workdir exists (a fresh named folder is fine).
|
|
70
|
+
mkdirSync(plan.workdir, {recursive: true});
|
|
71
|
+
|
|
72
|
+
// Hand off to netcage with inherited stdio so -it is a real interactive TTY.
|
|
73
|
+
const res = spawnSync('netcage', plan.netcageArgs, {stdio: 'inherit'});
|
|
74
|
+
if (res.error) {
|
|
75
|
+
process.stderr.write(
|
|
76
|
+
`anon-pi: failed to run netcage: ${res.error.message}\n`,
|
|
77
|
+
);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
// Propagate netcage's exit code (which itself propagates the tool's).
|
|
81
|
+
return res.status ?? 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasNetcage(): boolean {
|
|
85
|
+
const which = spawnSync(
|
|
86
|
+
process.platform === 'win32' ? 'where' : 'command',
|
|
87
|
+
['-v', 'netcage'],
|
|
88
|
+
{
|
|
89
|
+
stdio: 'ignore',
|
|
90
|
+
shell: process.platform !== 'win32',
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
if (which.status === 0) return true;
|
|
94
|
+
// Fallback: try running it harmlessly.
|
|
95
|
+
const probe = spawnSync('netcage', ['--help'], {stdio: 'ignore'});
|
|
96
|
+
return !probe.error;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
process.exit(main(process.argv));
|
package/src/index.ts
ADDED