@crewhaus/tool-desktop 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run.js ADDED
@@ -0,0 +1,483 @@
1
+ /**
2
+ * The one place this package starts a child process, and the seam every test
3
+ * drives instead of the machine.
4
+ *
5
+ * The conventions are `@crewhaus/tool-proc`'s (`src/spawn.ts`) and
6
+ * `@crewhaus/tool-pkgmgr`'s (`src/run.ts`), copied rather than re-invented:
7
+ *
8
+ * - ARGV IS AN ARRAY. There is no shell in this package: no `sh -c`, no
9
+ * `cmd /c`, no interpolated command line. A caller's value is an ELEMENT
10
+ * of an argv array or it does not reach a command at all. `assertArgv`
11
+ * refuses anything that would make that untrue BEFORE a process exists.
12
+ * - EVERY RUN HAS A DEADLINE. SIGTERM at the deadline, SIGKILL after a
13
+ * grace period, and a bounded drain. The drain bound is load-bearing
14
+ * here, not defensive: `xclip -i` and `wl-copy` DELIBERATELY fork a
15
+ * resident holder (under X11 and Wayland the writing process owns the
16
+ * selection, so it must outlive the tool call), and that holder inherits
17
+ * the stdout pipe. Reading to EOF would block until the operator copies
18
+ * something else — possibly for hours.
19
+ * - THE ENVIRONMENT IS PINNED. `PATH` plus a C locale, plus whatever the
20
+ * backend explicitly adds. Nothing else is inherited, so the harness's
21
+ * secrets are not in `pbpaste`'s environment.
22
+ *
23
+ * TWO DELIBERATE EXCEPTIONS, BOTH SECURITY MEASURES RATHER THAN CONVENIENCES.
24
+ *
25
+ * 1. `RunRequest.env` exists so a value can reach a PowerShell script WITHOUT
26
+ * being in the script's source. `powershell.exe -Command <script> a b c`
27
+ * CONCATENATES the trailing arguments onto the command string — argv is
28
+ * not a safe channel there the way it is for `notify-send`. So the Windows
29
+ * backends put caller values in the child's environment and the script
30
+ * reads `$env:CREWHAUS_*`. See ./lib/escape.ts, where that decision lives.
31
+ * 2. `RunRequest.stdin` is the channel for a PAYLOAD. `ClipboardWrite` pipes
32
+ * its text in rather than passing it as an argument, because argv is
33
+ * world-readable through `ps` on both macOS and Linux and a clipboard
34
+ * payload is exactly the kind of thing an operator would not want in a
35
+ * process listing.
36
+ *
37
+ * ── WHY THE "REAL HOST" GATE LIVES HERE AND NOT IN host.ts ────────────────
38
+ *
39
+ * `host.ts` imports from this file; this file imports from nothing in the
40
+ * package. The gate that decides whether an un-injected seam may consult the
41
+ * real machine is read by BOTH (`hostPlatform()` and `sessionEnv()` need it
42
+ * as much as `runHostCommand` does), so it is defined in the file at the
43
+ * bottom of the dependency order. An earlier draft had it in `host.ts` and
44
+ * `run.ts` importing it back, which is an ESM cycle: it happens to work while
45
+ * every use is inside a function body, and breaks the first time either file
46
+ * grows a module-scope constant that reads the other.
47
+ */
48
+ /** `PATH` for a child, with a floor so a stripped harness env still spawns. */
49
+ export const FALLBACK_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
50
+ /** Grace between the deadline's SIGTERM and the SIGKILL that follows it. */
51
+ const KILL_GRACE_MS = 2_000;
52
+ /** A child that forked a holder keeps the pipe open; drain this long and move on. */
53
+ const DRAIN_GRACE_MS = 500;
54
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
55
+ export const MAX_COMMAND_TIMEOUT_MS = 120_000;
56
+ /**
57
+ * Per-stream cap.
58
+ *
59
+ * A clipboard can legitimately hold a whole file, and `ClipboardRead` applies
60
+ * its OWN, much smaller cap on top of this one — this is only the ceiling
61
+ * past which the process pipe stops being drained.
62
+ */
63
+ export const DEFAULT_MAX_OUTPUT_CHARS = 4_000_000;
64
+ let runnerOverride;
65
+ let detacherOverride;
66
+ // ---------------------------------------------------------------------------
67
+ // the real-host gate
68
+ // ---------------------------------------------------------------------------
69
+ /**
70
+ * Whether an un-injected seam may consult the real machine.
71
+ *
72
+ * False under `bun test`. Bun sets `NODE_ENV=test` for its own runner
73
+ * (verified on bun 1.3.14 by printing it from inside a test), which is the
74
+ * only signal available without a shared setup file — and a setup file is
75
+ * exactly the thing a new test file forgets to import.
76
+ *
77
+ * `@crewhaus/tool-pkgmgr` deliberately has NO equivalent: it ties its hostile
78
+ * default to the runner seam alone and says so. This package is stricter on
79
+ * purpose, because the failure it is guarding against is worse here. A
80
+ * pkgmgr test that forgets `_setRunner` runs `brew list`; a test in THIS
81
+ * package that forgets it pops a real toast on the maintainer's screen, sends
82
+ * a real job to a real printer, reads whatever the operator last copied into
83
+ * a transcript, or parks a `caffeinate` on their laptop. The runner-seam tie
84
+ * is still here and is still the primary guard (see `hostPlatform`); this is
85
+ * the belt under it, for the case where the seam was never installed at all.
86
+ *
87
+ * The residual cost is stated plainly: a harness deliberately run with
88
+ * `NODE_ENV=test` gets `unavailable` from every tool here, with the reason
89
+ * naming the gate. That is fail-closed, and it is visible rather than silent.
90
+ */
91
+ let realHostAllowed = process.env["NODE_ENV"] !== "test";
92
+ /**
93
+ * Opt in to (or out of) the real machine for an un-injected seam.
94
+ *
95
+ * Exactly one test in this package calls it with `true`, and restores it in
96
+ * the same `finally`. Everything else drives a fixture.
97
+ */
98
+ export function _allowRealHost(allowed) {
99
+ realHostAllowed = allowed;
100
+ }
101
+ export function _realHostAllowed() {
102
+ return realHostAllowed;
103
+ }
104
+ /**
105
+ * True when a runner is installed.
106
+ *
107
+ * Read by `host.ts` to decide the platform default: a test that has installed
108
+ * a recorded runner has already declared "I am not talking to this host", so
109
+ * if it then forgets to declare which host it is PRETENDING to be it gets a
110
+ * platform no backend serves — identically on macOS and on Linux CI. That
111
+ * exact bug (a test inheriting the ambient `process.platform`) cost this
112
+ * project a CI round in tool-hostfs.
113
+ */
114
+ export function _runnerInstalled() {
115
+ return runnerOverride !== undefined;
116
+ }
117
+ // ---------------------------------------------------------------------------
118
+ // argv hygiene
119
+ // ---------------------------------------------------------------------------
120
+ /**
121
+ * Programs that turn a careful argv array back into one string.
122
+ *
123
+ * The moment argv[0] is `sh`, every escaping guarantee in this package
124
+ * evaporates: the elements after `-c` are source again. Refusing the family
125
+ * structurally is cheaper than auditing each new backend for it.
126
+ *
127
+ * `powershell` IS NOT IN THIS LIST, and that is a carve-out, not an
128
+ * oversight. Two of the three Windows backends here genuinely are PowerShell,
129
+ * run as `powershell.exe <fixed flags> <registered script>` with every caller
130
+ * value in the ENVIRONMENT. The guarantee that replaces the blanket refusal
131
+ * is narrower and checkable: `powershellArgv` only emits a script object
132
+ * registered at module scope, and nothing is ever appended after it (which is
133
+ * what would put a caller's value back into source). `every powershell argv
134
+ * is a registered script with nothing appended` in index.test.ts asserts that
135
+ * over every recorded call.
136
+ */
137
+ const SHELLS = new Set([
138
+ "sh",
139
+ "bash",
140
+ "zsh",
141
+ "dash",
142
+ "ksh",
143
+ "csh",
144
+ "tcsh",
145
+ "fish",
146
+ "cmd",
147
+ "cmd.exe",
148
+ "command.com",
149
+ "env",
150
+ "eval",
151
+ "xargs",
152
+ ]);
153
+ /**
154
+ * House rule 7, enforced structurally rather than only asserted.
155
+ *
156
+ * No tool here acquires privilege. Being root and BECOMING root are different
157
+ * things, and this package does neither — it uses what the process already
158
+ * has. The check covers more than argv[0], because `systemd-inhibit sudo …`
159
+ * is the same escalation one position over.
160
+ */
161
+ const ESCALATORS = /^(sudo|doas|runas|pkexec|gksudo|gksu)$/i;
162
+ /**
163
+ * `su` is refused as argv[0] only.
164
+ *
165
+ * As a later element it is a plausible VALUE — a CUPS destination may
166
+ * legitimately be called `su`, and `SAFE_DESTINATION` accepts it — so
167
+ * refusing it everywhere would reject a correct print with a message about
168
+ * privilege that made no sense.
169
+ */
170
+ const ESCALATOR_PROGRAMS = /^(sudo|doas|runas|pkexec|gksudo|gksu|su)$/i;
171
+ /**
172
+ * Could this element be NAMING A PROGRAM, rather than carrying a caller value?
173
+ *
174
+ * ADVERSARIAL REVIEW FOUND THAT THE ANSWER MATTERS. The first version of the
175
+ * escalation scan took `arg.split(/[\\/]/).pop()` of EVERY element and
176
+ * matched that against `ESCALATORS`, which made
177
+ * `OpenExternal({target:"https://docs.example.com/guides/sudo"})` — an
178
+ * ordinary https URL the scheme allow-list had already approved — come back
179
+ * as `argv names "…", and this package never escalates privilege`. A
180
+ * notification body of `see /var/log/sudo` did the same. That is a refusal a
181
+ * caller cannot act on, for a value that could never have been a program:
182
+ * the check was reading a path SEGMENT out of data.
183
+ *
184
+ * It is the trap the `su` carve-out above was written for, one step further
185
+ * out. So a path segment is only read out of an element that could plausibly
186
+ * be a command path: no whitespace, no leading `-` (that is an option), and
187
+ * none of `:` `=` `?` `#` (a URI, an `--opt=value`, a query). An element that
188
+ * is EXACTLY an escalator name is still refused whatever its shape, which is
189
+ * what catches the mistake this rule exists for — a future backend writing
190
+ * `["systemd-inhibit", "--what=idle", "sudo", "sleep", "60"]`.
191
+ *
192
+ * The residual is stated rather than hidden: a caller value that is itself a
193
+ * bare path ending in `/sudo`, in a position with no `--` in front of it, is
194
+ * still refused — `xdg-open /ws/sudo` and `lp /ws/sudo` are the two places
195
+ * that can happen, because neither program has a `--`. That is rare and it
196
+ * fails closed.
197
+ */
198
+ function couldNameAProgram(arg) {
199
+ return arg !== "" && !arg.startsWith("-") && !/[\s:=?#]/.test(arg);
200
+ }
201
+ export function assertArgv(argv) {
202
+ const program = argv[0];
203
+ if (program === undefined || program.trim() === "") {
204
+ return "argv[0] must name a program to run";
205
+ }
206
+ const base = (program.split(/[\\/]/).pop() ?? program).toLowerCase();
207
+ if (SHELLS.has(base)) {
208
+ return `argv[0] is "${base}", and this package never runs a shell — an argv array handed to a shell is a command line again`;
209
+ }
210
+ if (ESCALATOR_PROGRAMS.test(base.replace(/\.exe$/i, ""))) {
211
+ return `argv[0] is "${base}", and this package never escalates privilege`;
212
+ }
213
+ // Everything after the first `--` is a POSITIONAL by this package's own
214
+ // construction — that separator is only ever emitted to make the tail
215
+ // data — so the escalation scan stops there. The NUL check does not: a NUL
216
+ // truncates an argument at the syscall boundary wherever it sits.
217
+ const separator = argv.indexOf("--");
218
+ for (let i = 0; i < argv.length; i += 1) {
219
+ const arg = argv[i];
220
+ if (typeof arg !== "string")
221
+ return "every argv element must be a string";
222
+ // A NUL byte truncates an argument at the syscall boundary: a string that
223
+ // reads as harmless here arrives at the kernel cut short at the NUL, with
224
+ // whatever followed it gone.
225
+ if (arg.includes("\u0000"))
226
+ return "argv may not contain a NUL byte";
227
+ if (separator >= 0 && i > separator)
228
+ continue;
229
+ const whole = arg.replace(/\.exe$/i, "");
230
+ const argBase = couldNameAProgram(arg)
231
+ ? (arg.split(/[\\/]/).pop() ?? arg).replace(/\.exe$/i, "")
232
+ : whole;
233
+ if (ESCALATORS.test(argBase)) {
234
+ return `argv names "${arg}", and this package never escalates privilege`;
235
+ }
236
+ }
237
+ return undefined;
238
+ }
239
+ /**
240
+ * A child's environment: `PATH` so the program can be found, a C locale so a
241
+ * parsed message is the same string on every machine, and the backend's
242
+ * explicit additions (a display, a D-Bus address, a `CREWHAUS_*` payload).
243
+ *
244
+ * Nothing else. `process.env` is NOT copied in: `pbpaste` has no business
245
+ * seeing an API key, and neither does a PowerShell one-liner. The session
246
+ * variables a Linux backend needs arrive through `extra`, put there by a
247
+ * backend reading the INJECTED `sessionEnv()` — so a test controls them like
248
+ * everything else rather than inheriting whether the maintainer happened to
249
+ * be in an SSH session.
250
+ */
251
+ export function childEnv(extra) {
252
+ const inherited = process.env["PATH"];
253
+ const env = {
254
+ PATH: inherited === undefined || inherited === "" ? FALLBACK_PATH : inherited,
255
+ LC_ALL: "C",
256
+ LANG: "C",
257
+ };
258
+ // `HOME` is forwarded, and it is not decoration: three of these backends
259
+ // resolve USER-level configuration through it, and without it they silently
260
+ // answer for a different user than the operator. `xdg-open` reads the
261
+ // default-application table under `${XDG_DATA_HOME:-$HOME/.local/share}`
262
+ // and with `$HOME` empty falls back to the system table, so a document
263
+ // opens in whatever the distribution shipped rather than in the program the
264
+ // operator chose; `lp` reads `~/.cups/client.conf` for the server and
265
+ // default destination; and macOS `open` consults the per-user LaunchServices
266
+ // database. It is a path, not a credential, which is why it is the one
267
+ // inherited variable here besides `PATH`.
268
+ const home = process.env["HOME"];
269
+ if (home !== undefined && home !== "")
270
+ env["HOME"] = home;
271
+ for (const [key, value] of Object.entries(extra ?? {}))
272
+ env[key] = value;
273
+ return env;
274
+ }
275
+ /** Cut a captured stream at the cap, saying whether it was cut. */
276
+ export function capText(text, max) {
277
+ if (text.length <= max)
278
+ return { text, truncated: false };
279
+ return { text: text.slice(0, max), truncated: true };
280
+ }
281
+ /**
282
+ * The refusal an un-injected runner produces under test.
283
+ *
284
+ * Shaped like a real failure so every caller's existing "the command did not
285
+ * work" branch handles it, and worded so the test author sees immediately
286
+ * what they forgot.
287
+ */
288
+ export const NO_RUNNER_REASON = "no runner is installed and this process is not allowed to touch the real host (see _setRunner / _allowRealHost)";
289
+ /**
290
+ * Turn a `Bun.spawn` throw into the right kind of "it did not run".
291
+ *
292
+ * ADVERSARIAL REVIEW: the first version set `missing: true` for EVERY spawn
293
+ * error, so a program that is installed but not executable by this user
294
+ * (`EACCES`), a name that resolves to a directory (`EISDIR`/`ENOTDIR`) or an
295
+ * argv over the kernel's limit (`E2BIG`) all came back as "the program is not
296
+ * installed on this host" — and `WindowList` then told an operator to install
297
+ * the `wmctrl` they already have. That is rule 6 one level below the tools:
298
+ * "could not start it" is not "it is not there".
299
+ *
300
+ * Only the two errnos that genuinely mean the NAME does not resolve set
301
+ * `missing`. Everything else is a failure carrying the errno, so the caller's
302
+ * reason names what actually happened.
303
+ *
304
+ * Exported because it is the only part of the real-spawn path a test can
305
+ * reach without spawning (house rule 4 allows exactly one real-host test, and
306
+ * it spawns nothing).
307
+ */
308
+ export function classifySpawnError(err) {
309
+ const code = err?.code;
310
+ const message = err instanceof Error ? err.message : String(err);
311
+ const absent = code === "ENOENT" || code === "ENOTDIR";
312
+ return {
313
+ code: 127,
314
+ stdout: "",
315
+ stderr: absent ? message : `${code ?? "spawn failed"}: ${message}`,
316
+ timedOut: false,
317
+ missing: absent,
318
+ };
319
+ }
320
+ export async function runHostCommand(request) {
321
+ // Checked BEFORE the seam, not after: an injected runner must not be able
322
+ // to make an argv acceptable that the real spawn would refuse, or a test
323
+ // would pass on a command no host could run.
324
+ const bad = assertArgv(request.argv);
325
+ if (bad !== undefined) {
326
+ // `refused`, not a bare non-zero exit. ADVERSARIAL REVIEW: without it
327
+ // every classifier rendered this as "the opener exited -1: <reason>",
328
+ // which claims a process ran and reported a status. Nothing ran — the
329
+ // argv never left this function — and `refused` is the flag every
330
+ // classifier already maps to a plain `failed` with the reason verbatim.
331
+ return {
332
+ code: -1,
333
+ stdout: "",
334
+ stderr: bad,
335
+ timedOut: false,
336
+ missing: false,
337
+ refused: true,
338
+ };
339
+ }
340
+ if (runnerOverride !== undefined)
341
+ return runnerOverride(request);
342
+ if (!realHostAllowed) {
343
+ return {
344
+ code: -1,
345
+ stdout: "",
346
+ stderr: NO_RUNNER_REASON,
347
+ timedOut: false,
348
+ missing: false,
349
+ refused: true,
350
+ };
351
+ }
352
+ let proc;
353
+ try {
354
+ proc = Bun.spawn([...request.argv], {
355
+ stdin: request.stdin === undefined ? "ignore" : new TextEncoder().encode(request.stdin),
356
+ stdout: "pipe",
357
+ stderr: "pipe",
358
+ env: childEnv(request.env),
359
+ ...(request.signal !== undefined ? { signal: request.signal } : {}),
360
+ });
361
+ }
362
+ catch (err) {
363
+ // ENOENT means the backend is not installed on this host, which is a
364
+ // DIFFERENT answer from "it ran and told us nothing" — and a different
365
+ // answer again from "it is there and would not start". See
366
+ // `classifySpawnError`.
367
+ return classifySpawnError(err);
368
+ }
369
+ let timedOut = false;
370
+ const term = setTimeout(() => {
371
+ timedOut = true;
372
+ try {
373
+ proc.kill("SIGTERM");
374
+ }
375
+ catch {
376
+ // Already gone between the timer firing and the signal.
377
+ }
378
+ }, request.timeoutMs);
379
+ const hardKill = setTimeout(() => {
380
+ try {
381
+ proc.kill("SIGKILL");
382
+ }
383
+ catch {
384
+ // Already gone.
385
+ }
386
+ }, request.timeoutMs + KILL_GRACE_MS);
387
+ try {
388
+ // `.catch` is attached HERE and not after the race below: the loser of
389
+ // the race stays pending, and a stream torn down with a killed process
390
+ // rejects with nobody listening — an unhandled rejection that takes down
391
+ // the harness rather than the command that caused it.
392
+ const stdoutText = new Response(proc.stdout)
393
+ .text()
394
+ .catch(() => "");
395
+ const stderrText = new Response(proc.stderr)
396
+ .text()
397
+ .catch(() => "");
398
+ const code = await proc.exited;
399
+ let drainTimer;
400
+ const drainFallback = new Promise((resolve) => {
401
+ drainTimer = setTimeout(() => resolve(""), DRAIN_GRACE_MS);
402
+ });
403
+ try {
404
+ const [rawOut, rawErr] = await Promise.all([
405
+ Promise.race([stdoutText, drainFallback]),
406
+ Promise.race([stderrText, drainFallback]),
407
+ ]);
408
+ const cap = request.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
409
+ const out = capText(rawOut, cap);
410
+ const err = capText(rawErr, cap);
411
+ return {
412
+ code,
413
+ stdout: out.text,
414
+ stderr: err.text,
415
+ timedOut,
416
+ missing: false,
417
+ ...(out.truncated ? { stdoutTruncated: true } : {}),
418
+ ...(err.truncated ? { stderrTruncated: true } : {}),
419
+ };
420
+ }
421
+ finally {
422
+ // One shared timer, cleared as soon as the race settles: a fresh
423
+ // `setTimeout` per stream that nobody cancels keeps the event loop
424
+ // awake for the full grace period after every spawn.
425
+ if (drainTimer !== undefined)
426
+ clearTimeout(drainTimer);
427
+ }
428
+ }
429
+ finally {
430
+ clearTimeout(term);
431
+ clearTimeout(hardKill);
432
+ }
433
+ }
434
+ /**
435
+ * Start a child and return its pid without waiting for it.
436
+ *
437
+ * `stdio` is all `ignore`: a detached holder that inherited this process's
438
+ * pipes would keep them open after the harness exits, and a holder whose
439
+ * stdout nobody reads eventually blocks on a full pipe — an inhibitor that
440
+ * stops inhibiting halfway through the job it was held for.
441
+ */
442
+ export async function detachHostCommand(request) {
443
+ const bad = assertArgv(request.argv);
444
+ if (bad !== undefined)
445
+ return { ok: false, reason: bad, missing: false };
446
+ if (detacherOverride !== undefined)
447
+ return detacherOverride(request);
448
+ if (!realHostAllowed)
449
+ return { ok: false, reason: NO_RUNNER_REASON, missing: false };
450
+ try {
451
+ const proc = Bun.spawn([...request.argv], {
452
+ stdin: "ignore",
453
+ stdout: "ignore",
454
+ stderr: "ignore",
455
+ env: childEnv(request.env),
456
+ });
457
+ proc.unref();
458
+ return { ok: true, pid: proc.pid };
459
+ }
460
+ catch (err) {
461
+ // Same distinction as `runHostCommand`: only a name that does not resolve
462
+ // is `missing`. An inhibitor that would not START is not one that is not
463
+ // INSTALLED, and telling an operator to install `caffeinate` on a macOS
464
+ // box that has always had it sends them nowhere.
465
+ const failure = classifySpawnError(err);
466
+ return { ok: false, reason: failure.stderr, missing: failure.missing };
467
+ }
468
+ }
469
+ /**
470
+ * Test seam. Pass a function to answer every command from recorded output;
471
+ * pass `undefined` to go back to the (refused, under test) real spawn.
472
+ */
473
+ export function _setRunner(runner) {
474
+ runnerOverride = runner;
475
+ }
476
+ export function _setDetacher(detacher) {
477
+ detacherOverride = detacher;
478
+ }
479
+ export function _resetRunSeams() {
480
+ runnerOverride = undefined;
481
+ detacherOverride = undefined;
482
+ realHostAllowed = process.env["NODE_ENV"] !== "test";
483
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@crewhaus/tool-desktop",
3
+ "version": "0.7.0",
4
+ "type": "module",
5
+ "description": "Deterministic access to the operator's desktop: clipboard, notifications, opening a document, printing, windows, presence and sleep — failing closed on a headless host",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "bun test src"
16
+ },
17
+ "dependencies": {
18
+ "@crewhaus/errors": "0.7.0",
19
+ "@crewhaus/tool-builder": "0.7.0",
20
+ "@crewhaus/tool-catalog": "0.7.0",
21
+ "@crewhaus/tool-proc": "0.7.0",
22
+ "zod": "^3.23.8"
23
+ },
24
+ "devDependencies": {
25
+ "@crewhaus/tool-executor": "workspace:*"
26
+ },
27
+ "license": "Apache-2.0",
28
+ "author": {
29
+ "name": "Max Meier",
30
+ "email": "max@crewhaus.ai",
31
+ "url": "https://crewhaus.ai"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/crewhaus/factory.git",
36
+ "directory": "packages/tool-desktop"
37
+ },
38
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-desktop#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/crewhaus/factory/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
46
+ }