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 +45 -0
- package/lib/index.js +126 -21
- package/lib/index.js.map +3 -3
- package/lib/types/boot/escape.d.ts +51 -6
- package/lib/types/home-hosted/config-file.d.ts +23 -1
- package/lib/types/home-hosted/panel.d.ts +13 -0
- package/package.json +1 -1
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
|
-
|
|
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 {
|
|
@@ -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 (
|
|
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,
|
|
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
|
|
2540
|
-
|
|
2541
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 &&
|
|
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);
|