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 +45 -0
- package/docs/DESIGN.md +1 -1
- package/docs/PANEL.md +2 -1
- package/lib/index.js +60 -15
- package/lib/index.js.map +3 -3
- package/lib/types/boot/escape.d.ts +51 -6
- package/lib/types/home-hosted/panel.d.ts +13 -0
- package/lib/types/home-hosted/resolve.d.ts +1 -1
- package/package.json +2 -2
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1513
|
-
`
|
|
1528
|
+
desktopValue("Name", spec.label),
|
|
1529
|
+
desktopValue("Comment", `home-hosted daemon, managed by ${spec.marker}`),
|
|
1514
1530
|
`Exec=${desktopExec(spec.command, spec.args)}`,
|
|
1515
|
-
|
|
1531
|
+
desktopValue("Path", spec.cwd),
|
|
1516
1532
|
"Terminal=false",
|
|
1517
1533
|
"X-GNOME-Autostart-enabled=true",
|
|
1518
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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));
|