thinkncollab-shell 2.0.0-beta.5 → 2.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/Readme.md +64 -17
  2. package/dist/bin/thinknsh.js +1 -1
  3. package/dist/src/commands/auth/auth.js +7 -0
  4. package/dist/src/commands/auth/login.js +1 -1
  5. package/dist/src/commands/auth/logout.js +1 -1
  6. package/dist/src/commands/auth/register.js +1 -1
  7. package/dist/src/commands/auth/whoami.js +1 -1
  8. package/dist/src/commands/dock/cluster.js +1 -1
  9. package/dist/src/commands/dock/containers.js +1 -1
  10. package/dist/src/commands/index.js +1 -1
  11. package/dist/src/commands/room/connect.js +7 -0
  12. package/dist/src/commands/room/join.js +1 -1
  13. package/dist/src/commands/room/sync.js +1 -1
  14. package/dist/src/commands/sandbox/common.js +7 -0
  15. package/dist/src/commands/sandbox/console.js +1 -1
  16. package/dist/src/commands/sandbox/dep.js +1 -1
  17. package/dist/src/commands/sandbox/deploy.js +1 -1
  18. package/dist/src/commands/sandbox/dev.js +1 -1
  19. package/dist/src/commands/sandbox/exec.js +1 -1
  20. package/dist/src/commands/sandbox/push.js +1 -1
  21. package/dist/src/commands/sandbox/sandbox.js +1 -1
  22. package/dist/src/commands/sandbox/share.js +1 -1
  23. package/dist/src/commands/sandbox/snapshot.js +1 -1
  24. package/dist/src/commands/shell/doctor.js +1 -1
  25. package/dist/src/commands/tasks/build.js +1 -1
  26. package/dist/src/commands/terminal/share.js +1 -1
  27. package/dist/src/core/api-client.js +1 -1
  28. package/dist/src/core/auth/browser-login.js +7 -0
  29. package/dist/src/core/auth/gate.js +7 -0
  30. package/dist/src/core/auth/session-store.js +7 -0
  31. package/dist/src/core/crypto/seal.js +7 -0
  32. package/dist/src/core/dependency-tracker.js +1 -1
  33. package/dist/src/core/foreground.js +7 -0
  34. package/dist/src/core/platform-socket.js +7 -0
  35. package/dist/src/core/room/sync-manager.js +7 -0
  36. package/dist/src/core/room-link.js +7 -0
  37. package/dist/src/core/shell.js +1 -1
  38. package/dist/src/core/{sandbox/snapshot-manager.js → snapshot-manager.js} +1 -1
  39. package/dist/src/core/tunnel/client.js +7 -0
  40. package/dist/src/core/tunnel/daemon.js +7 -0
  41. package/dist/src/core/tunnel/index.js +7 -0
  42. package/dist/src/core/websocket.js +1 -1
  43. package/dist/src/core/window-launcher.js +7 -0
  44. package/dist/src/dock/bench/memory.js +1 -1
  45. package/dist/src/dock/cli/commands.js +1 -1
  46. package/dist/src/dock/cli/k8s.js +7 -0
  47. package/dist/src/dock/cli/services.js +1 -1
  48. package/dist/src/dock/cli/session.js +1 -1
  49. package/dist/src/dock/cli/workspace.js +1 -1
  50. package/dist/src/dock/engine.js +1 -1
  51. package/dist/src/dock/image/certs.js +7 -0
  52. package/dist/src/dock/image/registry.js +1 -1
  53. package/dist/src/dock/room-data.js +7 -0
  54. package/dist/src/dock/runtime/network.js +1 -1
  55. package/dist/src/dock/sandbox.js +7 -0
  56. package/dist/src/dock/setup/index.js +1 -1
  57. package/dist/src/dock/workspace/share.js +7 -0
  58. package/dist/src/dock/workspace/workspace.js +1 -1
  59. package/package.json +1 -1
  60. package/dist/src/core/sandbox/arch-shim.js +0 -7
  61. package/dist/src/core/sandbox/capsule-builder.js +0 -7
  62. package/dist/src/core/sandbox/cgroups.js +0 -7
  63. package/dist/src/core/sandbox/container-monitor.js +0 -7
  64. package/dist/src/core/sandbox/crun-manager.js +0 -7
  65. package/dist/src/core/sandbox/engine.js +0 -7
  66. package/dist/src/core/sandbox/image-manager.js +0 -7
  67. package/dist/src/core/sandbox/image-store.js +0 -7
  68. package/dist/src/core/sandbox/index.js +0 -7
  69. package/dist/src/core/sandbox/macos-sandbox.js +0 -7
  70. package/dist/src/core/sandbox/microvm-manager.js +0 -7
  71. package/dist/src/core/sandbox/native-service-manager.js +0 -7
  72. package/dist/src/core/sandbox/network.js +0 -7
  73. package/dist/src/core/sandbox/oci-exporter.js +0 -7
  74. package/dist/src/core/sandbox/oci-puller.js +0 -7
  75. package/dist/src/core/sandbox/oci-runtime-spec.js +0 -7
  76. package/dist/src/core/sandbox/port-proxy-daemon.js +0 -7
  77. package/dist/src/core/sandbox/room-sync-manager.js +0 -7
  78. package/dist/src/core/sandbox/runtime-manager.js +0 -7
  79. package/dist/src/core/sandbox/storage.js +0 -7
  80. package/dist/src/core/sandbox/supervisor.js +0 -7
  81. package/dist/src/core/sandbox/tunnel-daemon.js +0 -7
  82. package/dist/src/core/sandbox/tunnel.js +0 -7
  83. package/dist/src/core/sandbox/window-launcher.js +0 -7
  84. package/dist/src/core/sandbox/windows-wsl2.js +0 -7
  85. package/dist/src/core/sandbox-window.js +0 -8
  86. /package/dist/src/core/{sandbox/policy-engine.js → policy-engine.js} +0 -0
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const fs=require("fs"),os=require("os"),path=require("path"),chalk=require("chalk"),{DockApi,EngineError,demuxer,qs,enc}=require("./api"),{parseArgs,parseBytes,parseDuration,parseEnvList,parseLabels,UsageError}=require("./args"),fmt=require("./format"),{pipeSession}=require("./session"),{packContext}=require("./context"),{BUILD_FILE,BUILD_IGNORE_FILE,BUILD_FILE_PARAM}=require("../api/wire");function readEnvFile(e){const t=[];for(const s of fs.readFileSync(e,"utf8").split(/\r?\n/)){const e=s.trim();e&&!e.startsWith("#")&&t.push(e.replace(/^export\s+/,""))}return t}function parsePublish(e){const t={},s=e=>{const t=/^(\d+)(?:-(\d+))?$/.exec(e);if(!t)return null;const s=Number(t[1]),n=t[2]?Number(t[2]):s;return n>=s&&n-s<1e4?Array.from({length:n-s+1},(e,t)=>s+t):null};for(const n of e){const[e,r="tcp"]=n.split("/");if(!["tcp","udp"].includes(r))throw new UsageError(`invalid protocol "${r}" in "${n}" (tcp or udp)`);let o="",i=e;const a=/^\[([^\]]+)\]:(.*)$/.exec(e);a&&(o=a[1],i=a[2]);const l=i.split(":"),c=l.pop(),p=l.pop()||"";a||(o=l.join(":")||"");const u=s(c),h=p?s(p):null;if(!u||p&&!h)throw new UsageError(`invalid port "${n}" (examples: -p 8080:80, -p 5353:53/udp, -p 8000-8002:8000-8002)`);if(h&&u.length>1&&h.length!==u.length)throw new UsageError(`port ranges in "${n}" must have the same length`);u.forEach((e,s)=>{const n=h?u.length>1?String(h[s]):h.length>1?`${h[0]}-${h[h.length-1]}`:String(h[0]):"";(t[`${e}/${r}`]||=[]).push({HostIp:o,HostPort:n})})}return t}function parseMountFlag(e){const t={Type:"volume"};for(const s of String(e).split(",")){if(!s)continue;const e=s.indexOf("="),n=(-1===e?s:s.slice(0,e)).toLowerCase(),r=-1===e?"true":s.slice(e+1);if("type"===n)t.Type=r;else if(["source","src"].includes(n))t.Source=r;else if(["target","destination","dst"].includes(n))t.Target=r;else if(["readonly","ro"].includes(n))t.ReadOnly=!["false","0"].includes(r);else if("tmpfs-size"===n)t.TmpfsOptions={...t.TmpfsOptions||{},SizeBytes:parseBytes(r)};else{if("tmpfs-mode"!==n){if("bind-propagation"===n||"consistency"===n||"volume-nocopy"===n)continue;throw new UsageError(`unknown --mount option "${n}"`)}t.TmpfsOptions={...t.TmpfsOptions||{},Mode:parseInt(r,8)}}}if(!t.Target)throw new UsageError(`--mount needs target=… (${e})`);if(!["bind","volume","tmpfs"].includes(t.Type))throw new UsageError(`--mount type must be bind, volume or tmpfs (${e})`);if("bind"===t.Type){if(!t.Source)throw new UsageError(`--mount type=bind needs source=… (${e})`);t.Source=resolveBind(`${t.Source}:/x`).slice(0,-3)}return t}function parseUlimit(e){const t=/^([a-z]+)=(-?\d+)(?::(-?\d+))?$/.exec(String(e));if(!t)throw new UsageError(`invalid ulimit "${e}" (example: --ulimit nofile=1024:2048)`);return{Name:t[1],Soft:Number(t[2]),Hard:Number(t[3]??t[2])}}function gpuRequest(e){const t={count:[],device:[],capabilities:[],driver:[]};let s="count";for(const n of String(e).replace(/"/g,"").split(",").map(e=>e.trim()).filter(Boolean)){const e=/^(count|device|capabilities|driver)=(.*)$/.exec(n);e?(s=e[1],t[s].push(e[2])):t[s].push(n)}const n=t.count[0];if(!t.device.length&&!/^(all|\d+)$/.test(n||""))throw new UsageError(`invalid --gpus value "${e}" (all, a number, or device=0,1)`);return{Driver:t.driver[0]||"nvidia",Count:t.device.length?0:"all"===n?-1:Number(n),DeviceIDs:t.device,Capabilities:[["gpu",...t.capabilities]]}}function parseDevice(e){const[t,s,n]=String(e).split(":");return{PathOnHost:t,PathInContainer:s||t,CgroupPermissions:n||"rwm"}}function resolveBind(e){if(/^[A-Za-z]:[\\/]/.test(e))return e;const t=e.indexOf(":");if(-1===t)return e;let s=e.slice(0,t);return"."===s||s.startsWith("./")||s.startsWith("../")||s.startsWith(".\\")?s=path.resolve(s):("~"===s||s.startsWith("~/"))&&(s=path.join(os.homedir(),s.slice(1))),s+e.slice(t)}function restartPolicy(e){if(!e||"no"===e)return{Name:"no"};const[t,s]=e.split(":");if(!["always","unless-stopped","on-failure"].includes(t))throw new UsageError(`invalid restart policy "${e}" (no, always, unless-stopped, on-failure[:N])`);return{Name:t,MaximumRetryCount:s?parseInt(s,10):0}}function filtersOf(e){const t={};for(const s of e){const e=s.indexOf("=");if(-1===e)throw new UsageError(`invalid filter "${s}" (expected key=value)`);(t[s.slice(0,e)]||=[]).push(s.slice(e+1))}return Object.keys(t).length?t:void 0}function splitRepoTag(e){const t=e.lastIndexOf("/"),s=e.lastIndexOf(":");return s>t?{repo:e.slice(0,s),tag:e.slice(s+1)}:{repo:e,tag:"latest"}}async function pullImage(e,t,{quiet:s=!1,err:n=process.stderr,platform:r}={}){const{repo:o,tag:i}=splitRepoTag(t.includes("@")?t.split("@")[0]:t),a=fmt.pullPrinter(n),l={...t.includes("@")?{fromImage:t.split("@")[0],tag:t.split("@")[1]}:{fromImage:o,tag:i},platform:r};await e.progress("POST",`/images/create${qs(l)}`,null,e=>{s||a.onMessage(e)}),a.done()}const isMissingImage=e=>e instanceof EngineError&&404===e.status&&/image/i.test(e.message);async function createContainer(e,t,s,{pull:n="missing",quiet:r=!1,err:o,platform:i}){"always"===n&&await pullImage(e,s.Image,{quiet:r,err:o,platform:i});try{return await e.json("POST",`/containers/create${qs({name:t,platform:i})}`,s)}catch(a){if(!isMissingImage(a)||"never"===n)throw a;return r||o.write(`Unable to find image '${s.Image}'${i?` for ${i}`:""} locally\n`),await pullImage(e,s.Image,{quiet:r,err:o,platform:i}),e.json("POST",`/containers/create${qs({name:t,platform:i})}`,s)}}const CREATE_FLAGS={name:{type:"string"},platform:{type:"string"},init:{type:"bool"},ulimit:{type:"list"},dns:{type:"list"},"dns-search":{type:"list"},"dns-option":{type:"list"},mount:{type:"list"},sysctl:{type:"list"},"memory-swap":{type:"string"},"memory-reservation":{type:"string"},"cpuset-cpus":{type:"string"},"cpu-shares":{short:"c",type:"string"},device:{type:"list"},gpus:{type:"string"},"volumes-from":{type:"list"},"group-add":{type:"list"},"security-opt":{type:"list"},"log-driver":{type:"string"},"log-opt":{type:"list"},"label-file":{type:"list"},cidfile:{type:"string"},publish:{short:"p",type:"list"},env:{short:"e",type:"list"},"env-file":{type:"list"},volume:{short:"v",type:"list"},tmpfs:{type:"list"},workdir:{short:"w",type:"string"},user:{short:"u",type:"string"},entrypoint:{type:"string"},network:{type:"string"},net:{type:"string"},"network-alias":{type:"list"},hostname:{type:"string"},restart:{type:"string"},memory:{short:"m",type:"string"},cpus:{type:"string"},"pids-limit":{type:"string"},"shm-size":{type:"string"},cgroupns:{type:"string"},userns:{type:"string"},privileged:{type:"bool"},"cap-add":{type:"list"},"cap-drop":{type:"list"},"read-only":{type:"bool"},"add-host":{type:"list"},label:{short:"l",type:"list"},"health-cmd":{type:"string"},"health-interval":{type:"string"},"health-timeout":{type:"string"},"health-retries":{type:"string"},"health-start-period":{type:"string"},"no-healthcheck":{type:"bool"},"stop-signal":{type:"string"},"stop-timeout":{type:"string"},pull:{type:"string",default:"missing"},interactive:{short:"i",type:"bool"},tty:{short:"t",type:"bool"},rm:{type:"bool"},quiet:{short:"q",type:"bool"}};function createBody(e,t,s){const n=[];for(const t of e["env-file"])n.push(...readEnvFile(t));n.push(...parseEnvList(e.env));const r=e.network||e.net;if(e.cgroupns&&!["host","private"].includes(e.cgroupns))throw new UsageError(`invalid --cgroupns "${e.cgroupns}" (private or host)`);if(e.userns&&"host"!==e.userns)throw new UsageError(`invalid --userns "${e.userns}" (host: leave out userns-remap for this container)`);let o;e["no-healthcheck"]?o={Test:["NONE"]}:(e["health-cmd"]||e["health-interval"]||e["health-timeout"]||e["health-retries"]||e["health-start-period"])&&(o={...e["health-cmd"]?{Test:["CMD-SHELL",e["health-cmd"]]}:{},...e["health-interval"]?{Interval:parseDuration(e["health-interval"])}:{},...e["health-timeout"]?{Timeout:parseDuration(e["health-timeout"])}:{},...e["health-retries"]?{Retries:parseInt(e["health-retries"],10)}:{},...e["health-start-period"]?{StartPeriod:parseDuration(e["health-start-period"])}:{}});const i=parsePublish(e.publish),a=e.gpus?[gpuRequest(e.gpus)]:[],l={};for(const t of e["label-file"])Object.assign(l,parseLabels(readEnvFile(t)));Object.assign(l,parseLabels(e.label));const c=parseLabels(e["log-opt"]);return{Image:t,...s.length?{Cmd:s}:{},...void 0!==e.entrypoint?{Entrypoint:""===e.entrypoint?[""]:[e.entrypoint]}:{},Env:n,Labels:l,...e.workdir?{WorkingDir:e.workdir}:{},...e.user?{User:e.user}:{},...e.hostname?{Hostname:e.hostname}:{},Tty:e.tty,OpenStdin:e.interactive,StdinOnce:e.interactive,AttachStdin:e.interactive,AttachStdout:!0,AttachStderr:!0,ExposedPorts:Object.fromEntries(Object.keys(i).map(e=>[e,{}])),...o?{Healthcheck:o}:{},...e["stop-signal"]?{StopSignal:e["stop-signal"]}:{},...e["stop-timeout"]?{StopTimeout:parseInt(e["stop-timeout"],10)}:{},HostConfig:{Binds:e.volume.map(resolveBind),PortBindings:i,...r?{NetworkMode:r}:{},RestartPolicy:restartPolicy(e.restart),AutoRemove:e.rm,...e.memory?{Memory:parseBytes(e.memory)}:{},...e.cpus?{NanoCpus:Math.round(1e9*parseFloat(e.cpus))}:{},...e["pids-limit"]?{PidsLimit:parseInt(e["pids-limit"],10)}:{},...e["shm-size"]?{ShmSize:parseBytes(e["shm-size"])}:{},...e.cgroupns?{CgroupnsMode:e.cgroupns}:{},...e.userns?{UsernsMode:e.userns}:{},Privileged:e.privileged,CapAdd:e["cap-add"],CapDrop:e["cap-drop"],ReadonlyRootfs:e["read-only"],ExtraHosts:e["add-host"],Tmpfs:Object.fromEntries(e.tmpfs.map(e=>{const t=e.indexOf(":");return-1===t?[e,""]:[e.slice(0,t),e.slice(t+1)]})),...e.init?{Init:!0}:{},...e.mount.length?{Mounts:e.mount.map(parseMountFlag)}:{},...e.ulimit.length?{Ulimits:e.ulimit.map(parseUlimit)}:{},...e.dns.length?{Dns:e.dns}:{},...e["dns-search"].length?{DnsSearch:e["dns-search"]}:{},...e["dns-option"].length?{DnsOptions:e["dns-option"]}:{},...e.sysctl.length?{Sysctls:parseLabels(e.sysctl)}:{},...e["memory-swap"]?{MemorySwap:"-1"===e["memory-swap"]?-1:parseBytes(e["memory-swap"])}:{},...e["memory-reservation"]?{MemoryReservation:parseBytes(e["memory-reservation"])}:{},...e["cpuset-cpus"]?{CpusetCpus:e["cpuset-cpus"]}:{},...e["cpu-shares"]?{CpuShares:parseInt(e["cpu-shares"],10)}:{},...e.device.length?{Devices:e.device.map(parseDevice)}:{},...a.length?{DeviceRequests:a}:{},...e["volumes-from"].length?{VolumesFrom:e["volumes-from"]}:{},...e["group-add"].length?{GroupAdd:e["group-add"]}:{},...e["security-opt"].length?{SecurityOpt:e["security-opt"]}:{},...e["log-driver"]||e["log-opt"].length?{LogConfig:{Type:e["log-driver"]||"json-file",Config:c}}:{}},...r&&e["network-alias"].length?{NetworkingConfig:{EndpointsConfig:{[r]:{Aliases:e["network-alias"]}}}}:{}}}async function resizeTo(e,t,s,n,r){await e.json("POST",`/${t}/${enc(s)}/resize${qs({h:n,w:r})}`).catch(()=>{})}async function attachAndWait(e,t,{stdin:s,tty:n,startIt:r,err:o}){const i=await e.hijack(`/containers/${enc(t)}/attach${qs({stream:1,stdout:1,stderr:1,stdin:s?1:0})}`),a=e.stream("POST",`/containers/${enc(t)}/wait${qs({condition:"next-exit"})}`);await a;const l=a.then(e=>DockApi._read(e));r&&await e.json("POST",`/containers/${enc(t)}/start`);const c=["SIGINT","SIGTERM","SIGHUP"],p=s=>e.json("POST",`/containers/${enc(t)}/kill${qs({signal:s})}`).catch(()=>{});n||c.forEach(e=>process.on(e,p));try{const{detached:r}=await pipeSession(i,{stdin:s,resize:n?(s,n)=>resizeTo(e,"containers",t,s,n):null});if(r)return{detached:!0,code:0};const a=await l,c="string"==typeof a?JSON.parse(a.trim().split("\n").pop()||"{}"):a;return c?.Error?.Message&&o.write(`${c.Error.Message}\n`),{detached:!1,code:c?.StatusCode??0}}finally{n||c.forEach(e=>process.removeListener(e,p))}}const RUN_FLAGS={...CREATE_FLAGS,detach:{short:"d",type:"bool"}};async function run(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,RUN_FLAGS,{stopAtPositional:!0});if(!o.length)throw new UsageError("run needs an image, e.g. thinknsh run -it alpine sh");if(r.tty&&!process.stdin.isTTY&&r.interactive)throw new UsageError("the input device is not a TTY; drop -t when piping input");const[i,...a]=o,l=createBody(r,i,a);if(r.detach&&(l.AttachStdout=l.AttachStderr=!1),r.cidfile&&fs.existsSync(r.cidfile))throw new UsageError(`container ID file found, make sure the other container isn't running or delete ${r.cidfile}`);const{Id:c}=await createContainer(t,r.name,l,{pull:r.pull,quiet:r.quiet,err:n,platform:r.platform});if(r.cidfile&&fs.writeFileSync(r.cidfile,c),r.detach)return await t.json("POST",`/containers/${c}/start`),s.write(`${c}\n`),0;const{detached:p,code:u}=await attachAndWait(t,c,{stdin:r.interactive,tty:r.tty,startIt:!0,err:n});return p&&s.write(`${fmt.shortId(c)}\n`),u}async function create(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,CREATE_FLAGS,{stopAtPositional:!0});if(!o.length)throw new UsageError("create needs an image");const[i,...a]=o,{Id:l}=await createContainer(t,r.name,createBody(r,i,a),{pull:r.pull,quiet:r.quiet,err:n,platform:r.platform});return r.cidfile&&fs.writeFileSync(r.cidfile,l),s.write(`${l}\n`),0}async function each(e,t,{out:s,err:n},r){if(!e.length)throw new UsageError(`${r} needs at least one container`);let o=0;for(const r of e)try{await t(r),s.write(`${r}\n`)}catch(e){o++,n.write(`Error: ${e.message}\n`)}return o?1:0}async function start(e,t){const{opts:s,args:n}=parseArgs(e,{attach:{short:"a",type:"bool"},interactive:{short:"i",type:"bool"}});if(s.attach||s.interactive){if(1!==n.length)throw new UsageError("start -a takes exactly one container");const e=await t.api.json("GET",`/containers/${enc(n[0])}/json`),{code:r}=await attachAndWait(t.api,e.Id,{stdin:s.interactive,tty:e.Config.Tty,startIt:!0,err:t.err});return r}return each(n,e=>t.api.json("POST",`/containers/${enc(e)}/start`),t,"start")}async function stop(e,t){const{opts:s,args:n}=parseArgs(e,{time:{short:"t",type:"string"},timeout:{type:"string"}}),r=s.time??s.timeout;return each(n,e=>t.api.json("POST",`/containers/${enc(e)}/stop${qs({t:r})}`),t,"stop")}async function restart(e,t){const{opts:s,args:n}=parseArgs(e,{time:{short:"t",type:"string"}});return each(n,e=>t.api.json("POST",`/containers/${enc(e)}/restart${qs({t:s.time})}`),t,"restart")}async function kill(e,t){const{opts:s,args:n}=parseArgs(e,{signal:{short:"s",type:"string",default:"SIGKILL"}});return each(n,e=>t.api.json("POST",`/containers/${enc(e)}/kill${qs({signal:s.signal})}`),t,"kill")}async function rm(e,t){const{opts:s,args:n}=parseArgs(e,{force:{short:"f",type:"bool"},volumes:{short:"v",type:"bool"}});return each(n,e=>t.api.json("DELETE",`/containers/${enc(e)}${qs({force:s.force,v:s.volumes})}`),t,"rm")}async function wait(e,{api:t,out:s}){const{args:n}=parseArgs(e,{});if(!n.length)throw new UsageError("wait needs at least one container");let r=0;for(const e of n){const n=await t.json("POST",`/containers/${enc(e)}/wait`);r=("string"==typeof n?JSON.parse(n.trim()):n).StatusCode,s.write(`${r}\n`)}return 0}async function ps(e,{api:t,out:s}){const{opts:n}=parseArgs(e,{all:{short:"a",type:"bool"},quiet:{short:"q",type:"bool"},filter:{short:"f",type:"list"},"no-trunc":{type:"bool"},json:{type:"bool"},last:{short:"n",type:"string"}}),r=await t.json("GET",`/containers/json${qs({all:n.all||void 0,filters:filtersOf(n.filter),limit:n.last})}`);if(n.json)return s.write(JSON.stringify(r,null,2)+"\n"),0;if(n.quiet){for(const e of r)s.write(`${n["no-trunc"]?e.Id:fmt.shortId(e.Id)}\n`);return 0}return fmt.table(["CONTAINER ID","IMAGE","COMMAND","CREATED","STATUS","PORTS","NAMES"],r.map(e=>{return[n["no-trunc"]?e.Id:fmt.shortId(e.Id),e.Image,`"${t=e.Command||"",n["no-trunc"]||t.length<=20?t:t.slice(0,19)+"…"}"`,fmt.ago(e.Created),e.Status,fmt.ports(e.Ports),(e.Names||[]).map(e=>e.replace(/^\//,"")).join(",")];var t}),{out:s}),0}async function logs(e,{api:t}){const{opts:s,args:n}=parseArgs(e,{follow:{short:"f",type:"bool"},tail:{short:"n",type:"string"},timestamps:{short:"t",type:"bool"},since:{type:"string"},until:{type:"string"}});if(1!==n.length)throw new UsageError("logs takes exactly one container");const r=await t.stream("GET",`/containers/${enc(n[0])}/logs${qs({stdout:1,stderr:1,follow:s.follow,tail:s.tail,timestamps:s.timestamps,since:s.since,until:s.until})}`),o=(r.headers["content-type"]||"").includes("raw-stream")?e=>process.stdout.write(e):demuxer(e=>process.stdout.write(e),e=>process.stderr.write(e));for await(const e of r)o(e);return 0}async function exec(e,{api:t,out:s}){const{opts:n,args:r}=parseArgs(e,{interactive:{short:"i",type:"bool"},tty:{short:"t",type:"bool"},detach:{short:"d",type:"bool"},env:{short:"e",type:"list"},"env-file":{type:"list"},workdir:{short:"w",type:"string"},user:{short:"u",type:"string"},privileged:{type:"bool"}},{stopAtPositional:!0});if(r.length<2)throw new UsageError("exec needs a container and a command, e.g. thinknsh exec -it web sh");const[o,...i]=r,a=[];for(const e of n["env-file"])a.push(...readEnvFile(e));a.push(...parseEnvList(n.env));const{Id:l}=await t.json("POST",`/containers/${enc(o)}/exec`,{Cmd:i,AttachStdin:n.interactive&&!n.detach,AttachStdout:!n.detach,AttachStderr:!n.detach,Tty:n.tty,Env:a,...n.workdir?{WorkingDir:n.workdir}:{},...n.user?{User:n.user}:{},Privileged:n.privileged});if(n.detach)return await t.json("POST",`/exec/${l}/start`,{Detach:!0,Tty:n.tty}),0;const c=await t.hijack(`/exec/${l}/start`,{Detach:!1,Tty:n.tty}),{detached:p}=await pipeSession(c,{stdin:n.interactive,resize:n.tty?(e,s)=>resizeTo(t,"exec",l,e,s):null});return p?0:(await t.json("GET",`/exec/${l}/json`)).ExitCode??0}async function inspect(e,{api:t,out:s}){const{opts:n,args:r}=parseArgs(e,{type:{type:"string"}});if(!r.length)throw new UsageError("inspect needs at least one name");const o={container:e=>`/containers/${enc(e)}/json`,image:e=>`/images/${e}/json`,volume:e=>`/volumes/${enc(e)}`,network:e=>`/networks/${enc(e)}`},i=n.type?[n.type]:Object.keys(o),a=[];let l=0;for(const e of r){let s=null;for(const n of i){if(!o[n])throw new UsageError(`unknown type "${n}" (container, image, volume, network)`);try{s=await t.json("GET",o[n](e));break}catch(e){if(!(e instanceof EngineError)||404!==e.status)throw e}}s?a.push(s):(l++,process.stderr.write(`Error: no such object: ${e}\n`))}return s.write(JSON.stringify(a,null,2)+"\n"),l?1:0}async function images(e,{api:t,out:s}){const{opts:n,args:r}=parseArgs(e,{quiet:{short:"q",type:"bool"},all:{short:"a",type:"bool"},filter:{short:"f",type:"list"},json:{type:"bool"},"no-trunc":{type:"bool"}}),o=filtersOf(n.filter)||{};r[0]&&(o.reference=[r[0]]);const i=await t.json("GET",`/images/json${qs({all:n.all||void 0,filters:Object.keys(o).length?o:void 0})}`);if(n.json)return s.write(JSON.stringify(i,null,2)+"\n"),0;const a=e=>n["no-trunc"]?e.Id:fmt.shortId(e.Id);if(n.quiet)return[...new Set(i.map(a))].forEach(e=>s.write(`${e}\n`)),0;const l=[];for(const e of i){const t=e.RepoTags?.length?e.RepoTags:["<none>:<none>"];for(const s of t){const{repo:t,tag:n}=splitRepoTag(s);l.push([t,n,a(e),fmt.ago(e.Created),fmt.size(e.Size)])}}return fmt.table(["REPOSITORY","TAG","IMAGE ID","CREATED","SIZE"],l,{out:s}),0}async function pull(e,{api:t,err:s}){const{opts:n,args:r}=parseArgs(e,{quiet:{short:"q",type:"bool"},platform:{type:"string"}});if(1!==r.length)throw new UsageError("pull takes one image, e.g. thinknsh pull postgres:16");return await pullImage(t,r[0],{quiet:n.quiet,err:s,platform:n.platform}),n.quiet&&process.stdout.write(`${r[0]}\n`),0}async function rmi(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,{force:{short:"f",type:"bool"}});if(!o.length)throw new UsageError("rmi needs at least one image");let i=0;for(const e of o)try{const n=await t.json("DELETE",`/images/${e}${qs({force:r.force})}`);for(const e of n||[])s.write(e.Untagged?`Untagged: ${e.Untagged}\n`:`Deleted: ${e.Deleted}\n`)}catch(e){i++,n.write(`Error: ${e.message}\n`)}return i?1:0}async function tag(e,{api:t}){const{args:s}=parseArgs(e,{});if(2!==s.length)throw new UsageError("tag takes SOURCE TARGET, e.g. thinknsh tag myapp:dev myapp:1.0");const{repo:n,tag:r}=splitRepoTag(s[1]);return await t.json("POST",`/images/${s[0]}/tag${qs({repo:n,tag:r})}`),0}async function readStdin(e=process.stdin){const t=[];for await(const s of e)t.push(s);return Buffer.concat(t).toString("utf8")}function promptLine(e,{err:t=process.stderr}={}){const s=require("readline").createInterface({input:process.stdin,output:t});return new Promise(t=>s.question(e,e=>{s.close(),t(e)}))}function promptHidden(e,{err:t=process.stderr}={}){return new Promise((s,n)=>{const r=process.stdin;t.write(e),r.setRawMode(!0),r.resume();let o="";const i=e=>{r.setRawMode(!1),r.pause(),r.removeListener("data",a),t.write("\n"),e instanceof Error?n(e):s(e)},a=e=>{for(const t of e.toString("utf8")){if("\r"===t||"\n"===t||""===t)return i(o);if(""===t)return i(new UsageError("login cancelled"));""===t||"\b"===t?o=o.slice(0,-1):o+=t}};r.on("data",a)})}async function login(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,{username:{short:"u",type:"string"},"password-stdin":{type:"bool"}});if(o.length>1)throw new UsageError("login takes at most one registry, e.g. thinknsh login -u me ghcr.io");const i=o[0]||"",a=process.stdin.isTTY;let l,c=r.username;if(r["password-stdin"]){if(!c)throw new UsageError("--password-stdin needs --username (-u)");l=(await readStdin()).replace(/\r?\n$/,"")}else{if(!a)throw new UsageError("no terminal to ask for the password; pipe it in with --password-stdin");c||(c=(await promptLine("Username: ",{err:n})).trim()),l=await promptHidden("Password: ",{err:n})}if(!c)throw new UsageError("a username is required");if(!l)throw new UsageError("a password is required");const p=await t.json("POST","/auth",{username:c,password:l,serveraddress:i});return s.write(`Login Succeeded${p.Registry?` (${p.Registry})`:""}\n`),0}async function logout(e,{api:t,out:s}){const{args:n}=parseArgs(e,{});if(n.length>1)throw new UsageError("logout takes at most one registry");const r=await t.json("DELETE",`/auth${qs({serveraddress:n[0]})}`);return s.write(r.Removed?`Removing login credentials for ${r.Registry}\n`:`Not logged in to ${r.Registry}\n`),0}async function push(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,{"all-tags":{short:"a",type:"bool"},quiet:{short:"q",type:"bool"}});if(1!==o.length)throw new UsageError("push takes one image, e.g. thinknsh push ghcr.io/me/app:1.0");if(o[0].includes("@"))throw new UsageError("push a tag, not a digest");const{repo:i,tag:a}=splitRepoTag(o[0]),l=o[0].lastIndexOf(":")>o[0].lastIndexOf("/");if(r["all-tags"]&&l)throw new UsageError("--all-tags takes a repository without a tag");const c=fmt.pullPrinter(n);let p=null;return await t.progress("POST",`/images/${i}/push${qs({tag:r["all-tags"]?void 0:a})}`,null,e=>{e.aux?.Digest&&(p=e.aux),r.quiet||c.onMessage(e)}),c.done(),r.quiet&&p&&s.write(`${i}:${p.Tag}@${p.Digest}\n`),0}async function build(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,{tag:{short:"t",type:"list"},file:{short:"f",type:"string"},"build-arg":{type:"list"},target:{type:"string"},"no-cache":{type:"bool"},pull:{type:"bool"},quiet:{short:"q",type:"bool"},label:{type:"list"},network:{type:"string"},platform:{type:"string"},secret:{type:"list"},ssh:{type:"list"},push:{type:"bool"},"cache-from":{type:"list"},"cache-to":{type:"list"}}),{parseSshSpec:i,openSession:a}=require("../build/sshagent");let l;try{l=r.ssh.map(e=>i(e))}catch(e){throw new UsageError(e.message)}const c=r["cache-from"].map(localCachePath),p=r["cache-to"].map(localCachePath),u=(r.platform||"").split(",").map(e=>e.trim()).filter(Boolean);if(r.push&&!r.tag.length)throw new UsageError("--push needs a name: -t registry/repo:tag");const h=path.resolve(o[0]||".");if(!fs.existsSync(h)||!fs.statSync(h).isDirectory())throw new UsageError(`build context ${h} is not a folder`);const{tar:m,buildFile:g}=packContext(h,r.file,{secrets:r.secret.map(parseSecret)});r.quiet||n.write(chalk.dim(`sending build context (${fmt.size(m.length)})\n`));const f=Object.fromEntries(r["build-arg"].map(e=>{const t=e.indexOf("=");return-1===t?[e,process.env[e]??null]:[e.slice(0,t),e.slice(t+1)]})),d=l.length?await a(t,l):null;try{return await buildWith({api:t,out:s,err:n,o:r,tar:m,buildFile:g,buildargs:f,platforms:u,cacheFrom:c,cacheTo:p,agent:d})}finally{d?.close()}}function localCachePath(e){return String(e).replace(/(^|,)(src|dest)=([^,]+)/,(e,t,s,n)=>`${t}${s}=${path.resolve(n)}`)}async function buildWith({api:e,out:t,err:s,o:n,tar:r,buildFile:o,buildargs:i,platforms:a,cacheFrom:l,cacheTo:c,agent:p}){const u=async(a,u,h="")=>{let m=null;return await e.progress("POST",`/build${qs({t:u,[BUILD_FILE_PARAM]:o,buildargs:i,target:n.target,nocache:n["no-cache"],pull:n.pull,q:n.quiet,labels:n.label.length?parseLabels(n.label):void 0,networkmode:n.network,platform:a,cachefrom:l.length?JSON.stringify(l):void 0,cacheto:c.length?JSON.stringify(c):void 0,sshsession:p?.session})}`,r,e=>{e.aux?.ID&&(m=e.aux.ID),e.stream&&!n.quiet?t.write(h?e.stream.replace(/^(?=.)/gm,h):e.stream):e.status&&!n.quiet&&s.write(`${h}${e.status}\n`)},{"Content-Type":"application/x-tar"}),m};if(a.length<=1){const r=await u(a[0],n.tag);if(n.quiet&&r&&t.write(`${r}\n`),n.push)for(const r of n.tag)await push([r,...n.quiet?["-q"]:[]],{api:e,out:t,err:s});return 0}const h=[];for(const e of a)n.quiet||s.write(chalk.bold(`\n▸ ${e}\n`)),h.push({platform:e,id:await u(e,[],chalk.dim(`[${e}] `))});const m={arm64:"arm64",x64:"amd64"}[process.arch],g=h.find(e=>e.platform.split("/")[1]===m)||h[0];for(const t of n.tag){const{repo:s,tag:n}=splitRepoTag(t);await e.json("POST",`/images/${enc(g.id)}/tag${qs({repo:s,tag:n})}`)}if(n.push)for(const r of n.tag){const o=fmt.pullPrinter(s);let i=null;await e.progress("POST",`/tnc/manifests/${r}/push`,{Images:h.map(e=>e.id)},e=>{e.aux?.Digest&&(i=e.aux),n.quiet||o.onMessage(e)}),o.done(),i&&t.write(`${r}@${i.Digest} (${h.length} platforms)\n`)}else n.quiet||(s.write(chalk.dim(`\n ${n.tag.length?`${n.tag.join(", ")} → ${g.platform} here`:`${g.platform}: ${g.id}`}; ${h.filter(e=>e!==g).map(e=>`${e.platform}: ${e.id.replace(/^sha256:/,"").slice(0,12)}`).join(", ")}\n`)),s.write(chalk.dim(" publish all platforms as one image with --push (or: thinknsh manifest create NAME IMAGE… && thinknsh manifest push NAME)\n")));if(n.quiet)for(const e of h)t.write(`${e.id}\n`);return 0}function manifestFile(e){const{dockPaths:t}=require("../paths");return path.join(t().home,"manifests",`${encodeURIComponent(e)}.json`)}async function manifest(e,{api:t,out:s,err:n}){const[r,...o]=e;switch(r){case"create":{const{opts:e,args:n}=parseArgs(o,{amend:{short:"a",type:"bool"}});if(n.length<2)throw new UsageError("manifest create NAME IMAGE… (the images are local, one per platform)");const[r,...i]=n,a=manifestFile(r),l=e.amend&&fs.existsSync(a)?JSON.parse(fs.readFileSync(a,"utf8")).images:[];if(!e.amend&&fs.existsSync(a))throw new UsageError(`a manifest list ${r} exists; add to it with --amend or remove it: thinknsh manifest rm ${r}`);const c=[...l];for(const e of i){const s=await t.json("GET",`/images/${e}/json`);c.push({ref:e,id:s.Id,platform:`${s.Os||"linux"}/${s.Architecture}${s.Variant?"/"+s.Variant:""}`})}return fs.mkdirSync(path.dirname(a),{recursive:!0}),fs.writeFileSync(a,JSON.stringify({name:r,images:c},null,2)+"\n"),s.write(`Created manifest list ${r} (${c.map(e=>e.platform).join(", ")})\n`),0}case"inspect":{const{args:e}=parseArgs(o,{});if(1!==e.length)throw new UsageError("manifest inspect NAME");const n=manifestFile(e[0]);if(fs.existsSync(n))return s.write(fs.readFileSync(n,"utf8")),0;const r=await t.json("GET",`/tnc/manifests/${e[0]}`);return s.write(JSON.stringify(r.Manifest,null,2)+"\n"),0}case"push":{const{opts:e,args:r}=parseArgs(o,{purge:{short:"p",type:"bool"},quiet:{short:"q",type:"bool"}});if(1!==r.length)throw new UsageError("manifest push NAME");const i=manifestFile(r[0]);if(!fs.existsSync(i))throw new UsageError(`no manifest list ${r[0]}: thinknsh manifest create ${r[0]} IMAGE…`);const a=JSON.parse(fs.readFileSync(i,"utf8")),l=fmt.pullPrinter(n);let c=null;return await t.progress("POST",`/tnc/manifests/${r[0]}/push`,{Images:a.images.map(e=>e.id)},t=>{t.aux?.Digest&&(c=t.aux),e.quiet||l.onMessage(t)}),l.done(),c&&s.write(`${c.Digest}\n`),e.purge&&fs.rmSync(i),0}case"rm":{const{args:e}=parseArgs(o,{});for(const t of e){const e=manifestFile(t);if(!fs.existsSync(e))throw new UsageError(`no manifest list ${t}`);fs.rmSync(e),s.write(`${t}\n`)}return 0}default:throw new UsageError("manifest create | inspect | push | rm")}}async function search(e,{api:t,out:s}){const{opts:n,args:r}=parseArgs(e,{limit:{type:"string"},filter:{short:"f",type:"list"},"no-trunc":{type:"bool"},json:{type:"bool"}});if(1!==r.length)throw new UsageError("search takes one term, e.g. thinknsh search postgres");const o=await t.json("GET",`/images/search${qs({term:r[0],limit:n.limit||25,filters:filtersOf(n.filter)})}`);return n.json?(s.write(JSON.stringify(o,null,2)+"\n"),0):(fmt.table(["NAME","DESCRIPTION","STARS","OFFICIAL"],o.map(e=>{return[e.name,(t=e.description.replace(/\s+/g," "),n["no-trunc"]||t.length<=54?t:t.slice(0,53)+"…"),e.star_count,e.is_official?"[OK]":""];var t}),{out:s}),0)}async function events(e,{api:t,out:s}){const{opts:n}=parseArgs(e,{since:{type:"string"},until:{type:"string"},filter:{short:"f",type:"list"},json:{type:"bool"}}),r=e=>{if(!e)return;const t=/^(\d+)(s|m|h)$/.exec(e);return t?String(Math.floor(Date.now()/1e3)-Number(t[1])*{s:1,m:60,h:3600}[t[2]]):e},o=await t.stream("GET",`/events${qs({since:r(n.since),until:r(n.until),filters:filtersOf(n.filter)})}`);let i="";const a=e=>{if(!e.trim())return;let t;try{t=JSON.parse(e)}catch(e){return}if(n.json)return void s.write(e.trim()+"\n");const r=new Date(t.timeNano?t.timeNano/1e6:1e3*t.time).toISOString(),o=Object.entries(t.Actor?.Attributes||{}).filter(([e])=>!e.includes(".")).map(([e,t])=>`${e}=${t}`).join(", ");s.write(`${r} ${t.Type} ${t.Action} ${t.Actor?.ID||""}${o?` (${o})`:""}\n`)};for await(const e of o){let t;for(i+=e.toString("utf8");-1!==(t=i.indexOf("\n"));)a(i.slice(0,t)),i=i.slice(t+1)}return a(i),0}function parseSecret(e){const t=parseLabels(String(e).split(",")),s=t.id;if(!s||!/^[A-Za-z0-9._-]+$/.test(s))throw new UsageError(`--secret needs id=NAME (letters, digits, . _ -): ${e}`);const n=t.src||t.source;if(n){const e=path.resolve(n.replace(/^~(?=\/|$)/,os.homedir()));if(!fs.existsSync(e))throw new UsageError(`secret file ${e} not found`);return{id:s,data:fs.readFileSync(e)}}const r=t.env||s;if(void 0===process.env[r])throw new UsageError(`--secret ${s}: set src=FILE or env=VAR (${r} is not set)`);return{id:s,data:Buffer.from(process.env[r])}}async function confirm(e,t){if(t)return!0;if(!process.stdin.isTTY)throw new UsageError("refusing to prune without confirmation; pass -f");const s=require("readline").createInterface({input:process.stdin,output:process.stderr}),n=await new Promise(t=>s.question(`${e} [y/N] `,t));return s.close(),/^y(es)?$/i.test(n.trim())}async function volume(e,t){const{api:s,out:n}=t,[r="ls",...o]=e;switch(r){case"ls":case"list":{const{opts:e}=parseArgs(o,{quiet:{short:"q",type:"bool"},filter:{short:"f",type:"list"}}),t=(await s.json("GET",`/volumes${qs({filters:filtersOf(e.filter)})}`)).Volumes||[];return e.quiet?t.forEach(e=>n.write(`${e.Name}\n`)):fmt.table(["DRIVER","VOLUME NAME"],t.map(e=>[e.Driver,e.Name]),{out:n}),0}case"create":{const{opts:e,args:t}=parseArgs(o,{label:{type:"list"}}),r=await s.json("POST","/volumes/create",{Name:t[0]||"",Labels:parseLabels(e.label)});return n.write(`${r.Name}\n`),0}case"rm":case"remove":{const{opts:e,args:n}=parseArgs(o,{force:{short:"f",type:"bool"}});return each(n,t=>s.json("DELETE",`/volumes/${enc(t)}${qs({force:e.force})}`),t,"volume rm")}case"inspect":return inspect(["--type","volume",...o],t);case"prune":{const{opts:e}=parseArgs(o,{force:{short:"f",type:"bool"},all:{short:"a",type:"bool"}});if(!await confirm("Remove all unused volumes?",e.force))return 0;const t=await s.json("POST",`/volumes/prune${qs({filters:e.all?{all:["true"]}:void 0})}`);return(t.VolumesDeleted||[]).forEach(e=>n.write(`${e}\n`)),n.write(`Total reclaimed space: ${fmt.size(t.SpaceReclaimed||0)}\n`),0}default:throw new UsageError(`unknown volume command "${r}" (ls, create, rm, inspect, prune)`)}}async function network(e,t){const{api:s,out:n}=t,[r="ls",...o]=e;switch(r){case"ls":case"list":{const{opts:e}=parseArgs(o,{quiet:{short:"q",type:"bool"},filter:{short:"f",type:"list"}}),t=await s.json("GET",`/networks${qs({filters:filtersOf(e.filter)})}`);return e.quiet?t.forEach(e=>n.write(`${fmt.shortId(e.Id)}\n`)):fmt.table(["NETWORK ID","NAME","DRIVER","SCOPE"],t.map(e=>[fmt.shortId(e.Id),e.Name,e.Driver,e.Scope]),{out:n}),0}case"create":{const{opts:e,args:t}=parseArgs(o,{label:{type:"list"},internal:{type:"bool"},driver:{short:"d",type:"string"},ipv6:{type:"bool"},subnet:{type:"list"},opt:{short:"o",type:"list"},parent:{type:"string"},gateway:{type:"list"},"ip-range":{type:"list"}});if(1!==t.length)throw new UsageError("network create takes one name");const r=parseLabels(e.opt);e.parent&&(r.parent=e.parent);const i=e.subnet.map((t,s)=>({Subnet:t,...e.gateway[s]?{Gateway:e.gateway[s]}:{},...e["ip-range"][s]?{IPRange:e["ip-range"][s]}:{}})),a=await s.json("POST","/networks/create",{Name:t[0],Driver:e.driver||"bridge",Internal:e.internal,Labels:parseLabels(e.label),EnableIPv6:e.ipv6,IPAM:{Config:i},Options:r});return n.write(`${a.Id}\n`),0}case"rm":case"remove":{const{args:e}=parseArgs(o,{});return each(e,e=>s.json("DELETE",`/networks/${enc(e)}`),t,"network rm")}case"inspect":return inspect(["--type","network",...o],t);case"connect":case"disconnect":{const{opts:e,args:t}=parseArgs(o,{alias:{type:"list"},force:{short:"f",type:"bool"}});if(2!==t.length)throw new UsageError(`network ${r} takes NETWORK CONTAINER`);const n="connect"===r?{Container:t[1],EndpointConfig:{Aliases:e.alias}}:{Container:t[1],Force:e.force};return await s.json("POST",`/networks/${enc(t[0])}/${r}`,n),0}case"prune":{const{opts:e}=parseArgs(o,{force:{short:"f",type:"bool"}});return await confirm("Remove all unused networks?",e.force)?(((await s.json("POST","/networks/prune")).NetworksDeleted||[]).forEach(e=>n.write(`${e}\n`)),0):0}default:throw new UsageError(`unknown network command "${r}" (ls, create, rm, inspect, connect, disconnect, prune)`)}}async function system(e,t){const{api:s,out:n}=t,[r="info",...o]=e;switch(r){case"df":{const e=await s.json("GET","/system/df"),t=(e.Images||[]).reduce((e,t)=>e+(t.Size||0),0),r=(e.Containers||[]).reduce((e,t)=>e+(t.SizeRw||0),0),o=(e.Volumes||[]).reduce((e,t)=>e+(t.UsageData?.Size>0?t.UsageData.Size:0),0),i=(e.BuildCache||[]).reduce((e,t)=>e+(t.Size||0),0);return fmt.table(["TYPE","TOTAL","ACTIVE","SIZE"],[["Images",(e.Images||[]).length,(e.Images||[]).filter(e=>e.Containers>0).length,fmt.size(t)],["Containers",(e.Containers||[]).length,(e.Containers||[]).filter(e=>"running"===e.State).length,fmt.size(r)],["Local Volumes",(e.Volumes||[]).length,(e.Volumes||[]).filter(e=>e.UsageData?.RefCount>0).length,fmt.size(o)],["Build Cache",(e.BuildCache||[]).length,0,fmt.size(i)]],{out:n}),0}case"prune":{const{opts:e}=parseArgs(o,{force:{short:"f",type:"bool"},all:{short:"a",type:"bool"},volumes:{type:"bool"}}),t=["all stopped containers","all unused networks",e.all?"all unused images":"all dangling images","the build cache",...e.volumes?["all unused volumes"]:[]];if(!await confirm(`This removes:\n - ${t.join("\n - ")}\nContinue?`,e.force))return 0;let r=0;return r+=(await s.json("POST","/containers/prune")).SpaceReclaimed||0,await s.json("POST","/networks/prune"),r+=(await s.json("POST",`/images/prune${qs({filters:{dangling:[e.all?"false":"true"]}})}`)).SpaceReclaimed||0,r+=(await s.json("POST",`/build/prune${qs({all:e.all})}`)).SpaceReclaimed||0,e.volumes&&(r+=(await s.json("POST","/volumes/prune")).SpaceReclaimed||0),n.write(`Total reclaimed space: ${fmt.size(r)}\n`),0}case"info":{const[e,t]=await Promise.all([s.json("GET","/version"),s.json("GET","/info")]);n.write(`dock ${e.Version} (${t.OperatingSystem||""} ${t.Architecture||""})\n`),n.write(` containers ${t.Containers} (${t.ContainersRunning} running, ${t.ContainersStopped} stopped)\n`),n.write(` images ${t.Images}\n`),n.write(` cpus ${t.NCPU} memory ${fmt.size(t.MemTotal)}\n`),(t.SecurityOptions||[]).includes("name=rootless")&&n.write(" mode rootless\n"),(t.SecurityOptions||[]).includes("name=userns")&&n.write(" userns remapped: the root of containers is an unprivileged id range\n");for(const e of t.Warnings||[])n.write(chalk.yellow(` warning ${e}\n`));return 0}default:throw new UsageError(`unknown system command "${r}" (info, df, prune)`)}}const COMMANDS={run,create,start,stop,restart,kill,rm,wait,ps,logs,exec,inspect,images,pull,push,rmi,tag,build,login,logout,volume,network,system,manifest,search,events};for(const e of["cp","diff","save","load","import"])COMMANDS[e]=(t,s)=>require("./files")[e](t,s);COMMANDS.export=(e,t)=>require("./files").exportCmd(e,t);for(const e of["stats","top","pause","unpause","rename","update","commit","history","port"])COMMANDS[e]=(t,s)=>require("./manage")[e](t,s);async function container(e,t){const[s="ls",...n]=e;if("export"===s)return COMMANDS.export(n,t);if("prune"===s){const{opts:e}=parseArgs(n,{force:{short:"f",type:"bool"}});if(!await confirm("Remove all stopped containers?",e.force))return 0;const s=await t.api.json("POST","/containers/prune");return(s.ContainersDeleted||[]).forEach(e=>t.out.write(`${e}\n`)),t.out.write(`Total reclaimed space: ${fmt.size(s.SpaceReclaimed||0)}\n`),0}const r={ls:"ps",list:"ps",remove:"rm",prune:null}[s]||s;if(!COMMANDS[r]||["images","pull","push","rmi","tag","build","login","logout","volume","network","system","save","load","history","import","manifest","search","events"].includes(r))throw new UsageError(`unknown container command "${s}"`);return COMMANDS[r](n,t)}async function image(e,t){const[s="ls",...n]=e;if("prune"===s){const{opts:e}=parseArgs(n,{force:{short:"f",type:"bool"},all:{short:"a",type:"bool"}});if(!await confirm(e.all?"Remove all images without a container?":"Remove all dangling images?",e.force))return 0;const s=await t.api.json("POST",`/images/prune${qs({filters:{dangling:[e.all?"false":"true"]}})}`);return(s.ImagesDeleted||[]).forEach(e=>t.out.write(e.Untagged?`Untagged: ${e.Untagged}\n`:`Deleted: ${e.Deleted}\n`)),t.out.write(`Total reclaimed space: ${fmt.size(s.SpaceReclaimed||0)}\n`),0}const r={ls:"images",list:"images",rm:"rmi",remove:"rmi",pull:"pull",push:"push",tag:"tag",build:"build",inspect:null,save:"save",load:"load",history:"history",import:"import"};if("inspect"===s)return inspect(["--type","image",...n],t);if("scan"===s)return require("./trust").scan(n,t);if(!r[s])throw new UsageError(`unknown image command "${s}" (ls, pull, push, build, tag, rm, inspect, prune, save, load, history, import, scan)`);return COMMANDS[r[s]](n,t)}Object.assign(COMMANDS,{container,image}),COMMANDS.compose=(e,t)=>require("./compose").compose(e,t),COMMANDS.workspace=(e,t)=>require("./workspace").workspace(e,t),COMMANDS.trust=(e,t)=>require("./trust").trust(e,t),COMMANDS.scan=(e,t)=>require("./trust").scan(e,t),COMMANDS.service=(e,t)=>require("./services").service(e,t),COMMANDS.registry=(e,t)=>require("./services").registry(e,t);for(const e of["capsule","vault","mesh","stack","vm"])COMMANDS[e]=(t,s)=>require("./moved")[e](t,s);const USAGE={run:"Usage: thinknsh run [OPTIONS] IMAGE [COMMAND] [ARG…]\n\nCreate and start a container. Pulls the image if it is not here yet.\n\n -d, --detach Run in the background and print the container ID\n -i, --interactive Keep stdin open -t, --tty Allocate a terminal\n --rm Remove the container when it exits\n --name NAME Container name\n -p, --publish H:C Publish container port C on host port H (C/udp for udp, ranges 8000-8002:8000-8002)\n -e, --env K=V Set an environment variable --env-file FILE\n -v, --volume SRC:DST Named volume or host folder (./relative works)\n -w, --workdir DIR -u, --user USER --entrypoint CMD --hostname NAME\n --network NET --network-alias A --add-host HOST:IP\n --restart POLICY no | always | unless-stopped | on-failure[:N]\n -m, --memory 512m --cpus 1.5 --pids-limit N --shm-size 64m\n --cgroupns private|host (private: the container sees only its own cgroup)\n --userns host Run as real root although dock config --userns-remap is on\n --health-cmd CMD --health-interval 30s --health-retries N --no-healthcheck\n --privileged --cap-add CAP --cap-drop CAP --read-only\n -l, --label K=V --pull missing|always|never -q, --quiet\n --platform linux/amd64 Run an image built for another CPU (Rosetta 2 on Apple Silicon)\n --init A tiny PID 1 that forwards signals and reaps zombies\n --mount type=bind|volume|tmpfs,source=…,target=…[,readonly]\n --ulimit nofile=1024:2048 --sysctl net.core.somaxconn=1024\n --dns IP --dns-search DOMAIN --dns-option OPT\n --memory-swap 1g --memory-reservation 256m --cpuset-cpus 0-1 -c, --cpu-shares N\n --device /dev/x --volumes-from NAME[:ro] --group-add GROUP --security-opt OPT\n --log-opt max-size=10m --cidfile FILE --label-file FILE\n\nDetach from a -it session with Ctrl-P Ctrl-Q.",create:"Usage: thinknsh create [OPTIONS] IMAGE [COMMAND] [ARG…]\n\nLike run, without starting the container. Same options as run.",start:"Usage: thinknsh start [-a] [-i] CONTAINER…\n\n -a, --attach Attach to its output and wait\n -i Attach stdin too",stop:"Usage: thinknsh stop [-t SECONDS] CONTAINER…\n\nSends the stop signal, then SIGKILL after the timeout (default 10s).",restart:"Usage: thinknsh restart [-t SECONDS] CONTAINER…",kill:"Usage: thinknsh kill [-s SIGNAL] CONTAINER…",rm:"Usage: thinknsh rm [-f] [-v] CONTAINER…\n\n -f, --force Stop it first if running\n -v, --volumes Also remove its anonymous volumes",wait:"Usage: thinknsh wait CONTAINER…\n\nBlocks until each container stops, then prints its exit code.",ps:"Usage: thinknsh ps [-a] [-q] [-f KEY=VALUE] [--no-trunc] [--json]\n\n -a All containers (default: running only)\n -q IDs only\n -f Filter: name=, label=, status=, ancestor=, id=",logs:"Usage: thinknsh logs [-f] [-n N] [-t] [--since T] CONTAINER\n\n -f, --follow Keep streaming\n -n, --tail N Last N lines\n -t Show timestamps",exec:"Usage: thinknsh exec [-it] [-d] [-e K=V] [-w DIR] [-u USER] CONTAINER COMMAND [ARG…]\n\nRuns a command in a running container and exits with its code.",inspect:"Usage: thinknsh inspect [--type container|image|volume|network] NAME…",images:"Usage: thinknsh images [-a] [-q] [REPOSITORY[:TAG]]",pull:"Usage: thinknsh pull [-q] [--platform linux/amd64] IMAGE[:TAG|@DIGEST]",push:"Usage: thinknsh push [-a] [-q] IMAGE[:TAG]\n\nUpload an image to its registry (the host in its name; no host = the public\ndefault registry). Tag it first: thinknsh tag myapp:dev ghcr.io/me/myapp:1.0\nUses the login saved by thinknsh login for that registry.\n\n -a, --all-tags Push every local tag of the repository\n -q, --quiet Print only NAME:TAG@DIGEST",login:'Usage: thinknsh login [-u USER] [--password-stdin] [REGISTRY]\n\nCheck a registry login and save it for pull and push (default: the public\ndefault registry). Saved per registry host in ~/.tnc/dock/auth.json (mode 0600).\nAsks for the password without echo, or reads it from stdin:\n echo "$TOKEN" | thinknsh login -u me --password-stdin ghcr.io\n\n -u, --username USER --password-stdin Read the password from stdin',logout:"Usage: thinknsh logout [REGISTRY]\n\nForget the saved login for REGISTRY (default: the public default registry).",rmi:"Usage: thinknsh rmi [-f] IMAGE…",manifest:"Usage: thinknsh manifest create [--amend] NAME IMAGE… | inspect NAME | push [--purge] NAME | rm NAME…\n\nA multi-platform image from local images (one per platform), e.g.\n thinknsh build --platform linux/arm64 -t app:arm64 . && thinknsh build --platform linux/amd64 -t app:amd64 .\n thinknsh manifest create ghcr.io/me/app:1 app:arm64 app:amd64 && thinknsh manifest push ghcr.io/me/app:1\ninspect on a name with no local list shows the registry's manifest (or index).",search:"Usage: thinknsh search [--limit N] [-f is-official=true|stars=N] [--no-trunc] [--json] TERM\n\nSearches the public default registry.",events:"Usage: thinknsh events [--since 10m|TIME] [--until TIME] [-f type=container|event=start|container=NAME|label=K=V] [--json]\n\nStreams engine events (Ctrl-C stops). With --until it ends there.",tag:"Usage: thinknsh tag SOURCE TARGET",build:`Usage: thinknsh build [OPTIONS] [FOLDER]\n\nBuild an image from the build file (${BUILD_FILE} or Containerfile) in FOLDER (default: .).\nFiles listed in .tncignore (or an existing ${BUILD_IGNORE_FILE}) are not sent.\n\n -t, --tag NAME:TAG Name the image (repeatable)\n -f, --file FILE Build file to use\n --build-arg K=V --target STAGE --no-cache --pull -q, --quiet\n --platform linux/amd64 Build for another CPU (FROM --platform, TARGETARCH, … work)\n --platform linux/amd64,linux/arm64 Several at once: one build each; this machine's gets the tags\n --push Push the tags when done (several platforms: as one multi-platform image)\n --secret id=NAME,src=FILE | id=NAME,env=VAR For RUN --mount=type=secret,id=NAME\n --ssh default | ID=AGENT_SOCKET This machine's SSH agent for RUN --mount=type=ssh (git clone of private repos)\n --cache-from REF | type=registry,ref=REF | type=local,src=DIR Reuse a cache another build exported\n --cache-to type=registry,ref=REF[,mode=max] | type=local,dest=DIR | type=inline Export this build's cache\n\nBuild files may use RUN --mount=type=cache|secret|bind|tmpfs|ssh and heredocs (RUN <<EOF, COPY <<EOF /file).\nIndependent stages are built at the same time.`,cp:'Usage: thinknsh cp [-a] [-L] SRC DEST\n\nCopy files or folders between a container and this machine. One side is CONTAINER:PATH.\n"PATH/." copies a folder\'s contents. "-" as SRC or DEST is a tar on stdin or stdout.\n\n -a, --archive Keep owners (uid/gid) -L, --follow-link Follow a symlink in SRC\n\n thinknsh cp web:/etc/nginx/nginx.conf ./nginx.conf\n thinknsh cp ./site/. web:/usr/share/nginx/html',stats:"Usage: thinknsh stats [-a] [--no-stream] [--json] [CONTAINER…]\n\nLive CPU, memory, network, disk and process counts, read from the kernel (cgroups).",top:"Usage: thinknsh top CONTAINER",pause:"Usage: thinknsh pause CONTAINER…\n\nFreezes every process (cgroup freezer); unpause thaws them.",unpause:"Usage: thinknsh unpause CONTAINER…",rename:"Usage: thinknsh rename CONTAINER NEW_NAME",update:"Usage: thinknsh update [OPTIONS] CONTAINER…\n\n -m, --memory 1g --memory-swap 2g --memory-reservation 256m --cpus 1.5\n -c, --cpu-shares N --cpuset-cpus 0-1 --pids-limit N --restart POLICY\n\nA running container gets the new limits at once.",diff:"Usage: thinknsh diff CONTAINER\n\nA added, C changed, D deleted, compared with its image.",port:"Usage: thinknsh port CONTAINER [PRIVATE_PORT[/PROTO]]",commit:"Usage: thinknsh commit [-m MSG] [-a AUTHOR] [-c CHANGE]… [--no-pause] CONTAINER [REPO[:TAG]]\n\nMakes an image from the container's changes. -c applies build file instructions (CMD, ENV, EXPOSE, …).",save:"Usage: thinknsh save [-o FILE] IMAGE…\n\nWrites images (OCI layout + manifest.json) to FILE or stdout; loads in any engine.",load:"Usage: thinknsh load [-i FILE] [-q]\n\nReads images from FILE or stdin (plain or gzip tar).",import:"Usage: thinknsh import [-c CHANGE]… [-m MSG] [--platform P] FILE|- [REPO[:TAG]]\n\nMakes a one-layer image from a root filesystem tar.",export:"Usage: thinknsh container export [-o FILE] CONTAINER\n\nWrites the container's filesystem (without volumes) as a tar.",volume:"Usage: thinknsh volume ls | create [NAME] | rm [-f] NAME… | inspect NAME… | prune [-f] [-a]",network:"Usage: thinknsh network ls | create [--ipv6] [--subnet fd00:1::/64] [--internal] NAME | rm NAME… | inspect NAME… | connect NET CONTAINER | disconnect NET CONTAINER | prune [-f]\n\n --ipv6 containers get an IPv6 address too (a /64: given with --subnet, or a unique local one); names resolve to both\n -d macvlan|ipvlan --subnet 192.168.1.0/24 --gateway 192.168.1.1 [--ip-range 192.168.1.192/27] [--parent eth0] [-o macvlan_mode=bridge]\n containers on this machine's LAN with their own addresses (Linux as root only)\n -d overlay --subnet 10.20.0.0/16 --ip-range 10.20.1.0/24 -o peers=192.168.1.11,192.168.1.12 [-o vni=4242] [-o local=IP]\n one network across machines (VXLAN, UDP 4789): create it on each, same subnet, own --ip-range (Linux as root only)",system:"Usage: thinknsh system info | df | prune [-f] [-a] [--volumes]",container:"Usage: thinknsh container ls | run | create | start | stop | restart | kill | rm | logs | exec | inspect | wait | prune\n | cp | export | diff | stats | top | pause | unpause | rename | update | commit | port",image:"Usage: thinknsh image ls | pull | push | build | tag | rm | inspect | prune | save | load | history | import | scan",get trust(){return require("./trust").USAGE},get scan(){return require("./trust").SCAN_USAGE},history:"Usage: thinknsh image history [-q] [--no-trunc] IMAGE",get workspace(){return require("./workspace").USAGE}};async function runCommand(e,t,{api:s=new DockApi,out:n=process.stdout,err:r=process.stderr,autoStart:o=!0}={}){const i=COMMANDS[e];if(!i)return r.write(`unknown command ${e}\n`),2;if((["--help","-h","help"].includes(t[0])||!["run","create","exec","compose","workspace"].includes(e)&&t.includes("--help"))&&USAGE[e])return n.write(USAGE[e]+"\n"),0;try{return o&&await s.ensureRunning({quiet:!0}),await i(t,{api:s,out:n,err:r})}catch(t){return t instanceof UsageError?(r.write(`${chalk.red("Error:")} ${t.message}\n${chalk.dim(`See: thinknsh ${e} --help`)}\n`),2):(r.write(`${chalk.red("Error:")} ${t.message}\n`),"run"===e?125:1)}}module.exports={COMMANDS,USAGE,runCommand,promptHidden,parsePublish,parseMountFlag,parseUlimit,resolveBind,splitRepoTag,createBody,pullImage,readStdin,confirm,each,CREATE_FLAGS,attachAndWait,gpuRequest};
7
+ "use strict";const fs=require("fs"),os=require("os"),path=require("path"),chalk=require("chalk"),{DockApi,EngineError,demuxer,qs,enc}=require("./api"),{parseArgs,parseBytes,parseDuration,parseEnvList,parseLabels,UsageError}=require("./args"),fmt=require("./format"),{pipeSession}=require("./session"),{packContext}=require("./context"),{BUILD_FILE,BUILD_IGNORE_FILE,BUILD_FILE_PARAM}=require("../api/wire");function readEnvFile(e){const t=[];for(const s of fs.readFileSync(e,"utf8").split(/\r?\n/)){const e=s.trim();e&&!e.startsWith("#")&&t.push(e.replace(/^export\s+/,""))}return t}function parsePublish(e){const t={},s=e=>{const t=/^(\d+)(?:-(\d+))?$/.exec(e);if(!t)return null;const s=Number(t[1]),n=t[2]?Number(t[2]):s;return n>=s&&n-s<1e4?Array.from({length:n-s+1},(e,t)=>s+t):null};for(const n of e){const[e,r="tcp"]=n.split("/");if(!["tcp","udp"].includes(r))throw new UsageError(`invalid protocol "${r}" in "${n}" (tcp or udp)`);let o="",i=e;const a=/^\[([^\]]+)\]:(.*)$/.exec(e);a&&(o=a[1],i=a[2]);const l=i.split(":"),c=l.pop(),p=l.pop()||"";a||(o=l.join(":")||"");const u=s(c),h=p?s(p):null;if(!u||p&&!h)throw new UsageError(`invalid port "${n}" (examples: -p 8080:80, -p 5353:53/udp, -p 8000-8002:8000-8002)`);if(h&&u.length>1&&h.length!==u.length)throw new UsageError(`port ranges in "${n}" must have the same length`);u.forEach((e,s)=>{const n=h?u.length>1?String(h[s]):h.length>1?`${h[0]}-${h[h.length-1]}`:String(h[0]):"";(t[`${e}/${r}`]||=[]).push({HostIp:o,HostPort:n})})}return t}function parseMountFlag(e){const t={Type:"volume"};for(const s of String(e).split(",")){if(!s)continue;const e=s.indexOf("="),n=(-1===e?s:s.slice(0,e)).toLowerCase(),r=-1===e?"true":s.slice(e+1);if("type"===n)t.Type=r;else if(["source","src"].includes(n))t.Source=r;else if(["target","destination","dst"].includes(n))t.Target=r;else if(["readonly","ro"].includes(n))t.ReadOnly=!["false","0"].includes(r);else if("tmpfs-size"===n)t.TmpfsOptions={...t.TmpfsOptions||{},SizeBytes:parseBytes(r)};else{if("tmpfs-mode"!==n){if("bind-propagation"===n||"consistency"===n||"volume-nocopy"===n)continue;throw new UsageError(`unknown --mount option "${n}"`)}t.TmpfsOptions={...t.TmpfsOptions||{},Mode:parseInt(r,8)}}}if(!t.Target)throw new UsageError(`--mount needs target=… (${e})`);if(!["bind","volume","tmpfs"].includes(t.Type))throw new UsageError(`--mount type must be bind, volume or tmpfs (${e})`);if("bind"===t.Type){if(!t.Source)throw new UsageError(`--mount type=bind needs source=… (${e})`);t.Source=resolveBind(`${t.Source}:/x`).slice(0,-3)}return t}function parseUlimit(e){const t=/^([a-z]+)=(-?\d+)(?::(-?\d+))?$/.exec(String(e));if(!t)throw new UsageError(`invalid ulimit "${e}" (example: --ulimit nofile=1024:2048)`);return{Name:t[1],Soft:Number(t[2]),Hard:Number(t[3]??t[2])}}function gpuRequest(e){const t={count:[],device:[],capabilities:[],driver:[]};let s="count";for(const n of String(e).replace(/"/g,"").split(",").map(e=>e.trim()).filter(Boolean)){const e=/^(count|device|capabilities|driver)=(.*)$/.exec(n);e?(s=e[1],t[s].push(e[2])):t[s].push(n)}const n=t.count[0];if(!t.device.length&&!/^(all|\d+)$/.test(n||""))throw new UsageError(`invalid --gpus value "${e}" (all, a number, or device=0,1)`);return{Driver:t.driver[0]||"nvidia",Count:t.device.length?0:"all"===n?-1:Number(n),DeviceIDs:t.device,Capabilities:[["gpu",...t.capabilities]]}}function parseDevice(e){const[t,s,n]=String(e).split(":");return{PathOnHost:t,PathInContainer:s||t,CgroupPermissions:n||"rwm"}}function resolveBind(e){if(/^[A-Za-z]:[\\/]/.test(e))return e;const t=e.indexOf(":");if(-1===t)return e;let s=e.slice(0,t);return"."===s||s.startsWith("./")||s.startsWith("../")||s.startsWith(".\\")?s=path.resolve(s):("~"===s||s.startsWith("~/"))&&(s=path.join(os.homedir(),s.slice(1))),s+e.slice(t)}function restartPolicy(e){if(!e||"no"===e)return{Name:"no"};const[t,s]=e.split(":");if(!["always","unless-stopped","on-failure"].includes(t))throw new UsageError(`invalid restart policy "${e}" (no, always, unless-stopped, on-failure[:N])`);return{Name:t,MaximumRetryCount:s?parseInt(s,10):0}}function filtersOf(e){const t={};for(const s of e){const e=s.indexOf("=");if(-1===e)throw new UsageError(`invalid filter "${s}" (expected key=value)`);(t[s.slice(0,e)]||=[]).push(s.slice(e+1))}return Object.keys(t).length?t:void 0}function splitRepoTag(e){const t=e.lastIndexOf("/"),s=e.lastIndexOf(":");return s>t?{repo:e.slice(0,s),tag:e.slice(s+1)}:{repo:e,tag:"latest"}}async function pullImage(e,t,{quiet:s=!1,err:n=process.stderr,platform:r}={}){const{repo:o,tag:i}=splitRepoTag(t.includes("@")?t.split("@")[0]:t),a=fmt.pullPrinter(n),l={...t.includes("@")?{fromImage:t.split("@")[0],tag:t.split("@")[1]}:{fromImage:o,tag:i},platform:r};await e.progress("POST",`/images/create${qs(l)}`,null,e=>{s||a.onMessage(e)}),a.done()}const isMissingImage=e=>e instanceof EngineError&&404===e.status&&/image/i.test(e.message);async function createContainer(e,t,s,{pull:n="missing",quiet:r=!1,err:o,platform:i}){"always"===n&&await pullImage(e,s.Image,{quiet:r,err:o,platform:i});try{return await e.json("POST",`/containers/create${qs({name:t,platform:i})}`,s)}catch(a){if(!isMissingImage(a)||"never"===n)throw a;return r||o.write(`Unable to find image '${s.Image}'${i?` for ${i}`:""} locally\n`),await pullImage(e,s.Image,{quiet:r,err:o,platform:i}),e.json("POST",`/containers/create${qs({name:t,platform:i})}`,s)}}const CREATE_FLAGS={name:{type:"string"},platform:{type:"string"},init:{type:"bool"},ulimit:{type:"list"},dns:{type:"list"},"dns-search":{type:"list"},"dns-option":{type:"list"},mount:{type:"list"},sysctl:{type:"list"},"memory-swap":{type:"string"},"memory-reservation":{type:"string"},"cpuset-cpus":{type:"string"},"cpu-shares":{short:"c",type:"string"},device:{type:"list"},gpus:{type:"string"},"volumes-from":{type:"list"},"group-add":{type:"list"},"security-opt":{type:"list"},"log-driver":{type:"string"},"log-opt":{type:"list"},"label-file":{type:"list"},cidfile:{type:"string"},publish:{short:"p",type:"list"},env:{short:"e",type:"list"},"env-file":{type:"list"},volume:{short:"v",type:"list"},tmpfs:{type:"list"},workdir:{short:"w",type:"string"},user:{short:"u",type:"string"},entrypoint:{type:"string"},network:{type:"string"},net:{type:"string"},"network-alias":{type:"list"},hostname:{type:"string"},restart:{type:"string"},memory:{short:"m",type:"string"},cpus:{type:"string"},"pids-limit":{type:"string"},"shm-size":{type:"string"},cgroupns:{type:"string"},userns:{type:"string"},privileged:{type:"bool"},"cap-add":{type:"list"},"cap-drop":{type:"list"},"read-only":{type:"bool"},"add-host":{type:"list"},label:{short:"l",type:"list"},"health-cmd":{type:"string"},"health-interval":{type:"string"},"health-timeout":{type:"string"},"health-retries":{type:"string"},"health-start-period":{type:"string"},"no-healthcheck":{type:"bool"},"stop-signal":{type:"string"},"stop-timeout":{type:"string"},pull:{type:"string",default:"missing"},interactive:{short:"i",type:"bool"},tty:{short:"t",type:"bool"},rm:{type:"bool"},quiet:{short:"q",type:"bool"}};function createBody(e,t,s){const n=[];for(const t of e["env-file"])n.push(...readEnvFile(t));n.push(...parseEnvList(e.env));const r=e.network||e.net;if(e.cgroupns&&!["host","private"].includes(e.cgroupns))throw new UsageError(`invalid --cgroupns "${e.cgroupns}" (private or host)`);if(e.userns&&"host"!==e.userns)throw new UsageError(`invalid --userns "${e.userns}" (host: leave out userns-remap for this container)`);let o;e["no-healthcheck"]?o={Test:["NONE"]}:(e["health-cmd"]||e["health-interval"]||e["health-timeout"]||e["health-retries"]||e["health-start-period"])&&(o={...e["health-cmd"]?{Test:["CMD-SHELL",e["health-cmd"]]}:{},...e["health-interval"]?{Interval:parseDuration(e["health-interval"])}:{},...e["health-timeout"]?{Timeout:parseDuration(e["health-timeout"])}:{},...e["health-retries"]?{Retries:parseInt(e["health-retries"],10)}:{},...e["health-start-period"]?{StartPeriod:parseDuration(e["health-start-period"])}:{}});const i=parsePublish(e.publish),a=e.gpus?[gpuRequest(e.gpus)]:[],l={};for(const t of e["label-file"])Object.assign(l,parseLabels(readEnvFile(t)));Object.assign(l,parseLabels(e.label));const c=parseLabels(e["log-opt"]);return{Image:t,...s.length?{Cmd:s}:{},...void 0!==e.entrypoint?{Entrypoint:""===e.entrypoint?[""]:[e.entrypoint]}:{},Env:n,Labels:l,...e.workdir?{WorkingDir:e.workdir}:{},...e.user?{User:e.user}:{},...e.hostname?{Hostname:e.hostname}:{},Tty:e.tty,OpenStdin:e.interactive,StdinOnce:e.interactive,AttachStdin:e.interactive,AttachStdout:!0,AttachStderr:!0,ExposedPorts:Object.fromEntries(Object.keys(i).map(e=>[e,{}])),...o?{Healthcheck:o}:{},...e["stop-signal"]?{StopSignal:e["stop-signal"]}:{},...e["stop-timeout"]?{StopTimeout:parseInt(e["stop-timeout"],10)}:{},HostConfig:{Binds:e.volume.map(resolveBind),PortBindings:i,...r?{NetworkMode:r}:{},RestartPolicy:restartPolicy(e.restart),AutoRemove:e.rm,...e.memory?{Memory:parseBytes(e.memory)}:{},...e.cpus?{NanoCpus:Math.round(1e9*parseFloat(e.cpus))}:{},...e["pids-limit"]?{PidsLimit:parseInt(e["pids-limit"],10)}:{},...e["shm-size"]?{ShmSize:parseBytes(e["shm-size"])}:{},...e.cgroupns?{CgroupnsMode:e.cgroupns}:{},...e.userns?{UsernsMode:e.userns}:{},Privileged:e.privileged,CapAdd:e["cap-add"],CapDrop:e["cap-drop"],ReadonlyRootfs:e["read-only"],ExtraHosts:e["add-host"],Tmpfs:Object.fromEntries(e.tmpfs.map(e=>{const t=e.indexOf(":");return-1===t?[e,""]:[e.slice(0,t),e.slice(t+1)]})),...e.init?{Init:!0}:{},...e.mount.length?{Mounts:e.mount.map(parseMountFlag)}:{},...e.ulimit.length?{Ulimits:e.ulimit.map(parseUlimit)}:{},...e.dns.length?{Dns:e.dns}:{},...e["dns-search"].length?{DnsSearch:e["dns-search"]}:{},...e["dns-option"].length?{DnsOptions:e["dns-option"]}:{},...e.sysctl.length?{Sysctls:parseLabels(e.sysctl)}:{},...e["memory-swap"]?{MemorySwap:"-1"===e["memory-swap"]?-1:parseBytes(e["memory-swap"])}:{},...e["memory-reservation"]?{MemoryReservation:parseBytes(e["memory-reservation"])}:{},...e["cpuset-cpus"]?{CpusetCpus:e["cpuset-cpus"]}:{},...e["cpu-shares"]?{CpuShares:parseInt(e["cpu-shares"],10)}:{},...e.device.length?{Devices:e.device.map(parseDevice)}:{},...a.length?{DeviceRequests:a}:{},...e["volumes-from"].length?{VolumesFrom:e["volumes-from"]}:{},...e["group-add"].length?{GroupAdd:e["group-add"]}:{},...e["security-opt"].length?{SecurityOpt:e["security-opt"]}:{},...e["log-driver"]||e["log-opt"].length?{LogConfig:{Type:e["log-driver"]||"json-file",Config:c}}:{}},...r&&e["network-alias"].length?{NetworkingConfig:{EndpointsConfig:{[r]:{Aliases:e["network-alias"]}}}}:{}}}async function resizeTo(e,t,s,n,r){await e.json("POST",`/${t}/${enc(s)}/resize${qs({h:n,w:r})}`).catch(()=>{})}async function attachAndWait(e,t,{stdin:s,tty:n,startIt:r,err:o}){const i=await e.hijack(`/containers/${enc(t)}/attach${qs({stream:1,stdout:1,stderr:1,stdin:s?1:0})}`),a=e.stream("POST",`/containers/${enc(t)}/wait${qs({condition:"next-exit"})}`);await a;const l=a.then(e=>DockApi._read(e));r&&await e.json("POST",`/containers/${enc(t)}/start`);const c=["SIGINT","SIGTERM","SIGHUP"],p=s=>e.json("POST",`/containers/${enc(t)}/kill${qs({signal:s})}`).catch(()=>{});n||c.forEach(e=>process.on(e,p));try{const{detached:r}=await pipeSession(i,{stdin:s,resize:n?(s,n)=>resizeTo(e,"containers",t,s,n):null});if(r)return{detached:!0,code:0};const a=await l,c="string"==typeof a?JSON.parse(a.trim().split("\n").pop()||"{}"):a;return c?.Error?.Message&&o.write(`${c.Error.Message}\n`),{detached:!1,code:c?.StatusCode??0}}finally{n||c.forEach(e=>process.removeListener(e,p))}}const RUN_FLAGS={...CREATE_FLAGS,detach:{short:"d",type:"bool"}};async function run(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,RUN_FLAGS,{stopAtPositional:!0});if(!o.length)throw new UsageError("run needs an image, e.g. thinknsh run -it alpine sh");if(r.tty&&!process.stdin.isTTY&&r.interactive)throw new UsageError("the input device is not a TTY; drop -t when piping input");const[i,...a]=o,l=createBody(r,i,a);if(r.detach&&(l.AttachStdout=l.AttachStderr=!1),r.cidfile&&fs.existsSync(r.cidfile))throw new UsageError(`container ID file found, make sure the other container isn't running or delete ${r.cidfile}`);const{Id:c}=await createContainer(t,r.name,l,{pull:r.pull,quiet:r.quiet,err:n,platform:r.platform});if(r.cidfile&&fs.writeFileSync(r.cidfile,c),r.detach)return await t.json("POST",`/containers/${c}/start`),s.write(`${c}\n`),0;const{detached:p,code:u}=await attachAndWait(t,c,{stdin:r.interactive,tty:r.tty,startIt:!0,err:n});return p&&s.write(`${fmt.shortId(c)}\n`),u}async function create(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,CREATE_FLAGS,{stopAtPositional:!0});if(!o.length)throw new UsageError("create needs an image");const[i,...a]=o,{Id:l}=await createContainer(t,r.name,createBody(r,i,a),{pull:r.pull,quiet:r.quiet,err:n,platform:r.platform});return r.cidfile&&fs.writeFileSync(r.cidfile,l),s.write(`${l}\n`),0}async function each(e,t,{out:s,err:n},r){if(!e.length)throw new UsageError(`${r} needs at least one container`);let o=0;for(const r of e)try{await t(r),s.write(`${r}\n`)}catch(e){o++,n.write(`Error: ${e.message}\n`)}return o?1:0}async function start(e,t){const{opts:s,args:n}=parseArgs(e,{attach:{short:"a",type:"bool"},interactive:{short:"i",type:"bool"}});if(s.attach||s.interactive){if(1!==n.length)throw new UsageError("start -a takes exactly one container");const e=await t.api.json("GET",`/containers/${enc(n[0])}/json`),{code:r}=await attachAndWait(t.api,e.Id,{stdin:s.interactive,tty:e.Config.Tty,startIt:!0,err:t.err});return r}return each(n,e=>t.api.json("POST",`/containers/${enc(e)}/start`),t,"start")}async function stop(e,t){const{opts:s,args:n}=parseArgs(e,{time:{short:"t",type:"string"},timeout:{type:"string"}}),r=s.time??s.timeout;return each(n,e=>t.api.json("POST",`/containers/${enc(e)}/stop${qs({t:r})}`),t,"stop")}async function restart(e,t){const{opts:s,args:n}=parseArgs(e,{time:{short:"t",type:"string"}});return each(n,e=>t.api.json("POST",`/containers/${enc(e)}/restart${qs({t:s.time})}`),t,"restart")}async function kill(e,t){const{opts:s,args:n}=parseArgs(e,{signal:{short:"s",type:"string",default:"SIGKILL"}});return each(n,e=>t.api.json("POST",`/containers/${enc(e)}/kill${qs({signal:s.signal})}`),t,"kill")}async function rm(e,t){const{opts:s,args:n}=parseArgs(e,{force:{short:"f",type:"bool"},volumes:{short:"v",type:"bool"}});return each(n,e=>t.api.json("DELETE",`/containers/${enc(e)}${qs({force:s.force,v:s.volumes})}`),t,"rm")}async function wait(e,{api:t,out:s}){const{args:n}=parseArgs(e,{});if(!n.length)throw new UsageError("wait needs at least one container");let r=0;for(const e of n){const n=await t.json("POST",`/containers/${enc(e)}/wait`);r=("string"==typeof n?JSON.parse(n.trim()):n).StatusCode,s.write(`${r}\n`)}return 0}async function ps(e,{api:t,out:s}){const{opts:n}=parseArgs(e,{all:{short:"a",type:"bool"},quiet:{short:"q",type:"bool"},filter:{short:"f",type:"list"},"no-trunc":{type:"bool"},json:{type:"bool"},last:{short:"n",type:"string"}}),r=await t.json("GET",`/containers/json${qs({all:n.all||void 0,filters:filtersOf(n.filter),limit:n.last})}`);if(n.json)return s.write(JSON.stringify(r,null,2)+"\n"),0;if(n.quiet){for(const e of r)s.write(`${n["no-trunc"]?e.Id:fmt.shortId(e.Id)}\n`);return 0}return fmt.table(["CONTAINER ID","IMAGE","COMMAND","CREATED","STATUS","PORTS","NAMES"],r.map(e=>{return[n["no-trunc"]?e.Id:fmt.shortId(e.Id),e.Image,`"${t=e.Command||"",n["no-trunc"]||t.length<=20?t:t.slice(0,19)+"…"}"`,fmt.ago(e.Created),e.Status,fmt.ports(e.Ports),(e.Names||[]).map(e=>e.replace(/^\//,"")).join(",")];var t}),{out:s}),0}async function logs(e,{api:t}){const{opts:s,args:n}=parseArgs(e,{follow:{short:"f",type:"bool"},tail:{short:"n",type:"string"},timestamps:{short:"t",type:"bool"},since:{type:"string"},until:{type:"string"}});if(1!==n.length)throw new UsageError("logs takes exactly one container");const r=await t.stream("GET",`/containers/${enc(n[0])}/logs${qs({stdout:1,stderr:1,follow:s.follow,tail:s.tail,timestamps:s.timestamps,since:s.since,until:s.until})}`),o=(r.headers["content-type"]||"").includes("raw-stream")?e=>process.stdout.write(e):demuxer(e=>process.stdout.write(e),e=>process.stderr.write(e));for await(const e of r)o(e);return 0}async function exec(e,{api:t,out:s}){const{opts:n,args:r}=parseArgs(e,{interactive:{short:"i",type:"bool"},tty:{short:"t",type:"bool"},detach:{short:"d",type:"bool"},env:{short:"e",type:"list"},"env-file":{type:"list"},workdir:{short:"w",type:"string"},user:{short:"u",type:"string"},privileged:{type:"bool"}},{stopAtPositional:!0});if(r.length<2)throw new UsageError("exec needs a container and a command, e.g. thinknsh exec -it web sh");const[o,...i]=r,a=[];for(const e of n["env-file"])a.push(...readEnvFile(e));a.push(...parseEnvList(n.env));const{Id:l}=await t.json("POST",`/containers/${enc(o)}/exec`,{Cmd:i,AttachStdin:n.interactive&&!n.detach,AttachStdout:!n.detach,AttachStderr:!n.detach,Tty:n.tty,Env:a,...n.workdir?{WorkingDir:n.workdir}:{},...n.user?{User:n.user}:{},Privileged:n.privileged});if(n.detach)return await t.json("POST",`/exec/${l}/start`,{Detach:!0,Tty:n.tty}),0;const c=await t.hijack(`/exec/${l}/start`,{Detach:!1,Tty:n.tty}),{detached:p}=await pipeSession(c,{stdin:n.interactive,resize:n.tty?(e,s)=>resizeTo(t,"exec",l,e,s):null});return p?0:(await t.json("GET",`/exec/${l}/json`)).ExitCode??0}async function inspect(e,{api:t,out:s}){const{opts:n,args:r}=parseArgs(e,{type:{type:"string"}});if(!r.length)throw new UsageError("inspect needs at least one name");const o={container:e=>`/containers/${enc(e)}/json`,image:e=>`/images/${e}/json`,volume:e=>`/volumes/${enc(e)}`,network:e=>`/networks/${enc(e)}`},i=n.type?[n.type]:Object.keys(o),a=[];let l=0;for(const e of r){let s=null;for(const n of i){if(!o[n])throw new UsageError(`unknown type "${n}" (container, image, volume, network)`);try{s=await t.json("GET",o[n](e));break}catch(e){if(!(e instanceof EngineError)||404!==e.status)throw e}}s?a.push(s):(l++,process.stderr.write(`Error: no such object: ${e}\n`))}return s.write(JSON.stringify(a,null,2)+"\n"),l?1:0}async function images(e,{api:t,out:s}){const{opts:n,args:r}=parseArgs(e,{quiet:{short:"q",type:"bool"},all:{short:"a",type:"bool"},filter:{short:"f",type:"list"},json:{type:"bool"},"no-trunc":{type:"bool"}}),o=filtersOf(n.filter)||{};r[0]&&(o.reference=[r[0]]);const i=await t.json("GET",`/images/json${qs({all:n.all||void 0,filters:Object.keys(o).length?o:void 0})}`);if(n.json)return s.write(JSON.stringify(i,null,2)+"\n"),0;const a=e=>n["no-trunc"]?e.Id:fmt.shortId(e.Id);if(n.quiet)return[...new Set(i.map(a))].forEach(e=>s.write(`${e}\n`)),0;const l=[];for(const e of i){const t=e.RepoTags?.length?e.RepoTags:["<none>:<none>"];for(const s of t){const{repo:t,tag:n}=splitRepoTag(s);l.push([t,n,a(e),fmt.ago(e.Created),fmt.size(e.Size)])}}return fmt.table(["REPOSITORY","TAG","IMAGE ID","CREATED","SIZE"],l,{out:s}),0}async function pull(e,{api:t,err:s}){const{opts:n,args:r}=parseArgs(e,{quiet:{short:"q",type:"bool"},platform:{type:"string"}});if(1!==r.length)throw new UsageError("pull takes one image, e.g. thinknsh pull postgres:16");return await pullImage(t,r[0],{quiet:n.quiet,err:s,platform:n.platform}),n.quiet&&process.stdout.write(`${r[0]}\n`),0}async function rmi(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,{force:{short:"f",type:"bool"}});if(!o.length)throw new UsageError("rmi needs at least one image");let i=0;for(const e of o)try{const n=await t.json("DELETE",`/images/${e}${qs({force:r.force})}`);for(const e of n||[])s.write(e.Untagged?`Untagged: ${e.Untagged}\n`:`Deleted: ${e.Deleted}\n`)}catch(e){i++,n.write(`Error: ${e.message}\n`)}return i?1:0}async function tag(e,{api:t}){const{args:s}=parseArgs(e,{});if(2!==s.length)throw new UsageError("tag takes SOURCE TARGET, e.g. thinknsh tag myapp:dev myapp:1.0");const{repo:n,tag:r}=splitRepoTag(s[1]);return await t.json("POST",`/images/${s[0]}/tag${qs({repo:n,tag:r})}`),0}async function readStdin(e=process.stdin){const t=[];for await(const s of e)t.push(s);return Buffer.concat(t).toString("utf8")}function promptLine(e,{err:t=process.stderr}={}){const s=require("readline").createInterface({input:process.stdin,output:t});return new Promise(t=>s.question(e,e=>{s.close(),t(e)}))}function promptHidden(e,{err:t=process.stderr}={}){return new Promise((s,n)=>{const r=process.stdin;t.write(e),r.setRawMode(!0),r.resume();let o="";const i=e=>{r.setRawMode(!1),r.pause(),r.removeListener("data",a),t.write("\n"),e instanceof Error?n(e):s(e)},a=e=>{for(const t of e.toString("utf8")){if("\r"===t||"\n"===t||""===t)return i(o);if(""===t)return i(new UsageError("login cancelled"));""===t||"\b"===t?o=o.slice(0,-1):o+=t}};r.on("data",a)})}async function login(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,{username:{short:"u",type:"string"},"password-stdin":{type:"bool"}});if(o.length>1)throw new UsageError("login takes at most one registry, e.g. thinknsh login -u me ghcr.io");const i=o[0]||"",a=process.stdin.isTTY;let l,c=r.username;if(r["password-stdin"]){if(!c)throw new UsageError("--password-stdin needs --username (-u)");l=(await readStdin()).replace(/\r?\n$/,"")}else{if(!a)throw new UsageError("no terminal to ask for the password; pipe it in with --password-stdin");c||(c=(await promptLine("Username: ",{err:n})).trim()),l=await promptHidden("Password: ",{err:n})}if(!c)throw new UsageError("a username is required");if(!l)throw new UsageError("a password is required");const p=await t.json("POST","/auth",{username:c,password:l,serveraddress:i});return s.write(`Login Succeeded${p.Registry?` (${p.Registry})`:""}\n`),0}async function logout(e,{api:t,out:s}){const{args:n}=parseArgs(e,{});if(n.length>1)throw new UsageError("logout takes at most one registry");const r=await t.json("DELETE",`/auth${qs({serveraddress:n[0]})}`);return s.write(r.Removed?`Removing login credentials for ${r.Registry}\n`:`Not logged in to ${r.Registry}\n`),0}async function push(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,{"all-tags":{short:"a",type:"bool"},quiet:{short:"q",type:"bool"}});if(1!==o.length)throw new UsageError("push takes one image, e.g. thinknsh push ghcr.io/me/app:1.0");if(o[0].includes("@"))throw new UsageError("push a tag, not a digest");const{repo:i,tag:a}=splitRepoTag(o[0]),l=o[0].lastIndexOf(":")>o[0].lastIndexOf("/");if(r["all-tags"]&&l)throw new UsageError("--all-tags takes a repository without a tag");const c=fmt.pullPrinter(n);let p=null;return await t.progress("POST",`/images/${i}/push${qs({tag:r["all-tags"]?void 0:a})}`,null,e=>{e.aux?.Digest&&(p=e.aux),r.quiet||c.onMessage(e)}),c.done(),r.quiet&&p&&s.write(`${i}:${p.Tag}@${p.Digest}\n`),0}async function build(e,{api:t,out:s,err:n}){const{opts:r,args:o}=parseArgs(e,{tag:{short:"t",type:"list"},file:{short:"f",type:"string"},"build-arg":{type:"list"},target:{type:"string"},"no-cache":{type:"bool"},pull:{type:"bool"},quiet:{short:"q",type:"bool"},label:{type:"list"},network:{type:"string"},platform:{type:"string"},secret:{type:"list"},ssh:{type:"list"},push:{type:"bool"},"cache-from":{type:"list"},"cache-to":{type:"list"}}),{parseSshSpec:i,openSession:a}=require("../build/sshagent");let l;try{l=r.ssh.map(e=>i(e))}catch(e){throw new UsageError(e.message)}const c=r["cache-from"].map(localCachePath),p=r["cache-to"].map(localCachePath),u=(r.platform||"").split(",").map(e=>e.trim()).filter(Boolean);if(r.push&&!r.tag.length)throw new UsageError("--push needs a name: -t registry/repo:tag");const h=path.resolve(o[0]||".");if(!fs.existsSync(h)||!fs.statSync(h).isDirectory())throw new UsageError(`build context ${h} is not a folder`);const{tar:m,buildFile:g}=packContext(h,r.file,{secrets:r.secret.map(parseSecret)});r.quiet||n.write(chalk.dim(`sending build context (${fmt.size(m.length)})\n`));const f=Object.fromEntries(r["build-arg"].map(e=>{const t=e.indexOf("=");return-1===t?[e,process.env[e]??null]:[e.slice(0,t),e.slice(t+1)]})),d=l.length?await a(t,l):null;try{return await buildWith({api:t,out:s,err:n,o:r,tar:m,buildFile:g,buildargs:f,platforms:u,cacheFrom:c,cacheTo:p,agent:d})}finally{d?.close()}}function localCachePath(e){return String(e).replace(/(^|,)(src|dest)=([^,]+)/,(e,t,s,n)=>`${t}${s}=${path.resolve(n)}`)}async function buildWith({api:e,out:t,err:s,o:n,tar:r,buildFile:o,buildargs:i,platforms:a,cacheFrom:l,cacheTo:c,agent:p}){const u=async(a,u,h="")=>{let m=null;return await e.progress("POST",`/build${qs({t:u,[BUILD_FILE_PARAM]:o,buildargs:i,target:n.target,nocache:n["no-cache"],pull:n.pull,q:n.quiet,labels:n.label.length?parseLabels(n.label):void 0,networkmode:n.network,platform:a,cachefrom:l.length?JSON.stringify(l):void 0,cacheto:c.length?JSON.stringify(c):void 0,sshsession:p?.session})}`,r,e=>{e.aux?.ID&&(m=e.aux.ID),e.stream&&!n.quiet?t.write(h?e.stream.replace(/^(?=.)/gm,h):e.stream):e.status&&!n.quiet&&s.write(`${h}${e.status}\n`)},{"Content-Type":"application/x-tar"}),m};if(a.length<=1){const r=await u(a[0],n.tag);if(n.quiet&&r&&t.write(`${r}\n`),n.push)for(const r of n.tag)await push([r,...n.quiet?["-q"]:[]],{api:e,out:t,err:s});return 0}const h=[];for(const e of a)n.quiet||s.write(chalk.bold(`\n▸ ${e}\n`)),h.push({platform:e,id:await u(e,[],chalk.dim(`[${e}] `))});const m={arm64:"arm64",x64:"amd64"}[process.arch],g=h.find(e=>e.platform.split("/")[1]===m)||h[0];for(const t of n.tag){const{repo:s,tag:n}=splitRepoTag(t);await e.json("POST",`/images/${enc(g.id)}/tag${qs({repo:s,tag:n})}`)}if(n.push)for(const r of n.tag){const o=fmt.pullPrinter(s);let i=null;await e.progress("POST",`/tnc/manifests/${r}/push`,{Images:h.map(e=>e.id)},e=>{e.aux?.Digest&&(i=e.aux),n.quiet||o.onMessage(e)}),o.done(),i&&t.write(`${r}@${i.Digest} (${h.length} platforms)\n`)}else n.quiet||(s.write(chalk.dim(`\n ${n.tag.length?`${n.tag.join(", ")} → ${g.platform} here`:`${g.platform}: ${g.id}`}; ${h.filter(e=>e!==g).map(e=>`${e.platform}: ${e.id.replace(/^sha256:/,"").slice(0,12)}`).join(", ")}\n`)),s.write(chalk.dim(" publish all platforms as one image with --push (or: thinknsh manifest create NAME IMAGE… && thinknsh manifest push NAME)\n")));if(n.quiet)for(const e of h)t.write(`${e.id}\n`);return 0}function manifestFile(e){const{dockPaths:t}=require("../paths");return path.join(t().home,"manifests",`${encodeURIComponent(e)}.json`)}async function manifest(e,{api:t,out:s,err:n}){const[r,...o]=e;switch(r){case"create":{const{opts:e,args:n}=parseArgs(o,{amend:{short:"a",type:"bool"}});if(n.length<2)throw new UsageError("manifest create NAME IMAGE… (the images are local, one per platform)");const[r,...i]=n,a=manifestFile(r),l=e.amend&&fs.existsSync(a)?JSON.parse(fs.readFileSync(a,"utf8")).images:[];if(!e.amend&&fs.existsSync(a))throw new UsageError(`a manifest list ${r} exists; add to it with --amend or remove it: thinknsh manifest rm ${r}`);const c=[...l];for(const e of i){const s=await t.json("GET",`/images/${e}/json`);c.push({ref:e,id:s.Id,platform:`${s.Os||"linux"}/${s.Architecture}${s.Variant?"/"+s.Variant:""}`})}return fs.mkdirSync(path.dirname(a),{recursive:!0}),fs.writeFileSync(a,JSON.stringify({name:r,images:c},null,2)+"\n"),s.write(`Created manifest list ${r} (${c.map(e=>e.platform).join(", ")})\n`),0}case"inspect":{const{args:e}=parseArgs(o,{});if(1!==e.length)throw new UsageError("manifest inspect NAME");const n=manifestFile(e[0]);if(fs.existsSync(n))return s.write(fs.readFileSync(n,"utf8")),0;const r=await t.json("GET",`/tnc/manifests/${e[0]}`);return s.write(JSON.stringify(r.Manifest,null,2)+"\n"),0}case"push":{const{opts:e,args:r}=parseArgs(o,{purge:{short:"p",type:"bool"},quiet:{short:"q",type:"bool"}});if(1!==r.length)throw new UsageError("manifest push NAME");const i=manifestFile(r[0]);if(!fs.existsSync(i))throw new UsageError(`no manifest list ${r[0]}: thinknsh manifest create ${r[0]} IMAGE…`);const a=JSON.parse(fs.readFileSync(i,"utf8")),l=fmt.pullPrinter(n);let c=null;return await t.progress("POST",`/tnc/manifests/${r[0]}/push`,{Images:a.images.map(e=>e.id)},t=>{t.aux?.Digest&&(c=t.aux),e.quiet||l.onMessage(t)}),l.done(),c&&s.write(`${c.Digest}\n`),e.purge&&fs.rmSync(i),0}case"rm":{const{args:e}=parseArgs(o,{});for(const t of e){const e=manifestFile(t);if(!fs.existsSync(e))throw new UsageError(`no manifest list ${t}`);fs.rmSync(e),s.write(`${t}\n`)}return 0}default:throw new UsageError("manifest create | inspect | push | rm")}}async function search(e,{api:t,out:s}){const{opts:n,args:r}=parseArgs(e,{limit:{type:"string"},filter:{short:"f",type:"list"},"no-trunc":{type:"bool"},json:{type:"bool"}});if(1!==r.length)throw new UsageError("search takes one term, e.g. thinknsh search postgres");const o=await t.json("GET",`/images/search${qs({term:r[0],limit:n.limit||25,filters:filtersOf(n.filter)})}`);return n.json?(s.write(JSON.stringify(o,null,2)+"\n"),0):(fmt.table(["NAME","DESCRIPTION","STARS","OFFICIAL"],o.map(e=>{return[e.name,(t=e.description.replace(/\s+/g," "),n["no-trunc"]||t.length<=54?t:t.slice(0,53)+"…"),e.star_count,e.is_official?"[OK]":""];var t}),{out:s}),0)}async function events(e,{api:t,out:s}){const{opts:n}=parseArgs(e,{since:{type:"string"},until:{type:"string"},filter:{short:"f",type:"list"},json:{type:"bool"}}),r=e=>{if(!e)return;const t=/^(\d+)(s|m|h)$/.exec(e);return t?String(Math.floor(Date.now()/1e3)-Number(t[1])*{s:1,m:60,h:3600}[t[2]]):e},o=await t.stream("GET",`/events${qs({since:r(n.since),until:r(n.until),filters:filtersOf(n.filter)})}`);let i="";const a=e=>{if(!e.trim())return;let t;try{t=JSON.parse(e)}catch(e){return}if(n.json)return void s.write(e.trim()+"\n");const r=new Date(t.timeNano?t.timeNano/1e6:1e3*t.time).toISOString(),o=Object.entries(t.Actor?.Attributes||{}).filter(([e])=>!e.includes(".")).map(([e,t])=>`${e}=${t}`).join(", ");s.write(`${r} ${t.Type} ${t.Action} ${t.Actor?.ID||""}${o?` (${o})`:""}\n`)};for await(const e of o){let t;for(i+=e.toString("utf8");-1!==(t=i.indexOf("\n"));)a(i.slice(0,t)),i=i.slice(t+1)}return a(i),0}function parseSecret(e){const t=parseLabels(String(e).split(",")),s=t.id;if(!s||!/^[A-Za-z0-9._-]+$/.test(s))throw new UsageError(`--secret needs id=NAME (letters, digits, . _ -): ${e}`);const n=t.src||t.source;if(n){const e=path.resolve(n.replace(/^~(?=\/|$)/,os.homedir()));if(!fs.existsSync(e))throw new UsageError(`secret file ${e} not found`);return{id:s,data:fs.readFileSync(e)}}const r=t.env||s;if(void 0===process.env[r])throw new UsageError(`--secret ${s}: set src=FILE or env=VAR (${r} is not set)`);return{id:s,data:Buffer.from(process.env[r])}}async function confirm(e,t){if(t)return!0;if(!process.stdin.isTTY)throw new UsageError("refusing to prune without confirmation; pass -f");const s=require("readline").createInterface({input:process.stdin,output:process.stderr}),n=await new Promise(t=>s.question(`${e} [y/N] `,t));return s.close(),/^y(es)?$/i.test(n.trim())}async function volume(e,t){const{api:s,out:n}=t,[r="ls",...o]=e;switch(r){case"ls":case"list":{const{opts:e}=parseArgs(o,{quiet:{short:"q",type:"bool"},filter:{short:"f",type:"list"}}),t=(await s.json("GET",`/volumes${qs({filters:filtersOf(e.filter)})}`)).Volumes||[];return e.quiet?t.forEach(e=>n.write(`${e.Name}\n`)):fmt.table(["DRIVER","VOLUME NAME"],t.map(e=>[e.Driver,e.Name]),{out:n}),0}case"create":{const{opts:e,args:t}=parseArgs(o,{label:{type:"list"}}),r=await s.json("POST","/volumes/create",{Name:t[0]||"",Labels:parseLabels(e.label)});return n.write(`${r.Name}\n`),0}case"rm":case"remove":{const{opts:e,args:n}=parseArgs(o,{force:{short:"f",type:"bool"}});return each(n,t=>s.json("DELETE",`/volumes/${enc(t)}${qs({force:e.force})}`),t,"volume rm")}case"inspect":return inspect(["--type","volume",...o],t);case"prune":{const{opts:e}=parseArgs(o,{force:{short:"f",type:"bool"},all:{short:"a",type:"bool"}});if(!await confirm("Remove all unused volumes?",e.force))return 0;const t=await s.json("POST",`/volumes/prune${qs({filters:e.all?{all:["true"]}:void 0})}`);return(t.VolumesDeleted||[]).forEach(e=>n.write(`${e}\n`)),n.write(`Total reclaimed space: ${fmt.size(t.SpaceReclaimed||0)}\n`),0}default:throw new UsageError(`unknown volume command "${r}" (ls, create, rm, inspect, prune)`)}}async function network(e,t){const{api:s,out:n}=t,[r="ls",...o]=e;switch(r){case"ls":case"list":{const{opts:e}=parseArgs(o,{quiet:{short:"q",type:"bool"},filter:{short:"f",type:"list"}}),t=await s.json("GET",`/networks${qs({filters:filtersOf(e.filter)})}`);return e.quiet?t.forEach(e=>n.write(`${fmt.shortId(e.Id)}\n`)):fmt.table(["NETWORK ID","NAME","DRIVER","SCOPE"],t.map(e=>[fmt.shortId(e.Id),e.Name,e.Driver,e.Scope]),{out:n}),0}case"create":{const{opts:e,args:t}=parseArgs(o,{label:{type:"list"},internal:{type:"bool"},driver:{short:"d",type:"string"},ipv6:{type:"bool"},subnet:{type:"list"},opt:{short:"o",type:"list"},parent:{type:"string"},gateway:{type:"list"},"ip-range":{type:"list"}});if(1!==t.length)throw new UsageError("network create takes one name");const r=parseLabels(e.opt);e.parent&&(r.parent=e.parent);const i=e.subnet.map((t,s)=>({Subnet:t,...e.gateway[s]?{Gateway:e.gateway[s]}:{},...e["ip-range"][s]?{IPRange:e["ip-range"][s]}:{}})),a=await s.json("POST","/networks/create",{Name:t[0],Driver:e.driver||"bridge",Internal:e.internal,Labels:parseLabels(e.label),EnableIPv6:e.ipv6,IPAM:{Config:i},Options:r});return n.write(`${a.Id}\n`),0}case"rm":case"remove":{const{args:e}=parseArgs(o,{});return each(e,e=>s.json("DELETE",`/networks/${enc(e)}`),t,"network rm")}case"inspect":return inspect(["--type","network",...o],t);case"connect":case"disconnect":{const{opts:e,args:t}=parseArgs(o,{alias:{type:"list"},force:{short:"f",type:"bool"}});if(2!==t.length)throw new UsageError(`network ${r} takes NETWORK CONTAINER`);const n="connect"===r?{Container:t[1],EndpointConfig:{Aliases:e.alias}}:{Container:t[1],Force:e.force};return await s.json("POST",`/networks/${enc(t[0])}/${r}`,n),0}case"prune":{const{opts:e}=parseArgs(o,{force:{short:"f",type:"bool"}});return await confirm("Remove all unused networks?",e.force)?(((await s.json("POST","/networks/prune")).NetworksDeleted||[]).forEach(e=>n.write(`${e}\n`)),0):0}default:throw new UsageError(`unknown network command "${r}" (ls, create, rm, inspect, connect, disconnect, prune)`)}}async function system(e,t){const{api:s,out:n}=t,[r="info",...o]=e;switch(r){case"df":{const e=await s.json("GET","/system/df"),t=(e.Images||[]).reduce((e,t)=>e+(t.Size||0),0),r=(e.Containers||[]).reduce((e,t)=>e+(t.SizeRw||0),0),o=(e.Volumes||[]).reduce((e,t)=>e+(t.UsageData?.Size>0?t.UsageData.Size:0),0),i=(e.BuildCache||[]).reduce((e,t)=>e+(t.Size||0),0);return fmt.table(["TYPE","TOTAL","ACTIVE","SIZE"],[["Images",(e.Images||[]).length,(e.Images||[]).filter(e=>e.Containers>0).length,fmt.size(t)],["Containers",(e.Containers||[]).length,(e.Containers||[]).filter(e=>"running"===e.State).length,fmt.size(r)],["Local Volumes",(e.Volumes||[]).length,(e.Volumes||[]).filter(e=>e.UsageData?.RefCount>0).length,fmt.size(o)],["Build Cache",(e.BuildCache||[]).length,0,fmt.size(i)]],{out:n}),0}case"prune":{const{opts:e}=parseArgs(o,{force:{short:"f",type:"bool"},all:{short:"a",type:"bool"},volumes:{type:"bool"}}),t=["all stopped containers","all unused networks",e.all?"all unused images":"all dangling images","the build cache",...e.volumes?["all unused volumes"]:[]];if(!await confirm(`This removes:\n - ${t.join("\n - ")}\nContinue?`,e.force))return 0;let r=0;return r+=(await s.json("POST","/containers/prune")).SpaceReclaimed||0,await s.json("POST","/networks/prune"),r+=(await s.json("POST",`/images/prune${qs({filters:{dangling:[e.all?"false":"true"]}})}`)).SpaceReclaimed||0,r+=(await s.json("POST",`/build/prune${qs({all:e.all})}`)).SpaceReclaimed||0,e.volumes&&(r+=(await s.json("POST","/volumes/prune")).SpaceReclaimed||0),n.write(`Total reclaimed space: ${fmt.size(r)}\n`),0}case"info":{const[e,t]=await Promise.all([s.json("GET","/version"),s.json("GET","/info")]);n.write(`dock ${e.Version} (${t.OperatingSystem||""} ${t.Architecture||""})\n`),n.write(` containers ${t.Containers} (${t.ContainersRunning} running, ${t.ContainersStopped} stopped)\n`),n.write(` images ${t.Images}\n`),n.write(` cpus ${t.NCPU} memory ${fmt.size(t.MemTotal)}\n`),(t.SecurityOptions||[]).includes("name=rootless")&&n.write(" mode rootless\n"),(t.SecurityOptions||[]).includes("name=userns")&&n.write(" userns remapped: the root of containers is an unprivileged id range\n");for(const e of t.Warnings||[])n.write(chalk.yellow(` warning ${e}\n`));return 0}default:throw new UsageError(`unknown system command "${r}" (info, df, prune)`)}}const COMMANDS={run,create,start,stop,restart,kill,rm,wait,ps,logs,exec,inspect,images,pull,push,rmi,tag,build,login,logout,volume,network,system,manifest,search,events};for(const e of["cp","diff","save","load","import"])COMMANDS[e]=(t,s)=>require("./files")[e](t,s);COMMANDS.export=(e,t)=>require("./files").exportCmd(e,t);for(const e of["stats","top","pause","unpause","rename","update","commit","history","port"])COMMANDS[e]=(t,s)=>require("./manage")[e](t,s);async function container(e,t){const[s="ls",...n]=e;if("export"===s)return COMMANDS.export(n,t);if("prune"===s){const{opts:e}=parseArgs(n,{force:{short:"f",type:"bool"}});if(!await confirm("Remove all stopped containers?",e.force))return 0;const s=await t.api.json("POST","/containers/prune");return(s.ContainersDeleted||[]).forEach(e=>t.out.write(`${e}\n`)),t.out.write(`Total reclaimed space: ${fmt.size(s.SpaceReclaimed||0)}\n`),0}const r={ls:"ps",list:"ps",remove:"rm",prune:null}[s]||s;if(!COMMANDS[r]||["images","pull","push","rmi","tag","build","login","logout","volume","network","system","save","load","history","import","manifest","search","events"].includes(r))throw new UsageError(`unknown container command "${s}"`);return COMMANDS[r](n,t)}async function image(e,t){const[s="ls",...n]=e;if("prune"===s){const{opts:e}=parseArgs(n,{force:{short:"f",type:"bool"},all:{short:"a",type:"bool"}});if(!await confirm(e.all?"Remove all images without a container?":"Remove all dangling images?",e.force))return 0;const s=await t.api.json("POST",`/images/prune${qs({filters:{dangling:[e.all?"false":"true"]}})}`);return(s.ImagesDeleted||[]).forEach(e=>t.out.write(e.Untagged?`Untagged: ${e.Untagged}\n`:`Deleted: ${e.Deleted}\n`)),t.out.write(`Total reclaimed space: ${fmt.size(s.SpaceReclaimed||0)}\n`),0}const r={ls:"images",list:"images",rm:"rmi",remove:"rmi",pull:"pull",push:"push",tag:"tag",build:"build",inspect:null,save:"save",load:"load",history:"history",import:"import"};if("inspect"===s)return inspect(["--type","image",...n],t);if("scan"===s)return require("./trust").scan(n,t);if(!r[s])throw new UsageError(`unknown image command "${s}" (ls, pull, push, build, tag, rm, inspect, prune, save, load, history, import, scan)`);return COMMANDS[r[s]](n,t)}Object.assign(COMMANDS,{container,image}),COMMANDS.compose=(e,t)=>require("./compose").compose(e,t),COMMANDS.workspace=(e,t)=>require("./workspace").workspace(e,t),COMMANDS.trust=(e,t)=>require("./trust").trust(e,t),COMMANDS.scan=(e,t)=>require("./trust").scan(e,t),COMMANDS.service=(e,t)=>require("./services").service(e,t),COMMANDS.registry=(e,t)=>require("./services").registry(e,t),COMMANDS.k8s=(e,t)=>require("./k8s").k8s(e,t);for(const e of["capsule","vault","mesh","stack","vm"])COMMANDS[e]=(t,s)=>require("./moved")[e](t,s);const USAGE={run:"Usage: thinknsh run [OPTIONS] IMAGE [COMMAND] [ARG…]\n\nCreate and start a container. Pulls the image if it is not here yet.\n\n -d, --detach Run in the background and print the container ID\n -i, --interactive Keep stdin open -t, --tty Allocate a terminal\n --rm Remove the container when it exits\n --name NAME Container name\n -p, --publish H:C Publish container port C on host port H (C/udp for udp, ranges 8000-8002:8000-8002)\n -e, --env K=V Set an environment variable --env-file FILE\n -v, --volume SRC:DST Named volume or host folder (./relative works)\n -w, --workdir DIR -u, --user USER --entrypoint CMD --hostname NAME\n --network NET --network-alias A --add-host HOST:IP\n --restart POLICY no | always | unless-stopped | on-failure[:N]\n -m, --memory 512m --cpus 1.5 --pids-limit N --shm-size 64m\n --cgroupns private|host (private: the container sees only its own cgroup)\n --userns host Run as real root although dock config --userns-remap is on\n --health-cmd CMD --health-interval 30s --health-retries N --no-healthcheck\n --privileged --cap-add CAP --cap-drop CAP --read-only\n -l, --label K=V --pull missing|always|never -q, --quiet\n --platform linux/amd64 Run an image built for another CPU (Rosetta 2 on Apple Silicon)\n --init A tiny PID 1 that forwards signals and reaps zombies\n --mount type=bind|volume|tmpfs,source=…,target=…[,readonly]\n --ulimit nofile=1024:2048 --sysctl net.core.somaxconn=1024\n --dns IP --dns-search DOMAIN --dns-option OPT\n --memory-swap 1g --memory-reservation 256m --cpuset-cpus 0-1 -c, --cpu-shares N\n --device /dev/x --volumes-from NAME[:ro] --group-add GROUP --security-opt OPT\n --log-opt max-size=10m --cidfile FILE --label-file FILE\n\nDetach from a -it session with Ctrl-P Ctrl-Q.",create:"Usage: thinknsh create [OPTIONS] IMAGE [COMMAND] [ARG…]\n\nLike run, without starting the container. Same options as run.",start:"Usage: thinknsh start [-a] [-i] CONTAINER…\n\n -a, --attach Attach to its output and wait\n -i Attach stdin too",stop:"Usage: thinknsh stop [-t SECONDS] CONTAINER…\n\nSends the stop signal, then SIGKILL after the timeout (default 10s).",restart:"Usage: thinknsh restart [-t SECONDS] CONTAINER…",kill:"Usage: thinknsh kill [-s SIGNAL] CONTAINER…",rm:"Usage: thinknsh rm [-f] [-v] CONTAINER…\n\n -f, --force Stop it first if running\n -v, --volumes Also remove its anonymous volumes",wait:"Usage: thinknsh wait CONTAINER…\n\nBlocks until each container stops, then prints its exit code.",ps:"Usage: thinknsh ps [-a] [-q] [-f KEY=VALUE] [--no-trunc] [--json]\n\n -a All containers (default: running only)\n -q IDs only\n -f Filter: name=, label=, status=, ancestor=, id=",logs:"Usage: thinknsh logs [-f] [-n N] [-t] [--since T] CONTAINER\n\n -f, --follow Keep streaming\n -n, --tail N Last N lines\n -t Show timestamps",exec:"Usage: thinknsh exec [-it] [-d] [-e K=V] [-w DIR] [-u USER] CONTAINER COMMAND [ARG…]\n\nRuns a command in a running container and exits with its code.",inspect:"Usage: thinknsh inspect [--type container|image|volume|network] NAME…",images:"Usage: thinknsh images [-a] [-q] [REPOSITORY[:TAG]]",pull:"Usage: thinknsh pull [-q] [--platform linux/amd64] IMAGE[:TAG|@DIGEST]",push:"Usage: thinknsh push [-a] [-q] IMAGE[:TAG]\n\nUpload an image to its registry (the host in its name; no host = the public\ndefault registry). Tag it first: thinknsh tag myapp:dev ghcr.io/me/myapp:1.0\nUses the login saved by thinknsh login for that registry.\n\n -a, --all-tags Push every local tag of the repository\n -q, --quiet Print only NAME:TAG@DIGEST",login:'Usage: thinknsh login [-u USER] [--password-stdin] [REGISTRY]\n\nCheck a registry login and save it for pull and push (default: the public\ndefault registry). Saved per registry host in ~/.tnc/dock/auth.json (mode 0600).\nAsks for the password without echo, or reads it from stdin:\n echo "$TOKEN" | thinknsh login -u me --password-stdin ghcr.io\n\n -u, --username USER --password-stdin Read the password from stdin',logout:"Usage: thinknsh logout [REGISTRY]\n\nForget the saved login for REGISTRY (default: the public default registry).",rmi:"Usage: thinknsh rmi [-f] IMAGE…",manifest:"Usage: thinknsh manifest create [--amend] NAME IMAGE… | inspect NAME | push [--purge] NAME | rm NAME…\n\nA multi-platform image from local images (one per platform), e.g.\n thinknsh build --platform linux/arm64 -t app:arm64 . && thinknsh build --platform linux/amd64 -t app:amd64 .\n thinknsh manifest create ghcr.io/me/app:1 app:arm64 app:amd64 && thinknsh manifest push ghcr.io/me/app:1\ninspect on a name with no local list shows the registry's manifest (or index).",search:"Usage: thinknsh search [--limit N] [-f is-official=true|stars=N] [--no-trunc] [--json] TERM\n\nSearches the public default registry.",events:"Usage: thinknsh events [--since 10m|TIME] [--until TIME] [-f type=container|event=start|container=NAME|label=K=V] [--json]\n\nStreams engine events (Ctrl-C stops). With --until it ends there.",tag:"Usage: thinknsh tag SOURCE TARGET",build:`Usage: thinknsh build [OPTIONS] [FOLDER]\n\nBuild an image from the build file (${BUILD_FILE} or Containerfile) in FOLDER (default: .).\nFiles listed in .tncignore (or an existing ${BUILD_IGNORE_FILE}) are not sent.\n\n -t, --tag NAME:TAG Name the image (repeatable)\n -f, --file FILE Build file to use\n --build-arg K=V --target STAGE --no-cache --pull -q, --quiet\n --platform linux/amd64 Build for another CPU (FROM --platform, TARGETARCH, … work)\n --platform linux/amd64,linux/arm64 Several at once: one build each; this machine's gets the tags\n --push Push the tags when done (several platforms: as one multi-platform image)\n --secret id=NAME,src=FILE | id=NAME,env=VAR For RUN --mount=type=secret,id=NAME\n --ssh default | ID=AGENT_SOCKET This machine's SSH agent for RUN --mount=type=ssh (git clone of private repos)\n --cache-from REF | type=registry,ref=REF | type=local,src=DIR Reuse a cache another build exported\n --cache-to type=registry,ref=REF[,mode=max] | type=local,dest=DIR | type=inline Export this build's cache\n\nBuild files may use RUN --mount=type=cache|secret|bind|tmpfs|ssh and heredocs (RUN <<EOF, COPY <<EOF /file).\nIndependent stages are built at the same time.`,cp:'Usage: thinknsh cp [-a] [-L] SRC DEST\n\nCopy files or folders between a container and this machine. One side is CONTAINER:PATH.\n"PATH/." copies a folder\'s contents. "-" as SRC or DEST is a tar on stdin or stdout.\n\n -a, --archive Keep owners (uid/gid) -L, --follow-link Follow a symlink in SRC\n\n thinknsh cp web:/etc/nginx/nginx.conf ./nginx.conf\n thinknsh cp ./site/. web:/usr/share/nginx/html',stats:"Usage: thinknsh stats [-a] [--no-stream] [--json] [CONTAINER…]\n\nLive CPU, memory, network, disk and process counts, read from the kernel (cgroups).",top:"Usage: thinknsh top CONTAINER",pause:"Usage: thinknsh pause CONTAINER…\n\nFreezes every process (cgroup freezer); unpause thaws them.",unpause:"Usage: thinknsh unpause CONTAINER…",rename:"Usage: thinknsh rename CONTAINER NEW_NAME",update:"Usage: thinknsh update [OPTIONS] CONTAINER…\n\n -m, --memory 1g --memory-swap 2g --memory-reservation 256m --cpus 1.5\n -c, --cpu-shares N --cpuset-cpus 0-1 --pids-limit N --restart POLICY\n\nA running container gets the new limits at once.",diff:"Usage: thinknsh diff CONTAINER\n\nA added, C changed, D deleted, compared with its image.",port:"Usage: thinknsh port CONTAINER [PRIVATE_PORT[/PROTO]]",commit:"Usage: thinknsh commit [-m MSG] [-a AUTHOR] [-c CHANGE]… [--no-pause] CONTAINER [REPO[:TAG]]\n\nMakes an image from the container's changes. -c applies build file instructions (CMD, ENV, EXPOSE, …).",save:"Usage: thinknsh save [-o FILE] IMAGE…\n\nWrites images (OCI layout + manifest.json) to FILE or stdout; loads in any engine.",load:"Usage: thinknsh load [-i FILE] [-q]\n\nReads images from FILE or stdin (plain or gzip tar).",import:"Usage: thinknsh import [-c CHANGE]… [-m MSG] [--platform P] FILE|- [REPO[:TAG]]\n\nMakes a one-layer image from a root filesystem tar.",export:"Usage: thinknsh container export [-o FILE] CONTAINER\n\nWrites the container's filesystem (without volumes) as a tar.",volume:"Usage: thinknsh volume ls | create [NAME] | rm [-f] NAME… | inspect NAME… | prune [-f] [-a]",network:"Usage: thinknsh network ls | create [--ipv6] [--subnet fd00:1::/64] [--internal] NAME | rm NAME… | inspect NAME… | connect NET CONTAINER | disconnect NET CONTAINER | prune [-f]\n\n --ipv6 containers get an IPv6 address too (a /64: given with --subnet, or a unique local one); names resolve to both\n -d macvlan|ipvlan --subnet 192.168.1.0/24 --gateway 192.168.1.1 [--ip-range 192.168.1.192/27] [--parent eth0] [-o macvlan_mode=bridge]\n containers on this machine's LAN with their own addresses (Linux as root only)\n -d overlay --subnet 10.20.0.0/16 --ip-range 10.20.1.0/24 -o peers=192.168.1.11,192.168.1.12 [-o vni=4242] [-o local=IP]\n one network across machines (VXLAN, UDP 4789): create it on each, same subnet, own --ip-range (Linux as root only)\n -o encrypted -o key=HEX|key-file=PATH IPsec (AES-256-GCM) between the machines; the same 32-byte key on each\n (make one with: openssl rand -hex 32)",system:"Usage: thinknsh system info | df | prune [-f] [-a] [--volumes]",container:"Usage: thinknsh container ls | run | create | start | stop | restart | kill | rm | logs | exec | inspect | wait | prune\n | cp | export | diff | stats | top | pause | unpause | rename | update | commit | port",image:"Usage: thinknsh image ls | pull | push | build | tag | rm | inspect | prune | save | load | history | import | scan",get trust(){return require("./trust").USAGE},get scan(){return require("./trust").SCAN_USAGE},history:"Usage: thinknsh image history [-q] [--no-trunc] IMAGE",get workspace(){return require("./workspace").USAGE}};async function runCommand(e,t,{api:s=new DockApi,out:n=process.stdout,err:r=process.stderr,autoStart:o=!0}={}){const i=COMMANDS[e];if(!i)return r.write(`unknown command ${e}\n`),2;if((["--help","-h","help"].includes(t[0])||!["run","create","exec","compose","workspace"].includes(e)&&t.includes("--help"))&&USAGE[e])return n.write(USAGE[e]+"\n"),0;try{const a="workspace"===e&&["join","joined","leave","proxy"].includes(t[0])||"registry"===e&&"trust"===t[0];return o&&!a&&await s.ensureRunning({quiet:!0}),await i(t,{api:s,out:n,err:r})}catch(t){return t instanceof UsageError?(r.write(`${chalk.red("Error:")} ${t.message}\n${chalk.dim(`See: thinknsh ${e} --help`)}\n`),2):(r.write(`${chalk.red("Error:")} ${t.message}\n`),"run"===e?125:1)}}module.exports={COMMANDS,USAGE,runCommand,promptHidden,parsePublish,parseMountFlag,parseUlimit,resolveBind,splitRepoTag,createBody,pullImage,readStdin,confirm,each,CREATE_FLAGS,attachAndWait,gpuRequest};
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @license ThinkNCollab Enterprise & Open Core Ecosystem
3
+ * Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
4
+ * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
+ * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
+ */
7
+ "use strict";const fs=require("fs"),os=require("os"),path=require("path"),chalk=require("chalk"),yaml=require("js-yaml"),{EngineError,qs,enc}=require("./api"),{parseArgs,UsageError}=require("./args"),{pullImage}=require("./commands"),{dockHome}=require("../paths"),NAME="tnc-k8s",IMAGE="rancher/k3s",VERSION="v1.31.4-k3s1",CONTEXT="thinknsh",sleep=e=>new Promise(t=>setTimeout(t,e)),USAGE=`Usage: thinknsh k8s up [--port 6443] [--version ${VERSION}] [--no-kube-proxy]\n thinknsh k8s status | down | rm [-f] | kubeconfig [--print] | load IMAGE… | kubectl ARG…\n\nA local Kubernetes cluster (k3s) as the container ${NAME}; the API on 127.0.0.1 only.\n up create or start it and wait until the node is Ready; adds the kubectl context "${CONTEXT}"\n down stop it (workloads and state are kept)\n rm delete the cluster and its state\n kubeconfig where the kubeconfig is (--print: print it)\n load IMAGE copy images from this engine into the cluster (no registry needed)\n kubectl run kubectl inside the cluster container, e.g. thinknsh k8s kubectl get pods -A\n --no-kube-proxy API and pods only, without Service networking (for kernels without the iptables features)`,kubeDir=(e=dockHome())=>path.join(e,"k8s"),kubeconfigFile=(e=dockHome())=>path.join(kubeDir(e),"kubeconfig.yaml");async function inspectOrNull(e,t){try{return await e.json("GET",`/containers/${enc(t)}/json`)}catch(e){if(e instanceof EngineError&&404===e.status)return null;throw e}}async function execK8s(e,t,{stdinFile:n=null}={}){const r=path.join(os.tmpdir(),`tnc-k8s-${process.pid}-${Date.now()}.out`),{execIn:s}=require("../room-data");try{return{...await s(e,NAME,t,{stdinFile:n,outFile:r}),stdout:fs.readFileSync(r,"utf8")}}finally{fs.rmSync(r,{force:!0})}}const MEMORY='m=$(awk \'/^MemTotal/ {print $2}\' /proc/meminfo)\n[ "$m" -ge 1800000 ] || { echo "only $((m / 1024)) MB of memory for containers; Kubernetes needs 2 GB or more"; exit 4; }',PROBE='set -e\nip link add tnc-probe type vxlan id 42 dstport 4789 dev eth0 2>/dev/null && ip link del tnc-probe || { echo "no VXLAN"; exit 3; }\niptables -N TNC-PROBE\niptables -A TNC-PROBE -p tcp --dport 1 -m comment --comment probe -j RETURN 2>/dev/null || { iptables -X TNC-PROBE; echo "no iptables port/comment matches"; exit 3; }\niptables -F TNC-PROBE; iptables -X TNC-PROBE';function createBody({port:e,version:t,kubeProxy:n,home:r}){const s=["server","--disable=traefik","--secrets-encryption",`--node-name=${NAME}`,"--data-dir=/var/lib/tnc-k3s","--snapshotter=native","--kubelet-arg=local-storage-capacity-isolation=false","--kube-proxy-arg=conntrack-max-per-core=0","--tls-san=127.0.0.1","--tls-san=localhost","--write-kubeconfig-mode=600"];return n||s.push("--disable-kube-proxy","--disable-network-policy","--flannel-backend=host-gw"),{Image:`${IMAGE}:${t}`,Cmd:s,Env:["K3S_KUBECONFIG_OUTPUT=/output/kubeconfig.yaml","K3S_KUBECONFIG_MODE=600"],Labels:{"tnc.k8s":"1","tnc.k8s.port":String(e),"tnc.k8s.kube-proxy":n?"1":"0"},Hostname:NAME,ExposedPorts:{"6443/tcp":{}},HostConfig:{Privileged:!0,UsernsMode:"host",Tmpfs:{"/run":"","/var/run":""},Binds:[`${kubeDir(r)}:/output`],PortBindings:{"6443/tcp":[{HostIp:"127.0.0.1",HostPort:String(e)}]},RestartPolicy:{Name:"unless-stopped"}}}}function mergeKubeconfig(e,t,{name:n=CONTEXT,server:r=`https://127.0.0.1:${t}`,file:s=process.env.KUBECONFIG?.split(path.delimiter)[0]||path.join(os.homedir(),".kube","config")}={}){const o=yaml.load(e),i={...o.clusters[0].cluster,server:r},a=o.users[0].user;let c={apiVersion:"v1",kind:"Config",clusters:[],users:[],contexts:[],preferences:{}};try{c={...c,...yaml.load(fs.readFileSync(s,"utf8"))||{}}}catch(e){}const u=(e,t)=>[...(e||[]).filter(e=>e.name!==t.name),t];c.clusters=u(c.clusters,{name:n,cluster:i}),c.users=u(c.users,{name:n,user:a}),c.contexts=u(c.contexts,{name:n,context:{cluster:n,user:n}}),c["current-context"]||(c["current-context"]=n),fs.mkdirSync(path.dirname(s),{recursive:!0,mode:448}),fs.writeFileSync(s,yaml.dump(c),{mode:384});try{fs.chmodSync(s,384)}catch(e){}return{file:s,current:c["current-context"]}}function removeFromKubeconfig({file:e=process.env.KUBECONFIG?.split(path.delimiter)[0]||path.join(os.homedir(),".kube","config")}={}){let t;try{t=yaml.load(fs.readFileSync(e,"utf8"))}catch(e){return}if(t){for(const e of["clusters","users","contexts"])t[e]=(t[e]||[]).filter(e=>e.name!==CONTEXT);t["current-context"]===CONTEXT&&delete t["current-context"],fs.writeFileSync(e,yaml.dump(t),{mode:384})}}async function waitReady(e,t,n=24e4){const r=Date.now()+n;let s="";for(;Date.now()<r;){const n=await inspectOrNull(e,NAME);if(!n?.State.Running)throw new Error(`${NAME} stopped while starting; see: thinknsh logs ${NAME}`);const r=await execK8s(e,["kubectl","get","node",NAME,"-o",'jsonpath={.status.conditions[?(@.type=="Ready")].status}']).catch(()=>null);if("True"===r?.stdout.trim()&&fs.existsSync(kubeconfigFile()))return;const o=r?.stdout.trim()?"node not Ready yet":"API server starting";o!==s&&(t.write(chalk.dim(` ${o}…\n`)),s=o),await sleep(2e3)}throw new Error(`the cluster did not become Ready in ${n/1e3} s; see: thinknsh logs ${NAME}`)}async function up(e,{api:t,out:n,err:r}){const{opts:s}=parseArgs(e,{port:{type:"string"},version:{type:"string"},"no-kube-proxy":{type:"bool"}}),o=Number(s.port||6443);if(!(o>0&&o<65536))throw new UsageError(`invalid port "${s.port}"`);const i=s.version||VERSION;if(!/^v\d+\.\d+\.\d+[-+.\w]*$/.test(i))throw new UsageError(`invalid version "${i}" (e.g. ${VERSION})`);const a=!s["no-kube-proxy"],c=dockHome();fs.mkdirSync(kubeDir(c),{recursive:!0,mode:448});let u=await inspectOrNull(t,NAME);if(u&&(u.Config.Labels?.["tnc.k8s.port"]!==String(o)||u.Config.Image!==`${IMAGE}:${i}`||"1"===u.Config.Labels?.["tnc.k8s.kube-proxy"]!==a)&&(s.port||s.version||s["no-kube-proxy"]))throw new UsageError(`${NAME} already exists with other settings; remove it first: thinknsh k8s rm`);if(!u){const e=createBody({port:o,version:i,kubeProxy:a,home:c});try{await t.json("POST",`/containers/create${qs({name:NAME})}`,e)}catch(n){if(!(n instanceof EngineError&&404===n.status))throw n;r.write(`pulling ${e.Image}\n`),await pullImage(t,e.Image,{err:r}),await t.json("POST",`/containers/create${qs({name:NAME})}`,e)}u=await inspectOrNull(t,NAME)}u.State.Running||await t.json("POST",`/containers/${enc(NAME)}/start`);const l=async()=>{await t.json("POST",`/containers/${enc(NAME)}/stop?t=5`).catch(()=>{}),await t.json("DELETE",`/containers/${enc(NAME)}?force=1`).catch(()=>{})},d=await execK8s(t,["sh","-c",MEMORY]).catch(e=>({code:1,stdout:e.message}));if(4===d.code)throw await l(),new Error(`${d.stdout.trim()}. Give dock's Linux VM more: thinknsh dock config --memory 4g --cpus 2, then thinknsh dock vm-restart (it stops running containers)`);if(a){const e=await execK8s(t,["sh","-c",PROBE]).catch(e=>({code:1,stdout:e.message}));if(0!==e.code)throw await l(),new Error(`this machine's Linux kernel lacks what Kubernetes networking needs (${e.stdout.trim()||"probe failed"}). Update thinknsh to a version whose VM includes them, then run thinknsh dock setup and restart dock (thinknsh dock stop && thinknsh dock start); or run "thinknsh k8s up --no-kube-proxy" for the API and pods without Service networking`)}r.write(`starting Kubernetes (${i}) in ${NAME}\n`),await waitReady(t,r);const{file:p,current:h}=mergeKubeconfig(fs.readFileSync(kubeconfigFile(c),"utf8"),o);return n.write(`${chalk.green("✓")} Kubernetes is running on https://127.0.0.1:${o}\n`),n.write(chalk.dim(` kubectl context "${CONTEXT}" in ${p}${h===CONTEXT?" (current)":`; switch: kubectl config use-context ${CONTEXT}`}\n`)),n.write(chalk.dim(" local images: thinknsh k8s load myapp:1 then use imagePullPolicy: IfNotPresent\n")),a||n.write(chalk.yellow(" without kube-proxy: Services (ClusterIP, NodePort) do not route\n")),0}async function status({api:e,out:t}){const n=await inspectOrNull(e,NAME);if(!n)return t.write("k8s: not created (thinknsh k8s up)\n"),0;if(!n.State.Running)return t.write("k8s: stopped (thinknsh k8s up)\n"),0;t.write(`k8s: running on https://127.0.0.1:${n.Config.Labels?.["tnc.k8s.port"]||6443} (${n.Config.Image.split(":")[1]})\n`);const r=await execK8s(e,["kubectl","get","nodes,pods","-A","-o","wide"]).catch(()=>null);return r?.stdout&&t.write(r.stdout),0}async function load(e,{api:t,out:n,err:r}){if(!e.length)throw new UsageError("k8s load takes one or more images, e.g. thinknsh k8s load myapp:1");const s=await inspectOrNull(t,NAME);if(!s?.State.Running)throw new UsageError("the cluster is not running; start it with: thinknsh k8s up");const o=path.join(os.tmpdir(),`tnc-k8s-load-${process.pid}.tar`);try{const s=await t.stream("GET",`/images/get${qs({names:e})}`);await new Promise((e,t)=>{const n=fs.createWriteStream(o);s.pipe(n),n.on("finish",e),n.on("error",t),s.on("error",t)}),r.write(chalk.dim(` importing ${e.join(", ")}\n`));const i=await execK8s(t,["ctr","-n","k8s.io","images","import","--all-platforms","-"],{stdinFile:o});if(0!==i.code)throw new Error(`import failed: ${i.stderr||i.stdout}`);return n.write(`${chalk.green("✓")} loaded ${e.join(", ")} into the cluster\n`),0}finally{fs.rmSync(o,{force:!0})}}async function k8s(e,t){const{api:n,out:r}=t,[s="status",...o]=e;if(["--help","-h","help"].includes(s))return r.write(USAGE+"\n"),0;switch(s){case"up":case"start":return up(o,t);case"status":case"ps":return status(t);case"down":case"stop":return await n.json("POST",`/containers/${enc(NAME)}/stop?t=20`),r.write(`${NAME} stopped (state kept; thinknsh k8s up starts it again)\n`),0;case"rm":case"delete":if(!o.includes("-f")&&!o.includes("--force")){const{confirm:e}=require("./commands");if(!await e("Delete the local Kubernetes cluster and everything in it?"))return 1}return await n.json("DELETE",`/containers/${enc(NAME)}?force=1`).catch(e=>{if(!(e instanceof EngineError&&404===e.status))throw e}),fs.rmSync(kubeconfigFile(),{force:!0}),removeFromKubeconfig(),r.write(`${NAME} removed\n`),0;case"kubeconfig":if(!fs.existsSync(kubeconfigFile()))throw new UsageError("no cluster yet: thinknsh k8s up");return o.includes("--print")?r.write(fs.readFileSync(kubeconfigFile(),"utf8")):r.write(`${kubeconfigFile()}\n`),0;case"load":return load(o,t);case"kubectl":{const e=await execK8s(n,["kubectl",...o]);return r.write(e.stdout),e.stderr&&t.err.write(e.stderr+"\n"),e.code}default:throw new UsageError(`unknown k8s command "${s}" (up, status, down, rm, kubeconfig, load, kubectl)`)}}module.exports={k8s,USAGE,createBody,mergeKubeconfig,removeFromKubeconfig,NAME,VERSION};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const chalk=require("chalk"),{EngineError,qs,enc}=require("./api"),{parseArgs,UsageError}=require("./args"),{pullImage,runCommand,splitRepoTag}=require("./commands"),fmt=require("./format"),LABEL="tnc.service",SERVICES={postgres:{image:"postgres:16-alpine",port:5432,data:"/var/lib/postgresql/data",env:["POSTGRES_HOST_AUTH_METHOD=trust"],health:["CMD-SHELL","pg_isready -U postgres"],url:t=>`postgresql://postgres@localhost:${t}/postgres`},redis:{image:"redis:7-alpine",port:6379,data:"/data",env:[],health:["CMD","redis-cli","ping"],url:t=>`redis://localhost:${t}`},mysql:{image:"mysql:8.4",port:3306,data:"/var/lib/mysql",env:["MYSQL_ALLOW_EMPTY_PASSWORD=yes"],health:["CMD-SHELL","mysqladmin ping -h 127.0.0.1 --silent"],url:t=>`mysql://root@localhost:${t}`}},ALIASES={pg:"postgres",postgresql:"postgres",mariadb:"mysql"},REGISTRY={name:"tnc-registry",image:"registry:2",port:5e3,data:"/var/lib/registry"},containerName=t=>`tnc-svc-${t}`,sleep=t=>new Promise(e=>setTimeout(e,t));function serviceOf(t){const e=ALIASES[t]||t;if(!SERVICES[e])throw new UsageError(`unknown service "${t}" (${Object.keys(SERVICES).join(", ")})`);return e}async function inspectOrNull(t,e){try{return await t.json("GET",`/containers/${enc(e)}/json`)}catch(t){if(t instanceof EngineError&&404===t.status)return null;throw t}}const hostPortOf=t=>Number(t?.HostConfig?.PortBindings&&Object.values(t.HostConfig.PortBindings)[0]?.[0]?.HostPort)||null;async function ensureContainer(t,{name:e,image:r,port:s,hostPort:n,data:a,env:o=[],health:i,labels:l,err:c}){let u=await inspectOrNull(t,e);if(u&&n&&hostPortOf(u)!==n)throw new UsageError(`${e} already exists on port ${hostPortOf(u)}; remove it first (data stays in its volume): thinknsh rm -f ${e}`);if(!u){const h={Image:r,Env:o,Labels:l,ExposedPorts:{[`${s}/tcp`]:{}},...i?{Healthcheck:{Test:i,Interval:2e9,Timeout:5e9,Retries:30}}:{},HostConfig:{Binds:[`${e}:${a}`],PortBindings:{[`${s}/tcp`]:[{HostIp:"127.0.0.1",HostPort:String(n||s)}]},RestartPolicy:{Name:"unless-stopped"}}};try{await t.json("POST",`/containers/create${qs({name:e})}`,h)}catch(s){if(!(s instanceof EngineError&&404===s.status))throw s;c.write(`pulling ${r}\n`),await pullImage(t,r,{err:c}),await t.json("POST",`/containers/create${qs({name:e})}`,h)}u=await inspectOrNull(t,e)}return u.State.Running||(await t.json("POST",`/containers/${enc(e)}/start`),u=await inspectOrNull(t,e)),u}async function waitHealthy(t,e,r,s=12e4){const n=Date.now()+s;for(;;){const r=await inspectOrNull(t,e);if(!r?.State.Running)throw new Error(`${e} stopped while starting; see: thinknsh logs ${e}`);const a=r.State.Health?.Status;if(!r.Config.Healthcheck||"healthy"===a)return;if("unhealthy"===a)throw new Error(`${e} is unhealthy; see: thinknsh logs ${e}`);if(Date.now()>n)throw new Error(`${e} did not become healthy in ${s/1e3}s; see: thinknsh logs ${e}`);await sleep(500)}}const SERVICE_USAGE=`Usage: thinknsh service start NAME [-p PORT] | stop NAME | restart NAME | status [NAME] | logs [-f] NAME | rm [-v] NAME\n\nDev databases as containers: ${Object.keys(SERVICES).join(", ")}. Each listens on 127.0.0.1 only,\nkeeps its data in the volume tnc-svc-NAME, and restarts with dock unless you stop it.\n\n start NAME [-p PORT] Create or start it and wait until it is healthy (default ports: ${Object.entries(SERVICES).map(([t,e])=>`${t} ${e.port}`).join(", ")})\n rm NAME [-v] Remove the container; -v also deletes its data volume`;async function service(t,e){const{api:r,out:s,err:n}=e,[a="status",...o]=t;if(["--help","-h","help"].includes(a))return s.write(SERVICE_USAGE+"\n"),0;switch(a){case"start":{const{opts:t,args:e}=parseArgs(o,{port:{short:"p",type:"string"}});if(1!==e.length)throw new UsageError("service start takes one name, e.g. thinknsh service start postgres");const a=serviceOf(e[0]),i=SERVICES[a],l=t.port?Number(t.port):void 0;if(t.port&&!(l>0&&l<65536))throw new UsageError(`invalid port "${t.port}"`);const c=containerName(a),u=await ensureContainer(r,{name:c,image:i.image,port:i.port,hostPort:l,data:i.data,env:i.env,health:i.health,labels:{[LABEL]:a},err:n});n.write(chalk.dim(`waiting for ${a} to be ready…\n`)),await waitHealthy(r,c,n);const h=hostPortOf(u)||i.port;return s.write(`${chalk.green("✓")} ${a} is running ${i.url(h)}\n`),s.write(chalk.dim(` container ${c}, data in volume ${c}\n`)),0}case"stop":case"restart":{if(1!==o.length)throw new UsageError(`service ${a} takes one name`);const t=containerName(serviceOf(o[0]));return await r.json("POST",`/containers/${enc(t)}/${a}`),s.write(`${t}\n`),0}case"logs":{const{opts:t,args:r}=parseArgs(o,{follow:{short:"f",type:"bool"},tail:{short:"n",type:"string"}});if(1!==r.length)throw new UsageError("service logs takes one name");return runCommand("logs",[...t.follow?["-f"]:[],...t.tail?["-n",t.tail]:[],containerName(serviceOf(r[0]))],{...e,autoStart:!1})}case"rm":case"remove":{const{opts:t,args:e}=parseArgs(o,{volumes:{short:"v",type:"bool"}});if(1!==e.length)throw new UsageError("service rm takes one name");const n=containerName(serviceOf(e[0]));return await r.json("DELETE",`/containers/${enc(n)}${qs({force:!0})}`),t.volumes&&await r.json("DELETE",`/volumes/${enc(n)}`),s.write(`${n}${t.volumes?" (and its data)":""}\n`),0}case"status":case"ls":{const t=await r.json("GET",`/containers/json${qs({all:!0,filters:{label:[LABEL]}})}`),e=o[0]?serviceOf(o[0]):null,n=Object.keys(SERVICES).filter(t=>!e||t===e).map(e=>{const r=t.find(t=>t.Labels[LABEL]===e),s=r?.Ports?.find(t=>t.PublicPort)?.PublicPort;return[e,r?r.Status:"not created",r&&"running"===r.State&&s?SERVICES[e].url(s):"-"]});return fmt.table(["SERVICE","STATUS","CONNECT"],n,{out:s}),0}default:throw new UsageError(`unknown service command "${a}" (start, stop, restart, status, logs, rm)`)}}const REGISTRY_USAGE=`Usage: thinknsh registry start [PORT] | stop | status | push IMAGE | pull IMAGE\n\nA local image registry as the container ${REGISTRY.name} (127.0.0.1:${REGISTRY.port} by default, data in the volume ${REGISTRY.name}).\n push IMAGE tags IMAGE as localhost:PORT/IMAGE and pushes it there\n pull IMAGE pulls localhost:PORT/IMAGE`;async function registryPort(t){const e=await inspectOrNull(t,REGISTRY.name);if(!e?.State.Running)throw new UsageError("the registry is not running; start it with: thinknsh registry start");return hostPortOf(e)||REGISTRY.port}async function registry(t,e){const{api:r,out:s,err:n}=e,[a="status",...o]=t;if(["--help","-h","help"].includes(a))return s.write(REGISTRY_USAGE+"\n"),0;switch(a){case"start":{const t=o[0]?Number(o[0]):void 0;if(o[0]&&!(t>0&&t<65536))throw new UsageError(`invalid port "${o[0]}"`);const e=await ensureContainer(r,{...REGISTRY,hostPort:t,labels:{[LABEL]:"registry"},err:n}),a=hostPortOf(e)||REGISTRY.port;return s.write(`${chalk.green("✓")} registry is running on localhost:${a}\n`),s.write(chalk.dim(` push: thinknsh registry push myapp:1 (or thinknsh push localhost:${a}/myapp:1)\n`)),0}case"stop":return await r.json("POST",`/containers/${REGISTRY.name}/stop`),s.write(`${REGISTRY.name}\n`),0;case"status":{const t=await inspectOrNull(r,REGISTRY.name);if(!t)return s.write("registry: not created (thinknsh registry start)\n"),0;const e=hostPortOf(t)||REGISTRY.port;if(s.write(`registry: ${t.State.Running?`running on localhost:${e}`:"stopped"}\n`),t.State.Running)try{const t=await fetch(`http://127.0.0.1:${e}/v2/_catalog`,{signal:AbortSignal.timeout(5e3)}),r=(await t.json()).repositories||[];s.write(r.length?r.map(t=>` ${t}`).join("\n")+"\n":" (no repositories yet)\n")}catch(t){s.write(chalk.dim(" (could not list repositories)\n"))}return 0}case"push":case"pull":{if(1!==o.length)throw new UsageError(`registry ${a} takes one image, e.g. thinknsh registry ${a} myapp:1`);const t=await registryPort(r),{repo:s,tag:n}=splitRepoTag(o[0]),i=`localhost:${t}/${s}:${n}`;if("push"===a){return await runCommand("tag",[o[0],i],{...e,autoStart:!1})||runCommand("push",[i],{...e,autoStart:!1})}return runCommand("pull",[i],{...e,autoStart:!1})}default:throw new UsageError(`unknown registry command "${a}" (start, stop, status, push, pull)`)}}module.exports={service,registry,SERVICES,serviceOf,SERVICE_USAGE,REGISTRY_USAGE};
7
+ "use strict";const chalk=require("chalk"),{EngineError,qs,enc}=require("./api"),{parseArgs,UsageError}=require("./args"),{pullImage,runCommand,splitRepoTag}=require("./commands"),fmt=require("./format"),LABEL="tnc.service",SERVICES={postgres:{image:"postgres:16-alpine",port:5432,data:"/var/lib/postgresql/data",env:["POSTGRES_HOST_AUTH_METHOD=trust"],health:["CMD-SHELL","pg_isready -U postgres"],url:t=>`postgresql://postgres@localhost:${t}/postgres`},redis:{image:"redis:7-alpine",port:6379,data:"/data",env:[],health:["CMD","redis-cli","ping"],url:t=>`redis://localhost:${t}`},mysql:{image:"mysql:8.4",port:3306,data:"/var/lib/mysql",env:["MYSQL_ALLOW_EMPTY_PASSWORD=yes"],health:["CMD-SHELL","mysqladmin ping -h 127.0.0.1 --silent"],url:t=>`mysql://root@localhost:${t}`},mongo:{image:"mongo:7",port:27017,data:"/data/db",env:[],health:["CMD","mongosh","--quiet","--eval",'db.adminCommand("ping")'],url:t=>`mongodb://localhost:${t}`}},ALIASES={pg:"postgres",postgresql:"postgres",mariadb:"mysql",mongodb:"mongo"},REGISTRY={name:"tnc-registry",image:"registry:2",port:5e3,data:"/var/lib/registry"},containerName=t=>`tnc-svc-${t}`,sleep=t=>new Promise(e=>setTimeout(e,t));function serviceOf(t){const e=ALIASES[t]||t;if(!SERVICES[e])throw new UsageError(`unknown service "${t}" (${Object.keys(SERVICES).join(", ")})`);return e}async function inspectOrNull(t,e){try{return await t.json("GET",`/containers/${enc(e)}/json`)}catch(t){if(t instanceof EngineError&&404===t.status)return null;throw t}}const hostPortOf=t=>Number(t?.HostConfig?.PortBindings&&Object.values(t.HostConfig.PortBindings)[0]?.[0]?.HostPort)||null;async function ensureContainer(t,{name:e,image:r,port:s,hostPort:n,data:a,env:i=[],health:o,labels:c,err:l,hostIp:h="127.0.0.1",binds:u=[]}){let g=await inspectOrNull(t,e);if(g&&n&&hostPortOf(g)!==n)throw new UsageError(`${e} already exists on port ${hostPortOf(g)}; remove it first (data stays in its volume): thinknsh rm -f ${e}`);if(!g){const p={Image:r,Env:i,Labels:c,ExposedPorts:{[`${s}/tcp`]:{}},...o?{Healthcheck:{Test:o,Interval:2e9,Timeout:5e9,Retries:30}}:{},HostConfig:{Binds:[`${e}:${a}`,...u],PortBindings:{[`${s}/tcp`]:[{HostIp:h,HostPort:String(n||s)}]},RestartPolicy:{Name:"unless-stopped"}}};try{await t.json("POST",`/containers/create${qs({name:e})}`,p)}catch(s){if(!(s instanceof EngineError&&404===s.status))throw s;l.write(`pulling ${r}\n`),await pullImage(t,r,{err:l}),await t.json("POST",`/containers/create${qs({name:e})}`,p)}g=await inspectOrNull(t,e)}return g.State.Running||(await t.json("POST",`/containers/${enc(e)}/start`),g=await inspectOrNull(t,e)),g}async function waitHealthy(t,e,r,s=12e4){const n=Date.now()+s;for(;;){const r=await inspectOrNull(t,e);if(!r?.State.Running)throw new Error(`${e} stopped while starting; see: thinknsh logs ${e}`);const a=r.State.Health?.Status;if(!r.Config.Healthcheck||"healthy"===a)return;if("unhealthy"===a)throw new Error(`${e} is unhealthy; see: thinknsh logs ${e}`);if(Date.now()>n)throw new Error(`${e} did not become healthy in ${s/1e3}s; see: thinknsh logs ${e}`);await sleep(500)}}const SERVICE_USAGE=`Usage: thinknsh service start NAME [-p PORT] | stop NAME | restart NAME | status [NAME] | logs [-f] NAME | rm [-v] NAME\n\nDev databases as containers: ${Object.keys(SERVICES).join(", ")}. Each listens on 127.0.0.1 only,\nkeeps its data in the volume tnc-svc-NAME, and restarts with dock unless you stop it.\n\n start NAME [-p PORT] Create or start it and wait until it is healthy (default ports: ${Object.entries(SERVICES).map(([t,e])=>`${t} ${e.port}`).join(", ")})\n rm NAME [-v] Remove the container; -v also deletes its data volume`;async function service(t,e){const{api:r,out:s,err:n}=e,[a="status",...i]=t;if(["--help","-h","help"].includes(a))return s.write(SERVICE_USAGE+"\n"),0;switch(a){case"start":{const{opts:t,args:e}=parseArgs(i,{port:{short:"p",type:"string"}});if(1!==e.length)throw new UsageError("service start takes one name, e.g. thinknsh service start postgres");const a=serviceOf(e[0]),o=SERVICES[a],c=t.port?Number(t.port):void 0;if(t.port&&!(c>0&&c<65536))throw new UsageError(`invalid port "${t.port}"`);const l=containerName(a),h=await ensureContainer(r,{name:l,image:o.image,port:o.port,hostPort:c,data:o.data,env:o.env,health:o.health,labels:{[LABEL]:a},err:n});n.write(chalk.dim(`waiting for ${a} to be ready…\n`)),await waitHealthy(r,l,n);const u=hostPortOf(h)||o.port;return s.write(`${chalk.green("✓")} ${a} is running ${o.url(u)}\n`),s.write(chalk.dim(` container ${l}, data in volume ${l}\n`)),0}case"stop":case"restart":{if(1!==i.length)throw new UsageError(`service ${a} takes one name`);const t=containerName(serviceOf(i[0]));return await r.json("POST",`/containers/${enc(t)}/${a}`),s.write(`${t}\n`),0}case"logs":{const{opts:t,args:r}=parseArgs(i,{follow:{short:"f",type:"bool"},tail:{short:"n",type:"string"}});if(1!==r.length)throw new UsageError("service logs takes one name");return runCommand("logs",[...t.follow?["-f"]:[],...t.tail?["-n",t.tail]:[],containerName(serviceOf(r[0]))],{...e,autoStart:!1})}case"rm":case"remove":{const{opts:t,args:e}=parseArgs(i,{volumes:{short:"v",type:"bool"}});if(1!==e.length)throw new UsageError("service rm takes one name");const n=containerName(serviceOf(e[0]));return await r.json("DELETE",`/containers/${enc(n)}${qs({force:!0})}`),t.volumes&&await r.json("DELETE",`/volumes/${enc(n)}`),s.write(`${n}${t.volumes?" (and its data)":""}\n`),0}case"status":case"ls":{const t=await r.json("GET",`/containers/json${qs({all:!0,filters:{label:[LABEL]}})}`),e=i[0]?serviceOf(i[0]):null,n=Object.keys(SERVICES).filter(t=>!e||t===e).map(e=>{const r=t.find(t=>t.Labels[LABEL]===e),s=r?.Ports?.find(t=>t.PublicPort)?.PublicPort;return[e,r?r.Status:"not created",r&&"running"===r.State&&s?SERVICES[e].url(s):"-"]});return fmt.table(["SERVICE","STATUS","CONNECT"],n,{out:s}),0}default:throw new UsageError(`unknown service command "${a}" (start, stop, restart, status, logs, rm)`)}}const REGISTRY_USAGE=`Usage: thinknsh registry start [--lan] [PORT] | stop | status | push IMAGE | pull IMAGE | trust HOST:PORT [--fingerprint SHA256]\n\nA local image registry as the container ${REGISTRY.name} (127.0.0.1:${REGISTRY.port} by default, data in the volume ${REGISTRY.name}).\n --lan reachable from other machines, over TLS with its own certificate (made here; prints its fingerprint)\n trust on another machine: pin that registry's certificate after checking the fingerprint, so pulls and pushes\n are verified TLS (no insecure-registry exception)\n push IMAGE tags IMAGE as localhost:PORT/IMAGE and pushes it there\n pull IMAGE pulls localhost:PORT/IMAGE`;function lanAddresses(){const{isPrivate:t}=require("../workspace/share");return Object.values(require("os").networkInterfaces()).flat().filter(e=>e&&"IPv4"===e.family&&!e.internal&&t(e.address)).map(t=>t.address)}function registryCert(t=require("../paths").dockHome()){const e=require("fs"),r=require("path"),s=r.join(t,"registry-tls"),n=r.join(s,"cert.pem"),a=r.join(s,"key.pem");let i=!1;try{const t=new(require("crypto").X509Certificate)(e.readFileSync(n)).subjectAltName||"";i=e.existsSync(a)&&lanAddresses().every(e=>t.split(", ").includes(`IP Address:${e}`))}catch(t){}const o=!i&&e.existsSync(n);if(!i){e.mkdirSync(s,{recursive:!0,mode:448});const t=require("os").hostname().replace(/[^A-Za-z0-9.-]/g,""),r=["DNS:localhost","IP:127.0.0.1",...t?[`DNS:${t}`,...t.endsWith(".local")?[]:[`DNS:${t}.local`]]:[],...lanAddresses().map(t=>`IP:${t}`)],i=require("child_process").spawnSync("openssl",["req","-x509","-newkey","ec","-pkeyopt","ec_paramgen_curve:prime256v1","-nodes","-days","825","-subj","/CN=tnc-registry","-addext",`subjectAltName=${r.join(",")}`,"-keyout",a,"-out",n],{encoding:"utf8"});if(0!==i.status)throw new Error(`could not make the registry certificate with openssl: ${(i.stderr||i.error?.message||"").trim().slice(0,300)}`);e.chmodSync(a,384)}return{dir:s,renewed:o,pem:require("fs").readFileSync(n,"utf8")}}async function trustRegistry(t,{out:e}){const r=require("../image/certs"),{opts:s,args:n}=parseArgs(t,{fingerprint:{type:"string"}}),a=/^([A-Za-z0-9.-]+|\[[0-9a-fA-F:]+\]):(\d{1,5})$/.exec(n[0]||"");if(!a||1!==n.length)throw new UsageError("registry trust takes HOST:PORT, e.g. thinknsh registry trust 192.168.1.20:5000 --fingerprint AB:CD:…");const i=await r.fetchCertificate(a[1].replace(/^\[|\]$/g,""),Number(a[2])),o=r.fingerprintOf(i),c=t=>String(t||"").toUpperCase().replace(/[^0-9A-F]/g,"");if(s.fingerprint){if(c(s.fingerprint)!==c(o))throw new Error(`the certificate of ${n[0]} has fingerprint ${o}, not the one given: not trusted (someone may be in between)`)}else{if(e.write(`${n[0]} presents a certificate with fingerprint\n ${o}\n`),!process.stdin.isTTY)throw new UsageError('compare it with the one "registry start --lan" printed on that machine, then run again with --fingerprint');const t=require("readline").createInterface({input:process.stdin,output:process.stdout}),r=await new Promise(e=>t.question("Is it the same as on the registry machine? [y/N] ",e));if(t.close(),!/^y(es)?$/i.test(r.trim()))return e.write("not trusted\n"),1}return r.trust(n[0],i),e.write(`${chalk.green("✓")} ${n[0]} trusted: pulls and pushes to it are verified TLS\n`),0}async function registryPort(t){const e=await inspectOrNull(t,REGISTRY.name);if(!e?.State.Running)throw new UsageError("the registry is not running; start it with: thinknsh registry start");return hostPortOf(e)||REGISTRY.port}async function registry(t,e){const{api:r,out:s,err:n}=e,[a="status",...i]=t;if(["--help","-h","help"].includes(a))return s.write(REGISTRY_USAGE+"\n"),0;switch(a){case"start":{const t=i.includes("--lan"),e=i.filter(t=>"--lan"!==t)[0],a=e?Number(e):void 0;if(e&&!(a>0&&a<65536))throw new UsageError(`invalid port "${e}"`);const o=await inspectOrNull(r,REGISTRY.name);if(o&&!!o.Config?.Labels?.["tnc.registry-tls"]!==t)throw new UsageError(`${REGISTRY.name} already exists ${t?"without":"with"} --lan; remove it first (images stay in its volume): thinknsh rm -f ${REGISTRY.name}`);let c=!1;const l=t?(()=>{const t=registryCert();c=t.renewed;const{dir:e}=t;return{hostIp:"0.0.0.0",binds:[`${e}:/certs:ro`],env:["REGISTRY_HTTP_TLS_CERTIFICATE=/certs/cert.pem","REGISTRY_HTTP_TLS_KEY=/certs/key.pem"],labels:{[LABEL]:"registry","tnc.registry-tls":"1"}}})():{labels:{[LABEL]:"registry"}},h=await ensureContainer(r,{...REGISTRY,hostPort:a,...l,err:n});c&&o?.State?.Running&&(await r.json("POST",`/containers/${REGISTRY.name}/restart`),s.write(chalk.yellow(" this machine has a new address: the certificate was made again, so other machines must trust it again\n")));const u=hostPortOf(h)||REGISTRY.port;if(t){const t=require("../image/certs"),{pem:e}=registryCert();for(const r of["localhost","127.0.0.1",...lanAddresses()])t.trust(`${r}:${u}`,e);const r=t.fingerprintOf(e);s.write(`${chalk.green("✓")} registry is running on port ${u} for this network, over TLS\n`),s.write(` certificate fingerprint: ${r}\n`);for(const t of lanAddresses())s.write(chalk.dim(` on another machine: thinknsh registry trust ${t}:${u} --fingerprint ${r}\n`));return s.write(chalk.dim(` then: thinknsh push ${lanAddresses()[0]||"HOST"}:${u}/myapp:1\n`)),0}return s.write(`${chalk.green("✓")} registry is running on localhost:${u}\n`),s.write(chalk.dim(` push: thinknsh registry push myapp:1 (or thinknsh push localhost:${u}/myapp:1)\n`)),0}case"trust":return trustRegistry(i,e);case"stop":return await r.json("POST",`/containers/${REGISTRY.name}/stop`),s.write(`${REGISTRY.name}\n`),0;case"status":{const t=await inspectOrNull(r,REGISTRY.name);if(!t)return s.write("registry: not created (thinknsh registry start)\n"),0;const e=hostPortOf(t)||REGISTRY.port;if(s.write(`registry: ${t.State.Running?`running on localhost:${e}`:"stopped"}\n`),t.State.Running)try{const t=require("../image/certs"),r=t.caFor(`127.0.0.1:${e}`)?"https":"http",n=await t.tlsFetch(`${r}://127.0.0.1:${e}/v2/_catalog`,{signal:AbortSignal.timeout(5e3)}),a=(await n.json()).repositories||[];s.write(a.length?a.map(t=>` ${t}`).join("\n")+"\n":" (no repositories yet)\n")}catch(t){s.write(chalk.dim(" (could not list repositories)\n"))}return 0}case"push":case"pull":{if(1!==i.length)throw new UsageError(`registry ${a} takes one image, e.g. thinknsh registry ${a} myapp:1`);const t=await registryPort(r),{repo:s,tag:n}=splitRepoTag(i[0]),o=`localhost:${t}/${s}:${n}`;if("push"===a){return await runCommand("tag",[i[0],o],{...e,autoStart:!1})||runCommand("push",[o],{...e,autoStart:!1})}return runCommand("pull",[o],{...e,autoStart:!1})}default:throw new UsageError(`unknown registry command "${a}" (start, stop, status, push, pull, trust)`)}}module.exports={service,registry,SERVICES,serviceOf,SERVICE_USAGE,REGISTRY_USAGE};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const{demuxer}=require("./api"),DETACH=Buffer.from([16,17]);function pipeSession(e,{stdin:s=!1,resize:t=null}={}){const{socket:o,tty:r}=e,n=process.stdin,d=r&&s&&n.isTTY;let i=!1,c=null;return new Promise(u=>{const p=()=>{if(d)try{n.setRawMode(!1)}catch(e){}n.pause(),n.removeListener("data",a),process.stdout.removeListener("resize",f)},a=e=>{if(d){if((null!==c?Buffer.concat([Buffer.from([c]),e]):e).includes(DETACH))return i=!0,process.stderr.write("\r\nread escape sequence\r\n"),void o.destroy();c=e[e.length-1]}o.write(e)},f=()=>{t&&process.stdout.columns&&t(process.stdout.rows,process.stdout.columns)};if(r)o.on("data",e=>process.stdout.write(e));else{const e=demuxer(e=>process.stdout.write(e),e=>process.stderr.write(e));o.on("data",e)}o.on("error",()=>{}),o.on("close",()=>{p(),u({detached:i})}),o.on("end",()=>{p(),u({detached:i})}),s&&!e.readOnly&&(d&&n.setRawMode(!0),n.on("data",a),n.on("end",()=>{"function"==typeof o.end&&o.end()}),n.resume()),r&&t&&process.stdout.isTTY&&(f(),process.stdout.on("resize",f))})}module.exports={pipeSession};
7
+ "use strict";const{demuxer}=require("./api"),foreground=require("../../core/foreground"),DETACH=Buffer.from([16,17]);function pipeSession(e,{stdin:s=!1,resize:r=null}={}){const{socket:o,tty:t}=e,n=process.stdin,d=t&&s&&n.isTTY;let i=!1,u=null;return new Promise(c=>{let p=!1;const a=()=>{if(!p){if(p=!0,d)try{n.setRawMode(!1)}catch(e){}d&&foreground.leave(),n.removeListener("data",l),n.removeListener("end",f),n.pause(),process.stdout.removeListener("resize",m)}},f=()=>{"function"==typeof o.end&&o.end()},l=e=>{if(d){if((null!==u?Buffer.concat([Buffer.from([u]),e]):e).includes(DETACH))return i=!0,process.stderr.write("\r\nread escape sequence\r\n"),void o.destroy();u=e[e.length-1]}o.write(e)},m=()=>{r&&process.stdout.columns&&r(process.stdout.rows,process.stdout.columns)};if(t)o.on("data",e=>process.stdout.write(e));else{const e=demuxer(e=>process.stdout.write(e),e=>process.stderr.write(e));o.on("data",e)}o.on("error",()=>{}),o.on("close",()=>{a(),c({detached:i})}),o.on("end",()=>{a(),c({detached:i})}),s&&!e.readOnly&&(d&&(n.setRawMode(!0),foreground.enter()),n.on("data",l),n.on("end",f),n.resume()),t&&r&&process.stdout.isTTY&&(m(),process.stdout.on("resize",m))})}module.exports={pipeSession};
@@ -4,4 +4,4 @@
4
4
  * PROPRIETARY INTELLECTUAL PROPERTY & TRADE SECRETS.
5
5
  * UNAUTHORIZED REVERSE ENGINEERING, DECOMPILATION, OR RE-DISTRIBUTION IS STRICTLY PROHIBITED.
6
6
  */
7
- "use strict";const fs=require("fs"),os=require("os"),path=require("path"),chalk=require("chalk"),{EngineError,qs}=require("./api"),{parseArgs,parseBytes,UsageError}=require("./args"),{packContext}=require("./context"),{dockPaths}=require("../paths"),fmt=require("./format"),ws=require("../workspace/workspace"),{BUILD_FILE_PARAM,COMPOSE_LEGACY_FILES}=require("../api/wire"),home=()=>dockPaths().home;async function findContainer(e,o){try{return await e.json("GET",`/containers/${ws.containerName(o)}/json`)}catch(e){if(e instanceof EngineError&&404===e.status)return null;throw e}}async function imageExists(e,o){try{return await e.json("GET",`/images/${o}/json`),!0}catch(e){if(e instanceof EngineError&&404===e.status)return!1;throw e}}async function buildImage(e,o,t,{err:n,quiet:s}){const r=fs.mkdtempSync(path.join(os.tmpdir(),"tnc-workspace-image-"));try{ws.writeImageContext(r);const{tar:a,buildFile:i}=packContext(r);s||n.write(chalk.dim(`building the workspace image ${o} (once; agents: ${t.join(" ")||"none"})\n`)),await e.progress("POST",`/build${qs({t:o,[BUILD_FILE_PARAM]:i,buildargs:{AGENTS:t.join(" ")}})}`,a,e=>{e.stream&&!s&&/^Step |^Successfully/.test(e.stream)&&n.write(chalk.dim(e.stream))},{"Content-Type":"application/x-tar"})}finally{fs.rmSync(r,{recursive:!0,force:!0})}}function resolveName(e,o=!0){return e[0]?ws.workspaceName(e[0]):o?ws.workspaceName(path.basename(process.cwd())):null}const pathsOf=e=>e.paths||(e.path?[e.path]:[]),mountsOf=e=>ws.folderMounts(pathsOf(e)),openTarget=e=>pathsOf(e).length>1?`/run/tnc-workspace/${e.name}.code-workspace`:"/work";function connectHelp(e,{out:o,sshConfigChanged:t,sshConfigFile:n,sshConfigWritten:s,projects:r=[]}){const{detectApps:a}=require("../workspace/apps"),i=ws.sshHost(e.name),c=s?`ssh ${i}`:`ssh -F ${n} ${i}`,p=chalk.bold,w=chalk.cyan,h=mountsOf(e),l=(h.length,"/work");o.write(`\n${chalk.green("✓")} Workspace ${p(e.name)} is running. SSH host: ${w(i)}\n\n`),o.write(`${p("Your folders inside it")} (same files as on your Mac, live):\n`);for(const e of h)o.write(` ${e.host} → ${w(e.target)}\n`);if(o.write("\n"),e.joined?.length){o.write(`${p("Services it reaches by name")} (networks: ${e.joined.join(", ")}):\n`);for(const e of r)o.write(` compose project ${e.p.name}: ${Object.keys(e.p.services).map(e=>w(e)).join(", ")}\n`);const t=e.networks||[];t.length&&o.write(` any container on ${t.join(", ")}, by its name or alias\n`),o.write(chalk.dim(` e.g. thinknsh workspace exec ${e.name} getent hosts ${r[0]?Object.keys(r[0].p.services)[0]:"NAME"} · names resolve once those containers run (thinknsh compose up)\n\n`))}o.write(`${p("Open it in your apps")} (all of them share this one workspace):\n\n`);const m=a();for(const t of m){const n=t.ready?chalk.green("●"):chalk.dim("○");o.write(` ${n} ${p(t.label)}${t.ready?"":chalk.dim(` (${t.note})`)}\n`),"editor"===t.kind?(o.write(` ${w(`thinknsh workspace open ${e.name} --app ${t.id}`)}\n`),o.write(chalk.dim(` or in the app: Cmd+Shift+P → "Remote-SSH: Connect to Host" → ${i} → open ${openTarget(e)}\n`))):"claude"===t.id?(o.write(` Code tab → environment picker (where it says Local) → SSH → ${w(i)} → folder ${w(l)}\n`),o.write(chalk.dim(" Claude installs its engine in the workspace the first time; your Claude login is used inside it.\n"))):"codex"===t.id&&(o.write(` Settings → Connections → enable ${w(i)} → pick folder ${w(l)}\n`),o.write(chalk.dim(` First, log Codex in inside the workspace once: thinknsh workspace exec ${e.name} codex login --device-auth\n`))),o.write("\n")}o.write(` ${chalk.green("●")} ${p("Terminal")}\n ${w(c)} ${chalk.dim("(then: claude, codex, npm test, …)")}\n\n`),o.write(chalk.dim(`Status of every app: thinknsh workspace apps · Stop: thinknsh workspace stop ${e.name} · Remove: thinknsh workspace rm ${e.name} (folders are never touched)\n`)),s?o.write(chalk.dim(`SSH: 127.0.0.1:${e.port}, key-only, host key pinned.${t?` Host ${i} written to ${ws.userSshConfig()} (inside the thinknsh block).`:""}\n`)):o.write(chalk.dim("~/.ssh/config was not changed (--no-ssh-config), so apps will not list the host; only the ssh -F command above works.\n"))}const UP_FLAGS={name:{type:"string"},port:{type:"string"},memory:{short:"m",type:"string",default:"2g"},cpus:{type:"string",default:"2"},agents:{type:"string"},rebuild:{type:"bool"},"no-ssh-config":{type:"bool"},quiet:{short:"q",type:"bool"},network:{type:"list"},compose:{type:"string"},"no-compose":{type:"bool"}};function parseUpArgs(e){const o=e.map((o,t)=>"--compose"===o&&(t+1>=e.length||e[t+1].startsWith("-"))?"--compose=":o);return parseArgs(o,UP_FLAGS)}function composeFileIn(e){const{FILES:o}=require("./compose");for(const t of o)if(fs.existsSync(path.join(e,t)))return path.join(e,t);return null}function composeFileArg(e){const o=path.resolve(e);if(!fs.existsSync(o))throw new UsageError(`--compose: ${o} does not exist`);if(!fs.statSync(o).isDirectory())return o;const t=composeFileIn(o);if(!t)throw new UsageError(`--compose: no compose.yaml, compose.yml, ${COMPOSE_LEGACY_FILES.join(", ")} in ${o}`);return t}function composeProjects(e,o,{warn:t=()=>{},required:n=!1}={}){const{loadProject:s,projectNetworks:r}=require("./compose");if(!e)return[];const a="auto"===e?o.map(composeFileIn).filter(Boolean):[e];if(n&&!a.length)throw new UsageError(`--compose: no compose file in ${o.join(", ")}; pass --compose FILE|DIR`);if("auto"!==e&&!fs.existsSync(e))throw new UsageError(`compose file ${e} (remembered for this workspace) is gone; pass --compose FILE|DIR or --no-compose`);const i=[];for(const o of a)try{const e=s({file:o});i.push({p:e,nets:r(e)})}catch(s){if("auto"!==e||n)throw s;t(`skipping ${o}: ${s.message}`)}return i}async function networkExists(e,o){try{return await e.json("GET",`/networks/${encodeURIComponent(o)}`),!0}catch(e){if(e instanceof EngineError&&404===e.status)return!1;throw e}}async function joinNetworks(e,o,{networks:t,projects:n,joinedBefore:s=[],explicitCompose:r=!1,err:a,quiet:i}){const{ensureNetworks:c}=require("./compose"),p=e=>{i||a.write(chalk.dim(e+"\n"))};for(const o of t)if(!await networkExists(e,o))throw new UsageError(`network ${o} does not exist; create it with: thinknsh network create ${o}`);const w=[];for(const o of n)try{await c(e,o.p,o.p.services,(e,t)=>p(`created network ${t} for compose project ${o.p.name} (thinknsh compose up will use it)`)),w.push(o)}catch(e){if(r)throw e;p(`not joining compose project ${o.p.name}: ${e.message}`)}const h=ws.wantedNetworks(t,w.flatMap(e=>e.nets)).filter(e=>"bridge"!==e),l=await e.json("GET",`/containers/${o}/json`),m=Object.keys(l.NetworkSettings?.Networks||{});for(const t of h)m.includes(t)||await e.json("POST",`/networks/${encodeURIComponent(t)}/connect`,{Container:o});for(const t of s)"bridge"!==t&&!h.includes(t)&&m.includes(t)&&(await e.json("POST",`/networks/${encodeURIComponent(t)}/disconnect`,{Container:o}).catch(()=>{}),p(`left network ${t}`));return{joined:h,projects:w}}async function up(e,{api:o,out:t,err:n}){const{opts:s,args:r}=parseUpArgs(e),a=(r.length?r:["."]).map(e=>{const o=fs.realpathSync(path.resolve(e));if(!fs.statSync(o).isDirectory())throw new UsageError(`${o} is not a folder`);if(o===os.homedir()||"/"===o)throw new UsageError("pick project folders, not your whole home or /");return o});if(new Set(a).size!==a.length)throw new UsageError("the same folder is listed twice");if(a.length>1&&!s.name)throw new UsageError("several folders need a workspace name: --name NAME");const i=ws.folderMounts(a),c=ws.workspaceName(s.name||path.basename(a[0])),p=home(),w=ws.agentPackages(s.agents),h=ws.imageTag(w),l=a.join("\n");let m=ws.readMeta(p,c);const u=ws.networkChoice(m,{network:s.network,noCompose:s["no-compose"],compose:void 0===s.compose||""===s.compose?s.compose:composeFileArg(s.compose)}),d=composeProjects(u.compose,a,{warn:e=>n.write(chalk.yellow(`warning: ${e}\n`)),required:""===s.compose});let f=await findContainer(o,c);const g=f?.Config?.Labels?.[`${ws.LABEL}.path`],k=!(!f||!g||g===l);if(k&&0===r.length)throw new UsageError(`workspace "${c}" serves ${g.split("\n").join(", ")}; pass its folders or a different --name`);if(f&&(s.rebuild||k||void 0!==s.agents&&f.Config.Image!==h||s.port&&m&&String(m.port)!==s.port)&&(await o.json("DELETE",`/containers/${f.Id}?force=1`),f=null,k&&n.write(chalk.dim(`folders changed; recreating workspace ${c} (logins in its home volume are kept)\n`))),!f){const e=ws.listMeta(p).filter(e=>e.name!==c).map(e=>e.port),t=s.port?parseInt(s.port,10):m?.port&&await ws.portFree(m.port)?m.port:await ws.pickPort(e);if(!Number.isInteger(t)||t<1||t>65535)throw new UsageError(`invalid --port ${s.port}`);const r=ws.ensureKeys(p,c,t);ws.writeCodeWorkspace(r.server,c,i),!s.rebuild&&await imageExists(o,h)||await buildImage(o,h,w,{err:n,quiet:s.quiet}),m={name:c,path:a[0],paths:a,port:t,image:h,agents:w,createdAt:m?.createdAt||(new Date).toISOString()},ws.saveMeta(p,m);const l=ws.containerBody({name:c,image:h,mounts:i,serverDir:r.server,port:t,memory:parseBytes(s.memory),nanoCpus:Math.round(1e9*parseFloat(s.cpus))});f={Id:(await o.json("POST",`/containers/create${qs({name:ws.containerName(c)})}`,l)).Id,State:{Running:!1}}}const $=await joinNetworks(o,f.Id,{networks:u.networks,projects:d,joinedBefore:m?.joined||[],explicitCompose:void 0!==s.compose,err:n,quiet:s.quiet});if(m={...ws.readMeta(p,c)||m,networks:u.networks,compose:u.compose,joined:$.joined},ws.saveMeta(p,m),f.State?.Running||await o.json("POST",`/containers/${f.Id}/start`),!await waitForSsh(o,f.Id)){const e=await o.stream("GET",`/containers/${f.Id}/logs${qs({stdout:1,stderr:1,tail:20})}`).then(async e=>{const o=[];for await(const t of e)o.push(t);return Buffer.concat(o).toString("utf8").replace(/[\x00-\x08]/g,"")}).catch(()=>"");throw new Error(`workspace ssh did not come up on 127.0.0.1:${m.port}\n${e.trim()}`)}const E=ws.writeSshConfig(p),y=!s["no-ssh-config"]&&ws.syncUserSshConfig(p);return s.quiet?(t.write(`${ws.sshHost(c)}\n`),0):(connectHelp(m,{out:t,sshConfigChanged:y,sshConfigFile:E,sshConfigWritten:!s["no-ssh-config"],projects:$.projects}),0)}async function open(e,{api:o,out:t}){const{detectApps:n,openEditor:s,APPS:r}=require("../workspace/apps"),{opts:a,args:i}=parseArgs(e,{app:{short:"a",type:"string"}}),c=resolveName(i),p=ws.readMeta(home(),c);if(!p)throw new UsageError(`no workspace "${c}"; start one with: thinknsh workspace up`);const w=await findContainer(o,c);if(!w?.State?.Running)throw new UsageError(`workspace "${c}" is not running; run: thinknsh workspace up ${pathsOf(p).join(" ")}`);const h=n(),l=h.filter(e=>"editor"===e.kind);let m;if(a.app){if(!r[a.app])throw new UsageError(`unknown app "${a.app}" (${Object.keys(r).join(", ")})`);if(m=h.find(e=>e.id===a.app),"editor"!==m.kind)throw new UsageError(`${m.label} is connected inside the app, not from here: ${m.note}. Host: ${ws.sshHost(c)}`);if(!m.ready)throw new UsageError(`${m.label}: ${m.note}`)}else{const e=l.filter(e=>e.ready);if(1!==e.length)throw new UsageError(`pick one with --app (${e.map(e=>e.id).join(", ")||"no editor app is ready; see thinknsh workspace apps"})`);m=e[0]}return s(m,ws.sshHost(c),openTarget(p)),t.write(`opening ${m.label} on ${ws.sshHost(c)}:${openTarget(p)}\n`),t.write(chalk.dim(" first time: it installs its server in the workspace (a minute or two)\n")),0}async function appsCmd(e,{out:o}){const{detectApps:t}=require("../workspace/apps");parseArgs(e,{});const n=t().map(e=>[e.label,e.bundle?"installed":"-",e.ready?"ready":"no",e.note]);return fmt.table(["APP","INSTALLED","READY","HOW"],n,{out:o}),0}async function waitForSsh(e,o){for(let t=0;t<60;t++){if((await e.json("GET",`/containers/${o}/probe${qs({port:ws.SSH_PORT_IN})}`).catch(()=>({ok:!1}))).ok)return!0;const t=await e.json("GET",`/containers/${o}/json`).catch(()=>null);if(t&&!t.State.Running&&"exited"===t.State.Status)return!1;await new Promise(e=>setTimeout(e,250))}return!1}async function ls(e,{api:o,out:t}){parseArgs(e,{});const n=[];for(const e of ws.listMeta(home())){const t=await findContainer(o,e.name),s=t?Object.keys(t.NetworkSettings?.Networks||{}).filter(e=>"bridge"!==e):e.joined||[];n.push([e.name,t?t.State.Status:"removed",`${ws.sshHost(e.name)} (127.0.0.1:${e.port})`,s.join(", ")||"-",pathsOf(e).join(", ")])}return n.length?(fmt.table(["NAME","STATUS","SSH HOST","NETWORKS","PROJECT"],n,{out:t}),0):(t.write("no workspaces yet: thinknsh workspace up [FOLDER]\n"),0)}async function inside(e,o,t,{root:n=!1,tty:s=process.stdin.isTTY}={}){const{COMMANDS:r}=require("./commands"),a=await findContainer(t.api,e);if(!a)throw new UsageError(`no workspace "${e}"; start one with: thinknsh workspace up`);if(!a.State.Running)throw new UsageError(`workspace "${e}" is stopped; run: thinknsh workspace up ${a.Config.Labels[`${ws.LABEL}.path`]||""}`.trim());const i=["-i",...s?["-t"]:[],"-w","/work","-e",`TERM=${process.env.TERM||"xterm-256color"}`],c=n?[...i,"-u","root",ws.containerName(e),...o]:[...i,"-u","root",ws.containerName(e),...ws.DROP.split(" "),"bash","-lc",'exec "$@"',"bash",...o];return r.exec(c,t)}async function shell(e,o){const{args:t}=parseArgs(e,{});return inside(resolveName(t),["bash","-l"],o)}async function exec(e,o){const{opts:t,args:n}=parseArgs(e,{root:{type:"bool"}},{stopAtPositional:!0});if(n.length<2)throw new UsageError("exec takes NAME COMMAND…, e.g. thinknsh workspace exec myapp claude");const[s,...r]=n;return inside(ws.workspaceName(s),r,o,{root:t.root})}async function sshConfig(e,{out:o}){const{args:t}=parseArgs(e,{}),n=resolveName(t,!1),s=ws.listMeta(home()).filter(e=>!n||e.name===n);if(n&&!s.length)throw new UsageError(`no workspace "${n}"`);for(const e of s)o.write(ws.hostEntry(e,home())+"\n");return 0}async function stop(e,{api:o,out:t}){const{args:n}=parseArgs(e,{}),s=resolveName(n),r=await findContainer(o,s);if(!r)throw new UsageError(`no workspace "${s}"`);return await o.json("POST",`/containers/${r.Id}/stop`),t.write(`stopped workspace ${s}\n`),0}async function rm(e,{api:o,out:t}){const{opts:n,args:s}=parseArgs(e,{volumes:{short:"v",type:"bool"}}),r=resolveName(s),a=home(),i=await findContainer(o,r);if(!i&&!ws.readMeta(a,r))throw new UsageError(`no workspace "${r}"`);return i&&await o.json("DELETE",`/containers/${i.Id}?force=1`),n.volumes&&await o.json("DELETE",`/volumes/${ws.homeVolume(r)}`).catch(()=>{}),ws.removeState(a,r),ws.writeSshConfig(a),ws.syncUserSshConfig(a),t.write(`removed workspace ${r}${n.volumes?" and its home volume":` (home volume ${ws.homeVolume(r)} kept; -v removes it)`}\n`),t.write(chalk.dim(" your project folder was not touched\n")),0}const SUB={up,open,apps:appsCmd,ls,list:ls,shell,exec,"ssh-config":sshConfig,stop,rm,remove:rm};async function workspace(e,o){const[t="ls",...n]=e;if("exec"!==t&&n.some(e=>"--help"===e||"-h"===e))return o.out.write(USAGE+"\n"),0;const s=SUB[t];if(!s)throw new UsageError(`unknown workspace command "${t}" (up, open, apps, ls, shell, exec, ssh-config, stop, rm)`);try{return await s(n,o)}catch(e){if(e.workspace)throw new UsageError(e.message);throw e}}const USAGE='Usage: thinknsh workspace up [FOLDER…] | open [NAME] --app APP | apps | ls | shell [NAME] | exec [--root] NAME CMD… | ssh-config [NAME] | stop NAME | rm [-v] NAME\n\nOne secure TNC container that all your coding agents and editors share: Claude desktop,\nCodex app, Cursor, Antigravity IDE, VS Code, and the claude / codex CLIs. No sandbox per tool.\n\n up [FOLDER…] Create/start the workspace (default: .). One folder is /work;\n several are /work/<folder name> each, e.g.\n thinknsh workspace up ~/web ~/api ~/infra --name main\n --name NAME Workspace name (default: folder name; required for several folders)\n --agents LIST Agent CLIs to bake in: claude codex gemini or npm packages; "none" (default: claude codex)\n -m, --memory 2g --cpus 2 --port PORT (default: first free from 22220)\n --rebuild Rebuild the image and recreate the container (home volume kept)\n --no-ssh-config Do not add the Include line to ~/.ssh/config\n --compose [FILE|DIR] Join that compose project\'s networks, so services are\n reachable by name (db, redis, …). Default: the compose file\n in the workspace folder, if there is one\n --no-compose Do not join any compose project\n --network NET Also join NET (repeatable; "none" clears). Remembered for next up\n open [NAME] --app APP Open an editor on the workspace: cursor, antigravity, vscode\n apps Which desktop apps are installed and how each one connects\n shell [NAME] Login shell inside as user tnc\n exec NAME CMD… Run CMD inside as tnc (--root for system packages, e.g. apt-get)\n ssh-config [NAME] Print the SSH host entries (host tnc-NAME)\n rm [-v] NAME Remove the container (-v: also the home volume). The project folder is never touched.\n\nSecurity: SSH listens on 127.0.0.1 only, key-only, host key pinned; sessions run as tnc\nwith no capabilities, no sudo, no setuid binaries (no-new-privileges); memory, CPU and\nprocess limits apply. Agent logins live in the workspace\'s home volume, not on your Mac.';module.exports={workspace,USAGE,parseUpArgs,composeProjects,composeFileArg};
7
+ "use strict";const fs=require("fs"),os=require("os"),path=require("path"),chalk=require("chalk"),{EngineError,qs}=require("./api"),{parseArgs,parseBytes,UsageError}=require("./args"),{packContext}=require("./context"),{dockPaths}=require("../paths"),fmt=require("./format"),ws=require("../workspace/workspace"),{BUILD_FILE_PARAM,COMPOSE_LEGACY_FILES}=require("../api/wire"),home=()=>dockPaths().home;async function findContainer(e,o){try{return await e.json("GET",`/containers/${ws.containerName(o)}/json`)}catch(e){if(e instanceof EngineError&&404===e.status)return null;throw e}}async function imageExists(e,o){try{return await e.json("GET",`/images/${o}/json`),!0}catch(e){if(e instanceof EngineError&&404===e.status)return!1;throw e}}async function buildImage(e,o,n,{err:t,quiet:s}){const r=fs.mkdtempSync(path.join(os.tmpdir(),"tnc-workspace-image-"));try{ws.writeImageContext(r);const{tar:a,buildFile:i}=packContext(r);s||t.write(chalk.dim(`building the workspace image ${o} (once; agents: ${n.join(" ")||"none"})\n`)),await e.progress("POST",`/build${qs({t:o,[BUILD_FILE_PARAM]:i,buildargs:{AGENTS:n.join(" ")}})}`,a,e=>{e.stream&&!s&&/^Step |^Successfully/.test(e.stream)&&t.write(chalk.dim(e.stream))},{"Content-Type":"application/x-tar"})}finally{fs.rmSync(r,{recursive:!0,force:!0})}}function resolveName(e,o=!0){return e[0]?ws.workspaceName(e[0]):o?ws.workspaceName(path.basename(process.cwd())):null}const pathsOf=e=>e.paths||(e.path?[e.path]:[]),mountsOf=e=>ws.folderMounts(pathsOf(e)),openTarget=e=>pathsOf(e).length>1?`/run/tnc-workspace/${e.name}.code-workspace`:"/work";function connectHelp(e,{out:o,sshConfigChanged:n,sshConfigFile:t,sshConfigWritten:s,projects:r=[]}){const{detectApps:a}=require("../workspace/apps"),i=ws.sshHost(e.name),c=s?`ssh ${i}`:`ssh -F ${t} ${i}`,p=chalk.bold,h=chalk.cyan,w=mountsOf(e),l=(w.length,"/work");o.write(`\n${chalk.green("✓")} Workspace ${p(e.name)} is running. SSH host: ${h(i)}\n\n`),o.write(`${p("Your folders inside it")} (same files as on your Mac, live):\n`);for(const e of w)o.write(` ${e.host} → ${h(e.target)}\n`);if(o.write("\n"),e.joined?.length){o.write(`${p("Services it reaches by name")} (networks: ${e.joined.join(", ")}):\n`);for(const e of r)o.write(` compose project ${e.p.name}: ${Object.keys(e.p.services).map(e=>h(e)).join(", ")}\n`);const n=e.networks||[];n.length&&o.write(` any container on ${n.join(", ")}, by its name or alias\n`),o.write(chalk.dim(` e.g. thinknsh workspace exec ${e.name} getent hosts ${r[0]?Object.keys(r[0].p.services)[0]:"NAME"} · names resolve once those containers run (thinknsh compose up)\n\n`))}o.write(`${p("Open it in your apps")} (all of them share this one workspace):\n\n`);const d=a();for(const n of d){const t=n.ready?chalk.green("●"):chalk.dim("○");o.write(` ${t} ${p(n.label)}${n.ready?"":chalk.dim(` (${n.note})`)}\n`),"editor"===n.kind?(o.write(` ${h(`thinknsh workspace open ${e.name} --app ${n.id}`)}\n`),o.write(chalk.dim(` or in the app: Cmd+Shift+P → "Remote-SSH: Connect to Host" → ${i} → open ${openTarget(e)}\n`))):"claude"===n.id?(o.write(` Code tab → environment picker (where it says Local) → SSH → ${h(i)} → folder ${h(l)}\n`),o.write(chalk.dim(" Claude installs its engine in the workspace the first time; your Claude login is used inside it.\n"))):"codex"===n.id&&(o.write(` Settings → Connections → enable ${h(i)} → pick folder ${h(l)}\n`),o.write(chalk.dim(` First, log Codex in inside the workspace once: thinknsh workspace exec ${e.name} codex login --device-auth\n`))),o.write("\n")}o.write(` ${chalk.green("●")} ${p("Terminal")}\n ${h(c)} ${chalk.dim("(then: claude, codex, npm test, …)")}\n\n`),o.write(chalk.dim(`Status of every app: thinknsh workspace apps · Stop: thinknsh workspace stop ${e.name} · Remove: thinknsh workspace rm ${e.name} (folders are never touched)\n`)),s?o.write(chalk.dim(`SSH: 127.0.0.1:${e.port}, key-only, host key pinned.${n?` Host ${i} written to ${ws.userSshConfig()} (inside the thinknsh block).`:""}\n`)):o.write(chalk.dim("~/.ssh/config was not changed (--no-ssh-config), so apps will not list the host; only the ssh -F command above works.\n"))}const UP_FLAGS={name:{type:"string"},port:{type:"string"},memory:{short:"m",type:"string",default:"2g"},cpus:{type:"string",default:"2"},agents:{type:"string"},rebuild:{type:"bool"},"no-ssh-config":{type:"bool"},quiet:{short:"q",type:"bool"},network:{type:"list"},compose:{type:"string"},"no-compose":{type:"bool"}};function parseUpArgs(e){const o=e.map((o,n)=>"--compose"===o&&(n+1>=e.length||e[n+1].startsWith("-"))?"--compose=":o);return parseArgs(o,UP_FLAGS)}function composeFileIn(e){const{FILES:o}=require("./compose");for(const n of o)if(fs.existsSync(path.join(e,n)))return path.join(e,n);return null}function composeFileArg(e){const o=path.resolve(e);if(!fs.existsSync(o))throw new UsageError(`--compose: ${o} does not exist`);if(!fs.statSync(o).isDirectory())return o;const n=composeFileIn(o);if(!n)throw new UsageError(`--compose: no compose.yaml, compose.yml, ${COMPOSE_LEGACY_FILES.join(", ")} in ${o}`);return n}function composeProjects(e,o,{warn:n=()=>{},required:t=!1}={}){const{loadProject:s,projectNetworks:r}=require("./compose");if(!e)return[];const a="auto"===e?o.map(composeFileIn).filter(Boolean):[e];if(t&&!a.length)throw new UsageError(`--compose: no compose file in ${o.join(", ")}; pass --compose FILE|DIR`);if("auto"!==e&&!fs.existsSync(e))throw new UsageError(`compose file ${e} (remembered for this workspace) is gone; pass --compose FILE|DIR or --no-compose`);const i=[];for(const o of a)try{const e=s({file:o});i.push({p:e,nets:r(e)})}catch(s){if("auto"!==e||t)throw s;n(`skipping ${o}: ${s.message}`)}return i}async function networkExists(e,o){try{return await e.json("GET",`/networks/${encodeURIComponent(o)}`),!0}catch(e){if(e instanceof EngineError&&404===e.status)return!1;throw e}}async function joinNetworks(e,o,{networks:n,projects:t,joinedBefore:s=[],explicitCompose:r=!1,err:a,quiet:i}){const{ensureNetworks:c}=require("./compose"),p=e=>{i||a.write(chalk.dim(e+"\n"))};for(const o of n)if(!await networkExists(e,o))throw new UsageError(`network ${o} does not exist; create it with: thinknsh network create ${o}`);const h=[];for(const o of t)try{await c(e,o.p,o.p.services,(e,n)=>p(`created network ${n} for compose project ${o.p.name} (thinknsh compose up will use it)`)),h.push(o)}catch(e){if(r)throw e;p(`not joining compose project ${o.p.name}: ${e.message}`)}const w=ws.wantedNetworks(n,h.flatMap(e=>e.nets)).filter(e=>"bridge"!==e),l=await e.json("GET",`/containers/${o}/json`),d=Object.keys(l.NetworkSettings?.Networks||{});for(const n of w)d.includes(n)||await e.json("POST",`/networks/${encodeURIComponent(n)}/connect`,{Container:o});for(const n of s)"bridge"!==n&&!w.includes(n)&&d.includes(n)&&(await e.json("POST",`/networks/${encodeURIComponent(n)}/disconnect`,{Container:o}).catch(()=>{}),p(`left network ${n}`));return{joined:w,projects:h}}async function up(e,{api:o,out:n,err:t}){const{opts:s,args:r}=parseUpArgs(e),a=(r.length?r:["."]).map(e=>{const o=fs.realpathSync(path.resolve(e));if(!fs.statSync(o).isDirectory())throw new UsageError(`${o} is not a folder`);if(o===os.homedir()||"/"===o)throw new UsageError("pick project folders, not your whole home or /");return o});if(new Set(a).size!==a.length)throw new UsageError("the same folder is listed twice");if(a.length>1&&!s.name)throw new UsageError("several folders need a workspace name: --name NAME");const i=ws.folderMounts(a),c=ws.workspaceName(s.name||path.basename(a[0])),p=home(),h=ws.agentPackages(s.agents),w=ws.imageTag(h),l=a.join("\n");let d=ws.readMeta(p,c);const m=ws.networkChoice(d,{network:s.network,noCompose:s["no-compose"],compose:void 0===s.compose||""===s.compose?s.compose:composeFileArg(s.compose)}),u=composeProjects(m.compose,a,{warn:e=>t.write(chalk.yellow(`warning: ${e}\n`)),required:""===s.compose});let f=await findContainer(o,c);const g=f?.Config?.Labels?.[`${ws.LABEL}.path`],k=!(!f||!g||g===l);if(k&&0===r.length)throw new UsageError(`workspace "${c}" serves ${g.split("\n").join(", ")}; pass its folders or a different --name`);if(f&&(s.rebuild||k||void 0!==s.agents&&f.Config.Image!==w||s.port&&d&&String(d.port)!==s.port)&&(await o.json("DELETE",`/containers/${f.Id}?force=1`),f=null,k&&t.write(chalk.dim(`folders changed; recreating workspace ${c} (logins in its home volume are kept)\n`))),!f){const e=ws.listMeta(p).filter(e=>e.name!==c).map(e=>e.port),n=s.port?parseInt(s.port,10):d?.port&&await ws.portFree(d.port)?d.port:await ws.pickPort(e);if(!Number.isInteger(n)||n<1||n>65535)throw new UsageError(`invalid --port ${s.port}`);const r=ws.ensureKeys(p,c,n);ws.writeCodeWorkspace(r.server,c,i),!s.rebuild&&await imageExists(o,w)||await buildImage(o,w,h,{err:t,quiet:s.quiet}),d={name:c,path:a[0],paths:a,port:n,image:w,agents:h,createdAt:d?.createdAt||(new Date).toISOString()},ws.saveMeta(p,d);const l=ws.containerBody({name:c,image:w,mounts:i,serverDir:r.server,port:n,memory:parseBytes(s.memory),nanoCpus:Math.round(1e9*parseFloat(s.cpus))});f={Id:(await o.json("POST",`/containers/create${qs({name:ws.containerName(c)})}`,l)).Id,State:{Running:!1}}}const y=await joinNetworks(o,f.Id,{networks:m.networks,projects:u,joinedBefore:d?.joined||[],explicitCompose:void 0!==s.compose,err:t,quiet:s.quiet});if(d={...ws.readMeta(p,c)||d,networks:m.networks,compose:m.compose,joined:y.joined},ws.saveMeta(p,d),f.State?.Running||await o.json("POST",`/containers/${f.Id}/start`),!await waitForSsh(o,f.Id)){const e=await o.stream("GET",`/containers/${f.Id}/logs${qs({stdout:1,stderr:1,tail:20})}`).then(async e=>{const o=[];for await(const n of e)o.push(n);return Buffer.concat(o).toString("utf8").replace(/[\x00-\x08]/g,"")}).catch(()=>"");throw new Error(`workspace ssh did not come up on 127.0.0.1:${d.port}\n${e.trim()}`)}const $=ws.writeSshConfig(p),E=!s["no-ssh-config"]&&ws.syncUserSshConfig(p);return s.quiet?(n.write(`${ws.sshHost(c)}\n`),0):(connectHelp(d,{out:n,sshConfigChanged:E,sshConfigFile:$,sshConfigWritten:!s["no-ssh-config"],projects:y.projects}),0)}async function open(e,{api:o,out:n}){const{detectApps:t,openEditor:s,APPS:r}=require("../workspace/apps"),{opts:a,args:i}=parseArgs(e,{app:{short:"a",type:"string"}}),c=resolveName(i),p=ws.readMeta(home(),c);if(!p)throw new UsageError(`no workspace "${c}"; start one with: thinknsh workspace up`);const h=await findContainer(o,c);if(!h?.State?.Running)throw new UsageError(`workspace "${c}" is not running; run: thinknsh workspace up ${pathsOf(p).join(" ")}`);const w=t(),l=w.filter(e=>"editor"===e.kind);let d;if(a.app){if(!r[a.app])throw new UsageError(`unknown app "${a.app}" (${Object.keys(r).join(", ")})`);if(d=w.find(e=>e.id===a.app),"editor"!==d.kind)throw new UsageError(`${d.label} is connected inside the app, not from here: ${d.note}. Host: ${ws.sshHost(c)}`);if(!d.ready)throw new UsageError(`${d.label}: ${d.note}`)}else{const e=l.filter(e=>e.ready);if(1!==e.length)throw new UsageError(`pick one with --app (${e.map(e=>e.id).join(", ")||"no editor app is ready; see thinknsh workspace apps"})`);d=e[0]}return s(d,ws.sshHost(c),openTarget(p)),n.write(`opening ${d.label} on ${ws.sshHost(c)}:${openTarget(p)}\n`),n.write(chalk.dim(" first time: it installs its server in the workspace (a minute or two)\n")),0}async function appsCmd(e,{out:o}){const{detectApps:n}=require("../workspace/apps");parseArgs(e,{});const t=n().map(e=>[e.label,e.bundle?"installed":"-",e.ready?"ready":"no",e.note]);return fmt.table(["APP","INSTALLED","READY","HOW"],t,{out:o}),0}async function waitForSsh(e,o){for(let n=0;n<60;n++){if((await e.json("GET",`/containers/${o}/probe${qs({port:ws.SSH_PORT_IN})}`).catch(()=>({ok:!1}))).ok)return!0;const n=await e.json("GET",`/containers/${o}/json`).catch(()=>null);if(n&&!n.State.Running&&"exited"===n.State.Status)return!1;await new Promise(e=>setTimeout(e,250))}return!1}async function ls(e,{api:o,out:n}){parseArgs(e,{});const t=[];for(const e of ws.listMeta(home())){const n=await findContainer(o,e.name),s=n?Object.keys(n.NetworkSettings?.Networks||{}).filter(e=>"bridge"!==e):e.joined||[];t.push([e.name,n?n.State.Status:"removed",`${ws.sshHost(e.name)} (127.0.0.1:${e.port})`,s.join(", ")||"-",pathsOf(e).join(", ")])}return t.length?(fmt.table(["NAME","STATUS","SSH HOST","NETWORKS","PROJECT"],t,{out:n}),0):(n.write("no workspaces yet: thinknsh workspace up [FOLDER]\n"),0)}async function inside(e,o,n,{root:t=!1,tty:s=process.stdin.isTTY}={}){const{COMMANDS:r}=require("./commands"),a=await findContainer(n.api,e);if(!a)throw new UsageError(`no workspace "${e}"; start one with: thinknsh workspace up`);if(!a.State.Running)throw new UsageError(`workspace "${e}" is stopped; run: thinknsh workspace up ${a.Config.Labels[`${ws.LABEL}.path`]||""}`.trim());const i=["-i",...s?["-t"]:[],"-w","/work","-e",`TERM=${process.env.TERM||"xterm-256color"}`],c=t?[...i,"-u","root",ws.containerName(e),...o]:[...i,"-u","root",ws.containerName(e),...ws.DROP.split(" "),"bash","-lc",'exec "$@"',"bash",...o];return r.exec(c,n)}async function shell(e,o){const{args:n}=parseArgs(e,{});return inside(resolveName(n),["bash","-l"],o)}async function exec(e,o){const{opts:n,args:t}=parseArgs(e,{root:{type:"bool"}},{stopAtPositional:!0});if(t.length<2)throw new UsageError("exec takes NAME COMMAND…, e.g. thinknsh workspace exec myapp claude");const[s,...r]=t;return inside(ws.workspaceName(s),r,o,{root:n.root})}async function sshConfig(e,{out:o}){const{args:n}=parseArgs(e,{}),t=resolveName(n,!1),s=ws.listMeta(home()).filter(e=>!t||e.name===t);if(t&&!s.length)throw new UsageError(`no workspace "${t}"`);for(const e of s)o.write(ws.hostEntry(e,home())+"\n");return 0}async function stop(e,{api:o,out:n}){const{args:t}=parseArgs(e,{}),s=resolveName(t),r=await findContainer(o,s);if(!r)throw new UsageError(`no workspace "${s}"`);return await o.json("POST",`/containers/${r.Id}/stop`),n.write(`stopped workspace ${s}\n`),0}async function rm(e,{api:o,out:n}){const{opts:t,args:s}=parseArgs(e,{volumes:{short:"v",type:"bool"}}),r=resolveName(s),a=home(),i=await findContainer(o,r);if(!i&&!ws.readMeta(a,r))throw new UsageError(`no workspace "${r}"`);return i&&await o.json("DELETE",`/containers/${i.Id}?force=1`),await require("../workspace/share").removeGuestContainer(o,r),t.volumes&&await o.json("DELETE",`/volumes/${ws.homeVolume(r)}`).catch(()=>{}),ws.removeState(a,r),ws.writeSshConfig(a),ws.syncUserSshConfig(a),n.write(`removed workspace ${r}${t.volumes?" and its home volume":` (home volume ${ws.homeVolume(r)} kept; -v removes it)`}\n`),n.write(chalk.dim(" your project folder was not touched\n")),0}let asking=Promise.resolve();function askAllow(e){const o=()=>new Promise(o=>{const n=require("readline").createInterface({input:process.stdin,output:process.stderr});n.question(`${e} [y/N] `,e=>{n.close(),o(/^y(es)?$/i.test(e.trim()))})});return asking=asking.then(o,o)}async function shareCmd(e,{api:o,out:n,err:t}){const s=require("../workspace/share"),{opts:r,args:a}=parseArgs(e,{room:{type:"string"},yes:{short:"y",type:"bool"},"no-lan":{type:"bool"}}),i=resolveName(a),c=ws.readMeta(home(),i);if(!c)throw new UsageError(`no workspace "${i}"; start one with: thinknsh workspace up`);const p=await findContainer(o,i);if(!p?.State?.Running)throw new UsageError(`workspace "${i}" is not running; run: thinknsh workspace up ${pathsOf(c).join(" ")}`);if(!r.yes&&!process.stdin.isTTY)throw new UsageError("no terminal to ask before letting someone in; pass --yes to allow everyone with the code");let h=()=>{};const w=await s.share(c,home(),{api:o,owner:p,onStop:()=>h(),roomId:r.room||process.env.THINKNCOLLAB_ROOM_ID||null,lan:!r["no-lan"],log:e=>t.write(chalk.dim(` ${(new Date).toLocaleTimeString()} ${e}\n`)),approve:({name:e,userId:o})=>!!r.yes||askAllow(`${chalk.bold(e||"Someone")}${o?chalk.dim(` (${o})`):""} wants to open workspace ${i} in their editor. Allow?`)});return n.write(`${chalk.green("✓")} sharing workspace ${chalk.bold(i)}\n\n`),n.write(` Your teammate runs: ${chalk.cyan(`thinknsh workspace join ${w.code}`)}\n\n`),n.write(chalk.dim(" They get a guest container: your folders and services, but not your home (no Claude / Codex logins).\n")),n.write(chalk.dim(` Their editor connects over SSH${w.lanPort?` (same private network: directly, port ${w.lanPort}; otherwise through ThinkNCollab)`:" through ThinkNCollab"}.\n`)),n.write(chalk.dim(` ${r.yes?"Everyone with the code is let in.":"You are asked before anyone is let in."} Ctrl-C stops sharing and removes the guest container.\n`)),await new Promise(e=>{h=e,process.once("SIGINT",e),process.once("SIGTERM",e)}),await w.cleanup(),n.write("\nsharing stopped; guest container and its home removed\n"),0}async function joinCmd(e,{out:o}){const n=require("../workspace/share"),{detectApps:t,openEditor:s,APPS:r}=require("../workspace/apps"),{opts:a,args:i}=parseArgs(e,{app:{short:"a",type:"string"},"no-open":{type:"bool"}});if(1!==i.length)throw new UsageError("join takes the code from thinknsh workspace share, e.g. thinknsh workspace join abcd2345ef-XXXXXXXXXXXXXXXX-XXXXXXXXXXXXXXXX");if(a.app&&!r[a.app])throw new UsageError(`unknown app "${a.app}" (${Object.keys(r).join(", ")})`);o.write(chalk.dim("asking the owner to let you in…\n"));const c=await n.join(i[0],home());if(o.write(`${chalk.green("✓")} joined ${chalk.bold(c.workspace)}${c.owner?` (${c.owner})`:""}: SSH host ${chalk.cyan(c.host)}, folder ${c.folder}\n`),a["no-open"])return 0;const p=t().filter(e=>"editor"===e.kind&&e.ready),h=a.app?p.find(e=>e.id===a.app):p.find(e=>"vscode"===e.id)||p[0];return h?(s(h,c.host,c.folder),o.write(` opening ${h.label}… (first time it installs its server in the workspace, a minute or two)\n`),0):(o.write(` open it in your editor: Remote-SSH → Connect to Host → ${c.host} → ${c.folder}\n`),0)}async function joinedCmd(e,{out:o}){const n=ws.listJoined(home()).map(e=>[e.host,e.workspace,e.owner||"-",e.folder,new Date(e.joinedAt).toLocaleString()]);return fmt.table(["SSH HOST","WORKSPACE","OWNER","FOLDER","JOINED"],n,{out:o}),0}async function leaveCmd(e,{out:o}){const n=require("../workspace/share");if(1!==e.length)throw new UsageError("leave takes the SSH host or share id, e.g. thinknsh workspace leave tnc-join-abcd2345ef");const t=e[0].replace(/^tnc-join-/,"");if(!ws.listJoined(home()).some(e=>e.shareId===t))throw new UsageError(`not joined to "${e[0]}" (see: thinknsh workspace joined)`);return n.leave(t,home()),o.write(`left ${n.joinHost(t)}\n`),0}async function proxyCmd(e){if(1!==e.length)throw new UsageError("proxy takes a share id");return require("../workspace/share").proxy(e[0],home())}const SUB={up,open,apps:appsCmd,ls,list:ls,shell,exec,"ssh-config":sshConfig,stop,rm,remove:rm,share:shareCmd,join:joinCmd,joined:joinedCmd,leave:leaveCmd,proxy:proxyCmd};async function workspace(e,o){const[n="ls",...t]=e;if("exec"!==n&&t.some(e=>"--help"===e||"-h"===e))return o.out.write(USAGE+"\n"),0;const s=SUB[n];if(!s)throw new UsageError(`unknown workspace command "${n}" (up, open, apps, ls, shell, exec, ssh-config, share, join, joined, leave, stop, rm)`);try{return await s(t,o)}catch(e){if(e.workspace)throw new UsageError(e.message);throw e}}const USAGE="Usage: thinknsh workspace up [FOLDER…] | open [NAME] --app APP | apps | ls | shell [NAME] | exec [--root] NAME CMD… | ssh-config [NAME] | share [NAME] | join CODE | joined | leave HOST | stop NAME | rm [-v] NAME\n\nOne secure TNC container that all your coding agents and editors share: Claude desktop,\nCodex app, Cursor, Antigravity IDE, VS Code, and the claude / codex CLIs. No sandbox per tool.\n\n up [FOLDER…] Create/start the workspace (default: .). One folder is /work;\n several are /work/<folder name> each, e.g.\n thinknsh workspace up ~/web ~/api ~/infra --name main\n --name NAME Workspace name (default: folder name; required for several folders)\n --agents LIST Agent CLIs to bake in: claude codex gemini or npm packages; \"none\" (default: claude codex)\n -m, --memory 2g --cpus 2 --port PORT (default: first free from 22220)\n --rebuild Rebuild the image and recreate the container (home volume kept)\n --no-ssh-config Do not add the Include line to ~/.ssh/config\n --compose [FILE|DIR] Join that compose project's networks, so services are\n reachable by name (db, redis, …). Default: the compose file\n in the workspace folder, if there is one\n --no-compose Do not join any compose project\n --network NET Also join NET (repeatable; \"none\" clears). Remembered for next up\n open [NAME] --app APP Open an editor on the workspace: cursor, antigravity, vscode\n apps Which desktop apps are installed and how each one connects\n shell [NAME] Login shell inside as user tnc\n exec NAME CMD… Run CMD inside as tnc (--root for system packages, e.g. apt-get)\n ssh-config [NAME] Print the SSH host entries (host tnc-NAME)\n rm [-v] NAME Remove the container (-v: also the home volume). The project folder is never touched.\n\nShare with a teammate's editor (VS Code, Cursor, Antigravity), signed in to ThinkNCollab on both sides:\n share [NAME] Start a guest container (your folders and networks, its own home: none of\n your agent logins), print a join code, share until Ctrl-C removes it\n --room ROOM Only members of that ThinkNCollab room may join\n --yes Let in everyone with the code without asking\n --no-lan Always go through ThinkNCollab, never directly over the local network\n (direct is only offered on private addresses: 10.x, 172.16-31.x, 192.168.x, 100.64-127.x)\n join CODE On the teammate's machine: add host tnc-join-… to ~/.ssh/config and open\n the editor on it (--app vscode|cursor|antigravity, --no-open). Needs no dock.\n joined | leave HOST List joined workspaces / forget one\n\nSecurity: SSH listens on 127.0.0.1 only, key-only, host key pinned; sessions run as tnc\nwith no capabilities, no sudo, no setuid binaries (no-new-privileges); memory, CPU and\nprocess limits apply. Agent logins live in the workspace's home volume, not on your Mac.";module.exports={workspace,USAGE,parseUpArgs,composeProjects,composeFileArg};