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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user