yolocage 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/lib/update.js ADDED
@@ -0,0 +1,194 @@
1
+ // `yc update` — refresh the yolocage CLI itself + the cage images.
2
+ //
3
+ // Two halves:
4
+ // 1. CLI binary: re-install yolocage via npm from the global registry.
5
+ // We probe how the user is invoking yc (global npm install vs npx
6
+ // run vs unknown) and either install/upgrade or print a helpful
7
+ // no-op message.
8
+ // 2. Cage images: re-pull yolocage/claude:latest + yolocage/codex:latest
9
+ // so the next cage launch starts from current bytes.
10
+ //
11
+ // Sudo handling mirrors lib/docker.js — npm global installs typically
12
+ // land in a path that requires root (/usr/local/lib/node_modules on
13
+ // many distros). We try without sudo first; on permission failure we
14
+ // retry with `sudo -n` (non-interactive) and fall through to a clear
15
+ // "please re-run with sudo" message if even that fails.
16
+ //
17
+ // Tests in test/update.test.js mock child_process to drive each branch.
18
+
19
+ 'use strict';
20
+
21
+ const path = require('path');
22
+ const { spawnSync, execFileSync } = require('child_process');
23
+ const { runDocker } = require('./docker');
24
+ const { TYPE_DEFAULTS } = require('./types');
25
+
26
+ const PKG_NAME = 'yolocage';
27
+
28
+ // Identify how the current process was launched. Three classes matter:
29
+ // - 'npx' — running via `npx yolocage …`; the binary lives
30
+ // under a per-invocation cache dir. Re-installing is
31
+ // a no-op for the user; the next npx call fetches
32
+ // whatever's current.
33
+ // - 'npm-global' — installed via `npm install -g yolocage`. This is
34
+ // the normal case; `npm install -g yolocage@latest`
35
+ // upgrades in place.
36
+ // - 'unknown' — running from source / linked checkout / unusual
37
+ // layout. Refuse to mutate; print a hint.
38
+ function detectInstallType(scriptPath) {
39
+ const p = scriptPath || (require.main && require.main.filename) || __filename;
40
+ if (p.includes(`${path.sep}_npx${path.sep}`)) return 'npx';
41
+ if (p.includes(`${path.sep}node_modules${path.sep}${PKG_NAME}${path.sep}`)) {
42
+ return 'npm-global';
43
+ }
44
+ return 'unknown';
45
+ }
46
+
47
+ function getCurrentVersion() {
48
+ // Read our own package.json. Resolved relative to this file so the
49
+ // function works equally for tests, npx, and global installs.
50
+ return require(path.join(__dirname, '..', 'package.json')).version;
51
+ }
52
+
53
+ // Fetch the latest published version from npm. Throws on network or
54
+ // registry errors so the caller can present a clean diagnostic.
55
+ function getLatestVersion(deps) {
56
+ deps = deps || {};
57
+ const exec = deps.execFileSync || execFileSync;
58
+ try {
59
+ const out = exec('npm', ['view', PKG_NAME, 'version'], {
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ });
63
+ return String(out).trim();
64
+ } catch (e) {
65
+ const reason = (e.stderr && String(e.stderr).trim()) || e.message;
66
+ throw new Error(`failed to fetch latest version from npm registry: ${reason}`);
67
+ }
68
+ }
69
+
70
+ // Install yolocage@<version> globally via npm. Returns { ok, sudoUsed }.
71
+ // On EACCES (npm global path needs root) we retry once with `sudo -n`.
72
+ function runNpmInstall(version, deps) {
73
+ deps = deps || {};
74
+ const spawn = deps.spawnSync || spawnSync;
75
+ const stdout = deps.stdout || process.stdout;
76
+ const stderr = deps.stderr || process.stderr;
77
+ const target = `${PKG_NAME}@${version}`;
78
+
79
+ stdout.write(`yolocage: installing ${target} via npm...\n`);
80
+ const direct = spawn('npm', ['install', '-g', target], { stdio: 'inherit' });
81
+ if (direct.status === 0) return { ok: true, sudoUsed: false };
82
+
83
+ // Inspect the error. If it smells like a permission problem, retry
84
+ // with sudo non-interactively. If sudo would prompt or doesn't help,
85
+ // print actionable guidance and return failure.
86
+ const stderrBuf = direct.stderr ? String(direct.stderr) : '';
87
+ const looksLikePermission =
88
+ stderrBuf.includes('EACCES') ||
89
+ stderrBuf.includes('permission denied') ||
90
+ stderrBuf.includes('EPERM') ||
91
+ direct.status === 243;
92
+
93
+ if (!looksLikePermission) return { ok: false, sudoUsed: false };
94
+
95
+ stderr.write(
96
+ 'yolocage: npm install needs root for the global prefix; retrying with sudo\n'
97
+ );
98
+ const sudo = spawn('sudo', ['-n', 'npm', 'install', '-g', target], {
99
+ stdio: 'inherit',
100
+ });
101
+ if (sudo.status === 0) return { ok: true, sudoUsed: true };
102
+
103
+ stderr.write(
104
+ 'yolocage: passwordless sudo unavailable. Re-run with sudo:\n' +
105
+ ` sudo npm install -g ${target}\n`
106
+ );
107
+ return { ok: false, sudoUsed: true };
108
+ }
109
+
110
+ function pullImages(deps) {
111
+ deps = deps || {};
112
+ const stdout = deps.stdout || process.stdout;
113
+ const runner = deps.runDocker || runDocker;
114
+ let worstStatus = 0;
115
+ for (const t of Object.keys(TYPE_DEFAULTS)) {
116
+ const def = TYPE_DEFAULTS[t];
117
+ if (!def.v0) continue; // skip the opencode stub
118
+ stdout.write(`yolocage: pulling ${def.image}\n`);
119
+ const res = runner(['pull', def.image]);
120
+ if (res.status !== 0) worstStatus = res.status || 1;
121
+ }
122
+ return worstStatus;
123
+ }
124
+
125
+ // The exported entry. Accepts an `opts` bag for testing:
126
+ // - check — only report the version delta, don't install
127
+ // - pull — also refresh cage images (default true)
128
+ // - force — install even if already on latest
129
+ // - deps — DI bag for tests (execFileSync, spawnSync, runDocker,
130
+ // stdout, stderr, scriptPath)
131
+ async function update(opts) {
132
+ opts = opts || {};
133
+ const stdout = (opts.deps && opts.deps.stdout) || process.stdout;
134
+ const stderr = (opts.deps && opts.deps.stderr) || process.stderr;
135
+
136
+ const current = getCurrentVersion();
137
+ stdout.write(`yolocage: current version ${current}\n`);
138
+
139
+ let latest;
140
+ try {
141
+ latest = getLatestVersion(opts.deps);
142
+ } catch (e) {
143
+ stderr.write(`yolocage: ${e.message}\n`);
144
+ return 1;
145
+ }
146
+ stdout.write(`yolocage: latest version ${latest}\n`);
147
+
148
+ if (opts.check) return current === latest ? 0 : 1;
149
+
150
+ if (current === latest && !opts.force) {
151
+ stdout.write(`yolocage: already on latest (${latest})\n`);
152
+ if (opts.pull !== false) pullImages(opts.deps);
153
+ return 0;
154
+ }
155
+
156
+ const installType = detectInstallType(opts.deps && opts.deps.scriptPath);
157
+
158
+ if (installType === 'npx') {
159
+ stdout.write(
160
+ 'yolocage: running via npx — no install to update; the next ' +
161
+ '`npx yolocage` fetches the latest from the registry\n'
162
+ );
163
+ if (opts.pull !== false) pullImages(opts.deps);
164
+ return 0;
165
+ }
166
+
167
+ if (installType === 'unknown') {
168
+ stderr.write(
169
+ 'yolocage: cannot auto-update — running from a source checkout or ' +
170
+ 'a non-standard install layout. Update yolocage manually (git ' +
171
+ 'pull, npm link, or whatever your workflow uses).\n'
172
+ );
173
+ if (opts.pull !== false) pullImages(opts.deps);
174
+ return 1;
175
+ }
176
+
177
+ // npm-global: do the install.
178
+ const result = runNpmInstall(latest, opts.deps);
179
+ if (!result.ok) return 1;
180
+ stdout.write(`yolocage: updated to ${latest}\n`);
181
+
182
+ if (opts.pull !== false) pullImages(opts.deps);
183
+ return 0;
184
+ }
185
+
186
+ module.exports = {
187
+ update,
188
+ // exposed for tests
189
+ detectInstallType,
190
+ getCurrentVersion,
191
+ getLatestVersion,
192
+ runNpmInstall,
193
+ pullImages,
194
+ };
package/lib/ycrc.js ADDED
@@ -0,0 +1,98 @@
1
+ // .ycrc parser.
2
+ //
3
+ // Strict key=value with `#` comments. ONLY `~` (leading) and literal
4
+ // `$HOME` are expanded in values — no shell, no eval, no `$(…)` /
5
+ // backtick substitution. The threat model is hostile project-local
6
+ // `.ycrc` files: a malicious repo MUST NOT be able to execute
7
+ // `$(rm -rf ~)` just by being cloned. See README §"Security model".
8
+ //
9
+ // Multi-value keys (extra_bind_dirs) appear once per value; the cascade
10
+ // in lib/config.js unions them across layers.
11
+
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+
17
+ // Keys whose value may legitimately appear multiple times in a single
18
+ // .ycrc. Anything not listed here: last occurrence wins.
19
+ const MULTI_VALUE_KEYS = new Set(['extra_bind_dirs', 'bind_dirs']);
20
+
21
+ // Path-shaped keys get ~/$HOME expansion applied to their values.
22
+ // Non-path values (memory=4g, type=claude) pass through untouched.
23
+ const PATH_KEYS = new Set([
24
+ 'workspace',
25
+ 'config_dir',
26
+ 'ssproxy_extensions',
27
+ 'bind_dirs',
28
+ 'extra_bind_dirs',
29
+ ]);
30
+
31
+ function expandPathish(value) {
32
+ if (typeof value !== 'string') return value;
33
+ let v = value;
34
+ // Leading `~` → $HOME. Only at start; `foo~bar` is left alone.
35
+ if (v.startsWith('~/')) {
36
+ v = os.homedir() + v.slice(1);
37
+ } else if (v === '~') {
38
+ v = os.homedir();
39
+ }
40
+ // Literal `$HOME` → $HOME. Not `${HOME}`, not `$HOME_DIR`, not any
41
+ // other shell variable — that's the design. A hostile .ycrc can write
42
+ // `$(rm -rf ~)` and it stays the literal 11-character string.
43
+ v = v.replace(/\$HOME(?![A-Za-z0-9_])/g, os.homedir());
44
+ return v;
45
+ }
46
+
47
+ function parseYcrc(text) {
48
+ const out = {};
49
+ // Track multi-value-key accumulators.
50
+ for (const k of MULTI_VALUE_KEYS) out[k] = [];
51
+
52
+ const lines = String(text || '').split(/\r?\n/);
53
+ for (let lineno = 0; lineno < lines.length; lineno++) {
54
+ let line = lines[lineno];
55
+ // Strip comments: anything from `#` to EOL. We don't honour
56
+ // escaped `#` — the .ycrc format is intentionally minimal.
57
+ const hashIdx = line.indexOf('#');
58
+ if (hashIdx >= 0) line = line.slice(0, hashIdx);
59
+ line = line.trim();
60
+ if (!line) continue;
61
+ const eq = line.indexOf('=');
62
+ if (eq < 0) {
63
+ throw new Error(`.ycrc: malformed line ${lineno + 1}: ${JSON.stringify(line)} (expected key=value)`);
64
+ }
65
+ const key = line.slice(0, eq).trim();
66
+ let value = line.slice(eq + 1).trim();
67
+ if (!key) {
68
+ throw new Error(`.ycrc: empty key at line ${lineno + 1}`);
69
+ }
70
+ if (PATH_KEYS.has(key)) {
71
+ value = expandPathish(value);
72
+ }
73
+ if (MULTI_VALUE_KEYS.has(key)) {
74
+ out[key].push(value);
75
+ } else {
76
+ out[key] = value;
77
+ }
78
+ }
79
+ // Strip empty multi-value arrays so the cascade can treat "absent"
80
+ // and "empty" identically.
81
+ for (const k of MULTI_VALUE_KEYS) {
82
+ if (out[k].length === 0) delete out[k];
83
+ }
84
+ return out;
85
+ }
86
+
87
+ function readYcrc(filepath) {
88
+ let text;
89
+ try {
90
+ text = fs.readFileSync(filepath, 'utf8');
91
+ } catch (e) {
92
+ if (e.code === 'ENOENT') return null;
93
+ throw e;
94
+ }
95
+ return parseYcrc(text);
96
+ }
97
+
98
+ module.exports = { parseYcrc, readYcrc, expandPathish, MULTI_VALUE_KEYS, PATH_KEYS };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "yolocage",
3
+ "version": "0.1.0",
4
+ "description": "Sandboxed claude-code / codex container with built-in egress credential scrubber. Run AI coding agents in --dangerously-skip-permissions mode with a safety net.",
5
+ "license": "MIT",
6
+ "author": "yolocage contributors",
7
+ "homepage": "https://github.com/jlamendo/yolocage",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/jlamendo/yolocage.git"
11
+ },
12
+ "keywords": [
13
+ "claude-code",
14
+ "codex",
15
+ "docker",
16
+ "sandbox",
17
+ "secrets",
18
+ "credential-scrubber",
19
+ "ai-agent"
20
+ ],
21
+ "engines": {
22
+ "node": ">=16"
23
+ },
24
+ "main": "yc.js",
25
+ "bin": {
26
+ "yolocage": "./yc.js",
27
+ "yc": "./yc.js"
28
+ },
29
+ "files": [
30
+ "yc.js",
31
+ "lib/",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "test": "jest --colors",
37
+ "test:watch": "jest --watch",
38
+ "test:coverage": "jest --coverage",
39
+ "lint": "node --check yc.js && find lib -name '*.js' -exec node --check {} \\;"
40
+ },
41
+ "dependencies": {
42
+ "commander": "^12.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "jest": "^29.7.0"
46
+ },
47
+ "jest": {
48
+ "testEnvironment": "node",
49
+ "testMatch": [
50
+ "**/test/**/*.test.js"
51
+ ],
52
+ "collectCoverageFrom": [
53
+ "yc.js",
54
+ "lib/**/*.js"
55
+ ]
56
+ }
57
+ }