dsh-home-hosted 0.7.2 → 0.7.4

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/docs/BOOT.md CHANGED
@@ -92,6 +92,51 @@ stays `available` and its install reports "loads at the next login" instead of r
92
92
  `bootstrap`/`enable`/`print` steps need the domain, and they are skipped when it is unreachable. The
93
93
  daemon scope still needs root for `/Library/LaunchDaemons`.
94
94
 
95
+ ## Escaping is per setting, not per file
96
+
97
+ A unit file is read by systemd, not by a shell, and its settings do not share one
98
+ escaping rule. `ExecStart=` **is** unescaped (`\\`, `\"`, and `$`/`%` are special), which
99
+ is why its words are quoted and a literal `$` or `%` is doubled. `WorkingDirectory=`
100
+ and `Description=` are taken **verbatim**: measured with `systemctl show -p
101
+ WorkingDirectory --value`, every backslash written came straight back, so doubling one
102
+ turned a directory named `a\b` into `a\\b` and the unit died with
103
+ `status=200/CHDIR`. Only `%` is special there — `%%` is a literal percent, and an
104
+ unknown specifier such as `%i` in a system unit is fatal.
105
+
106
+ The one hazard they *do* share is the line continuation: a setting whose value ends in
107
+ a backslash continues onto the next line, so a `WorkingDirectory=` ending in `\`
108
+ swallowed the `KillMode=process` line under it. The generator appends a trailing space
109
+ in that case — systemd strips surrounding whitespace before expanding the value, so the
110
+ path is unchanged and the next directive survives.
111
+
112
+ The same two-layer mistake is possible in every format this plugin writes, and it is why
113
+ each generator is exercised against the real parser (`systemd-analyze verify`, `plutil`,
114
+ `desktop-file-validate`, and a `gio launch` for the XDG entry) rather than only against
115
+ its own expectations.
116
+
117
+ ### A `.desktop` line has two parsers, in order
118
+
119
+ The XDG entry is the one format that is genuinely read twice, and the first reader is
120
+ easy to forget. `GKeyFile` consumes the line — `\n`, `\t`, `\r`, `\s` and `\\` are its
121
+ escapes, and **a backslash that is none of those makes it refuse the key outright** —
122
+ and only then does the desktop session split `Exec=` with `g_shell_parse_argv` and
123
+ unquote each word. So a value is escaped for the key file *and* for the shell, and the
124
+ key-file layer runs first.
125
+
126
+ Escaping only for the shell therefore failed in a way that is invisible to
127
+ `desktop-file-validate`, which reports such a file as clean: a lone `\` anywhere in a
128
+ `Path=` or `Exec=` word (`C:\Users\…`, or any POSIX directory whose name has one) made
129
+ `g_key_file_get_string` fail with "Key file contains key … which has a value that cannot
130
+ be interpreted", the entry never loaded, and a `\$`, `` \` `` or `\"` did the same. A
131
+ backslash run was also lossy, because the key-file layer ate half of it: `a\b` came back
132
+ as `ab`. And since `desktop-file-validate` is not the loader, the entry failing to start
133
+ was the first sign.
134
+
135
+ `desktopValueEscape` is that missing first layer, `desktopValue` applies it to the plain
136
+ keys (`Path=`, `Name=`, `Comment=`), and `Exec=` applies it on top of its shell quoting.
137
+ Every ownership check reads the escaped marker too, or a marker carrying a backslash would
138
+ be written escaped and searched for raw — and the plugin would call its own entry foreign.
139
+
95
140
  ## Enabling autostart hands the panel over
96
141
 
97
142
  An install alone proves nothing. The panel the plugin started keeps running, so nothing shows whether
package/lib/index.js CHANGED
@@ -450,14 +450,16 @@ function systemdExecWord(value) {
450
450
  }
451
451
  function systemdText(value) {
452
452
  assertNoControl(value, "unit setting");
453
- return replaceAll(value, [["\\", "\\\\"], ["%", "%%"]]);
453
+ const escaped = value.split("%").join("%%");
454
+ return escaped.endsWith("\\") ? `${escaped} ` : escaped;
454
455
  }
455
456
  function systemdPath(value, what = "path") {
456
457
  assertAbsolute(value, what);
457
458
  assertNoControl(value, what);
458
459
  if (value.includes('"'))
459
460
  throw new Error(`${what} must not contain a double quote: ${JSON.stringify(value)}`);
460
- return replaceAll(value, [["\\", "\\\\"], ["%", "%%"]]);
461
+ const escaped = value.split("%").join("%%");
462
+ return escaped.endsWith("\\") ? `${escaped} ` : escaped;
461
463
  }
462
464
  function systemdEnvLine(key, value) {
463
465
  assertEnvKey(key);
@@ -478,14 +480,25 @@ function assertXmlCommentSafe(value) {
478
480
  return value;
479
481
  }
480
482
  var DESKTOP_RESERVED = /[\s"'\\><~|&;$*?#()`]/;
483
+ function desktopValueEscape(value) {
484
+ assertNoControl(value, "desktop value");
485
+ return value.split("\\").join("\\\\");
486
+ }
481
487
  function desktopWord(value) {
482
488
  assertNoControl(value, "desktop Exec word");
483
489
  const escaped = replaceAll(value, [["\\", "\\\\"], ['"', '\\"'], ["`", "\\`"], ["$", "\\$"], ["%", "%%"]]);
484
- return DESKTOP_RESERVED.test(value) || escaped !== value ? `"${escaped}"` : value;
490
+ const word = DESKTOP_RESERVED.test(value) || escaped !== value ? `"${escaped}"` : value;
491
+ return desktopValueEscape(word);
485
492
  }
486
493
  function desktopExec(program, args) {
487
494
  return [program, ...args].map(desktopWord).join(" ");
488
495
  }
496
+ function desktopValue(key, value) {
497
+ assertNoControl(value, `desktop ${key}`);
498
+ if (key.includes("=") || key.includes("\n"))
499
+ throw new Error(`desktop key ${JSON.stringify(key)} is not valid`);
500
+ return `${key}=${desktopValueEscape(value)}`;
501
+ }
489
502
  function windowsArg(value) {
490
503
  assertNoControl(value, "windows argument");
491
504
  if (value !== "" && !/[\s"]/.test(value))
@@ -1504,18 +1517,21 @@ function validate3(spec) {
1504
1517
  for (const key of Object.keys(spec.env))
1505
1518
  assertEnvKey(key);
1506
1519
  }
1520
+ function fileMarker(spec) {
1521
+ return desktopValueEscape(assertMarker(spec.marker));
1522
+ }
1507
1523
  function xdgDesktopEntry(spec) {
1508
1524
  validate3(spec);
1509
1525
  return `${[
1510
1526
  "[Desktop Entry]",
1511
1527
  "Type=Application",
1512
- `Name=${spec.label}`,
1513
- `Comment=home-hosted daemon, managed by ${spec.marker}`,
1528
+ desktopValue("Name", spec.label),
1529
+ desktopValue("Comment", `home-hosted daemon, managed by ${spec.marker}`),
1514
1530
  `Exec=${desktopExec(spec.command, spec.args)}`,
1515
- `Path=${spec.cwd}`,
1531
+ desktopValue("Path", spec.cwd),
1516
1532
  "Terminal=false",
1517
1533
  "X-GNOME-Autostart-enabled=true",
1518
- `X-HomeHosted-Marker=${spec.marker}`,
1534
+ desktopValue("X-HomeHosted-Marker", spec.marker),
1519
1535
  ""
1520
1536
  ].join("\n")}`;
1521
1537
  }
@@ -1547,7 +1563,7 @@ function createXdgAutostartProvider(ctx) {
1547
1563
  async status(spec) {
1548
1564
  try {
1549
1565
  const file = pathOf(spec);
1550
- const own = inspectOwned(file, assertMarker(spec.marker));
1566
+ const own = inspectOwned(file, fileMarker(spec));
1551
1567
  if (own.exists && !own.owned)
1552
1568
  return { state: "not-installed", unitPath: file, detail: own.reason ?? "foreign file", commands: [] };
1553
1569
  if (!own.owned || own.text === null)
@@ -1569,10 +1585,10 @@ function createXdgAutostartProvider(ctx) {
1569
1585
  validate3(spec);
1570
1586
  const file = pathOf(spec);
1571
1587
  fs5.mkdirSync(path4.dirname(file), { recursive: true });
1572
- const write = writeOwned(file, spec.marker, xdgDesktopEntry(spec), 420);
1588
+ const write = writeOwned(file, fileMarker(spec), xdgDesktopEntry(spec), 420);
1573
1589
  if (write.refusal)
1574
1590
  return failed(write.refusal);
1575
- const text = inspectOwned(file, spec.marker).text;
1591
+ const text = inspectOwned(file, fileMarker(spec)).text;
1576
1592
  const enabled = text !== null && desktopFlag(text, "X-GNOME-Autostart-enabled") !== "false" && desktopFlag(text, "Hidden") !== "true";
1577
1593
  if (!enabled)
1578
1594
  return failed(`${file} was written but does not read back as enabled`, { changed: write.changed });
@@ -1590,10 +1606,10 @@ function createXdgAutostartProvider(ctx) {
1590
1606
  async uninstall(spec) {
1591
1607
  try {
1592
1608
  const file = pathOf(spec);
1593
- const own = inspectOwned(file, spec.marker);
1609
+ const own = inspectOwned(file, fileMarker(spec));
1594
1610
  if (own.exists && !own.owned)
1595
1611
  return failed(own.reason ?? "foreign file");
1596
- const remove = removeOwned(file, spec.marker);
1612
+ const remove = removeOwned(file, fileMarker(spec));
1597
1613
  if (remove.refusal)
1598
1614
  return failed(remove.refusal);
1599
1615
  return {
@@ -2468,6 +2484,9 @@ function migrationRefusal(home) {
2468
2484
  var ConfigLayoutError = class extends Error {
2469
2485
  name = "ConfigLayoutError";
2470
2486
  };
2487
+ var ConfigPatchError = class extends Error {
2488
+ name = "ConfigPatchError";
2489
+ };
2471
2490
  function readConfig(home, workspace = DEFAULT_WORKSPACE) {
2472
2491
  const refusal = migrationRefusal(home);
2473
2492
  if (refusal !== null)
@@ -2478,8 +2497,15 @@ function readConfig(home, workspace = DEFAULT_WORKSPACE) {
2478
2497
  const exists = fs8.existsSync(file);
2479
2498
  return { raw: null, exists, error: exists ? "the workspace config file could not be parsed" : null };
2480
2499
  }
2500
+ if (!isRecordValue(raw))
2501
+ return { raw: null, exists: true, error: "the workspace config file does not contain a JSON object" };
2481
2502
  if (!Array.isArray(raw.servers) && raw.servers !== void 0)
2482
2503
  return { raw: null, exists: true, error: "the workspace config file has a servers field that is not an array" };
2504
+ if (Array.isArray(raw.servers)) {
2505
+ const index = raw.servers.findIndex((entry) => !isRecordValue(entry));
2506
+ if (index >= 0)
2507
+ return { raw: null, exists: true, error: `the workspace config file has an entry at servers[${index}] that is not an object` };
2508
+ }
2483
2509
  return { raw, exists: true, error: null };
2484
2510
  }
2485
2511
  function writeConfig(home, raw, writtenBy = "dsh-home-hosted", workspace = DEFAULT_WORKSPACE) {
@@ -2499,7 +2525,9 @@ function readGlobalSettings(home) {
2499
2525
  const exists = fs8.existsSync(file);
2500
2526
  return { raw: null, exists, error: exists ? "the panel settings file could not be parsed" : null };
2501
2527
  }
2502
- if (raw.control !== void 0 && (typeof raw.control !== "object" || raw.control === null || Array.isArray(raw.control)))
2528
+ if (!isRecordValue(raw))
2529
+ return { raw: null, exists: true, error: "the panel settings file does not contain a JSON object" };
2530
+ if (raw.control !== void 0 && !isRecordValue(raw.control))
2503
2531
  return { raw: null, exists: true, error: "the panel settings file has a control field that is not an object" };
2504
2532
  return { raw, exists: true, error: null };
2505
2533
  }
@@ -2513,7 +2541,7 @@ function setControl(home, patch, writtenBy = "dsh-home-hosted") {
2513
2541
  const read = readGlobalSettings(home);
2514
2542
  if (read.error !== null)
2515
2543
  throw new ConfigLayoutError(read.error);
2516
- writeGlobalSettings(home, { ...read.raw ?? {}, control: { ...read.raw?.control ?? {}, ...patch } }, writtenBy);
2544
+ writeGlobalSettings(home, patchControl(read.raw ?? {}, patch), writtenBy);
2517
2545
  }
2518
2546
  function refuseUnmigrated(home) {
2519
2547
  const refusal = migrationRefusal(home);
@@ -2532,17 +2560,61 @@ function upsertEntry(raw, entry) {
2532
2560
  servers.push(entry);
2533
2561
  return { ...raw, servers };
2534
2562
  }
2563
+ var SERVER_MERGE_KEYS = /* @__PURE__ */ new Set(["restart", "health", "stop"]);
2564
+ var CONTROL_MERGE_KEYS = /* @__PURE__ */ new Set(["auth", "tls"]);
2565
+ function isRecordValue(value) {
2566
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2567
+ }
2568
+ function mergeGroup(target, patch) {
2569
+ const merged = { ...target };
2570
+ for (const [key, value] of Object.entries(patch)) {
2571
+ if (value === void 0)
2572
+ continue;
2573
+ if (value === null) {
2574
+ delete merged[key];
2575
+ continue;
2576
+ }
2577
+ if (isRecordValue(value) && isRecordValue(merged[key])) {
2578
+ merged[key] = mergeGroup(merged[key], value);
2579
+ continue;
2580
+ }
2581
+ merged[key] = value;
2582
+ }
2583
+ return merged;
2584
+ }
2535
2585
  function patchEntry(raw, id, patch) {
2586
+ for (const key of SERVER_MERGE_KEYS) {
2587
+ if (patch[key] === null)
2588
+ throw new ConfigPatchError(`${key} must be an object (was null): the panel refuses to boot a config that carries it`);
2589
+ }
2536
2590
  const servers = (raw.servers ?? []).map((entry) => {
2537
2591
  if (entry.id !== id)
2538
2592
  return entry;
2539
- const merged = { ...entry, ...patch };
2540
- if (patch.stop !== void 0 && typeof entry.stop === "object" && entry.stop !== null)
2541
- merged.stop = { ...entry.stop, ...patch.stop };
2593
+ const merged = { ...entry };
2594
+ for (const [key, value] of Object.entries(patch)) {
2595
+ if (value === void 0)
2596
+ continue;
2597
+ if (SERVER_MERGE_KEYS.has(key) && isRecordValue(value) && isRecordValue(merged[key]))
2598
+ merged[key] = mergeGroup(merged[key], value);
2599
+ else
2600
+ merged[key] = value;
2601
+ }
2542
2602
  return merged;
2543
2603
  });
2544
2604
  return { ...raw, servers };
2545
2605
  }
2606
+ function patchControl(raw, patch) {
2607
+ const control = { ...raw.control ?? {} };
2608
+ for (const [key, value] of Object.entries(patch)) {
2609
+ if (value === void 0)
2610
+ continue;
2611
+ if (CONTROL_MERGE_KEYS.has(key) && isRecordValue(value) && isRecordValue(control[key]))
2612
+ control[key] = mergeGroup(control[key], value);
2613
+ else
2614
+ control[key] = value;
2615
+ }
2616
+ return { ...raw, control };
2617
+ }
2546
2618
  function removeEntry(raw, id) {
2547
2619
  return { ...raw, servers: (raw.servers ?? []).filter((entry) => entry.id !== id) };
2548
2620
  }
@@ -4167,6 +4239,9 @@ ${result.stderr}`.trim();
4167
4239
  }
4168
4240
 
4169
4241
  // src/home-hosted/panel.ts
4242
+ function isRecord2(value) {
4243
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4244
+ }
4170
4245
  var PanelError = class extends Error {
4171
4246
  constructor(message, code, status = null, detail = void 0) {
4172
4247
  super(message);
@@ -4200,6 +4275,32 @@ var PanelClient = class {
4200
4275
  const { workspaceId, ...rest } = raw;
4201
4276
  return { ...rest, workspace: workspaceId ?? workspace ?? this.workspace ?? "" };
4202
4277
  }
4278
+ /**
4279
+ * A `POST`/`PATCH` answer.
4280
+ *
4281
+ * The real panel answers the stored entry *flat* here — no `config` wrapper, no
4282
+ * `workspaceId`, no live status — while `GET` answers a view. Both are accepted:
4283
+ * a body that already carries a `config` object is a view, and anything else is
4284
+ * the entry itself. Reading a flat entry as a view is what made `created.config`
4285
+ * `undefined`, so the shape is decided here rather than assumed.
4286
+ *
4287
+ * A flat entry carries no live status, so it is reported as `unknown`: the panel
4288
+ * has not said. Callers that need one re-read the list.
4289
+ */
4290
+ written(raw, workspace) {
4291
+ const record = raw;
4292
+ if (isRecord2(record.config))
4293
+ return this.view(raw, workspace);
4294
+ const { workspaceId, id, ...config } = record;
4295
+ return {
4296
+ id: typeof id === "string" ? id : "",
4297
+ workspace: typeof workspaceId === "string" ? workspaceId : workspace ?? this.workspace ?? "",
4298
+ status: "unknown",
4299
+ pid: null,
4300
+ url: null,
4301
+ config
4302
+ };
4303
+ }
4203
4304
  ws(path17, workspace) {
4204
4305
  const id = workspace ?? this.workspace;
4205
4306
  if (id === null || id === void 0 || id.length === 0)
@@ -4249,11 +4350,11 @@ var PanelClient = class {
4249
4350
  }
4250
4351
  async createServer(entry, workspace) {
4251
4352
  const answer2 = await this.request("POST", this.ws("/api/servers", workspace), entry);
4252
- return this.view(answer2.server, workspace);
4353
+ return this.written(answer2.server, workspace);
4253
4354
  }
4254
4355
  async updateServer(id, patch, workspace) {
4255
4356
  const answer2 = await this.request("PATCH", this.ws(`/api/servers/${encodeURIComponent(id)}`, workspace), patch);
4256
- return this.view(answer2.server, workspace);
4357
+ return this.written(answer2.server, workspace);
4257
4358
  }
4258
4359
  async deleteServer(id, workspace) {
4259
4360
  await this.request("DELETE", this.ws(`/api/servers/${encodeURIComponent(id)}`, workspace));
@@ -4410,7 +4511,7 @@ var HomeHostedError = class extends Error {
4410
4511
  this.name = "HomeHostedError";
4411
4512
  }
4412
4513
  };
4413
- function isRecordValue(value) {
4514
+ function isRecordValue2(value) {
4414
4515
  return typeof value === "object" && value !== null && !Array.isArray(value);
4415
4516
  }
4416
4517
  function foreignView(entry, workspace) {
@@ -4909,7 +5010,7 @@ var HomeHostedService = class extends Service {
4909
5010
  const raw = readJson(this.snapshotsFile);
4910
5011
  if (raw === null)
4911
5012
  return {};
4912
- if (raw.version === 2 && isRecordValue(raw.entries))
5013
+ if (raw.version === 2 && isRecordValue2(raw.entries))
4913
5014
  return raw.workspace === this.managedWorkspace() ? raw.entries : {};
4914
5015
  return this.managedWorkspace() === DEFAULT_WORKSPACE ? raw : {};
4915
5016
  }
@@ -5798,6 +5899,10 @@ var HomeHostedService = class extends Service {
5798
5899
  const target = await this.targetHome(input.home);
5799
5900
  const id = String(input.id);
5800
5901
  const patch = input.patch ?? {};
5902
+ for (const key of ["restart", "health", "stop"]) {
5903
+ if (patch[key] === null)
5904
+ throw new HomeHostedError(`${key} must be an object (was null)`, "INVALID_PATCH");
5905
+ }
5801
5906
  const workspace = this.callWorkspace(input, target.home, target.foreign);
5802
5907
  if (!target.foreign)
5803
5908
  return await (await this.requireClient()).updateServer(id, patch, workspace);