@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
package/src/registry/lockfile.ts
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
-
import type { Lockfile, LockfileEntry } from './types.js';
|
|
4
|
-
|
|
5
|
-
export class RegistryLockfile {
|
|
6
|
-
private lockPath: string;
|
|
7
|
-
private studioDir: string;
|
|
8
|
-
|
|
9
|
-
constructor(studioDir: string) {
|
|
10
|
-
this.studioDir = studioDir;
|
|
11
|
-
this.lockPath = resolve(studioDir, 'registry.lock.json');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async read(): Promise<Lockfile> {
|
|
15
|
-
try {
|
|
16
|
-
const raw = await readFile(this.lockPath, 'utf8');
|
|
17
|
-
return JSON.parse(raw) as Lockfile;
|
|
18
|
-
} catch {
|
|
19
|
-
return { installed: {} };
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
private async write(data: Lockfile): Promise<void> {
|
|
24
|
-
await mkdir(this.studioDir, { recursive: true });
|
|
25
|
-
await writeFile(this.lockPath, JSON.stringify(data, null, 2) + '\n');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async add(name: string, entry: LockfileEntry): Promise<void> {
|
|
29
|
-
const data = await this.read();
|
|
30
|
-
data.installed[name] = entry;
|
|
31
|
-
await this.write(data);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
async remove(name: string): Promise<void> {
|
|
35
|
-
const data = await this.read();
|
|
36
|
-
delete data.installed[name];
|
|
37
|
-
await this.write(data);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async get(name: string): Promise<LockfileEntry | null> {
|
|
41
|
-
const data = await this.read();
|
|
42
|
-
return data.installed[name] ?? null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async list(): Promise<Array<{ name: string } & LockfileEntry>> {
|
|
46
|
-
const data = await this.read();
|
|
47
|
-
return Object.entries(data.installed).map(([name, entry]) => ({ name, ...entry }));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async addRequiredBy(name: string, requiredBy: string): Promise<void> {
|
|
51
|
-
const data = await this.read();
|
|
52
|
-
const entry = data.installed[name];
|
|
53
|
-
if (!entry) return;
|
|
54
|
-
const existing = entry.required_by ?? [];
|
|
55
|
-
if (!existing.includes(requiredBy)) {
|
|
56
|
-
data.installed[name] = { ...entry, required_by: [...existing, requiredBy] };
|
|
57
|
-
await this.write(data);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async removeRequiredBy(name: string, requiredBy: string): Promise<void> {
|
|
62
|
-
const data = await this.read();
|
|
63
|
-
const entry = data.installed[name];
|
|
64
|
-
if (!entry) return;
|
|
65
|
-
data.installed[name] = { ...entry, required_by: (entry.required_by ?? []).filter(r => r !== requiredBy) };
|
|
66
|
-
await this.write(data);
|
|
67
|
-
}
|
|
68
|
-
}
|
package/src/registry/resolver.ts
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import type { PackageMetadata, PackageType, RegistryIndex, Lockfile, PackageDependencies } from './types.js';
|
|
2
|
-
|
|
3
|
-
export interface DependencyNode {
|
|
4
|
-
name: string;
|
|
5
|
-
type: PackageType;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface ResolvedGraph {
|
|
9
|
-
required: DependencyNode[];
|
|
10
|
-
recommended: DependencyNode[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
type MetadataFetcher = (name: string) => Promise<PackageMetadata>;
|
|
14
|
-
|
|
15
|
-
/** Extract all dependency names (regardless of whether they're in the index) for cycle detection. */
|
|
16
|
-
function depNames(deps: PackageDependencies, kind: 'required' | 'recommended'): string[] {
|
|
17
|
-
const names: string[] = [];
|
|
18
|
-
for (const [, spec] of Object.entries(deps)) {
|
|
19
|
-
const arr: string[] = (spec as Record<string, string[]>)[kind] ?? [];
|
|
20
|
-
names.push(...arr);
|
|
21
|
-
}
|
|
22
|
-
return names;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function flattenDeps(
|
|
26
|
-
deps: PackageDependencies,
|
|
27
|
-
kind: 'required' | 'recommended',
|
|
28
|
-
index: RegistryIndex,
|
|
29
|
-
): DependencyNode[] {
|
|
30
|
-
const nodes: DependencyNode[] = [];
|
|
31
|
-
for (const [, spec] of Object.entries(deps)) {
|
|
32
|
-
const names: string[] = (spec as Record<string, string[]>)[kind] ?? [];
|
|
33
|
-
for (const name of names) {
|
|
34
|
-
const entry = index.packages.find(p => p.name === name);
|
|
35
|
-
if (entry) {
|
|
36
|
-
nodes.push({ name, type: entry.type as PackageType });
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return nodes;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function resolveDependencies(
|
|
44
|
-
rootPackageName: string,
|
|
45
|
-
meta: PackageMetadata,
|
|
46
|
-
index: RegistryIndex,
|
|
47
|
-
_lockfile: Lockfile,
|
|
48
|
-
fetchMeta: MetadataFetcher,
|
|
49
|
-
): Promise<ResolvedGraph> {
|
|
50
|
-
const resolved = new Map<string, DependencyNode>();
|
|
51
|
-
const visiting = new Set<string>();
|
|
52
|
-
|
|
53
|
-
// Track the root package so cycles back to it are detected
|
|
54
|
-
visiting.add(rootPackageName);
|
|
55
|
-
|
|
56
|
-
async function visit(name: string, pkgMeta: PackageMetadata): Promise<void> {
|
|
57
|
-
if (visiting.has(name)) {
|
|
58
|
-
throw new Error(`Circular dependency detected: ${name} is part of a cycle`);
|
|
59
|
-
}
|
|
60
|
-
if (resolved.has(name)) return;
|
|
61
|
-
|
|
62
|
-
visiting.add(name);
|
|
63
|
-
|
|
64
|
-
if (pkgMeta.dependencies) {
|
|
65
|
-
// Check for cycles on all required names (even those not in index)
|
|
66
|
-
for (const depName of depNames(pkgMeta.dependencies, 'required')) {
|
|
67
|
-
if (visiting.has(depName)) {
|
|
68
|
-
throw new Error(`Circular dependency detected: ${depName} is part of a cycle`);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const requiredNodes = flattenDeps(pkgMeta.dependencies, 'required', index);
|
|
73
|
-
for (const node of requiredNodes) {
|
|
74
|
-
if (!resolved.has(node.name)) {
|
|
75
|
-
const subMeta = await fetchMeta(node.name);
|
|
76
|
-
await visit(node.name, subMeta);
|
|
77
|
-
if (!resolved.has(node.name)) {
|
|
78
|
-
resolved.set(node.name, node);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
visiting.delete(name);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (meta.dependencies) {
|
|
88
|
-
// Check for cycles at the first level too (root → dep that cycles back to root)
|
|
89
|
-
for (const depName of depNames(meta.dependencies, 'required')) {
|
|
90
|
-
if (visiting.has(depName)) {
|
|
91
|
-
throw new Error(`Circular dependency detected: ${depName} is part of a cycle`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const firstLevelRequired = flattenDeps(meta.dependencies, 'required', index);
|
|
96
|
-
for (const node of firstLevelRequired) {
|
|
97
|
-
const subMeta = await fetchMeta(node.name);
|
|
98
|
-
await visit(node.name, subMeta);
|
|
99
|
-
if (!resolved.has(node.name)) {
|
|
100
|
-
resolved.set(node.name, node);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const recommended: DependencyNode[] = meta.dependencies
|
|
106
|
-
? flattenDeps(meta.dependencies, 'recommended', index)
|
|
107
|
-
: [];
|
|
108
|
-
|
|
109
|
-
return {
|
|
110
|
-
required: Array.from(resolved.values()),
|
|
111
|
-
recommended,
|
|
112
|
-
};
|
|
113
|
-
}
|
package/src/registry/types.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
export type PackageType =
|
|
2
|
-
| 'tool'
|
|
3
|
-
| 'template'
|
|
4
|
-
| 'pipeline'
|
|
5
|
-
| 'integration'
|
|
6
|
-
| 'agent'
|
|
7
|
-
| 'plugin'
|
|
8
|
-
| 'skill';
|
|
9
|
-
|
|
10
|
-
export interface PackageEntry {
|
|
11
|
-
name: string;
|
|
12
|
-
type: PackageType;
|
|
13
|
-
version: string;
|
|
14
|
-
description: string;
|
|
15
|
-
author: string;
|
|
16
|
-
license: string;
|
|
17
|
-
tags: string[];
|
|
18
|
-
studio_version: string | null;
|
|
19
|
-
downloads: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface RegistryIndex {
|
|
23
|
-
generated_at: string;
|
|
24
|
-
version: string;
|
|
25
|
-
packages: PackageEntry[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface PackageDependencies {
|
|
29
|
-
tools?: { required?: string[]; recommended?: string[] };
|
|
30
|
-
agents?: { required?: string[]; recommended?: string[] };
|
|
31
|
-
skills?: { required?: string[]; recommended?: string[] };
|
|
32
|
-
templates?: { required?: string[]; recommended?: string[] };
|
|
33
|
-
pipelines?: { required?: string[]; recommended?: string[] };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface PackageMetadata extends PackageEntry {
|
|
37
|
-
requires_binaries?: string[];
|
|
38
|
-
dependencies?: PackageDependencies;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface LockfileEntry {
|
|
42
|
-
version: string;
|
|
43
|
-
type: PackageType;
|
|
44
|
-
installed_at: string;
|
|
45
|
-
sha256: string;
|
|
46
|
-
required_by?: string[];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface Lockfile {
|
|
50
|
-
installed: Record<string, LockfileEntry>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Where packages get installed relative to the project's .studio/ dir */
|
|
54
|
-
export const INSTALL_DIRS: Record<PackageType, string> = {
|
|
55
|
-
tool: 'tools',
|
|
56
|
-
template: 'projects',
|
|
57
|
-
pipeline: 'pipelines',
|
|
58
|
-
integration: 'integrations',
|
|
59
|
-
agent: 'agents',
|
|
60
|
-
plugin: 'plugins',
|
|
61
|
-
skill: 'skills',
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export const REGISTRY_REPO = 'PipStudio/studio-community';
|
|
65
|
-
export const REGISTRY_RAW_BASE = `https://raw.githubusercontent.com/${REGISTRY_REPO}/main`;
|
|
66
|
-
export const REGISTRY_API_BASE = `https://api.github.com/repos/${REGISTRY_REPO}`;
|
package/src/run-logger.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { createWriteStream, mkdirSync } from 'node:fs';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
|
-
|
|
4
|
-
const RUNS_DIR = '.studio/runs';
|
|
5
|
-
|
|
6
|
-
function runIdShort(runId: string): string {
|
|
7
|
-
return runId.slice(0, 8);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function dateForFilename(): string {
|
|
11
|
-
const iso = new Date().toISOString();
|
|
12
|
-
return iso.slice(0, 16).replace(/(\d{2}):(\d{2})$/, '$1h$2m');
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface RunLogger {
|
|
16
|
-
start(runId: string, pipeline: string): void;
|
|
17
|
-
log(payload: Record<string, unknown>): void;
|
|
18
|
-
close(): Promise<void>;
|
|
19
|
-
getLogPath(): string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function createRunLogger(cwd: string = process.cwd()): RunLogger {
|
|
23
|
-
let logPath = '';
|
|
24
|
-
let stream: ReturnType<typeof createWriteStream> | null = null;
|
|
25
|
-
let shortRunId = '';
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
start(runId: string, pipeline: string): void {
|
|
29
|
-
shortRunId = runIdShort(runId);
|
|
30
|
-
const date = dateForFilename();
|
|
31
|
-
const base = resolve(cwd, RUNS_DIR);
|
|
32
|
-
logPath = resolve(base, `${date}-${pipeline}-${shortRunId}.jsonl`);
|
|
33
|
-
mkdirSync(base, { recursive: true });
|
|
34
|
-
stream = createWriteStream(logPath, { flags: 'a' });
|
|
35
|
-
},
|
|
36
|
-
|
|
37
|
-
log(payload: Record<string, unknown>): void {
|
|
38
|
-
const line: Record<string, unknown> = {
|
|
39
|
-
ts: new Date().toISOString(),
|
|
40
|
-
...payload,
|
|
41
|
-
run_id: payload.run_id !== undefined ? runIdShort(String(payload.run_id)) : shortRunId,
|
|
42
|
-
};
|
|
43
|
-
const out = JSON.stringify(line) + '\n';
|
|
44
|
-
if (stream?.writable) {
|
|
45
|
-
stream.write(out);
|
|
46
|
-
}
|
|
47
|
-
},
|
|
48
|
-
|
|
49
|
-
close(): Promise<void> {
|
|
50
|
-
return new Promise((resolve) => {
|
|
51
|
-
if (stream) {
|
|
52
|
-
stream.end(() => {
|
|
53
|
-
stream = null;
|
|
54
|
-
resolve();
|
|
55
|
-
});
|
|
56
|
-
} else {
|
|
57
|
-
resolve();
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
},
|
|
61
|
-
|
|
62
|
-
getLogPath(): string {
|
|
63
|
-
return logPath;
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
package/src/run-store-factory.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
|
-
import { mkdirSync } from 'node:fs';
|
|
3
|
-
import type { StudioConfig } from './config.js';
|
|
4
|
-
import { SQLiteRunStore, InMemoryRunStore, PgRunStore } from '@studio-foundation/engine';
|
|
5
|
-
import type { AnyRunStore } from '@studio-foundation/engine';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Create the production RunStore from config.
|
|
9
|
-
* Returns AnyRunStore (sync RunStore or async AsyncRunStore) based on config.db.type.
|
|
10
|
-
* Defaults to SQLite when no db config is present.
|
|
11
|
-
*/
|
|
12
|
-
export async function createRunStore(config: StudioConfig): Promise<AnyRunStore> {
|
|
13
|
-
const dbType = config.db?.type ?? 'sqlite';
|
|
14
|
-
|
|
15
|
-
if (dbType === 'inmemory') {
|
|
16
|
-
return new InMemoryRunStore();
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (dbType === 'postgres') {
|
|
20
|
-
const url = config.db?.url;
|
|
21
|
-
if (!url) throw new Error('db.url is required when db.type is postgres');
|
|
22
|
-
return new PgRunStore(url);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Default: sqlite
|
|
26
|
-
const studioDir = config.resolvedStudioDir ?? join(process.cwd(), '.studio');
|
|
27
|
-
mkdirSync(join(studioDir, 'runs'), { recursive: true });
|
|
28
|
-
const dbPath = join(studioDir, 'runs', 'runs.db');
|
|
29
|
-
return new SQLiteRunStore(dbPath);
|
|
30
|
-
}
|
package/src/studio-dir.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { access } from 'node:fs/promises';
|
|
2
|
-
import { dirname, join, resolve } from 'node:path';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Walk up the directory tree from `startDir` looking for a `.studio/` directory.
|
|
6
|
-
* Like git looking for `.git/`.
|
|
7
|
-
* Returns the absolute path to `.studio/`, or null if not found.
|
|
8
|
-
*/
|
|
9
|
-
export async function findStudioDir(startDir: string): Promise<string | null> {
|
|
10
|
-
let current = resolve(startDir);
|
|
11
|
-
|
|
12
|
-
while (true) {
|
|
13
|
-
const candidate = join(current, '.studio');
|
|
14
|
-
try {
|
|
15
|
-
await access(candidate);
|
|
16
|
-
return candidate;
|
|
17
|
-
} catch {
|
|
18
|
-
// not found here, go up
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const parent = dirname(current);
|
|
22
|
-
if (parent === current) {
|
|
23
|
-
// Reached filesystem root
|
|
24
|
-
return null;
|
|
25
|
-
}
|
|
26
|
-
current = parent;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { input } from '@inquirer/prompts';
|
|
2
|
-
import type { InputSchema } from '@studio-foundation/contracts';
|
|
3
|
-
|
|
4
|
-
export function validateInputSchema(raw: unknown): InputSchema {
|
|
5
|
-
const schema = raw as Record<string, unknown>;
|
|
6
|
-
|
|
7
|
-
if (!Array.isArray(schema?.fields) || schema.fields.length === 0) {
|
|
8
|
-
throw new Error('input_schema must have at least one field');
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
for (const field of schema.fields as any[]) {
|
|
12
|
-
const name = field.name ?? '(unnamed)';
|
|
13
|
-
|
|
14
|
-
if (!field.prompt || typeof field.prompt !== 'string' || !field.prompt.trim()) {
|
|
15
|
-
throw new Error(`Field '${name}' must have a non-empty 'prompt'`);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
if (field.type !== 'text' && field.type !== 'array') {
|
|
19
|
-
throw new Error(
|
|
20
|
-
`Field '${name}' has invalid type '${field.type}'. Must be 'text' or 'array'`
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (field.type === 'array' && field.items !== 'text') {
|
|
25
|
-
throw new Error(`Array field '${name}' must have items: 'text'`);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return raw as InputSchema;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function collectStructuredInput(
|
|
33
|
-
schema: InputSchema
|
|
34
|
-
): Promise<Record<string, unknown>> {
|
|
35
|
-
const result: Record<string, unknown> = {};
|
|
36
|
-
|
|
37
|
-
for (const field of schema.fields) {
|
|
38
|
-
if (field.type === 'text') {
|
|
39
|
-
result[field.name] = await input({
|
|
40
|
-
message: field.prompt,
|
|
41
|
-
required: field.required,
|
|
42
|
-
default: field.default,
|
|
43
|
-
});
|
|
44
|
-
} else if (field.type === 'array') {
|
|
45
|
-
const items: string[] = [];
|
|
46
|
-
let index = 1;
|
|
47
|
-
|
|
48
|
-
while (true) {
|
|
49
|
-
const value = await input({
|
|
50
|
-
message: `${field.prompt} (${index})`,
|
|
51
|
-
required: index === 1 && field.required,
|
|
52
|
-
default: '',
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
if (value === '') {
|
|
56
|
-
if (index === 1 && field.required) {
|
|
57
|
-
console.log('At least one value required.');
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
items.push(value);
|
|
64
|
-
index++;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
result[field.name] = items;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
console.log('\n✓ Input collected\n');
|
|
72
|
-
return result;
|
|
73
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Replace {{ALL_CAPS}} placeholders in `content` with values from `vars`.
|
|
3
|
-
* Throws if any placeholder has no corresponding entry in `vars`.
|
|
4
|
-
*/
|
|
5
|
-
export function applyPlaceholders(content: string, vars: Record<string, string>): string {
|
|
6
|
-
return content.replace(/\{\{([A-Z][A-Z0-9_]*)\}\}/g, (match, key) => {
|
|
7
|
-
if (key in vars) return vars[key];
|
|
8
|
-
throw new Error(`Unresolved placeholder: ${match}`);
|
|
9
|
-
});
|
|
10
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stub for @studio/runner used in tests when the package has not been built.
|
|
3
|
-
* Reads from the actual runner template files so tool/integration tests work correctly.
|
|
4
|
-
*/
|
|
5
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
6
|
-
import { resolve } from 'node:path';
|
|
7
|
-
import yaml from 'js-yaml';
|
|
8
|
-
|
|
9
|
-
const RUNNER_TEMPLATES = resolve(import.meta.dirname, '../../../runner/templates');
|
|
10
|
-
const TOOL_TEMPLATES_DIR = resolve(RUNNER_TEMPLATES, 'tools');
|
|
11
|
-
const INTEGRATION_TEMPLATES_DIR = resolve(RUNNER_TEMPLATES, 'integrations');
|
|
12
|
-
|
|
13
|
-
// ── Tool functions ────────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
export async function listAvailableToolTemplates(): Promise<{ name: string; description: string }[]> {
|
|
16
|
-
let files: string[];
|
|
17
|
-
try {
|
|
18
|
-
files = (await readdir(TOOL_TEMPLATES_DIR)).filter(f => f.endsWith('.tool.yaml')).sort();
|
|
19
|
-
} catch {
|
|
20
|
-
return [];
|
|
21
|
-
}
|
|
22
|
-
const result: { name: string; description: string }[] = [];
|
|
23
|
-
for (const file of files) {
|
|
24
|
-
const content = await readFile(resolve(TOOL_TEMPLATES_DIR, file), 'utf-8');
|
|
25
|
-
const def = yaml.load(content) as { description?: string };
|
|
26
|
-
result.push({ name: file.replace('.tool.yaml', ''), description: def.description ?? '' });
|
|
27
|
-
}
|
|
28
|
-
return result;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function getBundledToolTemplate(name: string): Promise<string | null> {
|
|
32
|
-
const filePath = resolve(TOOL_TEMPLATES_DIR, `${name}.tool.yaml`);
|
|
33
|
-
try {
|
|
34
|
-
return await readFile(filePath, 'utf-8');
|
|
35
|
-
} catch {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export const BUILTIN_TOOL_NAMES = new Set(['repo-manager', 'shell', 'search', 'git']);
|
|
41
|
-
|
|
42
|
-
// ── Integration functions ─────────────────────────────────────────────────────
|
|
43
|
-
|
|
44
|
-
export async function getBundledIntegrationTemplate(name: string): Promise<string | null> {
|
|
45
|
-
const filePath = resolve(INTEGRATION_TEMPLATES_DIR, `${name}.integration.yaml`);
|
|
46
|
-
try {
|
|
47
|
-
return await readFile(filePath, 'utf-8');
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function listAvailableIntegrationTemplates(): Promise<{ name: string; description: string }[]> {
|
|
54
|
-
let files: string[];
|
|
55
|
-
try {
|
|
56
|
-
files = (await readdir(INTEGRATION_TEMPLATES_DIR)).filter(f => f.endsWith('.integration.yaml')).sort();
|
|
57
|
-
} catch {
|
|
58
|
-
return [];
|
|
59
|
-
}
|
|
60
|
-
const result: { name: string; description: string }[] = [];
|
|
61
|
-
for (const file of files) {
|
|
62
|
-
const content = await readFile(resolve(INTEGRATION_TEMPLATES_DIR, file), 'utf-8');
|
|
63
|
-
const def = yaml.load(content) as { description?: string };
|
|
64
|
-
result.push({ name: file.replace('.integration.yaml', ''), description: def.description ?? '' });
|
|
65
|
-
}
|
|
66
|
-
return result;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export async function loadProjectIntegrations(): Promise<unknown[]> {
|
|
70
|
-
return [];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ── Runtime stubs (used by run.ts — only needs to be importable) ──────────────
|
|
74
|
-
|
|
75
|
-
export function createDefaultRegistry(..._args: unknown[]): unknown {
|
|
76
|
-
return {};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export class ToolRegistry {
|
|
80
|
-
addTool(..._args: unknown[]): void {}
|
|
81
|
-
getTools(): unknown[] { return []; }
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function createRepoManagerTools(..._args: unknown[]): unknown[] {
|
|
85
|
-
return [];
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function loadProjectTools(..._args: unknown[]): Promise<unknown[]> {
|
|
89
|
-
return [];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export async function loadPlugins(..._args: unknown[]): Promise<unknown[]> {
|
|
93
|
-
return [];
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export class MCPClient {
|
|
97
|
-
constructor(..._args: unknown[]) {}
|
|
98
|
-
async connect(): Promise<void> {}
|
|
99
|
-
async disconnect(): Promise<void> {}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export class StudioOAuthProvider {
|
|
103
|
-
constructor(..._args: unknown[]) {}
|
|
104
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
-
import { writeFile, rm } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
|
|
6
|
-
// Use a unique fake home dir so tests don't collide with other test files using /tmp
|
|
7
|
-
const FAKE_HOME = join(tmpdir(), `.studio-api-test-${process.pid}`);
|
|
8
|
-
|
|
9
|
-
vi.mock('node:os', async (importOriginal) => {
|
|
10
|
-
const actual = await importOriginal<typeof import('node:os')>();
|
|
11
|
-
return { ...actual, homedir: () => FAKE_HOME };
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
// Import after mocking
|
|
15
|
-
const { apiStopCommand, apiStatusCommand, writePid, readPid, clearPid, isProcessAlive } = await import('../../src/commands/api.js');
|
|
16
|
-
|
|
17
|
-
afterEach(async () => {
|
|
18
|
-
await clearPid();
|
|
19
|
-
// Remove the .studio dir created by writePid
|
|
20
|
-
await rm(join(FAKE_HOME, '.studio'), { recursive: true, force: true });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe('writePid / readPid / clearPid', () => {
|
|
24
|
-
it('writes and reads PID with port', async () => {
|
|
25
|
-
await writePid(3700);
|
|
26
|
-
const entry = await readPid();
|
|
27
|
-
expect(entry).not.toBeNull();
|
|
28
|
-
expect(entry!.pid).toBe(process.pid);
|
|
29
|
-
expect(entry!.port).toBe(3700);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('readPid returns null when no file exists', async () => {
|
|
33
|
-
const entry = await readPid();
|
|
34
|
-
expect(entry).toBeNull();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('clearPid removes the file', async () => {
|
|
38
|
-
await writePid(3700);
|
|
39
|
-
await clearPid();
|
|
40
|
-
const entry = await readPid();
|
|
41
|
-
expect(entry).toBeNull();
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe('isProcessAlive', () => {
|
|
46
|
-
it('returns true for the current process', () => {
|
|
47
|
-
expect(isProcessAlive(process.pid)).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('returns false for a non-existent PID', () => {
|
|
51
|
-
// PID 999999999 is very unlikely to exist
|
|
52
|
-
expect(isProcessAlive(999999999)).toBe(false);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe('apiStopCommand', () => {
|
|
57
|
-
it('prints "not running" when no PID file', async () => {
|
|
58
|
-
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
59
|
-
await apiStopCommand();
|
|
60
|
-
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
|
61
|
-
consoleSpy.mockRestore();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('prints "stale PID" and clears file when process is dead', async () => {
|
|
65
|
-
await writePid(3700);
|
|
66
|
-
// Overwrite with a dead PID
|
|
67
|
-
const pidFile = join(FAKE_HOME, '.studio', 'api.pid');
|
|
68
|
-
await writeFile(pidFile, '999999999:3700', 'utf-8');
|
|
69
|
-
|
|
70
|
-
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
71
|
-
await apiStopCommand();
|
|
72
|
-
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('stale'));
|
|
73
|
-
expect(await readPid()).toBeNull();
|
|
74
|
-
consoleSpy.mockRestore();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('sends SIGTERM and clears PID when process is the current process', async () => {
|
|
78
|
-
await writePid(3700);
|
|
79
|
-
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
80
|
-
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
81
|
-
|
|
82
|
-
await apiStopCommand();
|
|
83
|
-
|
|
84
|
-
expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM');
|
|
85
|
-
expect(await readPid()).toBeNull();
|
|
86
|
-
killSpy.mockRestore();
|
|
87
|
-
consoleSpy.mockRestore();
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe('apiStatusCommand', () => {
|
|
92
|
-
it('prints "not running" when no PID file', async () => {
|
|
93
|
-
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
94
|
-
await apiStatusCommand({});
|
|
95
|
-
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not running'));
|
|
96
|
-
consoleSpy.mockRestore();
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('prints "running" when process is alive and health check succeeds', async () => {
|
|
100
|
-
await writePid(3700);
|
|
101
|
-
// Override with current PID (alive) and mock fetch
|
|
102
|
-
global.fetch = vi.fn().mockResolvedValue({ ok: true }) as unknown as typeof fetch;
|
|
103
|
-
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
104
|
-
|
|
105
|
-
await apiStatusCommand({});
|
|
106
|
-
|
|
107
|
-
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('running'));
|
|
108
|
-
consoleSpy.mockRestore();
|
|
109
|
-
});
|
|
110
|
-
});
|