@north-light/cli 0.3.487 → 0.3.489

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.
Files changed (49) hide show
  1. package/dist/api/contract.d.ts +1 -1
  2. package/dist/api/contract.js +1 -1
  3. package/dist/api/index.d.ts +2 -1
  4. package/dist/build-root.js +1 -1
  5. package/dist/builtin-memory/crouter-sdk/release-notes.md +17 -0
  6. package/dist/builtin-memory/crouter-sdk/resources.md +2 -1
  7. package/dist/builtin-memory/internal/memory-loading.md +1 -1
  8. package/dist/builtin-memory/northlight-apps/cli.md +20 -6
  9. package/dist/builtin-memory/northlight-apps/coding-agents.md +9 -7
  10. package/dist/builtin-memory/northlight-apps/guides/manage-your-app.md +1 -0
  11. package/dist/builtin-memory/northlight-apps/quickstart.md +5 -3
  12. package/dist/clients/attach/viewer.js +505 -505
  13. package/dist/commands/doc/shared.js +2 -2
  14. package/dist/commands/profile/project.js +1 -1
  15. package/dist/core/canvas/recovery.d.ts +5 -0
  16. package/dist/core/canvas/recovery.js +2 -2
  17. package/dist/core/command-plugins/endpoint.js +1 -1
  18. package/dist/core/graph/repo-sync/sync.d.ts +4 -3
  19. package/dist/core/graph/repo-sync/sync.js +2 -2
  20. package/dist/core/graph/spaces/repo.d.ts +7 -7
  21. package/dist/core/graph/spaces/repo.js +1 -1
  22. package/dist/core/graph/types.d.ts +3 -2
  23. package/dist/core/human/page-errors.d.ts +4 -0
  24. package/dist/core/human/page-errors.js +1 -1
  25. package/dist/core/human/page.d.ts +10 -3
  26. package/dist/core/human/page.js +1 -1
  27. package/dist/core/peer/ingest.js +1 -1
  28. package/dist/core/peer/stubs.d.ts +4 -0
  29. package/dist/core/peer/stubs.js +1 -1
  30. package/dist/core/peer/version.d.ts +2 -2
  31. package/dist/core/peer/version.js +1 -1
  32. package/dist/core/render.js +1 -2
  33. package/dist/core/runtime/revive.js +2 -2
  34. package/dist/daemon/api/handlers/docs.js +1 -1
  35. package/dist/daemon/api/handlers/human-requests.js +1 -1
  36. package/dist/launcher/accepted-socket.d.ts +11 -0
  37. package/dist/launcher/accepted-socket.js +1 -0
  38. package/dist/launcher/main.js +3 -3
  39. package/dist/launcher/validate.js +1 -1
  40. package/docs/apps/cli.md +20 -6
  41. package/docs/apps/coding-agents.md +9 -7
  42. package/docs/apps/guides/manage-your-app.md +1 -0
  43. package/docs/apps/quickstart.md +5 -3
  44. package/docs/ci-runner.md +2 -1
  45. package/docs/sdk/release-notes.md +17 -0
  46. package/docs/sdk/resources.md +2 -1
  47. package/package.json +3 -3
  48. package/packages/crouter-identity/package.json +1 -1
  49. package/runtime.lock.json +12 -12
@@ -1,3 +1,3 @@
1
- var V=Object.defineProperty;var s=(e,r)=>V(e,"name",{value:r,configurable:!0});import{assertGranteeRunsHere as X}from"./unsandboxed.js";import{existsSync as P}from"node:fs";import{conversationIdFromPath as _,conversationStore as h,ConversationNotFoundError as A}from"../conversation-store/index.js";import{isAbsolute as Y}from"node:path";import{findNodeBySessionFile as q,getNode as g,updateNode as y,beginExecution as Q,recordExecutionPid as Z,abortExecution as b,endLiveExecution as I,unbindSession as O,recordLaunch as ee,setFrozen as re,clearFrozen as ie,fullName as D}from"../canvas/index.js";import{emitEvent as ne}from"../events/emit.js";import{cancelCronsOnWake as oe}from"../canvas/crons.js";import{isParked as te,transition as R}from"./lifecycle.js";import{publishNotice as se}from"./close.js";import{InputError as E}from"../io.js";import{buildPiArgv as m}from"./launch.js";import{buildReviveKickoff as le,drainBearings as ce,hasRetainedReports as ae,readGoal as S,readYieldMessage as ue,RUNTIME_RESTART_CONTINUATION as fe}from"./kickoff.js";import{readRoadmap as T}from"./roadmap.js";import{encodeKickoffOrigin as he,KICKOFF_ORIGIN_ENV as de}from"./stamp/protocol.js";import{headlessBrokerHost as B}from"./host.js";import{boundFleet as z,brokerCapReached as pe,brokerThresholdsForDaemon as U}from"./fleet.js";import{beginBootFaultAttempt as ve,clearFault as we}from"../canvas/recovery.js";import{clearNodeLedger as H,forkNodeLedger as _e,noticeChangedWhileDone as me}from"./ledger.js";import{forkSourceNode as ke,rootOfSpine as ge}from"./nodes.js";import{isReviewCompanionBound as ye}from"../review/companion.js";import{assertProfileActive as be}from"../profiles/manifest.js";import{isPidAlive as Re}from"../canvas/pid.js";import{crtrHome as Ee,jobDir as Se}from"../canvas/paths.js";import{contextDir as xe,ensureNodeDirs as $e,nodeAgentDir as Ne}from"../canvas/node-agent-paths.js";import{discoverLifecycleHookRegistry as Ce}from"../command-hooks/discovery.js";import{invokeLifecycleHooks as Fe}from"../command-hooks/transport/exec-lifecycle.js";function Pe(e,r){const i=e.pi_session_file,n=i?_(i):null;if(n===null)return{};try{const t=h().header(n);return r?{resumeSessionPath:t.path}:{resumeSessionPath:t.path,newCycle:!0}}catch(t){if(t instanceof A)return{};throw t}}s(Pe,"resumeArgs");function K(e){const r=_(e);if(r===null)return!1;try{return h().header(r),!0}catch(i){if(i instanceof A)return!1;throw i}}s(K,"conversationExists");function Ae(e){const r=_(e);if(r===null)return!0;const i=h().header(r).header.cwd;return typeof i!="string"||P(i)}s(Ae,"sessionCwdExists");function Oe(e,r,i){const n=r.pi_session_file;if(!n||!K(n))return{};const t=q(n);return t!==null&&t.node_id!==e&&Re(t.pi_pid)?{}:Ae(n)?Pe(r,i):{}}s(Oe,"resumableSessionArgs");function De(e){return T(e)===null&&ue(e)===null&&!ae(e)}s(De,"isUnstartedBirth");function ar(e){const r=S(e);if(r!==null&&r.trim()!=="")return!0;const i=T(e);return i!==null&&i.trim()!==""}s(ar,"hasFreshGroundState");async function ur(e,r){const i=await L(e,r,!0);if(i.parked)throw new Error(`reviveNode: internal parked decision for activating revive ${e}`);return i.result}s(ur,"reviveNode");async function fr(e,r,i){return L(e,{...r,viewerAttach:!0},i)}s(fr,"decideAttachRevive");async function L(e,r,i){let n=g(e);if(n===null)throw new Error(`reviveNode: unknown node ${e}`);if(n=g(e)??n,n.run_id&&g(n.run_id)?.deleting_at)throw new Error("run is deleting");if(n.final_report!==null)throw new Error(`reviveNode: refusing to revive ${e} \u2014 its finalization latch is set (final_report=${n.final_report}). A final landed between the caller's reopen-gate check and this revive; re-run with --reopen if this retask is still wanted.`);be(n.profile_id),X(n.grantee);const t=z();if(t.has(e))return R(e,n.intent==="refresh"?"refresh-launch":"revive"),{parked:!1,result:{window:null,session:n.tmux_session??null,resumed:!1,outcome:"already-live"}};if(t.isClaimed(e))return{parked:!1,result:{window:null,session:n.tmux_session??null,resumed:!1,outcome:"already-live"}};if(!i&&n.lifecycle==="resident"&&te(n))return{parked:!0};if(!t.reserveSlot(e)){if(r.capacity==="refuse")throw pe(U().automaticReviveCap,t.size());return n.frozen_at==null&&(re(e,new Date().toISOString()),I(e,{reason:"frozen"}),ne({level:"info",event:"broker.frozen",node_id:e,fields:{live_count:t.size(),hard_cap:U().automaticReviveCap}})),{parked:!1,result:{window:null,session:n.tmux_session??null,resumed:!1,outcome:"frozen"}}}try{return{parked:!1,result:await Te(e,n,r)}}finally{t.releaseReservation(e)}}s(L,"decideRevive");async function Te(e,r,i){if(!P(r.cwd)){const d=r.fork_from!=null?`Restore ${r.cwd}, then revive the fork normally with \`crtr node lifecycle revive ${e}\`.`:r.managed_worktree?.state==="open"?"Restore or reconcile the recorded checkout, then use the managed-worktree close or abandon recovery.":`Recreate ${r.cwd}, or repair it with \`crtr node config --cwd <path> --node ${e}\`; durable node work resumes in a fresh conversation. Then retry \`crtr node lifecycle revive ${e}\`.`;throw new E({error:"cwd_missing",message:`${e} cannot be revived \u2014 its recorded cwd no longer exists: ${r.cwd}`,next:d})}r.host_kind!=="broker"&&(y(e,{host_kind:"broker"}),r.host_kind="broker"),r.review_binding!=null&&!ye(e)&&(r.pi_session_id!=null||r.pi_session_file!=null)&&(O(e),r.pi_session_id=null,r.pi_session_file=null);const t=r.fork_from!=null&&r.pi_session_id==null,c=r.fork_source_file;if(t&&(typeof c!="string"||!Y(c)))throw new E({error:"fork_source_invalid",message:`${e} cannot be revived \u2014 its resolved fork source file is missing or invalid.`,next:`Restore the node's fork source metadata, then retry \`crtr node lifecycle revive ${e}\`.`});const f=t&&typeof c=="string";if(f&&!K(c))throw new E({error:"fork_from_missing",message:`${e} cannot be revived \u2014 its fork source no longer exists: ${c}`,next:`Restore ${c}, or update the node's fork source, then retry \`crtr node lifecycle revive ${e}\`.`});const a=Q(e,{daemonEpoch:z().epoch()});try{r.cycles=(r.cycles??0)+1,y(e,{cycles:r.cycles});const G=r.intent==="refresh"||r.cycle_pending===!0?!1:i.resume,p=f?{}:Oe(e,r,G),v=p.resumeSessionPath!==void 0,x=v&&p.newCycle===!0,k=v&&!x,$=!v&&(i.wakeReason===void 0||i.wakeReason==="runtime-restart-abort")&&De(e),M=f||$,N=r.cycle_pending===!0||x;y(e,{cycle_pending:N}),r.cycle_pending=N,!f&&!v&&(r.pi_session_id!=null||r.pi_session_file!=null)&&(O(e),r.pi_session_id=null,r.pi_session_file=null);let l,u=null;const C=s(()=>({type:"runtime",kind:"kickoff",...r.spawned_by!=null&&r.spawned_by!==""?{spawnedBy:r.spawned_by}:{}}),"birthOrigin");if(f){const o=S(e),F=_(c);if(F===null)throw new Error(`invalid fork source ${c}`);const J=h().header(h().fork(F,e)).path;l=m(r,{resumeSessionPath:J,...o!=null&&o!==""?{prompt:o}:{}}),o!=null&&o!==""&&(u=C()),_e(e,ke(c)?.node_id??null)}else if(k)l=m(r,{...p,...i.wakeReason==="runtime-restart-abort"?{prompt:fe}:{}}),i.wakeReason==="runtime-restart-abort"&&(u={type:"runtime",kind:"restart-continuation"});else if($){const o=S(e)?.trim();l=m(r,o!==void 0&&o!==""?{prompt:o}:{}),o!==void 0&&o!==""&&(u=C()),H(e)}else{const o=ce(r,a);l=m(r,{...p,prompt:le(r,o,i.wakeReason==="runtime-restart-abort"?void 0:i.wakeReason)}),r.run_id&&o.unreadMessageIds?.length&&(l.env.CRTR_KICKOFF_MESSAGE_IDS=JSON.stringify(o.unreadMessageIds)),u=i.wakeReason!==void 0&&i.wakeReason!=="runtime-restart-abort"?{type:"runtime",kind:"wake",wake:i.wakeReason}:{type:"runtime",kind:"revive",cycle:r.cycles??0},H(e)}l.env={...l.env,CRTR_SUBTREE:ge(e)},u!==null&&(l.env[de]=he(u)),r.status==="done"&&r.last_settled_at!=null&&me(e,r.last_settled_at),R(e,r.intent==="refresh"?"refresh-launch":"revive"),we(e,{preserveEpisode:!0});const j=ve(e);i.recovery!==!0&&i.viewerAttach!==!0&&oe(e),$e(e,r.grantee);const W=Ce(r.cwd,r.profile_id).plans.get("node:start")??[];await Fe(W,{node:{id:e,name:r.name,kind:r.kind,mode:r.mode,lifecycle:r.lifecycle,cwd:r.cwd,nodeDir:Ne(e),contextDir:xe(e),jobDir:Se(e)},runtime:{isBirth:M,canvasHome:Ee(),profile:r.profile_id??null}}),l.env={...l.env,CRTR_EXECUTION_ID:a};let w;try{if(w=await B.launch(e,l,{cwd:r.cwd,name:D(r),resuming:k}),w.pid===null)throw new Error(`broker host returned no pid for ${e}`)}catch(o){try{R(e,"crash",{reason:"launch_failed",outcome:{detail:{schema:"crtr.node-outcome-detail/v1",message:o instanceof Error?o.message:String(o)}}}),se(e,"notice.crash",`Child crashed \u2014 ${D(r)} (${e}) failed to relaunch and is now dead: ${o.message}
1
+ var Y=Object.defineProperty;var s=(e,r)=>Y(e,"name",{value:r,configurable:!0});import{assertGranteeRunsHere as q}from"./unsandboxed.js";import{existsSync as O}from"node:fs";import{conversationIdFromPath as p,conversationStore as h,ConversationNotFoundError as B}from"../conversation-store/index.js";import{isAbsolute as Q}from"node:path";import{findNodeBySessionFile as Z,getNode as R,updateNode as E,beginExecution as I,recordExecutionPid as ee,abortExecution as S,endLiveExecution as re,unbindSession as D,recordLaunch as ie,setFrozen as ne,clearFrozen as oe,fullName as T}from"../canvas/index.js";import{emitEvent as te}from"../events/emit.js";import{cancelCronsOnWake as se}from"../canvas/crons.js";import{isParked as le,transition as x}from"./lifecycle.js";import{publishNotice as ce}from"./close.js";import{InputError as $}from"../io.js";import{buildPiArgv as v}from"./launch.js";import{buildReviveKickoff as ae,drainBearings as ue,hasRetainedReports as fe,readGoal as g,readYieldMessage as he,RUNTIME_RESTART_CONTINUATION as de}from"./kickoff.js";import{readRoadmap as z}from"./roadmap.js";import{encodeKickoffOrigin as pe,KICKOFF_ORIGIN_ENV as ve}from"./stamp/protocol.js";import{headlessBrokerHost as H}from"./host.js";import{boundFleet as U,brokerCapReached as we,brokerThresholdsForDaemon as K}from"./fleet.js";import{beginBootFaultAttempt as me,clearFault as _e,hasAdmittedTurn as ke}from"../canvas/recovery.js";import{clearNodeLedger as G,forkNodeLedger as ge,noticeChangedWhileDone as ye}from"./ledger.js";import{forkSourceNode as be,rootOfSpine as Re}from"./nodes.js";import{isReviewCompanionBound as Ee}from"../review/companion.js";import{assertProfileActive as Se}from"../profiles/manifest.js";import{isPidAlive as xe}from"../canvas/pid.js";import{crtrHome as $e,jobDir as Ce}from"../canvas/paths.js";import{contextDir as Ne,ensureNodeDirs as Pe,nodeAgentDir as Ae}from"../canvas/node-agent-paths.js";import{discoverLifecycleHookRegistry as Fe}from"../command-hooks/discovery.js";import{invokeLifecycleHooks as Oe}from"../command-hooks/transport/exec-lifecycle.js";function Be(e,r){const i=e.pi_session_file,n=i?p(i):null;if(n===null)return{};try{const t=h().header(n);return r?{resumeSessionPath:t.path}:{resumeSessionPath:t.path,newCycle:!0}}catch(t){if(t instanceof B)return{};throw t}}s(Be,"resumeArgs");function L(e){const r=p(e);if(r===null)return!1;try{return h().header(r),!0}catch(i){if(i instanceof B)return!1;throw i}}s(L,"conversationExists");function De(e){const r=p(e);return r===null?!0:h().read(r).entries.some(i=>i.type==="message")}s(De,"sessionHasConversation");function Te(e){const r=p(e);if(r===null)return!0;const i=h().header(r).header.cwd;return typeof i!="string"||O(i)}s(Te,"sessionCwdExists");function ze(e,r,i){const n=r.pi_session_file;if(!n||!L(n))return{};const t=Z(n);return t!==null&&t.node_id!==e&&xe(t.pi_pid)?{}:Te(n)?Be(r,i):{}}s(ze,"resumableSessionArgs");function He(e){return z(e)===null&&he(e)===null&&!fe(e)}s(He,"isUnstartedBirth");function pr(e){const r=g(e);if(r!==null&&r.trim()!=="")return!0;const i=z(e);return i!==null&&i.trim()!==""}s(pr,"hasFreshGroundState");async function vr(e,r){const i=await M(e,r,!0);if(i.parked)throw new Error(`reviveNode: internal parked decision for activating revive ${e}`);return i.result}s(vr,"reviveNode");async function wr(e,r,i){return M(e,{...r,viewerAttach:!0},i)}s(wr,"decideAttachRevive");async function M(e,r,i){let n=R(e);if(n===null)throw new Error(`reviveNode: unknown node ${e}`);if(n=R(e)??n,n.run_id&&R(n.run_id)?.deleting_at)throw new Error("run is deleting");if(n.final_report!==null)throw new Error(`reviveNode: refusing to revive ${e} \u2014 its finalization latch is set (final_report=${n.final_report}). A final landed between the caller's reopen-gate check and this revive; re-run with --reopen if this retask is still wanted.`);Se(n.profile_id),q(n.grantee);const t=U();if(t.has(e))return x(e,n.intent==="refresh"?"refresh-launch":"revive"),{parked:!1,result:{window:null,session:n.tmux_session??null,resumed:!1,outcome:"already-live"}};if(t.isClaimed(e))return{parked:!1,result:{window:null,session:n.tmux_session??null,resumed:!1,outcome:"already-live"}};if(!i&&n.lifecycle==="resident"&&le(n))return{parked:!0};if(!t.reserveSlot(e)){if(r.capacity==="refuse")throw we(K().automaticReviveCap,t.size());return n.frozen_at==null&&(ne(e,new Date().toISOString()),re(e,{reason:"frozen"}),te({level:"info",event:"broker.frozen",node_id:e,fields:{live_count:t.size(),hard_cap:K().automaticReviveCap}})),{parked:!1,result:{window:null,session:n.tmux_session??null,resumed:!1,outcome:"frozen"}}}try{return{parked:!1,result:await Ue(e,n,r)}}finally{t.releaseReservation(e)}}s(M,"decideRevive");async function Ue(e,r,i){if(!O(r.cwd)){const w=r.fork_from!=null?`Restore ${r.cwd}, then revive the fork normally with \`crtr node lifecycle revive ${e}\`.`:r.managed_worktree?.state==="open"?"Restore or reconcile the recorded checkout, then use the managed-worktree close or abandon recovery.":`Recreate ${r.cwd}, or repair it with \`crtr node config --cwd <path> --node ${e}\`; durable node work resumes in a fresh conversation. Then retry \`crtr node lifecycle revive ${e}\`.`;throw new $({error:"cwd_missing",message:`${e} cannot be revived \u2014 its recorded cwd no longer exists: ${r.cwd}`,next:w})}r.host_kind!=="broker"&&(E(e,{host_kind:"broker"}),r.host_kind="broker"),r.review_binding!=null&&!Ee(e)&&(r.pi_session_id!=null||r.pi_session_file!=null)&&(D(e),r.pi_session_id=null,r.pi_session_file=null);const t=r.fork_from!=null&&r.pi_session_id==null,c=r.fork_source_file;if(t&&(typeof c!="string"||!Q(c)))throw new $({error:"fork_source_invalid",message:`${e} cannot be revived \u2014 its resolved fork source file is missing or invalid.`,next:`Restore the node's fork source metadata, then retry \`crtr node lifecycle revive ${e}\`.`});const d=t&&typeof c=="string";if(d&&!L(c))throw new $({error:"fork_from_missing",message:`${e} cannot be revived \u2014 its fork source no longer exists: ${c}`,next:`Restore ${c}, or update the node's fork source, then retry \`crtr node lifecycle revive ${e}\`.`});const u=I(e,{daemonEpoch:U().epoch()});try{r.cycles=(r.cycles??0)+1,E(e,{cycles:r.cycles});const j=r.intent==="refresh"||r.cycle_pending===!0?!1:i.resume,f=d?{}:ze(e,r,j),m=f.resumeSessionPath!==void 0,C=m&&f.newCycle===!0,_=m&&!C,N=!m&&(i.wakeReason===void 0||i.wakeReason==="runtime-restart-abort")&&He(e),y=_&&(i.wakeReason===void 0||i.wakeReason==="runtime-restart-abort")&&!ke(e)&&!De(f.resumeSessionPath)?g(e)?.trim():void 0,P=y!==void 0&&y!=="",W=d||N||P,A=r.cycle_pending===!0||C;E(e,{cycle_pending:A}),r.cycle_pending=A,!d&&!m&&(r.pi_session_id!=null||r.pi_session_file!=null)&&(D(e),r.pi_session_id=null,r.pi_session_file=null);let l,a=null;const b=s(()=>({type:"runtime",kind:"kickoff",...r.spawned_by!=null&&r.spawned_by!==""?{spawnedBy:r.spawned_by}:{}}),"birthOrigin");if(d){const o=g(e),F=p(c);if(F===null)throw new Error(`invalid fork source ${c}`);const X=h().header(h().fork(F,e)).path;l=v(r,{resumeSessionPath:X,...o!=null&&o!==""?{prompt:o}:{}}),o!=null&&o!==""&&(a=b()),ge(e,be(c)?.node_id??null)}else if(P)l=v(r,{...f,prompt:y}),a=b();else if(_)l=v(r,{...f,...i.wakeReason==="runtime-restart-abort"?{prompt:de}:{}}),i.wakeReason==="runtime-restart-abort"&&(a={type:"runtime",kind:"restart-continuation"});else if(N){const o=g(e)?.trim();l=v(r,o!==void 0&&o!==""?{prompt:o}:{}),o!==void 0&&o!==""&&(a=b()),G(e)}else{const o=ue(r,u);l=v(r,{...f,prompt:ae(r,o,i.wakeReason==="runtime-restart-abort"?void 0:i.wakeReason)}),r.run_id&&o.unreadMessageIds?.length&&(l.env.CRTR_KICKOFF_MESSAGE_IDS=JSON.stringify(o.unreadMessageIds)),a=i.wakeReason!==void 0&&i.wakeReason!=="runtime-restart-abort"?{type:"runtime",kind:"wake",wake:i.wakeReason}:{type:"runtime",kind:"revive",cycle:r.cycles??0},G(e)}l.env={...l.env,CRTR_SUBTREE:Re(e)},a!==null&&(l.env[ve]=pe(a)),r.status==="done"&&r.last_settled_at!=null&&ye(e,r.last_settled_at),x(e,r.intent==="refresh"?"refresh-launch":"revive"),_e(e,{preserveEpisode:!0});const J=me(e);i.recovery!==!0&&i.viewerAttach!==!0&&se(e),Pe(e,r.grantee);const V=Fe(r.cwd,r.profile_id).plans.get("node:start")??[];await Oe(V,{node:{id:e,name:r.name,kind:r.kind,mode:r.mode,lifecycle:r.lifecycle,cwd:r.cwd,nodeDir:Ae(e),contextDir:Ne(e),jobDir:Ce(e)},runtime:{isBirth:W,canvasHome:$e(),profile:r.profile_id??null}}),l.env={...l.env,CRTR_EXECUTION_ID:u};let k;try{if(k=await H.launch(e,l,{cwd:r.cwd,name:T(r),resuming:_}),k.pid===null)throw new Error(`broker host returned no pid for ${e}`)}catch(o){try{x(e,"crash",{reason:"launch_failed",outcome:{detail:{schema:"crtr.node-outcome-detail/v1",message:o instanceof Error?o.message:String(o)}}}),ce(e,"notice.crash",`Child crashed \u2014 ${T(r)} (${e}) failed to relaunch and is now dead: ${o.message}
2
2
 
3
- It stays dead until you revive it \u2014 \`crtr node lifecycle revive ${e}\`.`,{reason:"child-crashed",child:e})}catch{}throw b(a,{reason:"launch_failed"}),new Error(`[broker] refusing to revive ${e}: ${o.message}`)}if(ee(e,new Date().toISOString()),Z(a,e,w.pid)==="shutdown")throw b(a,{reason:"superseded"}),B.teardown(e),new Error(`[broker] refusing to revive ${e}: execution was superseded during launch`);return ie(e),{window:null,session:r.tmux_session??null,resumed:k,launch:w,faultExplanation:j.explanation,outcome:"launched"}}catch(d){throw b(a,{reason:"launch_failed"}),d}}s(Te,"launchRevive");export{fr as decideAttachRevive,ar as hasFreshGroundState,Pe as resumeArgs,ur as reviveNode};
3
+ It stays dead until you revive it \u2014 \`crtr node lifecycle revive ${e}\`.`,{reason:"child-crashed",child:e})}catch{}throw S(u,{reason:"launch_failed"}),new Error(`[broker] refusing to revive ${e}: ${o.message}`)}if(ie(e,new Date().toISOString()),ee(u,e,k.pid)==="shutdown")throw S(u,{reason:"superseded"}),H.teardown(e),new Error(`[broker] refusing to revive ${e}: execution was superseded during launch`);return oe(e),{window:null,session:r.tmux_session??null,resumed:_,launch:k,faultExplanation:J.explanation,outcome:"launched"}}catch(w){throw S(u,{reason:"launch_failed"}),w}}s(Ue,"launchRevive");export{wr as decideAttachRevive,pr as hasFreshGroundState,Be as resumeArgs,vr as reviveNode};
@@ -1 +1 @@
1
- var re=Object.defineProperty;var u=(t,e)=>re(t,"name",{value:e,configurable:!0});import{prepared as P}from"../../../core/sqlite-statements.js";import{ApiError as _}from"../../../api/errors.js";import{DOC_KINDS as q,DOC_UNSET_FIELDS as H}from"../../../api/dto/docs.js";import{PRIVACY_LEVELS_DTO as V}from"../../../api/dto/objects.js";import{withCanvasWrite as k}from"../../../core/canvas/db.js";import{CrtrError as ie}from"../../../core/errors.js";import{emitEvent as se}from"../../../core/events/emit.js";import{canChange as K,spaceOf as ae}from"../../../core/graph/access.js";import{get as A}from"../../../core/graph/bodies.js";import{deleteDocument as ce,documentRecord as F,editDocument as de,heads as I,history as ue,linksIn as U,moveDocument as fe,readBase as le,setDocumentRecord as me,withCurrentNames as pe,writeDocument as he}from"../../../core/graph/documents.js";import{resolveName as X}from"../../../core/graph/names.js";import{getByRowid as M}from"../../../core/graph/objects.js";import{noteRepoDocumentsChanged as we}from"../../../core/graph/repo-sync/index.js";import{checkoutsForCwd as ye}from"../../../core/graph/repo-sync/repos.js";import{lintRules as ve,rulesOf as ge}from"../../../core/graph/rules.js";import{unifiedDiff as _e}from"../../../core/graph/diff.js";import{ownerFromFlag as W,readerFor as E,resolveRef as z}from"../reader.js";function v(t,e,n,o,i={}){return new _(t,e,n,{...i,error:e,message:n,next:o})}u(v,"refuse");const b=u((t,e)=>new _(400,"invalid_request",t,e),"invalid");function D(t){const e=t.body;if(e===void 0)return{};if(typeof e!="object"||e===null||Array.isArray(e))throw b("the request body must be a JSON object");return e}u(D,"bodyOf");const L=u(t=>e=>ye(t,e),"reposFnFor");function C(t,e){if(t instanceof ie){const n=t.details;if(t.code==="read_first")throw v(409,"read_first",t.message,`Read it first with \`crtr canvas read ${e?.id??"<ref>"}\`, then rerun the edit.`,n??{});if(t.code==="conflict")throw new _(409,"name_taken",t.message,n);if(t.code==="forbidden")throw new _(403,"forbidden",t.message,n);if(t.code==="scope_missing")throw v(403,"scope_missing",t.message,`Launch the run with \`${String(n?.scope)}\` in its scopes list.`,n??{});if(n?.rule==="repo-public-only")throw v(422,"repo_documents_are_public",t.message,'Own it as `user` and add a rule with `if: {"repo": "<handle>"}` so it loads only in that repo.')}throw t}u(C,"mapGraphError");function x(t,e,n){const o=z(t,e,decodeURIComponent(n));if("remote"in o)throw v(404,"not_found",G(o.remote),"Check the name with `crtr canvas search`.");if("deleted"in o)throw new _(404,"not_found",`${o.deleted.name} is deleted`,{id:o.deleted.id,deleted:!0});if(o.type!=="document")throw b(`${n} is a ${o.type}, not a document`);return o}u(x,"refDocument");function G(t){return`no object named ${t} on this runtime; it may be on another runtime, or the name may be misspelled`}u(G,"remoteMessage");function be(t,e){const n=e.owner===null?null:M(t,e.owner);return n?n.type==="node"?n.id:n.name:""}u(be,"ownerHandle");function j(t,e){const n=be(t,e);return n?`${n}/${e.name}`:e.name}u(j,"canonicalName");function ke(t,e){const n=e.owner===null?null:M(t,e.owner),o=n?{id:n.id,type:n.type,handle:n.type==="node"?n.id:n.name}:null;return{id:e.id,type:e.type,kind:e.kind,owner:o,privacy:e.privacy,name:e.name,preview:e.preview??"",created_at:e.created_at,deleted_at:e.deleted_at}}u(ke,"objectDTO");function Ee(t,e){const n=e.owner===null?null:M(t,e.owner);return n?.type==="repo"?n:null}u(Ee,"repoOwner");function S(t,e,n,o){const i=[...new Map(n.flatMap(r=>{const f=Ee(t,r);return f?[[f.id,f]]:[]})).values()],a=e.class==="node"?e.cwd:o;return{repos:i,...a!==void 0?{cwd:a}:{}}}u(S,"repoChange");function T(t){for(const e of t.repos)try{we(e,t.cwd)}catch(n){se({level:"warn",event:"repo_sync.failed",outcome:"failed",error:n,fields:{repo:e.name,step:"export",reason:n instanceof Error?n.message:String(n)}})}}u(T,"syncChanged");const Y=/^[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*$/;function $e(t,e){if(t===void 0)return;if(!Array.isArray(t)){e.push("`rules` must be a list");return}const n=t.map(i=>{if(typeof i!="object"||i===null||Array.isArray(i))return i;const{match_frontmatter:a,...r}=i;return"matchFrontmatter"in r?{...r,match_frontmatter:r.matchFrontmatter}:a===void 0?r:{...r,matchFrontmatter:a}}),o=ve(n);if(o){e.push(o.replaceAll("matchFrontmatter","match_frontmatter"));return}return n}u($e,"rulesFromDTO");function Z(t,e){const n={},o=u(s=>{const c=t[s];if(c!==void 0){if(typeof c!="string"){e.push(`\`${s}\` must be a string`);return}return c}},"str"),i=o("preview");i!==void 0&&(i.trim()===""||/[\r\n]/.test(i)?e.push("`preview` must be one non-empty line"):n.preview=i);const a=o("summary");a!==void 0&&(n.summary=a);const r=o("why_it_exists");if(r!==void 0&&(n.whyItExists=r),t.privacy!==void 0&&(V.includes(t.privacy)?n.privacy=t.privacy:e.push(`\`privacy\` must be one of ${V.join(", ")}`)),t.unlisted!==void 0&&(typeof t.unlisted!="boolean"?e.push("`unlisted` must be a boolean"):n.unlisted=t.unlisted),t.lint_ignore!==void 0){const s=t.lint_ignore;!Array.isArray(s)||s.some(c=>typeof c!="string")?e.push("`lint_ignore` must be a list of strings"):n.lintIgnore=s}if(t.extensions!==void 0){const s=t.extensions;typeof s!="object"||s===null||Array.isArray(s)||Object.values(s).some(c=>typeof c!="object"||c===null||Array.isArray(c))?e.push("`extensions` must map each plugin to an object of fields"):n.extensions=s}const f=$e(t.rules,e);return f!==void 0&&(n.rules=f),n}u(Z,"fieldsFrom");function J(t){if(t.length>0)throw b(t.join("; "),{errors:t})}u(J,"failOn");function Q(t,e,n,o){const i=U(n).filter(a=>"remote"in X(t,e,a));return{...o.length>0?{unresolved_links:o}:{},...i.length>0?{remote_links:i}:{}}}u(Q,"linkReport");function Oe(t){const e=D(t),n=[],o=e.name;(typeof o!="string"||!Y.test(o))&&n.push("`name` must be segments of letters, digits, `_` or `-` joined by `/`"),typeof e.body!="string"&&n.push("`body` must be a string"),typeof e.preview!="string"&&n.push("`preview` is required");const i=e.kind??"knowledge";q.includes(i)||n.push(i==="report"?"reports are written only by `crtr push`":`\`kind\` must be one of ${q.join(", ")}`);const a=Z(e,n);e.cwd!==void 0&&typeof e.cwd!="string"&&n.push("`cwd` must be a string"),J(n);const r=e,{dto:f,change:s}=k(c=>{const h=E(c,t),d=W(c,h,r.owner,r.cwd);if(!K(c,h,d))throw new _(404,"not_found",`no owner ${d.name} you can write to`);try{const{object:p,unresolved:l}=he(c,h,{...a,owner:d,name:r.name,kind:i,preview:a.preview,privacy:a.privacy??"public"},r.body,L(c));return{dto:{id:p.id,name:j(c,p),...Q(c,h,r.body,l)},change:S(c,h,[p],r.cwd)}}catch(p){C(p)}});return T(s),f}u(Oe,"write");function Ae(t,e){let n=t;e.forEach(({old:o,new:i},a)=>{const r=o===""?-1:n.indexOf(o);if(r<0)throw v(422,"replace_no_match",`replace[${a}]: old text not found in the revision you read`,"Copy the old text exactly from your last read and rerun.",{index:a});if(n.indexOf(o,r+1)>=0)throw v(422,"replace_ambiguous",`replace[${a}]: old text occurs more than once`,"Include more surrounding text so the old text matches once, and rerun.",{index:a});n=n.slice(0,r)+i+n.slice(r+o.length)})}u(Ae,"checkReplacements");function je(t){const e=D(t),n=[],o=e.body!==void 0,i=e.replace!==void 0;o&&i&&n.push("give `body` or `replace`, not both"),o&&typeof e.body!="string"&&n.push("`body` must be a string");const a=e.replace;i&&(!Array.isArray(a)||a.length===0||a.some(d=>typeof d!="object"||d===null||typeof d.old!="string"||typeof d.new!="string"))&&n.push("`replace` must be a non-empty list of {old, new} strings"),(typeof e.rationale!="string"||e.rationale.trim()==="")&&n.push("`rationale` is required"),e.verbatim!==void 0&&typeof e.verbatim!="boolean"&&n.push("`verbatim` must be a boolean"),e.base!==void 0&&(typeof e.base!="string"||e.base==="")&&n.push("`base` must be a non-empty heads key"),e.base!==void 0&&!o&&!i&&n.push("`base` applies only to a `body` or `replace` edit");const r=Z(e,n),f=e.unset??[];if(!Array.isArray(f)||f.some(d=>!H.includes(d)))n.push(`\`unset\` takes only ${H.join(", ")}`);else for(const d of f)d==="summary"&&(r.summary=null),d==="why_it_exists"&&(r.whyItExists=null),d==="unlisted"&&(r.unlisted=!1),d==="lint_ignore"&&(r.lintIgnore=null),d==="extensions"&&(r.extensions=null),d==="rules"&&(r.rules=[]);n.length===0&&!o&&!i&&Object.keys(r).length===0&&n.push("nothing to change: give `body`, `replace`, or a field"),J(n);const s=e,{dto:c,synced:h}=k(d=>{const p=E(d,t),l=x(d,p,t.params.ref),g=j(d,l),O=S(d,p,[l]);try{if(!o&&!i)return me(d,p,l,r),{dto:{id:l.id,name:g,revision:F(d,l).revision},synced:O};const w=s.base??le(d,p,l);if(i&&w!==null){const oe=F(d,l),B=I(d,l);oe.heads_key===w&&B.length===1&&Ae(pe(d,l,A(B[0].body_hash)),s.replace)}const R=s.verbatim===!0,m=o?{body:s.body,verbatim:R}:{replace:s.replace,verbatim:R},y=de(d,p,l,m,s.rationale,w,r,L(d));if("stale"in y)throw v(409,"stale_revision",`${g} changed after your read; it is now at revision ${y.stale.revision}`,`Read it again with \`crtr canvas read ${g}\`, then redo your change on the current text.`,{stale:y.stale});const N=A(I(d,l)[0].body_hash);return{dto:{id:l.id,name:g,revision:y.revision,...Q(d,p,N,y.unresolved)},synced:O}}catch(w){C(w,l)}});return T(h),c}u(je,"edit");function Re(t){const e=D(t);if(e.new_name===void 0&&e.owner===void 0)throw b("give a new name, an owner, or both");if(e.new_name!==void 0&&(typeof e.new_name!="string"||!Y.test(e.new_name)))throw b("`new_name` must be segments of letters, digits, `_` or `-` joined by `/`");const{dto:n,change:o}=k(i=>{const a=E(i,t),r=x(i,a,t.params.ref),f=e.owner===void 0?void 0:W(i,a,e.owner,e.cwd);try{const s=fe(i,a,r,{...e.new_name!==void 0?{name:e.new_name}:{},...f?{owner:f}:{}});return{dto:{id:s.id,name:j(i,s)},change:S(i,a,[r,s],e.cwd)}}catch(s){C(s,r)}});return T(o),n}u(Re,"move");function De(t){const{dto:e,change:n}=k(o=>{const i=E(o,t),a=x(o,i,t.params.ref),r=j(o,a),f=S(o,i,[a]);try{ce(o,i,a,L(o))}catch(s){C(s,a)}return{dto:{id:a.id,name:r},change:f}});return T(n),e}u(De,"remove");function Ce(t){const e=t.query.get("diff"),n=e===null?void 0:Number(e);if(n!==void 0&&!(Number.isInteger(n)&&n>0))throw b("`diff` must be a revision number");return k(o=>{const i=E(o,t),a=z(o,i,decodeURIComponent(t.params.ref));if("remote"in a)throw v(404,"not_found",G(a.remote),"Check the name with `crtr canvas search`.");const r="deleted"in a?a.deleted:a;if(r.type!=="document")throw b(`${r.name} is a ${r.type}, not a document`);const f=ue(o,i,r),s={object:ke(o,r),revisions:f.map(c=>({rev_id:c.rev_id,number:c.number,author_id:c.author,rationale:c.rationale,verbatim:c.verbatim,created_at:c.created_at,parents:c.parents}))};if(n!==void 0){const c=f.find(d=>d.number===n);if(!c)throw new _(404,"not_found",`${r.name} has no revision ${n}`);const h=c.parents[0]===void 0?void 0:f.find(d=>d.rev_id===c.parents[0]);s.diff=_e(h?A(h.body_hash):"",A(c.body_hash))}return s})}u(Ce,"revisionHistory");const ee=1e3,ne=1e3,te=3e3,Se=/^When\b.+\bbecause\b.+/,Te=u(t=>{const e=t.trim();return e===""?0:e.split(/\s+/).length},"words");function Ne(t,e){if(t.some(o=>o.on==="boot"&&o.deliver==="content")&&e>ee)return`body is ${e} words, over the ${ee}-word cap for a boot rule at content (the whole body enters every node's context). Keep the core here and move separate subjects into [[linked]] documents.`;const n=[...new Set(t.filter(o=>o.on!=="boot"&&o.deliver==="content").map(o=>o.on))];return n.length>0&&e>ne?`body is ${e} words, over the ${ne}-word cap for a content rule on ${n.join("/")}. Keep the core here and move separate subjects into [[linked]] documents.`:t.some(o=>o.on==="boot"&&o.deliver==="preview")&&e>te?`body is ${e} words, over the ${te}-word cap for a boot rule at preview. Keep the core here and move separate subjects into [[linked]] documents.`:null}u(Ne,"lengthFinding");function Fe(t){const e=D(t);return k(n=>{const o=E(n,t),i=e.owner===void 0?null:W(n,o,e.owner,e.cwd),a=P(n,"SELECT * FROM objects WHERE type = 'document' AND deleted_at IS NULL AND kind != 'report' ORDER BY name").all(),r=[],f=new Map;for(const s of a){if(i&&s.owner!==i.rowid||!K(n,o,s))continue;const c=F(n,s),h=new Set(c.lint_ignore??[]),d=j(n,s),p=u((m,y,N)=>{h.has(m)||r.push({name:d,severity:y,rule:m,message:N})},"add"),l=ge(n,s),g=I(n,s)[0],O=g?A(g.body_hash):"",w=Ne(l,Te(O));w&&p("length","error",w),l.some(m=>m.deliver==="preview")&&!Se.test(s.preview??"")&&p("preview-shape","error",`a rule delivers this document at preview, so the preview must read "When <situation in the reader's work>, read this because <what the reader gains>"`);const R=new Set(P(n,"SELECT written_as FROM edges WHERE type = 'links-to' AND src = ?").all(s.rowid).map(m=>m.written_as));for(const m of U(O))R.has(m)||"remote"in X(n,o,m)||p("links","warning",`[[${m}]] names no object in your view`);if(l.some(m=>m.on==="workspace-open"&&m.deliver==="content")){const m=ae(n,s);m.type==="repo"&&!h.has("workspace-open")&&f.set(m.name,[...f.get(m.name)??[],d])}}for(const[s,c]of f)if(!(c.length<2))for(const h of c)r.push({name:h,severity:"warning",rule:"workspace-open",message:`repo ${s} has ${c.length} documents delivered at content on workspace-open (${c.join(", ")}); keep one and link the rest from it`});return{findings:r}})}u(Fe,"lint");const $=u((t,e=200)=>n=>({status:e,body:t(n)}),"ok"),Qe=[{method:"POST",pattern:"/v1/docs",handler:$(Oe,201)},{method:"PATCH",pattern:"/v1/docs/:ref",handler:$(je)},{method:"POST",pattern:"/v1/docs/:ref/move",handler:$(Re)},{method:"DELETE",pattern:"/v1/docs/:ref",handler:$(De)},{method:"GET",pattern:"/v1/docs/:ref/history",handler:$(Ce)},{method:"POST",pattern:"/v1/docs/lint",handler:$(Fe)}];export{Qe as docRoutes};
1
+ var re=Object.defineProperty;var u=(t,e)=>re(t,"name",{value:e,configurable:!0});import{prepared as P}from"../../../core/sqlite-statements.js";import{ApiError as _}from"../../../api/errors.js";import{DOC_KINDS as q,DOC_UNSET_FIELDS as H}from"../../../api/dto/docs.js";import{PRIVACY_LEVELS_DTO as V}from"../../../api/dto/objects.js";import{withCanvasWrite as k}from"../../../core/canvas/db.js";import{CrtrError as ie}from"../../../core/errors.js";import{emitEvent as se}from"../../../core/events/emit.js";import{canChange as K,spaceOf as ae}from"../../../core/graph/access.js";import{get as A}from"../../../core/graph/bodies.js";import{deleteDocument as ce,documentRecord as F,editDocument as de,heads as I,history as ue,linksIn as U,moveDocument as fe,readBase as le,setDocumentRecord as me,withCurrentNames as pe,writeDocument as he}from"../../../core/graph/documents.js";import{resolveName as X}from"../../../core/graph/names.js";import{getByRowid as M}from"../../../core/graph/objects.js";import{noteRepoDocumentsChanged as we}from"../../../core/graph/repo-sync/index.js";import{checkoutsForCwd as ye}from"../../../core/graph/repo-sync/repos.js";import{lintRules as ve,rulesOf as ge}from"../../../core/graph/rules.js";import{unifiedDiff as _e}from"../../../core/graph/diff.js";import{ownerFromFlag as W,readerFor as E,resolveRef as z}from"../reader.js";function v(t,e,n,o,s={}){return new _(t,e,n,{...s,error:e,message:n,next:o})}u(v,"refuse");const b=u((t,e)=>new _(400,"invalid_request",t,e),"invalid");function D(t){const e=t.body;if(e===void 0)return{};if(typeof e!="object"||e===null||Array.isArray(e))throw b("the request body must be a JSON object");return e}u(D,"bodyOf");const L=u(t=>e=>ye(t,e),"reposFnFor");function C(t,e){if(t instanceof ie){const n=t.details;if(t.code==="read_first")throw v(409,"read_first",t.message,`Read it first with \`crtr canvas read ${e?.id??"<ref>"}\`, then rerun the edit.`,n??{});if(t.code==="conflict")throw new _(409,"name_taken",t.message,n);if(t.code==="forbidden")throw new _(403,"forbidden",t.message,n);if(t.code==="scope_missing")throw v(403,"scope_missing",t.message,`Launch the run with \`${String(n?.scope)}\` in its scopes list.`,n??{});if(n?.rule==="repo-public-only")throw v(422,"repo_documents_are_public",t.message,'Own it as `user` and add a rule with `if: {"repo": "<handle>"}` so it loads only in that repo.')}throw t}u(C,"mapGraphError");function x(t,e,n){const o=z(t,e,decodeURIComponent(n));if("remote"in o)throw v(404,"not_found",G(o.remote),"Check the name with `crtr canvas search`.");if("deleted"in o)throw new _(404,"not_found",`${o.deleted.name} is deleted`,{id:o.deleted.id,deleted:!0});if(o.type!=="document")throw b(`${n} is a ${o.type}, not a document`);return o}u(x,"refDocument");function G(t){return`no object named ${t} on this runtime; it may be on another runtime, or the name may be misspelled`}u(G,"remoteMessage");function be(t,e){const n=e.owner===null?null:M(t,e.owner);return n?n.type==="node"?n.id:n.name:""}u(be,"ownerHandle");function j(t,e){const n=be(t,e);return n?`${n}/${e.name}`:e.name}u(j,"canonicalName");function ke(t,e){const n=e.owner===null?null:M(t,e.owner),o=n?{id:n.id,type:n.type,handle:n.type==="node"?n.id:n.name}:null;return{id:e.id,type:e.type,kind:e.kind,owner:o,privacy:e.privacy,name:e.name,preview:e.preview??"",created_at:e.created_at,deleted_at:e.deleted_at}}u(ke,"objectDTO");function Ee(t,e){const n=e.owner===null?null:M(t,e.owner);return n?.type==="repo"?n:null}u(Ee,"repoOwner");function S(t,e,n,o){const s=[...new Map(n.flatMap(f=>{const i=Ee(t,f);return i?[[i.id,i]]:[]})).values()],a=e.class==="node"?e.cwd:o,r=e.class==="node"?[...e.checkouts,...e.projectCheckouts].map(f=>f.root):[];return{repos:s,dirs:[...new Set([...a!==void 0?[a]:[],...r])]}}u(S,"repoChange");function T(t){for(const e of t.repos)try{we(e,t.dirs)}catch(n){se({level:"warn",event:"repo_sync.failed",outcome:"failed",error:n,fields:{repo:e.name,step:"export",reason:n instanceof Error?n.message:String(n)}})}}u(T,"syncChanged");const Y=/^[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*$/;function $e(t,e){if(t===void 0)return;if(!Array.isArray(t)){e.push("`rules` must be a list");return}const n=t.map(s=>{if(typeof s!="object"||s===null||Array.isArray(s))return s;const{match_frontmatter:a,...r}=s;return"matchFrontmatter"in r?{...r,match_frontmatter:r.matchFrontmatter}:a===void 0?r:{...r,matchFrontmatter:a}}),o=ve(n);if(o){e.push(o.replaceAll("matchFrontmatter","match_frontmatter"));return}return n}u($e,"rulesFromDTO");function Z(t,e){const n={},o=u(i=>{const c=t[i];if(c!==void 0){if(typeof c!="string"){e.push(`\`${i}\` must be a string`);return}return c}},"str"),s=o("preview");s!==void 0&&(s.trim()===""||/[\r\n]/.test(s)?e.push("`preview` must be one non-empty line"):n.preview=s);const a=o("summary");a!==void 0&&(n.summary=a);const r=o("why_it_exists");if(r!==void 0&&(n.whyItExists=r),t.privacy!==void 0&&(V.includes(t.privacy)?n.privacy=t.privacy:e.push(`\`privacy\` must be one of ${V.join(", ")}`)),t.unlisted!==void 0&&(typeof t.unlisted!="boolean"?e.push("`unlisted` must be a boolean"):n.unlisted=t.unlisted),t.lint_ignore!==void 0){const i=t.lint_ignore;!Array.isArray(i)||i.some(c=>typeof c!="string")?e.push("`lint_ignore` must be a list of strings"):n.lintIgnore=i}if(t.extensions!==void 0){const i=t.extensions;typeof i!="object"||i===null||Array.isArray(i)||Object.values(i).some(c=>typeof c!="object"||c===null||Array.isArray(c))?e.push("`extensions` must map each plugin to an object of fields"):n.extensions=i}const f=$e(t.rules,e);return f!==void 0&&(n.rules=f),n}u(Z,"fieldsFrom");function J(t){if(t.length>0)throw b(t.join("; "),{errors:t})}u(J,"failOn");function Q(t,e,n,o){const s=U(n).filter(a=>"remote"in X(t,e,a));return{...o.length>0?{unresolved_links:o}:{},...s.length>0?{remote_links:s}:{}}}u(Q,"linkReport");function Oe(t){const e=D(t),n=[],o=e.name;(typeof o!="string"||!Y.test(o))&&n.push("`name` must be segments of letters, digits, `_` or `-` joined by `/`"),typeof e.body!="string"&&n.push("`body` must be a string"),typeof e.preview!="string"&&n.push("`preview` is required");const s=e.kind??"knowledge";q.includes(s)||n.push(s==="report"?"reports are written only by `crtr push`":`\`kind\` must be one of ${q.join(", ")}`);const a=Z(e,n);e.cwd!==void 0&&typeof e.cwd!="string"&&n.push("`cwd` must be a string"),J(n);const r=e,{dto:f,change:i}=k(c=>{const h=E(c,t),d=W(c,h,r.owner,r.cwd);if(!K(c,h,d))throw new _(404,"not_found",`no owner ${d.name} you can write to`);try{const{object:p,unresolved:l}=he(c,h,{...a,owner:d,name:r.name,kind:s,preview:a.preview,privacy:a.privacy??"public"},r.body,L(c));return{dto:{id:p.id,name:j(c,p),...Q(c,h,r.body,l)},change:S(c,h,[p],r.cwd)}}catch(p){C(p)}});return T(i),f}u(Oe,"write");function Ae(t,e){let n=t;e.forEach(({old:o,new:s},a)=>{const r=o===""?-1:n.indexOf(o);if(r<0)throw v(422,"replace_no_match",`replace[${a}]: old text not found in the revision you read`,"Copy the old text exactly from your last read and rerun.",{index:a});if(n.indexOf(o,r+1)>=0)throw v(422,"replace_ambiguous",`replace[${a}]: old text occurs more than once`,"Include more surrounding text so the old text matches once, and rerun.",{index:a});n=n.slice(0,r)+s+n.slice(r+o.length)})}u(Ae,"checkReplacements");function je(t){const e=D(t),n=[],o=e.body!==void 0,s=e.replace!==void 0;o&&s&&n.push("give `body` or `replace`, not both"),o&&typeof e.body!="string"&&n.push("`body` must be a string");const a=e.replace;s&&(!Array.isArray(a)||a.length===0||a.some(d=>typeof d!="object"||d===null||typeof d.old!="string"||typeof d.new!="string"))&&n.push("`replace` must be a non-empty list of {old, new} strings"),(typeof e.rationale!="string"||e.rationale.trim()==="")&&n.push("`rationale` is required"),e.verbatim!==void 0&&typeof e.verbatim!="boolean"&&n.push("`verbatim` must be a boolean"),e.base!==void 0&&(typeof e.base!="string"||e.base==="")&&n.push("`base` must be a non-empty heads key"),e.base!==void 0&&!o&&!s&&n.push("`base` applies only to a `body` or `replace` edit");const r=Z(e,n),f=e.unset??[];if(!Array.isArray(f)||f.some(d=>!H.includes(d)))n.push(`\`unset\` takes only ${H.join(", ")}`);else for(const d of f)d==="summary"&&(r.summary=null),d==="why_it_exists"&&(r.whyItExists=null),d==="unlisted"&&(r.unlisted=!1),d==="lint_ignore"&&(r.lintIgnore=null),d==="extensions"&&(r.extensions=null),d==="rules"&&(r.rules=[]);n.length===0&&!o&&!s&&Object.keys(r).length===0&&n.push("nothing to change: give `body`, `replace`, or a field"),J(n);const i=e,{dto:c,synced:h}=k(d=>{const p=E(d,t),l=x(d,p,t.params.ref),g=j(d,l),O=S(d,p,[l]);try{if(!o&&!s)return me(d,p,l,r),{dto:{id:l.id,name:g,revision:F(d,l).revision},synced:O};const w=i.base??le(d,p,l);if(s&&w!==null){const oe=F(d,l),B=I(d,l);oe.heads_key===w&&B.length===1&&Ae(pe(d,l,A(B[0].body_hash)),i.replace)}const R=i.verbatim===!0,m=o?{body:i.body,verbatim:R}:{replace:i.replace,verbatim:R},y=de(d,p,l,m,i.rationale,w,r,L(d));if("stale"in y)throw v(409,"stale_revision",`${g} changed after your read; it is now at revision ${y.stale.revision}`,`Read it again with \`crtr canvas read ${g}\`, then redo your change on the current text.`,{stale:y.stale});const N=A(I(d,l)[0].body_hash);return{dto:{id:l.id,name:g,revision:y.revision,...Q(d,p,N,y.unresolved)},synced:O}}catch(w){C(w,l)}});return T(h),c}u(je,"edit");function Re(t){const e=D(t);if(e.new_name===void 0&&e.owner===void 0)throw b("give a new name, an owner, or both");if(e.new_name!==void 0&&(typeof e.new_name!="string"||!Y.test(e.new_name)))throw b("`new_name` must be segments of letters, digits, `_` or `-` joined by `/`");const{dto:n,change:o}=k(s=>{const a=E(s,t),r=x(s,a,t.params.ref),f=e.owner===void 0?void 0:W(s,a,e.owner,e.cwd);try{const i=fe(s,a,r,{...e.new_name!==void 0?{name:e.new_name}:{},...f?{owner:f}:{}});return{dto:{id:i.id,name:j(s,i)},change:S(s,a,[r,i],e.cwd)}}catch(i){C(i,r)}});return T(o),n}u(Re,"move");function De(t){const{dto:e,change:n}=k(o=>{const s=E(o,t),a=x(o,s,t.params.ref),r=j(o,a),f=S(o,s,[a]);try{ce(o,s,a,L(o))}catch(i){C(i,a)}return{dto:{id:a.id,name:r},change:f}});return T(n),e}u(De,"remove");function Ce(t){const e=t.query.get("diff"),n=e===null?void 0:Number(e);if(n!==void 0&&!(Number.isInteger(n)&&n>0))throw b("`diff` must be a revision number");return k(o=>{const s=E(o,t),a=z(o,s,decodeURIComponent(t.params.ref));if("remote"in a)throw v(404,"not_found",G(a.remote),"Check the name with `crtr canvas search`.");const r="deleted"in a?a.deleted:a;if(r.type!=="document")throw b(`${r.name} is a ${r.type}, not a document`);const f=ue(o,s,r),i={object:ke(o,r),revisions:f.map(c=>({rev_id:c.rev_id,number:c.number,author_id:c.author,rationale:c.rationale,verbatim:c.verbatim,created_at:c.created_at,parents:c.parents}))};if(n!==void 0){const c=f.find(d=>d.number===n);if(!c)throw new _(404,"not_found",`${r.name} has no revision ${n}`);const h=c.parents[0]===void 0?void 0:f.find(d=>d.rev_id===c.parents[0]);i.diff=_e(h?A(h.body_hash):"",A(c.body_hash))}return i})}u(Ce,"revisionHistory");const ee=1e3,ne=1e3,te=3e3,Se=/^When\b.+\bbecause\b.+/,Te=u(t=>{const e=t.trim();return e===""?0:e.split(/\s+/).length},"words");function Ne(t,e){if(t.some(o=>o.on==="boot"&&o.deliver==="content")&&e>ee)return`body is ${e} words, over the ${ee}-word cap for a boot rule at content (the whole body enters every node's context). Keep the core here and move separate subjects into [[linked]] documents.`;const n=[...new Set(t.filter(o=>o.on!=="boot"&&o.deliver==="content").map(o=>o.on))];return n.length>0&&e>ne?`body is ${e} words, over the ${ne}-word cap for a content rule on ${n.join("/")}. Keep the core here and move separate subjects into [[linked]] documents.`:t.some(o=>o.on==="boot"&&o.deliver==="preview")&&e>te?`body is ${e} words, over the ${te}-word cap for a boot rule at preview. Keep the core here and move separate subjects into [[linked]] documents.`:null}u(Ne,"lengthFinding");function Fe(t){const e=D(t);return k(n=>{const o=E(n,t),s=e.owner===void 0?null:W(n,o,e.owner,e.cwd),a=P(n,"SELECT * FROM objects WHERE type = 'document' AND deleted_at IS NULL AND kind != 'report' ORDER BY name").all(),r=[],f=new Map;for(const i of a){if(s&&i.owner!==s.rowid||!K(n,o,i))continue;const c=F(n,i),h=new Set(c.lint_ignore??[]),d=j(n,i),p=u((m,y,N)=>{h.has(m)||r.push({name:d,severity:y,rule:m,message:N})},"add"),l=ge(n,i),g=I(n,i)[0],O=g?A(g.body_hash):"",w=Ne(l,Te(O));w&&p("length","error",w),l.some(m=>m.deliver==="preview")&&!Se.test(i.preview??"")&&p("preview-shape","error",`a rule delivers this document at preview, so the preview must read "When <situation in the reader's work>, read this because <what the reader gains>"`);const R=new Set(P(n,"SELECT written_as FROM edges WHERE type = 'links-to' AND src = ?").all(i.rowid).map(m=>m.written_as));for(const m of U(O))R.has(m)||"remote"in X(n,o,m)||p("links","warning",`[[${m}]] names no object in your view`);if(l.some(m=>m.on==="workspace-open"&&m.deliver==="content")){const m=ae(n,i);m.type==="repo"&&!h.has("workspace-open")&&f.set(m.name,[...f.get(m.name)??[],d])}}for(const[i,c]of f)if(!(c.length<2))for(const h of c)r.push({name:h,severity:"warning",rule:"workspace-open",message:`repo ${i} has ${c.length} documents delivered at content on workspace-open (${c.join(", ")}); keep one and link the rest from it`});return{findings:r}})}u(Fe,"lint");const $=u((t,e=200)=>n=>({status:e,body:t(n)}),"ok"),Qe=[{method:"POST",pattern:"/v1/docs",handler:$(Oe,201)},{method:"PATCH",pattern:"/v1/docs/:ref",handler:$(je)},{method:"POST",pattern:"/v1/docs/:ref/move",handler:$(Re)},{method:"DELETE",pattern:"/v1/docs/:ref",handler:$(De)},{method:"GET",pattern:"/v1/docs/:ref/history",handler:$(Ce)},{method:"POST",pattern:"/v1/docs/lint",handler:$(Fe)}];export{Qe as docRoutes};
@@ -1 +1 @@
1
- var N=Object.defineProperty;var o=(e,t)=>N(e,"name",{value:t,configurable:!0});import{statSync as P}from"node:fs";import{isAbsolute as T}from"node:path";import{ApiError as s}from"../../../api/index.js";import{readDaemonActionById as C}from"../../../core/canvas/daemon-actions.js";import{isSafeNodeId as I}from"../../../core/canvas/paths.js";import{getNode as b}from"../../../core/canvas/canvas.js";import{assertScope as $}from"../../../core/scopes.js";import{resolvePageComponents as g}from"../../../core/config.js";import{resolveHumanAction as D}from"../../../core/human-actions.js";import{HUMAN_ACTION_NAME_PATTERN as x}from"../../../core/human/action-binding.js";import{createPageRequest as J,getRequest as F}from"../../../core/human/requests.js";import{preparePageSource as M}from"../../../core/human/page-authoring.js";import{publishPage as B,validatePageTicketResponses as G}from"../../../core/human/tickets.js";import{cancelHumanTicket as H,resolvePageTicket as L}from"../../human/finish.js";import{inboxPageProjection as U}from"./inbox-page.js";import{appOf as K}from"../router.js";const X=1e3,q=200,m={placement:"inline",inbox:!0,reply:!1},v=["sessionName","askedBy","emittedAt","profileName","nodeId"];function c(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}o(c,"isPlainObject");function l(e,t,n){for(const r of Object.keys(e))if(!t.includes(r))throw new s(400,"invalid_request",`unexpected field in ${n}: ${r}`)}o(l,"requireNoExtraKeys");function i(e){return new s(400,"invalid_request",e)}o(i,"invalid");function w(e){if(!c(e))throw i("`page` must be an object");if(l(e,["dialect","source"],"`page`"),e.dialect!=="jsx"&&e.dialect!=="html")throw i('`page.dialect` must be "jsx" or "html"');if(typeof e.source!="string"||e.source.trim()==="")throw i("`page.source` must be the authored page text");return{dialect:e.dialect,source:e.source}}o(w,"validatePage");function S(e,t){const n=e??t;if(n!=="inline"&&n!=="panel")throw i('`delivery.placement` must be "inline" or "panel"');return n}o(S,"validatePlacement");function z(e,t){if(e===void 0)return m;if(!c(e))throw i("`delivery` must be an object");l(e,["placement","inbox","reply"],"`delivery`");const n=e.inbox??m.inbox,r=e.reply??m.reply;if(typeof n!="boolean")throw i("`delivery.inbox` must be boolean");if(typeof r!="boolean")throw i("`delivery.reply` must be boolean");if(r&&t===null)throw i("`delivery.reply` requires `requester_node_id`");return{placement:S(e.placement,m.placement),inbox:n,reply:r}}o(z,"validateCreateDelivery");function V(e,t){if(e===void 0)return t;if(!c(e))throw i("`delivery` must be an object");return l(e,["placement"],"`delivery`"),{...t,placement:S(e.placement,t.placement)}}o(V,"validateReplaceDelivery");function Y(e){if(e===void 0)return;if(!c(e))throw i("`source` must be an object");l(e,v,"`source`");const t={};for(const n of v){const r=e[n];if(r!==void 0){if(typeof r!="string"||r==="")throw i(`\`source.${n}\` must be a nonempty string`);t[n]=r}}return t}o(Y,"validateSource");function Q(e){if(typeof e!="string"||e==="")throw i("`creator_cwd` is required");if(!T(e))throw i("`creator_cwd` must be an absolute path");try{if(!P(e).isDirectory())throw i(`\`creator_cwd\` is not a directory: ${e}`)}catch(t){throw t instanceof s?t:i(`\`creator_cwd\` does not exist: ${e}`)}return e}o(Q,"validateCreatorCwd");function W(e){if(e!==void 0){if(!c(e))throw i("`action` must be an object");if(l(e,["name","payload"],"`action`"),typeof e.name!="string"||!x.test(e.name))throw i("`action.name` must be a declared action name");return{name:e.name,payload:e.payload??null}}}o(W,"validateActionSelection");function Z(e){if(e==null)return null;if(typeof e!="string"||!I(e))throw i("`requester_node_id` must be a safe node id or null");return e}o(Z,"validateRequesterNodeId");function ee(e){if(!c(e))throw i("request body is required");l(e,["page","delivery","source","requester_node_id","creator_cwd","action"],"the request body");const t=Y(e.source),n=Z(e.requester_node_id),r=W(e.action);return{page:w(e.page),delivery:z(e.delivery,n),...t===void 0?{}:{source:t},requesterNodeId:n,creatorCwd:Q(e.creator_cwd),...r===void 0?{}:{action:r}}}o(ee,"validateCreateRequest");function te(e,t){if(!c(e))throw i("request body is required");return l(e,["page","delivery"],"the request body"),{page:w(e.page),delivery:V(e.delivery,t)}}o(te,"validateReplaceRequest");function _(e,t,n){if(e!==void 0){if(typeof e!="string"||e.trim()===""||e.length>n)throw i(`\`${t}\` must be nonempty after trim and at most ${n} characters`);return e}}o(_,"optionalBoundedString");function ne(e){if(!c(e))throw i("request body is required");if(l(e,["responses","actor"],"the request body"),!c(e.responses))throw i("`responses` must be an object");const t=_(e.actor,"actor",q);return{responses:e.responses,...t===void 0?{}:{actor:t}}}o(ne,"validateRespondRequest");function re(e){if(e===void 0)return{};if(!c(e))throw i("request body must be an object");l(e,["reason","actor"],"the request body");const t=_(e.reason,"reason",X),n=_(e.actor,"actor",q);return{...t===void 0?{}:{reason:t},...n===void 0?{}:{actor:n}}}o(re,"validateSettleRequest");function h(e){const t=e.params.request_id;if(typeof t!="string"||t==="")throw i("request id is required");return t}o(h,"requestId");function f(e){const t=F(e);if(t===null||t.kind!=="page")throw new s(404,"not_found","no request with that id");return t}o(f,"requirePageRequest");function A(e){try{const t=JSON.parse(e.specification_json);if(!c(t))throw new Error("not an object");return t}catch{throw new s(500,"internal","the request specification could not be read")}}o(A,"manifest");function oe(e){if(e.result_json===null)return null;try{return JSON.parse(e.result_json)}catch{throw new s(500,"internal","the request result could not be read")}}o(oe,"terminalResult");function ie(e){if(e.action_json===null)return null;try{const t=JSON.parse(e.action_json);if(t.schema!=="crtr.human-action-binding/v1"||typeof t.name!="string")throw new Error("invalid action");return t}catch{throw new s(500,"internal","the request action could not be read")}}o(ie,"frozenAction");function ae(e){return e.state!=="settled"?"pending":e.outcome==="answered"?"answered":e.outcome==="dismissed"?"dismissed":"canceled"}o(ae,"requestState");function se(e){if(e.action_delivery_id===null)return{state:"none",attempt:0};const t=C(e.action_delivery_id);if(t===null)return{state:"none",attempt:0};const n=t.state==="acknowledged"?"accepted":t.state==="permanent_failed"?"permanent_failed":t.state==="handed_off"?"running":"pending",r={state:n,attempt:t.attempts};return n==="pending"&&t.nextAttemptAt!==null&&(r.next_attempt_at=new Date(t.nextAttemptAt).toISOString()),t.acknowledgedAt!==null&&(r.accepted_at=new Date(t.acknowledgedAt).toISOString()),t.failedAt!==null&&(r.permanent_failed_at=new Date(t.failedAt).toISOString()),t.lastFailure!==null&&(r.last_failure=t.lastFailure),r}o(se,"deliveryDetail");function y(e){const t=A(e),n=oe(e),r=ae(e),a={request_id:e.request_id,inbox_ticket_id:e.inbox_ticket_id,request_dir:e.request_dir,kind:e.kind,state:r,title:e.title,source:t.source??{},emitted_at:t.source?.emittedAt??e.created_at};e.subtitle!==null&&(a.subtitle=e.subtitle),e.kind==="page"&&(a.page=U(e)),n!==null&&(a.settled_at=e.settled_at??e.updated_at,n.kind==="page"&&r==="answered"&&(a.responses=n.responses),n.kind==="canceled"&&n.reason!==void 0&&(a.reason=n.reason),n.kind==="page"&&n.actor!==void 0&&(a.actor=n.actor),n.kind==="canceled"&&n.actor!==void 0&&(a.actor=n.actor));const u=ie(e);return u!==null&&(a.action={name:u.name,payload:u.payload},a.delivery=se(e)),a}o(y,"requestToDTO");function k(e,t,n,r){try{return M({dialect:e,document:t,source:n,delivery:r,productKinds:g()})}catch(a){throw i(a instanceof Error?a.message:"the page could not be prepared")}}o(k,"prepareOrReject");function de(e){const t=ee(e.body),n=t.requesterNodeId??t.source?.nodeId;if(n!=null){const d=b(n);d!==null&&$(d,"crtr:act")}let r;if(t.action!==void 0){const d=D(t.action.name,t.creatorCwd);if("kind"in d){const E=d.kind==="unknown_action"?`no action named ${d.name} is declared from ${t.creatorCwd}`:`${d.name} is declared in ${d.configOrigin} but unusable: ${d.reason}`;throw new s(400,d.kind,E)}const p=n==null?null:b(n);if(n&&!p)throw new s(404,"not_found","action requester node not found");if(p&&e.principal.class!=="person"&&p.grantee!==K(e.principal))throw new s(403,"permission_error","action requester belongs to another app");r=JSON.stringify({schema:"crtr.human-action-binding/v1",name:t.action.name,payload:t.action.payload,creatorCwd:t.creatorCwd,configOrigin:d.configOrigin,argv:d.argv,cwd:d.cwd,app:p?.grantee??(e.principal.class==="broker"?e.principal.app:"app:terminal")})}const a=k(t.page.dialect,t.page.source,t.source,t.delivery),u=J({prepared:a,requester_node_id:t.requesterNodeId,...r===void 0?{}:{action_json:r}});return{status:201,body:{request_id:u.request_id,request_dir:u.request_dir,state:"pending",...t.action===void 0?{}:{action:{name:t.action.name}},delivery_state:"none"}}}o(de,"handleCreate");function ce(e){return{status:200,body:y(f(h(e)))}}o(ce,"handleGet");function ue(e){const t=f(h(e));if(t.state==="settled")throw new s(409,"already_settled","the request already has a terminal result");const n=A(t),r=te(e.body,n.delivery),a=k(r.page.dialect,r.page.source,n.source,r.delivery);try{B(t.request_dir,a)}catch(u){throw u instanceof Error&&u.message===`page request is not open: ${t.request_id}`?new s(409,"already_settled","the request already has a terminal result"):u}return{status:200,body:y(f(t.request_id))}}o(ue,"handleReplace");function O(e){return e instanceof s?e:e instanceof Error&&e.message.includes("unknown page request")?new s(404,"not_found","no request with that id"):e instanceof Error&&e.message==="page_has_no_response_lifecycle"?new s(409,"not_settleable","this request carries no response lifecycle"):new s(500,"internal","the request could not be settled")}o(O,"settlementError");function R(e){if(e.state==="settled")throw new s(409,"already_settled","the request already has a terminal result");if(!e.settleable)throw new s(409,"not_settleable","this request carries no response lifecycle")}o(R,"requireSettleable");async function le(e){const t=f(h(e)),n=ne(e.body);R(t);try{G(t.request_dir,n.responses)}catch(r){throw i(r instanceof Error?r.message:"the response map is invalid")}try{await L(t.request_id,n.responses,n.actor===void 0?{}:{actor:n.actor})}catch(r){throw O(r)}return{status:200,body:y(f(t.request_id))}}o(le,"handleRespond");async function j(e,t){const n=f(h(e)),r=re(e.body);R(n);try{await H(n.request_id,{...r,disposition:t})}catch(a){throw O(a)}return{status:200,body:y(f(n.request_id))}}o(j,"settle");function fe(){return{status:200,body:{components:[...g()]}}}o(fe,"handleComponents");const Ne=[{method:"GET",pattern:"/v1/human/components",handler:o(()=>fe(),"handler")},{method:"POST",pattern:"/v1/human/requests",scope:"crtr:act",handler:de},{method:"GET",pattern:"/v1/human/requests/:request_id",handler:ce},{method:"POST",pattern:"/v1/human/requests/:request_id/replace",handler:ue},{method:"POST",pattern:"/v1/human/requests/:request_id/respond",handler:le},{method:"POST",pattern:"/v1/human/requests/:request_id/dismiss",handler:o(e=>j(e,"dismissed"),"handler")},{method:"POST",pattern:"/v1/human/requests/:request_id/cancel",handler:o(e=>j(e,"canceled"),"handler")}];export{Ne as humanRequestRoutes};
1
+ var P=Object.defineProperty;var o=(e,t)=>P(e,"name",{value:t,configurable:!0});import{statSync as N}from"node:fs";import{isAbsolute as T}from"node:path";import{ApiError as s}from"../../../api/index.js";import{readDaemonActionById as C}from"../../../core/canvas/daemon-actions.js";import{isSafeNodeId as I}from"../../../core/canvas/paths.js";import{getNode as b}from"../../../core/canvas/canvas.js";import{assertScope as $}from"../../../core/scopes.js";import{resolvePageComponents as g}from"../../../core/config.js";import{resolveHumanAction as D}from"../../../core/human-actions.js";import{HUMAN_ACTION_NAME_PATTERN as x}from"../../../core/human/action-binding.js";import{createPageRequest as J,getRequest as F}from"../../../core/human/requests.js";import{preparePageSource as M}from"../../../core/human/page-authoring.js";import{PageCompilerError as B}from"../../../core/human/page-errors.js";import{publishPage as G,validatePageTicketResponses as H}from"../../../core/human/tickets.js";import{cancelHumanTicket as L,resolvePageTicket as U}from"../../human/finish.js";import{inboxPageProjection as K}from"./inbox-page.js";import{appOf as X}from"../router.js";const z=1e3,q=200,m={placement:"inline",inbox:!0,reply:!1},v=["sessionName","askedBy","emittedAt","profileName","nodeId"];function c(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}o(c,"isPlainObject");function l(e,t,n){for(const r of Object.keys(e))if(!t.includes(r))throw new s(400,"invalid_request",`unexpected field in ${n}: ${r}`)}o(l,"requireNoExtraKeys");function i(e){return new s(400,"invalid_request",e)}o(i,"invalid");function w(e){if(!c(e))throw i("`page` must be an object");if(l(e,["dialect","source"],"`page`"),e.dialect!=="jsx"&&e.dialect!=="html")throw i('`page.dialect` must be "jsx" or "html"');if(typeof e.source!="string"||e.source.trim()==="")throw i("`page.source` must be the authored page text");return{dialect:e.dialect,source:e.source}}o(w,"validatePage");function S(e,t){const n=e??t;if(n!=="inline"&&n!=="panel")throw i('`delivery.placement` must be "inline" or "panel"');return n}o(S,"validatePlacement");function V(e,t){if(e===void 0)return m;if(!c(e))throw i("`delivery` must be an object");l(e,["placement","inbox","reply"],"`delivery`");const n=e.inbox??m.inbox,r=e.reply??m.reply;if(typeof n!="boolean")throw i("`delivery.inbox` must be boolean");if(typeof r!="boolean")throw i("`delivery.reply` must be boolean");if(r&&t===null)throw i("`delivery.reply` requires `requester_node_id`");return{placement:S(e.placement,m.placement),inbox:n,reply:r}}o(V,"validateCreateDelivery");function Y(e,t){if(e===void 0)return t;if(!c(e))throw i("`delivery` must be an object");return l(e,["placement"],"`delivery`"),{...t,placement:S(e.placement,t.placement)}}o(Y,"validateReplaceDelivery");function Q(e){if(e===void 0)return;if(!c(e))throw i("`source` must be an object");l(e,v,"`source`");const t={};for(const n of v){const r=e[n];if(r!==void 0){if(typeof r!="string"||r==="")throw i(`\`source.${n}\` must be a nonempty string`);t[n]=r}}return t}o(Q,"validateSource");function W(e){if(typeof e!="string"||e==="")throw i("`creator_cwd` is required");if(!T(e))throw i("`creator_cwd` must be an absolute path");try{if(!N(e).isDirectory())throw i(`\`creator_cwd\` is not a directory: ${e}`)}catch(t){throw t instanceof s?t:i(`\`creator_cwd\` does not exist: ${e}`)}return e}o(W,"validateCreatorCwd");function Z(e){if(e!==void 0){if(!c(e))throw i("`action` must be an object");if(l(e,["name","payload"],"`action`"),typeof e.name!="string"||!x.test(e.name))throw i("`action.name` must be a declared action name");return{name:e.name,payload:e.payload??null}}}o(Z,"validateActionSelection");function ee(e){if(e==null)return null;if(typeof e!="string"||!I(e))throw i("`requester_node_id` must be a safe node id or null");return e}o(ee,"validateRequesterNodeId");function te(e){if(!c(e))throw i("request body is required");l(e,["page","delivery","source","requester_node_id","creator_cwd","action"],"the request body");const t=Q(e.source),n=ee(e.requester_node_id),r=Z(e.action);return{page:w(e.page),delivery:V(e.delivery,n),...t===void 0?{}:{source:t},requesterNodeId:n,creatorCwd:W(e.creator_cwd),...r===void 0?{}:{action:r}}}o(te,"validateCreateRequest");function ne(e,t){if(!c(e))throw i("request body is required");return l(e,["page","delivery"],"the request body"),{page:w(e.page),delivery:Y(e.delivery,t)}}o(ne,"validateReplaceRequest");function _(e,t,n){if(e!==void 0){if(typeof e!="string"||e.trim()===""||e.length>n)throw i(`\`${t}\` must be nonempty after trim and at most ${n} characters`);return e}}o(_,"optionalBoundedString");function re(e){if(!c(e))throw i("request body is required");if(l(e,["responses","actor"],"the request body"),!c(e.responses))throw i("`responses` must be an object");const t=_(e.actor,"actor",q);return{responses:e.responses,...t===void 0?{}:{actor:t}}}o(re,"validateRespondRequest");function oe(e){if(e===void 0)return{};if(!c(e))throw i("request body must be an object");l(e,["reason","actor"],"the request body");const t=_(e.reason,"reason",z),n=_(e.actor,"actor",q);return{...t===void 0?{}:{reason:t},...n===void 0?{}:{actor:n}}}o(oe,"validateSettleRequest");function h(e){const t=e.params.request_id;if(typeof t!="string"||t==="")throw i("request id is required");return t}o(h,"requestId");function f(e){const t=F(e);if(t===null||t.kind!=="page")throw new s(404,"not_found","no request with that id");return t}o(f,"requirePageRequest");function A(e){try{const t=JSON.parse(e.specification_json);if(!c(t))throw new Error("not an object");return t}catch{throw new s(500,"internal","the request specification could not be read")}}o(A,"manifest");function ie(e){if(e.result_json===null)return null;try{return JSON.parse(e.result_json)}catch{throw new s(500,"internal","the request result could not be read")}}o(ie,"terminalResult");function ae(e){if(e.action_json===null)return null;try{const t=JSON.parse(e.action_json);if(t.schema!=="crtr.human-action-binding/v1"||typeof t.name!="string")throw new Error("invalid action");return t}catch{throw new s(500,"internal","the request action could not be read")}}o(ae,"frozenAction");function se(e){return e.state!=="settled"?"pending":e.outcome==="answered"?"answered":e.outcome==="dismissed"?"dismissed":"canceled"}o(se,"requestState");function de(e){if(e.action_delivery_id===null)return{state:"none",attempt:0};const t=C(e.action_delivery_id);if(t===null)return{state:"none",attempt:0};const n=t.state==="acknowledged"?"accepted":t.state==="permanent_failed"?"permanent_failed":t.state==="handed_off"?"running":"pending",r={state:n,attempt:t.attempts};return n==="pending"&&t.nextAttemptAt!==null&&(r.next_attempt_at=new Date(t.nextAttemptAt).toISOString()),t.acknowledgedAt!==null&&(r.accepted_at=new Date(t.acknowledgedAt).toISOString()),t.failedAt!==null&&(r.permanent_failed_at=new Date(t.failedAt).toISOString()),t.lastFailure!==null&&(r.last_failure=t.lastFailure),r}o(de,"deliveryDetail");function y(e){const t=A(e),n=ie(e),r=se(e),a={request_id:e.request_id,inbox_ticket_id:e.inbox_ticket_id,request_dir:e.request_dir,kind:e.kind,state:r,title:e.title,source:t.source??{},emitted_at:t.source?.emittedAt??e.created_at};e.subtitle!==null&&(a.subtitle=e.subtitle),e.kind==="page"&&(a.page=K(e)),n!==null&&(a.settled_at=e.settled_at??e.updated_at,n.kind==="page"&&r==="answered"&&(a.responses=n.responses),n.kind==="canceled"&&n.reason!==void 0&&(a.reason=n.reason),n.kind==="page"&&n.actor!==void 0&&(a.actor=n.actor),n.kind==="canceled"&&n.actor!==void 0&&(a.actor=n.actor));const u=ae(e);return u!==null&&(a.action={name:u.name,payload:u.payload},a.delivery=de(e)),a}o(y,"requestToDTO");function k(e,t,n,r){try{return M({dialect:e,document:t,source:n,delivery:r,productKinds:g()})}catch(a){throw a instanceof B?a:i(a instanceof Error?a.message:"the page could not be prepared")}}o(k,"prepareOrReject");function ce(e){const t=te(e.body),n=t.requesterNodeId??t.source?.nodeId;if(n!=null){const d=b(n);d!==null&&$(d,"crtr:act")}let r;if(t.action!==void 0){const d=D(t.action.name,t.creatorCwd);if("kind"in d){const E=d.kind==="unknown_action"?`no action named ${d.name} is declared from ${t.creatorCwd}`:`${d.name} is declared in ${d.configOrigin} but unusable: ${d.reason}`;throw new s(400,d.kind,E)}const p=n==null?null:b(n);if(n&&!p)throw new s(404,"not_found","action requester node not found");if(p&&e.principal.class!=="person"&&p.grantee!==X(e.principal))throw new s(403,"permission_error","action requester belongs to another app");r=JSON.stringify({schema:"crtr.human-action-binding/v1",name:t.action.name,payload:t.action.payload,creatorCwd:t.creatorCwd,configOrigin:d.configOrigin,argv:d.argv,cwd:d.cwd,app:p?.grantee??(e.principal.class==="broker"?e.principal.app:"app:terminal")})}const a=k(t.page.dialect,t.page.source,t.source,t.delivery),u=J({prepared:a,requester_node_id:t.requesterNodeId,...r===void 0?{}:{action_json:r}});return{status:201,body:{request_id:u.request_id,request_dir:u.request_dir,state:"pending",...t.action===void 0?{}:{action:{name:t.action.name}},delivery_state:"none"}}}o(ce,"handleCreate");function ue(e){return{status:200,body:y(f(h(e)))}}o(ue,"handleGet");function le(e){const t=f(h(e));if(t.state==="settled")throw new s(409,"already_settled","the request already has a terminal result");const n=A(t),r=ne(e.body,n.delivery),a=k(r.page.dialect,r.page.source,n.source,r.delivery);try{G(t.request_dir,a)}catch(u){throw u instanceof Error&&u.message===`page request is not open: ${t.request_id}`?new s(409,"already_settled","the request already has a terminal result"):u}return{status:200,body:y(f(t.request_id))}}o(le,"handleReplace");function O(e){return e instanceof s?e:e instanceof Error&&e.message.includes("unknown page request")?new s(404,"not_found","no request with that id"):e instanceof Error&&e.message==="page_has_no_response_lifecycle"?new s(409,"not_settleable","this request carries no response lifecycle"):new s(500,"internal","the request could not be settled")}o(O,"settlementError");function R(e){if(e.state==="settled")throw new s(409,"already_settled","the request already has a terminal result");if(!e.settleable)throw new s(409,"not_settleable","this request carries no response lifecycle")}o(R,"requireSettleable");async function fe(e){const t=f(h(e)),n=re(e.body);R(t);try{H(t.request_dir,n.responses)}catch(r){throw i(r instanceof Error?r.message:"the response map is invalid")}try{await U(t.request_id,n.responses,n.actor===void 0?{}:{actor:n.actor})}catch(r){throw O(r)}return{status:200,body:y(f(t.request_id))}}o(fe,"handleRespond");async function j(e,t){const n=f(h(e)),r=oe(e.body);R(n);try{await L(n.request_id,{...r,disposition:t})}catch(a){throw O(a)}return{status:200,body:y(f(n.request_id))}}o(j,"settle");function pe(){return{status:200,body:{components:[...g()]}}}o(pe,"handleComponents");const Te=[{method:"GET",pattern:"/v1/human/components",handler:o(()=>pe(),"handler")},{method:"POST",pattern:"/v1/human/requests",scope:"crtr:act",handler:ce},{method:"GET",pattern:"/v1/human/requests/:request_id",handler:ue},{method:"POST",pattern:"/v1/human/requests/:request_id/replace",handler:le},{method:"POST",pattern:"/v1/human/requests/:request_id/respond",handler:fe},{method:"POST",pattern:"/v1/human/requests/:request_id/dismiss",handler:o(e=>j(e,"dismissed"),"handler")},{method:"POST",pattern:"/v1/human/requests/:request_id/cancel",handler:o(e=>j(e,"canceled"),"handler")}];export{Te as humanRequestRoutes};
@@ -0,0 +1,11 @@
1
+ import type { Socket } from 'node:net';
2
+ /**
3
+ * Serves one accepted launcher connection. The listener uses `pauseOnConnect` because the request is
4
+ * read with the native `recvFds`, so Node never sees the peer's FIN: ending the reply alone leaves the
5
+ * accepted descriptor open for the life of the process. This closes the connection on every path.
6
+ *
7
+ * `handle` returns the single reply line and calls `requestRead` once the request is read, which
8
+ * resumes the socket so a peer that leaves during a long operation (`wait`) is noticed and destroyed.
9
+ * Destroying happens after the reply flushes, so an async reply is never truncated.
10
+ */
11
+ export declare function serveAccepted(socket: Socket, handle: (requestRead: () => void) => Promise<string>): void;
@@ -0,0 +1 @@
1
+ var y=Object.defineProperty;var d=(e,r)=>y(e,"name",{value:r,configurable:!0});function t(e,r){e.on("error",()=>e.destroy());const n=d(o=>e.end(o,()=>e.destroy()),"endReply");r(()=>e.resume()).then(n,()=>e.destroy())}d(t,"serveAccepted");export{t as serveAccepted};
@@ -1,3 +1,3 @@
1
- var q=Object.defineProperty;var h=(u,c)=>q(u,"name",{value:c,configurable:!0});import{spawn as V}from"node:child_process";import{createHash as z}from"node:crypto";import{chownSync as O,chmodSync as P,closeSync as Q,existsSync as W,mkdirSync as H,readFileSync as X,rmSync as Y,statSync as Z}from"node:fs";import{createServer as ee}from"node:net";import{dirname as A,join as _}from"node:path";import{fileURLToPath as te}from"node:url";import{loadLinuxNative as re}from"../native/linux.js";import{envHomeOverride as oe}from"../shared/env.js";import{launchSandbox as ne}from"./bwrap.js";import{installNetworkRule as se}from"./netrule.js";import{LAUNCHER_SOCKET as S,LaunchRefused as f}from"./spec.js";import{systemIdentity as ie,AppUsers as ae}from"./users.js";import{SourceValidator as de}from"./validate.js";import{prepareDaemonScopeDirectories as ce}from"./scope-directories.js";import{LimitsManager as ue}from"./limits/manager.js";import{CRTRD_OOM_SCORE_ADJ as le}from"./limits/policy.js";import{writeLimitsLog as pe}from"./limits/log.js";const B=re();function U(u){const c=u._handle?.fd;if(c===void 0||c<0)throw new Error("launcher socket fd unavailable");return c}h(U,"socketFd");async function fe(){if(process.platform!=="linux"||process.getuid?.()!==0||!B)throw new Error("launcher requires Linux root and its native addon");const{daemonUid:u,daemonGid:c,dataGid:T}=ie(),x=A(S);if(H(x,{recursive:!0,mode:493}),(Z(x).mode&511)!==493)throw new Error(`${x} must be mode 0755`);O(x,u,c),process.umask(7);const G=_(x,"nodes");H(G,{recursive:!0,mode:504}),O(G,0,c),P(G,504);const y=new de(oe()||"/",c);ce(_(y.root,"runtime"),u,c);const v=new ae(T);se();const l=new ue({runtimeRoot:y.runtime,appsRoot:_(y.root,"apps"),stateDir:_(y.runtime,"limits-state"),dataGid:T,users:v});l.init();const L=new Map,$=h(r=>{const d=l.takeEvents();for(const o of d)pe(o);return d.length===0?r:{...r,events:d}},"reply_"),b=new Map,k=new Map,F=h((r,d)=>{const o=d.capture,e=z("sha256"),i=h((a,s)=>{let g=0,I=!1;const R=[];return{take(M){s?.update(M);const C=Math.max(0,Math.min(M.length,a-g));C&&(R.push(M.subarray(0,C)),g+=C),C!==M.length&&(I=!0)},result:h(()=>({text:Buffer.concat(R).toString("utf8"),truncated:I}),"result")}},"chunks"),t=i(o.stdoutBytes,e),n=i(o.stderrBytes);let m=Buffer.alloc(0),p=0;return r.stdout?.on("data",a=>t.take(a)),r.stderr?.on("data",a=>{const s=a;o.stderrTail?(p+=s.length,m=s.length>=o.stderrBytes?s.subarray(s.length-o.stderrBytes):Buffer.concat([m,s]).subarray(Math.max(0,m.length+s.length-o.stderrBytes))):n.take(s)}),new Promise(a=>r.once("close",(s,g)=>{const I=t.result(),R=o.stderrTail?{text:m.toString("utf8"),truncated:p>o.stderrBytes}:n.result();a({exitCode:s,signal:g,stdout:I.text,stderr:R.text,stdoutTruncated:I.truncated,stderrTruncated:R.truncated,stdoutSha256:e.digest("hex")})}))},"captureExit"),J=h(r=>{const d=new Set;for(;r>1&&!d.has(r);){const o=b.get(r);if(o)return o;d.add(r);try{const e=X(`/proc/${r}/status`,"utf8");r=Number(/^PPid:\s*(\d+)/m.exec(e)?.[1]??0)}catch{return null}}return null},"sandboxForPid");let w,E=!1;const j=_(A(te(import.meta.url)),"../daemon/crtrd-cli.js"),D=h(()=>{E||(w=V("/bin/setpriv",["--reuid",String(u),"--regid",String(c),"--groups",String(T),"--",process.execPath,j,...process.argv.slice(2)],{stdio:"inherit",env:{...process.env,CRTR_LAUNCHER_SOCKET:S,CRTR_HOME:y.root}}),w.pid&&l.protectDaemon(w.pid,le),w.once("exit",()=>{w=void 0,E||D()}),w.once("error",r=>{console.error("crtrd spawn failed:",r),process.exitCode=1,E=!0}))},"startDaemon");W(S)&&Y(S);const N=ee({pauseOnConnect:!0},r=>{const d=[];(async()=>{try{if(B.peerCred(U(r)).uid!==u)throw new f("invalid_spec","launcher peer is not crtrd");let o;for(;;)try{o=B.recvFds(U(r));break}catch(t){if(t.code!=="EINTR")throw t}d.push(...o.fds);const e=JSON.parse(o.data);let i;switch(e.operation){case"launch":{if(d.length>1||!v.has(e.spec.uid))throw new f("invalid_uid","app uid is not registered");let t,n;const m=l.prepareRun(e.spec.uid,e.spec.nodeId);try{n=await ne(e.spec,d[0],y,T,p=>{if(!p.pid)throw new f("launch_failed","bwrap has no pid");t=p.pid;const a=p.pid;if(b.set(a,{child:p,uid:e.spec.uid,nodeId:e.spec.nodeId,mounts:e.spec.mounts,plan:m}),p.once("exit",()=>{b.delete(a);const s=l.finishRun(m);L.set(a,s.oomKilled),setTimeout(()=>L.delete(a),5*6e4).unref()}),e.spec.capture){const s=F(p,e.spec).then(g=>({...g,oomKilled:L.get(a)===!0}));k.set(a,s),s.then(()=>{setTimeout(()=>{k.get(a)===s&&k.delete(a)},3e5).unref()})}},m)}catch(p){throw t!==void 0?k.delete(t):l.finishRun(m),p}if(!n.pid)throw new f("launch_failed","bwrap has no pid");l.confirmStarted(m,n.pid),i={pid:n.pid};break}case"wait":{const t=k.get(e.pid);if(!t)throw new f("invalid_spec","unknown captured sandbox pid");const n=await t;k.delete(e.pid),i={result:n};break}case"stop":{const t=Number.isInteger(e.pid)?J(e.pid):null;if(!t||e.nodeId!==void 0&&t.nodeId!==e.nodeId)throw new f("invalid_spec","unknown sandbox pid");if(e.signal!==void 0&&e.signal!=="SIGTERM"&&e.signal!=="SIGKILL")throw new f("invalid_spec","invalid stop signal");const n=e.signal??"SIGKILL";t.child.kill(n),n==="SIGKILL"&&l.killRun(t.plan),i={ok:!0};break}case"user.create":i={uid:v.create(e.app,e.uid)};break;case"user.delete":{if([...b.values()].some(t=>t.uid===e.uid))throw new f("invalid_spec","stop this app\u2019s sandboxes before deleting its user");v.delete(e.uid),i={ok:!0};break}case"users.sync":v.sync(e.users),i={ok:!0};break;case"list":i={sandboxes:[...b].map(([t,n])=>({pid:t,uid:n.uid,nodeId:n.nodeId,mounts:n.mounts}))};break;case"limits.apply":{const t=v.entries().find(n=>n.app===e.app.replace(/^app:/,""));if(!t)throw new f("invalid_uid","app is not registered");l.applyApp(t.app,t.uid,!0),i={ok:!0};break}case"limits.show":i={limits:l.show()};break;case"cache.purge":i={purged:await l.cachePass()};break;case"exit.status":i={oomKilled:L.get(e.pid)===!0};break;case"daemon.handover":w?.kill("SIGTERM"),i={ok:!0};break;default:throw new f("invalid_spec","unknown launcher operation")}r.end(`${JSON.stringify($(i))}
2
- `)}catch(o){const e=o instanceof f?o.refusal:"launch_failed";console.error(`launcher refusal ${e}:`,o),r.end(`${JSON.stringify({refusal:e})}
3
- `)}finally{for(const o of d)Q(o)}})()});await new Promise((r,d)=>{N.once("error",d),N.listen(S,r)}),O(S,u,c),P(S,384);const K=h(()=>{E=!0,w?.kill("SIGTERM");for(const{child:r}of b.values())r.kill("SIGKILL");N.close(),y.close()},"shutdown");process.once("SIGTERM",K),process.once("SIGINT",K),D()}h(fe,"runLauncher"),await fe().catch(u=>{console.error(u),process.exitCode=1});export{fe as runLauncher};
1
+ var q=Object.defineProperty;var h=(l,d)=>q(l,"name",{value:d,configurable:!0});import{spawn as V}from"node:child_process";import{createHash as z}from"node:crypto";import{chownSync as O,chmodSync as P,closeSync as Q,existsSync as W,mkdirSync as A,readFileSync as X,rmSync as Y,statSync as Z}from"node:fs";import{createServer as ee}from"node:net";import{dirname as H,join as _}from"node:path";import{fileURLToPath as te}from"node:url";import{loadLinuxNative as re}from"../native/linux.js";import{envHomeOverride as oe}from"../shared/env.js";import{launchSandbox as ne}from"./bwrap.js";import{serveAccepted as se}from"./accepted-socket.js";import{installNetworkRule as ie}from"./netrule.js";import{LAUNCHER_SOCKET as v,LaunchRefused as f}from"./spec.js";import{systemIdentity as ae,AppUsers as ce}from"./users.js";import{SourceValidator as de}from"./validate.js";import{prepareDaemonScopeDirectories as ue}from"./scope-directories.js";import{LimitsManager as le}from"./limits/manager.js";import{CRTRD_OOM_SCORE_ADJ as pe}from"./limits/policy.js";import{writeLimitsLog as fe}from"./limits/log.js";const B=re();function U(l){const d=l._handle?.fd;if(d===void 0||d<0)throw new Error("launcher socket fd unavailable");return d}h(U,"socketFd");async function me(){if(process.platform!=="linux"||process.getuid?.()!==0||!B)throw new Error("launcher requires Linux root and its native addon");const{daemonUid:l,daemonGid:d,dataGid:T}=ae(),I=H(v);if(A(I,{recursive:!0,mode:493}),(Z(I).mode&511)!==493)throw new Error(`${I} must be mode 0755`);O(I,l,d),process.umask(7);const G=_(I,"nodes");A(G,{recursive:!0,mode:504}),O(G,0,d),P(G,504);const S=new de(oe()||"/",d);ue(_(S.root,"runtime"),l,d);const b=new ce(T);ie();const p=new le({runtimeRoot:S.runtime,appsRoot:_(S.root,"apps"),stateDir:_(S.runtime,"limits-state"),dataGid:T,users:b});p.init();const L=new Map,$=h(r=>{const u=p.takeEvents();for(const s of u)fe(s);return u.length===0?r:{...r,events:u}},"reply_"),k=new Map,x=new Map,F=h((r,u)=>{const s=u.capture,c=z("sha256"),e=h((a,n)=>{let m=0,w=!1;const R=[];return{take(M){n?.update(M);const C=Math.max(0,Math.min(M.length,a-m));C&&(R.push(M.subarray(0,C)),m+=C),C!==M.length&&(w=!0)},result:h(()=>({text:Buffer.concat(R).toString("utf8"),truncated:w}),"result")}},"chunks"),i=e(s.stdoutBytes,c),t=e(s.stderrBytes);let o=Buffer.alloc(0),y=0;return r.stdout?.on("data",a=>i.take(a)),r.stderr?.on("data",a=>{const n=a;s.stderrTail?(y+=n.length,o=n.length>=s.stderrBytes?n.subarray(n.length-s.stderrBytes):Buffer.concat([o,n]).subarray(Math.max(0,o.length+n.length-s.stderrBytes))):t.take(n)}),new Promise(a=>r.once("close",(n,m)=>{const w=i.result(),R=s.stderrTail?{text:o.toString("utf8"),truncated:y>s.stderrBytes}:t.result();a({exitCode:n,signal:m,stdout:w.text,stderr:R.text,stdoutTruncated:w.truncated,stderrTruncated:R.truncated,stdoutSha256:c.digest("hex")})}))},"captureExit"),J=h(r=>{const u=new Set;for(;r>1&&!u.has(r);){const s=k.get(r);if(s)return s;u.add(r);try{const c=X(`/proc/${r}/status`,"utf8");r=Number(/^PPid:\s*(\d+)/m.exec(c)?.[1]??0)}catch{return null}}return null},"sandboxForPid");let g,E=!1;const j=_(H(te(import.meta.url)),"../daemon/crtrd-cli.js"),D=h(()=>{E||(g=V("/bin/setpriv",["--reuid",String(l),"--regid",String(d),"--groups",String(T),"--",process.execPath,j,...process.argv.slice(2)],{stdio:"inherit",env:{...process.env,CRTR_LAUNCHER_SOCKET:v,CRTR_HOME:S.root}}),g.pid&&p.protectDaemon(g.pid,pe),g.once("exit",()=>{g=void 0,E||D()}),g.once("error",r=>{console.error("crtrd spawn failed:",r),process.exitCode=1,E=!0}))},"startDaemon");W(v)&&Y(v);const N=ee({pauseOnConnect:!0},r=>se(r,u=>{const s=[];return(async()=>{try{if(B.peerCred(U(r)).uid!==l)throw new f("invalid_spec","launcher peer is not crtrd");let c;for(;;)try{c=B.recvFds(U(r));break}catch(t){if(t.code!=="EINTR")throw t}u(),s.push(...c.fds);const e=JSON.parse(c.data);let i;switch(e.operation){case"launch":{if(s.length>1||!b.has(e.spec.uid))throw new f("invalid_uid","app uid is not registered");let t,o;const y=p.prepareRun(e.spec.uid,e.spec.nodeId);try{o=await ne(e.spec,s[0],S,T,a=>{if(!a.pid)throw new f("launch_failed","bwrap has no pid");t=a.pid;const n=a.pid;if(k.set(n,{child:a,uid:e.spec.uid,nodeId:e.spec.nodeId,mounts:e.spec.mounts,plan:y}),a.once("exit",()=>{k.delete(n);const m=p.finishRun(y);L.set(n,m.oomKilled),setTimeout(()=>L.delete(n),5*6e4).unref()}),e.spec.capture){const m=F(a,e.spec).then(w=>({...w,oomKilled:L.get(n)===!0}));x.set(n,m),m.then(()=>{setTimeout(()=>{x.get(n)===m&&x.delete(n)},3e5).unref()})}},y)}catch(a){throw t!==void 0?x.delete(t):p.finishRun(y),a}if(!o.pid)throw new f("launch_failed","bwrap has no pid");p.confirmStarted(y,o.pid),i={pid:o.pid};break}case"wait":{const t=x.get(e.pid);if(!t)throw new f("invalid_spec","unknown captured sandbox pid");const o=await t;x.delete(e.pid),i={result:o};break}case"stop":{const t=Number.isInteger(e.pid)?J(e.pid):null;if(!t||e.nodeId!==void 0&&t.nodeId!==e.nodeId)throw new f("invalid_spec","unknown sandbox pid");if(e.signal!==void 0&&e.signal!=="SIGTERM"&&e.signal!=="SIGKILL")throw new f("invalid_spec","invalid stop signal");const o=e.signal??"SIGKILL";t.child.kill(o),o==="SIGKILL"&&p.killRun(t.plan),i={ok:!0};break}case"user.create":i={uid:b.create(e.app,e.uid)};break;case"user.delete":{if([...k.values()].some(t=>t.uid===e.uid))throw new f("invalid_spec","stop this app\u2019s sandboxes before deleting its user");b.delete(e.uid),i={ok:!0};break}case"users.sync":b.sync(e.users),i={ok:!0};break;case"list":i={sandboxes:[...k].map(([t,o])=>({pid:t,uid:o.uid,nodeId:o.nodeId,mounts:o.mounts}))};break;case"limits.apply":{const t=b.entries().find(o=>o.app===e.app.replace(/^app:/,""));if(!t)throw new f("invalid_uid","app is not registered");p.applyApp(t.app,t.uid,!0),i={ok:!0};break}case"limits.show":i={limits:p.show()};break;case"cache.purge":i={purged:await p.cachePass()};break;case"exit.status":i={oomKilled:L.get(e.pid)===!0};break;case"daemon.handover":g?.kill("SIGTERM"),i={ok:!0};break;default:throw new f("invalid_spec","unknown launcher operation")}return`${JSON.stringify($(i))}
2
+ `}catch(c){const e=c instanceof f?c.refusal:"launch_failed";return console.error(`launcher refusal ${e}:`,c),`${JSON.stringify({refusal:e})}
3
+ `}finally{for(const c of s)Q(c)}})()}));await new Promise((r,u)=>{N.once("error",u),N.listen(v,r)}),O(v,l,d),P(v,384);const K=h(()=>{E=!0,g?.kill("SIGTERM");for(const{child:r}of k.values())r.kill("SIGKILL");N.close(),S.close()},"shutdown");process.once("SIGTERM",K),process.once("SIGINT",K),D()}h(me,"runLauncher"),await me().catch(l=>{console.error(l),process.exitCode=1});export{me as runLauncher};
@@ -1 +1 @@
1
- var R=Object.defineProperty;var h=(n,t)=>R(n,"name",{value:t,configurable:!0});import{chownSync as P,chmodSync as $,closeSync as l,constants as _,lstatSync as w,mkdirSync as O,openSync as E,realpathSync as A}from"node:fs";import{isAbsolute as G,join as e,relative as T,resolve as j,sep as L}from"node:path";import{loadLinuxNative as q,O_PATH as x,RESOLVE_BENEATH as z,RESOLVE_NO_SYMLINKS as V}from"../native/linux.js";import{APP_UID_MAX as W,APP_UID_MIN as Z,LaunchRefused as s}from"./spec.js";import{systemIdentity as S}from"./users.js";const a=q(),v=x|_.O_NOFOLLOW,g=z|V,M=32768,b=16384,F=49152;function c(n,t){return n===t||n.startsWith(t==="/"?"/":`${t}${L}`)}h(c,"within");function I(n){if(!G(n)||j(n)!==n||n.includes("\0")||n.split(L).includes(".."))throw new s("invalid_spec",`non-canonical source: ${n}`)}h(I,"assertPath");class Q{static{h(this,"SourceValidator")}daemonGid;socketDir;rootFd;runtimeDev;runtimeIno;runtimeFd;root;runtime;constructor(t,d=S().daemonGid,u="/run/crtr"){if(this.daemonGid=d,this.socketDir=u,!a)throw new Error("Linux native addon is required by the root launcher");this.root=A(t),this.runtime=A(e(this.root,"runtime")),this.rootFd=E("/",v),this.runtimeFd=E(this.runtime,v);const r=a.fstat(this.runtimeFd);this.runtimeDev=r.dev,this.runtimeIno=r.ino}close(){l(this.rootFd),l(this.runtimeFd)}openLog(t){if(!a)throw new Error("Linux native addon is required");return a.openat2(this.runtimeFd,`nodes/${t}/job/broker.log`,_.O_WRONLY|_.O_APPEND|_.O_NOFOLLOW,g)}isRuntimeAncestor(t){if(!a)throw new Error("Linux native addon is required");const d=t.split("/").filter(Boolean);for(let u=1;u<=d.length;u++){let r;try{r=a.openat2(this.rootFd,d.slice(0,u).join("/"),v,g)}catch{return!1}try{const m=a.fstat(r);if(m.dev===this.runtimeDev&&m.ino===this.runtimeIno)return!0}finally{l(r)}}return!1}pin(t){if(!a)throw new Error("Linux native addon is required");if(!Number.isInteger(t.uid)||t.uid<Z||t.uid>W)throw new s("invalid_uid","uid outside app range");if(!/^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/.test(t.nodeId)||!Array.isArray(t.mounts)||!Array.isArray(t.command)||!t.command.length||!t.command.every(i=>typeof i=="string"&&i.length>0&&!i.includes("\0"))||!t.env||Object.entries(t.env).some(([i,o])=>!/^[A-Za-z_][A-Za-z0-9_]*$/.test(i)||typeof o!="string"||o.includes("\0")))throw new s("invalid_spec","invalid node id, command, environment or mount list");const d=e(this.socketDir,"nodes",t.nodeId),u=e(this.socketDir,"nodes");if(O(u,{recursive:!0,mode:493}),w(u).uid!==0||w(u).gid!==this.daemonGid||!w(u).isDirectory()||(w(u).mode&511)!==504)throw new s("run_source","nodes directory is not root-and-daemon-controlled");O(d,{recursive:!0,mode:504});const r=w(d);if(!r.isDirectory())throw new s("run_source","run folder is not a directory");if(r.uid===0||r.uid===S().daemonUid&&r.gid===this.daemonGid&&(r.mode&511)===504)P(d,t.uid,this.daemonGid),$(d,504);else if(r.uid!==t.uid||r.gid!==this.daemonGid||(r.mode&511)!==504)throw new s("run_source","run folder has wrong owner or mode");const m=[];try{for(const i of t.mounts){if(!i||typeof i.source!="string"||typeof i.writable!="boolean")throw new s("invalid_spec","invalid mount");const o=i.source;I(o);const p=t.pluginRoot===o&&!i.writable&&c(o,e(this.runtime,"plugins"))&&o!==e(this.runtime,"plugins");if(!p&&(c(o,this.runtime)||this.isRuntimeAncestor(o)))throw new s("runtime_source",o);const k=o===e(this.socketDir,"api.sock")||o===e(this.socketDir,"store.sock")||c(o,e(this.socketDir,"bin"))||o===d;if(c(o,this.socketDir)&&!k)throw new s("run_source",o);if(i.writable&&!c(o,e(this.root,"user"))&&!c(o,e(this.root,"apps"))&&o!==d&&o!==e(this.socketDir,"api.sock")&&o!==e(this.socketDir,"store.sock"))throw new s("writable_source",o);if(!c(o,e(this.root,"user"))&&!c(o,e(this.root,"apps"))&&!c(o,this.socketDir)&&!U(o)&&!p)throw new s("invalid_spec",`unknown read-only source: ${o}`);let f;try{f=a.openat2(this.rootFd,T("/",o)||".",v,g)}catch(D){const N=D.code;throw new s(N==="ENOENT"?"source_missing":"symlink_source",`${o}: ${String(D)}`)}const y=a.fstat(f).mode&61440;if(![M,b,F].includes(y)||y===F&&!k)throw l(f),new s(y===40960?"symlink_source":"source_type",o);m.push({source:o,writable:i.writable,fd:f,kind:y})}if(I(t.cwd),!m.some(i=>i.kind===b&&c(t.cwd,i.source)))throw new s("invalid_spec","cwd is not mounted");return m}catch(i){for(const o of m)l(o.fd);throw i}}}function U(n){return["/usr","/bin","/lib","/lib64","/opt/crouter","/etc/passwd","/etc/group","/etc/resolv.conf","/etc/hosts","/etc/ssl","/etc/ca-certificates"].some(t=>c(n,t))}h(U,"isSystemSource");export{Q as SourceValidator};
1
+ var R=Object.defineProperty;var h=(n,t)=>R(n,"name",{value:t,configurable:!0});import{chownSync as P,chmodSync as $,closeSync as l,constants as _,lstatSync as f,mkdirSync as E,openSync as L,realpathSync as A}from"node:fs";import{isAbsolute as G,join as e,relative as T,resolve as j,sep as S}from"node:path";import{loadLinuxNative as q,O_PATH as x,RESOLVE_BENEATH as z,RESOLVE_NO_SYMLINKS as V}from"../native/linux.js";import{APP_UID_MAX as W,APP_UID_MIN as Z,LaunchRefused as s}from"./spec.js";import{systemIdentity as b}from"./users.js";const a=q(),v=x|_.O_NOFOLLOW,g=z|V,M=32768,F=16384,I=49152;function c(n,t){return n===t||n.startsWith(t==="/"?"/":`${t}${S}`)}h(c,"within");function N(n){if(!G(n)||j(n)!==n||n.includes("\0")||n.split(S).includes(".."))throw new s("invalid_spec",`non-canonical source: ${n}`)}h(N,"assertPath");class Q{static{h(this,"SourceValidator")}daemonGid;socketDir;rootFd;runtimeDev;runtimeIno;runtimeFd;root;runtime;constructor(t,d=b().daemonGid,u="/run/crtr"){if(this.daemonGid=d,this.socketDir=u,!a)throw new Error("Linux native addon is required by the root launcher");this.root=A(t),this.runtime=A(e(this.root,"runtime")),this.rootFd=L("/",v),this.runtimeFd=L(this.runtime,v);const r=a.fstat(this.runtimeFd);this.runtimeDev=r.dev,this.runtimeIno=r.ino}close(){l(this.rootFd),l(this.runtimeFd)}openLog(t){if(!a)throw new Error("Linux native addon is required");return a.openat2(this.runtimeFd,`nodes/${t}/job/broker.log`,_.O_WRONLY|_.O_APPEND|_.O_NOFOLLOW,g)}isRuntimeAncestor(t){if(!a)throw new Error("Linux native addon is required");const d=t.split("/").filter(Boolean);for(let u=1;u<=d.length;u++){let r;try{r=a.openat2(this.rootFd,d.slice(0,u).join("/"),v,g)}catch{return!1}try{const m=a.fstat(r);if(m.dev===this.runtimeDev&&m.ino===this.runtimeIno)return!0}finally{l(r)}}return!1}pin(t){if(!a)throw new Error("Linux native addon is required");if(!Number.isInteger(t.uid)||t.uid<Z||t.uid>W)throw new s("invalid_uid","uid outside app range");if(!/^[a-zA-Z0-9][a-zA-Z0-9-]{0,127}$/.test(t.nodeId)||!Array.isArray(t.mounts)||!Array.isArray(t.command)||!t.command.length||!t.command.every(i=>typeof i=="string"&&i.length>0&&!i.includes("\0"))||!t.env||Object.entries(t.env).some(([i,o])=>!/^[A-Za-z_][A-Za-z0-9_]*$/.test(i)||typeof o!="string"||o.includes("\0")))throw new s("invalid_spec","invalid node id, command, environment or mount list");const d=e(this.socketDir,"nodes",t.nodeId),u=e(this.socketDir,"nodes");if(E(u,{recursive:!0,mode:493}),f(u).uid!==0||f(u).gid!==this.daemonGid||!f(u).isDirectory()||(f(u).mode&511)!==504)throw new s("run_source","nodes directory is not root-and-daemon-controlled");E(d,{recursive:!0,mode:504});const r=f(d);if(!r.isDirectory())throw new s("run_source","run folder is not a directory");if(r.uid===0||r.uid===b().daemonUid&&r.gid===this.daemonGid&&(r.mode&511)===504)P(d,t.uid,this.daemonGid),$(d,504);else if(r.uid!==t.uid||r.gid!==this.daemonGid||(r.mode&511)!==504)throw new s("run_source","run folder has wrong owner or mode");const m=[];try{for(const i of t.mounts){if(!i||typeof i.source!="string"||typeof i.writable!="boolean")throw new s("invalid_spec","invalid mount");const o=i.source;N(o);const p=t.pluginRoot===o&&!i.writable&&c(o,e(this.runtime,"plugins"))&&o!==e(this.runtime,"plugins");if(!p&&(c(o,this.runtime)||this.isRuntimeAncestor(o)))throw new s("runtime_source",o);const O=o===e(this.socketDir,"api.sock")||o===e(this.socketDir,"store.sock")||c(o,e(this.socketDir,"bin"))||o===d;if(c(o,this.socketDir)&&!O)throw new s("run_source",o);if(i.writable&&!c(o,e(this.root,"user"))&&!c(o,e(this.root,"apps"))&&o!==d&&o!==e(this.socketDir,"api.sock")&&o!==e(this.socketDir,"store.sock"))throw new s("writable_source",o);if(!c(o,e(this.root,"user"))&&!c(o,e(this.root,"apps"))&&!c(o,this.socketDir)&&!U(o)&&!p)throw new s("invalid_spec",`unknown read-only source: ${o}`);let w;try{w=a.openat2(this.rootFd,T("/",o)||".",v,g)}catch(k){const D=k.code;throw new s(D==="ENOENT"?"source_missing":D==="ELOOP"?"symlink_source":"launch_failed",`${o}: ${String(k)}`)}const y=a.fstat(w).mode&61440;if(![M,F,I].includes(y)||y===I&&!O)throw l(w),new s(y===40960?"symlink_source":"source_type",o);m.push({source:o,writable:i.writable,fd:w,kind:y})}if(N(t.cwd),!m.some(i=>i.kind===F&&c(t.cwd,i.source)))throw new s("invalid_spec","cwd is not mounted");return m}catch(i){for(const o of m)l(o.fd);throw i}}}function U(n){return["/usr","/bin","/lib","/lib64","/opt/crouter","/etc/passwd","/etc/group","/etc/resolv.conf","/etc/hosts","/etc/ssl","/etc/ca-certificates"].some(t=>c(n,t))}h(U,"isSystemSource");export{Q as SourceValidator};
package/docs/apps/cli.md CHANGED
@@ -5,7 +5,9 @@ description: The npx @north-light/sdk commands for scaffolding an app, logging i
5
5
 
6
6
  # CLI reference
7
7
 
8
- `npx @north-light/sdk <command>` runs the package's one command-line tool. Run it with no command, or with `--help`, to print the list below. An unknown command prints the list and exits 1.
8
+ `npx @north-light/sdk <command>` runs the package's one command-line tool. Run it with no command, or with `--help`, to print the list below. `npx @north-light/sdk <command> --help` (or `-h`) prints that command's usage and exits 0. An unknown command, or a bad flag, prints the usage and exits 1.
9
+
10
+ With no dependencies installed, `npx` downloads the newest published release of the SDK (the one tagged latest on npm), not the version your project pins. Run `npm i` in the project first if the pinned version must run; `init` pins the version of the CLI that ran it.
9
11
 
10
12
  | Command | What it does | Needs crtr |
11
13
  |---|---|---|
@@ -13,11 +15,11 @@ description: The npx @north-light/sdk commands for scaffolding an app, logging i
13
15
  | `npx @north-light/sdk skill [--agent claude\|agents\|all] [--global] [--print]` | Write the packaged `northlight` skill into `.agents/skills` and `.claude/skills` (or `~/` with `--global`), replacing an older copy. `--print` writes it to stdout. | No |
14
16
  | `npx @north-light/sdk keygen [--out <path>] [--app <client id>] [--yes]` | Write a private signing key to `local/app-key.json` (mode 0600) and print the public JWK to register. | Only with `--app` |
15
17
  | `npx @north-light/sdk env --client-id <client id> [--origin <url>] [--issuer <url>] [--out <path>]`<br/>`npx @north-light/sdk env --app <client id> [--out <path>]` | Write `CROUTER_ISSUER`, `CROUTER_CLIENT_ID` and `APP_ORIGIN` into `local/app.env`, keeping every other line. `--client-id` makes no network call; `--app` reads them through crtr. | Only with `--app` |
16
- | `npx @north-light/sdk login [--no-browser] [--new] [--issuer <url>]` | Log this computer in to your Northlight account: prints a link and a code, you approve once in the browser, and a revocable login is saved to `~/.config/northlight/credentials.json`. Run it again if it times out: it resumes the same code. Calls only the Northlight directory. | No |
17
- | `npx @north-light/sdk logout [--issuer <url>]` | Revoke this computer's login on the server and delete it locally. | No |
18
+ | `npx @north-light/sdk login [--no-browser] [--new] [--issuer <url>]` | Log this computer in to your Northlight account: prints a link and a code, you approve once in the browser, and a revocable login is saved to `~/.config/northlight/credentials.json`. Run it again if it times out or is interrupted: it resumes the same code. When this computer is already logged in it says so and exits 0; `--new` logs in again. Calls only the Northlight directory. | No |
19
+ | `npx @north-light/sdk logout [--issuer <url>]` | Revoke this computer's login on the server and remove it from the credentials file. The file stays, without that login. | No |
18
20
  | `npx @north-light/sdk whoami [--issuer <url>]` | Show the account this computer is logged in as and when the login expires. Exits 1 when not logged in. | No |
19
21
  | `npx @north-light/sdk register [--name <text>] [--scope <scope>]... [--logo <url>] [--justification <text>] [--redirect-uri <uri>]... [--issuer <url>]` | Run in a project made by `init`, after `login`: registers the app with the public key from `local/app-key.json`, adds each `--scope` beyond `crtr:llm crtr:act`, and writes `CROUTER_CLIENT_ID` and `CROUTER_ISSUER` to `local/app.env`. | No |
20
- | `npx @north-light/sdk apps <leaf> [--app <client id>] [--issuer <url>] [leaf flags] [--json]` | Call one app-management leaf with the login and print its answer as JSON. Leaves: `mine`, `get`, `update`, `set-redirect-uris`, `asks`, `set-scopes`, `set-requires`, `set-run-notice-url`, `keys list\|add\|retire\|delete\|set-jwks-uri\|remove-jwks-uri`, `testers list\|add\|remove`. `--app` defaults to `CROUTER_CLIENT_ID` in `local/app.env`; `keys add` takes `--key-file <path>` and sends only the public half. Deleting an app, its marketplace listing, spend cap, sharing and provider role are console-only. | No |
22
+ | `npx @north-light/sdk apps <leaf> [--app <client id>] [--issuer <url>] [leaf flags] [--json]` | Call one app-management leaf with the login and print its answer as JSON. Leaves: `mine`, `get`, `update`, `set-logo --file <path>`, `set-brand-color --color <#rrggbb|none>`, `set-redirect-uris`, `asks`, `set-scopes`, `set-requires`, `set-run-notice-url`, `keys list\|add\|retire\|delete\|set-jwks-uri\|remove-jwks-uri`, `testers list\|add\|remove`. `--app` defaults to `CROUTER_CLIENT_ID` in `local/app.env`; `keys add` takes `--key-file <path>` and sends only the public half. Deleting an app, its marketplace listing, monthly spend cap, sharing and provider role are console-only: those leaves exit 1 and name the console page, with no request. | No |
21
23
 
22
24
  `keygen` and `env` are the only commands that can call `crtr`, and only with `--app`. The documented path on this site never uses `--app`, so you do not need crtr. `login`, `logout`, `whoami`, `register` and `apps` call only the Northlight directory.
23
25
 
@@ -33,11 +35,23 @@ It never overwrites a file. On a refused step it exits non-zero and names the fi
33
35
 
34
36
  ### `login`, `logout`, `whoami`
35
37
 
36
- `login` prints a link and a code and waits while you approve in the browser, then saves a revocable login to `~/.config/northlight/credentials.json`. `--no-browser` does not open the link; `--new` discards a pending code and starts a fresh one. `whoami` shows the account and when the login expires, and exits 1 when this computer is not logged in. `logout` revokes the login on the server and deletes the local file. The login can manage your apps; it cannot delete one (not yet checked on production).
38
+ `login` prints a link and a code and waits while you approve in the browser, then saves a revocable login to `~/.config/northlight/credentials.json`. `--no-browser` does not open the link; `--new` discards a pending code and starts a fresh one.
39
+
40
+ What `login` does when it runs again:
41
+
42
+ - **A code is still pending** (the earlier run was killed, timed out or lost its connection): it prints "Still waiting for the login started at HH:MM." with the same link and code, and keeps polling. Do not pass `--new`.
43
+ - **This computer is already logged in**: it prints "Already logged in as … Nothing to do." and exits 0, without starting another login. `--new` makes a new login and revokes the old one once the new one is approved.
44
+ - **The saved login was revoked or has expired**: it says so and starts a new login.
45
+ - **The directory cannot be reached while it waits**: a failed poll is retried for up to 3 minutes. If it still cannot reach the directory it exits 1 and says the code is still saved, until when, and to run `login` again. Nothing is lost.
46
+ - **The code expired** (10 minutes) **or was declined**: it exits 1 and says which. Run `login` again for a new code.
47
+
48
+ It exits 0 only when the login is saved or was already there.
49
+
50
+ `whoami` shows the account and when the login expires, and exits 1 when this computer is not logged in. `logout` revokes the login on the server and removes it from the credentials file, which stays on disk as `{"version":1,"issuers":{}}` when no other login is in it. The login can manage your apps; it cannot delete one: `apps delete` exits 1 and names the console page, and a raw call to the directory's delete answers 403 `provider_refused`.
37
51
 
38
52
  ### `register`
39
53
 
40
- Run it in the project `init` made, after `login`. It sends the app's origin from `local/app.env`, the public half of `local/app-key.json` and the permissions you pass with `--scope`, and writes the client ID and issuer it gets back into `local/app.env`. `crtr:llm` and `crtr:act` are always requested (not yet checked on production).
54
+ Run it in the project `init` made, after `login`. It sends the app's origin from `local/app.env`, the public half of `local/app-key.json` and the permissions you pass with `--scope`, and writes the client ID and issuer it gets back into `local/app.env`. `crtr:llm` and `crtr:act` are always requested.
41
55
 
42
56
  ### `apps`
43
57
 
@@ -46,9 +46,11 @@ An agent that has no project yet can read the hosted copy:
46
46
 
47
47
  The agent runs `npx @north-light/sdk@latest init`, then `npx @north-light/sdk login`, which prints a link and a code. It shows you both. You open the link, check the page shows the same code, and choose **Allow**. The agent then runs `npx @north-light/sdk register --scope sms:send`, installs, starts the app, and asks you to open `/auth/login` and choose **Allow** on the consent page. You sign up for Northlight first. The hosted copy matches the SDK version the docs site was last built from.
48
48
 
49
- If the agent's shell call times out while you approve, it runs `login` again and the same code resumes. If it cannot reach `account.northlight.ai`, it falls back to asking you to register in the console.
49
+ If the agent's shell call times out or is interrupted while you approve, it runs `login` again and the same code resumes. If you approved already, `login` says "Already logged in" and exits 0: the agent goes on to `register`. A code left unapproved for 10 minutes expires; `login` then exits 1 saying the code expired, and the agent runs it again for a new code and asks you to approve that one. Only a message that says it could not reach `account.northlight.ai` means a network problem, and even then `login` says the code is still saved and can be resumed. An expired code or a failed run is not a reason to fall back to the console.
50
50
 
51
- Checked with Claude Code (`claude -p`, no crtr on `PATH`, an empty directory) before `login` existed: given only the skill URL plus "I want the quickstart app: sign in with Northlight, start a run, text me", it ran `init my-app`, read the generated `server.ts`, and stopped with the console steps and the public key for you to paste. Name what you want built in the prompt. The journey with `login` and `register` has not been run by an agent on production (not yet checked on production).
51
+ The skill tells the agent to read the whole message `login` printed and its exit code before it tells you what happened.
52
+
53
+ Checked on production with Claude Code (an interactive session, no crtr on `PATH`, an empty directory), given only the skill URL plus the quickstart request: a fresh session ran `init`, `login`, `npm i`, `register --scope sms:send` and `npm start` by itself. The person's part was choosing **Allow** on the login page and on the app's consent page, and pressing the button; the run replied `pong` and the text was sent (whether it reached the phone is not checked). When the first login code expired unapproved, that session said so and ran `login` again for a new code. In an earlier session on the same day, before this guidance, the agent misread a failed second `login` as a network timeout and fell back to the console steps. Name what you want built in the prompt.
52
54
 
53
55
  ## What the login can do
54
56
 
@@ -57,19 +59,19 @@ Checked with Claude Code (`claude -p`, no crtr on `PATH`, an empty directory) be
57
59
  | It can | It cannot |
58
60
  |---|---|
59
61
  | Register new apps (at most 10 a day) | Delete an app |
60
- | Change a name, origin, logo and description | List an app in the marketplace, or change its spend cap or sharing |
62
+ | Change a name, origin, logo and description, upload a logo image, set the brand colour (also on a verified app) | List an app in the marketplace, or change its spend cap or sharing |
61
63
  | Change what an app asks for and requires | Set the provider role |
62
64
  | Add and remove testers | See private keys, or use your runtime, your AI subscription, your texts or your data |
63
65
  | Change signing keys, redirect URIs and the run notice URL on an app in testing | Change those on a verified app: do it in the console |
64
66
  | | Approve another login |
65
67
 
66
- Deleting an app, its listing, spend cap and sharing stay in the console on purpose. The CLI refuses them without sending a request, and the directory refuses them if a call is made by hand.
68
+ Deleting an app, its listing, spend cap and sharing stay in the console on purpose. The CLI refuses them without sending a request, and the directory refuses them (403 `provider_refused`) if a call is made by hand. Checked on production for delete, the marketplace listing and the spend cap.
67
69
 
68
- The login lives in `~/.config/northlight/credentials.json` (`$XDG_CONFIG_HOME/northlight/credentials.json` when set), mode `0600`, outside your project. The CLI refuses a file that other users can read. It lasts 30 days, or until it has gone 14 days unused, and then you run `login` again. An account holds at most 10 logins; an eleventh replaces the oldest.
70
+ The login lives in `~/.config/northlight/credentials.json` (`$XDG_CONFIG_HOME/northlight/credentials.json` when set), mode `0600`, outside your project. The CLI refuses a file that other users can read. It lasts 30 days, or until it has gone 14 days unused, and then you run `login` again. An account holds at most 10 logins; an eleventh replaces the oldest. The Developer access page shows the date the login goes unused, "Expires Oct 31" for a login approved on Oct 17, not the 30-day date the CLI prints.
69
71
 
70
- Revoke it on your account's Developer access page (`https://account.northlight.ai/account/developer`), or run `npx @north-light/sdk logout`, which revokes it on the server and deletes the file. The next command then fails and asks for `login`.
72
+ Revoke it on your account's Developer access page (`https://account.northlight.ai/account/developer`), or run `npx @north-light/sdk logout`, which revokes it on the server and removes it from the file. The file is not deleted: it is rewritten as `{"version":1,"issuers":{}}`, with no secret in it. A revoked login stops working at once: `whoami` and every other command fail with "This login was revoked" and ask for `login`, and a token minted before the revoke is refused with 401 `credential_revoked` even before it expires. Checked on production: the Developer access page, Revoke, `logout`, and what the next command prints.
71
73
 
72
- Not yet checked on production: every statement in this section, including the limits and the page.
74
+ Not yet checked on production: the 10-login limit and the eleventh login replacing the oldest, and the 14-day and 30-day expiries.
73
75
 
74
76
  ## How it stays current
75
77
 
@@ -51,6 +51,7 @@ Until you retire the old key it is your rollback. After you retire it, a sign-in
51
51
  ## Profile
52
52
 
53
53
  - **How people see your app**: Name, Origin URL, Logo URL, and **Why your app needs access** (quoted on the consent page as your words). **Save details**. From the terminal: `apps update`.
54
+ - **Logo image and brand colour from the terminal.** `apps update --logo <url>` sets the logo URL. `apps set-logo --file <path>` uploads an image instead (PNG, JPEG, WebP or SVG, square, at least 256x256, at most 512 KB), and `apps set-brand-color --color <#rrggbb>` sets the colour (`--color none` clears it). Both work on testing and verified apps.
54
55
  - **Marketplace**: **Listed in the marketplace**, Price, **Sign-in start URL** and **Text welcome**. Agents can install a listed app for a person by text. A testing app stays out of the marketplace even when listed. The sign-in start URL is where Install sends people; your `/auth/login` route is a valid one, and a `localhost` URL is accepted. The text welcome is one or two plain sentences, at most 120 characters, with no links.
55
56
  - **Delete this app.** At the bottom of the tab; see [Delete an app](#delete-an-app).
56
57
 
@@ -89,7 +89,9 @@ and "Done. Go back to your terminal." in the browser. The name, id and dates are
89
89
 
90
90
  The login is saved outside your project, in `~/.config/northlight/credentials.json` (mode `0600`). Never commit, paste or print it.
91
91
 
92
- Not yet checked on production: this whole step, including the confirm page and its wording (not yet checked on production). The command, its output and its files are checked against a fake directory in the package's tests.
92
+ Checked on production: this whole step, including the confirm page and its wording. The confirm page reads "Let a coding agent manage your Northlight apps?", shows the code, the computer's name and platform and the address it asked from ("The computer named itself; Northlight did not check it"), and lists what the login can and can't do. After **Allow** the page reads "Done / Go back to your terminal." and the terminal prints the output above.
93
+
94
+ If you run `login` again, it does not start over. While a code is pending it prints "Still waiting for the login started at HH:MM." with the same link and code. Once you are logged in it prints "Already logged in as …" and exits 0. If the code expired unapproved it exits 1 and says so: run it again for a new code. See [the CLI reference](/docs/cli#login-logout-whoami).
93
95
 
94
96
  If `login` cannot reach `account.northlight.ai`, or you would rather use the browser, skip to [Register in the browser instead](#register-in-the-browser-instead).
95
97
 
@@ -99,7 +101,7 @@ If `login` cannot reach `account.northlight.ai`, or you would rather use the bro
99
101
  npx @north-light/sdk register --scope sms:send
100
102
  ```
101
103
 
102
- Run it in the folder `init` made. It registers the app with the public key from `local/app-key.json`, the name from `package.json`, the origin from `local/app.env` and the redirect URI `<origin>/auth/callback`, then adds `sms:send` to what the app asks for. A new app starts asking for `crtr:llm` and `crtr:act`. The server in this guide also asks for `sms:send`, so it needs the `--scope`. Take scope strings from `npx @north-light/sdk apps asks` or from `providers.tools`. Do not invent them. See [Scopes and grants](/docs/concepts/scopes-and-grants).
104
+ Run it in the folder `init` made. With no dependencies installed, `npx` downloads the newest published release of the SDK, not the version your `package.json` pins, so run `npm i` first if the pinned version should run. It registers the app with the public key from `local/app-key.json`, the name from `package.json`, the origin from `local/app.env` and the redirect URI `<origin>/auth/callback`, then adds `sms:send` to what the app asks for. A new app starts asking for `crtr:llm` and `crtr:act`. The server in this guide also asks for `sms:send`, so it needs the `--scope`. Take scope strings from `npx @north-light/sdk apps asks` or from `providers.tools`. Do not invent them. See [Scopes and grants](/docs/concepts/scopes-and-grants).
103
105
 
104
106
  You should see:
105
107
 
@@ -116,7 +118,7 @@ Next: npm i && npm start, then open http://localhost:3000/auth/login
116
118
 
117
119
  `local/app.env` now has all four settings. The name, client ID and key ID are examples. Change the defaults with `--name`, `--logo`, `--justification` and `--redirect-uri`; the default logo is a placeholder, `https://sdk.docs.northlight.ai/app-logo.png`. `register` refuses when `CROUTER_CLIENT_ID` is already set. A login can register at most 10 apps a day.
118
120
 
119
- Not yet checked on production: this step, including that the directory accepts the default logo (not yet checked on production). Its output and its effect on `local/app.env` are checked against a fake directory in the package's tests.
121
+ Checked on production: this step, including that the directory accepts the default logo.
120
122
 
121
123
  ## 5. Run it
122
124
 
package/docs/ci-runner.md CHANGED
@@ -108,7 +108,7 @@ The hourly `17 * * * *` schedule and the `promote`, `api_ref`, `env_docker_ref`
108
108
 
109
109
  | Event | ubuntu `checks` + `select` | ubuntu `linux` (container) | `macos` (laptop-only tests) |
110
110
  | --- | --- | --- | --- |
111
- | pull request push | yes | the selected files, sharded to ~4 min; none for a docs-only change | the selected files that carry `laptopOnly` tags, if any |
111
+ | pull request push | yes | the selected files (narrowed by Preflight's pick when there is one), sharded to ~4 min; none for a docs-only change | the selected files that carry `laptopOnly` tags, if any |
112
112
  | pull request with the `full-ci` label, or a change whose path forces it | yes | all of it | all of it |
113
113
  | merge queue run (`merge_group`; inert until a queue ruleset exists) | yes | all of it | all of it |
114
114
  | `workflow_dispatch` (`gh workflow run test.yml --ref <branch>`) | yes | all of it | all of it |
@@ -129,4 +129,5 @@ On a pull request push the selector turns the diff against the merge base into p
129
129
  - **Path rules** are in `classify()`; anything not named there under `src/` or `packages/` that is not TypeScript, and any unrecognized path, forces `full`. A no-test rule never beats evidence that a test reads the path: a file in some test's or the CLI's import graph selects the tests that reach it even under `scripts/` (e.g. `scripts/proof/stub-provider/*.ts`, reached through the plugin test fixture); a script whose path or file name a test mentions forces `full`; and a `bin/**` shim selects the CLI closure and its spawners plus every test that names the shim (`spawn-env.test.ts` runs `./bin/runtime-generation-manifest.mjs`).
130
130
  - **Shard sizing** uses `scripts/ci-select/timing.json`: per-file local work in seconds from one measured local run, times a per-lane factor that scales it to the Linux test-step totals, targeting 240 s per shard. A committed table, not a runtime measurement, because it must exist before the first selected run and cannot come from a run that is being skipped; a file missing from the table (a new test) costs its lane's median, which only skews shard balance. Selection never reads it.
131
131
  - `node scripts/ci-select.mjs --replay scripts/ci-select/replay-prs.json` replays the 44 merged PRs #1–#65 through the selector as shipped, path rules included: this is the projection to quote. Over those PRs it runs a Linux lane on 34/44 (full suite: 44/44), at 26.0 billed ubuntu minutes per PR (full suite 39), 3.0 macOS-equivalent cost including the 4 minutes of `checks` (full suite 4.3), 564 s of laptop-only local work on the minis (759), 9.6 hosted-fallback macOS minutes (13); 8 PRs are docs-only and select no lane.
132
+ - **Preflight's test pick can narrow a selected run.** Preflight's triage run (the `tests` block of `.github/preflight.yml`) publishes the check run `Preflight test pick` on the PR head commit, written by Preflight's GitHub App (app id 5351829, so a workflow cannot forge it). After selecting, and only on a `pull_request` in mode `selected` (no `full-ci` or `no-test-pick` label, not a fork), `ci-select.mjs` polls that check run for `github.event.pull_request.head.sha` (not the merge ref) with the job's `GITHUB_TOKEN` (the repo's default token permissions are the restricted set without `checks`, which answers HTTP 403, so the `select` job declares `contents: read` and `checks: read`, and publish.yml's `tests` job grants `checks: read` too because a called workflow cannot exceed its caller). It waits at most 120 s for a completed pick, gives up after 45 s when no such check run exists, and stops at once on `outcome: none`. A valid pick keeps `(selected ∩ pick) ∪ (selected test files the PR changed)`, re-plans the shards from them, and gives mode `none` when nothing is left. It never adds a file and never touches `checks`. Any HTTP error, bad JSON, unknown schema, wrong head SHA or timeout keeps the script's set, and the log says so (e.g. `no Preflight test pick for <sha> after 45 s; using the script's set`). Mode `full` stays `full`, a narrowed run stays `selected` (so it never records a tested tree and the release `gate` still needs a full-suite record), and merge_group, push, schedule, dispatch and Publish's call never consult it. Opt a PR out with `gh pr edit <n> --add-label no-test-pick`. Evidence: the `select` step summary opens with `Preflight test pick: used|not used`, and `selection.json` (artifact `selection`) has a `pick` object with `used`, `why`, `before` and `after` file counts per lane (linux and macOS), `billed` estimated minutes before and after, and `dropped`, the files the pick removed. The logic and its tests are `scripts/ci-test-pick-lib.mjs` and `scripts/ci-test-pick.test.mjs` (in `test:contract`).
132
133
  - `--static-only` is a **counterfactual**, not the selector: it drops every path rule, so a workflow, manifest, helper or unrecognized path selects nothing instead of `full`. It exists only to compare with the design's replay figures, which used the import-graph-plus-spawner model alone (30/44 PRs, 21.9 ubuntu minutes). Do not read its numbers as what the selector does.
@@ -7,6 +7,23 @@ description: Behavior changes in @north-light/sdk that an app must act on, newes
7
7
 
8
8
  Changes to `@north-light/sdk` that can change what an existing app sees. Newest first.
9
9
 
10
+ ## 0.3.488
11
+
12
+ ### Developer CLI: `--help` exits 0, `login` is safe to re-run (CLI only, no app change)
13
+
14
+ - `npx @north-light/sdk <command> --help` and `-h` print the command's usage and exit 0 for every command. A bad flag still exits 1.
15
+ - `login` run again while this computer is logged in prints `Already logged in as …` and exits 0 instead of starting a second login (`--new` still does). A failed poll (a dropped connection, a sleeping laptop) is retried for up to 3 minutes; if the directory is still unreachable, `login` exits 1 and says the code is still saved and to run `login` again. It no longer ends on the first failed poll with a console fallback message.
16
+ - `init` writes `"allowScripts": {"esbuild": false}` into a new app's `package.json`, so npm 11.20+ no longer warns that esbuild's postinstall script is not allowed.
17
+ - `apps set-logo --file <path>` uploads a logo image (PNG, JPEG, WebP or SVG, square, at least 256x256, at most 512 KB) and `apps set-brand-color --color <#rrggbb or none>` sets or clears the brand colour. Both work on testing and verified apps. The directory side must be live first: until it is, they answer with the directory's refusal.
18
+ - `apps set-listing`, `set-spend-cap` and `set-allows` refuse with the same plain wording as the docs. They still make no request.
19
+
20
+ ### `docs`: an app writes, reads and edits documents it owns (additive, no app change)
21
+
22
+ - `docs.write({name, body, preview, kind?, rules?, privacy?, summary?})` creates a document owned by your app and returns its canonical `name` (`<app>/<name>`). `kind: 'preference'` with `rules: [{on: 'boot', deliver: 'content'}]` puts the body into the context of every node at boot. A name already taken throws `ConflictError` (`name_taken`).
23
+ - `docs.get(name)` returns `{body, revision, base, rules, kind, owner, needsMerge, …}`; no such document throws `NotFoundError`.
24
+ - `docs.edit(name, {body | replace, rationale, base?, …})` changes it in place and returns the new `revision`. Pass the `base` from `docs.get`; a base that is no longer current throws `ConflictError` (`stale_revision`) and writes nothing.
25
+ - Served on the app listener, so `oauth.client(userId)` offers it. An app can write only as itself (`owner: 'app'`, the default), and needs no scope for documents it owns.
26
+
10
27
  ## 0.3.487
11
28
 
12
29
  ### Run notices and the app listener verify one call-token shape (additive, no app change)
@@ -28,6 +28,7 @@ Every namespace exported by the client. Namespaces are camelCase. Verbs are `cre
28
28
  | `models.config` | `update` | `PUT /v1/model-config` |
29
29
  | `customObjects` | `register`, `publish`, `unregister`, `ack`, `receive` | `/v1/custom-objects…` |
30
30
  | `memory` | `list`, `listAll`, `get` | `GET /v1/memory`, `GET /v1/memory/document` |
31
+ | `docs` | `write`, `edit`, `get` | `POST /v1/docs`, `PATCH /v1/docs/:ref`, `GET /v1/objects/:ref?fields=1` |
31
32
  | `runs` | `start`, `get`, `list`, `listAll`, `message`, `interrupt`, `cancel`, `delete`, `rename`, `trace`, `events`, `reply`, `stream`, `wait`, `parse` | `/v1/runs…` |
32
33
  | `uploads` | `create` | `POST /v1/uploads` |
33
34
  | `attachments` | `link` | `GET /v1/attachments/link` |
@@ -37,7 +38,7 @@ Every namespace exported by the client. Namespaces are camelCase. Verbs are `cre
37
38
  | `grant` | `get` (`wait`, `since`: hold until `version` changes) | `GET /v1/grant` |
38
39
  | `health` | `get` | `GET /v1/health` |
39
40
 
40
- `runs`, `uploads`, `attachments`, `shares`, `questions` and the app side of `grant` and `providers` are the application-facing API an app reaches through the app listener; `oauth.client(userId)` returns a client typed to exactly that set (`AppClient`); `nodes`, `bash`, `canvas`, `crons`, `human`, `models`, `customObjects`, `structuredRequests`, `system`, `auth`, `files.read/write/list` and `profiles.ensure/retrieve` are the owner's own daemon and are not served to an app. An app schedules work with its own timer calling `runs.start`, or a run's agent runs `crtr cron add` under `crtr:schedule`. See [Apps on Northlight](./guides/apps-on-northlight.md) and [Apps with many users](./guides/apps-with-many-users.md). `memory` is the person's `public` documents ([Memory](./memory.md)). `system.health` (`/healthz`) is the daemon's liveness check; `health.get` is the app listener's.
41
+ `runs`, `uploads`, `attachments`, `shares`, `questions`, `docs` and the app side of `grant` and `providers` are the application-facing API an app reaches through the app listener; `oauth.client(userId)` returns a client typed to exactly that set (`AppClient`); `nodes`, `bash`, `canvas`, `crons`, `human`, `models`, `customObjects`, `structuredRequests`, `system`, `auth`, `files.read/write/list` and `profiles.ensure/retrieve` are the owner's own daemon and are not served to an app. An app schedules work with its own timer calling `runs.start`, or a run's agent runs `crtr cron add` under `crtr:schedule`. See [Apps on Northlight](./guides/apps-on-northlight.md) and [Apps with many users](./guides/apps-with-many-users.md). `memory` is the person's `public` documents ([Memory](./memory.md)). `docs` writes, reads and edits documents the calling app owns: `docs.write({name, body, preview, kind?, rules?})` returns the canonical `name`; `docs.get(name)` returns `body`, `revision`, `rules` and the edit `base`; `docs.edit(name, {body, rationale, base?})` changes it in place and refuses a stale `base` with `ConflictError` (`stale_revision`). To keep one document current, `get` it (catch `NotFoundError` on first run to `write`), compare `body` to your text, and `edit` only when they differ. `system.health` (`/healthz`) is the daemon's liveness check; `health.get` is the app listener's.
41
42
 
42
43
  Action methods keep the product's literal name (`fork`, `revive`, `promote`, `yield`) rather than being renamed into a generic verb. Every request-capable method takes `RequestOptions` as its final argument after its path, body, or query arguments. `RequestOptions` is `{ headers?, signal?, timeout?, maxRetries? }`; stream event options exclude `timeout` and add `after`.
43
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/cli",
3
- "version": "0.3.487",
3
+ "version": "0.3.489",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -99,7 +99,7 @@
99
99
  "test:runtime-packaging": "node --test scripts/runtime-smoke.test.mjs scripts/pack-args.test.mjs scripts/ensure-native-linux.test.mjs scripts/release-version.test.mjs",
100
100
  "contract:check": "node scripts/contract.mjs check",
101
101
  "contract:surface": "node scripts/contract.mjs generate",
102
- "test:contract": "node --conditions=crtr-src --import tsx/esm --test scripts/contract.test.mjs scripts/promote-sdk.test.mjs scripts/release-version.test.mjs scripts/sdk-docs-commands.test.mjs scripts/ci-image.test.mjs scripts/ci-miss-triage.test.mjs",
102
+ "test:contract": "node --conditions=crtr-src --import tsx/esm --test scripts/contract.test.mjs scripts/promote-sdk.test.mjs scripts/release-version.test.mjs scripts/sdk-docs-commands.test.mjs scripts/ci-image.test.mjs scripts/ci-miss-triage.test.mjs scripts/ci-test-pick.test.mjs",
103
103
  "test:build-output": "node scripts/build-concurrency-test.mjs",
104
104
  "check:cli-no-canvas": "node scripts/check-cli-no-canvas.mjs",
105
105
  "check:cli-cold-start": "node scripts/check-cli-cold-start.mjs",
@@ -137,7 +137,7 @@
137
137
  },
138
138
  "license": "UNLICENSED",
139
139
  "dependencies": {
140
- "@north-light/identity": "0.3.487",
140
+ "@north-light/identity": "0.3.489",
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.487",
3
+ "version": "0.3.489",
4
4
  "description": "Northlight access-token verification and scope rules",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",