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.
- package/.github/workflows/ci.yml +37 -0
- package/.github/workflows/publish.yml +49 -0
- package/LICENCE.txt +16 -0
- package/ReadMe.md +16 -0
- package/dist/index.js +368 -0
- package/package.json +23 -0
- package/src/index.ts +361 -0
- package/tsconfig.json +12 -0
|
@@ -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