@stream44.studio/t44-docker.com 0.1.0-rc.3

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/caps/Image.ts ADDED
@@ -0,0 +1,623 @@
1
+ import { $ } from 'bun'
2
+ import { access, mkdir, cp, rm, readdir, stat, readFile, writeFile } from 'fs/promises'
3
+ import { constants } from 'fs'
4
+ import { join, isAbsolute } from 'path'
5
+ import { Glob } from 'bun'
6
+
7
+ /**
8
+ * Dockerfile variants to build
9
+ */
10
+ const DOCKERFILE_VARIANTS = {
11
+ alpine: { dockerfile: 'Dockerfile.alpine', tagSuffix: 'alpine', variantDir: 'alpine', variant: 'alpine' },
12
+ distroless: { dockerfile: 'Dockerfile.distroless', tagSuffix: 'distroless', variantDir: 'distroless', variant: 'distroless' },
13
+ } as const;
14
+
15
+ // TODO: Use "importer" bridge file that gets inlined when building.
16
+ const DEFAULT_TEMPLATE_DIR = join(__dirname, 'Image', 'tpl');
17
+
18
+ type FileSpecCallback = (context: { appBaseDir: string; archDir: string; buildContextDir: string }) => string | object | [string, string] | Promise<string | object | [string, string]>;
19
+ type FileSpec = string | object | [string, string] | FileSpecCallback;
20
+ type FilesSpec = Record<string, FileSpec>;
21
+
22
+ /**
23
+ * Trim common leading whitespace from all lines in a string.
24
+ */
25
+ function trimIndentation(str: string): string {
26
+ const lines = str.split('\n');
27
+ const nonEmptyLines = lines.filter(line => line.trim().length > 0);
28
+ if (nonEmptyLines.length === 0) {
29
+ return str;
30
+ }
31
+ const minIndent = Math.min(
32
+ ...nonEmptyLines.map(line => {
33
+ const match = line.match(/^[ \t]*/);
34
+ return match ? match[0].length : 0;
35
+ })
36
+ );
37
+ return lines.map(line => {
38
+ if (line.trim().length === 0) {
39
+ return '';
40
+ }
41
+ return line.slice(minIndent);
42
+ }).join('\n');
43
+ }
44
+
45
+ export async function capsule({
46
+ encapsulate,
47
+ CapsulePropertyTypes,
48
+ makeImportStack
49
+ }: {
50
+ encapsulate: any
51
+ CapsulePropertyTypes: any
52
+ makeImportStack: any
53
+ }) {
54
+
55
+ return encapsulate({
56
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
57
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
58
+ '#': {
59
+ cli: {
60
+ type: CapsulePropertyTypes.Mapping,
61
+ value: './Cli',
62
+ },
63
+
64
+ context: {
65
+ type: CapsulePropertyTypes.Mapping,
66
+ value: './ImageContext',
67
+ options: { /* requires new instance */ },
68
+ },
69
+
70
+ DOCKERFILE_VARIANTS: {
71
+ type: CapsulePropertyTypes.Constant,
72
+ value: DOCKERFILE_VARIANTS,
73
+ },
74
+
75
+ trimIndentation: {
76
+ type: CapsulePropertyTypes.Function,
77
+ value: function (this: any, str: string): string {
78
+ return trimIndentation(str);
79
+ }
80
+ },
81
+
82
+ /**
83
+ * Copy files based on custom file specification
84
+ */
85
+ copySpecifiedFiles: {
86
+ type: CapsulePropertyTypes.Function,
87
+ value: async function (this: any, {
88
+ filesSpec,
89
+ appBaseDir,
90
+ buildContextDir,
91
+ archDir,
92
+ }: {
93
+ filesSpec: FilesSpec;
94
+ appBaseDir: string;
95
+ buildContextDir: string;
96
+ archDir: string;
97
+ }): Promise<void> {
98
+ const context = { appBaseDir, archDir, buildContextDir };
99
+
100
+ for (const [destRelPath, spec] of Object.entries(filesSpec)) {
101
+ const destPath = join(buildContextDir, destRelPath);
102
+ await mkdir(join(destPath, '..'), { recursive: true });
103
+
104
+ let resolvedSpec: string | object | [string, string];
105
+ if (typeof spec === 'function') {
106
+ resolvedSpec = await spec(context);
107
+ } else {
108
+ resolvedSpec = spec;
109
+ }
110
+
111
+ if (typeof resolvedSpec === 'object' && !Array.isArray(resolvedSpec)) {
112
+ if (this.verbose) console.log(` Writing JSON file: ${destRelPath}`);
113
+ await writeFile(destPath, JSON.stringify(resolvedSpec, null, 2));
114
+ } else if (typeof resolvedSpec === 'string') {
115
+ const isContent = resolvedSpec.includes('\n') ||
116
+ resolvedSpec.includes('\r') ||
117
+ (resolvedSpec.includes(' ') && !isAbsolute(resolvedSpec) && !resolvedSpec.startsWith('.'));
118
+
119
+ if (isContent) {
120
+ if (this.verbose) console.log(` Writing content to file: ${destRelPath}`);
121
+ const trimmedContent = trimIndentation(resolvedSpec);
122
+ await writeFile(destPath, trimmedContent);
123
+ } else {
124
+ const srcPath = isAbsolute(resolvedSpec) ? resolvedSpec : join(appBaseDir, resolvedSpec);
125
+ if (this.verbose) console.log(` Copying file: ${srcPath} -> ${destRelPath}`);
126
+ try {
127
+ await access(srcPath, constants.F_OK);
128
+ const stats = await stat(srcPath);
129
+ if (stats.isDirectory()) {
130
+ await cp(srcPath, destPath, { recursive: true, force: true });
131
+ } else {
132
+ await cp(srcPath, destPath, { force: true });
133
+ }
134
+ } catch (error) {
135
+ throw new Error(`Failed to copy ${srcPath} to ${destPath}: ${error}`);
136
+ }
137
+ }
138
+ } else if (Array.isArray(resolvedSpec) && resolvedSpec.length === 2) {
139
+ const [baseDir, globPattern] = resolvedSpec;
140
+ const resolvedBaseDir = isAbsolute(baseDir) ? baseDir : join(appBaseDir, baseDir);
141
+ if (this.verbose) console.log(` Copying glob: ${globPattern} from ${resolvedBaseDir} -> ${destRelPath}`);
142
+
143
+ const glob = new Glob(globPattern);
144
+ const matches = Array.from(glob.scanSync({ cwd: resolvedBaseDir, absolute: false }));
145
+
146
+ if (matches.length === 0) {
147
+ if (this.verbose) console.log(` ⚠️ No files matched glob pattern: ${globPattern}`);
148
+ }
149
+
150
+ for (const match of matches) {
151
+ const srcPath = join(resolvedBaseDir, match);
152
+ const destFilePath = join(destPath, match);
153
+ await mkdir(join(destFilePath, '..'), { recursive: true });
154
+ const stats = await stat(srcPath);
155
+ if (stats.isDirectory()) {
156
+ await cp(srcPath, destFilePath, { recursive: true, force: true });
157
+ } else {
158
+ await cp(srcPath, destFilePath, { force: true });
159
+ }
160
+ }
161
+ } else {
162
+ throw new Error(`Invalid file spec for ${destRelPath}: ${JSON.stringify(resolvedSpec)}`);
163
+ }
164
+ }
165
+ }
166
+ },
167
+
168
+ /**
169
+ * Prepare build context by copying app files and Dockerfile template
170
+ */
171
+ prepareBuildContext: {
172
+ type: CapsulePropertyTypes.Function,
173
+ value: async function (this: any, {
174
+ appBaseDir,
175
+ buildContextDir,
176
+ templateDir,
177
+ variant,
178
+ arch,
179
+ files,
180
+ buildScriptName,
181
+ }: {
182
+ appBaseDir: string;
183
+ buildContextDir: string;
184
+ templateDir: string;
185
+ variant: typeof DOCKERFILE_VARIANTS[keyof typeof DOCKERFILE_VARIANTS];
186
+ arch: { archDir: string; arch: string; os: string };
187
+ files?: FilesSpec;
188
+ buildScriptName?: string;
189
+ }): Promise<void> {
190
+ // Create build context directory
191
+ try {
192
+ await access(buildContextDir, constants.F_OK);
193
+ await rm(buildContextDir, { recursive: true, force: true });
194
+ } catch {
195
+ // Directory doesn't exist
196
+ }
197
+ await mkdir(buildContextDir, { recursive: true });
198
+
199
+ // Copy template package.json first (as default)
200
+ const templatePackageJson = join(templateDir, 'package.json');
201
+ try {
202
+ await access(templatePackageJson, constants.F_OK);
203
+ if (this.verbose) console.log(` Copying template package.json...`);
204
+ await cp(templatePackageJson, join(buildContextDir, 'package.json'));
205
+ } catch {
206
+ // Template package.json doesn't exist, skip
207
+ }
208
+
209
+ // Check if package.json has a build script and run it
210
+ const appPackageJsonPath = join(appBaseDir, 'package.json');
211
+ try {
212
+ await access(appPackageJsonPath, constants.F_OK);
213
+ const packageJsonContent = await Bun.file(appPackageJsonPath).text();
214
+ const packageJson = JSON.parse(packageJsonContent);
215
+
216
+ const scriptName = buildScriptName || 'build';
217
+
218
+ if (this.verbose) {
219
+ console.log(` Checking for build script '${scriptName}' in ${appPackageJsonPath}...`);
220
+ console.log(` Available scripts: ${Object.keys(packageJson.scripts || {}).join(', ')}`);
221
+ }
222
+
223
+ if (packageJson.scripts?.[scriptName]) {
224
+ if (this.verbose) {
225
+ console.log(` Running ${scriptName} script...`);
226
+ console.log(` Working directory: ${appBaseDir}`);
227
+ }
228
+
229
+ const args: string[] = [];
230
+ args.push('--arch', arch.arch);
231
+ args.push('--os', arch.os);
232
+ args.push('--archDir', arch.archDir);
233
+ args.push('--variant', variant.variant);
234
+ args.push('--variantDir', variant.variantDir);
235
+
236
+ try {
237
+ if (this.verbose) {
238
+ await $`bun run ${scriptName} ${args}`.cwd(appBaseDir);
239
+ } else {
240
+ await $`bun run ${scriptName} ${args}`.cwd(appBaseDir).quiet();
241
+ }
242
+ if (this.verbose) console.log(` ✓ Build completed`);
243
+ } catch (buildError) {
244
+ if (this.verbose) console.log(` ❌ Build script failed: ${buildError}`);
245
+ throw buildError;
246
+ }
247
+ } else {
248
+ if (this.verbose) console.log(` ⚠️ Build script '${scriptName}' not found, skipping build step`);
249
+ }
250
+ } catch (error: any) {
251
+ if (error?.code === 'ENOENT' || error?.message?.includes('ENOENT')) {
252
+ if (this.verbose) console.log(` ℹ️ No package.json found, skipping build script`);
253
+ } else {
254
+ if (this.verbose) console.log(` ⚠️ Error checking/running build script: ${error}`);
255
+ }
256
+ }
257
+
258
+ // Copy Dockerfile template to build context
259
+ const dockerfileSrc = join(templateDir, variant.dockerfile);
260
+ const dockerfileDest = join(buildContextDir, 'Dockerfile');
261
+ if (this.verbose) console.log(` Copying Dockerfile template: ${variant.dockerfile}`);
262
+ await cp(dockerfileSrc, dockerfileDest);
263
+
264
+ // Handle file copying
265
+ if (files) {
266
+ if (this.verbose) console.log(` Copying specified files...`);
267
+ await this.copySpecifiedFiles({
268
+ filesSpec: files,
269
+ appBaseDir,
270
+ buildContextDir,
271
+ archDir: arch.archDir,
272
+ });
273
+ } else {
274
+ if (this.verbose) console.log(` Copying app files from ${appBaseDir}...`);
275
+
276
+ const dockerignorePath = join(appBaseDir, '.dockerignore');
277
+ let ignorePatterns: string[] = ['node_modules', '.git', '.DS_Store', '.~o'];
278
+ try {
279
+ const dockerignoreContent = await readFile(dockerignorePath, 'utf-8');
280
+ const customPatterns = dockerignoreContent
281
+ .split('\n')
282
+ .map(line => line.trim())
283
+ .filter(line => line && !line.startsWith('#'));
284
+ ignorePatterns = [...ignorePatterns, ...customPatterns];
285
+ if (this.verbose) console.log(` Loaded .dockerignore with ${customPatterns.length} patterns`);
286
+ } catch {
287
+ // .dockerignore doesn't exist
288
+ }
289
+
290
+ const entries = await readdir(appBaseDir);
291
+
292
+ for (const entry of entries) {
293
+ const srcPath = join(appBaseDir, entry);
294
+ const destPath = join(buildContextDir, entry);
295
+
296
+ if (srcPath === buildContextDir || srcPath.startsWith(buildContextDir + '/') || srcPath.startsWith(buildContextDir + '\\')) {
297
+ continue;
298
+ }
299
+
300
+ let shouldIgnore = false;
301
+ for (const pattern of ignorePatterns) {
302
+ const glob = new Glob(pattern);
303
+ if (glob.match(entry)) {
304
+ shouldIgnore = true;
305
+ break;
306
+ }
307
+ }
308
+
309
+ if (shouldIgnore) {
310
+ if (this.verbose) console.log(` Ignoring: ${entry}`);
311
+ continue;
312
+ }
313
+
314
+ const stats = await stat(srcPath);
315
+ if (stats.isDirectory()) {
316
+ await cp(srcPath, destPath, { recursive: true, force: true });
317
+ } else {
318
+ await cp(srcPath, destPath, { force: true });
319
+ }
320
+ }
321
+ }
322
+ }
323
+ },
324
+
325
+ /**
326
+ * Build a single Docker image variant for a specific architecture
327
+ */
328
+ buildVariant: {
329
+ type: CapsulePropertyTypes.Function,
330
+ value: async function (this: any, opts?: {
331
+ variant?: string;
332
+ arch?: string;
333
+ files?: FilesSpec;
334
+ tagLatest?: boolean;
335
+ attestations?: { sbom?: boolean; provenance?: boolean };
336
+ }): Promise<{ imageTag: string }> {
337
+ const variant = opts?.variant ?? this.context.variant;
338
+ const arch = opts?.arch ?? this.context.arch;
339
+ const files = opts?.files ?? this.context.files;
340
+ const shouldTagLatest = opts?.tagLatest ?? this.context.tagLatest;
341
+ const attestations = opts?.attestations ?? this.context.attestations;
342
+
343
+ if (!variant || !arch) {
344
+ throw new Error('variant and arch must be set');
345
+ }
346
+
347
+ if (this.context.verbose) {
348
+ console.log(`\nBuilding ${variant} for ${arch}...`);
349
+ }
350
+
351
+ const variantInfo = DOCKERFILE_VARIANTS[variant as keyof typeof DOCKERFILE_VARIANTS];
352
+ const archInfo = this.cli.DOCKER_ARCHS[arch as keyof typeof this.cli.DOCKER_ARCHS];
353
+ const buildContextDir = this.context.getBuildContextDir({ variant });
354
+
355
+ await this.prepareBuildContext({
356
+ appBaseDir: this.context.appBaseDir,
357
+ buildContextDir,
358
+ templateDir: this.context.templateDir,
359
+ variant: variantInfo,
360
+ arch: archInfo,
361
+ files,
362
+ buildScriptName: this.context.buildScriptName,
363
+ });
364
+
365
+ const imageTag = this.context.getImageTag({ variant, arch });
366
+
367
+ // Build Docker image
368
+ await this.buildImage({
369
+ context: buildContextDir,
370
+ dockerfile: join(buildContextDir, 'Dockerfile'),
371
+ tag: imageTag,
372
+ attestations,
373
+ });
374
+
375
+ if (this.context.verbose) {
376
+ console.log(`✅ Built ${imageTag}`);
377
+ console.log(` Build context preserved at: ${buildContextDir}`);
378
+ }
379
+
380
+ if (shouldTagLatest) {
381
+ const latestTag = this.context.getLatestImageTag({ variant, arch });
382
+ await this.cli.tagImage({ sourceImage: imageTag, targetImage: latestTag });
383
+ }
384
+
385
+ return { imageTag };
386
+ }
387
+ },
388
+
389
+ /**
390
+ * Build all Docker image variants for all architectures
391
+ */
392
+ buildAll: {
393
+ type: CapsulePropertyTypes.Function,
394
+ value: async function (this: any): Promise<{ imageTag: string }[]> {
395
+ const results: { imageTag: string }[] = [];
396
+
397
+ for (const variantKey of Object.keys(this.context.DOCKERFILE_VARIANTS)) {
398
+ for (const archKey of Object.keys(this.cli.DOCKER_ARCHS)) {
399
+ const result = await this.buildVariant({
400
+ variant: variantKey,
401
+ arch: archKey,
402
+ });
403
+ results.push(result);
404
+ }
405
+ }
406
+
407
+ return results;
408
+ }
409
+ },
410
+
411
+ /**
412
+ * Build for current platform only (convenience)
413
+ */
414
+ build: {
415
+ type: CapsulePropertyTypes.Function,
416
+ value: async function (this: any): Promise<{ imageTag: string }> {
417
+ const currentArch = this.cli.getCurrentPlatformArch();
418
+ const variant = this.context.variant || 'alpine';
419
+ return this.buildVariant({ variant, arch: currentArch });
420
+ }
421
+ },
422
+
423
+ /**
424
+ * Get all tags for the provided namespace with metadata
425
+ */
426
+ getTags: {
427
+ type: CapsulePropertyTypes.Function,
428
+ value: async function (this: any, opts?: { organization?: string; repository?: string }): Promise<any[]> {
429
+ const org = opts?.organization ?? this.context.organization;
430
+ const repo = opts?.repository ?? this.context.repository;
431
+ const imageNamespace = `${org}/${repo}`;
432
+
433
+ const imagesOutput = await this.listImages({
434
+ filter: `reference=${imageNamespace}`,
435
+ format: '{{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Size}}\t{{.CreatedAt}}',
436
+ });
437
+
438
+ const tags: any[] = [];
439
+
440
+ for (const line of (imagesOutput as string).split('\n')) {
441
+ const trimmed = line.trim();
442
+ if (!trimmed) continue;
443
+
444
+ const [tag, imageId, size, created] = trimmed.split('\t');
445
+ if (!tag || !imageId) continue;
446
+
447
+ let variant: string | undefined;
448
+ let arch: string | undefined;
449
+
450
+ const tagSuffix = tag.split(':')[1];
451
+ if (tagSuffix) {
452
+ for (const [variantKey, variantInfo] of Object.entries(DOCKERFILE_VARIANTS)) {
453
+ if (tagSuffix.startsWith(variantInfo.tagSuffix)) {
454
+ variant = variantKey;
455
+ for (const [archKey, archInfo] of Object.entries(this.cli.DOCKER_ARCHS) as Array<[string, any]>) {
456
+ if (tagSuffix.includes(archInfo.arch)) {
457
+ arch = archKey;
458
+ break;
459
+ }
460
+ }
461
+ break;
462
+ }
463
+ }
464
+ }
465
+
466
+ tags.push({ tag, imageId, size: size || '', created: created || '', variant, arch });
467
+ }
468
+
469
+ return tags;
470
+ }
471
+ },
472
+
473
+ /**
474
+ * Inspect the image
475
+ */
476
+ inspect: {
477
+ type: CapsulePropertyTypes.Function,
478
+ value: async function (this: any, opts?: { variant?: string; arch?: string }): Promise<string> {
479
+ const imageTag = this.context.getImageTag(opts);
480
+ return await this.inspectImage({ image: imageTag });
481
+ }
482
+ },
483
+
484
+ // --- Image-specific CLI methods (not shared) ---
485
+
486
+ /**
487
+ * Build a Docker image from a Dockerfile
488
+ */
489
+ buildImage: {
490
+ type: CapsulePropertyTypes.Function,
491
+ value: async function (this: any, options: {
492
+ dockerfile?: string;
493
+ context: string;
494
+ tag: string;
495
+ buildArgs?: Record<string, string>;
496
+ noCache?: boolean;
497
+ attestations?: { sbom?: boolean; provenance?: boolean };
498
+ }): Promise<string> {
499
+ const { dockerfile = 'Dockerfile', context, tag, buildArgs, noCache, attestations } = options;
500
+
501
+ const args = ['build'];
502
+
503
+ if (dockerfile) {
504
+ const dockerfilePath = isAbsolute(dockerfile) ? dockerfile : join(context, dockerfile);
505
+ args.push('-f', dockerfilePath);
506
+ }
507
+
508
+ args.push('-t', tag);
509
+
510
+ if (noCache) {
511
+ args.push('--no-cache');
512
+ }
513
+
514
+ if (buildArgs) {
515
+ for (const [key, value] of Object.entries(buildArgs)) {
516
+ args.push('--build-arg', `${key}=${value}`);
517
+ }
518
+ }
519
+
520
+ if (attestations?.sbom) {
521
+ args.push('--attest', 'type=sbom');
522
+ }
523
+ if (attestations?.provenance) {
524
+ args.push('--attest', 'type=provenance,mode=max');
525
+ }
526
+
527
+ args.push(context);
528
+
529
+ return await this.cli.exec(args);
530
+ }
531
+ },
532
+
533
+ /**
534
+ * Inspect a Docker image
535
+ */
536
+ inspectImage: {
537
+ type: CapsulePropertyTypes.Function,
538
+ value: async function (this: any, options: { image: string }): Promise<string> {
539
+ return await this.cli.exec(['image', 'inspect', options.image]);
540
+ }
541
+ },
542
+
543
+ /**
544
+ * List Docker images
545
+ */
546
+ listImages: {
547
+ type: CapsulePropertyTypes.Function,
548
+ value: async function (this: any, options: {
549
+ all?: boolean;
550
+ filter?: string;
551
+ format?: string;
552
+ json?: boolean;
553
+ } = {}): Promise<string | any[]> {
554
+ const { all = false, filter, format, json = false } = options;
555
+ const args = ['images'];
556
+
557
+ if (all) args.push('-a');
558
+ if (filter) args.push('--filter', filter);
559
+
560
+ if (json && !format) {
561
+ args.push('--format', 'json');
562
+ } else if (format) {
563
+ args.push('--format', format);
564
+ }
565
+
566
+ const result = await this.cli.exec(args);
567
+
568
+ if (json && !format) {
569
+ const lines = result.split('\n').filter((line: string) => line.trim());
570
+ if (lines.length === 0) return [];
571
+ return lines.map((line: string) => JSON.parse(line));
572
+ }
573
+
574
+ return result;
575
+ }
576
+ },
577
+
578
+ /**
579
+ * Check if a Docker image tag exists
580
+ */
581
+ doesImageTagExist: {
582
+ type: CapsulePropertyTypes.Function,
583
+ value: async function (this: any, imageTag: string): Promise<boolean> {
584
+ const normalizedTag = imageTag.includes(':') ? imageTag : `${imageTag}:latest`;
585
+ const images = await this.listImages({
586
+ filter: `reference=${normalizedTag}`,
587
+ json: true,
588
+ });
589
+ return Array.isArray(images) && images.length > 0;
590
+ }
591
+ },
592
+
593
+ /**
594
+ * Get the size of a Docker image
595
+ */
596
+ getImageSize: {
597
+ type: CapsulePropertyTypes.Function,
598
+ value: async function (this: any, options: { image: string }): Promise<string> {
599
+ return await this.cli.exec(['images', options.image, '--format', '{{.Size}}']);
600
+ }
601
+ },
602
+
603
+ /**
604
+ * Remove a Docker image
605
+ */
606
+ removeImage: {
607
+ type: CapsulePropertyTypes.Function,
608
+ value: async function (this: any, options: { image: string; force?: boolean }): Promise<string> {
609
+ const args = ['rmi'];
610
+ if (options.force) args.push('-f');
611
+ args.push(options.image);
612
+ return await this.cli.exec(args);
613
+ }
614
+ },
615
+ }
616
+ }
617
+ }, {
618
+ importMeta: import.meta,
619
+ importStack: makeImportStack(),
620
+ capsuleName: '@stream44.studio/t44-docker.com/caps/Image',
621
+
622
+ })
623
+ }