@studio-foundation/cli 0.3.0-beta.1 → 0.3.0-beta.6

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 (88) hide show
  1. package/package.json +9 -5
  2. package/ARCHITECTURE.md +0 -40
  3. package/src/commands/api.ts +0 -126
  4. package/src/commands/config.ts +0 -433
  5. package/src/commands/init.ts +0 -879
  6. package/src/commands/install.ts +0 -23
  7. package/src/commands/integrations.ts +0 -332
  8. package/src/commands/list.ts +0 -197
  9. package/src/commands/logs.ts +0 -119
  10. package/src/commands/ollama.ts +0 -168
  11. package/src/commands/project.ts +0 -167
  12. package/src/commands/registry/audit.ts +0 -87
  13. package/src/commands/registry/index.ts +0 -63
  14. package/src/commands/registry/install.ts +0 -222
  15. package/src/commands/registry/publish.ts +0 -112
  16. package/src/commands/registry/remove.ts +0 -89
  17. package/src/commands/registry/search.ts +0 -93
  18. package/src/commands/registry/sync.ts +0 -24
  19. package/src/commands/registry/update.ts +0 -63
  20. package/src/commands/replay.ts +0 -559
  21. package/src/commands/run.ts +0 -454
  22. package/src/commands/status.ts +0 -163
  23. package/src/commands/template/index.ts +0 -59
  24. package/src/commands/template/validate.ts +0 -175
  25. package/src/commands/templates.ts +0 -99
  26. package/src/commands/tools.ts +0 -227
  27. package/src/commands/users.ts +0 -127
  28. package/src/commands/validate.ts +0 -46
  29. package/src/config.ts +0 -101
  30. package/src/index.ts +0 -201
  31. package/src/models-cache.ts +0 -127
  32. package/src/output/file-changes.ts +0 -97
  33. package/src/output/formatter.ts +0 -85
  34. package/src/output/formatters.ts +0 -303
  35. package/src/output/logger.ts +0 -18
  36. package/src/output/parallel-progress.ts +0 -103
  37. package/src/output/progress.ts +0 -414
  38. package/src/provider-validator.ts +0 -109
  39. package/src/registry/cache.ts +0 -46
  40. package/src/registry/client.ts +0 -84
  41. package/src/registry/lockfile.ts +0 -68
  42. package/src/registry/resolver.ts +0 -113
  43. package/src/registry/types.ts +0 -66
  44. package/src/run-logger.ts +0 -66
  45. package/src/run-store-factory.ts +0 -30
  46. package/src/studio-dir.ts +0 -28
  47. package/src/utils/input-wizard.ts +0 -73
  48. package/src/utils/placeholders.ts +0 -10
  49. package/tests/__stubs__/studio-runner.ts +0 -104
  50. package/tests/commands/api.test.ts +0 -110
  51. package/tests/commands/config.test.ts +0 -221
  52. package/tests/commands/init.test.ts +0 -919
  53. package/tests/commands/install.test.ts +0 -52
  54. package/tests/commands/integrations.test.ts +0 -158
  55. package/tests/commands/ollama.test.ts +0 -139
  56. package/tests/commands/project.test.ts +0 -179
  57. package/tests/commands/registry/audit.test.ts +0 -56
  58. package/tests/commands/registry/install.test.ts +0 -200
  59. package/tests/commands/registry/publish.test.ts +0 -56
  60. package/tests/commands/registry/remove.test.ts +0 -103
  61. package/tests/commands/registry/search.test.ts +0 -44
  62. package/tests/commands/registry/sync.test.ts +0 -37
  63. package/tests/commands/registry/update.test.ts +0 -62
  64. package/tests/commands/replay.test.ts +0 -283
  65. package/tests/commands/template/validate.test.ts +0 -150
  66. package/tests/commands/templates.test.ts +0 -42
  67. package/tests/commands/tools.test.ts +0 -106
  68. package/tests/config.test.ts +0 -142
  69. package/tests/formatter.test.ts +0 -158
  70. package/tests/integration/sigint.test.ts +0 -188
  71. package/tests/models-cache.test.ts +0 -250
  72. package/tests/output/file-changes.test.ts +0 -178
  73. package/tests/output/formatters.test.ts +0 -448
  74. package/tests/output/progress-spinner.test.ts +0 -232
  75. package/tests/output/progress-timer.test.ts +0 -230
  76. package/tests/provider-validator.test.ts +0 -182
  77. package/tests/registry/cache.test.ts +0 -66
  78. package/tests/registry/client.test.ts +0 -70
  79. package/tests/registry/lockfile.test.ts +0 -87
  80. package/tests/registry/resolver.test.ts +0 -122
  81. package/tests/run-logger-events.test.ts +0 -326
  82. package/tests/run-logger.test.ts +0 -64
  83. package/tests/run-store-factory.test.ts +0 -51
  84. package/tests/studio-dir.test.ts +0 -31
  85. package/tests/utils/input-wizard.test.ts +0 -153
  86. package/tests/utils/placeholders.test.ts +0 -36
  87. package/tsconfig.json +0 -24
  88. package/vitest.config.ts +0 -20
@@ -1,230 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
-
3
- // Mock ora BEFORE importing progress.ts
4
- const mockOraInstance = {
5
- start: vi.fn().mockReturnThis(),
6
- stop: vi.fn().mockReturnThis(),
7
- succeed: vi.fn().mockReturnThis(),
8
- fail: vi.fn().mockReturnThis(),
9
- text: '',
10
- };
11
- vi.mock('ora', () => ({ default: vi.fn(() => mockOraInstance) }));
12
-
13
- // Helper functions for non-live tests
14
- function makeQuietDisplay() {
15
- return new ProgressDisplay(false, 'quiet');
16
- }
17
-
18
- function stageStartEvent(n = 1, total = 3) {
19
- return { stage_name: 'entity-extraction', stage_index: n - 1, total_stages: total, max_attempts: 3 };
20
- }
21
-
22
- import { ProgressDisplay } from '../../src/output/progress.js';
23
-
24
- describe('ProgressDisplay — timer utilities', () => {
25
- beforeEach(() => {
26
- vi.clearAllMocks();
27
- vi.useFakeTimers();
28
- });
29
-
30
- afterEach(() => {
31
- vi.useRealTimers();
32
- });
33
-
34
- it('clearTimer is idempotent (no error if called twice)', () => {
35
- const d = new ProgressDisplay(false, 'quiet');
36
- const p = d as unknown as { clearTimer(): void };
37
- expect(() => {
38
- p.clearTimer();
39
- p.clearTimer();
40
- }).not.toThrow();
41
- });
42
-
43
- it('startTimer calls updateFn with elapsed seconds after each tick', () => {
44
- const d = new ProgressDisplay(false, 'quiet');
45
- const p = d as unknown as {
46
- resetStageTimer(): void;
47
- startTimer(fn: (s: string) => void): void;
48
- clearTimer(): void;
49
- };
50
- const calls: string[] = [];
51
- p.resetStageTimer();
52
- p.startTimer((s) => calls.push(s));
53
-
54
- vi.advanceTimersByTime(1000);
55
- expect(calls[0]).toBe('1s');
56
-
57
- vi.advanceTimersByTime(2000);
58
- expect(calls[2]).toBe('3s');
59
-
60
- p.clearTimer();
61
- });
62
-
63
- it('clearTimer stops the interval (no more updateFn calls after clear)', () => {
64
- const d = new ProgressDisplay(false, 'quiet');
65
- const p = d as unknown as {
66
- resetStageTimer(): void;
67
- startTimer(fn: (s: string) => void): void;
68
- clearTimer(): void;
69
- };
70
- const calls: string[] = [];
71
- p.resetStageTimer();
72
- p.startTimer((s) => calls.push(s));
73
- vi.advanceTimersByTime(2000);
74
- p.clearTimer();
75
- const countAfterClear = calls.length;
76
- vi.advanceTimersByTime(5000);
77
- expect(calls.length).toBe(countAfterClear);
78
- });
79
-
80
- it('elapsedSeconds returns correct elapsed seconds', () => {
81
- const d = new ProgressDisplay(false, 'quiet');
82
- const p = d as unknown as {
83
- resetStageTimer(): void;
84
- elapsedSeconds(): number;
85
- };
86
- p.resetStageTimer();
87
- vi.advanceTimersByTime(7500);
88
- expect(p.elapsedSeconds()).toBe(7);
89
- });
90
- });
91
-
92
- describe('ProgressDisplay — timer in non-live mode', () => {
93
- beforeEach(() => {
94
- vi.clearAllMocks();
95
- vi.useFakeTimers();
96
- });
97
-
98
- afterEach(() => {
99
- vi.useRealTimers();
100
- });
101
-
102
- it('updates spinner text with elapsed seconds every second', () => {
103
- const d = makeQuietDisplay();
104
- const events = d.getEvents();
105
- events.onStageStart!(stageStartEvent());
106
-
107
- vi.advanceTimersByTime(5000);
108
-
109
- expect(mockOraInstance.text).toContain('5s');
110
- });
111
-
112
- it('clears timer on onStageComplete', () => {
113
- const d = makeQuietDisplay();
114
- const events = d.getEvents();
115
- events.onStageStart!(stageStartEvent());
116
- vi.advanceTimersByTime(3000);
117
-
118
- events.onStageComplete!({
119
- stage_name: 'entity-extraction', stage_index: 0, total_stages: 3,
120
- status: 'success', attempts: 1, duration_ms: 3000,
121
- });
122
-
123
- const textAfterComplete = mockOraInstance.text;
124
- vi.advanceTimersByTime(5000);
125
- expect(mockOraInstance.text).toBe(textAfterComplete);
126
- });
127
-
128
- it('clears timer on onTaskRetry', () => {
129
- const d = makeQuietDisplay();
130
- const events = d.getEvents();
131
- events.onStageStart!(stageStartEvent());
132
- vi.advanceTimersByTime(2000);
133
-
134
- events.onTaskRetry!({ stage: 'entity-extraction', attempt: 2, max_attempts: 3, failures: ['missing field'] });
135
-
136
- const textAfterRetry = mockOraInstance.text;
137
- vi.advanceTimersByTime(5000);
138
- expect(mockOraInstance.text).toBe(textAfterRetry);
139
- });
140
-
141
- it('clears timer on interrupt()', () => {
142
- const d = makeQuietDisplay();
143
- const events = d.getEvents();
144
- events.onStageStart!(stageStartEvent());
145
- vi.advanceTimersByTime(2000);
146
-
147
- d.interrupt();
148
-
149
- const textAfterInterrupt = mockOraInstance.text;
150
- vi.advanceTimersByTime(5000);
151
- expect(mockOraInstance.text).toBe(textAfterInterrupt);
152
- });
153
- });
154
-
155
- function makeLiveDisplay() {
156
- return new ProgressDisplay(false, 'live');
157
- }
158
-
159
- function toolCallStartEvent() {
160
- return { tool: 'repo_manager-write_file', params: { path: 'out.json' }, timestamp: Date.now() };
161
- }
162
-
163
- function toolCallCompleteEvent() {
164
- return { tool: 'repo_manager-write_file', result: 'ok', duration_ms: 100, timestamp: Date.now() };
165
- }
166
-
167
- describe('ProgressDisplay — timer in live mode', () => {
168
- beforeEach(() => {
169
- vi.clearAllMocks();
170
- vi.useFakeTimers();
171
- });
172
-
173
- afterEach(() => {
174
- vi.useRealTimers();
175
- });
176
-
177
- it('thinkingSpinner text includes elapsed seconds after ticks', () => {
178
- const d = makeLiveDisplay();
179
- const events = d.getEvents();
180
- events.onStageStart!(stageStartEvent());
181
-
182
- vi.advanceTimersByTime(7000);
183
-
184
- expect(mockOraInstance.text).toContain('7s');
185
- });
186
-
187
- it('after tool call completes, thinkingSpinner shows "from Xs" with accumulated time', () => {
188
- const d = makeLiveDisplay();
189
- const events = d.getEvents();
190
- events.onStageStart!(stageStartEvent());
191
- vi.advanceTimersByTime(10000); // 10s thinking
192
- events.onToolCallStart!(toolCallStartEvent());
193
- vi.advanceTimersByTime(2000); // 2s tool call
194
- events.onToolCallComplete!(toolCallCompleteEvent());
195
-
196
- // Spinner restarts — should show "from 12s"
197
- expect(mockOraInstance.text).toContain('from 12s');
198
- });
199
-
200
- it('clears timer on onStageComplete in live mode', () => {
201
- const d = makeLiveDisplay();
202
- const events = d.getEvents();
203
- events.onStageStart!(stageStartEvent());
204
- vi.advanceTimersByTime(5000);
205
-
206
- events.onStageComplete!({
207
- stage_name: 'entity-extraction', stage_index: 0, total_stages: 3,
208
- status: 'success', attempts: 1, duration_ms: 5000,
209
- });
210
-
211
- const textAfter = mockOraInstance.text;
212
- vi.advanceTimersByTime(5000);
213
- expect(mockOraInstance.text).toBe(textAfter);
214
- });
215
-
216
- it('clears timer on onAgentToken (stops updating while tokens stream)', () => {
217
- const d = makeLiveDisplay();
218
- const events = d.getEvents();
219
- events.onStageStart!(stageStartEvent());
220
- vi.advanceTimersByTime(3000);
221
-
222
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
223
- events.onAgentToken!({ token: 'Hello', stage: 'entity-extraction', timestamp: Date.now() });
224
- writeSpy.mockRestore();
225
-
226
- const textAfterToken = mockOraInstance.text;
227
- vi.advanceTimersByTime(5000);
228
- expect(mockOraInstance.text).toBe(textAfterToken);
229
- });
230
- });
@@ -1,182 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { validateApiKeyLive } from '../src/provider-validator.js';
3
- import { getCachedModels } from '../src/models-cache.js';
4
-
5
- const mockFetch = vi.fn();
6
- vi.stubGlobal('fetch', mockFetch);
7
-
8
- beforeEach(() => { mockFetch.mockReset(); });
9
-
10
- function mockResponse(status: number, body?: unknown): Response {
11
- return {
12
- status,
13
- ok: status >= 200 && status < 300,
14
- json: async () => body ?? {},
15
- } as Response;
16
- }
17
-
18
- // ─── Anthropic ───────────────────────────────────────────────────────────────
19
-
20
- describe('validateApiKeyLive — anthropic', () => {
21
- it('returns valid when /v1/models returns 200', async () => {
22
- mockFetch.mockResolvedValueOnce(mockResponse(200, { data: [] }));
23
- const result = await validateApiKeyLive('anthropic', 'sk-ant-valid-key');
24
- expect(result.status).toBe('valid');
25
- });
26
-
27
- it('calls GET https://api.anthropic.com/v1/models with x-api-key header', async () => {
28
- mockFetch.mockResolvedValueOnce(mockResponse(200, { data: [] }));
29
- await validateApiKeyLive('anthropic', 'sk-ant-mykey');
30
- const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
31
- expect(url).toBe('https://api.anthropic.com/v1/models');
32
- expect((init.headers as Record<string, string>)['x-api-key']).toBe('sk-ant-mykey');
33
- });
34
-
35
- it('populates models cache on 200', async () => {
36
- mockFetch.mockResolvedValueOnce(
37
- mockResponse(200, {
38
- data: [
39
- { id: 'claude-sonnet-4-20250514' },
40
- { id: 'claude-instant-1' }, // excluded by filter
41
- ],
42
- })
43
- );
44
- await validateApiKeyLive('anthropic', 'sk-ant-cache-pop');
45
- const cached = getCachedModels('anthropic', 'sk-ant-cache-pop');
46
- expect(cached).toContain('claude-sonnet-4-20250514');
47
- expect(cached).not.toContain('claude-instant-1');
48
- });
49
-
50
- it('returns invalid with error message when 401', async () => {
51
- mockFetch.mockResolvedValueOnce(mockResponse(401));
52
- const result = await validateApiKeyLive('anthropic', 'sk-ant-bad');
53
- expect(result.status).toBe('invalid');
54
- expect('error' in result && result.error).toContain('401');
55
- });
56
-
57
- it('returns invalid when 403', async () => {
58
- mockFetch.mockResolvedValueOnce(mockResponse(403));
59
- const result = await validateApiKeyLive('anthropic', 'sk-ant-bad');
60
- expect(result.status).toBe('invalid');
61
- });
62
-
63
- it('returns warning on network error', async () => {
64
- mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
65
- const result = await validateApiKeyLive('anthropic', 'sk-ant-key');
66
- expect(result.status).toBe('warning');
67
- expect('message' in result && result.message).toMatch(/Could not validate/i);
68
- });
69
-
70
- it('returns warning on abort (timeout)', async () => {
71
- const err = Object.assign(new Error('abort'), { name: 'AbortError' });
72
- mockFetch.mockRejectedValueOnce(err);
73
- const result = await validateApiKeyLive('anthropic', 'sk-ant-key');
74
- expect(result.status).toBe('warning');
75
- expect('message' in result && result.message).toMatch(/timed out/i);
76
- });
77
- });
78
-
79
- // ─── OpenAI ──────────────────────────────────────────────────────────────────
80
-
81
- describe('validateApiKeyLive — openai', () => {
82
- it('returns valid when /v1/models returns 200', async () => {
83
- mockFetch.mockResolvedValueOnce(mockResponse(200, { data: [] }));
84
- const result = await validateApiKeyLive('openai', 'sk-proj-valid');
85
- expect(result.status).toBe('valid');
86
- });
87
-
88
- it('calls GET https://api.openai.com/v1/models with Authorization Bearer header', async () => {
89
- mockFetch.mockResolvedValueOnce(mockResponse(200, { data: [] }));
90
- await validateApiKeyLive('openai', 'sk-proj-mykey');
91
- const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
92
- expect(url).toBe('https://api.openai.com/v1/models');
93
- expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer sk-proj-mykey');
94
- });
95
-
96
- it('populates models cache on 200', async () => {
97
- mockFetch.mockResolvedValueOnce(
98
- mockResponse(200, { data: [{ id: 'gpt-4o' }, { id: 'gpt-3.5-turbo' }] })
99
- );
100
- await validateApiKeyLive('openai', 'sk-openai-cache');
101
- const cached = getCachedModels('openai', 'sk-openai-cache');
102
- expect(cached).toContain('gpt-4o');
103
- expect(cached).not.toContain('gpt-3.5-turbo');
104
- });
105
-
106
- it('returns invalid when 401', async () => {
107
- mockFetch.mockResolvedValueOnce(mockResponse(401));
108
- const result = await validateApiKeyLive('openai', 'sk-bad');
109
- expect(result.status).toBe('invalid');
110
- expect('error' in result && result.error).toContain('401');
111
- });
112
- });
113
-
114
- // ─── Google ──────────────────────────────────────────────────────────────────
115
-
116
- describe('validateApiKeyLive — google', () => {
117
- it('returns valid when models endpoint returns 200', async () => {
118
- mockFetch.mockResolvedValueOnce(mockResponse(200));
119
- const result = await validateApiKeyLive('google', 'AIzaValid');
120
- expect(result.status).toBe('valid');
121
- });
122
-
123
- it('includes API key as query param in URL', async () => {
124
- mockFetch.mockResolvedValueOnce(mockResponse(200));
125
- await validateApiKeyLive('google', 'AIzaMyKey');
126
- const [url] = mockFetch.mock.calls[0] as [string, RequestInit];
127
- expect(url).toContain('AIzaMyKey');
128
- expect(url).toContain('generativelanguage.googleapis.com');
129
- });
130
-
131
- it('does NOT populate models cache (Google not supported)', async () => {
132
- mockFetch.mockResolvedValueOnce(mockResponse(200));
133
- await validateApiKeyLive('google', 'AIzaCache');
134
- expect(getCachedModels('google', 'AIzaCache')).toBeNull();
135
- });
136
-
137
- it('returns invalid when 400 or 403', async () => {
138
- mockFetch.mockResolvedValueOnce(mockResponse(400));
139
- const result = await validateApiKeyLive('google', 'AIzaBad');
140
- expect(result.status).toBe('invalid');
141
- });
142
- });
143
-
144
- // ─── Ollama ──────────────────────────────────────────────────────────────────
145
-
146
- describe('validateApiKeyLive — ollama', () => {
147
- it('returns valid when Ollama /api/tags returns 200', async () => {
148
- mockFetch.mockResolvedValueOnce(mockResponse(200));
149
- const result = await validateApiKeyLive('ollama', '', { baseUrl: 'http://localhost:11434' });
150
- expect(result.status).toBe('valid');
151
- });
152
-
153
- it('uses default http://localhost:11434 when no baseUrl', async () => {
154
- mockFetch.mockResolvedValueOnce(mockResponse(200));
155
- await validateApiKeyLive('ollama', '');
156
- const [url] = mockFetch.mock.calls[0] as [string, RequestInit];
157
- expect(url).toContain('localhost:11434');
158
- });
159
-
160
- it('uses custom baseUrl when provided', async () => {
161
- mockFetch.mockResolvedValueOnce(mockResponse(200));
162
- await validateApiKeyLive('ollama', '', { baseUrl: 'http://my-ollama:8080' });
163
- const [url] = mockFetch.mock.calls[0] as [string, RequestInit];
164
- expect(url).toContain('my-ollama:8080');
165
- });
166
-
167
- it('returns warning on connection refused', async () => {
168
- mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
169
- const result = await validateApiKeyLive('ollama', '', { baseUrl: 'http://localhost:11434' });
170
- expect(result.status).toBe('warning');
171
- });
172
- });
173
-
174
- // ─── Unknown provider ────────────────────────────────────────────────────────
175
-
176
- describe('validateApiKeyLive — unknown provider', () => {
177
- it('returns warning for unrecognized providers', async () => {
178
- const result = await validateApiKeyLive('future-provider', 'some-key');
179
- expect(result.status).toBe('warning');
180
- expect('message' in result && result.message).toMatch(/cannot validate/i);
181
- });
182
- });
@@ -1,66 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { rm, readFile, mkdir, writeFile } from 'node:fs/promises';
3
- import { resolve } from 'node:path';
4
- import { RegistryCache } from '../../src/registry/cache.js';
5
- import type { RegistryIndex } from '../../src/registry/types.js';
6
-
7
- const TMP = resolve(import.meta.dirname, '.tmp-registry-cache');
8
-
9
- const MOCK_INDEX: RegistryIndex = {
10
- generated_at: '2026-02-28T00:00:00Z',
11
- version: '1',
12
- packages: [
13
- {
14
- name: 'software',
15
- type: 'template',
16
- version: '1.0.0',
17
- description: 'Test template',
18
- author: 'studio-core',
19
- license: 'MIT',
20
- tags: ['software'],
21
- studio_version: '>=7.0.0',
22
- downloads: 0,
23
- },
24
- ],
25
- };
26
-
27
- beforeEach(async () => { await mkdir(TMP, { recursive: true }); });
28
- afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
29
-
30
- describe('RegistryCache', () => {
31
- it('returns null when cache does not exist', async () => {
32
- const cache = new RegistryCache(TMP);
33
- expect(await cache.read()).toBeNull();
34
- });
35
-
36
- it('writes and reads back correctly', async () => {
37
- const cache = new RegistryCache(TMP);
38
- await cache.write(MOCK_INDEX);
39
- const result = await cache.read();
40
- expect(result).not.toBeNull();
41
- expect(result!.packages).toHaveLength(1);
42
- expect(result!.packages[0].name).toBe('software');
43
- });
44
-
45
- it('returns null when cache is expired', async () => {
46
- const cache = new RegistryCache(TMP);
47
- await cache.write(MOCK_INDEX);
48
- // Force expire by overwriting with old _cached_at
49
- const cachePath = resolve(TMP, 'index.json');
50
- const data = JSON.parse(await readFile(cachePath, 'utf8'));
51
- data._cached_at = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
52
- await writeFile(cachePath, JSON.stringify(data));
53
- expect(await cache.read()).toBeNull();
54
- });
55
-
56
- it('isFresh returns false when no cache', async () => {
57
- const cache = new RegistryCache(TMP);
58
- expect(await cache.isFresh()).toBe(false);
59
- });
60
-
61
- it('isFresh returns true right after write', async () => {
62
- const cache = new RegistryCache(TMP);
63
- await cache.write(MOCK_INDEX);
64
- expect(await cache.isFresh()).toBe(true);
65
- });
66
- });
@@ -1,70 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { rm, mkdir } from 'node:fs/promises';
3
- import { resolve } from 'node:path';
4
-
5
- const TMP = resolve(import.meta.dirname, '.tmp-client');
6
-
7
- const MOCK_INDEX = {
8
- generated_at: '2026-02-28T00:00:00Z',
9
- version: '1',
10
- packages: [
11
- { name: 'software', type: 'template', version: '1.0.0', description: 'Test', author: 'studio-core', license: 'MIT', tags: [], studio_version: null, downloads: 0 },
12
- ],
13
- };
14
-
15
- const MOCK_METADATA = {
16
- name: 'software',
17
- version: '1.0.0',
18
- description: 'Code generation',
19
- author: 'studio-core',
20
- license: 'MIT',
21
- tags: ['software'],
22
- type: 'template',
23
- studio_version: '>=7.0.0',
24
- };
25
-
26
- beforeEach(async () => {
27
- await mkdir(TMP, { recursive: true });
28
- vi.stubGlobal('fetch', vi.fn());
29
- });
30
- afterEach(async () => {
31
- await rm(TMP, { recursive: true, force: true });
32
- vi.unstubAllGlobals();
33
- });
34
-
35
- describe('RegistryClient.fetchIndex', () => {
36
- it('fetches and returns the registry index', async () => {
37
- const mockFetch = vi.mocked(fetch);
38
- mockFetch.mockResolvedValueOnce({
39
- ok: true,
40
- json: async () => MOCK_INDEX,
41
- } as Response);
42
-
43
- const { RegistryClient } = await import('../../src/registry/client.js');
44
- const client = new RegistryClient();
45
- const index = await client.fetchIndex();
46
- expect(index.packages).toHaveLength(1);
47
- expect(index.packages[0].name).toBe('software');
48
- });
49
-
50
- it('throws on non-ok response', async () => {
51
- vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404 } as Response);
52
- const { RegistryClient } = await import('../../src/registry/client.js');
53
- const client = new RegistryClient();
54
- await expect(client.fetchIndex()).rejects.toThrow('Failed to fetch registry index');
55
- });
56
- });
57
-
58
- describe('RegistryClient.fetchMetadata', () => {
59
- it('fetches package metadata', async () => {
60
- vi.mocked(fetch).mockResolvedValueOnce({
61
- ok: true,
62
- json: async () => MOCK_METADATA,
63
- } as Response);
64
- const { RegistryClient } = await import('../../src/registry/client.js');
65
- const client = new RegistryClient();
66
- const meta = await client.fetchMetadata('template', 'software');
67
- expect(meta.name).toBe('software');
68
- expect(meta.type).toBe('template');
69
- });
70
- });
@@ -1,87 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { rm, mkdir } from 'node:fs/promises';
3
- import { resolve, join } from 'node:path';
4
- import { RegistryLockfile } from '../../src/registry/lockfile.js';
5
-
6
- const TMP = resolve(import.meta.dirname, '.tmp-lockfile');
7
- const STUDIO = join(TMP, '.studio');
8
-
9
- beforeEach(async () => { await mkdir(STUDIO, { recursive: true }); });
10
- afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
11
-
12
- describe('RegistryLockfile', () => {
13
- it('returns empty lockfile when file does not exist', async () => {
14
- const lf = new RegistryLockfile(TMP);
15
- const data = await lf.read();
16
- expect(data.installed).toEqual({});
17
- });
18
-
19
- it('adds a package entry', async () => {
20
- const lf = new RegistryLockfile(TMP);
21
- await lf.add('software', {
22
- version: '1.0.0',
23
- type: 'template',
24
- installed_at: '2026-02-28',
25
- sha256: 'abc123',
26
- });
27
- const data = await lf.read();
28
- expect(data.installed['software']).toMatchObject({
29
- version: '1.0.0',
30
- type: 'template',
31
- sha256: 'abc123',
32
- });
33
- });
34
-
35
- it('removes a package entry', async () => {
36
- const lf = new RegistryLockfile(TMP);
37
- await lf.add('software', { version: '1.0.0', type: 'template', installed_at: '2026-02-28', sha256: 'abc' });
38
- await lf.remove('software');
39
- const data = await lf.read();
40
- expect(data.installed['software']).toBeUndefined();
41
- });
42
-
43
- it('lists installed packages', async () => {
44
- const lf = new RegistryLockfile(TMP);
45
- await lf.add('software', { version: '1.0.0', type: 'template', installed_at: '2026-02-28', sha256: 'abc' });
46
- await lf.add('linear', { version: '1.0.0', type: 'integration', installed_at: '2026-02-28', sha256: 'def' });
47
- const list = await lf.list();
48
- expect(list).toHaveLength(2);
49
- expect(list.map(e => e.name)).toContain('software');
50
- expect(list.map(e => e.name)).toContain('linear');
51
- });
52
- });
53
-
54
- describe('RegistryLockfile.addRequiredBy', () => {
55
- it('adds required_by to an existing entry', async () => {
56
- const { RegistryLockfile } = await import('../../src/registry/lockfile.js');
57
- const lf = new RegistryLockfile(STUDIO);
58
- await lf.add('repo-manager', { version: '1.0.0', type: 'tool', installed_at: '2026-02-28', sha256: 'abc' });
59
-
60
- await lf.addRequiredBy('repo-manager', 'software-full');
61
-
62
- const entry = await lf.get('repo-manager');
63
- expect(entry?.required_by).toEqual(['software-full']);
64
- });
65
-
66
- it('does not duplicate if already present', async () => {
67
- const { RegistryLockfile } = await import('../../src/registry/lockfile.js');
68
- const lf = new RegistryLockfile(STUDIO);
69
- await lf.add('repo-manager', { version: '1.0.0', type: 'tool', installed_at: '2026-02-28', sha256: 'abc', required_by: ['software-full'] });
70
-
71
- await lf.addRequiredBy('repo-manager', 'software-full');
72
-
73
- const entry = await lf.get('repo-manager');
74
- expect(entry?.required_by).toEqual(['software-full']); // still just one
75
- });
76
-
77
- it('removes from required_by with removeRequiredBy', async () => {
78
- const { RegistryLockfile } = await import('../../src/registry/lockfile.js');
79
- const lf = new RegistryLockfile(STUDIO);
80
- await lf.add('repo-manager', { version: '1.0.0', type: 'tool', installed_at: '2026-02-28', sha256: 'abc', required_by: ['software-full', 'other-pkg'] });
81
-
82
- await lf.removeRequiredBy('repo-manager', 'software-full');
83
-
84
- const entry = await lf.get('repo-manager');
85
- expect(entry?.required_by).toEqual(['other-pkg']);
86
- });
87
- });