@nonbot/cli 0.5.13

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,145 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { getActiveProfile } from './auth.js';
7
+ const MAC_PLIST_LABEL = 'bot.non.nonbot-daemon';
8
+ const LINUX_UNIT_NAME = 'nonbot-daemon';
9
+ function defaultCliEntry() {
10
+ try {
11
+ const here = fileURLToPath(import.meta.url);
12
+ return path.resolve(path.dirname(here), '..', 'index.js');
13
+ }
14
+ catch {
15
+ return path.resolve(process.cwd(), 'dist', 'index.js');
16
+ }
17
+ }
18
+ function macPlistPath(home) {
19
+ return path.join(home, 'Library', 'LaunchAgents', `${MAC_PLIST_LABEL}.plist`);
20
+ }
21
+ function linuxUnitPath(home) {
22
+ return path.join(home, '.config', 'systemd', 'user', `${LINUX_UNIT_NAME}.service`);
23
+ }
24
+ function serviceDaemonArgs(profile) {
25
+ const args = ['daemon', '--headless'];
26
+ if (profile !== 'default')
27
+ args.push('--profile', profile);
28
+ return args;
29
+ }
30
+ function xmlEscape(s) {
31
+ return s
32
+ .replace(/&/g, '&')
33
+ .replace(/</g, '&lt;')
34
+ .replace(/>/g, '&gt;');
35
+ }
36
+ function macPlist(execPath, cliEntry, profile) {
37
+ const programArgs = [execPath, cliEntry, ...serviceDaemonArgs(profile)];
38
+ const argEls = programArgs
39
+ .map((a) => ` <string>${xmlEscape(a)}</string>`)
40
+ .join('\n');
41
+ return `<?xml version="1.0" encoding="UTF-8"?>
42
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
43
+ <plist version="1.0">
44
+ <dict>
45
+ <key>Label</key>
46
+ <string>${MAC_PLIST_LABEL}</string>
47
+ <key>ProgramArguments</key>
48
+ <array>
49
+ ${argEls}
50
+ </array>
51
+ <key>RunAtLoad</key>
52
+ <true/>
53
+ <key>KeepAlive</key>
54
+ <true/>
55
+ </dict>
56
+ </plist>
57
+ `;
58
+ }
59
+ function linuxUnit(execPath, cliEntry, profile) {
60
+ const quote = (s) => `"${s.replace(/"/g, '\\"')}"`;
61
+ const execStart = [execPath, cliEntry, ...serviceDaemonArgs(profile)].map(quote).join(' ');
62
+ return `[Unit]
63
+ Description=non.bot CLI daemon
64
+
65
+ [Service]
66
+ ExecStart=${execStart}
67
+ Restart=on-failure
68
+
69
+ [Install]
70
+ WantedBy=default.target
71
+ `;
72
+ }
73
+ export async function installService(deps = {}) {
74
+ const platform = deps.platform ?? process.platform;
75
+ const home = (deps.homedir ?? homedir)();
76
+ const execPath = deps.execPath ?? process.execPath;
77
+ const cliEntry = deps.cliEntry ?? defaultCliEntry();
78
+ const profile = deps.profile ?? getActiveProfile();
79
+ const writeFile = deps.writeFile ?? ((f, d) => fs.writeFile(f, d));
80
+ const mkdir = deps.mkdir ?? ((d) => fs.mkdir(d, { recursive: true }).then(() => undefined));
81
+ const runSync = deps.spawnSync ??
82
+ ((cmd, args) => spawnSync(cmd, args, { encoding: 'utf-8' }));
83
+ if (platform === 'win32') {
84
+ return {
85
+ ok: false,
86
+ message: 'Service install is not supported on Windows yet — run `nonbot daemon` manually or use a Task Scheduler entry.',
87
+ };
88
+ }
89
+ if (platform === 'darwin') {
90
+ const plistPath = macPlistPath(home);
91
+ await mkdir(path.dirname(plistPath));
92
+ await writeFile(plistPath, macPlist(execPath, cliEntry, profile));
93
+ const res = runSync('launchctl', ['load', '-w', plistPath]);
94
+ if (res.error || (typeof res.status === 'number' && res.status !== 0)) {
95
+ const why = res.error ? res.error.message : `launchctl exited ${res.status}`;
96
+ return { ok: false, message: `Wrote ${plistPath} but launchctl load failed: ${why}` };
97
+ }
98
+ return { ok: true, message: `Installed launchd agent at ${plistPath} (loaded).` };
99
+ }
100
+ const unitPath = linuxUnitPath(home);
101
+ await mkdir(path.dirname(unitPath));
102
+ await writeFile(unitPath, linuxUnit(execPath, cliEntry, profile));
103
+ const reload = runSync('systemctl', ['--user', 'daemon-reload']);
104
+ if (reload.error || (typeof reload.status === 'number' && reload.status !== 0)) {
105
+ const why = reload.error ? reload.error.message : `systemctl exited ${reload.status}`;
106
+ return { ok: false, message: `Wrote ${unitPath} but systemctl daemon-reload failed: ${why}` };
107
+ }
108
+ const enable = runSync('systemctl', ['--user', 'enable', '--now', LINUX_UNIT_NAME]);
109
+ if (enable.error || (typeof enable.status === 'number' && enable.status !== 0)) {
110
+ const why = enable.error ? enable.error.message : `systemctl exited ${enable.status}`;
111
+ return { ok: false, message: `Wrote ${unitPath} but systemctl enable --now failed: ${why}` };
112
+ }
113
+ return { ok: true, message: `Installed systemd --user unit at ${unitPath} (enabled + started).` };
114
+ }
115
+ export async function uninstallService(deps = {}) {
116
+ const platform = deps.platform ?? process.platform;
117
+ const home = (deps.homedir ?? homedir)();
118
+ const unlink = deps.unlink ?? ((f) => fs.unlink(f).catch(() => undefined));
119
+ const runSync = deps.spawnSync ??
120
+ ((cmd, args) => spawnSync(cmd, args, { encoding: 'utf-8' }));
121
+ if (platform === 'win32') {
122
+ return {
123
+ ok: false,
124
+ message: 'Service install is not supported on Windows yet — nothing to uninstall.',
125
+ };
126
+ }
127
+ if (platform === 'darwin') {
128
+ const plistPath = macPlistPath(home);
129
+ runSync('launchctl', ['unload', plistPath]);
130
+ await unlink(plistPath);
131
+ return { ok: true, message: `Unloaded + removed ${plistPath}.` };
132
+ }
133
+ const unitPath = linuxUnitPath(home);
134
+ runSync('systemctl', ['--user', 'disable', '--now', LINUX_UNIT_NAME]);
135
+ await unlink(unitPath);
136
+ return { ok: true, message: `Disabled + removed ${unitPath}.` };
137
+ }
138
+ export const _serviceInternals = {
139
+ macPlistPath,
140
+ linuxUnitPath,
141
+ macPlist,
142
+ linuxUnit,
143
+ serviceDaemonArgs,
144
+ defaultCliEntry,
145
+ };
@@ -0,0 +1,313 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ function applescriptBashCommand(scriptPath) {
3
+ const shellSafe = `bash '${scriptPath.replace(/'/g, "'\\''")}'`;
4
+ return shellSafe.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
5
+ }
6
+ function applescriptTryWrap(tag, body) {
7
+ return [
8
+ 'try',
9
+ ...body.map((line) => ' ' + line),
10
+ 'on error errMsg number errNum',
11
+ ` log "[nonbot ${tag}] AppleScript error (" & errNum & "): " & errMsg`,
12
+ ' error errMsg number errNum',
13
+ 'end try',
14
+ ];
15
+ }
16
+ function toOsascriptArgs(lines) {
17
+ const args = [];
18
+ for (const line of lines) {
19
+ args.push('-e', line);
20
+ }
21
+ return { cmd: 'osascript', args };
22
+ }
23
+ export const TMUX_PRESS_KEY_TRAILER = "; printf '\\n%s\\n' 'Run finished — press enter to close'" +
24
+ '; read _' +
25
+ '; tmux kill-pane';
26
+ export const TERMINAL_PROFILES = [
27
+ {
28
+ id: 'terminal',
29
+ displayName: 'Terminal',
30
+ platform: 'darwin',
31
+ launch: (s) => toOsascriptArgs(applescriptTryWrap('terminal', [
32
+ 'tell application "Terminal"',
33
+ ' activate',
34
+ ` do script "${applescriptBashCommand(s)}"`,
35
+ 'end tell',
36
+ ])),
37
+ },
38
+ {
39
+ id: 'iterm',
40
+ displayName: 'iTerm',
41
+ platform: 'darwin',
42
+ launch: (s, opts) => {
43
+ const profile = opts?.itermProfileName;
44
+ const profileLine = profile
45
+ ? `create window with profile "${profile.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
46
+ : 'create window with default profile';
47
+ return toOsascriptArgs(applescriptTryWrap('iterm', [
48
+ 'tell application "iTerm"',
49
+ ` set newWin to (${profileLine})`,
50
+ ' activate',
51
+ ' try',
52
+ ' tell current session of newWin',
53
+ ` write text "${applescriptBashCommand(s)}"`,
54
+ ' end tell',
55
+ ' on error',
56
+ ' delay 0.3',
57
+ ' tell current session of newWin',
58
+ ` write text "${applescriptBashCommand(s)}"`,
59
+ ' end tell',
60
+ ' end try',
61
+ 'end tell',
62
+ ]));
63
+ },
64
+ },
65
+ {
66
+ id: 'iterm-tab',
67
+ displayName: 'iTerm (new tab)',
68
+ platform: 'darwin',
69
+ launch: (s, opts) => {
70
+ const profile = opts?.itermProfileName;
71
+ const escaped = profile
72
+ ? `"${profile.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
73
+ : undefined;
74
+ const windowLine = escaped
75
+ ? `set t to (create window with profile ${escaped})`
76
+ : 'set t to (create window with default profile)';
77
+ const tabLine = escaped
78
+ ? `set t to (create tab with profile ${escaped})`
79
+ : 'set t to (create tab with default profile)';
80
+ return toOsascriptArgs(applescriptTryWrap('iterm-tab', [
81
+ 'tell application "iTerm"',
82
+ ' activate',
83
+ ' if (count of windows) = 0 then',
84
+ ` ${windowLine}`,
85
+ ' else',
86
+ ' tell current window',
87
+ ` ${tabLine}`,
88
+ ' end tell',
89
+ ' end if',
90
+ ' try',
91
+ ' tell current session of t',
92
+ ` write text "${applescriptBashCommand(s)}"`,
93
+ ' end tell',
94
+ ' on error',
95
+ ' delay 0.3',
96
+ ' tell current session of t',
97
+ ` write text "${applescriptBashCommand(s)}"`,
98
+ ' end tell',
99
+ ' end try',
100
+ 'end tell',
101
+ ]));
102
+ },
103
+ },
104
+ {
105
+ id: 'ghostty',
106
+ displayName: 'Ghostty',
107
+ platform: 'darwin',
108
+ launch: (s) => ({ cmd: 'open', args: ['-na', 'Ghostty', '--args', '-e', 'bash', s] }),
109
+ },
110
+ {
111
+ id: 'wezterm',
112
+ displayName: 'WezTerm',
113
+ platform: 'darwin',
114
+ launch: (s) => ({ cmd: 'open', args: ['-na', 'WezTerm', '--args', 'start', '--', 'bash', s] }),
115
+ },
116
+ {
117
+ id: 'alacritty',
118
+ displayName: 'Alacritty',
119
+ platform: 'darwin',
120
+ launch: (s) => ({ cmd: 'open', args: ['-na', 'Alacritty', '--args', '-e', 'bash', s] }),
121
+ },
122
+ {
123
+ id: 'warp',
124
+ displayName: 'Warp',
125
+ platform: 'darwin',
126
+ launch: (s) => ({ cmd: 'open', args: ['-a', 'Warp', s] }),
127
+ },
128
+ {
129
+ id: 'tmux',
130
+ displayName: 'tmux pane',
131
+ platform: 'darwin',
132
+ launch: (s) => {
133
+ const safe = s.replace(/'/g, `'\\''`);
134
+ return {
135
+ cmd: 'tmux',
136
+ args: [
137
+ 'split-window',
138
+ '-P',
139
+ '-F',
140
+ '#{pane_id}',
141
+ `bash '${safe}' 2>&1${TMUX_PRESS_KEY_TRAILER}`,
142
+ ';',
143
+ 'select-layout',
144
+ 'tiled',
145
+ ],
146
+ };
147
+ },
148
+ },
149
+ {
150
+ id: 'gnome-terminal',
151
+ displayName: 'GNOME Terminal',
152
+ platform: 'linux',
153
+ launch: (s) => ({ cmd: 'gnome-terminal', args: ['--', 'bash', s] }),
154
+ },
155
+ {
156
+ id: 'konsole',
157
+ displayName: 'Konsole',
158
+ platform: 'linux',
159
+ launch: (s) => ({ cmd: 'konsole', args: ['-e', 'bash', s] }),
160
+ },
161
+ {
162
+ id: 'xterm',
163
+ displayName: 'xterm',
164
+ platform: 'linux',
165
+ launch: (s) => ({ cmd: 'xterm', args: ['-e', 'bash', s] }),
166
+ },
167
+ {
168
+ id: 'alacritty',
169
+ displayName: 'Alacritty',
170
+ platform: 'linux',
171
+ launch: (s) => ({ cmd: 'alacritty', args: ['-e', 'bash', s] }),
172
+ },
173
+ {
174
+ id: 'kitty',
175
+ displayName: 'kitty',
176
+ platform: 'linux',
177
+ launch: (s) => ({ cmd: 'kitty', args: ['bash', s] }),
178
+ },
179
+ {
180
+ id: 'wezterm',
181
+ displayName: 'WezTerm',
182
+ platform: 'linux',
183
+ launch: (s) => ({ cmd: 'wezterm', args: ['start', '--', 'bash', s] }),
184
+ },
185
+ {
186
+ id: 'x-terminal-emulator',
187
+ displayName: 'x-terminal-emulator',
188
+ platform: 'linux',
189
+ launch: (s) => ({ cmd: 'x-terminal-emulator', args: ['-e', 'bash', s] }),
190
+ },
191
+ {
192
+ id: 'tmux',
193
+ displayName: 'tmux pane',
194
+ platform: 'linux',
195
+ launch: (s) => {
196
+ const safe = s.replace(/'/g, `'\\''`);
197
+ return {
198
+ cmd: 'tmux',
199
+ args: [
200
+ 'split-window',
201
+ '-P',
202
+ '-F',
203
+ '#{pane_id}',
204
+ `bash '${safe}' 2>&1${TMUX_PRESS_KEY_TRAILER}`,
205
+ ';',
206
+ 'select-layout',
207
+ 'tiled',
208
+ ],
209
+ };
210
+ },
211
+ },
212
+ {
213
+ id: 'wt',
214
+ displayName: 'Windows Terminal',
215
+ platform: 'win32',
216
+ launch: (s) => ({ cmd: 'wt.exe', args: ['bash', s] }),
217
+ },
218
+ {
219
+ id: 'cmd',
220
+ displayName: 'Command Prompt',
221
+ platform: 'win32',
222
+ launch: (s) => ({ cmd: 'cmd.exe', args: ['/c', 'start', 'bash', s] }),
223
+ },
224
+ ];
225
+ const PLATFORM_DEFAULT = {
226
+ darwin: 'terminal',
227
+ linux: 'x-terminal-emulator',
228
+ win32: 'wt',
229
+ };
230
+ function normalize(pref) {
231
+ return pref
232
+ .toLowerCase()
233
+ .trim()
234
+ .replace(/\.app$/, '')
235
+ .replace(/[^a-z0-9]/g, '');
236
+ }
237
+ const ALIASES = {
238
+ windowsterminal: 'wt',
239
+ wt: 'wt',
240
+ commandprompt: 'cmd',
241
+ cmd: 'cmd',
242
+ gnometerminal: 'gnome-terminal',
243
+ gnome: 'gnome-terminal',
244
+ xtermemulator: 'x-terminal-emulator',
245
+ xterminalemulator: 'x-terminal-emulator',
246
+ terminalapp: 'terminal',
247
+ appleterminal: 'terminal',
248
+ iterm2: 'iterm',
249
+ itermtab: 'iterm-tab',
250
+ iterm2tab: 'iterm-tab',
251
+ itermnewtab: 'iterm-tab',
252
+ };
253
+ export function paneTitleFromScriptPath(scriptPath) {
254
+ const base = scriptPath.split(/[\\/]/).pop() ?? '';
255
+ const stem = base.replace(/\.(command|sh)$/i, '');
256
+ const match = stem.match(/^nonbot-(.+)$/);
257
+ if (!match)
258
+ return 'nonbot';
259
+ const sanitized = match[1]
260
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
261
+ .replace(/ {2,}/g, ' ')
262
+ .trim();
263
+ return `nonbot ${sanitized.slice(0, 32)}`;
264
+ }
265
+ export function inTmuxSession(env = process.env) {
266
+ return !!env.TMUX;
267
+ }
268
+ export function nonbotTmuxOptOut(env = process.env) {
269
+ return env.NONBOT_NO_TMUX === '1' || env.NONBOT_DISABLE_TMUX === '1';
270
+ }
271
+ export function detectTmuxSession() {
272
+ if (!inTmuxSession())
273
+ return null;
274
+ try {
275
+ const result = spawnSync('tmux', ['display-message', '-p', '#S'], {
276
+ encoding: 'utf-8',
277
+ timeout: 1000,
278
+ windowsHide: true,
279
+ });
280
+ if (result.status === 0 && typeof result.stdout === 'string') {
281
+ const name = result.stdout.trim();
282
+ if (name.length > 0)
283
+ return name;
284
+ }
285
+ }
286
+ catch {
287
+ }
288
+ return 'tmux';
289
+ }
290
+ export function resolveTerminal(preference, platform = process.platform) {
291
+ const onPlatform = (id) => TERMINAL_PROFILES.find((p) => p.platform === platform && p.id === id);
292
+ const defaultId = PLATFORM_DEFAULT[platform] ?? 'terminal';
293
+ const fallback = onPlatform(defaultId) ??
294
+ TERMINAL_PROFILES.find((p) => p.platform === platform) ??
295
+ TERMINAL_PROFILES[0];
296
+ if (platform !== 'win32' && inTmuxSession() && !nonbotTmuxOptOut()) {
297
+ const tmux = onPlatform('tmux');
298
+ if (tmux)
299
+ return tmux;
300
+ }
301
+ const candidates = [process.env.NONBOT_TERMINAL, preference];
302
+ for (const raw of candidates) {
303
+ if (!raw || raw.trim().length === 0)
304
+ continue;
305
+ const norm = normalize(raw);
306
+ const canonical = ALIASES[norm] ?? norm;
307
+ const hit = onPlatform(canonical) ??
308
+ TERMINAL_PROFILES.find((p) => p.platform === platform && normalize(p.id) === norm);
309
+ if (hit)
310
+ return hit;
311
+ }
312
+ return fallback;
313
+ }
@@ -0,0 +1 @@
1
+ export const VERSION = '0.5.13';
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@nonbot/cli",
3
+ "version": "0.5.13",
4
+ "type": "module",
5
+ "description": "The local host for non.bot ▶ Run — opens a terminal on your machine and starts the work in your linked repo.",
6
+ "license": "UNLICENSED",
7
+ "bin": {
8
+ "nonbot": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "CHANGELOG.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "dev": "tsx src/index.ts",
22
+ "start": "node dist/index.js",
23
+ "test": "vitest run",
24
+ "typecheck": "tsc --noEmit",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "tsx": "^4.7.0",
30
+ "typescript": "^5.3.0",
31
+ "vitest": "^3.0.0"
32
+ }
33
+ }