@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.
@@ -0,0 +1,304 @@
1
+ export async function capsule({
2
+ encapsulate,
3
+ CapsulePropertyTypes,
4
+ makeImportStack
5
+ }: {
6
+ encapsulate: any
7
+ CapsulePropertyTypes: any
8
+ makeImportStack: any
9
+ }) {
10
+
11
+ return encapsulate({
12
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
13
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
14
+ '#': {
15
+ test: {
16
+ type: CapsulePropertyTypes.Mapping,
17
+ value: 't44/caps/ProjectTest',
18
+ },
19
+
20
+ cli: {
21
+ type: CapsulePropertyTypes.Mapping,
22
+ value: './Cli',
23
+ },
24
+
25
+ image: {
26
+ type: CapsulePropertyTypes.Mapping,
27
+ value: './Image',
28
+ options: { /* requires new instance */ },
29
+ },
30
+
31
+ container: {
32
+ type: CapsulePropertyTypes.Mapping,
33
+ value: './Container',
34
+ options: { /* requires new instance */ },
35
+ },
36
+
37
+ // --- Project-level config ---
38
+
39
+ dispose: {
40
+ type: CapsulePropertyTypes.Literal,
41
+ value: false as boolean,
42
+ },
43
+
44
+ // Internal state
45
+ _devContainerId: {
46
+ type: CapsulePropertyTypes.Literal,
47
+ value: undefined as string | undefined,
48
+ },
49
+
50
+ /**
51
+ * Get development container configuration as a derived ContainerContext plain object.
52
+ * Merges dev defaults (image tag, sanitized name, detach, waitFor) on top of
53
+ * whatever is already configured on container.context.
54
+ */
55
+ getDevelopmentContainerConfig: {
56
+ type: CapsulePropertyTypes.Function,
57
+ value: function (this: any): Record<string, any> {
58
+ const imageCtx = this.image.context;
59
+ const imageTag = imageCtx.getImageTag({
60
+ variant: imageCtx.variant || 'alpine',
61
+ arch: imageCtx.arch || this.cli.getCurrentPlatformArch(),
62
+ });
63
+ const sanitizedName = imageTag.replace(/[^a-zA-Z0-9_.-]/g, '-') + '-dev';
64
+
65
+ return this.container.context.derive({
66
+ image: imageTag,
67
+ name: sanitizedName,
68
+ detach: true,
69
+ waitFor: this.container.context.waitFor ?? 'READY',
70
+ waitTimeout: this.container.context.waitTimeout ?? 30000,
71
+ verbose: this.container.context.verbose ?? imageCtx.verbose,
72
+ showOutput: this.container.context.showOutput ?? imageCtx.verbose,
73
+ });
74
+ }
75
+ },
76
+
77
+ /**
78
+ * Build development image (current platform, alpine variant)
79
+ */
80
+ buildDev: {
81
+ type: CapsulePropertyTypes.Function,
82
+ value: async function (this: any, options?: {
83
+ files?: Record<string, any>;
84
+ tagLatest?: boolean;
85
+ attestations?: { sbom?: boolean; provenance?: boolean };
86
+ }): Promise<{ imageTag: string }> {
87
+ const ctx = this.image.context;
88
+ const files = (ctx.files || options?.files)
89
+ ? { ...ctx.files, ...options?.files }
90
+ : undefined;
91
+
92
+ return await this.image.buildVariant({
93
+ variant: ctx.variant || 'alpine',
94
+ arch: this.cli.getCurrentPlatformArch(),
95
+ files,
96
+ tagLatest: options?.tagLatest,
97
+ attestations: options?.attestations ?? ctx.attestations,
98
+ });
99
+ }
100
+ },
101
+
102
+ /**
103
+ * Build all distribution images (all variants × all archs)
104
+ */
105
+ buildDistribution: {
106
+ type: CapsulePropertyTypes.Function,
107
+ value: async function (this: any, options?: {
108
+ files?: Record<string, any>;
109
+ tagLatest?: boolean;
110
+ attestations?: { sbom?: boolean; provenance?: boolean };
111
+ }): Promise<{ imageTag: string }[]> {
112
+ const ctx = this.image.context;
113
+ const files = (ctx.files || options?.files)
114
+ ? { ...ctx.files, ...options?.files }
115
+ : undefined;
116
+
117
+ const results: { imageTag: string }[] = [];
118
+
119
+ const archKeys = ctx.arch
120
+ ? [ctx.arch]
121
+ : Object.keys(this.cli.DOCKER_ARCHS);
122
+ const variantKeys = ctx.variant
123
+ ? [ctx.variant]
124
+ : Object.keys(ctx.DOCKERFILE_VARIANTS);
125
+
126
+ for (const variantKey of variantKeys) {
127
+ for (const archKey of archKeys) {
128
+ results.push(await this.image.buildVariant({
129
+ variant: variantKey,
130
+ arch: archKey,
131
+ files,
132
+ tagLatest: options?.tagLatest,
133
+ attestations: options?.attestations ?? ctx.attestations,
134
+ }));
135
+ }
136
+ }
137
+
138
+ return results;
139
+ }
140
+ },
141
+
142
+ /**
143
+ * Run development container (with SIGINT/SIGTERM handlers)
144
+ */
145
+ runDev: {
146
+ type: CapsulePropertyTypes.Function,
147
+ value: async function (this: any, options?: { showOutput?: boolean }): Promise<{
148
+ containerId: string;
149
+ stop: () => Promise<void>;
150
+ ensureRunning: () => Promise<boolean>;
151
+ }> {
152
+ const containerContext = {
153
+ ...this.getDevelopmentContainerConfig(),
154
+ ...(options?.showOutput !== undefined ? { showOutput: options.showOutput } : {}),
155
+ };
156
+
157
+ let signalReceived = false;
158
+ let signalName = '';
159
+
160
+ const stop = async () => {
161
+ await this.container.cleanup(containerContext);
162
+ this._devContainerId = undefined;
163
+ };
164
+
165
+ const signalHandler = (signal: string) => {
166
+ console.error(`\n[runDev] Received ${signal}, stopping container...`);
167
+ signalReceived = true;
168
+ signalName = signal;
169
+ };
170
+
171
+ process.on('SIGINT', signalHandler);
172
+ process.on('SIGTERM', signalHandler);
173
+
174
+ await this.container.ensureStopped(containerContext);
175
+ if (containerContext.verbose) console.log(`\nRunning container from image: ${containerContext.image}...`);
176
+ const containerId = await this.container.run(containerContext);
177
+ if (containerContext.verbose) console.log(`✅ Container started: ${containerId}`);
178
+ this._devContainerId = containerId;
179
+
180
+ if (signalReceived) {
181
+ console.error(`[runDev] Signal ${signalName} was received during startup, stopping container...`);
182
+ await stop();
183
+ process.exit(0);
184
+ }
185
+
186
+ const ensureRunning = async () => {
187
+ const isRunning = await this.container.isRunning({
188
+ ...containerContext,
189
+ retryDelayMs: 2000,
190
+ requestTimeoutMs: 5000,
191
+ timeoutMs: 60000,
192
+ });
193
+ if (!isRunning) {
194
+ throw new Error(`Container ${containerId} failed to respond`);
195
+ }
196
+ return true;
197
+ };
198
+
199
+ const runningSignalHandler = async (signal: string) => {
200
+ console.error(`\n[runDev] Received ${signal}, stopping container...`);
201
+ await stop();
202
+ process.exit(0);
203
+ };
204
+
205
+ process.off('SIGINT', signalHandler);
206
+ process.off('SIGTERM', signalHandler);
207
+ process.on('SIGINT', runningSignalHandler);
208
+ process.on('SIGTERM', runningSignalHandler);
209
+
210
+ return { containerId, stop, ensureRunning };
211
+ }
212
+ },
213
+
214
+ /**
215
+ * Ensure dev container is running
216
+ */
217
+ ensureDevRunning: {
218
+ type: CapsulePropertyTypes.Function,
219
+ value: async function (this: any): Promise<boolean> {
220
+ if (!this._devContainerId) {
221
+ throw new Error('Container must be started first using runDev()');
222
+ }
223
+ const containerContext = this.getDevelopmentContainerConfig();
224
+ const isRunning = await this.container.isRunning({
225
+ ...containerContext,
226
+ retryDelayMs: 2000,
227
+ requestTimeoutMs: 5000,
228
+ timeoutMs: 60000,
229
+ });
230
+ if (!isRunning) {
231
+ throw new Error('Container failed to respond after 60 seconds');
232
+ }
233
+ return true;
234
+ }
235
+ },
236
+
237
+ /**
238
+ * Stop dev container
239
+ */
240
+ stopDev: {
241
+ type: CapsulePropertyTypes.Function,
242
+ value: async function (this: any): Promise<void> {
243
+ if (!this._devContainerId) {
244
+ throw new Error('Container must be started first using runDev()');
245
+ }
246
+ await this.container.cleanup();
247
+ this._devContainerId = undefined;
248
+ }
249
+ },
250
+
251
+ /**
252
+ * Retag images from a source org/repo to this project's org/repo
253
+ */
254
+ retagImages: {
255
+ type: CapsulePropertyTypes.Function,
256
+ value: async function (this: any, { organization, repository }: { organization: string; repository: string }): Promise<void> {
257
+ const ctx = this.image.context;
258
+ if (ctx.verbose) {
259
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
260
+ console.log(`Retagging images from ${organization}/${repository} to ${ctx.organization}/${ctx.repository}`);
261
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
262
+ }
263
+
264
+ const tags = await this.image.getTags({ organization, repository });
265
+
266
+ if (ctx.verbose) console.log(`Found ${tags.length} tags to retag`);
267
+
268
+ for (const tagInfo of tags) {
269
+ const tagSuffix = tagInfo.tag.split(':')[1];
270
+ if (!tagSuffix) continue;
271
+
272
+ const sourceImageTag = `${organization}/${repository}:${tagSuffix}`;
273
+ const targetImageTag = `${ctx.organization}/${ctx.repository}:${tagSuffix}`;
274
+
275
+ if (ctx.verbose) console.log(` Tagging: ${sourceImageTag} -> ${targetImageTag}`);
276
+
277
+ await this.cli.tagImage({ sourceImage: sourceImageTag, targetImage: targetImageTag });
278
+ }
279
+
280
+ if (ctx.verbose) console.log(`✅ Retagged ${tags.length} images`);
281
+ }
282
+ },
283
+
284
+ Dispose: {
285
+ type: CapsulePropertyTypes.Dispose,
286
+ value: async function (this: any): Promise<void> {
287
+ if (!this.dispose || !this._devContainerId) return;
288
+ try {
289
+ const containerContext = this.getDevelopmentContainerConfig();
290
+ await this.container.cleanup(containerContext);
291
+ } catch (error) {
292
+ if (this.image.context.verbose) console.log(`Warning: Failed to cleanup dev container on dispose: ${error}`);
293
+ }
294
+ this._devContainerId = undefined;
295
+ }
296
+ },
297
+ }
298
+ }
299
+ }, {
300
+ importMeta: import.meta,
301
+ importStack: makeImportStack(),
302
+ capsuleName: '@stream44.studio/t44-docker.com/caps/Project',
303
+ })
304
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Wait for a URL to respond with a specific condition
3
+ */
4
+ export interface WaitForFetchOptions {
5
+ url: string;
6
+ method?: string; // HTTP method (GET, POST, etc.)
7
+ headers?: Record<string, string>; // Request headers
8
+ body?: string; // Request body
9
+ status: true | false | number; // true = any response, false = no response (fetch fails), number = specific status code
10
+ retryDelayMs?: number;
11
+ requestTimeoutMs?: number;
12
+ timeoutMs?: number;
13
+ verbose?: boolean;
14
+ returnResponse?: boolean; // If true, return the Response object instead of boolean
15
+ }
16
+
17
+ export async function waitForFetch(options: WaitForFetchOptions & { returnResponse: true }): Promise<Response>;
18
+ export async function waitForFetch(options: WaitForFetchOptions & { returnResponse?: false }): Promise<boolean>;
19
+ export async function waitForFetch(options: WaitForFetchOptions): Promise<boolean | Response> {
20
+ const {
21
+ url,
22
+ method = 'GET',
23
+ headers,
24
+ body,
25
+ status,
26
+ retryDelayMs = 1000,
27
+ requestTimeoutMs = 2000,
28
+ timeoutMs = 30000,
29
+ verbose = false,
30
+ returnResponse = false
31
+ } = options;
32
+
33
+ const startTime = Date.now();
34
+ let attemptCount = 0;
35
+
36
+ while (Date.now() - startTime < timeoutMs) {
37
+ attemptCount++;
38
+ const elapsed = Date.now() - startTime;
39
+
40
+ try {
41
+ const response = await fetch(url, {
42
+ method,
43
+ headers,
44
+ body,
45
+ signal: AbortSignal.timeout(requestTimeoutMs)
46
+ });
47
+
48
+ // Check if condition is met
49
+ if (status === true) {
50
+ // Any response is success
51
+ if (verbose) {
52
+ console.log(`[waitForFetch] URL ${url} responded (status: ${response.status}) after ${attemptCount} attempts (${elapsed}ms)`);
53
+ }
54
+ return returnResponse ? response : true;
55
+ } else if (typeof status === 'number') {
56
+ // Specific status code required
57
+ if (response.status === status) {
58
+ if (verbose) {
59
+ console.log(`[waitForFetch] URL ${url} responded with status ${status} after ${attemptCount} attempts (${elapsed}ms)`);
60
+ }
61
+ return returnResponse ? response : true;
62
+ } else {
63
+ if (verbose) {
64
+ console.log(`[waitForFetch] Attempt ${attemptCount}: Got status ${response.status}, expected ${status} (${elapsed}ms)`);
65
+ }
66
+ }
67
+ }
68
+ // If status === false, we want fetch to fail, so getting a response means condition not met
69
+ } catch (error) {
70
+ // Fetch failed
71
+ if (status === false) {
72
+ // We want fetch to fail, so this is success
73
+ if (verbose) {
74
+ console.log(`[waitForFetch] URL ${url} is not responding (as expected) after ${attemptCount} attempts (${elapsed}ms)`);
75
+ }
76
+ return true;
77
+ } else {
78
+ if (verbose) {
79
+ console.log(`[waitForFetch] Attempt ${attemptCount}: Request failed (${elapsed}ms)`);
80
+ }
81
+ }
82
+ }
83
+
84
+ // Wait before next attempt, but don't exceed total timeout
85
+ const remainingTime = timeoutMs - (Date.now() - startTime);
86
+ if (remainingTime > 0) {
87
+ await new Promise(resolve => setTimeout(resolve, Math.min(retryDelayMs, remainingTime)));
88
+ }
89
+ }
90
+
91
+ if (verbose) {
92
+ console.log(`[waitForFetch] Timeout reached after ${attemptCount} attempts (${Date.now() - startTime}ms)`);
93
+ }
94
+ return false;
95
+ }
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@stream44.studio/t44-docker.com",
3
+ "version": "0.1.0-rc.3",
4
+ "private": false,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "bun test"
9
+ },
10
+ "dependencies": {
11
+ "t44": "^0.4.0-rc.17",
12
+ "@stream44.studio/encapsulate": "^0.4.0-rc.19"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "^1.3.4",
16
+ "@types/node": "^25.0.3",
17
+ "bun-types": "^1.3.4"
18
+ }
19
+ }
@@ -0,0 +1,53 @@
1
+
2
+ export async function capsule({
3
+ encapsulate,
4
+ CapsulePropertyTypes,
5
+ makeImportStack
6
+ }: any) {
7
+ return encapsulate({
8
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
9
+ '#t44/caps/ConfigSchemaStruct': {
10
+ as: 'schema',
11
+ options: {
12
+ '#': {
13
+ schema: {
14
+ type: 'object',
15
+ properties: {
16
+ username: {
17
+ type: 'string',
18
+ title: 'Docker Hub Username',
19
+ description: 'Your Docker Hub username from https://hub.docker.com',
20
+ minLength: 1,
21
+ },
22
+ password: {
23
+ type: 'string',
24
+ title: 'Docker Hub Password or Personal Access Token',
25
+ description: 'Your Docker Hub password or a Personal Access Token (PAT) from https://hub.docker.com/settings/security',
26
+ minLength: 1,
27
+ },
28
+ organization: {
29
+ type: 'string',
30
+ title: 'Docker Hub Organization (optional)',
31
+ description: 'Your Docker Hub organization name. Leave empty to use your username as the namespace.',
32
+ },
33
+ },
34
+ required: ['username', 'password']
35
+ }
36
+ }
37
+ }
38
+ },
39
+ '#': {
40
+ capsuleName: {
41
+ type: CapsulePropertyTypes.Literal,
42
+ value: capsule['#']
43
+ },
44
+ }
45
+ }
46
+ }, {
47
+ extendsCapsule: 't44/caps/WorkspaceConnection',
48
+ importMeta: import.meta,
49
+ importStack: makeImportStack(),
50
+ capsuleName: capsule['#'],
51
+ })
52
+ }
53
+ capsule['#'] = '@stream44.studio/t44-docker.com/structs/Hub/WorkspaceConnectionConfig'
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "extends": "../../../tsconfig.paths.json",
3
+ "compilerOptions": {
4
+ "target": "es2021",
5
+ "module": "esnext",
6
+ "lib": [
7
+ "ES2021",
8
+ "DOM"
9
+ ],
10
+ "types": [
11
+ "bun",
12
+ "node"
13
+ ],
14
+ "moduleResolution": "bundler",
15
+ "strict": true,
16
+ "esModuleInterop": true,
17
+ "skipLibCheck": true,
18
+ "forceConsistentCasingInFileNames": true,
19
+ "resolveJsonModule": true,
20
+ "allowSyntheticDefaultImports": true
21
+ },
22
+ "include": [
23
+ "**/*.ts"
24
+ ],
25
+ "exclude": [
26
+ "node_modules"
27
+ ]
28
+ }