localdeck 1.6.2 → 1.7.0
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/CHANGELOG.md +14 -0
- package/README.md +31 -11
- package/dashboard/assets/{index-CcN_iA3K.js → index-CbmaAEDy.js} +4 -4
- package/dashboard/index.html +1 -1
- package/dist/index-2da1tyad.js +135 -0
- package/dist/index-fmgnn6mt.js +70 -0
- package/dist/index.js +16 -1412
- package/dist/mcp-59a41rj3.js +6 -0
- package/dist/quarry-23qjnaj9.js +5 -0
- package/dist/update-zhsjwnrw.js +1 -0
- package/docs/commands.md +4 -0
- package/docs/quarry.md +49 -0
- package/package.json +3 -2
- package/quarry-extension.json +52 -0
- package/dist/index-xsyz4cc9.js +0 -9550
- package/dist/mcp-210n1fxv.js +0 -34244
- package/dist/update-hvkw7mkw.js +0 -171
package/dist/index.js
CHANGED
|
@@ -1,853 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
DEFAULT_API_PORT,
|
|
4
|
-
HOME_DIR,
|
|
5
|
-
VERSION,
|
|
6
|
-
__require,
|
|
7
|
-
applyProfiles,
|
|
8
|
-
chooseUpstream,
|
|
9
|
-
connectHost,
|
|
10
|
-
dashboardDir,
|
|
11
|
-
ensureDaemon,
|
|
12
|
-
findDaemon,
|
|
13
|
-
findProjectFile,
|
|
14
|
-
getFreePort,
|
|
15
|
-
inferPortEnvironment,
|
|
16
|
-
isPortFree,
|
|
17
|
-
killProcessTree,
|
|
18
|
-
listeningPorts,
|
|
19
|
-
loadProject,
|
|
20
|
-
loadProjectFile,
|
|
21
|
-
processTree,
|
|
22
|
-
resolveProjectSecrets,
|
|
23
|
-
secretOutput,
|
|
24
|
-
serviceKey,
|
|
25
|
-
shellCommand,
|
|
26
|
-
startDaemon,
|
|
27
|
-
startForwarder,
|
|
28
|
-
startupGuidance,
|
|
29
|
-
startupLayers,
|
|
30
|
-
streamLines,
|
|
31
|
-
stripAnsi,
|
|
32
|
-
unreachableListenerMessage,
|
|
33
|
-
waitForReadiness,
|
|
34
|
-
wrapper_default
|
|
35
|
-
} from "./index-xsyz4cc9.js";
|
|
1
|
+
import{M as $0,N as d,O as H1,Q as J1,S as Q1,T as a0,V as I0,W as A0,X as x0,Y as y0,Z as b0,ba as n0,ca as k0,da as C0,ea as _0,fa as M0,ga as O0,ha as D0,ia as G0,ja as s0,ka as L0,la as R0,ma as r0,na as e0,oa as q0,pa as $1,qa as Y1,ra as V0,sa as U1,ta as Y0,ua as z1,va as u,wa as K0}from"./index-2da1tyad.js";import{spawn as x1}from"node:child_process";import y1 from"node:crypto";import{parseArgs as v0}from"node:util";import{spawn as V1}from"node:child_process";import W1 from"node:fs";var q1=process.stdout.isTTY&&!process.env.NO_COLOR,v=($)=>(Y)=>q1?`\x1B[${$}m${Y}\x1B[0m`:Y,U={bold:v(1),dim:v(2),red:v(31),green:v(32),yellow:v(33),cyan:v(36),magenta:v(35),blue:v(34)};function M($){console.error(`${U.red("error")} ${$}`),process.exit(1)}function i($,Y){let K=[Y,...$],X=Y.map((q,z)=>Math.max(...K.map((W)=>c0(W[z]??"")))),Q=(q)=>q.map((z,W)=>z+" ".repeat(X[W]-c0(z))).join(" ");return[U.dim(Q(Y)),...$.map(Q)].join(`
|
|
2
|
+
`)}var c0=($)=>$.replace(/\x1b\[[0-9;]*m/g,"").length;function F0($){let Y=Math.max(0,Math.round((Date.now()-$)/1000));if(Y<60)return`${Y}s`;if(Y<3600)return`${Math.round(Y/60)}m`;if(Y<86400)return`${Math.round(Y/3600)}h`;return`${Math.round(Y/86400)}d`}async function o0($,Y,K=fetch,X=console.log){if(Y)X("LocalDeck: preparing app links. macOS may ask once to allow localhost port 80; cancelling keeps port-number links.");try{let Q=await K(`http://127.0.0.1:${$}/api/forwarder`,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({install:Y,automatic:!0}),signal:AbortSignal.timeout(135000)}),q=await Q.json();if(Q.ok&&q?.ready===!0)return!0;if(Y)X(`LocalDeck: using port-number links. ${q?.error??"Forwarding is unavailable."}`)}catch{if(Y)X("LocalDeck: using port-number links; forwarding setup was unavailable.")}return!1}var t0=8000,S1=500,_1=1000;async function M1($,Y={}){let K={ensureDaemon:u,getFreePort:a0,createWebSocket:(_,D)=>new d(_,D),spawn:V1,fetch:globalThis.fetch,...Y};if(!$.command.length)throw Error(`nothing to run for "${$.name}"`);let X=$.cwd??process.cwd();try{if(!W1.statSync(X).isDirectory())throw Error("not a directory")}catch{throw Error(`working directory does not exist or is not a directory: ${X}`)}let Q=await K.ensureDaemon(),q=$.project??{id:"standalone",name:"standalone",slug:"standalone"},z={},W=K.resolveProjectSecrets??n0;if($.secrets?.length&&q.id!=="standalone")z=W(q.id,$.secrets);let G=$.upstreamPort??await K.getFreePort(),B=$.color??U.cyan,Z=$.prefix?B(`[${$.name}] `):"",V=await L1($.env,$.after??[],q.id,Q.apiPort,K.fetch);V={...V,...z};let S=K.createWebSocket(`ws://127.0.0.1:${Q.apiPort}/api/attach`,{headers:{authorization:`Bearer ${Q.controlToken}`}}),H=(_)=>S.readyState===d.OPEN&&S.send(JSON.stringify(_)),J=await new Promise((_,D)=>{S.once("open",()=>{H({type:"register",key:C0(q.id,$.name.toLowerCase()),projectId:q.id,projectName:q.name,projectSlug:q.slug,name:$.name,managedBy:"terminal",stablePort:$.stablePort,upstreamPort:G,upstreamSource:$.upstreamPort===void 0?"assigned":"manual",cwd:X,command:$.displayCommand??$.command.join(" "),...$.project?{profiles:$.profiles??[]}:{}})}),S.on("message",(C)=>{let I=JSON.parse(C.toString());if(I.type==="registered")_(I.service);else if(I.type==="error")D(Error(I.message))}),S.once("error",D)}),O=$.command.map((_)=>_.replaceAll("{port}",String(G))),L=J.serviceType!==void 0&&J.serviceType!=="http",N=L?{}:e0($.displayCommand??$.command,X,G);V={...N,...V};for(let _ of Object.keys(N))if(V[_]===N[_]){let D=`LocalDeck: supplying assigned port ${G} through ${_}`;console.log(`${Z}${U.dim(D)}`),H({type:"log",stream:"stdout",line:D})}let R={...process.env,...V,...z,PORT:String(G),LOCALDECK_PORT:String(J.stablePort),LOCALDECK_SERVICE:J.name,...L?{}:{LOCALDECK_URL:`http://localhost:${J.stablePort}`}},b=new Set(Object.values(z));if(L)console.log(`${Z}${U.bold("LocalDeck")} ${U.dim("›")} ${B(J.name)} ${U.dim("→")} ${U.green(`database localhost:${J.upstreamPort}`)} ${U.dim("(published port · no HTTP proxy)")}`);else{let _=$.cleanUrls?J.urls[0].replace(/^(http:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)?\.localhost):\d+(?=\/|$)/i,"$1"):J.urls[0];console.log(`${Z}${U.bold("LocalDeck")} ${U.dim("›")} ${B(J.name)} ${U.dim("→")} ${U.green(_)} ${U.dim(`(also ${J.urls[1]} · dashboard http://localhost:${Q.apiPort})`)}`)}let B0=process.platform!=="win32",F,h=(_,D,C)=>{let I=b.size?_.pipe(Y1(b)):_;I.on("data",(g)=>{let y=R0(g.toString());if(w=(w+y).slice(-16000),$.prefix)C.write(y.replace(/^(?=.)/gm,Z));else C.write(g)}),s0(I,(g)=>{for(let y of N1(R0(g)))H({type:"log",stream:D,line:y})})},x=!1,w="",o=(_)=>{if(x)return;let D=_.toString();if(/EADDRINUSE|address already in use|already in use/i.test(D)&&D.includes(String(J.stablePort)))x=!0,console.error(`${Z}${U.yellow(`LocalDeck: your process tried to use port ${J.stablePort}, which LocalDeck is holding as the stable port for "${J.name}".`)}`),console.error(`${Z}${U.yellow(` Fix: run without --port (LocalDeck finds the tool's own port automatically), or pick a different stable port, e.g. --port ${J.stablePort+1000}.`)}`)},H0,t,u0,p,a=0,n=!1,j=!1,W0,k,f=!1,J0,G1=new Promise((_)=>{J0=_}),m0=(_)=>W0??=O1(F,_),l0=async(_)=>{if(f||n||!k)return;if(n=!0,!await m0(_)){W0=void 0,n=!1,j=!1,console.error(`${Z}${U.red("LocalDeck: process descendants survived cleanup; retry Stop")}`);return}if(f)return;if(H0)clearTimeout(H0);H({type:"log",stream:"stderr",line:`process exited (${k.signal??`code ${k.code}`})`});let D=["SIGINT","SIGTERM","SIGHUP"].includes(k.signal??"")||[130,143,129].includes(k.code??-1);if(!j&&!D&&a<3&&S.readyState===d.OPEN){let C=`LocalDeck: restarting in ${(K.retryDelayMs??3000)/1000} seconds (attempt ${++a}/3)`;console.log(`${Z}${U.yellow(C)}`),H({type:"log",stream:"stderr",line:C}),H({type:"readiness",status:"checking",message:C}),p=setTimeout(()=>{if(p=void 0,j||f)return;try{p0()}catch(I){f=!0,console.error(`${Z}${U.red(`LocalDeck: restart failed: ${I instanceof Error?I.message:I}`)}`),S.close(),J0(1)}},K.retryDelayMs??3000),n=!1;return}if(!j&&!D&&a===3)console.error(`${Z}${U.red("LocalDeck: automatic restart limit reached (3/3). Start the service manually to try again.")}`),H({type:"log",stream:"stderr",line:"LocalDeck: automatic restart limit reached (3/3). Start the service manually to try again."});f=!0,setTimeout(()=>{S.close(),J0(k.code??(k.signal?1:0))},50)},U0=(_="SIGTERM")=>{if(f||j)return;if(j=!0,p)clearTimeout(p),p=void 0;m0(_).then(()=>l0(_))};S.on("message",(_)=>{let D=JSON.parse(_.toString());if(D.type==="error")console.error(`${Z}${U.yellow(`LocalDeck: ${D.message}`)}`),t=void 0;else if(D.type==="stop")console.log(`${Z}${U.dim("LocalDeck: stop requested from dashboard/CLI")}`),U0()}),S.on("close",()=>{if(p){U0();return}if(!j&&!f)console.error(`${Z}${U.yellow("LocalDeck: lost connection to daemon; the stable port is no longer proxied. Process keeps running.")}`)});function p0(){if(a>0&&$.secrets?.length&&q.id!=="standalone"){z=W(q.id,$.secrets);for(let _ of Object.values(z))b.add(_);V={...V,...z},R={...process.env,...V,PORT:String(G),LOCALDECK_PORT:String(J.stablePort),LOCALDECK_SERVICE:J.name,...L?{}:{LOCALDECK_URL:`http://localhost:${J.stablePort}`}}}if(k=void 0,W0=void 0,n=!1,t=void 0,w="",x=!1,F=K.spawn(O[0],O.slice(1),{cwd:X,env:R,stdio:[$.prefix?"ignore":"inherit","pipe","pipe"],shell:!B0,detached:B0}),F.pid)H({type:"pid",pid:F.pid});if(h(F.stdout,"stdout",process.stdout),h(F.stderr,"stderr",process.stderr),F.stdout.on("data",o),F.stderr.on("data",o),!$.upstreamPort&&F.pid){let _=F,D=F.pid,C=0,I=async()=>{if(F!==_||F.exitCode!==null||F.signalCode)return;try{let g=await M0(await _0(D)),y=O0(g,G),s=y?void 0:D0(g);if(s&&s!==u0)console.error(`${Z}${U.yellow(`LocalDeck: ${s}`)}`),H({type:"log",stream:"stderr",line:`LocalDeck: ${s}`});if(u0=s,y){let S0=`${G0(y)}:${y.port}`;if(S0!==t){if(t=S0,H({type:"upstream",port:y.port,host:G0(y)}),y.port!==G)console.log(`${Z}${U.dim(`LocalDeck: process is listening on ${S0} (ignored $PORT) — ${J.urls[0]} now points there`)}`)}C++}}catch{}if(F!==_||F.exitCode!==null||j||k)return;H0=setTimeout(I,t?Math.min(2000+C*500,1e4):400)};H0=setTimeout(I,300)}if(F.on("exit",(_,D)=>{if(_!==0&&!x){let C=r0(w);if(C)console.error(`${Z}${U.yellow(["LocalDeck: "+C.title,...C.steps].join(`
|
|
3
|
+
`))}`)}k={code:_,signal:D},l0("SIGTERM")}),F.on("error",(_)=>{j=!0,f=!0,console.error(`${Z}${U.red(`LocalDeck: failed to start "${O[0]}": ${_.message}`)}`),S.close(),J0(1)}),a>0)i0().catch(()=>U0())}p0();let z0=$.ready,i0=()=>z0?(async()=>{H({type:"readiness",status:"checking",message:z0.type==="tcp"?"Waiting for the service to listen":"Running configured readiness check"});try{let _=await $1({name:J.name,run:$.displayCommand??$.command.join(" "),tags:[],after:$.after??[],ready:z0},async()=>{let D=await K.fetch(`http://127.0.0.1:${Q.apiPort}/api/services/${encodeURIComponent(J.key)}`);if(D.status===404)return;if(!D.ok)throw Error(`readiness status request failed (${D.status})`);return D.json()},{cwd:X,env:V});return H({type:"readiness",status:"ready",message:z0.type==="tcp"?"Listening on its upstream port":"Configured readiness check passed"}),_}catch(_){throw H({type:"readiness",status:"failed",message:_ instanceof Error?_.message.slice(0,500):"Readiness check failed"}),_}})():Promise.resolve(J),d0=i0();return d0.catch(()=>{}),{name:$.name,service:J,get child(){return F},stop:U0,done:G1,ready:d0}}async function O1($,Y){if(await L0($,Y),process.platform==="win32")return!0;let K=$.pid;if(!K||!T0(K))return!0;if(await F1($,K,S1))return!0;return await L0($,"SIGKILL"),D1(K,_1)}function T0($){try{return process.kill(-$,0),!0}catch(Y){return Y.code==="EPERM"}}async function D1($,Y){let K=Date.now()+Y;while(T0($)){if(Date.now()>=K)return!1;await new Promise((X)=>setTimeout(X,Math.min(20,Y)))}return!0}async function F1($,Y,K){let X=Date.now()+K;while(T0(Y)){if($.exitCode!==null||$.signalCode!==null||Date.now()>=X)return!1;await new Promise((Q)=>setTimeout(Q,Math.min(20,K)))}return!0}function N1($){if(Buffer.byteLength($)<=t0)return[$];let Y=[],K=[],X=0;for(let Q of $){let q=Buffer.byteLength(Q);if(K.length&&X+q>t0)Y.push(K.join("")),K=[],X=0;K.push(Q),X+=q}if(K.length)Y.push(K.join(""));return Y}async function L1($,Y,K,X,Q){if(!$)return{};let q=new Set(Y),z=new Map,W=async(Z)=>{let V=Z.toLowerCase();if(!q.has(V))throw Error(`environment references undeclared prerequisite "${Z}"`);let S=z.get(V);if(S)return S;let H=await Q(`http://127.0.0.1:${X}/api/services/${encodeURIComponent(C0(K,V))}`);if(H.status===404)throw Error(`environment references unresolved prerequisite "${Z}"`);if(!H.ok)throw Error(`could not resolve prerequisite "${Z}" (${H.status})`);let J=await H.json();return z.set(V,J),J},G=/\{service:([^}:]+):(port|url)\}/g,B={};for(let[Z,V]of Object.entries($)){let S=0,H="";for(let J of V.matchAll(G)){H+=V.slice(S,J.index);let O=await W(J[1]);if(J[2]==="port")H+=String(O.serviceType&&O.serviceType!=="http"?O.upstreamPort:O.stablePort);else{if(O.serviceType&&O.serviceType!=="http")throw Error(`environment variable ${Z} cannot use a URL for database prerequisite "${J[1]}"`);H+=`http://localhost:${O.stablePort}`}S=J.index+J[0].length}if(B[Z]=H+V.slice(S),B[Z].includes("{service:"))throw Error(`environment variable ${Z} has an invalid service reference`)}return B}async function K1($){let Y=await u(),K=await o0(Y.apiPort,Boolean(process.stdin.isTTY&&process.stdout.isTTY&&!process.env.CI)),X=[],Q=new AbortController,q=(G)=>{Q.abort(),X.forEach((B)=>B.stop(G))};for(let G of["SIGINT","SIGTERM","SIGHUP"])process.on(G,()=>q(G));let z=await R1($,async(G)=>{let B=await M1({...G,cleanUrls:K});return X.push(B),B},{signal:Q.signal});for(let G of z.failed)console.error(`${U.red("error")} starting "${G.name}": ${G.error.message}`);let W=await Promise.all(X.map((G)=>G.done));process.exit(W.find((G)=>G!==0)??(z.failed.length?1:0))}async function R1($,Y,K={}){let X=new Map($.map((W)=>[W.name.toLowerCase(),W])),Q=new Map,q=(W,G=new Set)=>{let B=W.name.toLowerCase();if(G.has(B))return Promise.resolve({error:Error("project dependency graph contains a cycle")});let Z=Q.get(B);if(Z)return Z;let V,S=new Promise((H)=>{V=H});return Q.set(B,S),(async()=>{let H=W.after??[],J=new Set(G).add(B),L=(await Promise.all(H.map((R)=>{let b=X.get(R.toLowerCase());if(!b)return Promise.resolve({error:Error(`${W.name} starts after unknown service ${R}`)});return q(b,J)}))).find((R)=>R.error);if(L)return V({error:Error(`${W.name} was not started because a prerequisite failed: ${L.error.message}`)});if(K.signal?.aborted)return V({error:Error(`${W.name} was not started because startup was cancelled`)});let N;try{N=await Y(W)}catch(R){return V({error:N0(R)})}if(K.signal?.aborted)return N.stop("SIGTERM"),V({error:Error(`${W.name} was stopped because startup was cancelled`)});if($.some((R)=>(R.after??[]).some((b)=>b.toLowerCase()===B)))console.log(`${U.dim(`LocalDeck: waiting for ${N.name} before starting its dependents`)}`);try{await N.ready}catch(R){return N.stop("SIGTERM"),V({error:N0(R)})}if(K.signal?.aborted)return N.stop("SIGTERM"),V({error:Error(`${W.name} was stopped because startup was cancelled`)});console.log(`${U.green("✓")} ${N.name} ${W.ready?.type==="tcp"?"is listening":"passed readiness"}`),V({service:N})})().catch((H)=>V({error:N0(H)})),S},z=await Promise.all($.map((W)=>q(W)));return{running:z.flatMap((W)=>W.service?[W.service]:[]),failed:$.flatMap((W,G)=>z[G].error?[{name:W.name,error:z[G].error}]:[])}}function N0($){return $ instanceof Error?$:Error(String($))}import m from"node:fs";import E from"node:path";function r($,Y){return $.commands.find((K)=>K.name===Y.toLowerCase())}function E0($=process.cwd()){let Y=e($);if(!Y)return[];let K=P0(Y),Q=C1(Y).flatMap(({file:Z,cwd:V})=>{let H=w0(Z)?.scripts;return H&&typeof H==="object"?Object.entries(H).filter(([,J])=>typeof J==="string").map(([J,O])=>({name:J,run:O,cwd:V})):[]}),q=Q.some((Z)=>Z.cwd),z=Q.filter((Z)=>!(q&&!Z.cwd&&/\b(?:turbo|nx|lerna)\b|\b(?:pnpm|npm|yarn|bun)\s+(?:-r|--recursive|workspaces?)/i.test(Z.run))),W=z.filter((Z)=>z.some((V)=>V.cwd===Z.cwd&&/^dev([:_-]|$)/.test(V.name))?/^dev([:_-]|$)/.test(Z.name):/^(start|serve|preview)([:_-]|$)/.test(Z.name)),G=[],B=new Set;for(let Z of W){let V=Z.name,[S,...H]=V.split(/[:_-]/),J=(Z.cwd&&!H.length?E.basename(Z.cwd):H.join("-")||S).replace(/[^a-z0-9_-]/gi,"-").toLowerCase().replace(/^[^a-z0-9]+/,"");if(!J)J="service";if(B.has(J))J=V.replace(/[^a-z0-9_-]/gi,"-").toLowerCase().replace(/^[^a-z0-9]+/,"");if(J=J.slice(0,41),B.has(J)){let O=2,L=J.replace(/-+$/,"").slice(0,37);while(B.has(J))J=`${L}-${O++}`.slice(0,41)}B.add(J),G.push({name:J,run:`${K} run ${V}`,tags:[],after:[],...Z.cwd?{cwd:Z.cwd}:{},ready:{type:"tcp",timeoutMs:60000,intervalMs:400}})}return G}function e($=process.cwd()){let Y=E.resolve($);for(;;){if(m.existsSync(E.join(Y,"package.json")))return Y;let K=E.dirname(Y);if(K===Y)return;Y=K}}function P0($){let Y=w0(E.join($,"package.json"));if(typeof Y?.packageManager==="string"){let X=Y.packageManager.split("@")[0];if(["npm","bun","pnpm","yarn"].includes(X))return X}return[["bun.lock","bun"],["bun.lockb","bun"],["pnpm-lock.yaml","pnpm"],["yarn.lock","yarn"],["package-lock.json","npm"]].find(([X])=>m.existsSync(E.join($,X)))?.[1]??"npm"}function w0($){try{let Y=JSON.parse(m.readFileSync($,"utf8"));if(!Y||typeof Y!=="object"||Array.isArray(Y))throw Error("manifest must contain an object");return Y}catch(Y){if(m.existsSync($))throw Error(`${$} is not valid JSON: ${Y.message}`);return}}function C1($){let Y=E.join($,"package.json"),K=w0(Y),X=[{file:Y}],z=(Array.isArray(K?.workspaces)?K.workspaces:Array.isArray(K?.packages)?K.packages:Array.isArray(K?.workspaces?.packages)?K.workspaces.packages:[]).filter((G)=>typeof G==="string"&&!G.includes("..")&&!E.isAbsolute(G)).map((G)=>{let B=G.replace(/\\/g,"/").replace(/^\.\//,"").replace(/\/$/,""),Z=B.split("/"),V=Z.map((S,H)=>S==="**"?H===Z.length-1?".*":"(?:[^/]+/)*":S.split("*").map((J)=>J.replace(/[.+?^${}()|[\]\\]/g,"\\$&")).join("[^/]*")+(H<Z.length-1?"/":"")).join("");return{regex:new RegExp(`^${V}$`),prefix:B.split("*")[0],depth:Z.includes("**")?1/0:Z.length}}),W=(G,B)=>{if(!z.some((Z)=>Z.depth>=B))return;for(let Z of m.readdirSync(G,{withFileTypes:!0}).sort((V,S)=>V.name.localeCompare(S.name))){if(!Z.isDirectory()||Z.isSymbolicLink()||["node_modules",".git",".superpowers","dist"].includes(Z.name))continue;let V=E.join(G,Z.name),S=E.relative($,V).replace(/\\/g,"/");if(!z.some((H)=>H.prefix.startsWith(`${S}/`)||S.startsWith(H.prefix)||H.prefix===S))continue;if(z.some((H)=>H.regex.test(S))){let H=E.join(V,"package.json");if(m.existsSync(H)&&m.lstatSync(H).isFile())X.push({file:H,cwd:S})}W(V,B+1)}};return W($,1),X}import j0 from"node:fs";import P from"node:path";import T from"node:fs";import A from"node:path";import{spawnSync as T1}from"node:child_process";async function X1($=process.cwd(),Y={}){let K=Y.executable??((B,Z,V)=>T1(B,Z,{encoding:"utf8",timeout:V})),X=[],Q=K("node",["--version"],2000),q=String(Q.stdout??"").trim().replace(/^v/,""),z=Number(q.split(".")[0]);X.push({name:"node",ok:Q.status===0&&z>=20,message:Q.status===0?`Node.js ${q}`:"Node.js executable is unavailable",fix:Q.status===0&&z>=20?void 0:"Install Node.js 20 or newer and put it on PATH."});let W=e($);if(!W)X.push({name:"package-manager",ok:!0,message:"No package.json; using explicitly configured commands."});else{let B="npm";try{B=P0(W)}catch(H){X.push({name:"package.json",ok:!1,message:H.message,fix:"Fix package.json syntax."})}let Z=K(B,["--version"],2000).status===0;X.push({name:"package-manager",ok:Z,message:`${B} ${Z?"is available":"is not available"}`,fix:Z?void 0:`Install ${B} or put it on PATH.`});let V;try{if(V=JSON.parse(T.readFileSync(A.join(W,"package.json"),"utf8")),!V||typeof V!=="object"||Array.isArray(V))throw Error("manifest must contain an object")}catch(H){X.push({name:"package.json",ok:!1,message:`${A.join(W,"package.json")} is not valid JSON: ${H.message}`,fix:"Fix package.json syntax."}),V={}}let S={...V.dependencies??{},...V.devDependencies??{},...V.optionalDependencies??{}};if(Object.keys(S).length){let H=T.existsSync(A.join(W,"node_modules"))||T.existsSync(A.join(W,".pnp.cjs"));X.push({name:"dependencies",ok:H,message:H?"node_modules exists":"dependencies are not installed",fix:H?void 0:`Run ${B} install.`})}}let G=I0($);if(!G)X.push({name:"config",ok:!1,message:"No LocalDeck config found",fix:"Run localdeck init."});else try{let B=x0(G);X.push({name:"config",ok:!0,message:`${A.basename(G)}: ${B.commands.length} service(s)`});let Z=[...new Set(B.commands.map((J)=>A.resolve(B.dir,J.cwd??".")))];for(let J of Z){let O=T.existsSync(J)&&T.statSync(J).isDirectory();X.push({name:`working-directory:${A.relative(B.dir,J)||"."}`,ok:O,message:O?"directory exists":"directory does not exist",fix:O?void 0:"Fix the command cwd in your LocalDeck config."})}for(let J of new Set([B.dir,...Z])){if(!T.existsSync(J)||!T.statSync(J).isDirectory())continue;let O=A.relative(B.dir,J)||".",L=A.join(J,"package.json");if(J!==W&&T.existsSync(L))try{let x=JSON.parse(T.readFileSync(L,"utf8"));if(Object.keys({...x.dependencies,...x.devDependencies}).length){let w=[J,W??B.dir].some((o)=>T.existsSync(A.join(o,"node_modules"))||T.existsSync(A.join(o,".pnp.cjs")));X.push({name:`dependencies:${O}`,ok:w,message:w?"Dependency installation found":"Dependencies are not installed",fix:w?void 0:"Run the project's package manager install command."})}}catch{X.push({name:`package.json:${O}`,ok:!1,message:"Invalid package.json",fix:"Fix this workspace's package.json syntax."})}let N=A.join(J,".env.example");if(!T.existsSync(N))continue;let R=I1(T.readFileSync(N,"utf8")),b=A.join(J,".env"),B0=A1(T.existsSync(b)?T.readFileSync(b,"utf8"):""),F=B.commands.filter((x)=>A.resolve(B.dir,x.cwd??".")===J),h=[...R].filter((x)=>!process.env[x]&&!B0.get(x)&&(!F.length||!F.every((w)=>Boolean(w.env?.[x]))));X.push({name:O==="."?"environment":`environment:${O}`,ok:!h.length,message:h.length?`missing variable names: ${h.join(", ")}`:"required variable names are present",fix:h.length?"Set the variables in this folder's .env or the environment.":void 0})}for(let J of B.commands)if(J.run.includes("docker")||J.serviceType==="compose"){let O=K("docker",["--version"],2000).status===0,L=O&&K("docker",["info"],3000).status===0;X.push({name:"docker",ok:L,message:L?"Docker executable and daemon are available":O?"Docker executable found but daemon is unavailable":"Docker executable is unavailable",fix:L?void 0:"Start Docker Desktop or the Docker daemon."});break}let V=B.commands.map((J)=>J.port).filter((J)=>Number.isInteger(J)),S=[...new Set(V.filter((J,O)=>V.indexOf(J)!==O))];X.push({name:"stable-ports",ok:!S.length,message:S.length?`conflicting configured ports: ${S.join(", ")}`:"configured stable ports are unique",fix:S.length?"Assign each service a different stable port.":void 0});let H=Y.portFree??Q1;for(let J of[...new Set(V)])if(!await H(J))X.push({name:`stable-port:${J}`,ok:!1,message:`port ${J} is already in use`,fix:"Choose another stable port or stop the process using it."});for(let J of B.commands){let O=J.serviceType,L=J.ready.type==="command"&&/^(pg_isready|redis-cli)\b/.exec(J.ready.run)?.[1];if(L){let N=L,R=K(N,["--version"],2000).status===0;X.push({name:N,ok:R,message:R?`${N} is available`:`${N} is unavailable for configured ${O} checks`,fix:R?void 0:`Install ${N} or use a configured container check.`})}}}catch(B){X.push({name:"config",ok:!1,message:B.message,fix:"Fix the config JSON and run doctor again."})}return{ok:X.every((B)=>B.ok),checks:X}}function I1($){return new Set($.split(/\r?\n/).map((Y)=>Y.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1]).filter((Y)=>Boolean(Y)))}function A1($){return new Map($.split(/\r?\n/).map((Y)=>Y.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)).filter((Y)=>Boolean(Y)).map((Y)=>[Y[1],Y[2].trim().replace(/^(['"])(.*)\1$/,"$2")]))}var Z1=`${U.bold("localdeck")} ${U.dim(`v${V0}`)} — stable ports, shareable links and logs for local dev servers
|
|
36
4
|
|
|
37
|
-
|
|
38
|
-
import { spawn as spawn2 } from "node:child_process";
|
|
39
|
-
import crypto from "node:crypto";
|
|
40
|
-
import { parseArgs } from "node:util";
|
|
41
|
-
|
|
42
|
-
// src/run.ts
|
|
43
|
-
import { spawn } from "node:child_process";
|
|
44
|
-
import fs from "node:fs";
|
|
45
|
-
// src/ui.ts
|
|
46
|
-
var on = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
47
|
-
var wrap = (code) => (s) => on ? `\x1B[${code}m${s}\x1B[0m` : s;
|
|
48
|
-
var c = {
|
|
49
|
-
bold: wrap(1),
|
|
50
|
-
dim: wrap(2),
|
|
51
|
-
red: wrap(31),
|
|
52
|
-
green: wrap(32),
|
|
53
|
-
yellow: wrap(33),
|
|
54
|
-
cyan: wrap(36),
|
|
55
|
-
magenta: wrap(35),
|
|
56
|
-
blue: wrap(34)
|
|
57
|
-
};
|
|
58
|
-
function fail(message) {
|
|
59
|
-
console.error(`${c.red("error")} ${message}`);
|
|
60
|
-
process.exit(1);
|
|
61
|
-
}
|
|
62
|
-
function table(rows, header) {
|
|
63
|
-
const all = [header, ...rows];
|
|
64
|
-
const widths = header.map((_, i) => Math.max(...all.map((r) => stripLen(r[i] ?? ""))));
|
|
65
|
-
const line = (r) => r.map((cell, i) => cell + " ".repeat(widths[i] - stripLen(cell))).join(" ");
|
|
66
|
-
return [c.dim(line(header)), ...rows.map(line)].join(`
|
|
67
|
-
`);
|
|
68
|
-
}
|
|
69
|
-
var stripLen = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
70
|
-
function ago(ts) {
|
|
71
|
-
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
|
72
|
-
if (s < 60)
|
|
73
|
-
return `${s}s`;
|
|
74
|
-
if (s < 3600)
|
|
75
|
-
return `${Math.round(s / 60)}m`;
|
|
76
|
-
if (s < 86400)
|
|
77
|
-
return `${Math.round(s / 3600)}h`;
|
|
78
|
-
return `${Math.round(s / 86400)}d`;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// src/clean-urls.ts
|
|
82
|
-
async function prepareCleanUrls(port, interactive, request = fetch, log = console.log) {
|
|
83
|
-
if (interactive)
|
|
84
|
-
log("LocalDeck: preparing app links. macOS may ask once to allow localhost port 80; cancelling keeps port-number links.");
|
|
85
|
-
try {
|
|
86
|
-
const response = await request(`http://127.0.0.1:${port}/api/forwarder`, {
|
|
87
|
-
method: "POST",
|
|
88
|
-
headers: { "content-type": "application/json" },
|
|
89
|
-
body: JSON.stringify({ install: interactive, automatic: true }),
|
|
90
|
-
signal: AbortSignal.timeout(135000)
|
|
91
|
-
});
|
|
92
|
-
const status = await response.json();
|
|
93
|
-
if (response.ok && status?.ready === true)
|
|
94
|
-
return true;
|
|
95
|
-
if (interactive)
|
|
96
|
-
log(`LocalDeck: using port-number links. ${status?.error ?? "Forwarding is unavailable."}`);
|
|
97
|
-
} catch {
|
|
98
|
-
if (interactive)
|
|
99
|
-
log("LocalDeck: using port-number links; forwarding setup was unavailable.");
|
|
100
|
-
}
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// src/run.ts
|
|
105
|
-
var MAX_ATTACH_LOG_SOURCE_BYTES = 8000;
|
|
106
|
-
var TERMINAL_GRACE_AFTER_MS = 500;
|
|
107
|
-
var TERMINAL_FORCE_AFTER_MS = 1000;
|
|
108
|
-
async function startService(opts, overrides = {}) {
|
|
109
|
-
const dependencies = {
|
|
110
|
-
ensureDaemon,
|
|
111
|
-
getFreePort,
|
|
112
|
-
createWebSocket: (url, options) => new wrapper_default(url, options),
|
|
113
|
-
spawn,
|
|
114
|
-
fetch: globalThis.fetch,
|
|
115
|
-
...overrides
|
|
116
|
-
};
|
|
117
|
-
if (!opts.command.length)
|
|
118
|
-
throw new Error(`nothing to run for "${opts.name}"`);
|
|
119
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
120
|
-
try {
|
|
121
|
-
if (!fs.statSync(cwd).isDirectory())
|
|
122
|
-
throw new Error("not a directory");
|
|
123
|
-
} catch {
|
|
124
|
-
throw new Error(`working directory does not exist or is not a directory: ${cwd}`);
|
|
125
|
-
}
|
|
126
|
-
const daemon = await dependencies.ensureDaemon();
|
|
127
|
-
const project = opts.project ?? {
|
|
128
|
-
id: "standalone",
|
|
129
|
-
name: "standalone",
|
|
130
|
-
slug: "standalone"
|
|
131
|
-
};
|
|
132
|
-
let secretEnv = {};
|
|
133
|
-
const resolveSecrets = dependencies.resolveProjectSecrets ?? resolveProjectSecrets;
|
|
134
|
-
if (opts.secrets?.length && project.id !== "standalone")
|
|
135
|
-
secretEnv = resolveSecrets(project.id, opts.secrets);
|
|
136
|
-
const upstreamPort = opts.upstreamPort ?? await dependencies.getFreePort();
|
|
137
|
-
const paint = opts.color ?? c.cyan;
|
|
138
|
-
const tag = opts.prefix ? paint(`[${opts.name}] `) : "";
|
|
139
|
-
let commandEnv = await expandRunEnvironment(opts.env, opts.after ?? [], project.id, daemon.apiPort, dependencies.fetch);
|
|
140
|
-
commandEnv = { ...commandEnv, ...secretEnv };
|
|
141
|
-
const ws = dependencies.createWebSocket(`ws://127.0.0.1:${daemon.apiPort}/api/attach`, {
|
|
142
|
-
headers: { authorization: `Bearer ${daemon.controlToken}` }
|
|
143
|
-
});
|
|
144
|
-
const send = (m) => ws.readyState === wrapper_default.OPEN && ws.send(JSON.stringify(m));
|
|
145
|
-
const service = await new Promise((resolve, reject) => {
|
|
146
|
-
ws.once("open", () => {
|
|
147
|
-
send({
|
|
148
|
-
type: "register",
|
|
149
|
-
key: serviceKey(project.id, opts.name.toLowerCase()),
|
|
150
|
-
projectId: project.id,
|
|
151
|
-
projectName: project.name,
|
|
152
|
-
projectSlug: project.slug,
|
|
153
|
-
name: opts.name,
|
|
154
|
-
managedBy: "terminal",
|
|
155
|
-
stablePort: opts.stablePort,
|
|
156
|
-
upstreamPort,
|
|
157
|
-
upstreamSource: opts.upstreamPort === undefined ? "assigned" : "manual",
|
|
158
|
-
cwd,
|
|
159
|
-
command: opts.displayCommand ?? opts.command.join(" "),
|
|
160
|
-
...opts.project ? { profiles: opts.profiles ?? [] } : {}
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
ws.on("message", (raw) => {
|
|
164
|
-
const msg = JSON.parse(raw.toString());
|
|
165
|
-
if (msg.type === "registered")
|
|
166
|
-
resolve(msg.service);
|
|
167
|
-
else if (msg.type === "error")
|
|
168
|
-
reject(new Error(msg.message));
|
|
169
|
-
});
|
|
170
|
-
ws.once("error", reject);
|
|
171
|
-
});
|
|
172
|
-
const argv = opts.command.map((a) => a.replaceAll("{port}", String(upstreamPort)));
|
|
173
|
-
const database = service.serviceType !== undefined && service.serviceType !== "http";
|
|
174
|
-
const portEnv = database ? {} : inferPortEnvironment(opts.displayCommand ?? opts.command, cwd, upstreamPort);
|
|
175
|
-
commandEnv = { ...portEnv, ...commandEnv };
|
|
176
|
-
for (const name of Object.keys(portEnv)) {
|
|
177
|
-
if (commandEnv[name] === portEnv[name]) {
|
|
178
|
-
const line = `LocalDeck: supplying assigned port ${upstreamPort} through ${name}`;
|
|
179
|
-
console.log(`${tag}${c.dim(line)}`);
|
|
180
|
-
send({ type: "log", stream: "stdout", line });
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
let env = {
|
|
184
|
-
...process.env,
|
|
185
|
-
...commandEnv,
|
|
186
|
-
...secretEnv,
|
|
187
|
-
PORT: String(upstreamPort),
|
|
188
|
-
LOCALDECK_PORT: String(service.stablePort),
|
|
189
|
-
LOCALDECK_SERVICE: service.name,
|
|
190
|
-
...database ? {} : { LOCALDECK_URL: `http://localhost:${service.stablePort}` }
|
|
191
|
-
};
|
|
192
|
-
const redactionValues = new Set(Object.values(secretEnv));
|
|
193
|
-
if (database) {
|
|
194
|
-
console.log(`${tag}${c.bold("LocalDeck")} ${c.dim("›")} ${paint(service.name)} ${c.dim("→")} ${c.green(`database localhost:${service.upstreamPort}`)} ${c.dim("(published port · no HTTP proxy)")}`);
|
|
195
|
-
} else {
|
|
196
|
-
const appUrl = opts.cleanUrls ? service.urls[0].replace(/^(http:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)?\.localhost):\d+(?=\/|$)/i, "$1") : service.urls[0];
|
|
197
|
-
console.log(`${tag}${c.bold("LocalDeck")} ${c.dim("›")} ${paint(service.name)} ${c.dim("→")} ${c.green(appUrl)} ${c.dim(`(also ${service.urls[1]} · dashboard http://localhost:${daemon.apiPort})`)}`);
|
|
198
|
-
}
|
|
199
|
-
const posix = process.platform !== "win32";
|
|
200
|
-
let child;
|
|
201
|
-
const pipe = (stream, name, out) => {
|
|
202
|
-
const output = redactionValues.size ? stream.pipe(secretOutput(redactionValues)) : stream;
|
|
203
|
-
output.on("data", (chunk) => {
|
|
204
|
-
const text = stripAnsi(chunk.toString());
|
|
205
|
-
recentOutput = (recentOutput + text).slice(-16000);
|
|
206
|
-
if (opts.prefix)
|
|
207
|
-
out.write(text.replace(/^(?=.)/gm, tag));
|
|
208
|
-
else
|
|
209
|
-
out.write(chunk);
|
|
210
|
-
});
|
|
211
|
-
streamLines(output, (line) => {
|
|
212
|
-
for (const part of splitAttachLogLine(stripAnsi(line)))
|
|
213
|
-
send({ type: "log", stream: name, line: part });
|
|
214
|
-
});
|
|
215
|
-
};
|
|
216
|
-
let hinted = false;
|
|
217
|
-
let recentOutput = "";
|
|
218
|
-
const hintOnPortClash = (chunk) => {
|
|
219
|
-
if (hinted)
|
|
220
|
-
return;
|
|
221
|
-
const text = chunk.toString();
|
|
222
|
-
if (/EADDRINUSE|address already in use|already in use/i.test(text) && text.includes(String(service.stablePort))) {
|
|
223
|
-
hinted = true;
|
|
224
|
-
console.error(`${tag}${c.yellow(`LocalDeck: your process tried to use port ${service.stablePort}, which LocalDeck is holding as the stable port for "${service.name}".`)}`);
|
|
225
|
-
console.error(`${tag}${c.yellow(` Fix: run without --port (LocalDeck finds the tool's own port automatically), or pick a different stable port, e.g. --port ${service.stablePort + 1000}.`)}`);
|
|
226
|
-
}
|
|
227
|
-
};
|
|
228
|
-
let detectTimer;
|
|
229
|
-
let reported;
|
|
230
|
-
let unreachable;
|
|
231
|
-
let retryTimer;
|
|
232
|
-
let retryAttempts = 0;
|
|
233
|
-
let finalizing = false;
|
|
234
|
-
let stopping = false;
|
|
235
|
-
let cleanupPromise;
|
|
236
|
-
let leaderExit;
|
|
237
|
-
let finished = false;
|
|
238
|
-
let resolveDone;
|
|
239
|
-
const done = new Promise((resolve) => {
|
|
240
|
-
resolveDone = resolve;
|
|
241
|
-
});
|
|
242
|
-
const cleanup = (signal) => cleanupPromise ??= cleanupTerminalTree(child, signal);
|
|
243
|
-
const finishAfterCleanup = async (signal) => {
|
|
244
|
-
if (finished || finalizing || !leaderExit)
|
|
245
|
-
return;
|
|
246
|
-
finalizing = true;
|
|
247
|
-
if (!await cleanup(signal)) {
|
|
248
|
-
cleanupPromise = undefined;
|
|
249
|
-
finalizing = false;
|
|
250
|
-
stopping = false;
|
|
251
|
-
console.error(`${tag}${c.red(`LocalDeck: process descendants survived cleanup; retry Stop`)}`);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
if (finished)
|
|
255
|
-
return;
|
|
256
|
-
if (detectTimer)
|
|
257
|
-
clearTimeout(detectTimer);
|
|
258
|
-
send({ type: "log", stream: "stderr", line: `process exited (${leaderExit.signal ?? `code ${leaderExit.code}`})` });
|
|
259
|
-
const interrupted = ["SIGINT", "SIGTERM", "SIGHUP"].includes(leaderExit.signal ?? "") || [130, 143, 129].includes(leaderExit.code ?? -1);
|
|
260
|
-
if (!stopping && !interrupted && retryAttempts < 3 && ws.readyState === wrapper_default.OPEN) {
|
|
261
|
-
const message = `LocalDeck: restarting in ${(dependencies.retryDelayMs ?? 3000) / 1000} seconds (attempt ${++retryAttempts}/3)`;
|
|
262
|
-
console.log(`${tag}${c.yellow(message)}`);
|
|
263
|
-
send({ type: "log", stream: "stderr", line: message });
|
|
264
|
-
send({ type: "readiness", status: "checking", message });
|
|
265
|
-
retryTimer = setTimeout(() => {
|
|
266
|
-
retryTimer = undefined;
|
|
267
|
-
if (stopping || finished)
|
|
268
|
-
return;
|
|
269
|
-
try {
|
|
270
|
-
launch();
|
|
271
|
-
} catch (error) {
|
|
272
|
-
finished = true;
|
|
273
|
-
console.error(`${tag}${c.red(`LocalDeck: restart failed: ${error instanceof Error ? error.message : error}`)}`);
|
|
274
|
-
ws.close();
|
|
275
|
-
resolveDone(1);
|
|
276
|
-
}
|
|
277
|
-
}, dependencies.retryDelayMs ?? 3000);
|
|
278
|
-
finalizing = false;
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
if (!stopping && !interrupted && retryAttempts === 3) {
|
|
282
|
-
const message = "LocalDeck: automatic restart limit reached (3/3). Start the service manually to try again.";
|
|
283
|
-
console.error(`${tag}${c.red(message)}`);
|
|
284
|
-
send({ type: "log", stream: "stderr", line: message });
|
|
285
|
-
}
|
|
286
|
-
finished = true;
|
|
287
|
-
setTimeout(() => {
|
|
288
|
-
ws.close();
|
|
289
|
-
resolveDone(leaderExit.code ?? (leaderExit.signal ? 1 : 0));
|
|
290
|
-
}, 50);
|
|
291
|
-
};
|
|
292
|
-
const stop = (signal = "SIGTERM") => {
|
|
293
|
-
if (finished || stopping)
|
|
294
|
-
return;
|
|
295
|
-
stopping = true;
|
|
296
|
-
if (retryTimer) {
|
|
297
|
-
clearTimeout(retryTimer);
|
|
298
|
-
retryTimer = undefined;
|
|
299
|
-
}
|
|
300
|
-
cleanup(signal).then(() => finishAfterCleanup(signal));
|
|
301
|
-
};
|
|
302
|
-
ws.on("message", (raw) => {
|
|
303
|
-
const msg = JSON.parse(raw.toString());
|
|
304
|
-
if (msg.type === "error") {
|
|
305
|
-
console.error(`${tag}${c.yellow(`LocalDeck: ${msg.message}`)}`);
|
|
306
|
-
reported = undefined;
|
|
307
|
-
} else if (msg.type === "stop") {
|
|
308
|
-
console.log(`${tag}${c.dim("LocalDeck: stop requested from dashboard/CLI")}`);
|
|
309
|
-
stop();
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
ws.on("close", () => {
|
|
313
|
-
if (retryTimer) {
|
|
314
|
-
stop();
|
|
315
|
-
return;
|
|
316
|
-
}
|
|
317
|
-
if (!stopping && !finished)
|
|
318
|
-
console.error(`${tag}${c.yellow("LocalDeck: lost connection to daemon; the stable port is no longer proxied. Process keeps running.")}`);
|
|
319
|
-
});
|
|
320
|
-
function launch() {
|
|
321
|
-
if (retryAttempts > 0 && opts.secrets?.length && project.id !== "standalone") {
|
|
322
|
-
secretEnv = resolveSecrets(project.id, opts.secrets);
|
|
323
|
-
for (const value of Object.values(secretEnv))
|
|
324
|
-
redactionValues.add(value);
|
|
325
|
-
commandEnv = { ...commandEnv, ...secretEnv };
|
|
326
|
-
env = { ...process.env, ...commandEnv, PORT: String(upstreamPort), LOCALDECK_PORT: String(service.stablePort), LOCALDECK_SERVICE: service.name, ...database ? {} : { LOCALDECK_URL: `http://localhost:${service.stablePort}` } };
|
|
327
|
-
}
|
|
328
|
-
leaderExit = undefined;
|
|
329
|
-
cleanupPromise = undefined;
|
|
330
|
-
finalizing = false;
|
|
331
|
-
reported = undefined;
|
|
332
|
-
recentOutput = "";
|
|
333
|
-
hinted = false;
|
|
334
|
-
child = dependencies.spawn(argv[0], argv.slice(1), { cwd, env, stdio: [opts.prefix ? "ignore" : "inherit", "pipe", "pipe"], shell: !posix, detached: posix });
|
|
335
|
-
if (child.pid)
|
|
336
|
-
send({ type: "pid", pid: child.pid });
|
|
337
|
-
pipe(child.stdout, "stdout", process.stdout);
|
|
338
|
-
pipe(child.stderr, "stderr", process.stderr);
|
|
339
|
-
child.stdout.on("data", hintOnPortClash);
|
|
340
|
-
child.stderr.on("data", hintOnPortClash);
|
|
341
|
-
if (!opts.upstreamPort && child.pid) {
|
|
342
|
-
const detectedChild = child;
|
|
343
|
-
const rootPid = child.pid;
|
|
344
|
-
let idle = 0;
|
|
345
|
-
const tick = async () => {
|
|
346
|
-
if (child !== detectedChild || child.exitCode !== null || child.signalCode)
|
|
347
|
-
return;
|
|
348
|
-
try {
|
|
349
|
-
const listeners = await listeningPorts(await processTree(rootPid));
|
|
350
|
-
const pick = chooseUpstream(listeners, upstreamPort);
|
|
351
|
-
const problem = pick ? undefined : unreachableListenerMessage(listeners);
|
|
352
|
-
if (problem && problem !== unreachable) {
|
|
353
|
-
console.error(`${tag}${c.yellow(`LocalDeck: ${problem}`)}`);
|
|
354
|
-
send({ type: "log", stream: "stderr", line: `LocalDeck: ${problem}` });
|
|
355
|
-
}
|
|
356
|
-
unreachable = problem;
|
|
357
|
-
if (pick) {
|
|
358
|
-
const key = `${connectHost(pick)}:${pick.port}`;
|
|
359
|
-
if (key !== reported) {
|
|
360
|
-
reported = key;
|
|
361
|
-
send({ type: "upstream", port: pick.port, host: connectHost(pick) });
|
|
362
|
-
if (pick.port !== upstreamPort)
|
|
363
|
-
console.log(`${tag}${c.dim(`LocalDeck: process is listening on ${key} (ignored $PORT) — ${service.urls[0]} now points there`)}`);
|
|
364
|
-
}
|
|
365
|
-
idle++;
|
|
366
|
-
}
|
|
367
|
-
} catch {}
|
|
368
|
-
if (child !== detectedChild || child.exitCode !== null || stopping || leaderExit)
|
|
369
|
-
return;
|
|
370
|
-
detectTimer = setTimeout(tick, reported ? Math.min(2000 + idle * 500, 1e4) : 400);
|
|
371
|
-
};
|
|
372
|
-
detectTimer = setTimeout(tick, 300);
|
|
373
|
-
}
|
|
374
|
-
child.on("exit", (code, signal) => {
|
|
375
|
-
if (code !== 0 && !hinted) {
|
|
376
|
-
const guidance = startupGuidance(recentOutput);
|
|
377
|
-
if (guidance)
|
|
378
|
-
console.error(`${tag}${c.yellow(["LocalDeck: " + guidance.title, ...guidance.steps].join(`
|
|
379
|
-
`))}`);
|
|
380
|
-
}
|
|
381
|
-
leaderExit = { code, signal };
|
|
382
|
-
finishAfterCleanup("SIGTERM");
|
|
383
|
-
});
|
|
384
|
-
child.on("error", (err) => {
|
|
385
|
-
stopping = true;
|
|
386
|
-
finished = true;
|
|
387
|
-
console.error(`${tag}${c.red(`LocalDeck: failed to start "${argv[0]}": ${err.message}`)}`);
|
|
388
|
-
ws.close();
|
|
389
|
-
resolveDone(1);
|
|
390
|
-
});
|
|
391
|
-
if (retryAttempts > 0)
|
|
392
|
-
checkReadiness().catch(() => stop());
|
|
393
|
-
}
|
|
394
|
-
launch();
|
|
395
|
-
const readiness = opts.ready;
|
|
396
|
-
const checkReadiness = () => readiness ? (async () => {
|
|
397
|
-
send({ type: "readiness", status: "checking", message: readiness.type === "tcp" ? "Waiting for the service to listen" : "Running configured readiness check" });
|
|
398
|
-
try {
|
|
399
|
-
const ready = await waitForReadiness({
|
|
400
|
-
name: service.name,
|
|
401
|
-
run: opts.displayCommand ?? opts.command.join(" "),
|
|
402
|
-
tags: [],
|
|
403
|
-
after: opts.after ?? [],
|
|
404
|
-
ready: readiness
|
|
405
|
-
}, async () => {
|
|
406
|
-
const response = await dependencies.fetch(`http://127.0.0.1:${daemon.apiPort}/api/services/${encodeURIComponent(service.key)}`);
|
|
407
|
-
if (response.status === 404)
|
|
408
|
-
return;
|
|
409
|
-
if (!response.ok)
|
|
410
|
-
throw new Error(`readiness status request failed (${response.status})`);
|
|
411
|
-
return response.json();
|
|
412
|
-
}, { cwd, env: commandEnv });
|
|
413
|
-
send({ type: "readiness", status: "ready", message: readiness.type === "tcp" ? "Listening on its upstream port" : "Configured readiness check passed" });
|
|
414
|
-
return ready;
|
|
415
|
-
} catch (error) {
|
|
416
|
-
send({ type: "readiness", status: "failed", message: error instanceof Error ? error.message.slice(0, 500) : "Readiness check failed" });
|
|
417
|
-
throw error;
|
|
418
|
-
}
|
|
419
|
-
})() : Promise.resolve(service);
|
|
420
|
-
const readyOperation = checkReadiness();
|
|
421
|
-
readyOperation.catch(() => {});
|
|
422
|
-
return { name: opts.name, service, get child() {
|
|
423
|
-
return child;
|
|
424
|
-
}, stop, done, ready: readyOperation };
|
|
425
|
-
}
|
|
426
|
-
async function cleanupTerminalTree(child, signal) {
|
|
427
|
-
await killProcessTree(child, signal);
|
|
428
|
-
if (process.platform === "win32")
|
|
429
|
-
return true;
|
|
430
|
-
const groupId = child.pid;
|
|
431
|
-
if (!groupId || !processGroupIsAlive(groupId))
|
|
432
|
-
return true;
|
|
433
|
-
if (await waitForProcessGroupExitWhileLeaderLives(child, groupId, TERMINAL_GRACE_AFTER_MS))
|
|
434
|
-
return true;
|
|
435
|
-
await killProcessTree(child, "SIGKILL");
|
|
436
|
-
return waitForProcessGroupExit(groupId, TERMINAL_FORCE_AFTER_MS);
|
|
437
|
-
}
|
|
438
|
-
function processGroupIsAlive(groupId) {
|
|
439
|
-
try {
|
|
440
|
-
process.kill(-groupId, 0);
|
|
441
|
-
return true;
|
|
442
|
-
} catch (error) {
|
|
443
|
-
return error.code === "EPERM";
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
async function waitForProcessGroupExit(groupId, timeoutMs) {
|
|
447
|
-
const deadline = Date.now() + timeoutMs;
|
|
448
|
-
while (processGroupIsAlive(groupId)) {
|
|
449
|
-
if (Date.now() >= deadline)
|
|
450
|
-
return false;
|
|
451
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(20, timeoutMs)));
|
|
452
|
-
}
|
|
453
|
-
return true;
|
|
454
|
-
}
|
|
455
|
-
async function waitForProcessGroupExitWhileLeaderLives(child, groupId, timeoutMs) {
|
|
456
|
-
const deadline = Date.now() + timeoutMs;
|
|
457
|
-
while (processGroupIsAlive(groupId)) {
|
|
458
|
-
if (child.exitCode !== null || child.signalCode !== null || Date.now() >= deadline)
|
|
459
|
-
return false;
|
|
460
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(20, timeoutMs)));
|
|
461
|
-
}
|
|
462
|
-
return true;
|
|
463
|
-
}
|
|
464
|
-
function splitAttachLogLine(line) {
|
|
465
|
-
if (Buffer.byteLength(line) <= MAX_ATTACH_LOG_SOURCE_BYTES)
|
|
466
|
-
return [line];
|
|
467
|
-
const chunks = [];
|
|
468
|
-
let characters = [];
|
|
469
|
-
let bytes = 0;
|
|
470
|
-
for (const character of line) {
|
|
471
|
-
const characterBytes = Buffer.byteLength(character);
|
|
472
|
-
if (characters.length && bytes + characterBytes > MAX_ATTACH_LOG_SOURCE_BYTES) {
|
|
473
|
-
chunks.push(characters.join(""));
|
|
474
|
-
characters = [];
|
|
475
|
-
bytes = 0;
|
|
476
|
-
}
|
|
477
|
-
characters.push(character);
|
|
478
|
-
bytes += characterBytes;
|
|
479
|
-
}
|
|
480
|
-
if (characters.length)
|
|
481
|
-
chunks.push(characters.join(""));
|
|
482
|
-
return chunks;
|
|
483
|
-
}
|
|
484
|
-
async function expandRunEnvironment(overrides, prerequisites, projectId, apiPort, fetchImpl) {
|
|
485
|
-
if (!overrides)
|
|
486
|
-
return {};
|
|
487
|
-
const allowed = new Set(prerequisites);
|
|
488
|
-
const services = new Map;
|
|
489
|
-
const load = async (rawName) => {
|
|
490
|
-
const name = rawName.toLowerCase();
|
|
491
|
-
if (!allowed.has(name))
|
|
492
|
-
throw new Error(`environment references undeclared prerequisite "${rawName}"`);
|
|
493
|
-
const cached = services.get(name);
|
|
494
|
-
if (cached)
|
|
495
|
-
return cached;
|
|
496
|
-
const response = await fetchImpl(`http://127.0.0.1:${apiPort}/api/services/${encodeURIComponent(serviceKey(projectId, name))}`);
|
|
497
|
-
if (response.status === 404)
|
|
498
|
-
throw new Error(`environment references unresolved prerequisite "${rawName}"`);
|
|
499
|
-
if (!response.ok)
|
|
500
|
-
throw new Error(`could not resolve prerequisite "${rawName}" (${response.status})`);
|
|
501
|
-
const service = await response.json();
|
|
502
|
-
services.set(name, service);
|
|
503
|
-
return service;
|
|
504
|
-
};
|
|
505
|
-
const reference = /\{service:([^}:]+):(port|url)\}/g;
|
|
506
|
-
const expanded = {};
|
|
507
|
-
for (const [name, value] of Object.entries(overrides)) {
|
|
508
|
-
let cursor = 0;
|
|
509
|
-
let output = "";
|
|
510
|
-
for (const match of value.matchAll(reference)) {
|
|
511
|
-
output += value.slice(cursor, match.index);
|
|
512
|
-
const service = await load(match[1]);
|
|
513
|
-
if (match[2] === "port")
|
|
514
|
-
output += String(service.serviceType && service.serviceType !== "http" ? service.upstreamPort : service.stablePort);
|
|
515
|
-
else {
|
|
516
|
-
if (service.serviceType && service.serviceType !== "http")
|
|
517
|
-
throw new Error(`environment variable ${name} cannot use a URL for database prerequisite "${match[1]}"`);
|
|
518
|
-
output += `http://localhost:${service.stablePort}`;
|
|
519
|
-
}
|
|
520
|
-
cursor = match.index + match[0].length;
|
|
521
|
-
}
|
|
522
|
-
expanded[name] = output + value.slice(cursor);
|
|
523
|
-
if (expanded[name].includes("{service:"))
|
|
524
|
-
throw new Error(`environment variable ${name} has an invalid service reference`);
|
|
525
|
-
}
|
|
526
|
-
return expanded;
|
|
527
|
-
}
|
|
528
|
-
async function runForeground(all) {
|
|
529
|
-
const daemon = await ensureDaemon();
|
|
530
|
-
const cleanUrls = await prepareCleanUrls(daemon.apiPort, Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI));
|
|
531
|
-
const running = [];
|
|
532
|
-
const controller = new AbortController;
|
|
533
|
-
const stopAll = (sig) => {
|
|
534
|
-
controller.abort();
|
|
535
|
-
running.forEach((r) => r.stop(sig));
|
|
536
|
-
};
|
|
537
|
-
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
538
|
-
process.on(sig, () => stopAll(sig));
|
|
539
|
-
const scheduled = await scheduleForegroundStarts(all, async (options) => {
|
|
540
|
-
const service = await startService({ ...options, cleanUrls });
|
|
541
|
-
running.push(service);
|
|
542
|
-
return service;
|
|
543
|
-
}, { signal: controller.signal });
|
|
544
|
-
for (const failed of scheduled.failed)
|
|
545
|
-
console.error(`${c.red("error")} starting "${failed.name}": ${failed.error.message}`);
|
|
546
|
-
const codes = await Promise.all(running.map((r) => r.done));
|
|
547
|
-
process.exit(codes.find((c2) => c2 !== 0) ?? (scheduled.failed.length ? 1 : 0));
|
|
548
|
-
}
|
|
549
|
-
async function scheduleForegroundStarts(all, launch, control = {}) {
|
|
550
|
-
const byName = new Map(all.map((options) => [options.name.toLowerCase(), options]));
|
|
551
|
-
const ready = new Map;
|
|
552
|
-
const schedule = (options, ancestors = new Set) => {
|
|
553
|
-
const name = options.name.toLowerCase();
|
|
554
|
-
if (ancestors.has(name))
|
|
555
|
-
return Promise.resolve({ error: new Error("project dependency graph contains a cycle") });
|
|
556
|
-
const existing = ready.get(name);
|
|
557
|
-
if (existing)
|
|
558
|
-
return existing;
|
|
559
|
-
let resolve;
|
|
560
|
-
const scheduled = new Promise((done) => {
|
|
561
|
-
resolve = done;
|
|
562
|
-
});
|
|
563
|
-
ready.set(name, scheduled);
|
|
564
|
-
(async () => {
|
|
565
|
-
const dependencies = options.after ?? [];
|
|
566
|
-
const nextAncestors = new Set(ancestors).add(name);
|
|
567
|
-
const prerequisiteResults = await Promise.all(dependencies.map((dependency) => {
|
|
568
|
-
const prerequisite = byName.get(dependency.toLowerCase());
|
|
569
|
-
if (!prerequisite)
|
|
570
|
-
return Promise.resolve({ error: new Error(`${options.name} starts after unknown service ${dependency}`) });
|
|
571
|
-
return schedule(prerequisite, nextAncestors);
|
|
572
|
-
}));
|
|
573
|
-
const failedDependency = prerequisiteResults.find((result) => result.error);
|
|
574
|
-
if (failedDependency)
|
|
575
|
-
return resolve({ error: new Error(`${options.name} was not started because a prerequisite failed: ${failedDependency.error.message}`) });
|
|
576
|
-
if (control.signal?.aborted)
|
|
577
|
-
return resolve({ error: new Error(`${options.name} was not started because startup was cancelled`) });
|
|
578
|
-
let service;
|
|
579
|
-
try {
|
|
580
|
-
service = await launch(options);
|
|
581
|
-
} catch (error) {
|
|
582
|
-
return resolve({ error: toError(error) });
|
|
583
|
-
}
|
|
584
|
-
if (control.signal?.aborted) {
|
|
585
|
-
service.stop("SIGTERM");
|
|
586
|
-
return resolve({ error: new Error(`${options.name} was stopped because startup was cancelled`) });
|
|
587
|
-
}
|
|
588
|
-
if (all.some((candidate) => (candidate.after ?? []).some((dependency) => dependency.toLowerCase() === name))) {
|
|
589
|
-
console.log(`${c.dim(`LocalDeck: waiting for ${service.name} before starting its dependents`)}`);
|
|
590
|
-
}
|
|
591
|
-
try {
|
|
592
|
-
await service.ready;
|
|
593
|
-
} catch (error) {
|
|
594
|
-
service.stop("SIGTERM");
|
|
595
|
-
return resolve({ error: toError(error) });
|
|
596
|
-
}
|
|
597
|
-
if (control.signal?.aborted) {
|
|
598
|
-
service.stop("SIGTERM");
|
|
599
|
-
return resolve({ error: new Error(`${options.name} was stopped because startup was cancelled`) });
|
|
600
|
-
}
|
|
601
|
-
console.log(`${c.green("✓")} ${service.name} ${options.ready?.type === "tcp" ? "is listening" : "passed readiness"}`);
|
|
602
|
-
resolve({ service });
|
|
603
|
-
})().catch((error) => resolve({ error: toError(error) }));
|
|
604
|
-
return scheduled;
|
|
605
|
-
};
|
|
606
|
-
const settled = await Promise.all(all.map((option) => schedule(option)));
|
|
607
|
-
return {
|
|
608
|
-
running: settled.flatMap((result) => result.service ? [result.service] : []),
|
|
609
|
-
failed: all.flatMap((option, index) => settled[index].error ? [{ name: option.name, error: settled[index].error }] : [])
|
|
610
|
-
};
|
|
611
|
-
}
|
|
612
|
-
function toError(error) {
|
|
613
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// src/project.ts
|
|
617
|
-
import fs2 from "node:fs";
|
|
618
|
-
import path from "node:path";
|
|
619
|
-
function findProjectCommand(project, name) {
|
|
620
|
-
return project.commands.find((command) => command.name === name.toLowerCase());
|
|
621
|
-
}
|
|
622
|
-
function proposeFromPackageJson(dir = process.cwd()) {
|
|
623
|
-
const root = findPackageRoot(dir);
|
|
624
|
-
if (!root)
|
|
625
|
-
return [];
|
|
626
|
-
const manager = detectPackageManager(root);
|
|
627
|
-
const manifests = discoverWorkspaceManifests(root);
|
|
628
|
-
const entries = manifests.flatMap(({ file, cwd }) => {
|
|
629
|
-
const pkg = readJson(file);
|
|
630
|
-
const scripts = pkg?.scripts;
|
|
631
|
-
return scripts && typeof scripts === "object" ? Object.entries(scripts).filter(([, value]) => typeof value === "string").map(([name, run]) => ({ name, run, cwd })) : [];
|
|
632
|
-
});
|
|
633
|
-
const hasWorkspaceScripts = entries.some((e) => e.cwd);
|
|
634
|
-
const usable = entries.filter((e) => !(hasWorkspaceScripts && !e.cwd && /\b(?:turbo|nx|lerna)\b|\b(?:pnpm|npm|yarn|bun)\s+(?:-r|--recursive|workspaces?)/i.test(e.run)));
|
|
635
|
-
const chosen = usable.filter((entry) => usable.some((other) => other.cwd === entry.cwd && /^dev([:_-]|$)/.test(other.name)) ? /^dev([:_-]|$)/.test(entry.name) : /^(start|serve|preview)([:_-]|$)/.test(entry.name));
|
|
636
|
-
const out = [];
|
|
637
|
-
const taken = new Set;
|
|
638
|
-
for (const source of chosen) {
|
|
639
|
-
const script = source.name;
|
|
640
|
-
const [prefix, ...rest] = script.split(/[:_-]/);
|
|
641
|
-
let name = (source.cwd && !rest.length ? path.basename(source.cwd) : rest.join("-") || prefix).replace(/[^a-z0-9_-]/gi, "-").toLowerCase().replace(/^[^a-z0-9]+/, "");
|
|
642
|
-
if (!name)
|
|
643
|
-
name = "service";
|
|
644
|
-
if (taken.has(name))
|
|
645
|
-
name = script.replace(/[^a-z0-9_-]/gi, "-").toLowerCase().replace(/^[^a-z0-9]+/, "");
|
|
646
|
-
name = name.slice(0, 41);
|
|
647
|
-
if (taken.has(name)) {
|
|
648
|
-
let n = 2;
|
|
649
|
-
const base = name.replace(/-+$/, "").slice(0, 37);
|
|
650
|
-
while (taken.has(name))
|
|
651
|
-
name = `${base}-${n++}`.slice(0, 41);
|
|
652
|
-
}
|
|
653
|
-
taken.add(name);
|
|
654
|
-
out.push({
|
|
655
|
-
name,
|
|
656
|
-
run: `${manager} run ${script}`,
|
|
657
|
-
tags: [],
|
|
658
|
-
after: [],
|
|
659
|
-
...source.cwd ? { cwd: source.cwd } : {},
|
|
660
|
-
ready: { type: "tcp", timeoutMs: 60000, intervalMs: 400 }
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
return out;
|
|
664
|
-
}
|
|
665
|
-
function findPackageRoot(from = process.cwd()) {
|
|
666
|
-
let dir = path.resolve(from);
|
|
667
|
-
for (;; ) {
|
|
668
|
-
if (fs2.existsSync(path.join(dir, "package.json")))
|
|
669
|
-
return dir;
|
|
670
|
-
const parent = path.dirname(dir);
|
|
671
|
-
if (parent === dir)
|
|
672
|
-
return;
|
|
673
|
-
dir = parent;
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
function detectPackageManager(root) {
|
|
677
|
-
const pkg = readJson(path.join(root, "package.json"));
|
|
678
|
-
if (typeof pkg?.packageManager === "string") {
|
|
679
|
-
const value = pkg.packageManager.split("@")[0];
|
|
680
|
-
if (["npm", "bun", "pnpm", "yarn"].includes(value))
|
|
681
|
-
return value;
|
|
682
|
-
}
|
|
683
|
-
const lockfiles = [["bun.lock", "bun"], ["bun.lockb", "bun"], ["pnpm-lock.yaml", "pnpm"], ["yarn.lock", "yarn"], ["package-lock.json", "npm"]];
|
|
684
|
-
return lockfiles.find(([file]) => fs2.existsSync(path.join(root, file)))?.[1] ?? "npm";
|
|
685
|
-
}
|
|
686
|
-
function readJson(file) {
|
|
687
|
-
try {
|
|
688
|
-
const value = JSON.parse(fs2.readFileSync(file, "utf8"));
|
|
689
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
690
|
-
throw new Error("manifest must contain an object");
|
|
691
|
-
return value;
|
|
692
|
-
} catch (error) {
|
|
693
|
-
if (fs2.existsSync(file))
|
|
694
|
-
throw new Error(`${file} is not valid JSON: ${error.message}`);
|
|
695
|
-
return;
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
function discoverWorkspaceManifests(root) {
|
|
699
|
-
const rootFile = path.join(root, "package.json");
|
|
700
|
-
const pkg = readJson(rootFile);
|
|
701
|
-
const out = [{ file: rootFile }];
|
|
702
|
-
const workspaces = Array.isArray(pkg?.workspaces) ? pkg.workspaces : Array.isArray(pkg?.packages) ? pkg.packages : Array.isArray(pkg?.workspaces?.packages) ? pkg.workspaces.packages : [];
|
|
703
|
-
const patterns = workspaces.filter((pattern) => typeof pattern === "string" && !pattern.includes("..") && !path.isAbsolute(pattern));
|
|
704
|
-
const matchers = patterns.map((pattern) => {
|
|
705
|
-
const clean = pattern.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
706
|
-
const parts = clean.split("/");
|
|
707
|
-
const regex = parts.map((part, index) => part === "**" ? index === parts.length - 1 ? ".*" : "(?:[^/]+/)*" : part.split("*").map((piece) => piece.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*") + (index < parts.length - 1 ? "/" : "")).join("");
|
|
708
|
-
return { regex: new RegExp(`^${regex}$`), prefix: clean.split("*")[0], depth: parts.includes("**") ? Infinity : parts.length };
|
|
709
|
-
});
|
|
710
|
-
const walk = (dir, depth) => {
|
|
711
|
-
if (!matchers.some((matcher) => matcher.depth >= depth))
|
|
712
|
-
return;
|
|
713
|
-
for (const entry of fs2.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
714
|
-
if (!entry.isDirectory() || entry.isSymbolicLink() || ["node_modules", ".git", ".superpowers", "dist"].includes(entry.name))
|
|
715
|
-
continue;
|
|
716
|
-
const full = path.join(dir, entry.name);
|
|
717
|
-
const rel = path.relative(root, full).replace(/\\/g, "/");
|
|
718
|
-
if (!matchers.some((matcher) => matcher.prefix.startsWith(`${rel}/`) || rel.startsWith(matcher.prefix) || matcher.prefix === rel))
|
|
719
|
-
continue;
|
|
720
|
-
if (matchers.some((matcher) => matcher.regex.test(rel))) {
|
|
721
|
-
const file = path.join(full, "package.json");
|
|
722
|
-
if (fs2.existsSync(file) && fs2.lstatSync(file).isFile())
|
|
723
|
-
out.push({ file, cwd: rel });
|
|
724
|
-
}
|
|
725
|
-
walk(full, depth + 1);
|
|
726
|
-
}
|
|
727
|
-
};
|
|
728
|
-
walk(root, 1);
|
|
729
|
-
return out;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
// src/index.ts
|
|
733
|
-
import fs4 from "node:fs";
|
|
734
|
-
import path3 from "node:path";
|
|
735
|
-
|
|
736
|
-
// src/setup.ts
|
|
737
|
-
import fs3 from "node:fs";
|
|
738
|
-
import path2 from "node:path";
|
|
739
|
-
import { spawnSync } from "node:child_process";
|
|
740
|
-
async function doctor(dir = process.cwd(), options = {}) {
|
|
741
|
-
const executable = options.executable ?? ((command, args, timeoutMs) => spawnSync(command, args, { encoding: "utf8", timeout: timeoutMs }));
|
|
742
|
-
const checks = [];
|
|
743
|
-
const nodeProbe = executable("node", ["--version"], 2000);
|
|
744
|
-
const nodeVersion = String(nodeProbe.stdout ?? "").trim().replace(/^v/, "");
|
|
745
|
-
const nodeMajor = Number(nodeVersion.split(".")[0]);
|
|
746
|
-
checks.push({ name: "node", ok: nodeProbe.status === 0 && nodeMajor >= 20, message: nodeProbe.status === 0 ? `Node.js ${nodeVersion}` : "Node.js executable is unavailable", fix: nodeProbe.status === 0 && nodeMajor >= 20 ? undefined : "Install Node.js 20 or newer and put it on PATH." });
|
|
747
|
-
const root = findPackageRoot(dir);
|
|
748
|
-
if (!root)
|
|
749
|
-
checks.push({ name: "package-manager", ok: true, message: "No package.json; using explicitly configured commands." });
|
|
750
|
-
else {
|
|
751
|
-
let manager = "npm";
|
|
752
|
-
try {
|
|
753
|
-
manager = detectPackageManager(root);
|
|
754
|
-
} catch (error) {
|
|
755
|
-
checks.push({ name: "package.json", ok: false, message: error.message, fix: "Fix package.json syntax." });
|
|
756
|
-
}
|
|
757
|
-
const available = executable(manager, ["--version"], 2000).status === 0;
|
|
758
|
-
checks.push({ name: "package-manager", ok: available, message: `${manager} ${available ? "is available" : "is not available"}`, fix: available ? undefined : `Install ${manager} or put it on PATH.` });
|
|
759
|
-
let pkg;
|
|
760
|
-
try {
|
|
761
|
-
pkg = JSON.parse(fs3.readFileSync(path2.join(root, "package.json"), "utf8"));
|
|
762
|
-
if (!pkg || typeof pkg !== "object" || Array.isArray(pkg))
|
|
763
|
-
throw new Error("manifest must contain an object");
|
|
764
|
-
} catch (error) {
|
|
765
|
-
checks.push({ name: "package.json", ok: false, message: `${path2.join(root, "package.json")} is not valid JSON: ${error.message}`, fix: "Fix package.json syntax." });
|
|
766
|
-
pkg = {};
|
|
767
|
-
}
|
|
768
|
-
const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {}, ...pkg.optionalDependencies ?? {} };
|
|
769
|
-
if (Object.keys(deps).length) {
|
|
770
|
-
const installed = fs3.existsSync(path2.join(root, "node_modules")) || fs3.existsSync(path2.join(root, ".pnp.cjs"));
|
|
771
|
-
checks.push({ name: "dependencies", ok: installed, message: installed ? "node_modules exists" : "dependencies are not installed", fix: installed ? undefined : `Run ${manager} install.` });
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
const projectFile = findProjectFile(dir);
|
|
775
|
-
if (!projectFile)
|
|
776
|
-
checks.push({ name: "config", ok: false, message: "No LocalDeck config found", fix: "Run localdeck init." });
|
|
777
|
-
else {
|
|
778
|
-
try {
|
|
779
|
-
const project = loadProjectFile(projectFile);
|
|
780
|
-
checks.push({ name: "config", ok: true, message: `${path2.basename(projectFile)}: ${project.commands.length} service(s)` });
|
|
781
|
-
const dirs = [...new Set(project.commands.map((c2) => path2.resolve(project.dir, c2.cwd ?? ".")))];
|
|
782
|
-
for (const cwd of dirs) {
|
|
783
|
-
const exists = fs3.existsSync(cwd) && fs3.statSync(cwd).isDirectory();
|
|
784
|
-
checks.push({ name: `working-directory:${path2.relative(project.dir, cwd) || "."}`, ok: exists, message: exists ? "directory exists" : "directory does not exist", fix: exists ? undefined : "Fix the command cwd in your LocalDeck config." });
|
|
785
|
-
}
|
|
786
|
-
for (const cwd of new Set([project.dir, ...dirs])) {
|
|
787
|
-
if (!fs3.existsSync(cwd) || !fs3.statSync(cwd).isDirectory())
|
|
788
|
-
continue;
|
|
789
|
-
const label = path2.relative(project.dir, cwd) || ".";
|
|
790
|
-
const manifest = path2.join(cwd, "package.json");
|
|
791
|
-
if (cwd !== root && fs3.existsSync(manifest)) {
|
|
792
|
-
try {
|
|
793
|
-
const pkg = JSON.parse(fs3.readFileSync(manifest, "utf8"));
|
|
794
|
-
if (Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).length) {
|
|
795
|
-
const installed = [cwd, root ?? project.dir].some((directory) => fs3.existsSync(path2.join(directory, "node_modules")) || fs3.existsSync(path2.join(directory, ".pnp.cjs")));
|
|
796
|
-
checks.push({ name: `dependencies:${label}`, ok: installed, message: installed ? "Dependency installation found" : "Dependencies are not installed", fix: installed ? undefined : "Run the project's package manager install command." });
|
|
797
|
-
}
|
|
798
|
-
} catch {
|
|
799
|
-
checks.push({ name: `package.json:${label}`, ok: false, message: "Invalid package.json", fix: "Fix this workspace's package.json syntax." });
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
const envExample = path2.join(cwd, ".env.example");
|
|
803
|
-
if (!fs3.existsSync(envExample))
|
|
804
|
-
continue;
|
|
805
|
-
const required = envNames(fs3.readFileSync(envExample, "utf8"));
|
|
806
|
-
const envFile = path2.join(cwd, ".env");
|
|
807
|
-
const values = envAssignments(fs3.existsSync(envFile) ? fs3.readFileSync(envFile, "utf8") : "");
|
|
808
|
-
const commands = project.commands.filter((command) => path2.resolve(project.dir, command.cwd ?? ".") === cwd);
|
|
809
|
-
const missing = [...required].filter((name) => !process.env[name] && !values.get(name) && (!commands.length || !commands.every((command) => Boolean(command.env?.[name]))));
|
|
810
|
-
checks.push({ name: label === "." ? "environment" : `environment:${label}`, ok: !missing.length, message: missing.length ? `missing variable names: ${missing.join(", ")}` : "required variable names are present", fix: missing.length ? "Set the variables in this folder's .env or the environment." : undefined });
|
|
811
|
-
}
|
|
812
|
-
for (const command of project.commands)
|
|
813
|
-
if (command.run.includes("docker") || command.serviceType === "compose") {
|
|
814
|
-
const docker = executable("docker", ["--version"], 2000).status === 0;
|
|
815
|
-
const daemon = docker && executable("docker", ["info"], 3000).status === 0;
|
|
816
|
-
checks.push({ name: "docker", ok: daemon, message: daemon ? "Docker executable and daemon are available" : docker ? "Docker executable found but daemon is unavailable" : "Docker executable is unavailable", fix: daemon ? undefined : "Start Docker Desktop or the Docker daemon." });
|
|
817
|
-
break;
|
|
818
|
-
}
|
|
819
|
-
const ports = project.commands.map((c2) => c2.port).filter((p) => Number.isInteger(p));
|
|
820
|
-
const duplicates = [...new Set(ports.filter((p, i) => ports.indexOf(p) !== i))];
|
|
821
|
-
checks.push({ name: "stable-ports", ok: !duplicates.length, message: duplicates.length ? `conflicting configured ports: ${duplicates.join(", ")}` : "configured stable ports are unique", fix: duplicates.length ? "Assign each service a different stable port." : undefined });
|
|
822
|
-
const portFree = options.portFree ?? isPortFree;
|
|
823
|
-
for (const port of [...new Set(ports)])
|
|
824
|
-
if (!await portFree(port))
|
|
825
|
-
checks.push({ name: `stable-port:${port}`, ok: false, message: `port ${port} is already in use`, fix: "Choose another stable port or stop the process using it." });
|
|
826
|
-
for (const command of project.commands) {
|
|
827
|
-
const type = command.serviceType;
|
|
828
|
-
const nativeCheck = command.ready.type === "command" && /^(pg_isready|redis-cli)\b/.exec(command.ready.run)?.[1];
|
|
829
|
-
if (nativeCheck) {
|
|
830
|
-
const tool = nativeCheck;
|
|
831
|
-
const available = executable(tool, ["--version"], 2000).status === 0;
|
|
832
|
-
checks.push({ name: tool, ok: available, message: available ? `${tool} is available` : `${tool} is unavailable for configured ${type} checks`, fix: available ? undefined : `Install ${tool} or use a configured container check.` });
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
} catch (error) {
|
|
836
|
-
checks.push({ name: "config", ok: false, message: error.message, fix: "Fix the config JSON and run doctor again." });
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
return { ok: checks.every((check) => check.ok), checks };
|
|
840
|
-
}
|
|
841
|
-
function envNames(text) {
|
|
842
|
-
return new Set(text.split(/\r?\n/).map((line) => line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1]).filter((v) => Boolean(v)));
|
|
843
|
-
}
|
|
844
|
-
function envAssignments(text) {
|
|
845
|
-
return new Map(text.split(/\r?\n/).map((line) => line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)).filter((m) => Boolean(m)).map((m) => [m[1], m[2].trim().replace(/^(['"])(.*)\1$/, "$2")]));
|
|
846
|
-
}
|
|
847
|
-
// src/index.ts
|
|
848
|
-
var HELP = `${c.bold("localdeck")} ${c.dim(`v${VERSION}`)} — stable ports, shareable links and logs for local dev servers
|
|
849
|
-
|
|
850
|
-
${c.bold("Usage")}
|
|
5
|
+
${U.bold("Usage")}
|
|
851
6
|
localdeck run <name> [--port 4000] [--upstream 5173] [--] <command...>
|
|
852
7
|
localdeck <name> run a command from the project's .localdeck file
|
|
853
8
|
localdeck up [tag|name...] run every .localdeck command (or just the tagged/named ones) in one terminal
|
|
@@ -856,6 +11,7 @@ ${c.bold("Usage")}
|
|
|
856
11
|
localdeck init --name web --command 'your command'
|
|
857
12
|
localdeck commands list configured services (alias: cmds)
|
|
858
13
|
localdeck mcp [--project <id>] connect an AI client over stdio
|
|
14
|
+
localdeck quarry show and control services in Quarry (stdio)
|
|
859
15
|
localdeck doctor [--json] diagnose setup without starting anything
|
|
860
16
|
localdeck forward [--to-port N] forward 127.0.0.1:80 to 127.0.0.1:7777
|
|
861
17
|
localdeck ps list running services and their ports
|
|
@@ -871,16 +27,16 @@ ${c.bold("Usage")}
|
|
|
871
27
|
localdeck --version | --v | -v show the installed version
|
|
872
28
|
localdeck --update update the global npm install and restart the daemon
|
|
873
29
|
|
|
874
|
-
${
|
|
30
|
+
${U.bold("How it works")}
|
|
875
31
|
LocalDeck holds a stable port for each service (e.g. 4000) and proxies to
|
|
876
32
|
whatever port your process actually opens — it watches the process and
|
|
877
33
|
finds the port itself, so wrangler, vite, next etc. need no configuration.
|
|
878
34
|
($PORT is also set, and {port} in a command is replaced, for tools that
|
|
879
35
|
want to be told.) Other services and browsers only ever use the stable
|
|
880
36
|
port, so nothing silently moves when a port is busy.
|
|
881
|
-
Dashboard: http://localhost:${
|
|
37
|
+
Dashboard: http://localhost:${J1}
|
|
882
38
|
|
|
883
|
-
${
|
|
39
|
+
${U.bold(".localdeck")} (JSON, at the project root)
|
|
884
40
|
[
|
|
885
41
|
{ "name": "database", "run": "npm run database" },
|
|
886
42
|
{ "name": "api", "run": "npm run api", "after": "database", "ready": "/health" },
|
|
@@ -889,563 +45,11 @@ ${c.bold(".localdeck")} (JSON, at the project root)
|
|
|
889
45
|
"after" is the beginner-friendly startup order. LocalDeck verifies each
|
|
890
46
|
prerequisite is ready before starting its dependents. Omit "ready" to use
|
|
891
47
|
automatic port readiness; HTTP paths and advanced checks are optional.
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
log: (message) => console.log(message),
|
|
901
|
-
warn: (message) => console.warn(message)
|
|
902
|
-
};
|
|
903
|
-
async function main(argv, overrides = {}) {
|
|
904
|
-
const dependencies = { ...defaultDependencies, ...overrides };
|
|
905
|
-
const [cmd, ...rest] = argv;
|
|
906
|
-
switch (cmd) {
|
|
907
|
-
case undefined:
|
|
908
|
-
console.log(HELP);
|
|
909
|
-
await printProjectCommands(false, dependencies);
|
|
910
|
-
return;
|
|
911
|
-
case "help":
|
|
912
|
-
case "-h":
|
|
913
|
-
case "--h":
|
|
914
|
-
case "--help":
|
|
915
|
-
console.log(HELP);
|
|
916
|
-
return;
|
|
917
|
-
case "--update":
|
|
918
|
-
if (rest.length)
|
|
919
|
-
throw new Error("Usage: localdeck --update");
|
|
920
|
-
return (await import("./update-hvkw7mkw.js")).updateLocalDeck();
|
|
921
|
-
case "up":
|
|
922
|
-
return cmdUp(rest, dependencies);
|
|
923
|
-
case "profile":
|
|
924
|
-
case "profiles":
|
|
925
|
-
return cmdProfile(rest, dependencies);
|
|
926
|
-
case "init":
|
|
927
|
-
return cmdInit(rest, dependencies);
|
|
928
|
-
case "mcp":
|
|
929
|
-
return (await import("./mcp-210n1fxv.js")).runMcp(rest);
|
|
930
|
-
case "doctor":
|
|
931
|
-
return cmdDoctor(rest);
|
|
932
|
-
case "forward":
|
|
933
|
-
return cmdForward(rest);
|
|
934
|
-
case "commands":
|
|
935
|
-
case "cmds":
|
|
936
|
-
return printProjectCommands(true, dependencies);
|
|
937
|
-
case "-v":
|
|
938
|
-
case "--v":
|
|
939
|
-
case "--version":
|
|
940
|
-
console.log(VERSION);
|
|
941
|
-
return;
|
|
942
|
-
case "run":
|
|
943
|
-
return cmdRun(rest, dependencies);
|
|
944
|
-
case "ps":
|
|
945
|
-
case "ls":
|
|
946
|
-
case "ports":
|
|
947
|
-
return cmdPs(dependencies);
|
|
948
|
-
case "port":
|
|
949
|
-
return cmdPort(rest[0]);
|
|
950
|
-
case "logs":
|
|
951
|
-
return cmdLogs(rest, dependencies);
|
|
952
|
-
case "requests":
|
|
953
|
-
return cmdRequests(rest);
|
|
954
|
-
case "share":
|
|
955
|
-
return cmdShare(rest);
|
|
956
|
-
case "unshare":
|
|
957
|
-
return cmdUnshare(rest[0]);
|
|
958
|
-
case "stop":
|
|
959
|
-
return cmdStop(rest[0]);
|
|
960
|
-
case "open":
|
|
961
|
-
return cmdOpen(rest[0]);
|
|
962
|
-
case "daemon":
|
|
963
|
-
return cmdDaemon(rest[0]);
|
|
964
|
-
default: {
|
|
965
|
-
const project = tryLoadProject(dependencies);
|
|
966
|
-
const found = project && findProjectCommand(project, cmd);
|
|
967
|
-
if (found) {
|
|
968
|
-
const remembered = await rememberProject(project, dependencies);
|
|
969
|
-
const launch = await resolveLaunch(project, remembered, dependencies);
|
|
970
|
-
const selected = startupLayers(project.commands, [found.name]).flat();
|
|
971
|
-
return dependencies.runForeground(selected.map((command, index) => toRunOptions(launch.effective(command), project, remembered, {
|
|
972
|
-
prefix: selected.length > 1,
|
|
973
|
-
color: PALETTE[index % PALETTE.length],
|
|
974
|
-
...launch.options
|
|
975
|
-
})));
|
|
976
|
-
}
|
|
977
|
-
const hint = project ? ` Commands in ${path3.basename(project.file)}: ${project.commands.map((p) => p.name).join(", ") || "(none)"}` : "";
|
|
978
|
-
fail(`unknown command "${cmd}". Run localdeck --help.${hint}`);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
function tryLoadProject(dependencies) {
|
|
983
|
-
try {
|
|
984
|
-
return dependencies.loadProject();
|
|
985
|
-
} catch (err) {
|
|
986
|
-
fail(err.message);
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
function toRunOptions(p, project, remembered, extra = {}) {
|
|
990
|
-
return {
|
|
991
|
-
name: p.name,
|
|
992
|
-
...remembered ? { project: remembered } : {},
|
|
993
|
-
stablePort: p.port,
|
|
994
|
-
upstreamPort: p.upstream,
|
|
995
|
-
command: shellCommand(p.run),
|
|
996
|
-
displayCommand: p.run,
|
|
997
|
-
cwd: p.cwd ? path3.resolve(project.dir, p.cwd) : project.dir,
|
|
998
|
-
after: p.after,
|
|
999
|
-
ready: p.ready,
|
|
1000
|
-
...p.env ? { env: p.env } : {},
|
|
1001
|
-
...p.secrets ? { secrets: p.secrets } : {},
|
|
1002
|
-
...p.serviceType ? { serviceType: p.serviceType } : {},
|
|
1003
|
-
...extra
|
|
1004
|
-
};
|
|
1005
|
-
}
|
|
1006
|
-
async function rememberProject(project, dependencies) {
|
|
1007
|
-
try {
|
|
1008
|
-
const daemon = await dependencies.ensureDaemon();
|
|
1009
|
-
return await dependencies.createApi(daemon.apiPort).rememberProject(project.file, daemon.controlToken);
|
|
1010
|
-
} catch (error) {
|
|
1011
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1012
|
-
dependencies.warn(`LocalDeck: could not remember this project: ${message}`);
|
|
1013
|
-
return;
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
async function resolveLaunch(project, remembered, dependencies) {
|
|
1017
|
-
let active = [];
|
|
1018
|
-
if (project.profiles?.length && remembered) {
|
|
1019
|
-
try {
|
|
1020
|
-
const daemon = await dependencies.ensureDaemon();
|
|
1021
|
-
active = (await dependencies.createApi(daemon.apiPort).profiles(remembered.id)).active;
|
|
1022
|
-
} catch (error) {
|
|
1023
|
-
dependencies.warn(`LocalDeck: could not read active profiles, running without them: ${error instanceof Error ? error.message : String(error)}`);
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
let resolved;
|
|
1027
|
-
try {
|
|
1028
|
-
resolved = applyProfiles(project, active);
|
|
1029
|
-
} catch (error) {
|
|
1030
|
-
fail(error instanceof Error ? error.message : String(error));
|
|
1031
|
-
}
|
|
1032
|
-
if (resolved.active.length)
|
|
1033
|
-
dependencies.log(c.dim(`Profiles: ${resolved.active.join(" + ")}`));
|
|
1034
|
-
for (const clash of resolved.clashes)
|
|
1035
|
-
dependencies.warn(`LocalDeck: ${clash.profiles.join(" and ")} both set ${clash.service} ${clash.field}; ${clash.winner} wins because it is listed later.`);
|
|
1036
|
-
const byName = new Map(resolved.commands.map((command) => [command.name, command]));
|
|
1037
|
-
return {
|
|
1038
|
-
effective: (command) => byName.get(command.name) ?? command,
|
|
1039
|
-
options: remembered ? { profiles: resolved.active } : {}
|
|
1040
|
-
};
|
|
1041
|
-
}
|
|
1042
|
-
async function printProjectCommands(failIfNone, dependencies) {
|
|
1043
|
-
const project = tryLoadProject(dependencies);
|
|
1044
|
-
if (!project) {
|
|
1045
|
-
if (failIfNone)
|
|
1046
|
-
fail("no .localdeck file found here or in a parent folder. Create one with: localdeck init");
|
|
1047
|
-
return;
|
|
1048
|
-
}
|
|
1049
|
-
await rememberProject(project, dependencies);
|
|
1050
|
-
console.log(`${c.bold("Project commands")} ${c.dim(`(${path3.relative(process.cwd(), project.file) || path3.basename(project.file)})`)}`);
|
|
1051
|
-
const rows = project.commands.map((p) => [c.cyan(p.name), p.port ? String(p.port) : c.dim("auto"), p.after.join(",") || "", p.tags.join(",") || "", c.dim(p.run)]);
|
|
1052
|
-
console.log(table(rows, ["NAME", "PORT", "AFTER", "TAGS", "RUN"]));
|
|
1053
|
-
if (project.profiles?.length)
|
|
1054
|
-
console.log(`
|
|
1055
|
-
${c.bold("Profiles")} ${project.profiles.map((profile) => profile.name).join(", ")} ${c.dim("· localdeck profile")}`);
|
|
1056
|
-
console.log(c.dim(`
|
|
1057
|
-
run one: localdeck <name> · run all: localdeck up · by tag: localdeck up <tag>`));
|
|
1058
|
-
}
|
|
1059
|
-
var PALETTE = [c.cyan, c.magenta, c.green, c.yellow, c.blue];
|
|
1060
|
-
async function cmdUp(args, dependencies) {
|
|
1061
|
-
const project = tryLoadProject(dependencies);
|
|
1062
|
-
if (!project)
|
|
1063
|
-
fail("no .localdeck file found here or in a parent folder. Create one with: localdeck init");
|
|
1064
|
-
const profileIndex = args.indexOf("--profile");
|
|
1065
|
-
let profile;
|
|
1066
|
-
if (profileIndex !== -1) {
|
|
1067
|
-
profile = args[profileIndex + 1];
|
|
1068
|
-
if (!profile)
|
|
1069
|
-
fail("--profile needs a profile name");
|
|
1070
|
-
args = args.filter((_, i) => i !== profileIndex && i !== profileIndex + 1);
|
|
1071
|
-
}
|
|
1072
|
-
const legacy = project.legacyProfiles;
|
|
1073
|
-
if (profile && project.profiles?.some((candidate) => candidate.name === profile))
|
|
1074
|
-
fail(`"${profile}" is a profile you turn on, not a group to start: localdeck profile on ${profile}`);
|
|
1075
|
-
if (profile && (!legacy || !Object.prototype.hasOwnProperty.call(legacy, profile)))
|
|
1076
|
-
fail(`unknown profile "${profile}". Group services with tags and run: localdeck up <tag>`);
|
|
1077
|
-
if (profile)
|
|
1078
|
-
dependencies.warn(`LocalDeck: --profile is deprecated. "${profile}" is now a tag; run: localdeck up ${profile}`);
|
|
1079
|
-
let selected = project.commands;
|
|
1080
|
-
if (profile) {
|
|
1081
|
-
const names = new Set(legacy[profile]);
|
|
1082
|
-
selected = project.commands.filter((command) => names.has(command.name));
|
|
1083
|
-
if (!selected.length)
|
|
1084
|
-
fail(`profile "${profile}" selects no configured services`);
|
|
1085
|
-
}
|
|
1086
|
-
if (args.length) {
|
|
1087
|
-
const allowed = new Set(selected);
|
|
1088
|
-
const matches = project.commands.filter((p) => args.some((a) => findProjectCommand(project, a) === p || p.tags.includes(a)));
|
|
1089
|
-
const outside = matches.filter((p) => !allowed.has(p));
|
|
1090
|
-
if (profile && outside.length)
|
|
1091
|
-
fail(`service outside profile "${profile}": ${outside.map((p) => p.name).join(", ")}`);
|
|
1092
|
-
selected = matches.filter((p) => allowed.has(p));
|
|
1093
|
-
const unknown = args.filter((a) => !findProjectCommand(project, a) && !project.commands.some((p) => p.tags.includes(a)));
|
|
1094
|
-
if (unknown.length)
|
|
1095
|
-
fail(`no command or tag named ${unknown.map((u) => `"${u}"`).join(", ")} in ${path3.basename(project.file)}`);
|
|
1096
|
-
}
|
|
1097
|
-
if (!selected.length)
|
|
1098
|
-
fail("nothing to run");
|
|
1099
|
-
selected = startupLayers(project.commands, selected.map((command) => command.name)).flat();
|
|
1100
|
-
const remembered = await rememberProject(project, dependencies);
|
|
1101
|
-
const launch = await resolveLaunch(project, remembered, dependencies);
|
|
1102
|
-
await dependencies.runForeground(selected.map((p, i) => toRunOptions(launch.effective(p), project, remembered, { prefix: selected.length > 1, color: PALETTE[i % PALETTE.length], ...launch.options })));
|
|
1103
|
-
}
|
|
1104
|
-
async function cmdProfile(args, dependencies) {
|
|
1105
|
-
const project = tryLoadProject(dependencies);
|
|
1106
|
-
if (!project)
|
|
1107
|
-
fail("no .localdeck file found here or in a parent folder. Create one with: localdeck init");
|
|
1108
|
-
const [action = "list", ...names] = args;
|
|
1109
|
-
if (!["list", "ls", "on", "off", "clear"].includes(action))
|
|
1110
|
-
fail(`unknown profile action "${action}". Usage: localdeck profile [on|off <name...>|clear]`);
|
|
1111
|
-
if ((action === "on" || action === "off") && !names.length)
|
|
1112
|
-
fail(`localdeck profile ${action} needs at least one profile name`);
|
|
1113
|
-
if (!project.profiles?.length) {
|
|
1114
|
-
if (action === "list" || action === "ls") {
|
|
1115
|
-
dependencies.log(`No profiles in ${path3.basename(project.file)}. Add a "profiles" object to run services differently, for example against staging.`);
|
|
1116
|
-
return;
|
|
1117
|
-
}
|
|
1118
|
-
fail(`${path3.basename(project.file)} defines no profiles`);
|
|
1119
|
-
}
|
|
1120
|
-
const remembered = await rememberProject(project, dependencies);
|
|
1121
|
-
if (!remembered)
|
|
1122
|
-
fail("the LocalDeck daemon is needed to switch profiles");
|
|
1123
|
-
const daemon = await dependencies.ensureDaemon();
|
|
1124
|
-
const api = dependencies.createApi(daemon.apiPort);
|
|
1125
|
-
let current = await api.profiles(remembered.id);
|
|
1126
|
-
if (action === "on" || action === "off" || action === "clear") {
|
|
1127
|
-
const wanted = names.map((name) => name.toLowerCase());
|
|
1128
|
-
const unknown = wanted.filter((name) => !current.profiles.some((candidate) => candidate.name === name));
|
|
1129
|
-
if (unknown.length)
|
|
1130
|
-
fail(`unknown profile ${unknown.map((name) => `"${name}"`).join(", ")}. Available profiles: ${current.profiles.map((candidate) => candidate.name).join(", ")}`);
|
|
1131
|
-
const next = action === "clear" ? [] : action === "on" ? [...new Set([...current.active, ...wanted])] : current.active.filter((name) => !wanted.includes(name));
|
|
1132
|
-
current = await api.setActiveProfiles(remembered.id, next);
|
|
1133
|
-
dependencies.log(current.active.length ? `Profiles on: ${c.cyan(current.active.join(" + "))}` : "No profiles on · running base services");
|
|
1134
|
-
dependencies.log(c.dim("Running services keep their settings until restarted."));
|
|
1135
|
-
} else {
|
|
1136
|
-
const rows = current.profiles.map((candidate) => [candidate.active ? c.green("on") : c.dim("off"), c.cyan(candidate.name), candidate.services.join(","), candidate.description ?? ""]);
|
|
1137
|
-
dependencies.log(table(rows, ["", "PROFILE", "CHANGES", "DESCRIPTION"]));
|
|
1138
|
-
dependencies.log(c.dim(`
|
|
1139
|
-
turn on: localdeck profile on <name> · off: localdeck profile off <name> · all off: localdeck profile clear`));
|
|
1140
|
-
}
|
|
1141
|
-
for (const clash of current.clashes)
|
|
1142
|
-
dependencies.warn(`LocalDeck: ${clash.profiles.join(" and ")} both set ${clash.service} ${clash.field}; ${clash.winner} wins because it is listed later.`);
|
|
1143
|
-
if (current.error)
|
|
1144
|
-
dependencies.warn(`LocalDeck: ${current.error}`);
|
|
1145
|
-
}
|
|
1146
|
-
async function cmdInit(args, dependencies) {
|
|
1147
|
-
const root = findPackageRoot() ?? process.cwd();
|
|
1148
|
-
const existing = [".localdeck", ".localdeck.json", "localdeck.json"].map((f) => path3.join(root, f)).find((f) => fs4.existsSync(f));
|
|
1149
|
-
const filename = args.find((a) => a === ".localdeck.json" || a === "localdeck.json") ?? (existing ? path3.basename(existing) : ".localdeck");
|
|
1150
|
-
const target = path3.join(root, filename);
|
|
1151
|
-
if ((existing && existing !== target || fs4.existsSync(target)) && !args.includes("--force") && !args.includes("--dry-run"))
|
|
1152
|
-
fail(`${path3.basename(existing ?? target)} already exists here (use --force to overwrite)`);
|
|
1153
|
-
const manual = args.indexOf("--command");
|
|
1154
|
-
const nameIndex = args.indexOf("--name");
|
|
1155
|
-
const manualCommand = manual >= 0 ? args[manual + 1] : undefined;
|
|
1156
|
-
const manualName = nameIndex >= 0 ? args[nameIndex + 1] : undefined;
|
|
1157
|
-
if (manual >= 0 && (!manualCommand || manualCommand.startsWith("--")))
|
|
1158
|
-
fail("--command requires a command value");
|
|
1159
|
-
if (nameIndex >= 0 && (!manualName || manualName.startsWith("--")))
|
|
1160
|
-
fail("--name requires a service name");
|
|
1161
|
-
if (manualCommand !== undefined && !manualCommand.trim())
|
|
1162
|
-
fail("--command must not be empty");
|
|
1163
|
-
if (manualCommand && !manualName)
|
|
1164
|
-
fail("--command requires --name <service>");
|
|
1165
|
-
if (manualName && !/^[a-z0-9][a-z0-9_-]{0,40}$/i.test(manualName))
|
|
1166
|
-
fail("--name must start with a letter or number and be at most 41 characters");
|
|
1167
|
-
const proposed = manualCommand ? [{ name: manualName.toLowerCase(), run: manualCommand, tags: [], cwd: undefined }] : proposeFromPackageJson();
|
|
1168
|
-
if (!proposed.length)
|
|
1169
|
-
fail("could not infer any runnable scripts. Add --command '<command>' --name <service>, then run localdeck init again");
|
|
1170
|
-
const commands = proposed;
|
|
1171
|
-
const json = commands.map((p) => ({ name: p.name, run: p.run, ...p.cwd ? { cwd: p.cwd } : {}, ...p.tags.length ? { tag: p.tags.length === 1 ? p.tags[0] : p.tags } : {} }));
|
|
1172
|
-
if (args.includes("--dry-run")) {
|
|
1173
|
-
console.log(JSON.stringify({ file: target, commands: json }, null, 2));
|
|
1174
|
-
return;
|
|
1175
|
-
}
|
|
1176
|
-
console.log(table(json.map((command) => [command.name, command.run, command.cwd ?? "."]), ["SERVICE", "COMMAND", "FOLDER"]));
|
|
1177
|
-
fs4.writeFileSync(target, JSON.stringify({ commands: json }, null, 2) + `
|
|
1178
|
-
`);
|
|
1179
|
-
console.log(`${c.green("created")} ${filename} ${c.dim(manualCommand ? "from your command" : "from package.json scripts")}`);
|
|
1180
|
-
await printProjectCommands(false, dependencies);
|
|
1181
|
-
console.log(c.dim(`
|
|
1182
|
-
Optional per command: "after": "api" to start in dependency order · "port": 5173 to pin the stable port · "tag": "apps" to start a group.`));
|
|
1183
|
-
}
|
|
1184
|
-
async function cmdDoctor(args) {
|
|
1185
|
-
const report = await doctor();
|
|
1186
|
-
if (!report.ok)
|
|
1187
|
-
process.exitCode = 1;
|
|
1188
|
-
if (args.includes("--json")) {
|
|
1189
|
-
console.log(JSON.stringify(report, null, 2));
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
for (const check of report.checks)
|
|
1193
|
-
console.log(`${check.ok ? c.green("ok") : c.red("fail")} ${check.name}: ${check.message}${check.fix ? ` — ${check.fix}` : ""}`);
|
|
1194
|
-
if (!report.ok)
|
|
1195
|
-
console.log(c.yellow("Repair the failed checks and run localdeck doctor again."));
|
|
1196
|
-
}
|
|
1197
|
-
async function cmdForward(args) {
|
|
1198
|
-
const targetPort = parseForwardArgs(args);
|
|
1199
|
-
let forwarder;
|
|
1200
|
-
try {
|
|
1201
|
-
forwarder = await startForwarder({ targetPort });
|
|
1202
|
-
} catch (error) {
|
|
1203
|
-
fail(error instanceof Error ? error.message : String(error));
|
|
1204
|
-
}
|
|
1205
|
-
console.log(`forwarding http://127.0.0.1:${forwarder.port} → 127.0.0.1:${targetPort} (Ctrl-C to stop)`);
|
|
1206
|
-
await new Promise((resolve) => {
|
|
1207
|
-
function stop() {
|
|
1208
|
-
process.removeListener("SIGINT", stop);
|
|
1209
|
-
process.removeListener("SIGTERM", stop);
|
|
1210
|
-
forwarder.close().then(resolve);
|
|
1211
|
-
}
|
|
1212
|
-
process.once("SIGINT", stop);
|
|
1213
|
-
process.once("SIGTERM", stop);
|
|
1214
|
-
});
|
|
1215
|
-
}
|
|
1216
|
-
function parseForwardArgs(args) {
|
|
1217
|
-
if (!args.length)
|
|
1218
|
-
return 7777;
|
|
1219
|
-
if (args.length !== 2 || args[0] !== "--to-port")
|
|
1220
|
-
fail("usage: localdeck forward [--to-port N]");
|
|
1221
|
-
const targetPort = Number(args[1]);
|
|
1222
|
-
if (!/^\d+$/.test(args[1]) || !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535)
|
|
1223
|
-
fail("--to-port needs a port number between 1 and 65535");
|
|
1224
|
-
return targetPort;
|
|
1225
|
-
}
|
|
1226
|
-
async function api() {
|
|
1227
|
-
const d = await findDaemon();
|
|
1228
|
-
if (!d)
|
|
1229
|
-
fail(`LocalDeck daemon is not running. Start a service with: localdeck run <name> -- <command>`);
|
|
1230
|
-
return new Api(d.apiPort);
|
|
1231
|
-
}
|
|
1232
|
-
function need(name) {
|
|
1233
|
-
if (!name)
|
|
1234
|
-
fail("missing service name");
|
|
1235
|
-
return name;
|
|
1236
|
-
}
|
|
1237
|
-
async function cmdRun(args, dependencies) {
|
|
1238
|
-
const opts = {};
|
|
1239
|
-
let name;
|
|
1240
|
-
let i = 0;
|
|
1241
|
-
while (i < args.length) {
|
|
1242
|
-
const a = args[i];
|
|
1243
|
-
if (a === "--") {
|
|
1244
|
-
i++;
|
|
1245
|
-
break;
|
|
1246
|
-
}
|
|
1247
|
-
const m = a.match(/^(?:--(port|upstream)|-(p|u))(?:=(.+))?$/);
|
|
1248
|
-
if (m) {
|
|
1249
|
-
const key = m[1] ?? (m[2] === "p" ? "port" : "upstream");
|
|
1250
|
-
const raw = m[3] ?? args[++i];
|
|
1251
|
-
const n = Number(raw);
|
|
1252
|
-
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
1253
|
-
fail(`--${key} needs a port number, got "${raw ?? ""}"`);
|
|
1254
|
-
opts[key] = n;
|
|
1255
|
-
i++;
|
|
1256
|
-
continue;
|
|
1257
|
-
}
|
|
1258
|
-
if (!name) {
|
|
1259
|
-
name = a;
|
|
1260
|
-
i++;
|
|
1261
|
-
continue;
|
|
1262
|
-
}
|
|
1263
|
-
break;
|
|
1264
|
-
}
|
|
1265
|
-
const command = args.slice(i);
|
|
1266
|
-
if (!command.length) {
|
|
1267
|
-
const project = tryLoadProject(dependencies);
|
|
1268
|
-
const found = project && name ? findProjectCommand(project, name) : undefined;
|
|
1269
|
-
if (found) {
|
|
1270
|
-
const remembered = await rememberProject(project, dependencies);
|
|
1271
|
-
const launch = await resolveLaunch(project, remembered, dependencies);
|
|
1272
|
-
const selected = startupLayers(project.commands, [found.name]).flat();
|
|
1273
|
-
return dependencies.runForeground(selected.map((command2, index) => toRunOptions(launch.effective(command2), project, remembered, {
|
|
1274
|
-
prefix: selected.length > 1,
|
|
1275
|
-
color: PALETTE[index % PALETTE.length],
|
|
1276
|
-
...launch.options,
|
|
1277
|
-
...command2.name === found.name ? { stablePort: opts.port ?? found.port, upstreamPort: opts.upstream ?? found.upstream } : {}
|
|
1278
|
-
})));
|
|
1279
|
-
}
|
|
1280
|
-
fail("nothing to run. Usage: localdeck run <name> [--port 4000] <command...>");
|
|
1281
|
-
}
|
|
1282
|
-
await dependencies.runForeground([{ name: need(name), stablePort: opts.port, upstreamPort: opts.upstream, command }]);
|
|
1283
|
-
}
|
|
1284
|
-
function status(s) {
|
|
1285
|
-
if (s.status === "stopped")
|
|
1286
|
-
return c.dim("stopped");
|
|
1287
|
-
if (!s.upstreamReady)
|
|
1288
|
-
return c.yellow("waiting");
|
|
1289
|
-
return c.green("running");
|
|
1290
|
-
}
|
|
1291
|
-
async function cmdPs(dependencies) {
|
|
1292
|
-
const daemon = await dependencies.findDaemon();
|
|
1293
|
-
if (!daemon)
|
|
1294
|
-
fail(`LocalDeck daemon is not running. Start a service with: localdeck run <name> -- <command>`);
|
|
1295
|
-
const list = await dependencies.createApi(daemon.apiPort).services();
|
|
1296
|
-
if (!list.length) {
|
|
1297
|
-
dependencies.log(c.dim("no services. Start one with: localdeck run <name> -- <command>"));
|
|
1298
|
-
return;
|
|
1299
|
-
}
|
|
1300
|
-
const rows = list.map((s) => [
|
|
1301
|
-
c.cyan(s.name),
|
|
1302
|
-
s.projectName,
|
|
1303
|
-
c.dim(s.key),
|
|
1304
|
-
status(s),
|
|
1305
|
-
String(s.stablePort),
|
|
1306
|
-
s.status === "stopped" ? "-" : `${s.upstreamHost === "::1" ? "[::1]:" : ""}${s.upstreamPort}${s.upstreamSource === "detected" ? c.dim(" ⌕") : ""}`,
|
|
1307
|
-
s.pid ? String(s.pid) : "-",
|
|
1308
|
-
`${s.stats.requests} ${s.stats.serverErrors + s.stats.proxyErrors ? c.red(`(${s.stats.serverErrors + s.stats.proxyErrors} err)`) : ""}`.trim(),
|
|
1309
|
-
s.share ? c.magenta(s.share.url) : "-",
|
|
1310
|
-
ago(s.startedAt),
|
|
1311
|
-
c.dim(s.command ?? "")
|
|
1312
|
-
]);
|
|
1313
|
-
dependencies.log(table(rows, ["NAME", "PROJECT", "KEY", "STATUS", "PORT", "UPSTREAM", "PID", "REQUESTS", "SHARE", "UP", "COMMAND"]));
|
|
1314
|
-
}
|
|
1315
|
-
async function cmdPort(name) {
|
|
1316
|
-
const s = await (await api()).service(need(name));
|
|
1317
|
-
console.log(s.stablePort);
|
|
1318
|
-
}
|
|
1319
|
-
function fmtLog(l) {
|
|
1320
|
-
const t = new Date(l.ts).toLocaleTimeString();
|
|
1321
|
-
const line = l.stream === "stderr" ? c.red(l.line) : l.stream === "system" ? c.dim(l.line) : l.line;
|
|
1322
|
-
return `${c.dim(t)} ${line}`;
|
|
1323
|
-
}
|
|
1324
|
-
async function cmdLogs(args, dependencies) {
|
|
1325
|
-
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { follow: { type: "boolean", short: "f" }, lines: { type: "string", short: "n", default: "100" } } });
|
|
1326
|
-
const reference = need(positionals[0]);
|
|
1327
|
-
const daemon = await dependencies.findDaemon();
|
|
1328
|
-
if (!daemon)
|
|
1329
|
-
fail(`LocalDeck daemon is not running. Start a service with: localdeck run <name> -- <command>`);
|
|
1330
|
-
const a = dependencies.createApi(daemon.apiPort);
|
|
1331
|
-
const service = await a.service(reference);
|
|
1332
|
-
for (const l of await a.logs(service.key, Number(values.lines)))
|
|
1333
|
-
dependencies.log(fmtLog(l));
|
|
1334
|
-
if (!values.follow)
|
|
1335
|
-
return;
|
|
1336
|
-
const ws = dependencies.createWebSocket(`ws://127.0.0.1:${daemon.apiPort}/api/events`);
|
|
1337
|
-
ws.on("message", (raw) => {
|
|
1338
|
-
const ev = JSON.parse(raw.toString());
|
|
1339
|
-
if (ev.type === "log" && ev.log.service === service.key)
|
|
1340
|
-
dependencies.log(fmtLog(ev.log));
|
|
1341
|
-
});
|
|
1342
|
-
await new Promise((resolve, reject) => {
|
|
1343
|
-
ws.once("close", resolve);
|
|
1344
|
-
ws.once("error", reject);
|
|
1345
|
-
});
|
|
1346
|
-
}
|
|
1347
|
-
async function cmdRequests(args) {
|
|
1348
|
-
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { lines: { type: "string", short: "n", default: "50" } } });
|
|
1349
|
-
const name = need(positionals[0]);
|
|
1350
|
-
const rows = (await (await api()).requests(name, Number(values.lines))).map((r) => [
|
|
1351
|
-
c.dim(new Date(r.ts).toLocaleTimeString()),
|
|
1352
|
-
r.error ? c.red("ERR") : r.status >= 500 ? c.red(String(r.status)) : r.status >= 400 ? c.yellow(String(r.status)) : c.green(String(r.status)),
|
|
1353
|
-
r.method,
|
|
1354
|
-
r.path,
|
|
1355
|
-
`${r.durationMs}ms`,
|
|
1356
|
-
r.via === "share" ? c.magenta("share") : "",
|
|
1357
|
-
r.error ? c.red(r.error) : ""
|
|
1358
|
-
]);
|
|
1359
|
-
console.log(table(rows, ["TIME", "STATUS", "METHOD", "PATH", "TIME", "VIA", ""]));
|
|
1360
|
-
}
|
|
1361
|
-
async function cmdShare(args) {
|
|
1362
|
-
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: { password: { type: "string" }, public: { type: "boolean" } } });
|
|
1363
|
-
if (values.public && values.password !== undefined)
|
|
1364
|
-
fail("cannot combine --password and --public");
|
|
1365
|
-
const name = need(positionals[0]);
|
|
1366
|
-
const password = values.public ? undefined : values.password ?? crypto.randomBytes(6).toString("base64url");
|
|
1367
|
-
process.stdout.write(c.dim("starting tunnel… "));
|
|
1368
|
-
const info = await (await api()).share(name, password, false, Boolean(values.public));
|
|
1369
|
-
console.log(c.green("ok"));
|
|
1370
|
-
console.log(`
|
|
1371
|
-
${c.bold(info.url)}`);
|
|
1372
|
-
console.log(password ? ` password: ${c.bold(password)}` : ` ${c.yellow("public — anyone with the link can open it")}`);
|
|
1373
|
-
console.log(c.dim(`
|
|
1374
|
-
stays up until: localdeck unshare ${name}`));
|
|
1375
|
-
}
|
|
1376
|
-
async function cmdUnshare(name) {
|
|
1377
|
-
const r = await (await api()).unshare(need(name));
|
|
1378
|
-
console.log(r.ok ? `share for ${name} stopped` : c.dim(`${name} was not shared`));
|
|
1379
|
-
}
|
|
1380
|
-
async function cmdStop(name) {
|
|
1381
|
-
const r = await (await api()).stop(need(name));
|
|
1382
|
-
console.log(r.ok ? `stop sent to ${name}` : c.dim(`${name} is not running`));
|
|
1383
|
-
}
|
|
1384
|
-
async function cmdOpen(name) {
|
|
1385
|
-
const d = await ensureDaemon();
|
|
1386
|
-
let url = `http://localhost:${d.apiPort}`;
|
|
1387
|
-
if (name) {
|
|
1388
|
-
const service = await new Api(d.apiPort).service(name);
|
|
1389
|
-
if (!service.urls[0])
|
|
1390
|
-
fail(`${name} is a database endpoint at ${service.upstreamHost}:${service.upstreamPort}; open it with a database client`);
|
|
1391
|
-
url = service.urls[0];
|
|
1392
|
-
}
|
|
1393
|
-
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1394
|
-
spawn2(opener, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).unref();
|
|
1395
|
-
console.log(url);
|
|
1396
|
-
}
|
|
1397
|
-
async function cmdDaemon(sub) {
|
|
1398
|
-
switch (sub) {
|
|
1399
|
-
case "status": {
|
|
1400
|
-
const d = await findDaemon();
|
|
1401
|
-
console.log(d ? `running · pid ${d.pid} · http://localhost:${d.apiPort} · up ${ago(d.startedAt)} · ${HOME_DIR}` : "not running");
|
|
1402
|
-
return;
|
|
1403
|
-
}
|
|
1404
|
-
case "stop": {
|
|
1405
|
-
const d = await findDaemon();
|
|
1406
|
-
if (!d)
|
|
1407
|
-
return console.log("not running");
|
|
1408
|
-
await new Api(d.apiPort).shutdown();
|
|
1409
|
-
console.log("daemon stopped");
|
|
1410
|
-
return;
|
|
1411
|
-
}
|
|
1412
|
-
case "start": {
|
|
1413
|
-
const d = await ensureDaemon();
|
|
1414
|
-
console.log(`running · pid ${d.pid} · http://localhost:${d.apiPort}`);
|
|
1415
|
-
return;
|
|
1416
|
-
}
|
|
1417
|
-
case "run": {
|
|
1418
|
-
const allowedBrowserOrigins = (process.env.LOCALDECK_DEV_ORIGINS ?? "").split(",").map((origin) => origin.trim()).filter(Boolean);
|
|
1419
|
-
const daemon = await startDaemon({
|
|
1420
|
-
dashboardDir: dashboardDir(),
|
|
1421
|
-
...allowedBrowserOrigins.length > 0 ? { allowedBrowserOrigins } : {}
|
|
1422
|
-
});
|
|
1423
|
-
console.log(`[${new Date().toISOString()}] localdeck daemon v${VERSION} on http://127.0.0.1:${daemon.apiPort} (pid ${process.pid})`);
|
|
1424
|
-
const bye = async () => {
|
|
1425
|
-
await daemon.stop();
|
|
1426
|
-
};
|
|
1427
|
-
process.on("SIGINT", bye);
|
|
1428
|
-
process.on("SIGTERM", bye);
|
|
1429
|
-
await daemon.stopped;
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
default:
|
|
1433
|
-
fail("usage: localdeck daemon status|start|stop|run");
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
async function runCli(argv = process.argv.slice(2)) {
|
|
1437
|
-
try {
|
|
1438
|
-
await main(argv);
|
|
1439
|
-
} catch (error) {
|
|
1440
|
-
fail(error instanceof Error ? error.message : String(error));
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
var WRAPPER_IMPORT = Symbol.for("localdeck.cli.wrapper-import");
|
|
1444
|
-
if (__require.main == __require.module && Reflect.get(globalThis, WRAPPER_IMPORT) !== true)
|
|
1445
|
-
await runCli();
|
|
1446
|
-
export {
|
|
1447
|
-
toRunOptions,
|
|
1448
|
-
runCli,
|
|
1449
|
-
parseForwardArgs,
|
|
1450
|
-
main
|
|
1451
|
-
};
|
|
48
|
+
`,b1={loadProject:A0,ensureDaemon:u,createApi:($)=>new K0($),findDaemon:Y0,createWebSocket:($)=>new d($),runForeground:K1,log:($)=>console.log($),warn:($)=>console.warn($)};async function E1($,Y={}){let K={...b1,...Y},[X,...Q]=$;switch(X){case void 0:console.log(Z1),await f0(!1,K);return;case"help":case"-h":case"--h":case"--help":console.log(Z1);return;case"--update":if(Q.length)throw Error("Usage: localdeck --update");return(await import("./update-zhsjwnrw.js")).updateLocalDeck();case"up":return P1(Q,K);case"profile":case"profiles":return w1(Q,K);case"init":return k1(Q,K);case"mcp":return(await import("./mcp-59a41rj3.js")).runMcp(Q);case"quarry":return(await import("./quarry-23qjnaj9.js")).runQuarry(Q);case"doctor":return j1(Q);case"forward":return f1(Q);case"commands":case"cmds":return f0(!0,K);case"-v":case"--v":case"--version":console.log(V0);return;case"run":return h1(Q,K);case"ps":case"ls":case"ports":return u1(K);case"port":return m1(Q[0]);case"logs":return l1(Q,K);case"requests":return p1(Q);case"share":return i1(Q);case"unshare":return d1(Q[0]);case"stop":return c1(Q[0]);case"open":return o1(Q[0]);case"daemon":return t1(Q[0]);default:{let q=Q0(K),z=q&&r(q,X);if(z){let G=await X0(q,K),B=await g0(q,G,K),Z=q0(q.commands,[z.name]).flat();return K.runForeground(Z.map((V,S)=>h0(B.effective(V),q,G,{prefix:Z.length>1,color:c[S%c.length],...B.options})))}let W=q?` Commands in ${P.basename(q.file)}: ${q.commands.map((G)=>G.name).join(", ")||"(none)"}`:"";M(`unknown command "${X}". Run localdeck --help.${W}`)}}}function Q0($){try{return $.loadProject()}catch(Y){M(Y.message)}}function h0($,Y,K,X={}){return{name:$.name,...K?{project:K}:{},stablePort:$.port,upstreamPort:$.upstream,command:b0($.run),displayCommand:$.run,cwd:$.cwd?P.resolve(Y.dir,$.cwd):Y.dir,after:$.after,ready:$.ready,...$.env?{env:$.env}:{},...$.secrets?{secrets:$.secrets}:{},...$.serviceType?{serviceType:$.serviceType}:{},...X}}async function X0($,Y){try{let K=await Y.ensureDaemon();return await Y.createApi(K.apiPort).rememberProject($.file,K.controlToken)}catch(K){let X=K instanceof Error?K.message:String(K);Y.warn(`LocalDeck: could not remember this project: ${X}`);return}}async function g0($,Y,K){let X=[];if($.profiles?.length&&Y)try{let z=await K.ensureDaemon();X=(await K.createApi(z.apiPort).profiles(Y.id)).active}catch(z){K.warn(`LocalDeck: could not read active profiles, running without them: ${z instanceof Error?z.message:String(z)}`)}let Q;try{Q=y0($,X)}catch(z){M(z instanceof Error?z.message:String(z))}if(Q.active.length)K.log(U.dim(`Profiles: ${Q.active.join(" + ")}`));for(let z of Q.clashes)K.warn(`LocalDeck: ${z.profiles.join(" and ")} both set ${z.service} ${z.field}; ${z.winner} wins because it is listed later.`);let q=new Map(Q.commands.map((z)=>[z.name,z]));return{effective:(z)=>q.get(z.name)??z,options:Y?{profiles:Q.active}:{}}}async function f0($,Y){let K=Q0(Y);if(!K){if($)M("no .localdeck file found here or in a parent folder. Create one with: localdeck init");return}await X0(K,Y),console.log(`${U.bold("Project commands")} ${U.dim(`(${P.relative(process.cwd(),K.file)||P.basename(K.file)})`)}`);let X=K.commands.map((Q)=>[U.cyan(Q.name),Q.port?String(Q.port):U.dim("auto"),Q.after.join(",")||"",Q.tags.join(",")||"",U.dim(Q.run)]);if(console.log(i(X,["NAME","PORT","AFTER","TAGS","RUN"])),K.profiles?.length)console.log(`
|
|
49
|
+
${U.bold("Profiles")} ${K.profiles.map((Q)=>Q.name).join(", ")} ${U.dim("· localdeck profile")}`);console.log(U.dim(`
|
|
50
|
+
run one: localdeck <name> · run all: localdeck up · by tag: localdeck up <tag>`))}var c=[U.cyan,U.magenta,U.green,U.yellow,U.blue];async function P1($,Y){let K=Q0(Y);if(!K)M("no .localdeck file found here or in a parent folder. Create one with: localdeck init");let X=$.indexOf("--profile"),Q;if(X!==-1){if(Q=$[X+1],!Q)M("--profile needs a profile name");$=$.filter((B,Z)=>Z!==X&&Z!==X+1)}let q=K.legacyProfiles;if(Q&&K.profiles?.some((B)=>B.name===Q))M(`"${Q}" is a profile you turn on, not a group to start: localdeck profile on ${Q}`);if(Q&&(!q||!Object.prototype.hasOwnProperty.call(q,Q)))M(`unknown profile "${Q}". Group services with tags and run: localdeck up <tag>`);if(Q)Y.warn(`LocalDeck: --profile is deprecated. "${Q}" is now a tag; run: localdeck up ${Q}`);let z=K.commands;if(Q){let B=new Set(q[Q]);if(z=K.commands.filter((Z)=>B.has(Z.name)),!z.length)M(`profile "${Q}" selects no configured services`)}if($.length){let B=new Set(z),Z=K.commands.filter((H)=>$.some((J)=>r(K,J)===H||H.tags.includes(J))),V=Z.filter((H)=>!B.has(H));if(Q&&V.length)M(`service outside profile "${Q}": ${V.map((H)=>H.name).join(", ")}`);z=Z.filter((H)=>B.has(H));let S=$.filter((H)=>!r(K,H)&&!K.commands.some((J)=>J.tags.includes(H)));if(S.length)M(`no command or tag named ${S.map((H)=>`"${H}"`).join(", ")} in ${P.basename(K.file)}`)}if(!z.length)M("nothing to run");z=q0(K.commands,z.map((B)=>B.name)).flat();let W=await X0(K,Y),G=await g0(K,W,Y);await Y.runForeground(z.map((B,Z)=>h0(G.effective(B),K,W,{prefix:z.length>1,color:c[Z%c.length],...G.options})))}async function w1($,Y){let K=Q0(Y);if(!K)M("no .localdeck file found here or in a parent folder. Create one with: localdeck init");let[X="list",...Q]=$;if(!["list","ls","on","off","clear"].includes(X))M(`unknown profile action "${X}". Usage: localdeck profile [on|off <name...>|clear]`);if((X==="on"||X==="off")&&!Q.length)M(`localdeck profile ${X} needs at least one profile name`);if(!K.profiles?.length){if(X==="list"||X==="ls"){Y.log(`No profiles in ${P.basename(K.file)}. Add a "profiles" object to run services differently, for example against staging.`);return}M(`${P.basename(K.file)} defines no profiles`)}let q=await X0(K,Y);if(!q)M("the LocalDeck daemon is needed to switch profiles");let z=await Y.ensureDaemon(),W=Y.createApi(z.apiPort),G=await W.profiles(q.id);if(X==="on"||X==="off"||X==="clear"){let B=Q.map((S)=>S.toLowerCase()),Z=B.filter((S)=>!G.profiles.some((H)=>H.name===S));if(Z.length)M(`unknown profile ${Z.map((S)=>`"${S}"`).join(", ")}. Available profiles: ${G.profiles.map((S)=>S.name).join(", ")}`);let V=X==="clear"?[]:X==="on"?[...new Set([...G.active,...B])]:G.active.filter((S)=>!B.includes(S));G=await W.setActiveProfiles(q.id,V),Y.log(G.active.length?`Profiles on: ${U.cyan(G.active.join(" + "))}`:"No profiles on · running base services"),Y.log(U.dim("Running services keep their settings until restarted."))}else{let B=G.profiles.map((Z)=>[Z.active?U.green("on"):U.dim("off"),U.cyan(Z.name),Z.services.join(","),Z.description??""]);Y.log(i(B,["","PROFILE","CHANGES","DESCRIPTION"])),Y.log(U.dim(`
|
|
51
|
+
turn on: localdeck profile on <name> · off: localdeck profile off <name> · all off: localdeck profile clear`))}for(let B of G.clashes)Y.warn(`LocalDeck: ${B.profiles.join(" and ")} both set ${B.service} ${B.field}; ${B.winner} wins because it is listed later.`);if(G.error)Y.warn(`LocalDeck: ${G.error}`)}async function k1($,Y){let K=e()??process.cwd(),X=[".localdeck",".localdeck.json","localdeck.json"].map((H)=>P.join(K,H)).find((H)=>j0.existsSync(H)),Q=$.find((H)=>H===".localdeck.json"||H==="localdeck.json")??(X?P.basename(X):".localdeck"),q=P.join(K,Q);if((X&&X!==q||j0.existsSync(q))&&!$.includes("--force")&&!$.includes("--dry-run"))M(`${P.basename(X??q)} already exists here (use --force to overwrite)`);let z=$.indexOf("--command"),W=$.indexOf("--name"),G=z>=0?$[z+1]:void 0,B=W>=0?$[W+1]:void 0;if(z>=0&&(!G||G.startsWith("--")))M("--command requires a command value");if(W>=0&&(!B||B.startsWith("--")))M("--name requires a service name");if(G!==void 0&&!G.trim())M("--command must not be empty");if(G&&!B)M("--command requires --name <service>");if(B&&!/^[a-z0-9][a-z0-9_-]{0,40}$/i.test(B))M("--name must start with a letter or number and be at most 41 characters");let Z=G?[{name:B.toLowerCase(),run:G,tags:[],cwd:void 0}]:E0();if(!Z.length)M("could not infer any runnable scripts. Add --command '<command>' --name <service>, then run localdeck init again");let S=Z.map((H)=>({name:H.name,run:H.run,...H.cwd?{cwd:H.cwd}:{},...H.tags.length?{tag:H.tags.length===1?H.tags[0]:H.tags}:{}}));if($.includes("--dry-run")){console.log(JSON.stringify({file:q,commands:S},null,2));return}console.log(i(S.map((H)=>[H.name,H.run,H.cwd??"."]),["SERVICE","COMMAND","FOLDER"])),j0.writeFileSync(q,JSON.stringify({commands:S},null,2)+`
|
|
52
|
+
`),console.log(`${U.green("created")} ${Q} ${U.dim(G?"from your command":"from package.json scripts")}`),await f0(!1,Y),console.log(U.dim(`
|
|
53
|
+
Optional per command: "after": "api" to start in dependency order · "port": 5173 to pin the stable port · "tag": "apps" to start a group.`))}async function j1($){let Y=await X1();if(!Y.ok)process.exitCode=1;if($.includes("--json")){console.log(JSON.stringify(Y,null,2));return}for(let K of Y.checks)console.log(`${K.ok?U.green("ok"):U.red("fail")} ${K.name}: ${K.message}${K.fix?` — ${K.fix}`:""}`);if(!Y.ok)console.log(U.yellow("Repair the failed checks and run localdeck doctor again."))}async function f1($){let Y=v1($),K;try{K=await k0({targetPort:Y})}catch(X){M(X instanceof Error?X.message:String(X))}console.log(`forwarding http://127.0.0.1:${K.port} → 127.0.0.1:${Y} (Ctrl-C to stop)`),await new Promise((X)=>{function Q(){process.removeListener("SIGINT",Q),process.removeListener("SIGTERM",Q),K.close().then(X)}process.once("SIGINT",Q),process.once("SIGTERM",Q)})}function v1($){if(!$.length)return 7777;if($.length!==2||$[0]!=="--to-port")M("usage: localdeck forward [--to-port N]");let Y=Number($[1]);if(!/^\d+$/.test($[1])||!Number.isInteger(Y)||Y<1||Y>65535)M("--to-port needs a port number between 1 and 65535");return Y}async function Z0(){let $=await Y0();if(!$)M("LocalDeck daemon is not running. Start a service with: localdeck run <name> -- <command>");return new K0($.apiPort)}function l($){if(!$)M("missing service name");return $}async function h1($,Y){let K={},X,Q=0;while(Q<$.length){let z=$[Q];if(z==="--"){Q++;break}let W=z.match(/^(?:--(port|upstream)|-(p|u))(?:=(.+))?$/);if(W){let G=W[1]??(W[2]==="p"?"port":"upstream"),B=W[3]??$[++Q],Z=Number(B);if(!Number.isInteger(Z)||Z<1||Z>65535)M(`--${G} needs a port number, got "${B??""}"`);K[G]=Z,Q++;continue}if(!X){X=z,Q++;continue}break}let q=$.slice(Q);if(!q.length){let z=Q0(Y),W=z&&X?r(z,X):void 0;if(W){let G=await X0(z,Y),B=await g0(z,G,Y),Z=q0(z.commands,[W.name]).flat();return Y.runForeground(Z.map((V,S)=>h0(B.effective(V),z,G,{prefix:Z.length>1,color:c[S%c.length],...B.options,...V.name===W.name?{stablePort:K.port??W.port,upstreamPort:K.upstream??W.upstream}:{}})))}M("nothing to run. Usage: localdeck run <name> [--port 4000] <command...>")}await Y.runForeground([{name:l(X),stablePort:K.port,upstreamPort:K.upstream,command:q}])}function g1($){if($.status==="stopped")return U.dim("stopped");if(!$.upstreamReady)return U.yellow("waiting");return U.green("running")}async function u1($){let Y=await $.findDaemon();if(!Y)M("LocalDeck daemon is not running. Start a service with: localdeck run <name> -- <command>");let K=await $.createApi(Y.apiPort).services();if(!K.length){$.log(U.dim("no services. Start one with: localdeck run <name> -- <command>"));return}let X=K.map((Q)=>[U.cyan(Q.name),Q.projectName,U.dim(Q.key),g1(Q),String(Q.stablePort),Q.status==="stopped"?"-":`${Q.upstreamHost==="::1"?"[::1]:":""}${Q.upstreamPort}${Q.upstreamSource==="detected"?U.dim(" ⌕"):""}`,Q.pid?String(Q.pid):"-",`${Q.stats.requests} ${Q.stats.serverErrors+Q.stats.proxyErrors?U.red(`(${Q.stats.serverErrors+Q.stats.proxyErrors} err)`):""}`.trim(),Q.share?U.magenta(Q.share.url):"-",F0(Q.startedAt),U.dim(Q.command??"")]);$.log(i(X,["NAME","PROJECT","KEY","STATUS","PORT","UPSTREAM","PID","REQUESTS","SHARE","UP","COMMAND"]))}async function m1($){let Y=await(await Z0()).service(l($));console.log(Y.stablePort)}function B1($){let Y=new Date($.ts).toLocaleTimeString(),K=$.stream==="stderr"?U.red($.line):$.stream==="system"?U.dim($.line):$.line;return`${U.dim(Y)} ${K}`}async function l1($,Y){let{values:K,positionals:X}=v0({args:$,allowPositionals:!0,options:{follow:{type:"boolean",short:"f"},lines:{type:"string",short:"n",default:"100"}}}),Q=l(X[0]),q=await Y.findDaemon();if(!q)M("LocalDeck daemon is not running. Start a service with: localdeck run <name> -- <command>");let z=Y.createApi(q.apiPort),W=await z.service(Q);for(let B of await z.logs(W.key,Number(K.lines)))Y.log(B1(B));if(!K.follow)return;let G=Y.createWebSocket(`ws://127.0.0.1:${q.apiPort}/api/events`);G.on("message",(B)=>{let Z=JSON.parse(B.toString());if(Z.type==="log"&&Z.log.service===W.key)Y.log(B1(Z.log))}),await new Promise((B,Z)=>{G.once("close",B),G.once("error",Z)})}async function p1($){let{values:Y,positionals:K}=v0({args:$,allowPositionals:!0,options:{lines:{type:"string",short:"n",default:"50"}}}),X=l(K[0]),Q=(await(await Z0()).requests(X,Number(Y.lines))).map((q)=>[U.dim(new Date(q.ts).toLocaleTimeString()),q.error?U.red("ERR"):q.status>=500?U.red(String(q.status)):q.status>=400?U.yellow(String(q.status)):U.green(String(q.status)),q.method,q.path,`${q.durationMs}ms`,q.via==="share"?U.magenta("share"):"",q.error?U.red(q.error):""]);console.log(i(Q,["TIME","STATUS","METHOD","PATH","TIME","VIA",""]))}async function i1($){let{values:Y,positionals:K}=v0({args:$,allowPositionals:!0,options:{password:{type:"string"},public:{type:"boolean"}}});if(Y.public&&Y.password!==void 0)M("cannot combine --password and --public");let X=l(K[0]),Q=Y.public?void 0:Y.password??y1.randomBytes(6).toString("base64url");process.stdout.write(U.dim("starting tunnel… "));let q=await(await Z0()).share(X,Q,!1,Boolean(Y.public));console.log(U.green("ok")),console.log(`
|
|
54
|
+
${U.bold(q.url)}`),console.log(Q?` password: ${U.bold(Q)}`:` ${U.yellow("public — anyone with the link can open it")}`),console.log(U.dim(`
|
|
55
|
+
stays up until: localdeck unshare ${X}`))}async function d1($){let Y=await(await Z0()).unshare(l($));console.log(Y.ok?`share for ${$} stopped`:U.dim(`${$} was not shared`))}async function c1($){let Y=await(await Z0()).stop(l($));console.log(Y.ok?`stop sent to ${$}`:U.dim(`${$} is not running`))}async function o1($){let Y=await u(),K=`http://localhost:${Y.apiPort}`;if($){let Q=await new K0(Y.apiPort).service($);if(!Q.urls[0])M(`${$} is a database endpoint at ${Q.upstreamHost}:${Q.upstreamPort}; open it with a database client`);K=Q.urls[0]}let X=process.platform==="darwin"?"open":process.platform==="win32"?"start":"xdg-open";x1(X,[K],{stdio:"ignore",detached:!0,shell:process.platform==="win32"}).unref(),console.log(K)}async function t1($){switch($){case"status":{let Y=await Y0();console.log(Y?`running · pid ${Y.pid} · http://localhost:${Y.apiPort} · up ${F0(Y.startedAt)} · ${H1}`:"not running");return}case"stop":{let Y=await Y0();if(!Y)return console.log("not running");await new K0(Y.apiPort).shutdown(),console.log("daemon stopped");return}case"start":{let Y=await u();console.log(`running · pid ${Y.pid} · http://localhost:${Y.apiPort}`);return}case"run":{let Y=(process.env.LOCALDECK_DEV_ORIGINS??"").split(",").map((Q)=>Q.trim()).filter(Boolean),K=await U1({dashboardDir:z1(),...Y.length>0?{allowedBrowserOrigins:Y}:{}});console.log(`[${new Date().toISOString()}] localdeck daemon v${V0} on http://127.0.0.1:${K.apiPort} (pid ${process.pid})`);let X=async()=>{await K.stop()};process.on("SIGINT",X),process.on("SIGTERM",X),await K.stopped;return}default:M("usage: localdeck daemon status|start|stop|run")}}async function a1($=process.argv.slice(2)){try{await E1($)}catch(Y){M(Y instanceof Error?Y.message:String(Y))}}var n1=Symbol.for("localdeck.cli.wrapper-import");if($0.main==$0.module&&Reflect.get(globalThis,n1)!==!0)await a1();export{h0 as toRunOptions,a1 as runCli,v1 as parseForwardArgs,E1 as main};
|