octo-dev 0.4.3 → 0.5.2
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 +4 -2
- package/src/build/build-orchestrator.ts +3 -3
- package/src/cli/add.command.ts +3 -3
- package/src/cli/build.command.ts +4 -4
- package/src/cli/down.command.ts +1 -1
- package/src/cli/graph.command.ts +2 -2
- package/src/cli/index.ts +12 -12
- package/src/cli/init.command.ts +2 -5
- package/src/cli/status.command.ts +1 -1
- package/src/cli/up.command.ts +2 -2
- package/src/infra/compose-smart-merger.ts +14 -31
- package/src/infra/infra-manager.ts +4 -4
- package/src/manifest/manifest-mutator.ts +1 -1
- package/src/shared/git.ts +4 -4
- package/src/shared/llm.ts +83 -0
- package/src/shared/logger.ts +10 -12
- package/src/shared/shutdown.ts +2 -2
- package/src/version/changelog-generator.ts +3 -11
- package/src/version/version-propagator.ts +3 -3
- package/src/shared/ollama.ts +0 -96
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "octo-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "CLI for monorepo build orchestration, semantic versioning, and local infrastructure management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,9 @@
|
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"commander": "^13.1.0",
|
|
47
47
|
"execa": "^9.6.1",
|
|
48
|
-
"
|
|
48
|
+
"@huggingface/transformers": "^4.2.0",
|
|
49
|
+
"pino": "^10.3.1",
|
|
50
|
+
"pino-pretty": "^13.1.3",
|
|
49
51
|
"semver": "^7.7.2",
|
|
50
52
|
"tsx": "^4.19.4",
|
|
51
53
|
"yaml": "^2.7.1",
|
|
@@ -48,13 +48,13 @@ export function createBuildOrchestrator(): BuildOrchestrator {
|
|
|
48
48
|
const inProgress = lastProgress.filter((p) => p.status === 'building');
|
|
49
49
|
const pending = lastProgress.filter((p) => p.status === 'pending');
|
|
50
50
|
logger.info(
|
|
51
|
-
`
|
|
51
|
+
`Partial state: ${completed.length} completed, ${inProgress.length} in progress, ${pending.length} pending`,
|
|
52
52
|
);
|
|
53
53
|
if (completed.length > 0) {
|
|
54
|
-
logger.info(`
|
|
54
|
+
logger.info(` Completed: ${completed.map((p) => p.service).join(', ')}`);
|
|
55
55
|
}
|
|
56
56
|
if (inProgress.length > 0) {
|
|
57
|
-
logger.info(`
|
|
57
|
+
logger.info(` In progress: ${inProgress.map((p) => p.service).join(', ')}`);
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
});
|
package/src/cli/add.command.ts
CHANGED
|
@@ -22,7 +22,7 @@ export async function addCommand(repoUrl: string, opts: AddCommandOptions): Prom
|
|
|
22
22
|
const targetDir = resolve(rootDir, dirName);
|
|
23
23
|
|
|
24
24
|
if (existsSync(targetDir)) {
|
|
25
|
-
throw new OctoError(`
|
|
25
|
+
throw new OctoError(`Directory already exists: ${dirName}. Use --name to specify another name.`);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
await cloneRepository(repoUrl, targetDir);
|
|
@@ -36,10 +36,10 @@ export async function addCommand(repoUrl: string, opts: AddCommandOptions): Prom
|
|
|
36
36
|
const added = addEntry(manifest, projectName, dirName, type);
|
|
37
37
|
|
|
38
38
|
if (!added) {
|
|
39
|
-
logger.info(`
|
|
39
|
+
logger.info(`Project "${projectName}" is already registered in octo.yaml.`);
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
saveManifest(manifestPath, manifest, originalContent);
|
|
44
|
-
logger.info(`
|
|
44
|
+
logger.info(`Project "${projectName}" added as ${type} in octo.yaml.`);
|
|
45
45
|
}
|
package/src/cli/build.command.ts
CHANGED
|
@@ -24,7 +24,7 @@ export async function buildCommand(service?: string, opts?: { affected?: boolean
|
|
|
24
24
|
try {
|
|
25
25
|
content = readFileSync(manifestPath, 'utf-8');
|
|
26
26
|
} catch {
|
|
27
|
-
throw new OctoError('octo.yaml
|
|
27
|
+
throw new OctoError('octo.yaml not found. Run `octo init` first.');
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const parsed = parseManifest(content, manifestPath);
|
|
@@ -48,11 +48,11 @@ export async function buildCommand(service?: string, opts?: { affected?: boolean
|
|
|
48
48
|
const affected = await detector.detect(graph, rootDir);
|
|
49
49
|
|
|
50
50
|
if (affected.length === 0) {
|
|
51
|
-
logger.info('
|
|
51
|
+
logger.info('No affected services detected.');
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
logger.info(`
|
|
55
|
+
logger.info(`Affected services: ${affected.join(', ')}`);
|
|
56
56
|
result = await orchestrator.buildTargets(affected, graph, registry, buildOptions);
|
|
57
57
|
|
|
58
58
|
if (result.success) {
|
|
@@ -67,7 +67,7 @@ export async function buildCommand(service?: string, opts?: { affected?: boolean
|
|
|
67
67
|
|
|
68
68
|
if (!allNames.includes(service)) {
|
|
69
69
|
throw new OctoError(
|
|
70
|
-
`
|
|
70
|
+
`Service "${service}" not found in manifest.\nAvailable: ${allNames.join(', ')}`,
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
package/src/cli/down.command.ts
CHANGED
|
@@ -14,7 +14,7 @@ export async function downCommand(opts: { volumes?: boolean }): Promise<void> {
|
|
|
14
14
|
try {
|
|
15
15
|
content = readFileSync(manifestPath, 'utf-8');
|
|
16
16
|
} catch {
|
|
17
|
-
throw new OctoError('octo.yaml
|
|
17
|
+
throw new OctoError('octo.yaml not found. Run `octo init` first.');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const parsed = parseManifest(content, manifestPath);
|
package/src/cli/graph.command.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { buildGraphFromManifest } from '../graph/build-graph.js';
|
|
|
5
5
|
import { OctoError } from '../shared/errors.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* `octo graph` —
|
|
8
|
+
* `octo graph` — displays the dependency graph on stdout as an indented adjacency list.
|
|
9
9
|
*/
|
|
10
10
|
export async function graphCommand(): Promise<void> {
|
|
11
11
|
const cwd = process.cwd();
|
|
@@ -15,7 +15,7 @@ export async function graphCommand(): Promise<void> {
|
|
|
15
15
|
try {
|
|
16
16
|
content = readFileSync(manifestPath, 'utf-8');
|
|
17
17
|
} catch {
|
|
18
|
-
throw new OctoError(`
|
|
18
|
+
throw new OctoError(`Could not read ${manifestPath}. Run "octo init" first.`);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const result = parseManifest(content, manifestPath);
|
package/src/cli/index.ts
CHANGED
|
@@ -11,12 +11,12 @@ const program = new Command();
|
|
|
11
11
|
program
|
|
12
12
|
.name('octo')
|
|
13
13
|
.description('Monorepo build orchestration, versioning, and infrastructure CLI')
|
|
14
|
-
.version('0.
|
|
14
|
+
.version('0.5.2');
|
|
15
15
|
|
|
16
16
|
program
|
|
17
17
|
.command('init')
|
|
18
|
-
.description('
|
|
19
|
-
.option('--standalone', '
|
|
18
|
+
.description('Scan the monorepo and generate octo.yaml')
|
|
19
|
+
.option('--standalone', 'Generate manifest for the current project only')
|
|
20
20
|
.action(async (opts) => {
|
|
21
21
|
const { initCommand } = await import('./init.command.js');
|
|
22
22
|
await initCommand(opts);
|
|
@@ -24,7 +24,7 @@ program
|
|
|
24
24
|
|
|
25
25
|
program
|
|
26
26
|
.command('graph')
|
|
27
|
-
.description('
|
|
27
|
+
.description('Display the dependency graph')
|
|
28
28
|
.action(async () => {
|
|
29
29
|
const { graphCommand } = await import('./graph.command.js');
|
|
30
30
|
await graphCommand();
|
|
@@ -32,8 +32,8 @@ program
|
|
|
32
32
|
|
|
33
33
|
program
|
|
34
34
|
.command('build [service]')
|
|
35
|
-
.description('
|
|
36
|
-
.option('--affected', '
|
|
35
|
+
.description('Build monorepo services')
|
|
36
|
+
.option('--affected', 'Build only affected services')
|
|
37
37
|
.action(async (service, opts) => {
|
|
38
38
|
const { buildCommand } = await import('./build.command.js');
|
|
39
39
|
await buildCommand(service, opts);
|
|
@@ -54,7 +54,7 @@ program
|
|
|
54
54
|
|
|
55
55
|
program
|
|
56
56
|
.command('up [service]')
|
|
57
|
-
.description('
|
|
57
|
+
.description('Start local infrastructure')
|
|
58
58
|
.action(async (service) => {
|
|
59
59
|
const { upCommand } = await import('./up.command.js');
|
|
60
60
|
await upCommand(service);
|
|
@@ -62,8 +62,8 @@ program
|
|
|
62
62
|
|
|
63
63
|
program
|
|
64
64
|
.command('down')
|
|
65
|
-
.description('
|
|
66
|
-
.option('--volumes', 'Remove volumes
|
|
65
|
+
.description('Stop local infrastructure')
|
|
66
|
+
.option('--volumes', 'Remove volumes as well')
|
|
67
67
|
.action(async (opts) => {
|
|
68
68
|
const { downCommand } = await import('./down.command.js');
|
|
69
69
|
await downCommand(opts);
|
|
@@ -71,7 +71,7 @@ program
|
|
|
71
71
|
|
|
72
72
|
program
|
|
73
73
|
.command('status')
|
|
74
|
-
.description('
|
|
74
|
+
.description('Show container status')
|
|
75
75
|
.action(async () => {
|
|
76
76
|
const { statusCommand } = await import('./status.command.js');
|
|
77
77
|
await statusCommand();
|
|
@@ -79,8 +79,8 @@ program
|
|
|
79
79
|
|
|
80
80
|
program
|
|
81
81
|
.command('add <repo-url>')
|
|
82
|
-
.description('
|
|
83
|
-
.option('--name <name>', '
|
|
82
|
+
.description('Clone a repository and register it in octo.yaml')
|
|
83
|
+
.option('--name <name>', 'Custom name for the cloned directory')
|
|
84
84
|
.action(async (repoUrl, opts) => {
|
|
85
85
|
const { addCommand } = await import('./add.command.js');
|
|
86
86
|
await addCommand(repoUrl, opts);
|
package/src/cli/init.command.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { readdir, readFile, access, writeFile } from 'node:fs/promises';
|
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
import { logger } from '../shared/logger.js';
|
|
4
4
|
import { printManifest } from '../manifest/manifest-printer.js';
|
|
5
|
-
import { ensureOllamaSetup } from '../shared/ollama.js';
|
|
6
5
|
import type { OctoManifest } from '../manifest/manifest-schema.js';
|
|
7
6
|
|
|
8
7
|
const EXCLUDED_DIRS = new Set(['node_modules', 'dist']);
|
|
@@ -67,8 +66,6 @@ async function scanDirectory(
|
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
export async function initCommand(opts: { standalone?: boolean }): Promise<void> {
|
|
70
|
-
await ensureOllamaSetup();
|
|
71
|
-
|
|
72
69
|
const rootDir = process.cwd();
|
|
73
70
|
const results: DiscoveredProject[] = [];
|
|
74
71
|
|
|
@@ -91,7 +88,7 @@ export async function initCommand(opts: { standalone?: boolean }): Promise<void>
|
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
if (results.length === 0) {
|
|
94
|
-
logger.info('
|
|
91
|
+
logger.info('No package.json found. Creating empty octo.yaml.');
|
|
95
92
|
}
|
|
96
93
|
|
|
97
94
|
const services = results.filter((p) => p.hasDockerfile).map((p) => p.name);
|
|
@@ -106,5 +103,5 @@ export async function initCommand(opts: { standalone?: boolean }): Promise<void>
|
|
|
106
103
|
const outputPath = join(rootDir, 'octo.yaml');
|
|
107
104
|
await writeFile(outputPath, yaml, 'utf-8');
|
|
108
105
|
|
|
109
|
-
logger.info(`octo.yaml
|
|
106
|
+
logger.info(`octo.yaml generated with ${services.length} service(s)${packages.length > 0 ? ` and ${packages.length} package(s)` : ''}.`);
|
|
110
107
|
}
|
|
@@ -14,7 +14,7 @@ export async function statusCommand(): Promise<void> {
|
|
|
14
14
|
try {
|
|
15
15
|
content = readFileSync(manifestPath, 'utf-8');
|
|
16
16
|
} catch {
|
|
17
|
-
throw new OctoError('octo.yaml
|
|
17
|
+
throw new OctoError('octo.yaml not found. Run `octo init` first.');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const parsed = parseManifest(content, manifestPath);
|
package/src/cli/up.command.ts
CHANGED
|
@@ -14,7 +14,7 @@ export async function upCommand(service?: string): Promise<void> {
|
|
|
14
14
|
try {
|
|
15
15
|
content = readFileSync(manifestPath, 'utf-8');
|
|
16
16
|
} catch {
|
|
17
|
-
throw new OctoError('octo.yaml
|
|
17
|
+
throw new OctoError('octo.yaml not found. Run `octo init` first.');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
const parsed = parseManifest(content, manifestPath);
|
|
@@ -28,7 +28,7 @@ export async function upCommand(service?: string): Promise<void> {
|
|
|
28
28
|
const node = graph.getNode(service);
|
|
29
29
|
if (!node) {
|
|
30
30
|
const available = graph.getNodeNames().join(', ');
|
|
31
|
-
throw new OctoError(`
|
|
31
|
+
throw new OctoError(`Service "${service}" not found. Available: ${available}`);
|
|
32
32
|
}
|
|
33
33
|
// Include service + its dependencies
|
|
34
34
|
const deps = graph.getDependencies(service);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Ollama } from 'ollama';
|
|
2
1
|
import { z } from 'zod';
|
|
2
|
+
import { generateJSON, isAvailable } from '../shared/llm.js';
|
|
3
3
|
import { createComposeAggregator, type DiscoveredCompose, type MergedCompose } from './compose-aggregator.js';
|
|
4
4
|
import { logger } from '../shared/logger.js';
|
|
5
5
|
|
|
@@ -14,7 +14,7 @@ export interface ComposeSmartMerger {
|
|
|
14
14
|
deduplicate(composes: DiscoveredCompose[]): Promise<MergedCompose>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** Build the structured prompt
|
|
17
|
+
/** Build the structured prompt */
|
|
18
18
|
function buildPrompt(composes: DiscoveredCompose[]): string {
|
|
19
19
|
const composesText = composes
|
|
20
20
|
.map((c) => `--- ${c.serviceName} (${c.path}) ---\n${JSON.stringify(c.content, null, 2)}`)
|
|
@@ -36,18 +36,7 @@ Return ONLY valid JSON with this exact structure:
|
|
|
36
36
|
{"services": {...}, "networks": {...}, "volumes": {...}}`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** Check if Ollama is available with phi4 model */
|
|
40
|
-
async function isOllamaAvailable(client: Ollama): Promise<boolean> {
|
|
41
|
-
try {
|
|
42
|
-
const models = await client.list();
|
|
43
|
-
return models.models.some((m) => m.name.startsWith('phi4'));
|
|
44
|
-
} catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
39
|
export function createComposeSmartMerger(): ComposeSmartMerger {
|
|
50
|
-
const client = new Ollama();
|
|
51
40
|
const aggregator = createComposeAggregator();
|
|
52
41
|
|
|
53
42
|
return {
|
|
@@ -57,32 +46,26 @@ export function createComposeSmartMerger(): ComposeSmartMerger {
|
|
|
57
46
|
}
|
|
58
47
|
|
|
59
48
|
// Try LLM-based merge
|
|
60
|
-
if (await
|
|
49
|
+
if (await isAvailable()) {
|
|
61
50
|
try {
|
|
62
|
-
logger.info('
|
|
51
|
+
logger.info('Using local AI for smart compose merge...');
|
|
63
52
|
const prompt = buildPrompt(composes);
|
|
64
|
-
const
|
|
65
|
-
model: 'phi4',
|
|
66
|
-
prompt,
|
|
67
|
-
format: 'json',
|
|
68
|
-
stream: false,
|
|
69
|
-
});
|
|
53
|
+
const parsed = await generateJSON(prompt);
|
|
70
54
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
if (parsed) {
|
|
56
|
+
const validated = MergedComposeSchema.safeParse(parsed);
|
|
57
|
+
if (validated.success) {
|
|
58
|
+
logger.info('Smart merge completed successfully.');
|
|
59
|
+
return validated.data;
|
|
60
|
+
}
|
|
61
|
+
logger.warn('AI output failed validation. Using deterministic fallback.');
|
|
77
62
|
}
|
|
78
|
-
|
|
79
|
-
logger.warn('Output da LLM falhou na validação Zod. Usando fallback determinístico.');
|
|
80
63
|
} catch (err) {
|
|
81
64
|
const msg = err instanceof Error ? err.message : String(err);
|
|
82
|
-
logger.warn(`
|
|
65
|
+
logger.warn(`AI error: ${msg}. Using deterministic fallback.`);
|
|
83
66
|
}
|
|
84
67
|
} else {
|
|
85
|
-
logger.info('
|
|
68
|
+
logger.info('Local AI unavailable. Using deterministic merge.');
|
|
86
69
|
}
|
|
87
70
|
|
|
88
71
|
// Fallback: deterministic merge
|
|
@@ -58,7 +58,7 @@ async function waitForHealthcheck(containerName: string): Promise<boolean> {
|
|
|
58
58
|
async function showContainerLogs(containerName: string): Promise<void> {
|
|
59
59
|
const result = await run('docker', ['logs', '--tail', '20', containerName]);
|
|
60
60
|
const output = result.stdout || result.stderr;
|
|
61
|
-
if (output) logger.error(
|
|
61
|
+
if (output) logger.error(`Last 20 lines of log (${containerName}):\n${output}`);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function createInfraManager(servicePaths: string[]): InfraManager {
|
|
@@ -77,11 +77,11 @@ export function createInfraManager(servicePaths: string[]): InfraManager {
|
|
|
77
77
|
return { success: true, message: 'Nenhum docker-compose.yml encontrado.' };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
logger.info(`
|
|
80
|
+
logger.info(`Discovered ${discovered.length} compose file(s). Merging...`);
|
|
81
81
|
const merged = await smartMerger.deduplicate(discovered);
|
|
82
82
|
composePath = await writeTempCompose(merged);
|
|
83
83
|
|
|
84
|
-
logger.info('
|
|
84
|
+
logger.info('Starting containers...');
|
|
85
85
|
const result = await run('docker', ['compose', '-f', composePath, 'up', '-d']);
|
|
86
86
|
if (result.exitCode !== 0) {
|
|
87
87
|
return { success: false, message: `docker compose up falhou: ${result.stderr}` };
|
|
@@ -92,7 +92,7 @@ export function createInfraManager(servicePaths: string[]): InfraManager {
|
|
|
92
92
|
for (const svc of serviceNames) {
|
|
93
93
|
const healthy = await waitForHealthcheck(svc);
|
|
94
94
|
if (!healthy) {
|
|
95
|
-
logger.error(`Healthcheck timeout
|
|
95
|
+
logger.error(`Healthcheck timeout for container "${svc}".`);
|
|
96
96
|
await showContainerLogs(svc);
|
|
97
97
|
// Stop dependents but don't tear down everything
|
|
98
98
|
return { success: false, message: `Healthcheck timeout: ${svc}` };
|
|
@@ -19,7 +19,7 @@ export function loadOrCreateManifest(manifestPath: string): LoadedManifest {
|
|
|
19
19
|
if (parsed.ok) {
|
|
20
20
|
return { manifest: parsed.value, originalContent: content };
|
|
21
21
|
}
|
|
22
|
-
throw new OctoError(`octo.yaml
|
|
22
|
+
throw new OctoError(`Invalid octo.yaml: ${parsed.error.message}`);
|
|
23
23
|
}
|
|
24
24
|
return { manifest: { services: [] } };
|
|
25
25
|
}
|
package/src/shared/git.ts
CHANGED
|
@@ -15,7 +15,7 @@ export function extractRepoName(url: string): string {
|
|
|
15
15
|
const parts = cleaned.split(/[/:]/);
|
|
16
16
|
const name = parts[parts.length - 1];
|
|
17
17
|
if (!name) {
|
|
18
|
-
throw new OctoError(`
|
|
18
|
+
throw new OctoError(`Could not extract repository name from: ${url}`);
|
|
19
19
|
}
|
|
20
20
|
return name;
|
|
21
21
|
}
|
|
@@ -24,7 +24,7 @@ export function extractRepoName(url: string): string {
|
|
|
24
24
|
* Clones a git repository into the target directory.
|
|
25
25
|
*/
|
|
26
26
|
export async function cloneRepository(url: string, targetDir: string): Promise<void> {
|
|
27
|
-
logger.info(`
|
|
27
|
+
logger.info(`Cloning ${url}...`);
|
|
28
28
|
|
|
29
29
|
const result = await run('git', ['clone', url, targetDir], {
|
|
30
30
|
timeout: 120_000,
|
|
@@ -32,8 +32,8 @@ export async function cloneRepository(url: string, targetDir: string): Promise<v
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
if (result.exitCode !== 0) {
|
|
35
|
-
throw new OctoError(
|
|
35
|
+
throw new OctoError('Failed to clone repository.');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
logger.info(`
|
|
38
|
+
logger.info(`Repository cloned to ./${basename(targetDir)}`);
|
|
39
39
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { pipeline, type TextGenerationPipeline } from '@huggingface/transformers';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
|
|
4
|
+
const MODEL_ID = 'onnx-community/Qwen2.5-0.5B-Instruct';
|
|
5
|
+
|
|
6
|
+
let generator: TextGenerationPipeline | null = null;
|
|
7
|
+
let initFailed = false;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lazily initializes the text generation pipeline.
|
|
11
|
+
* Downloads the ONNX model on first use (~500MB, cached locally).
|
|
12
|
+
*/
|
|
13
|
+
async function getGenerator(): Promise<TextGenerationPipeline | null> {
|
|
14
|
+
if (initFailed) return null;
|
|
15
|
+
if (generator) return generator;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
logger.info('Loading local AI model (first run may take a while)...');
|
|
19
|
+
generator = await pipeline('text-generation', MODEL_ID, {
|
|
20
|
+
dtype: 'q4',
|
|
21
|
+
}) as TextGenerationPipeline;
|
|
22
|
+
return generator;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
initFailed = true;
|
|
25
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
26
|
+
logger.info(`Local AI model unavailable: ${msg}. Using deterministic fallback.`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generates text from a prompt using the local ONNX model.
|
|
33
|
+
* Returns null if the model is unavailable or generation fails.
|
|
34
|
+
*/
|
|
35
|
+
export async function generate(prompt: string, maxTokens = 512): Promise<string | null> {
|
|
36
|
+
const gen = await getGenerator();
|
|
37
|
+
if (!gen) return null;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const messages = [
|
|
41
|
+
{ role: 'user', content: prompt },
|
|
42
|
+
];
|
|
43
|
+
const result = await gen(messages, {
|
|
44
|
+
max_new_tokens: maxTokens,
|
|
45
|
+
do_sample: false,
|
|
46
|
+
});
|
|
47
|
+
const output = result[0]?.generated_text;
|
|
48
|
+
if (Array.isArray(output)) {
|
|
49
|
+
const last = output[output.length - 1];
|
|
50
|
+
return typeof last === 'object' && 'content' in last ? String(last.content) : null;
|
|
51
|
+
}
|
|
52
|
+
return typeof output === 'string' ? output : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generates structured JSON output from a prompt.
|
|
60
|
+
* Returns null if parsing fails or model is unavailable.
|
|
61
|
+
*/
|
|
62
|
+
export async function generateJSON<T = unknown>(prompt: string, maxTokens = 1024): Promise<T | null> {
|
|
63
|
+
const text = await generate(prompt, maxTokens);
|
|
64
|
+
if (!text) return null;
|
|
65
|
+
|
|
66
|
+
// Extract JSON from the response (model may wrap in markdown code blocks)
|
|
67
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
68
|
+
if (!jsonMatch) return null;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(jsonMatch[0]) as T;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Checks if the LLM is available (model loaded or loadable).
|
|
79
|
+
*/
|
|
80
|
+
export async function isAvailable(): Promise<boolean> {
|
|
81
|
+
const gen = await getGenerator();
|
|
82
|
+
return gen !== null;
|
|
83
|
+
}
|
package/src/shared/logger.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
export const logger = {
|
|
3
|
-
info(message: string): void {
|
|
4
|
-
process.stdout.write(`[INFO] ${message}\n`);
|
|
5
|
-
},
|
|
6
|
-
|
|
7
|
-
error(message: string): void {
|
|
8
|
-
process.stderr.write(`[ERRO] ${message}\n`);
|
|
9
|
-
},
|
|
1
|
+
import pino from 'pino';
|
|
10
2
|
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
export const logger = pino({
|
|
4
|
+
transport: {
|
|
5
|
+
target: 'pino-pretty',
|
|
6
|
+
options: {
|
|
7
|
+
colorize: true,
|
|
8
|
+
ignore: 'pid,hostname,time',
|
|
9
|
+
messageFormat: '{msg}',
|
|
10
|
+
},
|
|
13
11
|
},
|
|
14
|
-
};
|
|
12
|
+
});
|
package/src/shared/shutdown.ts
CHANGED
|
@@ -21,7 +21,7 @@ export async function triggerShutdown(signal: string): Promise<void> {
|
|
|
21
21
|
shuttingDown = true;
|
|
22
22
|
|
|
23
23
|
const { logger } = await import('./logger.js');
|
|
24
|
-
logger.warn(`Shutdown
|
|
24
|
+
logger.warn(`Shutdown requested (${signal}). Cancelling ongoing operations...`);
|
|
25
25
|
|
|
26
26
|
for (const cb of callbacks) {
|
|
27
27
|
try {
|
|
@@ -31,6 +31,6 @@ export async function triggerShutdown(signal: string): Promise<void> {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
logger.info('Shutdown
|
|
34
|
+
logger.info('Shutdown complete.');
|
|
35
35
|
process.exit(130);
|
|
36
36
|
}
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { generate } from '../shared/llm.js';
|
|
4
4
|
import { run } from '../shared/process-runner.js';
|
|
5
5
|
import { logger } from '../shared/logger.js';
|
|
6
6
|
|
|
7
7
|
const HEADER = '# Changelog\n';
|
|
8
8
|
|
|
9
9
|
export class ChangelogGenerator {
|
|
10
|
-
private ollama = new Ollama();
|
|
11
|
-
|
|
12
10
|
async generate(packageDir: string, newVersion: string): Promise<string> {
|
|
13
11
|
const changelogPath = join(packageDir, 'CHANGELOG.md');
|
|
14
12
|
const commits = await this.getCommitsSinceLastTag(packageDir);
|
|
@@ -60,10 +58,6 @@ export class ChangelogGenerator {
|
|
|
60
58
|
|
|
61
59
|
private async generateWithLLM(version: string, date: string, commits: string[]): Promise<string | null> {
|
|
62
60
|
try {
|
|
63
|
-
const models = await this.ollama.list();
|
|
64
|
-
const hasModel = models.models.some((m) => m.name.startsWith('phi4'));
|
|
65
|
-
if (!hasModel) return null;
|
|
66
|
-
|
|
67
61
|
const commitList = commits.map((c) => `- ${c}`).join('\n');
|
|
68
62
|
const prompt = `You are a changelog writer. Given these git commits, generate a concise, well-organized changelog entry in Keep a Changelog format.
|
|
69
63
|
|
|
@@ -75,12 +69,10 @@ ${commitList}
|
|
|
75
69
|
|
|
76
70
|
Output ONLY the markdown sections (### Added, ### Changed, etc.) with bullet points. No header, no version line.`;
|
|
77
71
|
|
|
78
|
-
const
|
|
79
|
-
const sections = response.response.trim();
|
|
80
|
-
|
|
72
|
+
const sections = await generate(prompt);
|
|
81
73
|
if (!sections || sections.length < 10) return null;
|
|
82
74
|
|
|
83
|
-
return [`## [${version}] - ${date}`, '', sections, ''].join('\n');
|
|
75
|
+
return [`## [${version}] - ${date}`, '', sections.trim(), ''].join('\n');
|
|
84
76
|
} catch {
|
|
85
77
|
return null;
|
|
86
78
|
}
|
|
@@ -23,15 +23,15 @@ export class VersionPropagator {
|
|
|
23
23
|
await this.propagateRecursive(packageName, newVersion, entries, visited);
|
|
24
24
|
|
|
25
25
|
if (entries.length === 0) {
|
|
26
|
-
logger.info(`
|
|
26
|
+
logger.info(`No consumers found for ${packageName}. Propagation skipped.`);
|
|
27
27
|
return { entries };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// Display summary
|
|
31
|
-
logger.info('---
|
|
31
|
+
logger.info('--- Propagation Summary ---');
|
|
32
32
|
for (const entry of entries) {
|
|
33
33
|
if (entry.skipped) {
|
|
34
|
-
logger.warn(` ${entry.project}:
|
|
34
|
+
logger.warn(` ${entry.project}: SKIPPED — ${entry.reason}`);
|
|
35
35
|
} else {
|
|
36
36
|
logger.info(` ${entry.project}: ${entry.previousVersion} → ${entry.newVersion}`);
|
|
37
37
|
}
|
package/src/shared/ollama.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { run } from './process-runner.js';
|
|
2
|
-
import { logger } from './logger.js';
|
|
3
|
-
import { confirm } from './prompt.js';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Checks if Ollama CLI is installed on the system.
|
|
7
|
-
*/
|
|
8
|
-
async function isInstalled(): Promise<boolean> {
|
|
9
|
-
const result = await run('ollama', ['--version'], { timeout: 5_000 });
|
|
10
|
-
return result.exitCode === 0;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Checks if the phi4 model is available locally.
|
|
15
|
-
*/
|
|
16
|
-
async function hasModel(model: string): Promise<boolean> {
|
|
17
|
-
const result = await run('ollama', ['list'], { timeout: 10_000 });
|
|
18
|
-
if (result.exitCode !== 0) return false;
|
|
19
|
-
return result.stdout.includes(model);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Ensures a system dependency is available, installing it if the user agrees.
|
|
24
|
-
*/
|
|
25
|
-
async function ensureDependency(command: string, installCmd: string, name: string): Promise<boolean> {
|
|
26
|
-
const check = await run('which', [command], { timeout: 5_000 });
|
|
27
|
-
if (check.exitCode === 0) return true;
|
|
28
|
-
|
|
29
|
-
const shouldInstall = await confirm(`${name} não encontrado (necessário para Ollama). Instalar? (s/n) `);
|
|
30
|
-
if (!shouldInstall) return false;
|
|
31
|
-
|
|
32
|
-
const result = await run('sudo', installCmd.split(' '), { timeout: 60_000, interactive: true });
|
|
33
|
-
return result.exitCode === 0;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Installs Ollama via the official install script.
|
|
38
|
-
*/
|
|
39
|
-
async function install(): Promise<boolean> {
|
|
40
|
-
// Ollama requires zstd for extraction
|
|
41
|
-
if (!await ensureDependency('zstd', 'apt-get install -y zstd', 'zstd')) {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
logger.info('Instalando Ollama...');
|
|
46
|
-
const result = await run('curl -fsSL https://ollama.com/install.sh | sh', [], {
|
|
47
|
-
timeout: 120_000,
|
|
48
|
-
interactive: true,
|
|
49
|
-
shell: true,
|
|
50
|
-
});
|
|
51
|
-
return result.exitCode === 0;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Pulls a model from the Ollama registry.
|
|
56
|
-
*/
|
|
57
|
-
async function pull(model: string): Promise<boolean> {
|
|
58
|
-
logger.info(`Baixando modelo ${model}...`);
|
|
59
|
-
const result = await run('ollama', ['pull', model], {
|
|
60
|
-
timeout: 300_000,
|
|
61
|
-
interactive: true,
|
|
62
|
-
});
|
|
63
|
-
return result.exitCode === 0;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Ensures Ollama and the specified model are available.
|
|
68
|
-
* Prompts the user interactively if installation is needed.
|
|
69
|
-
*/
|
|
70
|
-
export async function ensureOllamaSetup(model = 'phi4'): Promise<void> {
|
|
71
|
-
if (!await isInstalled()) {
|
|
72
|
-
const shouldInstall = await confirm('Ollama não encontrado. Deseja instalar? (s/n) ');
|
|
73
|
-
if (!shouldInstall) {
|
|
74
|
-
logger.info('Ollama não instalado. Funcionalidades de IA indisponíveis.');
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
if (!await install()) {
|
|
78
|
-
logger.info('Falha ao instalar Ollama. Prosseguindo sem IA.');
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!await hasModel(model)) {
|
|
84
|
-
const shouldPull = await confirm(`Modelo ${model} não encontrado. Deseja baixar? (s/n) `);
|
|
85
|
-
if (!shouldPull) {
|
|
86
|
-
logger.info(`${model} não instalado. Funcionalidades de IA indisponíveis.`);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
if (!await pull(model)) {
|
|
90
|
-
logger.info(`Falha ao baixar ${model}. Prosseguindo sem IA.`);
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
logger.info(`Ollama + ${model} configurados.`);
|
|
96
|
-
}
|