From dd9d53060b80e0890c6a4e313b32479d08ff6a61 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 12 May 2026 09:36:15 -0500 Subject: [PATCH] Add StartOS 0.4 package scaffold (manifest, main, interfaces, 2 actions) - package/Makefile + s9pk.mk + package.json + tsconfig.json - startos/manifest: dockerBuild source pointing at ../image/Dockerfile - startos/main: reads /data/config.yaml reactively, passes env vars to container - startos/interfaces: binds port 9999 as HTTP UI - startos/actions: showPublicKey (read /data/ssh/id_ed25519.pub), configureSparks - TS + JS bundle compile clean (tsc --noEmit, ncc build) --- package/.dockerignore | 4 + package/.gitignore | 3 + package/Makefile | 3 + package/icon.svg | 5 + package/instructions.md | 35 ++ package/package-lock.json | 359 ++++++++++++++++++ package/package.json | 23 ++ package/s9pk.mk | 132 +++++++ package/startos/actions/configureSparks.ts | 60 +++ package/startos/actions/index.ts | 7 + package/startos/actions/showPublicKey.ts | 54 +++ package/startos/backups.ts | 5 + package/startos/dependencies.ts | 5 + .../startos/fileModels/sparkConfig.yaml.ts | 17 + package/startos/i18n/dictionaries/default.ts | 24 ++ .../startos/i18n/dictionaries/translations.ts | 3 + package/startos/i18n/index.ts | 8 + package/startos/index.ts | 11 + package/startos/init/index.ts | 16 + package/startos/interfaces.ts | 25 ++ package/startos/main.ts | 50 +++ package/startos/manifest/i18n.ts | 8 + package/startos/manifest/index.ts | 35 ++ package/startos/sdk.ts | 7 + package/startos/utils.ts | 2 + package/startos/versions/index.ts | 7 + package/startos/versions/v0_1_0.ts | 12 + package/tsconfig.json | 11 + 28 files changed, 931 insertions(+) create mode 100644 package/.dockerignore create mode 100644 package/.gitignore create mode 100644 package/Makefile create mode 100644 package/icon.svg create mode 100644 package/instructions.md create mode 100644 package/package-lock.json create mode 100644 package/package.json create mode 100644 package/s9pk.mk create mode 100644 package/startos/actions/configureSparks.ts create mode 100644 package/startos/actions/index.ts create mode 100644 package/startos/actions/showPublicKey.ts create mode 100644 package/startos/backups.ts create mode 100644 package/startos/dependencies.ts create mode 100644 package/startos/fileModels/sparkConfig.yaml.ts create mode 100644 package/startos/i18n/dictionaries/default.ts create mode 100644 package/startos/i18n/dictionaries/translations.ts create mode 100644 package/startos/i18n/index.ts create mode 100644 package/startos/index.ts create mode 100644 package/startos/init/index.ts create mode 100644 package/startos/interfaces.ts create mode 100644 package/startos/main.ts create mode 100644 package/startos/manifest/i18n.ts create mode 100644 package/startos/manifest/index.ts create mode 100644 package/startos/sdk.ts create mode 100644 package/startos/utils.ts create mode 100644 package/startos/versions/index.ts create mode 100644 package/startos/versions/v0_1_0.ts create mode 100644 package/tsconfig.json diff --git a/package/.dockerignore b/package/.dockerignore new file mode 100644 index 0000000..43ff8db --- /dev/null +++ b/package/.dockerignore @@ -0,0 +1,4 @@ +node_modules +javascript +*.s9pk +.git diff --git a/package/.gitignore b/package/.gitignore new file mode 100644 index 0000000..300d670 --- /dev/null +++ b/package/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +javascript/ +*.s9pk diff --git a/package/Makefile b/package/Makefile new file mode 100644 index 0000000..927e1fc --- /dev/null +++ b/package/Makefile @@ -0,0 +1,3 @@ +ARCHES := x86 +# overrides to s9pk.mk must precede the include statement +include s9pk.mk diff --git a/package/icon.svg b/package/icon.svg new file mode 100644 index 0000000..545c630 --- /dev/null +++ b/package/icon.svg @@ -0,0 +1,5 @@ + + + + diff --git a/package/instructions.md b/package/instructions.md new file mode 100644 index 0000000..a1bad30 --- /dev/null +++ b/package/instructions.md @@ -0,0 +1,35 @@ +# Spark Control + +A browser-based control panel for a dual-DGX-Spark vLLM cluster on your LAN. See which LLM is loaded, swap to another with one click, and watch the streaming log until the new model is ready. + +## What you get on StartOS + +After install you have: + +- **A web UI** at the package's LAN address (HTTPS, .local). +- **One-click model swaps** for any model in your `models.yaml` catalog. +- **Live status** of vLLM, Parakeet (STT), and Magpie (TTS). + +## Getting set up + +This package SSHes into your Spark server to run cluster commands, so it needs a one-time setup: + +1. **Open Actions → Show Public Key.** Copy the ed25519 public key that the package generated. +2. **SSH into each Spark** and append the key to `~/.ssh/authorized_keys`: + ```bash + echo "" >> ~/.ssh/authorized_keys + ``` +3. **Open Actions → Configure Sparks.** Enter the LAN hostnames or IPs for Spark 1 and Spark 2, plus the SSH username (usually ``). +4. **Open the Web UI.** It will hit each Spark to confirm. If both indicators are green you're done. + +## Using Spark Control + +Once configured, open the web interface from your phone or laptop. The current model is shown in the top bar. Each available model has a card with a "Switch to this" button. Clicking it stops the current model and launches the new one — the log tails in real time until `Application startup complete.` appears (3–6 min depending on the model). + +## Editing the model catalog + +The bundled catalog covers the models in the starter `models.yaml`. To add a model, ssh into the StartOS server and edit `/embassy-data/package-data/volumes/spark-control/main/models.yaml`, then restart the service. (A proper "Edit Model Catalog" action is on the roadmap.) + +## Source code + + (TBD) diff --git a/package/package-lock.json b/package/package-lock.json new file mode 100644 index 0000000..700a3b4 --- /dev/null +++ b/package/package-lock.json @@ -0,0 +1,359 @@ +{ + "name": "spark-control-startos", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spark-control-startos", + "dependencies": { + "@start9labs/start-sdk": "1.3.3" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-1.1.0.tgz", + "integrity": "sha512-bidpxmTBP0pOsxULw6XlxzQpTgrAGLDHGBK/JuWhPDL6ZV0GZ/PmN9CA9do6e+A9lYI6qx6ikJUtJYRxup141g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.3.3.tgz", + "integrity": "sha512-aL9ilSj6CyP2+tSooxPZFqWXP1/1dMgYljcu1s2ECoPv6CTmSBqwrBw9SdsQp7PYmnU4rsSZQteWogkDOf93YQ==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "fast-xml-parser": "~5.6.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "yaml": "^2.8.3", + "zod": "^4.3.6", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", + "dev": true, + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.6.0.tgz", + "integrity": "sha512-5G+uaEBbOm9M4dgMOV3K/rBzfUNGqGqoUTaYJM3hBwM8t71w07gxLQZoTsjkY8FtfjabqgQHEkeIySBDYeBmJw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^1.1.0", + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-deep-partial": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", + "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } + } + } +} diff --git a/package/package.json b/package/package.json new file mode 100644 index 0000000..dca2cb4 --- /dev/null +++ b/package/package.json @@ -0,0 +1,23 @@ +{ + "name": "spark-control-startos", + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "1.3.3" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/package/s9pk.mk b/package/s9pk.mk new file mode 100644 index 0000000..978a059 --- /dev/null +++ b/package/s9pk.mk @@ -0,0 +1,132 @@ +# ** Plumbing. DO NOT EDIT **. +# This file is imported by ./Makefile. Make edits there + +PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts) +INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null) +# Resolve the actual git dir so this works inside git worktrees, where .git +# is a file pointing at
/.git/worktrees/ rather than a directory. +GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null) +GIT_DEPS := $(if $(GIT_DIR),$(GIT_DIR)/HEAD $(GIT_DIR)/index) +ARCHES ?= x86 arm riscv +TARGETS ?= arches +ifdef VARIANT +BASE_NAME := $(PACKAGE_ID)_$(VARIANT) +else +BASE_NAME := $(PACKAGE_ID) +endif + +.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients +.DELETE_ON_ERROR: +.SECONDARY: + +define SUMMARY + @manifest=$$(start-cli s9pk inspect $(1) manifest); \ + size=$$(du -h $(1) | awk '{print $$1}'); \ + title=$$(printf '%s' "$$manifest" | jq -r .title); \ + version=$$(printf '%s' "$$manifest" | jq -r .version); \ + arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \ + sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \ + gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \ + printf "\n"; \ + printf "\033[1;32m✅ Build Complete!\033[0m\n"; \ + printf "\n"; \ + printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \ + printf "───────────────────────────────\n"; \ + printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \ + printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \ + printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \ + printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \ + printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \ + echo "" +endef + +all: $(TARGETS) + +arches: $(ARCHES) + +universal: $(BASE_NAME).s9pk + $(call SUMMARY,$<) + +arch/%: $(BASE_NAME)_%.s9pk + $(call SUMMARY,$<) + +x86 x86_64: arch/x86_64 +arm arm64 aarch64: arch/aarch64 +riscv riscv64: arch/riscv64 + +$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack -o $@ + +$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack --arch=$* -o $@ + +ingredients: $(INGREDIENTS) + @echo " Re-evaluating ingredients..." + +install: | check-deps check-init + @HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$HOST" ]; then \ + echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \ + if [ -z "$$S9PK" ]; then \ + echo "Error: No .s9pk file found. Run 'make' first."; \ + exit 1; \ + fi; \ + printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \ + start-cli package install -s "$$S9PK" + +publish: | all + @REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$REGISTRY" ]; then \ + echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$S3BASE" ]; then \ + echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + command -v s3cmd >/dev/null || \ + (echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \ + printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \ + for s9pk in *.s9pk; do \ + age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \ + if [ "$$age" -gt 3600 ]; then \ + printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \ + read -r ans; \ + case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \ + fi; \ + start-cli s9pk publish "$$s9pk"; \ + done + +check-deps: + @command -v start-cli >/dev/null || \ + (echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1) + @command -v npm >/dev/null || \ + (echo "Error: npm not found. Please install Node.js and npm." && exit 1) + +check-init: + @if [ ! -f ~/.startos/developer.key.pem ]; then \ + echo "Initializing StartOS developer environment..."; \ + start-cli init-key; \ + fi + +javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules + npm run check + npm run build + +node_modules: package-lock.json + npm ci + +package-lock.json: package.json + npm i + +clean: + @echo "Cleaning up build artifacts..." + @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules diff --git a/package/startos/actions/configureSparks.ts b/package/startos/actions/configureSparks.ts new file mode 100644 index 0000000..3f58bd9 --- /dev/null +++ b/package/startos/actions/configureSparks.ts @@ -0,0 +1,60 @@ +import { sdk } from '../sdk' +import { sparkConfigYaml } from '../fileModels/sparkConfig.yaml' + +const { InputSpec, Value } = sdk + +const inputSpec = InputSpec.of({ + spark1_host: Value.text({ + name: 'Spark 1 hostname or IP', + description: 'Head node. Example: ', + required: true, + default: null, + placeholder: '', + masked: false, + }), + spark1_user: Value.text({ + name: 'Spark 1 SSH user', + description: 'Usually "".', + required: true, + default: '', + placeholder: '', + masked: false, + }), + spark2_host: Value.text({ + name: 'Spark 2 hostname or IP', + description: 'Worker node. Example: ', + required: true, + default: null, + placeholder: '', + masked: false, + }), + spark2_user: Value.text({ + name: 'Spark 2 SSH user', + description: 'Usually "".', + required: true, + default: '', + placeholder: '', + masked: false, + }), +}) + +export const configureSparks = sdk.Action.withInput( + 'configure-sparks', + async () => ({ + name: 'Configure Sparks', + description: 'Set the hostnames and SSH users for your two Spark nodes.', + warning: null, + visibility: 'enabled', + allowedStatuses: 'any', + group: null, + }), + async () => inputSpec, + async ({ effects }) => { + const cfg = await sparkConfigYaml.read().once() + return cfg ?? null + }, + async ({ effects, input }) => { + await sparkConfigYaml.merge(effects, input) + return null + }, +) diff --git a/package/startos/actions/index.ts b/package/startos/actions/index.ts new file mode 100644 index 0000000..4c1eb0e --- /dev/null +++ b/package/startos/actions/index.ts @@ -0,0 +1,7 @@ +import { sdk } from '../sdk' +import { configureSparks } from './configureSparks' +import { showPublicKey } from './showPublicKey' + +export const actions = sdk.Actions.of() + .addAction(showPublicKey) + .addAction(configureSparks) diff --git a/package/startos/actions/showPublicKey.ts b/package/startos/actions/showPublicKey.ts new file mode 100644 index 0000000..6427006 --- /dev/null +++ b/package/startos/actions/showPublicKey.ts @@ -0,0 +1,54 @@ +import { sdk } from '../sdk' +import { promises as fs } from 'fs' +import * as path from 'path' + +export const showPublicKey = sdk.Action.withoutInput( + 'show-public-key', + async () => ({ + name: 'Show Public Key', + description: + 'Display the SSH public key. Paste it into ~/.ssh/authorized_keys on each Spark to grant access.', + warning: null, + visibility: 'enabled', + allowedStatuses: 'any', + group: null, + }), + async () => { + // The container generates the key under /data/ssh/id_ed25519.pub on first boot. + // The volume "main" is mounted at the host path that StartOS exposes via `sdk.volumes.main`. + // For an Action running in the host, we read the file directly through the volume path. + const pubKeyPath = path.join( + sdk.volumes.main.path, + 'ssh', + 'id_ed25519.pub', + ) + let key: string + try { + key = (await fs.readFile(pubKeyPath, 'utf8')).trim() + } catch (e) { + return { + version: '1' as const, + title: 'Public Key Not Found', + message: + 'The container has not yet generated its SSH keypair. Start the service, wait a few seconds, and try again.', + result: null, + } + } + return { + version: '1' as const, + title: 'SSH Public Key', + message: + 'Append this single line to ~/.ssh/authorized_keys on EACH Spark ( user):\n\n' + + key, + result: { + type: 'single' as const, + name: 'Public Key', + description: 'Copy this line to each Spark.', + value: key, + masked: false, + copyable: true, + qr: false, + }, + } + }, +) diff --git a/package/startos/backups.ts b/package/startos/backups.ts new file mode 100644 index 0000000..0a90b1e --- /dev/null +++ b/package/startos/backups.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.ofVolumes('main'), +) diff --git a/package/startos/dependencies.ts b/package/startos/dependencies.ts new file mode 100644 index 0000000..7221c4b --- /dev/null +++ b/package/startos/dependencies.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const setDependencies = sdk.setupDependencies( + async ({ effects }) => ({}), +) diff --git a/package/startos/fileModels/sparkConfig.yaml.ts b/package/startos/fileModels/sparkConfig.yaml.ts new file mode 100644 index 0000000..3b7a6e6 --- /dev/null +++ b/package/startos/fileModels/sparkConfig.yaml.ts @@ -0,0 +1,17 @@ +import { FileHelper } from '@start9labs/start-sdk' +import { z } from 'zod' +import { sdk } from '../sdk' + +export const sparkConfigSchema = z.object({ + spark1_host: z.string().catch(''), + spark1_user: z.string().catch(''), + spark2_host: z.string().catch(''), + spark2_user: z.string().catch(''), +}) + +export type SparkConfig = z.infer + +export const sparkConfigYaml = FileHelper.yaml( + { base: sdk.volumes.main, subpath: 'config.yaml' }, + sparkConfigSchema, +) diff --git a/package/startos/i18n/dictionaries/default.ts b/package/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..ea12930 --- /dev/null +++ b/package/startos/i18n/dictionaries/default.ts @@ -0,0 +1,24 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + // main.ts + 'Starting Spark Control…': 0, + 'Web Interface': 1, + 'The web interface is ready': 2, + 'The web interface is not ready': 3, + + // interfaces.ts + 'Web UI': 4, + 'The Spark Control web interface': 5, + + // actions + 'Show Public Key': 6, + 'Configure Sparks': 7, +} as const + +/** + * Plumbing. DO NOT EDIT. + */ +export type I18nKey = keyof typeof dict +export type LangDict = Record<(typeof dict)[I18nKey], string> +export default dict diff --git a/package/startos/i18n/dictionaries/translations.ts b/package/startos/i18n/dictionaries/translations.ts new file mode 100644 index 0000000..98f9348 --- /dev/null +++ b/package/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,3 @@ +import { LangDict } from './default' + +export default {} satisfies Record diff --git a/package/startos/i18n/index.ts b/package/startos/i18n/index.ts new file mode 100644 index 0000000..04cea20 --- /dev/null +++ b/package/startos/i18n/index.ts @@ -0,0 +1,8 @@ +/** + * Plumbing. DO NOT EDIT this file. + */ +import { setupI18n } from '@start9labs/start-sdk' +import defaultDict, { DEFAULT_LANG } from './dictionaries/default' +import translations from './dictionaries/translations' + +export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) diff --git a/package/startos/index.ts b/package/startos/index.ts new file mode 100644 index 0000000..7af589b --- /dev/null +++ b/package/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup } from './backups' +export { main } from './main' +export { init, uninit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versionGraph } from './versions' +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/package/startos/init/index.ts b/package/startos/init/index.ts new file mode 100644 index 0000000..2e671e5 --- /dev/null +++ b/package/startos/init/index.ts @@ -0,0 +1,16 @@ +import { sdk } from '../sdk' +import { setDependencies } from '../dependencies' +import { setInterfaces } from '../interfaces' +import { versionGraph } from '../versions' +import { actions } from '../actions' +import { restoreInit } from '../backups' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + setInterfaces, + setDependencies, + actions, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/package/startos/interfaces.ts b/package/startos/interfaces.ts new file mode 100644 index 0000000..8879678 --- /dev/null +++ b/package/startos/interfaces.ts @@ -0,0 +1,25 @@ +import { i18n } from './i18n' +import { sdk } from './sdk' +import { uiPort } from './utils' + +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + const uiMulti = sdk.MultiHost.of(effects, 'ui-multi') + const uiMultiOrigin = await uiMulti.bindPort(uiPort, { + protocol: 'http', + }) + const ui = sdk.createInterface(effects, { + name: i18n('Web UI'), + id: 'ui', + description: i18n('The Spark Control web interface'), + type: 'ui', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + const uiReceipt = await uiMultiOrigin.export([ui]) + + return [uiReceipt] +}) diff --git a/package/startos/main.ts b/package/startos/main.ts new file mode 100644 index 0000000..e9f7bdd --- /dev/null +++ b/package/startos/main.ts @@ -0,0 +1,50 @@ +import { i18n } from './i18n' +import { sdk } from './sdk' +import { uiPort } from './utils' +import { sparkConfigYaml } from './fileModels/sparkConfig.yaml' + +export const main = sdk.setupMain(async ({ effects }) => { + console.info(i18n('Starting Spark Control…')) + + // Reactively read SSH targets from the user-configured yaml file. + // Changing this file via the "Configure Sparks" action restarts the daemon. + const cfg = (await sparkConfigYaml.read().const(effects)) ?? { + spark1_host: '', + spark1_user: '', + spark2_host: '', + spark2_user: '', + } + + return sdk.Daemons.of(effects).addDaemon('primary', { + subcontainer: await sdk.SubContainer.of( + effects, + { imageId: 'spark-control' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/data', + readonly: false, + }), + 'spark-control-sub', + ), + exec: { + command: ['/app/entrypoint.sh'], + env: { + SPARK1_HOST: cfg.spark1_host, + SPARK1_USER: cfg.spark1_user, + SPARK2_HOST: cfg.spark2_host, + SPARK2_USER: cfg.spark2_user, + BIND_PORT: String(uiPort), + }, + }, + ready: { + display: i18n('Web Interface'), + fn: () => + sdk.healthCheck.checkPortListening(effects, uiPort, { + successMessage: i18n('The web interface is ready'), + errorMessage: i18n('The web interface is not ready'), + }), + }, + requires: [], + }) +}) diff --git a/package/startos/manifest/i18n.ts b/package/startos/manifest/i18n.ts new file mode 100644 index 0000000..c52f8c7 --- /dev/null +++ b/package/startos/manifest/i18n.ts @@ -0,0 +1,8 @@ +export const short = { + en_US: 'Control panel for a DGX Spark vLLM cluster', +} + +export const long = { + en_US: + 'Browser-based control panel for a dual-DGX-Spark vLLM cluster on the LAN. See which model is loaded, swap models with one click, and watch streaming logs until the new model is ready. SSHes into the Spark to run launch-cluster.sh.', +} diff --git a/package/startos/manifest/index.ts b/package/startos/manifest/index.ts new file mode 100644 index 0000000..de886e8 --- /dev/null +++ b/package/startos/manifest/index.ts @@ -0,0 +1,35 @@ +import { setupManifest } from '@start9labs/start-sdk' +import { long, short } from './i18n' + +export const manifest = setupManifest({ + id: 'spark-control', + title: 'Spark Control', + license: 'MIT', + packageRepo: 'https://github.com/grant/spark-control', + upstreamRepo: 'https://github.com/grant/spark-control', + marketingUrl: 'https://github.com/grant/spark-control', + donationUrl: 'https://github.com/grant/spark-control', + docsUrls: [], + description: { short, long }, + volumes: ['main'], + images: { + 'spark-control': { + source: { + dockerBuild: { + dockerfile: '../image/Dockerfile', + workdir: '..', + }, + }, + arch: ['x86_64', 'aarch64'], + }, + }, + alerts: { + install: null, + update: null, + uninstall: null, + restore: null, + start: null, + stop: null, + }, + dependencies: {}, +}) diff --git a/package/startos/sdk.ts b/package/startos/sdk.ts new file mode 100644 index 0000000..a2969a4 --- /dev/null +++ b/package/startos/sdk.ts @@ -0,0 +1,7 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +/** + * Plumbing. DO NOT EDIT. + */ +export const sdk = StartSdk.of().withManifest(manifest).build(true) diff --git a/package/startos/utils.ts b/package/startos/utils.ts new file mode 100644 index 0000000..b9645c5 --- /dev/null +++ b/package/startos/utils.ts @@ -0,0 +1,2 @@ +// Shared constants for the spark-control StartOS package. +export const uiPort = 9999 diff --git a/package/startos/versions/index.ts b/package/startos/versions/index.ts new file mode 100644 index 0000000..beb08df --- /dev/null +++ b/package/startos/versions/index.ts @@ -0,0 +1,7 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { v0_1_0 } from './v0_1_0' + +export const versionGraph = VersionGraph.of({ + current: v0_1_0, + other: [], +}) diff --git a/package/startos/versions/v0_1_0.ts b/package/startos/versions/v0_1_0.ts new file mode 100644 index 0000000..c3fa04a --- /dev/null +++ b/package/startos/versions/v0_1_0.ts @@ -0,0 +1,12 @@ +import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' + +export const v0_1_0 = VersionInfo.of({ + version: '0.1.0:0', + releaseNotes: { + en_US: 'Initial release: swap UI, status, health for Parakeet/Magpie.', + }, + migrations: { + up: async ({ effects }) => {}, + down: IMPOSSIBLE, + }, +}) diff --git a/package/tsconfig.json b/package/tsconfig.json new file mode 100644 index 0000000..a2945a5 --- /dev/null +++ b/package/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +}