trooper-cli 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/README.md +14 -3
  2. package/bin/trooper.js +165 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Official command-line installer for Trooper's local desktop runtime.
4
4
 
5
- Trooper can run on your own Mac without a hosted cloud computer. This CLI installs the local bridge, starts the local AI gateway, and lets the Trooper desktop app pair the machine to your workspace.
5
+ Trooper can run on your own Mac without a hosted cloud computer. This CLI installs the local bridge, starts the local AI gateway, installs the Trooper desktop app when it is missing, and opens Trooper so the machine can pair to your workspace.
6
6
 
7
7
  ## Quick Start
8
8
 
@@ -12,7 +12,7 @@ Install Trooper locally on this Mac:
12
12
  npx -y trooper-cli
13
13
  ```
14
14
 
15
- Then open the Trooper desktop app to connect this computer to your workspace.
15
+ The command opens the Trooper desktop app when setup finishes.
16
16
 
17
17
  ## What It Sets Up
18
18
 
@@ -23,9 +23,12 @@ On macOS, `npx -y trooper-cli` prepares:
23
23
  - LaunchAgents so the local host can keep running
24
24
  - A Docker-compatible runtime using existing Docker or Colima when needed
25
25
  - Local runtime files under `~/Library/Application Support/Trooper/runtime`
26
+ - Trooper.app in `~/Applications` when the desktop app is not already installed
26
27
 
27
28
  The local desktop plan does not require a Trooper cloud computer or Stripe checkout.
28
29
 
30
+ One computer can host one local Trooper workspace at a time. Re-running the CLI for the same workspace is safe. If the computer is already paired to another workspace, Trooper leaves that installation unchanged and asks you to use another computer, choose Trooper Cloud, or uninstall the existing local host first.
31
+
29
32
  ## Commands
30
33
 
31
34
  ### Install local runtime
@@ -40,6 +43,12 @@ This is shorthand for:
40
43
  npx -y trooper-cli onboard --yes
41
44
  ```
42
45
 
46
+ Install only the runtime and skip desktop app install/open:
47
+
48
+ ```bash
49
+ npx -y trooper-cli --skip-app
50
+ ```
51
+
43
52
  ### Install with a Trooper setup token
44
53
 
45
54
  The Trooper app may generate a short-lived setup token for workspace pairing:
@@ -65,6 +74,8 @@ npx -y --prefer-online trooper-cli@latest uninstall --yes
65
74
 
66
75
  This removes Trooper's local runtime, LaunchAgents, installer command file, and Trooper local Docker container. It leaves Docker, Colima, Homebrew, Node, and npm installed because those may be used by other projects.
67
76
 
77
+ When online, uninstall also releases this computer's workspace binding so it can be paired elsewhere.
78
+
68
79
  Keep local workspace/config data:
69
80
 
70
81
  ```bash
@@ -85,7 +96,7 @@ npx -y --prefer-online trooper-cli@latest uninstall --yes --remove-app
85
96
 
86
97
  ## Desktop App
87
98
 
88
- Local desktop install is meant to be started from the Trooper Mac or Windows app. The web onboarding page shows the plan, but only the desktop app can safely launch the local helper installer.
99
+ The CLI installs and opens the Trooper Mac app automatically when it is missing. If the app is already installed in `~/Applications`, `/Applications`, or registered with Launch Services, the CLI opens the existing app instead of downloading it again.
89
100
 
90
101
  ## Links
91
102
 
package/bin/trooper.js CHANGED
@@ -2,9 +2,13 @@
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
4
  import { randomBytes } from 'node:crypto';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
5
8
 
6
9
  const DEFAULT_API_URL = 'https://trooper-production.up.railway.app';
7
10
  const LOCAL_MAC_SETUP_SCRIPT_URL = 'https://raw.githubusercontent.com/absurdfounder/trooper-bridge/main/setup-local-mac-host.sh';
11
+ const TROOPER_MAC_APP_DMG_URL = 'https://github.com/absurdfounder/trooper_landing/releases/download/macos-latest/Trooper.dmg';
8
12
  const LOCAL_MAC_LABELS = [
9
13
  'so.trooper.local-gateway',
10
14
  'so.trooper.local-bridge',
@@ -33,6 +37,7 @@ Options:
33
37
  --yes Run non-interactively with sensible defaults
34
38
  --keep-data Preserve local workspace/config data during uninstall
35
39
  --remove-app Also remove Trooper.app when uninstalling, if it is writable
40
+ --skip-app Install the local runtime only; do not install/open Trooper.app
36
41
  --print-command Print the installer command without running it
37
42
  --dry-run Same as --print-command
38
43
  -h, --help Show this help
@@ -49,7 +54,7 @@ function parseArgs(argv) {
49
54
  }
50
55
  const [key, inlineValue] = arg.split('=', 2);
51
56
  const name = key.replace(/^-+/, '');
52
- if (['help', 'h', 'dry-run', 'print-command', 'yes', 'y', 'keep-data', 'remove-app'].includes(name)) {
57
+ if (['help', 'h', 'dry-run', 'print-command', 'yes', 'y', 'keep-data', 'remove-app', 'skip-app'].includes(name)) {
53
58
  args[name] = true;
54
59
  continue;
55
60
  }
@@ -86,7 +91,110 @@ function localToken(prefix) {
86
91
  return `${prefix}_${randomBytes(24).toString('base64url')}`;
87
92
  }
88
93
 
89
- function buildLocalMacInstallCommand({ apiUrl }) {
94
+ function decodeShellEnvValue(value = '') {
95
+ const normalized = String(value || '').trim();
96
+ if (normalized.startsWith("'") && normalized.endsWith("'")) return normalized.slice(1, -1);
97
+ return normalized.replace(/\\([\\ :/?&=])/g, '$1');
98
+ }
99
+
100
+ function readExistingLocalHostBinding() {
101
+ if (process.platform !== 'darwin') return null;
102
+ const installIdPath = join(homedir(), 'Library', 'Application Support', 'Trooper', 'install-id');
103
+ const envPath = join(homedir(), 'Library', 'Application Support', 'Trooper', 'runtime', 'trooper-local-host.env');
104
+ const persistedHostDeviceId = existsSync(installIdPath)
105
+ ? String(readFileSync(installIdPath, 'utf8') || '').trim()
106
+ : '';
107
+ if (!existsSync(envPath)) {
108
+ return persistedHostDeviceId ? {
109
+ envPath,
110
+ orgId: '',
111
+ hostDeviceId: persistedHostDeviceId,
112
+ apiUrl: DEFAULT_API_URL,
113
+ token: '',
114
+ paired: false,
115
+ } : null;
116
+ }
117
+ try {
118
+ const values = {};
119
+ for (const line of readFileSync(envPath, 'utf8').split(/\r?\n/)) {
120
+ const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
121
+ if (match) values[match[1]] = decodeShellEnvValue(match[2]);
122
+ }
123
+ if (!values.ORG_ID) return null;
124
+ return {
125
+ envPath,
126
+ orgId: values.ORG_ID,
127
+ hostDeviceId: persistedHostDeviceId || values.TROOPER_INSTALLATION_ID || values.HOST_DEVICE_ID || '',
128
+ apiUrl: values.API_URL || DEFAULT_API_URL,
129
+ token: values.BRIDGE_AUTH_TOKEN || values.GATEWAY_TOKEN || '',
130
+ paired: values.ORG_ID !== 'local-unpaired',
131
+ };
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function buildLocalMacAppInstallCommand() {
138
+ return `install_or_open_trooper_app() {
139
+ export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
140
+ local app_path=""
141
+ for candidate in "$HOME/Applications/Trooper.app" "/Applications/Trooper.app"; do
142
+ if [[ -d "$candidate" ]]; then
143
+ app_path="$candidate"
144
+ break
145
+ fi
146
+ done
147
+
148
+ if [[ -z "$app_path" ]] && /usr/bin/open -Ra "Trooper" >/dev/null 2>&1; then
149
+ echo "Opening Trooper desktop app..."
150
+ /usr/bin/open -a "Trooper"
151
+ return 0
152
+ fi
153
+
154
+ if [[ -n "$app_path" ]]; then
155
+ echo "Opening Trooper desktop app..."
156
+ /usr/bin/open "$app_path"
157
+ return 0
158
+ fi
159
+
160
+ echo "Installing Trooper desktop app..."
161
+ local dmg_url="\${TROOPER_MAC_APP_DMG_URL:-${TROOPER_MAC_APP_DMG_URL}}"
162
+ local tmp_dmg mount_dir app_src install_root
163
+ tmp_dmg="$(/usr/bin/mktemp -t trooper-macos.XXXXXX).dmg"
164
+ mount_dir="$(/usr/bin/mktemp -d -t trooper-dmg.XXXXXX)"
165
+ install_root="$HOME/Applications"
166
+
167
+ cleanup_trooper_dmg() {
168
+ /usr/bin/hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
169
+ /bin/rm -f "$tmp_dmg"
170
+ /bin/rm -rf "$mount_dir"
171
+ }
172
+ trap cleanup_trooper_dmg EXIT
173
+
174
+ /usr/bin/curl -fL --retry 3 --retry-delay 2 "$dmg_url" -o "$tmp_dmg"
175
+ /usr/bin/hdiutil attach "$tmp_dmg" -mountpoint "$mount_dir" -nobrowse -quiet
176
+ app_src="$(/usr/bin/find "$mount_dir" -maxdepth 1 -name "Trooper.app" -type d | /usr/bin/head -1)"
177
+ if [[ -z "$app_src" ]]; then
178
+ echo "Trooper.app was not found inside the downloaded DMG."
179
+ return 1
180
+ fi
181
+
182
+ /bin/mkdir -p "$install_root"
183
+ /bin/rm -rf "$install_root/Trooper.app"
184
+ /usr/bin/ditto "$app_src" "$install_root/Trooper.app"
185
+ /usr/bin/hdiutil detach "$mount_dir" -quiet >/dev/null 2>&1 || true
186
+ trap - EXIT
187
+ /bin/rm -f "$tmp_dmg"
188
+ /bin/rm -rf "$mount_dir"
189
+
190
+ echo "Trooper desktop app installed at: $install_root/Trooper.app"
191
+ /usr/bin/open "$install_root/Trooper.app"
192
+ }
193
+
194
+ install_or_open_trooper_app`;
195
+ }
196
+
197
+ function buildLocalMacInstallCommand({ apiUrl, installApp = true } = {}) {
90
198
  const hostSuffix = randomBytes(6).toString('hex');
91
199
  const env = {
92
200
  TROOPER_LOCAL_HOST: '1',
@@ -101,10 +209,12 @@ function buildLocalMacInstallCommand({ apiUrl }) {
101
209
  TUNNEL_PROVIDER: 'local',
102
210
  };
103
211
  return [
212
+ 'set -euo pipefail',
104
213
  buildExportCommand(env),
105
214
  `curl -fsSL ${shellSingleQuote(LOCAL_MAC_SETUP_SCRIPT_URL)} -o /tmp/trooper-local-mac-host.sh`,
106
215
  'chmod +x /tmp/trooper-local-mac-host.sh',
107
216
  'bash /tmp/trooper-local-mac-host.sh',
217
+ installApp ? buildLocalMacAppInstallCommand() : '',
108
218
  ].join('\n');
109
219
  }
110
220
 
@@ -122,6 +232,26 @@ GROUP_ID="\$(/usr/bin/id -g)"
122
232
 
123
233
  echo "Uninstalling Trooper local host from this Mac..."
124
234
 
235
+ if [[ -f "$TROOPER_HOME/trooper-local-host.env" ]]; then
236
+ set -a
237
+ source "$TROOPER_HOME/trooper-local-host.env"
238
+ set +a
239
+ if [[ -n "\${ORG_ID:-}" && "\${ORG_ID:-}" != "local-unpaired" && -n "\${BRIDGE_AUTH_TOKEN:-}" ]]; then
240
+ RELEASE_API="\${API_URL:-${DEFAULT_API_URL}}"
241
+ RELEASE_BODY="$(ORG_ID="$ORG_ID" HOST_DEVICE_ID="\${TROOPER_INSTALLATION_ID:-\${HOST_DEVICE_ID:-}}" BRIDGE_AUTH_TOKEN="$BRIDGE_AUTH_TOKEN" /usr/bin/python3 - <<'PY'
242
+ import json, os
243
+ print(json.dumps({
244
+ "token": os.environ.get("BRIDGE_AUTH_TOKEN", ""),
245
+ "hostDeviceId": os.environ.get("HOST_DEVICE_ID", ""),
246
+ }, separators=(",", ":")))
247
+ PY
248
+ )"
249
+ /usr/bin/curl -fsS -X POST "$RELEASE_API/api/organizations/$ORG_ID/local-host/release" \
250
+ -H 'Content-Type: application/json' --data "$RELEASE_BODY" >/dev/null \
251
+ || echo "Warning: Trooper could not release the online workspace binding. Local removal will continue."
252
+ fi
253
+ fi
254
+
125
255
  ROOT_OWNED_PATHS=()
126
256
  for path in "$TROOPER_HOME" "$TROOPER_PARENT_DIR/install-local-host.command" "$PLIST_DIR"/so.trooper.local-*.plist; do
127
257
  if [[ -e "$path" && ! -O "$path" ]]; then
@@ -174,14 +304,14 @@ fi
174
304
  echo "Docker, Colima, Homebrew, Node, and npm were left installed."`;
175
305
  }
176
306
 
177
- async function fetchSetupGuide({ apiUrl, token, platform }) {
307
+ async function fetchSetupGuide({ apiUrl, token, platform, hostDeviceId = '' }) {
178
308
  const res = await fetch(`${apiUrl}/api/local-host/setup`, {
179
309
  method: 'POST',
180
310
  headers: {
181
311
  'Content-Type': 'application/json',
182
312
  'x-trooper-setup-token': token,
183
313
  },
184
- body: JSON.stringify({ token, platform }),
314
+ body: JSON.stringify({ token, platform, hostDeviceId }),
185
315
  });
186
316
  const data = await res.json().catch(async () => ({ message: await res.text() }));
187
317
  if (!res.ok) {
@@ -261,7 +391,21 @@ async function main() {
261
391
  console.error('Tokenless local install is currently available for macOS. Open Trooper to prepare a paired installer for this platform.');
262
392
  process.exit(1);
263
393
  }
264
- const installCommand = buildLocalMacInstallCommand({ apiUrl });
394
+ const existingBinding = readExistingLocalHostBinding();
395
+ if (existingBinding?.paired) {
396
+ console.log(`This Mac already hosts Trooper workspace ${existingBinding.orgId}.`);
397
+ console.log('The existing local workspace was left unchanged. Opening Trooper...');
398
+ if (args['print-command'] || args['dry-run']) {
399
+ console.log('\n' + buildLocalMacAppInstallCommand());
400
+ return;
401
+ }
402
+ runShellCommand(buildLocalMacAppInstallCommand(), platform, {
403
+ failureLabel: 'Trooper desktop app',
404
+ successMessage: '\nTrooper desktop app is ready.',
405
+ });
406
+ return;
407
+ }
408
+ const installCommand = buildLocalMacInstallCommand({ apiUrl, installApp: !args['skip-app'] });
265
409
  console.log('Starting Trooper local host setup on this Mac...');
266
410
  if (args['print-command'] || args['dry-run']) {
267
411
  console.log('\n' + installCommand);
@@ -269,13 +413,27 @@ async function main() {
269
413
  }
270
414
  runShellCommand(installCommand, platform, {
271
415
  failureLabel: 'Installer',
272
- successMessage: '\nTrooper local host is installed. Open Trooper to connect this Mac to your workspace.',
416
+ successMessage: args['skip-app']
417
+ ? '\nTrooper local host is installed. Open Trooper to connect this Mac to your workspace.'
418
+ : '\nTrooper local host is installed. Trooper desktop app is ready.',
273
419
  });
274
420
  return;
275
421
  }
276
422
 
423
+ const existingBinding = readExistingLocalHostBinding();
277
424
  console.log(`Preparing Trooper local setup from ${apiUrl}...`);
278
- const guide = await fetchSetupGuide({ apiUrl, token, platform });
425
+ const guide = await fetchSetupGuide({
426
+ apiUrl,
427
+ token,
428
+ platform,
429
+ hostDeviceId: existingBinding?.hostDeviceId || '',
430
+ });
431
+ if (existingBinding?.paired && existingBinding.orgId !== guide.orgId) {
432
+ console.error(`This Mac already hosts Trooper workspace ${existingBinding.orgId}.`);
433
+ console.error(`Trooper will not replace it with workspace ${guide.orgId}.`);
434
+ console.error('Open the existing workspace, use another computer, or run `npx -y trooper-cli uninstall --yes` first.');
435
+ process.exit(73);
436
+ }
279
437
  const label = guide.organizationName ? ` for ${guide.organizationName}` : '';
280
438
  console.log(`Starting Trooper ${guide.platform || platform} local host setup${label}.`);
281
439
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trooper-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Official Trooper CLI for installing and managing the local desktop AI runtime.",
5
5
  "type": "module",
6
6
  "bin": {