@edspencer/paddock 0.67.0 → 0.68.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.
Files changed (102) hide show
  1. package/README.md +24 -30
  2. package/package.json +1 -1
  3. package/packages/server/dist/adoptable.d.ts +57 -0
  4. package/packages/server/dist/adoptable.js +26 -3
  5. package/packages/server/dist/agent-errors.js +1 -1
  6. package/packages/server/dist/claude-home.d.ts +4 -4
  7. package/packages/server/dist/claude-home.js +4 -4
  8. package/packages/server/dist/claude-mcp.d.ts +4 -2
  9. package/packages/server/dist/cli/args.d.ts +41 -2
  10. package/packages/server/dist/cli/args.js +119 -17
  11. package/packages/server/dist/cli/paddock.js +87 -96
  12. package/packages/server/dist/cli/service/backend.d.ts +38 -0
  13. package/packages/server/dist/cli/service/backend.js +19 -0
  14. package/packages/server/dist/cli/service/index.d.ts +28 -0
  15. package/packages/server/dist/cli/service/index.js +179 -0
  16. package/packages/server/dist/cli/service/launchd.d.ts +47 -0
  17. package/packages/server/dist/cli/service/launchd.js +206 -0
  18. package/packages/server/dist/cli/service/spec.d.ts +106 -0
  19. package/packages/server/dist/cli/service/spec.js +124 -0
  20. package/packages/server/dist/cli/service/systemd.d.ts +48 -0
  21. package/packages/server/dist/cli/service/systemd.js +155 -0
  22. package/packages/server/dist/discover.d.ts +170 -0
  23. package/packages/server/dist/discover.js +365 -0
  24. package/packages/server/dist/herdctl.d.ts +25 -2
  25. package/packages/server/dist/herdctl.js +30 -1
  26. package/packages/server/dist/routes/discover.d.ts +3 -0
  27. package/packages/server/dist/routes/discover.js +140 -0
  28. package/packages/server/dist/routes.d.ts +1 -0
  29. package/packages/server/dist/routes.js +2 -0
  30. package/packages/server/package.json +1 -1
  31. package/packages/web/dist/assets/Chip-NMeHUE94.js +2 -0
  32. package/packages/web/dist/assets/DiscoverPage-LY0CRBVz.js +2 -0
  33. package/packages/web/dist/assets/DiscoverView-Bt4FUTjB.js +2 -0
  34. package/packages/web/dist/assets/InstanceConfigPage-DIQJtIYC.js +2 -0
  35. package/packages/web/dist/assets/{MarkdownRenderer-MaSe5aAI.js → MarkdownRenderer-BVMZB_mP.js} +11 -11
  36. package/packages/web/dist/assets/{ProjectMenu-DmWpr9od.js → ProjectMenu-D9Nsqjxy.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectRedirect-BJeDERUd.js → ProjectRedirect-B8rJ9qqe.js} +1 -1
  38. package/packages/web/dist/assets/{ProjectView-Bbecs1u7.js → ProjectView-D-21ULHU.js} +10 -10
  39. package/packages/web/dist/assets/{ProjectsGrid-BE4cAtu5.js → ProjectsGrid-CDqvd4bj.js} +1 -1
  40. package/packages/web/dist/assets/RootHome-CmiJG9wb.js +2 -0
  41. package/packages/web/dist/assets/{Surfaces-DtSsbnLh.js → Surfaces-c410wTbw.js} +1 -1
  42. package/packages/web/dist/assets/{arc-NLb6J_GZ.js → arc-DN2jEPE9.js} +1 -1
  43. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-BbiSIctn.js → architectureDiagram-3BPJPVTR-BLMFd81z.js} +1 -1
  44. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Br1sRzRE.js → blockDiagram-GPEHLZMM-BweEECqE.js} +1 -1
  45. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DUvJurko.js → c4Diagram-AAUBKEIU-DpN_9YFO.js} +1 -1
  46. package/packages/web/dist/assets/channel-B_SiD-tA.js +2 -0
  47. package/packages/web/dist/assets/{chunk-2J33WTMH-Cm_E_LAD.js → chunk-2J33WTMH-BxWEBoli.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-4BX2VUAB-CK6R2MCN.js → chunk-4BX2VUAB-C5nkcPRR.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-55IACEB6-D7HXvxGy.js → chunk-55IACEB6-B8P3CNX8.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-727SXJPM-BLwevzlY.js → chunk-727SXJPM-D3yuzl-m.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-AQP2D5EJ-CpLWD5so.js → chunk-AQP2D5EJ-CzWxcEIw.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-FMBD7UC4-BNlt1t19.js → chunk-FMBD7UC4-CCB91otB.js} +1 -1
  53. package/packages/web/dist/assets/{chunk-ND2GUHAM-CIt5gAq8.js → chunk-ND2GUHAM-BymMfuY4.js} +1 -1
  54. package/packages/web/dist/assets/{chunk-QZHKN3VN-B1uwIQ9c.js → chunk-QZHKN3VN-CezHuNcf.js} +1 -1
  55. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-Detq0lSJ.js +2 -0
  56. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-Detq0lSJ.js +2 -0
  57. package/packages/web/dist/assets/{core-HXUN76Po.js → core-LjceHm3Z.js} +1 -1
  58. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-DJ4_5jxB.js → cose-bilkent-S5V4N54A-BWhmWR_V.js} +1 -1
  59. package/packages/web/dist/assets/{dagre-BM42HDAG-Bc9-haAu.js → dagre-BM42HDAG-BTXWnJYV.js} +1 -1
  60. package/packages/web/dist/assets/diagram-2AECGRRQ-BY2LZdf0.js +44 -0
  61. package/packages/web/dist/assets/diagram-5GNKFQAL-BZ78Imy8.js +11 -0
  62. package/packages/web/dist/assets/{diagram-KO2AKTUF-C5hcR1vA.js → diagram-KO2AKTUF-BV4Ax_0D.js} +3 -3
  63. package/packages/web/dist/assets/{diagram-LMA3HP47-BEFzrSHy.js → diagram-LMA3HP47-1kcTal-d.js} +1 -1
  64. package/packages/web/dist/assets/{diagram-OG6HWLK6-BY5lPsX7.js → diagram-OG6HWLK6-CX3RfHlO.js} +1 -1
  65. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-y_kB8ETg.js → erDiagram-TEJ5UH35-BBS46D95.js} +1 -1
  66. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Cuhha5cF.js → flowDiagram-I6XJVG4X-CpPHahLZ.js} +1 -1
  67. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DFJpciK5.js → ganttDiagram-6RSMTGT7-CxL_QCuL.js} +1 -1
  68. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-FoA0yuh-.js → gitGraphDiagram-PVQCEYII-BeTkPHaG.js} +2 -2
  69. package/packages/web/dist/assets/index-DYccdR9x.css +1 -0
  70. package/packages/web/dist/assets/index-DjhUG9sA.js +71 -0
  71. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-CKYqD8-c.js → infoDiagram-5YYISTIA-CiMnl36I.js} +1 -1
  72. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-CCIxHIBw.js → ishikawaDiagram-YF4QCWOH-CjASKQeF.js} +2 -2
  73. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-lGYoSyk0.js → journeyDiagram-JHISSGLW-BZzehSqh.js} +1 -1
  74. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-B3D7CIwy.js → kanban-definition-UN3LZRKU-Cn-l0UIy.js} +1 -1
  75. package/packages/web/dist/assets/{linear-BiplJBex.js → linear-DyELsH8s.js} +1 -1
  76. package/packages/web/dist/assets/{mermaid.core-BJ7aEinZ.js → mermaid.core-C1adXzqy.js} +5 -5
  77. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-BiXcOtHo.js → mindmap-definition-RKZ34NQL-DP6iSaA_.js} +1 -1
  78. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-tnkj45YF.js → pieDiagram-4H26LBE5-DHpN8Fse.js} +3 -3
  79. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-LqReq3Gl.js → quadrantDiagram-W4KKPZXB-DAfc21Kd.js} +1 -1
  80. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-C1uk9Jty.js → requirementDiagram-4Y6WPE33-X3FfIiGh.js} +3 -3
  81. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-eQQ3OKrV.js → sankeyDiagram-5OEKKPKP-Ch319LBM.js} +4 -4
  82. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-C6-a0lH4.js → sequenceDiagram-3UESZ5HK-CAyajDJc.js} +1 -1
  83. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-CsTOkKqz.js → stateDiagram-AJRCARHV-C1G4309_.js} +1 -1
  84. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-C0-BEM28.js +2 -0
  85. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-DUBXH1x-.js → timeline-definition-PNZ67QCA-Bj5ihSj4.js} +1 -1
  86. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-CcQrNnou.js → vennDiagram-CIIHVFJN-WIZuVHvV.js} +1 -1
  87. package/packages/web/dist/assets/{wardley-L42UT6IY-Br823uUV.js → wardley-L42UT6IY-CwRQf2fI.js} +1 -1
  88. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DRBECQc_.js → wardleyDiagram-YWT4CUSO-Bug_Zcav.js} +2 -2
  89. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6--HtSFP24.js → xychartDiagram-2RQKCTM6-Cw5xgfwo.js} +1 -1
  90. package/packages/web/dist/index.html +2 -2
  91. package/packages/web/dist/sw.js +1 -1
  92. package/packages/server/dist/cli/here.d.ts +0 -25
  93. package/packages/server/dist/cli/here.js +0 -104
  94. package/packages/web/dist/assets/InstanceConfigPage-sDbCoFxC.js +0 -2
  95. package/packages/web/dist/assets/channel-bezC0w5b.js +0 -2
  96. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DMgrTmQU.js +0 -2
  97. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DMgrTmQU.js +0 -2
  98. package/packages/web/dist/assets/diagram-2AECGRRQ-nLOtPDzu.js +0 -44
  99. package/packages/web/dist/assets/diagram-5GNKFQAL-DThV84R3.js +0 -11
  100. package/packages/web/dist/assets/index-BxLurJvz.css +0 -1
  101. package/packages/web/dist/assets/index-C_PXsGTD.js +0 -71
  102. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Bsc-VlVI.js +0 -2
@@ -23,10 +23,19 @@ import os from "node:os";
23
23
  import path from "node:path";
24
24
  import { spawn } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
- import { HERE_MARKER, isHereWorkspace, countClaudeSessions, ensureGitignored } from "./here.js";
27
- import { CliError, USAGE, parseArgs, nodeVersionProblem, explainListenError, } from "./args.js";
28
- /** This module's own directory: `<…>/packages/server/dist/cli`. */
29
- const moduleDir = path.dirname(fileURLToPath(import.meta.url));
26
+ import { CliError, USAGE, SERVICE_USAGE, parseCommand, nodeVersionProblem, explainListenError, } from "./args.js";
27
+ import { runService, safeHomeDir } from "./service/index.js";
28
+ /**
29
+ * This module's own file and directory: `<…>/packages/server/dist/cli/`.
30
+ *
31
+ * `import.meta.url` is the module's REALPATH, which is why the service unit
32
+ * names this rather than `process.argv[1]` — npm installs a `bin` as a symlink,
33
+ * and a unit file pointing at `node_modules/.bin/paddock` would depend on that
34
+ * symlink surviving. (The same realpath-vs-argv[1] mismatch is what shipped a
35
+ * silent no-op to npm three times; see args.ts.)
36
+ */
37
+ const entryScript = fileURLToPath(import.meta.url);
38
+ const moduleDir = path.dirname(entryScript);
30
39
  /**
31
40
  * Walk up from this module looking for `rel`, returning the containing dir.
32
41
  *
@@ -95,6 +104,14 @@ function addBundledBinsToPath() {
95
104
  * `npx` is stateless enough that people reasonably assume the whole thing is
96
105
  * ephemeral. It is not — projects, chat transcripts, and settings all persist
97
106
  * here across runs, so say so once, at the moment the directory appears.
107
+ *
108
+ * The third line replaces `--here`'s discovery hint (#798). That hint scanned
109
+ * the CURRENT directory's Claude Code history and named a flag; both halves are
110
+ * gone. What a new instance actually offers is Discover (#745), which reads the
111
+ * whole history rather than one directory — so the CLI names it and lets the
112
+ * UI, which can show the list with tick-boxes against it, do the work. No
113
+ * filesystem scan happens here: the line is true whatever a scan would find,
114
+ * and an empty instance's Home leads with the same thing.
98
115
  */
99
116
  function noteFirstRun(dataDir) {
100
117
  if (fs.existsSync(dataDir))
@@ -103,57 +120,7 @@ function noteFirstRun(dataDir) {
103
120
  "",
104
121
  ` Welcome to Paddock. Creating a new instance in ${dataDir}`,
105
122
  " Projects, chats and settings persist there between runs.",
106
- "",
107
- ].join("\n"));
108
- }
109
- /**
110
- * Say what `--here` is about to do to this directory, on the run that does it.
111
- *
112
- * The flag IS the consent — there is no prompt, and no `--yes` to remember.
113
- * But consent only means something if you can know what you agreed to, so the
114
- * effects are announced rather than asked about. Printed only on the first
115
- * `--here` in a directory; resuming an already-opened one says nothing.
116
- */
117
- function announceHereConsent(dir, dataDir) {
118
- console.log([
119
- "",
120
- ` Opening ${dir} as a Paddock workspace.`,
121
- ` · ${path.relative(dir, dataDir) || HERE_MARKER}/ and .chats/ created here, and added to .gitignore`,
122
- // Worth stating because it is not what you would predict: paddock reading
123
- // the home a user's sessions live in does not turn them into paddock's
124
- // chats. Verified against a running instance — a pre-existing transcript
125
- // for the directory comes back from `adoptable-chats` (offered) and NOT
126
- // from `chats` (listed), because session discovery is attribution-based,
127
- // not "whatever is in the folder". So #663's confirmation still gates
128
- // every import.
129
- " · ~/.claude sessions for this directory are offered for import (nothing is moved)",
130
- " Later runs here resume it — no flag needed.",
131
- "",
132
- ].join("\n"));
133
- }
134
- /**
135
- * Tell the user their existing Claude history could be opened here.
136
- *
137
- * The only way anyone discovers `--here` exists. Read-only: a bare run counts
138
- * transcripts and prints, and changes nothing on disk.
139
- */
140
- function offerHereIfSessionsExist(dir) {
141
- // The USER's own home, always: this is looking for `claude` history they made
142
- // in a terminal, which is by definition in `~/.claude` and never in paddock's.
143
- const claudeHome = path.join(os.homedir(), ".claude");
144
- let sessions = 0;
145
- try {
146
- sessions = countClaudeSessions(claudeHome, dir);
147
- }
148
- catch {
149
- return; // detection is a courtesy; never let it break a boot
150
- }
151
- if (sessions === 0)
152
- return;
153
- console.log([
154
- "",
155
- ` This directory has ${sessions} Claude Code session${sessions === 1 ? "" : "s"}.`,
156
- " Run `paddock --here` to open it as a workspace, where they can be imported.",
123
+ " Open the app to find directories you have used Claude Code in.",
157
124
  "",
158
125
  ].join("\n"));
159
126
  }
@@ -175,18 +142,59 @@ function openBrowser(url) {
175
142
  /* as above */
176
143
  }
177
144
  }
145
+ /**
146
+ * `paddock service …` — register/inspect the background service (#796).
147
+ *
148
+ * Split out so `main` stays the start path. Note what it is handed: the
149
+ * interpreter and script by absolute path, and `packageRoot` — which is tested
150
+ * for npx's cache, because a unit file pointing into a prunable hash-keyed
151
+ * directory rots at some future login with nobody watching.
152
+ */
153
+ function service(command) {
154
+ const { action, opts } = command;
155
+ // Unreachable: `parseCommand` only omits the action when `--help` was given,
156
+ // which `main` has already handled. Typed rather than asserted.
157
+ if (action === undefined) {
158
+ console.log(SERVICE_USAGE);
159
+ return;
160
+ }
161
+ try {
162
+ runService(action, opts, {
163
+ platform: process.platform,
164
+ nodePath: process.execPath,
165
+ scriptPath: entryScript,
166
+ packageRoot,
167
+ homeDir: safeHomeDir(),
168
+ ...(process.env.PADDOCK_DATA_DIR !== undefined
169
+ ? { envDataDir: process.env.PADDOCK_DATA_DIR }
170
+ : {}),
171
+ ...(process.env.XDG_CONFIG_HOME !== undefined
172
+ ? { xdgConfigHome: process.env.XDG_CONFIG_HOME }
173
+ : {}),
174
+ ...(process.env.PATH !== undefined ? { pathEnv: process.env.PATH } : {}),
175
+ });
176
+ }
177
+ catch (err) {
178
+ if (err instanceof CliError)
179
+ fail(err.message);
180
+ if (err instanceof Error)
181
+ fail(err.message);
182
+ throw err;
183
+ }
184
+ }
178
185
  async function main() {
179
- let opts;
186
+ let command;
180
187
  try {
181
- opts = parseArgs(process.argv.slice(2));
188
+ command = parseCommand(process.argv.slice(2));
182
189
  }
183
190
  catch (err) {
184
191
  if (err instanceof CliError)
185
192
  fail(err.message);
186
193
  throw err;
187
194
  }
195
+ const opts = command.opts;
188
196
  if (opts.help) {
189
- console.log(USAGE);
197
+ console.log(command.verb === "service" ? SERVICE_USAGE : USAGE);
190
198
  return;
191
199
  }
192
200
  if (opts.version) {
@@ -196,23 +204,21 @@ async function main() {
196
204
  const problem = nodeVersionProblem(process.versions.node);
197
205
  if (problem !== undefined)
198
206
  fail(problem);
199
- addBundledBinsToPath();
200
- // Canonical, because working directories "MUST be canonical so session
201
- // discovery (which encodes the real path) can find Claude transcripts"
202
- // (config.ts). On macOS a /tmp vs /private/tmp divergence would silently
203
- // break adoption — and macOS is most laptops.
204
- let cwd = process.cwd();
205
- try {
206
- cwd = fs.realpathSync(cwd);
207
- }
208
- catch {
209
- /* keep the literal path; the server will surface any real problem */
207
+ if (command.verb === "service") {
208
+ service(command);
209
+ return;
210
210
  }
211
- // `--here` is the consent. A flagless run in a directory ALREADY opened this
212
- // way resumes it — the git model, where `--here` is `git init` and `.paddock/`
213
- // is `.git`. A flagless run anywhere else touches nothing.
214
- const resuming = !opts.here && isHereWorkspace(cwd);
215
- const hereMode = opts.here || resuming;
211
+ addBundledBinsToPath();
212
+ // NOTHING here reads `process.cwd()`, and that is the point (#798). `--here`
213
+ // used to make the current directory the workspace by setting
214
+ // `PADDOCK_PROJECTS_DIR = cwd`, which was the only place that variable was
215
+ // ever set from cwd. It also made the instance you got depend on where you
216
+ // stood — and because its marker directory was `.paddock`, the same name as
217
+ // the default data dir, a bare run from `$HOME` on any machine that had ever
218
+ // run paddock resumed `$HOME` itself as the workspace, silently, even with an
219
+ // explicit `--data-dir`. An instance is now decided by its data dir alone;
220
+ // directories are added inside the app as linked projects (Discover, #745),
221
+ // and the CLI writes nothing into any of them.
216
222
  // No Claude-home choice happens here any more (#691). The CLI used to point
217
223
  // `CLAUDE_HOME` at `~/.claude` so a macOS Keychain login would be visible
218
224
  // (#683); paddock now always owns its home, and what a user actually wanted
@@ -224,24 +230,8 @@ async function main() {
224
230
  // server resolves config inside `buildApp()`, so anything set before the
225
231
  // dynamic import below is picked up with no special-casing in config.ts.
226
232
  // Precedence is explicit flag > existing env > our default.
227
- const dataDir = path.resolve(opts.dataDir ??
228
- process.env.PADDOCK_DATA_DIR ??
229
- (hereMode ? path.join(cwd, HERE_MARKER) : path.join(os.homedir(), ".paddock")));
230
- if (hereMode) {
231
- if (!resuming)
232
- announceHereConsent(cwd, dataDir);
233
- // The root workspace IS `projectsRoot` (its key is ""), so pointing this at
234
- // the user's directory makes that directory the workspace. No schema change,
235
- // no new project type — see cli/here.ts.
236
- process.env.PADDOCK_PROJECTS_DIR = cwd;
237
- fs.mkdirSync(dataDir, { recursive: true });
238
- ensureGitignored(cwd, `/${HERE_MARKER}/`);
239
- ensureGitignored(cwd, "/.chats/");
240
- }
241
- else {
242
- noteFirstRun(dataDir);
243
- offerHereIfSessionsExist(cwd);
244
- }
233
+ const dataDir = path.resolve(opts.dataDir ?? process.env.PADDOCK_DATA_DIR ?? path.join(os.homedir(), ".paddock"));
234
+ noteFirstRun(dataDir);
245
235
  process.env.PADDOCK_DATA_DIR = dataDir;
246
236
  if (opts.port)
247
237
  process.env.PORT = opts.port;
@@ -288,13 +278,14 @@ async function main() {
288
278
  fail(explainListenError(err, host, port));
289
279
  }
290
280
  const url = `http://${host}:${port}`;
291
- // ALWAYS name the workspace, not just in --here mode. Behaviour that varies
292
- // with cwd is only safe if it is observable: "why is this instance different
293
- // today" should be answerable by reading the two lines the tool just printed.
281
+ // Still name the data dir, even though it no longer varies with cwd: "which
282
+ // instance am I looking at" is a real question the moment anyone passes
283
+ // `--data-dir`, and the answer should be readable off the two lines the tool
284
+ // just printed rather than inferred.
294
285
  console.log([
295
286
  "",
296
287
  ` Paddock is running at ${url}`,
297
- ` Workspace: ${hereMode ? cwd : dataDir}${resuming ? " (resumed)" : ""}`,
288
+ ` Data: ${dataDir}`,
298
289
  " Press Ctrl-C to stop.",
299
290
  "",
300
291
  ].join("\n"));
@@ -0,0 +1,38 @@
1
+ import type { ServiceSpec } from "./spec.js";
2
+ export interface RunResult {
3
+ status: number | null;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ /**
8
+ * Run an external command.
9
+ *
10
+ * Injected rather than imported so the install/uninstall/status flows can be
11
+ * driven in tests on a box that has neither `launchctl` nor a user systemd.
12
+ */
13
+ export type Runner = (cmd: string, args: string[]) => RunResult;
14
+ export declare const spawnRunner: Runner;
15
+ export interface ServiceState {
16
+ /** Is a unit file installed for us? */
17
+ registered: boolean;
18
+ /** Does the init system say the process is up right now? */
19
+ running: boolean;
20
+ pid?: string;
21
+ /** The Paddock arguments recorded in the installed unit, interpreter stripped. */
22
+ argv: string[];
23
+ }
24
+ export interface ServiceBackend {
25
+ platform: "darwin" | "linux";
26
+ /** Where the plist / unit file goes. */
27
+ unitPath: string;
28
+ /** launchd label or systemd unit name — the string a user would type. */
29
+ label: string;
30
+ install(spec: ServiceSpec): void;
31
+ /** True if something was actually there to remove. */
32
+ uninstall(): boolean;
33
+ status(): ServiceState;
34
+ /** How to read the logs, as printed lines. */
35
+ logsHint(spec: ServiceSpec): string;
36
+ /** The `enable-linger` warning, or undefined where it does not apply. */
37
+ lingerNote(): string | undefined;
38
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The seam between "what a Paddock service is" and "how this init system spells
3
+ * it" (#796).
4
+ *
5
+ * Two backends implement this: `launchd.ts` and `systemd.ts`. They differ in
6
+ * more than syntax — launchd wants log file paths and systemd sends everything
7
+ * to journald; systemd needs `loginctl enable-linger` and launchd has no
8
+ * equivalent — so the interface carries those asymmetries explicitly rather
9
+ * than pretending the two are the same shape with different quotes.
10
+ */
11
+ import { spawnSync } from "node:child_process";
12
+ export const spawnRunner = (cmd, args) => {
13
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
14
+ return {
15
+ status: r.status,
16
+ stdout: r.stdout ?? "",
17
+ stderr: r.stderr ?? "",
18
+ };
19
+ };
@@ -0,0 +1,28 @@
1
+ import { type CliOptions, type ServiceAction } from "../args.js";
2
+ import { isNpxPath, serviceDir, type ServiceSpec } from "./spec.js";
3
+ import { type Runner } from "./backend.js";
4
+ export interface ServiceContext {
5
+ /** `process.platform`. */
6
+ platform: NodeJS.Platform;
7
+ /** `process.execPath` — the interpreter the unit will name explicitly. */
8
+ nodePath: string;
9
+ /** Absolute realpath of `dist/cli/paddock.js`. */
10
+ scriptPath: string;
11
+ /** The `findUp("package.json")` root, tested for an npx cache path. */
12
+ packageRoot: string;
13
+ homeDir: string;
14
+ /** `process.env.PADDOCK_DATA_DIR`, if the installing shell had one. */
15
+ envDataDir?: string;
16
+ /** `process.env.XDG_CONFIG_HOME`, if set — where the systemd user unit goes. */
17
+ xdgConfigHome?: string;
18
+ pathEnv?: string;
19
+ run?: Runner;
20
+ }
21
+ export declare function runService(action: ServiceAction, opts: CliOptions, ctx: ServiceContext): void;
22
+ /** Exported for the entrypoint's context assembly, and for tests. */
23
+ export { serviceDir, isNpxPath };
24
+ export type { ServiceSpec };
25
+ /** Best-effort home dir; `os.homedir()` throws on a machine with no passwd entry. */
26
+ export declare function safeHomeDir(): string;
27
+ /** Realpath, falling back to the input — a broken symlink is not worth a crash. */
28
+ export declare function realpathOr(p: string): string;
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `paddock service install | uninstall | status` (#796).
3
+ *
4
+ * Registers Paddock as a **per-user** background service — a launchd
5
+ * LaunchAgent on macOS, a `systemd --user` unit on Linux — so it is up from
6
+ * login rather than for as long as a terminal tab.
7
+ *
8
+ * This file is the dispatch and the human output; the two backends do the
9
+ * writing and `spec.ts` holds every decision that is not init-system-specific.
10
+ *
11
+ * Three things this deliberately does not do:
12
+ *
13
+ * - **It does not set `PADDOCK_DATA_DIR`.** With nothing named, the service and
14
+ * a `paddock` typed into a terminal both land on `~/.paddock`: one instance,
15
+ * two ways to reach it.
16
+ * - **It does not generate an auth token.** A service is up longer than an
17
+ * `npx` run, but not reachable by anything new: a local process that can
18
+ * reach the port could already read the same Claude login as the same user.
19
+ * Duration, not reach. `PADDOCK_AUTH_MODE` is there for anyone who wants one
20
+ * anyway, and the bind-safety guard (#435) still refuses a routable interface
21
+ * with auth off.
22
+ * - **It does not claim to start at boot.** See `launchd.ts`.
23
+ */
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { CliError } from "../args.js";
28
+ import { buildSpec, defaultDataDir, hostFromArgv, isNpxPath, NPX_REFUSAL, portFromArgv, serviceDir, } from "./spec.js";
29
+ import { spawnRunner } from "./backend.js";
30
+ import { createLaunchdBackend } from "./launchd.js";
31
+ import { createSystemdBackend } from "./systemd.js";
32
+ /**
33
+ * Pick a backend, or explain why there isn't one.
34
+ *
35
+ * Windows is the honest gap. It has no per-user service concept that maps onto
36
+ * either of these without a scheduled task or a service wrapper, and shipping
37
+ * an untested third writer would be worse than saying so.
38
+ */
39
+ function backendFor(ctx) {
40
+ const run = ctx.run ?? spawnRunner;
41
+ if (ctx.platform === "darwin")
42
+ return createLaunchdBackend(run, ctx.homeDir);
43
+ if (ctx.platform === "linux")
44
+ return createSystemdBackend(run, ctx.homeDir, ctx.xdgConfigHome);
45
+ throw new CliError(`\`paddock service\` supports macOS (launchd) and Linux (systemd --user), not ${ctx.platform}.\n` +
46
+ "Run `paddock` in a terminal, or keep it up with whatever your platform uses.");
47
+ }
48
+ /** The data dir the service will use, and whether it has to be named explicitly. */
49
+ function resolveDataDir(opts, ctx) {
50
+ // Mirrors `paddock.ts`: flag beats env beats default. An env var set in the
51
+ // installing shell is an explicit choice too — but it is one the service
52
+ // would NOT inherit, so it gets written into the unit rather than silently
53
+ // dropped in favour of ~/.paddock.
54
+ const chosen = opts.dataDir ?? ctx.envDataDir;
55
+ if (chosen !== undefined) {
56
+ const resolved = path.resolve(chosen);
57
+ return { dataDir: resolved, explicit: resolved };
58
+ }
59
+ return { dataDir: defaultDataDir(ctx.homeDir), explicit: undefined };
60
+ }
61
+ function specFor(opts, ctx) {
62
+ const { dataDir, explicit } = resolveDataDir(opts, ctx);
63
+ const spec = buildSpec({
64
+ nodePath: ctx.nodePath,
65
+ scriptPath: ctx.scriptPath,
66
+ dataDir,
67
+ ...(opts.port !== undefined ? { port: opts.port } : {}),
68
+ ...(opts.host !== undefined ? { host: opts.host } : {}),
69
+ ...(explicit !== undefined ? { dataDirArg: explicit } : {}),
70
+ ...(opts.verbose ? { verbose: true } : {}),
71
+ ...(ctx.pathEnv !== undefined ? { pathEnv: ctx.pathEnv } : {}),
72
+ });
73
+ return { spec, dataDir };
74
+ }
75
+ /** The line every surface has to carry, because otherwise it gets filed as a bug. */
76
+ const AT_LOGIN = " Paddock starts when you LOG IN, not when the machine boots — it runs as\n" +
77
+ " you, so it can use the Claude login you already have. After a restart\n" +
78
+ " that nobody logs into, Paddock is not running.";
79
+ function install(opts, ctx, backend) {
80
+ if (isNpxPath(ctx.packageRoot))
81
+ throw new CliError(NPX_REFUSAL);
82
+ const { spec, dataDir } = specFor(opts, ctx);
83
+ backend.install(spec);
84
+ const url = `http://${hostFromArgv(spec.args)}:${portFromArgv(spec.args)}`;
85
+ const kind = backend.platform === "darwin" ? "a launchd LaunchAgent" : "a systemd --user service";
86
+ const linger = backend.lingerNote();
87
+ console.log([
88
+ "",
89
+ ` Paddock is installed as ${kind}, and running now.`,
90
+ "",
91
+ ` URL: ${url}`,
92
+ ` Data: ${dataDir}`,
93
+ ` Unit: ${backend.unitPath}`,
94
+ backend.logsHint(spec),
95
+ "",
96
+ AT_LOGIN,
97
+ ...(linger !== undefined ? ["", linger] : []),
98
+ "",
99
+ " paddock service status is it up, and where",
100
+ " paddock service uninstall stop it and remove the unit",
101
+ "",
102
+ ].join("\n"));
103
+ }
104
+ function uninstall(ctx, backend) {
105
+ const existed = backend.uninstall();
106
+ console.log(existed
107
+ ? `\n Paddock's ${backend.platform === "darwin" ? "LaunchAgent" : "user service"} is stopped and removed.\n ${backend.unitPath}\n\n Your data is untouched — it was never in there.\n`
108
+ : `\n No Paddock service was installed (${backend.unitPath}).\n Nothing to do.\n`);
109
+ // Leave the service directory: it holds logs, and the last thing anyone wants
110
+ // from `uninstall` is for the record of why they are uninstalling to vanish.
111
+ void ctx;
112
+ }
113
+ function status(opts, ctx, backend) {
114
+ const state = backend.status();
115
+ if (!state.registered) {
116
+ console.log([
117
+ "",
118
+ " Paddock is not installed as a service.",
119
+ ` Looked for: ${backend.unitPath}`,
120
+ "",
121
+ " paddock service install register it and start it now",
122
+ "",
123
+ ].join("\n"));
124
+ return;
125
+ }
126
+ // Everything below is read back out of the INSTALLED unit, not recomputed
127
+ // from today's flags — the installed port is the one the URL has to name.
128
+ const url = `http://${hostFromArgv(state.argv)}:${portFromArgv(state.argv)}`;
129
+ const dataFromUnit = state.argv.indexOf("--data-dir");
130
+ const dataDir = dataFromUnit >= 0 && state.argv[dataFromUnit + 1] !== undefined
131
+ ? state.argv[dataFromUnit + 1]
132
+ : defaultDataDir(ctx.homeDir);
133
+ const { spec } = specFor(opts, { ...ctx, envDataDir: dataDir });
134
+ const linger = backend.lingerNote();
135
+ console.log([
136
+ "",
137
+ state.running
138
+ ? ` Paddock is running${state.pid !== undefined ? ` (pid ${state.pid})` : ""}.`
139
+ : " Paddock is registered as a service, but not running right now.",
140
+ "",
141
+ ` URL: ${url}`,
142
+ ` Data: ${dataDir}`,
143
+ ` Unit: ${backend.unitPath}`,
144
+ ` Args: paddock ${state.argv.join(" ")}`,
145
+ backend.logsHint(spec),
146
+ "",
147
+ AT_LOGIN,
148
+ ...(linger !== undefined && !state.running ? ["", linger] : []),
149
+ "",
150
+ ].join("\n"));
151
+ }
152
+ export function runService(action, opts, ctx) {
153
+ const backend = backendFor(ctx);
154
+ if (action === "install")
155
+ return install(opts, ctx, backend);
156
+ if (action === "uninstall")
157
+ return uninstall(ctx, backend);
158
+ return status(opts, ctx, backend);
159
+ }
160
+ /** Exported for the entrypoint's context assembly, and for tests. */
161
+ export { serviceDir, isNpxPath };
162
+ /** Best-effort home dir; `os.homedir()` throws on a machine with no passwd entry. */
163
+ export function safeHomeDir() {
164
+ try {
165
+ return os.homedir();
166
+ }
167
+ catch {
168
+ return process.env.HOME ?? "/";
169
+ }
170
+ }
171
+ /** Realpath, falling back to the input — a broken symlink is not worth a crash. */
172
+ export function realpathOr(p) {
173
+ try {
174
+ return fs.realpathSync(p);
175
+ }
176
+ catch {
177
+ return p;
178
+ }
179
+ }
@@ -0,0 +1,47 @@
1
+ import { type ServiceSpec } from "./spec.js";
2
+ import type { Runner, ServiceBackend } from "./backend.js";
3
+ /** `~/Library/LaunchAgents/net.edspencer.paddock.plist`. */
4
+ export declare function plistPath(homeDir?: string): string;
5
+ /**
6
+ * Render the plist.
7
+ *
8
+ * `KeepAlive` is a dict rather than `<true/>` on purpose: `SuccessfulExit:
9
+ * false` means "restart it if it exits nonzero, leave it alone if it exits
10
+ * cleanly". A bare `<true/>` would fight a deliberate `launchctl bootout` and
11
+ * make stopping Paddock a wrestling match.
12
+ *
13
+ * `EnvironmentVariables` carries PATH and nothing else. It carries PATH because
14
+ * launchd hands a process a stub PATH that a version manager's `node`, and the
15
+ * `git` an agent will want, are both missing from. It carries nothing else
16
+ * because a plist is a world-readable file in the user's home, and the one
17
+ * category of value that would be tempting to put here — a credential — is
18
+ * precisely what the agent shape exists to avoid needing.
19
+ *
20
+ * No `PADDOCK_DATA_DIR`: see `buildSpec`. One `~/.paddock` instance, reachable
21
+ * from the service and from a terminal.
22
+ */
23
+ export declare function renderPlist(spec: ServiceSpec): string;
24
+ /**
25
+ * Pull `ProgramArguments` back out of a plist we wrote.
26
+ *
27
+ * `status` reports the port the service is ACTUALLY installed with, which is
28
+ * only knowable from the installed unit — recomputing it from today's flags
29
+ * would print a confident URL for a server listening somewhere else. Regex
30
+ * rather than a plist parser because the input is a file this module wrote, in
31
+ * a shape this module controls, and adding an XML dependency to read it back
32
+ * would be the larger risk.
33
+ */
34
+ export declare function parsePlistArgv(xml: string): string[];
35
+ /**
36
+ * Read liveness out of `launchctl print gui/<uid>/<label>`.
37
+ *
38
+ * That command is the honest answer to "is it running": it reports the service
39
+ * as launchd currently understands it, including `state = running` and the pid.
40
+ * The alternative — checking whether the plist file exists — answers "is it
41
+ * registered", which is a different and much weaker question.
42
+ */
43
+ export declare function parseLaunchctlPrint(out: string): {
44
+ running: boolean;
45
+ pid?: string;
46
+ };
47
+ export declare function createLaunchdBackend(run: Runner, homeDir?: string): ServiceBackend;