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
This commit is contained in:
2026-08-18 22:45:14 -03:00
co-authored by Claude Opus 5
parent 0879e5a163
commit 0506f44db3
80 changed files with 5201 additions and 3 deletions
+142
View File
@@ -0,0 +1,142 @@
//
// Reads and writes Apple Container's system configuration.
//
// The default DNS domain -- the suffix every container's hostname gets -- lives
// in config.toml, and the CLI has no setter for it (`container system property`
// only lists). The file is small and hand-editable, so it is edited in place.
//
const { exec } = require("./Process");
const RELATIVE_PATH = "Library/Application Support/com.apple.container/config/config.toml";
function path() {
let home = nova.environment.HOME;
if (!home) {
return null;
}
return nova.path.join(home, RELATIVE_PATH);
}
function read() {
let file = path();
if (!file || !nova.fs.access(file, nova.fs.R_OK)) {
return "";
}
try {
let handle = nova.fs.open(file, "r");
let contents = handle.read();
handle.close();
return contents || "";
} catch (error) {
return "";
}
}
// Locates the [dns] table's body within a set of lines. A table runs until the
// next table header, which is a line that *starts* with "[" -- an array value
// such as `nameservers = ["1.1.1.1"]` does not end it.
function dnsTable(lines) {
let start = lines.findIndex((line) => /^\s*\[dns\]\s*$/.test(line));
if (start === -1) {
return null;
}
let end = lines.findIndex((line, index) => index > start && /^\s*\[/.test(line));
return { start: start, end: end === -1 ? lines.length : end };
}
// The configured default DNS domain, or null when containers get no suffix.
function defaultDomain() {
let lines = read().split("\n");
let table = dnsTable(lines);
if (!table) {
return null;
}
for (let line of lines.slice(table.start + 1, table.end)) {
let match = line.match(/^\s*domain\s*=\s*["']([^"']*)["']/);
if (match) {
return match[1].length > 0 ? match[1] : null;
}
}
return null;
}
// Rewrites the `domain` key inside the `[dns]` table, adding either as needed.
// Passing null removes the key, which restores unsuffixed hostnames.
function withDomain(contents, domain) {
let assignment = domain === null ? "" : `domain = "${domain}"\n`;
let lines = contents.length > 0 ? contents.split("\n") : [];
let table = dnsTable(lines);
if (!table) {
if (domain === null) {
return contents;
}
let prefix = contents.length > 0 && !contents.endsWith("\n") ? `${contents}\n` : contents;
return `${prefix}\n[dns]\n${assignment}`;
}
let body = lines.slice(table.start + 1, table.end);
let existing = body.findIndex((line) => /^\s*domain\s*=/.test(line));
if (existing === -1) {
if (domain !== null) {
body.unshift(assignment.trimEnd());
}
} else if (domain === null) {
body.splice(existing, 1);
} else {
body[existing] = assignment.trimEnd();
}
return lines
.slice(0, table.start + 1)
.concat(body, lines.slice(table.end))
.join("\n");
}
async function setDefaultDomain(domain) {
let file = path();
if (!file) {
throw new Error("Could not locate Apple Container's configuration directory.");
}
let contents = nova.fs.access(file, nova.fs.F_OK) ? read() : "";
let updated = withDomain(contents, domain);
// The file ships read-only, so widen it, write, and put the mode back.
let existed = nova.fs.access(file, nova.fs.F_OK);
if (existed) {
await exec("/bin/chmod", ["u+w", file]);
} else {
let directory = nova.path.dirname(file);
if (!nova.fs.access(directory, nova.fs.F_OK)) {
nova.fs.mkdir(directory);
}
}
let handle = nova.fs.open(file, "w");
handle.write(updated);
handle.close();
await exec("/bin/chmod", ["444", file]);
return updated;
}
module.exports = { path, read, defaultDomain, setDefaultDomain, withDomain };