@git.zone/tsdocker 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@git.zone/tsdocker",
3
- "version": "2.0.2",
3
+ "version": "2.2.0",
4
4
  "private": false,
5
5
  "description": "develop npm modules cross platform with docker",
6
6
  "main": "dist_ts/index.js",
@@ -27,22 +27,22 @@
27
27
  },
28
28
  "homepage": "https://gitlab.com/gitzone/tsdocker#readme",
29
29
  "devDependencies": {
30
- "@git.zone/tsbuild": "^4.1.2",
30
+ "@git.zone/tsbuild": "^4.3.0",
31
31
  "@git.zone/tsrun": "^2.0.1",
32
- "@git.zone/tstest": "^3.1.6",
33
- "@types/node": "^25.0.9"
32
+ "@git.zone/tstest": "^3.3.2",
33
+ "@types/node": "^25.5.0"
34
34
  },
35
35
  "dependencies": {
36
- "@push.rocks/lik": "^6.2.2",
36
+ "@push.rocks/lik": "^6.3.1",
37
37
  "@push.rocks/npmextra": "^5.3.3",
38
38
  "@push.rocks/projectinfo": "^5.0.2",
39
39
  "@push.rocks/smartcli": "^4.0.20",
40
- "@push.rocks/smartfs": "^1.3.1",
40
+ "@push.rocks/smartfs": "^1.5.0",
41
41
  "@push.rocks/smartinteract": "^2.0.16",
42
- "@push.rocks/smartlog": "^3.1.10",
42
+ "@push.rocks/smartlog": "^3.2.1",
43
43
  "@push.rocks/smartlog-destination-local": "^9.0.2",
44
44
  "@push.rocks/smartlog-source-ora": "^1.0.9",
45
- "@push.rocks/smartshell": "^3.3.0"
45
+ "@push.rocks/smartshell": "^3.3.7"
46
46
  },
47
47
  "packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
48
48
  "type": "module",
package/readme.md CHANGED
@@ -93,6 +93,7 @@ tsdocker push --no-build Dockerfile_api Dockerfile_web
93
93
  | `tsdocker test` | Build + run container test scripts (`test_*.sh`) |
94
94
  | `tsdocker login` | Authenticate with configured registries |
95
95
  | `tsdocker list` | Display discovered Dockerfiles and their dependencies |
96
+ | `tsdocker config` | Manage global tsdocker configuration (remote builders, etc.) |
96
97
  | `tsdocker clean` | Interactively clean Docker environment |
97
98
 
98
99
  ### Build Flags
@@ -117,6 +118,24 @@ tsdocker push --no-build Dockerfile_api Dockerfile_web
117
118
  | `--registry=<url>` | Push to a single specific registry instead of all configured |
118
119
  | `--no-build` | Skip the build phase; only push existing images from local registry |
119
120
 
121
+ ### Config Subcommands
122
+
123
+ | Subcommand | Description |
124
+ |------------|-------------|
125
+ | `add-builder` | Add or update a remote builder node |
126
+ | `remove-builder` | Remove a remote builder by name |
127
+ | `list-builders` | List all configured remote builders |
128
+ | `show` | Show the full global configuration |
129
+
130
+ **`add-builder` flags:**
131
+
132
+ | Flag | Description |
133
+ |------|-------------|
134
+ | `--name=<name>` | Builder name (e.g. `arm64-builder`) |
135
+ | `--host=<user@ip>` | SSH host (e.g. `armbuilder@192.168.1.100`) |
136
+ | `--platform=<p>` | Target platform (e.g. `linux/arm64`) |
137
+ | `--ssh-key=<path>` | SSH key path (optional, uses SSH agent/config by default) |
138
+
120
139
  ### Clean Flags
121
140
 
122
141
  | Flag | Description |
@@ -294,6 +313,51 @@ tsdocker automatically:
294
313
  - Pushes multi-platform images to the local registry via `buildx --push`
295
314
  - Copies the full manifest list (including all platform variants) to remote registries on `tsdocker push`
296
315
 
316
+ ### 🖥️ Native Remote Builders
317
+
318
+ Instead of relying on slow QEMU emulation for cross-platform builds, tsdocker can use **native remote machines** via SSH as build nodes. For example, use a real arm64 machine for `linux/arm64` builds:
319
+
320
+ ```bash
321
+ # Add a remote arm64 builder
322
+ tsdocker config add-builder \
323
+ --name=arm64-builder \
324
+ --host=armbuilder@192.168.1.100 \
325
+ --platform=linux/arm64 \
326
+ --ssh-key=~/.ssh/id_ed25519
327
+
328
+ # List configured builders
329
+ tsdocker config list-builders
330
+
331
+ # Remove a builder
332
+ tsdocker config remove-builder --name=arm64-builder
333
+
334
+ # Show full global config
335
+ tsdocker config show
336
+ ```
337
+
338
+ Global configuration is stored at `~/.git.zone/tsdocker/config.json`.
339
+
340
+ **How it works:**
341
+
342
+ When remote builders are configured and the project's `platforms` includes a matching platform, tsdocker automatically:
343
+
344
+ 1. Creates a **multi-node buildx builder** — local node for `linux/amd64`, remote SSH node for `linux/arm64`
345
+ 2. Opens **SSH reverse tunnels** so the remote builder can push to the local staging registry
346
+ 3. Builds natively on each platform's hardware — no QEMU overhead
347
+ 4. Tears down tunnels after the build completes
348
+
349
+ ```
350
+ [Local machine] [Remote arm64 machine]
351
+ registry:2 on localhost:PORT <──── SSH reverse tunnel ──── localhost:PORT
352
+ BuildKit (amd64) ──push──> BuildKit (arm64) ──push──>
353
+ localhost:PORT localhost:PORT (tunneled)
354
+ ```
355
+
356
+ **Prerequisites for the remote machine:**
357
+ - Docker installed and running
358
+ - A user with Docker group access (no sudo needed)
359
+ - SSH key access configured
360
+
297
361
  ### ⚡ Parallel Builds
298
362
 
299
363
  Speed up builds by building independent images concurrently:
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@git.zone/tsdocker',
6
- version: '2.0.2',
6
+ version: '2.2.0',
7
7
  description: 'develop npm modules cross platform with docker'
8
8
  }
@@ -266,12 +266,15 @@ export class Dockerfile {
266
266
  public static async buildDockerfiles(
267
267
  sortedArrayArg: Dockerfile[],
268
268
  session: TsDockerSession,
269
- options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean; isRootless?: boolean; parallel?: boolean; parallelConcurrency?: number },
269
+ options?: { platform?: string; timeout?: number; noCache?: boolean; pull?: boolean; verbose?: boolean; isRootless?: boolean; parallel?: boolean; parallelConcurrency?: number; onRegistryStarted?: () => Promise<void>; onBeforeRegistryStop?: () => Promise<void> },
270
270
  ): Promise<Dockerfile[]> {
271
271
  const total = sortedArrayArg.length;
272
272
  const overallStart = Date.now();
273
273
 
274
274
  await Dockerfile.startLocalRegistry(session, options?.isRootless);
275
+ if (options?.onRegistryStarted) {
276
+ await options.onRegistryStarted();
277
+ }
275
278
 
276
279
  try {
277
280
  if (options?.parallel) {
@@ -351,6 +354,9 @@ export class Dockerfile {
351
354
  }
352
355
  }
353
356
  } finally {
357
+ if (options?.onBeforeRegistryStop) {
358
+ await options.onBeforeRegistryStop();
359
+ }
354
360
  await Dockerfile.stopLocalRegistry(session);
355
361
  }
356
362
 
@@ -662,13 +668,14 @@ export class Dockerfile {
662
668
  /**
663
669
  * Builds the Dockerfile
664
670
  */
665
- public async build(options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean }): Promise<number> {
671
+ public async build(options?: { platform?: string; timeout?: number; noCache?: boolean; pull?: boolean; verbose?: boolean }): Promise<number> {
666
672
  const startTime = Date.now();
667
673
  const buildArgsString = await Dockerfile.getDockerBuildArgs(this.managerRef);
668
674
  const config = this.managerRef.config;
669
675
  const platformOverride = options?.platform;
670
676
  const timeout = options?.timeout;
671
677
  const noCacheFlag = options?.noCache ? ' --no-cache' : '';
678
+ const pullFlag = options?.pull !== false ? ' --pull' : '';
672
679
  const verbose = options?.verbose ?? false;
673
680
 
674
681
  let buildContextFlag = '';
@@ -683,23 +690,24 @@ export class Dockerfile {
683
690
  }
684
691
 
685
692
  let buildCommand: string;
693
+ const builderFlag = this.managerRef.currentBuilderName ? ` --builder ${this.managerRef.currentBuilderName}` : '';
686
694
 
687
695
  if (platformOverride) {
688
696
  // Single platform override via buildx
689
- buildCommand = `docker buildx build --progress=plain --platform ${platformOverride}${noCacheFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
697
+ buildCommand = `docker buildx build${builderFlag} --progress=plain --platform ${platformOverride}${noCacheFlag}${pullFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
690
698
  logger.log('info', `Build: buildx --platform ${platformOverride} --load`);
691
699
  } else if (config.platforms && config.platforms.length > 1) {
692
700
  // Multi-platform build using buildx — always push to local registry
693
701
  const platformString = config.platforms.join(',');
694
702
  const registryHost = this.session?.config.registryHost || 'localhost:5234';
695
703
  const localTag = `${registryHost}/${this.buildTag}`;
696
- buildCommand = `docker buildx build --progress=plain --platform ${platformString}${noCacheFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`;
704
+ buildCommand = `docker buildx build${builderFlag} --progress=plain --platform ${platformString}${noCacheFlag}${pullFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`;
697
705
  this.localRegistryTag = localTag;
698
706
  logger.log('info', `Build: buildx --platform ${platformString} --push to local registry`);
699
707
  } else {
700
708
  // Standard build
701
709
  const versionLabel = this.managerRef.projectInfo?.npm?.version || 'unknown';
702
- buildCommand = `docker build --progress=plain --label="version=${versionLabel}"${noCacheFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
710
+ buildCommand = `docker build --progress=plain --label="version=${versionLabel}"${noCacheFlag}${pullFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
703
711
  logger.log('info', 'Build: docker build (standard)');
704
712
  }
705
713
 
@@ -0,0 +1,76 @@
1
+ import * as fs from 'fs';
2
+ import * as plugins from './tsdocker.plugins.js';
3
+ import { logger } from './tsdocker.logging.js';
4
+ import type { IGlobalConfig, IRemoteBuilder } from './interfaces/index.js';
5
+
6
+ const CONFIG_DIR = plugins.path.join(
7
+ process.env.HOME || process.env.USERPROFILE || '~',
8
+ '.git.zone',
9
+ 'tsdocker',
10
+ );
11
+ const CONFIG_PATH = plugins.path.join(CONFIG_DIR, 'config.json');
12
+
13
+ const DEFAULT_CONFIG: IGlobalConfig = {
14
+ remoteBuilders: [],
15
+ };
16
+
17
+ export class GlobalConfig {
18
+ static getConfigPath(): string {
19
+ return CONFIG_PATH;
20
+ }
21
+
22
+ static load(): IGlobalConfig {
23
+ try {
24
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
25
+ const parsed = JSON.parse(raw);
26
+ return {
27
+ ...DEFAULT_CONFIG,
28
+ ...parsed,
29
+ };
30
+ } catch {
31
+ return { ...DEFAULT_CONFIG };
32
+ }
33
+ }
34
+
35
+ static save(config: IGlobalConfig): void {
36
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
37
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf-8');
38
+ }
39
+
40
+ static addBuilder(builder: IRemoteBuilder): void {
41
+ const config = GlobalConfig.load();
42
+ const existing = config.remoteBuilders.findIndex((b) => b.name === builder.name);
43
+ if (existing >= 0) {
44
+ config.remoteBuilders[existing] = builder;
45
+ logger.log('info', `Updated remote builder: ${builder.name}`);
46
+ } else {
47
+ config.remoteBuilders.push(builder);
48
+ logger.log('info', `Added remote builder: ${builder.name}`);
49
+ }
50
+ GlobalConfig.save(config);
51
+ }
52
+
53
+ static removeBuilder(name: string): void {
54
+ const config = GlobalConfig.load();
55
+ const before = config.remoteBuilders.length;
56
+ config.remoteBuilders = config.remoteBuilders.filter((b) => b.name !== name);
57
+ if (config.remoteBuilders.length < before) {
58
+ logger.log('info', `Removed remote builder: ${name}`);
59
+ } else {
60
+ logger.log('warn', `Remote builder not found: ${name}`);
61
+ }
62
+ GlobalConfig.save(config);
63
+ }
64
+
65
+ static getBuilders(): IRemoteBuilder[] {
66
+ return GlobalConfig.load().remoteBuilders;
67
+ }
68
+
69
+ /**
70
+ * Returns remote builders that match any of the requested platforms
71
+ */
72
+ static getBuildersForPlatforms(platforms: string[]): IRemoteBuilder[] {
73
+ const builders = GlobalConfig.getBuilders();
74
+ return builders.filter((b) => platforms.includes(b.platform));
75
+ }
76
+ }
@@ -0,0 +1,77 @@
1
+ import * as plugins from './tsdocker.plugins.js';
2
+ import { logger } from './tsdocker.logging.js';
3
+ import type { IRemoteBuilder } from './interfaces/index.js';
4
+
5
+ const smartshellInstance = new plugins.smartshell.Smartshell({
6
+ executor: 'bash',
7
+ });
8
+
9
+ /**
10
+ * Manages SSH reverse tunnels for remote builder nodes.
11
+ * Opens tunnels so that the local staging registry (localhost:<port>)
12
+ * is accessible as localhost:<port> on each remote machine.
13
+ */
14
+ export class SshTunnelManager {
15
+ private tunnelPids: number[] = [];
16
+
17
+ /**
18
+ * Opens a reverse SSH tunnel to make localPort accessible on the remote machine.
19
+ * ssh -f -N -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes
20
+ * -R <localPort>:localhost:<localPort> [-i keyPath] user@host
21
+ */
22
+ async openTunnel(builder: IRemoteBuilder, localPort: number): Promise<void> {
23
+ const keyOpt = builder.sshKeyPath ? `-i ${builder.sshKeyPath} ` : '';
24
+ const cmd = [
25
+ 'ssh -f -N',
26
+ '-o StrictHostKeyChecking=no',
27
+ '-o ExitOnForwardFailure=yes',
28
+ `-R ${localPort}:localhost:${localPort}`,
29
+ `${keyOpt}${builder.host}`,
30
+ ].join(' ');
31
+
32
+ logger.log('info', `Opening SSH tunnel to ${builder.host} for port ${localPort}...`);
33
+ const result = await smartshellInstance.exec(cmd);
34
+
35
+ if (result.exitCode !== 0) {
36
+ throw new Error(
37
+ `Failed to open SSH tunnel to ${builder.host}: ${result.stderr || 'unknown error'}`
38
+ );
39
+ }
40
+
41
+ // Find the PID of the tunnel process we just started
42
+ const pidResult = await smartshellInstance.exec(
43
+ `pgrep -f "ssh.*-R ${localPort}:localhost:${localPort}.*${builder.host}" | tail -1`
44
+ );
45
+ if (pidResult.exitCode === 0 && pidResult.stdout.trim()) {
46
+ const pid = parseInt(pidResult.stdout.trim(), 10);
47
+ if (!isNaN(pid)) {
48
+ this.tunnelPids.push(pid);
49
+ logger.log('ok', `SSH tunnel to ${builder.host} established (PID ${pid})`);
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Opens tunnels for all provided remote builders
56
+ */
57
+ async openTunnels(builders: IRemoteBuilder[], localPort: number): Promise<void> {
58
+ for (const builder of builders) {
59
+ await this.openTunnel(builder, localPort);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Closes all tunnel processes
65
+ */
66
+ async closeAll(): Promise<void> {
67
+ for (const pid of this.tunnelPids) {
68
+ try {
69
+ process.kill(pid, 'SIGTERM');
70
+ logger.log('info', `Closed SSH tunnel (PID ${pid})`);
71
+ } catch {
72
+ // Process may have already exited
73
+ }
74
+ }
75
+ this.tunnelPids = [];
76
+ }
77
+ }
@@ -8,7 +8,9 @@ import { TsDockerCache } from './classes.tsdockercache.js';
8
8
  import { DockerContext } from './classes.dockercontext.js';
9
9
  import { TsDockerSession } from './classes.tsdockersession.js';
10
10
  import { RegistryCopy } from './classes.registrycopy.js';
11
- import type { ITsDockerConfig, IBuildCommandOptions } from './interfaces/index.js';
11
+ import { GlobalConfig } from './classes.globalconfig.js';
12
+ import { SshTunnelManager } from './classes.sshtunnel.js';
13
+ import type { ITsDockerConfig, IBuildCommandOptions, IRemoteBuilder } from './interfaces/index.js';
12
14
 
13
15
  const smartshellInstance = new plugins.smartshell.Smartshell({
14
16
  executor: 'bash',
@@ -23,7 +25,10 @@ export class TsDockerManager {
23
25
  public projectInfo: any;
24
26
  public dockerContext: DockerContext;
25
27
  public session!: TsDockerSession;
28
+ public currentBuilderName?: string;
26
29
  private dockerfiles: Dockerfile[] = [];
30
+ private activeRemoteBuilders: IRemoteBuilder[] = [];
31
+ private sshTunnelManager?: SshTunnelManager;
27
32
 
28
33
  constructor(config: ITsDockerConfig) {
29
34
  this.config = config;
@@ -235,6 +240,7 @@ export class TsDockerManager {
235
240
  const total = toBuild.length;
236
241
  const overallStart = Date.now();
237
242
  await Dockerfile.startLocalRegistry(this.session, this.dockerContext.contextInfo?.isRootless);
243
+ await this.openRemoteTunnels();
238
244
 
239
245
  try {
240
246
  if (options?.parallel) {
@@ -261,6 +267,7 @@ export class TsDockerManager {
261
267
  platform: options?.platform,
262
268
  timeout: options?.timeout,
263
269
  noCache: options?.noCache,
270
+ pull: options?.pull,
264
271
  verbose: options?.verbose,
265
272
  });
266
273
  logger.log('ok', `${progress} Built ${df.cleanTag} in ${formatDuration(elapsed)}`);
@@ -306,6 +313,7 @@ export class TsDockerManager {
306
313
  platform: options?.platform,
307
314
  timeout: options?.timeout,
308
315
  noCache: options?.noCache,
316
+ pull: options?.pull,
309
317
  verbose: options?.verbose,
310
318
  });
311
319
  logger.log('ok', `${progress} Built ${dockerfileArg.cleanTag} in ${formatDuration(elapsed)}`);
@@ -332,6 +340,7 @@ export class TsDockerManager {
332
340
  }
333
341
  }
334
342
  } finally {
343
+ await this.closeRemoteTunnels();
335
344
  await Dockerfile.stopLocalRegistry(this.session);
336
345
  }
337
346
 
@@ -343,10 +352,13 @@ export class TsDockerManager {
343
352
  platform: options?.platform,
344
353
  timeout: options?.timeout,
345
354
  noCache: options?.noCache,
355
+ pull: options?.pull,
346
356
  verbose: options?.verbose,
347
357
  isRootless: this.dockerContext.contextInfo?.isRootless,
348
358
  parallel: options?.parallel,
349
359
  parallelConcurrency: options?.parallelConcurrency,
360
+ onRegistryStarted: () => this.openRemoteTunnels(),
361
+ onBeforeRegistryStop: () => this.closeRemoteTunnels(),
350
362
  });
351
363
  }
352
364
 
@@ -373,35 +385,120 @@ export class TsDockerManager {
373
385
  }
374
386
 
375
387
  /**
376
- * Ensures Docker buildx is set up for multi-architecture builds
388
+ * Ensures Docker buildx is set up for multi-architecture builds.
389
+ * When remote builders are configured in the global config, creates a multi-node
390
+ * builder with native nodes instead of relying on QEMU emulation.
377
391
  */
378
392
  private async ensureBuildx(): Promise<void> {
379
393
  const builderName = this.dockerContext.getBuilderName() + (this.session?.config.builderSuffix || '');
380
394
  const platforms = this.config.platforms?.join(', ') || 'default';
381
395
  logger.log('info', `Setting up Docker buildx [${platforms}]...`);
382
396
  logger.log('info', `Builder: ${builderName}`);
397
+
398
+ // Check for remote builders matching our target platforms
399
+ const requestedPlatforms = this.config.platforms || ['linux/amd64'];
400
+ const remoteBuilders = GlobalConfig.getBuildersForPlatforms(requestedPlatforms);
401
+
402
+ if (remoteBuilders.length > 0) {
403
+ await this.ensureBuildxWithRemoteNodes(builderName, requestedPlatforms, remoteBuilders);
404
+ } else {
405
+ await this.ensureBuildxLocal(builderName);
406
+ }
407
+
408
+ this.currentBuilderName = builderName;
409
+ logger.log('ok', `Docker buildx ready (builder: ${builderName}, platforms: ${platforms})`);
410
+ }
411
+
412
+ /**
413
+ * Creates a multi-node buildx builder with local + remote SSH nodes.
414
+ */
415
+ private async ensureBuildxWithRemoteNodes(
416
+ builderName: string,
417
+ requestedPlatforms: string[],
418
+ remoteBuilders: IRemoteBuilder[],
419
+ ): Promise<void> {
420
+ const remotePlatforms = new Set(remoteBuilders.map((b) => b.platform));
421
+ const localPlatforms = requestedPlatforms.filter((p) => !remotePlatforms.has(p));
422
+
423
+ logger.log('info', `Remote builders: ${remoteBuilders.map((b) => `${b.name} (${b.platform} @ ${b.host})`).join(', ')}`);
424
+ if (localPlatforms.length > 0) {
425
+ logger.log('info', `Local platforms: ${localPlatforms.join(', ')}`);
426
+ }
427
+
428
+ // Always recreate the builder to ensure correct node topology
429
+ await smartshellInstance.execSilent(`docker buildx rm ${builderName} 2>/dev/null || true`);
430
+
431
+ // Create the local node
432
+ const localPlatformFlag = localPlatforms.length > 0 ? ` --platform ${localPlatforms.join(',')}` : '';
433
+ await smartshellInstance.exec(
434
+ `docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host${localPlatformFlag}`
435
+ );
436
+
437
+ // Append remote nodes
438
+ for (const builder of remoteBuilders) {
439
+ logger.log('info', `Appending remote node: ${builder.name} (${builder.platform}) via ssh://${builder.host}`);
440
+ const appendResult = await smartshellInstance.exec(
441
+ `docker buildx create --append --name ${builderName} --driver docker-container --driver-opt network=host --platform ${builder.platform} --node ${builder.name} ssh://${builder.host}`
442
+ );
443
+ if (appendResult.exitCode !== 0) {
444
+ throw new Error(`Failed to append remote builder ${builder.name}: ${appendResult.stderr}`);
445
+ }
446
+ }
447
+
448
+ // Bootstrap all nodes
449
+ await smartshellInstance.exec(`docker buildx inspect --builder ${builderName} --bootstrap`);
450
+
451
+ // Store active remote builders for SSH tunnel setup during build
452
+ this.activeRemoteBuilders = remoteBuilders;
453
+ }
454
+
455
+ /**
456
+ * Creates a single-node local buildx builder (original behavior, uses QEMU for cross-platform).
457
+ */
458
+ private async ensureBuildxLocal(builderName: string): Promise<void> {
383
459
  const inspectResult = await smartshellInstance.exec(`docker buildx inspect ${builderName} 2>/dev/null`);
384
460
 
385
461
  if (inspectResult.exitCode !== 0) {
386
462
  logger.log('info', 'Creating new buildx builder with host network...');
387
463
  await smartshellInstance.exec(
388
- `docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host --use`
464
+ `docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host`
389
465
  );
390
- await smartshellInstance.exec('docker buildx inspect --bootstrap');
466
+ await smartshellInstance.exec(`docker buildx inspect --builder ${builderName} --bootstrap`);
391
467
  } else {
392
468
  const inspectOutput = inspectResult.stdout || '';
393
469
  if (!inspectOutput.includes('network=host')) {
394
470
  logger.log('info', 'Recreating buildx builder with host network (migration)...');
395
471
  await smartshellInstance.exec(`docker buildx rm ${builderName} 2>/dev/null`);
396
472
  await smartshellInstance.exec(
397
- `docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host --use`
473
+ `docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host`
398
474
  );
399
- await smartshellInstance.exec('docker buildx inspect --bootstrap');
400
- } else {
401
- await smartshellInstance.exec(`docker buildx use ${builderName}`);
475
+ await smartshellInstance.exec(`docker buildx inspect --builder ${builderName} --bootstrap`);
402
476
  }
403
477
  }
404
- logger.log('ok', `Docker buildx ready (builder: ${builderName}, platforms: ${platforms})`);
478
+ this.activeRemoteBuilders = [];
479
+ }
480
+
481
+ /**
482
+ * Opens SSH reverse tunnels for remote builders so they can reach the local registry.
483
+ */
484
+ private async openRemoteTunnels(): Promise<void> {
485
+ if (this.activeRemoteBuilders.length === 0) return;
486
+
487
+ this.sshTunnelManager = new SshTunnelManager();
488
+ await this.sshTunnelManager.openTunnels(
489
+ this.activeRemoteBuilders,
490
+ this.session.config.registryPort,
491
+ );
492
+ }
493
+
494
+ /**
495
+ * Closes any active SSH tunnels.
496
+ */
497
+ private async closeRemoteTunnels(): Promise<void> {
498
+ if (this.sshTunnelManager) {
499
+ await this.sshTunnelManager.closeAll();
500
+ this.sshTunnelManager = undefined;
501
+ }
405
502
  }
406
503
 
407
504
  /**
@@ -4,6 +4,7 @@ import { logger } from './tsdocker.logging.js';
4
4
 
5
5
  export interface ISessionConfig {
6
6
  sessionId: string;
7
+ projectHash: string;
7
8
  registryPort: number;
8
9
  registryHost: string;
9
10
  registryContainerName: string;
@@ -17,8 +18,8 @@ export interface ISessionConfig {
17
18
  * Generates unique ports, container names, and builder names so that
18
19
  * concurrent CI jobs on the same Docker host don't collide.
19
20
  *
20
- * In local (non-CI) dev the builder suffix is empty, preserving the
21
- * persistent builder behavior.
21
+ * In local (non-CI) dev the builder suffix contains a project hash so
22
+ * that concurrent runs in different project directories use separate builders.
22
23
  */
23
24
  export class TsDockerSession {
24
25
  public config: ISessionConfig;
@@ -34,16 +35,18 @@ export class TsDockerSession {
34
35
  public static async create(): Promise<TsDockerSession> {
35
36
  const sessionId =
36
37
  process.env.TSDOCKER_SESSION_ID || crypto.randomBytes(4).toString('hex');
38
+ const projectHash = crypto.createHash('sha256').update(process.cwd()).digest('hex').substring(0, 8);
37
39
 
38
40
  const registryPort = await TsDockerSession.allocatePort();
39
41
  const registryHost = `localhost:${registryPort}`;
40
42
  const registryContainerName = `tsdocker-registry-${sessionId}`;
41
43
 
42
44
  const { isCI, ciSystem } = TsDockerSession.detectCI();
43
- const builderSuffix = isCI ? `-${sessionId}` : '';
45
+ const builderSuffix = isCI ? `-${projectHash}-${sessionId}` : `-${projectHash}`;
44
46
 
45
47
  const config: ISessionConfig = {
46
48
  sessionId,
49
+ projectHash,
47
50
  registryPort,
48
51
  registryHost,
49
52
  registryContainerName,
@@ -99,9 +102,10 @@ export class TsDockerSession {
99
102
  logger.log('info', '=== TSDOCKER SESSION ===');
100
103
  logger.log('info', `Session ID: ${c.sessionId}`);
101
104
  logger.log('info', `Registry: ${c.registryHost} (container: ${c.registryContainerName})`);
105
+ logger.log('info', `Project hash: ${c.projectHash}`);
106
+ logger.log('info', `Builder suffix: ${c.builderSuffix}`);
102
107
  if (c.isCI) {
103
108
  logger.log('info', `CI detected: ${c.ciSystem}`);
104
- logger.log('info', `Builder suffix: ${c.builderSuffix}`);
105
109
  }
106
110
  }
107
111
  }
@@ -69,6 +69,7 @@ export interface IBuildCommandOptions {
69
69
  platform?: string; // Single platform override (e.g., 'linux/arm64')
70
70
  timeout?: number; // Build timeout in seconds
71
71
  noCache?: boolean; // Force rebuild without Docker layer cache (--no-cache)
72
+ pull?: boolean; // Pull fresh base images before building (default: true)
72
73
  cached?: boolean; // Skip builds when Dockerfile content hasn't changed
73
74
  verbose?: boolean; // Stream raw docker build output (default: silent)
74
75
  context?: string; // Explicit Docker context name (--context flag)
@@ -95,3 +96,20 @@ export interface IDockerContextInfo {
95
96
  dockerHost?: string; // value of DOCKER_HOST env var, if set
96
97
  topology?: 'socket-mount' | 'dind' | 'local';
97
98
  }
99
+
100
+ /**
101
+ * A remote builder node for native cross-platform builds
102
+ */
103
+ export interface IRemoteBuilder {
104
+ name: string; // e.g., "arm64-builder"
105
+ host: string; // e.g., "armbuilder@192.168.190.216"
106
+ platform: string; // e.g., "linux/arm64"
107
+ sshKeyPath?: string; // e.g., "~/.ssh/id_ed25519"
108
+ }
109
+
110
+ /**
111
+ * Global tsdocker configuration stored at ~/.git.zone/tsdocker/config.json
112
+ */
113
+ export interface IGlobalConfig {
114
+ remoteBuilders: IRemoteBuilder[];
115
+ }