@danypops/pi-packed 0.21.5 → 0.21.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/dist/client.js +1 -1
- package/package.json +1 -1
- package/service/src/daemon/daemon.ts +11 -2
- package/service/src/public/client.ts +1 -1
- package/service/src/registry/registry.ts +5 -1
- package/service/test/core.test.ts +4 -1
- package/service/test/daemon-maintenance.test.ts +24 -0
- package/service/test/npm-metadata-e2e.test.ts +90 -0
- package/service/test/public-client.test.ts +12 -5
package/dist/client.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{spawn as E}from"node:child_process";import{readFileSync as g}from"node:fs";import{dirname as S,join as f,resolve as A}from"node:path";import{fileURLToPath as I}from"node:url";class j{baseUrl;token;opsPath;transport;label;constructor(z,J,G){this.baseUrl=z;this.token=J;this.label=G.label,this.opsPath=G.opsPath??"/api/v1/ops",this.transport=G.transport??fetch}authedRequest(z,J={}){return this.transport(new Request(`${this.baseUrl}${z}`,{...J,headers:{...J.headers,authorization:`Bearer ${this.token}`}}))}async call(z,J){let G=await this.authedRequest(this.opsPath,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({op:z,input:J})}),Z=await G.json();if(!G.ok)throw Error(Z.error??`${this.label} operation failed with HTTP ${G.status}`);return Z.result}async operations(){let z=await this.authedRequest(this.opsPath),J=await z.json();if(!z.ok)throw Error(J.error??`${this.label} discovery failed with HTTP ${z.status}`);return J.operations??[]}async ready(){let z=await this.authedRequest("/ready");if(z.status===503)return!1;if(!z.ok)throw Error(`${this.label} readiness check failed with HTTP ${z.status}`);return!0}async health(){let z=await this.authedRequest("/health"),J=await z.json();if(!z.ok||J.ok!==!0||typeof J.version!=="string")throw Error(J.error??`${this.label} health check failed`);return{ok:!0,version:J.version}}}import{chmodSync as d,existsSync as c,mkdirSync as l,readFileSync as C,renameSync as i,rmSync as r,writeFileSync as p}from"node:fs";import{homedir as D,tmpdir as b}from"node:os";import{dirname as t,join as K,win32 as Q}from"node:path";var N="127.0.0.1";function q(z,J={}){let G=J.platform??process.platform,Z=J.home??D();if(G==="darwin")return R(z,Z);if(G==="win32")return L(z,J.env??process.env,Z);return _(z,J,Z)}function _(z,J,G){let Z=J.env??process.env,$=J.uid??process.getuid?.()??0,W=Z.XDG_DATA_HOME??K(G,".local","share"),Y=Z.XDG_STATE_HOME??K(G,".local","state"),V=Z.XDG_RUNTIME_DIR??K("/run","user",String($)),x=Z.XDG_CONFIG_HOME??K(G,".config");return{database:K(W,z.stateDirectoryName,z.databaseFilename),token:K(Y,z.stateDirectoryName,z.tokenFilename),handle:K(V,z.stateDirectoryName,z.handleFilename),serviceDescriptor:K(x,"systemd","user",z.systemdUnitName)}}function R(z,J){let G=K(J,"Library"),Z=K(G,"Application Support",z.stateDirectoryName);return{database:K(Z,z.databaseFilename),token:K(Z,z.tokenFilename),handle:K(b(),z.stateDirectoryName,z.handleFilename),serviceDescriptor:K(Z,z.systemdUnitName)}}function L(z,J,G){let Z=J.LOCALAPPDATA??Q.join(G,"AppData","Local"),$=J.APPDATA??Q.join(G,"AppData","Roaming"),W=Q.join(Z,z.stateDirectoryName,"Data");return{database:Q.join(W,z.databaseFilename),token:Q.join(W,z.tokenFilename),handle:Q.join(Z,"Temp",z.stateDirectoryName,z.handleFilename),serviceDescriptor:Q.join($,z.stateDirectoryName,"Config",z.systemdUnitName)}}function B(z){try{let J=JSON.parse(C(z,"utf8"));if(J.host!==N||!Number.isInteger(J.port)||J.port<1||J.port>65535||!Number.isInteger(J.pid))return null;return J}catch{return null}}import{execFileSync as M}from"node:child_process";import{fileURLToPath as w}from"node:url";function X(z,J){let G=J.runCommand(process.execPath,[J.armadaCliPath,"status","--json"]);if(!G.ok)return!1;try{return JSON.parse(G.output).vehicles?.some(($)=>$.name===z)??!1}catch{return!1}}function H(){return{armadaCliPath:w(import.meta.resolve("@danypops/armada/cli")),runCommand:(z,J,G)=>{try{return{ok:!0,output:M(z,J,{encoding:"utf8",input:G,stdio:[G===void 0?"ignore":"pipe","pipe","pipe"]})}}catch(Z){let $=Z;return{ok:!1,output:($.stdout??"")+($.stderr??$.message)}}}}}class O extends Error{notADaemon;constructor(z,J){super(z);this.notADaemon=J;this.name="InstallServiceError"}}var y="pi-packed",F="pi-packed.service";function U(z={}){let J=z.env??process.env;if(J.PI_PACKED_HOME){let Z=A(J.PI_PACKED_HOME);return{token:`${Z}/token`,handle:`${Z}/handle.json`,serviceDescriptor:`${Z}/${F}`}}let G=q({stateDirectoryName:"pi-packed",databaseFilename:"packages.db",tokenFilename:"token",handleFilename:"handle.json",systemdUnitName:F},z);return{token:G.token,handle:G.handle,serviceDescriptor:G.serviceDescriptor}}function k(){return X(y,H())}function P(z,J=30000){return(G)=>z(new Request(G,{signal:AbortSignal.any([G.signal,AbortSignal.timeout(J)])}))}class T{rpc;constructor(z,J,G=fetch){this.rpc=new j(z,J,{label:"Packed",transport:P(G)})}call(z,J){return this.rpc.call(z,J)}health(){return this.rpc.health()}search(z,J,G=!1){return this.call("package.search",{query:z,limit:J,offline:G})}info(z){return this.call("package.info",{name:z})}installed(){return this.call("package.installed",{})}async updates(){return(await this.call("package.updates",{})).updates}security(){return this.call("package.security.get",{})}setMutationApproval(z,J=!1){return this.call("package.security.set",{mutationApproval:z,approved:J})}async install(z,J=!1){let G=await this.call("package.install",{source:z,approved:J});if(!G.ok)throw Error(G.output);return G.output}async installService(z,J=!1){let G=await this.call("package.install_service",{source:z,approved:J});if(!G.ok)throw new O(G.output,G.notADaemon);return{output:G.output,spec:G.spec}}async remove(z,J=!1){let G=await this.call("package.remove",{name:z,approved:J});if(!G.ok)throw Error(G.output);return G.output}async update(z,J=!1){let G=await this.call("package.update",{source:z,approved:J});if(!G.ok)throw Error(G.output);return{output:G.output,reloadRequired:G.reloadRequired??!0,alreadyUpToDate:G.alreadyUpToDate??!1,pinned:G.pinned??!1,previousVersion:G.previousVersion,currentVersion:G.currentVersion}}setupPlan(z,J=!1){return this.call("setup.plan",{manifestPath:z,prune:J})}setupApply(z,J=!1,G=!1){return this.call("setup.apply",{manifestPath:z,approved:J,prune:G})}listResources(z){return this.call("resources.list",{projectRoot:z})}async toggleResource(z,J,G,Z,$,W=!1){let Y=await this.call("resources.toggle",{source:z,field:J,path:G,enabled:Z,projectRoot:$,approved:W});if(!Y.ok)throw Error(Y.output);return Y.output}piStatus(){return this.call("pi.status",{})}}async function u(z=U(),J=fetch){let G=B(z.handle);if(!G)throw Error("Packed daemon is not running");let Z;try{Z=g(z.token,"utf8").trim()}catch{throw Error("Packed daemon token is unavailable")}if(!/^[a-f0-9]{64}$/.test(Z))throw Error("Packed daemon token is invalid");let $=new T(`http://${G.host}:${G.port}`,Z,J);return await $.health(),$}async function v(z){try{return await z.connect()}catch{}let J=z.retryAttempts??30,G=z.retryDelayMs??100,Z=z.isServiceInstalled();if(!Z)z.spawn();for(let W=0;W<J;W++){await z.sleep(G);try{return await z.connect()}catch{}}let $=J*G/1000;throw Error(Z?`Packed
|
|
1
|
+
import{spawn as E}from"node:child_process";import{readFileSync as g}from"node:fs";import{dirname as S,join as f,resolve as A}from"node:path";import{fileURLToPath as I}from"node:url";class j{baseUrl;token;opsPath;transport;label;constructor(z,J,G){this.baseUrl=z;this.token=J;this.label=G.label,this.opsPath=G.opsPath??"/api/v1/ops",this.transport=G.transport??fetch}authedRequest(z,J={}){return this.transport(new Request(`${this.baseUrl}${z}`,{...J,headers:{...J.headers,authorization:`Bearer ${this.token}`}}))}async call(z,J){let G=await this.authedRequest(this.opsPath,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({op:z,input:J})}),Z=await G.json();if(!G.ok)throw Error(Z.error??`${this.label} operation failed with HTTP ${G.status}`);return Z.result}async operations(){let z=await this.authedRequest(this.opsPath),J=await z.json();if(!z.ok)throw Error(J.error??`${this.label} discovery failed with HTTP ${z.status}`);return J.operations??[]}async ready(){let z=await this.authedRequest("/ready");if(z.status===503)return!1;if(!z.ok)throw Error(`${this.label} readiness check failed with HTTP ${z.status}`);return!0}async health(){let z=await this.authedRequest("/health"),J=await z.json();if(!z.ok||J.ok!==!0||typeof J.version!=="string")throw Error(J.error??`${this.label} health check failed`);return{ok:!0,version:J.version}}}import{chmodSync as d,existsSync as c,mkdirSync as l,readFileSync as C,renameSync as i,rmSync as r,writeFileSync as p}from"node:fs";import{homedir as D,tmpdir as b}from"node:os";import{dirname as t,join as K,win32 as Q}from"node:path";var N="127.0.0.1";function q(z,J={}){let G=J.platform??process.platform,Z=J.home??D();if(G==="darwin")return R(z,Z);if(G==="win32")return L(z,J.env??process.env,Z);return _(z,J,Z)}function _(z,J,G){let Z=J.env??process.env,$=J.uid??process.getuid?.()??0,W=Z.XDG_DATA_HOME??K(G,".local","share"),Y=Z.XDG_STATE_HOME??K(G,".local","state"),V=Z.XDG_RUNTIME_DIR??K("/run","user",String($)),x=Z.XDG_CONFIG_HOME??K(G,".config");return{database:K(W,z.stateDirectoryName,z.databaseFilename),token:K(Y,z.stateDirectoryName,z.tokenFilename),handle:K(V,z.stateDirectoryName,z.handleFilename),serviceDescriptor:K(x,"systemd","user",z.systemdUnitName)}}function R(z,J){let G=K(J,"Library"),Z=K(G,"Application Support",z.stateDirectoryName);return{database:K(Z,z.databaseFilename),token:K(Z,z.tokenFilename),handle:K(b(),z.stateDirectoryName,z.handleFilename),serviceDescriptor:K(Z,z.systemdUnitName)}}function L(z,J,G){let Z=J.LOCALAPPDATA??Q.join(G,"AppData","Local"),$=J.APPDATA??Q.join(G,"AppData","Roaming"),W=Q.join(Z,z.stateDirectoryName,"Data");return{database:Q.join(W,z.databaseFilename),token:Q.join(W,z.tokenFilename),handle:Q.join(Z,"Temp",z.stateDirectoryName,z.handleFilename),serviceDescriptor:Q.join($,z.stateDirectoryName,"Config",z.systemdUnitName)}}function B(z){try{let J=JSON.parse(C(z,"utf8"));if(J.host!==N||!Number.isInteger(J.port)||J.port<1||J.port>65535||!Number.isInteger(J.pid))return null;return J}catch{return null}}import{execFileSync as M}from"node:child_process";import{fileURLToPath as w}from"node:url";function X(z,J){let G=J.runCommand(process.execPath,[J.armadaCliPath,"status","--json"]);if(!G.ok)return!1;try{return JSON.parse(G.output).vehicles?.some(($)=>$.name===z)??!1}catch{return!1}}function H(){return{armadaCliPath:w(import.meta.resolve("@danypops/armada/cli")),runCommand:(z,J,G)=>{try{return{ok:!0,output:M(z,J,{encoding:"utf8",input:G,stdio:[G===void 0?"ignore":"pipe","pipe","pipe"]})}}catch(Z){let $=Z;return{ok:!1,output:($.stdout??"")+($.stderr??$.message)}}}}}class O extends Error{notADaemon;constructor(z,J){super(z);this.notADaemon=J;this.name="InstallServiceError"}}var y="pi-packed",F="pi-packed.service";function U(z={}){let J=z.env??process.env;if(J.PI_PACKED_HOME){let Z=A(J.PI_PACKED_HOME);return{token:`${Z}/token`,handle:`${Z}/handle.json`,serviceDescriptor:`${Z}/${F}`}}let G=q({stateDirectoryName:"pi-packed",databaseFilename:"packages.db",tokenFilename:"token",handleFilename:"handle.json",systemdUnitName:F},z);return{token:G.token,handle:G.handle,serviceDescriptor:G.serviceDescriptor}}function k(){return X(y,H())}function P(z,J=30000){return(G)=>z(new Request(G,{signal:AbortSignal.any([G.signal,AbortSignal.timeout(J)])}))}class T{rpc;constructor(z,J,G=fetch){this.rpc=new j(z,J,{label:"Packed",transport:P(G)})}call(z,J){return this.rpc.call(z,J)}health(){return this.rpc.health()}search(z,J,G=!1){return this.call("package.search",{query:z,limit:J,offline:G})}info(z){return this.call("package.info",{name:z})}installed(){return this.call("package.installed",{})}async updates(){return(await this.call("package.updates",{})).updates}security(){return this.call("package.security.get",{})}setMutationApproval(z,J=!1){return this.call("package.security.set",{mutationApproval:z,approved:J})}async install(z,J=!1){let G=await this.call("package.install",{source:z,approved:J});if(!G.ok)throw Error(G.output);return G.output}async installService(z,J=!1){let G=await this.call("package.install_service",{source:z,approved:J});if(!G.ok)throw new O(G.output,G.notADaemon);return{output:G.output,spec:G.spec}}async remove(z,J=!1){let G=await this.call("package.remove",{name:z,approved:J});if(!G.ok)throw Error(G.output);return G.output}async update(z,J=!1){let G=await this.call("package.update",{source:z,approved:J});if(!G.ok)throw Error(G.output);return{output:G.output,reloadRequired:G.reloadRequired??!0,alreadyUpToDate:G.alreadyUpToDate??!1,pinned:G.pinned??!1,previousVersion:G.previousVersion,currentVersion:G.currentVersion}}setupPlan(z,J=!1){return this.call("setup.plan",{manifestPath:z,prune:J})}setupApply(z,J=!1,G=!1){return this.call("setup.apply",{manifestPath:z,approved:J,prune:G})}listResources(z){return this.call("resources.list",{projectRoot:z})}async toggleResource(z,J,G,Z,$,W=!1){let Y=await this.call("resources.toggle",{source:z,field:J,path:G,enabled:Z,projectRoot:$,approved:W});if(!Y.ok)throw Error(Y.output);return Y.output}piStatus(){return this.call("pi.status",{})}}async function u(z=U(),J=fetch){let G=B(z.handle);if(!G)throw Error("Packed daemon is not running");let Z;try{Z=g(z.token,"utf8").trim()}catch{throw Error("Packed daemon token is unavailable")}if(!/^[a-f0-9]{64}$/.test(Z))throw Error("Packed daemon token is invalid");let $=new T(`http://${G.host}:${G.port}`,Z,J);return await $.health(),$}async function v(z){try{return await z.connect()}catch{}let J=z.retryAttempts??30,G=z.retryDelayMs??100,Z=z.isServiceInstalled();if(!Z)z.spawn();for(let W=0;W<J;W++){await z.sleep(G);try{return await z.connect()}catch{}}let $=J*G/1000;throw Error(Z?`The managed Packed service is registered but unreachable after ${$} seconds. Its supervisor may have stopped it or it may be failing; restart the managed service and inspect its status and logs. Packed will not auto-spawn a competing process.`:`Packed daemon did not become ready within ${$} seconds`)}async function Yz(z=U(),J=fetch){return v({connect:()=>u(z,J),isServiceInstalled:()=>k(),spawn:()=>{let G=process.env.PI_PACKED_BIN,Z=G??process.env.PI_PACKED_BUN??"bun",$=G?["serve"]:[f(S(I(import.meta.url)),"../service/src/cli/cli.ts"),"serve"],W=E(Z,$,{detached:!0,stdio:"ignore",env:{...process.env,DAEMON_KIT_LAUNCH_PROVENANCE:"auto-spawn"}});W.once("error",()=>{}),W.unref()},sleep:(G)=>new Promise((Z)=>setTimeout(Z,G))})}export{U as resolvePackedClientPaths,Yz as ensurePackedClient,v as ensureClient,u as connectPackedClient,T as PackedClient,O as InstallServiceError};
|
package/package.json
CHANGED
|
@@ -18,7 +18,6 @@ import { HttpRegistry } from "../registry/registry.ts";
|
|
|
18
18
|
import {
|
|
19
19
|
CATALOG_INTERVAL_DEFAULT_MS,
|
|
20
20
|
ENV,
|
|
21
|
-
IDLE_BUDGET_DEFAULT_MS,
|
|
22
21
|
INDEX_INTERVAL_DEFAULT_MS,
|
|
23
22
|
RECONCILE_INTERVAL_DEFAULT_MS,
|
|
24
23
|
WATCH_INTERVAL_DEFAULT_MS,
|
|
@@ -41,6 +40,15 @@ export interface StartPackedDaemonOptions {
|
|
|
41
40
|
maintenanceTasks?: MaintenanceTask[];
|
|
42
41
|
idleBudgetMs?: number;
|
|
43
42
|
migrateLegacy?: boolean;
|
|
43
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function configuredIdleBudgetMs(options: StartPackedDaemonOptions): number | undefined {
|
|
47
|
+
if (options.idleBudgetMs !== undefined) return options.idleBudgetMs;
|
|
48
|
+
const raw = (options.env ?? process.env)[ENV.IDLE_SECS];
|
|
49
|
+
if (raw === undefined) return undefined;
|
|
50
|
+
const seconds = Number(raw);
|
|
51
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1_000 : undefined;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOptions {
|
|
@@ -99,13 +107,14 @@ export function daemonOptions(options: StartPackedDaemonOptions): StartDaemonOpt
|
|
|
99
107
|
},
|
|
100
108
|
];
|
|
101
109
|
const maintenanceTasks = configuredMaintenanceTasks.filter((task) => task.intervalMs > 0);
|
|
110
|
+
const idleBudgetMs = configuredIdleBudgetMs(options);
|
|
102
111
|
|
|
103
112
|
return {
|
|
104
113
|
daemonLabel: "Packed",
|
|
105
114
|
handlePath: paths.handle,
|
|
106
115
|
logger,
|
|
107
116
|
maintenanceTasks,
|
|
108
|
-
idleBudgetMs:
|
|
117
|
+
...(idleBudgetMs === undefined ? {} : { idleBudgetMs }),
|
|
109
118
|
idleTickMs: WATCHDOG_TICK_MS,
|
|
110
119
|
buildApp: () => createApp({ reg, inst, token, stateDir: paths.stateDirectory, dataDir: dirname(paths.database), piHome, daemonServiceInstaller }),
|
|
111
120
|
onShutdown: () => database.close(),
|
|
@@ -239,7 +239,7 @@ export async function ensureClient(deps: EnsureClientDeps): Promise<PackedClient
|
|
|
239
239
|
const waitedSeconds = (attempts * delayMs) / 1000;
|
|
240
240
|
throw new Error(
|
|
241
241
|
serviceInstalled
|
|
242
|
-
? `Packed
|
|
242
|
+
? `The managed Packed service is registered but unreachable after ${waitedSeconds} seconds. Its supervisor may have stopped it or it may be failing; restart the managed service and inspect its status and logs. Packed will not auto-spawn a competing process.`
|
|
243
243
|
: `Packed daemon did not become ready within ${waitedSeconds} seconds`,
|
|
244
244
|
);
|
|
245
245
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { createLogger } from "../shared/log.ts";
|
|
16
16
|
|
|
17
17
|
const log = createLogger("registry");
|
|
18
|
+
const README_MAX_CHARS = 50_000;
|
|
18
19
|
|
|
19
20
|
/** Upstream etiquette: honor Retry-After on 429, exponential backoff
|
|
20
21
|
* otherwise, give up after RETRY_MAX_ATTEMPTS. */
|
|
@@ -125,9 +126,11 @@ export class HttpRegistry implements Registry {
|
|
|
125
126
|
pi?: Record<string, unknown>;
|
|
126
127
|
peerDependencies?: Record<string, string>;
|
|
127
128
|
scripts?: Record<string, string>;
|
|
129
|
+
readme?: unknown;
|
|
128
130
|
dist?: { unpackedSize?: number; integrity?: string; attestations?: { url?: string; provenance?: unknown } };
|
|
129
131
|
};
|
|
130
132
|
const manifestFields = v.pi ? Object.keys(v.pi).filter((key) => ["extensions", "skills", "prompts", "themes"].includes(key)) : [];
|
|
133
|
+
const readme = boundedString(v.readme, README_MAX_CHARS);
|
|
131
134
|
return {
|
|
132
135
|
name: boundedString(v.name, 214) ?? boundedString(name, 214)!,
|
|
133
136
|
version: boundedString(v.version, 128) ?? "",
|
|
@@ -144,7 +147,8 @@ export class HttpRegistry implements Registry {
|
|
|
144
147
|
pi: boundedPiManifest(v.pi),
|
|
145
148
|
peerDependencies: boundedDependencies(v.peerDependencies),
|
|
146
149
|
scripts: boundedDependencies(v.scripts),
|
|
147
|
-
|
|
150
|
+
readme,
|
|
151
|
+
readmeAvailable: readme !== undefined && readme.trim().length > 0,
|
|
148
152
|
unpackedSize: v.dist?.unpackedSize,
|
|
149
153
|
packageEvidence:
|
|
150
154
|
manifestFields.length > 0
|
|
@@ -69,6 +69,7 @@ describe("HttpRegistry", () => {
|
|
|
69
69
|
license: "MIT",
|
|
70
70
|
keywords: ["pi-package", "lsp"],
|
|
71
71
|
pi: { extensions: ["./src/index.ts"] },
|
|
72
|
+
readme: `# pi-lsp\n\n${"r".repeat(60_000)}`,
|
|
72
73
|
dist: {
|
|
73
74
|
unpackedSize: 12345,
|
|
74
75
|
integrity: "sha512-test",
|
|
@@ -103,8 +104,10 @@ describe("HttpRegistry", () => {
|
|
|
103
104
|
repository: "git+https://github.com/x/pi-lsp.git",
|
|
104
105
|
license: "MIT",
|
|
105
106
|
unpackedSize: 12345,
|
|
106
|
-
readmeAvailable:
|
|
107
|
+
readmeAvailable: true,
|
|
107
108
|
});
|
|
109
|
+
expect(info.readme).toHaveLength(50_000);
|
|
110
|
+
expect(info.readme).toStartWith("# pi-lsp");
|
|
108
111
|
expect(info.pi?.extensions).toBeDefined();
|
|
109
112
|
expect(info.packageEvidence).toMatchObject({ shape: "manifest", verified: false });
|
|
110
113
|
expect(info.publication).toEqual({
|
|
@@ -104,6 +104,30 @@ class RecordingDaemonServiceInstaller {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
describe("startPackedDaemon's own maintenance-task wiring (self-heals Vehicle drift, not just per-package on demand)", () => {
|
|
107
|
+
it("leaves the idle budget unspecified when no Packed override exists, so Vehicle can keep a service-launched daemon always on", () => {
|
|
108
|
+
const options = daemonOptions({
|
|
109
|
+
paths: fakePaths(),
|
|
110
|
+
reg: registry,
|
|
111
|
+
inst: installer,
|
|
112
|
+
piHome: fakePiHome([]),
|
|
113
|
+
maintenanceTasks: [],
|
|
114
|
+
env: {},
|
|
115
|
+
});
|
|
116
|
+
expect(options.idleBudgetMs).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("honors an explicit Packed idle override without replacing Vehicle's provenance default", () => {
|
|
120
|
+
const options = daemonOptions({
|
|
121
|
+
paths: fakePaths(),
|
|
122
|
+
reg: registry,
|
|
123
|
+
inst: installer,
|
|
124
|
+
piHome: fakePiHome([]),
|
|
125
|
+
maintenanceTasks: [],
|
|
126
|
+
env: { PI_PACKED_IDLE_SECS: "17" },
|
|
127
|
+
});
|
|
128
|
+
expect(options.idleBudgetMs).toBe(17_000);
|
|
129
|
+
});
|
|
130
|
+
|
|
107
131
|
it("includes a vehicle-reconcile task, defaulting to RECONCILE_INTERVAL_DEFAULT_MS", () => {
|
|
108
132
|
const piHome = fakePiHome([]);
|
|
109
133
|
const paths = fakePaths();
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { Server } from "bun";
|
|
6
|
+
import type { Installer, UpdateOutcome } from "../src/packages/package.ts";
|
|
7
|
+
import { PackedClient } from "../src/public/client.ts";
|
|
8
|
+
import { HttpRegistry } from "../src/registry/registry.ts";
|
|
9
|
+
import { createApp } from "../src/daemon/service.ts";
|
|
10
|
+
|
|
11
|
+
const TOKEN = "e".repeat(64);
|
|
12
|
+
const PUBLISHED_README = `# @danypops/enigma
|
|
13
|
+
|
|
14
|
+
Encrypted credential vault and supervisor daemon. Holds delegated OAuth/API
|
|
15
|
+
credentials for other daemons and injects them as environment variables into
|
|
16
|
+
spawned child processes.
|
|
17
|
+
|
|
18
|
+
## Why this exists
|
|
19
|
+
|
|
20
|
+
Consumers receive only their scoped credentials.`;
|
|
21
|
+
|
|
22
|
+
class NoopInstaller implements Installer {
|
|
23
|
+
async install(): Promise<string> {
|
|
24
|
+
throw new Error("not exercised");
|
|
25
|
+
}
|
|
26
|
+
async remove(): Promise<string> {
|
|
27
|
+
throw new Error("not exercised");
|
|
28
|
+
}
|
|
29
|
+
async update(): Promise<UpdateOutcome> {
|
|
30
|
+
throw new Error("not exercised");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("published npm metadata E2E", () => {
|
|
35
|
+
let npmServer: Server<undefined>;
|
|
36
|
+
let daemonServer: Server<undefined>;
|
|
37
|
+
let stateDir: string;
|
|
38
|
+
const npmRequests: Array<{ path: string; accept: string | null }> = [];
|
|
39
|
+
|
|
40
|
+
beforeAll(() => {
|
|
41
|
+
stateDir = mkdtempSync(join(tmpdir(), "packed-npm-metadata-"));
|
|
42
|
+
npmServer = Bun.serve({
|
|
43
|
+
hostname: "127.0.0.1",
|
|
44
|
+
port: 0,
|
|
45
|
+
fetch(request) {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
npmRequests.push({ path: url.pathname, accept: request.headers.get("accept") });
|
|
48
|
+
if (url.pathname.endsWith("/latest")) {
|
|
49
|
+
return Response.json({
|
|
50
|
+
name: "@danypops/enigma",
|
|
51
|
+
version: "0.22.1",
|
|
52
|
+
description: "Encrypted credential vault daemon",
|
|
53
|
+
license: "MIT",
|
|
54
|
+
repository: { type: "git", url: "git+https://github.com/DanyPops/enigma.git" },
|
|
55
|
+
readme: PUBLISHED_README,
|
|
56
|
+
dist: { integrity: "sha512-published-fixture" },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return new Response("not found", { status: 404 });
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const app = createApp({
|
|
63
|
+
reg: new HttpRegistry(`http://127.0.0.1:${npmServer.port}`, 20, 0, 1),
|
|
64
|
+
inst: new NoopInstaller(),
|
|
65
|
+
token: TOKEN,
|
|
66
|
+
stateDir,
|
|
67
|
+
});
|
|
68
|
+
daemonServer = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: app.fetch });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(() => {
|
|
72
|
+
daemonServer.stop(true);
|
|
73
|
+
npmServer.stop(true);
|
|
74
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("preserves an npm-published README through HttpRegistry, daemon RPC, and PackedClient", async () => {
|
|
78
|
+
const client = new PackedClient(`http://127.0.0.1:${daemonServer.port}`, TOKEN);
|
|
79
|
+
const info = await client.info("@danypops/enigma");
|
|
80
|
+
|
|
81
|
+
expect(npmRequests).toEqual([{ path: "/%40danypops/enigma/latest", accept: "application/json" }]);
|
|
82
|
+
expect(info).toMatchObject({
|
|
83
|
+
name: "@danypops/enigma",
|
|
84
|
+
version: "0.22.1",
|
|
85
|
+
license: "MIT",
|
|
86
|
+
readmeAvailable: true,
|
|
87
|
+
readme: PUBLISHED_README,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -67,9 +67,10 @@ describe("ensureClient (packed daemon auto-spawn-or-wait decision)", () => {
|
|
|
67
67
|
expect(connectAttempts).toBeGreaterThan(1); // genuinely retried, not a single shot
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
it("
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
it("explains the supervisor failure and recovery when a managed service never becomes reachable", async () => {
|
|
71
|
+
let message = "";
|
|
72
|
+
try {
|
|
73
|
+
await ensureClient({
|
|
73
74
|
connect: async () => {
|
|
74
75
|
throw new Error("never reachable");
|
|
75
76
|
},
|
|
@@ -80,8 +81,14 @@ describe("ensureClient (packed daemon auto-spawn-or-wait decision)", () => {
|
|
|
80
81
|
sleep: async () => {},
|
|
81
82
|
retryAttempts: 2,
|
|
82
83
|
retryDelayMs: 0,
|
|
83
|
-
})
|
|
84
|
-
|
|
84
|
+
});
|
|
85
|
+
} catch (error) {
|
|
86
|
+
message = error instanceof Error ? error.message : String(error);
|
|
87
|
+
}
|
|
88
|
+
expect(message).toContain("managed Packed service is registered but unreachable");
|
|
89
|
+
expect(message).toContain("restart the managed service");
|
|
90
|
+
expect(message).toContain("will not auto-spawn a competing process");
|
|
91
|
+
expect(message).not.toContain("packed doctor");
|
|
85
92
|
});
|
|
86
93
|
|
|
87
94
|
it("fails with a plain timeout message, and did spawn, when no service is installed and nothing ever becomes reachable", async () => {
|