skynot 0.0.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.
@@ -0,0 +1,37 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ workflow_dispatch:
8
+
9
+ # to execute once a day (more info see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule )
10
+ schedule:
11
+ - cron: "0 0 * * *"
12
+
13
+ concurrency:
14
+ group: ci-${{ github.sha }}
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ build-check:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - name: Checkout
22
+ uses: actions/checkout@v4
23
+
24
+ - name: Setup Node.js
25
+ uses: actions/setup-node@v4
26
+ with:
27
+ node-version: 22
28
+
29
+ - name: Update system dependencies
30
+ run: |
31
+ sudo apt-get update
32
+
33
+ - name: Install dependencies
34
+ run: npm install
35
+
36
+ - name: Build
37
+ run: npm run build
@@ -0,0 +1,49 @@
1
+ name: Publish Binaries
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ bin-publish:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout
13
+ uses: actions/checkout@v4
14
+
15
+ - name: Verify version matches tag
16
+ run: |
17
+ # Extract tag version (remove 'v' prefix if present)
18
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
19
+
20
+ # Get version from package.json
21
+ PKG_VERSION=$(node -p "require('./package.json').version")
22
+
23
+ echo "Git tag version: $TAG_VERSION"
24
+ echo "Package.json version: $PKG_VERSION"
25
+
26
+ # Compare versions
27
+ if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
28
+ echo "ERROR: Git tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)"
29
+ exit 1
30
+ fi
31
+
32
+ echo "✓ Versions match!"
33
+
34
+ - name: Setup Node.js
35
+ uses: actions/setup-node@v4
36
+ with:
37
+ node-version: '22'
38
+ registry-url: 'https://registry.npmjs.org'
39
+
40
+ - name: Install dependencies
41
+ run: npm install
42
+
43
+ - name: Build
44
+ run: npm run build
45
+
46
+ - name: Publish skynot to npm
47
+ run: npm publish --access public
48
+ env:
49
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/LICENCE.txt ADDED
@@ -0,0 +1,16 @@
1
+ AGPL-3.0 License
2
+
3
+ Copyright (c) 2026 skynot contributors
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as published
7
+ by the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
package/ReadMe.md ADDED
@@ -0,0 +1,16 @@
1
+ SkyNot
2
+ =======
3
+
4
+ Ever tempted to use or try out the infamous `pi-coding-agent` but got put off by its lack of out-of-the-box sandbox?
5
+
6
+ Some people use and/or develop guardrails extensions or poor-man sandboxing solutions; others just deploy it to their VPS so that any potential damage is contained.
7
+
8
+ But the virtue is somewhere in the middle:
9
+ - No need to go to the extreme of complicated cloud deploys to just try out some clanking business.
10
+ - No need to setup complicated tweaks or plugins that may give you a false sense of security, or too many permissions issues for your clanker to be productive.
11
+
12
+ Why not just use the unix model? Give pi a user profile in your system, a $HOME where to place its git repositories and you're off to the races.
13
+
14
+ This repository is just a quick NPX tool that helps you set up this ideal approach: run it with a simple `npx skynot` and it will guide you through the process and ask you for sudo permissions in each step that it requires, informing you of what it is doing at all times.
15
+
16
+ (This repo is of course opensource too so that you can check that what it says it does is what it really does.)
package/dist/index.js ADDED
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ var __importDefault = (this && this.__importDefault) || function (mod) {
37
+ return (mod && mod.__esModule) ? mod : { "default": mod };
38
+ };
39
+ Object.defineProperty(exports, "__esModule", { value: true });
40
+ const child_process_1 = require("child_process");
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ const readline = __importStar(require("readline"));
44
+ const util_1 = require("util");
45
+ const os = __importStar(require("os"));
46
+ const commander_1 = require("commander");
47
+ const package_json_1 = __importDefault(require("../package.json"));
48
+ const execAsync = (0, util_1.promisify)(child_process_1.exec);
49
+ function getShellRcFile() {
50
+ const platform = os.platform();
51
+ if (platform === 'darwin') {
52
+ return '.zshrc';
53
+ }
54
+ return '.bashrc';
55
+ }
56
+ function getPiHome() {
57
+ const platform = os.platform();
58
+ if (platform === 'darwin') {
59
+ return '/Users/pi';
60
+ }
61
+ return '/home/pi';
62
+ }
63
+ function getPiInstallDir() {
64
+ return `${getPiHome()}/pi`;
65
+ }
66
+ async function askQuestion(query, silent = false) {
67
+ if (silent) {
68
+ return new Promise((resolve) => {
69
+ process.stdout.write(query);
70
+ const stdin = process.stdin;
71
+ const wasRaw = stdin.isRaw;
72
+ stdin.setRawMode(true);
73
+ stdin.resume();
74
+ stdin.setEncoding('utf-8');
75
+ let input = '';
76
+ const onData = (char) => {
77
+ if (char === '\n' || char === '\r' || char === '\u0004') {
78
+ stdin.removeListener('data', onData);
79
+ stdin.setRawMode(wasRaw);
80
+ stdin.pause();
81
+ process.stdout.write('\n');
82
+ resolve(input);
83
+ }
84
+ else if (char === '\u0003') {
85
+ // Ctrl+C
86
+ stdin.setRawMode(wasRaw);
87
+ process.exit(1);
88
+ }
89
+ else if (char === '\u007F' || char === '\b') {
90
+ input = input.slice(0, -1);
91
+ }
92
+ else {
93
+ input += char;
94
+ }
95
+ };
96
+ stdin.on('data', onData);
97
+ });
98
+ }
99
+ const rl = readline.createInterface({
100
+ input: process.stdin,
101
+ output: process.stdout,
102
+ });
103
+ return new Promise((resolve) => {
104
+ rl.question(query, (answer) => {
105
+ rl.close();
106
+ resolve(answer);
107
+ });
108
+ });
109
+ }
110
+ const MAX_SUDO_RETRIES = 3;
111
+ // Cached sudo password so we only ask once
112
+ let cachedSudoPassword = null;
113
+ function runSudoWithPassword(command, password, asUser) {
114
+ return new Promise((resolve, reject) => {
115
+ const sudoArgs = ['-S', '-k'];
116
+ if (asUser) {
117
+ sudoArgs.push('-u', asUser);
118
+ }
119
+ sudoArgs.push('bash', '-c', command);
120
+ const child = (0, child_process_1.spawn)('sudo', sudoArgs, {
121
+ stdio: ['pipe', 'pipe', 'pipe'],
122
+ });
123
+ child.stdin.write(password + '\n');
124
+ child.stdin.end();
125
+ let stderr = '';
126
+ child.stderr.on('data', (data) => {
127
+ const line = data.toString();
128
+ // Filter out sudo's own password prompt
129
+ if (!line.includes('Password:') && !line.includes('password for')) {
130
+ stderr += line;
131
+ }
132
+ });
133
+ child.on('close', (code) => {
134
+ if (code === 0) {
135
+ resolve();
136
+ }
137
+ else {
138
+ // Sanitize: never include the password in error messages
139
+ const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
140
+ const safeStderr = stderr.replace(new RegExp(escaped, 'g'), '***');
141
+ reject(new Error(`sudo command failed (exit code ${code}): ${safeStderr.trim()}`));
142
+ }
143
+ });
144
+ });
145
+ }
146
+ async function askSudoPasswordAndRun(command, reason) {
147
+ for (let attempt = 1; attempt <= MAX_SUDO_RETRIES; attempt++) {
148
+ const password = await askQuestion(`Enter sudo password (${reason}): `, true);
149
+ try {
150
+ await runSudoWithPassword(command, password.trim());
151
+ cachedSudoPassword = password.trim();
152
+ return;
153
+ }
154
+ catch (e) {
155
+ if (attempt < MAX_SUDO_RETRIES) {
156
+ console.error('Incorrect password, please try again.');
157
+ }
158
+ else {
159
+ throw new Error(`Failed after ${MAX_SUDO_RETRIES} attempts. Aborting.`);
160
+ }
161
+ }
162
+ }
163
+ }
164
+ async function runAsPi(command) {
165
+ if (!cachedSudoPassword) {
166
+ const password = await askQuestion('Enter sudo password (required to run as pi): ', true);
167
+ cachedSudoPassword = password.trim();
168
+ }
169
+ const piHome = getPiHome();
170
+ // Set HOME and cd to pi's home to avoid inheriting the current user's
171
+ // working directory (which pi can't access) and npm cache.
172
+ const wrappedCommand = `export HOME=${piHome} && cd ${piHome} && ${command}`;
173
+ await runSudoWithPassword(wrappedCommand, cachedSudoPassword, 'pi');
174
+ }
175
+ async function userExists(username) {
176
+ try {
177
+ await execAsync(`id -u ${username}`);
178
+ return true;
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ async function ensurePiUser() {
185
+ const exists = await userExists('pi');
186
+ if (exists) {
187
+ console.log('User "pi" already exists.');
188
+ return;
189
+ }
190
+ console.log('Creating user "pi"...');
191
+ const platform = os.platform();
192
+ if (platform === 'darwin') {
193
+ await askSudoPasswordAndRun(`sysadminctl -addUser pi -home /Users/pi -shell /bin/zsh && createhomedir -c -u pi 2>/dev/null; mkdir -p /Users/pi && chown pi:staff /Users/pi`, 'required to create user');
194
+ }
195
+ else {
196
+ await askSudoPasswordAndRun(`useradd -m -s /bin/bash pi`, 'required to create user');
197
+ }
198
+ console.log('User "pi" created.');
199
+ }
200
+ async function installAgent() {
201
+ const installDir = getPiInstallDir();
202
+ const packageDir = path.join(installDir, 'node_modules', '@mariozechner', 'pi-coding-agent');
203
+ if (fs.existsSync(packageDir)) {
204
+ console.log('@mariozechner/pi-coding-agent is already installed, skipping.');
205
+ return;
206
+ }
207
+ console.log(`Installing @mariozechner/pi-coding-agent into ${installDir}...`);
208
+ const cmd = `mkdir -p ${installDir} && cd ${installDir} && npm install @mariozechner/pi-coding-agent`;
209
+ await runAsPi(cmd);
210
+ console.log('Package installed.');
211
+ }
212
+ async function updatePath() {
213
+ const rcFile = getShellRcFile();
214
+ const piHome = getPiHome();
215
+ const line = "export PATH=\$HOME/pi/node_modules/.bin:\$PATH";
216
+ const rcPath = `${piHome}/${rcFile}`;
217
+ // Check locally if the line is already present
218
+ if (fs.existsSync(rcPath)) {
219
+ const content = fs.readFileSync(rcPath, 'utf-8');
220
+ if (content.includes(line)) {
221
+ console.log(`pi's PATH already configured in ${rcFile}, skipping.`);
222
+ return;
223
+ }
224
+ }
225
+ console.log(`Adding agent binary directory to pi's PATH via ${rcFile}...`);
226
+ const checkCmd = `grep -Fx '${line}' ${rcPath} 2>/dev/null || echo '${line}' >> ${rcPath}`;
227
+ await runAsPi(checkCmd);
228
+ console.log(`${rcFile} updated.`);
229
+ }
230
+ async function createLauncherScript() {
231
+ const currentUserHome = os.homedir();
232
+ const binDir = path.join(currentUserHome, 'bin');
233
+ const scriptPath = path.join(binDir, 'pi');
234
+ const installDir = getPiInstallDir();
235
+ console.log(`Creating launcher script at ${scriptPath}...`);
236
+ // Create ~/bin/ if it doesn't exist
237
+ if (!fs.existsSync(binDir)) {
238
+ fs.mkdirSync(binDir, { recursive: true });
239
+ }
240
+ const piHome = getPiHome();
241
+ const platform = os.platform();
242
+ const homeBase = platform === 'darwin' ? '/Users' : '/home';
243
+ // Write the launcher shell script with permission checks
244
+ const scriptContent = `#!/bin/bash
245
+
246
+ echo "About to launch pi-coding-agent..."
247
+
248
+ # Check permissions of other users' home directories
249
+ EXPOSED_DIRS=()
250
+ HOME_BASE="${homeBase}"
251
+ PI_HOME="${piHome}"
252
+
253
+ for user_home in "$HOME_BASE"/*/; do
254
+ # Skip pi's own home
255
+ if [ "$user_home" = "$PI_HOME/" ]; then
256
+ continue
257
+ fi
258
+
259
+ # Check if group or others have any permissions (r, w, or x)
260
+ perms=$(stat -f "%Sp" "$user_home" 2>/dev/null || stat -c "%A" "$user_home" 2>/dev/null)
261
+ if [ -z "$perms" ]; then
262
+ continue
263
+ fi
264
+
265
+ # Extract group and others permissions (characters 5-10 of e.g. drwxr-xr-x)
266
+ group_others="\${perms:4:6}"
267
+ # Check if any of group/others have r, w, or x
268
+ if echo "$group_others" | grep -q '[rwx]'; then
269
+ # On macOS, handle /Users/Shared separately (it's world-accessible by default)
270
+ if [ "$user_home" = "/Users/Shared/" ]; then
271
+ echo "NOTE: /Users/Shared is world-accessible. This is a macOS default, but you may want to restrict it manually if it contains sensitive data."
272
+ read -n 1 -s -r -p "Press any key to continue..."
273
+ echo ""
274
+ else
275
+ EXPOSED_DIRS+=("$user_home")
276
+ fi
277
+ fi
278
+ done
279
+
280
+ if [ \${#EXPOSED_DIRS[@]} -gt 0 ]; then
281
+ echo "WARNING: The following user home directories are accessible by other users (including pi):"
282
+ for dir in "\${EXPOSED_DIRS[@]}"; do
283
+ echo " $dir"
284
+ done
285
+ echo ""
286
+ read -p "Would you like to shield these directories? (recommended) [Y/n]" answer
287
+ answer=\${answer:-Y}
288
+ if [[ "$answer" =~ ^[Yy] ]]; then
289
+ for dir in "\${EXPOSED_DIRS[@]}"; do
290
+ sudo chmod go-rwx "$dir"
291
+ echo "Shielded: $dir"
292
+ done
293
+ echo "Done."
294
+ fi
295
+ echo ""
296
+ fi
297
+
298
+ echo "Launching pi-coding-agent with pi user (sudo is required to impersonate 'pi' user)..."
299
+ exec sudo -i -u pi bash -c 'cd ${installDir} && npx --yes @mariozechner/pi-coding-agent "$@"' -- "$@"
300
+ `;
301
+ fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
302
+ console.log('Launcher script created.');
303
+ // Add $HOME/bin to the current user's PATH via their rc file if not already present
304
+ const rcFile = getShellRcFile();
305
+ const rcPath = path.join(currentUserHome, rcFile);
306
+ const pathLine = 'export PATH="$HOME/bin:$PATH"';
307
+ let rcContent = '';
308
+ if (fs.existsSync(rcPath)) {
309
+ rcContent = fs.readFileSync(rcPath, 'utf-8');
310
+ }
311
+ if (!rcContent.includes(pathLine)) {
312
+ console.log(`Adding $HOME/bin to PATH in ${rcFile}...`);
313
+ fs.appendFileSync(rcPath, `\n${pathLine}\n`);
314
+ console.log(`${rcFile} updated.`);
315
+ }
316
+ else {
317
+ console.log(`$HOME/bin already in PATH (${rcFile}).`);
318
+ }
319
+ }
320
+ async function launchAgent() {
321
+ const scriptPath = path.join(os.homedir(), 'bin', 'pi');
322
+ const child = (0, child_process_1.spawn)(scriptPath, [], { stdio: 'inherit' });
323
+ return new Promise((resolve, reject) => {
324
+ child.on('close', (code) => {
325
+ if (code === 0) {
326
+ resolve();
327
+ }
328
+ else {
329
+ reject(new Error(`pi-coding-agent exited with code ${code}`));
330
+ }
331
+ });
332
+ });
333
+ }
334
+ async function wipeInstallation() {
335
+ const installDir = getPiInstallDir();
336
+ if (fs.existsSync(installDir)) {
337
+ console.log(`Wiping existing installation at ${installDir}...`);
338
+ await runAsPi(`rm -rf ${installDir}`);
339
+ console.log('Installation wiped.');
340
+ }
341
+ else {
342
+ console.log('No existing installation found, nothing to wipe.');
343
+ }
344
+ }
345
+ async function main() {
346
+ if (os.platform() === 'win32') {
347
+ throw new Error('Windows is not supported. Please run skynot on Linux or macOS.');
348
+ }
349
+ const program = new commander_1.Command();
350
+ program
351
+ .version(package_json_1.default.version)
352
+ .description(package_json_1.default.description)
353
+ .option('--update', 'Wipe and reinstall pi-coding-agent to get the latest version');
354
+ program.parse(process.argv);
355
+ const opts = program.opts();
356
+ await ensurePiUser();
357
+ if (opts.update) {
358
+ await wipeInstallation();
359
+ }
360
+ await installAgent();
361
+ await updatePath();
362
+ await createLauncherScript();
363
+ await launchAgent();
364
+ }
365
+ main().catch((err) => {
366
+ console.error('Error:', err);
367
+ process.exit(1);
368
+ });
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "skynot",
3
+ "version": "0.0.1",
4
+ "description": "Quick NPX tool to set up a pi user and install the pi-coding-agent",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "skynot": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "npx tsc",
11
+ "prepublishOnly": "npm run build",
12
+ "run": "npm install && npm run build && node dist/index.js"
13
+ },
14
+ "author": "",
15
+ "license": "MIT",
16
+ "dependencies": {
17
+ "commander": "^11.0.0"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^20.12.0",
21
+ "typescript": "^5.3.3"
22
+ }
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { exec, spawn } from 'child_process';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as readline from 'readline';
7
+ import { promisify } from 'util';
8
+ import * as os from 'os';
9
+ import { Command } from 'commander';
10
+ import pkg from '../package.json';
11
+
12
+ const execAsync = promisify(exec);
13
+
14
+ function getShellRcFile(): string {
15
+ const platform = os.platform();
16
+ if (platform === 'darwin') {
17
+ return '.zshrc';
18
+ }
19
+ return '.bashrc';
20
+ }
21
+
22
+ function getPiHome(): string {
23
+ const platform = os.platform();
24
+ if (platform === 'darwin') {
25
+ return '/Users/pi';
26
+ }
27
+ return '/home/pi';
28
+ }
29
+
30
+ function getPiInstallDir(): string {
31
+ return `${getPiHome()}/pi`;
32
+ }
33
+
34
+ async function askQuestion(query: string, silent = false): Promise<string> {
35
+ if (silent) {
36
+ return new Promise<string>((resolve) => {
37
+ process.stdout.write(query);
38
+ const stdin = process.stdin;
39
+ const wasRaw = stdin.isRaw;
40
+ stdin.setRawMode(true);
41
+ stdin.resume();
42
+ stdin.setEncoding('utf-8');
43
+ let input = '';
44
+ const onData = (char: string) => {
45
+ if (char === '\n' || char === '\r' || char === '\u0004') {
46
+ stdin.removeListener('data', onData);
47
+ stdin.setRawMode(wasRaw);
48
+ stdin.pause();
49
+ process.stdout.write('\n');
50
+ resolve(input);
51
+ } else if (char === '\u0003') {
52
+ // Ctrl+C
53
+ stdin.setRawMode(wasRaw);
54
+ process.exit(1);
55
+ } else if (char === '\u007F' || char === '\b') {
56
+ input = input.slice(0, -1);
57
+ } else {
58
+ input += char;
59
+ }
60
+ };
61
+ stdin.on('data', onData);
62
+ });
63
+ }
64
+ const rl = readline.createInterface({
65
+ input: process.stdin,
66
+ output: process.stdout,
67
+ });
68
+ return new Promise<string>((resolve) => {
69
+ rl.question(query, (answer) => {
70
+ rl.close();
71
+ resolve(answer);
72
+ });
73
+ });
74
+ }
75
+
76
+ const MAX_SUDO_RETRIES = 3;
77
+
78
+
79
+ // Cached sudo password so we only ask once
80
+ let cachedSudoPassword: string | null = null;
81
+
82
+ function runSudoWithPassword(command: string, password: string, asUser?: string): Promise<void> {
83
+ return new Promise<void>((resolve, reject) => {
84
+ const sudoArgs = ['-S', '-k'];
85
+ if (asUser) {
86
+ sudoArgs.push('-u', asUser);
87
+ }
88
+ sudoArgs.push('bash', '-c', command);
89
+ const child = spawn('sudo', sudoArgs, {
90
+ stdio: ['pipe', 'pipe', 'pipe'],
91
+ });
92
+ child.stdin.write(password + '\n');
93
+ child.stdin.end();
94
+
95
+ let stderr = '';
96
+ child.stderr.on('data', (data: Buffer) => {
97
+ const line = data.toString();
98
+ // Filter out sudo's own password prompt
99
+ if (!line.includes('Password:') && !line.includes('password for')) {
100
+ stderr += line;
101
+ }
102
+ });
103
+
104
+ child.on('close', (code) => {
105
+ if (code === 0) {
106
+ resolve();
107
+ } else {
108
+ // Sanitize: never include the password in error messages
109
+ const escaped = password.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
110
+ const safeStderr = stderr.replace(new RegExp(escaped, 'g'), '***');
111
+ reject(new Error(`sudo command failed (exit code ${code}): ${safeStderr.trim()}`));
112
+ }
113
+ });
114
+ });
115
+ }
116
+
117
+ async function askSudoPasswordAndRun(command: string, reason: string): Promise<void> {
118
+ for (let attempt = 1; attempt <= MAX_SUDO_RETRIES; attempt++) {
119
+ const password = await askQuestion(`Enter sudo password (${reason}): `, true);
120
+ try {
121
+ await runSudoWithPassword(command, password.trim());
122
+ cachedSudoPassword = password.trim();
123
+ return;
124
+ } catch (e) {
125
+ if (attempt < MAX_SUDO_RETRIES) {
126
+ console.error('Incorrect password, please try again.');
127
+ } else {
128
+ throw new Error(`Failed after ${MAX_SUDO_RETRIES} attempts. Aborting.`);
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ async function runAsPi(command: string): Promise<void> {
135
+ if (!cachedSudoPassword) {
136
+ const password = await askQuestion('Enter sudo password (required to run as pi): ', true);
137
+ cachedSudoPassword = password.trim();
138
+ }
139
+ const piHome = getPiHome();
140
+ // Set HOME and cd to pi's home to avoid inheriting the current user's
141
+ // working directory (which pi can't access) and npm cache.
142
+ const wrappedCommand = `export HOME=${piHome} && cd ${piHome} && ${command}`;
143
+ await runSudoWithPassword(wrappedCommand, cachedSudoPassword, 'pi');
144
+ }
145
+
146
+ async function userExists(username: string): Promise<boolean> {
147
+ try {
148
+ await execAsync(`id -u ${username}`);
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ async function ensurePiUser(): Promise<void> {
156
+ const exists = await userExists('pi');
157
+ if (exists) {
158
+ console.log('User "pi" already exists.');
159
+ return;
160
+ }
161
+ console.log('Creating user "pi"...');
162
+ const platform = os.platform();
163
+ if (platform === 'darwin') {
164
+ await askSudoPasswordAndRun(
165
+ `sysadminctl -addUser pi -home /Users/pi -shell /bin/zsh && createhomedir -c -u pi 2>/dev/null; mkdir -p /Users/pi && chown pi:staff /Users/pi`,
166
+ 'required to create user',
167
+ );
168
+ } else {
169
+ await askSudoPasswordAndRun(
170
+ `useradd -m -s /bin/bash pi`,
171
+ 'required to create user',
172
+ );
173
+ }
174
+ console.log('User "pi" created.');
175
+ }
176
+
177
+ async function installAgent(): Promise<void> {
178
+ const installDir = getPiInstallDir();
179
+ const packageDir = path.join(installDir, 'node_modules', '@mariozechner', 'pi-coding-agent');
180
+ if (fs.existsSync(packageDir)) {
181
+ console.log('@mariozechner/pi-coding-agent is already installed, skipping.');
182
+ return;
183
+ }
184
+ console.log(`Installing @mariozechner/pi-coding-agent into ${installDir}...`);
185
+ const cmd = `mkdir -p ${installDir} && cd ${installDir} && npm install @mariozechner/pi-coding-agent`;
186
+ await runAsPi(cmd);
187
+ console.log('Package installed.');
188
+ }
189
+
190
+ async function updatePath(): Promise<void> {
191
+ const rcFile = getShellRcFile();
192
+ const piHome = getPiHome();
193
+ const line = "export PATH=\$HOME/pi/node_modules/.bin:\$PATH";
194
+ const rcPath = `${piHome}/${rcFile}`;
195
+
196
+ // Check locally if the line is already present
197
+ if (fs.existsSync(rcPath)) {
198
+ const content = fs.readFileSync(rcPath, 'utf-8');
199
+ if (content.includes(line)) {
200
+ console.log(`pi's PATH already configured in ${rcFile}, skipping.`);
201
+ return;
202
+ }
203
+ }
204
+
205
+ console.log(`Adding agent binary directory to pi's PATH via ${rcFile}...`);
206
+ const checkCmd = `grep -Fx '${line}' ${rcPath} 2>/dev/null || echo '${line}' >> ${rcPath}`;
207
+ await runAsPi(checkCmd);
208
+ console.log(`${rcFile} updated.`);
209
+ }
210
+
211
+ async function createLauncherScript(): Promise<void> {
212
+ const currentUserHome = os.homedir();
213
+ const binDir = path.join(currentUserHome, 'bin');
214
+ const scriptPath = path.join(binDir, 'pi');
215
+ const installDir = getPiInstallDir();
216
+
217
+ console.log(`Creating launcher script at ${scriptPath}...`);
218
+
219
+ // Create ~/bin/ if it doesn't exist
220
+ if (!fs.existsSync(binDir)) {
221
+ fs.mkdirSync(binDir, { recursive: true });
222
+ }
223
+
224
+ const piHome = getPiHome();
225
+ const platform = os.platform();
226
+ const homeBase = platform === 'darwin' ? '/Users' : '/home';
227
+
228
+ // Write the launcher shell script with permission checks
229
+ const scriptContent = `#!/bin/bash
230
+
231
+ echo "About to launch pi-coding-agent..."
232
+
233
+ # Check permissions of other users' home directories
234
+ EXPOSED_DIRS=()
235
+ HOME_BASE="${homeBase}"
236
+ PI_HOME="${piHome}"
237
+
238
+ for user_home in "$HOME_BASE"/*/; do
239
+ # Skip pi's own home
240
+ if [ "$user_home" = "$PI_HOME/" ]; then
241
+ continue
242
+ fi
243
+
244
+ # Check if group or others have any permissions (r, w, or x)
245
+ perms=$(stat -f "%Sp" "$user_home" 2>/dev/null || stat -c "%A" "$user_home" 2>/dev/null)
246
+ if [ -z "$perms" ]; then
247
+ continue
248
+ fi
249
+
250
+ # Extract group and others permissions (characters 5-10 of e.g. drwxr-xr-x)
251
+ group_others="\${perms:4:6}"
252
+ # Check if any of group/others have r, w, or x
253
+ if echo "$group_others" | grep -q '[rwx]'; then
254
+ # On macOS, handle /Users/Shared separately (it's world-accessible by default)
255
+ if [ "$user_home" = "/Users/Shared/" ]; then
256
+ echo "NOTE: /Users/Shared is world-accessible. This is a macOS default, but you may want to restrict it manually if it contains sensitive data."
257
+ read -n 1 -s -r -p "Press any key to continue..."
258
+ echo ""
259
+ else
260
+ EXPOSED_DIRS+=("$user_home")
261
+ fi
262
+ fi
263
+ done
264
+
265
+ if [ \${#EXPOSED_DIRS[@]} -gt 0 ]; then
266
+ echo "WARNING: The following user home directories are accessible by other users (including pi):"
267
+ for dir in "\${EXPOSED_DIRS[@]}"; do
268
+ echo " $dir"
269
+ done
270
+ echo ""
271
+ read -p "Would you like to shield these directories? (recommended) [Y/n]" answer
272
+ answer=\${answer:-Y}
273
+ if [[ "$answer" =~ ^[Yy] ]]; then
274
+ for dir in "\${EXPOSED_DIRS[@]}"; do
275
+ sudo chmod go-rwx "$dir"
276
+ echo "Shielded: $dir"
277
+ done
278
+ echo "Done."
279
+ fi
280
+ echo ""
281
+ fi
282
+
283
+ echo "Launching pi-coding-agent with pi user (sudo is required to impersonate 'pi' user)..."
284
+ exec sudo -i -u pi bash -c 'cd ${installDir} && npx --yes @mariozechner/pi-coding-agent "$@"' -- "$@"
285
+ `;
286
+ fs.writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
287
+ console.log('Launcher script created.');
288
+
289
+ // Add $HOME/bin to the current user's PATH via their rc file if not already present
290
+ const rcFile = getShellRcFile();
291
+ const rcPath = path.join(currentUserHome, rcFile);
292
+ const pathLine = 'export PATH="$HOME/bin:$PATH"';
293
+
294
+ let rcContent = '';
295
+ if (fs.existsSync(rcPath)) {
296
+ rcContent = fs.readFileSync(rcPath, 'utf-8');
297
+ }
298
+
299
+ if (!rcContent.includes(pathLine)) {
300
+ console.log(`Adding $HOME/bin to PATH in ${rcFile}...`);
301
+ fs.appendFileSync(rcPath, `\n${pathLine}\n`);
302
+ console.log(`${rcFile} updated.`);
303
+ } else {
304
+ console.log(`$HOME/bin already in PATH (${rcFile}).`);
305
+ }
306
+ }
307
+
308
+ async function launchAgent(): Promise<void> {
309
+ const scriptPath = path.join(os.homedir(), 'bin', 'pi');
310
+ const child = spawn(scriptPath, [], { stdio: 'inherit' });
311
+ return new Promise<void>((resolve, reject) => {
312
+ child.on('close', (code) => {
313
+ if (code === 0) {
314
+ resolve();
315
+ } else {
316
+ reject(new Error(`pi-coding-agent exited with code ${code}`));
317
+ }
318
+ });
319
+ });
320
+ }
321
+
322
+ async function wipeInstallation(): Promise<void> {
323
+ const installDir = getPiInstallDir();
324
+ if (fs.existsSync(installDir)) {
325
+ console.log(`Wiping existing installation at ${installDir}...`);
326
+ await runAsPi(`rm -rf ${installDir}`);
327
+ console.log('Installation wiped.');
328
+ } else {
329
+ console.log('No existing installation found, nothing to wipe.');
330
+ }
331
+ }
332
+
333
+ async function main() {
334
+ if (os.platform() === 'win32') {
335
+ throw new Error('Windows is not supported. Please run skynot on Linux or macOS.');
336
+ }
337
+
338
+ const program = new Command();
339
+ program
340
+ .version(pkg.version)
341
+ .description(pkg.description)
342
+ .option('--update', 'Wipe and reinstall pi-coding-agent to get the latest version');
343
+ program.parse(process.argv);
344
+ const opts = program.opts();
345
+
346
+ await ensurePiUser();
347
+
348
+ if (opts.update) {
349
+ await wipeInstallation();
350
+ }
351
+
352
+ await installAgent();
353
+ await updatePath();
354
+ await createLauncherScript();
355
+ await launchAgent();
356
+ }
357
+
358
+ main().catch((err) => {
359
+ console.error('Error:', err);
360
+ process.exit(1);
361
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "CommonJS",
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "resolveJsonModule": true,
10
+ "forceConsistentCasingInFileNames": true
11
+ }
12
+ }