@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,919 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { mkdir, rm, readFile, writeFile, access } from 'node:fs/promises';
3
- import { resolve } from 'node:path';
4
- import * as yaml from 'js-yaml';
5
-
6
- vi.mock('node:os', async (importOriginal) => {
7
- const actual = await importOriginal<typeof import('node:os')>();
8
- return { ...actual, totalmem: vi.fn(actual.totalmem) };
9
- });
10
-
11
- vi.mock('node:child_process', async (importOriginal) => {
12
- const actual = await importOriginal<typeof import('node:child_process')>();
13
- return { ...actual, spawnSync: vi.fn(actual.spawnSync) };
14
- });
15
-
16
- // Mock @studio/runner since it may not be built in the worktree environment
17
- vi.mock('@studio-foundation/runner', () => ({
18
- listAvailableToolTemplates: vi.fn().mockResolvedValue([]),
19
- }));
20
-
21
- // Mock node:child_process so detectOllamaInstalled doesn't run real shell commands.
22
- // By default delegates to the real spawnSync for non-ollama commands so that
23
- // initGitRepo (which calls spawnSync('git', ['init'])) still works correctly.
24
- vi.mock('node:child_process', async (importOriginal) => {
25
- const original = await importOriginal<typeof import('node:child_process')>();
26
- const realSpawnSync = original.spawnSync;
27
- const mockedSpawnSync = vi.fn((...args: Parameters<typeof original.spawnSync>) => {
28
- if (args[0] === 'ollama') {
29
- return { status: 0 } as ReturnType<typeof original.spawnSync>;
30
- }
31
- return (realSpawnSync as (...a: unknown[]) => unknown)(...args) as ReturnType<typeof original.spawnSync>;
32
- });
33
- return {
34
- ...original,
35
- spawnSync: mockedSpawnSync,
36
- };
37
- });
38
-
39
- // Mock node:os so hasAdequateRam doesn't read real hardware
40
- vi.mock('node:os', async (importOriginal) => {
41
- const original = await importOriginal<typeof import('node:os')>();
42
- const totalMemFn = vi.fn().mockReturnValue(32 * 1024 ** 3); // default: 32GB
43
- const mocked = {
44
- ...original,
45
- totalmem: totalMemFn,
46
- default: { ...(original as unknown as Record<string, unknown>), totalmem: totalMemFn },
47
- };
48
- return mocked;
49
- });
50
-
51
- // Mock installPackage so tests don't hit the network.
52
- // For known registry templates (software, content, document-analysis, software-full),
53
- // the mock creates a minimal synthetic template at .studio/projects/<name>/ with the
54
- // files the tests need — mirroring what the real installPackage does for templates.
55
- // Unknown template names produce no directory (simulating "not found in registry").
56
- vi.mock('../../src/commands/registry/install.js', async () => {
57
- const { mkdir, writeFile } = await import('node:fs/promises');
58
- const { resolve: _resolve, join: _join } = await import('node:path');
59
-
60
- const KNOWN_TEMPLATES = new Set(['software', 'content', 'document-analysis', 'software-full']);
61
-
62
- async function createSyntheticTemplate(destDir: string, templateName: string): Promise<void> {
63
- await mkdir(_join(destDir, 'pipelines'), { recursive: true });
64
- await mkdir(_join(destDir, 'agents'), { recursive: true });
65
- await mkdir(_join(destDir, 'contracts'), { recursive: true });
66
- await mkdir(_join(destDir, 'tools'), { recursive: true });
67
- await mkdir(_join(destDir, 'inputs'), { recursive: true });
68
- await mkdir(_join(destDir, 'src'), { recursive: true });
69
- await mkdir(_join(destDir, 'prisma'), { recursive: true });
70
-
71
- await writeFile(_join(destDir, 'pipelines', 'feature-builder.pipeline.yaml'), 'name: feature-builder\n', 'utf-8');
72
- await writeFile(_join(destDir, 'agents', 'coder.agent.yaml'), 'name: coder\n', 'utf-8');
73
- await writeFile(_join(destDir, 'contracts', 'code-output.contract.yaml'), 'name: code-output\n', 'utf-8');
74
- await writeFile(_join(destDir, 'tools', 'repo-manager.tool.yaml'), 'name: repo-manager\n', 'utf-8');
75
- await writeFile(_join(destDir, 'inputs', 'example.input.yaml'), 'input: example\n', 'utf-8');
76
- await writeFile(_join(destDir, 'src', 'index.ts'), '// {{PROJECT_NAME}}\nexport {};\n', 'utf-8');
77
- await writeFile(_join(destDir, 'prisma', 'schema.prisma'), '// prisma schema\n', 'utf-8');
78
- await writeFile(
79
- _join(destDir, 'package.json'),
80
- JSON.stringify({ name: '{{PROJECT_NAME}}', version: '0.0.1' }, null, 2) + '\n',
81
- 'utf-8'
82
- );
83
- await writeFile(
84
- _join(destDir, 'README.md'),
85
- '# {{PROJECT_NAME}}\n\nTemplate: {{TEMPLATE_NAME}}\n',
86
- 'utf-8'
87
- );
88
- }
89
-
90
- return {
91
- installPackage: vi.fn(async (templateName: string, options: { studioDir?: string } = {}) => {
92
- if (!KNOWN_TEMPLATES.has(templateName)) {
93
- // Unknown template — do nothing (no directory created)
94
- // createStudioStructure will detect the missing dir and throw the expected error.
95
- return;
96
- }
97
- const studioDir = options.studioDir ?? _resolve(process.cwd(), '.studio');
98
- const destDir = _resolve(studioDir, 'projects', templateName);
99
- await createSyntheticTemplate(destDir, templateName);
100
- }),
101
- };
102
- });
103
-
104
- // Use /tmp as base to avoid interference from the Studio repo's own .studio/
105
- const TMP = resolve('/tmp', '.studio-init-test');
106
-
107
- beforeEach(async () => {
108
- // Clean up any stale /tmp/.studio left by worktree tests — findStudioDir would find it
109
- // walking up from TMP and incorrectly report "already initialized"
110
- await rm('/tmp/.studio', { recursive: true, force: true });
111
- await mkdir(TMP, { recursive: true });
112
- });
113
- afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
114
-
115
- async function exists(p: string): Promise<boolean> {
116
- try { await access(p); return true; } catch { return false; }
117
- }
118
-
119
- describe('createStudioStructure', () => {
120
- it('creates .studio/ directory structure', async () => {
121
- const { createStudioStructure } = await import('../../src/commands/init.js');
122
- await createStudioStructure(TMP);
123
-
124
- expect(await exists(resolve(TMP, '.studio'))).toBe(true);
125
- expect(await exists(resolve(TMP, '.studio', 'config.yaml'))).toBe(true);
126
- expect(await exists(resolve(TMP, '.studio', 'pipelines'))).toBe(true);
127
- expect(await exists(resolve(TMP, '.studio', 'agents'))).toBe(true);
128
- expect(await exists(resolve(TMP, '.studio', 'contracts'))).toBe(true);
129
- expect(await exists(resolve(TMP, '.studio', 'tools'))).toBe(true);
130
- expect(await exists(resolve(TMP, '.studio', 'inputs'))).toBe(true);
131
- expect(await exists(resolve(TMP, '.studio', 'runs', 'logs'))).toBe(true);
132
- expect(await exists(resolve(TMP, '.studio', 'registry.lock.json'))).toBe(true);
133
- });
134
-
135
- it('adds .studio/config.yaml, .studio/runs/, and *.keymap.json to .gitignore', async () => {
136
- const { createStudioStructure } = await import('../../src/commands/init.js');
137
- await createStudioStructure(TMP);
138
-
139
- const gitignore = await readFile(resolve(TMP, '.gitignore'), 'utf-8');
140
- expect(gitignore).toContain('.studio/config.yaml');
141
- expect(gitignore).toContain('.studio/runs/');
142
- expect(gitignore).toContain('*.keymap.json');
143
- });
144
-
145
- it('appends to existing .gitignore without duplicating', async () => {
146
- const { createStudioStructure } = await import('../../src/commands/init.js');
147
- const gitignorePath = resolve(TMP, '.gitignore');
148
- await writeFile(gitignorePath, 'node_modules/\n.studio/config.yaml\n');
149
-
150
- await createStudioStructure(TMP);
151
-
152
- const content = await readFile(gitignorePath, 'utf-8');
153
- const lines = content.split('\n').filter((l) => l.trim() === '.studio/config.yaml');
154
- expect(lines.length).toBe(1); // no duplicate
155
- });
156
-
157
- it('creates flat structure (no projects/ subdir) when templateName is provided', async () => {
158
- const { createStudioStructure } = await import('../../src/commands/init.js');
159
- await createStudioStructure(TMP, 'software');
160
-
161
- expect(await exists(resolve(TMP, '.studio', 'pipelines'))).toBe(true);
162
- expect(await exists(resolve(TMP, '.studio', 'agents'))).toBe(true);
163
- });
164
-
165
- it('writes empty registry.lock.json', async () => {
166
- const { createStudioStructure } = await import('../../src/commands/init.js');
167
- await createStudioStructure(TMP);
168
-
169
- const content = await readFile(resolve(TMP, '.studio', 'registry.lock.json'), 'utf-8');
170
- expect(JSON.parse(content)).toEqual({ installed: {} });
171
- });
172
- });
173
-
174
- describe('initCommand already initialized', () => {
175
- it('throws when .studio/ already exists', async () => {
176
- const { createStudioStructure } = await import('../../src/commands/init.js');
177
- // First init
178
- await createStudioStructure(TMP);
179
- // Second init should throw
180
- await expect(createStudioStructure(TMP)).rejects.toThrow('already initialized');
181
- });
182
-
183
- it('error message includes path to the found .studio/', async () => {
184
- const { createStudioStructure } = await import('../../src/commands/init.js');
185
- await createStudioStructure(TMP);
186
- try {
187
- await createStudioStructure(TMP);
188
- expect.fail('should have thrown');
189
- } catch (err) {
190
- expect(err instanceof Error && err.message).toContain('.studio');
191
- }
192
- });
193
- });
194
-
195
- describe('createStudioStructure with templates', () => {
196
- it('copies template project files when templateName is software', async () => {
197
- const { createStudioStructure } = await import('../../src/commands/init.js');
198
- await createStudioStructure(TMP, 'software');
199
-
200
- expect(await exists(resolve(TMP, '.studio', 'pipelines', 'feature-builder.pipeline.yaml'))).toBe(true);
201
- expect(await exists(resolve(TMP, '.studio', 'agents', 'coder.agent.yaml'))).toBe(true);
202
- expect(await exists(resolve(TMP, '.studio', 'contracts', 'code-output.contract.yaml'))).toBe(true);
203
- expect(await exists(resolve(TMP, '.studio', 'tools', 'repo-manager.tool.yaml'))).toBe(true);
204
- expect(await exists(resolve(TMP, '.studio', 'inputs', 'example.input.yaml'))).toBe(true);
205
- });
206
-
207
- it('creates empty dirs for blank template (no project/ subdir)', async () => {
208
- const { createStudioStructure } = await import('../../src/commands/init.js');
209
- await createStudioStructure(TMP, 'blank');
210
-
211
- expect(await exists(resolve(TMP, '.studio', 'pipelines'))).toBe(true);
212
- const { readdir } = await import('node:fs/promises');
213
- const entries = await readdir(resolve(TMP, '.studio', 'pipelines'));
214
- expect(entries).toEqual([]);
215
- });
216
-
217
- it('throws with helpful message when template does not exist', async () => {
218
- const { createStudioStructure } = await import('../../src/commands/init.js');
219
- await expect(createStudioStructure(TMP, 'xyz')).rejects.toThrow(
220
- "Template 'xyz' not found"
221
- );
222
- });
223
-
224
- it('error message mentions studio templates list', async () => {
225
- const { createStudioStructure } = await import('../../src/commands/init.js');
226
- try {
227
- await createStudioStructure(TMP, 'xyz');
228
- expect.fail('should have thrown');
229
- } catch (err) {
230
- expect(err instanceof Error && err.message).toContain('studio templates list');
231
- }
232
- });
233
-
234
- it('flat structure: template files copied directly into .studio/', async () => {
235
- const { createStudioStructure } = await import('../../src/commands/init.js');
236
- await createStudioStructure(TMP, 'software');
237
-
238
- expect(await exists(resolve(TMP, '.studio', 'pipelines', 'feature-builder.pipeline.yaml'))).toBe(true);
239
- });
240
- });
241
-
242
- describe('validateApiKeyFormat', () => {
243
- it('accepts a valid Anthropic key', async () => {
244
- const { validateApiKeyFormat } = await import('../../src/commands/init.js');
245
- expect(validateApiKeyFormat('anthropic', 'sk-ant-api03-abc123')).toBe(true);
246
- });
247
-
248
- it('rejects an Anthropic key with wrong prefix', async () => {
249
- const { validateApiKeyFormat } = await import('../../src/commands/init.js');
250
- const result = validateApiKeyFormat('anthropic', 'sk-wrong-key');
251
- expect(typeof result).toBe('string'); // returns error message
252
- expect(result).toContain('sk-ant-');
253
- });
254
-
255
- it('accepts a valid OpenAI key', async () => {
256
- const { validateApiKeyFormat } = await import('../../src/commands/init.js');
257
- expect(validateApiKeyFormat('openai', 'sk-proj-abc123')).toBe(true);
258
- });
259
-
260
- it('rejects an OpenAI key with wrong prefix', async () => {
261
- const { validateApiKeyFormat } = await import('../../src/commands/init.js');
262
- const result = validateApiKeyFormat('openai', 'wrong-key');
263
- expect(typeof result).toBe('string');
264
- expect(result).toContain('sk-');
265
- });
266
-
267
- it('accepts any key for unknown provider', async () => {
268
- const { validateApiKeyFormat } = await import('../../src/commands/init.js');
269
- expect(validateApiKeyFormat('later', '')).toBe(true);
270
- });
271
- });
272
-
273
- describe('backupStudioDir', () => {
274
- it('moves .studio/ to a backup directory', async () => {
275
- const { createStudioStructure, backupStudioDir } = await import('../../src/commands/init.js');
276
- await createStudioStructure(TMP);
277
-
278
- const backupPath = await backupStudioDir(TMP);
279
-
280
- // Original .studio/ is gone
281
- expect(await exists(resolve(TMP, '.studio'))).toBe(false);
282
- // Backup dir exists
283
- expect(await exists(backupPath)).toBe(true);
284
- });
285
-
286
- it('backup directory name starts with .studio.backup-', async () => {
287
- const { createStudioStructure, backupStudioDir } = await import('../../src/commands/init.js');
288
- await createStudioStructure(TMP);
289
-
290
- const backupPath = await backupStudioDir(TMP);
291
- const backupName = backupPath.split('/').at(-1)!;
292
-
293
- expect(backupName).toMatch(/^\.studio\.backup-\d{4}-\d{2}-\d{2}-\d{2}h\d{2}m\d{2}s$/);
294
- });
295
-
296
- it('backup preserves files from original .studio/', async () => {
297
- const { createStudioStructure, backupStudioDir } = await import('../../src/commands/init.js');
298
- await createStudioStructure(TMP);
299
-
300
- const backupPath = await backupStudioDir(TMP);
301
-
302
- expect(await exists(resolve(backupPath, 'config.yaml'))).toBe(true);
303
- expect(await exists(resolve(backupPath, 'registry.lock.json'))).toBe(true);
304
- });
305
-
306
- it('throws if .studio/ does not exist', async () => {
307
- const { backupStudioDir } = await import('../../src/commands/init.js');
308
- await expect(backupStudioDir(TMP)).rejects.toThrow();
309
- });
310
- });
311
-
312
- describe('directInit', () => {
313
- it('creates structure and writes provider config', async () => {
314
- const { directInit } = await import('../../src/commands/init.js');
315
- await directInit(TMP, 'software', 'anthropic', 'sk-ant-test-key');
316
-
317
- expect(await exists(resolve(TMP, '.studio', 'pipelines', 'feature-builder.pipeline.yaml'))).toBe(true);
318
-
319
- const raw = await readFile(resolve(TMP, '.studio', 'config.yaml'), 'utf-8');
320
- const parsed = yaml.load(raw) as Record<string, unknown>;
321
- const providers = parsed.providers as Record<string, { apiKey: string }>;
322
- expect(providers.anthropic.apiKey).toBe('sk-ant-test-key');
323
- });
324
-
325
- it('skips writing config when provider is "later"', async () => {
326
- const { directInit } = await import('../../src/commands/init.js');
327
- await directInit(TMP, 'software', 'later', '');
328
-
329
- expect(await exists(resolve(TMP, '.studio'))).toBe(true);
330
- // Config.yaml exists (from template) but has no providers key written by directInit
331
- const raw = await readFile(resolve(TMP, '.studio', 'config.yaml'), 'utf-8');
332
- // The template config.yaml has anthropic placeholder but no actual key
333
- expect(raw).not.toContain('sk-ant-');
334
- });
335
-
336
- it('throws when template does not exist', async () => {
337
- const { directInit } = await import('../../src/commands/init.js');
338
- await expect(
339
- directInit(TMP, 'nonexistent', 'anthropic', 'sk-ant-key')
340
- ).rejects.toThrow("Template 'nonexistent' not found");
341
- });
342
-
343
- it('throws when .studio/ already exists', async () => {
344
- const { directInit } = await import('../../src/commands/init.js');
345
- await directInit(TMP, 'software', 'anthropic', 'sk-ant-key');
346
- await expect(
347
- directInit(TMP, 'software', 'anthropic', 'sk-ant-key')
348
- ).rejects.toThrow('already initialized');
349
- });
350
-
351
- it('works with force: backup then directInit succeeds', async () => {
352
- const { directInit, backupStudioDir } = await import('../../src/commands/init.js');
353
- // First init
354
- await directInit(TMP, 'software', 'anthropic', 'sk-ant-first');
355
- // Backup + reinit
356
- await backupStudioDir(TMP);
357
- await directInit(TMP, 'software', 'anthropic', 'sk-ant-second');
358
-
359
- const raw = await readFile(resolve(TMP, '.studio', 'config.yaml'), 'utf-8');
360
- const parsed = yaml.load(raw) as Record<string, unknown>;
361
- const providers = parsed.providers as Record<string, { apiKey: string }>;
362
- expect(providers.anthropic.apiKey).toBe('sk-ant-second');
363
- });
364
-
365
- it('writes ollama config when provider is ollama (no api key needed)', async () => {
366
- const { directInit } = await import('../../src/commands/init.js');
367
- await directInit(TMP, 'software', 'ollama', '');
368
- const studioDir = resolve(TMP, '.studio');
369
- const raw = await readFile(resolve(studioDir, 'config.yaml'), 'utf-8');
370
- const parsed = yaml.load(raw) as Record<string, unknown>;
371
- const providers = parsed.providers as Record<string, unknown>;
372
- expect(providers.ollama).toEqual({});
373
- const defaults = parsed.defaults as { provider: string; model: string };
374
- expect(defaults.provider).toBe('ollama');
375
- expect(defaults.model).toBe('llama3.3');
376
- });
377
- });
378
-
379
- describe('createStudioStructure with withTools: false', () => {
380
- it('does not copy tool files from template', async () => {
381
- const { createStudioStructure } = await import('../../src/commands/init.js');
382
- await createStudioStructure(TMP, 'software', false);
383
-
384
- const toolsDir = resolve(TMP, '.studio', 'tools');
385
- expect(await exists(toolsDir)).toBe(true);
386
-
387
- const { readdir } = await import('node:fs/promises');
388
- const toolFiles = await readdir(toolsDir);
389
- expect(toolFiles).toEqual([]);
390
- });
391
-
392
- it('still copies other template files when withTools is false', async () => {
393
- const { createStudioStructure } = await import('../../src/commands/init.js');
394
- await createStudioStructure(TMP, 'software', false);
395
-
396
- expect(await exists(resolve(TMP, '.studio', 'pipelines', 'feature-builder.pipeline.yaml'))).toBe(true);
397
- expect(await exists(resolve(TMP, '.studio', 'agents', 'coder.agent.yaml'))).toBe(true);
398
- });
399
- });
400
-
401
- describe('directInit with provider=ollama (no apiKey)', () => {
402
- it('writes empty providers.ollama config without requiring an apiKey', async () => {
403
- const { directInit } = await import('../../src/commands/init.js');
404
- await directInit(TMP, 'software', 'ollama', '');
405
-
406
- const raw = await readFile(resolve(TMP, '.studio', 'config.yaml'), 'utf-8');
407
- const parsed = yaml.load(raw) as Record<string, unknown>;
408
- const providers = parsed.providers as Record<string, unknown>;
409
- expect(providers['ollama']).toEqual({});
410
- });
411
- });
412
-
413
- describe('directInit with noTools: true', () => {
414
- it('creates project without copying tool files', async () => {
415
- const { directInit } = await import('../../src/commands/init.js');
416
- await directInit(TMP, 'software', 'anthropic', 'sk-ant-key', true);
417
-
418
- const toolsDir = resolve(TMP, '.studio', 'tools');
419
- expect(await exists(toolsDir)).toBe(true);
420
-
421
- const { readdir } = await import('node:fs/promises');
422
- const toolFiles = await readdir(toolsDir);
423
- expect(toolFiles).toEqual([]);
424
- });
425
-
426
- it('still creates config.yaml when noTools is true', async () => {
427
- const { directInit } = await import('../../src/commands/init.js');
428
- await directInit(TMP, 'software', 'anthropic', 'sk-ant-key', true);
429
-
430
- expect(await exists(resolve(TMP, '.studio', 'config.yaml'))).toBe(true);
431
- });
432
- });
433
-
434
- describe('writeProviderToConfig', () => {
435
- // We need a fresh .studio/ for each test — reuse the outer TMP/beforeEach/afterEach.
436
-
437
- it('writes anthropic key and defaults to config.yaml', async () => {
438
- const { createStudioStructure, writeProviderToConfig } = await import('../../src/commands/init.js');
439
- await createStudioStructure(TMP);
440
-
441
- const studioDir = resolve(TMP, '.studio');
442
- await writeProviderToConfig(studioDir, 'anthropic', { apiKey: 'sk-ant-test-key' });
443
-
444
- const raw = await readFile(resolve(studioDir, 'config.yaml'), 'utf-8');
445
- const parsed = yaml.load(raw) as Record<string, unknown>;
446
-
447
- const providers = parsed.providers as Record<string, { apiKey: string }>;
448
- expect(providers.anthropic.apiKey).toBe('sk-ant-test-key');
449
-
450
- const defaults = parsed.defaults as { provider: string; model: string };
451
- expect(defaults.provider).toBe('anthropic');
452
- expect(defaults.model).toBe('claude-sonnet-4-20250514');
453
- });
454
-
455
- it('writes openai key and defaults to config.yaml', async () => {
456
- const { createStudioStructure, writeProviderToConfig } = await import('../../src/commands/init.js');
457
- await createStudioStructure(TMP);
458
-
459
- const studioDir = resolve(TMP, '.studio');
460
- await writeProviderToConfig(studioDir, 'openai', { apiKey: 'sk-openai-test-key' });
461
-
462
- const raw = await readFile(resolve(studioDir, 'config.yaml'), 'utf-8');
463
- const parsed = yaml.load(raw) as Record<string, unknown>;
464
-
465
- const providers = parsed.providers as Record<string, { apiKey: string }>;
466
- expect(providers.openai.apiKey).toBe('sk-openai-test-key');
467
-
468
- const defaults = parsed.defaults as { provider: string; model: string };
469
- expect(defaults.provider).toBe('openai');
470
- expect(defaults.model).toBe('gpt-4o');
471
- });
472
-
473
- it('is idempotent — writing twice does not duplicate keys', async () => {
474
- const { createStudioStructure, writeProviderToConfig } = await import('../../src/commands/init.js');
475
- await createStudioStructure(TMP);
476
-
477
- const studioDir = resolve(TMP, '.studio');
478
- await writeProviderToConfig(studioDir, 'anthropic', { apiKey: 'sk-ant-first' });
479
- await writeProviderToConfig(studioDir, 'anthropic', { apiKey: 'sk-ant-second' });
480
-
481
- const raw = await readFile(resolve(studioDir, 'config.yaml'), 'utf-8');
482
- const parsed = yaml.load(raw) as Record<string, unknown>;
483
- const providers = parsed.providers as Record<string, { apiKey: string }>;
484
- expect(providers.anthropic.apiKey).toBe('sk-ant-second');
485
- });
486
-
487
- it('writes ollama config with empty credentials and llama3.3 default model', async () => {
488
- const { createStudioStructure, writeProviderToConfig } = await import('../../src/commands/init.js');
489
- await createStudioStructure(TMP);
490
- const studioDir = resolve(TMP, '.studio');
491
- await writeProviderToConfig(studioDir, 'ollama', {});
492
-
493
- const raw = await readFile(resolve(studioDir, 'config.yaml'), 'utf-8');
494
- const parsed = yaml.load(raw) as Record<string, unknown>;
495
- const providers = parsed.providers as Record<string, unknown>;
496
- expect(providers.ollama).toEqual({});
497
- expect(providers.ollama).not.toHaveProperty('apiKey');
498
- const defaults = parsed.defaults as { provider: string; model: string };
499
- expect(defaults.provider).toBe('ollama');
500
- expect(defaults.model).toBe('llama3.3');
501
- });
502
- });
503
-
504
- // Helper: create a synthetic template directory with the files generateAppFiles needs.
505
- // Used by generateAppFiles tests since the bundled project templates were removed
506
- // (they now live in the community registry).
507
- async function createSyntheticTemplateDir(dir: string): Promise<void> {
508
- await mkdir(resolve(dir, 'src'), { recursive: true });
509
- await mkdir(resolve(dir, 'prisma'), { recursive: true });
510
- await writeFile(resolve(dir, 'src', 'index.ts'), '// {{PROJECT_NAME}}\nexport {};\n', 'utf-8');
511
- await writeFile(resolve(dir, 'prisma', 'schema.prisma'), '// prisma schema\n', 'utf-8');
512
- await writeFile(
513
- resolve(dir, 'package.json'),
514
- JSON.stringify({ name: '{{PROJECT_NAME}}', version: '0.0.1' }, null, 2) + '\n',
515
- 'utf-8'
516
- );
517
- await writeFile(resolve(dir, 'README.md'), '# {{PROJECT_NAME}}\n\nTemplate: {{TEMPLATE_NAME}}\n', 'utf-8');
518
- }
519
-
520
- describe('generateAppFiles', () => {
521
- it('copies src/ with placeholder replacement', async () => {
522
- const { generateAppFiles } = await import('../../src/commands/init.js');
523
- const templateDir = resolve(TMP, '_template');
524
- await createSyntheticTemplateDir(templateDir);
525
-
526
- await generateAppFiles(templateDir, TMP, {
527
- PROJECT_NAME: 'my-app',
528
- TEMPLATE_NAME: 'software',
529
- YEAR: '2026',
530
- });
531
-
532
- const srcIndex = await readFile(resolve(TMP, 'src', 'index.ts'), 'utf-8');
533
- expect(srcIndex).toContain('my-app');
534
- expect(srcIndex).not.toContain('{{PROJECT_NAME}}');
535
- });
536
-
537
- it('copies package.json with placeholder replacement', async () => {
538
- const { generateAppFiles } = await import('../../src/commands/init.js');
539
- const templateDir = resolve(TMP, '_template');
540
- await createSyntheticTemplateDir(templateDir);
541
-
542
- await generateAppFiles(templateDir, TMP, {
543
- PROJECT_NAME: 'cool-project',
544
- TEMPLATE_NAME: 'software',
545
- YEAR: '2026',
546
- });
547
-
548
- const pkg = JSON.parse(await readFile(resolve(TMP, 'package.json'), 'utf-8'));
549
- expect(pkg.name).toBe('cool-project');
550
- });
551
-
552
- it('copies README.md with placeholder replacement', async () => {
553
- const { generateAppFiles } = await import('../../src/commands/init.js');
554
- const templateDir = resolve(TMP, '_template');
555
- await createSyntheticTemplateDir(templateDir);
556
-
557
- await generateAppFiles(templateDir, TMP, {
558
- PROJECT_NAME: 'my-app',
559
- TEMPLATE_NAME: 'software',
560
- YEAR: '2026',
561
- });
562
-
563
- const readme = await readFile(resolve(TMP, 'README.md'), 'utf-8');
564
- expect(readme).toContain('my-app');
565
- expect(readme).toContain('software');
566
- expect(readme).not.toContain('{{PROJECT_NAME}}');
567
- });
568
-
569
- it('copies prisma/schema.prisma', async () => {
570
- const { generateAppFiles } = await import('../../src/commands/init.js');
571
- const templateDir = resolve(TMP, '_template');
572
- await createSyntheticTemplateDir(templateDir);
573
-
574
- await generateAppFiles(templateDir, TMP, {
575
- PROJECT_NAME: 'my-app',
576
- TEMPLATE_NAME: 'software',
577
- YEAR: '2026',
578
- });
579
-
580
- expect(await exists(resolve(TMP, 'prisma', 'schema.prisma'))).toBe(true);
581
- });
582
-
583
- it('skips items not present in template (blank template has no src/)', async () => {
584
- const { generateAppFiles } = await import('../../src/commands/init.js');
585
- // Use an empty template dir (no src/, no package.json, etc.)
586
- const templateDir = resolve(TMP, '_blank_template');
587
- await mkdir(templateDir, { recursive: true });
588
-
589
- await expect(
590
- generateAppFiles(templateDir, TMP, {
591
- PROJECT_NAME: 'x',
592
- TEMPLATE_NAME: 'blank',
593
- YEAR: '2026',
594
- })
595
- ).resolves.not.toThrow();
596
-
597
- // blank template has no src/ so it should not be created
598
- expect(await exists(resolve(TMP, 'src'))).toBe(false);
599
- });
600
-
601
- it('returns list of generated top-level items', async () => {
602
- const { generateAppFiles } = await import('../../src/commands/init.js');
603
- const templateDir = resolve(TMP, '_template');
604
- await createSyntheticTemplateDir(templateDir);
605
-
606
- const generated = await generateAppFiles(templateDir, TMP, {
607
- PROJECT_NAME: 'my-app',
608
- TEMPLATE_NAME: 'software',
609
- YEAR: '2026',
610
- });
611
-
612
- expect(generated).toContain('package.json');
613
- expect(generated).toContain('README.md');
614
- expect(generated).toContain('src/'); // directories have trailing slash
615
- expect(generated).toContain('prisma/'); // directories have trailing slash
616
- });
617
- });
618
-
619
- describe('initGitRepo', () => {
620
- it('creates a .git/ directory in cwd', async () => {
621
- const { initGitRepo } = await import('../../src/commands/init.js');
622
- await initGitRepo(TMP);
623
- expect(await exists(resolve(TMP, '.git'))).toBe(true);
624
- });
625
-
626
- it('returns true when it initializes git', async () => {
627
- const { initGitRepo } = await import('../../src/commands/init.js');
628
- const result = await initGitRepo(TMP);
629
- expect(result).toBe(true);
630
- });
631
-
632
- it('returns false (skips) when .git/ already exists', async () => {
633
- const { initGitRepo } = await import('../../src/commands/init.js');
634
- await initGitRepo(TMP);
635
- const result = await initGitRepo(TMP);
636
- expect(result).toBe(false);
637
- });
638
- });
639
-
640
- describe('generateFullApp', () => {
641
- it('creates .studio/ AND src/ AND package.json AND README.md', async () => {
642
- const { generateFullApp } = await import('../../src/commands/init.js');
643
- await generateFullApp(TMP, 'my-app', 'software');
644
-
645
- expect(await exists(resolve(TMP, '.studio', 'pipelines'))).toBe(true);
646
- expect(await exists(resolve(TMP, 'src', 'index.ts'))).toBe(true);
647
- expect(await exists(resolve(TMP, 'package.json'))).toBe(true);
648
- expect(await exists(resolve(TMP, 'README.md'))).toBe(true);
649
- expect(await exists(resolve(TMP, 'prisma', 'schema.prisma'))).toBe(true);
650
- });
651
-
652
- it('applies PROJECT_NAME placeholder in package.json', async () => {
653
- const { generateFullApp } = await import('../../src/commands/init.js');
654
- await generateFullApp(TMP, 'cool-app', 'software');
655
-
656
- const pkg = JSON.parse(await readFile(resolve(TMP, 'package.json'), 'utf-8'));
657
- expect(pkg.name).toBe('cool-app');
658
- });
659
-
660
- it('initializes a git repository', async () => {
661
- const { generateFullApp } = await import('../../src/commands/init.js');
662
- const result = await generateFullApp(TMP, 'my-app', 'software');
663
- expect(result.gitInitialized).toBe(true);
664
- expect(await exists(resolve(TMP, '.git'))).toBe(true);
665
- });
666
-
667
- it('throws when template does not exist', async () => {
668
- const { generateFullApp } = await import('../../src/commands/init.js');
669
- await expect(generateFullApp(TMP, 'my-app', 'nonexistent-template')).rejects.toThrow();
670
- });
671
-
672
- it('skips git init when skipGit option is true', async () => {
673
- const { generateFullApp } = await import('../../src/commands/init.js');
674
- const result = await generateFullApp(TMP, 'my-app', 'software', { skipGit: true });
675
- expect(result.gitInitialized).toBe(false);
676
- expect(await exists(resolve(TMP, '.git'))).toBe(false);
677
- expect(await exists(resolve(TMP, '.studio'))).toBe(true);
678
- });
679
- });
680
-
681
- describe('validateProjectName', () => {
682
- it('accepts valid names', async () => {
683
- const { validateProjectName } = await import('../../src/commands/init.js');
684
- expect(validateProjectName('my-app')).toBe(true);
685
- expect(validateProjectName('my_project')).toBe(true);
686
- expect(validateProjectName('MyApp123')).toBe(true);
687
- expect(validateProjectName('app.v2')).toBe(true);
688
- });
689
-
690
- it('rejects empty string', async () => {
691
- const { validateProjectName } = await import('../../src/commands/init.js');
692
- expect(validateProjectName('')).toBeTypeOf('string');
693
- });
694
-
695
- it('rejects names with spaces or tabs', async () => {
696
- const { validateProjectName } = await import('../../src/commands/init.js');
697
- expect(validateProjectName('my app')).toBeTypeOf('string');
698
- expect(validateProjectName('my\tapp')).toBeTypeOf('string');
699
- });
700
-
701
- it('rejects names starting with a hyphen', async () => {
702
- const { validateProjectName } = await import('../../src/commands/init.js');
703
- const result = validateProjectName('-bad');
704
- expect(result).toBeTypeOf('string');
705
- expect(result as string).toContain('letter or digit');
706
- });
707
-
708
- it('rejects names with special characters', async () => {
709
- const { validateProjectName } = await import('../../src/commands/init.js');
710
- expect(validateProjectName('my@app')).toBeTypeOf('string');
711
- expect(validateProjectName('app!')).toBeTypeOf('string');
712
- expect(validateProjectName('app/dir')).toBeTypeOf('string');
713
- });
714
-
715
- it('rejects whitespace-only names', async () => {
716
- const { validateProjectName } = await import('../../src/commands/init.js');
717
- expect(validateProjectName(' ')).toBeTypeOf('string');
718
- });
719
-
720
- it('rejects names starting with a dot', async () => {
721
- const { validateProjectName } = await import('../../src/commands/init.js');
722
- expect(validateProjectName('.hidden')).toBeTypeOf('string');
723
- });
724
- });
725
-
726
- describe('detectOllamaInstalled', () => {
727
- let spawnSyncMock: ReturnType<typeof vi.fn>;
728
-
729
- beforeEach(async () => {
730
- const childProcess = await import('node:child_process');
731
- spawnSyncMock = vi.mocked(childProcess.spawnSync);
732
- spawnSyncMock.mockReset(); // clear any mockReturnValue from previous test
733
- });
734
-
735
- it('returns true when ollama --version exits 0', async () => {
736
- spawnSyncMock.mockReturnValue({ status: 0 });
737
- const { detectOllamaInstalled } = await import('../../src/commands/init.js');
738
- expect(detectOllamaInstalled()).toBe(true);
739
- });
740
-
741
- it('returns false when ollama --version exits non-zero', async () => {
742
- spawnSyncMock.mockReturnValue({ status: 1 });
743
- const { detectOllamaInstalled } = await import('../../src/commands/init.js');
744
- expect(detectOllamaInstalled()).toBe(false);
745
- });
746
-
747
- it('returns false when ollama is not found (status null)', async () => {
748
- spawnSyncMock.mockReturnValue({ status: null });
749
- const { detectOllamaInstalled } = await import('../../src/commands/init.js');
750
- expect(detectOllamaInstalled()).toBe(false);
751
- });
752
- });
753
-
754
- describe('hasAdequateRam', () => {
755
- let totalMemMock: ReturnType<typeof vi.fn>;
756
-
757
- beforeEach(async () => {
758
- const os = await import('node:os');
759
- totalMemMock = vi.mocked(os.totalmem);
760
- totalMemMock.mockReset(); // clear any mockReturnValue from previous test
761
- });
762
-
763
- afterEach(() => {
764
- totalMemMock.mockReturnValue(32 * 1024 ** 3); // restore default so later describe blocks aren't affected
765
- });
766
-
767
- it('returns true when RAM >= 16GB', async () => {
768
- totalMemMock.mockReturnValue(16 * 1024 ** 3);
769
- const { hasAdequateRam } = await import('../../src/commands/init.js');
770
- expect(hasAdequateRam()).toBe(true);
771
- });
772
-
773
- it('returns true when RAM > 16GB', async () => {
774
- totalMemMock.mockReturnValue(32 * 1024 ** 3);
775
- const { hasAdequateRam } = await import('../../src/commands/init.js');
776
- expect(hasAdequateRam()).toBe(true);
777
- });
778
-
779
- it('returns false when RAM < 16GB', async () => {
780
- totalMemMock.mockReturnValue(8 * 1024 ** 3);
781
- const { hasAdequateRam } = await import('../../src/commands/init.js');
782
- expect(hasAdequateRam()).toBe(false);
783
- });
784
-
785
- it('returns false when os.totalmem throws', async () => {
786
- totalMemMock.mockImplementation(() => { throw new Error('no access'); });
787
- const { hasAdequateRam } = await import('../../src/commands/init.js');
788
- expect(hasAdequateRam()).toBe(false);
789
- });
790
- });
791
-
792
- describe('generateFullApp (registry-backed)', () => {
793
- it('calls installPackage when template is specified', async () => {
794
- // Mock installPackage so it doesn't hit the network, and verify it is called
795
- const installPackageMock = vi.fn().mockResolvedValue(undefined);
796
- vi.doMock('../../src/commands/registry/install.js', () => ({
797
- installPackage: installPackageMock,
798
- }));
799
- vi.resetModules();
800
-
801
- const { createStudioStructure } = await import('../../src/commands/init.js');
802
- // Verify createStudioStructure still works (the basic init path, which doesn't call installPackage)
803
- await createStudioStructure(TMP);
804
- expect(await exists(resolve(TMP, '.studio'))).toBe(true);
805
-
806
- vi.doUnmock('../../src/commands/registry/install.js');
807
- vi.resetModules();
808
- });
809
-
810
- it('uses installed template dir (.studio/projects/<name>/) for app scaffold', async () => {
811
- // This test verifies the new flow: installPackage is called and scaffold is read
812
- // from the installed location, not from local bundled templates.
813
- const installPackageMock = vi.fn().mockResolvedValue(undefined);
814
- vi.doMock('../../src/commands/registry/install.js', () => ({
815
- installPackage: installPackageMock,
816
- }));
817
- vi.resetModules();
818
-
819
- // Set up a fake installed template directory before calling generateFullApp
820
- const studioDir = resolve(TMP, '.studio');
821
- const installedTemplateDir = resolve(studioDir, 'projects', 'software');
822
- await mkdir(installedTemplateDir, { recursive: true });
823
- // Write a minimal package.json so generateAppFiles has something to copy
824
- await writeFile(
825
- resolve(installedTemplateDir, 'package.json'),
826
- JSON.stringify({ name: '{{PROJECT_NAME}}', version: '0.0.1' }),
827
- 'utf-8'
828
- );
829
-
830
- // Also seed local .studio/ structure that createStudioStructure expects
831
- // (createStudioStructure will throw "already initialized" if .studio already exists,
832
- // so we skip that by noting this test pre-creates .studio; use generateAppFiles directly)
833
- const { generateAppFiles } = await import('../../src/commands/init.js');
834
- const generated = await generateAppFiles(installedTemplateDir, TMP, {
835
- PROJECT_NAME: 'registry-app',
836
- TEMPLATE_NAME: 'software',
837
- YEAR: '2026',
838
- });
839
-
840
- const pkg = JSON.parse(await readFile(resolve(TMP, 'package.json'), 'utf-8'));
841
- expect(pkg.name).toBe('registry-app');
842
- expect(generated).toContain('package.json');
843
-
844
- vi.doUnmock('../../src/commands/registry/install.js');
845
- vi.resetModules();
846
- });
847
- });
848
-
849
- describe('detectHardware', () => {
850
- it('returns totalRamGb as a positive number', async () => {
851
- const { detectHardware } = await import('../../src/commands/init.js');
852
- const hw = detectHardware();
853
- expect(hw.totalRamGb).toBeGreaterThan(0);
854
- });
855
-
856
- it('returns all expected fields', async () => {
857
- const { detectHardware } = await import('../../src/commands/init.js');
858
- const hw = detectHardware();
859
- expect(typeof hw.hasDocker).toBe('boolean');
860
- expect(typeof hw.hasNativeOllama).toBe('boolean');
861
- expect(typeof hw.ollamaAvailable).toBe('boolean');
862
- expect(hw.ollamaAvailable).toBe(hw.hasDocker || hw.hasNativeOllama);
863
- });
864
- });
865
-
866
- describe('writeProviderToConfig — ollama (no apiKey)', () => {
867
- it('writes empty providers.ollama object when apiKey is undefined', async () => {
868
- const { writeProviderToConfig } = await import('../../src/commands/init.js');
869
- const studioDir = resolve(TMP, '.studio');
870
- await mkdir(studioDir, { recursive: true });
871
-
872
- await writeProviderToConfig(studioDir, 'ollama', undefined, 'llama3.3');
873
-
874
- const raw = await readFile(resolve(studioDir, 'config.yaml'), 'utf-8');
875
- const parsed = yaml.load(raw) as Record<string, unknown>;
876
- const providers = parsed.providers as Record<string, unknown>;
877
- expect(providers['ollama']).toEqual({});
878
- const defaults = parsed.defaults as Record<string, unknown>;
879
- expect(defaults['provider']).toBe('ollama');
880
- expect(defaults['model']).toBe('llama3.3');
881
- });
882
- });
883
-
884
- describe('detectHardware with mocks', () => {
885
- it('returns ollamaAvailable=true when docker reports success', async () => {
886
- // spawnSync is mocked at module level — configure it
887
- const cp = await import('node:child_process');
888
- vi.mocked(cp.spawnSync).mockReturnValue({ status: 0 } as ReturnType<typeof cp.spawnSync>);
889
- const os = await import('node:os');
890
- vi.mocked(os.totalmem).mockReturnValue(16 * 1024 ** 3);
891
-
892
- const { detectHardware } = await import('../../src/commands/init.js');
893
- const hw = detectHardware();
894
- expect(hw.hasDocker).toBe(true);
895
- expect(hw.ollamaAvailable).toBe(true);
896
- });
897
-
898
- it('returns ollamaAvailable=false when neither docker nor ollama present', async () => {
899
- const cp = await import('node:child_process');
900
- vi.mocked(cp.spawnSync).mockReturnValue({ status: 1 } as ReturnType<typeof cp.spawnSync>);
901
- const os = await import('node:os');
902
- vi.mocked(os.totalmem).mockReturnValue(8 * 1024 ** 3);
903
-
904
- const { detectHardware } = await import('../../src/commands/init.js');
905
- const hw = detectHardware();
906
- expect(hw.hasDocker).toBe(false);
907
- expect(hw.hasNativeOllama).toBe(false);
908
- expect(hw.ollamaAvailable).toBe(false);
909
- });
910
-
911
- it('returns totalRamGb correctly from os.totalmem()', async () => {
912
- const os = await import('node:os');
913
- vi.mocked(os.totalmem).mockReturnValue(32 * 1024 ** 3);
914
-
915
- const { detectHardware } = await import('../../src/commands/init.js');
916
- const hw = detectHardware();
917
- expect(hw.totalRamGb).toBeCloseTo(32, 0);
918
- });
919
- });