@danypops/pi-packed 0.28.5 → 0.28.7
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/README.md +6 -0
- package/dist/daemon/daemon-service.d.ts +6 -1
- package/dist/daemon/daemon-service.d.ts.map +1 -1
- package/dist/daemon/isolated-service-install.d.ts +33 -0
- package/dist/daemon/isolated-service-install.d.ts.map +1 -0
- package/package.json +4 -4
- package/service/src/daemon/daemon-service.ts +47 -8
- package/service/src/daemon/daemon.ts +4 -1
- package/service/src/daemon/isolated-service-install.ts +196 -0
- package/service/test/daemon-service.test.ts +47 -2
- package/service/test/isolated-service-install.test.ts +148 -0
package/README.md
CHANGED
|
@@ -8,6 +8,12 @@ pi install npm:@danypops/pi-packed
|
|
|
8
8
|
|
|
9
9
|
The extension connects to Packed's authenticated user daemon and starts the package-local `packed serve` process when no healthy daemon is available. It contains no SQLite, registry, package execution, or daemon implementation code.
|
|
10
10
|
|
|
11
|
+
## Isolated daemon services
|
|
12
|
+
|
|
13
|
+
Packed materializes every managed daemon into a private, versioned npm project under its state directory (`services/<package>/<version>/`). Each root has its own `package.json`, lockfile, and `node_modules`; Pi's shared global project, root-level `overrides`, hoisting, and unrelated package updates therefore cannot rewrite a daemon's dependency closure. Downloads still use npm's shared cache.
|
|
14
|
+
|
|
15
|
+
A complete root is activated only after installation and manifest validation, using an atomic directory rename. Armada switches the persistent service to that root, and Packed retains the previous root for rollback while pruning older versions within a fixed bound. Removing the service removes its private roots. Pi extensions remain installed in Pi's own package project; only their daemon processes run from the isolated closure.
|
|
16
|
+
|
|
11
17
|
## Commands
|
|
12
18
|
|
|
13
19
|
- `/packed` -- one floating overlay, four tabs (Packages/Find/Config/Settings), always shown in a persistent tab bar at the top: the focused tab reverses the theme's text color for high contrast in both dark and light modes, unfocused tabs show only a foreground color with their own first letter highlighted as a jump mnemonic (the focused tab needs no mnemonic -- there's nothing to jump to when you're already there). `Tab`/`Shift-Tab` (or `←`/`→`) sweep between them -- recognized under legacy CSI, xterm's modifyOtherKeys, and the Kitty keyboard protocol alike, not just one hardcoded encoding; from any tab other than Packages, `p`/`f`/`c`/`s` also jump straight to the matching one (never stolen while that tab's own filter/query box is capturing text). `Esc` returns to Packages from anywhere else, or closes the panel from Packages itself. Every approval and reload confirmation, on any tab, renders inline on this same panel (`y`/`n` keys), never a separate popup or native dialog. A standing test (`keymap.test.ts`, using Malevich's tree-style `findMnemonicConflicts`) verifies no two actions genuinely reachable at once share a key.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type VehicleRegistrar, type VehicleRegistrationOutcome, type VehicleSpec } from "@danypops/armada";
|
|
2
2
|
import { type ServiceInstallResult, type ServiceSpec } from "@danypops/vehicle-server/service";
|
|
3
3
|
import type { InstalledPkg } from "../packages/package.ts";
|
|
4
|
+
import { type DaemonPackageMaterializer } from "./isolated-service-install.ts";
|
|
4
5
|
export interface DaemonServiceManifest {
|
|
5
6
|
/** Relative to the installed package's own root directory. */
|
|
6
7
|
binPath: string;
|
|
@@ -234,6 +235,8 @@ export interface DaemonServiceInstaller {
|
|
|
234
235
|
* whatever used to produce it.
|
|
235
236
|
*/
|
|
236
237
|
unregisterVehicleByName(name: string): Promise<VehicleRegistrationOutcome>;
|
|
238
|
+
/** True when an executable lives in this installer's private managed-service roots. */
|
|
239
|
+
ownsExecutable?(path: string): boolean;
|
|
237
240
|
}
|
|
238
241
|
/**
|
|
239
242
|
* Calls Armada's own VehicleRegistrar directly (in-process), rather than
|
|
@@ -245,9 +248,11 @@ export interface DaemonServiceInstaller {
|
|
|
245
248
|
export declare class RealDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
246
249
|
private readonly registrar;
|
|
247
250
|
private readonly selfRestart;
|
|
248
|
-
|
|
251
|
+
private readonly materializer;
|
|
252
|
+
constructor(registrar?: VehicleRegistrar, selfRestart?: SelfRestartScheduler, materializer?: DaemonPackageMaterializer);
|
|
249
253
|
listRegisteredVehicles(): Promise<readonly VehicleSpec[]>;
|
|
250
254
|
unregisterVehicleByName(name: string): Promise<VehicleRegistrationOutcome>;
|
|
255
|
+
ownsExecutable(path: string): boolean;
|
|
251
256
|
install(piHome: string, source: string): Promise<{
|
|
252
257
|
ok: true;
|
|
253
258
|
result: ServiceInstallResult;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon-service.d.ts","sourceRoot":"","sources":["../../service/src/daemon/daemon-service.ts"],"names":[],"mappings":"AA+BA,OAAO,EAA0B,KAAK,gBAAgB,EAAE,KAAK,0BAA0B,EAAE,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpI,OAAO,EAGN,KAAK,oBAAoB,EACzB,KAAK,WAAW,EAEhB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"daemon-service.d.ts","sourceRoot":"","sources":["../../service/src/daemon/daemon-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface MaterializedDaemonPackage {
|
|
2
|
+
readonly piHome: string;
|
|
3
|
+
readonly source: string;
|
|
4
|
+
commit(): void;
|
|
5
|
+
rollback(): void;
|
|
6
|
+
}
|
|
7
|
+
export interface DaemonPackageMaterializer {
|
|
8
|
+
materialize(piHome: string, source: string): Promise<MaterializedDaemonPackage>;
|
|
9
|
+
remove(source: string): void;
|
|
10
|
+
ownsExecutable(path: string): boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare class PassThroughDaemonPackageMaterializer implements DaemonPackageMaterializer {
|
|
13
|
+
materialize(piHome: string, source: string): Promise<MaterializedDaemonPackage>;
|
|
14
|
+
remove(_source: string): void;
|
|
15
|
+
ownsExecutable(_path: string): boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Installs a daemon source into a private, versioned npm project. The project deliberately has
|
|
19
|
+
* no `overrides` field, so a host-level Pi/Bun override cannot rewrite the daemon's transitive
|
|
20
|
+
* dependency closure. npm's cache remains shared; only resolution metadata and node_modules are
|
|
21
|
+
* private. A completed directory is published with one atomic rename and retained alongside one
|
|
22
|
+
* prior version for rollback.
|
|
23
|
+
*/
|
|
24
|
+
export declare class IsolatedDaemonPackageMaterializer implements DaemonPackageMaterializer {
|
|
25
|
+
private readonly stateDirectory;
|
|
26
|
+
private readonly npmBin;
|
|
27
|
+
constructor(stateDirectory: string, npmBin?: string);
|
|
28
|
+
materialize(piHome: string, source: string): Promise<MaterializedDaemonPackage>;
|
|
29
|
+
remove(source: string): void;
|
|
30
|
+
ownsExecutable(path: string): boolean;
|
|
31
|
+
private prune;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=isolated-service-install.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"isolated-service-install.d.ts","sourceRoot":"","sources":["../../service/src/daemon/isolated-service-install.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,yBAAyB;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,MAAM,IAAI,IAAI,CAAC;IACf,QAAQ,IAAI,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,yBAAyB;IACzC,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAChF,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;CACtC;AA4CD,qBAAa,oCAAqC,YAAW,yBAAyB;IAC/E,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,CAAC;IAIrF,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAE7B,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO;CAGtC;AAED;;;;;;GAMG;AACH,qBAAa,iCAAkC,YAAW,yBAAyB;IAEjF,OAAO,CAAC,QAAQ,CAAC,cAAc;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,cAAc,EAAE,MAAM,EACtB,MAAM,SAAgE;IAGlF,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,yBAAyB,CAAC;IA+ErF,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAM5B,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAIrC,OAAO,CAAC,KAAK;CAiBb"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-packed",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.7",
|
|
4
4
|
"description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,9 +30,9 @@
|
|
|
30
30
|
"@danypops/armada": "^0.5.0",
|
|
31
31
|
"@danypops/packed": "^0.7.0",
|
|
32
32
|
"@danypops/pi-extension-harness": "^0.7.0",
|
|
33
|
-
"@danypops/vehicle-client": "^0.10.
|
|
34
|
-
"@danypops/vehicle-core": "^0.
|
|
35
|
-
"@danypops/vehicle-server": "^0.
|
|
33
|
+
"@danypops/vehicle-client": "^0.10.5",
|
|
34
|
+
"@danypops/vehicle-core": "^0.18.4",
|
|
35
|
+
"@danypops/vehicle-server": "^0.26.0",
|
|
36
36
|
"jiti": "^2.7.0",
|
|
37
37
|
"malevich-tui-components": "^0.32.1",
|
|
38
38
|
"publint": "0.3.22",
|
|
@@ -41,6 +41,10 @@ import {
|
|
|
41
41
|
import { npmPackageName, readInstalledPackagesAcrossScopes, readPackageDeclarations } from "../packages/installed.ts";
|
|
42
42
|
import type { InstalledPkg } from "../packages/package.ts";
|
|
43
43
|
import { versionAtLeast } from "../publish/publish.ts";
|
|
44
|
+
import {
|
|
45
|
+
type DaemonPackageMaterializer,
|
|
46
|
+
PassThroughDaemonPackageMaterializer,
|
|
47
|
+
} from "./isolated-service-install.ts";
|
|
44
48
|
|
|
45
49
|
export interface DaemonServiceManifest {
|
|
46
50
|
/** Relative to the installed package's own root directory. */
|
|
@@ -612,7 +616,7 @@ export async function reconcileAllDaemonServices(
|
|
|
612
616
|
async function pruneStaleVehicles(
|
|
613
617
|
piHome: string,
|
|
614
618
|
discovered: ReadonlySet<string>,
|
|
615
|
-
installer: Pick<DaemonServiceInstaller, "listRegisteredVehicles" | "unregisterVehicleByName">,
|
|
619
|
+
installer: Pick<DaemonServiceInstaller, "listRegisteredVehicles" | "unregisterVehicleByName" | "ownsExecutable">,
|
|
616
620
|
): Promise<Pick<ReconcileAllResult, "pruned" | "pruneFailed">> {
|
|
617
621
|
const managedRoot = join(piHome, "npm", "node_modules") + "/";
|
|
618
622
|
const pruned: ReconcileAllResult["pruned"] = [];
|
|
@@ -621,7 +625,7 @@ async function pruneStaleVehicles(
|
|
|
621
625
|
for (const vehicle of registered) {
|
|
622
626
|
if (vehicle.name === PACKED_VEHICLE_NAME) continue;
|
|
623
627
|
if (discovered.has(vehicle.name)) continue;
|
|
624
|
-
if (!vehicle.executable.startsWith(managedRoot)) continue;
|
|
628
|
+
if (!vehicle.executable.startsWith(managedRoot) && !installer.ownsExecutable?.(vehicle.executable)) continue;
|
|
625
629
|
const outcome = await installer.unregisterVehicleByName(vehicle.name);
|
|
626
630
|
if (outcome.ok) pruned.push({ vehicleName: vehicle.name, executable: vehicle.executable });
|
|
627
631
|
else pruneFailed.push({ vehicleName: vehicle.name, reason: outcome.diagnostics.map((d) => d.message).join("; ") || "unregister failed" });
|
|
@@ -651,6 +655,8 @@ export interface DaemonServiceInstaller {
|
|
|
651
655
|
* whatever used to produce it.
|
|
652
656
|
*/
|
|
653
657
|
unregisterVehicleByName(name: string): Promise<VehicleRegistrationOutcome>;
|
|
658
|
+
/** True when an executable lives in this installer's private managed-service roots. */
|
|
659
|
+
ownsExecutable?(path: string): boolean;
|
|
654
660
|
}
|
|
655
661
|
|
|
656
662
|
/**
|
|
@@ -664,6 +670,7 @@ export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
|
664
670
|
constructor(
|
|
665
671
|
private readonly registrar: VehicleRegistrar = createVehicleRegistrar(),
|
|
666
672
|
private readonly selfRestart: SelfRestartScheduler = scheduleSelfRestart,
|
|
673
|
+
private readonly materializer: DaemonPackageMaterializer = new PassThroughDaemonPackageMaterializer(),
|
|
667
674
|
) {}
|
|
668
675
|
|
|
669
676
|
async listRegisteredVehicles(): Promise<readonly VehicleSpec[]> {
|
|
@@ -674,16 +681,35 @@ export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
|
674
681
|
return this.registrar.unregister(name);
|
|
675
682
|
}
|
|
676
683
|
|
|
684
|
+
ownsExecutable(path: string): boolean {
|
|
685
|
+
return this.materializer.ownsExecutable(path);
|
|
686
|
+
}
|
|
687
|
+
|
|
677
688
|
async install(
|
|
678
689
|
piHome: string,
|
|
679
690
|
source: string,
|
|
680
691
|
): Promise<{ ok: true; result: ServiceInstallResult; spec: ServiceSpec } | { ok: false; reason: string; notADaemon?: boolean }> {
|
|
681
|
-
const
|
|
682
|
-
if (!
|
|
683
|
-
if (
|
|
684
|
-
return { ok: true, result: { installed: false, reason: SELF_REGISTRATION_REASON }, spec:
|
|
692
|
+
const discovered = resolveDaemonServiceSpec(piHome, source);
|
|
693
|
+
if (!discovered.ok) return discovered;
|
|
694
|
+
if (discovered.spec.name === PACKED_VEHICLE_NAME) {
|
|
695
|
+
return { ok: true, result: { installed: false, reason: SELF_REGISTRATION_REASON }, spec: discovered.spec };
|
|
696
|
+
}
|
|
697
|
+
let materialized;
|
|
698
|
+
try {
|
|
699
|
+
materialized = await this.materializer.materialize(piHome, source);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
702
|
+
}
|
|
703
|
+
const resolved = resolveDaemonServiceSpec(materialized.piHome, materialized.source);
|
|
704
|
+
if (!resolved.ok) {
|
|
705
|
+
materialized.rollback();
|
|
706
|
+
return resolved;
|
|
685
707
|
}
|
|
686
708
|
const result = await registerVehicleService(resolved.spec, this.registrar);
|
|
709
|
+
// Armada may have started the process before a later readiness/reconciliation step reports
|
|
710
|
+
// failure. Never delete an executable after registration has observed it; retain the candidate
|
|
711
|
+
// for diagnosis/retry and prune old roots only after an unambiguous success.
|
|
712
|
+
if (result.installed) materialized.commit();
|
|
687
713
|
return { ok: true, result, spec: resolved.spec };
|
|
688
714
|
}
|
|
689
715
|
|
|
@@ -697,6 +723,7 @@ export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
|
697
723
|
return { ok: true, result: { installed: false, reason: SELF_REGISTRATION_REASON }, spec: resolved.spec };
|
|
698
724
|
}
|
|
699
725
|
const result = await unregisterVehicleService(resolved.spec.name, this.registrar);
|
|
726
|
+
if (result.installed) this.materializer.remove(source);
|
|
700
727
|
return { ok: true, result, spec: resolved.spec };
|
|
701
728
|
}
|
|
702
729
|
|
|
@@ -713,12 +740,24 @@ export class RealDaemonServiceInstaller implements DaemonServiceInstaller {
|
|
|
713
740
|
if (!(await isVehicleServiceRegistered(resolved.spec.name, this.registrar))) {
|
|
714
741
|
return { ok: true, restarted: false, reason: `no persistent service is registered for ${resolved.spec.name}`, spec: resolved.spec };
|
|
715
742
|
}
|
|
716
|
-
|
|
743
|
+
let materialized;
|
|
744
|
+
try {
|
|
745
|
+
materialized = await this.materializer.materialize(piHome, source);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
748
|
+
}
|
|
749
|
+
const isolated = resolveDaemonServiceSpec(materialized.piHome, materialized.source);
|
|
750
|
+
if (!isolated.ok) {
|
|
751
|
+
materialized.rollback();
|
|
752
|
+
return isolated;
|
|
753
|
+
}
|
|
754
|
+
const result = await registerVehicleService(isolated.spec, this.registrar);
|
|
755
|
+
if (result.installed) materialized.commit();
|
|
717
756
|
return {
|
|
718
757
|
ok: true,
|
|
719
758
|
restarted: result.installed,
|
|
720
759
|
...(result.installed ? {} : { reason: result.reason }),
|
|
721
|
-
spec:
|
|
760
|
+
spec: isolated.spec,
|
|
722
761
|
};
|
|
723
762
|
}
|
|
724
763
|
}
|
|
@@ -36,6 +36,7 @@ import { createLogger } from "../shared/log.ts";
|
|
|
36
36
|
import { legacyPackedStateDirectory, migrateLegacyPackedState, type PackedPaths, resolvePackedPaths } from "../shared/paths.ts";
|
|
37
37
|
import { envMs } from "../shared/state.ts";
|
|
38
38
|
import { createApp } from "./service.ts";
|
|
39
|
+
import { IsolatedDaemonPackageMaterializer } from "./isolated-service-install.ts";
|
|
39
40
|
import { checkUpdates, saveUpdates } from "./watcher.ts";
|
|
40
41
|
|
|
41
42
|
const logger = createLogger("daemon");
|
|
@@ -89,7 +90,9 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
|
|
|
89
90
|
// already uses via checkUpdates() -- one mirror, two consumers, never two
|
|
90
91
|
// notions of "the real latest version".
|
|
91
92
|
const inst = options.inst ?? new ExecInstaller(undefined, piHome, undefined, undefined, undefined, (name) => latestVersion(database, name));
|
|
92
|
-
const daemonServiceInstaller =
|
|
93
|
+
const daemonServiceInstaller =
|
|
94
|
+
options.daemonServiceInstaller ??
|
|
95
|
+
new RealDaemonServiceInstaller(undefined, undefined, new IsolatedDaemonPackageMaterializer(paths.stateDirectory));
|
|
93
96
|
const configuredMaintenanceTasks = options.maintenanceTasks ?? [
|
|
94
97
|
{
|
|
95
98
|
name: "package-update-check",
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, sep } from "node:path";
|
|
3
|
+
import { npmPackageName } from "../packages/installed.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_VERSION_DIRECTORIES = 20;
|
|
6
|
+
const RETAINED_VERSION_DIRECTORIES = 2;
|
|
7
|
+
const MAX_INSTALL_OUTPUT_BYTES = 64 * 1024;
|
|
8
|
+
const VERSION_DIRECTORY_RE = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$/;
|
|
9
|
+
|
|
10
|
+
export interface MaterializedDaemonPackage {
|
|
11
|
+
readonly piHome: string;
|
|
12
|
+
readonly source: string;
|
|
13
|
+
commit(): void;
|
|
14
|
+
rollback(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DaemonPackageMaterializer {
|
|
18
|
+
materialize(piHome: string, source: string): Promise<MaterializedDaemonPackage>;
|
|
19
|
+
remove(source: string): void;
|
|
20
|
+
ownsExecutable(path: string): boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readVersion(packageJsonPath: string): string | undefined {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
|
26
|
+
return typeof parsed.version === "string" && parsed.version.length > 0 ? parsed.version : undefined;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readBounded(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
33
|
+
const reader = stream.getReader();
|
|
34
|
+
const decoder = new TextDecoder();
|
|
35
|
+
let output = "";
|
|
36
|
+
let retainedBytes = 0;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done) break;
|
|
40
|
+
if (retainedBytes >= MAX_INSTALL_OUTPUT_BYTES) continue;
|
|
41
|
+
const remaining = MAX_INSTALL_OUTPUT_BYTES - retainedBytes;
|
|
42
|
+
const retained = value.byteLength <= remaining ? value : value.slice(0, remaining);
|
|
43
|
+
output += decoder.decode(retained, { stream: true });
|
|
44
|
+
retainedBytes += retained.byteLength;
|
|
45
|
+
}
|
|
46
|
+
return output + decoder.decode();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function packageDirectory(piHome: string, packageName: string): string {
|
|
50
|
+
return join(piHome, "npm", "node_modules", packageName);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function installKey(packageName: string): string {
|
|
54
|
+
return encodeURIComponent(packageName).replaceAll("%", "_");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isolatedPiHome(versionDirectory: string): string {
|
|
58
|
+
return join(versionDirectory, "pi-home");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolvedPackageJson(piHome: string, packageName: string): string {
|
|
62
|
+
return join(packageDirectory(piHome, packageName), "package.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class PassThroughDaemonPackageMaterializer implements DaemonPackageMaterializer {
|
|
66
|
+
async materialize(piHome: string, source: string): Promise<MaterializedDaemonPackage> {
|
|
67
|
+
return { piHome, source, commit() {}, rollback() {} };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
remove(_source: string): void {}
|
|
71
|
+
|
|
72
|
+
ownsExecutable(_path: string): boolean {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Installs a daemon source into a private, versioned npm project. The project deliberately has
|
|
79
|
+
* no `overrides` field, so a host-level Pi/Bun override cannot rewrite the daemon's transitive
|
|
80
|
+
* dependency closure. npm's cache remains shared; only resolution metadata and node_modules are
|
|
81
|
+
* private. A completed directory is published with one atomic rename and retained alongside one
|
|
82
|
+
* prior version for rollback.
|
|
83
|
+
*/
|
|
84
|
+
export class IsolatedDaemonPackageMaterializer implements DaemonPackageMaterializer {
|
|
85
|
+
constructor(
|
|
86
|
+
private readonly stateDirectory: string,
|
|
87
|
+
private readonly npmBin = process.env.PI_PACKED_NPM_BIN ?? process.env.NPM_BIN ?? "npm",
|
|
88
|
+
) {}
|
|
89
|
+
|
|
90
|
+
async materialize(piHome: string, source: string): Promise<MaterializedDaemonPackage> {
|
|
91
|
+
const packageName = npmPackageName(source);
|
|
92
|
+
if (!packageName) throw new Error(`isolated daemon installation only supports npm sources: ${source}`);
|
|
93
|
+
const version = readVersion(resolvedPackageJson(piHome, packageName));
|
|
94
|
+
if (!version) throw new Error(`cannot isolate ${packageName}: installed package version is unreadable`);
|
|
95
|
+
if (!VERSION_DIRECTORY_RE.test(version)) throw new Error(`cannot isolate ${packageName}: installed package version is unsafe`);
|
|
96
|
+
|
|
97
|
+
const packageRoot = join(this.stateDirectory, "services", installKey(packageName));
|
|
98
|
+
const finalDirectory = join(packageRoot, version);
|
|
99
|
+
const finalPiHome = isolatedPiHome(finalDirectory);
|
|
100
|
+
const finalPackageJson = resolvedPackageJson(finalPiHome, packageName);
|
|
101
|
+
if (readVersion(finalPackageJson) === version) {
|
|
102
|
+
return {
|
|
103
|
+
piHome: finalPiHome,
|
|
104
|
+
source: `npm:${packageName}@${version}`,
|
|
105
|
+
commit: () => this.prune(packageRoot, finalDirectory),
|
|
106
|
+
rollback() {},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
mkdirSync(packageRoot, { recursive: true, mode: 0o700 });
|
|
111
|
+
if (existsSync(finalDirectory)) rmSync(finalDirectory, { recursive: true, force: true });
|
|
112
|
+
const temporaryDirectory = join(packageRoot, `.${version}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
113
|
+
const temporaryPiHome = isolatedPiHome(temporaryDirectory);
|
|
114
|
+
const projectDirectory = join(temporaryPiHome, "npm");
|
|
115
|
+
mkdirSync(projectDirectory, { recursive: true, mode: 0o700 });
|
|
116
|
+
writeFileSync(
|
|
117
|
+
join(projectDirectory, "package.json"),
|
|
118
|
+
JSON.stringify({ name: `packed-service-${installKey(packageName)}`, private: true, dependencies: { [packageName]: version } }, null, 2),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
let publishedByThisCall = false;
|
|
122
|
+
try {
|
|
123
|
+
const proc = Bun.spawn(
|
|
124
|
+
[
|
|
125
|
+
this.npmBin,
|
|
126
|
+
"install",
|
|
127
|
+
"--ignore-scripts",
|
|
128
|
+
"--no-audit",
|
|
129
|
+
"--no-fund",
|
|
130
|
+
"--install-strategy=nested",
|
|
131
|
+
"--loglevel=error",
|
|
132
|
+
],
|
|
133
|
+
{ cwd: projectDirectory, stdout: "pipe", stderr: "pipe", env: process.env },
|
|
134
|
+
);
|
|
135
|
+
const [stdout, stderr, code] = await Promise.all([readBounded(proc.stdout), readBounded(proc.stderr), proc.exited]);
|
|
136
|
+
if (code !== 0) {
|
|
137
|
+
const output = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
|
|
138
|
+
throw new Error(`isolated npm install failed for ${packageName}@${version} (exit ${code}): ${output || "no output"}`);
|
|
139
|
+
}
|
|
140
|
+
if (readVersion(resolvedPackageJson(temporaryPiHome, packageName)) !== version) {
|
|
141
|
+
throw new Error(`isolated npm install did not resolve ${packageName}@${version}`);
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
renameSync(temporaryDirectory, finalDirectory);
|
|
145
|
+
publishedByThisCall = true;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (readVersion(finalPackageJson) !== version) throw error;
|
|
148
|
+
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let committed = false;
|
|
156
|
+
return {
|
|
157
|
+
piHome: finalPiHome,
|
|
158
|
+
source: `npm:${packageName}@${version}`,
|
|
159
|
+
commit: () => {
|
|
160
|
+
committed = true;
|
|
161
|
+
this.prune(packageRoot, finalDirectory);
|
|
162
|
+
},
|
|
163
|
+
rollback: () => {
|
|
164
|
+
if (!committed && publishedByThisCall) rmSync(finalDirectory, { recursive: true, force: true });
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
remove(source: string): void {
|
|
170
|
+
const packageName = npmPackageName(source);
|
|
171
|
+
if (!packageName) return;
|
|
172
|
+
rmSync(join(this.stateDirectory, "services", installKey(packageName)), { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
ownsExecutable(path: string): boolean {
|
|
176
|
+
return path.startsWith(join(this.stateDirectory, "services") + sep);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private prune(packageRoot: string, activeDirectory: string): void {
|
|
180
|
+
let directories: string[];
|
|
181
|
+
try {
|
|
182
|
+
directories = readdirSync(packageRoot)
|
|
183
|
+
.filter((name) => !name.startsWith("."))
|
|
184
|
+
.slice(0, MAX_VERSION_DIRECTORIES)
|
|
185
|
+
.map((name) => join(packageRoot, name))
|
|
186
|
+
.filter((path) => statSync(path).isDirectory());
|
|
187
|
+
} catch {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
directories.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs);
|
|
191
|
+
const retained = new Set([activeDirectory, ...directories.slice(0, RETAINED_VERSION_DIRECTORIES)]);
|
|
192
|
+
for (const directory of directories) {
|
|
193
|
+
if (!retained.has(directory)) rmSync(directory, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { VehicleRegistrar } from "@danypops/armada";
|
|
6
6
|
import { createArmadaTestHarness } from "@danypops/armada/testing";
|
|
7
|
+
import type { DaemonPackageMaterializer } from "../src/daemon/isolated-service-install.ts";
|
|
7
8
|
import {
|
|
8
9
|
classifyUpdateSource,
|
|
9
10
|
detectVehicleDaemonService,
|
|
@@ -490,6 +491,38 @@ describe("RealDaemonServiceInstaller -- Armada registration through the in-proce
|
|
|
490
491
|
}
|
|
491
492
|
});
|
|
492
493
|
|
|
494
|
+
it("install() registers the isolated executable and commits that root only after Armada accepts it", async () => {
|
|
495
|
+
const piHome = fakePiHome();
|
|
496
|
+
const isolatedPiHome = fakePiHome();
|
|
497
|
+
writePackage(piHome, "@danypops/lector", { binPath: "src/cli.ts", args: ["serve"] });
|
|
498
|
+
writePackage(isolatedPiHome, "@danypops/lector", { binPath: "src/cli.ts", args: ["serve"] });
|
|
499
|
+
let committed = 0;
|
|
500
|
+
let rolledBack = 0;
|
|
501
|
+
const materializer: DaemonPackageMaterializer = {
|
|
502
|
+
materialize: async () => ({
|
|
503
|
+
piHome: isolatedPiHome,
|
|
504
|
+
source: "npm:@danypops/lector@1.0.0",
|
|
505
|
+
commit: () => committed++,
|
|
506
|
+
rollback: () => rolledBack++,
|
|
507
|
+
}),
|
|
508
|
+
remove() {},
|
|
509
|
+
ownsExecutable: (path) => path.startsWith(isolatedPiHome),
|
|
510
|
+
};
|
|
511
|
+
const harness = await createArmadaTestHarness();
|
|
512
|
+
try {
|
|
513
|
+
const installer = new RealDaemonServiceInstaller(harness.registrar, undefined, materializer);
|
|
514
|
+
const outcome = await installer.install(piHome, "npm:@danypops/lector");
|
|
515
|
+
|
|
516
|
+
expect(outcome.ok).toBe(true);
|
|
517
|
+
if (!outcome.ok) throw new Error("expected ok");
|
|
518
|
+
expect(outcome.spec.binPath).toBe(join(isolatedPiHome, "npm", "node_modules", "@danypops/lector", "src/cli.ts"));
|
|
519
|
+
expect(committed).toBe(1);
|
|
520
|
+
expect(rolledBack).toBe(0);
|
|
521
|
+
} finally {
|
|
522
|
+
await harness.dispose();
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
493
526
|
it("install() never asks Armada to replace Packed from inside Packed's own process", async () => {
|
|
494
527
|
const piHome = fakePiHome();
|
|
495
528
|
writePackage(piHome, "@danypops/pi-packed", { binPath: "service/src/cli/cli.ts", args: ["serve"] });
|
|
@@ -639,7 +672,7 @@ describe("RealDaemonServiceInstaller -- Armada registration through the in-proce
|
|
|
639
672
|
}
|
|
640
673
|
});
|
|
641
674
|
|
|
642
|
-
it("surfaces a failed Armada registration's diagnostics
|
|
675
|
+
it("surfaces a failed Armada registration's diagnostics without deleting the executable Armada may already have started", async () => {
|
|
643
676
|
const piHome = fakePiHome();
|
|
644
677
|
writePackage(piHome, "@danypops/web-spider-daemon", { binPath: "dist/cli.js", args: ["serve"] });
|
|
645
678
|
const registrar: VehicleRegistrar = {
|
|
@@ -648,10 +681,22 @@ describe("RealDaemonServiceInstaller -- Armada registration through the in-proce
|
|
|
648
681
|
isRegistered: async () => false,
|
|
649
682
|
listRegistered: async () => [],
|
|
650
683
|
};
|
|
651
|
-
|
|
684
|
+
let rolledBack = 0;
|
|
685
|
+
const materializer: DaemonPackageMaterializer = {
|
|
686
|
+
materialize: async (_home, source) => ({
|
|
687
|
+
piHome,
|
|
688
|
+
source,
|
|
689
|
+
commit() {},
|
|
690
|
+
rollback: () => rolledBack++,
|
|
691
|
+
}),
|
|
692
|
+
remove() {},
|
|
693
|
+
ownsExecutable: () => true,
|
|
694
|
+
};
|
|
695
|
+
const installer = new RealDaemonServiceInstaller(registrar, undefined, materializer);
|
|
652
696
|
|
|
653
697
|
const outcome = await installer.install(piHome, "npm:@danypops/web-spider-daemon");
|
|
654
698
|
|
|
655
699
|
expect(outcome).toMatchObject({ ok: true, result: { installed: false, reason: "native failure" } });
|
|
700
|
+
expect(rolledBack).toBe(0);
|
|
656
701
|
});
|
|
657
702
|
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { IsolatedDaemonPackageMaterializer } from "../src/daemon/isolated-service-install.ts";
|
|
6
|
+
|
|
7
|
+
const roots: string[] = [];
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function temporaryRoot(): string {
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), "packed-isolated-service-"));
|
|
14
|
+
roots.push(root);
|
|
15
|
+
return root;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeInstalledPackage(piHome: string, name: string, version: string): void {
|
|
19
|
+
const directory = join(piHome, "npm", "node_modules", name);
|
|
20
|
+
mkdirSync(directory, { recursive: true });
|
|
21
|
+
writeFileSync(join(directory, "package.json"), JSON.stringify({ name, version }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeFakeNpm(root: string, vehicleVersions: string | Record<string, string> = "0.10.3"): string {
|
|
25
|
+
const path = join(root, `fake-npm-${crypto.randomUUID()}`);
|
|
26
|
+
writeFileSync(
|
|
27
|
+
path,
|
|
28
|
+
`#!/usr/bin/env bun
|
|
29
|
+
const fs = require("node:fs");
|
|
30
|
+
const path = require("node:path");
|
|
31
|
+
const project = JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8"));
|
|
32
|
+
if (project.overrides) throw new Error("host overrides leaked into isolated project");
|
|
33
|
+
const [name, version] = Object.entries(project.dependencies)[0];
|
|
34
|
+
const vehicleVersions = ${JSON.stringify(vehicleVersions)};
|
|
35
|
+
const vehicleVersion = typeof vehicleVersions === "string" ? vehicleVersions : vehicleVersions[name];
|
|
36
|
+
if (!vehicleVersion) throw new Error("missing fake Vehicle version for " + name);
|
|
37
|
+
const packageDir = path.join(process.cwd(), "node_modules", name);
|
|
38
|
+
fs.mkdirSync(packageDir, { recursive: true });
|
|
39
|
+
fs.writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ name, version, packed: { daemonService: { binPath: "src/cli.ts", args: ["serve"] } } }));
|
|
40
|
+
const vehicleDir = path.join(packageDir, "node_modules", "@danypops", "vehicle-client");
|
|
41
|
+
fs.mkdirSync(vehicleDir, { recursive: true });
|
|
42
|
+
fs.writeFileSync(path.join(vehicleDir, "package.json"), JSON.stringify({ name: "@danypops/vehicle-client", version: vehicleVersion }));
|
|
43
|
+
`,
|
|
44
|
+
);
|
|
45
|
+
chmodSync(path, 0o755);
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("IsolatedDaemonPackageMaterializer", () => {
|
|
50
|
+
it("materializes a private dependency closure that cannot inherit the host project's incompatible override", async () => {
|
|
51
|
+
const root = temporaryRoot();
|
|
52
|
+
const piHome = join(root, "pi");
|
|
53
|
+
const stateDirectory = join(root, "state");
|
|
54
|
+
mkdirSync(join(piHome, "npm"), { recursive: true });
|
|
55
|
+
writeFileSync(
|
|
56
|
+
join(piHome, "npm", "package.json"),
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
dependencies: { "@danypops/lector": "0.19.21" },
|
|
59
|
+
overrides: { "@danypops/vehicle-client": "0.6.2" },
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
writeInstalledPackage(piHome, "@danypops/lector", "0.19.21");
|
|
63
|
+
writeInstalledPackage(piHome, "@danypops/vehicle-client", "0.6.2");
|
|
64
|
+
const materializer = new IsolatedDaemonPackageMaterializer(stateDirectory, writeFakeNpm(root));
|
|
65
|
+
|
|
66
|
+
const result = await materializer.materialize(piHome, "npm:@danypops/lector");
|
|
67
|
+
const isolatedLector = join(result.piHome, "npm", "node_modules", "@danypops", "lector");
|
|
68
|
+
const isolatedVehicle = join(isolatedLector, "node_modules", "@danypops", "vehicle-client", "package.json");
|
|
69
|
+
|
|
70
|
+
expect(JSON.parse(readFileSync(isolatedVehicle, "utf8")).version).toBe("0.10.3");
|
|
71
|
+
expect(JSON.parse(readFileSync(join(piHome, "npm", "node_modules", "@danypops", "vehicle-client", "package.json"), "utf8")).version).toBe("0.6.2");
|
|
72
|
+
expect(result.source).toBe("npm:@danypops/lector@0.19.21");
|
|
73
|
+
expect(existsSync(join(isolatedLector, "package.json"))).toBe(true);
|
|
74
|
+
expect(materializer.ownsExecutable(join(isolatedLector, "src", "cli.ts"))).toBe(true);
|
|
75
|
+
expect(materializer.ownsExecutable(join(piHome, "npm", "node_modules", "@danypops", "lector", "src", "cli.ts"))).toBe(false);
|
|
76
|
+
result.commit();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("keeps two managed daemons with incompatible shared dependency requirements in separate roots", async () => {
|
|
80
|
+
const root = temporaryRoot();
|
|
81
|
+
const piHome = join(root, "pi");
|
|
82
|
+
const stateDirectory = join(root, "state");
|
|
83
|
+
writeInstalledPackage(piHome, "daemon-old", "1.0.0");
|
|
84
|
+
writeInstalledPackage(piHome, "daemon-new", "2.0.0");
|
|
85
|
+
const materializer = new IsolatedDaemonPackageMaterializer(
|
|
86
|
+
stateDirectory,
|
|
87
|
+
writeFakeNpm(root, { "daemon-old": "0.6.2", "daemon-new": "0.10.5" }),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const oldDaemon = await materializer.materialize(piHome, "npm:daemon-old");
|
|
91
|
+
const newDaemon = await materializer.materialize(piHome, "npm:daemon-new");
|
|
92
|
+
const versionAt = (materialized: { piHome: string }, name: string) =>
|
|
93
|
+
JSON.parse(
|
|
94
|
+
readFileSync(join(materialized.piHome, "npm", "node_modules", name, "node_modules", "@danypops", "vehicle-client", "package.json"), "utf8"),
|
|
95
|
+
).version;
|
|
96
|
+
|
|
97
|
+
expect(versionAt(oldDaemon, "daemon-old")).toBe("0.6.2");
|
|
98
|
+
expect(versionAt(newDaemon, "daemon-new")).toBe("0.10.5");
|
|
99
|
+
expect(oldDaemon.piHome).not.toBe(newDaemon.piHome);
|
|
100
|
+
oldDaemon.commit();
|
|
101
|
+
newDaemon.commit();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("publishes complete roots atomically and removes an uncommitted candidate on rollback", async () => {
|
|
105
|
+
const root = temporaryRoot();
|
|
106
|
+
const piHome = join(root, "pi");
|
|
107
|
+
const stateDirectory = join(root, "state");
|
|
108
|
+
writeInstalledPackage(piHome, "daemon", "1.0.0");
|
|
109
|
+
const materializer = new IsolatedDaemonPackageMaterializer(stateDirectory, writeFakeNpm(root));
|
|
110
|
+
|
|
111
|
+
const result = await materializer.materialize(piHome, "npm:daemon");
|
|
112
|
+
const versionDirectory = join(result.piHome, "..");
|
|
113
|
+
expect(existsSync(join(result.piHome, "npm", "node_modules", "daemon", "package.json"))).toBe(true);
|
|
114
|
+
expect(readdirSync(join(stateDirectory, "services", "daemon")).some((name) => name.startsWith("."))).toBe(false);
|
|
115
|
+
|
|
116
|
+
result.rollback();
|
|
117
|
+
expect(existsSync(versionDirectory)).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("rejects an unsafe installed version before creating a service directory", async () => {
|
|
121
|
+
const root = temporaryRoot();
|
|
122
|
+
const piHome = join(root, "pi");
|
|
123
|
+
const stateDirectory = join(root, "state");
|
|
124
|
+
writeInstalledPackage(piHome, "daemon", "../../escape");
|
|
125
|
+
const materializer = new IsolatedDaemonPackageMaterializer(stateDirectory, writeFakeNpm(root));
|
|
126
|
+
|
|
127
|
+
await expect(materializer.materialize(piHome, "npm:daemon")).rejects.toThrow(/version is unsafe/);
|
|
128
|
+
expect(existsSync(join(stateDirectory, "services"))).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("reuses a committed version without invoking npm again and removes all roots on uninstall", async () => {
|
|
132
|
+
const root = temporaryRoot();
|
|
133
|
+
const piHome = join(root, "pi");
|
|
134
|
+
const stateDirectory = join(root, "state");
|
|
135
|
+
writeInstalledPackage(piHome, "daemon", "1.0.0");
|
|
136
|
+
const npmBin = writeFakeNpm(root);
|
|
137
|
+
const materializer = new IsolatedDaemonPackageMaterializer(stateDirectory, npmBin);
|
|
138
|
+
const first = await materializer.materialize(piHome, "npm:daemon");
|
|
139
|
+
first.commit();
|
|
140
|
+
rmSync(npmBin);
|
|
141
|
+
|
|
142
|
+
const second = await materializer.materialize(piHome, "npm:daemon");
|
|
143
|
+
expect(second.piHome).toBe(first.piHome);
|
|
144
|
+
second.commit();
|
|
145
|
+
materializer.remove("npm:daemon");
|
|
146
|
+
expect(existsSync(join(stateDirectory, "services", "daemon"))).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
});
|