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 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.5",
4
- "description": "CLI for monorepo build orchestration, semantic versioning, and local infrastructure management",
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
- "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 };
@@ -49,6 +49,7 @@ export async function bumpCommand(pkg: string, type: string, opts: BumpCommandOp
49
49
  );
50
50
  }
51
51
 
52
+ await ensureRepositories(manifest, rootDir);
52
53
  const graph = buildGraphFromManifest(manifest, rootDir);
53
54
  const node = graph.getNode(pkg);
54
55
 
@@ -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.5');
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 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');
@@ -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');
@@ -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('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
 
@@ -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
+ }
@@ -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,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
- /** 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
  }
@@ -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 SSH formats:
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
- * https://github.com/user/repo → repo
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
- logger.info(`Cloning ${url}...`);
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('Failed to clone repository.');
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)}`);
@@ -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} incompatível com ${newVersion}`,
98
+ reason: `Range ${currentRange} incompatible with ${newVersion}`,
99
99
  });
100
100
  return false;
101
101
  }