thinkncollab-shell 2.0.0-beta.4 → 2.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +1 -1
- package/dist/native/brand/tnc-256.png +0 -0
- package/dist/src/dock/linux/host.js +1 -1
- package/dist/src/dock/selftest.js +1 -1
- package/dist/src/dock/setup/autostart.js +1 -1
- package/dist/src/dock/setup/index.js +1 -1
- package/dist/src/dock/tray/install.js +1 -1
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -135,7 +135,7 @@ New here? Read the step-by-step guide: [`docs/HOW-TO-USE.md`](docs/HOW-TO-USE.md
|
|
|
135
135
|
| Dashboard in the browser (`dock ui`), with settings, troubleshooting and a terminal | ✅ |
|
|
136
136
|
| Local CI (`ci run`, and TNC CI/CD runs from the dashboard): jobs, `needs`, matrix, both CPUs on one machine, `services` on localhost, `GITHUB_ENV`/`GITHUB_OUTPUT` | ✅ (macOS/Windows jobs and third-party actions are skipped) |
|
|
137
137
|
| `--network container:<name>` and compose `network_mode: service:<name>` | ✅ |
|
|
138
|
-
| Menu-bar app (macOS) / tray icon (Windows), start at login (`dock autostart`) | ✅ |
|
|
138
|
+
| Menu-bar app (macOS) / tray icon (Windows) / applications-menu launcher (Linux), all with the ThinkNCollab logo; start at login (`dock autostart`) | ✅ |
|
|
139
139
|
| Installers with their own Node.js: `.pkg`/`.dmg`, Windows setup, Linux tarball + systemd | ✅ built in CI (`dock-bundle.yml`); signing when the certificates are set |
|
|
140
140
|
| Corporate proxy and CA certificates, registry mirrors, insecure registries (`dock config`) | ✅ |
|
|
141
141
|
| Registry passwords in the system keychain (macOS Keychain, Windows Credential Manager, Secret Service) | ✅ |
|
|
Binary file
|
|
@@ -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"),net=require("net"),path=require("path"),{spawn:cpSpawn}=require("child_process"),{PassThrough}=require("stream"),{EventEmitter}=require("events"),FRAME={STDIN:0,STDOUT:1,STDERR:2,EXIT:3,SIGNAL:4},SIGNALS={SIGHUP:1,SIGINT:2,SIGQUIT:3,SIGKILL:9,SIGUSR1:10,SIGUSR2:12,SIGTERM:15};function collect(t){return new Promise(e=>{const s=[],r=[];t.stdout.on("data",t=>s.push(t)),t.stderr.on("data",t=>r.push(t)),t.exited.then(t=>e({code:t,stdout:Buffer.concat(s).toString("utf8"),stderr:Buffer.concat(r).toString("utf8")}))})}class LinuxHostBase{async run(t,e={}){const s=this.spawn(t,e),r=collect(s);return void 0!==e.input&&s.write(e.input),s.end(),r}async check(t,e={}){const s=await this.run(t,e);if(0!==s.code){const e="/bin/sh"===t[0]?"Linux step":`${t[0]} ${t.slice(1,3).join(" ")}`,r=new Error(`${e} failed (exit ${s.code}): ${(s.stderr||s.stdout).trim().slice(0,500)||"no output"}`);throw r.result=s,r}return s}sh(t,e=[],s={}){return this.check(["/bin/sh","-c",t,"dock",...e.map(String)],s)}}class LocalLinuxHost extends LinuxHostBase{constructor(){super(),this.kind="local"}async ready(){if(process.getuid&&0!==process.getuid())throw new Error("dock on Linux must run as root (it manages namespaces, mounts and iptables)")}guestPath(t){return t}async isUp(){return!0}binaries(){const t="arm64"===process.arch?"aarch64":"x86_64",e=path.join(os.homedir(),".tnc","bin",`crun-linux-${t}`),s=process.env.TNC_BUSYBOX||path.join(os.homedir(),".tnc","bin",`tnc-busybox-linux-${t}`);return{crun:process.env.TNC_CRUN||(fs.existsSync(e)?e:"crun"),shim:process.env.TNC_SHIM||path.join(os.homedir(),".tnc","bin",`tnc-shim-linux-${t}`),busybox:fs.existsSync(s)?s:null}}spawn(t,{env:e,cwd:s}={}){const r=cpSpawn(t[0],t.slice(1),{cwd:s||"/",env:e?Object.fromEntries(e.map(t=>{const e=t.indexOf("=");return[t.slice(0,e),t.slice(e+1)]})):process.env,stdio:["pipe","pipe","pipe"]}),n=new Promise(t=>{r.on("error",()=>t(127)),r.on("close",(e,s)=>t(e??128+(os.constants.signals[s]||0)))});return r.stdin.on("error",()=>{}),{stdout:r.stdout,stderr:r.stderr,write:t=>r.stdin.write(t),end:()=>r.stdin.end(),kill:(t="SIGTERM")=>{try{r.kill(t)}catch(t){}},exited:n}}connect(t,e){return net.createConnection({host:t,port:e})}}const ROOTLESS_SETUP='\nset -e\ndata="$1"\nmkdir -p "$data/lib" "$data/save"\n# the host\'s resolver files live under /run, which is replaced below: keep copies\n[ -r /run/systemd/resolve/resolv.conf ] && cp /run/systemd/resolve/resolv.conf "$data/save/resolved.conf" || true\nreal=$(readlink -f /etc/resolv.conf 2>/dev/null || echo /etc/resolv.conf)\n[ -r "$real" ] && cp "$real" "$data/save/resolv.conf" || true\nmount -t tmpfs -o mode=755 tnc-run /run\nmkdir -p /run/netns /run/tnc-dock /run/crun /run/tnc-xdg /run/systemd/resolve\n[ -f "$data/save/resolved.conf" ] && cp "$data/save/resolved.conf" /run/systemd/resolve/resolv.conf || true\ncase "$real" in /run/*) mkdir -p "$(dirname "$real")"; cp "$data/save/resolv.conf" "$real" 2>/dev/null || true;; esac\nmount -t tmpfs -o mode=755 tnc-lib /var/lib\nmkdir -p /var/lib/tnc-dock\nmount --bind "$data/lib" /var/lib/tnc-dock\necho tnc-rootless-ready\nexec sleep 2147483647\n',ROOTLESS_MARK="tnc-rootless-ready",ROOTLESS_DNS="10.0.2.3",which=t=>(process.env.PATH||"").split(path.delimiter).map(e=>path.join(e,t)).find(t=>{try{return fs.accessSync(t,fs.constants.X_OK),!0}catch(t){return!1}})||null,pidAlive=t=>{try{return process.kill(t,0),!0}catch(t){return"EPERM"===t.code}};function rootlessProblems({user:t=os.userInfo().username,read:e=t=>fs.readFileSync(t,"utf8"),has:s=which}={}){const r=[],[n,o]=os.release().split(".").map(Number);(n<5||5===n&&o<11)&&r.push(`Linux ${os.release()} is too old: rootless mode needs 5.11 or newer (overlay mounts in user namespaces)`);for(const t of["unshare","nsenter","newuidmap","newgidmap","iptables","ip"])s(t)||r.push(`${t} is missing: sudo apt-get install -y uidmap util-linux iproute2 iptables (Fedora: sudo dnf install shadow-utils util-linux iproute iptables)`);s("pasta")||s("slirp4netns")||r.push("no user-mode network: sudo apt-get install -y passt (or slirp4netns)");const i=s=>{try{return e(s).split("\n").some(e=>e.split(":")[0]===t||e.split(":")[0]===String(process.getuid()))}catch(t){return!1}};i("/etc/subuid")&&i("/etc/subgid")||r.push(`no subordinate ids for ${t}: sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 ${t}`);try{"1"===e("/proc/sys/kernel/apparmor_restrict_unprivileged_userns").trim()&&r.push("AppArmor blocks user namespaces (Ubuntu 23.10+): sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 (keep it: add the line to /etc/sysctl.d/60-thinknsh.conf)")}catch(t){}try{"0"===e("/proc/sys/kernel/unprivileged_userns_clone").trim()&&r.push("user namespaces are off: sudo sysctl -w kernel.unprivileged_userns_clone=1")}catch(t){}try{0===Number(e("/proc/sys/user/max_user_namespaces").trim())&&r.push("user namespaces are off: sudo sysctl -w user.max_user_namespaces=28633")}catch(t){}return[...new Set(r)]}class RootlessLinuxHost extends LocalLinuxHost{constructor({log:t=()=>{},home:e=process.env.TNC_DOCK_HOME||path.join(os.homedir(),".tnc","dock")}={}){super(),this.kind="rootless",this.log=t,this.dir=path.join(e,"rootless"),this.pidFile=path.join(this.dir,"holder.pid"),this.netPidFile=path.join(this.dir,"net.pid"),this.pid=null,this.gateway=null,this._starting=null,this.cgroupBase=null,this.cgroupControllers=[]}cgroupManager(){return this.cgroupBase?"cgroupfs":"disabled"}cgroupParent(){return this.cgroupBase||void 0}resolvConf(){return"nameserver 10.0.2.3\n"}warnings(){if(!this.cgroupBase)return["rootless mode without a delegated cgroup: memory, CPU and process limits (-m, --cpus, --pids-limit) are not enforced and pause is not available; start dock from a systemd user session (thinknsh dock autostart on) to get them"];const t=["memory","cpu","pids","io"].filter(t=>!this.cgroupControllers.includes(t));return t.length?[`rootless mode: the ${t.join(", ")} controller${t.length>1?"s are":" is"} not delegated to this user, so those limits are not enforced (sudo systemctl edit user@.service: [Service] Delegate=cpu cpuset io memory pids)`]:[]}_setupCgroups(t){this.cgroupBase=null,this.cgroupControllers=[];const e="/sys/fs/cgroup",s=t=>{try{return(fs.readFileSync(`/proc/${t}/cgroup`,"utf8").split("\n").find(t=>t.startsWith("0::"))||"").slice(3).trim()||null}catch(t){return null}},r=t=>{try{return fs.accessSync(t,fs.constants.W_OK),!0}catch(t){return!1}};let n=null;if(t){if(n=s(process.pid),!n||"/"===n||!r(`${e}${n}`)||!r(`${e}${n}/cgroup.procs`))return;try{for(const t of["daemon","holder","containers"])fs.mkdirSync(`${e}${n}/${t}`,{recursive:!0});const t=fs.readFileSync(`${e}${n}/cgroup.procs`,"utf8").split("\n").filter(Boolean);for(const s of t)try{fs.writeFileSync(`${e}${n}/${Number(s)===this.pid?"holder":"daemon"}/cgroup.procs`,s)}catch(t){}s(this.pid)!==`${n}/holder`&&fs.writeFileSync(`${e}${n}/holder/cgroup.procs`,String(this.pid))}catch(t){return void this.log?.(`rootless cgroups: ${t.message}`)}}else{const t=s(this.pid);if(!t||!t.endsWith("/holder"))return;if(n=t.slice(0,-7),!r(`${e}${n}/containers`))return}let o=[];try{o=fs.readFileSync(`${e}${n}/cgroup.controllers`,"utf8").trim().split(/\s+/).filter(Boolean)}catch(t){return}for(const t of[`${e}${n}`,`${e}${n}/containers`])for(const e of["cpu","cpuset","io","memory","pids"])if(o.includes(e))try{fs.writeFileSync(`${t}/cgroup.subtree_control`,`+${e}`)}catch(t){}try{this.cgroupControllers=fs.readFileSync(`${e}${n}/containers/cgroup.controllers`,"utf8").trim().split(/\s+/).filter(Boolean)}catch(t){}this.cgroupBase=`${n}/containers`}_holderPid(){let t;try{t=Number(fs.readFileSync(this.pidFile,"utf8").trim())}catch(t){return null}if(!t||!pidAlive(t))return null;try{if(!fs.readFileSync(`/proc/${t}/cmdline`,"utf8").includes("2147483647"))return null;if(fs.readlinkSync(`/proc/${t}/ns/user`)===fs.readlinkSync("/proc/self/ns/user"))return null}catch(t){return null}return t}async isUp(){return!!this._holderPid()}ready(){return this.pid&&this._holderPid()===this.pid?Promise.resolve():(this._starting||(this._starting=this._start().finally(()=>{this._starting=null})),this._starting)}async _start(){const t=this._holderPid();if(t)this.pid=t,this._setupCgroups(!1);else{const t=rootlessProblems();if(t.length)throw new Error(`rootless mode is not ready on this machine:\n - ${t.join("\n - ")}`);fs.mkdirSync(this.dir,{recursive:!0,mode:448}),this.pid=await this._startHolder(),this._setupCgroups(!0),await this._startNetwork()}const e=await this.run(["/bin/sh","-c","ip -4 route show default | awk '{print $3; exit}'"]);if(this.gateway=e.stdout.trim()||null,!this.gateway)throw new Error("rootless network is not up (no default route in the namespace); see "+path.join(this.dir,"net.log"))}_startHolder(){return new Promise((t,e)=>{const s=fs.openSync(path.join(this.dir,"holder.log"),"a"),r=cpSpawn("unshare",["--user","--map-root-user","--map-auto","--mount","--net","--propagation","private","--","/bin/sh","-c",ROOTLESS_SETUP,"tnc-rootless",this.dir],{stdio:["ignore","pipe",s],detached:!0});fs.closeSync(s);let n="";const o=setTimeout(()=>{r.kill("SIGKILL"),e(new Error(`the rootless namespace did not start; see ${path.join(this.dir,"holder.log")}`))},15e3);r.stdout.on("data",e=>{n+=e,n.includes(ROOTLESS_MARK)&&(clearTimeout(o),r.stdout.destroy(),r.unref(),fs.writeFileSync(this.pidFile,String(r.pid)),t(r.pid))}),r.on("error",t=>{clearTimeout(o),e(t)}),r.on("exit",t=>{clearTimeout(o),e(new Error(`the rootless namespace exited (code ${t}); see ${path.join(this.dir,"holder.log")}`))})})}async _startNetwork(){const t=fs.openSync(path.join(this.dir,"net.log"),"a"),e=which("pasta"),s=e?[e,["--config-net","--quiet","--foreground","-a","10.0.2.100","-n","24","-g","10.0.2.2","--dns-forward","10.0.2.3","-t","none","-u","none","-T","none","-U","none",String(this.pid)]]:[which("slirp4netns"),["--configure","--mtu=65520",String(this.pid),"tap0"]],r=cpSpawn(s[0],s[1],{stdio:["ignore",t,t],detached:!0});fs.closeSync(t),r.unref(),fs.writeFileSync(this.netPidFile,String(r.pid));for(let t=0;t<50;t++){if((await this.run(["/bin/sh","-c","ip -4 route show default"])).stdout.trim())return;if(null!==r.exitCode)break;await new Promise(t=>setTimeout(t,100))}throw new Error(`${path.basename(s[0])} did not bring up the rootless network; see ${path.join(this.dir,"net.log")}`)}_ns(t){if(this.pid||(this.pid=this._holderPid()),!this.pid)throw new Error("the rootless namespace is not running");return["nsenter","--target",String(this.pid),"--user","--mount","--net","--",...t]}spawn(t,e={}){const s=e.env||Object.entries(process.env).map(([t,e])=>`${t}=${e}`);return super.spawn(this._ns(t),{...e,env:[...s,"XDG_RUNTIME_DIR=/run/tnc-xdg","TNC_ROOTLESS=1"]})}connect(t,e){const[s,...r]=this._ns([this.binaries().shim,"connect",t,String(e)]),n=cpSpawn(s,r,{stdio:["pipe","pipe","ignore"]}),o=require("stream").Duplex.from({writable:n.stdin,readable:n.stdout});return n.on("error",t=>o.destroy(t)),n.on("close",s=>{s&&o.destroy(Object.assign(new Error(`connect ${t}:${e} failed`),{code:"ECONNREFUSED"}))}),o.on("close",()=>{try{n.kill()}catch(t){}}),o}stop(){for(const t of[this.netPidFile,this.pidFile]){try{const e=Number(fs.readFileSync(t,"utf8"));e&&process.kill(e,"SIGKILL")}catch(t){}fs.rmSync(t,{force:!0})}this.pid=null}}const{rootlessMode}=require("../paths"),TNC_DIR=path.join(os.homedir(),".tnc"),VM_SOCK=path.join(TNC_DIR,"vmctl.sock"),TNC_VZ=path.join(TNC_DIR,"bin","tnc-vz"),VZ_CTL=path.join(TNC_DIR,"vzctl.sock"),DATA_DISK=path.join(TNC_DIR,"dock","vm-disk.img");function vmRequest(t,e=5e3){return new Promise((s,r)=>{const n=net.createConnection(VM_SOCK);let o=Buffer.alloc(0);const i=setTimeout(()=>{n.destroy(),r(new Error(`MicroVM did not answer "${t.cmd}"`))},e),a=t=>{o=Buffer.concat([o,t]);const e=o.indexOf(10);if(-1===e)return;let c;clearTimeout(i),n.removeListener("data",a),n.pause();try{c=JSON.parse(o.subarray(0,e).toString("utf8"))}catch(t){return n.destroy(),r(new Error(`bad MicroVM reply: ${o.subarray(0,e)}`))}s({sock:n,reply:c,rest:o.subarray(e+1)})};n.on("connect",()=>n.write(JSON.stringify(t)+"\n")),n.on("data",a),n.on("error",t=>{clearTimeout(i),r(new Error(`MicroVM unreachable: ${t.message}`))})})}const b64argv=t=>Buffer.from(t.map(t=>t+"\0").join(""),"utf8").toString("base64");function vmShares(t=[]){return[{host:"/private/tmp",also:["/tmp"],guest:"/tmp_host"},{host:"/private/var/folders",also:["/var/folders"],guest:"/mnt/tnc/varfolders",tag:"tnc_varfolders"},{host:"/Volumes",also:[],guest:"/mnt/tnc/volumes",tag:"tnc_volumes"},...t.map((t,e)=>({host:t,also:[],guest:`/mnt/tnc/share-${e}`,tag:`tnc_share_${e}`}))]}class VmLinuxHost extends LinuxHostBase{constructor({log:t=()=>{},memMB:e,cpus:s}={}){super();const r=require("../config").effective();this.kind="vm",this.log=t,this.memMB=e||r.memoryMB,this.cpus=s||r.cpus,this.diskGB=r.diskGB,this.home=os.homedir(),this.shares=vmShares(r.shares),this.unmounted=new Set,this._sharesReady=!1,this._booting=null}guestPath(t){const e=path.resolve(t);if(e===this.home)return"/workspace";if(e.startsWith(this.home+path.sep))return"/workspace"+e.slice(this.home.length).split(path.sep).join("/");for(const t of this.shares)for(const s of[t.host,...t.also])if(e===s||e.startsWith(s+path.sep)){if(t.tag&&this.unmounted.has(t.tag))break;return t.guest+e.slice(s.length).split(path.sep).join("/")}const s=[this.home,...this.shares.filter(t=>!this.unmounted.has(t.tag)).map(t=>t.host)].join(", "),r=new Error(`${e} is not in a folder shared with the MicroVM (${s}); add one with: thinknsh dock config --share /path`);throw r.statusCode=400,r}async _mountShares(){if(this._sharesReady)return;let t=this.shares.filter(t=>t.tag);for(let e=0;e<15&&t.length;e++){e&&await new Promise(t=>setTimeout(t,200));const s=await this.run(["/bin/sh","-c",'while [ $# -gt 1 ]; do mkdir -p "$2"; mountpoint -q "$2" || mount -t virtiofs "$1" "$2" 2>/dev/null || echo "$1"; shift 2; done',"dock",...t.flatMap(t=>[t.tag,t.guest])]),r=new Set(s.stdout.split("\n").map(t=>t.trim()).filter(Boolean));t=t.filter(t=>r.has(t.tag))}this.unmounted=new Set(t.map(t=>t.tag)),this.unmounted.size&&this.log(`shared folders not available in this MicroVM: ${[...this.unmounted].join(", ")} (an older tnc-vz? run: thinknsh dock setup)`),this._sharesReady=!0}binaries(){return{crun:"/usr/bin/crun",shim:"/usr/bin/tnc-shim",busybox:"/bin/busybox.static"}}balloon(t){return new Promise(e=>{const s=net.createConnection(VZ_CTL,()=>s.write(t?`balloon ${Math.round(t)}\n`:"target\n"));let r="";s.on("data",t=>{r+=t}),s.on("end",()=>{const t=/^ok (\d+)/.exec(r);e(t?Number(t[1]):null)}),s.on("error",()=>e(null)),s.setTimeout(3e3,()=>{s.destroy(),e(null)})})}async stop(){this._sharesReady=!1,await new Promise(t=>{const e=cpSpawn(TNC_VZ,["stop"],{stdio:"ignore"});e.on("close",t),e.on("error",t)})}async _ping(){try{const{sock:t,reply:e}=await vmRequest({cmd:"ping"},1500);return t.destroy(),e}catch(t){return null}}async isUp(){const t=await this._ping();return!(!t||1!==t.dock)}async ready(){const t=await this._ping();if(t&&1===t.dock)return this._mountShares();if(t&&!t.dock)throw new Error("The running MicroVM has no dock runtime. Run: thinknsh dock setup");return this._sharesReady=!1,this._booting||(this._booting=this._boot().finally(()=>{this._booting=null})),await this._booting,this._mountShares()}async _boot(){if(!fs.existsSync(TNC_VZ))throw new Error("The dock runtime is not installed. Run: thinknsh dock setup");this.log(`booting MicroVM (${this.cpus} CPUs, ${this.memMB} MB)`);const t=fs.openSync(path.join(TNC_DIR,"tnc-vz.log"),"a");fs.mkdirSync(path.dirname(DATA_DISK),{recursive:!0}),cpSpawn(TNC_VZ,["start","--cpus",String(this.cpus),"--mem",String(this.memMB),"--workspace",this.home,"--disk",DATA_DISK,"--disk-size",String(this.diskGB||64),"--shares",this.shares.filter(t=>t.tag).map(t=>`${t.tag}=${t.host}`).join(",")],{detached:!0,stdio:["ignore",t,t]}).unref(),fs.closeSync(t);const e=Date.now()+3e4;for(;Date.now()<e;){const t=await this._ping();if(t){if(!t.dock)throw new Error("MicroVM booted without the dock runtime. Run: thinknsh dock setup");return}await new Promise(t=>setTimeout(t,200))}throw new Error("MicroVM did not become ready within 30s (see ~/.tnc/tnc-vz.log)")}spawn(t,{env:e,cwd:s}={}){const r=new PassThrough,n=new PassThrough,o=new EventEmitter;let i=null,a=null;const c=[],u=(t,e=Buffer.alloc(0))=>{const s=Buffer.alloc(5);s[0]=t,s.writeUInt32BE(e.length,1);const r=Buffer.concat([s,e]);i?i.write(r):c.push(r)},l=new Promise(t=>o.once("exit",t)),d=t=>{null===a&&(a=t,r.end(),n.end(),o.emit("exit",t))},h={cmd:"spawn",argv_b64:b64argv(t),cwd:s||"/"};return e&&(h.env_b64=b64argv(e)),vmRequest(h,1e4).then(({sock:t,reply:e,rest:s})=>{if(!e.ok)return n.write(`${e.error}\n`),t.destroy(),d(127);i=t;for(const t of c.splice(0))i.write(t);let o=s;const u=()=>{for(;o.length>=5;){const t=o.readUInt32BE(1);if(o.length<5+t)return;const e=o[0],s=o.subarray(5,5+t);o=o.subarray(5+t),e===FRAME.STDOUT?r.write(s):e===FRAME.STDERR?n.write(s):e===FRAME.EXIT&&(d(s.readInt32BE(0)),i.end())}};i.on("data",t=>{o=Buffer.concat([o,t]),u()}),i.on("close",()=>d(a??255)),i.on("error",()=>{}),i.resume(),u()}).catch(t=>{n.write(`${t.message}\n`),d(127)}),{stdout:r,stderr:n,write:t=>u(FRAME.STDIN,Buffer.isBuffer(t)?t:Buffer.from(String(t))),end:()=>u(FRAME.STDIN),kill:(t="SIGTERM")=>u(FRAME.SIGNAL,Buffer.from([SIGNALS[t]||15])),exited:l}}connect(t,e){const s=new PassThrough,r=new PassThrough,n=require("stream").Duplex.from({writable:s,readable:r});return vmRequest({cmd:"connect",host:t,port:e},5e3).then(({sock:t,reply:e,rest:o})=>{if(!e.ok)return t.destroy(),void n.destroy(new Error(e.error));o.length&&r.write(o),n.emit("ready"),t.pipe(r),s.pipe(t),t.on("error",t=>n.destroy(t)),n.on("close",()=>t.destroy()),t.resume()}).catch(t=>n.destroy(t)),n}}function windowsToWsl(t){const e=/^([A-Za-z]):[\\/]?(.*)$/.exec(t);if(!e){const e=new Error(`${t} is not a local Windows drive path; dock can only share drive paths (C:\\...) with WSL`);throw e.statusCode=400,e}const s=e[2].split(/[\\/]+/).filter(Boolean).join("/");return`/mnt/${e[1].toLowerCase()}${s?"/"+s:""}`}class WslLinuxHost extends LinuxHostBase{constructor({log:t=()=>{},distro:e=process.env.TNC_WSL_DISTRO||"tnc-dock",wsl:s=process.env.TNC_WSL_EXE||"wsl.exe"}={}){super(),this.kind="wsl",this.log=t,this.distro=e,this.wslCmd=Array.isArray(s)?s:[s],this._readyAt=0}guestPath(t){return windowsToWsl(path.win32.resolve(t))}binaries(){return{crun:"/usr/bin/crun",shim:"/usr/bin/tnc-shim",busybox:"/bin/busybox.static"}}_wslArgs(t,{env:e,cwd:s}={}){return["-d",this.distro,"-u","root","--cd",s||"/","--exec",...e?["/usr/bin/env","-i",...e]:[],...t]}_list(t){return new Promise(e=>{const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),"--list","--quiet",...t?["--running"]:[]],{windowsHide:!0}),r=[];s.stdout.on("data",t=>r.push(t)),s.on("error",()=>e(null)),s.on("close",s=>{if(0!==s)return e(t?[]:null);const n=Buffer.concat(r),o=n.includes(0)?n.toString("utf16le"):n.toString("utf8");e(o.split(/\r?\n/).map(t=>t.replace(/\0/g,"").trim()).filter(Boolean))})})}async isUp(){const t=await this._list(!0);return!!t&&t.includes(this.distro)}async ready(){if(Date.now()-this._readyAt<5e3)return;const t=await this._list(!1);if(null===t)throw new Error("WSL is not installed. Run in an admin PowerShell: wsl --install --no-distribution, then: thinknsh dock setup");if(!t.includes(this.distro))throw new Error(`The WSL distro "${this.distro}" is missing. Run: thinknsh dock setup`);const e=await this.run(["/usr/local/bin/tnc-wsl-init"]);if(0!==e.code)throw new Error(`could not start WSL distro ${this.distro}: ${e.stderr||e.stdout}`);this._readyAt=Date.now()}async stop(){this._readyAt=0,await new Promise(t=>{const e=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),"--terminate",this.distro],{stdio:"ignore",windowsHide:!0});e.on("close",t),e.on("error",t)})}spawn(t,e={}){const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),...this._wslArgs(t,e)],{stdio:["pipe","pipe","pipe"],windowsHide:!0}),r=new Promise(t=>{s.on("error",()=>t(127)),s.on("close",e=>t(e??255))});return s.stdin.on("error",()=>{}),{stdout:s.stdout,stderr:s.stderr,write:t=>s.stdin.write(t),end:()=>s.stdin.end(),kill:(t="SIGTERM")=>{try{s.kill(t)}catch(t){}},exited:r}}connect(t,e){const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),...this._wslArgs(["/usr/bin/tnc-shim","connect",t,String(e)])],{stdio:["pipe","pipe","ignore"],windowsHide:!0}),r=require("stream").Duplex.from({writable:s.stdin,readable:s.stdout});return s.on("error",t=>r.destroy(t)),s.on("close",s=>{s&&r.destroy(Object.assign(new Error(`connect ${t}:${e} failed`),{code:"ECONNREFUSED"}))}),r.on("close",()=>{try{s.kill()}catch(t){}}),r}async probe(t,e){return 0===(await this.run(["/usr/bin/tnc-shim","probe",t,String(e)])).code}}function createLinuxHost(t={}){if("linux"===process.platform)return rootlessMode()?new RootlessLinuxHost(t):new LocalLinuxHost(t);if("darwin"===process.platform)return new VmLinuxHost(t);if("win32"===process.platform)return new WslLinuxHost(t);throw new Error(`dock does not support ${process.platform}`)}module.exports={createLinuxHost,LocalLinuxHost,RootlessLinuxHost,VmLinuxHost,WslLinuxHost,windowsToWsl,vmRequest,vmShares,rootlessMode,rootlessProblems,ROOTLESS_SETUP};
|
|
7
|
+
"use strict";const fs=require("fs"),os=require("os"),net=require("net"),path=require("path"),{spawn:cpSpawn}=require("child_process"),{PassThrough}=require("stream"),{EventEmitter}=require("events"),FRAME={STDIN:0,STDOUT:1,STDERR:2,EXIT:3,SIGNAL:4},SIGNALS={SIGHUP:1,SIGINT:2,SIGQUIT:3,SIGKILL:9,SIGUSR1:10,SIGUSR2:12,SIGTERM:15};function collect(t){return new Promise(e=>{const s=[],r=[];t.stdout.on("data",t=>s.push(t)),t.stderr.on("data",t=>r.push(t)),t.exited.then(t=>e({code:t,stdout:Buffer.concat(s).toString("utf8"),stderr:Buffer.concat(r).toString("utf8")}))})}class LinuxHostBase{async run(t,e={}){const s=this.spawn(t,e),r=collect(s);return void 0!==e.input&&s.write(e.input),s.end(),r}async check(t,e={}){const s=await this.run(t,e);if(0!==s.code){const e="/bin/sh"===t[0]?"Linux step":`${t[0]} ${t.slice(1,3).join(" ")}`,r=new Error(`${e} failed (exit ${s.code}): ${(s.stderr||s.stdout).trim().slice(0,500)||"no output"}`);throw r.result=s,r}return s}sh(t,e=[],s={}){return this.check(["/bin/sh","-c",t,"dock",...e.map(String)],s)}}class LocalLinuxHost extends LinuxHostBase{constructor(){super(),this.kind="local"}async ready(){if(process.getuid&&0!==process.getuid())throw new Error("dock on Linux must run as root (it manages namespaces, mounts and iptables)")}guestPath(t){return t}async isUp(){return!0}binaries(){const t="arm64"===process.arch?"aarch64":"x86_64",e=path.join(os.homedir(),".tnc","bin",`crun-linux-${t}`),s=process.env.TNC_BUSYBOX||path.join(os.homedir(),".tnc","bin",`tnc-busybox-linux-${t}`);return{crun:process.env.TNC_CRUN||(fs.existsSync(e)?e:"crun"),shim:process.env.TNC_SHIM||path.join(os.homedir(),".tnc","bin",`tnc-shim-linux-${t}`),busybox:fs.existsSync(s)?s:null}}spawn(t,{env:e,cwd:s}={}){const r=cpSpawn(t[0],t.slice(1),{cwd:s||"/",env:e?Object.fromEntries(e.map(t=>{const e=t.indexOf("=");return[t.slice(0,e),t.slice(e+1)]})):process.env,stdio:["pipe","pipe","pipe"]}),n=new Promise(t=>{r.on("error",()=>t(127)),r.on("close",(e,s)=>t(e??128+(os.constants.signals[s]||0)))});return r.stdin.on("error",()=>{}),{stdout:r.stdout,stderr:r.stderr,write:t=>r.stdin.write(t),end:()=>r.stdin.end(),kill:(t="SIGTERM")=>{try{r.kill(t)}catch(t){}},exited:n}}connect(t,e){return net.createConnection({host:t,port:e})}}const ROOTLESS_SETUP='\nset -e\ndata="$1"\nmkdir -p "$data/lib" "$data/save"\n# the host\'s resolver files live under /run, which is replaced below: keep copies\n[ -r /run/systemd/resolve/resolv.conf ] && cp /run/systemd/resolve/resolv.conf "$data/save/resolved.conf" || true\nreal=$(readlink -f /etc/resolv.conf 2>/dev/null || echo /etc/resolv.conf)\n[ -r "$real" ] && cp "$real" "$data/save/resolv.conf" || true\nmount -t tmpfs -o mode=755 tnc-run /run\nmkdir -p /run/netns /run/tnc-dock /run/crun /run/tnc-xdg /run/systemd/resolve\n[ -f "$data/save/resolved.conf" ] && cp "$data/save/resolved.conf" /run/systemd/resolve/resolv.conf || true\ncase "$real" in /run/*) mkdir -p "$(dirname "$real")"; cp "$data/save/resolv.conf" "$real" 2>/dev/null || true;; esac\nmount -t tmpfs -o mode=755 tnc-lib /var/lib\nmkdir -p /var/lib/tnc-dock\nmount --bind "$data/lib" /var/lib/tnc-dock\necho tnc-rootless-ready\nexec sleep 2147483647\n',ROOTLESS_MARK="tnc-rootless-ready",ROOTLESS_DNS="10.0.2.3",which=t=>(process.env.PATH||"").split(path.delimiter).map(e=>path.join(e,t)).find(t=>{try{return fs.accessSync(t,fs.constants.X_OK),!0}catch(t){return!1}})||null,pidAlive=t=>{try{return process.kill(t,0),!0}catch(t){return"EPERM"===t.code}};function rootlessProblems({user:t=os.userInfo().username,read:e=t=>fs.readFileSync(t,"utf8"),has:s=which}={}){const r=[],[n,o]=os.release().split(".").map(Number);(n<5||5===n&&o<11)&&r.push(`Linux ${os.release()} is too old: rootless mode needs 5.11 or newer (overlay mounts in user namespaces)`);for(const t of["unshare","nsenter","newuidmap","newgidmap","iptables","ip"])s(t)||r.push(`${t} is missing: sudo apt-get install -y uidmap util-linux iproute2 iptables (Fedora: sudo dnf install shadow-utils util-linux iproute iptables)`);s("pasta")||s("slirp4netns")||r.push("no user-mode network: sudo apt-get install -y passt (or slirp4netns)");const i=s=>{try{return e(s).split("\n").some(e=>e.split(":")[0]===t||e.split(":")[0]===String(process.getuid()))}catch(t){return!1}};i("/etc/subuid")&&i("/etc/subgid")||r.push(`no subordinate ids for ${t}: sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 ${t}`);try{"1"===e("/proc/sys/kernel/apparmor_restrict_unprivileged_userns").trim()&&r.push("AppArmor blocks user namespaces (Ubuntu 23.10+): sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 (keep it: add the line to /etc/sysctl.d/60-thinknsh.conf)")}catch(t){}try{"0"===e("/proc/sys/kernel/unprivileged_userns_clone").trim()&&r.push("user namespaces are off: sudo sysctl -w kernel.unprivileged_userns_clone=1")}catch(t){}try{0===Number(e("/proc/sys/user/max_user_namespaces").trim())&&r.push("user namespaces are off: sudo sysctl -w user.max_user_namespaces=28633")}catch(t){}return[...new Set(r)]}class RootlessLinuxHost extends LocalLinuxHost{constructor({log:t=()=>{},home:e=process.env.TNC_DOCK_HOME||path.join(os.homedir(),".tnc","dock")}={}){super(),this.kind="rootless",this.log=t,this.dir=path.join(e,"rootless"),this.pidFile=path.join(this.dir,"holder.pid"),this.netPidFile=path.join(this.dir,"net.pid"),this.pid=null,this.gateway=null,this._starting=null,this.cgroupBase=null,this.cgroupControllers=[]}cgroupManager(){return this.cgroupBase?"cgroupfs":"disabled"}cgroupParent(){return this.cgroupBase||void 0}resolvConf(){return"nameserver 10.0.2.3\n"}warnings(){if(!this.cgroupBase)return["rootless mode without a delegated cgroup: memory, CPU and process limits (-m, --cpus, --pids-limit) are not enforced and pause is not available; start dock from a systemd user session (thinknsh dock autostart on) to get them"];const t=["memory","cpu","pids","io"].filter(t=>!this.cgroupControllers.includes(t));return t.length?[`rootless mode: the ${t.join(", ")} controller${t.length>1?"s are":" is"} not delegated to this user, so those limits are not enforced (sudo systemctl edit user@.service: [Service] Delegate=cpu cpuset io memory pids)`]:[]}_setupCgroups(t){this.cgroupBase=null,this.cgroupControllers=[];const e="/sys/fs/cgroup",s=t=>{try{return(fs.readFileSync(`/proc/${t}/cgroup`,"utf8").split("\n").find(t=>t.startsWith("0::"))||"").slice(3).trim()||null}catch(t){return null}},r=t=>{try{return fs.accessSync(t,fs.constants.W_OK),!0}catch(t){return!1}};let n=null;if(t){if(n=s(process.pid),!n||"/"===n||!r(`${e}${n}`)||!r(`${e}${n}/cgroup.procs`))return;try{for(const t of["daemon","holder","containers"])fs.mkdirSync(`${e}${n}/${t}`,{recursive:!0});const t=fs.readFileSync(`${e}${n}/cgroup.procs`,"utf8").split("\n").filter(Boolean);for(const s of t)try{fs.writeFileSync(`${e}${n}/${Number(s)===this.pid?"holder":"daemon"}/cgroup.procs`,s)}catch(t){}s(this.pid)!==`${n}/holder`&&fs.writeFileSync(`${e}${n}/holder/cgroup.procs`,String(this.pid))}catch(t){return void this.log?.(`rootless cgroups: ${t.message}`)}}else{const t=s(this.pid);if(!t||!t.endsWith("/holder"))return;if(n=t.slice(0,-7),!r(`${e}${n}/containers`))return}let o=[];try{o=fs.readFileSync(`${e}${n}/cgroup.controllers`,"utf8").trim().split(/\s+/).filter(Boolean)}catch(t){return}for(const t of[`${e}${n}`,`${e}${n}/containers`])for(const e of["cpu","cpuset","io","memory","pids"])if(o.includes(e))try{fs.writeFileSync(`${t}/cgroup.subtree_control`,`+${e}`)}catch(t){}try{this.cgroupControllers=fs.readFileSync(`${e}${n}/containers/cgroup.controllers`,"utf8").trim().split(/\s+/).filter(Boolean)}catch(t){}this.cgroupBase=`${n}/containers`}_holderPid(){let t;try{t=Number(fs.readFileSync(this.pidFile,"utf8").trim())}catch(t){return null}if(!t||!pidAlive(t))return null;try{if(!fs.readFileSync(`/proc/${t}/cmdline`,"utf8").includes("2147483647"))return null;if(fs.readlinkSync(`/proc/${t}/ns/user`)===fs.readlinkSync("/proc/self/ns/user"))return null}catch(t){return null}return t}async isUp(){return!!this._holderPid()}ready(){return this.pid&&this._holderPid()===this.pid?Promise.resolve():(this._starting||(this._starting=this._start().finally(()=>{this._starting=null})),this._starting)}async _start(){const t=this._holderPid();if(t)this.pid=t,this._setupCgroups(!1);else{const t=rootlessProblems();if(t.length)throw new Error(`rootless mode is not ready on this machine:\n - ${t.join("\n - ")}`);fs.mkdirSync(this.dir,{recursive:!0,mode:448}),this.pid=await this._startHolder(),this._setupCgroups(!0),await this._startNetwork()}const e=await this.run(["/bin/sh","-c","ip -4 route show default | awk '{print $3; exit}'"]);if(this.gateway=e.stdout.trim()||null,!this.gateway)throw new Error("rootless network is not up (no default route in the namespace); see "+path.join(this.dir,"net.log"))}_startHolder(){return new Promise((t,e)=>{const s=fs.openSync(path.join(this.dir,"holder.log"),"a"),r=cpSpawn("unshare",["--user","--map-root-user","--map-auto","--mount","--net","--propagation","private","--","/bin/sh","-c",ROOTLESS_SETUP,"tnc-rootless",this.dir],{stdio:["ignore","pipe",s],detached:!0});fs.closeSync(s);let n="";const o=setTimeout(()=>{r.kill("SIGKILL"),e(new Error(`the rootless namespace did not start; see ${path.join(this.dir,"holder.log")}`))},15e3);r.stdout.on("data",e=>{n+=e,n.includes(ROOTLESS_MARK)&&(clearTimeout(o),r.stdout.destroy(),r.unref(),fs.writeFileSync(this.pidFile,String(r.pid)),t(r.pid))}),r.on("error",t=>{clearTimeout(o),e(t)}),r.on("exit",t=>{clearTimeout(o),e(new Error(`the rootless namespace exited (code ${t}); see ${path.join(this.dir,"holder.log")}`))})})}async _startNetwork(){const t=fs.openSync(path.join(this.dir,"net.log"),"a"),e=which("pasta"),s=e?[e,["--config-net","--quiet","--foreground","-a","10.0.2.100","-n","24","-g","10.0.2.2","--dns-forward","10.0.2.3","-t","none","-u","none","-T","none","-U","none",String(this.pid)]]:[which("slirp4netns"),["--configure","--mtu=65520",String(this.pid),"tap0"]],r=cpSpawn(s[0],s[1],{stdio:["ignore",t,t],detached:!0});fs.closeSync(t),r.unref(),fs.writeFileSync(this.netPidFile,String(r.pid));for(let t=0;t<50;t++){if((await this.run(["/bin/sh","-c","ip -4 route show default"])).stdout.trim())return;if(null!==r.exitCode)break;await new Promise(t=>setTimeout(t,100))}throw new Error(`${path.basename(s[0])} did not bring up the rootless network; see ${path.join(this.dir,"net.log")}`)}_ns(t){if(this.pid||(this.pid=this._holderPid()),!this.pid)throw new Error("the rootless namespace is not running");return["nsenter","--target",String(this.pid),"--user","--mount","--net","--",...t]}spawn(t,e={}){const s=e.env||Object.entries(process.env).map(([t,e])=>`${t}=${e}`);return super.spawn(this._ns(t),{...e,env:[...s,"XDG_RUNTIME_DIR=/run/tnc-xdg","TNC_ROOTLESS=1"]})}connect(t,e){const[s,...r]=this._ns([this.binaries().shim,"connect",t,String(e)]),n=cpSpawn(s,r,{stdio:["pipe","pipe","ignore"]}),o=require("stream").Duplex.from({writable:n.stdin,readable:n.stdout});return n.on("error",t=>o.destroy(t)),n.on("close",s=>{s&&o.destroy(Object.assign(new Error(`connect ${t}:${e} failed`),{code:"ECONNREFUSED"}))}),o.on("close",()=>{try{n.kill()}catch(t){}}),o}stop(){for(const t of[this.netPidFile,this.pidFile]){try{const e=Number(fs.readFileSync(t,"utf8"));e&&process.kill(e,"SIGKILL")}catch(t){}fs.rmSync(t,{force:!0})}this.pid=null}}const{rootlessMode}=require("../paths"),TNC_DIR=path.join(os.homedir(),".tnc"),VM_SOCK=path.join(TNC_DIR,"vmctl.sock"),TNC_VZ=path.join(TNC_DIR,"bin","tnc-vz"),VZ_CTL=path.join(TNC_DIR,"vzctl.sock"),DATA_DISK=path.join(TNC_DIR,"dock","vm-disk.img");function vmRequest(t,e=5e3){return new Promise((s,r)=>{const n=net.createConnection(VM_SOCK);let o=Buffer.alloc(0);const i=setTimeout(()=>{n.destroy(),r(new Error(`MicroVM did not answer "${t.cmd}"`))},e),c=t=>{o=Buffer.concat([o,t]);const e=o.indexOf(10);if(-1===e)return;let a;clearTimeout(i),n.removeListener("data",c),n.pause();try{a=JSON.parse(o.subarray(0,e).toString("utf8"))}catch(t){return n.destroy(),r(new Error(`bad MicroVM reply: ${o.subarray(0,e)}`))}s({sock:n,reply:a,rest:o.subarray(e+1)})};n.on("connect",()=>n.write(JSON.stringify(t)+"\n")),n.on("data",c),n.on("error",t=>{clearTimeout(i),r(new Error(`MicroVM unreachable: ${t.message}`))})})}const b64argv=t=>Buffer.from(t.map(t=>t+"\0").join(""),"utf8").toString("base64");function vmShares(t=[]){return[{host:"/private/tmp",also:["/tmp"],guest:"/tmp_host"},{host:"/private/var/folders",also:["/var/folders"],guest:"/mnt/tnc/varfolders",tag:"tnc_varfolders"},{host:"/Volumes",also:[],guest:"/mnt/tnc/volumes",tag:"tnc_volumes"},...t.map((t,e)=>({host:t,also:[],guest:`/mnt/tnc/share-${e}`,tag:`tnc_share_${e}`}))]}class VmLinuxHost extends LinuxHostBase{constructor({log:t=()=>{},memMB:e,cpus:s}={}){super();const r=require("../config").effective();this.kind="vm",this.log=t,this.memMB=e||r.memoryMB,this.cpus=s||r.cpus,this.diskGB=r.diskGB,this.home=os.homedir(),this.shares=vmShares(r.shares),this.unmounted=new Set,this._sharesReady=!1,this._booting=null}guestPath(t){const e=path.resolve(t);if(e===this.home)return"/workspace";if(e.startsWith(this.home+path.sep))return"/workspace"+e.slice(this.home.length).split(path.sep).join("/");for(const t of this.shares)for(const s of[t.host,...t.also])if(e===s||e.startsWith(s+path.sep)){if(t.tag&&this.unmounted.has(t.tag))break;return t.guest+e.slice(s.length).split(path.sep).join("/")}const s=[this.home,...this.shares.filter(t=>!this.unmounted.has(t.tag)).map(t=>t.host)].join(", "),r=new Error(`${e} is not in a folder shared with the MicroVM (${s}); add one with: thinknsh dock config --share /path`);throw r.statusCode=400,r}async _mountShares(){if(this._sharesReady)return;let t=this.shares.filter(t=>t.tag);for(let e=0;e<15&&t.length;e++){e&&await new Promise(t=>setTimeout(t,200));const s=await this.run(["/bin/sh","-c",'while [ $# -gt 1 ]; do mkdir -p "$2"; mountpoint -q "$2" || mount -t virtiofs "$1" "$2" 2>/dev/null || echo "$1"; shift 2; done',"dock",...t.flatMap(t=>[t.tag,t.guest])]),r=new Set(s.stdout.split("\n").map(t=>t.trim()).filter(Boolean));t=t.filter(t=>r.has(t.tag))}this.unmounted=new Set(t.map(t=>t.tag)),this.unmounted.size&&this.log(`shared folders not available in this MicroVM: ${[...this.unmounted].join(", ")} (an older tnc-vz? run: thinknsh dock setup)`),this._sharesReady=!0}binaries(){return{crun:"/usr/bin/crun",shim:"/usr/bin/tnc-shim",busybox:"/bin/busybox.static"}}balloon(t){return new Promise(e=>{const s=net.createConnection(VZ_CTL,()=>s.write(t?`balloon ${Math.round(t)}\n`:"target\n"));let r="";s.on("data",t=>{r+=t}),s.on("end",()=>{const t=/^ok (\d+)/.exec(r);e(t?Number(t[1]):null)}),s.on("error",()=>e(null)),s.setTimeout(3e3,()=>{s.destroy(),e(null)})})}async stop(){this._sharesReady=!1,await new Promise(t=>{const e=cpSpawn(TNC_VZ,["stop"],{stdio:"ignore"});e.on("close",t),e.on("error",t)})}async _ping(){try{const{sock:t,reply:e}=await vmRequest({cmd:"ping"},1500);return t.destroy(),e}catch(t){return null}}async isUp(){const t=await this._ping();return!(!t||1!==t.dock)}async ready(){const t=await this._ping();if(t&&1===t.dock)return this._mountShares();if(t&&!t.dock)throw new Error("The running MicroVM has no dock runtime. Run: thinknsh dock setup");return this._sharesReady=!1,this._booting||(this._booting=this._boot().finally(()=>{this._booting=null})),await this._booting,this._mountShares()}async _boot(){if(!fs.existsSync(TNC_VZ))throw new Error("The dock runtime is not installed. Run: thinknsh dock setup");this.log(`booting MicroVM (${this.cpus} CPUs, ${this.memMB} MB)`);const t=fs.openSync(path.join(TNC_DIR,"tnc-vz.log"),"a");fs.mkdirSync(path.dirname(DATA_DISK),{recursive:!0}),cpSpawn(TNC_VZ,["start","--cpus",String(this.cpus),"--mem",String(this.memMB),"--workspace",this.home,"--disk",DATA_DISK,"--disk-size",String(this.diskGB||64),"--shares",this.shares.filter(t=>t.tag).map(t=>`${t.tag}=${t.host}`).join(",")],{detached:!0,stdio:["ignore",t,t]}).unref(),fs.closeSync(t);const e=Date.now()+3e4;for(;Date.now()<e;){const t=await this._ping();if(t){if(!t.dock)throw new Error("MicroVM booted without the dock runtime. Run: thinknsh dock setup");return}await new Promise(t=>setTimeout(t,200))}throw new Error("MicroVM did not become ready within 30s (see ~/.tnc/tnc-vz.log)")}spawn(t,{env:e,cwd:s}={}){const r=new PassThrough,n=new PassThrough,o=new EventEmitter;let i=null,c=null;const a=[],u=(t,e=Buffer.alloc(0))=>{const s=Buffer.alloc(5);s[0]=t,s.writeUInt32BE(e.length,1);const r=Buffer.concat([s,e]);i?i.write(r):a.push(r)},l=new Promise(t=>o.once("exit",t)),d=t=>{null===c&&(c=t,r.end(),n.end(),o.emit("exit",t))},h={cmd:"spawn",argv_b64:b64argv(t),cwd:s||"/"};return e&&(h.env_b64=b64argv(e)),vmRequest(h,1e4).then(({sock:t,reply:e,rest:s})=>{if(!e.ok)return n.write(`${e.error}\n`),t.destroy(),d(127);i=t;for(const t of a.splice(0))i.write(t);let o=s;const u=()=>{for(;o.length>=5;){const t=o.readUInt32BE(1);if(o.length<5+t)return;const e=o[0],s=o.subarray(5,5+t);o=o.subarray(5+t),e===FRAME.STDOUT?r.write(s):e===FRAME.STDERR?n.write(s):e===FRAME.EXIT&&(d(s.readInt32BE(0)),i.end())}};i.on("data",t=>{o=Buffer.concat([o,t]),u()}),i.on("close",()=>d(c??255)),i.on("error",()=>{}),i.resume(),u()}).catch(t=>{n.write(`${t.message}\n`),d(127)}),{stdout:r,stderr:n,write:t=>u(FRAME.STDIN,Buffer.isBuffer(t)?t:Buffer.from(String(t))),end:()=>u(FRAME.STDIN),kill:(t="SIGTERM")=>u(FRAME.SIGNAL,Buffer.from([SIGNALS[t]||15])),exited:l}}connect(t,e){const s=new PassThrough,r=new PassThrough,n=require("stream").Duplex.from({writable:s,readable:r});return vmRequest({cmd:"connect",host:t,port:e},5e3).then(({sock:t,reply:e,rest:o})=>{if(!e.ok)return t.destroy(),void n.destroy(new Error(e.error));o.length&&r.write(o),n.emit("ready"),t.pipe(r),s.pipe(t),t.on("error",t=>n.destroy(t)),n.on("close",()=>t.destroy()),t.resume()}).catch(t=>n.destroy(t)),n}}function windowsToWsl(t){const e=/^([A-Za-z]):[\\/]?(.*)$/.exec(t);if(!e){const e=new Error(`${t} is not a local Windows drive path; dock can only share drive paths (C:\\...) with WSL`);throw e.statusCode=400,e}const s=e[2].split(/[\\/]+/).filter(Boolean).join("/");return`/mnt/${e[1].toLowerCase()}${s?"/"+s:""}`}class WslLinuxHost extends LinuxHostBase{constructor({log:t=()=>{},distro:e=process.env.TNC_WSL_DISTRO||"tnc-dock",wsl:s=process.env.TNC_WSL_EXE||"wsl.exe"}={}){super(),this.kind="wsl",this.log=t,this.distro=e,this.wslCmd=Array.isArray(s)?s:[s],this._readyAt=0}guestPath(t){return windowsToWsl(path.win32.resolve(t))}binaries(){return{crun:"/usr/bin/crun",shim:"/usr/bin/tnc-shim",busybox:"/bin/busybox.static"}}_wslArgs(t,{env:e,cwd:s}={}){return["-d",this.distro,"-u","root","--cd",s||"/","--exec",...e?["/usr/bin/env","-i",...e]:[],...t]}_list(t){return new Promise(e=>{const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),"--list","--quiet",...t?["--running"]:[]],{windowsHide:!0}),r=[];s.stdout.on("data",t=>r.push(t)),s.on("error",()=>e(null)),s.on("close",s=>{if(0!==s)return e(t?[]:null);const n=Buffer.concat(r),o=n.includes(0)?n.toString("utf16le"):n.toString("utf8");e(o.split(/\r?\n/).map(t=>t.replace(/\0/g,"").trim()).filter(Boolean))})})}async isUp(){const t=await this._list(!0);return!!t&&t.includes(this.distro)}async ready(){if(Date.now()-this._readyAt<5e3)return;const t=await this._list(!1);if(null===t)throw new Error(require("../setup").wslMissingMessage());if(!t.includes(this.distro))throw new Error(`The WSL distro "${this.distro}" is missing. Run: thinknsh dock setup`);const e=await this.run(["/usr/local/bin/tnc-wsl-init"]);if(0!==e.code)throw new Error(`could not start WSL distro ${this.distro}: ${e.stderr||e.stdout}`);this._readyAt=Date.now()}async stop(){this._readyAt=0,await new Promise(t=>{const e=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),"--terminate",this.distro],{stdio:"ignore",windowsHide:!0});e.on("close",t),e.on("error",t)})}spawn(t,e={}){const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),...this._wslArgs(t,e)],{stdio:["pipe","pipe","pipe"],windowsHide:!0}),r=new Promise(t=>{s.on("error",()=>t(127)),s.on("close",e=>t(e??255))});return s.stdin.on("error",()=>{}),{stdout:s.stdout,stderr:s.stderr,write:t=>s.stdin.write(t),end:()=>s.stdin.end(),kill:(t="SIGTERM")=>{try{s.kill(t)}catch(t){}},exited:r}}connect(t,e){const s=cpSpawn(this.wslCmd[0],[...this.wslCmd.slice(1),...this._wslArgs(["/usr/bin/tnc-shim","connect",t,String(e)])],{stdio:["pipe","pipe","ignore"],windowsHide:!0}),r=require("stream").Duplex.from({writable:s.stdin,readable:s.stdout});return s.on("error",t=>r.destroy(t)),s.on("close",s=>{s&&r.destroy(Object.assign(new Error(`connect ${t}:${e} failed`),{code:"ECONNREFUSED"}))}),r.on("close",()=>{try{s.kill()}catch(t){}}),r}async probe(t,e){return 0===(await this.run(["/usr/bin/tnc-shim","probe",t,String(e)])).code}}function createLinuxHost(t={}){if("linux"===process.platform)return rootlessMode()?new RootlessLinuxHost(t):new LocalLinuxHost(t);if("darwin"===process.platform)return new VmLinuxHost(t);if("win32"===process.platform)return new WslLinuxHost(t);throw new Error(`dock does not support ${process.platform}`)}module.exports={createLinuxHost,LocalLinuxHost,RootlessLinuxHost,VmLinuxHost,WslLinuxHost,windowsToWsl,vmRequest,vmShares,rootlessMode,rootlessProblems,ROOTLESS_SETUP};
|
|
@@ -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"),http=require("http"),{spawnSync}=require("child_process"),{request}=require("./client"),{dockPaths}=require("./paths"),{DOCK_VERSION}=require("./version"),{BUILD_FILE}=require("./api/wire"),sleep=t=>new Promise(e=>setTimeout(e,t)),PREFIX="dock-selftest";function demux(t){const e={stdout:"",stderr:""};for(;t.length>=8;){const
|
|
7
|
+
"use strict";const fs=require("fs"),os=require("os"),path=require("path"),http=require("http"),{spawnSync}=require("child_process"),{request}=require("./client"),{dockPaths}=require("./paths"),{DOCK_VERSION}=require("./version"),{BUILD_FILE}=require("./api/wire"),sleep=t=>new Promise(e=>setTimeout(e,t)),PREFIX="dock-selftest";function demux(t){const e={stdout:"",stderr:""};for(;t.length>=8;){const n=t.readUInt32BE(4);e[2===t[0]?"stderr":"stdout"]+=t.subarray(8,8+n).toString("utf8"),t=t.subarray(8+n)}return e}async function runSelftest({log:t=()=>{},keepGoing:e=!0}={}){const n=dockPaths().socket,a=[],s=async(t,e,a,s=3e5)=>{const o=await request(n,t,e,{body:a,timeout:s});if(o.status>=400)throw new Error(`${t} ${e} → ${o.status} ${"object"==typeof o.body?o.body.message:o.body}`);return o.body},o=async(t,e)=>{await request(n,"DELETE",`/containers/${t}?force=1&v=1`).catch(()=>{});const{Id:a}=await s("POST",`/containers/create?name=${t}`,e);await s("POST",`/containers/${a}/start`);const{StatusCode:o}=await s("POST",`/containers/${a}/wait`),r=await(i=a,new Promise((t,e)=>{http.get({socketPath:n,path:`/containers/${i}/logs?stdout=1&stderr=1`},e=>{const n=[];e.on("data",t=>n.push(t)),e.on("end",()=>t(demux(Buffer.concat(n))))}).on("error",e)}));var i;return await s("DELETE",`/containers/${a}?v=1`),{code:o,...r}},r=async(n,s)=>{const o=Date.now();try{const e=await s();a.push({name:n,ok:!0,ms:Date.now()-o,detail:e||""}),t(`✓ ${n} (${Date.now()-o} ms)`)}catch(s){if(a.push({name:n,ok:!1,ms:Date.now()-o,error:s.message}),t(`✗ ${n}: ${s.message}`),!e)throw s}},i=(t,e)=>{if(!t)throw new Error(e)};await r("daemon reachable",async()=>{const t=await s("GET","/version");return`dock ${t.Version}, API ${t.ApiVersion}`}),await r("pull alpine:3.20 and nginx:alpine",async()=>{await s("POST","/images/create?fromImage=alpine&tag=3.20"),await s("POST","/images/create?fromImage=nginx&tag=alpine")}),await r("run a container (Linux runtime boots on demand)",async()=>{const t=await o(`${PREFIX}-run`,{Image:"alpine:3.20",Cmd:["sh","-c","uname -srm; echo err >&2; exit 3"]});return i(3===t.code&&"err\n"===t.stderr&&/Linux/.test(t.stdout),JSON.stringify(t)),t.stdout.trim()}),await r("container network: own IP, DNS and internet",async()=>{let t;for(let e=0;e<2;e++)if(e&&await new Promise(t=>setTimeout(t,3e3)),t=await o(`${PREFIX}-net`,{Image:"alpine:3.20",Cmd:["sh","-c","nslookup dl-cdn.alpinelinux.org >/dev/null 2>&1 || { echo DNS_FAIL; exit 0; }; wget -q -O /dev/null http://dl-cdn.alpinelinux.org/alpine/ && echo NET_OK || echo NET_FAIL"]}),t.stdout.includes("NET_OK"))return;const e=t.stdout.includes("DNS_FAIL")?"containers could not resolve names (twice): check this machine's internet, VPN or DNS":t.stdout.includes("NET_FAIL")?"names resolve, but the internet is not reachable from containers (twice)":`${t.stdout} ${t.stderr}`.trim();i(!1,e)});let c=null;await r("published port: nginx on localhost:18765",async()=>{await request(n,"DELETE",`/containers/${PREFIX}-web?force=1`).catch(()=>{}),({Id:c}=await s("POST",`/containers/create?name=${PREFIX}-web`,{Image:"nginx:alpine",HostConfig:{PortBindings:{"80/tcp":[{HostPort:"18765"}]}}})),await s("POST",`/containers/${c}/start`);let t="";for(let e=0;e<60&&!t.includes("nginx");e++)t=await new Promise(t=>{http.get("http://127.0.0.1:18765/",e=>{let n="";e.on("data",t=>{n+=t}),e.on("end",()=>t(n))}).on("error",()=>t(""))}),t.includes("nginx")||await sleep(250);i(t.includes("Welcome to nginx"),"nginx not reachable on 127.0.0.1:18765")}),await r("exec into a running container",async()=>{i(c,"no web container");const{Id:t}=await s("POST",`/containers/${c}/exec`,{Cmd:["nginx","-v"],AttachStdout:!0,AttachStderr:!0}),e=demux(await new Promise((e,a)=>{const s=http.request({socketPath:n,method:"POST",path:`/exec/${t}/start`,headers:{"Content-Type":"application/json"}},t=>{const n=[];t.on("data",t=>n.push(t)),t.on("end",()=>e(Buffer.concat(n)))});s.on("error",a),s.end(JSON.stringify({Detach:!1,Tty:!1}))}));i(/nginx version/.test(e.stdout+e.stderr),JSON.stringify(e))}),c&&await request(n,"DELETE",`/containers/${c}?force=1`).catch(()=>{}),await r("named volume persists across containers",async()=>{await request(n,"DELETE",`/volumes/${PREFIX}-vol?force=1`).catch(()=>{}),await o(`${PREFIX}-vol-a`,{Image:"alpine:3.20",Cmd:["sh","-c","echo kept > /data/f"],HostConfig:{Binds:[`${PREFIX}-vol:/data`]}});const t=await o(`${PREFIX}-vol-b`,{Image:"alpine:3.20",Cmd:["cat","/data/f"],HostConfig:{Binds:[`${PREFIX}-vol:/data:ro`]}});await request(n,"DELETE",`/volumes/${PREFIX}-vol`).catch(()=>{}),i("kept\n"===t.stdout,JSON.stringify(t))}),await r("bind mount from your home folder",async()=>{const t=fs.mkdtempSync(path.join(os.homedir(),".tnc","selftest-bind-"));try{await o(`${PREFIX}-bind`,{Image:"alpine:3.20",Cmd:["sh","-c","echo from-container > /mnt/out.txt"],HostConfig:{Binds:[`${t}:/mnt`]}});const e=fs.readFileSync(path.join(t,"out.txt"),"utf8");i("from-container\n"===e,JSON.stringify(e))}finally{fs.rmSync(t,{recursive:!0,force:!0})}}),await r("build an image (multi-stage)",async()=>{const t=fs.mkdtempSync(path.join(os.tmpdir(),"dock-selftest-build-"));try{fs.writeFileSync(path.join(t,BUILD_FILE),'FROM alpine:3.20 AS b\nRUN echo built > /x\nFROM alpine:3.20\nCOPY --from=b /x /x\nCMD ["cat", "/x"]\n');const e=path.join(t,"ctx.tar"),a=spawnSync("tar",["-cf",e,"-C",t,BUILD_FILE]);i(0===a.status,`tar failed: ${a.stderr}`);const s=await new Promise((t,a)=>{const s=http.request({socketPath:n,method:"POST",path:`/build?t=${PREFIX}-img:1&q=1`,headers:{"Content-Type":"application/x-tar"}},e=>{let n="";e.on("data",t=>{n+=t}),e.on("end",()=>t(n))});s.on("error",a),s.end(fs.readFileSync(e))});i(!/"error"/.test(s),s.slice(0,300));const r=await o(`${PREFIX}-built`,{Image:`${PREFIX}-img:1`});i("built\n"===r.stdout,JSON.stringify(r)),await request(n,"DELETE",`/images/${PREFIX}-img:1?force=1`).catch(()=>{})}finally{fs.rmSync(t,{recursive:!0,force:!0})}}),await r("custom network: containers find each other by name",async()=>{await request(n,"DELETE",`/containers/${PREFIX}-srv?force=1`).catch(()=>{}),await request(n,"DELETE",`/networks/${PREFIX}-net`).catch(()=>{}),await s("POST","/networks/create",{Name:`${PREFIX}-net`});const{Id:t}=await s("POST",`/containers/create?name=${PREFIX}-srv`,{Image:"alpine:3.20",Cmd:["sleep","60"],HostConfig:{NetworkMode:`${PREFIX}-net`}});await s("POST",`/containers/${t}/start`);try{const t=await o(`${PREFIX}-cli`,{Image:"alpine:3.20",Cmd:["sh","-c",`ping -c1 -W2 ${PREFIX}-srv >/dev/null && echo FOUND`],HostConfig:{NetworkMode:`${PREFIX}-net`}});i(t.stdout.includes("FOUND"),JSON.stringify(t))}finally{await request(n,"DELETE",`/containers/${t}?force=1`).catch(()=>{}),await request(n,"DELETE",`/networks/${PREFIX}-net`).catch(()=>{})}}),await r("stop sends SIGTERM, then SIGKILL after the timeout",async()=>{await request(n,"DELETE",`/containers/${PREFIX}-stop?force=1`).catch(()=>{});const{Id:t}=await s("POST",`/containers/create?name=${PREFIX}-stop`,{Image:"alpine:3.20",Cmd:["sleep","1000"]});await s("POST",`/containers/${t}/start`),await s("POST",`/containers/${t}/stop?t=1`);const e=await s("GET",`/containers/${t}/json`);await s("DELETE",`/containers/${t}`),i("exited"===e.State.Status,e.State.Status)}),await r("nothing left behind",async()=>{const t=(await s("GET","/containers/json?all=1")).filter(t=>t.Names.some(t=>t.includes(PREFIX)));i(0===t.length,t.map(t=>t.Names[0]).join(", "))});let l=null;try{l=JSON.parse(fs.readFileSync(path.join(os.homedir(),".tnc","dock","runtime.json"),"utf8"))}catch(t){}const d={tool:"thinknsh dock selftest",when:(new Date).toISOString(),dock:DOCK_VERSION,runtime:l,machine:{platform:process.platform,release:os.release(),arch:process.arch,cpus:os.cpus().length,cpuModel:os.cpus()[0]?.model,memoryMB:Math.round(os.totalmem()/1048576),node:process.version},passed:a.filter(t=>t.ok).length,total:a.length,results:a},u=path.join(os.homedir(),".tnc","dock",`selftest-${process.platform}-${process.arch}-${d.when.replace(/[:.]/g,"-")}.json`);return fs.mkdirSync(path.dirname(u),{recursive:!0}),fs.writeFileSync(u,JSON.stringify(d,null,2)),{report:d,file:u}}module.exports={runSelftest};
|
|
@@ -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"),{spawnSync}=require("child_process"),{dockPaths}=require("../paths"),LABEL="com.thinkncollab.dock",APP_LABEL="com.thinkncollab.dock.app",UNIT="tnc-dock.service",DAEMON_MAIN=path.join(__dirname,"..","daemon-main.js"),launchAgentsDir=()=>process.env.TNC_LAUNCH_AGENTS_DIR||path.join(os.homedir(),"Library","LaunchAgents"),userService=()=>"linux"===process.platform&&require("../paths").rootlessMode(),systemdDir=()=>process.env.TNC_SYSTEMD_DIR||(userService()?path.join(process.env.XDG_CONFIG_HOME||path.join(os.homedir(),".config"),"systemd","user"):"/etc/systemd/system"),systemctl=n=>userService()?["--user",...n]:n,startupDir=()=>process.env.TNC_STARTUP_DIR||path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Startup"),dryRun=()=>"1"===process.env.TNC_AUTOSTART_DRY_RUN,xml=n=>String(n).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");function daemonEnvironment(n=process.env){const e={PATH:n.PATH||"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"};for(const t of["TNC_DOCK_HOME","TNC_DOCK_BUILDKIT","TNC_DOCK_INSECURE_REGISTRIES","TNC_SHIM","TNC_BUSYBOX","TNC_DOCK_SOCKET"])n[t]&&(e[t]=n[t]);return e}function plist({label:n,program:e,logFile:t,env:s,keepAlive:r=!0}){const
|
|
7
|
+
"use strict";const fs=require("fs"),os=require("os"),path=require("path"),{spawnSync}=require("child_process"),{dockPaths}=require("../paths"),LABEL="com.thinkncollab.dock",APP_LABEL="com.thinkncollab.dock.app",UNIT="tnc-dock.service",DAEMON_MAIN=path.join(__dirname,"..","daemon-main.js"),launchAgentsDir=()=>process.env.TNC_LAUNCH_AGENTS_DIR||path.join(os.homedir(),"Library","LaunchAgents"),userService=()=>"linux"===process.platform&&require("../paths").rootlessMode(),systemdDir=()=>process.env.TNC_SYSTEMD_DIR||(userService()?path.join(process.env.XDG_CONFIG_HOME||path.join(os.homedir(),".config"),"systemd","user"):"/etc/systemd/system"),systemctl=n=>userService()?["--user",...n]:n,startupDir=()=>process.env.TNC_STARTUP_DIR||path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Startup"),dryRun=()=>"1"===process.env.TNC_AUTOSTART_DRY_RUN,xml=n=>String(n).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");function daemonEnvironment(n=process.env){const e={PATH:n.PATH||"/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"};for(const t of["TNC_DOCK_HOME","TNC_DOCK_BUILDKIT","TNC_DOCK_INSECURE_REGISTRIES","TNC_SHIM","TNC_BUSYBOX","TNC_DOCK_SOCKET"])n[t]&&(e[t]=n[t]);return e}function plist({label:n,program:e,logFile:t,env:s,keepAlive:r=!0}){const a=e.map(n=>` <string>${xml(n)}</string>`).join("\n"),o=Object.entries(s||{}).map(([n,e])=>` <key>${xml(n)}</key><string>${xml(e)}</string>`).join("\n");return`<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${xml(n)}</string>\n <key>ProgramArguments</key>\n <array>\n${a}\n </array>\n <key>RunAtLoad</key><true/>\n${r?" <key>KeepAlive</key>\n <dict><key>SuccessfulExit</key><false/></dict>\n <key>ThrottleInterval</key><integer>10</integer>\n":""} <key>ProcessType</key><string>${r?"Background":"Interactive"}</string>\n${t?` <key>StandardOutPath</key><string>${xml(t)}</string>\n <key>StandardErrorPath</key><string>${xml(t)}</string>\n`:""}${o?` <key>EnvironmentVariables</key>\n <dict>\n${o}\n </dict>\n`:""}</dict>\n</plist>\n`}function systemdUnit({node:n,main:e,env:t,user:s=!1}){return`[Unit]\nDescription=ThinkNCollab dock container engine\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart="${n}" "${e}"\nRestart=on-failure\nRestartSec=5\nKillMode=process\nTimeoutStopSec=30\n${s?"Delegate=yes\n":""}${Object.entries(t).map(([n,e])=>`Environment="${n}=${String(e).replace(/"/g,'\\"')}"`).join("\n")}\n\n[Install]\nWantedBy=${s?"default.target":"multi-user.target"}\n`}function windowsLauncher({node:n,main:e,logFile:t}){const s=n=>n.replace(/"/g,'""');return`' ThinkNCollab dock: starts the engine at login (written by thinknsh dock autostart on)\r\nSet sh = CreateObject("WScript.Shell")\r\nsh.Run "cmd /c """"${s(n)}"" ""${s(e)}"" >> ""${s(t)}"" 2>&1""", 0, False\r\n`}function run(n,e){return dryRun()?{status:0,stdout:"",stderr:""}:spawnSync(n,e,{encoding:"utf8"})}function appBundle(){for(const n of[path.join(os.homedir(),"Applications"),"/Applications"]){const e=path.join(n,"Thinknsh.app");if(fs.existsSync(path.join(e,"Contents","MacOS")))return e}return null}function files(n=process.platform){return"darwin"===n?{daemon:path.join(launchAgentsDir(),`${LABEL}.plist`),app:path.join(launchAgentsDir(),`${APP_LABEL}.plist`)}:"linux"===n?{daemon:path.join(systemdDir(),UNIT)}:"win32"===n?{daemon:path.join(startupDir(),"tnc-dock.vbs"),app:path.join(startupDir(),"Thinknsh-tray.vbs")}:{}}function reloadLaunchAgent(n,e){const t=`gui/${n}/${LABEL}`;run("launchctl",["bootout",t]);for(let n=0;n<50&&0===run("launchctl",["print",t]).status;n++)sleepMs(100);let s;for(let t=0;t<5;t++){if(s=run("launchctl",["bootstrap",`gui/${n}`,e]),0===s.status||/already/i.test(s.stderr||""))return;sleepMs(500)}throw new Error(`launchctl bootstrap: ${(s.stderr||"").trim()}`)}function sleepMs(n){Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,n)}function enableApp({platform:n=process.platform}={}){const e=files(n);if("darwin"===n){const n=appBundle();return n?(fs.mkdirSync(path.dirname(e.app),{recursive:!0}),fs.writeFileSync(e.app,plist({label:APP_LABEL,program:["/usr/bin/open","-a",n],keepAlive:!1})),[`menu-bar app: opens at login (${n})`]):[]}if("win32"===n){const n=path.join(process.env.TNC_APP_DIR||path.join(process.env.LOCALAPPDATA||path.join(os.homedir(),"AppData","Local"),"Thinknsh"),"Thinknsh.vbs");return fs.existsSync(n)?(fs.mkdirSync(path.dirname(e.app),{recursive:!0}),fs.copyFileSync(n,e.app),[`tray app: opens at login (${e.app})`]):[]}return[]}function enable({platform:n=process.platform,node:e=process.execPath,app:t=!0}={}){const s=dockPaths(),r=daemonEnvironment(),a=files(n),o=[];if("darwin"===n){fs.mkdirSync(path.dirname(a.daemon),{recursive:!0}),fs.writeFileSync(a.daemon,plist({label:LABEL,program:[e,DAEMON_MAIN],logFile:s.logFile,env:r})),reloadLaunchAgent(process.getuid(),a.daemon),o.push(`daemon: starts at login (${a.daemon})`);const n=appBundle();t&&n?(fs.writeFileSync(a.app,plist({label:APP_LABEL,program:["/usr/bin/open","-a",n],keepAlive:!1})),o.push(`menu-bar app: opens at login (${n})`)):fs.existsSync(a.app)&&fs.rmSync(a.app)}else if("linux"===n){const n=userService();if(process.getuid&&0!==process.getuid()&&!n&&!process.env.TNC_SYSTEMD_DIR)throw new Error("the system engine runs as root: sudo thinknsh dock autostart on");fs.mkdirSync(path.dirname(a.daemon),{recursive:!0}),fs.writeFileSync(a.daemon,systemdUnit({node:e,main:DAEMON_MAIN,env:r,user:n}));const t=run("systemctl",systemctl(["daemon-reload"]));if(0!==t.status)throw new Error(`systemctl daemon-reload: ${(t.stderr||"").trim()}`);const s=run("systemctl",systemctl(["enable",UNIT]));if(0!==s.status)throw new Error(`systemctl enable: ${(s.stderr||"").trim()}`);o.push(n?`daemon: systemd user service ${UNIT}, starts when you log in (${a.daemon}); to start at boot without a login: loginctl enable-linger ${os.userInfo().username}`:`daemon: systemd unit ${UNIT}, starts at boot (${a.daemon})`)}else{if("win32"!==n)throw new Error(`auto-start is not available on ${n}`);{fs.mkdirSync(path.dirname(a.daemon),{recursive:!0}),fs.writeFileSync(a.daemon,windowsLauncher({node:e,main:DAEMON_MAIN,logFile:s.logFile})),o.push(`daemon: starts at login (${a.daemon})`);const n=path.join(process.env.TNC_APP_DIR||path.join(process.env.LOCALAPPDATA||path.join(os.homedir(),"AppData","Local"),"Thinknsh"),"Thinknsh.vbs");t&&fs.existsSync(n)?(fs.copyFileSync(n,a.app),o.push(`tray app: opens at login (${a.app})`)):fs.existsSync(a.app)&&fs.rmSync(a.app)}}return o}function disable({platform:n=process.platform}={}){const e=files(n),t=[];if("darwin"===n)for(const n of[e.daemon,e.app])fs.existsSync(n)&&(fs.rmSync(n),t.push(`removed ${n}`));else if("linux"===n)fs.existsSync(e.daemon)&&(run("systemctl",systemctl(["disable",UNIT])),fs.rmSync(e.daemon),run("systemctl",systemctl(["daemon-reload"])),t.push(`removed ${e.daemon}`));else if("win32"===n)for(const n of[e.daemon,e.app])fs.existsSync(n)&&(fs.rmSync(n),t.push(`removed ${n}`));return t.length||t.push("auto-start was not on"),t}function status({platform:n=process.platform}={}){const e=files(n);return{enabled:!(!e.daemon||!fs.existsSync(e.daemon)),app:!(!e.app||!fs.existsSync(e.app)),file:e.daemon||null}}function kickstart({platform:n=process.platform}={}){const e=status({platform:n});if(!e.enabled||dryRun())return!1;try{if(!fs.readFileSync(e.file,"utf8").includes("daemon-main.js"))return!1}catch(n){return!1}return process.env.XPC_SERVICE_NAME!==LABEL&&!process.env.INVOCATION_ID&&("darwin"===n?0===run("launchctl",["kickstart",`gui/${process.getuid()}/${LABEL}`]).status:"linux"===n&&0===run("systemctl",systemctl(["start",UNIT])).status)}module.exports={enable,enableApp,disable,status,kickstart,plist,systemdUnit,windowsLauncher,daemonEnvironment,appBundle,LABEL,APP_LABEL,UNIT};
|
|
@@ -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"),crypto=require("crypto"),{spawnSync}=require("child_process"),TNC_DIR=path.join(os.homedir(),".tnc"),BUNDLE_VERSION="0.2.3",DEFAULT_BASE_URL=process.env.TNC_DOCK_BUNDLE_URL||"https://thinkncollab.com/downloads/dock/0.2.3",WSL_DISTRO="tnc-dock",linuxArch=(n=process.arch)=>({arm64:"aarch64",x64:"x86_64"}[n]);function setupError(n){const t=new Error(n);return t.setup=!0,t}function sha256File(n){const t=crypto.createHash("sha256"),
|
|
7
|
+
"use strict";const fs=require("fs"),os=require("os"),path=require("path"),crypto=require("crypto"),{spawnSync}=require("child_process"),TNC_DIR=path.join(os.homedir(),".tnc"),BUNDLE_VERSION="0.2.3",DEFAULT_BASE_URL=process.env.TNC_DOCK_BUNDLE_URL||"https://thinkncollab.com/downloads/dock/0.2.3",WSL_DISTRO="tnc-dock",linuxArch=(n=process.arch)=>({arm64:"aarch64",x64:"x86_64"}[n]);function setupError(n){const t=new Error(n);return t.setup=!0,t}function sha256File(n){const t=crypto.createHash("sha256"),e=fs.openSync(n,"r"),s=Buffer.alloc(1<<20);let r;for(;(r=fs.readSync(e,s,0,s.length,null))>0;)t.update(s.subarray(0,r));return fs.closeSync(e),t.digest("hex")}const OPTIONAL_FILES=/^tnc-busybox-linux-|^tnc-tray-/;function wantedFiles(n=process.platform,t=process.arch){const e=linuxArch(t);if(!e)throw setupError(`unsupported CPU architecture ${t}`);if("darwin"===n)return[`tnc-dock-vm-${e}.tar.gz`,"tnc-vz-darwin-universal","tnc-tray-darwin-universal.tar.gz"];if("win32"===n)return[`tnc-dock-wsl-${e}.tar.gz`];if("linux"===n)return[`tnc-shim-linux-${e}`,`crun-linux-${e}`,`tnc-busybox-linux-${e}`];throw setupError(`unsupported platform ${n}`)}async function download(n,t){const e=await fetch(n,{redirect:"follow"});if(!e.ok)throw setupError(`download failed: ${n} (HTTP ${e.status})`);fs.mkdirSync(path.dirname(t),{recursive:!0}),fs.writeFileSync(`${t}.part`,Buffer.from(await e.arrayBuffer())),fs.renameSync(`${t}.part`,t)}async function fetchBundle({from:n,baseUrl:t=DEFAULT_BASE_URL,log:e}){let s,r;if(n)r=path.resolve(n),s=JSON.parse(fs.readFileSync(path.join(r,"manifest.json"),"utf8"));else{let n=await fetch(`${t}/manifest.json`,{redirect:"follow"});if(!n.ok){const e="https://thinkncollab.com/downloads/dock",r=await fetch(`${e}/manifest.json`,{redirect:"follow"});r.ok&&(s=await r.json(),t=`${e}/${s.version}`,n=r)}if(!n.ok)throw setupError(`cannot fetch bundle manifest from ${t} (HTTP ${n.status})`);s||(s=await n.json()),r=path.join(TNC_DIR,"cache","bundles",s.version)}const i={};for(const o of wantedFiles()){const a=s.files[o];if(!a&&OPTIONAL_FILES.test(o)){e(`bundle ${s.version} has no ${o}; ${/tray/.test(o)?"the menu-bar app":"cp"} needs a newer bundle`);continue}if(!a)throw setupError(`bundle ${s.version} has no ${o}`);const c=path.join(r,o);if(n||fs.existsSync(c)&&sha256File(c)===a.sha256||(e(`downloading ${o} (${(a.size/1024/1024).toFixed(1)} MB)`),await download(`${t}/${o}`,c)),!fs.existsSync(c))throw setupError(`missing ${c}`);if(sha256File(c)!==a.sha256)throw setupError(`sha256 mismatch for ${o}; refusing to install`);i[o]=c}return{manifest:s,files:i}}function run(n,t,e={}){const s=spawnSync(n,t,{encoding:"utf8",...e});return{code:s.status,out:`${s.stdout||""}${s.stderr||""}`.replace(/\0/g,"")}}function installDarwin(n,t){const e=linuxArch(),s=path.join(TNC_DIR,"kernel"),r=path.join(TNC_DIR,"bin");fs.mkdirSync(s,{recursive:!0}),fs.mkdirSync(r,{recursive:!0}),run(path.join(r,"tnc-vz"),["stop"]);for(const n of["vmlinuz","initramfs.img"]){const t=path.join(s,n);fs.existsSync(t)&&!fs.existsSync(`${t}.pre-bundle`)&&fs.copyFileSync(t,`${t}.pre-bundle`)}const i=run("tar",["-xzf",n[`tnc-dock-vm-${e}.tar.gz`],"-C",s]);if(0!==i.code)throw setupError(`cannot unpack VM image: ${i.out}`);const o=path.join(r,"tnc-vz");fs.existsSync(o)&&!fs.existsSync(`${o}.pre-bundle`)&&fs.copyFileSync(o,`${o}.pre-bundle`),fs.copyFileSync(n["tnc-vz-darwin-universal"],`${o}.new`),fs.chmodSync(`${o}.new`,493),fs.renameSync(`${o}.new`,o),t(`MicroVM image (${e}) and tnc-vz installed`);const a=n["tnc-tray-darwin-universal.tar.gz"];if(a){fs.rmSync(path.join(r,"Thinknsh.app"),{recursive:!0,force:!0});const n=run("tar",["-xzf",a,"-C",r]);if(0!==n.code)throw setupError(`cannot unpack the menu-bar app: ${n.out}`);t("menu-bar app unpacked (install it: thinknsh dock app install)")}}function wslDistros(){const n=run("wsl.exe",["--list","--quiet"]);return 0===n.code?n.out.split(/\r?\n/).map(n=>n.trim()).filter(Boolean):null}function sanitizeWslTar(n){const t=require("zlib");let e;try{e=t.gunzipSync(fs.readFileSync(n))}catch(t){return{path:n,cleanup:()=>{}}}let s=0;const r=[];let i=!1;for(;s<e.length;){const n=e.subarray(s,s+512);if(n.length<512||n.every(n=>0===n)){r.push(e.subarray(s));break}const t=n.subarray(124,136).toString("latin1").replace(/\0.*$/,"").trim(),o=parseInt(t,8)||0,a=String.fromCharCode(n[156]),c=512+512*Math.ceil(o/512);"x"===a||"g"===a?i=!0:r.push(e.subarray(s,s+c)),s+=c}if(!i)return{path:n,cleanup:()=>{}};const o=path.join(os.tmpdir(),`tnc-dock-wsl-clean-${Date.now()}.tar.gz`);return fs.writeFileSync(o,t.gzipSync(Buffer.concat(r))),{path:o,cleanup:()=>{try{fs.unlinkSync(o)}catch(n){}}}}function wslMissingMessage(n=""){return["Linux containers on Windows run in WSL2, and WSL2 is not ready on this PC yet.","","Set it up once (about 5 minutes):",' 1. Open PowerShell as administrator: Start, type "PowerShell", right-click it, "Run as administrator"'," 2. Run: wsl --install --no-distribution"," 3. Restart Windows"," 4. Run: thinknsh dock setup",.../BIOS|UEFI|virtuali[sz]ation (is )?(not enabled|disabled)|enable virtuali[sz]ation/i.test(n)?["","This PC also has virtualization switched off. Turn it on in the BIOS/UEFI settings",'(often called "Intel VT-x", "AMD-V" or "SVM"), then do the steps above.']:[],"","Needs Windows 10 version 2004 or newer, or Windows 11."].join("\n")}function installWindows(n,t,{force:e=!1}={}){const s=linuxArch(),r=run("wsl.exe",["--status"]);if(0!==r.code)throw setupError(wslMissingMessage(r.out));const i=wslDistros()||[],o=n[`tnc-dock-wsl-${s}.tar.gz`],{path:a,cleanup:c}=sanitizeWslTar(o);try{if(i.includes("tnc-dock")&&!e){const n=run("wsl.exe",["-d","tnc-dock","-u","root","--","tar","-xzf","-","-C","/","./usr/bin/tnc-shim","./usr/bin/crun","./usr/local/bin/tnc-wsl-init","./etc/wsl.conf","./bin/busybox.static"],{input:fs.readFileSync(a)});if(0!==n.code)throw setupError(`cannot upgrade WSL distro: ${n.out}`);return run("wsl.exe",["--terminate","tnc-dock"]),void t("WSL distro tnc-dock upgraded in place (layers and volumes kept)")}i.includes("tnc-dock")&&run("wsl.exe",["--unregister","tnc-dock"]);const n=path.join(TNC_DIR,"wsl","tnc-dock");fs.mkdirSync(n,{recursive:!0});const s=run("wsl.exe",["--import","tnc-dock",n,a,"--version","2"]);if(0!==s.code)throw setupError(`wsl --import failed: ${s.out.trim()}`);t(`WSL2 distro tnc-dock created in ${n}`)}finally{c()}}function installLinux(n,t){const e=linuxArch(),s=path.join(TNC_DIR,"bin");fs.mkdirSync(s,{recursive:!0});for(const t of[`tnc-shim-linux-${e}`,`crun-linux-${e}`,`tnc-busybox-linux-${e}`]){const e=t;n[t]&&(fs.copyFileSync(n[t],path.join(s,`${e}.new`)),fs.chmodSync(path.join(s,`${e}.new`),493),fs.renameSync(path.join(s,`${e}.new`),path.join(s,e)))}if(t(`tnc-shim, crun and busybox installed in ${s}`),0!==process.getuid?.()){const n=require("../linux/host").rootlessProblems();t(n.length?`rootless mode needs:\n - ${n.join("\n - ")}`:"rootless mode is ready (dock runs as you, no sudo)")}}async function prefetchBuildKit(n){if("0"===process.env.TNC_DOCK_BUILDKIT)return;const{installBuildKit:t,buildKitDir:e,buildKitInstalled:s,VERSION:r}=require("../build/buildkit"),i={arm64:"arm64",x64:"amd64"}[process.arch];if(!i)return;const o=e(process.env.TNC_DOCK_HOME||path.join(TNC_DIR,"dock"),i);if(s(o))return n(`BuildKit ${r} already installed`);try{n(`downloading BuildKit ${r} (linux/${i})`),await t(o,i),n(`BuildKit ${r} installed in ${o}`)}catch(t){n(`warning: ${t.message}; it will be downloaded on the first BuildKit build`)}}async function setup({from:n,baseUrl:t,force:e=!1,log:s=()=>{}}={}){const{manifest:r,files:i}=await fetchBundle({from:n,baseUrl:t,log:s});return"darwin"===process.platform?installDarwin(i,s):"win32"===process.platform?installWindows(i,s,{force:e}):installLinux(i,s),await prefetchBuildKit(s),fs.mkdirSync(path.join(TNC_DIR,"dock"),{recursive:!0}),fs.writeFileSync(path.join(TNC_DIR,"dock","runtime.json"),JSON.stringify({version:r.version,platform:process.platform,arch:process.arch,installedAt:(new Date).toISOString()},null,2)),r.version}module.exports={wslMissingMessage,setup,prefetchBuildKit,wantedFiles,fetchBundle,linuxArch,WSL_DISTRO:"tnc-dock",sha256File,BUNDLE_VERSION:"0.2.3",sanitizeWslTar};
|
|
@@ -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"),{spawnSync,spawn}=require("child_process"),ROOT=path.join(__dirname,"..","..",".."),CLI=path.join(ROOT,"bin","thinknsh.js"),SOURCE=path.join(ROOT,"native","tnc-tray"),macTarget=()=>process.env.TNC_APP_DIR||path.join(os.homedir(),"Applications","Thinknsh.app"),winDir=()=>process.env.TNC_APP_DIR||path.join(process.env.LOCALAPPDATA||path.join(os.homedir(),"AppData","Local"),"Thinknsh"),winMenuEntry=()=>path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Thinknsh.vbs");function agentConfig(){return JSON.stringify({node:process.execPath,cli:CLI},null,2)+"\n"}function findMacApp(n){const t=path.join(os.homedir(),".tnc","bin","Thinknsh.app");if(fs.existsSync(path.join(t,"Contents","MacOS","thinknsh-tray")))return t;const
|
|
7
|
+
"use strict";const fs=require("fs"),os=require("os"),path=require("path"),{spawnSync,spawn}=require("child_process"),ROOT=path.join(__dirname,"..","..",".."),CLI=path.join(ROOT,"bin","thinknsh.js"),SOURCE=path.join(ROOT,"native","tnc-tray"),macTarget=()=>process.env.TNC_APP_DIR||path.join(os.homedir(),"Applications","Thinknsh.app"),winDir=()=>process.env.TNC_APP_DIR||path.join(process.env.LOCALAPPDATA||path.join(os.homedir(),"AppData","Local"),"Thinknsh"),linuxData=()=>process.env.XDG_DATA_HOME||path.join(os.homedir(),".local","share"),linuxDesktop=()=>path.join(linuxData(),"applications","thinknsh.desktop"),linuxIcon=()=>path.join(linuxData(),"icons","hicolor","256x256","apps","thinknsh.png"),winMenuEntry=()=>path.join(process.env.APPDATA||path.join(os.homedir(),"AppData","Roaming"),"Microsoft","Windows","Start Menu","Programs","Thinknsh.vbs");function linuxLauncher(n=process.execPath,t=CLI){const e=n=>`"${String(n).replace(/(["\\`$])/g,"\\$1")}"`;return["[Desktop Entry]","Type=Application","Name=Thinknsh","GenericName=Container dashboard","Comment=ThinkNCollab dock: containers, images, logs and settings",`Exec=${e(n)} ${e(t)} dock ui`,"Icon=thinknsh","Terminal=false","Categories=Development;","Keywords=ThinkNCollab;dock;containers;",""].join("\n")}function agentConfig(){return JSON.stringify({node:process.execPath,cli:CLI},null,2)+"\n"}function findMacApp(n){const t=path.join(os.homedir(),".tnc","bin","Thinknsh.app");if(fs.existsSync(path.join(t,"Contents","MacOS","thinknsh-tray")))return t;const e=path.join(SOURCE,"build-macos.sh");if(fs.existsSync(e)&&0===spawnSync("xcrun",["--find","swiftc"],{stdio:"ignore"}).status){const t=fs.mkdtempSync(path.join(os.tmpdir(),"tnc-tray-"));n?.("building the menu-bar app from source (swiftc)");const s=spawnSync("bash",[e,t],{encoding:"utf8"});if(0!==s.status)throw new Error(`building the app failed: ${(s.stderr||s.stdout).trim().split("\n").slice(-3).join(" ")}`);return path.join(t,"Thinknsh.app")}throw new Error("the menu-bar app is not in this runtime: run thinknsh dock setup again, or install Xcode command line tools to build it")}function winLauncher(n){const t=n=>n.replace(/"/g,'""');return`' thinknsh dock tray (written by thinknsh dock app install)\r\nSet sh = CreateObject("WScript.Shell")\r\nsh.Run "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ""${t(path.join(n,"tray.ps1"))}"" -Node ""${t(process.execPath)}"" -Cli ""${t(CLI)}""", 0, False\r\n`}function removeEarlierApp(n){const t=path.join(os.homedir(),"Applications","TNC Dock.app"),e=path.join(os.homedir(),"Library","LaunchAgents","com.thinkncollab.dock.menubar.plist");fs.existsSync(e)&&(spawnSync("launchctl",["bootout",`gui/${process.getuid()}/com.thinkncollab.dock.menubar`],{stdio:"ignore"}),fs.rmSync(e,{force:!0})),fs.existsSync(t)&&(spawnSync("pkill",["-f",`${t}/Contents/MacOS/`],{stdio:"ignore"}),fs.rmSync(t,{recursive:!0,force:!0}),n.push(`removed the earlier menu bar app (${t})`))}function install({log:n}={}){const t=[];if("darwin"===process.platform){removeEarlierApp(t);const e=findMacApp(n),s=macTarget();fs.mkdirSync(path.dirname(s),{recursive:!0}),spawnSync("pkill",["-f",`${s}/Contents/MacOS/thinknsh-tray`],{stdio:"ignore"}),fs.rmSync(s,{recursive:!0,force:!0});const i=spawnSync("ditto",[e,s],{encoding:"utf8"});if(0!==i.status)throw new Error(`copying the app: ${i.stderr.trim()}`);fs.writeFileSync(path.join(s,"Contents","Resources","agent.json"),agentConfig()),t.push(`menu-bar app: ${s}`)}else{if("win32"!==process.platform){if("linux"===process.platform)return fs.mkdirSync(path.dirname(linuxIcon()),{recursive:!0}),fs.copyFileSync(path.join(ROOT,"native","brand","tnc-256.png"),linuxIcon()),fs.mkdirSync(path.dirname(linuxDesktop()),{recursive:!0}),fs.writeFileSync(linuxDesktop(),linuxLauncher(),{mode:493}),t.push(`applications menu: Thinknsh opens the dashboard (${linuxDesktop()})`),t;throw new Error(`there is no desktop app for ${process.platform}; the dashboard is thinknsh dock ui`)}{const n=winDir();fs.mkdirSync(n,{recursive:!0}),fs.copyFileSync(path.join(SOURCE,"tray.ps1"),path.join(n,"tray.ps1"));const e=path.join(ROOT,"native","brand","tnc.ico");fs.existsSync(e)&&fs.copyFileSync(e,path.join(n,"tnc.ico")),fs.writeFileSync(path.join(n,"Thinknsh.vbs"),winLauncher(n)),fs.mkdirSync(path.dirname(winMenuEntry()),{recursive:!0}),fs.copyFileSync(path.join(n,"Thinknsh.vbs"),winMenuEntry()),t.push(`tray app: ${n} (Start menu: Thinknsh)`)}}const e=require("../setup/autostart");return e.status().enabled&&t.push(...e.enableApp()),open(),t}function installedPath(){if("darwin"===process.platform)return fs.existsSync(macTarget())?macTarget():require("../setup/autostart").appBundle();if("win32"===process.platform){const n=path.join(winDir(),"Thinknsh.vbs");return fs.existsSync(n)?n:null}return"linux"===process.platform&&fs.existsSync(linuxDesktop())?linuxDesktop():null}function open(){const n=installedPath();if(!n)throw new Error("the tray app is not installed: thinknsh dock app install");"darwin"===process.platform?spawn("open",[n],{stdio:"ignore",detached:!0}).unref():"linux"===process.platform?spawn(process.execPath,[CLI,"dock","ui"],{stdio:"ignore",detached:!0}).unref():spawn("wscript.exe",[n],{stdio:"ignore",detached:!0}).unref()}function uninstall(){const n=[];if("darwin"===process.platform){const t=macTarget();spawnSync("pkill",["-f",`${t}/Contents/MacOS/thinknsh-tray`],{stdio:"ignore"}),fs.existsSync(t)&&(fs.rmSync(t,{recursive:!0,force:!0}),n.push(`removed ${t}`))}else if("linux"===process.platform)for(const t of[linuxDesktop(),linuxIcon()])fs.existsSync(t)&&(fs.rmSync(t,{force:!0}),n.push(`removed ${t}`));else if("win32"===process.platform)for(const t of[winDir(),winMenuEntry()])fs.existsSync(t)&&(fs.rmSync(t,{recursive:!0,force:!0}),n.push(`removed ${t}`));const t=require("../setup/autostart");return t.status().app&&(fs.rmSync(path.join(path.dirname(t.status().file),`${t.APP_LABEL}.plist`),{force:!0}),n.push("removed the login item of the app")),n.length||n.push("the tray app was not installed"),n}module.exports={install,uninstall,open,installedPath,winLauncher,linuxLauncher};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkncollab-shell",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.5",
|
|
4
4
|
"description": "Lightweight MicroVM Sandbox & Zero-Daemon Dev Runner: Fast MicroVM Containers, Native Microservices, Live Database Snapshots, and End-to-End Encrypted State Sync",
|
|
5
5
|
"main": "dist/src/core/shell.js",
|
|
6
6
|
"bin": {
|