dsh-home-hosted 0.7.1 → 0.7.3

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/docs/DESIGN.md CHANGED
@@ -38,7 +38,7 @@ its page.
38
38
  `onPortConflict: kill` arrived in home-hosted 0.6.0 and `persistent` in 0.6.3, and an older panel
39
39
  handed either key can refuse to *boot* from the config. The plugin used to consult the answering
40
40
  panel's version and refuse or drop them (`KILL_UNSUPPORTED`). That is gone on purpose: it pins
41
- `home-hosted@^0.7.2` and autostarts its own copy, so a panel older than those keys is only reachable by
41
+ `home-hosted@^0.7.3` and autostarts its own copy, so a panel older than those keys is only reachable by
42
42
  deliberately preferring an old global install — and this is pre-1.0. Supporting older panels again means
43
43
  bringing that check back from history, not re-deriving it.
44
44
 
package/docs/PANEL.md CHANGED
@@ -16,7 +16,8 @@ The range is stated twice and both must agree: `dependencies['home-hosted']` in
16
16
  is older). Bumping one alone makes the page recommend a range the plugin is not built against. A bump
17
17
  is therefore those two plus the version-coupled tests — *unless* the release moved something this
18
18
  plugin names: a path under `.hh`, `CONFIG_SCHEMA`, `serverSchema`, `/api/settings`, or a CLI
19
- subcommand. 0.7.2 (the panel's reverse proxy, all under `.hh/.proxy/`) moved none of them.
19
+ subcommand. 0.7.2 (the panel's reverse proxy, all under `.hh/.proxy/`) moved none of them, and neither
20
+ did 0.7.3 (DNS-01 through the panel's own DNS accounts, and a Namecheap API provider).
20
21
 
21
22
  That copy resolves to a pnpm path carrying a version and a peer hash, which moves on the next install
22
23
  and disappears when the profile is rebuilt. A boot entry that baked it in would fail exactly when it
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 {
@@ -2722,7 +2738,7 @@ import { createRequire } from "node:module";
2722
2738
  import fs11 from "node:fs";
2723
2739
  import path9 from "node:path";
2724
2740
  import process5 from "node:process";
2725
- var EXPECTED_RANGE = "^0.7.2";
2741
+ var EXPECTED_RANGE = "^0.7.3";
2726
2742
  var MIN_SUPPORTED_VERSION = "0.7.0";
2727
2743
  function pinnedManifestPath() {
2728
2744
  try {
@@ -4167,6 +4183,9 @@ ${result.stderr}`.trim();
4167
4183
  }
4168
4184
 
4169
4185
  // src/home-hosted/panel.ts
4186
+ function isRecord2(value) {
4187
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4188
+ }
4170
4189
  var PanelError = class extends Error {
4171
4190
  constructor(message, code, status = null, detail = void 0) {
4172
4191
  super(message);
@@ -4200,6 +4219,32 @@ var PanelClient = class {
4200
4219
  const { workspaceId, ...rest } = raw;
4201
4220
  return { ...rest, workspace: workspaceId ?? workspace ?? this.workspace ?? "" };
4202
4221
  }
4222
+ /**
4223
+ * A `POST`/`PATCH` answer.
4224
+ *
4225
+ * The real panel answers the stored entry *flat* here — no `config` wrapper, no
4226
+ * `workspaceId`, no live status — while `GET` answers a view. Both are accepted:
4227
+ * a body that already carries a `config` object is a view, and anything else is
4228
+ * the entry itself. Reading a flat entry as a view is what made `created.config`
4229
+ * `undefined`, so the shape is decided here rather than assumed.
4230
+ *
4231
+ * A flat entry carries no live status, so it is reported as `unknown`: the panel
4232
+ * has not said. Callers that need one re-read the list.
4233
+ */
4234
+ written(raw, workspace) {
4235
+ const record = raw;
4236
+ if (isRecord2(record.config))
4237
+ return this.view(raw, workspace);
4238
+ const { workspaceId, id, ...config } = record;
4239
+ return {
4240
+ id: typeof id === "string" ? id : "",
4241
+ workspace: typeof workspaceId === "string" ? workspaceId : workspace ?? this.workspace ?? "",
4242
+ status: "unknown",
4243
+ pid: null,
4244
+ url: null,
4245
+ config
4246
+ };
4247
+ }
4203
4248
  ws(path17, workspace) {
4204
4249
  const id = workspace ?? this.workspace;
4205
4250
  if (id === null || id === void 0 || id.length === 0)
@@ -4249,11 +4294,11 @@ var PanelClient = class {
4249
4294
  }
4250
4295
  async createServer(entry, workspace) {
4251
4296
  const answer2 = await this.request("POST", this.ws("/api/servers", workspace), entry);
4252
- return this.view(answer2.server, workspace);
4297
+ return this.written(answer2.server, workspace);
4253
4298
  }
4254
4299
  async updateServer(id, patch, workspace) {
4255
4300
  const answer2 = await this.request("PATCH", this.ws(`/api/servers/${encodeURIComponent(id)}`, workspace), patch);
4256
- return this.view(answer2.server, workspace);
4301
+ return this.written(answer2.server, workspace);
4257
4302
  }
4258
4303
  async deleteServer(id, workspace) {
4259
4304
  await this.request("DELETE", this.ws(`/api/servers/${encodeURIComponent(id)}`, workspace));