@dovocode/workstation 0.1.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/index.js ADDED
@@ -0,0 +1,1318 @@
1
+ // src/api/jsonc.ts
2
+ var JsoncDocument = class _JsoncDocument {
3
+ /** Store the rendered document text; callers construct documents through the builder commands. */
4
+ constructor(content) {
5
+ this.content = content;
6
+ }
7
+ /** @internal Build from typed commands; arbitrary unvalidated JSONC is not accepted. */
8
+ static from(lines) {
9
+ const flattened = flatten(lines);
10
+ if (flattened.filter((line) => line.kind === "value").length !== 1 || flattened.some((line) => line.kind === "property")) {
11
+ throw new Error("A JSONC document must contain exactly one value and no top-level properties");
12
+ }
13
+ return new _JsoncDocument(renderLines(flattened, 0, false).join("\n") + "\n");
14
+ }
15
+ };
16
+ var jsonc = {
17
+ /** Join a document's comments and single root value, separating commands by newlines.
18
+ * @example jsonc.concat(jsonc.comment("Editor settings"), jsonc.object([jsonc.property("theme", "dark")]))
19
+ */
20
+ concat(...lines) {
21
+ return JsoncDocument.from(lines);
22
+ },
23
+ /** Add a // comment. Each line of multiline text receives its own comment prefix. */
24
+ comment(text) {
25
+ return { kind: "comment", text };
26
+ },
27
+ /** Insert an intentional empty line. */
28
+ blank() {
29
+ return { kind: "blank" };
30
+ },
31
+ /** Add an object property. Values may be ordinary JSON data or a nested JSONC document. */
32
+ property(name, value) {
33
+ return { kind: "property", name, content: renderValue(value) };
34
+ },
35
+ /** Add a JSON value, for example an array element or a primitive document root. */
36
+ value(value) {
37
+ return { kind: "value", content: renderValue(value) };
38
+ },
39
+ /** Build an object from properties, comments, blank lines, and optional groups. */
40
+ object(lines) {
41
+ return container(lines, "property", "{", "}");
42
+ },
43
+ /** Build an array from values, comments, blank lines, and optional groups. */
44
+ array(lines) {
45
+ return container(lines, "value", "[", "]");
46
+ }
47
+ };
48
+ function container(lines, kind, open, close) {
49
+ const commands = flatten(lines);
50
+ if (commands.some((line) => line.kind !== kind && line.kind !== "comment" && line.kind !== "blank")) {
51
+ throw new Error(`JSONC ${open === "{" ? "objects require properties" : "arrays require values"}`);
52
+ }
53
+ const body = renderLines(commands, 1, true);
54
+ const content = body.length ? [open, ...body, close].join("\n") : open + close;
55
+ return JsoncDocument.from([{ kind: "value", content }]);
56
+ }
57
+ function flatten(lines) {
58
+ return lines.flatMap((line) => {
59
+ if (!line) return [];
60
+ if (line instanceof JsoncDocument) return [{ kind: "value", content: line.content.trimEnd() }];
61
+ if (isGroup(line)) return flatten(line);
62
+ return [line];
63
+ });
64
+ }
65
+ function isGroup(line) {
66
+ return Array.isArray(line);
67
+ }
68
+ function renderLines(lines, depth, commas) {
69
+ const indent = " ".repeat(depth);
70
+ const lastValue = lines.findLastIndex((line) => line.kind === "value" || line.kind === "property");
71
+ return lines.flatMap((line, index) => {
72
+ if (line.kind === "blank") return [""];
73
+ if (line.kind === "comment") return line.text.split(/\r\n|\r|\n/).map((text) => `${indent}// ${text}`);
74
+ const content = line.kind === "property" ? `${JSON.stringify(line.name)}: ${line.content}` : line.content;
75
+ const rendered = content.split("\n").map((text) => indent + text);
76
+ if (commas && index !== lastValue) {
77
+ if (/^\s*\/\//.test(rendered.at(-1) ?? "")) rendered.push(indent + ",");
78
+ else rendered[rendered.length - 1] += ",";
79
+ }
80
+ return rendered;
81
+ });
82
+ }
83
+ function renderValue(value) {
84
+ if (value instanceof JsoncDocument) return value.content.trimEnd();
85
+ const content = JSON.stringify(value, (_key, item) => {
86
+ if (typeof item === "number" && !Number.isFinite(item)) throw new Error("JSONC numbers must be finite");
87
+ if (item === void 0 || typeof item === "function" || typeof item === "symbol") throw new Error("Invalid JSONC value");
88
+ return item;
89
+ }, 2);
90
+ if (content === void 0) throw new Error("Invalid JSONC value");
91
+ return content;
92
+ }
93
+
94
+ // src/api/tasks.ts
95
+ function task(command, args = [], options = {}) {
96
+ return { command, args, ...options };
97
+ }
98
+
99
+ // src/config/tasks.ts
100
+ import { isAbsolute, resolve } from "path";
101
+ function resolveTasks(definition, context) {
102
+ const tasks = /* @__PURE__ */ Object.create(null);
103
+ const aliases = /* @__PURE__ */ Object.create(null);
104
+ for (const [name, value] of Object.entries(definition.tasks ?? {})) {
105
+ validateName(name);
106
+ if (!value || typeof value !== "object" || typeof value.command !== "string" || !value.command.trim() || value.args !== void 0 && (!Array.isArray(value.args) || !value.args.every((arg) => typeof arg === "string")) || value.cwd !== void 0 && typeof value.cwd !== "string" || value.description !== void 0 && typeof value.description !== "string" || value.environment !== void 0 && (typeof value.environment !== "object" || value.environment === null || Array.isArray(value.environment) || !Object.values(value.environment).every((item) => typeof item === "string"))) {
107
+ throw new Error(`Invalid task: ${name}`);
108
+ }
109
+ const cwd = value.cwd?.replace(/^~(?=\/|$)/, context.home) ?? context.configDir;
110
+ tasks[name] = { ...value, cwd: isAbsolute(cwd) ? cwd : resolve(context.configDir, cwd) };
111
+ }
112
+ for (const [name, target] of Object.entries(definition.aliases ?? {})) {
113
+ validateName(name);
114
+ if (Object.hasOwn(tasks, name)) throw new Error(`Task and alias share a name: ${name}`);
115
+ if (typeof target !== "string") throw new Error(`Invalid alias target: ${name}`);
116
+ aliases[name] = target;
117
+ }
118
+ for (const name of Object.keys(aliases)) resolveTaskName(name, tasks, aliases);
119
+ return { tasks, aliases };
120
+ }
121
+ function resolveTaskName(name, tasks, aliases) {
122
+ const visited = /* @__PURE__ */ new Set();
123
+ let current = name;
124
+ while (Object.hasOwn(aliases, current)) {
125
+ if (visited.has(current)) throw new Error(`Task alias cycle: ${[...visited, current].join(" -> ")}`);
126
+ visited.add(current);
127
+ const target = aliases[current];
128
+ if (target === void 0) throw new Error(`Invalid alias: ${current}`);
129
+ current = target;
130
+ }
131
+ if (!Object.hasOwn(tasks, current)) throw new Error(`Unknown task: ${current}`);
132
+ return current;
133
+ }
134
+ function validateName(name) {
135
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9:_-]*$/.test(name) || name === "help" || name === "init") throw new Error(`Invalid or reserved task name: ${name}`);
136
+ }
137
+
138
+ // src/resources/tasks.ts
139
+ async function runTask(config, name, args, runner) {
140
+ const tasks = config.tasks ?? {};
141
+ const resolved = resolveTaskName(name, tasks, config.aliases ?? {});
142
+ const task2 = tasks[resolved];
143
+ if (!task2) throw new Error(`Unknown task: ${resolved}`);
144
+ return await runner.run(task2.command, [...task2.args ?? [], ...args], {
145
+ ...task2.cwd ? { cwd: task2.cwd } : {},
146
+ ...task2.environment ? { environment: task2.environment } : {}
147
+ });
148
+ }
149
+
150
+ // src/api/config.ts
151
+ function packageList(manager, packages, upgrade) {
152
+ if (Array.isArray(packages)) {
153
+ return packages.map((name) => ({
154
+ kind: "package",
155
+ manager,
156
+ name,
157
+ ...upgrade ? { upgrade } : {}
158
+ }));
159
+ }
160
+ return Object.entries(packages).map(([name, version]) => ({
161
+ kind: "package",
162
+ manager,
163
+ name,
164
+ version
165
+ }));
166
+ }
167
+ var tools = {
168
+ /**
169
+ * Install mise tools. A list requests `latest`; a map accepts version selectors.
170
+ * @example tools.mise({ node: "lts", go: "1.27" })
171
+ */
172
+ mise: (packages) => packageList("mise", packages),
173
+ /** Install Homebrew formulae (command-line packages). */
174
+ brew: (packages) => packageList("brew", packages),
175
+ /**
176
+ * Install macOS applications. Greedy casks refresh their lock pin each run.
177
+ * @example tools.brewCask(["ghostty"], { greedy: true, force: true })
178
+ */
179
+ brewCask: (packages, upgrade) => packageList("brew-cask", packages, upgrade),
180
+ /** Install Debian/Ubuntu packages through APT. Mutations request sudo. */
181
+ apt: (packages) => packageList("apt", packages),
182
+ /** Use the configured platform manager; defaults to Homebrew on macOS and APT on Linux. */
183
+ system: (packages) => packageList("system", packages)
184
+ };
185
+ function symlink(source, target) {
186
+ return { kind: "symlink", source, target };
187
+ }
188
+ function launchAgent(label, options) {
189
+ return { kind: "launch-agent", label, ...options };
190
+ }
191
+ function generatedFile(format, target, value, options = {}) {
192
+ return {
193
+ kind: "generated-file",
194
+ format,
195
+ target,
196
+ value,
197
+ ifExists: options.ifExists ?? "overwrite",
198
+ ...options.mode !== void 0 ? { mode: options.mode } : {}
199
+ };
200
+ }
201
+ var files = {
202
+ /**
203
+ * Generate TOML. Values must be representable in TOML (for example, no null).
204
+ * @example files.toml("~/.config/app/config.toml", { server: { port: 3000 } })
205
+ */
206
+ toml: (target, value, options) => generatedFile("toml", target, value, options),
207
+ /** Generate YAML with the standard overwrite/restore policy. */
208
+ yaml: (target, value, options) => generatedFile("yaml", target, value, options),
209
+ /**
210
+ * Generate formatted JSON.
211
+ * @example files.json("~/.config/app/config.json", { enabled: true })
212
+ */
213
+ json: (target, value, options) => generatedFile("json", target, value, options),
214
+ /** Generate JSONC from plain data or jsonc.concat/object commands with comments. */
215
+ jsonc: (target, value, options) => value instanceof JsoncDocument ? { ...generatedFile("jsonc", target, null, options), renderedContent: value.content } : generatedFile("jsonc", target, value, options)
216
+ };
217
+ function systemdService(name, options) {
218
+ return { kind: "systemd-service", name, scope: options.scope ?? "user", ...options };
219
+ }
220
+ function customTool(name, options) {
221
+ return { kind: "custom-tool", name, ...options };
222
+ }
223
+ function when(condition, resources) {
224
+ return condition ? resources : [];
225
+ }
226
+ function defineConfig(config) {
227
+ return config;
228
+ }
229
+ function configure(factory) {
230
+ return factory;
231
+ }
232
+ function darwin(config) {
233
+ return conditional((context) => context.platform === "darwin", config);
234
+ }
235
+ function linux(config) {
236
+ return conditional((context) => context.platform === "linux", config);
237
+ }
238
+ function machine(names, config) {
239
+ const accepted = typeof names === "string" ? [names] : names;
240
+ return conditional((context) => accepted.includes(context.machine), config);
241
+ }
242
+ function conditional(predicate, config) {
243
+ return (context) => predicate(context) ? resolveInput(config, context) : void 0;
244
+ }
245
+ function resolveInput(config, context) {
246
+ return typeof config === "function" ? config(context) : config;
247
+ }
248
+
249
+ // src/api/shell.ts
250
+ var shell = {
251
+ /**
252
+ * Reference a shell variable at runtime.
253
+ * @example shell.export("VISUAL", shell.variable("EDITOR"))
254
+ */
255
+ variable(name) {
256
+ return { kind: "variable", name: validateVariable(name) };
257
+ },
258
+ /**
259
+ * Expand a path under the shell's HOME at runtime.
260
+ * @example shell.home(".local/bin")
261
+ */
262
+ home(path = "") {
263
+ if (path.includes("\0") || path.includes("\n")) throw new Error("Invalid home-relative path");
264
+ return { kind: "home", path: path.replace(/^\//, "") };
265
+ },
266
+ /** Join literals and expressions into one shell value. */
267
+ concat(...values) {
268
+ return { kind: "concat", values };
269
+ },
270
+ /** Use a command's stdout as a value via command substitution. */
271
+ capture(command) {
272
+ return { kind: "capture", command };
273
+ },
274
+ /** Describe a command and its arguments. Use `capture` for its output or `eval` for initialization code. */
275
+ command(command, args = [], options = {}) {
276
+ if (!command || command.includes("\0") || command.includes("\n")) {
277
+ throw new Error("Invalid shell command");
278
+ }
279
+ return { command, args, ...options };
280
+ },
281
+ /** Set and export an environment variable. */
282
+ export(name, value) {
283
+ return { kind: "export", name: validateVariable(name), value };
284
+ },
285
+ /** Set a shell variable without exporting it. */
286
+ assign(name, value) {
287
+ return { kind: "assign", name: validateVariable(name), value };
288
+ },
289
+ /** Remove one or more shell variables. */
290
+ unset(...names) {
291
+ if (names.length === 0) throw new Error("unset requires at least one variable");
292
+ return { kind: "unset", names: names.map(validateVariable) };
293
+ },
294
+ /**
295
+ * Prepend paths while retaining the current PATH.
296
+ * @example shell.prependPath(shell.home(".local/bin"))
297
+ */
298
+ prependPath(...values) {
299
+ return { kind: "prepend-path", values };
300
+ },
301
+ /** Define an alias; its command text is interpreted when the alias runs. */
302
+ alias(name, command) {
303
+ if (!/^[A-Za-z0-9_.-]+$/.test(name)) throw new Error(`Invalid shell alias: ${name}`);
304
+ return { kind: "alias", name, command };
305
+ },
306
+ /**
307
+ * Evaluate shell code printed by a command.
308
+ * @example shell.eval(shell.command("mise", ["activate", "zsh"]))
309
+ */
310
+ eval(command) {
311
+ return { kind: "eval", command };
312
+ },
313
+ /** Source another shell file. With `ifExists: true`, source only when readable. */
314
+ source(path, options = {}) {
315
+ return { kind: "source", path, ifExists: options.ifExists ?? false };
316
+ },
317
+ /** Generate a shell-time conditional containing one or more statements. */
318
+ when(condition, statements) {
319
+ if (statements.length === 0) throw new Error("Shell condition requires at least one statement");
320
+ return { kind: "if", condition, statements };
321
+ },
322
+ /** Insert literal shell code without validation or quoting. Prefer typed helpers for ordinary statements. */
323
+ raw(code) {
324
+ return { kind: "raw", code };
325
+ },
326
+ /** Construct conditions evaluated when the shell starts. */
327
+ condition: {
328
+ /** Test whether a command is available on PATH. */
329
+ commandExists(command) {
330
+ if (!command || command.includes("\0") || command.includes("\n")) {
331
+ throw new Error("Invalid shell command");
332
+ }
333
+ return { kind: "command-exists", command };
334
+ },
335
+ /** Test whether a path is executable. */
336
+ executable(path) {
337
+ return { kind: "executable", path };
338
+ },
339
+ /** Test whether a path is a regular file. */
340
+ file(path) {
341
+ return { kind: "file", path };
342
+ },
343
+ /** Test whether a path is a directory. */
344
+ directory(path) {
345
+ return { kind: "directory", path };
346
+ },
347
+ /** Test whether a value is empty. */
348
+ empty(value) {
349
+ return { kind: "empty", value };
350
+ },
351
+ /** Test whether a value is non-empty. */
352
+ nonEmpty(value) {
353
+ return { kind: "non-empty", value };
354
+ },
355
+ /** Combine conditions with shell AND. */
356
+ and(...conditions) {
357
+ if (conditions.length === 0) throw new Error("and requires at least one condition");
358
+ return { kind: "and", conditions };
359
+ },
360
+ /** Combine conditions with shell OR. */
361
+ or(...conditions) {
362
+ if (conditions.length === 0) throw new Error("or requires at least one condition");
363
+ return { kind: "or", conditions };
364
+ },
365
+ /** Negate a condition. */
366
+ not(condition) {
367
+ return { kind: "not", condition };
368
+ }
369
+ }
370
+ };
371
+ var zsh = {
372
+ /** Generate ~/.zshenv, read by every Zsh invocation. Keep this minimal. */
373
+ zshenv: (statements, options) => shellFile("zsh", "~/.zshenv", statements, options),
374
+ /** Generate ~/.zprofile for login-shell environment initialization. */
375
+ zprofile: (statements, options) => shellFile("zsh", "~/.zprofile", statements, options),
376
+ /** Generate ~/.zshrc for interactive aliases, prompts, and completion. */
377
+ zshrc: (statements, options) => shellFile("zsh", "~/.zshrc", statements, options),
378
+ /** Enable Zsh-only options using uppercase names. Cannot be rendered to Bash. */
379
+ setopt(...options) {
380
+ if (options.length === 0) throw new Error("setopt requires at least one option");
381
+ for (const option of options) {
382
+ if (!/^[A-Z_]+$/.test(option)) throw new Error(`Invalid Zsh option: ${option}`);
383
+ }
384
+ return { kind: "zsh-setopt", options };
385
+ }
386
+ };
387
+ var bash = {
388
+ /** Generate ~/.bashrc for interactive non-login shells. */
389
+ bashrc: (statements, options) => shellFile("bash", "~/.bashrc", statements, options),
390
+ /** Generate ~/.bash_profile for Bash login shells; source ~/.bashrc explicitly if desired. */
391
+ bashProfile: (statements, options) => shellFile("bash", "~/.bash_profile", statements, options),
392
+ /** Generate ~/.profile using Bash syntax; use only where Bash will read it. */
393
+ profile: (statements, options) => shellFile("bash", "~/.profile", statements, options)
394
+ };
395
+ function renderShell(statements, target) {
396
+ return `${statements.map((statement) => renderStatement(statement, target, 0)).join("\n")}
397
+ `;
398
+ }
399
+ function shellFile(format, target, statements, options = {}) {
400
+ return {
401
+ kind: "generated-file",
402
+ target,
403
+ format,
404
+ value: renderShell(statements, format),
405
+ ifExists: options.ifExists ?? "overwrite",
406
+ mode: options.mode ?? 420
407
+ };
408
+ }
409
+ function renderStatement(statement, target, depth) {
410
+ const indent = " ".repeat(depth);
411
+ switch (statement.kind) {
412
+ case "export":
413
+ return `${indent}export ${statement.name}=${renderValue2(statement.value)}`;
414
+ case "assign":
415
+ return `${indent}${statement.name}=${renderValue2(statement.value)}`;
416
+ case "unset":
417
+ return `${indent}unset ${statement.names.join(" ")}`;
418
+ case "prepend-path":
419
+ return `${indent}export PATH=${[...statement.values.map(renderValue2), '"$PATH"'].join(":")}`;
420
+ case "alias":
421
+ return `${indent}alias ${statement.name}=${quote(statement.command)}`;
422
+ case "eval":
423
+ return `${indent}eval "${escapeDouble(`$(${renderCommand(statement.command)})`)}"`;
424
+ case "source": {
425
+ const source = `source ${renderValue2(statement.path)}`;
426
+ return statement.ifExists ? `${indent}if [[ -r ${renderValue2(statement.path)} ]]; then ${source}; fi` : `${indent}${source}`;
427
+ }
428
+ case "if":
429
+ return [
430
+ `${indent}if ${renderCondition(statement.condition)}; then`,
431
+ ...statement.statements.map((child) => renderStatement(child, target, depth + 1)),
432
+ `${indent}fi`
433
+ ].join("\n");
434
+ case "zsh-setopt":
435
+ if (target !== "zsh") throw new Error("setopt is only valid in Zsh configuration");
436
+ return `${indent}setopt ${statement.options.join(" ")}`;
437
+ case "raw":
438
+ return statement.code.split("\n").map((line) => `${indent}${line}`).join("\n");
439
+ }
440
+ }
441
+ function renderCondition(condition) {
442
+ switch (condition.kind) {
443
+ case "command-exists":
444
+ return `command -v ${quote(condition.command)} >/dev/null 2>&1`;
445
+ case "executable":
446
+ return `[[ -x ${renderValue2(condition.path)} ]]`;
447
+ case "file":
448
+ return `[[ -f ${renderValue2(condition.path)} ]]`;
449
+ case "directory":
450
+ return `[[ -d ${renderValue2(condition.path)} ]]`;
451
+ case "empty":
452
+ return `[[ -z ${renderValue2(condition.value)} ]]`;
453
+ case "non-empty":
454
+ return `[[ -n ${renderValue2(condition.value)} ]]`;
455
+ case "and":
456
+ return condition.conditions.map(renderCondition).join(" && ");
457
+ case "or":
458
+ return condition.conditions.map(renderCondition).join(" || ");
459
+ case "not":
460
+ return `! ${renderCondition(condition.condition)}`;
461
+ }
462
+ }
463
+ function renderCommand(command) {
464
+ const stderr = command.stderr === "ignore" ? " 2>/dev/null" : "";
465
+ return [quote(command.command), ...(command.args ?? []).map(renderValue2)].join(" ") + stderr;
466
+ }
467
+ function renderValue2(value) {
468
+ if (typeof value === "string") return quote(value);
469
+ switch (value.kind) {
470
+ case "variable":
471
+ return `"\${${value.name}}"`;
472
+ case "home":
473
+ return `"\${HOME}${value.path ? `/${escapeDouble(value.path)}` : ""}"`;
474
+ case "concat":
475
+ return `"${value.values.map(renderInsideDoubleQuotes).join("")}"`;
476
+ case "capture":
477
+ return `"$(${renderCommand(value.command)})"`;
478
+ }
479
+ }
480
+ function renderInsideDoubleQuotes(value) {
481
+ if (typeof value === "string") return escapeDouble(value);
482
+ const rendered = renderValue2(value);
483
+ return rendered.startsWith('"') && rendered.endsWith('"') ? rendered.slice(1, -1) : rendered;
484
+ }
485
+ function quote(value) {
486
+ return `'${value.replaceAll("'", `'\\''`)}'`;
487
+ }
488
+ function escapeDouble(value) {
489
+ return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
490
+ }
491
+ function validateVariable(name) {
492
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`Invalid shell variable: ${name}`);
493
+ return name;
494
+ }
495
+
496
+ // src/config/load.ts
497
+ import { hostname as readHostname, homedir, platform as readPlatform } from "os";
498
+ import { dirname, isAbsolute as isAbsolute2, resolve as resolve3 } from "path";
499
+ import { access } from "fs/promises";
500
+
501
+ // src/config/identity.ts
502
+ import { createHash } from "crypto";
503
+ function resourceId(resource) {
504
+ switch (resource.kind) {
505
+ case "package":
506
+ return `package:${resource.manager}:${resource.name}`;
507
+ case "symlink":
508
+ return `file:${resource.target}`;
509
+ case "launch-agent":
510
+ return `launch-agent:${resource.label}`;
511
+ case "generated-file":
512
+ return `file:${resource.target}`;
513
+ case "systemd-service":
514
+ return `systemd-service:${resource.scope}:${resource.name}`;
515
+ case "custom-tool":
516
+ return `file:${resource.target}`;
517
+ }
518
+ }
519
+ function fingerprint(resource) {
520
+ return createHash("sha256").update(stableJson(resource)).digest("hex");
521
+ }
522
+ function stableJson(value) {
523
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
524
+ if (typeof value === "object" && value !== null) {
525
+ return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`).join(",")}}`;
526
+ }
527
+ return JSON.stringify(value);
528
+ }
529
+
530
+ // src/config/source-hash.ts
531
+ import { createHash as createHash2 } from "crypto";
532
+ import { lstat, readFile, readdir, readlink } from "fs/promises";
533
+ import { resolve as resolve2 } from "path";
534
+ async function hashSource(path) {
535
+ const hash = createHash2("sha256");
536
+ await addPathToHash(hash, path, ".");
537
+ return hash.digest("hex");
538
+ }
539
+ async function addPathToHash(hash, path, relativePath) {
540
+ const stats = await lstat(path);
541
+ if (stats.isSymbolicLink()) {
542
+ hash.update(`link\0${relativePath}\0${await readlink(path)}\0`);
543
+ return;
544
+ }
545
+ if (stats.isFile()) {
546
+ hash.update(`file\0${relativePath}\0`);
547
+ hash.update(await readFile(path));
548
+ return;
549
+ }
550
+ if (!stats.isDirectory()) throw new Error(`Unsupported custom tool source entry: ${path}`);
551
+ hash.update(`directory\0${relativePath}\0`);
552
+ const entries = (await readdir(path, { withFileTypes: true })).sort(
553
+ (left, right) => left.name.localeCompare(right.name)
554
+ );
555
+ for (const entry of entries) {
556
+ await addPathToHash(hash, resolve2(path, entry.name), `${relativePath}/${entry.name}`);
557
+ }
558
+ }
559
+
560
+ // src/config/validation.ts
561
+ function validateResource(value) {
562
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
563
+ throw new Error("Every configured resource must be an object, array, or falsey value");
564
+ }
565
+ const candidate = value;
566
+ switch (candidate.kind) {
567
+ case "package":
568
+ if (!["mise", "brew", "brew-cask", "apt", "system"].includes(String(candidate.manager)) || typeof candidate.name !== "string" || candidate.name.length === 0 || candidate.version !== void 0 && typeof candidate.version !== "string" || !isBrewCaskUpgradeOptions(candidate.upgrade)) {
569
+ throw new Error("Invalid package resource");
570
+ }
571
+ return;
572
+ case "symlink":
573
+ if (typeof candidate.source !== "string" || typeof candidate.target !== "string") {
574
+ throw new Error("Invalid symlink resource");
575
+ }
576
+ return;
577
+ case "launch-agent":
578
+ if (typeof candidate.label !== "string" || !/^[A-Za-z0-9.-]+$/.test(candidate.label) || typeof candidate.program !== "string" || candidate.args !== void 0 && (!Array.isArray(candidate.args) || !candidate.args.every((argument) => typeof argument === "string"))) {
579
+ throw new Error("Invalid LaunchAgent resource");
580
+ }
581
+ return;
582
+ case "generated-file":
583
+ if (typeof candidate.target !== "string" || !["toml", "yaml", "json", "jsonc", "zsh", "bash"].includes(String(candidate.format)) || !["update", "overwrite", "ignore"].includes(String(candidate.ifExists)) || !isConfigValue(candidate.value) || candidate.renderedContent !== void 0 && (candidate.format !== "jsonc" || typeof candidate.renderedContent !== "string") || ["zsh", "bash"].includes(String(candidate.format)) && typeof candidate.value !== "string" || candidate.mode !== void 0 && (typeof candidate.mode !== "number" || !Number.isInteger(candidate.mode) || candidate.mode < 0 || candidate.mode > 511)) {
584
+ throw new Error("Invalid generated file resource");
585
+ }
586
+ return;
587
+ case "custom-tool":
588
+ if (typeof candidate.name !== "string" || !/^[A-Za-z0-9_.-]+$/.test(candidate.name) || typeof candidate.source !== "string" || typeof candidate.target !== "string" || !isCommandSpec(candidate.build)) {
589
+ throw new Error("Invalid custom tool resource");
590
+ }
591
+ return;
592
+ case "systemd-service":
593
+ if (typeof candidate.name !== "string" || !/^[A-Za-z0-9_.@-]+(?:\.service)?$/.test(candidate.name) || typeof candidate.program !== "string" || !["user", "system"].includes(String(candidate.scope)) || candidate.restart !== void 0 && !["no", "on-failure", "always"].includes(String(candidate.restart)) || candidate.args !== void 0 && (!Array.isArray(candidate.args) || !candidate.args.every((argument) => typeof argument === "string")) || candidate.environment !== void 0 && !isStringRecord(candidate.environment)) {
594
+ throw new Error("Invalid systemd service resource");
595
+ }
596
+ return;
597
+ default:
598
+ throw new Error(`Unknown resource kind: ${String(candidate.kind)}`);
599
+ }
600
+ }
601
+ function isConfigValue(value) {
602
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
603
+ return true;
604
+ }
605
+ if (Array.isArray(value)) return value.every(isConfigValue);
606
+ return typeof value === "object" && value !== null && Object.values(value).every(isConfigValue);
607
+ }
608
+ function isStringRecord(value) {
609
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string");
610
+ }
611
+ function isCommandSpec(value) {
612
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
613
+ const candidate = value;
614
+ return typeof candidate.command === "string" && candidate.command.length > 0 && (candidate.args === void 0 || Array.isArray(candidate.args) && candidate.args.every((item) => typeof item === "string")) && (candidate.cwd === void 0 || typeof candidate.cwd === "string") && (candidate.environment === void 0 || isStringRecord(candidate.environment));
615
+ }
616
+ function isBrewCaskUpgradeOptions(value) {
617
+ if (value === void 0) return true;
618
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
619
+ const options = value;
620
+ return Object.keys(options).every((key) => key === "greedy" || key === "force") && (options.greedy === void 0 || typeof options.greedy === "boolean") && (options.force === void 0 || typeof options.force === "boolean");
621
+ }
622
+
623
+ // src/config/load.ts
624
+ import { createJiti } from "jiti/static";
625
+ var DEFAULT_CONFIG_FILE = "workstation.config.ts";
626
+ async function findConfig(explicit) {
627
+ if (explicit) return resolve3(explicit);
628
+ const path = resolve3(DEFAULT_CONFIG_FILE);
629
+ try {
630
+ await access(path);
631
+ return path;
632
+ } catch {
633
+ throw new Error(`No configuration found (${DEFAULT_CONFIG_FILE})`);
634
+ }
635
+ }
636
+ async function loadConfig(configPath, machineOverride) {
637
+ const nativePlatform = readPlatform();
638
+ if (nativePlatform !== "darwin" && nativePlatform !== "linux") {
639
+ throw new Error(`Unsupported platform: ${nativePlatform}`);
640
+ }
641
+ const context = {
642
+ machine: machineOverride ?? readHostname().split(".")[0] ?? readHostname(),
643
+ hostname: readHostname(),
644
+ platform: nativePlatform,
645
+ home: homedir(),
646
+ configDir: dirname(configPath)
647
+ };
648
+ const jiti = createJiti(configPath);
649
+ const imported = await jiti.import(configPath, { default: true });
650
+ const definitions = collectDefinitions(imported, context);
651
+ if (definitions.length === 0) {
652
+ throw new Error(`${configPath} did not produce any configuration`);
653
+ }
654
+ const definition = mergeDefinitions(definitions);
655
+ const inputs = definitions.flatMap((fragment) => [
656
+ fragment.resources,
657
+ fragment.machines?.[context.machine]
658
+ ]);
659
+ const resolved = await Promise.all(
660
+ flatten2(inputs).map((resource) => resolveResource(resource, definition, context))
661
+ );
662
+ const resources = [...new Map(resolved.map((resource) => [resourceId(resource), resource])).values()];
663
+ return {
664
+ context,
665
+ ...resolveTasks(definition, context),
666
+ resources,
667
+ stateFile: expandPath(
668
+ definition.stateFile ?? "~/.local/state/workstation/state.json",
669
+ context,
670
+ false
671
+ )
672
+ };
673
+ }
674
+ async function resolveResource(resource, definition, context) {
675
+ if (resource.kind === "package") {
676
+ const manager = resolveManager(resource.manager, definition, context);
677
+ if (resource.upgrade !== void 0 && manager !== "brew-cask") {
678
+ throw new Error(`Upgrade options are only supported for Homebrew casks (${resource.name})`);
679
+ }
680
+ if (manager === "mise") {
681
+ return { ...resource, manager, version: resource.version ?? "latest" };
682
+ }
683
+ if (resource.version !== void 0) {
684
+ throw new Error(`${manager} package ${resource.name} cannot declare a version`);
685
+ }
686
+ return { ...resource, manager };
687
+ }
688
+ if (resource.kind === "symlink") {
689
+ return {
690
+ ...resource,
691
+ source: expandPath(resource.source, context, true),
692
+ target: expandPath(resource.target, context, false)
693
+ };
694
+ }
695
+ if (resource.kind === "generated-file") {
696
+ return { ...resource, target: expandPath(resource.target, context, false) };
697
+ }
698
+ if (resource.kind === "systemd-service") {
699
+ if (context.platform !== "linux") {
700
+ throw new Error(`systemd service ${resource.name} is only supported on Linux`);
701
+ }
702
+ return {
703
+ ...resource,
704
+ name: resource.name.endsWith(".service") ? resource.name : `${resource.name}.service`,
705
+ program: expandPath(resource.program, context, true)
706
+ };
707
+ }
708
+ if (resource.kind === "custom-tool") {
709
+ const source = expandPath(resource.source, context, true);
710
+ const target = expandPath(resource.target, context, false);
711
+ return {
712
+ ...resource,
713
+ source,
714
+ target,
715
+ sourceHash: await hashSource(source),
716
+ build: {
717
+ ...resource.build,
718
+ ...resource.build.cwd && !resource.build.cwd.includes("{") ? { cwd: expandPath(resource.build.cwd, context, true) } : {}
719
+ }
720
+ };
721
+ }
722
+ if (context.platform !== "darwin") {
723
+ throw new Error(`LaunchAgent ${resource.label} is only supported on macOS`);
724
+ }
725
+ return {
726
+ ...resource,
727
+ program: expandPath(resource.program, context, true),
728
+ ...resource.stdoutPath ? { stdoutPath: expandPath(resource.stdoutPath, context, false) } : {},
729
+ ...resource.stderrPath ? { stderrPath: expandPath(resource.stderrPath, context, false) } : {}
730
+ };
731
+ }
732
+ function resolveManager(manager, definition, context) {
733
+ if (manager !== "system") return manager;
734
+ return definition.managers?.[context.platform] ?? (context.platform === "darwin" ? "brew" : "apt");
735
+ }
736
+ function expandPath(path, context, relativeToConfig) {
737
+ const expanded = path === "~" ? context.home : path.replace(/^~\//, `${context.home}/`);
738
+ if (isAbsolute2(expanded)) return resolve3(expanded);
739
+ return resolve3(relativeToConfig ? context.configDir : context.home, expanded);
740
+ }
741
+ function flatten2(input) {
742
+ if (!input) return [];
743
+ if (Array.isArray(input)) return input.flatMap((item) => flatten2(item));
744
+ validateResource(input);
745
+ return [input];
746
+ }
747
+ function validateDefinition(value) {
748
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
749
+ throw new Error("Configuration must resolve to an object");
750
+ }
751
+ }
752
+ function collectDefinitions(value, context) {
753
+ if (value === null || value === void 0 || value === false) return [];
754
+ if (typeof value === "function") {
755
+ return collectDefinitions(value(context), context);
756
+ }
757
+ if (Array.isArray(value)) {
758
+ return value.flatMap((fragment) => collectDefinitions(fragment, context));
759
+ }
760
+ validateDefinition(value);
761
+ return [value];
762
+ }
763
+ function mergeDefinitions(definitions) {
764
+ return definitions.reduce(
765
+ (merged, fragment) => ({
766
+ managers: { ...merged.managers, ...fragment.managers },
767
+ tasks: { ...merged.tasks, ...fragment.tasks },
768
+ aliases: { ...merged.aliases, ...fragment.aliases },
769
+ ...fragment.stateFile !== void 0 ? { stateFile: fragment.stateFile } : merged.stateFile !== void 0 ? { stateFile: merged.stateFile } : {}
770
+ }),
771
+ {}
772
+ );
773
+ }
774
+
775
+ // src/persistence/manifest.ts
776
+ import { mkdir, readFile as readFile2, rename, writeFile } from "fs/promises";
777
+ import { dirname as dirname2, resolve as resolve4 } from "path";
778
+ import { parse, stringify } from "smol-toml";
779
+ var MANIFEST_VERSION = 1;
780
+ function manifestPath(config) {
781
+ return resolve4(dirname2(config.stateFile), "config.toml");
782
+ }
783
+ async function writeManifest(path, config) {
784
+ const document = {
785
+ version: MANIFEST_VERSION,
786
+ state_file: config.stateFile,
787
+ context: {
788
+ machine: config.context.machine,
789
+ hostname: config.context.hostname,
790
+ platform: config.context.platform,
791
+ home: config.context.home,
792
+ config_dir: config.context.configDir
793
+ },
794
+ resources: config.resources.map(toTomlResource)
795
+ };
796
+ await mkdir(dirname2(path), { recursive: true, mode: 448 });
797
+ const temporary = `${path}.${process.pid}.tmp`;
798
+ await writeFile(temporary, stringify(document), { mode: 384 });
799
+ await rename(temporary, path);
800
+ }
801
+ async function readManifest(path) {
802
+ const document = parse(await readFile2(path, "utf8"));
803
+ if (document.version !== MANIFEST_VERSION) {
804
+ throw new Error(`Unsupported workstation manifest version in ${path}`);
805
+ }
806
+ const stateFile = requireString(document.state_file, "state_file");
807
+ const context = parseContext(requireTable(document.context, "context"));
808
+ if (!Array.isArray(document.resources)) throw new Error("Manifest resources must be an array");
809
+ const resources = document.resources.map(
810
+ (resource, index) => parseResource(requireTable(resource, `resources[${index}]`))
811
+ );
812
+ return { context, resources, stateFile };
813
+ }
814
+ function toTomlResource(resource) {
815
+ switch (resource.kind) {
816
+ case "package":
817
+ return {
818
+ kind: resource.kind,
819
+ manager: resource.manager,
820
+ name: resource.name,
821
+ ...resource.version ? { version: resource.version } : {},
822
+ ...resource.lockedVersion ? { locked_version: resource.lockedVersion } : {},
823
+ ...resource.upgrade ? {
824
+ upgrade: {
825
+ ...resource.upgrade.greedy !== void 0 ? { greedy: resource.upgrade.greedy } : {},
826
+ ...resource.upgrade.force !== void 0 ? { force: resource.upgrade.force } : {}
827
+ }
828
+ } : {}
829
+ };
830
+ case "symlink":
831
+ return { kind: resource.kind, source: resource.source, target: resource.target };
832
+ case "launch-agent":
833
+ return {
834
+ kind: resource.kind,
835
+ label: resource.label,
836
+ program: resource.program,
837
+ ...resource.args ? { args: [...resource.args] } : {},
838
+ ...resource.environment ? { environment: { ...resource.environment } } : {},
839
+ ...resource.runAtLoad !== void 0 ? { run_at_load: resource.runAtLoad } : {},
840
+ ...resource.keepAlive !== void 0 ? { keep_alive: resource.keepAlive } : {},
841
+ ...resource.stdoutPath ? { stdout_path: resource.stdoutPath } : {},
842
+ ...resource.stderrPath ? { stderr_path: resource.stderrPath } : {}
843
+ };
844
+ case "generated-file":
845
+ return {
846
+ kind: resource.kind,
847
+ target: resource.target,
848
+ format: resource.format,
849
+ if_exists: resource.ifExists,
850
+ value_json: JSON.stringify(resource.value),
851
+ ...resource.renderedContent !== void 0 ? { rendered_content: resource.renderedContent } : {},
852
+ ...resource.mode !== void 0 ? { mode: resource.mode } : {}
853
+ };
854
+ case "systemd-service":
855
+ return {
856
+ kind: resource.kind,
857
+ name: resource.name,
858
+ scope: resource.scope,
859
+ program: resource.program,
860
+ ...resource.description ? { description: resource.description } : {},
861
+ ...resource.args ? { args: [...resource.args] } : {},
862
+ ...resource.environment ? { environment: { ...resource.environment } } : {},
863
+ ...resource.restart ? { restart: resource.restart } : {},
864
+ ...resource.wantedBy ? { wanted_by: resource.wantedBy } : {}
865
+ };
866
+ case "custom-tool":
867
+ return {
868
+ kind: resource.kind,
869
+ name: resource.name,
870
+ source: resource.source,
871
+ source_hash: resource.sourceHash ?? "",
872
+ target: resource.target,
873
+ build: {
874
+ command: resource.build.command,
875
+ ...resource.build.args ? { args: [...resource.build.args] } : {},
876
+ ...resource.build.cwd ? { cwd: resource.build.cwd } : {},
877
+ ...resource.build.environment ? { environment: { ...resource.build.environment } } : {}
878
+ }
879
+ };
880
+ }
881
+ }
882
+ function parseContext(value) {
883
+ const platform = requireString(value.platform, "context.platform");
884
+ if (platform !== "darwin" && platform !== "linux") {
885
+ throw new Error(`Invalid manifest platform: ${platform}`);
886
+ }
887
+ return {
888
+ machine: requireString(value.machine, "context.machine"),
889
+ hostname: requireString(value.hostname, "context.hostname"),
890
+ platform,
891
+ home: requireString(value.home, "context.home"),
892
+ configDir: requireString(value.config_dir, "context.config_dir")
893
+ };
894
+ }
895
+ function parseResource(value) {
896
+ const kind = requireString(value.kind, "resource.kind");
897
+ if (kind === "package") return parsePackage(value);
898
+ if (kind === "symlink") return parseSymlink(value);
899
+ if (kind === "launch-agent") return parseLaunchAgent(value);
900
+ if (kind === "generated-file") return parseGeneratedFile(value);
901
+ if (kind === "systemd-service") return parseSystemdService(value);
902
+ if (kind === "custom-tool") return parseCustomTool(value);
903
+ throw new Error(`Unknown manifest resource kind: ${kind}`);
904
+ }
905
+ function parseGeneratedFile(value) {
906
+ const format = requireString(value.format, "generated-file.format");
907
+ if (value.rendered_content !== void 0 && format !== "jsonc") {
908
+ throw new Error("Pre-rendered content requires JSONC format");
909
+ }
910
+ const ifExists = requireString(value.if_exists, "generated-file.if_exists");
911
+ if (format !== "toml" && format !== "yaml" && format !== "json" && format !== "jsonc" && format !== "zsh" && format !== "bash") {
912
+ throw new Error(`Invalid generated file format: ${format}`);
913
+ }
914
+ if (ifExists !== "update" && ifExists !== "overwrite" && ifExists !== "ignore") {
915
+ throw new Error(`Invalid generated file policy: ${ifExists}`);
916
+ }
917
+ const configValue = JSON.parse(requireString(value.value_json, "generated-file.value_json"));
918
+ if (!isConfigValue2(configValue)) throw new Error("Invalid generated file value");
919
+ const mode = value.mode;
920
+ if (mode !== void 0 && (typeof mode !== "number" || !Number.isInteger(mode) || mode < 0 || mode > 511)) {
921
+ throw new Error("generated-file.mode must be between 0 and 0777");
922
+ }
923
+ return {
924
+ kind: "generated-file",
925
+ target: requireString(value.target, "generated-file.target"),
926
+ format,
927
+ ifExists,
928
+ value: configValue,
929
+ ...value.rendered_content !== void 0 ? { renderedContent: requireString(value.rendered_content, "generated-file.rendered_content") } : {},
930
+ ...mode !== void 0 ? { mode } : {}
931
+ };
932
+ }
933
+ function parseCustomTool(value) {
934
+ const build = requireTable(value.build, "custom-tool.build");
935
+ return {
936
+ kind: "custom-tool",
937
+ name: requireString(value.name, "custom-tool.name"),
938
+ source: requireString(value.source, "custom-tool.source"),
939
+ sourceHash: requireString(value.source_hash, "custom-tool.source_hash"),
940
+ target: requireString(value.target, "custom-tool.target"),
941
+ build: {
942
+ command: requireString(build.command, "custom-tool.build.command"),
943
+ ...build.args !== void 0 ? { args: requireStringArray(build.args, "custom-tool.build.args") } : {},
944
+ ...build.cwd !== void 0 ? { cwd: requireString(build.cwd, "custom-tool.build.cwd") } : {},
945
+ ...build.environment !== void 0 ? { environment: requireStringTable(build.environment, "custom-tool.build.environment") } : {}
946
+ }
947
+ };
948
+ }
949
+ function parseSystemdService(value) {
950
+ const scope = requireString(value.scope, "systemd-service.scope");
951
+ if (scope !== "user" && scope !== "system") throw new Error(`Invalid systemd scope: ${scope}`);
952
+ const restart = value.restart === void 0 ? void 0 : requireString(value.restart, "systemd-service.restart");
953
+ if (restart !== void 0 && restart !== "no" && restart !== "on-failure" && restart !== "always") {
954
+ throw new Error(`Invalid systemd restart policy: ${restart}`);
955
+ }
956
+ return {
957
+ kind: "systemd-service",
958
+ name: requireString(value.name, "systemd-service.name"),
959
+ scope,
960
+ program: requireString(value.program, "systemd-service.program"),
961
+ ...value.description !== void 0 ? { description: requireString(value.description, "systemd-service.description") } : {},
962
+ ...value.args !== void 0 ? { args: requireStringArray(value.args, "systemd-service.args") } : {},
963
+ ...value.environment !== void 0 ? { environment: requireStringTable(value.environment, "systemd-service.environment") } : {},
964
+ ...restart ? { restart } : {},
965
+ ...value.wanted_by !== void 0 ? { wantedBy: requireString(value.wanted_by, "systemd-service.wanted_by") } : {}
966
+ };
967
+ }
968
+ function parsePackage(value) {
969
+ const manager = requireString(value.manager, "package.manager");
970
+ if (!isPackageManager(manager)) {
971
+ throw new Error(`Invalid manifest package manager: ${manager}`);
972
+ }
973
+ const upgrade = value.upgrade === void 0 ? void 0 : requireTable(value.upgrade, "package.upgrade");
974
+ if (value.version !== void 0 && manager !== "mise") {
975
+ throw new Error(`Manifest ${manager} package cannot declare a version`);
976
+ }
977
+ if (upgrade !== void 0 && manager !== "brew-cask") {
978
+ throw new Error(`Manifest upgrade options require a Homebrew cask`);
979
+ }
980
+ return {
981
+ kind: "package",
982
+ manager,
983
+ name: requireString(value.name, "package.name"),
984
+ ...value.version !== void 0 ? { version: requireString(value.version, "package.version") } : {},
985
+ ...value.locked_version !== void 0 ? { lockedVersion: requireString(value.locked_version, "package.locked_version") } : {},
986
+ ...upgrade ? {
987
+ upgrade: {
988
+ ...upgrade.greedy !== void 0 ? { greedy: requireBoolean(upgrade.greedy, "package.upgrade.greedy") } : {},
989
+ ...upgrade.force !== void 0 ? { force: requireBoolean(upgrade.force, "package.upgrade.force") } : {}
990
+ }
991
+ } : {}
992
+ };
993
+ }
994
+ function parseSymlink(value) {
995
+ return {
996
+ kind: "symlink",
997
+ source: requireString(value.source, "symlink.source"),
998
+ target: requireString(value.target, "symlink.target")
999
+ };
1000
+ }
1001
+ function parseLaunchAgent(value) {
1002
+ const args = value.args === void 0 ? void 0 : requireStringArray(value.args, "launch-agent.args");
1003
+ const environment = value.environment === void 0 ? void 0 : requireStringTable(value.environment, "launch-agent.environment");
1004
+ return {
1005
+ kind: "launch-agent",
1006
+ label: requireString(value.label, "launch-agent.label"),
1007
+ program: requireString(value.program, "launch-agent.program"),
1008
+ ...args ? { args } : {},
1009
+ ...environment ? { environment } : {},
1010
+ ...value.run_at_load !== void 0 ? { runAtLoad: requireBoolean(value.run_at_load, "launch-agent.run_at_load") } : {},
1011
+ ...value.keep_alive !== void 0 ? { keepAlive: requireBoolean(value.keep_alive, "launch-agent.keep_alive") } : {},
1012
+ ...value.stdout_path !== void 0 ? { stdoutPath: requireString(value.stdout_path, "launch-agent.stdout_path") } : {},
1013
+ ...value.stderr_path !== void 0 ? { stderrPath: requireString(value.stderr_path, "launch-agent.stderr_path") } : {}
1014
+ };
1015
+ }
1016
+ function requireTable(value, field) {
1017
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1018
+ throw new Error(`${field} must be a table`);
1019
+ }
1020
+ return value;
1021
+ }
1022
+ function requireString(value, field) {
1023
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${field} must be a string`);
1024
+ return value;
1025
+ }
1026
+ function requireBoolean(value, field) {
1027
+ if (typeof value !== "boolean") throw new Error(`${field} must be a boolean`);
1028
+ return value;
1029
+ }
1030
+ function requireStringArray(value, field) {
1031
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
1032
+ throw new Error(`${field} must be an array of strings`);
1033
+ }
1034
+ return value;
1035
+ }
1036
+ function requireStringTable(value, field) {
1037
+ const table = requireTable(value, field);
1038
+ const result = {};
1039
+ for (const [key, item] of Object.entries(table)) {
1040
+ if (typeof item !== "string") throw new Error(`${field} must contain strings`);
1041
+ result[key] = item;
1042
+ }
1043
+ return result;
1044
+ }
1045
+ function isPackageManager(value) {
1046
+ return value === "mise" || value === "brew" || value === "brew-cask" || value === "apt";
1047
+ }
1048
+ function isConfigValue2(value) {
1049
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
1050
+ return true;
1051
+ }
1052
+ if (Array.isArray(value)) return value.every(isConfigValue2);
1053
+ return typeof value === "object" && value !== null && Object.values(value).every(isConfigValue2);
1054
+ }
1055
+
1056
+ // src/persistence/lock.ts
1057
+ import { readFile as readFile3, rename as rename3, writeFile as writeFile3 } from "fs/promises";
1058
+ import { dirname as dirname3, resolve as resolve5 } from "path";
1059
+ import { parse as parse2, stringify as stringify2 } from "smol-toml";
1060
+
1061
+ // src/resources/shared.ts
1062
+ import { chmod, rename as rename2, writeFile as writeFile2 } from "fs/promises";
1063
+ async function requireSuccess(runner, command, args, options) {
1064
+ const result = await runner.run(command, args, options);
1065
+ if (result.exitCode !== 0) {
1066
+ throw new Error(
1067
+ `${command} ${args.join(" ")} failed (${result.exitCode})${result.stderr ? `: ${result.stderr.trim()}` : ""}`
1068
+ );
1069
+ }
1070
+ return result;
1071
+ }
1072
+
1073
+ // src/resources/brew-info.ts
1074
+ async function readAvailableBrewVersion(resource, runner) {
1075
+ const item = brewItem(await readBrewInfo(resource, runner), resource);
1076
+ const baseVersion = resource.manager === "brew-cask" ? item.version : typeof item.versions === "object" && item.versions !== null && "stable" in item.versions ? item.versions.stable : void 0;
1077
+ const version = resource.manager === "brew" && typeof baseVersion === "string" && typeof item.revision === "number" && item.revision > 0 ? `${baseVersion}_${item.revision}` : baseVersion;
1078
+ if (typeof version !== "string" || version.length === 0) {
1079
+ throw new Error(`Homebrew did not report an available version for ${resource.name}`);
1080
+ }
1081
+ return version;
1082
+ }
1083
+ async function readBrewInfo(resource, runner) {
1084
+ const flag = resource.manager === "brew-cask" ? "--cask" : "--formula";
1085
+ const result = await requireSuccess(runner, "brew", ["info", "--json=v2", flag, resource.name]);
1086
+ try {
1087
+ const value = JSON.parse(result.stdout);
1088
+ if (!isRecord(value)) throw new Error("Expected a JSON object");
1089
+ return value;
1090
+ } catch (error) {
1091
+ throw new Error(`Homebrew returned invalid JSON for ${resource.name}`, { cause: error });
1092
+ }
1093
+ }
1094
+ function brewItem(document, resource) {
1095
+ const collection = document[resource.manager === "brew-cask" ? "casks" : "formulae"];
1096
+ const value = Array.isArray(collection) ? collection[0] : void 0;
1097
+ if (!isRecord(value)) {
1098
+ throw new Error(`Homebrew did not report information for ${resource.name}`);
1099
+ }
1100
+ return value;
1101
+ }
1102
+ function isRecord(value) {
1103
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1104
+ }
1105
+
1106
+ // src/resources/package-version.ts
1107
+ async function resolvePackageVersion(resource, runner) {
1108
+ if (resource.kind !== "package") return void 0;
1109
+ switch (resource.manager) {
1110
+ case "mise": {
1111
+ const spec = `${resource.name}@${resource.version ?? "latest"}`;
1112
+ const result = await requireSuccess(runner, "mise", ["latest", spec]);
1113
+ const version = result.stdout.trim().split(/\s+/).at(-1);
1114
+ if (!version) throw new Error(`mise latest ${spec} did not report a version`);
1115
+ return version;
1116
+ }
1117
+ case "apt": {
1118
+ const result = await requireSuccess(runner, "apt-cache", ["policy", resource.name]);
1119
+ const candidate = /^\s*Candidate:\s*(\S+)\s*$/m.exec(result.stdout)?.[1];
1120
+ if (!candidate || candidate === "(none)") {
1121
+ throw new Error(`apt has no installation candidate for ${resource.name}`);
1122
+ }
1123
+ return candidate;
1124
+ }
1125
+ case "brew":
1126
+ case "brew-cask": {
1127
+ const version = await readAvailableBrewVersion(resource, runner);
1128
+ return version === "latest" ? void 0 : version;
1129
+ }
1130
+ case "system":
1131
+ throw new Error("System package manager must be resolved before locking");
1132
+ }
1133
+ }
1134
+
1135
+ // src/persistence/lock.ts
1136
+ var LOCK_VERSION = 1;
1137
+ function lockPath(configPath) {
1138
+ return resolve5(dirname3(configPath), "workstation.lock");
1139
+ }
1140
+ async function lockConfig(configPath, config, runner) {
1141
+ const path = lockPath(configPath);
1142
+ const previousText = await readOptional(path);
1143
+ const previous = previousText === void 0 ? emptyLock() : parseLock(previousText, path);
1144
+ const target = previous.targets.find(({ machine: machine2 }) => machine2 === config.context.machine);
1145
+ const existing = new Map(target?.resources.map((entry) => [entry.id, entry]));
1146
+ const resources = [];
1147
+ const entries = [];
1148
+ for (const resource of config.resources) {
1149
+ const id = resourceId(resource);
1150
+ const declarationFingerprint = fingerprint(resource);
1151
+ const prior = existing.get(id);
1152
+ const refreshesOnRun = resource.kind === "package" && resource.manager === "brew-cask" && resource.upgrade?.greedy === true;
1153
+ const canReuse = !refreshesOnRun && prior?.fingerprint === declarationFingerprint && (resource.kind !== "package" || prior.lockedVersion !== void 0);
1154
+ const lockedVersion = canReuse ? prior.lockedVersion : await resolvePackageVersion(resource, runner);
1155
+ resources.push(withLockedVersion(resource, lockedVersion));
1156
+ entries.push({
1157
+ id,
1158
+ fingerprint: declarationFingerprint,
1159
+ ...lockedVersion ? { lockedVersion } : {}
1160
+ });
1161
+ }
1162
+ const nextTarget = {
1163
+ machine: config.context.machine,
1164
+ platform: config.context.platform,
1165
+ resources: entries.sort((left, right) => left.id.localeCompare(right.id))
1166
+ };
1167
+ const next = {
1168
+ version: LOCK_VERSION,
1169
+ targets: [
1170
+ ...previous.targets.filter(({ machine: machine2 }) => machine2 !== config.context.machine),
1171
+ nextTarget
1172
+ ].sort((left, right) => left.machine.localeCompare(right.machine))
1173
+ };
1174
+ const nextText = stringifyLock(next);
1175
+ const changed = previousText !== nextText;
1176
+ if (changed) await atomicWrite(path, nextText);
1177
+ return { config: { ...config, resources }, path, changed };
1178
+ }
1179
+ function withLockedVersion(resource, lockedVersion) {
1180
+ if (resource.kind !== "package" || lockedVersion === void 0) return resource;
1181
+ return { ...resource, lockedVersion };
1182
+ }
1183
+ function emptyLock() {
1184
+ return { version: LOCK_VERSION, targets: [] };
1185
+ }
1186
+ function stringifyLock(lock) {
1187
+ return stringify2({
1188
+ version: lock.version,
1189
+ targets: lock.targets.map((target) => ({
1190
+ machine: target.machine,
1191
+ platform: target.platform,
1192
+ resources: target.resources.map((entry) => ({
1193
+ id: entry.id,
1194
+ fingerprint: entry.fingerprint,
1195
+ ...entry.lockedVersion ? { locked_version: entry.lockedVersion } : {}
1196
+ }))
1197
+ }))
1198
+ });
1199
+ }
1200
+ function parseLock(text, path) {
1201
+ const document = parse2(text);
1202
+ if (document.version !== LOCK_VERSION) {
1203
+ throw new Error(`Unsupported workstation lock version in ${path}`);
1204
+ }
1205
+ if (!Array.isArray(document.targets)) throw new Error(`Lock targets must be an array in ${path}`);
1206
+ const machines = /* @__PURE__ */ new Set();
1207
+ const targets = document.targets.map((value, targetIndex) => {
1208
+ const target = requireTable2(value, `targets[${targetIndex}]`);
1209
+ const machine2 = requireString2(target.machine, `targets[${targetIndex}].machine`);
1210
+ if (machines.has(machine2)) throw new Error(`Duplicate machine ${machine2} in ${path}`);
1211
+ machines.add(machine2);
1212
+ const platform = requireString2(target.platform, `targets[${targetIndex}].platform`);
1213
+ if (platform !== "darwin" && platform !== "linux") {
1214
+ throw new Error(`Invalid platform for ${machine2} in ${path}`);
1215
+ }
1216
+ if (!Array.isArray(target.resources)) {
1217
+ throw new Error(`Lock resources for ${machine2} must be an array in ${path}`);
1218
+ }
1219
+ const ids = /* @__PURE__ */ new Set();
1220
+ const resources = target.resources.map((value2, resourceIndex) => {
1221
+ const entry = requireTable2(value2, `targets[${targetIndex}].resources[${resourceIndex}]`);
1222
+ const id = requireString2(entry.id, "lock resource id");
1223
+ if (ids.has(id)) throw new Error(`Duplicate resource ${id} for ${machine2} in ${path}`);
1224
+ ids.add(id);
1225
+ return {
1226
+ id,
1227
+ fingerprint: requireString2(entry.fingerprint, `lock fingerprint for ${id}`),
1228
+ ...entry.locked_version === void 0 ? {} : { lockedVersion: requireString2(entry.locked_version, `locked version for ${id}`) }
1229
+ };
1230
+ });
1231
+ return { machine: machine2, platform, resources };
1232
+ });
1233
+ return { version: LOCK_VERSION, targets };
1234
+ }
1235
+ function requireTable2(value, field) {
1236
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1237
+ throw new Error(`${field} must be a table`);
1238
+ }
1239
+ return value;
1240
+ }
1241
+ function requireString2(value, field) {
1242
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${field} must be a string`);
1243
+ return value;
1244
+ }
1245
+ async function readOptional(path) {
1246
+ try {
1247
+ return await readFile3(path, "utf8");
1248
+ } catch (error) {
1249
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
1250
+ return void 0;
1251
+ }
1252
+ throw error;
1253
+ }
1254
+ }
1255
+ async function atomicWrite(path, contents) {
1256
+ const temporary = `${path}.${process.pid}.tmp`;
1257
+ await writeFile3(temporary, contents, { mode: 420 });
1258
+ await rename3(temporary, path);
1259
+ }
1260
+
1261
+ // src/resources/runner.ts
1262
+ import { spawn } from "child_process";
1263
+ var ProcessRunner = class {
1264
+ /** Execute a command directly and return captured output and its exit code; spawn failures reject. */
1265
+ async run(command, args, options) {
1266
+ return await new Promise((resolve6, reject) => {
1267
+ const child = spawn(command, [...args], {
1268
+ cwd: options?.cwd,
1269
+ env: { ...process.env, ...options?.environment },
1270
+ stdio: ["inherit", "pipe", "pipe"]
1271
+ });
1272
+ let stdout = "";
1273
+ let stderr = "";
1274
+ child.stdout.setEncoding("utf8");
1275
+ child.stderr.setEncoding("utf8");
1276
+ child.stdout.on("data", (chunk) => stdout += chunk);
1277
+ child.stderr.on("data", (chunk) => stderr += chunk);
1278
+ child.once("error", reject);
1279
+ child.once(
1280
+ "close",
1281
+ (exitCode) => resolve6({ exitCode: exitCode ?? 1, stdout, stderr })
1282
+ );
1283
+ });
1284
+ }
1285
+ };
1286
+ export {
1287
+ JsoncDocument,
1288
+ ProcessRunner,
1289
+ bash,
1290
+ configure,
1291
+ customTool,
1292
+ darwin,
1293
+ defineConfig,
1294
+ files,
1295
+ findConfig,
1296
+ fingerprint,
1297
+ jsonc,
1298
+ launchAgent,
1299
+ linux,
1300
+ loadConfig,
1301
+ lockConfig,
1302
+ lockPath,
1303
+ machine,
1304
+ manifestPath,
1305
+ readManifest,
1306
+ renderShell,
1307
+ resourceId,
1308
+ runTask,
1309
+ shell,
1310
+ symlink,
1311
+ systemdService,
1312
+ task,
1313
+ tools,
1314
+ when,
1315
+ writeManifest,
1316
+ zsh
1317
+ };
1318
+ //# sourceMappingURL=index.js.map