570 lines
19 KiB
TypeScript
570 lines
19 KiB
TypeScript
import * as plugins from './tsdocker.plugins.js';
|
|
import * as paths from './tsdocker.paths.js';
|
|
|
|
// modules
|
|
import * as ConfigModule from './tsdocker.config.js';
|
|
|
|
import { logger, ora } from './tsdocker.logging.js';
|
|
import { TsDockerManager } from './classes.tsdockermanager.js';
|
|
import { DockerContext } from './classes.dockercontext.js';
|
|
import { GlobalConfig } from './classes.globalconfig.js';
|
|
import type { IBuildCommandOptions } from './interfaces/index.js';
|
|
import { commitinfo } from './00_commitinfo_data.js';
|
|
|
|
const tsdockerCli = new plugins.smartcli.Smartcli();
|
|
tsdockerCli.addVersion(commitinfo.version);
|
|
|
|
const printManPage = () => {
|
|
const manPage = `
|
|
TSDOCKER(1) User Commands TSDOCKER(1)
|
|
|
|
NAME
|
|
tsdocker - build, test, and push Docker images
|
|
|
|
VERSION
|
|
${commitinfo.version}
|
|
|
|
SYNOPSIS
|
|
tsdocker <command> [options]
|
|
|
|
COMMANDS
|
|
build [patterns...] [flags] Build Dockerfiles in dependency order
|
|
push [patterns...] [flags] Build and push images to registries
|
|
pull <registry-url> Pull images from a registry
|
|
test [flags] Build and run container test scripts
|
|
login Authenticate with configured registries
|
|
list List discovered Dockerfiles
|
|
config <subcommand> [flags] Manage global tsdocker configuration
|
|
clean [-y] [--all] Interactive Docker resource cleanup
|
|
|
|
BUILD / PUSH OPTIONS
|
|
--platform=<p> Target platform (e.g. linux/arm64)
|
|
--timeout=<s> Build timeout in seconds
|
|
--no-cache Rebuild without Docker layer cache
|
|
--cached Skip builds when Dockerfile is unchanged
|
|
--verbose Stream raw docker build output
|
|
--parallel[=<n>] Parallel builds (optional concurrency limit)
|
|
--context=<name> Docker context to use
|
|
|
|
PUSH-ONLY OPTIONS
|
|
--registry=<url> Push to a specific registry
|
|
--no-build Push already-built images (skip build step)
|
|
|
|
CLEAN OPTIONS
|
|
-y Auto-confirm all prompts
|
|
--all Include all images and volumes (not just dangling)
|
|
|
|
CONFIG SUBCOMMANDS
|
|
add-builder Add a remote builder node
|
|
--name=<n> Builder name (e.g. arm64-builder)
|
|
--host=<h> SSH host (e.g. user@192.168.1.100)
|
|
--platform=<p> Platform (e.g. linux/arm64)
|
|
--ssh-key=<path> SSH key path (optional)
|
|
remove-builder Remove a remote builder by name
|
|
--name=<n> Builder name to remove
|
|
list-builders List all configured remote builders
|
|
show Show full global config
|
|
|
|
CONFIGURATION
|
|
Configure via npmextra.json under the "@git.zone/tsdocker" key:
|
|
|
|
registries Array of registry URLs to push to
|
|
registryRepoMap Map of registry URL to repo path overrides
|
|
buildArgEnvMap Map of Docker build-arg names to env var names
|
|
platforms Array of target platforms (default: ["linux/amd64"])
|
|
push Boolean, auto-push after build
|
|
testDir Directory containing test_*.sh scripts
|
|
|
|
Global config is stored at ~/.git.zone/tsdocker/config.json
|
|
and managed via the "config" command.
|
|
|
|
EXAMPLES
|
|
tsdocker build
|
|
tsdocker build Dockerfile_app --platform=linux/arm64
|
|
tsdocker push --registry=ghcr.io
|
|
tsdocker test --verbose
|
|
tsdocker clean -y --all
|
|
tsdocker config add-builder --name=arm64 --host=user@host --platform=linux/arm64
|
|
tsdocker config list-builders
|
|
`;
|
|
console.log(manPage);
|
|
};
|
|
|
|
export let run = () => {
|
|
// Default command: print man page
|
|
tsdockerCli.standardCommand().subscribe(async () => {
|
|
printManPage();
|
|
});
|
|
|
|
/**
|
|
* Build Dockerfiles in dependency order
|
|
* Usage: tsdocker build [Dockerfile_patterns...] [--platform=linux/arm64] [--timeout=600]
|
|
*/
|
|
tsdockerCli.addCommand('build').subscribe(async argvArg => {
|
|
try {
|
|
const config = await ConfigModule.run();
|
|
const manager = new TsDockerManager(config);
|
|
await manager.prepare(argvArg.context as string | undefined);
|
|
|
|
const buildOptions: IBuildCommandOptions = {};
|
|
const patterns = argvArg._.slice(1) as string[];
|
|
if (patterns.length > 0) {
|
|
buildOptions.patterns = patterns;
|
|
}
|
|
if (argvArg.platform) {
|
|
buildOptions.platform = argvArg.platform as string;
|
|
}
|
|
if (argvArg.timeout) {
|
|
buildOptions.timeout = Number(argvArg.timeout);
|
|
}
|
|
if (argvArg.cache === false) {
|
|
buildOptions.noCache = true;
|
|
}
|
|
if (argvArg.cached) {
|
|
buildOptions.cached = true;
|
|
}
|
|
if (argvArg.verbose) {
|
|
buildOptions.verbose = true;
|
|
}
|
|
if (argvArg.parallel) {
|
|
buildOptions.parallel = true;
|
|
if (typeof argvArg.parallel === 'number') {
|
|
buildOptions.parallelConcurrency = argvArg.parallel;
|
|
}
|
|
}
|
|
|
|
await manager.build(buildOptions);
|
|
await manager.cleanup();
|
|
logger.log('success', 'Build completed successfully');
|
|
} catch (err) {
|
|
logger.log('error', `Build failed: ${(err as Error).message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Push built images to configured registries
|
|
* Usage: tsdocker push [Dockerfile_patterns...] [--platform=linux/arm64] [--timeout=600] [--registry=url]
|
|
*/
|
|
tsdockerCli.addCommand('push').subscribe(async argvArg => {
|
|
try {
|
|
const config = await ConfigModule.run();
|
|
const manager = new TsDockerManager(config);
|
|
await manager.prepare(argvArg.context as string | undefined);
|
|
|
|
// Login first
|
|
await manager.login();
|
|
|
|
// Parse build options from positional args and flags
|
|
const buildOptions: IBuildCommandOptions = {};
|
|
const patterns = argvArg._.slice(1) as string[];
|
|
if (patterns.length > 0) {
|
|
buildOptions.patterns = patterns;
|
|
}
|
|
if (argvArg.platform) {
|
|
buildOptions.platform = argvArg.platform as string;
|
|
}
|
|
if (argvArg.timeout) {
|
|
buildOptions.timeout = Number(argvArg.timeout);
|
|
}
|
|
if (argvArg.cache === false) {
|
|
buildOptions.noCache = true;
|
|
}
|
|
if (argvArg.verbose) {
|
|
buildOptions.verbose = true;
|
|
}
|
|
if (argvArg.parallel) {
|
|
buildOptions.parallel = true;
|
|
if (typeof argvArg.parallel === 'number') {
|
|
buildOptions.parallelConcurrency = argvArg.parallel;
|
|
}
|
|
}
|
|
|
|
// Build images first, unless --no-build is set
|
|
if (argvArg.build === false) {
|
|
await manager.discoverDockerfiles();
|
|
if (buildOptions.patterns?.length) {
|
|
manager.filterDockerfiles(buildOptions.patterns);
|
|
}
|
|
} else {
|
|
await manager.build(buildOptions);
|
|
}
|
|
|
|
// Get registry from --registry flag
|
|
const registryArg = argvArg.registry as string | undefined;
|
|
const registries = registryArg ? [registryArg] : undefined;
|
|
|
|
await manager.push(registries);
|
|
await manager.cleanup();
|
|
logger.log('success', 'Push completed successfully');
|
|
} catch (err) {
|
|
logger.log('error', `Push failed: ${(err as Error).message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Pull images from a specified registry
|
|
*/
|
|
tsdockerCli.addCommand('pull').subscribe(async argvArg => {
|
|
try {
|
|
const registryArg = argvArg._[1]; // e.g., tsdocker pull registry.gitlab.com
|
|
if (!registryArg) {
|
|
logger.log('error', 'Registry URL required. Usage: tsdocker pull <registry-url>');
|
|
process.exit(1);
|
|
}
|
|
|
|
const config = await ConfigModule.run();
|
|
const manager = new TsDockerManager(config);
|
|
await manager.prepare(argvArg.context as string | undefined);
|
|
|
|
// Login first
|
|
await manager.login();
|
|
|
|
await manager.pull(registryArg);
|
|
logger.log('success', 'Pull completed successfully');
|
|
} catch (err) {
|
|
logger.log('error', `Pull failed: ${(err as Error).message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Run container tests for all Dockerfiles
|
|
*/
|
|
tsdockerCli.addCommand('test').subscribe(async argvArg => {
|
|
try {
|
|
const config = await ConfigModule.run();
|
|
const manager = new TsDockerManager(config);
|
|
await manager.prepare(argvArg.context as string | undefined);
|
|
|
|
// Build images first
|
|
const buildOptions: IBuildCommandOptions = {};
|
|
if (argvArg.cache === false) {
|
|
buildOptions.noCache = true;
|
|
}
|
|
if (argvArg.cached) {
|
|
buildOptions.cached = true;
|
|
}
|
|
if (argvArg.verbose) {
|
|
buildOptions.verbose = true;
|
|
}
|
|
if (argvArg.parallel) {
|
|
buildOptions.parallel = true;
|
|
if (typeof argvArg.parallel === 'number') {
|
|
buildOptions.parallelConcurrency = argvArg.parallel;
|
|
}
|
|
}
|
|
await manager.build(buildOptions);
|
|
|
|
// Run tests
|
|
await manager.test();
|
|
await manager.cleanup();
|
|
logger.log('success', 'Tests completed successfully');
|
|
} catch (err) {
|
|
logger.log('error', `Tests failed: ${(err as Error).message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Login to configured registries
|
|
*/
|
|
tsdockerCli.addCommand('login').subscribe(async argvArg => {
|
|
try {
|
|
const config = await ConfigModule.run();
|
|
const manager = new TsDockerManager(config);
|
|
await manager.prepare(argvArg.context as string | undefined);
|
|
await manager.login();
|
|
logger.log('success', 'Login completed successfully');
|
|
} catch (err) {
|
|
logger.log('error', `Login failed: ${(err as Error).message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* List discovered Dockerfiles and their dependencies
|
|
*/
|
|
tsdockerCli.addCommand('list').subscribe(async argvArg => {
|
|
try {
|
|
const config = await ConfigModule.run();
|
|
const manager = new TsDockerManager(config);
|
|
await manager.prepare(argvArg.context as string | undefined);
|
|
await manager.list();
|
|
} catch (err) {
|
|
logger.log('error', `List failed: ${(err as Error).message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Manage global tsdocker configuration (remote builders, etc.)
|
|
* Usage: tsdocker config <subcommand> [--name=...] [--host=...] [--platform=...] [--ssh-key=...]
|
|
*/
|
|
tsdockerCli.addCommand('config').subscribe(async argvArg => {
|
|
try {
|
|
const subcommand = argvArg._[1] as string;
|
|
|
|
switch (subcommand) {
|
|
case 'add-builder': {
|
|
const name = argvArg.name as string;
|
|
const host = argvArg.host as string;
|
|
const platform = argvArg.platform as string;
|
|
const sshKeyPath = argvArg['ssh-key'] as string | undefined;
|
|
|
|
if (!name || !host || !platform) {
|
|
logger.log('error', 'Required: --name, --host, --platform');
|
|
logger.log('info', 'Usage: tsdocker config add-builder --name=arm64-builder --host=user@host --platform=linux/arm64 [--ssh-key=~/.ssh/id_ed25519]');
|
|
process.exit(1);
|
|
}
|
|
|
|
GlobalConfig.addBuilder({ name, host, platform, sshKeyPath });
|
|
logger.log('success', `Remote builder "${name}" configured: ${platform} via ssh://${host}`);
|
|
break;
|
|
}
|
|
|
|
case 'remove-builder': {
|
|
const name = argvArg.name as string;
|
|
if (!name) {
|
|
logger.log('error', 'Required: --name');
|
|
logger.log('info', 'Usage: tsdocker config remove-builder --name=arm64-builder');
|
|
process.exit(1);
|
|
}
|
|
GlobalConfig.removeBuilder(name);
|
|
logger.log('success', `Remote builder "${name}" removed`);
|
|
break;
|
|
}
|
|
|
|
case 'list-builders': {
|
|
const builders = GlobalConfig.getBuilders();
|
|
if (builders.length === 0) {
|
|
logger.log('info', 'No remote builders configured');
|
|
} else {
|
|
logger.log('info', `${builders.length} remote builder(s):`);
|
|
for (const b of builders) {
|
|
const keyInfo = b.sshKeyPath ? ` (key: ${b.sshKeyPath})` : '';
|
|
logger.log('info', ` ${b.name}: ${b.platform} via ssh://${b.host}${keyInfo}`);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'show': {
|
|
const config = GlobalConfig.load();
|
|
logger.log('info', `Config file: ${GlobalConfig.getConfigPath()}`);
|
|
console.log(JSON.stringify(config, null, 2));
|
|
break;
|
|
}
|
|
|
|
default:
|
|
logger.log('error', `Unknown config subcommand: ${subcommand || '(none)'}`);
|
|
logger.log('info', 'Available: add-builder, remove-builder, list-builders, show');
|
|
process.exit(1);
|
|
}
|
|
} catch (err) {
|
|
logger.log('error', `Config failed: ${(err as Error).message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
tsdockerCli.addCommand('clean').subscribe(async argvArg => {
|
|
try {
|
|
const autoYes = !!argvArg.y;
|
|
const includeAll = !!argvArg.all;
|
|
|
|
const smartshellInstance = new plugins.smartshell.Smartshell({ executor: 'bash' });
|
|
const interact = new plugins.smartinteract.SmartInteract();
|
|
|
|
// --- Docker context detection ---
|
|
ora.text('detecting docker context...');
|
|
const dockerContext = new DockerContext();
|
|
if (argvArg.context) {
|
|
dockerContext.setContext(argvArg.context as string);
|
|
}
|
|
await dockerContext.detect();
|
|
ora.stop();
|
|
dockerContext.logContextInfo();
|
|
|
|
// --- Helper: parse docker output into resource list ---
|
|
interface IDockerResource {
|
|
id: string;
|
|
display: string;
|
|
}
|
|
|
|
const listResources = async (command: string): Promise<IDockerResource[]> => {
|
|
const result = await smartshellInstance.execSilent(command);
|
|
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
return [];
|
|
}
|
|
return result.stdout.trim().split('\n').filter(Boolean).map((line) => {
|
|
const parts = line.split('\t');
|
|
return {
|
|
id: parts[0],
|
|
display: parts.join(' | '),
|
|
};
|
|
});
|
|
};
|
|
|
|
// --- Helper: checkbox selection ---
|
|
const selectResources = async (
|
|
name: string,
|
|
message: string,
|
|
resources: IDockerResource[],
|
|
): Promise<string[]> => {
|
|
if (autoYes) {
|
|
return resources.map((r) => r.id);
|
|
}
|
|
const answer = await interact.askQuestion({
|
|
name,
|
|
type: 'checkbox',
|
|
message,
|
|
default: [],
|
|
choices: resources.map((r) => ({ name: r.display, value: r.id })),
|
|
});
|
|
return answer.value as string[];
|
|
};
|
|
|
|
// --- Helper: confirm action ---
|
|
const confirmAction = async (
|
|
name: string,
|
|
message: string,
|
|
): Promise<boolean> => {
|
|
if (autoYes) {
|
|
return true;
|
|
}
|
|
const answer = await interact.askQuestion({
|
|
name,
|
|
type: 'confirm',
|
|
message,
|
|
default: false,
|
|
});
|
|
return answer.value as boolean;
|
|
};
|
|
|
|
// === RUNNING CONTAINERS ===
|
|
const runningContainers = await listResources(
|
|
`docker ps --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}'`
|
|
);
|
|
if (runningContainers.length > 0) {
|
|
logger.log('info', `Found ${runningContainers.length} running container(s)`);
|
|
const selectedIds = await selectResources(
|
|
'runningContainers',
|
|
'Select running containers to kill:',
|
|
runningContainers,
|
|
);
|
|
if (selectedIds.length > 0) {
|
|
logger.log('info', `Killing ${selectedIds.length} container(s)...`);
|
|
await smartshellInstance.exec(`docker kill ${selectedIds.join(' ')}`);
|
|
}
|
|
} else {
|
|
logger.log('info', 'No running containers found');
|
|
}
|
|
|
|
// === STOPPED CONTAINERS ===
|
|
const stoppedContainers = await listResources(
|
|
`docker ps -a --filter status=exited --filter status=created --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}'`
|
|
);
|
|
if (stoppedContainers.length > 0) {
|
|
logger.log('info', `Found ${stoppedContainers.length} stopped container(s)`);
|
|
const selectedIds = await selectResources(
|
|
'stoppedContainers',
|
|
'Select stopped containers to remove:',
|
|
stoppedContainers,
|
|
);
|
|
if (selectedIds.length > 0) {
|
|
logger.log('info', `Removing ${selectedIds.length} container(s)...`);
|
|
await smartshellInstance.exec(`docker rm ${selectedIds.join(' ')}`);
|
|
}
|
|
} else {
|
|
logger.log('info', 'No stopped containers found');
|
|
}
|
|
|
|
// === DANGLING IMAGES ===
|
|
const danglingImages = await listResources(
|
|
`docker images -f dangling=true --format '{{.ID}}\t{{.Repository}}:{{.Tag}}\t{{.Size}}'`
|
|
);
|
|
if (danglingImages.length > 0) {
|
|
const confirmed = await confirmAction(
|
|
'removeDanglingImages',
|
|
`Remove ${danglingImages.length} dangling image(s)?`,
|
|
);
|
|
if (confirmed) {
|
|
logger.log('info', `Removing ${danglingImages.length} dangling image(s)...`);
|
|
const ids = danglingImages.map((r) => r.id).join(' ');
|
|
await smartshellInstance.exec(`docker rmi ${ids}`);
|
|
}
|
|
} else {
|
|
logger.log('info', 'No dangling images found');
|
|
}
|
|
|
|
// === ALL IMAGES (only with --all) ===
|
|
if (includeAll) {
|
|
const allImages = await listResources(
|
|
`docker images --format '{{.ID}}\t{{.Repository}}:{{.Tag}}\t{{.Size}}'`
|
|
);
|
|
if (allImages.length > 0) {
|
|
logger.log('info', `Found ${allImages.length} image(s) total`);
|
|
const selectedIds = await selectResources(
|
|
'allImages',
|
|
'Select images to remove:',
|
|
allImages,
|
|
);
|
|
if (selectedIds.length > 0) {
|
|
logger.log('info', `Removing ${selectedIds.length} image(s)...`);
|
|
await smartshellInstance.exec(`docker rmi -f ${selectedIds.join(' ')}`);
|
|
}
|
|
} else {
|
|
logger.log('info', 'No images found');
|
|
}
|
|
}
|
|
|
|
// === DANGLING VOLUMES ===
|
|
const danglingVolumes = await listResources(
|
|
`docker volume ls -f dangling=true --format '{{.Name}}\t{{.Driver}}'`
|
|
);
|
|
if (danglingVolumes.length > 0) {
|
|
const confirmed = await confirmAction(
|
|
'removeDanglingVolumes',
|
|
`Remove ${danglingVolumes.length} dangling volume(s)?`,
|
|
);
|
|
if (confirmed) {
|
|
logger.log('info', `Removing ${danglingVolumes.length} dangling volume(s)...`);
|
|
const names = danglingVolumes.map((r) => r.id).join(' ');
|
|
await smartshellInstance.exec(`docker volume rm ${names}`);
|
|
}
|
|
} else {
|
|
logger.log('info', 'No dangling volumes found');
|
|
}
|
|
|
|
// === ALL VOLUMES (only with --all) ===
|
|
if (includeAll) {
|
|
const allVolumes = await listResources(
|
|
`docker volume ls --format '{{.Name}}\t{{.Driver}}'`
|
|
);
|
|
if (allVolumes.length > 0) {
|
|
logger.log('info', `Found ${allVolumes.length} volume(s) total`);
|
|
const selectedIds = await selectResources(
|
|
'allVolumes',
|
|
'Select volumes to remove:',
|
|
allVolumes,
|
|
);
|
|
if (selectedIds.length > 0) {
|
|
logger.log('info', `Removing ${selectedIds.length} volume(s)...`);
|
|
await smartshellInstance.exec(`docker volume rm ${selectedIds.join(' ')}`);
|
|
}
|
|
} else {
|
|
logger.log('info', 'No volumes found');
|
|
}
|
|
}
|
|
|
|
logger.log('success', 'Docker cleanup completed!');
|
|
} catch (err) {
|
|
logger.log('error', `Clean failed: ${(err as Error).message}`);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
tsdockerCli.startParse();
|
|
};
|