octo-dev 0.5.5 → 0.7.0
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/README.md +5 -5
- package/package.json +3 -3
- package/src/build/build-orchestrator.ts +1 -2
- package/src/build/build-scheduler.ts +0 -5
- package/src/cli/bump.command.ts +1 -0
- package/src/cli/down.command.ts +1 -1
- package/src/cli/index.ts +12 -4
- package/src/cli/init.command.ts +4 -0
- package/src/cli/status.command.ts +3 -3
- package/src/cli/sync.command.ts +28 -0
- package/src/cli/up.command.ts +1 -1
- package/src/graph/build-graph.ts +5 -1
- package/src/infra/infra-manager.ts +185 -51
- package/src/manifest/manifest-discovery.ts +1 -1
- package/src/shared/git-auth.ts +37 -0
- package/src/shared/git.ts +37 -9
- package/src/shared/prompt.ts +20 -1
- package/src/shared/sync.ts +81 -0
- package/src/version/version-propagator.ts +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<h1 align="center">🐙 Octo</h1>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
|
-
<strong>Build orchestration, semantic versioning, and local infrastructure management for
|
|
11
|
+
<strong>Build orchestration, semantic versioning, and local infrastructure management for repository workspaces.</strong>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
14
|
<p align="center">
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
## Why Octo?
|
|
21
21
|
|
|
22
|
-
Managing a
|
|
22
|
+
Managing a workspace with multiple repositories, microservices and shared packages means dealing with:
|
|
23
23
|
|
|
24
24
|
- **Manual build ordering** — services depend on shared packages that must be built first
|
|
25
25
|
- **Version drift** — bumping a shared SDK requires updating every consumer by hand
|
|
@@ -33,9 +33,9 @@ Octo solves all three with a single CLI that understands your dependency graph.
|
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
35
|
# Install globally
|
|
36
|
-
|
|
36
|
+
npm install -g octo-dev
|
|
37
37
|
|
|
38
|
-
# Initialize in your
|
|
38
|
+
# Initialize in your workspace
|
|
39
39
|
octo init
|
|
40
40
|
|
|
41
41
|
# Build everything in dependency order
|
|
@@ -65,7 +65,7 @@ octo up
|
|
|
65
65
|
|
|
66
66
|
### `octo init`
|
|
67
67
|
|
|
68
|
-
Scans the
|
|
68
|
+
Scans the workspace, discovers services (directories with `Dockerfile`) and packages, and generates `octo.yaml`.
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
71
|
octo init # Recursive scan, generates root manifest
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "octo-dev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Build orchestration, semantic versioning, and local infrastructure management for repository workspaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "congeant",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"cli",
|
|
18
|
-
"
|
|
18
|
+
"workspace",
|
|
19
19
|
"build",
|
|
20
20
|
"orchestration",
|
|
21
21
|
"docker",
|
|
@@ -4,13 +4,12 @@ import { onShutdown } from '../shared/shutdown.js';
|
|
|
4
4
|
import { runHooks } from '../hooks/hook-runner.js';
|
|
5
5
|
import type { HookContext } from '../hooks/hook-runner.js';
|
|
6
6
|
import type { DependencyGraph } from '../graph/dependency-graph.js';
|
|
7
|
-
import type {
|
|
7
|
+
import type { BuildEngineRegistry, BuildTarget } from './ports/build-engine.port.js';
|
|
8
8
|
import type { OctoManifest } from '../manifest/manifest-schema.js';
|
|
9
9
|
import {
|
|
10
10
|
createBuildScheduler,
|
|
11
11
|
type BuildResult,
|
|
12
12
|
type BuildProgress,
|
|
13
|
-
type BuildStatus,
|
|
14
13
|
} from './build-scheduler.js';
|
|
15
14
|
|
|
16
15
|
export interface BuildOptions {
|
|
@@ -161,11 +161,6 @@ export function createBuildScheduler(): BuildScheduler {
|
|
|
161
161
|
clearInterval(progressInterval);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
const success = [...results.values()].every(
|
|
165
|
-
(r) => r.status === 'success' || r.status === 'cancelled',
|
|
166
|
-
) && [...results.values()].some((r) => r.status === 'success');
|
|
167
|
-
|
|
168
|
-
// Overall success: no failures
|
|
169
164
|
const hasFailure = [...results.values()].some((r) => r.status === 'failure');
|
|
170
165
|
|
|
171
166
|
return { success: !hasFailure, results };
|
package/src/cli/bump.command.ts
CHANGED
package/src/cli/down.command.ts
CHANGED
|
@@ -25,7 +25,7 @@ export async function downCommand(opts: { volumes?: boolean }): Promise<void> {
|
|
|
25
25
|
.map((name) => graph.getNode(name)?.path)
|
|
26
26
|
.filter((p): p is string => !!p);
|
|
27
27
|
|
|
28
|
-
const manager = createInfraManager(servicePaths);
|
|
28
|
+
const manager = createInfraManager(servicePaths, rootDir);
|
|
29
29
|
const result = await manager.down({ volumes: opts.volumes });
|
|
30
30
|
|
|
31
31
|
if (!result.success) {
|
package/src/cli/index.ts
CHANGED
|
@@ -10,12 +10,12 @@ const program = new Command();
|
|
|
10
10
|
|
|
11
11
|
program
|
|
12
12
|
.name('octo')
|
|
13
|
-
.description('
|
|
14
|
-
.version('0.
|
|
13
|
+
.description('Build orchestration, semantic versioning, and local infrastructure management for repository workspaces')
|
|
14
|
+
.version('0.7.0');
|
|
15
15
|
|
|
16
16
|
program
|
|
17
17
|
.command('init')
|
|
18
|
-
.description('Scan the
|
|
18
|
+
.description('Scan the workspace and generate octo.yaml')
|
|
19
19
|
.option('--standalone', 'Generate manifest for the current project only')
|
|
20
20
|
.action(async (opts) => {
|
|
21
21
|
const { initCommand } = await import('./init.command.js');
|
|
@@ -32,7 +32,7 @@ program
|
|
|
32
32
|
|
|
33
33
|
program
|
|
34
34
|
.command('build [service]')
|
|
35
|
-
.description('Build
|
|
35
|
+
.description('Build workspace services')
|
|
36
36
|
.option('--affected', 'Build only affected services')
|
|
37
37
|
.action(async (service, opts) => {
|
|
38
38
|
const { buildCommand } = await import('./build.command.js');
|
|
@@ -86,6 +86,14 @@ program
|
|
|
86
86
|
await addCommand(repoUrl, opts);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
program
|
|
90
|
+
.command('sync')
|
|
91
|
+
.description('Clone all missing repositories declared in octo.yaml')
|
|
92
|
+
.action(async () => {
|
|
93
|
+
const { syncCommand } = await import('./sync.command.js');
|
|
94
|
+
await syncCommand();
|
|
95
|
+
});
|
|
96
|
+
|
|
89
97
|
const config = program
|
|
90
98
|
.command('config')
|
|
91
99
|
.description('Configure octo and git settings');
|
package/src/cli/init.command.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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 { ensureRepositories } from '../shared/sync.js';
|
|
5
6
|
import type { OctoManifest } from '../manifest/manifest-schema.js';
|
|
6
7
|
|
|
7
8
|
const EXCLUDED_DIRS = new Set(['node_modules', 'dist']);
|
|
@@ -104,4 +105,7 @@ export async function initCommand(opts: { standalone?: boolean }): Promise<void>
|
|
|
104
105
|
await writeFile(outputPath, yaml, 'utf-8');
|
|
105
106
|
|
|
106
107
|
logger.info(`octo.yaml generated with ${services.length} service(s)${packages.length > 0 ? ` and ${packages.length} package(s)` : ''}.`);
|
|
108
|
+
|
|
109
|
+
// Sync: clone any missing remote repos declared in the manifest
|
|
110
|
+
await ensureRepositories(manifest, rootDir);
|
|
107
111
|
}
|
|
@@ -25,16 +25,16 @@ export async function statusCommand(): Promise<void> {
|
|
|
25
25
|
.map((name) => graph.getNode(name)?.path)
|
|
26
26
|
.filter((p): p is string => !!p);
|
|
27
27
|
|
|
28
|
-
const manager = createInfraManager(servicePaths);
|
|
28
|
+
const manager = createInfraManager(servicePaths, rootDir);
|
|
29
29
|
const containers = await manager.status();
|
|
30
30
|
|
|
31
31
|
if (containers.length === 0) {
|
|
32
|
-
logger.info('
|
|
32
|
+
logger.info('No containers running.');
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// Print table header
|
|
37
|
-
const header = `${'
|
|
37
|
+
const header = `${'NAME'.padEnd(30)} ${'IMAGE'.padEnd(35)} ${'STATE'.padEnd(12)} PORT`;
|
|
38
38
|
console.log(header);
|
|
39
39
|
console.log('-'.repeat(header.length));
|
|
40
40
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { parseManifest } from '../manifest/manifest-parser.js';
|
|
4
|
+
import { ensureRepositories } from '../shared/sync.js';
|
|
5
|
+
import { OctoError } from '../shared/errors.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* octo sync
|
|
9
|
+
*
|
|
10
|
+
* Clones all missing repositories declared in octo.yaml.
|
|
11
|
+
* Repos using org/repo format are cloned from GitHub into ./repo-name.
|
|
12
|
+
*/
|
|
13
|
+
export async function syncCommand(): Promise<void> {
|
|
14
|
+
const rootDir = process.cwd();
|
|
15
|
+
const manifestPath = resolve(rootDir, 'octo.yaml');
|
|
16
|
+
|
|
17
|
+
let content: string;
|
|
18
|
+
try {
|
|
19
|
+
content = readFileSync(manifestPath, 'utf-8');
|
|
20
|
+
} catch {
|
|
21
|
+
throw new OctoError('octo.yaml not found. Run `octo init` first.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const parsed = parseManifest(content, manifestPath);
|
|
25
|
+
if (!parsed.ok) throw parsed.error;
|
|
26
|
+
|
|
27
|
+
await ensureRepositories(parsed.value, rootDir);
|
|
28
|
+
}
|
package/src/cli/up.command.ts
CHANGED
|
@@ -41,7 +41,7 @@ export async function upCommand(service?: string): Promise<void> {
|
|
|
41
41
|
.map((name) => graph.getNode(name)?.path)
|
|
42
42
|
.filter((p): p is string => !!p);
|
|
43
43
|
|
|
44
|
-
const manager = createInfraManager(servicePaths);
|
|
44
|
+
const manager = createInfraManager(servicePaths, rootDir);
|
|
45
45
|
const result = await manager.up(targetServices);
|
|
46
46
|
|
|
47
47
|
if (!result.success) {
|
package/src/graph/build-graph.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { DependencyGraph, type GraphNode } from './dependency-graph.js';
|
|
4
|
+
import { isRemoteRepo, extractRepoName } from '../shared/git.js';
|
|
4
5
|
import type { OctoManifest, ServiceEntry, PackageEntry } from '../manifest/manifest-schema.js';
|
|
5
6
|
|
|
6
7
|
interface PackageJson {
|
|
@@ -94,11 +95,14 @@ export function buildGraphFromManifest(manifest: OctoManifest, rootDir: string):
|
|
|
94
95
|
|
|
95
96
|
if (entry.path) {
|
|
96
97
|
dir = resolve(rootDir, entry.path);
|
|
98
|
+
} else if (isRemoteRepo(entry.name)) {
|
|
99
|
+
// org/repo format — directory is the repo name
|
|
100
|
+
dir = resolve(rootDir, extractRepoName(entry.name));
|
|
97
101
|
} else {
|
|
98
102
|
dir = findPackageDir(rootDir, entry.name);
|
|
99
103
|
}
|
|
100
104
|
|
|
101
|
-
if (!dir) continue;
|
|
105
|
+
if (!dir || !existsSync(dir)) continue;
|
|
102
106
|
|
|
103
107
|
resolvedPaths.set(entry.name, dir);
|
|
104
108
|
const node: GraphNode = { name: entry.name, type: entry.type, path: dir };
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
4
|
import { stringify } from 'yaml';
|
|
5
5
|
import { run } from '../shared/process-runner.js';
|
|
6
6
|
import { logger } from '../shared/logger.js';
|
|
7
|
-
import { OctoError } from '../shared/errors.js';
|
|
8
7
|
import { createComposeSmartMerger } from './compose-smart-merger.js';
|
|
9
|
-
import { createComposeAggregator, type MergedCompose } from './compose-aggregator.js';
|
|
8
|
+
import { createComposeAggregator, type DiscoveredCompose, type MergedCompose } from './compose-aggregator.js';
|
|
10
9
|
|
|
11
10
|
export type ContainerState = 'running' | 'stopped' | 'unhealthy';
|
|
12
11
|
|
|
@@ -30,126 +29,261 @@ export interface InfraManager {
|
|
|
30
29
|
|
|
31
30
|
const HEALTHCHECK_TIMEOUT_MS = 60_000;
|
|
32
31
|
const HEALTHCHECK_POLL_MS = 2_000;
|
|
32
|
+
const CHECKSUM_FILE = '.octo-compose-checksum';
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
// --- Checksum ---
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Computes a SHA-256 checksum from the combined content of all discovered compose files.
|
|
38
|
+
* Files are sorted by path to ensure deterministic output regardless of discovery order.
|
|
39
|
+
*
|
|
40
|
+
* @param discovered - Array of discovered compose file entries with path and content.
|
|
41
|
+
* @returns Hex-encoded SHA-256 hash string.
|
|
42
|
+
*/
|
|
43
|
+
async function computeChecksum(discovered: DiscoveredCompose[]): Promise<string> {
|
|
44
|
+
const hash = createHash('sha256');
|
|
45
|
+
for (const entry of discovered.sort((a, b) => a.path.localeCompare(b.path))) {
|
|
46
|
+
const content = await readFile(entry.path, 'utf-8');
|
|
47
|
+
hash.update(content);
|
|
48
|
+
}
|
|
49
|
+
return hash.digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reads the previously stored checksum from disk.
|
|
54
|
+
*
|
|
55
|
+
* @param rootDir - Workspace root directory containing the checksum file.
|
|
56
|
+
* @returns The stored checksum string, or null if the file does not exist.
|
|
57
|
+
*/
|
|
58
|
+
async function readStoredChecksum(rootDir: string): Promise<string | null> {
|
|
59
|
+
try {
|
|
60
|
+
return (await readFile(join(rootDir, CHECKSUM_FILE), 'utf-8')).trim();
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Persists the current checksum to disk for future comparison.
|
|
68
|
+
*
|
|
69
|
+
* @param rootDir - Workspace root directory where the checksum file is stored.
|
|
70
|
+
* @param checksum - The SHA-256 hex string to persist.
|
|
71
|
+
*/
|
|
72
|
+
async function writeChecksum(rootDir: string, checksum: string): Promise<void> {
|
|
73
|
+
await writeFile(join(rootDir, CHECKSUM_FILE), checksum, 'utf-8');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Compose output ---
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Serializes a merged compose object to YAML and writes it to `docker-compose.yml` in the project root.
|
|
80
|
+
*
|
|
81
|
+
* @param merged - The unified compose structure (services, networks, volumes).
|
|
82
|
+
* @param rootDir - Workspace root directory where the file is written.
|
|
83
|
+
* @returns Absolute path to the written `docker-compose.yml`.
|
|
84
|
+
*/
|
|
85
|
+
async function writeMergedCompose(merged: MergedCompose, rootDir: string): Promise<string> {
|
|
86
|
+
const outputPath = join(rootDir, 'docker-compose.yml');
|
|
37
87
|
const content = stringify(merged);
|
|
38
|
-
await writeFile(
|
|
39
|
-
|
|
88
|
+
await writeFile(outputPath, content, 'utf-8');
|
|
89
|
+
logger.info(`Merged compose written to ${outputPath}`);
|
|
90
|
+
return outputPath;
|
|
40
91
|
}
|
|
41
92
|
|
|
42
|
-
|
|
93
|
+
// --- Healthcheck ---
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Polls a container's health status via `docker inspect` until healthy, unhealthy, or timeout.
|
|
97
|
+
* Containers without a healthcheck defined are considered healthy immediately.
|
|
98
|
+
*
|
|
99
|
+
* @param containerName - Full Docker container name (e.g. "project-db-1").
|
|
100
|
+
* @returns `true` if container is healthy or has no healthcheck; `false` if unhealthy or timed out.
|
|
101
|
+
*/
|
|
43
102
|
async function waitForHealthcheck(containerName: string): Promise<boolean> {
|
|
44
103
|
const start = Date.now();
|
|
45
104
|
while (Date.now() - start < HEALTHCHECK_TIMEOUT_MS) {
|
|
46
105
|
const result = await run('docker', [
|
|
47
106
|
'inspect', '--format', '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}', containerName,
|
|
48
107
|
]);
|
|
108
|
+
if (result.exitCode !== 0) return true;
|
|
49
109
|
const status = result.stdout.trim();
|
|
110
|
+
if (status.includes('unhealthy')) return false;
|
|
50
111
|
if (status === 'healthy' || status === 'none') return true;
|
|
51
|
-
if (status === 'unhealthy') return false;
|
|
52
112
|
await new Promise((r) => setTimeout(r, HEALTHCHECK_POLL_MS));
|
|
53
113
|
}
|
|
54
114
|
return false;
|
|
55
115
|
}
|
|
56
116
|
|
|
57
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Outputs the last 20 log lines of a container to stderr for debugging failed healthchecks.
|
|
119
|
+
*
|
|
120
|
+
* @param containerName - Full Docker container name to retrieve logs from.
|
|
121
|
+
*/
|
|
58
122
|
async function showContainerLogs(containerName: string): Promise<void> {
|
|
59
123
|
const result = await run('docker', ['logs', '--tail', '20', containerName]);
|
|
60
124
|
const output = result.stdout || result.stderr;
|
|
61
|
-
if (output) logger.error(`
|
|
125
|
+
if (output) logger.error(`Container "${containerName}" recent logs:\n${output}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- Resolve compose path ---
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Determines which compose file to use for docker operations.
|
|
132
|
+
*
|
|
133
|
+
* - Single compose file: returns its path directly (no merge needed).
|
|
134
|
+
* - Multiple compose files: compares SHA-256 checksum against stored value.
|
|
135
|
+
* If checksums match and the merged file exists, reuses it (cache hit).
|
|
136
|
+
* If checksums differ or merged file is missing, triggers a re-merge.
|
|
137
|
+
*
|
|
138
|
+
* @param discovered - Array of discovered compose files from service directories.
|
|
139
|
+
* @param rootDir - Workspace root where merged output and checksum are stored.
|
|
140
|
+
* @param smartMerger - Smart merger instance for deduplication and LLM-assisted merge.
|
|
141
|
+
* @returns Absolute path to the compose file to use with `docker compose -f`.
|
|
142
|
+
*/
|
|
143
|
+
async function resolveComposePath(
|
|
144
|
+
discovered: DiscoveredCompose[],
|
|
145
|
+
rootDir: string,
|
|
146
|
+
smartMerger: ReturnType<typeof createComposeSmartMerger>,
|
|
147
|
+
): Promise<string> {
|
|
148
|
+
if (discovered.length === 1) {
|
|
149
|
+
return discovered[0].path;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const currentChecksum = await computeChecksum(discovered);
|
|
153
|
+
const storedChecksum = await readStoredChecksum(rootDir);
|
|
154
|
+
const mergedPath = join(rootDir, 'docker-compose.yml');
|
|
155
|
+
|
|
156
|
+
if (currentChecksum === storedChecksum) {
|
|
157
|
+
try {
|
|
158
|
+
await readFile(mergedPath, 'utf-8');
|
|
159
|
+
logger.info('Compose files unchanged — using cached docker-compose.yml');
|
|
160
|
+
return mergedPath;
|
|
161
|
+
} catch {
|
|
162
|
+
// Merged file missing despite matching checksum — re-merge below
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
logger.info(`Found ${discovered.length} compose files with changes — merging into unified docker-compose.yml`);
|
|
167
|
+
const merged = await smartMerger.deduplicate(discovered);
|
|
168
|
+
const outputPath = await writeMergedCompose(merged, rootDir);
|
|
169
|
+
await writeChecksum(rootDir, currentChecksum);
|
|
170
|
+
return outputPath;
|
|
62
171
|
}
|
|
63
172
|
|
|
64
|
-
|
|
173
|
+
// --- Manager ---
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Creates an infrastructure manager that handles docker compose operations for the workspace.
|
|
177
|
+
*
|
|
178
|
+
* @param servicePaths - Array of absolute paths to service directories (each may contain a docker-compose.yml).
|
|
179
|
+
* @param rootDir - Workspace root directory for merged compose output and checksum storage.
|
|
180
|
+
* @returns An InfraManager instance with `up`, `down`, and `status` methods.
|
|
181
|
+
*/
|
|
182
|
+
export function createInfraManager(servicePaths: string[], rootDir: string): InfraManager {
|
|
65
183
|
const aggregator = createComposeAggregator();
|
|
66
184
|
const smartMerger = createComposeSmartMerger();
|
|
67
|
-
let composePath: string | undefined;
|
|
68
185
|
|
|
69
186
|
return {
|
|
187
|
+
/**
|
|
188
|
+
* Starts containers defined in the workspace's compose file(s).
|
|
189
|
+
* Discovers compose files, resolves/merges if needed, runs `docker compose up -d`,
|
|
190
|
+
* and waits for all container healthchecks to pass.
|
|
191
|
+
*
|
|
192
|
+
* @param services - Optional list of service names to filter. If empty, starts all.
|
|
193
|
+
* @returns Result indicating success/failure with a descriptive message.
|
|
194
|
+
*/
|
|
70
195
|
async up(services?: string[]): Promise<InfraResult> {
|
|
71
|
-
const paths = services
|
|
196
|
+
const paths = services?.length
|
|
72
197
|
? servicePaths.filter((p) => services.some((s) => p.endsWith(s)))
|
|
73
198
|
: servicePaths;
|
|
74
199
|
|
|
75
200
|
const discovered = aggregator.discover(paths);
|
|
76
201
|
if (discovered.length === 0) {
|
|
77
|
-
return { success: true, message: '
|
|
202
|
+
return { success: true, message: 'No docker-compose.yml found in any service directory.' };
|
|
78
203
|
}
|
|
79
204
|
|
|
80
|
-
|
|
81
|
-
const merged = await smartMerger.deduplicate(discovered);
|
|
82
|
-
composePath = await writeTempCompose(merged);
|
|
205
|
+
const composePath = await resolveComposePath(discovered, rootDir, smartMerger);
|
|
83
206
|
|
|
84
|
-
logger.info('Starting containers...');
|
|
207
|
+
logger.info('Starting containers with docker compose...');
|
|
85
208
|
const result = await run('docker', ['compose', '-f', composePath, 'up', '-d']);
|
|
86
209
|
if (result.exitCode !== 0) {
|
|
87
|
-
return {
|
|
210
|
+
return {
|
|
211
|
+
success: false,
|
|
212
|
+
message: `Failed to start containers. docker compose exited with code ${result.exitCode}:\n${result.stderr.trim()}`,
|
|
213
|
+
};
|
|
88
214
|
}
|
|
89
215
|
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
216
|
+
const psResult = await run('docker', ['compose', '-f', composePath, 'ps', '--format', '{{.Name}}']);
|
|
217
|
+
const containerNames = psResult.stdout.trim().split('\n').filter(Boolean);
|
|
218
|
+
|
|
219
|
+
for (const container of containerNames) {
|
|
220
|
+
const healthy = await waitForHealthcheck(container);
|
|
94
221
|
if (!healthy) {
|
|
95
|
-
logger.error(`
|
|
96
|
-
await showContainerLogs(
|
|
97
|
-
|
|
98
|
-
return { success: false, message: `Healthcheck timeout: ${svc}` };
|
|
222
|
+
logger.error(`Container "${container}" failed healthcheck after ${HEALTHCHECK_TIMEOUT_MS / 1000}s.`);
|
|
223
|
+
await showContainerLogs(container);
|
|
224
|
+
return { success: false, message: `Healthcheck failed for "${container}". Check logs above for details.` };
|
|
99
225
|
}
|
|
100
226
|
}
|
|
101
227
|
|
|
102
|
-
return { success: true, message:
|
|
228
|
+
return { success: true, message: `All ${containerNames.length} container(s) are up and healthy.` };
|
|
103
229
|
},
|
|
104
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Stops all containers managed by the workspace's compose file.
|
|
233
|
+
* Optionally removes associated volumes.
|
|
234
|
+
*
|
|
235
|
+
* @param options - `{ volumes: true }` to also remove Docker volumes on teardown.
|
|
236
|
+
* @returns Result indicating success/failure with a descriptive message.
|
|
237
|
+
*/
|
|
105
238
|
async down(options: { volumes?: boolean }): Promise<InfraResult> {
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
if (discovered.length === 0) {
|
|
110
|
-
return { success: true, message: 'Nenhum container para parar.' };
|
|
111
|
-
}
|
|
112
|
-
const merged = await smartMerger.deduplicate(discovered);
|
|
113
|
-
composePath = await writeTempCompose(merged);
|
|
239
|
+
const discovered = aggregator.discover(servicePaths);
|
|
240
|
+
if (discovered.length === 0) {
|
|
241
|
+
return { success: true, message: 'No compose files found — nothing to stop.' };
|
|
114
242
|
}
|
|
115
243
|
|
|
244
|
+
const composePath = await resolveComposePath(discovered, rootDir, smartMerger);
|
|
116
245
|
const args = ['compose', '-f', composePath, 'down'];
|
|
117
246
|
if (options.volumes) args.push('--volumes');
|
|
118
247
|
|
|
119
248
|
const result = await run('docker', args);
|
|
120
249
|
if (result.exitCode !== 0) {
|
|
121
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
success: false,
|
|
252
|
+
message: `Failed to stop containers. docker compose exited with code ${result.exitCode}:\n${result.stderr.trim()}`,
|
|
253
|
+
};
|
|
122
254
|
}
|
|
123
255
|
|
|
124
|
-
return { success: true, message: '
|
|
256
|
+
return { success: true, message: options.volumes ? 'All containers stopped and volumes removed.' : 'All containers stopped.' };
|
|
125
257
|
},
|
|
126
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Retrieves the current status of all containers managed by the workspace's compose file.
|
|
261
|
+
* Parses `docker compose ps --format json` output into structured ContainerStatus objects.
|
|
262
|
+
*
|
|
263
|
+
* @returns Array of container statuses. Empty array if no compose files exist or command fails.
|
|
264
|
+
*/
|
|
127
265
|
async status(): Promise<ContainerStatus[]> {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (discovered.length === 0) return [];
|
|
131
|
-
const merged = await smartMerger.deduplicate(discovered);
|
|
132
|
-
composePath = await writeTempCompose(merged);
|
|
133
|
-
}
|
|
266
|
+
const discovered = aggregator.discover(servicePaths);
|
|
267
|
+
if (discovered.length === 0) return [];
|
|
134
268
|
|
|
269
|
+
const composePath = await resolveComposePath(discovered, rootDir, smartMerger);
|
|
135
270
|
const result = await run('docker', ['compose', '-f', composePath, 'ps', '--format', 'json']);
|
|
136
271
|
if (result.exitCode !== 0 || !result.stdout.trim()) return [];
|
|
137
272
|
|
|
138
273
|
const containers: ContainerStatus[] = [];
|
|
139
|
-
// docker compose ps --format json outputs one JSON object per line
|
|
140
274
|
for (const line of result.stdout.trim().split('\n')) {
|
|
141
275
|
try {
|
|
142
276
|
const entry = JSON.parse(line);
|
|
143
277
|
const state: ContainerState =
|
|
144
|
-
entry.
|
|
145
|
-
entry.
|
|
278
|
+
entry.State !== 'running' ? 'stopped' :
|
|
279
|
+
entry.Health === 'unhealthy' ? 'unhealthy' : 'running';
|
|
146
280
|
containers.push({
|
|
147
281
|
name: entry.Name ?? entry.Service ?? '',
|
|
148
282
|
image: entry.Image ?? '',
|
|
149
283
|
state,
|
|
150
284
|
port: entry.Publishers?.map((p: any) => `${p.PublishedPort}:${p.TargetPort}`).join(', ') ?? '',
|
|
151
285
|
});
|
|
152
|
-
} catch { /* skip malformed lines */ }
|
|
286
|
+
} catch { /* skip malformed JSON lines */ }
|
|
153
287
|
}
|
|
154
288
|
return containers;
|
|
155
289
|
},
|
|
@@ -133,7 +133,7 @@ export function displayDiscoveredProjects(rootDir: string): DiscoveredManifest[]
|
|
|
133
133
|
const mode = resolveMode(rootDir);
|
|
134
134
|
|
|
135
135
|
if (mode === 'aggregated') {
|
|
136
|
-
logger.info(`
|
|
136
|
+
logger.info(`Aggregated mode: ${manifests.length} projects discovered`);
|
|
137
137
|
for (const m of manifests) {
|
|
138
138
|
logger.info(` → ${m.name} (${relative(rootDir, m.path)})`);
|
|
139
139
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ask } from './prompt.js';
|
|
2
|
+
|
|
3
|
+
let cachedToken: string | undefined;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Acquires a GitHub Personal Access Token for git HTTPS operations.
|
|
7
|
+
* Prompts the user once and caches in memory for the process lifetime.
|
|
8
|
+
* The token is never persisted to disk.
|
|
9
|
+
*
|
|
10
|
+
* @returns The PAT string, or undefined if the user provides empty input (skip).
|
|
11
|
+
*/
|
|
12
|
+
export async function acquireGitToken(): Promise<string | undefined> {
|
|
13
|
+
if (cachedToken) return cachedToken;
|
|
14
|
+
const input = await ask('GitHub token (PAT): ');
|
|
15
|
+
cachedToken = input || undefined;
|
|
16
|
+
return cachedToken;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Embeds a token into an HTTPS git URL for credential-less cloning.
|
|
21
|
+
* Produces format: https://<token>@github.com/org/repo.git
|
|
22
|
+
*
|
|
23
|
+
* @param baseUrl - The base HTTPS clone URL (e.g. "https://github.com/org/repo.git").
|
|
24
|
+
* @param token - The GitHub PAT to embed.
|
|
25
|
+
* @returns The URL with credentials embedded in the authority segment.
|
|
26
|
+
*/
|
|
27
|
+
export function embedTokenInUrl(baseUrl: string, token: string): string {
|
|
28
|
+
return baseUrl.replace('https://', `https://${token}@`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Clears the cached token from process memory.
|
|
33
|
+
* Should be called after sync completes as defense-in-depth.
|
|
34
|
+
*/
|
|
35
|
+
export function clearCachedToken(): void {
|
|
36
|
+
cachedToken = undefined;
|
|
37
|
+
}
|
package/src/shared/git.ts
CHANGED
|
@@ -4,11 +4,15 @@ import { logger } from './logger.js';
|
|
|
4
4
|
import { OctoError } from './errors.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Extracts the repository name from a git URL.
|
|
8
|
-
* Supports HTTPS and
|
|
7
|
+
* Extracts the repository name from a git URL or org/repo shorthand.
|
|
8
|
+
* Supports HTTPS, SSH, and GitHub shorthand formats:
|
|
9
9
|
* https://github.com/user/repo.git → repo
|
|
10
10
|
* git@github.com:user/repo.git → repo
|
|
11
|
-
*
|
|
11
|
+
* user/repo → repo
|
|
12
|
+
*
|
|
13
|
+
* @param url - Git URL or org/repo shorthand.
|
|
14
|
+
* @returns The repository name (last segment without .git suffix).
|
|
15
|
+
* @throws OctoError if the name cannot be extracted.
|
|
12
16
|
*/
|
|
13
17
|
export function extractRepoName(url: string): string {
|
|
14
18
|
const cleaned = url.replace(/\.git$/, '').replace(/\/$/, '');
|
|
@@ -20,19 +24,43 @@ export function extractRepoName(url: string): string {
|
|
|
20
24
|
return name;
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Checks if a manifest entry name is a git remote reference (org/repo format).
|
|
29
|
+
*
|
|
30
|
+
* @param name - The entry name from octo.yaml.
|
|
31
|
+
* @returns true if the name matches org/repo pattern (contains exactly one slash, no @ prefix).
|
|
32
|
+
*/
|
|
33
|
+
export function isRemoteRepo(name: string): boolean {
|
|
34
|
+
return /^[^@/]+\/[^/]+$/.test(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Converts an org/repo shorthand to a full GitHub HTTPS URL.
|
|
39
|
+
*
|
|
40
|
+
* @param shorthand - The org/repo string (e.g. "vguerato/spectre-tasks").
|
|
41
|
+
* @returns Full clone URL (e.g. "https://github.com/vguerato/spectre-tasks.git").
|
|
42
|
+
*/
|
|
43
|
+
export function resolveGitUrl(shorthand: string): string {
|
|
44
|
+
return `https://github.com/${shorthand}.git`;
|
|
45
|
+
}
|
|
46
|
+
|
|
23
47
|
/**
|
|
24
48
|
* Clones a git repository into the target directory.
|
|
49
|
+
* Uses interactive stdio when no auth is embedded in the URL.
|
|
50
|
+
*
|
|
51
|
+
* @param url - Full git URL to clone (may contain embedded token).
|
|
52
|
+
* @param targetDir - Absolute path where the repo will be cloned.
|
|
53
|
+
* @throws OctoError if clone exits with non-zero code.
|
|
25
54
|
*/
|
|
26
55
|
export async function cloneRepository(url: string, targetDir: string): Promise<void> {
|
|
27
|
-
|
|
56
|
+
// Log without exposing token
|
|
57
|
+
const safeUrl = url.replace(/\/\/[^@]+@/, '//***@');
|
|
58
|
+
logger.info(`Cloning ${safeUrl}...`);
|
|
28
59
|
|
|
29
|
-
const result = await run('git', ['clone', url, targetDir], {
|
|
30
|
-
timeout: 120_000,
|
|
31
|
-
interactive: true,
|
|
32
|
-
});
|
|
60
|
+
const result = await run('git', ['clone', url, targetDir], { timeout: 120_000 });
|
|
33
61
|
|
|
34
62
|
if (result.exitCode !== 0) {
|
|
35
|
-
throw new OctoError(
|
|
63
|
+
throw new OctoError(`Failed to clone repository: ${result.stderr.trim() || 'unknown error'}`);
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
logger.info(`Repository cloned to ./${basename(targetDir)}`);
|
package/src/shared/prompt.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Prompts the user with a question and returns their text input.
|
|
5
|
+
*
|
|
6
|
+
* @param message - The prompt message displayed to the user.
|
|
7
|
+
* @returns The trimmed user input string.
|
|
8
|
+
*/
|
|
9
|
+
export function ask(message: string): Promise<string> {
|
|
10
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
rl.question(message, (answer) => {
|
|
13
|
+
rl.close();
|
|
14
|
+
resolve(answer.trim());
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
3
19
|
/**
|
|
4
20
|
* Prompts the user with a yes/no question via stdin.
|
|
5
|
-
* Accepts 'y', 'Y', 's', 'S' as affirmative.
|
|
21
|
+
* Accepts 'y', 'Y', 's', 'S' as affirmative responses.
|
|
22
|
+
*
|
|
23
|
+
* @param message - The yes/no question to display.
|
|
24
|
+
* @returns `true` if the user confirmed, `false` otherwise.
|
|
6
25
|
*/
|
|
7
26
|
export function confirm(message: string): Promise<boolean> {
|
|
8
27
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { logger } from './logger.js';
|
|
4
|
+
import { isRemoteRepo, resolveGitUrl, extractRepoName, cloneRepository } from './git.js';
|
|
5
|
+
import { acquireGitToken, embedTokenInUrl, clearCachedToken } from './git-auth.js';
|
|
6
|
+
import type { OctoManifest, ServiceEntry, PackageEntry } from '../manifest/manifest-schema.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensures all repositories declared in the manifest are present locally.
|
|
10
|
+
* Prompts for a GitHub token once, embeds it in clone URLs (in-memory only).
|
|
11
|
+
* Token is cleared from memory after all clones complete.
|
|
12
|
+
*
|
|
13
|
+
* @param manifest - The parsed octo.yaml manifest.
|
|
14
|
+
* @param rootDir - Workspace root directory where repos are cloned.
|
|
15
|
+
* @returns Number of repositories that were cloned.
|
|
16
|
+
*/
|
|
17
|
+
export async function ensureRepositories(manifest: OctoManifest, rootDir: string): Promise<number> {
|
|
18
|
+
const entries = collectEntries(manifest);
|
|
19
|
+
const pending = entries.filter(({ name, path: explicitPath }) => {
|
|
20
|
+
if (!isRemoteRepo(name)) return false;
|
|
21
|
+
const repoName = extractRepoName(name);
|
|
22
|
+
const targetDir = resolve(rootDir, explicitPath ?? repoName);
|
|
23
|
+
return !existsSync(targetDir);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (pending.length === 0) return 0;
|
|
27
|
+
|
|
28
|
+
logger.info(`${pending.length} repository(ies) to clone.`);
|
|
29
|
+
|
|
30
|
+
const token = await acquireGitToken();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
let cloned = 0;
|
|
34
|
+
for (const { name, path: explicitPath } of pending) {
|
|
35
|
+
const repoName = extractRepoName(name);
|
|
36
|
+
const targetDir = resolve(rootDir, explicitPath ?? repoName);
|
|
37
|
+
const baseUrl = resolveGitUrl(name);
|
|
38
|
+
const url = token ? embedTokenInUrl(baseUrl, token) : baseUrl;
|
|
39
|
+
await cloneRepository(url, targetDir);
|
|
40
|
+
cloned++;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
logger.info(`Synced ${cloned} repository(ies).`);
|
|
44
|
+
return cloned;
|
|
45
|
+
} finally {
|
|
46
|
+
clearCachedToken();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extracts all entry names and optional path overrides from the manifest.
|
|
52
|
+
*
|
|
53
|
+
* @param manifest - The parsed octo.yaml manifest.
|
|
54
|
+
* @returns Array of entries with name and optional explicit path.
|
|
55
|
+
*/
|
|
56
|
+
function collectEntries(manifest: OctoManifest): Array<{ name: string; path?: string }> {
|
|
57
|
+
const results: Array<{ name: string; path?: string }> = [];
|
|
58
|
+
|
|
59
|
+
for (const entry of manifest.services) {
|
|
60
|
+
results.push(resolveEntry(entry));
|
|
61
|
+
}
|
|
62
|
+
for (const entry of manifest.packages ?? []) {
|
|
63
|
+
results.push(resolveEntry(entry));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolves the name and optional path from a manifest entry.
|
|
71
|
+
* String entries return name only. Object entries extract key + path config.
|
|
72
|
+
*
|
|
73
|
+
* @param entry - A service or package entry from octo.yaml.
|
|
74
|
+
* @returns Object with resolved name and optional path override.
|
|
75
|
+
*/
|
|
76
|
+
function resolveEntry(entry: ServiceEntry | PackageEntry): { name: string; path?: string } {
|
|
77
|
+
if (typeof entry === 'string') return { name: entry };
|
|
78
|
+
const key = Object.keys(entry)[0];
|
|
79
|
+
const config = (entry as Record<string, { path?: string }>)[key];
|
|
80
|
+
return { name: key, path: config?.path };
|
|
81
|
+
}
|
|
@@ -95,7 +95,7 @@ export class VersionPropagator {
|
|
|
95
95
|
previousVersion: currentRange,
|
|
96
96
|
newVersion,
|
|
97
97
|
skipped: true,
|
|
98
|
-
reason: `Range ${currentRange}
|
|
98
|
+
reason: `Range ${currentRange} incompatible with ${newVersion}`,
|
|
99
99
|
});
|
|
100
100
|
return false;
|
|
101
101
|
}
|