@dovocode/workstation 0.1.0 → 0.1.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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  type Platform = "darwin" | "linux";
2
- type PackageManager = "mise" | "brew" | "brew-cask" | "apt" | "system";
2
+ type PackageManager = "mise" | "brew" | "brew-cask" | "apt" | "dnf" | "yum" | "pacman" | "flatpak" | "mas" | "system";
3
+ /** Flatpak installation target; the selected remote must already be configured. */
4
+ interface FlatpakOptions {
5
+ readonly scope?: "user" | "system";
6
+ readonly remote?: string;
7
+ readonly branch?: string;
8
+ }
3
9
  /** Homebrew cask upgrade behavior. */
4
10
  interface BrewCaskUpgradeOptions {
5
11
  /** Include auto-updating casks and refresh their lock pins on each run. */
@@ -26,6 +32,7 @@ interface PackageResource {
26
32
  readonly name: string;
27
33
  readonly version?: string;
28
34
  readonly upgrade?: BrewCaskUpgradeOptions;
35
+ readonly flatpak?: FlatpakOptions;
29
36
  }
30
37
  interface ResolvedPackageResource extends PackageResource {
31
38
  /** Concrete version resolved into workstation.lock. */
@@ -163,10 +170,14 @@ interface CommandResult {
163
170
  }
164
171
  /** Command execution boundary, replaceable in tests or embedded integrations. */
165
172
  interface Runner {
173
+ /** Report a resource-level progress message when supported by the host. */
174
+ report?(message: string): void;
166
175
  /** Execute a command directly and return captured output and its exit code; spawn failures reject. */
167
176
  run(command: string, args: readonly string[], options?: RunOptions): Promise<CommandResult>;
168
177
  }
169
178
  interface RunOptions {
179
+ /** Stream captured output when the process runner has progress logging enabled. */
180
+ readonly streamOutput?: boolean;
170
181
  readonly cwd?: string;
171
182
  readonly environment?: Readonly<Record<string, string>>;
172
183
  }
@@ -239,7 +250,17 @@ declare const tools: {
239
250
  brewCask: (packages: readonly string[], upgrade?: BrewCaskUpgradeOptions) => PackageResource[];
240
251
  /** Install Debian/Ubuntu packages through APT. Mutations request sudo. */
241
252
  apt: (packages: readonly string[]) => PackageResource[];
242
- /** Use the configured platform manager; defaults to Homebrew on macOS and APT on Linux. */
253
+ /** Install RPM packages through DNF. Mutations request sudo. */
254
+ dnf: (packages: readonly string[]) => PackageResource[];
255
+ /** Install RPM packages through legacy YUM. Mutations request sudo. */
256
+ yum: (packages: readonly string[]) => PackageResource[];
257
+ /** Install Arch Linux repository packages through pacman; does not refresh databases or upgrade the whole system. */
258
+ pacman: (packages: readonly string[]) => PackageResource[];
259
+ /** Install Flatpak app IDs; defaults to the user's flathub remote and stable branch. */
260
+ flatpak: (packages: readonly string[], options?: FlatpakOptions) => PackageResource[];
261
+ /** Install previously acquired Mac App Store apps by numeric ID; follows available updates without version pins. */
262
+ mas: (ids: readonly (string | number)[]) => PackageResource[];
263
+ /** Use the configured manager, Homebrew on macOS, or detect APT/DNF/YUM/pacman on Linux. */
243
264
  system: (packages: readonly string[]) => PackageResource[];
244
265
  };
245
266
  /**
@@ -518,8 +539,16 @@ declare function lockConfig(configPath: string, config: ResolvedConfig, runner:
518
539
 
519
540
  /** Run child processes directly, inheriting stdin and capturing stdout/stderr. No implicit shell is used. */
520
541
  declare class ProcessRunner implements Runner {
542
+ private readonly logging;
543
+ /** Opt into command progress and streaming; verbose also prints captured query output. */
544
+ constructor(logging?: {
545
+ readonly progress?: boolean;
546
+ readonly verbose?: boolean;
547
+ });
548
+ /** Print resource progress only when logging is enabled. */
549
+ report(message: string): void;
521
550
  /** Execute a command directly and return captured output and its exit code; spawn failures reject. */
522
551
  run(command: string, args: readonly string[], options?: RunOptions): Promise<CommandResult>;
523
552
  }
524
553
 
525
- export { type Action, type ActionType, type BrewCaskUpgradeOptions, type CommandResult, type CommandSpec, type ConfigDefinition, type ConfigFactory, type ConfigInput, type ConfigValue, type Context, type CustomToolResource, type GeneratedFileOptions, type GeneratedFileResource, type IfExistsPolicy, type JsoncCommand, JsoncDocument, type JsoncLine, type LaunchAgentResource, type LockedConfigResult, type OriginalFile, type PackageManager, type PackageResource, type Platform, ProcessRunner, type ResolvedConfig, type ResolvedPackageResource, type ResolvedResource, type Resource, type ResourceInput, type RunOptions, type Runner, type Shell, type ShellCommand, type ShellCondition, type ShellExpression, type ShellFileOptions, type ShellStatement, type ShellValue, type StateEntry, type StructuredFormat, type SymlinkResource, type SystemdScope, type SystemdServiceResource, type TaskDefinition, type WorkstationConfig, type WorkstationState, bash, configure, customTool, darwin, defineConfig, files, findConfig, fingerprint, jsonc, launchAgent, linux, loadConfig, lockConfig, lockPath, machine, manifestPath, readManifest, renderShell, resourceId, runTask, shell, symlink, systemdService, task, tools, when, writeManifest, zsh };
554
+ export { type Action, type ActionType, type BrewCaskUpgradeOptions, type CommandResult, type CommandSpec, type ConfigDefinition, type ConfigFactory, type ConfigInput, type ConfigValue, type Context, type CustomToolResource, type FlatpakOptions, type GeneratedFileOptions, type GeneratedFileResource, type IfExistsPolicy, type JsoncCommand, JsoncDocument, type JsoncLine, type LaunchAgentResource, type LockedConfigResult, type OriginalFile, type PackageManager, type PackageResource, type Platform, ProcessRunner, type ResolvedConfig, type ResolvedPackageResource, type ResolvedResource, type Resource, type ResourceInput, type RunOptions, type Runner, type Shell, type ShellCommand, type ShellCondition, type ShellExpression, type ShellFileOptions, type ShellStatement, type ShellValue, type StateEntry, type StructuredFormat, type SymlinkResource, type SystemdScope, type SystemdServiceResource, type TaskDefinition, type WorkstationConfig, type WorkstationState, bash, configure, customTool, darwin, defineConfig, files, findConfig, fingerprint, jsonc, launchAgent, linux, loadConfig, lockConfig, lockPath, machine, manifestPath, readManifest, renderShell, resourceId, runTask, shell, symlink, systemdService, task, tools, when, writeManifest, zsh };
package/dist/index.js CHANGED
@@ -109,11 +109,11 @@ function resolveTasks(definition, context) {
109
109
  const cwd = value.cwd?.replace(/^~(?=\/|$)/, context.home) ?? context.configDir;
110
110
  tasks[name] = { ...value, cwd: isAbsolute(cwd) ? cwd : resolve(context.configDir, cwd) };
111
111
  }
112
- for (const [name, target] of Object.entries(definition.aliases ?? {})) {
112
+ for (const [name, target2] of Object.entries(definition.aliases ?? {})) {
113
113
  validateName(name);
114
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;
115
+ if (typeof target2 !== "string") throw new Error(`Invalid alias target: ${name}`);
116
+ aliases[name] = target2;
117
117
  }
118
118
  for (const name of Object.keys(aliases)) resolveTaskName(name, tasks, aliases);
119
119
  return { tasks, aliases };
@@ -124,9 +124,9 @@ function resolveTaskName(name, tasks, aliases) {
124
124
  while (Object.hasOwn(aliases, current)) {
125
125
  if (visited.has(current)) throw new Error(`Task alias cycle: ${[...visited, current].join(" -> ")}`);
126
126
  visited.add(current);
127
- const target = aliases[current];
128
- if (target === void 0) throw new Error(`Invalid alias: ${current}`);
129
- current = target;
127
+ const target2 = aliases[current];
128
+ if (target2 === void 0) throw new Error(`Invalid alias: ${current}`);
129
+ current = target2;
130
130
  }
131
131
  if (!Object.hasOwn(tasks, current)) throw new Error(`Unknown task: ${current}`);
132
132
  return current;
@@ -179,20 +179,35 @@ var tools = {
179
179
  brewCask: (packages, upgrade) => packageList("brew-cask", packages, upgrade),
180
180
  /** Install Debian/Ubuntu packages through APT. Mutations request sudo. */
181
181
  apt: (packages) => packageList("apt", packages),
182
- /** Use the configured platform manager; defaults to Homebrew on macOS and APT on Linux. */
182
+ /** Install RPM packages through DNF. Mutations request sudo. */
183
+ dnf: (packages) => packageList("dnf", packages),
184
+ /** Install RPM packages through legacy YUM. Mutations request sudo. */
185
+ yum: (packages) => packageList("yum", packages),
186
+ /** Install Arch Linux repository packages through pacman; does not refresh databases or upgrade the whole system. */
187
+ pacman: (packages) => packageList("pacman", packages),
188
+ /** Install Flatpak app IDs; defaults to the user's flathub remote and stable branch. */
189
+ flatpak: (packages, options = {}) => packages.map((name) => ({ kind: "package", manager: "flatpak", name, flatpak: { ...options } })),
190
+ /** Install previously acquired Mac App Store apps by numeric ID; follows available updates without version pins. */
191
+ mas: (ids) => ids.map((id) => {
192
+ if (typeof id === "number" && !Number.isSafeInteger(id) || !/^[1-9]\d*$/.test(String(id))) {
193
+ throw new Error(`Invalid Mac App Store ID: ${id}`);
194
+ }
195
+ return { kind: "package", manager: "mas", name: String(id) };
196
+ }),
197
+ /** Use the configured manager, Homebrew on macOS, or detect APT/DNF/YUM/pacman on Linux. */
183
198
  system: (packages) => packageList("system", packages)
184
199
  };
185
- function symlink(source, target) {
186
- return { kind: "symlink", source, target };
200
+ function symlink(source, target2) {
201
+ return { kind: "symlink", source, target: target2 };
187
202
  }
188
203
  function launchAgent(label, options) {
189
204
  return { kind: "launch-agent", label, ...options };
190
205
  }
191
- function generatedFile(format, target, value, options = {}) {
206
+ function generatedFile(format, target2, value, options = {}) {
192
207
  return {
193
208
  kind: "generated-file",
194
209
  format,
195
- target,
210
+ target: target2,
196
211
  value,
197
212
  ifExists: options.ifExists ?? "overwrite",
198
213
  ...options.mode !== void 0 ? { mode: options.mode } : {}
@@ -203,16 +218,16 @@ var files = {
203
218
  * Generate TOML. Values must be representable in TOML (for example, no null).
204
219
  * @example files.toml("~/.config/app/config.toml", { server: { port: 3000 } })
205
220
  */
206
- toml: (target, value, options) => generatedFile("toml", target, value, options),
221
+ toml: (target2, value, options) => generatedFile("toml", target2, value, options),
207
222
  /** Generate YAML with the standard overwrite/restore policy. */
208
- yaml: (target, value, options) => generatedFile("yaml", target, value, options),
223
+ yaml: (target2, value, options) => generatedFile("yaml", target2, value, options),
209
224
  /**
210
225
  * Generate formatted JSON.
211
226
  * @example files.json("~/.config/app/config.json", { enabled: true })
212
227
  */
213
- json: (target, value, options) => generatedFile("json", target, value, options),
228
+ json: (target2, value, options) => generatedFile("json", target2, value, options),
214
229
  /** 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)
230
+ jsonc: (target2, value, options) => value instanceof JsoncDocument ? { ...generatedFile("jsonc", target2, null, options), renderedContent: value.content } : generatedFile("jsonc", target2, value, options)
216
231
  };
217
232
  function systemdService(name, options) {
218
233
  return { kind: "systemd-service", name, scope: options.scope ?? "user", ...options };
@@ -392,21 +407,21 @@ var bash = {
392
407
  /** Generate ~/.profile using Bash syntax; use only where Bash will read it. */
393
408
  profile: (statements, options) => shellFile("bash", "~/.profile", statements, options)
394
409
  };
395
- function renderShell(statements, target) {
396
- return `${statements.map((statement) => renderStatement(statement, target, 0)).join("\n")}
410
+ function renderShell(statements, target2) {
411
+ return `${statements.map((statement) => renderStatement(statement, target2, 0)).join("\n")}
397
412
  `;
398
413
  }
399
- function shellFile(format, target, statements, options = {}) {
414
+ function shellFile(format, target2, statements, options = {}) {
400
415
  return {
401
416
  kind: "generated-file",
402
- target,
417
+ target: target2,
403
418
  format,
404
419
  value: renderShell(statements, format),
405
420
  ifExists: options.ifExists ?? "overwrite",
406
421
  mode: options.mode ?? 420
407
422
  };
408
423
  }
409
- function renderStatement(statement, target, depth) {
424
+ function renderStatement(statement, target2, depth) {
410
425
  const indent = " ".repeat(depth);
411
426
  switch (statement.kind) {
412
427
  case "export":
@@ -428,11 +443,11 @@ function renderStatement(statement, target, depth) {
428
443
  case "if":
429
444
  return [
430
445
  `${indent}if ${renderCondition(statement.condition)}; then`,
431
- ...statement.statements.map((child) => renderStatement(child, target, depth + 1)),
446
+ ...statement.statements.map((child) => renderStatement(child, target2, depth + 1)),
432
447
  `${indent}fi`
433
448
  ].join("\n");
434
449
  case "zsh-setopt":
435
- if (target !== "zsh") throw new Error("setopt is only valid in Zsh configuration");
450
+ if (target2 !== "zsh") throw new Error("setopt is only valid in Zsh configuration");
436
451
  return `${indent}setopt ${statement.options.join(" ")}`;
437
452
  case "raw":
438
453
  return statement.code.split("\n").map((line) => `${indent}${line}`).join("\n");
@@ -495,14 +510,15 @@ function validateVariable(name) {
495
510
 
496
511
  // src/config/load.ts
497
512
  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";
513
+ import { dirname, isAbsolute as isAbsolute2, resolve as resolve4 } from "path";
514
+ import { access as access2 } from "fs/promises";
500
515
 
501
516
  // src/config/identity.ts
502
517
  import { createHash } from "crypto";
503
518
  function resourceId(resource) {
504
519
  switch (resource.kind) {
505
520
  case "package":
521
+ if (resource.manager === "flatpak") return `package:flatpak:${resource.flatpak?.scope ?? "user"}:${resource.name}:${resource.flatpak?.branch ?? "stable"}`;
506
522
  return `package:${resource.manager}:${resource.name}`;
507
523
  case "symlink":
508
524
  return `file:${resource.target}`;
@@ -557,6 +573,20 @@ async function addPathToHash(hash, path, relativePath) {
557
573
  }
558
574
  }
559
575
 
576
+ // src/config/package-options.ts
577
+ function isFlatpakOptions(value) {
578
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
579
+ const options = value;
580
+ return Object.keys(options).every((key) => ["scope", "remote", "branch"].includes(key)) && (options.scope === void 0 || options.scope === "user" || options.scope === "system") && [options.remote, options.branch].every((field) => field === void 0 || typeof field === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(field));
581
+ }
582
+ function isPackageName(manager, name) {
583
+ if (typeof name !== "string" || name.length === 0) return false;
584
+ if (manager === "mas") return /^[1-9]\d*$/.test(name);
585
+ if (manager === "flatpak") return /^[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*){2,}$/.test(name);
586
+ if (manager === "pacman") return /^[A-Za-z0-9@_+][A-Za-z0-9@._+-]*$/.test(name);
587
+ return true;
588
+ }
589
+
560
590
  // src/config/validation.ts
561
591
  function validateResource(value) {
562
592
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -565,7 +595,7 @@ function validateResource(value) {
565
595
  const candidate = value;
566
596
  switch (candidate.kind) {
567
597
  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)) {
598
+ if (!["mise", "brew", "brew-cask", "apt", "dnf", "yum", "pacman", "flatpak", "mas", "system"].includes(String(candidate.manager)) || !isPackageName(candidate.manager, candidate.name) || candidate.flatpak !== void 0 && (!["flatpak", "system"].includes(String(candidate.manager)) || !isFlatpakOptions(candidate.flatpak)) || candidate.version !== void 0 && typeof candidate.version !== "string" || !isBrewCaskUpgradeOptions(candidate.upgrade)) {
569
599
  throw new Error("Invalid package resource");
570
600
  }
571
601
  return;
@@ -622,12 +652,33 @@ function isBrewCaskUpgradeOptions(value) {
622
652
 
623
653
  // src/config/load.ts
624
654
  import { createJiti } from "jiti/static";
655
+
656
+ // src/config/system-manager.ts
657
+ import { access, stat } from "fs/promises";
658
+ import { constants } from "fs";
659
+ import { delimiter, resolve as resolve3 } from "path";
660
+ async function detectLinuxManager(searchPath = process.env.PATH ?? "") {
661
+ for (const [manager, command] of [["apt", "apt-get"], ["dnf", "dnf"], ["yum", "yum"], ["pacman", "pacman"]]) {
662
+ for (const directory of searchPath.split(delimiter).filter(Boolean)) {
663
+ const path = resolve3(directory, command);
664
+ try {
665
+ await access(path, constants.X_OK);
666
+ if ((await stat(path)).isFile()) return manager;
667
+ } catch (error) {
668
+ if (!(error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR", "EACCES"].includes(String(error.code)))) throw error;
669
+ }
670
+ }
671
+ }
672
+ throw new Error("No supported Linux package manager found on PATH (apt-get, dnf, yum, pacman). Install one or set managers.linux explicitly.");
673
+ }
674
+
675
+ // src/config/load.ts
625
676
  var DEFAULT_CONFIG_FILE = "workstation.config.ts";
626
677
  async function findConfig(explicit) {
627
- if (explicit) return resolve3(explicit);
628
- const path = resolve3(DEFAULT_CONFIG_FILE);
678
+ if (explicit) return resolve4(explicit);
679
+ const path = resolve4(DEFAULT_CONFIG_FILE);
629
680
  try {
630
- await access(path);
681
+ await access2(path);
631
682
  return path;
632
683
  } catch {
633
684
  throw new Error(`No configuration found (${DEFAULT_CONFIG_FILE})`);
@@ -673,7 +724,11 @@ async function loadConfig(configPath, machineOverride) {
673
724
  }
674
725
  async function resolveResource(resource, definition, context) {
675
726
  if (resource.kind === "package") {
676
- const manager = resolveManager(resource.manager, definition, context);
727
+ const manager = await resolveManager(resource.manager, definition, context);
728
+ if (!isPackageName(manager, resource.name)) throw new Error(`Invalid ${manager} package ID: ${resource.name}`);
729
+ if ((manager === "pacman" || manager === "flatpak") && context.platform !== "linux") throw new Error(`${manager} requires Linux`);
730
+ if (manager === "mas" && context.platform !== "darwin") throw new Error("mas requires macOS");
731
+ if (resource.flatpak !== void 0 && manager !== "flatpak") throw new Error(`Flatpak options require the flatpak backend (${resource.name})`);
677
732
  if (resource.upgrade !== void 0 && manager !== "brew-cask") {
678
733
  throw new Error(`Upgrade options are only supported for Homebrew casks (${resource.name})`);
679
734
  }
@@ -683,7 +738,11 @@ async function resolveResource(resource, definition, context) {
683
738
  if (resource.version !== void 0) {
684
739
  throw new Error(`${manager} package ${resource.name} cannot declare a version`);
685
740
  }
686
- return { ...resource, manager };
741
+ return { ...resource, manager, ...manager === "flatpak" ? { flatpak: {
742
+ scope: resource.flatpak?.scope ?? "user",
743
+ remote: resource.flatpak?.remote ?? "flathub",
744
+ branch: resource.flatpak?.branch ?? "stable"
745
+ } } : {} };
687
746
  }
688
747
  if (resource.kind === "symlink") {
689
748
  return {
@@ -707,11 +766,11 @@ async function resolveResource(resource, definition, context) {
707
766
  }
708
767
  if (resource.kind === "custom-tool") {
709
768
  const source = expandPath(resource.source, context, true);
710
- const target = expandPath(resource.target, context, false);
769
+ const target2 = expandPath(resource.target, context, false);
711
770
  return {
712
771
  ...resource,
713
772
  source,
714
- target,
773
+ target: target2,
715
774
  sourceHash: await hashSource(source),
716
775
  build: {
717
776
  ...resource.build,
@@ -729,14 +788,16 @@ async function resolveResource(resource, definition, context) {
729
788
  ...resource.stderrPath ? { stderrPath: expandPath(resource.stderrPath, context, false) } : {}
730
789
  };
731
790
  }
732
- function resolveManager(manager, definition, context) {
791
+ async function resolveManager(manager, definition, context) {
733
792
  if (manager !== "system") return manager;
734
- return definition.managers?.[context.platform] ?? (context.platform === "darwin" ? "brew" : "apt");
793
+ const configured = definition.managers?.[context.platform];
794
+ if (configured !== void 0) return configured;
795
+ return context.platform === "darwin" ? "brew" : await detectLinuxManager();
735
796
  }
736
797
  function expandPath(path, context, relativeToConfig) {
737
798
  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);
799
+ if (isAbsolute2(expanded)) return resolve4(expanded);
800
+ return resolve4(relativeToConfig ? context.configDir : context.home, expanded);
740
801
  }
741
802
  function flatten2(input) {
742
803
  if (!input) return [];
@@ -774,11 +835,11 @@ function mergeDefinitions(definitions) {
774
835
 
775
836
  // src/persistence/manifest.ts
776
837
  import { mkdir, readFile as readFile2, rename, writeFile } from "fs/promises";
777
- import { dirname as dirname2, resolve as resolve4 } from "path";
838
+ import { dirname as dirname2, resolve as resolve5 } from "path";
778
839
  import { parse, stringify } from "smol-toml";
779
840
  var MANIFEST_VERSION = 1;
780
841
  function manifestPath(config) {
781
- return resolve4(dirname2(config.stateFile), "config.toml");
842
+ return resolve5(dirname2(config.stateFile), "config.toml");
782
843
  }
783
844
  async function writeManifest(path, config) {
784
845
  const document = {
@@ -820,6 +881,11 @@ function toTomlResource(resource) {
820
881
  name: resource.name,
821
882
  ...resource.version ? { version: resource.version } : {},
822
883
  ...resource.lockedVersion ? { locked_version: resource.lockedVersion } : {},
884
+ ...resource.flatpak ? { flatpak: {
885
+ ...resource.flatpak.scope ? { scope: resource.flatpak.scope } : {},
886
+ ...resource.flatpak.remote ? { remote: resource.flatpak.remote } : {},
887
+ ...resource.flatpak.branch ? { branch: resource.flatpak.branch } : {}
888
+ } } : {},
823
889
  ...resource.upgrade ? {
824
890
  upgrade: {
825
891
  ...resource.upgrade.greedy !== void 0 ? { greedy: resource.upgrade.greedy } : {},
@@ -970,6 +1036,9 @@ function parsePackage(value) {
970
1036
  if (!isPackageManager(manager)) {
971
1037
  throw new Error(`Invalid manifest package manager: ${manager}`);
972
1038
  }
1039
+ if (!isPackageName(manager, value.name)) throw new Error(`Invalid ${manager} package ID`);
1040
+ if (value.flatpak !== void 0 && (manager !== "flatpak" || !isFlatpakOptions(value.flatpak))) throw new Error("Invalid Flatpak options");
1041
+ if (manager === "mas" && value.locked_version !== void 0) throw new Error("mas does not support version pins");
973
1042
  const upgrade = value.upgrade === void 0 ? void 0 : requireTable(value.upgrade, "package.upgrade");
974
1043
  if (value.version !== void 0 && manager !== "mise") {
975
1044
  throw new Error(`Manifest ${manager} package cannot declare a version`);
@@ -981,6 +1050,7 @@ function parsePackage(value) {
981
1050
  kind: "package",
982
1051
  manager,
983
1052
  name: requireString(value.name, "package.name"),
1053
+ ...isFlatpakOptions(value.flatpak) ? { flatpak: value.flatpak } : {},
984
1054
  ...value.version !== void 0 ? { version: requireString(value.version, "package.version") } : {},
985
1055
  ...value.locked_version !== void 0 ? { lockedVersion: requireString(value.locked_version, "package.locked_version") } : {},
986
1056
  ...upgrade ? {
@@ -1000,13 +1070,13 @@ function parseSymlink(value) {
1000
1070
  }
1001
1071
  function parseLaunchAgent(value) {
1002
1072
  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");
1073
+ const environment3 = value.environment === void 0 ? void 0 : requireStringTable(value.environment, "launch-agent.environment");
1004
1074
  return {
1005
1075
  kind: "launch-agent",
1006
1076
  label: requireString(value.label, "launch-agent.label"),
1007
1077
  program: requireString(value.program, "launch-agent.program"),
1008
1078
  ...args ? { args } : {},
1009
- ...environment ? { environment } : {},
1079
+ ...environment3 ? { environment: environment3 } : {},
1010
1080
  ...value.run_at_load !== void 0 ? { runAtLoad: requireBoolean(value.run_at_load, "launch-agent.run_at_load") } : {},
1011
1081
  ...value.keep_alive !== void 0 ? { keepAlive: requireBoolean(value.keep_alive, "launch-agent.keep_alive") } : {},
1012
1082
  ...value.stdout_path !== void 0 ? { stdoutPath: requireString(value.stdout_path, "launch-agent.stdout_path") } : {},
@@ -1043,7 +1113,7 @@ function requireStringTable(value, field) {
1043
1113
  return result;
1044
1114
  }
1045
1115
  function isPackageManager(value) {
1046
- return value === "mise" || value === "brew" || value === "brew-cask" || value === "apt";
1116
+ return value === "mise" || value === "brew" || value === "brew-cask" || value === "apt" || value === "dnf" || value === "yum" || value === "pacman" || value === "flatpak" || value === "mas";
1047
1117
  }
1048
1118
  function isConfigValue2(value) {
1049
1119
  if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) {
@@ -1055,7 +1125,7 @@ function isConfigValue2(value) {
1055
1125
 
1056
1126
  // src/persistence/lock.ts
1057
1127
  import { readFile as readFile3, rename as rename3, writeFile as writeFile3 } from "fs/promises";
1058
- import { dirname as dirname3, resolve as resolve5 } from "path";
1128
+ import { dirname as dirname3, resolve as resolve6 } from "path";
1059
1129
  import { parse as parse2, stringify as stringify2 } from "smol-toml";
1060
1130
 
1061
1131
  // src/resources/shared.ts
@@ -1103,6 +1173,69 @@ function isRecord(value) {
1103
1173
  return typeof value === "object" && value !== null && !Array.isArray(value);
1104
1174
  }
1105
1175
 
1176
+ // src/resources/rpm.ts
1177
+ var environment = { LC_ALL: "C" };
1178
+ var versionPattern = /^\d+:[A-Za-z0-9._+~^]+-[A-Za-z0-9._+~^]+\.[A-Za-z0-9_]+$/;
1179
+ async function resolveRpmVersion(resource, runner) {
1180
+ const architecture = (await requireSuccess(runner, "rpm", ["--eval", "%{_arch}"], { environment })).stdout.trim();
1181
+ if (!/^[A-Za-z0-9_]+$/.test(architecture)) throw new Error("RPM did not report a valid native architecture");
1182
+ const result = await requireSuccess(runner, resource.manager, ["--quiet", "--color=never", "list", resource.name], { environment });
1183
+ const candidates = [];
1184
+ let available = false;
1185
+ const lines = result.stdout.replace(/^(\S+\.\S+)\n[ \t]+(?=\S+\s+\S+)/gm, "$1 ").split("\n");
1186
+ for (const line of lines) {
1187
+ if (/^Available packages\s*$/i.test(line.trim())) {
1188
+ available = true;
1189
+ continue;
1190
+ }
1191
+ if (/^Installed packages\s*$/i.test(line.trim())) {
1192
+ available = false;
1193
+ continue;
1194
+ }
1195
+ const [nameArch, evr, repository] = line.trim().split(/\s+/);
1196
+ if (!nameArch || !evr || !repository) continue;
1197
+ const dot = nameArch.lastIndexOf(".");
1198
+ const name = nameArch.slice(0, dot);
1199
+ const arch = nameArch.slice(dot + 1);
1200
+ if (nameArch !== resource.name && (name !== resource.name || arch !== architecture && arch !== "noarch")) continue;
1201
+ const version = `${evr.includes(":") ? evr : `0:${evr}`}.${arch}`;
1202
+ if (!versionPattern.test(version)) throw new Error(`${resource.manager} reported an invalid RPM version for ${resource.name}: ${version}`);
1203
+ candidates.push({ version, native: arch === architecture, available });
1204
+ }
1205
+ candidates.sort((left, right) => Number(right.available) - Number(left.available) || Number(right.native) - Number(left.native));
1206
+ const candidate = candidates[0];
1207
+ if (!candidate) throw new Error(`${resource.manager} has no installation candidate for ${resource.name}`);
1208
+ return candidate.version;
1209
+ }
1210
+
1211
+ // src/resources/pacman.ts
1212
+ var environment2 = { LC_ALL: "C" };
1213
+ async function resolvePacmanVersion(resource, runner) {
1214
+ const result = await requireSuccess(runner, "pacman", ["-Sp", "--print-format", "%n %v", resource.name], { environment: environment2 });
1215
+ const versions = result.stdout.trim().split("\n").flatMap((line) => {
1216
+ const [name, version, extra] = line.trim().split(/\s+/);
1217
+ return name === resource.name && version && !extra ? [version] : [];
1218
+ });
1219
+ if (versions.length !== 1 || !versions[0]) throw new Error(`pacman did not report a unique version for ${resource.name}`);
1220
+ return versions[0];
1221
+ }
1222
+
1223
+ // src/resources/flatpak.ts
1224
+ function target(resource) {
1225
+ const scope = resource.flatpak?.scope ?? "user";
1226
+ const branch = resource.flatpak?.branch ?? "stable";
1227
+ return { scope, branch, flag: `--${scope}`, remote: resource.flatpak?.remote ?? "flathub", ref: `app/${resource.name}//${branch}` };
1228
+ }
1229
+ function commit(value) {
1230
+ const hash = value.trim();
1231
+ if (!/^[a-f0-9]{64}$/.test(hash)) throw new Error(`Flatpak did not report a full commit ID: ${hash}`);
1232
+ return hash;
1233
+ }
1234
+ async function resolveFlatpakVersion(resource, runner) {
1235
+ const { flag, remote, ref } = target(resource);
1236
+ return commit((await requireSuccess(runner, "flatpak", ["remote-info", flag, "--show-commit", remote, ref])).stdout);
1237
+ }
1238
+
1106
1239
  // src/resources/package-version.ts
1107
1240
  async function resolvePackageVersion(resource, runner) {
1108
1241
  if (resource.kind !== "package") return void 0;
@@ -1127,22 +1260,31 @@ async function resolvePackageVersion(resource, runner) {
1127
1260
  const version = await readAvailableBrewVersion(resource, runner);
1128
1261
  return version === "latest" ? void 0 : version;
1129
1262
  }
1263
+ case "dnf":
1264
+ case "yum":
1265
+ return await resolveRpmVersion(resource, runner);
1130
1266
  case "system":
1131
1267
  throw new Error("System package manager must be resolved before locking");
1268
+ case "pacman":
1269
+ return await resolvePacmanVersion(resource, runner);
1270
+ case "flatpak":
1271
+ return await resolveFlatpakVersion(resource, runner);
1272
+ case "mas":
1273
+ return void 0;
1132
1274
  }
1133
1275
  }
1134
1276
 
1135
1277
  // src/persistence/lock.ts
1136
1278
  var LOCK_VERSION = 1;
1137
1279
  function lockPath(configPath) {
1138
- return resolve5(dirname3(configPath), "workstation.lock");
1280
+ return resolve6(dirname3(configPath), "workstation.lock");
1139
1281
  }
1140
1282
  async function lockConfig(configPath, config, runner) {
1141
1283
  const path = lockPath(configPath);
1142
1284
  const previousText = await readOptional(path);
1143
1285
  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]));
1286
+ const target2 = previous.targets.find(({ machine: machine2 }) => machine2 === config.context.machine);
1287
+ const existing = new Map(target2?.resources.map((entry) => [entry.id, entry]));
1146
1288
  const resources = [];
1147
1289
  const entries = [];
1148
1290
  for (const resource of config.resources) {
@@ -1186,10 +1328,10 @@ function emptyLock() {
1186
1328
  function stringifyLock(lock) {
1187
1329
  return stringify2({
1188
1330
  version: lock.version,
1189
- targets: lock.targets.map((target) => ({
1190
- machine: target.machine,
1191
- platform: target.platform,
1192
- resources: target.resources.map((entry) => ({
1331
+ targets: lock.targets.map((target2) => ({
1332
+ machine: target2.machine,
1333
+ platform: target2.platform,
1334
+ resources: target2.resources.map((entry) => ({
1193
1335
  id: entry.id,
1194
1336
  fingerprint: entry.fingerprint,
1195
1337
  ...entry.lockedVersion ? { locked_version: entry.lockedVersion } : {}
@@ -1205,19 +1347,19 @@ function parseLock(text, path) {
1205
1347
  if (!Array.isArray(document.targets)) throw new Error(`Lock targets must be an array in ${path}`);
1206
1348
  const machines = /* @__PURE__ */ new Set();
1207
1349
  const targets = document.targets.map((value, targetIndex) => {
1208
- const target = requireTable2(value, `targets[${targetIndex}]`);
1209
- const machine2 = requireString2(target.machine, `targets[${targetIndex}].machine`);
1350
+ const target2 = requireTable2(value, `targets[${targetIndex}]`);
1351
+ const machine2 = requireString2(target2.machine, `targets[${targetIndex}].machine`);
1210
1352
  if (machines.has(machine2)) throw new Error(`Duplicate machine ${machine2} in ${path}`);
1211
1353
  machines.add(machine2);
1212
- const platform = requireString2(target.platform, `targets[${targetIndex}].platform`);
1354
+ const platform = requireString2(target2.platform, `targets[${targetIndex}].platform`);
1213
1355
  if (platform !== "darwin" && platform !== "linux") {
1214
1356
  throw new Error(`Invalid platform for ${machine2} in ${path}`);
1215
1357
  }
1216
- if (!Array.isArray(target.resources)) {
1358
+ if (!Array.isArray(target2.resources)) {
1217
1359
  throw new Error(`Lock resources for ${machine2} must be an array in ${path}`);
1218
1360
  }
1219
1361
  const ids = /* @__PURE__ */ new Set();
1220
- const resources = target.resources.map((value2, resourceIndex) => {
1362
+ const resources = target2.resources.map((value2, resourceIndex) => {
1221
1363
  const entry = requireTable2(value2, `targets[${targetIndex}].resources[${resourceIndex}]`);
1222
1364
  const id = requireString2(entry.id, "lock resource id");
1223
1365
  if (ids.has(id)) throw new Error(`Duplicate resource ${id} for ${machine2} in ${path}`);
@@ -1261,9 +1403,24 @@ async function atomicWrite(path, contents) {
1261
1403
  // src/resources/runner.ts
1262
1404
  import { spawn } from "child_process";
1263
1405
  var ProcessRunner = class {
1406
+ /** Opt into command progress and streaming; verbose also prints captured query output. */
1407
+ constructor(logging = {}) {
1408
+ this.logging = logging;
1409
+ }
1410
+ /** Print resource progress only when logging is enabled. */
1411
+ report(message) {
1412
+ if (this.logging.progress || this.logging.verbose) process.stderr.write(` ${message}
1413
+ `);
1414
+ }
1264
1415
  /** Execute a command directly and return captured output and its exit code; spawn failures reject. */
1265
1416
  async run(command, args, options) {
1266
- return await new Promise((resolve6, reject) => {
1417
+ const started = Date.now();
1418
+ if (this.logging.progress || this.logging.verbose) {
1419
+ process.stderr.write(` $ ${[command, ...args].map((value) => /^[\w./:@=+-]+$/.test(value) ? value : JSON.stringify(value)).join(" ")}${options?.cwd ? ` (in ${options.cwd})` : ""}
1420
+ `);
1421
+ }
1422
+ const stream = this.logging.verbose || this.logging.progress && options?.streamOutput;
1423
+ return await new Promise((resolve7, reject) => {
1267
1424
  const child = spawn(command, [...args], {
1268
1425
  cwd: options?.cwd,
1269
1426
  env: { ...process.env, ...options?.environment },
@@ -1273,13 +1430,22 @@ var ProcessRunner = class {
1273
1430
  let stderr = "";
1274
1431
  child.stdout.setEncoding("utf8");
1275
1432
  child.stderr.setEncoding("utf8");
1276
- child.stdout.on("data", (chunk) => stdout += chunk);
1277
- child.stderr.on("data", (chunk) => stderr += chunk);
1433
+ child.stdout.on("data", (chunk) => {
1434
+ stdout += chunk;
1435
+ if (stream) process.stdout.write(chunk);
1436
+ });
1437
+ child.stderr.on("data", (chunk) => {
1438
+ stderr += chunk;
1439
+ if (stream || this.logging.progress) process.stderr.write(chunk);
1440
+ });
1278
1441
  child.once("error", reject);
1279
- child.once(
1280
- "close",
1281
- (exitCode) => resolve6({ exitCode: exitCode ?? 1, stdout, stderr })
1282
- );
1442
+ child.once("close", (exitCode) => {
1443
+ if (this.logging.progress || this.logging.verbose) {
1444
+ process.stderr.write(` -> exit ${exitCode ?? 1} (${((Date.now() - started) / 1e3).toFixed(1)}s)
1445
+ `);
1446
+ }
1447
+ resolve7({ exitCode: exitCode ?? 1, stdout, stderr });
1448
+ });
1283
1449
  });
1284
1450
  }
1285
1451
  };