@north-light/cli 0.3.435 → 0.3.436

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.
@@ -1 +1 @@
1
- var c=Object.defineProperty;var s=(r,e)=>c(r,"name",{value:e,configurable:!0});import{defineLeaf as d,defineBranch as w}from"../../core/command.js";import{InputError as o}from"../../core/io.js";import{parseDurationMs as f}from"../../core/wake.js";import{DaemonNotPreparedError as g,probeDaemonHealth as u,probeErrorMessage as y,spawnDaemon as h,stopDaemonProcess as b,sweepStrayDaemons as v}from"../../daemon/manage.js";import{isDaemonRunning as _,readPidfile as m,isPidAlive as l}from"../../daemon/pidfile.js";import{cliClient as k}from"../api-client.js";import{localClient as T}from"../../api/node-transport.js";import{defineSysBranch as q}from"./branch.js";const x=d({name:"start",description:"start the daemon in the background",whenToUse:"bringing the crtrd supervisor up for the first time in a session so node window-exits get handled (no-op only when its Unix API socket already serves)",help:{name:"sys daemon start",summary:"start the crtrd supervisor daemon in the background (no-op only if its Unix API socket serves)",params:[{kind:"flag",name:"prepare",type:"bool",required:!1,constraint:"Hold normal daemon work after storage preparation until `sys daemon admit`; omit for ordinary startup."}],output:[{name:"started",type:"boolean",required:!0,constraint:"True when a new daemon process was spawned; false when one was already running."},{name:"pid",type:"number",required:!1,constraint:"PID of the newly spawned daemon."},{name:"existing_pid",type:"number",required:!1,constraint:"PID of the already-running daemon (when started=false)."},{name:"running",type:"boolean",required:!1,constraint:"True only when a live pidfile owner does not serve its Unix API socket."},{name:"serving",type:"boolean",required:!1,constraint:"False only when a live pidfile owner does not answer /healthz."},{name:"probe_error",type:"string",required:!1,constraint:"The final /healthz probe failure when a live owner missed readiness."},{name:"startup_phase",type:"string",required:!1,constraint:"The observed daemon phase when it was reachable."}],outputKind:"object",effects:["Spawns dist/daemon/crtrd-cli.js detached + unref'd; it outlives this process.","Writes a pidfile at CRTR_PIDFILE if set, otherwise crtrHome()/crtrd.pid."]},run:s(async r=>{try{return await h({targetPhase:r.prepare===!0?"prepared":"ready"})}catch(e){throw e instanceof g?new o({error:"daemon_not_prepared",message:e.message,next:"Stop the live daemon, then start a new one with `crtr sys daemon start --prepare` before setup."}):e}},"run")}),D=100;async function I(r,e){const n=Date.now(),a=n+e;let i="initializing",p="the daemon socket was not reachable";for(;Date.now()<a;){try{const t=await u(Math.min(1e3,Math.max(1,a-Date.now())));if(i=t.startup_phase,p=t.startup_blocked??`daemon is ${t.startup_phase}`,t.startup_phase==="blocked")throw new o({error:"startup_blocked",message:t.startup_blocked??"daemon startup is blocked",received:t.startup_phase,field:"phase",next:"Repair the reported blocker paths with `crtr sys migrate`, then retry the await."});if(t.startup_phase===r)return{startup_phase:r,waited_ms:Date.now()-n};if(r==="prepared"&&t.startup_phase==="ready")throw new o({error:"await_missed_phase",message:"daemon is already ready and cannot return to prepared in this process",received:t.startup_phase,field:"phase",next:"Start a new daemon with `crtr sys daemon start --prepare` before awaiting prepared."})}catch(t){if(t instanceof o)throw t;p=t instanceof Error?t.message:String(t)}await new Promise(t=>setTimeout(t,Math.min(D,Math.max(0,a-Date.now()))))}throw new o({error:"await_timeout",message:`daemon did not reach ${r} within ${e}ms (last phase: ${i}; last observation: ${p})`,received:i,field:"phase",next:"Inspect `crtr sys daemon status` and resolve any reported startup blocker before retrying."})}s(I,"awaitDaemonPhase");const S=d({name:"await",description:"wait for a daemon startup phase",whenToUse:"a caller started or was given a daemon that must reach prepared or ready before its next step.",help:{name:"sys daemon await",summary:"wait for the running daemon to report one startup phase",params:[{kind:"flag",name:"phase",type:"enum",choices:["prepared","ready"],required:!0,constraint:"The phase that permits the caller\u2019s next step."},{kind:"flag",name:"timeout",type:"string",required:!1,default:"5m",constraint:"Maximum wait as a positive duration such as 90s, 5m, or 1h30m."}],output:[{name:"startup_phase",type:"string",required:!0,constraint:"The requested phase."},{name:"waited_ms",type:"number",required:!0,constraint:"Elapsed wall-clock time before the phase was observed."}],outputKind:"object",effects:["Read-only: polls /healthz until the requested phase is observed or the caller-bounded timeout expires."]},run:s(async r=>{const e=r.timeout,n=f(e);if(n===null||n<=0)throw new o({error:"invalid_duration",message:`not a positive duration: ${e}`,received:e,field:"timeout",next:"Use a positive duration such as 90s, 5m, or 1h30m."});return I(r.phase,n)},"run")}),E=d({name:"admit",description:"admit normal work after daemon preparation",whenToUse:"the caller has completed its setup against a prepared daemon and is ready for normal daemon work to start.",help:{name:"sys daemon admit",summary:"ask a prepared daemon to start normal services",params:[],output:[{name:"admitted",type:"boolean",required:!0,constraint:"True only when this request began admission."},{name:"already_admitted",type:"boolean",required:!0,constraint:"True when admission had already begun or completed."},{name:"startup_phase",type:"string",required:!0,constraint:"The phase after admission settles."}],outputKind:"object",effects:["Starts normal daemon services when the daemon is prepared."]},run:s(async r=>await T({autostart:!1}).admitDaemon(),"run")}),P=d({name:"status",description:"check whether the daemon is running",whenToUse:"operator/dev diagnostic \u2014 probing whether the crtrd supervisor process is alive while troubleshooting revive/supervision behavior",help:{name:"sys daemon status",summary:"check whether the crtrd supervisor daemon is currently running",params:[],output:[{name:"running",type:"boolean",required:!0,constraint:"True when the daemon pidfile owner is alive."},{name:"serving",type:"boolean",required:!1,constraint:"True when the live daemon answers /healthz; false when one probe does not."},{name:"pid",type:"number",required:!1,constraint:"PID of the running daemon."},{name:"probe_error",type:"string",required:!1,constraint:"The failed /healthz observation when the live owner did not answer."},{name:"startup_phase",type:"string",required:!1,constraint:"The observed daemon startup phase when the socket answered."}],outputKind:"object",effects:["Read-only: reads the pidfile, probes the pid, and requests /healthz without autostart."]},run:s(async r=>{const e=m();if(!(e!==null&&l(e)))return{running:!1};try{const a=await u();return{running:!0,serving:a.ok,pid:e,...a.startup_phase===void 0?{}:{startup_phase:a.startup_phase}}}catch(a){return{running:!0,serving:!1,pid:e,probe_error:y(a)}}},"run")}),R=d({name:"restart",description:"put the selected runtime generation in service via a daemon-owned handover",whenToUse:"adopting a newly installed runtime generation \u2014 the routine restart. Use this instead of stop+start: the daemon answers before it hands over, so a caller running inside a node (whose broker the handover tears down) still gets a settled result and is resumed automatically.",help:{name:"sys daemon restart",summary:"ask the running daemon to replace itself with a successor on the selected runtime generation",params:[],output:[{name:"restarted",type:"boolean",required:!0,constraint:"True when a running daemon accepted the handover. False when none was running \u2014 one was started instead."},{name:"started",type:"boolean",required:!1,constraint:"True when no daemon was running and this call started one (restarted=false)."},{name:"pid",type:"number",required:!1,constraint:"The daemon that accepted the handover, or the one just started."},{name:"grace_ms",type:"number",required:!1,constraint:"How long the daemon waits after answering before teardown begins."},{name:"note",type:"string",required:!1,constraint:"What happens next to the calling node. Read it before acting on an interrupted turn."}],outputKind:"object",effects:["The daemon acknowledges immediately, then after a short grace tears its whole broker fleet down, releases its claim, spawns a successor on the runtime generation currently selected, and exits.","Each torn-down broker hands its foreground bash commands to the file-backed background job system; they keep running and report completion by urgent inbox message.","Every node it tore down \u2014 including the caller \u2014 is resolved by the successor through the ordinary row-driven supervision tick.","The calling process is killed as part of that teardown, AFTER this result is returned."]},run:s(async r=>{if(!_()){const e=await h();return{restarted:!1,started:e.started,...e.pid!==void 0?{pid:e.pid}:{},...e.existing_pid!==void 0?{pid:e.existing_pid}:{},note:"No daemon was running, so there was nothing to hand over; one was started on the selected runtime generation."}}try{const e=await k().restartDaemon();return{restarted:e.accepted,pid:e.predecessor_pid,grace_ms:e.grace_ms,note:"Handover accepted. If you are running inside a node, your broker is about to be torn down and resumed on the new generation \u2014 THIS RESTART ALREADY SUCCEEDED. When your turn resumes with this result in the transcript, do not re-run it."}}catch(e){throw new o({error:"daemon_restart_failed",message:`the daemon did not accept a handover: ${e.message}`,next:"Run `crtr sys daemon status` to inspect the daemon."})}},"run")}),A=d({name:"stop",description:"stop the running daemon",whenToUse:"shutting the crtrd supervisor down for good, ending auto-revival of nodes on window exit. Not the way to pick up a new runtime generation \u2014 `restart` is, because stopping kills every broker of this epoch, including the caller's own, so this command cannot report its result to an agent that runs it.",help:{name:"sys daemon stop",summary:"stop the crtrd supervisor daemon and confirm it exited",params:[],output:[{name:"stopped",type:"boolean",required:!0,constraint:"True when a daemon (recorded or stray) was found and killed. False when none was running."},{name:"pid",type:"number",required:!1,constraint:"The recorded pidfile pid that exited (when one was recorded and alive)."},{name:"strays_killed",type:"array",required:!1,constraint:"PIDs of stray crtrd daemons (alive but not in the pidfile) swept alongside the recorded one \u2014 the backstop against a daemon that survived a prior stop."},{name:"force_killed",type:"boolean",required:!1,constraint:"True when the recorded daemon ignored SIGTERM and had to be SIGKILLed."}],outputKind:"object",effects:["Sends SIGTERM to the recorded pid and waits for it to exit and release its pidfile, escalating to SIGKILL if it does not.","Then sweeps any OTHER live crtrd daemon process (SIGTERM, escalating to SIGKILL after 2s) so a stray daemon orphaned from the pidfile cannot keep supervising."]},run:s(async r=>{const e=m(),n=e!==null&&l(e);try{let a=!1;n&&(a=await b(e)==="killed");const i=v(null);return!n&&i.length===0?{stopped:!1}:{stopped:!0,...n?{pid:e}:{},...i.length>0?{strays_killed:i}:{},...a?{force_killed:!0}:{}}}catch(a){throw new o({error:"daemon_stop_failed",message:`failed to stop daemon${n?` pid ${e}`:""}: ${a.message}`,next:"Run `crtr sys daemon status` to inspect the daemon before retrying."})}},"run")}),U=w({name:"daemon",description:"manage the crtrd supervisor process",whenToUse:"managing the crtrd supervisor that auto-revives nodes on window exit \u2014 start it, restart it onto a new runtime generation, check its status, or stop it",help:{name:"sys daemon",summary:"manage the crtrd canvas supervisor daemon",model:"crtrd is a thin background daemon that polls active+idle nodes and acts on window exit: crashed windows become dead; refresh-yield windows get a fresh respawn. It holds no orchestration logic \u2014 just process supervision. These are operator/dev self-management, not something a normal agent turn needs to check. Picking up a newly installed runtime generation is `restart` (a handover the daemon performs on itself); `stop` is the terminal one, and it kills the caller along with the rest of the fleet."},children:[x,S,E,R,P,A]});function F(){return q([U])}s(F,"registerDaemonControlSys");export{U as daemonBranch,F as registerDaemonControlSys};
1
+ var c=Object.defineProperty;var s=(r,e)=>c(r,"name",{value:e,configurable:!0});import{defineLeaf as d,defineBranch as w}from"../../core/command.js";import{InputError as o}from"../../core/io.js";import{parseDurationMs as f}from"../../core/wake.js";import{DaemonNotPreparedError as g,probeDaemonHealth as u,probeErrorMessage as y,spawnDaemon as h,stopDaemonProcess as b,sweepStrayDaemons as v}from"../../daemon/manage.js";import{isDaemonRunning as _,readPidfile as l,isPidAlive as m}from"../../daemon/pidfile.js";import{cliClient as k}from"../api-client.js";import{localClient as T}from"../../api/node-transport.js";import{defineSysBranch as q}from"./branch.js";const x=d({name:"start",description:"start the daemon in the background",whenToUse:"bringing the crtrd supervisor up for the first time in a session so node window-exits get handled (no-op only when its Unix API socket already serves)",help:{name:"sys daemon start",summary:"start the crtrd supervisor daemon in the background (no-op only if its Unix API socket serves)",params:[{kind:"flag",name:"prepare",type:"bool",required:!1,constraint:"Hold normal daemon work after storage preparation until `sys daemon admit`; omit for ordinary startup."},{kind:"flag",name:"force",type:"bool",required:!1,default:!1,constraint:"Start even while `crtr sys join` holds the daemon down. Joining then fails `daemon_running`; use only to clear a join that is stuck."}],output:[{name:"started",type:"boolean",required:!0,constraint:"True when a new daemon process was spawned; false when one was already running."},{name:"pid",type:"number",required:!1,constraint:"PID of the newly spawned daemon."},{name:"existing_pid",type:"number",required:!1,constraint:"PID of the already-running daemon (when started=false)."},{name:"running",type:"boolean",required:!1,constraint:"True only when a live pidfile owner does not serve its Unix API socket."},{name:"serving",type:"boolean",required:!1,constraint:"False only when a live pidfile owner does not answer /healthz."},{name:"probe_error",type:"string",required:!1,constraint:"The final /healthz probe failure when a live owner missed readiness."},{name:"startup_phase",type:"string",required:!1,constraint:"The observed daemon phase when it was reachable."}],outputKind:"object",effects:["Refuses `join_in_progress` while a live `crtr sys join` holds the daemon down, unless `--force`. `sys daemon restart` and `ensureDaemon` stand down the same way.","Spawns dist/daemon/crtrd-cli.js detached + unref'd; it outlives this process.","Writes a pidfile at CRTR_PIDFILE if set, otherwise crtrHome()/crtrd.pid."]},run:s(async r=>{try{return await h({targetPhase:r.prepare===!0?"prepared":"ready",ignoreJoinLock:r.force===!0})}catch(e){throw e instanceof g?new o({error:"daemon_not_prepared",message:e.message,next:"Stop the live daemon, then start a new one with `crtr sys daemon start --prepare` before setup."}):e}},"run")}),D=100;async function I(r,e){const a=Date.now(),n=a+e;let i="initializing",p="the daemon socket was not reachable";for(;Date.now()<n;){try{const t=await u(Math.min(1e3,Math.max(1,n-Date.now())));if(i=t.startup_phase,p=t.startup_blocked??`daemon is ${t.startup_phase}`,t.startup_phase==="blocked")throw new o({error:"startup_blocked",message:t.startup_blocked??"daemon startup is blocked",received:t.startup_phase,field:"phase",next:"Repair the reported blocker paths with `crtr sys migrate`, then retry the await."});if(t.startup_phase===r)return{startup_phase:r,waited_ms:Date.now()-a};if(r==="prepared"&&t.startup_phase==="ready")throw new o({error:"await_missed_phase",message:"daemon is already ready and cannot return to prepared in this process",received:t.startup_phase,field:"phase",next:"Start a new daemon with `crtr sys daemon start --prepare` before awaiting prepared."})}catch(t){if(t instanceof o)throw t;p=t instanceof Error?t.message:String(t)}await new Promise(t=>setTimeout(t,Math.min(D,Math.max(0,n-Date.now()))))}throw new o({error:"await_timeout",message:`daemon did not reach ${r} within ${e}ms (last phase: ${i}; last observation: ${p})`,received:i,field:"phase",next:"Inspect `crtr sys daemon status` and resolve any reported startup blocker before retrying."})}s(I,"awaitDaemonPhase");const S=d({name:"await",description:"wait for a daemon startup phase",whenToUse:"a caller started or was given a daemon that must reach prepared or ready before its next step.",help:{name:"sys daemon await",summary:"wait for the running daemon to report one startup phase",params:[{kind:"flag",name:"phase",type:"enum",choices:["prepared","ready"],required:!0,constraint:"The phase that permits the caller\u2019s next step."},{kind:"flag",name:"timeout",type:"string",required:!1,default:"5m",constraint:"Maximum wait as a positive duration such as 90s, 5m, or 1h30m."}],output:[{name:"startup_phase",type:"string",required:!0,constraint:"The requested phase."},{name:"waited_ms",type:"number",required:!0,constraint:"Elapsed wall-clock time before the phase was observed."}],outputKind:"object",effects:["Read-only: polls /healthz until the requested phase is observed or the caller-bounded timeout expires."]},run:s(async r=>{const e=r.timeout,a=f(e);if(a===null||a<=0)throw new o({error:"invalid_duration",message:`not a positive duration: ${e}`,received:e,field:"timeout",next:"Use a positive duration such as 90s, 5m, or 1h30m."});return I(r.phase,a)},"run")}),E=d({name:"admit",description:"admit normal work after daemon preparation",whenToUse:"the caller has completed its setup against a prepared daemon and is ready for normal daemon work to start.",help:{name:"sys daemon admit",summary:"ask a prepared daemon to start normal services",params:[],output:[{name:"admitted",type:"boolean",required:!0,constraint:"True only when this request began admission."},{name:"already_admitted",type:"boolean",required:!0,constraint:"True when admission had already begun or completed."},{name:"startup_phase",type:"string",required:!0,constraint:"The phase after admission settles."}],outputKind:"object",effects:["Starts normal daemon services when the daemon is prepared."]},run:s(async r=>await T({autostart:!1}).admitDaemon(),"run")}),R=d({name:"status",description:"check whether the daemon is running",whenToUse:"operator/dev diagnostic \u2014 probing whether the crtrd supervisor process is alive while troubleshooting revive/supervision behavior",help:{name:"sys daemon status",summary:"check whether the crtrd supervisor daemon is currently running",params:[],output:[{name:"running",type:"boolean",required:!0,constraint:"True when the daemon pidfile owner is alive."},{name:"serving",type:"boolean",required:!1,constraint:"True when the live daemon answers /healthz; false when one probe does not."},{name:"pid",type:"number",required:!1,constraint:"PID of the running daemon."},{name:"probe_error",type:"string",required:!1,constraint:"The failed /healthz observation when the live owner did not answer."},{name:"startup_phase",type:"string",required:!1,constraint:"The observed daemon startup phase when the socket answered."}],outputKind:"object",effects:["Read-only: reads the pidfile, probes the pid, and requests /healthz without autostart."]},run:s(async r=>{const e=l();if(!(e!==null&&m(e)))return{running:!1};try{const n=await u();return{running:!0,serving:n.ok,pid:e,...n.startup_phase===void 0?{}:{startup_phase:n.startup_phase}}}catch(n){return{running:!0,serving:!1,pid:e,probe_error:y(n)}}},"run")}),P=d({name:"restart",description:"put the selected runtime generation in service via a daemon-owned handover",whenToUse:"adopting a newly installed runtime generation \u2014 the routine restart. Use this instead of stop+start: the daemon answers before it hands over, so a caller running inside a node (whose broker the handover tears down) still gets a settled result and is resumed automatically.",help:{name:"sys daemon restart",summary:"ask the running daemon to replace itself with a successor on the selected runtime generation",params:[],output:[{name:"restarted",type:"boolean",required:!0,constraint:"True when a running daemon accepted the handover. False when none was running \u2014 one was started instead."},{name:"started",type:"boolean",required:!1,constraint:"True when no daemon was running and this call started one (restarted=false)."},{name:"pid",type:"number",required:!1,constraint:"The daemon that accepted the handover, or the one just started."},{name:"grace_ms",type:"number",required:!1,constraint:"How long the daemon waits after answering before teardown begins."},{name:"note",type:"string",required:!1,constraint:"What happens next to the calling node. Read it before acting on an interrupted turn."}],outputKind:"object",effects:["The daemon acknowledges immediately, then after a short grace tears its whole broker fleet down, releases its claim, spawns a successor on the runtime generation currently selected, and exits.","Each torn-down broker hands its foreground bash commands to the file-backed background job system; they keep running and report completion by urgent inbox message.","Every node it tore down \u2014 including the caller \u2014 is resolved by the successor through the ordinary row-driven supervision tick.","The calling process is killed as part of that teardown, AFTER this result is returned."]},run:s(async r=>{if(!_()){const e=await h();return{restarted:!1,started:e.started,...e.pid!==void 0?{pid:e.pid}:{},...e.existing_pid!==void 0?{pid:e.existing_pid}:{},note:"No daemon was running, so there was nothing to hand over; one was started on the selected runtime generation."}}try{const e=await k().restartDaemon();return{restarted:e.accepted,pid:e.predecessor_pid,grace_ms:e.grace_ms,note:"Handover accepted. If you are running inside a node, your broker is about to be torn down and resumed on the new generation \u2014 THIS RESTART ALREADY SUCCEEDED. When your turn resumes with this result in the transcript, do not re-run it."}}catch(e){throw new o({error:"daemon_restart_failed",message:`the daemon did not accept a handover: ${e.message}`,next:"Run `crtr sys daemon status` to inspect the daemon."})}},"run")}),j=d({name:"stop",description:"stop the running daemon",whenToUse:"shutting the crtrd supervisor down for good, ending auto-revival of nodes on window exit. Not the way to pick up a new runtime generation \u2014 `restart` is, because stopping kills every broker of this epoch, including the caller's own, so this command cannot report its result to an agent that runs it.",help:{name:"sys daemon stop",summary:"stop the crtrd supervisor daemon and confirm it exited",params:[],output:[{name:"stopped",type:"boolean",required:!0,constraint:"True when a daemon (recorded or stray) was found and killed. False when none was running."},{name:"pid",type:"number",required:!1,constraint:"The recorded pidfile pid that exited (when one was recorded and alive)."},{name:"strays_killed",type:"array",required:!1,constraint:"PIDs of stray crtrd daemons (alive but not in the pidfile) swept alongside the recorded one \u2014 the backstop against a daemon that survived a prior stop."},{name:"force_killed",type:"boolean",required:!1,constraint:"True when the recorded daemon ignored SIGTERM and had to be SIGKILLed."}],outputKind:"object",effects:["Sends SIGTERM to the recorded pid and waits for it to exit and release its pidfile, escalating to SIGKILL if it does not.","Then sweeps any OTHER live crtrd daemon process (SIGTERM, escalating to SIGKILL after 2s) so a stray daemon orphaned from the pidfile cannot keep supervising."]},run:s(async r=>{const e=l(),a=e!==null&&m(e);try{let n=!1;a&&(n=await b(e)==="killed");const i=v(null);return!a&&i.length===0?{stopped:!1}:{stopped:!0,...a?{pid:e}:{},...i.length>0?{strays_killed:i}:{},...n?{force_killed:!0}:{}}}catch(n){throw new o({error:"daemon_stop_failed",message:`failed to stop daemon${a?` pid ${e}`:""}: ${n.message}`,next:"Run `crtr sys daemon status` to inspect the daemon before retrying."})}},"run")}),A=w({name:"daemon",description:"manage the crtrd supervisor process",whenToUse:"managing the crtrd supervisor that auto-revives nodes on window exit \u2014 start it, restart it onto a new runtime generation, check its status, or stop it",help:{name:"sys daemon",summary:"manage the crtrd canvas supervisor daemon",model:"crtrd is a thin background daemon that polls active+idle nodes and acts on window exit: crashed windows become dead; refresh-yield windows get a fresh respawn. It holds no orchestration logic \u2014 just process supervision. These are operator/dev self-management, not something a normal agent turn needs to check. Picking up a newly installed runtime generation is `restart` (a handover the daemon performs on itself); `stop` is the terminal one, and it kills the caller along with the rest of the fleet."},children:[x,S,E,P,R,j]});function F(){return q([A])}s(F,"registerDaemonControlSys");export{A as daemonBranch,F as registerDaemonControlSys};
@@ -1 +1 @@
1
- var r=Object.defineProperty;var t=(e,n)=>r(e,"name",{value:n,configurable:!0});import{defineLeaf as i}from"../../core/command.js";import{joinDirectory as s}from"../../core/directory/join.js";const u=i({name:"join",description:"register this runtime with its directory using an enrollment token, offline",whenToUse:"at a hosted runtime's first boot, before the daemon starts, with the enrollment token provisioning passed in, or on your own computer, with a token from `<issuer>/account/machines`. Not for a runtime that is already running: the daemon must be stopped.",help:{name:"sys join",summary:"exchange a directory enrollment token for a new runtime client key and record the returned identity",guide:"Generates a new ES256 client key, sends only its public key to `PUT <directory>/v1/runtimes/registration` with the token as bearer, and learns the runtime id, client id, issuer, owner and URL from the answer. Only after that answer does it write the key file, set the user settings `runtime.issuer` and `runtime.audience`, and rewrite `user:local` profile grantees to `app:terminal`. canvas.db belongs to the daemon: its next start rewrites `user:local` there, before it serves anything. Presenting the token of the stored successful registration again finishes those local writes without calling the directory. Any other token generates a new key and registers again.",params:[{kind:"flag",name:"directory",type:"string",required:!0,constraint:"Directory base URL (http or https) that minted the token."},{kind:"flag",name:"token",type:"string",required:!0,sensitive:!0,constraint:"Single-use enrollment token. Sensitive: never echoed in output or errors."},{kind:"flag",name:"name",type:"string",required:!1,constraint:"Label shown for this machine on the account page. Default: this computer's hostname."}],output:[{name:"runtime",type:"string",required:!0,constraint:"Runtime principal from the directory, `runtime:<id>`."},{name:"client_id",type:"string",required:!0,constraint:"OAuth client id registered for the new key."},{name:"issuer",type:"string",required:!0,constraint:"Canonical issuer, written to `runtime.issuer`."},{name:"owner",type:"string",required:!0,constraint:"Owner principal, `user:<id>`, that replaces `user:local` subjects."},{name:"canvas_rewrite",type:"string",required:!0,constraint:"Always `next daemon start`: when canvas.db identity fields are rewritten."},{name:"url",type:"string",required:!0,constraint:"Runtime URL, written to `runtime.audience`."},{name:"registered",type:"boolean",required:!0,constraint:"False when the token matched the stored registration and the directory was not called."}],outputKind:"object",effects:["Refuses while a daemon answers on the API socket.","On a new token: calls the directory once; on success writes `<runtime>/secrets/runtime-client.json` (mode 0600) with the private key, registration and the token's SHA-256, replacing any earlier key file. A refused or failed call writes nothing.","Sends name, platform, crtr version, installed plugin ids and sandbox state with the public key. Before the request it writes `runtime-client.pending.json` (mode 0600); a rerun with the same token and directory reuses that key, so a lost reply is retried safely. The file is deleted on success or a refusal.","Sets `runtime.issuer` and `runtime.audience` in user settings. When the directory says this machine is not the account's default (`is_default: false`) it replaces the whole `runtime` setting with `{issuer, audience, unsandboxed: true}`: no app listener, and only `app:terminal` nodes run.","Rewrites `user:local` profile grantees to `app:terminal`.","At every later daemon start, before any listener: exact `user:local` subjects in canvas.db nodes and node_tokens become the owner (as do missing subjects on `app:terminal` nodes), `user:local` grantees in nodes, run_events, idempotency_keys and node_tokens become `app:terminal`, references to the unjoined runtime's `local:terminal` grant are cleared, and that local grant mirror is deleted so grant sync installs the directory's. Application grantees and free text are unchanged; a start with nothing left to rewrite changes no rows."]},run:t(async e=>({...await s(e.directory,e.token,e.name||void 0),canvas_rewrite:"next daemon start"}),"run")});export{u as sysJoinLeaf};
1
+ var a=Object.defineProperty;var n=(t,r)=>a(t,"name",{value:r,configurable:!0});import{defineLeaf as s}from"../../core/command.js";import{existsSync as o}from"node:fs";import{joinDirectory as d}from"../../core/directory/join.js";import{apiSocketPath as u}from"../../core/canvas/paths.js";import{listProcessTable as l}from"../../core/canvas/pid.js";import{daemonAnswers as c}from"../../core/layout-migrate/index.js";import{envNodeId as m}from"../../shared/env.js";import{isDaemonRunning as h}from"../../daemon/pidfile.js";import{findDaemonPids as p,spawnDaemon as f,stopAllDaemons as w}from"../../daemon/manage.js";function y(){const t=l();if(t===null||(m()??"")!=="")return!0;const r=new Map(t.map(e=>[e.pid,e])),i=new Set;for(let e=process.ppid;e>1&&!i.has(e);e=r.get(e)?.ppid??0)if(i.add(e),r.get(e)?.command.includes("broker-cli"))return!0;return!1}n(y,"insideBroker");function g(){if(!(process.platform==="linux"&&o("/run/crtr/launcher.sock")))return{running:n(async()=>h()||p().length>0||await c(u()),"running"),stop:n(async()=>{await w()},"stop"),start:n(async()=>{await f()},"start"),insideNode:y}}n(g,"daemonControl");const D=s({name:"join",description:"register this runtime with its directory using an enrollment token, offline",whenToUse:"on your own computer, with a token from `<issuer>/account/machines`: run it from a plain terminal, with any viewers open. It stops the daemon, joins, and starts it again. Also at a hosted runtime's first boot, before the daemon starts, with the enrollment token provisioning passed in.",help:{name:"sys join",summary:"exchange a directory enrollment token for a new runtime client key and record the returned identity",guide:"Generates a new ES256 client key, sends only its public key to `PUT <directory>/v1/runtimes/registration` with the token as bearer, and learns the runtime id, client id, issuer, owner and URL from the answer. Only after that answer does it write the key file, set the user settings `runtime.issuer` and `runtime.audience`, and rewrite `user:local` profile grantees to `app:terminal`. canvas.db belongs to the daemon: its next start rewrites `user:local` there, before it serves anything. On your own computer a running daemon is no obstacle: join takes a `join.lock` in the runtime directory (so no open viewer's redial can autostart a daemon while it works), registers with the directory while the daemon still serves, stops the daemon, writes the identity, and starts the daemon again, whose start applies the canvas.db rewrite. Run it from a plain terminal, not from inside a node: a node's broker dies with the daemon, so join refuses `join_inside_node` instead. Where the launcher owns the daemon (a hosted container) join keeps the offline contract and refuses `daemon_running`. Presenting the token of the stored successful registration again finishes those local writes without calling the directory. Any other token generates a new key and registers again.",params:[{kind:"flag",name:"directory",type:"string",required:!0,constraint:"Directory base URL (http or https) that minted the token."},{kind:"flag",name:"token",type:"string",required:!0,sensitive:!0,constraint:"Single-use enrollment token. Sensitive: never echoed in output or errors."},{kind:"flag",name:"name",type:"string",required:!1,constraint:"Label shown for this machine on the account page. Default: this computer's hostname."}],output:[{name:"runtime",type:"string",required:!0,constraint:"Runtime principal from the directory, `runtime:<id>`."},{name:"client_id",type:"string",required:!0,constraint:"OAuth client id registered for the new key."},{name:"issuer",type:"string",required:!0,constraint:"Canonical issuer, written to `runtime.issuer`."},{name:"owner",type:"string",required:!0,constraint:"Owner principal, `user:<id>`, that replaces `user:local` subjects."},{name:"canvas_rewrite",type:"string",required:!0,constraint:"Always `next daemon start`: when canvas.db identity fields are rewritten."},{name:"url",type:"string",required:!0,constraint:"Runtime URL, written to `runtime.audience`."},{name:"registered",type:"boolean",required:!0,constraint:"False when the token matched the stored registration and the directory was not called."},{name:"daemon",type:"string",required:!0,constraint:"`restarted` (a daemon was stopped and started on the joined settings), `not_running` (none was running; none was started), or `restart_failed` (joined, but the new daemon did not start: see `daemon_error`, then run `crtr sys daemon start`)."},{name:"daemon_error",type:"string",required:!1,constraint:"Why the new daemon did not start, with `daemon: restart_failed`."}],outputKind:"object",effects:["Holds `<runtime>/join.lock` (its pid) from the start until the daemon is back; `ensureDaemon` and `sys daemon start` (without `--force`) decline to start a daemon while a live process holds it (including a start already in flight when the join began). A lock whose holder died is reclaimed.","A running daemon is stopped (SIGTERM, then SIGKILL, plus strays) after the directory accepts the registration and before the local writes, then started again. Every broker dies with it and is revived by the new daemon. If the join fails after the stop, the daemon is started again anyway. With no daemon running none is started: the next command's autostart does it. Refuses `join_inside_node` when run from inside a node and a daemon is running; refuses `daemon_running` where a container launcher owns the daemon.","On a new token: calls the directory once; on success writes `<runtime>/secrets/runtime-client.json` (mode 0600) with the private key, registration and the token's SHA-256, replacing any earlier key file. A refused or failed call writes nothing.","Sends name, platform, crtr version, installed plugin ids and sandbox state with the public key. Before the request it writes `runtime-client.pending.json` (mode 0600); a rerun with the same token and directory reuses that key, so a lost reply is retried safely. The file is deleted on success or a refusal.","Sets `runtime.issuer` and `runtime.audience` in user settings. When the directory says this machine is not the account's default (`is_default: false`) it replaces the whole `runtime` setting with `{issuer, audience, unsandboxed: true}`: no app listener, and only `app:terminal` nodes run.","Rewrites `user:local` profile grantees to `app:terminal`.","At every later daemon start, before any listener: exact `user:local` subjects in canvas.db nodes and node_tokens become the owner (as do missing subjects on `app:terminal` nodes), `user:local` grantees in nodes, run_events, idempotency_keys and node_tokens become `app:terminal`, references to the unjoined runtime's `local:terminal` grant are cleared, and that local grant mirror is deleted so grant sync installs the directory's. Application grantees and free text are unchanged; a start with nothing left to rewrite changes no rows."]},run:n(async t=>({...await d(t.directory,t.token,t.name||void 0,g()),canvas_rewrite:"next daemon start"}),"run")});export{D as sysJoinLeaf};
@@ -0,0 +1,13 @@
1
+ import { InputError } from '../io.js';
2
+ export declare function joinLockPath(): string;
3
+ /** The live process holding the lock, or null when no join is running (no lock, or a holder that died). */
4
+ export declare function joinLockHolder(): number | null;
5
+ /** Whether a join is in progress. The joiner starts its own daemon through `spawnDaemon`, never through the implicit paths that ask this. */
6
+ export declare function joinInProgress(): boolean;
7
+ export declare function joinInProgressError(pid: number | null): InputError;
8
+ /** Resolve once no other live process holds the join lock, or after `timeoutMs`. For a start that must happen
9
+ * but must read the settings a join is about to commit (a daemon handover's successor): it waits the join out
10
+ * instead of bypassing the lock, so nothing but the join starts a daemon while the lock is held. */
11
+ export declare function awaitJoinLockRelease(timeoutMs: number, pollMs?: number): Promise<void>;
12
+ /** Run `operation` holding the join lock. Throws `join_in_progress` when a live process holds it. */
13
+ export declare function withJoinLock<T>(operation: () => Promise<T>): Promise<T>;
@@ -0,0 +1 @@
1
+ var a=Object.defineProperty;var n=(o,r)=>a(o,"name",{value:r,configurable:!0});import{mkdirSync as l}from"node:fs";import{join as m}from"node:path";import{crtrHome as e}from"../canvas/paths.js";import{isPidAlive as f}from"../canvas/pid.js";import{exclusiveLockOwnerPid as d,withExclusiveDirectoryLockAsync as p}from"../exclusive-lock.js";import{InputError as h}from"../io.js";const w=1e3;function s(){return m(e(),"join.lock")}n(s,"joinLockPath");function t(){const o=d(s());return o!==null&&f(o)?o:null}n(t,"joinLockHolder");function E(){return t()!==null}n(E,"joinInProgress");function j(o){return new h({error:"join_in_progress",message:o===null?"`crtr sys join` is running and holds the daemon down until it finishes":`\`crtr sys join\` (pid ${o}) is running and holds the daemon down until it finishes`,next:"Wait for the join to finish; it starts the daemon again itself. If it is stuck, `crtr sys daemon start --force`."})}n(j,"joinInProgressError");async function _(o,r=100){const c=Date.now()+o;for(;;){const i=t();if(i===null||i===process.pid||Date.now()>=c)return;await new Promise(u=>setTimeout(u,r))}}n(_,"awaitJoinLockRelease");async function v(o){return l(e(),{recursive:!0}),p(s(),o,{timeoutMs:w,timeoutError:n(()=>j(t()),"timeoutError")})}n(v,"withJoinLock");export{_ as awaitJoinLockRelease,E as joinInProgress,j as joinInProgressError,t as joinLockHolder,s as joinLockPath,v as withJoinLock};
@@ -20,10 +20,32 @@ type Registration = Pick<JoinedClient, 'runtime' | 'client_id' | 'issuer' | 'own
20
20
  export interface JoinResult extends Registration {
21
21
  /** False when the token matched the stored successful registration and no directory call was made. */
22
22
  registered: boolean;
23
+ /** `restarted`: a daemon was running, join stopped it and started a new one on the joined config. `not_running`: none was running and none was started. `restart_failed`: the join is committed but the new daemon did not start (see `daemon_error`). */
24
+ daemon: 'restarted' | 'not_running' | 'restart_failed';
25
+ daemon_error?: string;
26
+ }
27
+ /** How join takes a running daemon down and brings it back. Absent, join keeps
28
+ * the offline contract and refuses `daemon_running` while a daemon answers. */
29
+ export interface JoinDaemonControl {
30
+ /** Whether a daemon is running or answering on this runtime's socket. */
31
+ running(): Promise<boolean>;
32
+ /** Stop the daemon and every stray; resolves once none remains. */
33
+ stop(): Promise<void>;
34
+ /** Start a daemon on the settings now on disk and wait until it serves. */
35
+ start(): Promise<void>;
36
+ /** Whether this process runs inside a node, whose broker the stop kills. */
37
+ insideNode(): boolean;
23
38
  }
24
39
  /** The owner a successful join recorded, or null when `crtr sys join` never
25
40
  * wrote the client file (no file, or one provisioned without join, which has
26
41
  * no `token_sha256`). The daemon applies OWN-11 to canvas.db from it at every start. */
27
42
  export declare function joinedOwner(): string | null;
28
- export declare function joinDirectory(directory: string, token: string, name?: string): Promise<JoinResult>;
43
+ /** Join this runtime to a directory.
44
+ *
45
+ * With `daemon` control, a running daemon is no obstacle: the join holds the
46
+ * join lock (so no viewer's redial can autostart a daemon while it is down),
47
+ * registers with the directory while the daemon still serves, stops the
48
+ * daemon, writes the identity, and starts the daemon again so its start
49
+ * applies the canvas owner rewrite. Without it, a running daemon refuses. */
50
+ export declare function joinDirectory(directory: string, token: string, name?: string, daemon?: JoinDaemonControl): Promise<JoinResult>;
29
51
  export {};
@@ -1 +1 @@
1
- var E=Object.defineProperty;var s=(e,t)=>E(e,"name",{value:t,configurable:!0});import{createHash as J,generateKeyPairSync as U,randomUUID as D}from"node:crypto";import{existsSync as I,mkdirSync as L,readdirSync as O,rmSync as j}from"node:fs";import{hostname as R}from"node:os";import{join as x}from"node:path";import{InputError as f}from"../io.js";import{network as h}from"../errors.js";import{secretsDir as w}from"../layout.js";import{apiSocketPath as C}from"../canvas/paths.js";import{daemonAnswers as K}from"../layout-migrate/index.js";import{atomicWriteJson as _,readJsonIfExists as g}from"../fs-utils.js";import{updateConfig as N}from"../config.js";import{launcherPresent as A}from"../grants/sandboxes.js";import{crtrVersion as H}from"../../shared/crtr-version.js";import{pluginsDir as T}from"../scope.js";import{userScopeRoot as W}from"../scope.js";import{rewriteLocalProfileGrantees as q}from"../scoped-state/profiles.js";function p(){return x(w(),"runtime-client.json")}s(p,"clientPath");function y(e){if(typeof e!="string")return!1;try{const{protocol:t}=new URL(e);return t==="http:"||t==="https:"}catch{return!1}}s(y,"isUrl");function b(e){const t=e??{},{runtime:c,client_id:a,issuer:i,owner:n,url:r}=t;if(typeof c!="string"||!/^runtime:.+/.test(c)||typeof a!="string"||a===""||!y(i)||typeof n!="string"||!/^user:.+/.test(n)||!y(r))throw h("directory registration answer is missing runtime, client_id, issuer, owner, or url");const{machine_id:o,is_default:u}=t;return{runtime:c,client_id:a,issuer:i,owner:n,url:r,...typeof o=="string"?{machine_id:o}:{},...typeof u=="boolean"?{is_default:u}:{}}}s(b,"registration");function d(){return x(w(),"runtime-client.pending.json")}s(d,"pendingPath");function z(){try{const e=T("user");return!e||!I(e)?[]:O(e,{withFileTypes:!0}).filter(t=>t.isDirectory()&&!t.name.startsWith(".")).map(t=>t.name).sort()}catch{return[]}}s(z,"installedPluginIds");async function B(e,t,c,a){const i=`${e.replace(/\/$/,"")}/v1/runtimes/registration`;let n;try{n=await fetch(i,{method:"PUT",headers:{authorization:`Bearer ${t}`,"content-type":"application/json"},body:JSON.stringify({jwk:c,name:a,platform:`${process.platform}-${process.arch}`,crtr_version:H(),plugins:z(),sandboxed:A()}),signal:AbortSignal.timeout(3e4)})}catch(o){throw h(`directory registration request to ${i} failed: ${o instanceof Error?o.message:String(o)}`)}const r=await n.text();if(!n.ok)throw n.status<500&&j(d(),{force:!0}),new f({error:"join_refused",message:`directory refused the registration: ${n.status} ${r.slice(0,500)}`,next:`Create a new join token at ${e.replace(/\/$/,"")}/account/machines (tokens are single-use and expire after 10 minutes), stop crtr, and run join again.`});try{return b(JSON.parse(r))}catch(o){throw o instanceof SyntaxError?h("directory registration answer is not JSON"):o}}s(B,"register");function S(e){N("user",t=>{t.runtime=e.is_default===!1?{issuer:e.issuer,audience:e.url,unsandboxed:!0}:{...t.runtime,issuer:e.issuer,audience:e.url}}),q(W())}s(S,"commit");function ue(){const e=g(p());if(e?.token_sha256===void 0)return null;const{owner:t}=e;if(typeof t!="string"||!/^user:.+/.test(t))throw new Error(`${p()} was written by join but has a missing or invalid owner`);return t}s(ue,"joinedOwner");async function me(e,t,c=R()){if(!y(e))throw new f({error:"invalid_directory",message:"directory must be an http or https URL",received:e,field:"directory",next:"Pass the directory base URL, for example https://directory.example."});if(t==="")throw new f({error:"invalid_token",message:"token is empty",field:"token",next:"Pass the enrollment token provisioning supplied."});const a=C();if(await K(a))throw new f({error:"daemon_running",message:`cannot join while a daemon answers on ${a}`,next:"Join before the daemon starts: stop the daemon that owns this volume, then run `crtr sys join` again."});const i=J("sha256").update(t).digest("hex"),n=g(p());if(n?.token_sha256===i){const m=b(n);return S(m),{...m,registered:!1}}L(w(),{recursive:!0,mode:448});let r=g(d());if(r?.token_sha256!==i||r.directory!==e||!r.jwk||!r.public_jwk){const{privateKey:m,publicKey:v}=U("ec",{namedCurve:"P-256"}),k=D();r={token_sha256:i,directory:e,jwk:{...m.export({format:"jwk"}),kid:k,alg:"ES256",use:"sig"},public_jwk:{...v.export({format:"jwk"}),kid:k,alg:"ES256",use:"sig"}},_(d(),r,{mode:384})}const{jwk:o,public_jwk:u}=r,$=o.kid,l=await B(e,t,u,c),P={jwk:o,kid:$,alg:"ES256",...l,token_sha256:i};return _(p(),P,{mode:384}),j(d(),{force:!0}),S(l),{...l,registered:!0}}s(me,"joinDirectory");export{me as joinDirectory,ue as joinedOwner};
1
+ var J=Object.defineProperty;var s=(r,t)=>J(r,"name",{value:t,configurable:!0});import{createHash as N,generateKeyPairSync as R,randomUUID as U}from"node:crypto";import{existsSync as I,mkdirSync as D,readdirSync as L,rmSync as y}from"node:fs";import{hostname as O}from"node:os";import{join as S}from"node:path";import{InputError as p}from"../io.js";import{network as k}from"../errors.js";import{secretsDir as j}from"../layout.js";import{apiSocketPath as C}from"../canvas/paths.js";import{daemonAnswers as K}from"../layout-migrate/index.js";import{atomicWriteJson as _,readJsonIfExists as x}from"../fs-utils.js";import{updateConfig as T}from"../config.js";import{launcherPresent as A}from"../grants/sandboxes.js";import{crtrVersion as W}from"../../shared/crtr-version.js";import{pluginsDir as q}from"../scope.js";import{userScopeRoot as z}from"../scope.js";import{rewriteLocalProfileGrantees as B}from"../scoped-state/profiles.js";import{withJoinLock as F}from"./join-lock.js";function l(){return S(j(),"runtime-client.json")}s(l,"clientPath");function b(r){if(typeof r!="string")return!1;try{const{protocol:t}=new URL(r);return t==="http:"||t==="https:"}catch{return!1}}s(b,"isUrl");function $(r){const t=r??{},{runtime:u,client_id:e,issuer:a,owner:n,url:o}=t;if(typeof u!="string"||!/^runtime:.+/.test(u)||typeof e!="string"||e===""||!b(a)||typeof n!="string"||!/^user:.+/.test(n)||!b(o))throw k("directory registration answer is missing runtime, client_id, issuer, owner, or url");const{machine_id:i,is_default:c}=t;return{runtime:u,client_id:e,issuer:a,owner:n,url:o,...typeof i=="string"?{machine_id:i}:{},...typeof c=="boolean"?{is_default:c}:{}}}s($,"registration");function w(){return S(j(),"runtime-client.pending.json")}s(w,"pendingPath");function G(){try{const r=q("user");return!r||!I(r)?[]:L(r,{withFileTypes:!0}).filter(t=>t.isDirectory()&&!t.name.startsWith(".")).map(t=>t.name).sort()}catch{return[]}}s(G,"installedPluginIds");async function V(r,t,u,e){const a=`${r.replace(/\/$/,"")}/v1/runtimes/registration`;let n;try{n=await fetch(a,{method:"PUT",headers:{authorization:`Bearer ${t}`,"content-type":"application/json"},body:JSON.stringify({jwk:u,name:e,platform:`${process.platform}-${process.arch}`,crtr_version:W(),plugins:G(),sandboxed:A()}),signal:AbortSignal.timeout(3e4)})}catch(i){throw k(`directory registration request to ${a} failed: ${i instanceof Error?i.message:String(i)}`)}const o=await n.text();if(!n.ok)throw n.status<500&&y(w(),{force:!0}),new p({error:"join_refused",message:`directory refused the registration: ${n.status} ${o.slice(0,500)}`,next:`Create a new join token at ${r.replace(/\/$/,"")}/account/machines (tokens are single-use and expire after 10 minutes), and run \`crtr sys join\` again.`});try{return $(JSON.parse(o))}catch(i){throw i instanceof SyntaxError?k("directory registration answer is not JSON"):i}}s(V,"register");function P(r){T("user",t=>{t.runtime=r.is_default===!1?{issuer:r.issuer,audience:r.url,unsandboxed:!0}:{...t.runtime,issuer:r.issuer,audience:r.url}}),B(z())}s(P,"commit");function mr(){const r=x(l());if(r?.token_sha256===void 0)return null;const{owner:t}=r;if(typeof t!="string"||!/^user:.+/.test(t))throw new Error(`${l()} was written by join but has a missing or invalid owner`);return t}s(mr,"joinedOwner");function v(r){return new p({error:"daemon_running",message:`cannot join while a daemon answers on ${r}`,next:"This daemon is managed outside crtr, so join cannot restart it: stop the daemon that owns this volume, then run `crtr sys join` again."})}s(v,"daemonRunningError");async function E(r,t,u,e){const a=x(l());if(a?.token_sha256===e)return{joined:$(a),registered:!1};D(j(),{recursive:!0,mode:448});let n=x(w());if(n?.token_sha256!==e||n.directory!==r||!n.jwk||!n.public_jwk){const{privateKey:h,publicKey:f}=R("ec",{namedCurve:"P-256"}),d=U();n={token_sha256:e,directory:r,jwk:{...h.export({format:"jwk"}),kid:d,alg:"ES256",use:"sig"},public_jwk:{...f.export({format:"jwk"}),kid:d,alg:"ES256",use:"sig"}},_(w(),n,{mode:384})}const{jwk:o,public_jwk:i}=n,c=await V(r,t,i,u);return{joined:c,registered:!0,client:{jwk:o,kid:o.kid,alg:"ES256",...c,token_sha256:e}}}s(E,"obtainRegistration");async function pr(r,t,u=O(),e){if(!b(r))throw new p({error:"invalid_directory",message:"directory must be an http or https URL",received:r,field:"directory",next:"Pass the directory base URL, for example https://directory.example."});if(t==="")throw new p({error:"invalid_token",message:"token is empty",field:"token",next:"Pass the enrollment token provisioning supplied."});const a=C(),n=N("sha256").update(t).digest("hex");if(e===void 0){if(await K(a))throw v(a);const{joined:o,registered:i,client:c}=await E(r,t,u,n);return c&&(_(l(),c,{mode:384}),y(w(),{force:!0})),P(o),{...o,registered:i,daemon:"not_running"}}return F(async()=>{const o=s(()=>new p({error:"join_inside_node",message:"a daemon is running and this command is inside a node: stopping the daemon would kill this node's broker and this command with it",next:"Run `crtr sys join` from a plain terminal outside any crtr node. It stops the daemon, joins, and starts it again for you."}),"refuseInsideNode");if(await e.running()&&e.insideNode())throw o();const{joined:i,registered:c,client:h}=await E(r,t,u,n);let f=!1,d;try{if(await e.running()){if(e.insideNode())throw o();if(f=!0,await e.stop(),await e.running())throw v(a)}if(h&&(_(l(),h,{mode:384}),y(w(),{force:!0})),P(i),await e.running()){if(e.insideNode())throw o();f=!0,await e.stop()}}catch(m){d=m}let g={daemon:"not_running"};if(f)try{await e.start(),g={daemon:"restarted"}}catch(m){g={daemon:"restart_failed",daemon_error:m instanceof Error?m.message:String(m)}}if(d!==void 0)throw d;return{...i,registered:c,...g}})}s(pr,"joinDirectory");export{pr as joinDirectory,mr as joinedOwner};
@@ -1 +1 @@
1
- var k=Object.defineProperty;var n=(e,r)=>k(e,"name",{value:r,configurable:!0});import{createPrivateKey as w,randomUUID as v,sign as S}from"node:crypto";import{readFileSync as b}from"node:fs";import{join as E}from"node:path";import{crtrHome as j}from"../canvas/paths.js";import{isUnsandboxedMachine as x}from"../runtime/unsandboxed.js";import{emitEvent as p}from"../events/emit.js";import{allGrants as $,dependenciesOf as T,replaceGrants as A}from"./mirror.js";import{onGrantSync as N}from"./on-sync.js";const z=300;function G(e,r){const t=Math.floor(Date.now()/1e3),i=Buffer.from(JSON.stringify({alg:"ES256",typ:"JWT",kid:e.kid})).toString("base64url"),a=Buffer.from(JSON.stringify({iss:e.client_id,sub:e.client_id,aud:r,iat:t,exp:t+300,jti:v()})).toString("base64url"),o=`${i}.${a}`,s=S("sha256",Buffer.from(o),{key:w({key:e.jwk,format:"jwk"}),dsaEncoding:"ieee-p1363"});return`${o}.${s.toString("base64url")}`}n(G,"assertion");function y(){const e=JSON.parse(b(E(j(),"secrets","runtime-client.json"),"utf8"));if(!e.jwk||e.alg!=="ES256"||!e.kid||!e.client_id||!e.runtime||e.jwk.kid!==e.kid||e.jwk.kty!=="EC"||e.jwk.crv!=="P-256"||!e.jwk.d)throw new TypeError("invalid runtime client key");return e}n(y,"runtimeClient");function H(e=y()){return e.runtime.startsWith("runtime:")?e.runtime.slice(8):e.runtime}n(H,"runtimeId");function R(e){return`This machine was removed from your Northlight account. To use it again, create a join token at ${e.replace(/\/$/,"")}/account/machines, stop crtr, and run \`crtr sys join\`.`}n(R,"machineRevokedMessage");class g extends Error{static{n(this,"MachineRevokedError")}constructor(r){super(R(r)),this.name="MachineRevokedError"}}async function O(e,r){const t=y(),i=`${e.replace(/\/$/,"")}/token`,a=new URLSearchParams({grant_type:"client_credentials",client_id:t.client_id,client_assertion_type:"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",client_assertion:G(t,i),...r===void 0?{}:{audience:r}}),o=await fetch(i,{method:"POST",body:a,signal:AbortSignal.timeout(1e4)});if(!o.ok)throw o.status===401&&x()&&await B(o)?new g(e):new Error(`directory token request failed: ${o.status}`);const s=await o.json();if(typeof s.access_token!="string")throw new TypeError("directory token response has no access_token");return{access_token:s.access_token,expires_at:D(s.access_token,s.expires_in)}}n(O,"mintDirectoryToken");async function B(e){try{return(await e.json()).error==="invalid_client"}catch{return!1}}n(B,"isInvalidClient");function D(e,r){if(typeof r=="number"&&Number.isFinite(r)&&r>0)return Date.now()+r*1e3;try{const t=JSON.parse(Buffer.from(e.split(".")[1]??"","base64url").toString("utf8"));if(typeof t.exp=="number")return t.exp*1e3}catch{}return Date.now()+3e5}n(D,"tokenExpiry");function J(e,r){if(!e||typeof e!="object")throw new TypeError("invalid grant sync response");const t=e;if(t.runtime!==(r.runtime.startsWith("runtime:")?r.runtime:`runtime:${r.runtime}`)||typeof t.id!="string"||typeof t.sub!="string"||typeof t.grantee!="string"||!Number.isInteger(t.rev)||!Array.isArray(t.scopes)||!Array.isArray(t.resolved_scopes)||!["active","revoked","removed"].includes(t.status??"")||typeof t.synced_at!="string")throw new TypeError("invalid grant sync response");const a=e.revisions?.find(o=>o.rev===t.rev)?.approved_at;if(typeof a!="string")throw new TypeError("grant revision has no approval time");return{...t,spend_cap:t.spend_cap??null,expires_at:t.expires_at??null,revoked_at:t.revoked_at??null,approved_at:a,payer:"user",dependencies:T(t.dependencies),run_notice_url:typeof t.run_notice_url=="string"&&t.run_notice_url!==""?t.run_notice_url:null}}n(J,"responseGrant");let m;function K(e){return m?m(e):Promise.resolve()}n(K,"requestGrantSync");function V(e,r=t=>{}){let t;const i=e.replace(/\/$/,""),a=n(async()=>{const c=y(),u=await O(i),f=await fetch(`${i}/v1/runtime/grants`,{headers:{Authorization:`Bearer ${u.access_token}`},signal:AbortSignal.timeout(1e4)});if(!f.ok)throw new Error(`directory grant sync failed: ${f.status}`);const d=await f.json();if(!Array.isArray(d.grants)||typeof d.synced_at!="string")throw new TypeError("invalid grant sync response");const h=$();A(d.grants.map(l=>J(l,c)),d.synced_at,i),p({level:"info",event:"daemon.grants.synced",fields:{grants:d.grants.length,synced_at:d.synced_at}});try{await N(h,r)}catch(l){p({level:"error",event:"daemon.grants.on_sync_failed",error:l})}},"run");let o;const s=n(()=>{const c=a().catch(u=>{throw u instanceof g?p({level:"error",event:"daemon.grants.machine_revoked",error:u}):p({level:"error",event:"daemon.grants.sync_failed",error:u}),u}).finally(()=>{t===c&&(t=void 0)});return t=c,c},"start"),_=n((c={})=>t?c.fresh?(o??=t.catch(()=>{}).then(()=>(o=void 0,t??s())),o):t:s(),"sync");return m=_,_}n(V,"createGrantSync");export{z as GRANT_SYNC_INTERVAL_S,g as MachineRevokedError,G as assertion,V as createGrantSync,R as machineRevokedMessage,O as mintDirectoryToken,K as requestGrantSync,y as runtimeClient,H as runtimeId};
1
+ var k=Object.defineProperty;var n=(e,r)=>k(e,"name",{value:r,configurable:!0});import{createPrivateKey as w,randomUUID as v,sign as S}from"node:crypto";import{readFileSync as b}from"node:fs";import{join as E}from"node:path";import{crtrHome as j}from"../canvas/paths.js";import{isUnsandboxedMachine as x}from"../runtime/unsandboxed.js";import{emitEvent as p}from"../events/emit.js";import{allGrants as $,dependenciesOf as T,replaceGrants as A}from"./mirror.js";import{onGrantSync as N}from"./on-sync.js";const z=300;function G(e,r){const t=Math.floor(Date.now()/1e3),i=Buffer.from(JSON.stringify({alg:"ES256",typ:"JWT",kid:e.kid})).toString("base64url"),a=Buffer.from(JSON.stringify({iss:e.client_id,sub:e.client_id,aud:r,iat:t,exp:t+300,jti:v()})).toString("base64url"),o=`${i}.${a}`,s=S("sha256",Buffer.from(o),{key:w({key:e.jwk,format:"jwk"}),dsaEncoding:"ieee-p1363"});return`${o}.${s.toString("base64url")}`}n(G,"assertion");function y(){const e=JSON.parse(b(E(j(),"secrets","runtime-client.json"),"utf8"));if(!e.jwk||e.alg!=="ES256"||!e.kid||!e.client_id||!e.runtime||e.jwk.kid!==e.kid||e.jwk.kty!=="EC"||e.jwk.crv!=="P-256"||!e.jwk.d)throw new TypeError("invalid runtime client key");return e}n(y,"runtimeClient");function H(e=y()){return e.runtime.startsWith("runtime:")?e.runtime.slice(8):e.runtime}n(H,"runtimeId");function R(e){return`This machine was removed from your Northlight account. To use it again, create a join token at ${e.replace(/\/$/,"")}/account/machines, and run \`crtr sys join\` from a plain terminal.`}n(R,"machineRevokedMessage");class g extends Error{static{n(this,"MachineRevokedError")}constructor(r){super(R(r)),this.name="MachineRevokedError"}}async function O(e,r){const t=y(),i=`${e.replace(/\/$/,"")}/token`,a=new URLSearchParams({grant_type:"client_credentials",client_id:t.client_id,client_assertion_type:"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",client_assertion:G(t,i),...r===void 0?{}:{audience:r}}),o=await fetch(i,{method:"POST",body:a,signal:AbortSignal.timeout(1e4)});if(!o.ok)throw o.status===401&&x()&&await B(o)?new g(e):new Error(`directory token request failed: ${o.status}`);const s=await o.json();if(typeof s.access_token!="string")throw new TypeError("directory token response has no access_token");return{access_token:s.access_token,expires_at:D(s.access_token,s.expires_in)}}n(O,"mintDirectoryToken");async function B(e){try{return(await e.json()).error==="invalid_client"}catch{return!1}}n(B,"isInvalidClient");function D(e,r){if(typeof r=="number"&&Number.isFinite(r)&&r>0)return Date.now()+r*1e3;try{const t=JSON.parse(Buffer.from(e.split(".")[1]??"","base64url").toString("utf8"));if(typeof t.exp=="number")return t.exp*1e3}catch{}return Date.now()+3e5}n(D,"tokenExpiry");function J(e,r){if(!e||typeof e!="object")throw new TypeError("invalid grant sync response");const t=e;if(t.runtime!==(r.runtime.startsWith("runtime:")?r.runtime:`runtime:${r.runtime}`)||typeof t.id!="string"||typeof t.sub!="string"||typeof t.grantee!="string"||!Number.isInteger(t.rev)||!Array.isArray(t.scopes)||!Array.isArray(t.resolved_scopes)||!["active","revoked","removed"].includes(t.status??"")||typeof t.synced_at!="string")throw new TypeError("invalid grant sync response");const a=e.revisions?.find(o=>o.rev===t.rev)?.approved_at;if(typeof a!="string")throw new TypeError("grant revision has no approval time");return{...t,spend_cap:t.spend_cap??null,expires_at:t.expires_at??null,revoked_at:t.revoked_at??null,approved_at:a,payer:"user",dependencies:T(t.dependencies),run_notice_url:typeof t.run_notice_url=="string"&&t.run_notice_url!==""?t.run_notice_url:null}}n(J,"responseGrant");let m;function K(e){return m?m(e):Promise.resolve()}n(K,"requestGrantSync");function V(e,r=t=>{}){let t;const i=e.replace(/\/$/,""),a=n(async()=>{const c=y(),u=await O(i),f=await fetch(`${i}/v1/runtime/grants`,{headers:{Authorization:`Bearer ${u.access_token}`},signal:AbortSignal.timeout(1e4)});if(!f.ok)throw new Error(`directory grant sync failed: ${f.status}`);const d=await f.json();if(!Array.isArray(d.grants)||typeof d.synced_at!="string")throw new TypeError("invalid grant sync response");const h=$();A(d.grants.map(l=>J(l,c)),d.synced_at,i),p({level:"info",event:"daemon.grants.synced",fields:{grants:d.grants.length,synced_at:d.synced_at}});try{await N(h,r)}catch(l){p({level:"error",event:"daemon.grants.on_sync_failed",error:l})}},"run");let o;const s=n(()=>{const c=a().catch(u=>{throw u instanceof g?p({level:"error",event:"daemon.grants.machine_revoked",error:u}):p({level:"error",event:"daemon.grants.sync_failed",error:u}),u}).finally(()=>{t===c&&(t=void 0)});return t=c,c},"start"),_=n((c={})=>t?c.fresh?(o??=t.catch(()=>{}).then(()=>(o=void 0,t??s())),o):t:s(),"sync");return m=_,_}n(V,"createGrantSync");export{z as GRANT_SYNC_INTERVAL_S,g as MachineRevokedError,G as assertion,V as createGrantSync,R as machineRevokedMessage,O as mintDirectoryToken,K as requestGrantSync,y as runtimeClient,H as runtimeId};
@@ -7,7 +7,7 @@ var Se=Object.defineProperty;var s=(t,n)=>Se(t,"name",{value:n,configurable:!0})
7
7
  `);for(const e of r.quarantined)process.stderr.write(`[crtrd] plugin "${e.name}" has a damaged ${e.file} (${e.reason}); ${e.movedTo!==void 0?`moved to ${e.movedTo}`:"left in place and skipped"}
8
8
  `);m=Ur(),process.platform==="linux"&&k(R)&&(Ne(),await P({operation:"users.sync",users:Le()})),vr(a.token)}catch(r){se(),O.close();try{const{released:e}=oe(a);if(!e)throw new Error("daemon ownership claim was not released")}catch(e){throw new AggregateError([r,e],"daemon startup failed and cleanup also failed")}throw r}let f=!0,o,w=Promise.resolve(),v=!1,d,l;const i=new Set;let p=!1,g=!1,S,L,q=!1,$,T,D;const pe=sr();let I,h={phase:"initializing",message:"daemon startup is running the canvas import and normal services are unavailable",blockerPaths:[]},x="initial startup activation",me=null;function fe(){return h.phase==="ready"?null:{phase:h.phase,message:h.message,blockerPaths:h.blockerPaths}}s(fe,"startupBlock");function b(r){i.add(r),r.finally(()=>{i.delete(r)}).catch(()=>{})}s(b,"registerDetached");const j={acceptsDetachedWork:s(()=>f,"acceptsDetachedWork"),registerDetached:b};Br(j,ie);async function ue(){try{f=!1,Fr.close(),se(),O.close(),d!==void 0&&(clearInterval(d),d=void 0),D!==void 0&&(clearInterval(D),D=void 0);let r;do r=w,await r;while(r!==w);for(;i.size>0;)await Promise.allSettled([...i]);if(l!==void 0&&(await ot(l),l.dispose()),L!==void 0&&!q&&(q=!0,await L.close()),T!==void 0){const e=T;T=void 0,await e.close()}if($!==void 0){const e=$;$=void 0,await e.close()}if(p||(le(process.pid),p=!0),p&&!g){const{released:e}=oe(a);if(!e)throw new Error("daemon ownership claim was not released");g=!0}}finally{Tr(),process.off("SIGINT",U),process.off("SIGTERM",K),process.off("exit",J)}}s(ue,"cleanupAttempt");const A=1e3;let z=!1;function he(){return z?{graceMs:A}:(z=!0,_.fresh(()=>{u({level:"info",event:"daemon.handover.armed",fields:{pid:process.pid,grace_ms:A}})}),setTimeout(()=>{ve()},A),{graceMs:A})}s(he,"requestHandover");async function ve(){try{await E()}catch(r){const e=r instanceof Error?r.stack??r.message:String(r);process.stderr.write(`[crtrd] handover cleanup failed:
9
9
  ${e}
10
- `),process.exit(1)}try{process.platform==="linux"&&k(R)&&(await P({operation:"daemon.handover"}),process.exit(0)),process.pid===1&&await Dr();const r=await Cr();_.fresh(()=>{u({level:"info",event:"daemon.handover.completed",fields:{predecessor_pid:process.pid,successor_pid:r.pid??r.existing_pid??null,spawned:r.started}})})}catch(r){_.fresh(()=>{u({level:"error",event:"daemon.handover.successor_failed",error:r})});const e=r instanceof Error?r.message:String(r);process.stderr.write(`[crtrd] handover successor failed to start: ${e}
10
+ `),process.exit(1)}try{process.platform==="linux"&&k(R)&&(await P({operation:"daemon.handover"}),process.exit(0)),process.pid===1&&await Dr();const r=await Cr({waitForJoinLock:!0});_.fresh(()=>{u({level:"info",event:"daemon.handover.completed",fields:{predecessor_pid:process.pid,successor_pid:r.pid??r.existing_pid??null,spawned:r.started}})})}catch(r){_.fresh(()=>{u({level:"error",event:"daemon.handover.successor_failed",error:r})});const e=r instanceof Error?r.message:String(r);process.stderr.write(`[crtrd] handover successor failed to start: ${e}
11
11
  `),process.exit(1)}process.exit(0)}s(ve,"performHandover");function E(){if(S!==void 0)return S;const r=ue();return S=r,r.then(()=>{S===r&&(S=void 0)},()=>{S===r&&(S=void 0)}),r}s(E,"requestCleanup");async function W(r){try{await E(),process.exit(0)}catch(e){process.exitCode=1;const c=e instanceof Error?e.stack??`${e.name}: ${e.message}`:String(e);process.stderr.write(`[crtrd] ${r} cleanup failed:
12
12
  ${c}
13
13
  `),process.exit(1)}}s(W,"handleSignal");function U(){W("SIGINT")}s(U,"onSigint");function K(){W("SIGTERM")}s(K,"onSigterm");function J(){E().catch(()=>{})}s(J,"onExit");function N(r){f&&(w=w.then(r).then(()=>{},e=>{_.fresh(()=>{u({level:"error",event:"daemon.lane_job.failed",error:e})})}))}s(N,"enqueue");function V(){!f||v||(pe.check()&&I&&(u({level:"info",event:"daemon.resume.detected"}),b(I().catch(()=>{}))),v=!0,N(async()=>{if(v=!1,!!f)try{await gt(Date.now(),j)}catch(r){_.fresh(()=>{u({level:"error",event:"daemon.tick.failed",error:r})})}}))}s(V,"enqueueTick");let C=null;async function we(){if(C!==null)throw{status:500,code:"admission_failed",message:`daemon admission previously failed: ${C instanceof Error?C.message:String(C)}`};if(h.phase==="ready")return{admitted:!1,already_admitted:!0,startup_phase:"ready"};if(h.phase!=="prepared"&&h.phase!=="recovering")throw{status:409,code:"not_prepared",message:h.message};const r=h.phase==="prepared";try{return await M(),h={phase:"ready",message:"",blockerPaths:[]},r?{admitted:!0,already_admitted:!1,startup_phase:"ready"}:{admitted:!1,already_admitted:!0,startup_phase:"ready"}}catch(e){throw C=e,{status:500,code:"admission_failed",message:`daemon admission failed: ${e instanceof Error?e.message:String(e)}`}}}s(we,"admitStartup");async function ge(r){if(x!==null)throw{status:409,code:"migration_in_progress",message:`the canvas import is already running (${x})`};x="repair";try{const e=await Qr({dryRun:r.dryRun});if(r.dryRun||e.pending||h.phase!=="blocked")return{result:e,resumed:!1};if(t.prepare)return h={phase:"prepared",message:"daemon storage is prepared and normal services are unavailable until admission",blockerPaths:[]},{result:e,resumed:!1};try{return await M(),h={phase:"ready",message:"",blockerPaths:[]},{result:e,resumed:!0}}catch(c){const y=c instanceof Error?c.message:String(c);return setTimeout(()=>{E().catch(()=>{})},1e3),{result:e,resumed:!1,resumeError:y}}}finally{x=null}}s(ge,"repairMigrations");async function ye(){o=t.intervalMs??ir;const r=a.token;l=new zr({epoch:r,enqueue:N}),Ir(l),h={phase:"recovering",message:"daemon startup recovery is running and normal services are unavailable",blockerPaths:[]},_.fresh(()=>{u({level:"info",event:"daemon.started",fields:{pid:process.pid,interval_ms:o}})});const e=$r();if(Ar(e),_.fresh(()=>{u({level:"info",event:"daemon.install_id.resolved",fields:{install_id:e}})}),_.fresh(()=>{let c;try{c=or()}catch{return}c!==null&&u({level:"warn",event:"daemon.startup.warning",fields:{stage:"termrender",warning:c.slice(0,2048)}})}),await _.fresh(async()=>{await nr(),await er(),rr(Date.now()),tr(Date.now())}),_.fresh(()=>{const{reconcile:c,migrated:y}=Lr(hr);c.changed&&u({level:"info",event:"daemon.boot_change.reconciled",fields:{cleared_count:c.cleared.length,same_boot_proven:c.sameBootProven}}),y.length>0&&u({level:"info",event:"daemon.pid_identity.migrated",fields:{migrated_count:y.length}})}),_.fresh(()=>{kr();for(const c of Wr){const y=c.apply();y.changed&&u({level:"info",event:"daemon.boot_migration.applied",fields:{migration:c.id,...y.summary}})}}),b(Ye()),I){const c=s(()=>{b(I().catch(()=>{}))},"refresh");b(Ve().catch(()=>{})),b(Qe().then(({completion:y})=>y).catch(y=>{u({level:"error",event:"daemon.grants.sweep_failed",error:y})})),c(),D=setInterval(c,Je*1e3),D.unref()}N(()=>{jr(),qr()}),d=setInterval(V,o),V()}s(ye,"completeStartup");function M(){return me??=ye().catch(r=>{throw h={phase:"initializing",message:"daemon startup recovery failed and normal services are unavailable",blockerPaths:[]},r})}s(M,"resumeStartup");try{process.on("SIGINT",U),process.on("SIGTERM",K),process.on("exit",J),Kr(),m!==null&&u({level:"info",event:"daemon.joined_owner.applied",fields:{owner:m.owner,rows:m.rows}}),$=await xe();const r=a.token;Pr({epoch:r,requestHandover:he,registerDetached:b,startupBlock:fe,admitStartup:we,repairMigrations:ge}),ce(),L=qe({tcp:t.tcp}),await L.ready;const e=Ze("user").runtime;e?.issuer?(I=Ke(e.issuer,b),We(c=>Ue().appended(c))):ze(),e?.unsandboxed===!0&&e.listener!==void 0&&u({level:"warn",event:"daemon.app_listener.refused_unsandboxed",fields:{reason:"runtime.unsandboxed is set; runtime.listener is ignored"}}),e!==void 0&&e.listener!==void 0&&e.unsandboxed!==!0&&(T=je(e,()=>Xe(e.issuer.replace(/\/$/,"")),I),await T.ready);try{await Jr()}catch(c){if(x=null,c instanceof Vr)return h={phase:"blocked",message:c.message,blockerPaths:c.blockerPaths},E;throw c}process.platform==="linux"&&k(R)&&(Ae(),await $e("app:terminal"));try{t.prepare?h={phase:"prepared",message:"daemon storage is prepared and normal services are unavailable until admission",blockerPaths:[]}:(await M(),h={phase:"ready",message:"",blockerPaths:[]})}finally{x=null}return E}catch(r){try{await E()}catch(e){throw new AggregateError([r,e],"daemon startup failed and cleanup also failed")}throw r}}s(St,"runDaemon");var zn=St;export{F as brokerCommandDeclaresCanvasHome,H as brokerCommandEpoch,zn as default,St as runDaemon,gt as superviseTick,ot as teardownFleet};
@@ -43,6 +43,10 @@ export declare function execDaemon(): Promise<never>;
43
43
  export interface SpawnDaemonOptions {
44
44
  /** The phase that proves a daemon launched by this call is available. */
45
45
  targetPhase?: 'ready' | 'prepared';
46
+ /** Start even while another process's `crtr sys join` holds the daemon down. */
47
+ ignoreJoinLock?: boolean;
48
+ /** While another process's `crtr sys join` holds the daemon down, wait for it to finish, then start. For a handover successor. */
49
+ waitForJoinLock?: boolean;
46
50
  }
47
51
  export interface SpawnDaemonResult {
48
52
  /** True when a new daemon process was spawned. */
@@ -136,6 +140,14 @@ export declare function waitForDaemonExit(pid: number, timeoutMs?: number, deps?
136
140
  *
137
141
  * Returns how the daemon went down, for a truthful report. */
138
142
  export declare function stopDaemonProcess(pid: number, timeoutMs?: number, deps?: DaemonWaitDeps): Promise<'terminated' | 'killed'>;
143
+ /** Stop the recorded daemon and sweep every stray crtrd of this canvas home.
144
+ * Resolves once none remains. Shared by `crtr sys daemon stop` and `crtr sys join`. */
145
+ export declare function stopAllDaemons(): Promise<{
146
+ stopped: boolean;
147
+ pid?: number;
148
+ strays_killed: number[];
149
+ force_killed: boolean;
150
+ }>;
139
151
  /** Spawn crtrd detached. Returns immediately; the child outlives this process.
140
152
  *
141
153
  * If the daemon is already running, returns {started:false, existing_pid}.
@@ -169,7 +181,8 @@ export interface EnsureDaemonDeps {
169
181
  * rather than at the end of the startup window. */
170
182
  export declare function daemonAutostartStartupFailure(): string | undefined;
171
183
  /** Start the daemon if it is not already running. No-op if already up, if
172
- * autostart is suppressed for this invocation, while an attempt is in flight,
184
+ * autostart is suppressed for this invocation, while `crtr sys join` holds
185
+ * the join lock (see core/directory/join-lock.ts), while an attempt is in flight,
173
186
  * or while backing off from a recent failure.
174
187
  * Silently swallows spawn errors (the canvas still works without the daemon;
175
188
  * nodes just won't be auto-revived). */
@@ -1,2 +1,2 @@
1
- var z=Object.defineProperty;var o=(e,t)=>z(e,"name",{value:t,configurable:!0});import{spawn as Y,execFileSync as X}from"node:child_process";import{createRequire as V}from"node:module";import{dirname as b,join as E}from"node:path";import{fileURLToPath as q,pathToFileURL as M}from"node:url";import{existsSync as $,lstatSync as J,mkdirSync as Q,openSync as C,closeSync as F,fstatSync as B,readSync as Z}from"node:fs";import{crtrHome as x}from"../core/canvas/paths.js";import{listProcessTable as ee}from"../core/canvas/pid.js";import{bindDaemonEventSource as te,eventSource as re}from"../core/events/source.js";import{hostExecPath as U}from"../core/runtime/branded-host.js";import{isDaemonRunning as ne,readPidfile as g,isPidAlive as h}from"./pidfile.js";import{DAEMON_STARTUP_WINDOW_MS as W}from"./startup-policy.js";import{exclusiveLockOwnerPid as oe}from"../core/exclusive-lock.js";import{onDiskMigrationLockPath as ae}from"../core/canvas/paths.js";import{envNoDaemonAutostart as ie}from"../shared/env.js";import{waitForDaemonAvailability as se}from"../api/index.js";import{localClient as ce}from"../api/node-transport.js";const ue=["CRTR_NODE_ID","CRTR_KIND","CRTR_MODE","CRTR_LIFECYCLE","CRTR_NODE_CWD","CRTR_CONTEXT_DIR","CRTR_CYCLES","CRTR_PROFILE_ID","CRTR_PARENT_NODE_ID","CRTR_FRONT_DOOR","TMUX","TMUX_PANE","CRTR_BROKER_ENGINE","CRTR_PI_BINARY","NODE_OPTIONS"];function H(){const e={...process.env};for(const t of ue)delete e[t];return e}o(H,"sanitizedDaemonEnv");function le(){for(let e=b(q(import.meta.url));;e=b(e)){if($(E(e,"bin","runtime-generation-manifest.mjs")))return e;if(b(e)===e)throw new Error(`cannot locate the crouter generation root from ${import.meta.url}`)}}o(le,"generationRoot");async function K(){const e=le(),t=M(E(e,"bin","runtime-generation-manifest.mjs")).href,{runtimeHome:r}=await import(t);try{J(E(r(),"selected"))}catch(c){if(c.code!=="ENOENT")throw c;return["--conditions","crtr-src","--import",M(V(import.meta.url).resolve("tsx/esm")).href,E(e,"src","daemon","crtrd-cli.ts")]}const n=M(E(e,"bin","runtime-selector.mjs")).href,{tryResolveRuntime:a}=await import(n),s=a("daemon");if(!s.ok)throw new Error(`cannot resolve the selected runtime generation's daemon entry: ${s.error}`);return[s.runtime.entryPath]}o(K,"resolveDaemonLaunch");function de(e,t){return e.includes(` --canvas-home ${t} `)||e.endsWith(` --canvas-home ${t}`)}o(de,"daemonCommandDeclaresCanvasHome");function fe(){const e=ee();if(e===null)return[];const t=x(),r=[];for(const{pid:n,command:a}of e)a.includes("daemon/crtrd")&&de(a,t)&&n!==process.pid&&r.push(n);return r}o(fe,"findDaemonPids");function Ke(e){const t=fe().filter(n=>n!==e);for(const n of t)try{process.kill(n,"SIGTERM")}catch{}if(t.length===0)return t;const r=Date.now()+2e3;for(;Date.now()<r&&t.some(n=>h(n));)X("sleep",["0.1"]);for(const n of t)if(h(n))try{process.kill(n,"SIGKILL")}catch{}return t}o(Ke,"sweepStrayDaemons");async function Ge(){const e=process.execve;if(e===void 0)throw new Error(`this Node.js ${process.version} runtime cannot replace PID 1 with the successor daemon`);const t=U(),r=await K();return e(t,[t,...r,"--canvas-home",x()],H())}o(Ge,"execDaemon");const G=2e4,O=10,A=1e3;async function N(e=A){const t=Math.min(e,A);return ce({autostart:!1,timeoutMs:t}).probeHealthz(t)}o(N,"probeDaemonHealth");function pe(e){const t=e instanceof Error?e.message:String(e);if(typeof e!="object"||e===null)return t;const r=e.code,n=e.cause,a=typeof r=="string"?r:n?.code;return typeof a=="string"&&!t.includes(a)?`${t}: ${a}`:t}o(pe,"probeErrorMessage");async function j(e=A){const t=await N(e);if(t.startup_blocked!==void 0){const r=g()??process.pid;throw new S(r,t.startup_blocked)}if(!t.ok)throw new Error("crtrd health check reported not ready")}o(j,"probeDaemonServing");async function je(){try{return await j(),!0}catch{return!1}}o(je,"isDaemonServing");function P(e){return new Promise(t=>{setTimeout(t,e)})}o(P,"sleepMs");class I extends Error{static{o(this,"DaemonExitedBeforeReadyError")}stderrTail;constructor(t,r){super(r===""?t:`${t}
2
- ${r}`),this.name="DaemonExitedBeforeReadyError",this.stderrTail=r}}class S extends Error{static{o(this,"DaemonStartupBlockedError")}constructor(t,r){super(`daemon ${t} is running but normal startup is blocked: ${r}`),this.name="DaemonStartupBlockedError"}}class k extends Error{static{o(this,"DaemonNotPreparedError")}constructor(t,r){super(r===void 0?`daemon ${t} is serving but did not report the prepared startup phase`:`daemon ${t} reported startup phase ${r}, not prepared`),this.name="DaemonNotPreparedError"}}async function me(e,t=W,r={},n="ready"){const a=r.readPidfile??g,s=r.isPidAlive??h,c=r.childExited,u=r.isDaemonServing,p=r.probeDaemonServing??(async w=>{if(u!==void 0){if(await u())return;throw new Error("crtrd health check reported not ready")}await j(w)}),D=r.probeDaemonHealth??N,y=r.sleepMs??P,i=r.now??Date.now;let f=null,l=null;return await se({windowMs:t,pollIntervalMs:O,now:i,sleep:y,retry:o(w=>w!==l,"retry"),probe:o(async w=>{const m=a();if(m===null||!s(m)){const d=c?.();throw d!=null&&(d.signal!==null?l=new I(`daemon ${e} exited before becoming ready (by ${d.signal})`,r.childStderrTail?.()??""):d.code!==0&&(l=new I(`daemon ${e} exited before becoming ready (with exit code ${d.code??"?"})`,r.childStderrTail?.()??"")),l!==null)?l:new Error(`daemon ${e} has no live pidfile owner`)}if(n==="ready"){try{await p(w)}catch(d){throw d instanceof S&&(l=d),d}f=m;return}const _=await D(w);if(_.startup_phase==="prepared"){f=m;return}throw _.startup_phase==="blocked"?(l=new S(m,_.startup_blocked??"on-disk migration is blocked"),l):_.startup_phase==="ready"?(l=new k(m,_.startup_phase),l):new Error(`daemon ${m} is ${_.startup_phase??"not reporting a startup phase"}; waiting for prepared`)},"probe")}),f===e?null:f}o(me,"verifyDaemonStartup");async function he(e,t=G,r={}){const n=r.readPidfile??g,a=r.isPidAlive??h,s=r.sleepMs??P,c=r.now??Date.now,u=c()+t;for(;c()<=u;){if(!a(e)&&n()!==e)return;await s(O)}throw new Error(`daemon ${e} did not exit and release its pidfile within ${t}ms`)}o(he,"waitForDaemonExit");const we=2e3,_e=5e3;async function Ee(e,t=G,r={}){const n=r.isPidAlive??h,a=r.sleepMs??P,s=r.now??Date.now;try{process.kill(e,"SIGTERM")}catch{}try{return await he(e,t,r),"terminated"}catch{}try{process.kill(e,"SIGKILL")}catch{}const c=s()+we;for(;s()<=c;){if(!n(e))return"killed";await a(O)}throw new Error(`daemon ${e} survived both SIGTERM and SIGKILL`)}o(Ee,"stopDaemonProcess");function De(e,t,r=2048){let n;try{n=C(e,"r");const{size:a}=B(n),s=Math.max(t,a-r),c=a-s;if(c<=0)return"";const u=Buffer.alloc(c);return Z(n,u,0,c,s),u.toString("utf8").trim()}catch{return""}finally{if(n!==void 0)try{F(n)}catch{}}}o(De,"readLogTailFrom");async function ye(e={}){if(process.platform==="linux"&&$("/run/crtr/launcher.sock")&&!h(g()??-1))throw new Error("crtrd is managed by the root launcher; it cannot self-daemonize");const t=e.targetPhase??"ready",r=g();if(r!==null&&h(r))try{const i=await N();if(t==="prepared"){if(i.startup_phase==="prepared")return{started:!1,existing_pid:r,startup_phase:i.startup_phase};throw new k(r,i.startup_phase)}return i.ok?{started:!1,existing_pid:r,...i.startup_phase===void 0?{}:{startup_phase:i.startup_phase}}:{started:!1,existing_pid:r,running:!0,serving:!1,probe_error:i.startup_blocked??"crtrd health check reported not ready",...i.startup_phase===void 0?{}:{startup_phase:i.startup_phase}}}catch(i){if(i instanceof k)throw i;return{started:!1,existing_pid:r,running:!0,serving:!1,probe_error:pe(i)}}Q(x(),{recursive:!0});const n=await K(),a=E(x(),"crtrd.err"),s=C(a,"a"),c=(()=>{try{return B(s).size}catch{return 0}})();re()===void 0&&te();const u=Y(U(),[...n,"--canvas-home",x(),...t==="prepared"?["--prepare"]:[]],{detached:!0,stdio:["ignore",s,s],env:H()});F(s);const p=u.pid;if(p===void 0)throw new Error("daemon spawn did not return a pid");let D=null;u.once("exit",(i,f)=>{D={code:i,signal:f}}),u.unref();let y;try{y=await me(p,W,{childExited:o(()=>D,"childExited"),childStderrTail:o(()=>De(a,c),"childStderrTail")},t)}catch(i){const f=oe(ae())===p;if(D===null&&!f&&!(i instanceof S))try{await Ee(p,_e)}catch{}throw i}return y!==null?{started:!1,existing_pid:y,startup_phase:t}:{started:!0,pid:p,startup_phase:t}}o(ye,"spawnDaemon");function xe(){return ie()||process.argv.includes("--no-autostart")}o(xe,"autostartDisabled");const ge=5e3,Re=6e4;let L=!1,R,v=0,T=0;function Se(e){return Math.min(ge*2**(e-1),Re)}o(Se,"autostartBackoffMs");function ve(){R=void 0,v=0,T=0}o(ve,"resetDaemonAutostart");function ze(){return R}o(ze,"daemonAutostartStartupFailure");function Ye(e={}){const t=e.isDaemonRunning??ne,r=e.spawnDaemon??ye,n=e.now??Date.now;if(!xe()&&!L&&!(n()<T)){if(t()){ve();return}R=void 0,L=!0,r().then(()=>{R=void 0,v=0,T=0},a=>{a instanceof I&&(R=a.message),v+=1,T=n()+Se(v)}).finally(()=>{L=!1})}}o(Ye,"ensureDaemon");export{ue as DAEMON_ENV_STRIP_KEYS,I as DaemonExitedBeforeReadyError,k as DaemonNotPreparedError,S as DaemonStartupBlockedError,xe as autostartDisabled,ze as daemonAutostartStartupFailure,de as daemonCommandDeclaresCanvasHome,Ye as ensureDaemon,Ge as execDaemon,fe as findDaemonPids,je as isDaemonServing,N as probeDaemonHealth,j as probeDaemonServing,pe as probeErrorMessage,ve as resetDaemonAutostart,H as sanitizedDaemonEnv,ye as spawnDaemon,Ee as stopDaemonProcess,Ke as sweepStrayDaemons,me as verifyDaemonStartup,he as waitForDaemonExit};
1
+ var Y=Object.defineProperty;var o=(e,t)=>Y(e,"name",{value:t,configurable:!0});import{spawn as J,execFileSync as X}from"node:child_process";import{createRequire as V}from"node:module";import{dirname as b,join as _}from"node:path";import{fileURLToPath as q,pathToFileURL as k}from"node:url";import{existsSync as $,lstatSync as Q,mkdirSync as Z,openSync as C,closeSync as F,fstatSync as W,readSync as ee}from"node:fs";import{crtrHome as x}from"../core/canvas/paths.js";import{listProcessTable as te}from"../core/canvas/pid.js";import{bindDaemonEventSource as re,eventSource as ne}from"../core/events/source.js";import{hostExecPath as B}from"../core/runtime/branded-host.js";import{isDaemonRunning as oe,readPidfile as E,isPidAlive as f}from"./pidfile.js";import{DAEMON_STARTUP_WINDOW_MS as H}from"./startup-policy.js";import{exclusiveLockOwnerPid as ae}from"../core/exclusive-lock.js";import{onDiskMigrationLockPath as ie}from"../core/canvas/paths.js";import{envNoDaemonAutostart as se}from"../shared/env.js";import{awaitJoinLockRelease as ce,joinInProgress as ue,joinInProgressError as le,joinLockHolder as de}from"../core/directory/join-lock.js";import{waitForDaemonAvailability as fe}from"../api/index.js";import{localClient as pe}from"../api/node-transport.js";const me=["CRTR_NODE_ID","CRTR_KIND","CRTR_MODE","CRTR_LIFECYCLE","CRTR_NODE_CWD","CRTR_CONTEXT_DIR","CRTR_CYCLES","CRTR_PROFILE_ID","CRTR_PARENT_NODE_ID","CRTR_FRONT_DOOR","TMUX","TMUX_PANE","CRTR_BROKER_ENGINE","CRTR_PI_BINARY","NODE_OPTIONS"];function U(){const e={...process.env};for(const t of me)delete e[t];return e}o(U,"sanitizedDaemonEnv");function he(){for(let e=b(q(import.meta.url));;e=b(e)){if($(_(e,"bin","runtime-generation-manifest.mjs")))return e;if(b(e)===e)throw new Error(`cannot locate the crouter generation root from ${import.meta.url}`)}}o(he,"generationRoot");async function j(){const e=he(),t=k(_(e,"bin","runtime-generation-manifest.mjs")).href,{runtimeHome:r}=await import(t);try{Q(_(r(),"selected"))}catch(s){if(s.code!=="ENOENT")throw s;return["--conditions","crtr-src","--import",k(V(import.meta.url).resolve("tsx/esm")).href,_(e,"src","daemon","crtrd-cli.ts")]}const n=k(_(e,"bin","runtime-selector.mjs")).href,{tryResolveRuntime:a}=await import(n),c=a("daemon");if(!c.ok)throw new Error(`cannot resolve the selected runtime generation's daemon entry: ${c.error}`);return[c.runtime.entryPath]}o(j,"resolveDaemonLaunch");function we(e,t){return e.includes(` --canvas-home ${t} `)||e.endsWith(` --canvas-home ${t}`)}o(we,"daemonCommandDeclaresCanvasHome");function _e(){const e=te();if(e===null)return[];const t=x(),r=[];for(const{pid:n,command:a}of e)a.includes("daemon/crtrd")&&we(a,t)&&n!==process.pid&&r.push(n);return r}o(_e,"findDaemonPids");function Ee(e){const t=_e().filter(n=>n!==e);for(const n of t)try{process.kill(n,"SIGTERM")}catch{}if(t.length===0)return t;const r=Date.now()+2e3;for(;Date.now()<r&&t.some(n=>f(n));)X("sleep",["0.1"]);for(const n of t)if(f(n))try{process.kill(n,"SIGKILL")}catch{}return t}o(Ee,"sweepStrayDaemons");async function Ve(){const e=process.execve;if(e===void 0)throw new Error(`this Node.js ${process.version} runtime cannot replace PID 1 with the successor daemon`);const t=B(),r=await j();return e(t,[t,...r,"--canvas-home",x()],U())}o(Ve,"execDaemon");const De=3*6e4,K=2e4,M=10,O=1e3;async function I(e=O){const t=Math.min(e,O);return pe({autostart:!1,timeoutMs:t}).probeHealthz(t)}o(I,"probeDaemonHealth");function ye(e){const t=e instanceof Error?e.message:String(e);if(typeof e!="object"||e===null)return t;const r=e.code,n=e.cause,a=typeof r=="string"?r:n?.code;return typeof a=="string"&&!t.includes(a)?`${t}: ${a}`:t}o(ye,"probeErrorMessage");async function G(e=O){const t=await I(e);if(t.startup_blocked!==void 0){const r=E()??process.pid;throw new S(r,t.startup_blocked)}if(!t.ok)throw new Error("crtrd health check reported not ready")}o(G,"probeDaemonServing");async function qe(){try{return await G(),!0}catch{return!1}}o(qe,"isDaemonServing");function A(e){return new Promise(t=>{setTimeout(t,e)})}o(A,"sleepMs");class N extends Error{static{o(this,"DaemonExitedBeforeReadyError")}stderrTail;constructor(t,r){super(r===""?t:`${t}
2
+ ${r}`),this.name="DaemonExitedBeforeReadyError",this.stderrTail=r}}class S extends Error{static{o(this,"DaemonStartupBlockedError")}constructor(t,r){super(`daemon ${t} is running but normal startup is blocked: ${r}`),this.name="DaemonStartupBlockedError"}}class P extends Error{static{o(this,"DaemonNotPreparedError")}constructor(t,r){super(r===void 0?`daemon ${t} is serving but did not report the prepared startup phase`:`daemon ${t} reported startup phase ${r}, not prepared`),this.name="DaemonNotPreparedError"}}async function ge(e,t=H,r={},n="ready"){const a=r.readPidfile??E,c=r.isPidAlive??f,s=r.childExited,l=r.isDaemonServing,D=r.probeDaemonServing??(async h=>{if(l!==void 0){if(await l())return;throw new Error("crtrd health check reported not ready")}await G(h)}),p=r.probeDaemonHealth??I,y=r.sleepMs??A,g=r.now??Date.now;let i=null,u=null;return await fe({windowMs:t,pollIntervalMs:M,now:g,sleep:y,retry:o(h=>h!==u,"retry"),probe:o(async h=>{const m=a();if(m===null||!c(m)){const d=s?.();throw d!=null&&(d.signal!==null?u=new N(`daemon ${e} exited before becoming ready (by ${d.signal})`,r.childStderrTail?.()??""):d.code!==0&&(u=new N(`daemon ${e} exited before becoming ready (with exit code ${d.code??"?"})`,r.childStderrTail?.()??"")),u!==null)?u:new Error(`daemon ${e} has no live pidfile owner`)}if(n==="ready"){try{await D(h)}catch(d){throw d instanceof S&&(u=d),d}i=m;return}const w=await p(h);if(w.startup_phase==="prepared"){i=m;return}throw w.startup_phase==="blocked"?(u=new S(m,w.startup_blocked??"on-disk migration is blocked"),u):w.startup_phase==="ready"?(u=new P(m,w.startup_phase),u):new Error(`daemon ${m} is ${w.startup_phase??"not reporting a startup phase"}; waiting for prepared`)},"probe")}),i===e?null:i}o(ge,"verifyDaemonStartup");async function xe(e,t=K,r={}){const n=r.readPidfile??E,a=r.isPidAlive??f,c=r.sleepMs??A,s=r.now??Date.now,l=s()+t;for(;s()<=l;){if(!a(e)&&n()!==e)return;await c(M)}throw new Error(`daemon ${e} did not exit and release its pidfile within ${t}ms`)}o(xe,"waitForDaemonExit");const Re=2e3,Se=5e3;async function z(e,t=K,r={}){const n=r.isPidAlive??f,a=r.sleepMs??A,c=r.now??Date.now;try{process.kill(e,"SIGTERM")}catch{}try{return await xe(e,t,r),"terminated"}catch{}try{process.kill(e,"SIGKILL")}catch{}const s=c()+Re;for(;c()<=s;){if(!n(e))return"killed";await a(M)}throw new Error(`daemon ${e} survived both SIGTERM and SIGKILL`)}o(z,"stopDaemonProcess");async function Qe(){const e=E(),t=e!==null&&f(e),r=t&&await z(e)==="killed",n=Ee(null);return{stopped:t||n.length>0,...t?{pid:e}:{},strays_killed:n,force_killed:r}}o(Qe,"stopAllDaemons");function Te(e,t,r=2048){let n;try{n=C(e,"r");const{size:a}=W(n),c=Math.max(t,a-r),s=a-c;if(s<=0)return"";const l=Buffer.alloc(s);return ee(n,l,0,s,c),l.toString("utf8").trim()}catch{return""}finally{if(n!==void 0)try{F(n)}catch{}}}o(Te,"readLogTailFrom");async function ve(e={}){if(process.platform==="linux"&&$("/run/crtr/launcher.sock")&&!f(E()??-1))throw new Error("crtrd is managed by the root launcher; it cannot self-daemonize");const t=e.targetPhase??"ready",r=E();if(r!==null&&f(r))try{const i=await I();if(t==="prepared"){if(i.startup_phase==="prepared")return{started:!1,existing_pid:r,startup_phase:i.startup_phase};throw new P(r,i.startup_phase)}return i.ok?{started:!1,existing_pid:r,...i.startup_phase===void 0?{}:{startup_phase:i.startup_phase}}:{started:!1,existing_pid:r,running:!0,serving:!1,probe_error:i.startup_blocked??"crtrd health check reported not ready",...i.startup_phase===void 0?{}:{startup_phase:i.startup_phase}}}catch(i){if(i instanceof P)throw i;return{started:!1,existing_pid:r,running:!0,serving:!1,probe_error:ye(i)}}Z(x(),{recursive:!0});const n=await j();e.waitForJoinLock===!0&&await ce(De);const a=de();if(a!==null&&a!==process.pid&&e.ignoreJoinLock!==!0)throw le(a);const c=_(x(),"crtrd.err"),s=C(c,"a"),l=(()=>{try{return W(s).size}catch{return 0}})();ne()===void 0&&re();const D=J(B(),[...n,"--canvas-home",x(),...t==="prepared"?["--prepare"]:[]],{detached:!0,stdio:["ignore",s,s],env:U()});F(s);const p=D.pid;if(p===void 0)throw new Error("daemon spawn did not return a pid");let y=null;D.once("exit",(i,u)=>{y={code:i,signal:u}}),D.unref();let g;try{g=await ge(p,H,{childExited:o(()=>y,"childExited"),childStderrTail:o(()=>Te(c,l),"childStderrTail")},t)}catch(i){const u=ae(ie())===p;if(y===null&&!u&&!(i instanceof S))try{await z(p,Se)}catch{}throw i}return g!==null?{started:!1,existing_pid:g,startup_phase:t}:{started:!0,pid:p,startup_phase:t}}o(ve,"spawnDaemon");function be(){return se()||process.argv.includes("--no-autostart")}o(be,"autostartDisabled");const ke=5e3,Me=6e4;let L=!1,R,T=0,v=0;function Oe(e){return Math.min(ke*2**(e-1),Me)}o(Oe,"autostartBackoffMs");function Ie(){R=void 0,T=0,v=0}o(Ie,"resetDaemonAutostart");function Ze(){return R}o(Ze,"daemonAutostartStartupFailure");function et(e={}){const t=e.isDaemonRunning??oe,r=e.spawnDaemon??ve,n=e.now??Date.now;if(!be()&&!ue()&&!L&&!(n()<v)){if(t()){Ie();return}R=void 0,L=!0,r().then(()=>{R=void 0,T=0,v=0},a=>{a instanceof N&&(R=a.message),T+=1,v=n()+Oe(T)}).finally(()=>{L=!1})}}o(et,"ensureDaemon");export{me as DAEMON_ENV_STRIP_KEYS,N as DaemonExitedBeforeReadyError,P as DaemonNotPreparedError,S as DaemonStartupBlockedError,be as autostartDisabled,Ze as daemonAutostartStartupFailure,we as daemonCommandDeclaresCanvasHome,et as ensureDaemon,Ve as execDaemon,_e as findDaemonPids,qe as isDaemonServing,I as probeDaemonHealth,G as probeDaemonServing,ye as probeErrorMessage,Ie as resetDaemonAutostart,U as sanitizedDaemonEnv,ve as spawnDaemon,Qe as stopAllDaemons,z as stopDaemonProcess,Ee as sweepStrayDaemons,ge as verifyDaemonStartup,xe as waitForDaemonExit};
@@ -21,19 +21,18 @@ The example app texts you, so it asks for the `sms` provider's `sms:send` scope.
21
21
 
22
22
  Open `https://account.northlight.ai/signup` and create your account. Signup texts a confirmation code to your phone, and that text is sent over iMessage, so use a phone number that has iMessage. A number that cannot receive iMessage gets "We couldn't send the text", and retrying does not help. One phone number belongs to one account.
23
23
 
24
- Install the CLI (`npm i -g @north-light/cli`). Joining needs `crtr` 0.3.422 or later; `crtr --version` shows yours.
24
+ Install the CLI (`npm i -g @north-light/cli`). Joining needs `crtr` 0.3.434 or later (older versions need `crtr sys daemon stop` first and fail if a viewer is open); `crtr --version` shows yours.
25
25
 
26
26
  Then join your laptop to the account:
27
27
 
28
28
  1. Open `https://account.northlight.ai/account/machines` and choose **Create join token**. A token needs a sign-in within the last 10 minutes, so the page asks you to sign in again if yours is older.
29
- 2. The page shows two commands, with the token filled in. On your laptop, run them: first `crtr sys daemon stop` (a running daemon makes the join answer `daemon_running`), then `crtr sys join --directory https://account.northlight.ai --token <token>`. Add `--name <label>` to name the machine on the page. The token works once and expires at the time the page shows; the page shows it only once.
29
+ 2. The page shows the join command with the token filled in. On your laptop, run it from a plain terminal: `crtr sys join --directory https://account.northlight.ai --token <token>`. Leave your crtr windows open: join stops the daemon itself, holds it down while it writes your identity (so an open viewer cannot restart it early), and starts it again on the joined settings. Run it outside any crtr node, because a node's engine stops with the daemon; from inside one, join refuses with `join_inside_node`. Add `--name <label>` to name the machine on the page. The token works once and expires at the time the page shows; the page shows it only once.
30
30
 
31
31
  ```bash
32
- crtr sys daemon stop
33
32
  crtr sys join --directory https://account.northlight.ai --token <token> --name my-laptop
34
33
  ```
35
34
 
36
- It answers with `registered: true`, the machine id (`runtime:<id>`) and your user id.
35
+ It answers with `registered: true`, the machine id (`runtime:<id>`), your user id and `daemon: restarted` (`daemon: not_running` when crtr was not running; the next crtr command starts it). Your open viewers and nodes reconnect on their own.
37
36
  3. Check it: `crtr cloud apps mine` lists the apps you manage. Before you have joined, it answers `not_joined` and prints these same steps.
38
37
 
39
38
  Things to know before you join:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.435",
3
+ "version": "0.3.436",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -137,7 +137,7 @@
137
137
  },
138
138
  "license": "UNLICENSED",
139
139
  "dependencies": {
140
- "@north-light/identity": "0.3.435",
140
+ "@north-light/identity": "0.3.436",
141
141
  "@earendil-works/pi-agent-core": "1.1.0",
142
142
  "@earendil-works/pi-ai": "1.1.0",
143
143
  "@earendil-works/pi-coding-agent": "1.1.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/identity",
3
- "version": "0.3.435",
3
+ "version": "0.3.436",
4
4
  "description": "Northlight access-token verification and scope rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.435",
3
+ "version": "0.3.436",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/cli",
9
- "version": "0.3.435",
9
+ "version": "0.3.436",
10
10
  "hasInstallScript": true,
11
11
  "license": "UNLICENSED",
12
12
  "workspaces": [
@@ -18,7 +18,7 @@
18
18
  "@earendil-works/pi-ai": "1.1.0",
19
19
  "@earendil-works/pi-coding-agent": "1.1.0",
20
20
  "@earendil-works/pi-tui": "1.1.0",
21
- "@north-light/identity": "0.3.435",
21
+ "@north-light/identity": "0.3.436",
22
22
  "cron-parser": "^5.6.0",
23
23
  "esbuild": "^0.27.7",
24
24
  "jose": "^6.2.12",
@@ -12095,17 +12095,17 @@
12095
12095
  },
12096
12096
  "packages/crouter-api": {
12097
12097
  "name": "@north-light/api",
12098
- "version": "0.3.435",
12098
+ "version": "0.3.436",
12099
12099
  "license": "UNLICENSED"
12100
12100
  },
12101
12101
  "packages/crouter-env-docker": {
12102
12102
  "name": "@north-light/env-docker",
12103
- "version": "0.3.435",
12103
+ "version": "0.3.436",
12104
12104
  "license": "UNLICENSED"
12105
12105
  },
12106
12106
  "packages/crouter-identity": {
12107
12107
  "name": "@north-light/identity",
12108
- "version": "0.3.435",
12108
+ "version": "0.3.436",
12109
12109
  "license": "UNLICENSED",
12110
12110
  "dependencies": {
12111
12111
  "jose": "^6.2.1"
@@ -12113,20 +12113,20 @@
12113
12113
  },
12114
12114
  "packages/crouter-plugin": {
12115
12115
  "name": "@north-light/plugin",
12116
- "version": "0.3.435",
12116
+ "version": "0.3.436",
12117
12117
  "license": "UNLICENSED",
12118
12118
  "dependencies": {
12119
- "@north-light/api": "0.3.435",
12120
- "@north-light/identity": "0.3.435"
12119
+ "@north-light/api": "0.3.436",
12120
+ "@north-light/identity": "0.3.436"
12121
12121
  }
12122
12122
  },
12123
12123
  "packages/crouter-sdk": {
12124
12124
  "name": "@north-light/sdk",
12125
- "version": "0.3.435",
12125
+ "version": "0.3.436",
12126
12126
  "license": "UNLICENSED",
12127
12127
  "dependencies": {
12128
- "@north-light/api": "0.3.435",
12129
- "@north-light/identity": "0.3.435",
12128
+ "@north-light/api": "0.3.436",
12129
+ "@north-light/identity": "0.3.436",
12130
12130
  "jose": "^6.2.1"
12131
12131
  },
12132
12132
  "bin": {