terminalhire 0.42.2 → 0.42.3

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.
@@ -294,10 +294,12 @@ __export(protocol_exports, {
294
294
  handleUrl: () => handleUrl,
295
295
  healStaleHandler: () => healStaleHandler,
296
296
  parseClaimUrl: () => parseClaimUrl,
297
+ preferredTerminalApp: () => preferredTerminalApp,
297
298
  printClaimCommand: () => printClaimCommand,
298
299
  registerScheme: () => registerScheme,
299
300
  schemeStatus: () => schemeStatus,
300
- unregisterScheme: () => unregisterScheme
301
+ unregisterScheme: () => unregisterScheme,
302
+ writeClaimLauncher: () => writeClaimLauncher
301
303
  });
302
304
  import { spawn, spawnSync } from "child_process";
303
305
  import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3, renameSync } from "fs";
@@ -329,8 +331,11 @@ function defaultProtocolDeps() {
329
331
  ensureStateDir: (path6) => {
330
332
  ensureStateDir(path6);
331
333
  },
332
- writeFileSync: (path6, contents) => {
333
- writeFileSync3(path6, contents, "utf8");
334
+ ensureStateDirForSecret: (path6) => {
335
+ ensureStateDirForSecret(path6);
336
+ },
337
+ writeFileSync: (path6, contents, mode) => {
338
+ writeFileSync3(path6, contents, mode === void 0 ? "utf8" : { encoding: "utf8", mode });
334
339
  },
335
340
  readFileSync: (path6) => readFileSync3(path6, "utf8"),
336
341
  rmSync: (path6) => {
@@ -370,30 +375,52 @@ function appleScriptStringLiteral(s) {
370
375
  function shellQuoteSingle(s) {
371
376
  return `'${s.replace(/'/g, `'\\''`)}'`;
372
377
  }
373
- function buildAppleScriptHandler(execPath, dispatchPath) {
378
+ function preferredTerminalApp(env) {
379
+ const raw = typeof env.TERM_PROGRAM === "string" ? env.TERM_PROGRAM.trim() : "";
380
+ if (!raw) return null;
381
+ const known = {
382
+ apple_terminal: "Terminal",
383
+ "iterm.app": "iTerm"
384
+ };
385
+ return known[raw.toLowerCase()] ?? null;
386
+ }
387
+ function buildAppleScriptHandler(execPath, dispatchPath, terminalApp) {
374
388
  const execLit = appleScriptStringLiteral(execPath);
375
389
  const dispatchLit = appleScriptStringLiteral(dispatchPath);
390
+ const fallback = (indent) => [
391
+ `${indent}display notification "Couldn't open your terminal. Run: " & launcher with title "Terminalhire"`,
392
+ `${indent}try`,
393
+ `${indent} do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " handle-url " & quoted form of theURL & " > /dev/null 2>&1 &"`,
394
+ `${indent}end try`
395
+ ];
396
+ const launch = terminalApp ? [
397
+ " try",
398
+ ` do shell script "open -a " & quoted form of ${appleScriptStringLiteral(terminalApp)} & " " & quoted form of launcher`,
399
+ " on error",
400
+ " try",
401
+ ' do shell script "open " & quoted form of launcher',
402
+ " on error",
403
+ ...fallback(" "),
404
+ " end try",
405
+ " end try"
406
+ ] : [
407
+ " try",
408
+ ' do shell script "open " & quoted form of launcher',
409
+ " on error",
410
+ ...fallback(" "),
411
+ " end try"
412
+ ];
376
413
  return [
377
414
  "on open location theURL",
378
- ' set claimCmd to ""',
415
+ ' set launcher to ""',
379
416
  " try",
380
- ` set claimCmd to do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " print-claim-command " & quoted form of theURL`,
417
+ ` set launcher to do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " write-claim-launcher " & quoted form of theURL`,
381
418
  " end try",
382
- ' if claimCmd is "" then',
419
+ ' if launcher is "" then',
383
420
  ` display notification "That isn't a valid Terminalhire claim link." with title "Terminalhire"`,
384
421
  " return",
385
422
  " end if",
386
- " try",
387
- ' tell application "Terminal"',
388
- " activate",
389
- " do script claimCmd",
390
- " end tell",
391
- " on error",
392
- ` display notification "Couldn't open Terminal automatically. Run: " & claimCmd with title "Terminalhire"`,
393
- " try",
394
- ` do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " handle-url " & quoted form of theURL & " > /dev/null 2>&1 &"`,
395
- " end try",
396
- " end try",
423
+ ...launch,
397
424
  "end open location",
398
425
  ""
399
426
  ].join("\n");
@@ -404,7 +431,12 @@ function buildPreviewShellCommand(token, deps) {
404
431
  shellQuoteSingle(deps.dispatchPath),
405
432
  "claim",
406
433
  "preview",
407
- token
434
+ // Quoted like the paths beside it, though parseClaimUrl's `[A-Za-z0-9_-]{8}` already
435
+ // forbids every shell metacharacter. Leaving the ONE value that came from a URL bare
436
+ // while quoting the two that never did is backwards: it makes the safety depend on a
437
+ // regex two files away rather than on this line, and a future loosening of that regex
438
+ // would open an injection here silently.
439
+ shellQuoteSingle(token)
408
440
  ].join(" ");
409
441
  }
410
442
  function printClaimCommand(raw, deps = defaultProtocolDeps()) {
@@ -416,6 +448,28 @@ function printClaimCommand(raw, deps = defaultProtocolDeps()) {
416
448
  deps.log(buildPreviewShellCommand(parsed.token, deps));
417
449
  deps.exit(0);
418
450
  }
451
+ function launcherPath(token, deps) {
452
+ return join3(stateDir(deps), `claim-${token}.command`);
453
+ }
454
+ function buildLauncherScript(token, deps) {
455
+ return ["#!/bin/sh", `exec ${buildPreviewShellCommand(token, deps)}`, ""].join("\n");
456
+ }
457
+ function writeLauncherFile(token, deps) {
458
+ deps.ensureStateDirForSecret(stateDir(deps));
459
+ const path6 = launcherPath(token, deps);
460
+ deps.rmSync(path6);
461
+ deps.writeFileSync(path6, buildLauncherScript(token, deps), 448);
462
+ return path6;
463
+ }
464
+ function writeClaimLauncher(raw, deps = defaultProtocolDeps()) {
465
+ const parsed = parseClaimUrl(raw);
466
+ if (!parsed) {
467
+ deps.exit(1);
468
+ return;
469
+ }
470
+ deps.log(writeLauncherFile(parsed.token, deps));
471
+ deps.exit(0);
472
+ }
419
473
  function darwinAppPaths(deps) {
420
474
  const appDir = join3(deps.homedir(), "Applications");
421
475
  const appPath = join3(appDir, "Terminalhire Handler.app");
@@ -428,7 +482,16 @@ function darwinRegister(deps) {
428
482
  const { appDir, appPath, plistPath } = darwinAppPaths(deps);
429
483
  deps.mkdirSync(appDir);
430
484
  const scriptPath = join3(dir, "handler.applescript");
431
- deps.writeFileSync(scriptPath, buildAppleScriptHandler(deps.execPath, deps.dispatchPath));
485
+ deps.writeFileSync(
486
+ scriptPath,
487
+ buildAppleScriptHandler(
488
+ deps.execPath,
489
+ deps.dispatchPath,
490
+ // Resolved at REGISTER time, from the shell the user ran `protocol register` in.
491
+ // `open location` runs with no TERM_PROGRAM at all, so this cannot be deferred.
492
+ preferredTerminalApp(deps.env)
493
+ )
494
+ );
432
495
  deps.rmSync(appPath);
433
496
  const compile = deps.spawnSync("osacompile", ["-o", appPath, scriptPath]);
434
497
  if (compile.status !== 0) {
@@ -496,14 +559,10 @@ function darwinStatus(deps) {
496
559
  return { registered: exists && registered, appExists: exists };
497
560
  }
498
561
  function darwinOpenPreviewTerminal(token, deps) {
499
- const doScript = `do script ${appleScriptStringLiteral(buildPreviewShellCommand(token, deps))}`;
500
- const res = deps.spawnSync("osascript", [
501
- "-e",
502
- 'tell application "Terminal" to activate',
503
- "-e",
504
- doScript
505
- ]);
506
- return res.status === 0;
562
+ const path6 = writeLauncherFile(token, deps);
563
+ const app = preferredTerminalApp(deps.env);
564
+ if (app && deps.spawnSync("open", ["-a", app, path6]).status === 0) return true;
565
+ return deps.spawnSync("open", [path6]).status === 0;
507
566
  }
508
567
  function win32Register(deps) {
509
568
  for (const scheme of WIN32_SCHEMES) {
@@ -792,7 +851,7 @@ var init_protocol = __esm({
792
851
  ["konsole", ["-e"]],
793
852
  ["xterm", ["-e"]]
794
853
  ];
795
- HANDLER_TEMPLATE_VERSION = 2;
854
+ HANDLER_TEMPLATE_VERSION = 3;
796
855
  PENDING_CLAIMS_CAP = 20;
797
856
  PENDING_TOKEN_RE = /^[A-Za-z0-9_-]{8}$/;
798
857
  }
@@ -67272,6 +67331,10 @@ if (firstArg === "print-claim-command") {
67272
67331
  const { printClaimCommand: printClaimCommand2 } = await Promise.resolve().then(() => (init_protocol(), protocol_exports));
67273
67332
  printClaimCommand2(process.argv[3]);
67274
67333
  }
67334
+ if (firstArg === "write-claim-launcher") {
67335
+ const { writeClaimLauncher: writeClaimLauncher2 } = await Promise.resolve().then(() => (init_protocol(), protocol_exports));
67336
+ writeClaimLauncher2(process.argv[3]);
67337
+ }
67275
67338
  if (firstArg === "profile") {
67276
67339
  const mod2 = await Promise.resolve().then(() => (init_jpi_profile(), jpi_profile_exports));
67277
67340
  await mod2.run();
@@ -51,7 +51,31 @@ function ensureStateDir(dir) {
51
51
  }
52
52
  }
53
53
  }
54
- var STATE_DIR_MODE, STATE_DIR_OK, STATE_DIR_SYMLINK, STATE_DIR_UNVERIFIED, warnedDirs;
54
+ function applyStateDirSecretPolicy(dir, status) {
55
+ if (status === STATE_DIR_SYMLINK) {
56
+ throw new Error(
57
+ `terminalhire: refusing to write key material into ${dir} \u2014 it is a symlink, not a directory.
58
+ A write through it would FOLLOW THE LINK and place key/token material wherever the symlink points, outside our control and outside the "owner-only" (0700) guarantee this directory is supposed to carry.
59
+ Fix: remove the symlink so terminalhire can recreate it as a real directory \u2014
60
+ rm ${dir}
61
+ then re-run the command. If the symlink is intentional, point TERMINALHIRE_DIR at a real directory instead of routing it through this one.`
62
+ );
63
+ }
64
+ if (status === STATE_DIR_UNVERIFIED && !warnedUnverifiedSecretWriteThisProcess) {
65
+ warnedUnverifiedSecretWriteThisProcess = true;
66
+ try {
67
+ process.stderr.write(
68
+ `terminalhire: could not verify ${dir}'s permissions (expected on Windows \u2014 POSIX mode bits do not apply there) \u2014 proceeding, but the "owner-only" guarantee on key/token storage is NOT enforced on this platform.
69
+ `
70
+ );
71
+ } catch {
72
+ }
73
+ }
74
+ }
75
+ function ensureStateDirForSecret(dir) {
76
+ applyStateDirSecretPolicy(dir, ensureStateDir(dir));
77
+ }
78
+ var STATE_DIR_MODE, STATE_DIR_OK, STATE_DIR_SYMLINK, STATE_DIR_UNVERIFIED, warnedDirs, warnedUnverifiedSecretWriteThisProcess;
55
79
  var init_state_dir = __esm({
56
80
  "src/state-dir.ts"() {
57
81
  "use strict";
@@ -60,6 +84,7 @@ var init_state_dir = __esm({
60
84
  STATE_DIR_SYMLINK = "symlink";
61
85
  STATE_DIR_UNVERIFIED = "unverified";
62
86
  warnedDirs = /* @__PURE__ */ new Set();
87
+ warnedUnverifiedSecretWriteThisProcess = false;
63
88
  }
64
89
  });
65
90
 
@@ -72,10 +97,12 @@ __export(protocol_exports, {
72
97
  handleUrl: () => handleUrl,
73
98
  healStaleHandler: () => healStaleHandler,
74
99
  parseClaimUrl: () => parseClaimUrl,
100
+ preferredTerminalApp: () => preferredTerminalApp,
75
101
  printClaimCommand: () => printClaimCommand,
76
102
  registerScheme: () => registerScheme,
77
103
  schemeStatus: () => schemeStatus,
78
- unregisterScheme: () => unregisterScheme
104
+ unregisterScheme: () => unregisterScheme,
105
+ writeClaimLauncher: () => writeClaimLauncher
79
106
  });
80
107
  import { spawn, spawnSync } from "child_process";
81
108
  import { existsSync, mkdirSync as mkdirSync2, readFileSync, rmSync, writeFileSync, renameSync } from "fs";
@@ -107,8 +134,11 @@ function defaultProtocolDeps() {
107
134
  ensureStateDir: (path) => {
108
135
  ensureStateDir(path);
109
136
  },
110
- writeFileSync: (path, contents) => {
111
- writeFileSync(path, contents, "utf8");
137
+ ensureStateDirForSecret: (path) => {
138
+ ensureStateDirForSecret(path);
139
+ },
140
+ writeFileSync: (path, contents, mode) => {
141
+ writeFileSync(path, contents, mode === void 0 ? "utf8" : { encoding: "utf8", mode });
112
142
  },
113
143
  readFileSync: (path) => readFileSync(path, "utf8"),
114
144
  rmSync: (path) => {
@@ -148,30 +178,52 @@ function appleScriptStringLiteral(s) {
148
178
  function shellQuoteSingle(s) {
149
179
  return `'${s.replace(/'/g, `'\\''`)}'`;
150
180
  }
151
- function buildAppleScriptHandler(execPath, dispatchPath) {
181
+ function preferredTerminalApp(env) {
182
+ const raw = typeof env.TERM_PROGRAM === "string" ? env.TERM_PROGRAM.trim() : "";
183
+ if (!raw) return null;
184
+ const known = {
185
+ apple_terminal: "Terminal",
186
+ "iterm.app": "iTerm"
187
+ };
188
+ return known[raw.toLowerCase()] ?? null;
189
+ }
190
+ function buildAppleScriptHandler(execPath, dispatchPath, terminalApp) {
152
191
  const execLit = appleScriptStringLiteral(execPath);
153
192
  const dispatchLit = appleScriptStringLiteral(dispatchPath);
193
+ const fallback = (indent) => [
194
+ `${indent}display notification "Couldn't open your terminal. Run: " & launcher with title "Terminalhire"`,
195
+ `${indent}try`,
196
+ `${indent} do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " handle-url " & quoted form of theURL & " > /dev/null 2>&1 &"`,
197
+ `${indent}end try`
198
+ ];
199
+ const launch = terminalApp ? [
200
+ " try",
201
+ ` do shell script "open -a " & quoted form of ${appleScriptStringLiteral(terminalApp)} & " " & quoted form of launcher`,
202
+ " on error",
203
+ " try",
204
+ ' do shell script "open " & quoted form of launcher',
205
+ " on error",
206
+ ...fallback(" "),
207
+ " end try",
208
+ " end try"
209
+ ] : [
210
+ " try",
211
+ ' do shell script "open " & quoted form of launcher',
212
+ " on error",
213
+ ...fallback(" "),
214
+ " end try"
215
+ ];
154
216
  return [
155
217
  "on open location theURL",
156
- ' set claimCmd to ""',
218
+ ' set launcher to ""',
157
219
  " try",
158
- ` set claimCmd to do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " print-claim-command " & quoted form of theURL`,
220
+ ` set launcher to do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " write-claim-launcher " & quoted form of theURL`,
159
221
  " end try",
160
- ' if claimCmd is "" then',
222
+ ' if launcher is "" then',
161
223
  ` display notification "That isn't a valid Terminalhire claim link." with title "Terminalhire"`,
162
224
  " return",
163
225
  " end if",
164
- " try",
165
- ' tell application "Terminal"',
166
- " activate",
167
- " do script claimCmd",
168
- " end tell",
169
- " on error",
170
- ` display notification "Couldn't open Terminal automatically. Run: " & claimCmd with title "Terminalhire"`,
171
- " try",
172
- ` do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " handle-url " & quoted form of theURL & " > /dev/null 2>&1 &"`,
173
- " end try",
174
- " end try",
226
+ ...launch,
175
227
  "end open location",
176
228
  ""
177
229
  ].join("\n");
@@ -182,7 +234,12 @@ function buildPreviewShellCommand(token, deps) {
182
234
  shellQuoteSingle(deps.dispatchPath),
183
235
  "claim",
184
236
  "preview",
185
- token
237
+ // Quoted like the paths beside it, though parseClaimUrl's `[A-Za-z0-9_-]{8}` already
238
+ // forbids every shell metacharacter. Leaving the ONE value that came from a URL bare
239
+ // while quoting the two that never did is backwards: it makes the safety depend on a
240
+ // regex two files away rather than on this line, and a future loosening of that regex
241
+ // would open an injection here silently.
242
+ shellQuoteSingle(token)
186
243
  ].join(" ");
187
244
  }
188
245
  function printClaimCommand(raw, deps = defaultProtocolDeps()) {
@@ -194,6 +251,28 @@ function printClaimCommand(raw, deps = defaultProtocolDeps()) {
194
251
  deps.log(buildPreviewShellCommand(parsed.token, deps));
195
252
  deps.exit(0);
196
253
  }
254
+ function launcherPath(token, deps) {
255
+ return join(stateDir(deps), `claim-${token}.command`);
256
+ }
257
+ function buildLauncherScript(token, deps) {
258
+ return ["#!/bin/sh", `exec ${buildPreviewShellCommand(token, deps)}`, ""].join("\n");
259
+ }
260
+ function writeLauncherFile(token, deps) {
261
+ deps.ensureStateDirForSecret(stateDir(deps));
262
+ const path = launcherPath(token, deps);
263
+ deps.rmSync(path);
264
+ deps.writeFileSync(path, buildLauncherScript(token, deps), 448);
265
+ return path;
266
+ }
267
+ function writeClaimLauncher(raw, deps = defaultProtocolDeps()) {
268
+ const parsed = parseClaimUrl(raw);
269
+ if (!parsed) {
270
+ deps.exit(1);
271
+ return;
272
+ }
273
+ deps.log(writeLauncherFile(parsed.token, deps));
274
+ deps.exit(0);
275
+ }
197
276
  function darwinAppPaths(deps) {
198
277
  const appDir = join(deps.homedir(), "Applications");
199
278
  const appPath = join(appDir, "Terminalhire Handler.app");
@@ -206,7 +285,16 @@ function darwinRegister(deps) {
206
285
  const { appDir, appPath, plistPath } = darwinAppPaths(deps);
207
286
  deps.mkdirSync(appDir);
208
287
  const scriptPath = join(dir, "handler.applescript");
209
- deps.writeFileSync(scriptPath, buildAppleScriptHandler(deps.execPath, deps.dispatchPath));
288
+ deps.writeFileSync(
289
+ scriptPath,
290
+ buildAppleScriptHandler(
291
+ deps.execPath,
292
+ deps.dispatchPath,
293
+ // Resolved at REGISTER time, from the shell the user ran `protocol register` in.
294
+ // `open location` runs with no TERM_PROGRAM at all, so this cannot be deferred.
295
+ preferredTerminalApp(deps.env)
296
+ )
297
+ );
210
298
  deps.rmSync(appPath);
211
299
  const compile = deps.spawnSync("osacompile", ["-o", appPath, scriptPath]);
212
300
  if (compile.status !== 0) {
@@ -274,14 +362,10 @@ function darwinStatus(deps) {
274
362
  return { registered: exists && registered, appExists: exists };
275
363
  }
276
364
  function darwinOpenPreviewTerminal(token, deps) {
277
- const doScript = `do script ${appleScriptStringLiteral(buildPreviewShellCommand(token, deps))}`;
278
- const res = deps.spawnSync("osascript", [
279
- "-e",
280
- 'tell application "Terminal" to activate',
281
- "-e",
282
- doScript
283
- ]);
284
- return res.status === 0;
365
+ const path = writeLauncherFile(token, deps);
366
+ const app = preferredTerminalApp(deps.env);
367
+ if (app && deps.spawnSync("open", ["-a", app, path]).status === 0) return true;
368
+ return deps.spawnSync("open", [path]).status === 0;
285
369
  }
286
370
  function win32Register(deps) {
287
371
  for (const scheme of WIN32_SCHEMES) {
@@ -570,7 +654,7 @@ var init_protocol = __esm({
570
654
  ["konsole", ["-e"]],
571
655
  ["xterm", ["-e"]]
572
656
  ];
573
- HANDLER_TEMPLATE_VERSION = 2;
657
+ HANDLER_TEMPLATE_VERSION = 3;
574
658
  PENDING_CLAIMS_CAP = 20;
575
659
  PENDING_TOKEN_RE = /^[A-Za-z0-9_-]{8}$/;
576
660
  }
@@ -51,7 +51,31 @@ function ensureStateDir(dir) {
51
51
  }
52
52
  }
53
53
  }
54
- var STATE_DIR_MODE, STATE_DIR_OK, STATE_DIR_SYMLINK, STATE_DIR_UNVERIFIED, warnedDirs;
54
+ function applyStateDirSecretPolicy(dir, status) {
55
+ if (status === STATE_DIR_SYMLINK) {
56
+ throw new Error(
57
+ `terminalhire: refusing to write key material into ${dir} \u2014 it is a symlink, not a directory.
58
+ A write through it would FOLLOW THE LINK and place key/token material wherever the symlink points, outside our control and outside the "owner-only" (0700) guarantee this directory is supposed to carry.
59
+ Fix: remove the symlink so terminalhire can recreate it as a real directory \u2014
60
+ rm ${dir}
61
+ then re-run the command. If the symlink is intentional, point TERMINALHIRE_DIR at a real directory instead of routing it through this one.`
62
+ );
63
+ }
64
+ if (status === STATE_DIR_UNVERIFIED && !warnedUnverifiedSecretWriteThisProcess) {
65
+ warnedUnverifiedSecretWriteThisProcess = true;
66
+ try {
67
+ process.stderr.write(
68
+ `terminalhire: could not verify ${dir}'s permissions (expected on Windows \u2014 POSIX mode bits do not apply there) \u2014 proceeding, but the "owner-only" guarantee on key/token storage is NOT enforced on this platform.
69
+ `
70
+ );
71
+ } catch {
72
+ }
73
+ }
74
+ }
75
+ function ensureStateDirForSecret(dir) {
76
+ applyStateDirSecretPolicy(dir, ensureStateDir(dir));
77
+ }
78
+ var STATE_DIR_MODE, STATE_DIR_OK, STATE_DIR_SYMLINK, STATE_DIR_UNVERIFIED, warnedDirs, warnedUnverifiedSecretWriteThisProcess;
55
79
  var init_state_dir = __esm({
56
80
  "src/state-dir.ts"() {
57
81
  "use strict";
@@ -60,6 +84,7 @@ var init_state_dir = __esm({
60
84
  STATE_DIR_SYMLINK = "symlink";
61
85
  STATE_DIR_UNVERIFIED = "unverified";
62
86
  warnedDirs = /* @__PURE__ */ new Set();
87
+ warnedUnverifiedSecretWriteThisProcess = false;
63
88
  }
64
89
  });
65
90
 
@@ -72,10 +97,12 @@ __export(protocol_exports, {
72
97
  handleUrl: () => handleUrl,
73
98
  healStaleHandler: () => healStaleHandler,
74
99
  parseClaimUrl: () => parseClaimUrl,
100
+ preferredTerminalApp: () => preferredTerminalApp,
75
101
  printClaimCommand: () => printClaimCommand,
76
102
  registerScheme: () => registerScheme,
77
103
  schemeStatus: () => schemeStatus,
78
- unregisterScheme: () => unregisterScheme
104
+ unregisterScheme: () => unregisterScheme,
105
+ writeClaimLauncher: () => writeClaimLauncher
79
106
  });
80
107
  import { spawn, spawnSync } from "child_process";
81
108
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2, renameSync } from "fs";
@@ -107,8 +134,11 @@ function defaultProtocolDeps() {
107
134
  ensureStateDir: (path2) => {
108
135
  ensureStateDir(path2);
109
136
  },
110
- writeFileSync: (path2, contents) => {
111
- writeFileSync2(path2, contents, "utf8");
137
+ ensureStateDirForSecret: (path2) => {
138
+ ensureStateDirForSecret(path2);
139
+ },
140
+ writeFileSync: (path2, contents, mode) => {
141
+ writeFileSync2(path2, contents, mode === void 0 ? "utf8" : { encoding: "utf8", mode });
112
142
  },
113
143
  readFileSync: (path2) => readFileSync2(path2, "utf8"),
114
144
  rmSync: (path2) => {
@@ -148,30 +178,52 @@ function appleScriptStringLiteral(s) {
148
178
  function shellQuoteSingle(s) {
149
179
  return `'${s.replace(/'/g, `'\\''`)}'`;
150
180
  }
151
- function buildAppleScriptHandler(execPath, dispatchPath) {
181
+ function preferredTerminalApp(env) {
182
+ const raw = typeof env.TERM_PROGRAM === "string" ? env.TERM_PROGRAM.trim() : "";
183
+ if (!raw) return null;
184
+ const known = {
185
+ apple_terminal: "Terminal",
186
+ "iterm.app": "iTerm"
187
+ };
188
+ return known[raw.toLowerCase()] ?? null;
189
+ }
190
+ function buildAppleScriptHandler(execPath, dispatchPath, terminalApp) {
152
191
  const execLit = appleScriptStringLiteral(execPath);
153
192
  const dispatchLit = appleScriptStringLiteral(dispatchPath);
193
+ const fallback = (indent) => [
194
+ `${indent}display notification "Couldn't open your terminal. Run: " & launcher with title "Terminalhire"`,
195
+ `${indent}try`,
196
+ `${indent} do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " handle-url " & quoted form of theURL & " > /dev/null 2>&1 &"`,
197
+ `${indent}end try`
198
+ ];
199
+ const launch = terminalApp ? [
200
+ " try",
201
+ ` do shell script "open -a " & quoted form of ${appleScriptStringLiteral(terminalApp)} & " " & quoted form of launcher`,
202
+ " on error",
203
+ " try",
204
+ ' do shell script "open " & quoted form of launcher',
205
+ " on error",
206
+ ...fallback(" "),
207
+ " end try",
208
+ " end try"
209
+ ] : [
210
+ " try",
211
+ ' do shell script "open " & quoted form of launcher',
212
+ " on error",
213
+ ...fallback(" "),
214
+ " end try"
215
+ ];
154
216
  return [
155
217
  "on open location theURL",
156
- ' set claimCmd to ""',
218
+ ' set launcher to ""',
157
219
  " try",
158
- ` set claimCmd to do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " print-claim-command " & quoted form of theURL`,
220
+ ` set launcher to do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " write-claim-launcher " & quoted form of theURL`,
159
221
  " end try",
160
- ' if claimCmd is "" then',
222
+ ' if launcher is "" then',
161
223
  ` display notification "That isn't a valid Terminalhire claim link." with title "Terminalhire"`,
162
224
  " return",
163
225
  " end if",
164
- " try",
165
- ' tell application "Terminal"',
166
- " activate",
167
- " do script claimCmd",
168
- " end tell",
169
- " on error",
170
- ` display notification "Couldn't open Terminal automatically. Run: " & claimCmd with title "Terminalhire"`,
171
- " try",
172
- ` do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " handle-url " & quoted form of theURL & " > /dev/null 2>&1 &"`,
173
- " end try",
174
- " end try",
226
+ ...launch,
175
227
  "end open location",
176
228
  ""
177
229
  ].join("\n");
@@ -182,7 +234,12 @@ function buildPreviewShellCommand(token, deps) {
182
234
  shellQuoteSingle(deps.dispatchPath),
183
235
  "claim",
184
236
  "preview",
185
- token
237
+ // Quoted like the paths beside it, though parseClaimUrl's `[A-Za-z0-9_-]{8}` already
238
+ // forbids every shell metacharacter. Leaving the ONE value that came from a URL bare
239
+ // while quoting the two that never did is backwards: it makes the safety depend on a
240
+ // regex two files away rather than on this line, and a future loosening of that regex
241
+ // would open an injection here silently.
242
+ shellQuoteSingle(token)
186
243
  ].join(" ");
187
244
  }
188
245
  function printClaimCommand(raw, deps = defaultProtocolDeps()) {
@@ -194,6 +251,28 @@ function printClaimCommand(raw, deps = defaultProtocolDeps()) {
194
251
  deps.log(buildPreviewShellCommand(parsed.token, deps));
195
252
  deps.exit(0);
196
253
  }
254
+ function launcherPath(token, deps) {
255
+ return join2(stateDir2(deps), `claim-${token}.command`);
256
+ }
257
+ function buildLauncherScript(token, deps) {
258
+ return ["#!/bin/sh", `exec ${buildPreviewShellCommand(token, deps)}`, ""].join("\n");
259
+ }
260
+ function writeLauncherFile(token, deps) {
261
+ deps.ensureStateDirForSecret(stateDir2(deps));
262
+ const path2 = launcherPath(token, deps);
263
+ deps.rmSync(path2);
264
+ deps.writeFileSync(path2, buildLauncherScript(token, deps), 448);
265
+ return path2;
266
+ }
267
+ function writeClaimLauncher(raw, deps = defaultProtocolDeps()) {
268
+ const parsed = parseClaimUrl(raw);
269
+ if (!parsed) {
270
+ deps.exit(1);
271
+ return;
272
+ }
273
+ deps.log(writeLauncherFile(parsed.token, deps));
274
+ deps.exit(0);
275
+ }
197
276
  function darwinAppPaths(deps) {
198
277
  const appDir = join2(deps.homedir(), "Applications");
199
278
  const appPath = join2(appDir, "Terminalhire Handler.app");
@@ -206,7 +285,16 @@ function darwinRegister(deps) {
206
285
  const { appDir, appPath, plistPath } = darwinAppPaths(deps);
207
286
  deps.mkdirSync(appDir);
208
287
  const scriptPath = join2(dir, "handler.applescript");
209
- deps.writeFileSync(scriptPath, buildAppleScriptHandler(deps.execPath, deps.dispatchPath));
288
+ deps.writeFileSync(
289
+ scriptPath,
290
+ buildAppleScriptHandler(
291
+ deps.execPath,
292
+ deps.dispatchPath,
293
+ // Resolved at REGISTER time, from the shell the user ran `protocol register` in.
294
+ // `open location` runs with no TERM_PROGRAM at all, so this cannot be deferred.
295
+ preferredTerminalApp(deps.env)
296
+ )
297
+ );
210
298
  deps.rmSync(appPath);
211
299
  const compile = deps.spawnSync("osacompile", ["-o", appPath, scriptPath]);
212
300
  if (compile.status !== 0) {
@@ -274,14 +362,10 @@ function darwinStatus(deps) {
274
362
  return { registered: exists && registered, appExists: exists };
275
363
  }
276
364
  function darwinOpenPreviewTerminal(token, deps) {
277
- const doScript = `do script ${appleScriptStringLiteral(buildPreviewShellCommand(token, deps))}`;
278
- const res = deps.spawnSync("osascript", [
279
- "-e",
280
- 'tell application "Terminal" to activate',
281
- "-e",
282
- doScript
283
- ]);
284
- return res.status === 0;
365
+ const path2 = writeLauncherFile(token, deps);
366
+ const app = preferredTerminalApp(deps.env);
367
+ if (app && deps.spawnSync("open", ["-a", app, path2]).status === 0) return true;
368
+ return deps.spawnSync("open", [path2]).status === 0;
285
369
  }
286
370
  function win32Register(deps) {
287
371
  for (const scheme of WIN32_SCHEMES) {
@@ -570,7 +654,7 @@ var init_protocol = __esm({
570
654
  ["konsole", ["-e"]],
571
655
  ["xterm", ["-e"]]
572
656
  ];
573
- HANDLER_TEMPLATE_VERSION = 2;
657
+ HANDLER_TEMPLATE_VERSION = 3;
574
658
  PENDING_CLAIMS_CAP = 20;
575
659
  PENDING_TOKEN_RE = /^[A-Za-z0-9_-]{8}$/;
576
660
  }
@@ -52,6 +52,31 @@ function ensureStateDir(dir) {
52
52
  }
53
53
  }
54
54
  }
55
+ var warnedUnverifiedSecretWriteThisProcess = false;
56
+ function applyStateDirSecretPolicy(dir, status) {
57
+ if (status === STATE_DIR_SYMLINK) {
58
+ throw new Error(
59
+ `terminalhire: refusing to write key material into ${dir} \u2014 it is a symlink, not a directory.
60
+ A write through it would FOLLOW THE LINK and place key/token material wherever the symlink points, outside our control and outside the "owner-only" (0700) guarantee this directory is supposed to carry.
61
+ Fix: remove the symlink so terminalhire can recreate it as a real directory \u2014
62
+ rm ${dir}
63
+ then re-run the command. If the symlink is intentional, point TERMINALHIRE_DIR at a real directory instead of routing it through this one.`
64
+ );
65
+ }
66
+ if (status === STATE_DIR_UNVERIFIED && !warnedUnverifiedSecretWriteThisProcess) {
67
+ warnedUnverifiedSecretWriteThisProcess = true;
68
+ try {
69
+ process.stderr.write(
70
+ `terminalhire: could not verify ${dir}'s permissions (expected on Windows \u2014 POSIX mode bits do not apply there) \u2014 proceeding, but the "owner-only" guarantee on key/token storage is NOT enforced on this platform.
71
+ `
72
+ );
73
+ } catch {
74
+ }
75
+ }
76
+ }
77
+ function ensureStateDirForSecret(dir) {
78
+ applyStateDirSecretPolicy(dir, ensureStateDir(dir));
79
+ }
55
80
 
56
81
  // src/protocol.ts
57
82
  var CLAIM_URL_RE = /^(th|terminalhire):\/\/claim\/([A-Za-z0-9_-]{8})\/?$/i;
@@ -80,8 +105,11 @@ function defaultProtocolDeps() {
80
105
  ensureStateDir: (path) => {
81
106
  ensureStateDir(path);
82
107
  },
83
- writeFileSync: (path, contents) => {
84
- writeFileSync(path, contents, "utf8");
108
+ ensureStateDirForSecret: (path) => {
109
+ ensureStateDirForSecret(path);
110
+ },
111
+ writeFileSync: (path, contents, mode) => {
112
+ writeFileSync(path, contents, mode === void 0 ? "utf8" : { encoding: "utf8", mode });
85
113
  },
86
114
  readFileSync: (path) => readFileSync(path, "utf8"),
87
115
  rmSync: (path) => {
@@ -123,30 +151,52 @@ function appleScriptStringLiteral(s) {
123
151
  function shellQuoteSingle(s) {
124
152
  return `'${s.replace(/'/g, `'\\''`)}'`;
125
153
  }
126
- function buildAppleScriptHandler(execPath, dispatchPath) {
154
+ function preferredTerminalApp(env) {
155
+ const raw = typeof env.TERM_PROGRAM === "string" ? env.TERM_PROGRAM.trim() : "";
156
+ if (!raw) return null;
157
+ const known = {
158
+ apple_terminal: "Terminal",
159
+ "iterm.app": "iTerm"
160
+ };
161
+ return known[raw.toLowerCase()] ?? null;
162
+ }
163
+ function buildAppleScriptHandler(execPath, dispatchPath, terminalApp) {
127
164
  const execLit = appleScriptStringLiteral(execPath);
128
165
  const dispatchLit = appleScriptStringLiteral(dispatchPath);
166
+ const fallback = (indent) => [
167
+ `${indent}display notification "Couldn't open your terminal. Run: " & launcher with title "Terminalhire"`,
168
+ `${indent}try`,
169
+ `${indent} do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " handle-url " & quoted form of theURL & " > /dev/null 2>&1 &"`,
170
+ `${indent}end try`
171
+ ];
172
+ const launch = terminalApp ? [
173
+ " try",
174
+ ` do shell script "open -a " & quoted form of ${appleScriptStringLiteral(terminalApp)} & " " & quoted form of launcher`,
175
+ " on error",
176
+ " try",
177
+ ' do shell script "open " & quoted form of launcher',
178
+ " on error",
179
+ ...fallback(" "),
180
+ " end try",
181
+ " end try"
182
+ ] : [
183
+ " try",
184
+ ' do shell script "open " & quoted form of launcher',
185
+ " on error",
186
+ ...fallback(" "),
187
+ " end try"
188
+ ];
129
189
  return [
130
190
  "on open location theURL",
131
- ' set claimCmd to ""',
191
+ ' set launcher to ""',
132
192
  " try",
133
- ` set claimCmd to do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " print-claim-command " & quoted form of theURL`,
193
+ ` set launcher to do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " write-claim-launcher " & quoted form of theURL`,
134
194
  " end try",
135
- ' if claimCmd is "" then',
195
+ ' if launcher is "" then',
136
196
  ` display notification "That isn't a valid Terminalhire claim link." with title "Terminalhire"`,
137
197
  " return",
138
198
  " end if",
139
- " try",
140
- ' tell application "Terminal"',
141
- " activate",
142
- " do script claimCmd",
143
- " end tell",
144
- " on error",
145
- ` display notification "Couldn't open Terminal automatically. Run: " & claimCmd with title "Terminalhire"`,
146
- " try",
147
- ` do shell script quoted form of ${execLit} & " " & quoted form of ${dispatchLit} & " handle-url " & quoted form of theURL & " > /dev/null 2>&1 &"`,
148
- " end try",
149
- " end try",
199
+ ...launch,
150
200
  "end open location",
151
201
  ""
152
202
  ].join("\n");
@@ -157,7 +207,12 @@ function buildPreviewShellCommand(token, deps) {
157
207
  shellQuoteSingle(deps.dispatchPath),
158
208
  "claim",
159
209
  "preview",
160
- token
210
+ // Quoted like the paths beside it, though parseClaimUrl's `[A-Za-z0-9_-]{8}` already
211
+ // forbids every shell metacharacter. Leaving the ONE value that came from a URL bare
212
+ // while quoting the two that never did is backwards: it makes the safety depend on a
213
+ // regex two files away rather than on this line, and a future loosening of that regex
214
+ // would open an injection here silently.
215
+ shellQuoteSingle(token)
161
216
  ].join(" ");
162
217
  }
163
218
  function printClaimCommand(raw, deps = defaultProtocolDeps()) {
@@ -169,6 +224,28 @@ function printClaimCommand(raw, deps = defaultProtocolDeps()) {
169
224
  deps.log(buildPreviewShellCommand(parsed.token, deps));
170
225
  deps.exit(0);
171
226
  }
227
+ function launcherPath(token, deps) {
228
+ return join(stateDir(deps), `claim-${token}.command`);
229
+ }
230
+ function buildLauncherScript(token, deps) {
231
+ return ["#!/bin/sh", `exec ${buildPreviewShellCommand(token, deps)}`, ""].join("\n");
232
+ }
233
+ function writeLauncherFile(token, deps) {
234
+ deps.ensureStateDirForSecret(stateDir(deps));
235
+ const path = launcherPath(token, deps);
236
+ deps.rmSync(path);
237
+ deps.writeFileSync(path, buildLauncherScript(token, deps), 448);
238
+ return path;
239
+ }
240
+ function writeClaimLauncher(raw, deps = defaultProtocolDeps()) {
241
+ const parsed = parseClaimUrl(raw);
242
+ if (!parsed) {
243
+ deps.exit(1);
244
+ return;
245
+ }
246
+ deps.log(writeLauncherFile(parsed.token, deps));
247
+ deps.exit(0);
248
+ }
172
249
  function darwinAppPaths(deps) {
173
250
  const appDir = join(deps.homedir(), "Applications");
174
251
  const appPath = join(appDir, "Terminalhire Handler.app");
@@ -181,7 +258,16 @@ function darwinRegister(deps) {
181
258
  const { appDir, appPath, plistPath } = darwinAppPaths(deps);
182
259
  deps.mkdirSync(appDir);
183
260
  const scriptPath = join(dir, "handler.applescript");
184
- deps.writeFileSync(scriptPath, buildAppleScriptHandler(deps.execPath, deps.dispatchPath));
261
+ deps.writeFileSync(
262
+ scriptPath,
263
+ buildAppleScriptHandler(
264
+ deps.execPath,
265
+ deps.dispatchPath,
266
+ // Resolved at REGISTER time, from the shell the user ran `protocol register` in.
267
+ // `open location` runs with no TERM_PROGRAM at all, so this cannot be deferred.
268
+ preferredTerminalApp(deps.env)
269
+ )
270
+ );
185
271
  deps.rmSync(appPath);
186
272
  const compile = deps.spawnSync("osacompile", ["-o", appPath, scriptPath]);
187
273
  if (compile.status !== 0) {
@@ -249,14 +335,10 @@ function darwinStatus(deps) {
249
335
  return { registered: exists && registered, appExists: exists };
250
336
  }
251
337
  function darwinOpenPreviewTerminal(token, deps) {
252
- const doScript = `do script ${appleScriptStringLiteral(buildPreviewShellCommand(token, deps))}`;
253
- const res = deps.spawnSync("osascript", [
254
- "-e",
255
- 'tell application "Terminal" to activate',
256
- "-e",
257
- doScript
258
- ]);
259
- return res.status === 0;
338
+ const path = writeLauncherFile(token, deps);
339
+ const app = preferredTerminalApp(deps.env);
340
+ if (app && deps.spawnSync("open", ["-a", app, path]).status === 0) return true;
341
+ return deps.spawnSync("open", [path]).status === 0;
260
342
  }
261
343
  var WIN32_SCHEMES = ["th", "terminalhire"];
262
344
  function win32Register(deps) {
@@ -348,7 +430,7 @@ var LINUX_TERMINAL_CANDIDATES = [
348
430
  ["konsole", ["-e"]],
349
431
  ["xterm", ["-e"]]
350
432
  ];
351
- var HANDLER_TEMPLATE_VERSION = 2;
433
+ var HANDLER_TEMPLATE_VERSION = 3;
352
434
  function handlerTemplateVersionPath(deps) {
353
435
  return join(stateDir(deps), "handler-template-version");
354
436
  }
@@ -547,8 +629,10 @@ export {
547
629
  handleUrl,
548
630
  healStaleHandler,
549
631
  parseClaimUrl,
632
+ preferredTerminalApp,
550
633
  printClaimCommand,
551
634
  registerScheme,
552
635
  schemeStatus,
553
- unregisterScheme
636
+ unregisterScheme,
637
+ writeClaimLauncher
554
638
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminalhire",
3
- "version": "0.42.2",
3
+ "version": "0.42.3",
4
4
  "description": "Local-first job matching for developers — ambient job matches in the Claude Code spinner. Your profile never leaves your machine.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,7 +31,7 @@
31
31
  "test": "node scripts/run-tests.mjs test",
32
32
  "build": "tsup",
33
33
  "bundle:plugin": "npm run build && rm -rf ../../plugins/terminalhire/dist && cp -R dist ../../plugins/terminalhire/dist && cp package.json ../../plugins/terminalhire/dist/package.json && cp install.js ../../plugins/terminalhire/dist/install.js && cp statusline-install.js ../../plugins/terminalhire/dist/statusline-install.js && cp postinstall.js ../../plugins/terminalhire/dist/postinstall.js && node ../../scripts/write-bundle-manifest.mjs",
34
- "prepublishOnly": "npm run build",
34
+ "prepublishOnly": "node ../../scripts/check-publish-preconditions.mjs && npm run build",
35
35
  "install-hook": "node install.js",
36
36
  "postinstall": "node ./postinstall.js"
37
37
  },