octo-dev 0.6.0 → 0.7.1

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.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Build orchestration, semantic versioning, and local infrastructure management for repository workspaces",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
 
package/src/cli/index.ts CHANGED
@@ -11,7 +11,7 @@ const program = new Command();
11
11
  program
12
12
  .name('octo')
13
13
  .description('Build orchestration, semantic versioning, and local infrastructure management for repository workspaces')
14
- .version('0.6.0');
14
+ .version('0.7.1');
15
15
 
16
16
  program
17
17
  .command('init')
@@ -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
  }
@@ -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
+ }
@@ -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 };
@@ -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)}`);
@@ -5,7 +5,7 @@ export const logger = pino({
5
5
  target: 'pino-pretty',
6
6
  options: {
7
7
  colorize: true,
8
- ignore: 'pid,hostname,time',
8
+ ignore: 'pid,hostname,time,level',
9
9
  messageFormat: '{msg}',
10
10
  },
11
11
  },
@@ -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,82 @@
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
+ // Acquire token once for all clones
31
+ const token = await acquireGitToken();
32
+
33
+ try {
34
+ let cloned = 0;
35
+ for (const { name, path: explicitPath } of pending) {
36
+ const repoName = extractRepoName(name);
37
+ const targetDir = resolve(rootDir, explicitPath ?? repoName);
38
+ const baseUrl = resolveGitUrl(name);
39
+ const url = token ? embedTokenInUrl(baseUrl, token) : baseUrl;
40
+ await cloneRepository(url, targetDir);
41
+ cloned++;
42
+ }
43
+
44
+ logger.info(`Synced ${cloned} repository(ies).`);
45
+ return cloned;
46
+ } finally {
47
+ clearCachedToken();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Extracts all entry names and optional path overrides from the manifest.
53
+ *
54
+ * @param manifest - The parsed octo.yaml manifest.
55
+ * @returns Array of entries with name and optional explicit path.
56
+ */
57
+ function collectEntries(manifest: OctoManifest): Array<{ name: string; path?: string }> {
58
+ const results: Array<{ name: string; path?: string }> = [];
59
+
60
+ for (const entry of manifest.services) {
61
+ results.push(resolveEntry(entry));
62
+ }
63
+ for (const entry of manifest.packages ?? []) {
64
+ results.push(resolveEntry(entry));
65
+ }
66
+
67
+ return results;
68
+ }
69
+
70
+ /**
71
+ * Resolves the name and optional path from a manifest entry.
72
+ * String entries return name only. Object entries extract key + path config.
73
+ *
74
+ * @param entry - A service or package entry from octo.yaml.
75
+ * @returns Object with resolved name and optional path override.
76
+ */
77
+ function resolveEntry(entry: ServiceEntry | PackageEntry): { name: string; path?: string } {
78
+ if (typeof entry === 'string') return { name: entry };
79
+ const key = Object.keys(entry)[0];
80
+ const config = (entry as Record<string, { path?: string }>)[key];
81
+ return { name: key, path: config?.path };
82
+ }