@enbox/gitd 0.1.0 → 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.
@@ -23,8 +23,14 @@
23
23
 
24
24
  import type { AgentContext } from '../agent.js';
25
25
 
26
+ import { createFullBundle } from '../../git-server/bundle-sync.js';
27
+ import { flagValue } from '../flags.js';
28
+ import { getRepoContext } from '../repo-context.js';
26
29
  import { getRepoContextId } from '../repo-context.js';
27
- import { spawnSync } from 'node:child_process';
30
+ import { GitBackend } from '../../git-server/git-backend.js';
31
+ import { readGitRefs } from '../../git-server/ref-sync.js';
32
+ import { readFile, unlink } from 'node:fs/promises';
33
+ import { spawn, spawnSync } from 'node:child_process';
28
34
 
29
35
  // ---------------------------------------------------------------------------
30
36
  // GitHub auth — resolve a token from env or the gh CLI
@@ -63,7 +69,7 @@ export function resolveGitHubToken(): string | undefined {
63
69
  try {
64
70
  const result = spawnSync('gh', ['auth', 'token'], {
65
71
  stdio : ['pipe', 'pipe', 'pipe'],
66
- timeout : 5_000,
72
+ timeout : 2_000,
67
73
  });
68
74
  const token = result.stdout?.toString().trim();
69
75
  if (result.status === 0 && token) {
@@ -230,7 +236,7 @@ export async function migrateCommand(ctx: AgentContext, args: string[]): Promise
230
236
  case 'pulls': return migratePulls(ctx, rest);
231
237
  case 'releases': return migrateReleases(ctx, rest);
232
238
  default:
233
- console.error('Usage: gitd migrate <all|repo|issues|pulls|releases> <owner/repo>');
239
+ console.error('Usage: gitd migrate <all|repo|issues|pulls|releases> [owner/repo] [--repos <path>]');
234
240
  process.exit(1);
235
241
  }
236
242
  }
@@ -327,8 +333,18 @@ function prependAuthor(body: string, ghLogin: string): string {
327
333
  // migrate all
328
334
  // ---------------------------------------------------------------------------
329
335
 
336
+ /**
337
+ * Resolve the repos base path from CLI flags or environment.
338
+ * Returns `null` when no explicit path was provided, so callers can
339
+ * decide whether to skip git content migration.
340
+ */
341
+ function resolveReposPath(args: string[]): string | null {
342
+ return flagValue(args, '--repos') ?? process.env.GITD_REPOS ?? null;
343
+ }
344
+
330
345
  async function migrateAll(ctx: AgentContext, args: string[]): Promise<void> {
331
346
  const { owner, repo } = resolveGhRepo(args);
347
+ const reposPath = resolveReposPath(args);
332
348
  const slug = `${owner}/${repo}`;
333
349
 
334
350
  const token = resolveGitHubToken();
@@ -343,13 +359,26 @@ async function migrateAll(ctx: AgentContext, args: string[]): Promise<void> {
343
359
  // Step 1: repo metadata.
344
360
  await migrateRepoInner(ctx, owner, repo);
345
361
 
346
- // Step 2: issues + comments.
362
+ // Step 2: git content (clone, bundle, refs).
363
+ if (reposPath) {
364
+ try {
365
+ await migrateGitContent(ctx, owner, repo, reposPath);
366
+ } catch (err) {
367
+ console.error(` Warning: git content migration failed: ${(err as Error).message}`);
368
+ console.error(' Metadata, issues, PRs, and releases will still be imported.');
369
+ console.error(' Re-run with a valid --repos path to retry git content migration.\n');
370
+ }
371
+ } else {
372
+ console.log(' Skipping git content — pass --repos <path> or set GITD_REPOS to include git data.');
373
+ }
374
+
375
+ // Step 3: issues + comments.
347
376
  const issueCount = await migrateIssuesInner(ctx, owner, repo);
348
377
 
349
- // Step 3: pull requests + reviews.
378
+ // Step 4: pull requests + reviews.
350
379
  const pullCount = await migratePullsInner(ctx, owner, repo);
351
380
 
352
- // Step 4: releases.
381
+ // Step 5: releases.
353
382
  const releaseCount = await migrateReleasesInner(ctx, owner, repo);
354
383
 
355
384
  console.log(`\nMigration complete: ${slug}`);
@@ -368,8 +397,14 @@ async function migrateAll(ctx: AgentContext, args: string[]): Promise<void> {
368
397
 
369
398
  async function migrateRepo(ctx: AgentContext, args: string[]): Promise<void> {
370
399
  const { owner, repo } = resolveGhRepo(args);
400
+ const reposPath = resolveReposPath(args);
371
401
  try {
372
402
  await migrateRepoInner(ctx, owner, repo);
403
+ if (reposPath) {
404
+ await migrateGitContent(ctx, owner, repo, reposPath);
405
+ } else {
406
+ console.log(' Skipping git content — pass --repos <path> or set GITD_REPOS to include git data.');
407
+ }
373
408
  } catch (err) {
374
409
  console.error(`Failed to migrate repo: ${(err as Error).message}`);
375
410
  process.exit(1);
@@ -413,6 +448,131 @@ async function migrateRepoInner(ctx: AgentContext, owner: string, repo: string):
413
448
  console.log(` Source: ${gh.html_url}`);
414
449
  }
415
450
 
451
+ // ---------------------------------------------------------------------------
452
+ // migrate git content (clone + bundle + refs)
453
+ // ---------------------------------------------------------------------------
454
+
455
+ /**
456
+ * Clone the GitHub repository as a bare repo, create a full git bundle,
457
+ * upload it to DWN, and sync all refs to DWN records.
458
+ *
459
+ * This is the "git content" migration step that turns the metadata-only
460
+ * repo record into a fully cloneable repo.
461
+ */
462
+ async function migrateGitContent(
463
+ ctx: AgentContext,
464
+ owner: string,
465
+ repo: string,
466
+ reposPath: string,
467
+ ): Promise<void> {
468
+ const slug = `${owner}/${repo}`;
469
+ console.log(`Importing git content from ${slug}...`);
470
+
471
+ const backend = new GitBackend({ basePath: reposPath });
472
+ const repoPath = backend.repoPath(ctx.did, repo);
473
+
474
+ // Step 1: Clone bare repo from GitHub (or skip if already on disk).
475
+ if (backend.exists(ctx.did, repo)) {
476
+ console.log(` Bare repo already exists at ${repoPath} — skipping clone.`);
477
+ } else {
478
+ const cloneUrl = buildCloneUrl(owner, repo);
479
+ console.log(` Cloning ${cloneUrl} → ${repoPath}`);
480
+ await cloneBare(cloneUrl, repoPath);
481
+ console.log(' Clone complete.');
482
+ }
483
+
484
+ // Step 2: Create full git bundle.
485
+ console.log(' Creating git bundle...');
486
+ const bundleInfo = await createFullBundle(repoPath);
487
+ console.log(` Bundle: ${bundleInfo.size} bytes, ${bundleInfo.refCount} ref(s), tip: ${bundleInfo.tipCommit.slice(0, 8)}`);
488
+
489
+ // Step 3: Upload bundle to DWN.
490
+ const { contextId: repoContextId, visibility } = await getRepoContext(ctx);
491
+ const encrypt = visibility === 'private';
492
+
493
+ try {
494
+ const bundleData = new Uint8Array(await readFile(bundleInfo.path));
495
+
496
+ const { status } = await ctx.repo.records.create('repo/bundle', {
497
+ data : bundleData,
498
+ dataFormat : 'application/x-git-bundle',
499
+ tags : {
500
+ tipCommit : bundleInfo.tipCommit,
501
+ isFull : true,
502
+ refCount : bundleInfo.refCount,
503
+ size : bundleInfo.size,
504
+ },
505
+ parentContextId : repoContextId,
506
+ encryption : encrypt,
507
+ } as any);
508
+
509
+ if (status.code >= 300) {
510
+ throw new Error(`Failed to create bundle record: ${status.code} ${status.detail}`);
511
+ }
512
+ console.log(' Bundle uploaded to DWN.');
513
+ } finally {
514
+ await unlink(bundleInfo.path).catch(() => {});
515
+ }
516
+
517
+ // Step 4: Sync git refs to DWN.
518
+ console.log(' Syncing refs to DWN...');
519
+ const gitRefs = await readGitRefs(repoPath);
520
+
521
+ let refCount = 0;
522
+ for (const ref of gitRefs) {
523
+ const { status } = await ctx.refs.records.create('repo/ref', {
524
+ data : { name: ref.name, target: ref.target, type: ref.type },
525
+ tags : { name: ref.name, type: ref.type, target: ref.target },
526
+ parentContextId : repoContextId,
527
+ });
528
+
529
+ if (status.code >= 300) {
530
+ console.error(` Failed to sync ref ${ref.name}: ${status.code} ${status.detail}`);
531
+ continue;
532
+ }
533
+ refCount++;
534
+ }
535
+
536
+ console.log(` Synced ${refCount} ref(s) to DWN.`);
537
+ console.log(` Git content migration complete.`);
538
+ }
539
+
540
+ /**
541
+ * Build the clone URL for a GitHub repository.
542
+ * Uses the token (if available) for authenticated HTTPS clone.
543
+ */
544
+ function buildCloneUrl(owner: string, repo: string): string {
545
+ const token = resolveGitHubToken();
546
+ if (token) {
547
+ return `https://${token}@github.com/${owner}/${repo}.git`;
548
+ }
549
+ return `https://github.com/${owner}/${repo}.git`;
550
+ }
551
+
552
+ /**
553
+ * Clone a git repository as a bare repo using `git clone --bare`.
554
+ */
555
+ function cloneBare(url: string, destPath: string): Promise<void> {
556
+ return new Promise((resolve, reject) => {
557
+ const child = spawn('git', ['clone', '--bare', url, destPath], {
558
+ stdio: ['pipe', 'pipe', 'pipe'],
559
+ });
560
+
561
+ const stderrChunks: Buffer[] = [];
562
+ child.stderr!.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
563
+
564
+ child.on('error', reject);
565
+ child.on('exit', (code: number | null) => {
566
+ if (code !== 0) {
567
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8');
568
+ reject(new Error(`git clone --bare failed (exit ${code}): ${stderr}`));
569
+ } else {
570
+ resolve();
571
+ }
572
+ });
573
+ });
574
+ }
575
+
416
576
  // ---------------------------------------------------------------------------
417
577
  // migrate issues
418
578
  // ---------------------------------------------------------------------------
package/src/cli/main.ts CHANGED
@@ -66,10 +66,13 @@
66
66
  * @module
67
67
  */
68
68
 
69
+ import { authCommand } from './commands/auth.js';
69
70
  import { ciCommand } from './commands/ci.js';
70
71
  import { cloneCommand } from './commands/clone.js';
71
72
  import { connectAgent } from './agent.js';
73
+ import { createRequire } from 'node:module';
72
74
  import { daemonCommand } from './commands/daemon.js';
75
+ import { flagValue } from './flags.js';
73
76
  import { githubApiCommand } from './commands/github-api.js';
74
77
  import { indexerCommand } from '../indexer/main.js';
75
78
  import { initCommand } from './commands/init.js';
@@ -88,6 +91,7 @@ import { shimCommand } from './commands/shim.js';
88
91
  import { socialCommand } from './commands/social.js';
89
92
  import { webCommand } from './commands/web.js';
90
93
  import { wikiCommand } from './commands/wiki.js';
94
+ import { profileDataPath, resolveProfile } from '../profiles/config.js';
91
95
 
92
96
  // ---------------------------------------------------------------------------
93
97
  // Arg parsing
@@ -104,6 +108,11 @@ const rest = args.slice(1);
104
108
  function printUsage(): void {
105
109
  console.log('gitd — decentralized forge powered by DWN protocols\n');
106
110
  console.log('Commands:');
111
+ console.log(' auth Show current identity info');
112
+ console.log(' auth login Create or import an identity');
113
+ console.log(' auth list List all profiles');
114
+ console.log(' auth use <profile> [--global] Set active profile');
115
+ console.log('');
107
116
  console.log(' setup Configure git for DID-based remotes');
108
117
  console.log(' clone <did>/<repo> Clone a repository via DID');
109
118
  console.log(' init <name> Create a repo record + bare git repo');
@@ -216,8 +225,49 @@ async function getPassword(): Promise<string> {
216
225
  const env = process.env.GITD_PASSWORD;
217
226
  if (env) { return env; }
218
227
 
219
- // Interactive prompt via stdin.
228
+ // Interactive prompt hide input when running in a TTY.
220
229
  process.stdout.write('Vault password: ');
230
+
231
+ if (process.stdin.isTTY) {
232
+ // Raw mode: read character-by-character, echo nothing.
233
+ const password = await new Promise<string>((resolve) => {
234
+ let buf = '';
235
+ process.stdin.setRawMode(true);
236
+ process.stdin.setEncoding('utf8');
237
+ process.stdin.resume();
238
+
239
+ const onData = (ch: string): void => {
240
+ const code = ch.charCodeAt(0);
241
+
242
+ if (ch === '\r' || ch === '\n') {
243
+ // Enter — done.
244
+ process.stdin.setRawMode(false);
245
+ process.stdin.pause();
246
+ process.stdin.removeListener('data', onData);
247
+ process.stdout.write('\n');
248
+ resolve(buf);
249
+ } else if (code === 3) {
250
+ // Ctrl-C — abort.
251
+ process.stdin.setRawMode(false);
252
+ process.stdout.write('\n');
253
+ process.exit(130);
254
+ } else if (code === 127 || code === 8) {
255
+ // Backspace / Delete.
256
+ if (buf.length > 0) {
257
+ buf = buf.slice(0, -1);
258
+ }
259
+ } else if (code >= 32) {
260
+ // Printable character.
261
+ buf += ch;
262
+ }
263
+ };
264
+
265
+ process.stdin.on('data', onData);
266
+ });
267
+ return password;
268
+ }
269
+
270
+ // Non-TTY fallback (piped input).
221
271
  const response = await new Promise<string>((resolve) => {
222
272
  let buf = '';
223
273
  process.stdin.setEncoding('utf8');
@@ -234,7 +284,18 @@ async function getPassword(): Promise<string> {
234
284
  // Main
235
285
  // ---------------------------------------------------------------------------
236
286
 
287
+ function printVersion(): void {
288
+ const require = createRequire(import.meta.url);
289
+ const pkg = require('../../package.json') as { version: string };
290
+ console.log(`gitd ${pkg.version}`);
291
+ }
292
+
237
293
  async function main(): Promise<void> {
294
+ if (command === '--version' || command === '-v' || command === 'version') {
295
+ printVersion();
296
+ return;
297
+ }
298
+
238
299
  if (!command || command === 'help' || command === '--help' || command === '-h') {
239
300
  printUsage();
240
301
  return;
@@ -249,11 +310,19 @@ async function main(): Promise<void> {
249
310
  case 'clone':
250
311
  await cloneCommand(rest);
251
312
  return;
313
+
314
+ case 'auth':
315
+ // Auth can run without a pre-existing profile (for `login`).
316
+ await authCommand(null, rest);
317
+ return;
252
318
  }
253
319
 
254
320
  // Commands that require the Web5 agent.
255
321
  const password = await getPassword();
256
- const ctx = await connectAgent(password);
322
+ const profileFlag = flagValue(rest, '--profile');
323
+ const profileName = resolveProfile(profileFlag);
324
+ const dataPath = profileName ? profileDataPath(profileName) : undefined;
325
+ const ctx = await connectAgent({ password, dataPath });
257
326
 
258
327
  switch (command) {
259
328
  case 'init':
@@ -342,6 +411,13 @@ async function main(): Promise<void> {
342
411
  printUsage();
343
412
  process.exit(1);
344
413
  }
414
+
415
+ // One-shot commands reach here after completing. The Web5 agent keeps
416
+ // LevelDB stores and other handles open, which prevents the process from
417
+ // exiting naturally. Long-running commands (serve, web, daemon, indexer,
418
+ // github-api, shim) never reach this point because they block on an
419
+ // infinite promise internally.
420
+ process.exit(0);
345
421
  }
346
422
 
347
423
  main().catch((err: Error) => {
@@ -16,12 +16,15 @@
16
16
  * helper = /path/to/git-remote-did-credential
17
17
  *
18
18
  * Environment:
19
- * GITD_PASSWORD — vault password for the local agent
19
+ * GITD_PASSWORD — vault password for the local agent
20
+ * ENBOX_PROFILE — (optional) profile name override
20
21
  *
21
22
  * @module
22
23
  */
23
24
 
24
- import { Web5 } from '@enbox/api';
25
+ import type { BearerDid } from '@enbox/dids';
26
+
27
+ import { Web5UserAgent } from '@enbox/agent';
25
28
 
26
29
  import {
27
30
  createPushTokenPayload,
@@ -33,6 +36,7 @@ import {
33
36
  formatCredentialResponse,
34
37
  parseCredentialRequest,
35
38
  } from './credential-helper.js';
39
+ import { profileDataPath, resolveProfile } from '../profiles/config.js';
36
40
 
37
41
  // ---------------------------------------------------------------------------
38
42
  // Main
@@ -63,10 +67,7 @@ async function main(): Promise<void> {
63
67
  return;
64
68
  }
65
69
 
66
- const { web5, did } = await Web5.connect({
67
- password,
68
- sync: 'off',
69
- });
70
+ const { did, bearerDid } = await connectForCredentials(password);
70
71
 
71
72
  // Extract the owner DID and repo from the URL path.
72
73
  const path = request.path ?? '';
@@ -82,8 +83,8 @@ async function main(): Promise<void> {
82
83
  const payload = createPushTokenPayload(did, ownerDid, repo);
83
84
  const token = encodePushToken(payload);
84
85
 
85
- // Sign the token using the agent's DID signer.
86
- const signer = await web5.agent.agentDid.getSigner();
86
+ // Sign the token using the identity's DID signer (not the agent DID).
87
+ const signer = await bearerDid.getSigner();
87
88
  const tokenBytes = new TextEncoder().encode(token);
88
89
  const signature = await signer.sign({ data: tokenBytes });
89
90
  const signatureBase64url = Buffer.from(signature).toString('base64url');
@@ -96,6 +97,38 @@ async function main(): Promise<void> {
96
97
  process.stdout.write(formatCredentialResponse(creds));
97
98
  }
98
99
 
100
+ // ---------------------------------------------------------------------------
101
+ // Agent connection
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Connect to the Web5 agent and return the identity DID and its BearerDid.
106
+ *
107
+ * Resolves the active profile (env, git config, global default, or single
108
+ * fallback) and connects using the profile's agent data path. Falls back
109
+ * to the legacy CWD-relative `DATA/AGENT` path when no profile exists.
110
+ */
111
+ async function connectForCredentials(
112
+ password: string,
113
+ ): Promise<{ did: string; bearerDid: BearerDid }> {
114
+ // Resolve profile (env, git config, global default, single fallback).
115
+ // When a profile exists, the agent lives at ~/.enbox/profiles/<name>/DATA/AGENT.
116
+ // Otherwise, fall back to the CWD-relative default path (legacy).
117
+ const profileName = resolveProfile();
118
+ const dataPath = profileName ? profileDataPath(profileName) : undefined;
119
+
120
+ const agent = await Web5UserAgent.create(dataPath ? { dataPath } : undefined);
121
+ await agent.start({ password });
122
+
123
+ const identities = await agent.identity.list();
124
+ const identity = identities[0];
125
+ if (!identity) {
126
+ throw new Error('No identity found in agent');
127
+ }
128
+
129
+ return { did: identity.did.uri, bearerDid: identity.did };
130
+ }
131
+
99
132
  // ---------------------------------------------------------------------------
100
133
  // Helpers
101
134
  // ---------------------------------------------------------------------------
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Profile configuration — persistent config at `~/.enbox/config.json`.
3
+ *
4
+ * Manages the set of named identity profiles and the default selection.
5
+ * This module handles reading, writing, and resolving profiles across
6
+ * multiple selection sources (flag, env, git config, global default).
7
+ *
8
+ * Storage layout:
9
+ * ~/.enbox/
10
+ * config.json Global config (this module)
11
+ * profiles/
12
+ * <name>/DATA/AGENT/... Per-profile Web5 agent stores
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Base directory for all enbox data. Override with `ENBOX_HOME`. */
27
+ export function enboxHome(): string {
28
+ return process.env.ENBOX_HOME ?? join(homedir(), '.enbox');
29
+ }
30
+
31
+ /** Path to the global config file. */
32
+ export function configPath(): string {
33
+ return join(enboxHome(), 'config.json');
34
+ }
35
+
36
+ /** Base directory for all profile agent data. */
37
+ export function profilesDir(): string {
38
+ return join(enboxHome(), 'profiles');
39
+ }
40
+
41
+ /** Path to a specific profile's agent data directory. */
42
+ export function profileDataPath(name: string): string {
43
+ return join(profilesDir(), name, 'DATA', 'AGENT');
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Config types
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** Metadata about a single profile stored in config.json. */
51
+ export type ProfileEntry = {
52
+ /** Display name for the profile. */
53
+ name : string;
54
+ /** The DID URI associated with this profile. */
55
+ did : string;
56
+ /** ISO 8601 timestamp when the profile was created. */
57
+ createdAt : string;
58
+ };
59
+
60
+ /** Top-level config.json shape. */
61
+ export type EnboxConfig = {
62
+ /** Schema version for forward-compatibility. */
63
+ version : number;
64
+ /** Name of the default profile. */
65
+ defaultProfile : string;
66
+ /** Map of profile name → metadata. */
67
+ profiles : Record<string, ProfileEntry>;
68
+ };
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Config I/O
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /** Read the global config. Returns a default if the file doesn't exist. */
75
+ export function readConfig(): EnboxConfig {
76
+ const path = configPath();
77
+ if (!existsSync(path)) {
78
+ return { version: 1, defaultProfile: '', profiles: {} };
79
+ }
80
+ const raw = readFileSync(path, 'utf-8');
81
+ return JSON.parse(raw) as EnboxConfig;
82
+ }
83
+
84
+ /** Write the global config atomically. */
85
+ export function writeConfig(config: EnboxConfig): void {
86
+ const path = configPath();
87
+ mkdirSync(join(path, '..'), { recursive: true });
88
+ writeFileSync(path, JSON.stringify(config, null, 2) + '\n', 'utf-8');
89
+ }
90
+
91
+ /** Add or update a profile entry in the global config. */
92
+ export function upsertProfile(name: string, entry: ProfileEntry): void {
93
+ const config = readConfig();
94
+ config.profiles[name] = entry;
95
+
96
+ // If this is the first profile, make it the default.
97
+ if (!config.defaultProfile || Object.keys(config.profiles).length === 1) {
98
+ config.defaultProfile = name;
99
+ }
100
+
101
+ writeConfig(config);
102
+ }
103
+
104
+ /** Remove a profile entry from the global config. */
105
+ export function removeProfile(name: string): void {
106
+ const config = readConfig();
107
+ delete config.profiles[name];
108
+
109
+ // If we removed the default, pick the first remaining (or clear).
110
+ if (config.defaultProfile === name) {
111
+ const remaining = Object.keys(config.profiles);
112
+ config.defaultProfile = remaining[0] ?? '';
113
+ }
114
+
115
+ writeConfig(config);
116
+ }
117
+
118
+ /** List all profile names. */
119
+ export function listProfiles(): string[] {
120
+ return Object.keys(readConfig().profiles);
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Profile resolution
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Resolve which profile to use.
129
+ *
130
+ * Precedence (highest to lowest):
131
+ * 1. `--profile <name>` flag (passed as `flagProfile`)
132
+ * 2. `ENBOX_PROFILE` environment variable
133
+ * 3. `.git/config` → `[enbox] profile = <name>`
134
+ * 4. `~/.enbox/config.json` → `defaultProfile`
135
+ * 5. First (and only) profile, if exactly one exists
136
+ *
137
+ * Returns `null` when no profile can be resolved (i.e. none exist).
138
+ */
139
+ export function resolveProfile(flagProfile?: string): string | null {
140
+ // 1. Explicit flag.
141
+ if (flagProfile) { return flagProfile; }
142
+
143
+ // 2. Environment variable.
144
+ const envProfile = process.env.ENBOX_PROFILE;
145
+ if (envProfile) { return envProfile; }
146
+
147
+ // 3. Per-repo git config.
148
+ const gitProfile = readGitConfigProfile();
149
+ if (gitProfile) { return gitProfile; }
150
+
151
+ // 4. Global default.
152
+ const config = readConfig();
153
+ if (config.defaultProfile) { return config.defaultProfile; }
154
+
155
+ // 5. Single profile fallback.
156
+ const names = Object.keys(config.profiles);
157
+ if (names.length === 1) { return names[0]; }
158
+
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Read the `[enbox] profile` setting from the current repo's `.git/config`.
164
+ * Returns `null` if not in a git repo or the setting is absent.
165
+ */
166
+ function readGitConfigProfile(): string | null {
167
+ try {
168
+ const result = spawnSync('git', ['config', '--local', 'enbox.profile'], {
169
+ stdio : ['pipe', 'pipe', 'pipe'],
170
+ timeout : 2_000,
171
+ });
172
+ const value = result.stdout?.toString().trim();
173
+ if (result.status === 0 && value) { return value; }
174
+ } catch {
175
+ // Not in a git repo or git not available.
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /**
181
+ * Write the `[enbox] profile` setting to the current repo's `.git/config`.
182
+ */
183
+ export function setGitConfigProfile(name: string): void {
184
+ spawnSync('git', ['config', '--local', 'enbox.profile', name], {
185
+ stdio : ['pipe', 'pipe', 'pipe'],
186
+ timeout : 2_000,
187
+ });
188
+ }