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
+107
View File
@@ -0,0 +1,107 @@
//
// Formatting helpers shared by the tree items.
//
const UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
function bytes(value) {
if (typeof value !== "number" || !isFinite(value) || value < 0) {
return "";
}
let index = 0;
let size = value;
while (size >= 1000 && index < UNITS.length - 1) {
size = size / 1000;
index += 1;
}
let precision = size >= 100 || index === 0 ? 0 : 1;
return `${size.toFixed(precision)} ${UNITS[index]}`;
}
// Relative age, in the same spirit as Docker's "2 hours ago".
function age(value) {
if (!value) {
return "";
}
let date = value instanceof Date ? value : new Date(value);
if (isNaN(date.getTime()) || date.getTime() === 0) {
return "";
}
let seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
if (seconds < 60) {
return "just now";
}
let steps = [
["minute", 60],
["hour", 60],
["day", 24],
["week", 7],
["month", 4.35],
["year", 12]
];
let amount = seconds;
let label = "second";
for (let [name, divisor] of steps) {
if (amount < divisor) {
break;
}
amount = amount / divisor;
label = name;
}
let rounded = Math.floor(amount);
return `${rounded} ${label}${rounded === 1 ? "" : "s"} ago`;
}
// Splits an image reference into its registry, repository and tag.
function reference(value) {
let text = String(value || "");
let digest = null;
let tag = null;
let atIndex = text.lastIndexOf("@");
if (atIndex > 0) {
digest = text.slice(atIndex + 1);
text = text.slice(0, atIndex);
}
let colonIndex = text.lastIndexOf(":");
let slashIndex = text.lastIndexOf("/");
if (colonIndex > slashIndex) {
tag = text.slice(colonIndex + 1);
text = text.slice(0, colonIndex);
}
let registry = null;
let repository = text;
let firstSlash = text.indexOf("/");
if (firstSlash > 0) {
let candidate = text.slice(0, firstSlash);
if (candidate.includes(".") || candidate.includes(":") || candidate === "localhost") {
registry = candidate;
repository = text.slice(firstSlash + 1);
}
}
return {
registry: registry,
repository: repository,
tag: tag || "latest",
digest: digest,
// docker.io/library/nginx reads better as nginx.
short: repository.replace(/^library\//, "")
};
}
module.exports = { bytes, age, reference };
+53
View File
@@ -0,0 +1,53 @@
//
// Notification helpers. Each notification uses a stable identifier so that
// repeated failures replace one another rather than stacking up.
//
function notify(identifier, title, body, actions) {
let request = new NotificationRequest(identifier);
request.title = title;
request.body = body;
if (actions && actions.length > 0) {
request.actions = actions.map((action) => action.title);
}
return nova.notifications.add(request).then((reply) => {
if (!actions || reply.actionIdx === undefined || reply.actionIdx === null) {
return reply;
}
let action = actions[reply.actionIdx];
if (action && action.handler) {
action.handler();
}
return reply;
});
}
// Reports a failed operation, logging the underlying error for the console.
function failure(title, error, identifier) {
let message = error instanceof Error ? error.message : String(error || "");
if (nova.inDevMode()) {
console.error(title, message);
}
return notify(identifier || "apple-container-failure", title, message);
}
// Reports a failure with a shortcut to the extension's preferences.
function configFailure(title, error, identifier) {
let message = error instanceof Error ? error.message : String(error || "");
return notify(identifier || "apple-container-config", title, message, [
{ title: "Preferences", handler: () => nova.openConfig() },
{ title: "Dismiss" }
]);
}
function info(title, body, identifier) {
return notify(identifier || "apple-container-info", title, body);
}
module.exports = { notify, failure, configFailure, info };
+89
View File
@@ -0,0 +1,89 @@
//
// Thin promise wrappers around Nova's Process API.
//
// Runs a command to completion and resolves with its captured output.
// Rejects with an Error carrying the exit status and stderr when the
// command fails, so callers can surface a useful message.
function exec(path, args, options = {}) {
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
let process;
try {
process = new Process(path, {
args: args,
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"]
});
} catch (error) {
reject(error);
return;
}
process.onStdout((line) => {
stdout += line;
});
process.onStderr((line) => {
stderr += line;
});
process.onDidExit((status) => {
if (status === 0 || options.ignoreStatus) {
resolve({ status: status, stdout: stdout, stderr: stderr });
return;
}
let error = new Error(cleanMessage(stderr) || `${path} exited with status ${status}.`);
error.status = status;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
});
try {
process.start();
} catch (error) {
reject(error);
}
});
}
// Runs a command and parses its stdout as JSON.
async function execJSON(path, args, options = {}) {
let result = await exec(path, args, options);
if (!result.stdout.trim()) {
return [];
}
try {
return JSON.parse(result.stdout);
} catch (error) {
throw new Error(`Unable to parse output of ${path}: ${error.message}`);
}
}
// Apple Container prefixes failures with "Error: " and appends a hint about
// starting the system services. Keep the first meaningful line only.
function cleanMessage(stderr) {
if (!stderr) {
return "";
}
let line = stderr
.split("\n")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)[0];
if (!line) {
return "";
}
return line.replace(/^Error:\s*/, "");
}
module.exports = { exec, execJSON, cleanMessage };
+34
View File
@@ -0,0 +1,34 @@
//
// Promise wrappers around Nova's workspace prompts.
//
// Asks for confirmation before something destructive. Resolves true when the
// destructive button is chosen.
function confirm(message, buttonTitle = "Delete") {
return new Promise((resolve) => {
nova.workspace.showActionPanel(
message,
{ buttons: [buttonTitle, "Cancel"] },
(index) => resolve(index === 0)
);
});
}
// Asks for a single line of text. Resolves null when cancelled, and the
// trimmed text otherwise -- which may be an empty string.
function input(label, options = {}) {
return new Promise((resolve) => {
nova.workspace.showInputPanel(label, options, (value) => {
resolve(value === null || value === undefined ? null : value.trim());
});
});
}
// Asks the user to pick from a list. Resolves null when cancelled.
function choose(choices, options = {}) {
return new Promise((resolve) => {
nova.workspace.showChoicePalette(choices, options, (choice) => resolve(choice || null));
});
}
module.exports = { confirm, input, choose };
+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 };