@danypops/pi-packed 0.21.8 → 0.21.10

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.d.ts CHANGED
@@ -105,5 +105,12 @@ export interface EnsureClientDeps {
105
105
  * creating a competing one.
106
106
  */
107
107
  export declare function ensureClient(deps: EnsureClientDeps): Promise<PackedClient>;
108
+ /**
109
+ * An auto-spawned daemon is detached and long-lived, so it keeps whatever
110
+ * PATH its host process had at spawn time forever. Resolve `pi`'s absolute
111
+ * path now and pin it, rather than trust a bare "pi" to keep resolving in
112
+ * that frozen PATH. Leaves an explicit PI_PACKED_PI_BIN/PI_BIN untouched.
113
+ */
114
+ export declare function resolvePiBinForSpawn(env?: NodeJS.ProcessEnv): string | undefined;
108
115
  export declare function ensurePackedClient(paths?: PackedClientPaths, transport?: FetchTransport): Promise<PackedClient>;
109
116
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../service/src/public/client.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAGX,yBAAyB,EACzB,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,QAAQ,EACR,aAAa,EACb,gBAAgB,EAChB,kBAAkB,EAClB,gBAAgB,EAChB,SAAS,EACT,WAAW,EACX,aAAa,EACb,MAAM,eAAe,CAAC;AAEvB,6NAA6N;AAC7N,qBAAa,mBAAoB,SAAQ,KAAK;IAG5C,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO;gBAD7B,OAAO,EAAE,MAAM,EACN,UAAU,CAAC,EAAE,OAAO,YAAA;CAK9B;AAED,MAAM,WAAW,iBAAiB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,EAAE,MAAM,CAAC;CAC1B;AAGD,MAAM,WAAW,iBAAiB;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;CAC3B;AACD,MAAM,WAAW,qBAAqB;IACrC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,cAAc,EAAE,CAAA;KAAE,CAAC,CAAC;IAC9H,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACzC,SAAS,IAAI,OAAO,CAAC,yBAAyB,CAAC,mBAAmB,CAAC,CAAC,CAAC;IACrE,OAAO,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;IAClC,QAAQ,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACtC,mBAAmB,CAAC,KAAK,EAAE,gBAAgB,CAAC,kBAAkB,CAAC,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAChH,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7D,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,kBAAkB,CAAA;KAAE,CAAC,CAAC;IAC3G,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC1D,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACnE,SAAS,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IACrE,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACjG,aAAa,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC;QAAC,OAAO,EAAE,gBAAgB,EAAE,CAAA;KAAE,CAAC,CAAC;IAC1G,cAAc,CACb,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,aAAa,EACpB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,OAAO,EAChB,WAAW,CAAC,EAAE,MAAM,EACpB,QAAQ,CAAC,EAAE,OAAO,GAChB,OAAO,CAAC,MAAM,CAAC,CAAC;IACnB,QAAQ,IAAI,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC9B;AAED,wBAAgB,wBAAwB,CAAC,OAAO,GAAE,iBAAsB,GAAG,iBAAiB,CAiB3F;AAOD,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAMrE,qBAAa,YAAa,YAAW,qBAAqB;IACzD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAsG;gBAC9G,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,GAAE,cAAsB;IAG1E,OAAO,CAAC,IAAI;IAMZ,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;IAG1B,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,UAAQ;;;;;;IAGpD,IAAI,CAAC,IAAI,EAAE,MAAM;IAGjB,SAAS;IAGH,OAAO;IAGb,QAAQ;IAGR,mBAAmB,CAAC,gBAAgB,EAAE,gBAAgB,CAAC,kBAAkB,CAAC,EAAE,QAAQ,UAAQ;IAGtF,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,UAAQ;IAKxC,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,UAAQ;;;;IAK/C,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,UAAQ;IAKrC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,UAAQ,GAAG,OAAO,CAAC,aAAa,CAAC;IAYtE,SAAS,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,UAAQ;IAG7C,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,UAAQ,EAAE,KAAK,UAAQ;IAGhE,aAAa,CAAC,WAAW,CAAC,EAAE,MAAM;;;;IAG5B,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,CAAC,EAAE,MAAM,EAAE,QAAQ,UAAQ;IAKjI,QAAQ;CAGR;AAED,wBAAsB,mBAAmB,CAAC,KAAK,oBAA6B,EAAE,SAAS,GAAE,cAAsB,GAAG,OAAO,CAAC,YAAY,CAAC,CAatI;AAED,MAAM,WAAW,gBAAgB;IAChC,OAAO,IAAI,OAAO,CAAC,YAAY,CAAC,CAAC;IACjC;;mCAE+B;IAC/B,kBAAkB,IAAI,OAAO,CAAC;IAC9B;yDACqD;IACrD,KAAK,IAAI,IAAI,CAAC;IACd,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC,CAoBhF;AAED,wBAAsB,kBAAkB,CAAC,KAAK,oBAA6B,EAAE,SAAS,GAAE,cAAsB,GAAG,OAAO,CAAC,YAAY,CAAC,CAkBrI"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../service/src/public/client.ts"],"names":[],"mappings":"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"}
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?`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};
1
+ import{spawn as g}from"node:child_process";import{accessSync as A,constants as f,readFileSync as S}from"node:fs";import{delimiter as I,dirname as y,join as O,resolve as k}from"node:path";import{fileURLToPath as P}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 r,existsSync as p,mkdirSync as o,readFileSync as C,renameSync as n,rmSync as t,writeFileSync as a}from"node:fs";import{homedir as b,tmpdir as N}from"node:os";import{dirname as zz,join as W,win32 as Y}from"node:path";var _="127.0.0.1";function q(z,J={}){let G=J.platform??process.platform,Z=J.home??b();if(G==="darwin")return L(z,Z);if(G==="win32")return M(z,J.env??process.env,Z);return R(z,J,Z)}function R(z,J,G){let Z=J.env??process.env,$=J.uid??process.getuid?.()??0,K=Z.XDG_DATA_HOME??W(G,".local","share"),Q=Z.XDG_STATE_HOME??W(G,".local","state"),x=Z.XDG_RUNTIME_DIR??W("/run","user",String($)),D=Z.XDG_CONFIG_HOME??W(G,".config");return{database:W(K,z.stateDirectoryName,z.databaseFilename),token:W(Q,z.stateDirectoryName,z.tokenFilename),handle:W(x,z.stateDirectoryName,z.handleFilename),serviceDescriptor:W(D,"systemd","user",z.systemdUnitName)}}function L(z,J){let G=W(J,"Library"),Z=W(G,"Application Support",z.stateDirectoryName);return{database:W(Z,z.databaseFilename),token:W(Z,z.tokenFilename),handle:W(N(),z.stateDirectoryName,z.handleFilename),serviceDescriptor:W(Z,z.systemdUnitName)}}function M(z,J,G){let Z=J.LOCALAPPDATA??Y.join(G,"AppData","Local"),$=J.APPDATA??Y.join(G,"AppData","Roaming"),K=Y.join(Z,z.stateDirectoryName,"Data");return{database:Y.join(K,z.databaseFilename),token:Y.join(K,z.tokenFilename),handle:Y.join(Z,"Temp",z.stateDirectoryName,z.handleFilename),serviceDescriptor:Y.join($,z.stateDirectoryName,"Config",z.systemdUnitName)}}function X(z){try{let J=JSON.parse(C(z,"utf8"));if(J.host!==_||!Number.isInteger(J.port)||J.port<1||J.port>65535||!Number.isInteger(J.pid))return null;return J}catch{return null}}import{execFileSync as E}from"node:child_process";import{fileURLToPath as w}from"node:url";function H(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 B(){return{armadaCliPath:w(import.meta.resolve("@danypops/armada/cli")),runCommand:(z,J,G)=>{try{return{ok:!0,output:E(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 U extends Error{notADaemon;constructor(z,J){super(z);this.notADaemon=J;this.name="InstallServiceError"}}var u="pi-packed",F="pi-packed.service";function T(z={}){let J=z.env??process.env;if(J.PI_PACKED_HOME){let Z=k(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 v(){return H(u,B())}function h(z,J=30000){return(G)=>z(new Request(G,{signal:AbortSignal.any([G.signal,AbortSignal.timeout(J)])}))}class V{rpc;constructor(z,J,G=fetch){this.rpc=new j(z,J,{label:"Packed",transport:h(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 U(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,$,K=!1){let Q=await this.call("resources.toggle",{source:z,field:J,path:G,enabled:Z,projectRoot:$,approved:K});if(!Q.ok)throw Error(Q.output);return Q.output}piStatus(){return this.call("pi.status",{})}}async function m(z=T(),J=fetch){let G=X(z.handle);if(!G)throw Error("Packed daemon is not running");let Z;try{Z=S(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 V(`http://${G.host}:${G.port}`,Z,J);return await $.health(),$}async function d(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 K=0;K<J;K++){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`)}function c(z=process.env){if(z.PI_PACKED_PI_BIN||z.PI_BIN)return;let J=z.PATH;if(!J)return;let G=process.platform==="win32"?["pi.exe","pi.cmd","pi.bat","pi"]:["pi"];for(let Z of J.split(I)){if(!Z)continue;for(let $ of G){let K=O(Z,$);try{return A(K,f.X_OK),K}catch{}}}return}async function Hz(z=T(),J=fetch){return d({connect:()=>m(z,J),isServiceInstalled:()=>v(),spawn:()=>{let G=process.env.PI_PACKED_BIN,Z=G??process.env.PI_PACKED_BUN??"bun",$=G?["serve"]:[O(y(P(import.meta.url)),"../service/src/cli/cli.ts"),"serve"],K=c(),Q=g(Z,$,{detached:!0,stdio:"ignore",env:{...process.env,DAEMON_KIT_LAUNCH_PROVENANCE:"auto-spawn",...K?{PI_BIN:K}:{}}});Q.once("error",()=>{}),Q.unref()},sleep:(G)=>new Promise((Z)=>setTimeout(Z,G))})}export{c as resolvePiBinForSpawn,T as resolvePackedClientPaths,Hz as ensurePackedClient,d as ensureClient,m as connectPackedClient,V as PackedClient,U as InstallServiceError};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.21.8",
3
+ "version": "0.21.10",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  /** install.ts — driven adapter: pi CLI mutations via Bun.spawn. */
2
2
 
3
+ import { join } from "node:path";
3
4
  import { HeadlessInstallValidator, type InstallValidator } from "../adoption/install-validation.ts";
4
5
  import { defaultPiHome, isPinnedNpmSource, readResolvedVersion } from "./installed.ts";
5
6
  import type { Installer, UpdateOutcome } from "./package.ts";
@@ -11,11 +12,16 @@ export function defaultPiBin(): string {
11
12
  return process.env.PI_PACKED_PI_BIN ?? process.env.PI_BIN ?? "pi";
12
13
  }
13
14
 
15
+ export function defaultNpmBin(): string {
16
+ return process.env.PI_PACKED_NPM_BIN ?? process.env.NPM_BIN ?? "npm";
17
+ }
18
+
14
19
  export class ExecInstaller implements Installer {
15
20
  constructor(
16
21
  private bin = defaultPiBin(),
17
22
  private piHome = defaultPiHome(),
18
23
  private validator: InstallValidator = new HeadlessInstallValidator(),
24
+ private npmBin = defaultNpmBin(),
19
25
  ) {}
20
26
 
21
27
  private async run(args: string[]): Promise<string> {
@@ -27,6 +33,26 @@ export class ExecInstaller implements Installer {
27
33
  return out;
28
34
  }
29
35
 
36
+ /**
37
+ * `pi install`/`pi update --extension <source>` only resolve the target
38
+ * package's own subtree. npm's own `dedupe` docs (confirmed by
39
+ * npm/cli#5307 and npm/cli#7277) describe rearranging already-resolved
40
+ * versions, never installing a newer one -- so a sibling package's own
41
+ * declared range can go unsatisfied at the shared root and node's
42
+ * resolution silently walks up to a stale copy nothing wanted. A full
43
+ * `npm install` (no args) at piHome/npm is npm's own documented reliable
44
+ * fix: it re-resolves the whole tree, not just the last-touched package.
45
+ */
46
+ private async reresolveDependencyTree(): Promise<string> {
47
+ const cwd = join(this.piHome, "npm");
48
+ const proc = Bun.spawn([this.npmBin, "install"], { cwd, stdout: "pipe", stderr: "pipe" });
49
+ const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
50
+ const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
51
+ const code = await proc.exited;
52
+ if (code !== 0) throw new Error(`npm install failed to re-resolve the dependency tree at ${cwd} (exit ${code}): ${out || "no output"}`);
53
+ return out;
54
+ }
55
+
30
56
  async install(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string> {
31
57
  const validation = await this.validator.validate(source);
32
58
  if (!validation.ok) {
@@ -36,7 +62,9 @@ export class ExecInstaller implements Installer {
36
62
  .join("; ");
37
63
  throw new Error(`install refused -- ${detail || validation.message || "extension failed a headless load check"}`);
38
64
  }
39
- return this.run(["install", ...(options?.local ? ["-l"] : []), source]);
65
+ const output = await this.run(["install", ...(options?.local ? ["-l"] : []), source]);
66
+ await this.reresolveDependencyTree();
67
+ return output;
40
68
  }
41
69
 
42
70
  remove(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string> {
@@ -47,6 +75,7 @@ export class ExecInstaller implements Installer {
47
75
  const pinned = isPinnedNpmSource(source);
48
76
  const previousVersion = readResolvedVersion(this.piHome, source);
49
77
  const output = await this.run(["update", "--extension", source]);
78
+ await this.reresolveDependencyTree();
50
79
  const currentVersion = readResolvedVersion(this.piHome, source);
51
80
  // Only trust a "nothing changed" conclusion when we actually read a
52
81
  // real version both before and after (npm source, resolvable in
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, join, resolve } from "node:path";
2
+ import { accessSync, constants as fsConstants, readFileSync } from "node:fs";
3
+ import { delimiter, dirname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { AuthenticatedRpcClient } from "@danypops/vehicle-client/rpc-client";
6
6
  import { readDaemonHandle, resolveDaemonPaths } from "@danypops/vehicle-server/paths";
@@ -244,6 +244,30 @@ export async function ensureClient(deps: EnsureClientDeps): Promise<PackedClient
244
244
  );
245
245
  }
246
246
 
247
+ /**
248
+ * An auto-spawned daemon is detached and long-lived, so it keeps whatever
249
+ * PATH its host process had at spawn time forever. Resolve `pi`'s absolute
250
+ * path now and pin it, rather than trust a bare "pi" to keep resolving in
251
+ * that frozen PATH. Leaves an explicit PI_PACKED_PI_BIN/PI_BIN untouched.
252
+ */
253
+ export function resolvePiBinForSpawn(env: NodeJS.ProcessEnv = process.env): string | undefined {
254
+ if (env.PI_PACKED_PI_BIN || env.PI_BIN) return undefined; // explicit override already present; nothing to pin
255
+ const path = env.PATH;
256
+ if (!path) return undefined;
257
+ const names = process.platform === "win32" ? ["pi.exe", "pi.cmd", "pi.bat", "pi"] : ["pi"];
258
+ for (const dir of path.split(delimiter)) {
259
+ if (!dir) continue;
260
+ for (const name of names) {
261
+ const candidate = join(dir, name);
262
+ try {
263
+ accessSync(candidate, fsConstants.X_OK);
264
+ return candidate;
265
+ } catch {}
266
+ }
267
+ }
268
+ return undefined;
269
+ }
270
+
247
271
  export async function ensurePackedClient(paths = resolvePackedClientPaths(), transport: FetchTransport = fetch): Promise<PackedClient> {
248
272
  return ensureClient({
249
273
  connect: () => connectPackedClient(paths, transport),
@@ -252,10 +276,15 @@ export async function ensurePackedClient(paths = resolvePackedClientPaths(), tra
252
276
  const override = process.env.PI_PACKED_BIN;
253
277
  const command = override ?? process.env.PI_PACKED_BUN ?? "bun";
254
278
  const args = override ? ["serve"] : [join(dirname(fileURLToPath(import.meta.url)), "../service/src/cli/cli.ts"), "serve"];
279
+ const resolvedPiBin = resolvePiBinForSpawn();
255
280
  const child = spawn(command, args, {
256
281
  detached: true,
257
282
  stdio: "ignore",
258
- env: { ...process.env, DAEMON_KIT_LAUNCH_PROVENANCE: "auto-spawn" },
283
+ env: {
284
+ ...process.env,
285
+ DAEMON_KIT_LAUNCH_PROVENANCE: "auto-spawn",
286
+ ...(resolvedPiBin ? { PI_BIN: resolvedPiBin } : {}),
287
+ },
259
288
  });
260
289
  child.once("error", () => {});
261
290
  child.unref();
@@ -8,6 +8,8 @@
8
8
  * pass.
9
9
  */
10
10
  import { describe, expect, it } from "bun:test";
11
+ import { mkdirSync, mkdtempSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
11
13
  import { dirname, join } from "node:path";
12
14
  import { fileURLToPath } from "node:url";
13
15
  import type { InstallValidationResult, InstallValidator } from "../src/adoption/install-validation.ts";
@@ -94,13 +96,18 @@ describe("ExecInstaller.install() -- refuses before ever spawning the real pi bi
94
96
  await expect(installer.install("npm:broken-pkg")).rejects.toThrow(/install refused.*extension\/index\.ts: boom/);
95
97
  });
96
98
 
97
- it("proceeds to the real install when the validator approves", async () => {
98
- // /bin/true always exits 0 -- proves the real spawn path was reached
99
- // (a refused install never gets this far to find out).
99
+ it("proceeds to the real install when the validator approves, then re-resolves the whole tree", async () => {
100
+ // A real piHome/npm dir must exist for the post-install re-resolution
101
+ // step to cwd into -- /bin/true always exits 0 regardless, proving both
102
+ // the real install spawn *and* the re-resolution spawn were reached (a
103
+ // refused install never gets this far to find out).
104
+ const piHome = mkdtempSync(join(tmpdir(), "packed-install-validation-pihome-"));
105
+ mkdirSync(join(piHome, "npm"), { recursive: true });
100
106
  const installer = new ExecInstaller(
101
107
  "/bin/true",
102
- "/tmp/unused-pihome",
108
+ piHome,
103
109
  fakeValidator({ ok: true, source: "npm:good-pkg", extensions: [] }),
110
+ "/bin/true",
104
111
  );
105
112
 
106
113
  await expect(installer.install("npm:good-pkg")).resolves.toBeDefined();
@@ -40,6 +40,7 @@ function writeFakePi(dir: string, rewrite?: { piHome: string; name: string; newV
40
40
 
41
41
  function writePiHome(nodeModules: Record<string, string> = {}): string {
42
42
  const dir = mkdtempSync(join(tmpdir(), "packed-exec-pihome-"));
43
+ mkdirSync(join(dir, "npm"), { recursive: true });
43
44
  for (const [name, version] of Object.entries(nodeModules)) {
44
45
  const pkgDir = join(dir, "npm", "node_modules", name);
45
46
  mkdirSync(pkgDir, { recursive: true });
@@ -48,12 +49,35 @@ function writePiHome(nodeModules: Record<string, string> = {}): string {
48
49
  return dir;
49
50
  }
50
51
 
52
+ /**
53
+ * A fake `npm` binary standing in for ExecInstaller's post-install/update
54
+ * full-tree re-resolution step. Writes every invocation's cwd to `logFile`
55
+ * (so a test can assert it actually ran against piHome/npm, not some other
56
+ * directory) and, when `rewrite` is given, writes a fresh version for a
57
+ * *different* package than the one the fake `pi` binary touched --
58
+ * reproducing the real defect: only a real `npm install` (no args, whole
59
+ * tree) reaches a stale sibling that a targeted `pi update` never does.
60
+ */
61
+ function writeFakeNpm(dir: string, logFile: string, rewrite?: { piHome: string; name: string; newVersion: string }): string {
62
+ const script = join(dir, "fake-npm");
63
+ const rewriteLine = rewrite
64
+ ? `mkdir -p '${join(rewrite.piHome, "npm", "node_modules", rewrite.name)}' && printf '{"version":"%s"}' '${rewrite.newVersion}' > '${join(rewrite.piHome, "npm", "node_modules", rewrite.name, "package.json")}'`
65
+ : "true";
66
+ writeFileSync(
67
+ script,
68
+ ["#!/usr/bin/env bash", "set -euo pipefail", `pwd >> '${logFile}'`, `echo "$@" >> '${logFile}'`, rewriteLine, "exit 0"].join("\n"),
69
+ );
70
+ chmodSync(script, 0o755);
71
+ return script;
72
+ }
73
+
51
74
  describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguous exit-0 text", () => {
52
75
  it("pinned source, version genuinely unchanged: alreadyUpToDate, reloadRequired false", async () => {
53
76
  const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
54
77
  const bin = writeFakePi(scriptDir);
78
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
55
79
  const piHome = writePiHome({ "@scope/pkg": "1.2.3" });
56
- const installer = new ExecInstaller(bin, piHome);
80
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
57
81
 
58
82
  const outcome = await installer.update("npm:@scope/pkg@1.2.3");
59
83
 
@@ -68,8 +92,9 @@ describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguou
68
92
  it('unpinned source, already latest (pi still exits 0 and says "Updated"): alreadyUpToDate', async () => {
69
93
  const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
70
94
  const bin = writeFakePi(scriptDir);
95
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
71
96
  const piHome = writePiHome({ plain: "0.5.0" });
72
- const installer = new ExecInstaller(bin, piHome);
97
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
73
98
 
74
99
  const outcome = await installer.update("npm:plain");
75
100
 
@@ -84,7 +109,8 @@ describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguou
84
109
  const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
85
110
  const piHome = writePiHome({ plain: "0.5.0" });
86
111
  const bin = writeFakePi(scriptDir, { piHome, name: "plain", newVersion: "0.6.0" });
87
- const installer = new ExecInstaller(bin, piHome);
112
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
113
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
88
114
 
89
115
  const outcome = await installer.update("npm:plain");
90
116
 
@@ -98,8 +124,9 @@ describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguou
98
124
  it("git: source (no npm resolution possible either side): conservatively assumes it may have changed", async () => {
99
125
  const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
100
126
  const bin = writeFakePi(scriptDir);
127
+ const npmBin = writeFakeNpm(scriptDir, join(scriptDir, "npm.log"));
101
128
  const piHome = writePiHome();
102
- const installer = new ExecInstaller(bin, piHome);
129
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
103
130
 
104
131
  const outcome = await installer.update("git:github.com/u/r@main");
105
132
 
@@ -111,3 +138,61 @@ describe("ExecInstaller.update() — honest reloadRequired despite pi's ambiguou
111
138
  expect(outcome.reloadRequired).toBe(true);
112
139
  });
113
140
  });
141
+
142
+ describe("ExecInstaller — forces full dependency re-resolution, not just the target's own subtree", () => {
143
+ it("update() fixes a stale root-level sibling that the targeted pi update never touched", async () => {
144
+ const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
145
+ // Simulates the confirmed live defect: after `pi update npm:@scope/leaf`,
146
+ // the leaf's own version bumps, but a root-level sibling
147
+ // (@scope/shared) that the freshly-updated leaf now needs a newer
148
+ // range of stays pinned at its old, now-unsatisfied version --
149
+ // `pi update --extension` only ever resolved the leaf's own subtree.
150
+ const piHome = writePiHome({ "@scope/leaf": "1.0.0", "@scope/shared": "1.0.0" });
151
+ const bin = writeFakePi(scriptDir, { piHome, name: "@scope/leaf", newVersion: "2.0.0" });
152
+ // A real `npm install` (no args, whole-tree) is the only thing that
153
+ // actually reaches @scope/shared -- reproduced here as the fake npm
154
+ // binary rewriting it to the version the leaf's new range needs.
155
+ const npmLog = join(scriptDir, "npm.log");
156
+ const npmBin = writeFakeNpm(scriptDir, npmLog, { piHome, name: "@scope/shared", newVersion: "2.0.0" });
157
+ const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
158
+
159
+ const outcome = await installer.update("npm:@scope/leaf");
160
+
161
+ expect(outcome.currentVersion).toBe("2.0.0");
162
+ // The real assertion: a package `pi update` never named is resolved
163
+ // too, everywhere in the tree -- not just the target's own package.json.
164
+ expect(readFileSync(join(piHome, "npm", "node_modules", "@scope/shared", "package.json"), "utf8")).toContain("2.0.0");
165
+ // And it ran the re-resolution against piHome/npm specifically, with
166
+ // no target argument -- a full-tree resolve, not another targeted op.
167
+ const log = readFileSync(npmLog, "utf8").trim().split("\n");
168
+ expect(log[0]).toBe(join(piHome, "npm"));
169
+ expect(log[1]).toBe("install");
170
+ });
171
+
172
+ it("install() also forces a full re-resolution after a successful pi install", async () => {
173
+ const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
174
+ const piHome = writePiHome({ "@scope/shared": "1.0.0" });
175
+ const bin = writeFakePi(scriptDir);
176
+ const npmLog = join(scriptDir, "npm.log");
177
+ const npmBin = writeFakeNpm(scriptDir, npmLog, { piHome, name: "@scope/shared", newVersion: "2.0.0" });
178
+ const installer = new ExecInstaller(bin, piHome, { validate: async (source) => ({ ok: true, source, extensions: [] }) }, npmBin);
179
+
180
+ await installer.install("npm:@scope/new-pkg");
181
+
182
+ expect(readFileSync(join(piHome, "npm", "node_modules", "@scope/shared", "package.json"), "utf8")).toContain("2.0.0");
183
+ const log = readFileSync(npmLog, "utf8").trim().split("\n");
184
+ expect(log[0]).toBe(join(piHome, "npm"));
185
+ });
186
+
187
+ it("surfaces a failed re-resolution instead of silently reporting success", async () => {
188
+ const scriptDir = mkdtempSync(join(tmpdir(), "packed-exec-bin-"));
189
+ const piHome = writePiHome({ plain: "0.5.0" });
190
+ const bin = writeFakePi(scriptDir, { piHome, name: "plain", newVersion: "0.6.0" });
191
+ const failingNpm = join(scriptDir, "fake-npm-fail");
192
+ writeFileSync(failingNpm, ["#!/usr/bin/env bash", "echo 'ERESOLVE unable to resolve dependency tree' >&2", "exit 1"].join("\n"));
193
+ chmodSync(failingNpm, 0o755);
194
+ const installer = new ExecInstaller(bin, piHome, undefined, failingNpm);
195
+
196
+ await expect(installer.update("npm:plain")).rejects.toThrow(/npm install failed to re-resolve/);
197
+ });
198
+ });
@@ -1,5 +1,8 @@
1
+ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { describe, expect, it } from "bun:test";
2
- import { ensureClient } from "../src/public/client.ts";
5
+ import { ensureClient, resolvePiBinForSpawn } from "../src/public/client.ts";
3
6
 
4
7
  describe("ensureClient (packed daemon auto-spawn-or-wait decision)", () => {
5
8
  it("connects immediately without ever checking for a service or spawning, when already reachable", async () => {
@@ -132,3 +135,41 @@ describe("ensureClient (packed daemon auto-spawn-or-wait decision)", () => {
132
135
  expect(serviceChecks).toBe(1);
133
136
  });
134
137
  });
138
+
139
+ describe("resolvePiBinForSpawn", () => {
140
+ it("leaves an explicit PI_PACKED_PI_BIN untouched", () => {
141
+ expect(resolvePiBinForSpawn({ PI_PACKED_PI_BIN: "/somewhere/pi", PATH: "/usr/bin" })).toBeUndefined();
142
+ });
143
+
144
+ it("leaves an explicit PI_BIN untouched", () => {
145
+ expect(resolvePiBinForSpawn({ PI_BIN: "/somewhere/pi", PATH: "/usr/bin" })).toBeUndefined();
146
+ });
147
+
148
+ it("returns undefined when PATH is missing", () => {
149
+ expect(resolvePiBinForSpawn({})).toBeUndefined();
150
+ });
151
+
152
+ it("returns undefined when no PATH directory has an executable `pi`", () => {
153
+ const dir = mkdtempSync(join(tmpdir(), "packed-resolve-pi-bin-"));
154
+ expect(resolvePiBinForSpawn({ PATH: dir })).toBeUndefined();
155
+ });
156
+
157
+ it("resolves the absolute path to an executable `pi` on PATH", () => {
158
+ const dir = mkdtempSync(join(tmpdir(), "packed-resolve-pi-bin-"));
159
+ const piPath = join(dir, "pi");
160
+ writeFileSync(piPath, "#!/bin/sh\necho pi\n");
161
+ chmodSync(piPath, 0o755);
162
+ expect(resolvePiBinForSpawn({ PATH: dir })).toBe(piPath);
163
+ });
164
+
165
+ it("skips a non-executable `pi` earlier on PATH and resolves a later one", () => {
166
+ const deadDir = mkdtempSync(join(tmpdir(), "packed-resolve-pi-bin-dead-"));
167
+ const liveDir = mkdtempSync(join(tmpdir(), "packed-resolve-pi-bin-live-"));
168
+ writeFileSync(join(deadDir, "pi"), "not executable");
169
+ chmodSync(join(deadDir, "pi"), 0o644);
170
+ const livePi = join(liveDir, "pi");
171
+ writeFileSync(livePi, "#!/bin/sh\necho pi\n");
172
+ chmodSync(livePi, 0o755);
173
+ expect(resolvePiBinForSpawn({ PATH: `${deadDir}:${liveDir}` })).toBe(livePi);
174
+ });
175
+ });