Files
thatguygriffandClaude Opus 5 0506f44db3 Build the Apple Container extension
Turns the empty scaffold into a working Nova extension for Apple Container
and container-compose, in the shape of the Docker Suite extension.

Sidebar (Scripts/Sidebar):
- System, Containers, Images, Volumes and Networks sections
- Containers grouped into compose projects via the com.docker.compose.project
  label container-compose stamps; project rows drive compose up/down/build
  against the matching workspace file
- Lifecycle, shell, logs, inspect, browse and copy commands per resource
- Apple Container publishes no event stream, so the sidebar polls while
  visible and only reloads a section when a fingerprint of its data changes,
  which keeps selection and expansion intact

Hostnames (Scripts/Hostnames.js):
- A container's hostname is <name>.<domain> and the domain lives in Apple
  Container's config.toml, which has no CLI setter, so the Hostname Domain
  preference reads and writes that file directly
- Changing it offers to register the domain with macOS and restart the
  services; the write preserves the file's other tables and its mode

Language support:
- Dockerfile and Compose syntaxes backed by tree-sitter grammars built by
  Tools/build-syntaxes.sh, pinned to revisions that generate ABI 14
- The Compose syntax is named dockercompose because that is the languageId
  docker-language-server recognises compose files by
- docker-language-server is downloaded on first use rather than bundled, at
  roughly 40 MB

Icons are generated by Tools/make-icons.py so they can be regenerated rather
than hand-maintained.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UnSBcR5Lywz5Fbj5FmVbfc
2026-08-18 22:45:14 -03:00

184 lines
4.4 KiB
JavaScript

//
// Language support for Dockerfiles and Compose files, backed by
// docker-language-server (github.com/docker/docker-language-server).
//
// The binary is a single ~40 MB executable, which is too large to ship inside
// the extension, so the managed mode downloads the pinned release into the
// extension's global storage the first time it is needed.
//
const { exec } = require("../Utilities/Process");
const notify = require("../Utilities/Notify");
const RELEASE_URL = "https://github.com/docker/docker-language-server/releases/download";
// docker-language-server decides what a document is from the languageId the
// editor sends, and Nova sends the syntax name. Compose support only switches
// on for "dockercompose", which is why the Compose syntax carries that name.
const SYNTAXES = ["dockerfile", "dockercompose"];
class DockerLanguageClient {
constructor() {
this.client = null;
this.disposables = new CompositeDisposable();
for (let key of ["apple_container.lsp.mode", "apple_container.lsp.path", "apple_container.lsp.version"]) {
this.disposables.add(nova.config.onDidChange(key, () => this.restart()));
}
}
get mode() {
return nova.config.get("apple_container.lsp.mode", "string") || "Managed Binary";
}
get version() {
return (nova.config.get("apple_container.lsp.version", "string") || "").trim() || "v0.20.1";
}
async start() {
if (this.mode === "Disabled") {
return;
}
let path = null;
try {
path = this.mode === "Custom Path" ? this.customPath() : await this.managedPath();
} catch (error) {
notify.configFailure("Docker Language Server unavailable", error, "apple-container-lsp");
return;
}
if (!path) {
return;
}
this.stop();
try {
this.client = new LanguageClient(
"unsupervised.AppleContainer.dls",
"Docker Language Server",
{
path: path,
args: ["start", "--stdio"],
type: "stdio"
},
{
syntaxes: SYNTAXES
}
);
this.client.onDidStop((error) => {
if (error) {
notify.failure(
"Docker Language Server stopped",
error,
"apple-container-lsp-stopped"
);
}
});
this.client.start();
nova.subscriptions.add(this.client);
} catch (error) {
notify.failure("Docker Language Server failed to launch", error, "apple-container-lsp");
this.client = null;
}
}
stop() {
if (!this.client) {
return;
}
this.client.stop();
nova.subscriptions.remove(this.client);
this.client = null;
}
async restart() {
this.stop();
await this.start();
}
dispose() {
this.stop();
this.disposables.dispose();
}
customPath() {
let path = (nova.config.get("apple_container.lsp.path", "string") || "").trim();
if (!path) {
throw new Error("Set the path to docker-language-server in the extension's preferences.");
}
if (!nova.fs.access(path, nova.fs.X_OK)) {
throw new Error(`${path} is not an executable file.`);
}
return path;
}
// Returns the managed binary, downloading it when it is missing.
async managedPath() {
let architecture = await this.architecture();
let name = `docker-language-server-darwin-${architecture}-${this.version}`;
let directory = nova.extension.globalStoragePath;
let path = nova.path.join(directory, name);
if (nova.fs.access(path, nova.fs.X_OK)) {
return path;
}
if (!nova.fs.access(directory, nova.fs.F_OK)) {
nova.fs.mkdir(directory);
}
await this.download(`${RELEASE_URL}/${this.version}/${name}`, path);
return path;
}
async architecture() {
let result = await exec("/usr/bin/uname", ["-m"]);
return result.stdout.trim() === "x86_64" ? "amd64" : "arm64";
}
async download(url, destination) {
notify.info(
"Downloading Docker Language Server",
`${this.version} for Dockerfile and Compose support. This happens once.`,
"apple-container-lsp-download"
);
// A partial file would look like a working binary on the next launch,
// so download beside the target and move it into place at the end.
let temporary = `${destination}.download`;
try {
await exec("/usr/bin/curl", [
"--fail",
"--location",
"--silent",
"--show-error",
"--output",
temporary,
url
]);
await exec("/bin/chmod", ["+x", temporary]);
nova.fs.move(temporary, destination);
} catch (error) {
try {
nova.fs.remove(temporary);
} catch (cleanupError) {
// Nothing to clean up.
}
throw new Error(`Could not download ${url}: ${error.message}`);
}
}
}
module.exports = { DockerLanguageClient };