@intelmesh/sdk 0.1.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.
Files changed (45) hide show
  1. package/.github/scripts/compute-disttag.sh +47 -0
  2. package/.github/workflows/release.yml +206 -0
  3. package/.husky/commit-msg +1 -0
  4. package/.husky/pre-commit +2 -0
  5. package/.prettierrc +8 -0
  6. package/CLAUDE.md +37 -0
  7. package/LICENSE +21 -0
  8. package/commitlint.config.cjs +3 -0
  9. package/dist/index.d.ts +1293 -0
  10. package/dist/index.js +1651 -0
  11. package/docs/superpowers/plans/2026-04-10-release-pipeline.md +798 -0
  12. package/docs/superpowers/specs/2026-04-10-release-pipeline-design.md +309 -0
  13. package/eslint.config.mjs +38 -0
  14. package/package.json +72 -0
  15. package/src/builders/event.ts +72 -0
  16. package/src/builders/rule.ts +143 -0
  17. package/src/client/errors.ts +171 -0
  18. package/src/client/http.ts +209 -0
  19. package/src/client/intelmesh.ts +57 -0
  20. package/src/client/pagination.ts +50 -0
  21. package/src/generated/types.ts +11 -0
  22. package/src/index.ts +106 -0
  23. package/src/provision/index.ts +6 -0
  24. package/src/provision/provisioner.ts +326 -0
  25. package/src/provision/rule-builder.ts +193 -0
  26. package/src/resources/apikeys.ts +63 -0
  27. package/src/resources/audit.ts +29 -0
  28. package/src/resources/evaluations.ts +38 -0
  29. package/src/resources/events.ts +61 -0
  30. package/src/resources/lists.ts +91 -0
  31. package/src/resources/phases.ts +71 -0
  32. package/src/resources/rules.ts +98 -0
  33. package/src/resources/scopes.ts +71 -0
  34. package/src/resources/scores.ts +63 -0
  35. package/src/testkit/assertion.ts +76 -0
  36. package/src/testkit/harness.ts +252 -0
  37. package/src/testkit/index.ts +7 -0
  38. package/src/types.ts +330 -0
  39. package/tests/client/errors.test.ts +159 -0
  40. package/tests/provision/provisioner.test.ts +311 -0
  41. package/tests/scripts/compute-disttag.test.ts +178 -0
  42. package/tests/testkit/harness.test.ts +291 -0
  43. package/tsconfig.eslint.json +8 -0
  44. package/tsconfig.json +29 -0
  45. package/vitest.config.ts +14 -0
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ ForbiddenError,
4
+ IntelMeshError,
5
+ InternalError,
6
+ isForbidden,
7
+ isIntelMeshError,
8
+ isNetwork,
9
+ isNotFound,
10
+ isUnauthorized,
11
+ isValidation,
12
+ mapStatusToError,
13
+ NetworkError,
14
+ NotFoundError,
15
+ ParseError,
16
+ UnavailableError,
17
+ UnauthorizedError,
18
+ ValidationError,
19
+ } from '../../src/client/errors.js';
20
+
21
+ describe('IntelMeshError', () => {
22
+ it('stores status, code, and message', () => {
23
+ const err = new IntelMeshError('test', 500, 'ERR');
24
+ expect(err.message).toBe('test');
25
+ expect(err.status).toBe(500);
26
+ expect(err.code).toBe('ERR');
27
+ expect(err.name).toBe('IntelMeshError');
28
+ });
29
+
30
+ it('extends Error', () => {
31
+ const err = new IntelMeshError('test', 500, 'ERR');
32
+ expect(err).toBeInstanceOf(Error);
33
+ });
34
+ });
35
+
36
+ describe('Typed error subclasses', () => {
37
+ it('ValidationError has status 400', () => {
38
+ const err = new ValidationError('bad input');
39
+ expect(err.status).toBe(400);
40
+ expect(err.name).toBe('ValidationError');
41
+ expect(err).toBeInstanceOf(IntelMeshError);
42
+ });
43
+
44
+ it('NotFoundError has status 404', () => {
45
+ const err = new NotFoundError('missing');
46
+ expect(err.status).toBe(404);
47
+ expect(err.name).toBe('NotFoundError');
48
+ });
49
+
50
+ it('UnauthorizedError has status 401', () => {
51
+ const err = new UnauthorizedError('no auth');
52
+ expect(err.status).toBe(401);
53
+ expect(err.name).toBe('UnauthorizedError');
54
+ });
55
+
56
+ it('ForbiddenError has status 403', () => {
57
+ const err = new ForbiddenError('denied');
58
+ expect(err.status).toBe(403);
59
+ expect(err.name).toBe('ForbiddenError');
60
+ });
61
+
62
+ it('InternalError has status 500', () => {
63
+ const err = new InternalError('boom');
64
+ expect(err.status).toBe(500);
65
+ expect(err.name).toBe('InternalError');
66
+ });
67
+
68
+ it('UnavailableError has status 503', () => {
69
+ const err = new UnavailableError('down');
70
+ expect(err.status).toBe(503);
71
+ expect(err.name).toBe('UnavailableError');
72
+ });
73
+
74
+ it('NetworkError has status 0', () => {
75
+ const err = new NetworkError('offline');
76
+ expect(err.status).toBe(0);
77
+ expect(err.code).toBe('NETWORK_ERROR');
78
+ });
79
+
80
+ it('ParseError has status 0', () => {
81
+ const err = new ParseError('bad json');
82
+ expect(err.status).toBe(0);
83
+ expect(err.code).toBe('PARSE_ERROR');
84
+ });
85
+ });
86
+
87
+ describe('Type guard helpers', () => {
88
+ it('isNotFound identifies NotFoundError', () => {
89
+ expect(isNotFound(new NotFoundError('x'))).toBe(true);
90
+ expect(isNotFound(new ValidationError('x'))).toBe(false);
91
+ expect(isNotFound(new Error('x'))).toBe(false);
92
+ expect(isNotFound(null)).toBe(false);
93
+ });
94
+
95
+ it('isValidation identifies ValidationError', () => {
96
+ expect(isValidation(new ValidationError('x'))).toBe(true);
97
+ expect(isValidation(new NotFoundError('x'))).toBe(false);
98
+ });
99
+
100
+ it('isUnauthorized identifies UnauthorizedError', () => {
101
+ expect(isUnauthorized(new UnauthorizedError('x'))).toBe(true);
102
+ expect(isUnauthorized(new ForbiddenError('x'))).toBe(false);
103
+ });
104
+
105
+ it('isForbidden identifies ForbiddenError', () => {
106
+ expect(isForbidden(new ForbiddenError('x'))).toBe(true);
107
+ expect(isForbidden(new UnauthorizedError('x'))).toBe(false);
108
+ });
109
+
110
+ it('isNetwork identifies NetworkError', () => {
111
+ expect(isNetwork(new NetworkError('x'))).toBe(true);
112
+ expect(isNetwork(new ParseError('x'))).toBe(false);
113
+ });
114
+
115
+ it('isIntelMeshError matches all subclasses', () => {
116
+ expect(isIntelMeshError(new ValidationError('x'))).toBe(true);
117
+ expect(isIntelMeshError(new NetworkError('x'))).toBe(true);
118
+ expect(isIntelMeshError(new Error('x'))).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe('mapStatusToError', () => {
123
+ it('maps 400 to ValidationError', () => {
124
+ const err = mapStatusToError(400, 'bad', 'INVALID');
125
+ expect(err).toBeInstanceOf(ValidationError);
126
+ expect(err.code).toBe('INVALID');
127
+ });
128
+
129
+ it('maps 401 to UnauthorizedError', () => {
130
+ const err = mapStatusToError(401, 'no auth', 'UNAUTH');
131
+ expect(err).toBeInstanceOf(UnauthorizedError);
132
+ });
133
+
134
+ it('maps 403 to ForbiddenError', () => {
135
+ const err = mapStatusToError(403, 'denied', 'FORBIDDEN');
136
+ expect(err).toBeInstanceOf(ForbiddenError);
137
+ });
138
+
139
+ it('maps 404 to NotFoundError', () => {
140
+ const err = mapStatusToError(404, 'gone', 'MISSING');
141
+ expect(err).toBeInstanceOf(NotFoundError);
142
+ });
143
+
144
+ it('maps 503 to UnavailableError', () => {
145
+ const err = mapStatusToError(503, 'down', 'UNAVAIL');
146
+ expect(err).toBeInstanceOf(UnavailableError);
147
+ });
148
+
149
+ it('maps 502 to InternalError', () => {
150
+ const err = mapStatusToError(502, 'gateway', 'GW');
151
+ expect(err).toBeInstanceOf(InternalError);
152
+ });
153
+
154
+ it('maps unknown status to IntelMeshError', () => {
155
+ const err = mapStatusToError(429, 'rate', 'RATE');
156
+ expect(err).toBeInstanceOf(IntelMeshError);
157
+ expect(err.status).toBe(429);
158
+ });
159
+ });
@@ -0,0 +1,311 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Provisioner unit tests — mock fetch, no network calls
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { IntelMesh } from '../../src/client/intelmesh.js';
7
+ import { Provisioner } from '../../src/provision/provisioner.js';
8
+
9
+ /** Tracks resource creation and deletion counts. */
10
+ interface MockState {
11
+ created: Record<string, number>;
12
+ deleted: Record<string, number>;
13
+ idSeq: number;
14
+ }
15
+
16
+ /**
17
+ * Handles POST requests in the mock fetch.
18
+ * @param url
19
+ * @param state
20
+ */
21
+ // eslint-disable-next-line max-lines-per-function -- each branch is a simple JSON fixture; splitting further would obscure test data
22
+ function handlePost(url: string, state: MockState): Response | null {
23
+ if (url.includes('/api/v1/phases')) {
24
+ state.created['phases'] = (state.created['phases'] ?? 0) + 1;
25
+ state.idSeq++;
26
+ return jsonResponse({
27
+ data: {
28
+ id: `phase-${String(state.idSeq)}`,
29
+ name: 'p',
30
+ position: 1,
31
+ created_at: '2025-01-01T00:00:00Z',
32
+ },
33
+ });
34
+ }
35
+ if (url.includes('/api/v1/scopes')) {
36
+ state.created['scopes'] = (state.created['scopes'] ?? 0) + 1;
37
+ state.idSeq++;
38
+ return jsonResponse({
39
+ data: {
40
+ id: `scope-${String(state.idSeq)}`,
41
+ name: 's',
42
+ json_path: '$.x',
43
+ created_at: '2025-01-01T00:00:00Z',
44
+ },
45
+ });
46
+ }
47
+ if (url.includes('/api/v1/lists')) {
48
+ state.created['lists'] = (state.created['lists'] ?? 0) + 1;
49
+ state.idSeq++;
50
+ return jsonResponse({
51
+ data: {
52
+ id: `list-${String(state.idSeq)}`,
53
+ name: 'l',
54
+ description: '',
55
+ created_at: '2025-01-01T00:00:00Z',
56
+ updated_at: '2025-01-01T00:00:00Z',
57
+ },
58
+ });
59
+ }
60
+ if (url.includes('/api/v1/rules')) {
61
+ state.created['rules'] = (state.created['rules'] ?? 0) + 1;
62
+ state.idSeq++;
63
+ return jsonResponse({
64
+ data: {
65
+ id: `rule-${String(state.idSeq)}`,
66
+ name: 'r',
67
+ phase_id: 'p1',
68
+ priority: 1,
69
+ expression: 'true',
70
+ applicable_when: '',
71
+ actions: {},
72
+ enabled: true,
73
+ dry_run: false,
74
+ current_version_id: 'v1',
75
+ created_at: '2025-01-01T00:00:00Z',
76
+ updated_at: '2025-01-01T00:00:00Z',
77
+ },
78
+ });
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Handles DELETE requests in the mock fetch.
85
+ * @param url
86
+ * @param state
87
+ */
88
+ function handleDelete(url: string, state: MockState): Response | null {
89
+ if (url.includes('/api/v1/phases/')) {
90
+ state.deleted['phases'] = (state.deleted['phases'] ?? 0) + 1;
91
+ return jsonResponse({ data: null });
92
+ }
93
+ if (url.includes('/api/v1/scopes/')) {
94
+ state.deleted['scopes'] = (state.deleted['scopes'] ?? 0) + 1;
95
+ return jsonResponse({ data: null });
96
+ }
97
+ if (url.includes('/api/v1/lists/')) {
98
+ state.deleted['lists'] = (state.deleted['lists'] ?? 0) + 1;
99
+ return jsonResponse({ data: null });
100
+ }
101
+ if (url.includes('/api/v1/rules/')) {
102
+ state.deleted['rules'] = (state.deleted['rules'] ?? 0) + 1;
103
+ return jsonResponse({ data: null });
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Creates a mock fetch that simulates the IntelMesh API.
110
+ * @param state
111
+ */
112
+ function createMockFetch(state: MockState): typeof globalThis.fetch {
113
+ return vi.fn((input: string | URL | Request, init?: RequestInit) => {
114
+ const url =
115
+ typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
116
+ const method = init?.method ?? 'GET';
117
+
118
+ if (method === 'POST') {
119
+ const res = handlePost(url, state);
120
+ if (res) return Promise.resolve(res);
121
+ }
122
+
123
+ if (method === 'DELETE') {
124
+ const res = handleDelete(url, state);
125
+ if (res) return Promise.resolve(res);
126
+ }
127
+
128
+ return Promise.resolve(new Response('Not Found', { status: 404 }));
129
+ }) as typeof globalThis.fetch;
130
+ }
131
+
132
+ /**
133
+ * Creates a JSON Response.
134
+ * @param body
135
+ */
136
+ function jsonResponse(body: unknown): Response {
137
+ return new Response(JSON.stringify(body), {
138
+ status: 200,
139
+ headers: { 'Content-Type': 'application/json' },
140
+ });
141
+ }
142
+
143
+ // eslint-disable-next-line max-lines-per-function -- describe block spans many test cases; splitting into multiple files would obscure test coverage
144
+ describe('Provisioner', () => {
145
+ let state: MockState;
146
+ let client: IntelMesh;
147
+
148
+ beforeEach(() => {
149
+ state = { created: {}, deleted: {}, idSeq: 0 };
150
+ client = new IntelMesh({
151
+ baseUrl: 'http://localhost:8080',
152
+ apiKey: 'test-key',
153
+ fetch: createMockFetch(state),
154
+ });
155
+ });
156
+
157
+ it('applies phases, scopes, lists, and rules in order', async () => {
158
+ const p = new Provisioner(client)
159
+ .phase('screening', 1)
160
+ .scope('client_uuid', 'event.metadata.client_uuid')
161
+ .list('blocklist')
162
+ .rule('block-rule')
163
+ .inPhase('screening')
164
+ .priority(1)
165
+ .when('true')
166
+ .decide('block', 'critical')
167
+ .halt()
168
+ .done();
169
+
170
+ await p.apply();
171
+
172
+ expect(state.created['phases']).toBe(1);
173
+ expect(state.created['scopes']).toBe(1);
174
+ expect(state.created['lists']).toBe(1);
175
+ expect(state.created['rules']).toBe(1);
176
+ });
177
+
178
+ it('stores resolved IDs after apply', async () => {
179
+ const p = new Provisioner(client)
180
+ .phase('screening', 1)
181
+ .scope('client_uuid', 'event.metadata.client_uuid')
182
+ .list('blocklist')
183
+ .rule('block-rule')
184
+ .inPhase('screening')
185
+ .priority(1)
186
+ .when('true')
187
+ .done();
188
+
189
+ await p.apply();
190
+
191
+ expect(p.phaseId('screening')).toBeTruthy();
192
+ expect(p.scopeId('client_uuid')).toBeTruthy();
193
+ expect(p.listId('blocklist')).toBeTruthy();
194
+ expect(p.ruleId('block-rule')).toBeTruthy();
195
+ });
196
+
197
+ it('returns empty string for unknown names', () => {
198
+ const p = new Provisioner(client);
199
+ expect(p.phaseId('unknown')).toBe('');
200
+ expect(p.scopeId('unknown')).toBe('');
201
+ expect(p.listId('unknown')).toBe('');
202
+ expect(p.ruleId('unknown')).toBe('');
203
+ });
204
+
205
+ it('throws when rule references missing phase', async () => {
206
+ const p = new Provisioner(client)
207
+ .rule('orphan-rule')
208
+ .inPhase('nonexistent')
209
+ .priority(1)
210
+ .when('true')
211
+ .done();
212
+
213
+ await expect(p.apply()).rejects.toThrow('phase not found');
214
+ });
215
+
216
+ it('tears down resources in reverse order', async () => {
217
+ const p = new Provisioner(client)
218
+ .phase('screening', 1)
219
+ .scope('client_uuid', 'event.metadata.client_uuid')
220
+ .list('blocklist')
221
+ .rule('block-rule')
222
+ .inPhase('screening')
223
+ .priority(1)
224
+ .when('true')
225
+ .done();
226
+
227
+ await p.apply();
228
+ await p.teardown();
229
+
230
+ expect(state.deleted['rules']).toBe(1);
231
+ expect(state.deleted['lists']).toBe(1);
232
+ expect(state.deleted['scopes']).toBe(1);
233
+ expect(state.deleted['phases']).toBe(1);
234
+ });
235
+
236
+ it('supports multiple phases', async () => {
237
+ const p = new Provisioner(client)
238
+ .phase('screening', 1)
239
+ .phase('scoring', 2)
240
+ .phase('decision', 3);
241
+
242
+ await p.apply();
243
+
244
+ expect(state.created['phases']).toBe(3);
245
+ });
246
+
247
+ it('supports rule with score delta', async () => {
248
+ const p = new Provisioner(client)
249
+ .phase('scoring', 1)
250
+ .rule('add-score')
251
+ .inPhase('scoring')
252
+ .priority(1)
253
+ .when('true')
254
+ .addScore(10)
255
+ .continue()
256
+ .done();
257
+
258
+ await p.apply();
259
+
260
+ expect(state.created['rules']).toBe(1);
261
+ });
262
+
263
+ it('supports rule with list mutation', async () => {
264
+ const p = new Provisioner(client)
265
+ .phase('scoring', 1)
266
+ .list('med_blocked')
267
+ .rule('med-add')
268
+ .inPhase('scoring')
269
+ .priority(1)
270
+ .applicableWhen("event.type == 'bacen.med.add'")
271
+ .when('true')
272
+ .mutateList('list.add', 'med_blocked', 'event.metadata.client_uuid')
273
+ .continue()
274
+ .done();
275
+
276
+ await p.apply();
277
+
278
+ expect(state.created['lists']).toBe(1);
279
+ expect(state.created['rules']).toBe(1);
280
+ });
281
+
282
+ it('supports dry-run rules', async () => {
283
+ const p = new Provisioner(client)
284
+ .phase('screening', 1)
285
+ .rule('dry-rule')
286
+ .inPhase('screening')
287
+ .priority(1)
288
+ .when('true')
289
+ .dryRun()
290
+ .done();
291
+
292
+ await p.apply();
293
+
294
+ expect(state.created['rules']).toBe(1);
295
+ });
296
+
297
+ it('supports skip-phase flow', async () => {
298
+ const p = new Provisioner(client)
299
+ .phase('screening', 1)
300
+ .rule('skip-rule')
301
+ .inPhase('screening')
302
+ .priority(1)
303
+ .when('true')
304
+ .skipPhase()
305
+ .done();
306
+
307
+ await p.apply();
308
+
309
+ expect(state.created['rules']).toBe(1);
310
+ });
311
+ });
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const here = dirname(fileURLToPath(import.meta.url));
9
+ const scriptPath = resolve(here, '../../.github/scripts/compute-disttag.sh');
10
+
11
+ interface ScriptOutput {
12
+ version: string;
13
+ disttag: string;
14
+ is_prerelease: string;
15
+ }
16
+
17
+ interface RawResult {
18
+ status: number;
19
+ stderr: string;
20
+ }
21
+
22
+ function parseOutput(raw: string): ScriptOutput {
23
+ const map = new Map<string, string>();
24
+ for (const line of raw.split(/\r?\n/)) {
25
+ const idx = line.indexOf('=');
26
+ if (idx === -1) continue;
27
+ map.set(line.slice(0, idx), line.slice(idx + 1));
28
+ }
29
+ return {
30
+ version: map.get('version') ?? '',
31
+ disttag: map.get('disttag') ?? '',
32
+ is_prerelease: map.get('is_prerelease') ?? '',
33
+ };
34
+ }
35
+
36
+ function runScriptRaw(env: Record<string, string>): RawResult {
37
+ const dir = mkdtempSync(join(tmpdir(), 'disttag-'));
38
+ // Ensure we do NOT inherit GITHUB_REF_NAME or GITHUB_OUTPUT from
39
+ // the parent process — compose the env from scratch with only
40
+ // PATH (needed for bash discovery) and whatever the caller passes.
41
+ const cleanEnv: Record<string, string> = { PATH: process.env.PATH ?? '' };
42
+ Object.assign(cleanEnv, env);
43
+ const result = spawnSync('bash', [scriptPath], {
44
+ env: cleanEnv,
45
+ encoding: 'utf-8',
46
+ cwd: dir,
47
+ });
48
+ return {
49
+ status: result.status ?? -1,
50
+ stderr: result.stderr,
51
+ };
52
+ }
53
+
54
+ function runScript(refName: string): ScriptOutput {
55
+ const dir = mkdtempSync(join(tmpdir(), 'disttag-'));
56
+ const outputFile = join(dir, 'github_output');
57
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
58
+ writeFileSync(outputFile, '');
59
+ const result = spawnSync('bash', [scriptPath], {
60
+ env: {
61
+ ...process.env,
62
+ GITHUB_REF_NAME: refName,
63
+ GITHUB_OUTPUT: outputFile,
64
+ },
65
+ encoding: 'utf-8',
66
+ cwd: dir,
67
+ });
68
+ if (result.status !== 0) {
69
+ throw new Error(`Script failed (status=${String(result.status)}): ${result.stderr}`);
70
+ }
71
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
72
+ return parseOutput(readFileSync(outputFile, 'utf-8'));
73
+ }
74
+
75
+ describe('compute-disttag.sh', () => {
76
+ it('stable v1.2.3 resolves to latest', () => {
77
+ const out = runScript('v1.2.3');
78
+ expect(out.version).toBe('1.2.3');
79
+ expect(out.disttag).toBe('latest');
80
+ expect(out.is_prerelease).toBe('false');
81
+ });
82
+
83
+ it('v1.2.3-beta.1 resolves to beta dist-tag', () => {
84
+ const out = runScript('v1.2.3-beta.1');
85
+ expect(out.version).toBe('1.2.3-beta.1');
86
+ expect(out.disttag).toBe('beta');
87
+ expect(out.is_prerelease).toBe('true');
88
+ });
89
+
90
+ it('v1.2.3-rc.2 resolves to rc dist-tag', () => {
91
+ const out = runScript('v1.2.3-rc.2');
92
+ expect(out.disttag).toBe('rc');
93
+ expect(out.is_prerelease).toBe('true');
94
+ });
95
+
96
+ it('v2.0.0-alpha.7 resolves to alpha dist-tag', () => {
97
+ const out = runScript('v2.0.0-alpha.7');
98
+ expect(out.disttag).toBe('alpha');
99
+ expect(out.is_prerelease).toBe('true');
100
+ });
101
+
102
+ it('v2.0.0-next.0 resolves to next dist-tag', () => {
103
+ const out = runScript('v2.0.0-next.0');
104
+ expect(out.disttag).toBe('next');
105
+ expect(out.is_prerelease).toBe('true');
106
+ });
107
+
108
+ it('v1.0.0-beta without dot suffix resolves to beta', () => {
109
+ const out = runScript('v1.0.0-beta');
110
+ expect(out.version).toBe('1.0.0-beta');
111
+ expect(out.disttag).toBe('beta');
112
+ expect(out.is_prerelease).toBe('true');
113
+ });
114
+
115
+ it('v1.0.0-beta-2 (valid semver, hyphen in prerelease) resolves to beta-2', () => {
116
+ const out = runScript('v1.0.0-beta-2');
117
+ expect(out.version).toBe('1.0.0-beta-2');
118
+ expect(out.disttag).toBe('beta-2');
119
+ expect(out.is_prerelease).toBe('true');
120
+ });
121
+
122
+ it('v1.2.3-BETA.1 lowercases the disttag to beta', () => {
123
+ const out = runScript('v1.2.3-BETA.1');
124
+ expect(out.version).toBe('1.2.3-BETA.1');
125
+ expect(out.disttag).toBe('beta');
126
+ expect(out.is_prerelease).toBe('true');
127
+ });
128
+ });
129
+
130
+ describe('compute-disttag.sh — error handling', () => {
131
+ it('exits non-zero when GITHUB_REF_NAME is unset', () => {
132
+ const result = runScriptRaw({ GITHUB_OUTPUT: join(tmpdir(), 'unused') });
133
+ expect(result.status).not.toBe(0);
134
+ expect(result.stderr).toContain('GITHUB_REF_NAME');
135
+ });
136
+
137
+ it('exits non-zero when GITHUB_REF_NAME is empty', () => {
138
+ const result = runScriptRaw({
139
+ GITHUB_REF_NAME: '',
140
+ GITHUB_OUTPUT: join(tmpdir(), 'unused'),
141
+ });
142
+ expect(result.status).not.toBe(0);
143
+ expect(result.stderr).toContain('GITHUB_REF_NAME');
144
+ });
145
+
146
+ it('exits non-zero when GITHUB_OUTPUT is unset', () => {
147
+ const result = runScriptRaw({ GITHUB_REF_NAME: 'v1.2.3' });
148
+ expect(result.status).not.toBe(0);
149
+ expect(result.stderr).toContain('GITHUB_OUTPUT');
150
+ });
151
+
152
+ it('rejects non-semver tag vA.B.C', () => {
153
+ const result = runScriptRaw({
154
+ GITHUB_REF_NAME: 'vA.B.C',
155
+ GITHUB_OUTPUT: join(tmpdir(), 'unused'),
156
+ });
157
+ expect(result.status).not.toBe(0);
158
+ expect(result.stderr).toContain('not a valid semver tag');
159
+ });
160
+
161
+ it('rejects non-semver tag v1.2', () => {
162
+ const result = runScriptRaw({
163
+ GITHUB_REF_NAME: 'v1.2',
164
+ GITHUB_OUTPUT: join(tmpdir(), 'unused'),
165
+ });
166
+ expect(result.status).not.toBe(0);
167
+ expect(result.stderr).toContain('not a valid semver tag');
168
+ });
169
+
170
+ it('rejects build-metadata tag v1.2.3+build.1', () => {
171
+ const result = runScriptRaw({
172
+ GITHUB_REF_NAME: 'v1.2.3+build.1',
173
+ GITHUB_OUTPUT: join(tmpdir(), 'unused'),
174
+ });
175
+ expect(result.status).not.toBe(0);
176
+ expect(result.stderr).toContain('not a valid semver tag');
177
+ });
178
+ });