thinkncollab-shell 2.0.0-beta.2 → 2.0.0-beta.4

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 (52) hide show
  1. package/Readme.md +43 -10
  2. package/dist/native/brand/favicon-64.png +0 -0
  3. package/dist/native/brand/tnc.ico +0 -0
  4. package/dist/native/tnc-tray/build-macos.sh +59 -0
  5. package/dist/native/tnc-tray/main.swift +204 -0
  6. package/dist/native/tnc-tray/tray.ps1 +137 -0
  7. package/dist/src/commands/dock/containers.js +1 -1
  8. package/dist/src/commands/dock/dock.js +1 -1
  9. package/dist/src/dock/api/routes.js +1 -1
  10. package/dist/src/dock/api/server.js +1 -1
  11. package/dist/src/dock/api/websocket.js +7 -0
  12. package/dist/src/dock/api/wire.js +1 -1
  13. package/dist/src/dock/build/builder.js +1 -1
  14. package/dist/src/dock/build/buildkit.js +1 -1
  15. package/dist/src/dock/build/cache.js +7 -0
  16. package/dist/src/dock/build/sshagent.js +7 -0
  17. package/dist/src/dock/cli/api.js +1 -1
  18. package/dist/src/dock/cli/commands.js +1 -1
  19. package/dist/src/dock/cli/compose.js +1 -1
  20. package/dist/src/dock/cli/trust.js +7 -0
  21. package/dist/src/dock/compat/cases.js +1 -1
  22. package/dist/src/dock/config.js +1 -1
  23. package/dist/src/dock/daemon-main.js +1 -1
  24. package/dist/src/dock/daemon.js +1 -1
  25. package/dist/src/dock/engine.js +1 -1
  26. package/dist/src/dock/image/auth.js +1 -1
  27. package/dist/src/dock/image/credhelpers.js +7 -0
  28. package/dist/src/dock/image/keychain.js +7 -0
  29. package/dist/src/dock/image/registry.js +1 -1
  30. package/dist/src/dock/image/scan.js +7 -0
  31. package/dist/src/dock/image/sign.js +7 -0
  32. package/dist/src/dock/linux/host.js +1 -1
  33. package/dist/src/dock/paths.js +1 -1
  34. package/dist/src/dock/runtime/cdi.js +7 -0
  35. package/dist/src/dock/runtime/fsops.js +1 -1
  36. package/dist/src/dock/runtime/network.js +1 -1
  37. package/dist/src/dock/runtime/overlaydns.js +7 -0
  38. package/dist/src/dock/runtime/ports.js +1 -1
  39. package/dist/src/dock/runtime/rootfs.js +1 -1
  40. package/dist/src/dock/runtime/spec.js +1 -1
  41. package/dist/src/dock/runtime/stats.js +1 -1
  42. package/dist/src/dock/runtime/userns.js +7 -0
  43. package/dist/src/dock/runtime/volumes.js +1 -1
  44. package/dist/src/dock/setup/autostart.js +1 -1
  45. package/dist/src/dock/setup/diagnose.js +7 -0
  46. package/dist/src/dock/setup/index.js +1 -1
  47. package/dist/src/dock/tray/agent.js +7 -0
  48. package/dist/src/dock/tray/install.js +7 -0
  49. package/dist/src/dock/ui/page.js +232 -16
  50. package/dist/src/dock/ui/server.js +1 -1
  51. package/package.json +1 -1
  52. package/dist/src/dock/setup/app.js +0 -7
package/Readme.md CHANGED
@@ -1,3 +1,5 @@
1
+ <p align="center"><img src="native/brand/thinkncollab-logo.png" alt="ThinkNCollab" width="320"></p>
2
+
1
3
  # ThinkNCollab Shell V2 (`thinknsh`)
2
4
 
3
5
  > **The Native-First Collaborative Developer OS: Lightweight MicroVM Sandbox & Zero-Daemon Dev Runner with Live Database Snapshots, P2P Terminal Multiplexing, and OCI Compatibility.**
@@ -43,17 +45,17 @@ Every command has its own help, for example `thinknsh run --help`. The full list
43
45
  | Area | Commands |
44
46
  | :--- | :--- |
45
47
  | Containers | `run`, `create`, `start`, `stop`, `restart`, `kill`, `rm`, `ps`, `logs`, `exec`, `wait`, `inspect` |
46
- | Images | `images`, `pull`, `push`, `build`, `tag`, `rmi`, `image prune` |
48
+ | Images | `images`, `pull`, `push`, `build` (`--platform a,b --push`), `tag`, `rmi`, `image prune`, `search`, `manifest create / inspect / push / rm` |
47
49
  | Registries | `login [-u USER] [--password-stdin] [REGISTRY]`, `logout [REGISTRY]` |
48
50
  | Agent workspaces | `workspace up / open / apps / ls / shell / exec / ssh-config / stop / rm` |
49
51
  | Dev services | `service start postgres\|redis\|mysql [-p PORT]`, `service stop / restart / status / logs / rm [-v]` |
50
52
  | Local registry | `registry start [PORT] / stop / status / push IMAGE / pull IMAGE` |
51
- | Apps | `compose up / down / ps / logs / exec / build / pull / start / stop / restart / rm / config` |
53
+ | Apps | `compose up / down / ps / logs / exec / run / create / build / pull / push / start / stop / restart / kill / pause / unpause / rm / top / port / images / cp / scale / wait / events / ls / watch / config` |
52
54
  | Storage and networks | `volume ls / create / rm / inspect / prune`, `network ls / create / rm / connect / disconnect / prune` |
53
55
  | Files and state | `cp`, `diff`, `export`, `commit`, `save`, `load`, `import`, `history`, `stats`, `top`, `pause` / `unpause`, `rename`, `update`, `port` |
54
- | Engine | `system info / df / prune`, `dock setup / start / stop / status / selftest / check / config`, `dock autostart [--off]` (macOS: start at login) |
56
+ | Engine | `system info / df / prune`, `events`, `dock setup / start / stop / status / selftest / check / config / autostart / diagnose / reset` |
55
57
  | Other tools and engines | `dock use [--system] [--off]` (engine CLI, compose, buildx, VS Code Dev Containers, testcontainers, the standard socket path), `dock env`, `dock migrate` |
56
- | Dashboard | `dock ui` — containers, logs, live stats, images, volumes, networks in the browser; `dock app` — TNC Dock in the macOS menu bar |
58
+ | Desktop | `dock app install` (menu-bar app on macOS, tray icon on Windows), `dock ui` — dashboard in the browser: containers by compose app, logs with a filter, live stats, a terminal, images, volumes, networks, settings, troubleshooting |
57
59
  | CI on this machine | `ci run [WORKFLOW] [--job ID]`, `ci list` — `.tnc/workflows` jobs, matrix legs for amd64 and arm64, services, JavaScript / composite / container actions, artifacts, each in a fresh container |
58
60
 
59
61
  ### One container for all your coding agents: `thinknsh workspace`
@@ -107,21 +109,44 @@ New here? Read the step-by-step guide: [`docs/HOW-TO-USE.md`](docs/HOW-TO-USE.md
107
109
  | :--- | :--- |
108
110
  | Run, attach, interactive terminals, exec, logs, exit codes, signals | ✅ |
109
111
  | Pull from public and private OCI registries (token and basic auth) | ✅ |
110
- | Build: Containerfile, multi-stage, build args, targets, layer cache, `.tncignore`, `RUN --mount` (cache, secret, bind, tmpfs), heredocs | ✅ |
112
+ | Build: Containerfile, multi-stage, build args, targets, layer cache, `.tncignore`, `RUN --mount` (cache, secret, bind, tmpfs, ssh), heredocs | ✅ |
113
+ | Build: independent stages at the same time; `--cache-from` / `--cache-to` (registry, local folder, inline) to share the cache with CI and other machines | ✅ |
114
+ | Build: `--ssh default` (your SSH agent for `RUN --mount=type=ssh`, e.g. `git clone` of private repos; keys never leave the agent) | ✅ needs runtime bundle 0.2.3+ |
115
+ | Pushes and pulls that survive a dropped connection (chunked uploads, resumed downloads) | ✅ |
116
+ | Image signatures: `trust key generate`, `trust sign`, `trust verify` (the format cosign reads); only signed pulls with `dock config --trust-key`; keyless with `--keyless` | ✅ keyless needs cosign installed |
117
+ | Vulnerability scan and SBOM: `image scan` (apk, deb, rpm, npm, Python packages against OSV; `--exit-code` for CI; CycloneDX / SPDX) | ✅ OS advisories for Alpine, Debian, Ubuntu, AlmaLinux, Rocky, openSUSE; Fedora and Amazon Linux: packages listed only |
118
+ | userns-remap: the root of containers (and of build steps) is an unprivileged id range on the host (`dock config --userns-remap default`) | ✅ Linux as root; needs runtime bundle 0.2.3+ |
119
+ | Overlay networks across machines (`network create -d overlay`, VXLAN), names of every machine's containers | ✅ Linux as root; the tunnel is not encrypted |
111
120
  | BuildKit: buildx and BuildKit-mode builds through the engine CLI (`--secret`, cache mounts, and SSH-agent forwarding in BuildKit mode) | ✅ |
121
+ | buildx multi-platform: `buildx build --platform linux/amd64,linux/arm64 --push` (one OCI index, pushed with dock's logins), `--load` | ✅ |
112
122
  | Compose: build, depends_on with `service_healthy`, healthchecks, profiles, `.env`, `extends`, `secrets`, `configs`, recreate on change | ✅ |
123
+ | Compose `run`, `watch` (sync, sync+restart, rebuild), `scale`, `cp`, `top`, `port`, `ls`, `events` and the other verbs | ✅ |
124
+ | Multi-platform images: `build --platform linux/amd64,linux/arm64 --push`, `manifest`; image `search` | ✅ |
113
125
  | Networks with name resolution, named volumes, bind mounts, published TCP and UDP ports, `host.tnc.internal` | ✅ |
126
+ | IPv6 dual-stack networks (`network create --ipv6 [--subnet …/64]`, compose `enable_ipv6`): addresses, routes, AAAA, outbound NAT | ✅ |
127
+ | macvlan / ipvlan networks (containers with their own address on the LAN) | ✅ Linux as root; ❌ not possible inside the macOS VM, WSL or rootless (they sit behind NAT) |
128
+ | Embedded DNS on user networks (`127.0.0.11`: every replica of a scaled service in turn, PTR, TCP, forwarding; needs runtime bundle 0.2.3+) | ✅ |
129
+ | systemd as PID 1 and kind/k3d nodes (`--privileged`: writable cgroups, host devices, `SIGRTMIN+3` stop signal) | ✅ |
130
+ | cgroup v1 Linux hosts (for example RHEL/CentOS 8): limits, `stats`, `top` | ✅ (CI covers v2 only) |
114
131
  | `linux/amd64` images on Apple Silicon (Rosetta 2; P8 compatibility check is optional) | ✅ |
115
132
  | Copy files in and out (`cp`), commit, save/load, export/import, stats, top | ✅ |
116
133
  | Unmodified tools: engine CLI, compose, VS Code Dev Containers (incl. Features), testcontainers | ✅ checked by `tests/ecosystem` |
117
134
  | Move images and volumes over from another engine (`dock migrate`) | ✅ |
118
- | Dashboard in the browser (`dock ui`) | ✅ |
135
+ | Dashboard in the browser (`dock ui`), with settings, troubleshooting and a terminal | ✅ |
119
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) |
120
137
  | `--network container:<name>` and compose `network_mode: service:<name>` | ✅ |
121
- | GPUs | ❌ not yet (not possible on macOS) |
138
+ | Menu-bar app (macOS) / tray icon (Windows), start at login (`dock autostart`) | ✅ |
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
+ | Corporate proxy and CA certificates, registry mirrors, insecure registries (`dock config`) | ✅ |
141
+ | Registry passwords in the system keychain (macOS Keychain, Windows Credential Manager, Secret Service) | ✅ |
142
+ | Diagnostics bundle, factory reset (`dock diagnose`, `dock reset`) | ✅ |
143
+ | GPUs (`--gpus`, compose `deploy.resources.reservations.devices`, CDI names like `nvidia.com/gpu=all`) | ✅ Linux and WSL2 through CDI (NVIDIA Container Toolkit); ❌ not possible on macOS |
144
+ | Registry logins from credential helpers (ECR, GCR, OS keychains: `credHelpers` / `credsStore` of the engine CLI config, or `dock config --credential-helper`) | ✅ |
145
+ | zstd-compressed image layers | ✅ |
146
+ | Prometheus metrics (`GET /metrics` on the engine socket) | ✅ |
122
147
  | Push to a registry (skips existing layers, cross-repo mount), registry login/logout | ✅ |
123
148
  | Agent workspaces over SSH (key-only, loopback, no capabilities) | ✅ |
124
- | Rootless mode | ❌ not yet |
149
+ | Rootless mode on Linux (a normal user, no sudo: user namespaces, pasta / slirp4netns; limits and pause through the cgroup systemd delegates to the user) | ✅ |
125
150
  | Deploy compose apps to your own servers | ✅ `thinknsh cluster deploy` |
126
151
 
127
152
  ### Platforms
@@ -147,6 +172,13 @@ Measured on an Apple Silicon Mac with `thinknsh bench`. Run the same command on
147
172
 
148
173
  Downloads and the full guide are at **https://thinkncollab.com/dock**.
149
174
 
175
+ **Installers** (no Node.js needed; they bring Node 24): `Thinknsh-<version>.dmg` for macOS,
176
+ `Thinknsh-<version>-<arch>-setup.exe` for Windows, `thinknsh-<version>-linux-<arch>.tar.gz`
177
+ for Linux (`sudo ./thinknsh/install.sh`). They set up the runtime, start dock at login and
178
+ install the menu-bar / tray app. Built by `native/installer/` (see `.github/workflows/dock-bundle.yml`).
179
+
180
+ **With npm:**
181
+
150
182
  1. **Node.js 22.5 or newer.** dock stores its state with `node:sqlite`.
151
183
  2. **Windows only:** turn on WSL2 in an administrator PowerShell, then reboot. If WSL is already installed, run `wsl --update` instead.
152
184
  ```powershell
@@ -157,13 +189,14 @@ Downloads and the full guide are at **https://thinkncollab.com/dock**.
157
189
  npm install -g https://thinkncollab.com/downloads/dock/0.2.0/thinkncollab-shell.tgz
158
190
  thinknsh dock setup
159
191
  ```
160
- On Linux, dock runs containers natively as root: run `sudo thinknsh dock setup` and `sudo thinknsh dock start`, then `sudo groupadd tnc-dock && sudo usermod -aG tnc-dock $USER` and restart dock to use the commands without sudo.
192
+ On Linux, as a normal user, dock runs **rootless** (no sudo): it needs `uidmap` and `passt`, and subordinate ids (`sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER`); `thinknsh dock setup` lists anything missing. To run it as a system service instead, run `sudo thinknsh dock setup` and `sudo thinknsh dock start` (or `sudo thinknsh dock autostart on` for a systemd service), then `sudo groupadd tnc-dock && sudo usermod -aG tnc-dock $USER` and restart dock to use the commands without sudo. The engine listens on `/run/thinknsh/dock.sock` for every user; settings are system-wide in `/etc/thinknsh/config.json` (change them with `sudo thinknsh dock config …`).
161
193
  On a machine without internet, download the files for your platform and `manifest.json` from the downloads page into one folder, then run `thinknsh dock setup --from <folder>`.
162
194
  4. **Run something:**
163
195
  ```bash
164
196
  thinknsh run --rm -p 8080:80 nginx:alpine
165
197
  ```
166
198
  Then open http://localhost:8080.
199
+ 5. **Optional:** `thinknsh dock autostart on` (start at login) and `thinknsh dock app install` (menu-bar / tray app).
167
200
 
168
201
  ### Check it works on your machine
169
202
 
@@ -318,4 +351,4 @@ thinknsh dep sync # Reconcile environment from thinkncollab.yml
318
351
  ## 📄 License
319
352
 
320
353
  Apache License 2.0. See [LICENSE](LICENSE) for details.
321
- Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
354
+ Copyright (c) 2024-2026 ThinkNCollab Systems Inc. All rights reserved.
Binary file
Binary file
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # native/tnc-tray/build-macos.sh — builds Thinknsh.app, the menu-bar app (Intel + Apple Silicon).
4
+ #
5
+ # build-macos.sh <outdir> <outdir>/Thinknsh.app
6
+ #
7
+ # Signing: TNC_SIGN_IDENTITY="Developer ID Application: …" signs with the
8
+ # hardened runtime (needed for notarization); without it the app is signed ad hoc.
9
+ #
10
+ set -euo pipefail
11
+
12
+ HERE="$(cd "$(dirname "$0")" && pwd)"
13
+ OUT="${1:?usage: build-macos.sh <outdir>}"
14
+ VERSION="${TNC_APP_VERSION:-$(node -p "require('$HERE/../../package.json').version" 2>/dev/null || echo 0.0.0)}"
15
+ mkdir -p "$OUT"
16
+ OUT="$(cd "$OUT" && pwd)"
17
+ APP="$OUT/Thinknsh.app"
18
+ WORK="$(mktemp -d)"
19
+ trap 'rm -rf "$WORK"' EXIT
20
+
21
+ rm -rf "$APP"
22
+ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
23
+
24
+ for arch in arm64 x86_64; do
25
+ xcrun swiftc -O -target "$arch-apple-macos13" -o "$WORK/tray-$arch" "$HERE/main.swift"
26
+ done
27
+ lipo -create -output "$APP/Contents/MacOS/thinknsh-tray" "$WORK/tray-arm64" "$WORK/tray-x86_64"
28
+ # ThinkNCollab icons (native/brand, made by make-icons.py): the app icon and the menu bar template
29
+ for f in AppIcon.icns menubar.png menubar@2x.png; do
30
+ cp "$HERE/../brand/$f" "$APP/Contents/Resources/$f"
31
+ done
32
+
33
+ cat > "$APP/Contents/Info.plist" <<PLIST
34
+ <?xml version="1.0" encoding="UTF-8"?>
35
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
36
+ <plist version="1.0">
37
+ <dict>
38
+ <key>CFBundleName</key><string>Thinknsh</string>
39
+ <key>CFBundleDisplayName</key><string>Thinknsh</string>
40
+ <key>CFBundleIdentifier</key><string>com.thinkncollab.thinknsh</string>
41
+ <key>CFBundleExecutable</key><string>thinknsh-tray</string>
42
+ <key>CFBundlePackageType</key><string>APPL</string>
43
+ <key>CFBundleIconFile</key><string>AppIcon</string>
44
+ <key>CFBundleShortVersionString</key><string>${VERSION}</string>
45
+ <key>CFBundleVersion</key><string>${VERSION}</string>
46
+ <key>LSMinimumSystemVersion</key><string>13.0</string>
47
+ <key>LSUIElement</key><true/>
48
+ <key>NSHighResolutionCapable</key><true/>
49
+ <key>NSAppleEventsUsageDescription</key><string>Opens Terminal windows for container logs and shells.</string>
50
+ </dict>
51
+ </plist>
52
+ PLIST
53
+
54
+ if [[ -n "${TNC_SIGN_IDENTITY:-}" ]]; then
55
+ codesign --force --options runtime --timestamp --sign "$TNC_SIGN_IDENTITY" "$APP"
56
+ else
57
+ codesign --force --sign - "$APP"
58
+ fi
59
+ echo "✓ $APP"
@@ -0,0 +1,204 @@
1
+ // native/tnc-tray/main.swift — the thinknsh menu-bar app (macOS 13+).
2
+ //
3
+ // A thin shell: it runs `thinknsh dock tray` (src/dock/tray/agent.js) and draws
4
+ // the menu that process sends, one JSON object per line on its stdout. Clicks
5
+ // go back as {"click": ID} on its stdin, after a confirm dialog when the item
6
+ // asks for one. All engine logic lives in the agent.
7
+ //
8
+ // The agent command comes from the bundle: Resources/node/bin/node and
9
+ // Resources/cli/bin/thinknsh.js (installer builds), or Resources/agent.json
10
+ // {"node": …, "cli": …} written by `thinknsh dock app install`.
11
+
12
+ import Cocoa
13
+
14
+ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
15
+ private var statusItem: NSStatusItem!
16
+ private var agent: Process?
17
+ private var agentInput: FileHandle?
18
+ private var buffer = Data()
19
+ private var restarts = 0
20
+ private var quitting = false
21
+
22
+ func applicationDidFinishLaunching(_ notification: Notification) {
23
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
24
+ setIcon("stopped")
25
+ let menu = NSMenu()
26
+ menu.autoenablesItems = false
27
+ menu.delegate = self
28
+ statusItem.menu = menu
29
+ showMessage("Starting…")
30
+ startAgent()
31
+ }
32
+
33
+ func applicationWillTerminate(_ notification: Notification) {
34
+ quitting = true
35
+ agent?.terminate()
36
+ }
37
+
38
+ // MARK: agent process
39
+
40
+ private func agentCommand() -> (String, [String])? {
41
+ guard let res = Bundle.main.resourceURL else { return nil }
42
+ let fm = FileManager.default
43
+ let node = res.appendingPathComponent("node/bin/node").path
44
+ let cli = res.appendingPathComponent("cli/bin/thinknsh.js").path
45
+ if fm.isExecutableFile(atPath: node) && fm.fileExists(atPath: cli) { return (node, [cli, "dock", "tray"]) }
46
+ if let data = try? Data(contentsOf: res.appendingPathComponent("agent.json")),
47
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: String],
48
+ let n = json["node"], let c = json["cli"] {
49
+ return (n, [c, "dock", "tray"])
50
+ }
51
+ return nil
52
+ }
53
+
54
+ private func startAgent() {
55
+ guard let (exe, args) = agentCommand() else {
56
+ showMessage("thinknsh not found: run thinknsh dock app install")
57
+ return
58
+ }
59
+ let p = Process()
60
+ p.executableURL = URL(fileURLWithPath: exe)
61
+ p.arguments = args
62
+ var env = ProcessInfo.processInfo.environment
63
+ env["PATH"] = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin:" + (env["PATH"] ?? "")
64
+ env["FORCE_COLOR"] = "0"
65
+ p.environment = env
66
+ let out = Pipe(), inp = Pipe()
67
+ p.standardOutput = out
68
+ p.standardInput = inp
69
+ p.standardError = FileHandle.nullDevice
70
+ out.fileHandleForReading.readabilityHandler = { [weak self] h in
71
+ let d = h.availableData
72
+ DispatchQueue.main.async { self?.received(d) }
73
+ }
74
+ p.terminationHandler = { [weak self] _ in
75
+ DispatchQueue.main.async { self?.agentEnded() }
76
+ }
77
+ do {
78
+ try p.run()
79
+ agent = p
80
+ agentInput = inp.fileHandleForWriting
81
+ } catch {
82
+ showMessage("Cannot start thinknsh: \(error.localizedDescription)")
83
+ }
84
+ }
85
+
86
+ private func agentEnded() {
87
+ agent = nil
88
+ agentInput = nil
89
+ if quitting { NSApp.terminate(nil); return }
90
+ restarts += 1
91
+ if restarts > 5 { showMessage("thinknsh tray stopped; reopen the app"); return }
92
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in self?.startAgent() }
93
+ }
94
+
95
+ private func received(_ data: Data) {
96
+ if data.isEmpty { return }
97
+ buffer.append(data)
98
+ while let nl = buffer.firstIndex(of: 0x0a) {
99
+ let line = buffer.subdata(in: buffer.startIndex..<nl)
100
+ buffer.removeSubrange(buffer.startIndex...nl)
101
+ handle(line)
102
+ }
103
+ }
104
+
105
+ private func handle(_ line: Data) {
106
+ guard let msg = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any] else { return }
107
+ if let menu = msg["menu"] as? [String: Any] { render(menu) }
108
+ if let text = msg["notify"] as? String { notify(text) }
109
+ if msg["exit"] as? Bool == true {
110
+ quitting = true
111
+ NSApp.terminate(nil)
112
+ }
113
+ }
114
+
115
+ private func send(_ obj: [String: Any]) {
116
+ guard let input = agentInput, let d = try? JSONSerialization.data(withJSONObject: obj) else { return }
117
+ var line = d
118
+ line.append(0x0a)
119
+ try? input.write(contentsOf: line)
120
+ }
121
+
122
+ // MARK: menu
123
+
124
+ // The TNC mark (Resources/menubar.png + @2x, a template macOS tints for light and dark),
125
+ // dimmed while dock is not running; a system symbol when the bundle has no icon.
126
+ private func setIcon(_ status: String) {
127
+ let img = Bundle.main.image(forResource: "menubar")
128
+ ?? NSImage(systemSymbolName: status == "running" ? "shippingbox.fill" : "shippingbox", accessibilityDescription: nil)
129
+ img?.isTemplate = true
130
+ img?.accessibilityDescription = "thinknsh dock \(status)"
131
+ statusItem.button?.image = img
132
+ statusItem.button?.appearsDisabled = status != "running"
133
+ }
134
+
135
+ private func showMessage(_ text: String) {
136
+ let menu = statusItem.menu!
137
+ menu.removeAllItems()
138
+ let item = NSMenuItem(title: text, action: nil, keyEquivalent: "")
139
+ item.isEnabled = false
140
+ menu.addItem(item)
141
+ menu.addItem(.separator())
142
+ menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
143
+ }
144
+
145
+ private func render(_ model: [String: Any]) {
146
+ setIcon(model["status"] as? String ?? "stopped")
147
+ let title = model["title"] as? String ?? ""
148
+ statusItem.button?.title = title.isEmpty ? "" : " \(title)"
149
+ let menu = statusItem.menu!
150
+ menu.removeAllItems()
151
+ fill(menu, model["items"] as? [[String: Any]] ?? [])
152
+ }
153
+
154
+ private func fill(_ menu: NSMenu, _ items: [[String: Any]]) {
155
+ for it in items {
156
+ if it["separator"] as? Bool == true { menu.addItem(.separator()); continue }
157
+ let item = NSMenuItem(title: it["label"] as? String ?? "", action: nil, keyEquivalent: "")
158
+ item.isEnabled = it["enabled"] as? Bool ?? true
159
+ if it["checked"] as? Bool == true { item.state = .on }
160
+ if let sub = it["items"] as? [[String: Any]] {
161
+ let submenu = NSMenu()
162
+ submenu.autoenablesItems = false
163
+ fill(submenu, sub)
164
+ item.submenu = submenu
165
+ } else {
166
+ item.target = self
167
+ item.action = #selector(clicked(_:))
168
+ item.representedObject = it
169
+ }
170
+ menu.addItem(item)
171
+ }
172
+ }
173
+
174
+ @objc private func clicked(_ sender: NSMenuItem) {
175
+ guard let it = sender.representedObject as? [String: Any], let id = it["id"] as? String else { return }
176
+ if let question = it["confirm"] as? String {
177
+ NSApp.activate(ignoringOtherApps: true)
178
+ let alert = NSAlert()
179
+ alert.messageText = question
180
+ alert.alertStyle = .warning
181
+ alert.addButton(withTitle: "Continue")
182
+ alert.addButton(withTitle: "Cancel")
183
+ if alert.runModal() != .alertFirstButtonReturn { return }
184
+ }
185
+ send(["click": id])
186
+ }
187
+
188
+ func menuWillOpen(_ menu: NSMenu) {
189
+ send(["refresh": true])
190
+ }
191
+
192
+ private func notify(_ text: String) {
193
+ let p = Process()
194
+ p.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
195
+ p.arguments = ["-e", "on run argv\ndisplay notification (item 1 of argv) with title \"thinknsh dock\"\nend run", text]
196
+ try? p.run()
197
+ }
198
+ }
199
+
200
+ let app = NSApplication.shared
201
+ let delegate = AppDelegate()
202
+ app.delegate = delegate
203
+ app.setActivationPolicy(.accessory)
204
+ app.run()
@@ -0,0 +1,137 @@
1
+ # native/tnc-tray/tray.ps1 — the thinknsh notification-area (tray) icon on Windows.
2
+ #
3
+ # Same design as the macOS app: runs `thinknsh dock tray` (src/dock/tray/agent.js)
4
+ # and draws the menu it sends, one JSON object per line; clicks go back as
5
+ # {"click": ID}. Installed and started by `thinknsh dock app install`.
6
+ #
7
+ # powershell -WindowStyle Hidden -ExecutionPolicy Bypass -File tray.ps1 -Node <node.exe> -Cli <thinknsh.js>
8
+ # powershell -File tray.ps1 -CompileOnly compile the C# part and exit (CI)
9
+
10
+ param(
11
+ [string]$Node,
12
+ [string]$Cli,
13
+ [switch]$CompileOnly
14
+ )
15
+
16
+ $ErrorActionPreference = 'Stop'
17
+
18
+ Add-Type -ReferencedAssemblies System.Windows.Forms, System.Drawing, System.Web.Extensions -TypeDefinition @'
19
+ using System;
20
+ using System.Collections;
21
+ using System.Collections.Concurrent;
22
+ using System.Collections.Generic;
23
+ using System.Diagnostics;
24
+ using System.Drawing;
25
+ using System.Reflection;
26
+ using System.Threading;
27
+ using System.Web.Script.Serialization;
28
+ using System.Windows.Forms;
29
+
30
+ public static class TncTray {
31
+ static Process agent;
32
+ static NotifyIcon icon;
33
+ static readonly ConcurrentQueue<string> lines = new ConcurrentQueue<string>();
34
+ static readonly JavaScriptSerializer json = new JavaScriptSerializer();
35
+
36
+ public static void Run(string node, string cli, string dir) {
37
+ var psi = new ProcessStartInfo(node, "\"" + cli + "\" dock tray");
38
+ psi.UseShellExecute = false;
39
+ psi.RedirectStandardInput = true;
40
+ psi.RedirectStandardOutput = true;
41
+ psi.RedirectStandardError = true;
42
+ psi.CreateNoWindow = true;
43
+ psi.StandardOutputEncoding = System.Text.Encoding.UTF8; // menu labels are UTF-8 (●, …)
44
+ psi.EnvironmentVariables["FORCE_COLOR"] = "0";
45
+ agent = Process.Start(psi);
46
+ var reader = new Thread(() => {
47
+ string l;
48
+ while ((l = agent.StandardOutput.ReadLine()) != null) lines.Enqueue(l);
49
+ lines.Enqueue("{\"exit\":true}");
50
+ });
51
+ reader.IsBackground = true;
52
+ reader.Start();
53
+ var errors = new Thread(() => { agent.StandardError.ReadToEnd(); });
54
+ errors.IsBackground = true;
55
+ errors.Start();
56
+
57
+ icon = new NotifyIcon();
58
+ var ico = System.IO.Path.Combine(dir ?? "", "tnc.ico"); // the TNC icon, next to this script
59
+ icon.Icon = System.IO.File.Exists(ico) ? new Icon(ico) : SystemIcons.Application;
60
+ icon.Text = "thinknsh dock";
61
+ icon.Visible = true;
62
+ icon.ContextMenuStrip = new ContextMenuStrip();
63
+ icon.ContextMenuStrip.Items.Add(new ToolStripMenuItem("Starting...") { Enabled = false });
64
+ icon.ContextMenuStrip.Opening += (s, e) => Send("{\"refresh\":true}");
65
+ icon.MouseClick += (s, e) => {
66
+ if (e.Button != MouseButtons.Left) return;
67
+ var show = typeof(NotifyIcon).GetMethod("ShowContextMenu", BindingFlags.Instance | BindingFlags.NonPublic);
68
+ if (show != null) show.Invoke(icon, null);
69
+ };
70
+
71
+ var timer = new System.Windows.Forms.Timer();
72
+ timer.Interval = 150;
73
+ timer.Tick += (s, e) => Drain();
74
+ timer.Start();
75
+ Application.Run();
76
+ icon.Visible = false;
77
+ try { if (!agent.HasExited) agent.Kill(); } catch (Exception) { }
78
+ }
79
+
80
+ static void Send(string line) {
81
+ try { agent.StandardInput.WriteLine(line); agent.StandardInput.Flush(); } catch (Exception) { }
82
+ }
83
+
84
+ static void Drain() {
85
+ string l;
86
+ while (lines.TryDequeue(out l)) {
87
+ Dictionary<string, object> m;
88
+ try { m = json.Deserialize<Dictionary<string, object>>(l); } catch (Exception) { continue; }
89
+ if (m == null) continue;
90
+ if (m.ContainsKey("notify")) icon.ShowBalloonTip(5000, "thinknsh dock", Convert.ToString(m["notify"]), ToolTipIcon.Info);
91
+ if (m.ContainsKey("menu")) Render(m["menu"] as IDictionary<string, object>);
92
+ if (m.ContainsKey("exit")) { Application.Exit(); return; }
93
+ }
94
+ }
95
+
96
+ static void Render(IDictionary<string, object> menu) {
97
+ if (menu == null) return;
98
+ var strip = icon.ContextMenuStrip;
99
+ strip.Items.Clear();
100
+ Fill(strip.Items, menu["items"] as IEnumerable);
101
+ string status = Convert.ToString(menu["status"]);
102
+ string title = menu.ContainsKey("title") ? Convert.ToString(menu["title"]) : "";
103
+ string text = "thinknsh dock: " + status + (title.Length > 0 ? " (" + title + " running)" : "");
104
+ icon.Text = text.Length > 63 ? text.Substring(0, 63) : text;
105
+ }
106
+
107
+ static void Fill(ToolStripItemCollection items, IEnumerable list) {
108
+ if (list == null) return;
109
+ foreach (object o in list) {
110
+ var it = o as IDictionary<string, object>;
111
+ if (it == null) continue;
112
+ if (it.ContainsKey("separator")) { items.Add(new ToolStripSeparator()); continue; }
113
+ var mi = new ToolStripMenuItem(Convert.ToString(it["label"]));
114
+ if (it.ContainsKey("enabled") && !Convert.ToBoolean(it["enabled"])) mi.Enabled = false;
115
+ if (it.ContainsKey("checked") && Convert.ToBoolean(it["checked"])) mi.Checked = true;
116
+ if (it.ContainsKey("items")) {
117
+ Fill(mi.DropDownItems, it["items"] as IEnumerable);
118
+ } else {
119
+ string id = Convert.ToString(it["id"]);
120
+ string confirm = it.ContainsKey("confirm") ? Convert.ToString(it["confirm"]) : null;
121
+ mi.Click += (s, e) => {
122
+ if (confirm != null && MessageBox.Show(confirm, "thinknsh dock", MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) != DialogResult.OK) return;
123
+ var click = new Dictionary<string, object>();
124
+ click["click"] = id;
125
+ Send(json.Serialize(click));
126
+ };
127
+ }
128
+ items.Add(mi);
129
+ }
130
+ }
131
+ }
132
+ '@
133
+
134
+ if ($CompileOnly) { Write-Output 'tray compiled'; exit 0 }
135
+ if (-not $Node -or -not $Cli) { throw 'usage: tray.ps1 -Node <node.exe> -Cli <thinknsh.js>' }
136
+ [System.Windows.Forms.Application]::EnableVisualStyles()
137
+ [TncTray]::Run($Node, $Cli, $PSScriptRoot)
@@ -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{runCommand}=require("../../dock/cli/commands"),DESCRIPTIONS={run:"Run a container (pulls the image if needed)",create:"Create a container without starting it",start:"Start stopped containers",stop:"Stop running containers",restart:"Restart containers",kill:"Send a signal to containers",rm:"Remove containers",wait:"Wait for containers to stop and print exit codes",ps:"List containers",logs:"Show container output",exec:"Run a command in a running container",inspect:"Show details of containers, images, volumes or networks",images:"List images",pull:"Download an image",push:"Upload an image to a registry (also: sandbox push ecr://… / --export)",login:"Log in to a container registry (-u USER [REGISTRY]); with an email, to ThinkNCollab",logout:"Log out of a container registry (REGISTRY); without one, of ThinkNCollab",rmi:"Remove images",tag:"Give an image another name",build:"Build an image from a Containerfile",cp:"Copy files between a container and this machine (CONTAINER:PATH)",stats:"Live CPU, memory, network and disk use of containers",top:"Show the processes of a container",pause:"Freeze every process of containers",unpause:"Thaw paused containers",rename:"Rename a container",update:"Change memory/CPU limits or the restart policy of containers",diff:"Show files changed in a container",port:"Show the published ports of a container",commit:"Make an image from a container's changes",save:"Write images to a tar file (loads in any engine)",load:"Read images from a tar file",import:"Make an image from a root filesystem tar",compose:"Run multi-container apps from a compose file",workspace:"One secure container per project for all coding agents and editors (Claude, Codex, Antigravity, Cursor)",service:"Dev databases as containers: postgres, redis, mysql (start, stop, status, logs, rm)",registry:"A local image registry as a container (start, stop, status, push, pull)",capsule:"Older name for image commands (build, images, run, rmi)",vault:"Older name for volume commands, plus backup and restore",mesh:"Older name for network commands",stack:"Older name for compose",vm:"Older name for dock setup/start/stop/status",volume:"Manage volumes",network:"Manage networks",system:"Disk usage, cleanup and engine info",container:"Manage containers (ls, rm, prune, …)",image:"Manage images (ls, rm, prune, …)"},NAMES=Object.keys(DESCRIPTIONS),looksLikeRegistry=e=>!!e&&!e.startsWith("-")&&!e.includes("@")&&(e.includes(".")||e.includes(":")||"localhost"===e),REGISTRY_FORM={login:e=>e.some(e=>/^(-u|--username|--password-stdin)(=|$)/.test(e)||/^-u./.test(e))||1===e.length&&looksLikeRegistry(e[0])||["--help","-h"].includes(e[0]),logout:e=>1===e.length&&(looksLikeRegistry(e[0])||["--help","-h"].includes(e[0])),push:e=>!e.some(e=>/^[a-z][a-z0-9+.-]*:\/\//.test(e)||/^--export/.test(e))&&"export"!==e[0]},LEGACY={login:"../auth/login",logout:"../auth/logout",push:"../sandbox/push"},ALIASES={volume:["vol","volumes"],network:["net","networks"],images:["img"],service:["svc","services","db"],capsule:["cap"],vm:["microvm","vz"]};async function runLegacy(e,a,o,s){const t=require(LEGACY[e]);if(!t.requiresAuth||o?.api?.isAuthenticated())return t.execute(a,o,s);console.log(require("chalk").red("[FAIL] Please login first: login"))}module.exports=NAMES.map(e=>({name:e,description:DESCRIPTIONS[e],usage:`thinknsh ${e} --help`,requiresAuth:!1,selfExiting:!0,...LEGACY[e]?{aliases:require(LEGACY[e]).aliases}:ALIASES[e]?{aliases:ALIASES[e]}:{},async execute(a=[],o,s=e){if(LEGACY[e]&&(s!==e||!REGISTRY_FORM[e](a)))return runLegacy(e,a,o,s);process.exitCode=await runCommand(e,a)}})),module.exports.REGISTRY_FORM=REGISTRY_FORM,module.exports.NAMES=NAMES,module.exports.ALL_NAMES=module.exports.flatMap(e=>[e.name,...e.aliases||[]]);
7
+ "use strict";const{runCommand}=require("../../dock/cli/commands"),DESCRIPTIONS={run:"Run a container (pulls the image if needed)",create:"Create a container without starting it",start:"Start stopped containers",stop:"Stop running containers",restart:"Restart containers",kill:"Send a signal to containers",rm:"Remove containers",wait:"Wait for containers to stop and print exit codes",ps:"List containers",logs:"Show container output",exec:"Run a command in a running container",inspect:"Show details of containers, images, volumes or networks",images:"List images",pull:"Download an image",push:"Upload an image to a registry (also: sandbox push ecr://… / --export)",login:"Log in to a container registry (-u USER [REGISTRY]); with an email, to ThinkNCollab",logout:"Log out of a container registry (REGISTRY); without one, of ThinkNCollab",rmi:"Remove images",tag:"Give an image another name",manifest:"Multi-platform images from local images (create, inspect, push, rm)",search:"Search the public image registry",events:"Stream engine events (container, image, volume, network)",build:"Build an image from a Containerfile",trust:"Sign images and check their signatures (key generate, sign, verify)",scan:"Packages and known vulnerabilities of an image (also: image scan), or its SBOM",cp:"Copy files between a container and this machine (CONTAINER:PATH)",stats:"Live CPU, memory, network and disk use of containers",top:"Show the processes of a container",pause:"Freeze every process of containers",unpause:"Thaw paused containers",rename:"Rename a container",update:"Change memory/CPU limits or the restart policy of containers",diff:"Show files changed in a container",port:"Show the published ports of a container",commit:"Make an image from a container's changes",save:"Write images to a tar file (loads in any engine)",load:"Read images from a tar file",import:"Make an image from a root filesystem tar",compose:"Run multi-container apps from a compose file",workspace:"One secure container per project for all coding agents and editors (Claude, Codex, Antigravity, Cursor)",service:"Dev databases as containers: postgres, redis, mysql (start, stop, status, logs, rm)",registry:"A local image registry as a container (start, stop, status, push, pull)",capsule:"Older name for image commands (build, images, run, rmi)",vault:"Older name for volume commands, plus backup and restore",mesh:"Older name for network commands",stack:"Older name for compose",vm:"Older name for dock setup/start/stop/status",volume:"Manage volumes",network:"Manage networks",system:"Disk usage, cleanup and engine info",container:"Manage containers (ls, rm, prune, …)",image:"Manage images (ls, rm, prune, …)"},NAMES=Object.keys(DESCRIPTIONS),looksLikeRegistry=e=>!!e&&!e.startsWith("-")&&!e.includes("@")&&(e.includes(".")||e.includes(":")||"localhost"===e),REGISTRY_FORM={login:e=>e.some(e=>/^(-u|--username|--password-stdin)(=|$)/.test(e)||/^-u./.test(e))||1===e.length&&looksLikeRegistry(e[0])||["--help","-h"].includes(e[0]),logout:e=>1===e.length&&(looksLikeRegistry(e[0])||["--help","-h"].includes(e[0])),push:e=>!e.some(e=>/^[a-z][a-z0-9+.-]*:\/\//.test(e)||/^--export/.test(e))&&"export"!==e[0]},LEGACY={login:"../auth/login",logout:"../auth/logout",push:"../sandbox/push"},ALIASES={volume:["vol","volumes"],network:["net","networks"],images:["img"],service:["svc","services","db"],capsule:["cap"],vm:["microvm","vz"]};async function runLegacy(e,a,s,t){const n=require(LEGACY[e]);if(!n.requiresAuth||s?.api?.isAuthenticated())return n.execute(a,s,t);console.log(require("chalk").red("[FAIL] Please login first: login"))}module.exports=NAMES.map(e=>({name:e,description:DESCRIPTIONS[e],usage:`thinknsh ${e} --help`,requiresAuth:!1,selfExiting:!0,...LEGACY[e]?{aliases:require(LEGACY[e]).aliases}:ALIASES[e]?{aliases:ALIASES[e]}:{},async execute(a=[],s,t=e){if(LEGACY[e]&&(t!==e||!REGISTRY_FORM[e](a)))return runLegacy(e,a,s,t);process.exitCode=await runCommand(e,a)}})),module.exports.REGISTRY_FORM=REGISTRY_FORM,module.exports.NAMES=NAMES,module.exports.ALL_NAMES=module.exports.flatMap(e=>[e.name,...e.aliases||[]]);