octo-dev 0.5.4 → 0.6.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 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 monorepos.</strong>
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 monorepo with multiple microservices and shared packages means dealing with:
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
- pnpm add -g octo-monorepo
36
+ npm install -g octo-dev
37
37
 
38
- # Initialize in your monorepo
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 monorepo, discovers services (directories with `Dockerfile`) and packages, and generates `octo.yaml`.
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.5.4",
4
- "description": "CLI for monorepo build orchestration, semantic versioning, and local infrastructure management",
3
+ "version": "0.6.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
- "monorepo",
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 { BuildEngine, BuildEngineRegistry, BuildTarget } from './ports/build-engine.port.js';
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 };
@@ -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('Monorepo build orchestration, versioning, and infrastructure CLI')
14
- .version('0.5.4');
13
+ .description('Build orchestration, semantic versioning, and local infrastructure management for repository workspaces')
14
+ .version('0.6.0');
15
15
 
16
16
  program
17
17
  .command('init')
18
- .description('Scan the monorepo and generate octo.yaml')
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 monorepo services')
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');
@@ -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('Nenhum container em execução.');
32
+ logger.info('No containers running.');
33
33
  return;
34
34
  }
35
35
 
36
36
  // Print table header
37
- const header = `${'NOME'.padEnd(30)} ${'IMAGEM'.padEnd(35)} ${'ESTADO'.padEnd(12)} PORTA`;
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
 
@@ -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) {
@@ -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
- /** Write merged compose to a temp file and return its path */
35
- async function writeTempCompose(merged: MergedCompose): Promise<string> {
36
- const tempPath = join(tmpdir(), `octo-compose-${Date.now()}.yml`);
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(tempPath, content, 'utf-8');
39
- return tempPath;
88
+ await writeFile(outputPath, content, 'utf-8');
89
+ logger.info(`Merged compose written to ${outputPath}`);
90
+ return outputPath;
40
91
  }
41
92
 
42
- /** Poll healthcheck for a container, returns true if healthy within timeout */
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
- /** Show last 20 lines of container log */
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(`Last 20 lines of log (${containerName}):\n${output}`);
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
- export function createInfraManager(servicePaths: string[]): InfraManager {
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 && services.length > 0
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: 'Nenhum docker-compose.yml encontrado.' };
202
+ return { success: true, message: 'No docker-compose.yml found in any service directory.' };
78
203
  }
79
204
 
80
- logger.info(`Discovered ${discovered.length} compose file(s). Merging...`);
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 { success: false, message: `docker compose up falhou: ${result.stderr}` };
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
- // Wait for healthchecks
91
- const serviceNames = Object.keys(merged.services);
92
- for (const svc of serviceNames) {
93
- const healthy = await waitForHealthcheck(svc);
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(`Healthcheck timeout for container "${svc}".`);
96
- await showContainerLogs(svc);
97
- // Stop dependents but don't tear down everything
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: `${serviceNames.length} container(s) iniciado(s).` };
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
- // Discover and merge to get the compose file path
107
- if (!composePath) {
108
- const discovered = aggregator.discover(servicePaths);
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 { success: false, message: `docker compose down falhou: ${result.stderr}` };
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: 'Containers parados.' };
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
- if (!composePath) {
129
- const discovered = aggregator.discover(servicePaths);
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.Health === 'unhealthy' ? 'unhealthy' :
145
- entry.State === 'running' ? 'running' : 'stopped';
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(`Modo agregado: ${manifests.length} projetos descobertos`);
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
  }
@@ -95,7 +95,7 @@ export class VersionPropagator {
95
95
  previousVersion: currentRange,
96
96
  newVersion,
97
97
  skipped: true,
98
- reason: `Range ${currentRange} incompatível com ${newVersion}`,
98
+ reason: `Range ${currentRange} incompatible with ${newVersion}`,
99
99
  });
100
100
  return false;
101
101
  }