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

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 (91) hide show
  1. package/package.json +8 -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/templates/.studiorc.yaml +0 -16
  50. package/templates/projects/blank/metadata.json +0 -7
  51. package/templates/studio-config.yaml +0 -14
  52. package/tests/__stubs__/studio-runner.ts +0 -104
  53. package/tests/commands/api.test.ts +0 -110
  54. package/tests/commands/config.test.ts +0 -221
  55. package/tests/commands/init.test.ts +0 -919
  56. package/tests/commands/install.test.ts +0 -52
  57. package/tests/commands/integrations.test.ts +0 -158
  58. package/tests/commands/ollama.test.ts +0 -139
  59. package/tests/commands/project.test.ts +0 -179
  60. package/tests/commands/registry/audit.test.ts +0 -56
  61. package/tests/commands/registry/install.test.ts +0 -200
  62. package/tests/commands/registry/publish.test.ts +0 -56
  63. package/tests/commands/registry/remove.test.ts +0 -103
  64. package/tests/commands/registry/search.test.ts +0 -44
  65. package/tests/commands/registry/sync.test.ts +0 -37
  66. package/tests/commands/registry/update.test.ts +0 -62
  67. package/tests/commands/replay.test.ts +0 -283
  68. package/tests/commands/template/validate.test.ts +0 -150
  69. package/tests/commands/templates.test.ts +0 -42
  70. package/tests/commands/tools.test.ts +0 -106
  71. package/tests/config.test.ts +0 -142
  72. package/tests/formatter.test.ts +0 -158
  73. package/tests/integration/sigint.test.ts +0 -188
  74. package/tests/models-cache.test.ts +0 -250
  75. package/tests/output/file-changes.test.ts +0 -178
  76. package/tests/output/formatters.test.ts +0 -448
  77. package/tests/output/progress-spinner.test.ts +0 -232
  78. package/tests/output/progress-timer.test.ts +0 -230
  79. package/tests/provider-validator.test.ts +0 -182
  80. package/tests/registry/cache.test.ts +0 -66
  81. package/tests/registry/client.test.ts +0 -70
  82. package/tests/registry/lockfile.test.ts +0 -87
  83. package/tests/registry/resolver.test.ts +0 -122
  84. package/tests/run-logger-events.test.ts +0 -326
  85. package/tests/run-logger.test.ts +0 -64
  86. package/tests/run-store-factory.test.ts +0 -51
  87. package/tests/studio-dir.test.ts +0 -31
  88. package/tests/utils/input-wizard.test.ts +0 -153
  89. package/tests/utils/placeholders.test.ts +0 -36
  90. package/tsconfig.json +0 -24
  91. package/vitest.config.ts +0 -20
@@ -1,52 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
-
3
- vi.mock('node:child_process', () => ({
4
- execSync: vi.fn(),
5
- }));
6
-
7
- import { execSync } from 'node:child_process';
8
- import { installExtensionCommand } from '../../src/commands/install.js';
9
-
10
- const mockExecSync = vi.mocked(execSync);
11
-
12
- beforeEach(() => {
13
- mockExecSync.mockClear();
14
- });
15
-
16
- describe('installExtensionCommand', () => {
17
- it('runs npm install -g @studio-foundation/api when extension is "api"', async () => {
18
- const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
19
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); });
20
-
21
- await installExtensionCommand('api');
22
-
23
- expect(mockExecSync).toHaveBeenCalledWith('npm install -g @studio-foundation/api', { stdio: 'inherit' });
24
- expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('@studio-foundation/api installed'));
25
- consoleSpy.mockRestore();
26
- exitSpy.mockRestore();
27
- });
28
-
29
- it('exits with error for unknown extension', async () => {
30
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
31
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); });
32
-
33
- await expect(installExtensionCommand('web')).rejects.toThrow('exit');
34
-
35
- expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown extension'));
36
- expect(exitSpy).toHaveBeenCalledWith(1);
37
- errorSpy.mockRestore();
38
- exitSpy.mockRestore();
39
- });
40
-
41
- it('exits with error when npm install fails', async () => {
42
- mockExecSync.mockImplementationOnce(() => { throw new Error('npm ERR! 404'); });
43
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
44
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); });
45
-
46
- await expect(installExtensionCommand('api')).rejects.toThrow('exit');
47
-
48
- expect(exitSpy).toHaveBeenCalledWith(1);
49
- errorSpy.mockRestore();
50
- exitSpy.mockRestore();
51
- });
52
- });
@@ -1,158 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { mkdtemp, mkdir, writeFile, rm, readFile, access, unlink } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { tmpdir } from 'node:os';
5
- import type { IntegrationPluginDef } from '@studio-foundation/contracts';
6
- import { installIntegration, getIntegrationStatus, removeIntegration, runIntegrationTest } from '../../src/commands/integrations.js';
7
- import type { IntegrationTestResult } from '../../src/commands/integrations.js';
8
-
9
- let studioDir: string;
10
- let integrationsDir: string;
11
-
12
- beforeEach(async () => {
13
- studioDir = await mkdtemp(join(tmpdir(), 'studio-int-test-'));
14
- integrationsDir = join(studioDir, 'integrations');
15
- await mkdir(integrationsDir, { recursive: true });
16
- });
17
-
18
- afterEach(async () => {
19
- await rm(studioDir, { recursive: true, force: true });
20
- });
21
-
22
- describe('installIntegration — bundled source', () => {
23
- it('installs a known bundled integration by @studio/integration-<name>', async () => {
24
- await installIntegration('@studio-foundation/integration-linear', integrationsDir);
25
- const destPath = join(integrationsDir, 'linear.integration.yaml');
26
- await expect(access(destPath)).resolves.toBeUndefined();
27
- const content = await readFile(destPath, 'utf-8');
28
- expect(content).toContain('name: linear');
29
- });
30
-
31
- it('throws if integration name is unknown', async () => {
32
- await expect(
33
- installIntegration('@studio-foundation/integration-doesnotexist', integrationsDir)
34
- ).rejects.toThrow("Unknown integration 'doesnotexist'");
35
- });
36
-
37
- it('throws if already installed', async () => {
38
- await installIntegration('@studio-foundation/integration-linear', integrationsDir);
39
- await expect(
40
- installIntegration('@studio-foundation/integration-linear', integrationsDir)
41
- ).rejects.toThrow("'linear' already installed");
42
- });
43
- });
44
-
45
- describe('installIntegration — local path', () => {
46
- it('installs from a local .integration.yaml file', async () => {
47
- const localFile = join(studioDir, 'my-custom.integration.yaml');
48
- await writeFile(localFile, 'name: my-custom\nversion: 1\ndescription: "Custom"');
49
- await installIntegration(localFile, integrationsDir);
50
- const destPath = join(integrationsDir, 'my-custom.integration.yaml');
51
- await expect(access(destPath)).resolves.toBeUndefined();
52
- });
53
-
54
- it('throws if local file does not exist', async () => {
55
- await expect(
56
- installIntegration('/nonexistent/file.integration.yaml', integrationsDir)
57
- ).rejects.toThrow('File not found');
58
- });
59
- });
60
-
61
- describe('getIntegrationStatus', () => {
62
- it('returns configured when all required vars are set in config', () => {
63
- const plugin: IntegrationPluginDef = {
64
- name: 'linear',
65
- version: 1,
66
- config: { required: ['LINEAR_API_KEY', 'LINEAR_WEBHOOK_SECRET'] },
67
- };
68
- const config = { LINEAR_API_KEY: 'abc', LINEAR_WEBHOOK_SECRET: 'secret' };
69
- expect(getIntegrationStatus(plugin, config)).toBe('configured');
70
- });
71
-
72
- it('returns not-configured when a required var is missing', () => {
73
- const plugin: IntegrationPluginDef = {
74
- name: 'linear',
75
- version: 1,
76
- config: { required: ['LINEAR_API_KEY', 'LINEAR_WEBHOOK_SECRET'] },
77
- };
78
- const config = { LINEAR_API_KEY: 'abc' };
79
- expect(getIntegrationStatus(plugin, config)).toBe('not-configured');
80
- });
81
-
82
- it('returns configured when plugin has no required vars', () => {
83
- const plugin: IntegrationPluginDef = { name: 'webhook', version: 1 };
84
- expect(getIntegrationStatus(plugin, {})).toBe('configured');
85
- });
86
- });
87
-
88
- describe('removeIntegration', () => {
89
- it('removes an installed integration file', async () => {
90
- await installIntegration('@studio-foundation/integration-linear', integrationsDir);
91
- const destPath = join(integrationsDir, 'linear.integration.yaml');
92
- await expect(access(destPath)).resolves.toBeUndefined();
93
-
94
- await removeIntegration('linear', integrationsDir);
95
- await expect(access(destPath)).rejects.toThrow();
96
- });
97
-
98
- it('throws if integration is not installed', async () => {
99
- await expect(
100
- removeIntegration('doesnotexist', integrationsDir)
101
- ).rejects.toThrow("Integration 'doesnotexist' not found");
102
- });
103
- });
104
-
105
- describe('runIntegrationTest', () => {
106
- it('makes an HTTP request with correct headers when auth is provided', async () => {
107
- const fetchCalls: { url: string; init: RequestInit }[] = [];
108
- const mockFetch = async (url: string, init: RequestInit) => {
109
- fetchCalls.push({ url, init });
110
- return new Response('{"data":{"viewer":{"id":"1","name":"Test"}}}', { status: 200 });
111
- };
112
-
113
- const plugin: IntegrationPluginDef = {
114
- name: 'linear',
115
- version: 1,
116
- test: {
117
- type: 'http',
118
- endpoint: 'https://api.linear.app/graphql',
119
- method: 'POST',
120
- auth: 'bearer:${LINEAR_API_KEY}',
121
- body: '{"query":"{ viewer { id name } }"}',
122
- expect: { status: 200 },
123
- },
124
- };
125
- const config = { LINEAR_API_KEY: 'my-api-key' };
126
-
127
- const result = await runIntegrationTest(plugin, config, mockFetch as typeof fetch);
128
- expect(result.success).toBe(true);
129
- expect(fetchCalls[0]!.url).toBe('https://api.linear.app/graphql');
130
- expect((fetchCalls[0]!.init.headers as Record<string, string>)['Authorization']).toBe('Bearer my-api-key');
131
- });
132
-
133
- it('returns success=false when HTTP status does not match expect.status', async () => {
134
- const mockFetch = async () => new Response('Unauthorized', { status: 401 });
135
- const plugin: IntegrationPluginDef = {
136
- name: 'linear',
137
- version: 1,
138
- test: {
139
- type: 'http',
140
- endpoint: 'https://api.linear.app/graphql',
141
- method: 'POST',
142
- auth: 'bearer:my-key',
143
- body: '{"query":"{ viewer { id } }"}',
144
- expect: { status: 200 },
145
- },
146
- };
147
- const result = await runIntegrationTest(plugin, {}, mockFetch as typeof fetch);
148
- expect(result.success).toBe(false);
149
- expect(result.statusCode).toBe(401);
150
- });
151
-
152
- it('throws when plugin has no test: block', async () => {
153
- const plugin: IntegrationPluginDef = { name: 'webhook', version: 1 };
154
- await expect(runIntegrationTest(plugin, {}, fetch)).rejects.toThrow(
155
- "Integration 'webhook' has no test: configuration"
156
- );
157
- });
158
- });
@@ -1,139 +0,0 @@
1
- import { describe, it, expect, vi, afterEach } from 'vitest';
2
-
3
- // Mock child_process before importing the module
4
- vi.mock('node:child_process', async (importOriginal) => {
5
- const actual = await importOriginal<typeof import('node:child_process')>();
6
- return { ...actual, spawnSync: vi.fn() };
7
- });
8
-
9
- const { spawnSync } = await import('node:child_process');
10
- const { ollamaStatusCommand, ollamaStartCommand, ollamaStopCommand, ollamaPullCommand } = await import('../../src/commands/ollama.js');
11
-
12
- afterEach(() => {
13
- vi.restoreAllMocks();
14
- vi.stubGlobal('fetch', undefined);
15
- });
16
-
17
- describe('ollamaStatusCommand', () => {
18
- it('prints running + models when Ollama responds', async () => {
19
- global.fetch = vi.fn().mockResolvedValue({
20
- ok: true,
21
- json: async () => ({
22
- models: [
23
- { name: 'llama3.3:latest', size: 4_200_000_000 },
24
- { name: 'codellama:7b', size: 3_800_000_000 },
25
- ],
26
- }),
27
- }) as unknown as typeof fetch;
28
-
29
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
30
- await ollamaStatusCommand('http://localhost:11434');
31
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
32
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('llama3.3:latest'));
33
- logSpy.mockRestore();
34
- });
35
-
36
- it('prints not running when fetch fails', async () => {
37
- global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) as unknown as typeof fetch;
38
-
39
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
40
- await ollamaStatusCommand('http://localhost:11434');
41
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
42
- logSpy.mockRestore();
43
- });
44
- });
45
-
46
- describe('ollamaStartCommand', () => {
47
- it('prints "already running" when Ollama is reachable', async () => {
48
- global.fetch = vi.fn().mockResolvedValue({
49
- ok: true,
50
- json: async () => ({ models: [] }),
51
- }) as unknown as typeof fetch;
52
-
53
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
54
- await ollamaStartCommand('http://localhost:11434');
55
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('already running'));
56
- logSpy.mockRestore();
57
- });
58
-
59
- it('prints native ollama serve command when ollama is installed', async () => {
60
- global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) as unknown as typeof fetch;
61
- vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType<typeof spawnSync>);
62
-
63
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
64
- await ollamaStartCommand('http://localhost:11434');
65
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('ollama serve'));
66
- logSpy.mockRestore();
67
- });
68
-
69
- it('prints docker run command when only docker is available', async () => {
70
- global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) as unknown as typeof fetch;
71
- vi.mocked(spawnSync)
72
- .mockReturnValueOnce({ status: 1 } as ReturnType<typeof spawnSync>) // ollama not found
73
- .mockReturnValueOnce({ status: 0 } as ReturnType<typeof spawnSync>); // docker found
74
-
75
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
76
- await ollamaStartCommand('http://localhost:11434');
77
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('docker run'));
78
- logSpy.mockRestore();
79
- });
80
-
81
- it('prints install instructions when neither is available', async () => {
82
- global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) as unknown as typeof fetch;
83
- vi.mocked(spawnSync).mockReturnValue({ status: 1 } as ReturnType<typeof spawnSync>);
84
-
85
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
86
- await ollamaStartCommand('http://localhost:11434');
87
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('docker.com'));
88
- logSpy.mockRestore();
89
- });
90
- });
91
-
92
- describe('ollamaStopCommand', () => {
93
- it('always prints stop instructions including docker stop', async () => {
94
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
95
- await ollamaStopCommand();
96
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('docker stop'));
97
- logSpy.mockRestore();
98
- });
99
- });
100
-
101
- describe('ollamaPullCommand', () => {
102
- it('exits with error when Ollama not running', async () => {
103
- global.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')) as unknown as typeof fetch;
104
-
105
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as (code?: number) => never);
106
- const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
107
- await ollamaPullCommand('llama3.3', 'http://localhost:11434');
108
- expect(exitSpy).toHaveBeenCalledWith(1);
109
- exitSpy.mockRestore();
110
- errSpy.mockRestore();
111
- });
112
-
113
- it('streams pull and prints success', async () => {
114
- const encoder = new TextEncoder();
115
- const ndjson = '{"status":"pulling manifest"}\n{"status":"success"}\n';
116
-
117
- let callCount = 0;
118
- global.fetch = vi.fn().mockImplementation(async () => {
119
- callCount++;
120
- if (callCount === 1) {
121
- return { ok: true, json: async () => ({ models: [] }) };
122
- }
123
- const stream = new ReadableStream({
124
- start(controller) {
125
- controller.enqueue(encoder.encode(ndjson));
126
- controller.close();
127
- },
128
- });
129
- return { ok: true, body: stream };
130
- }) as unknown as typeof fetch;
131
-
132
- const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
133
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
134
- await ollamaPullCommand('llama3.3', 'http://localhost:11434');
135
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Pulled llama3.3'));
136
- writeSpy.mockRestore();
137
- logSpy.mockRestore();
138
- });
139
- });
@@ -1,179 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { mkdir, rm, access, readdir } from 'node:fs/promises';
3
- import { resolve, join } from 'node:path';
4
-
5
- // IMPORTANT: must be under /tmp, not a Studio repo subdirectory
6
- const TMP = resolve('/tmp', '.studio-project-test');
7
- const PROJECTS_DIR = join(TMP, '.studio', 'projects');
8
-
9
- beforeEach(async () => { await mkdir(PROJECTS_DIR, { recursive: true }); });
10
- afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
11
-
12
- async function exists(p: string): Promise<boolean> {
13
- try { await access(p); return true; } catch { return false; }
14
- }
15
-
16
- describe.skip('createProjectDir', () => {
17
- it('creates 5 empty subdirs when no template given', async () => {
18
- const { createProjectDir } = await import('../../src/commands/project.js');
19
- await createProjectDir(PROJECTS_DIR, 'my-app');
20
-
21
- const projectDir = join(PROJECTS_DIR, 'my-app');
22
- for (const sub of ['pipelines', 'agents', 'contracts', 'tools', 'inputs']) {
23
- expect(await exists(join(projectDir, sub))).toBe(true);
24
- }
25
- });
26
-
27
- it('creates 5 empty subdirs for blank template (no project/ subdir in template)', async () => {
28
- const { createProjectDir } = await import('../../src/commands/project.js');
29
- await createProjectDir(PROJECTS_DIR, 'my-app', 'blank');
30
-
31
- const pipelinesDir = join(PROJECTS_DIR, 'my-app', 'pipelines');
32
- expect(await exists(pipelinesDir)).toBe(true);
33
- const entries = await readdir(pipelinesDir);
34
- expect(entries).toEqual([]);
35
- });
36
-
37
- it('copies software template files into the project dir', async () => {
38
- const { createProjectDir } = await import('../../src/commands/project.js');
39
- await createProjectDir(PROJECTS_DIR, 'my-app', 'software');
40
-
41
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'pipelines', 'feature-builder.pipeline.yaml'))).toBe(true);
42
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'agents', 'coder.agent.yaml'))).toBe(true);
43
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'contracts', 'code-output.contract.yaml'))).toBe(true);
44
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'tools', 'repo-manager.tool.yaml'))).toBe(true);
45
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'inputs', 'example.input.yaml'))).toBe(true);
46
- });
47
-
48
- it('throws "already exists" when the project dir already exists', async () => {
49
- const { createProjectDir } = await import('../../src/commands/project.js');
50
- await createProjectDir(PROJECTS_DIR, 'my-app');
51
- await expect(createProjectDir(PROJECTS_DIR, 'my-app')).rejects.toThrow("Project 'my-app' already exists");
52
- });
53
-
54
- it('throws "not found" with template list hint for invalid template', async () => {
55
- const { createProjectDir } = await import('../../src/commands/project.js');
56
- const err = await createProjectDir(PROJECTS_DIR, 'my-app', 'nonexistent').catch((e: Error) => e);
57
- expect(err).toBeInstanceOf(Error);
58
- expect((err as Error).message).toContain("Template 'nonexistent' not found");
59
- expect((err as Error).message).toContain('studio templates list');
60
- });
61
- });
62
-
63
- describe('validateProjectName', () => {
64
- it('accepts valid lowercase alphanumeric names', async () => {
65
- const { validateProjectName } = await import('../../src/commands/project.js');
66
- expect(validateProjectName('software')).toBe(true);
67
- expect(validateProjectName('legal-analyzer')).toBe(true);
68
- expect(validateProjectName('my-project-v2')).toBe(true);
69
- expect(validateProjectName('x')).toBe(true);
70
- expect(validateProjectName('abc123')).toBe(true);
71
- });
72
-
73
- it('rejects names with uppercase letters', async () => {
74
- const { validateProjectName } = await import('../../src/commands/project.js');
75
- expect(validateProjectName('Legal')).not.toBe(true);
76
- expect(validateProjectName('MY-PROJECT')).not.toBe(true);
77
- });
78
-
79
- it('rejects names with leading or trailing hyphens', async () => {
80
- const { validateProjectName } = await import('../../src/commands/project.js');
81
- expect(validateProjectName('-legal')).not.toBe(true);
82
- expect(validateProjectName('legal-')).not.toBe(true);
83
- });
84
-
85
- it('rejects names with spaces or underscores', async () => {
86
- const { validateProjectName } = await import('../../src/commands/project.js');
87
- expect(validateProjectName('my project')).not.toBe(true);
88
- expect(validateProjectName('my_project')).not.toBe(true);
89
- });
90
-
91
- it('returns an error string (not false) for invalid names', async () => {
92
- const { validateProjectName } = await import('../../src/commands/project.js');
93
- const result = validateProjectName('Bad Name');
94
- expect(typeof result).toBe('string');
95
- expect(result).toContain('lowercase');
96
- });
97
- });
98
-
99
- describe.skip('createProjectDir with { withTools: false }', () => {
100
- it('copies software template files but leaves tools/ empty', async () => {
101
- const { createProjectDir } = await import('../../src/commands/project.js');
102
- await createProjectDir(PROJECTS_DIR, 'my-app', 'software', { withTools: false });
103
-
104
- // Non-tool template files are copied
105
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'pipelines', 'feature-builder.pipeline.yaml'))).toBe(true);
106
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'agents', 'coder.agent.yaml'))).toBe(true);
107
-
108
- // tools/ directory exists but is empty
109
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'tools'))).toBe(true);
110
- const toolFiles = await readdir(join(PROJECTS_DIR, 'my-app', 'tools'));
111
- expect(toolFiles).toEqual([]);
112
- });
113
-
114
- it('blank template with { withTools: false } still creates all 5 subdirs including tools/', async () => {
115
- const { createProjectDir } = await import('../../src/commands/project.js');
116
- await createProjectDir(PROJECTS_DIR, 'my-blank', 'blank', { withTools: false });
117
-
118
- for (const sub of ['pipelines', 'agents', 'contracts', 'tools', 'inputs']) {
119
- expect(await exists(join(PROJECTS_DIR, 'my-blank', sub))).toBe(true);
120
- }
121
- });
122
-
123
- it('default behavior (withTools: true) still copies tools', async () => {
124
- const { createProjectDir } = await import('../../src/commands/project.js');
125
- await createProjectDir(PROJECTS_DIR, 'my-app', 'software');
126
-
127
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'tools', 'repo-manager.tool.yaml'))).toBe(true);
128
- });
129
- });
130
-
131
- describe.skip('projectAddDirect', () => {
132
- it('creates project dirs with a valid name and no template', async () => {
133
- const { projectAddDirect } = await import('../../src/commands/project.js');
134
- const studioDir = join(TMP, '.studio');
135
-
136
- await projectAddDirect(studioDir, 'legal-analyzer');
137
-
138
- expect(await exists(join(PROJECTS_DIR, 'legal-analyzer', 'pipelines'))).toBe(true);
139
- expect(await exists(join(PROJECTS_DIR, 'legal-analyzer', 'agents'))).toBe(true);
140
- });
141
-
142
- it('creates project with software template', async () => {
143
- const { projectAddDirect } = await import('../../src/commands/project.js');
144
- const studioDir = join(TMP, '.studio');
145
-
146
- await projectAddDirect(studioDir, 'my-app', 'software');
147
-
148
- expect(await exists(join(PROJECTS_DIR, 'my-app', 'pipelines', 'feature-builder.pipeline.yaml'))).toBe(true);
149
- });
150
-
151
- it('throws on invalid project name', async () => {
152
- const { projectAddDirect } = await import('../../src/commands/project.js');
153
- const studioDir = join(TMP, '.studio');
154
- await expect(projectAddDirect(studioDir, 'Invalid Name')).rejects.toThrow('lowercase');
155
- });
156
-
157
- it('throws "already exists" when project already present', async () => {
158
- const { projectAddDirect } = await import('../../src/commands/project.js');
159
- const studioDir = join(TMP, '.studio');
160
- await projectAddDirect(studioDir, 'legal-analyzer');
161
- await expect(projectAddDirect(studioDir, 'legal-analyzer')).rejects.toThrow('already exists');
162
- });
163
- });
164
-
165
- describe('projectCommand', () => {
166
- it('exits 1 with deprecation message when called', async () => {
167
- const { projectCommand } = await import('../../src/commands/project.js');
168
- const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
169
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
170
-
171
- await projectCommand('add', [], {});
172
-
173
- expect(exitSpy).toHaveBeenCalledWith(1);
174
- expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('no longer supported'));
175
-
176
- exitSpy.mockRestore();
177
- consoleErrorSpy.mockRestore();
178
- });
179
- });
@@ -1,56 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { rm, mkdir, writeFile } from 'node:fs/promises';
3
- import { resolve, join } from 'node:path';
4
- import { createHash } from 'node:crypto';
5
-
6
- const TMP = resolve(import.meta.dirname, '.tmp-audit');
7
- const STUDIO = join(TMP, '.studio');
8
- const TOOLS = join(STUDIO, 'tools');
9
-
10
- beforeEach(async () => {
11
- await mkdir(TOOLS, { recursive: true });
12
- });
13
- afterEach(async () => { await rm(TMP, { recursive: true, force: true }); });
14
-
15
- describe('auditPackages', () => {
16
- it('returns ok for intact file', async () => {
17
- const content = 'name: my-tool\n';
18
- const sha256 = createHash('sha256').update(content).digest('hex');
19
- await writeFile(join(TOOLS, 'my-tool.tool.yaml'), content);
20
- await writeFile(join(STUDIO, 'registry.lock.json'), JSON.stringify({
21
- installed: { 'my-tool': { version: '1.0.0', type: 'tool', installed_at: '2026-02-28', sha256 } },
22
- }));
23
-
24
- const { auditPackages } = await import('../../../src/commands/registry/audit.js');
25
- const results = await auditPackages({ studioDir: STUDIO });
26
- expect(results[0].ok).toBe(true);
27
- expect(results[0].status).toBe('ok');
28
- });
29
-
30
- it('returns tampered for modified file', async () => {
31
- const original = 'name: my-tool\n';
32
- const sha256 = createHash('sha256').update(original).digest('hex');
33
- await writeFile(join(TOOLS, 'my-tool.tool.yaml'), 'name: evil-tool\n'); // tampered
34
- await writeFile(join(STUDIO, 'registry.lock.json'), JSON.stringify({
35
- installed: { 'my-tool': { version: '1.0.0', type: 'tool', installed_at: '2026-02-28', sha256 } },
36
- }));
37
-
38
- const { auditPackages } = await import('../../../src/commands/registry/audit.js');
39
- const results = await auditPackages({ studioDir: STUDIO });
40
- expect(results[0].ok).toBe(false);
41
- expect(results[0].status).toBe('tampered');
42
- });
43
-
44
- it('returns missing when file does not exist', async () => {
45
- const sha256 = createHash('sha256').update('content').digest('hex');
46
- await writeFile(join(STUDIO, 'registry.lock.json'), JSON.stringify({
47
- installed: { 'my-tool': { version: '1.0.0', type: 'tool', installed_at: '2026-02-28', sha256 } },
48
- }));
49
- // no file written to TOOLS
50
-
51
- const { auditPackages } = await import('../../../src/commands/registry/audit.js');
52
- const results = await auditPackages({ studioDir: STUDIO });
53
- expect(results[0].ok).toBe(false);
54
- expect(results[0].status).toBe('missing');
55
- });
56
- });