octo-dev 0.2.2 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "octo-dev",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "CLI for monorepo build orchestration, semantic versioning, and local infrastructure management",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,45 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { logger } from '../shared/logger.js';
4
+ import { OctoError } from '../shared/errors.js';
5
+ import { extractRepoName, cloneRepository } from '../shared/git.js';
6
+ import { detectProjectType, resolveProjectName } from '../manifest/manifest-discovery.js';
7
+ import { loadOrCreateManifest, addEntry, saveManifest } from '../manifest/manifest-mutator.js';
8
+
9
+ export interface AddCommandOptions {
10
+ name?: string;
11
+ }
12
+
13
+ /**
14
+ * octo add <repo-url>
15
+ *
16
+ * Clones a repository and registers it in octo.yaml.
17
+ * The directory is resolved automatically from the repo name (or --name override).
18
+ */
19
+ export async function addCommand(repoUrl: string, opts: AddCommandOptions): Promise<void> {
20
+ const rootDir = process.cwd();
21
+ const dirName = opts.name || extractRepoName(repoUrl);
22
+ const targetDir = resolve(rootDir, dirName);
23
+
24
+ if (existsSync(targetDir)) {
25
+ throw new OctoError(`Diretório já existe: ${dirName}. Use --name para especificar outro nome.`);
26
+ }
27
+
28
+ await cloneRepository(repoUrl, targetDir);
29
+
30
+ const type = detectProjectType(targetDir);
31
+ const projectName = resolveProjectName(targetDir, dirName);
32
+
33
+ const manifestPath = resolve(rootDir, 'octo.yaml');
34
+ const { manifest, originalContent } = loadOrCreateManifest(manifestPath);
35
+
36
+ const added = addEntry(manifest, projectName, dirName, type);
37
+
38
+ if (!added) {
39
+ logger.info(`Projeto "${projectName}" já está registrado no octo.yaml.`);
40
+ return;
41
+ }
42
+
43
+ saveManifest(manifestPath, manifest, originalContent);
44
+ logger.info(`Projeto "${projectName}" adicionado como ${type} no octo.yaml.`);
45
+ }
package/src/cli/index.ts CHANGED
@@ -77,4 +77,13 @@ program
77
77
  await statusCommand();
78
78
  });
79
79
 
80
+ program
81
+ .command('add <repo-url>')
82
+ .description('Clona um repositório e registra no octo.yaml')
83
+ .option('--name <name>', 'Nome customizado para o diretório clonado')
84
+ .action(async (repoUrl, opts) => {
85
+ const { addCommand } = await import('./add.command.js');
86
+ await addCommand(repoUrl, opts);
87
+ });
88
+
80
89
  program.parse();
@@ -1,4 +1,4 @@
1
- import { readdirSync, statSync, existsSync } from 'node:fs';
1
+ import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
2
2
  import { join, relative } from 'node:path';
3
3
  import { ManifestError } from '../shared/errors.js';
4
4
  import { logger } from '../shared/logger.js';
@@ -142,3 +142,29 @@ export function displayDiscoveredProjects(rootDir: string): DiscoveredManifest[]
142
142
  detectNameCollisions(manifests);
143
143
  return manifests;
144
144
  }
145
+
146
+ /**
147
+ * Detects whether a project directory is a service (has Dockerfile) or a package.
148
+ */
149
+ export function detectProjectType(projectDir: string): 'service' | 'package' {
150
+ return existsSync(join(projectDir, 'Dockerfile')) ? 'service' : 'package';
151
+ }
152
+
153
+ /**
154
+ * Resolves the project name from package.json, falling back to the directory name.
155
+ */
156
+ export function resolveProjectName(projectDir: string, fallbackName: string): string {
157
+ const pkgPath = join(projectDir, 'package.json');
158
+ if (existsSync(pkgPath)) {
159
+ try {
160
+ const content = readFileSync(pkgPath, 'utf-8');
161
+ const pkg = JSON.parse(content);
162
+ if (pkg.name && typeof pkg.name === 'string') {
163
+ return pkg.name;
164
+ }
165
+ } catch {
166
+ // Fall through to fallback
167
+ }
168
+ }
169
+ return fallbackName;
170
+ }
@@ -0,0 +1,70 @@
1
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
2
+ import { parseManifest } from './manifest-parser.js';
3
+ import { printManifest } from './manifest-printer.js';
4
+ import { OctoError } from '../shared/errors.js';
5
+ import type { OctoManifest } from './manifest-schema.js';
6
+
7
+ export interface LoadedManifest {
8
+ manifest: OctoManifest;
9
+ originalContent?: string;
10
+ }
11
+
12
+ /**
13
+ * Loads an existing octo.yaml manifest, or returns a minimal empty one.
14
+ */
15
+ export function loadOrCreateManifest(manifestPath: string): LoadedManifest {
16
+ if (existsSync(manifestPath)) {
17
+ const content = readFileSync(manifestPath, 'utf-8');
18
+ const parsed = parseManifest(content, manifestPath);
19
+ if (parsed.ok) {
20
+ return { manifest: parsed.value, originalContent: content };
21
+ }
22
+ throw new OctoError(`octo.yaml inválido: ${parsed.error.message}`);
23
+ }
24
+ return { manifest: { services: [], packages: [] } };
25
+ }
26
+
27
+ /**
28
+ * Adds a project entry to the manifest, avoiding duplicates.
29
+ * If the directory name matches the project name, registers as a simple string
30
+ * (octo resolves the path by convention). Otherwise, uses an object with explicit path.
31
+ *
32
+ * Returns true if the entry was added, false if it already existed.
33
+ */
34
+ export function addEntry(
35
+ manifest: OctoManifest,
36
+ name: string,
37
+ dirName: string,
38
+ type: 'service' | 'package',
39
+ ): boolean {
40
+ const list = type === 'service' ? manifest.services : manifest.packages;
41
+
42
+ const alreadyExists = list.some((entry) => {
43
+ if (typeof entry === 'string') return entry === name;
44
+ if (typeof entry === 'object' && entry !== null) return Object.keys(entry).includes(name);
45
+ return false;
46
+ });
47
+
48
+ if (alreadyExists) {
49
+ return false;
50
+ }
51
+
52
+ if (name === dirName) {
53
+ list.push(name);
54
+ } else {
55
+ list.push({ [name]: { path: `./${dirName}` } });
56
+ }
57
+ return true;
58
+ }
59
+
60
+ /**
61
+ * Persists the manifest to disk, preserving comments when possible.
62
+ */
63
+ export function saveManifest(
64
+ manifestPath: string,
65
+ manifest: OctoManifest,
66
+ originalContent?: string,
67
+ ): void {
68
+ const yaml = printManifest(manifest, originalContent);
69
+ writeFileSync(manifestPath, yaml, 'utf-8');
70
+ }
@@ -0,0 +1,39 @@
1
+ import { basename } from 'node:path';
2
+ import { run } from './process-runner.js';
3
+ import { logger } from './logger.js';
4
+ import { OctoError } from './errors.js';
5
+
6
+ /**
7
+ * Extracts the repository name from a git URL.
8
+ * Supports HTTPS and SSH formats:
9
+ * https://github.com/user/repo.git → repo
10
+ * git@github.com:user/repo.git → repo
11
+ * https://github.com/user/repo → repo
12
+ */
13
+ export function extractRepoName(url: string): string {
14
+ const cleaned = url.replace(/\.git$/, '').replace(/\/$/, '');
15
+ const parts = cleaned.split(/[/:]/);
16
+ const name = parts[parts.length - 1];
17
+ if (!name) {
18
+ throw new OctoError(`Não foi possível extrair o nome do repositório de: ${url}`);
19
+ }
20
+ return name;
21
+ }
22
+
23
+ /**
24
+ * Clones a git repository into the target directory.
25
+ */
26
+ export async function cloneRepository(url: string, targetDir: string): Promise<void> {
27
+ logger.info(`Clonando ${url}...`);
28
+
29
+ const result = await run('git', ['clone', url, targetDir], {
30
+ timeout: 120_000,
31
+ interactive: true,
32
+ });
33
+
34
+ if (result.exitCode !== 0) {
35
+ throw new OctoError(`Falha ao clonar repositório.`);
36
+ }
37
+
38
+ logger.info(`Repositório clonado em ./${basename(targetDir)}`);
39
+ }
@@ -4,6 +4,8 @@ export interface RunOptions {
4
4
  cwd?: string;
5
5
  timeout?: number;
6
6
  env?: Record<string, string>;
7
+ /** When true, inherits stdio so the user can interact (e.g. git password prompts) */
8
+ interactive?: boolean;
7
9
  }
8
10
 
9
11
  export interface RunResult {
@@ -14,20 +16,23 @@ export interface RunResult {
14
16
 
15
17
  /** Wrapper for child_process.spawn with Promise, timeout, and stdout/stderr capture */
16
18
  export function run(command: string, args: string[] = [], options: RunOptions = {}): Promise<RunResult> {
17
- const { cwd, timeout = 60_000, env } = options;
19
+ const { cwd, timeout = 60_000, env, interactive = false } = options;
18
20
 
19
21
  return new Promise((resolve, reject) => {
20
22
  const child = spawn(command, args, {
21
23
  cwd,
22
24
  env: env ? { ...process.env, ...env } : process.env,
23
25
  shell: true,
26
+ stdio: interactive ? 'inherit' : 'pipe',
24
27
  });
25
28
 
26
29
  let stdout = '';
27
30
  let stderr = '';
28
31
 
29
- child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
30
- child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
32
+ if (!interactive) {
33
+ child.stdout!.on('data', (data: Buffer) => { stdout += data.toString(); });
34
+ child.stderr!.on('data', (data: Buffer) => { stderr += data.toString(); });
35
+ }
31
36
 
32
37
  const timer = setTimeout(() => {
33
38
  child.kill('SIGTERM');