@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.
- package/package.json +9 -5
- package/ARCHITECTURE.md +0 -40
- package/src/commands/api.ts +0 -126
- package/src/commands/config.ts +0 -433
- package/src/commands/init.ts +0 -879
- package/src/commands/install.ts +0 -23
- package/src/commands/integrations.ts +0 -332
- package/src/commands/list.ts +0 -197
- package/src/commands/logs.ts +0 -119
- package/src/commands/ollama.ts +0 -168
- package/src/commands/project.ts +0 -167
- package/src/commands/registry/audit.ts +0 -87
- package/src/commands/registry/index.ts +0 -63
- package/src/commands/registry/install.ts +0 -222
- package/src/commands/registry/publish.ts +0 -112
- package/src/commands/registry/remove.ts +0 -89
- package/src/commands/registry/search.ts +0 -93
- package/src/commands/registry/sync.ts +0 -24
- package/src/commands/registry/update.ts +0 -63
- package/src/commands/replay.ts +0 -559
- package/src/commands/run.ts +0 -454
- package/src/commands/status.ts +0 -163
- package/src/commands/template/index.ts +0 -59
- package/src/commands/template/validate.ts +0 -175
- package/src/commands/templates.ts +0 -99
- package/src/commands/tools.ts +0 -227
- package/src/commands/users.ts +0 -127
- package/src/commands/validate.ts +0 -46
- package/src/config.ts +0 -101
- package/src/index.ts +0 -201
- package/src/models-cache.ts +0 -127
- package/src/output/file-changes.ts +0 -97
- package/src/output/formatter.ts +0 -85
- package/src/output/formatters.ts +0 -303
- package/src/output/logger.ts +0 -18
- package/src/output/parallel-progress.ts +0 -103
- package/src/output/progress.ts +0 -414
- package/src/provider-validator.ts +0 -109
- package/src/registry/cache.ts +0 -46
- package/src/registry/client.ts +0 -84
- package/src/registry/lockfile.ts +0 -68
- package/src/registry/resolver.ts +0 -113
- package/src/registry/types.ts +0 -66
- package/src/run-logger.ts +0 -66
- package/src/run-store-factory.ts +0 -30
- package/src/studio-dir.ts +0 -28
- package/src/utils/input-wizard.ts +0 -73
- package/src/utils/placeholders.ts +0 -10
- package/tests/__stubs__/studio-runner.ts +0 -104
- package/tests/commands/api.test.ts +0 -110
- package/tests/commands/config.test.ts +0 -221
- package/tests/commands/init.test.ts +0 -919
- package/tests/commands/install.test.ts +0 -52
- package/tests/commands/integrations.test.ts +0 -158
- package/tests/commands/ollama.test.ts +0 -139
- package/tests/commands/project.test.ts +0 -179
- package/tests/commands/registry/audit.test.ts +0 -56
- package/tests/commands/registry/install.test.ts +0 -200
- package/tests/commands/registry/publish.test.ts +0 -56
- package/tests/commands/registry/remove.test.ts +0 -103
- package/tests/commands/registry/search.test.ts +0 -44
- package/tests/commands/registry/sync.test.ts +0 -37
- package/tests/commands/registry/update.test.ts +0 -62
- package/tests/commands/replay.test.ts +0 -283
- package/tests/commands/template/validate.test.ts +0 -150
- package/tests/commands/templates.test.ts +0 -42
- package/tests/commands/tools.test.ts +0 -106
- package/tests/config.test.ts +0 -142
- package/tests/formatter.test.ts +0 -158
- package/tests/integration/sigint.test.ts +0 -188
- package/tests/models-cache.test.ts +0 -250
- package/tests/output/file-changes.test.ts +0 -178
- package/tests/output/formatters.test.ts +0 -448
- package/tests/output/progress-spinner.test.ts +0 -232
- package/tests/output/progress-timer.test.ts +0 -230
- package/tests/provider-validator.test.ts +0 -182
- package/tests/registry/cache.test.ts +0 -66
- package/tests/registry/client.test.ts +0 -70
- package/tests/registry/lockfile.test.ts +0 -87
- package/tests/registry/resolver.test.ts +0 -122
- package/tests/run-logger-events.test.ts +0 -326
- package/tests/run-logger.test.ts +0 -64
- package/tests/run-store-factory.test.ts +0 -51
- package/tests/studio-dir.test.ts +0 -31
- package/tests/utils/input-wizard.test.ts +0 -153
- package/tests/utils/placeholders.test.ts +0 -36
- package/tsconfig.json +0 -24
- 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
|
-
});
|