@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,59 @@
1
+ #!/usr/bin/env bun test --timeout 30000
2
+
3
+ import * as bunTest from 'bun:test'
4
+ import { run } from 't44/standalone-rt'
5
+
6
+ const {
7
+ test: { describe, it, expect },
8
+ containers,
9
+ } = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
10
+ const spine = await encapsulate({
11
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
12
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
13
+ '#': {
14
+ test: {
15
+ type: CapsulePropertyTypes.Mapping,
16
+ value: 't44/caps/ProjectTest',
17
+ options: { '#': { bunTest, env: {} } }
18
+ },
19
+ containers: {
20
+ type: CapsulePropertyTypes.Mapping,
21
+ value: './Containers',
22
+ },
23
+ }
24
+ }
25
+ }, {
26
+ importMeta: import.meta,
27
+ importStack: makeImportStack(),
28
+ capsuleName: '@stream44.studio/t44-docker.com/caps/Containers.test'
29
+ })
30
+ return { spine }
31
+ }, async ({ spine, apis }: any) => {
32
+ return apis[spine.capsuleSourceLineRef]
33
+ }, { importMeta: import.meta })
34
+
35
+ describe('Containers Capsule', () => {
36
+
37
+ describe('list', () => {
38
+ it('should return a string with default format', async () => {
39
+ const result = await containers.list({ all: true });
40
+ expect(typeof result).toBe('string');
41
+ });
42
+
43
+ it('should return a string with custom format', async () => {
44
+ const result = await containers.list({ all: true, format: '{{.ID}}' });
45
+ expect(typeof result).toBe('string');
46
+ });
47
+
48
+ it('should return an array when json is true', async () => {
49
+ const result = await containers.list({ all: true, json: true });
50
+ expect(Array.isArray(result)).toBe(true);
51
+ });
52
+
53
+ it('should filter by name', async () => {
54
+ const result = await containers.list({ all: true, filter: 'name=nonexistent-container-xyz', format: '{{.ID}}' });
55
+ expect(typeof result).toBe('string');
56
+ expect((result as string).trim()).toBe('');
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,47 @@
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
+ cli: {
16
+ type: CapsulePropertyTypes.Mapping,
17
+ value: './Cli',
18
+ },
19
+
20
+ list: {
21
+ type: CapsulePropertyTypes.Function,
22
+ value: async function (this: any, options: {
23
+ all?: boolean; filter?: string; format?: string; json?: boolean;
24
+ } = {}): Promise<string | any[]> {
25
+ const { all = false, filter, format, json = false } = options;
26
+ const args = ['ps'];
27
+ if (all) args.push('-a');
28
+ if (filter) args.push('--filter', filter);
29
+ if (json && !format) { args.push('--format', 'json'); }
30
+ else if (format) { args.push('--format', format); }
31
+ const result = await this.cli.exec(args);
32
+ if (json && !format) {
33
+ const lines = result.split('\n').filter((line: string) => line.trim());
34
+ if (lines.length === 0) return [];
35
+ return lines.map((line: string) => JSON.parse(line));
36
+ }
37
+ return result;
38
+ }
39
+ },
40
+ }
41
+ }
42
+ }, {
43
+ importMeta: import.meta,
44
+ importStack: makeImportStack(),
45
+ capsuleName: '@stream44.studio/t44-docker.com/caps/Containers',
46
+ })
47
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bun test --timeout 60000
2
+
3
+ import * as bunTest from 'bun:test'
4
+ import { run } from 't44/workspace-rt'
5
+
6
+ const {
7
+ test: { describe, it, expect },
8
+ hub,
9
+ } = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
10
+ const spine = await encapsulate({
11
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
12
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
13
+ '#': {
14
+ test: {
15
+ type: CapsulePropertyTypes.Mapping,
16
+ value: 't44/caps/ProjectTest',
17
+ options: {
18
+ '#': {
19
+ bunTest,
20
+ env: {
21
+ DOCKERHUB_USERNAME: { factReference: '@stream44.studio/t44-docker.com/structs/Hub/WorkspaceConnectionConfig:username' },
22
+ DOCKERHUB_PASSWORD: { factReference: '@stream44.studio/t44-docker.com/structs/Hub/WorkspaceConnectionConfig:password' },
23
+ DOCKERHUB_ORGANIZATION: { factReference: '@stream44.studio/t44-docker.com/structs/Hub/WorkspaceConnectionConfig:organization' },
24
+ }
25
+ }
26
+ }
27
+ },
28
+ hub: {
29
+ type: CapsulePropertyTypes.Mapping,
30
+ value: './Hub',
31
+ },
32
+ }
33
+ }
34
+ }, {
35
+ importMeta: import.meta,
36
+ importStack: makeImportStack(),
37
+ capsuleName: '@stream44.studio/t44-docker.com/caps/Hub.test'
38
+ })
39
+ return { spine }
40
+ }, async ({ spine, apis }: any) => {
41
+ return apis[spine.capsuleSourceLineRef]
42
+ }, {
43
+ importMeta: import.meta
44
+ })
45
+
46
+ describe('Docker Hub Capsule', function () {
47
+
48
+ it('should have default values', function () {
49
+ expect(hub.verbose).toBe(false);
50
+ expect(hub._token).toBeUndefined();
51
+ })
52
+
53
+ it('authenticate()', async function () {
54
+ const token = await hub.authenticate();
55
+ expect(token).toBeTruthy();
56
+ expect(typeof token).toBe('string');
57
+ expect(hub._token).toBe(token);
58
+ })
59
+
60
+ it('getNamespace() returns organization or username', async function () {
61
+ const ns = await hub.getNamespace();
62
+ expect(ns).toBeTruthy();
63
+ expect(typeof ns).toBe('string');
64
+ })
65
+
66
+ it('getStats() for a public repository', async function () {
67
+ const stats = await hub.getStats({
68
+ repository: 'alpine',
69
+ namespace: 'library',
70
+ });
71
+
72
+ expect(stats).toBeDefined();
73
+ expect(stats.name).toBe('alpine');
74
+ expect(stats.namespace).toBe('library');
75
+ expect(stats.pull_count).toBeGreaterThan(0);
76
+ })
77
+
78
+ it('getTags() for a public repository', async function () {
79
+ const tags = await hub.getTags({
80
+ repository: 'alpine',
81
+ namespace: 'library',
82
+ });
83
+
84
+ expect(tags).toBeArray();
85
+ expect(tags.length).toBeGreaterThan(0);
86
+ expect(tags).toContain('latest');
87
+ })
88
+
89
+ it('ensureTagged() succeeds for existing tag', async function () {
90
+ const tag = await hub.ensureTagged({
91
+ repository: 'alpine',
92
+ namespace: 'library',
93
+ tag: 'latest',
94
+ });
95
+
96
+ expect(tag).toBe('latest');
97
+ })
98
+
99
+ it('ensureTagged() throws for non-existent tag', async function () {
100
+ await expect(hub.ensureTagged({
101
+ repository: 'alpine',
102
+ namespace: 'library',
103
+ tag: 'this-tag-does-not-exist-ever-12345',
104
+ })).rejects.toThrow('not found');
105
+ })
106
+
107
+ })
package/caps/Hub.ts ADDED
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Docker Hub API Capsule
3
+ * @see https://docs.docker.com/docker-hub/api/latest/
4
+ */
5
+
6
+ export async function capsule({
7
+ encapsulate,
8
+ CapsulePropertyTypes,
9
+ makeImportStack
10
+ }: {
11
+ encapsulate: any
12
+ CapsulePropertyTypes: any
13
+ makeImportStack: any
14
+ }) {
15
+
16
+ return encapsulate({
17
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
18
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
19
+ '#@stream44.studio/t44-docker.com/structs/Hub/WorkspaceConnectionConfig': {
20
+ as: '$ConnectionConfig'
21
+ },
22
+ '#': {
23
+
24
+ verbose: {
25
+ type: CapsulePropertyTypes.Literal,
26
+ value: false,
27
+ },
28
+
29
+ _token: {
30
+ type: CapsulePropertyTypes.Literal,
31
+ value: undefined as string | undefined,
32
+ },
33
+
34
+ /**
35
+ * Get the namespace (organization or username)
36
+ */
37
+ getNamespace: {
38
+ type: CapsulePropertyTypes.Function,
39
+ value: async function (this: any): Promise<string> {
40
+ const org = await this.$ConnectionConfig.getConfigValue('organization').catch(() => undefined);
41
+ const username = await this.$ConnectionConfig.getConfigValue('username');
42
+ return org || username;
43
+ }
44
+ },
45
+
46
+ /**
47
+ * Authenticate with Docker Hub and get a JWT token
48
+ */
49
+ authenticate: {
50
+ type: CapsulePropertyTypes.Function,
51
+ value: async function (this: any): Promise<string> {
52
+ const username = await this.$ConnectionConfig.getConfigValue('username');
53
+ const password = await this.$ConnectionConfig.getConfigValue('password');
54
+
55
+ if (this.verbose) {
56
+ console.log(`[Hub] Authenticating as ${username}`);
57
+ }
58
+
59
+ const response = await fetch('https://hub.docker.com/v2/users/login', {
60
+ method: 'POST',
61
+ headers: { 'Content-Type': 'application/json' },
62
+ body: JSON.stringify({
63
+ username,
64
+ password,
65
+ }),
66
+ });
67
+
68
+ if (!response.ok) {
69
+ const error = await response.text();
70
+ throw new Error(`Docker Hub authentication failed: ${response.status} ${error}`);
71
+ }
72
+
73
+ const data = await response.json() as { token?: string };
74
+ this._token = data.token;
75
+
76
+ if (!this._token) {
77
+ throw new Error('Docker Hub authentication failed: No token received');
78
+ }
79
+
80
+ if (this.verbose) {
81
+ console.log(`[Hub] Authentication successful`);
82
+ }
83
+
84
+ return this._token;
85
+ }
86
+ },
87
+
88
+ /**
89
+ * Ensure we have a valid token
90
+ */
91
+ ensureAuthenticated: {
92
+ type: CapsulePropertyTypes.Function,
93
+ value: async function (this: any): Promise<string> {
94
+ if (!this._token) {
95
+ await this.authenticate();
96
+ }
97
+ return this._token!;
98
+ }
99
+ },
100
+
101
+ /**
102
+ * Internal helper to make API calls to Docker Hub
103
+ */
104
+ apiCall: {
105
+ type: CapsulePropertyTypes.Function,
106
+ value: async function (this: any, options: {
107
+ method: 'GET' | 'POST' | 'DELETE';
108
+ path: string;
109
+ requireAuth?: boolean;
110
+ body?: any;
111
+ }): Promise<any> {
112
+ const { method, path, requireAuth = true, body } = options;
113
+
114
+ const headers: Record<string, string> = {};
115
+
116
+ if (requireAuth) {
117
+ const token = await this.ensureAuthenticated();
118
+ headers['Authorization'] = `JWT ${token}`;
119
+ } else if (this._token) {
120
+ headers['Authorization'] = `JWT ${this._token}`;
121
+ }
122
+
123
+ if (body) {
124
+ headers['Content-Type'] = 'application/json';
125
+ }
126
+
127
+ const url = `https://hub.docker.com${path}`;
128
+
129
+ if (this.verbose) {
130
+ console.log(`[Hub] ${method} ${path}`);
131
+ }
132
+
133
+ const response = await fetch(url, {
134
+ method,
135
+ headers,
136
+ body: body ? JSON.stringify(body) : undefined,
137
+ });
138
+
139
+ if (!response.ok) {
140
+ const error = await response.text();
141
+ throw new Error(`API call failed: ${method} ${path} - ${response.status} ${error}`);
142
+ }
143
+
144
+ if (method === 'DELETE') {
145
+ return {};
146
+ }
147
+
148
+ return await response.json();
149
+ }
150
+ },
151
+
152
+ /**
153
+ * Get all tags in a repository
154
+ */
155
+ getTags: {
156
+ type: CapsulePropertyTypes.Function,
157
+ value: async function (this: any, options: {
158
+ repository: string;
159
+ namespace?: string;
160
+ }): Promise<string[]> {
161
+ const namespace = options.namespace || this.getNamespace();
162
+ const repository = options.repository;
163
+
164
+ const data = await this.apiCall({
165
+ method: 'GET',
166
+ path: `/v2/repositories/${namespace}/${repository}/tags/?page_size=100`,
167
+ });
168
+
169
+ return data.results?.map((result: any) => result.name) || [];
170
+ }
171
+ },
172
+
173
+ /**
174
+ * Verify that a tag exists in the repository
175
+ */
176
+ ensureTagged: {
177
+ type: CapsulePropertyTypes.Function,
178
+ value: async function (this: any, options: {
179
+ repository: string;
180
+ tag: string;
181
+ namespace?: string;
182
+ }): Promise<string> {
183
+ const tags = await this.getTags({
184
+ repository: options.repository,
185
+ namespace: options.namespace,
186
+ });
187
+
188
+ if (!tags.includes(options.tag)) {
189
+ throw new Error(`Tag ${options.tag} not found in repository`);
190
+ }
191
+
192
+ return options.tag;
193
+ }
194
+ },
195
+
196
+ /**
197
+ * Get repository statistics including pull count, star count, etc.
198
+ */
199
+ getStats: {
200
+ type: CapsulePropertyTypes.Function,
201
+ value: async function (this: any, options: {
202
+ repository: string;
203
+ namespace?: string;
204
+ }): Promise<{
205
+ pull_count: number;
206
+ star_count: number;
207
+ name: string;
208
+ namespace: string;
209
+ description: string;
210
+ is_private: boolean;
211
+ last_updated: string;
212
+ }> {
213
+ const namespace = options.namespace || this.getNamespace();
214
+ const repository = options.repository;
215
+
216
+ const data = await this.apiCall({
217
+ method: 'GET',
218
+ path: `/v2/repositories/${namespace}/${repository}/`,
219
+ requireAuth: false,
220
+ });
221
+
222
+ return {
223
+ pull_count: data.pull_count || 0,
224
+ star_count: data.star_count || 0,
225
+ name: data.name,
226
+ namespace: data.namespace,
227
+ description: data.description || '',
228
+ is_private: data.is_private || false,
229
+ last_updated: data.last_updated,
230
+ };
231
+ }
232
+ },
233
+
234
+ /**
235
+ * Delete a specific tag from a repository
236
+ */
237
+ deleteTag: {
238
+ type: CapsulePropertyTypes.Function,
239
+ value: async function (this: any, options: {
240
+ repository: string;
241
+ tag: string;
242
+ namespace?: string;
243
+ timeoutMs?: number;
244
+ pollIntervalMs?: number;
245
+ }): Promise<void> {
246
+ const namespace = options.namespace || this.getNamespace();
247
+ const repository = options.repository;
248
+ const tag = options.tag;
249
+ const timeoutMs = options.timeoutMs ?? 30000;
250
+ const pollIntervalMs = options.pollIntervalMs ?? 2000;
251
+
252
+ if (this.verbose) {
253
+ console.log(`[Hub] Deleting tag ${namespace}/${repository}:${tag}`);
254
+ }
255
+
256
+ try {
257
+ await this.apiCall({
258
+ method: 'DELETE',
259
+ path: `/v2/repositories/${namespace}/${repository}/tags/${tag}/`,
260
+ });
261
+ } catch (error: any) {
262
+ if (error?.message?.includes('403')) {
263
+ throw new Error('Tag deletion not permitted: token lacks delete permissions');
264
+ }
265
+ throw error;
266
+ }
267
+
268
+ // Poll to verify deletion
269
+ const startTime = Date.now();
270
+ while (Date.now() - startTime < timeoutMs) {
271
+ const tags = await this.getTags({ repository, namespace });
272
+ if (!tags.includes(tag)) {
273
+ if (this.verbose) {
274
+ console.log(`[Hub] Tag deleted successfully`);
275
+ }
276
+ return;
277
+ }
278
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
279
+ }
280
+
281
+ throw new Error(`Tag deletion verification timed out after ${timeoutMs}ms`);
282
+ }
283
+ },
284
+
285
+ /**
286
+ * Delete an entire repository from Docker Hub
287
+ */
288
+ deleteRepository: {
289
+ type: CapsulePropertyTypes.Function,
290
+ value: async function (this: any, options: {
291
+ repository: string;
292
+ namespace?: string;
293
+ wait?: boolean;
294
+ timeoutMs?: number;
295
+ pollIntervalMs?: number;
296
+ }): Promise<void> {
297
+ const namespace = options.namespace || this.getNamespace();
298
+ const repository = options.repository;
299
+ const { wait = false, timeoutMs = 5 * 60 * 1000, pollIntervalMs = 15000 } = options;
300
+
301
+ if (this.verbose) {
302
+ console.log(`[Hub] Deleting repository ${namespace}/${repository}`);
303
+ }
304
+
305
+ await this.apiCall({
306
+ method: 'DELETE',
307
+ path: `/v2/repositories/${namespace}/${repository}/`,
308
+ });
309
+
310
+ if (wait) {
311
+ const startTime = Date.now();
312
+ while (Date.now() - startTime < timeoutMs) {
313
+ try {
314
+ await this.getStats({ repository, namespace });
315
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
316
+ } catch (error: any) {
317
+ if (error?.message?.includes('404') || error?.message?.includes('not found')) {
318
+ if (this.verbose) {
319
+ console.log(`[Hub] Repository deletion confirmed`);
320
+ }
321
+ return;
322
+ }
323
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
324
+ }
325
+ }
326
+ throw new Error(`Timeout waiting for repository deletion after ${timeoutMs}ms`);
327
+ }
328
+ }
329
+ },
330
+
331
+ /**
332
+ * Login to Docker Hub registry via CLI
333
+ */
334
+ loginCli: {
335
+ type: CapsulePropertyTypes.Function,
336
+ value: async function (this: any, options?: { cli?: any }): Promise<string> {
337
+ const cli = options?.cli;
338
+ if (!cli) {
339
+ throw new Error('cli capsule must be provided to loginCli');
340
+ }
341
+
342
+ const username = await this.$ConnectionConfig.getConfigValue('username');
343
+ const password = await this.$ConnectionConfig.getConfigValue('password');
344
+
345
+ if (this.verbose) {
346
+ console.log(`[Hub] Logging in to Docker Hub as ${username}`);
347
+ }
348
+
349
+ const result = await cli.exec([
350
+ 'login',
351
+ '-u', username,
352
+ '--password-stdin',
353
+ 'registry.hub.docker.com'
354
+ ], { stdin: password + '\n' });
355
+
356
+ return result;
357
+ }
358
+ },
359
+
360
+ }
361
+ }
362
+ }, {
363
+ importMeta: import.meta,
364
+ importStack: makeImportStack(),
365
+ capsuleName: '@stream44.studio/t44-docker.com/caps/Hub',
366
+ })
367
+ }
@@ -0,0 +1,40 @@
1
+ # Smallest option: Alpine with glibc (~16MB base + binary)
2
+ # Best for: Production where size is critical
3
+ # Trade-off: Minimal debugging tools
4
+
5
+ FROM frolvlad/alpine-glibc:latest
6
+
7
+ # Install wget for health checks and bun for native modules
8
+ RUN apk add --no-cache wget ca-certificates unzip bash curl libstdc++ libgcc
9
+
10
+ # Install Bun to system-wide location
11
+ RUN wget -qO- https://bun.sh/install | bash -s -- bun-v1.2.0 && \
12
+ mv /root/.bun/bin/bun /usr/local/bin/bun && \
13
+ chmod +x /usr/local/bin/bun
14
+
15
+ # Set working directory
16
+ WORKDIR /app
17
+
18
+ # Copy all app files from build context
19
+ COPY . /app/
20
+
21
+ # Install only production dependencies (native modules)
22
+ RUN bun install --production --no-save
23
+
24
+ # Make binaries executable (if any)
25
+ RUN find /app -type f -name "*.sh" -exec chmod +x {} \; || true
26
+ RUN find /app/dist -type f -executable -exec chmod +x {} \; 2>/dev/null || true
27
+
28
+ # Create non-root user
29
+ RUN addgroup -g 1001 -S appuser && \
30
+ adduser -u 1001 -S appuser -G appuser && \
31
+ chown -R appuser:appuser /app
32
+
33
+ # Switch to non-root user
34
+ USER appuser
35
+
36
+ # Environment variables (can be overridden at runtime)
37
+ ENV IPFS_GATEWAY_TOKEN=""
38
+
39
+ # Run the app using bun
40
+ CMD ["bun", "run", "start"]
@@ -0,0 +1,45 @@
1
+ # Most secure option: Google Distroless (~20-30MB base)
2
+ # Best for: Production security (no shell, minimal attack surface)
3
+ # Trade-off: No wget for health checks, harder to debug
4
+
5
+ # Build stage for installing native dependencies
6
+ FROM oven/bun:1.2-slim AS builder
7
+ WORKDIR /app
8
+ # Copy all app files
9
+ COPY . /app/
10
+ RUN bun install --production --no-save
11
+
12
+ # Use Debian base for glibc compatibility
13
+ FROM gcr.io/distroless/base-debian12:latest
14
+
15
+ # Set working directory
16
+ WORKDIR /app
17
+
18
+ # Copy Bun runtime from builder
19
+ COPY --from=builder /usr/local/bin/bun /usr/local/bin/bun
20
+
21
+ # Copy C++ runtime libraries from builder (required by libsql native module)
22
+ COPY --from=builder /usr/lib/*/libstdc++.so.6 /usr/lib/
23
+ COPY --from=builder /usr/lib/*/libgcc_s.so.1 /usr/lib/
24
+
25
+ # Copy native dependencies from builder
26
+ COPY --from=builder /app/node_modules ./node_modules
27
+
28
+ # Copy all app files from builder
29
+ COPY --from=builder /app /app
30
+
31
+ # Create non-root user and set ownership
32
+ # Note: distroless uses numeric UIDs since there's no shell to create users
33
+ COPY --from=builder --chown=1001:1001 /app /app
34
+
35
+ # Switch to non-root user (numeric UID since distroless has no user database)
36
+ USER 1001
37
+
38
+ # Environment variables (can be overridden at runtime)
39
+ ENV IPFS_GATEWAY_TOKEN=""
40
+
41
+ # Note: Health checks won't work without wget/curl
42
+ # You'll need to rely on external monitoring or TCP checks
43
+
44
+ # Run the app using bun
45
+ CMD ["bun", "run", "start"]
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "ncloud-docker-image",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "start": "echo 'Specify your own package.json file!'"
7
+ }
8
+ }