@enbox/gitd 0.2.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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * `gitd auth` — identity and profile management.
3
+ *
4
+ * Usage:
5
+ * gitd auth Show current identity info
6
+ * gitd auth login Create or import an identity
7
+ * gitd auth list List all profiles
8
+ * gitd auth use <profile> Set profile for current repo
9
+ * gitd auth use <profile> --global Set default profile
10
+ * gitd auth export [profile] Export portable identity
11
+ * gitd auth import Import from recovery phrase
12
+ * gitd auth logout [profile] Remove a profile
13
+ *
14
+ * @module
15
+ */
16
+
17
+ import type { AgentContext } from '../agent.js';
18
+
19
+ import * as p from '@clack/prompts';
20
+
21
+ import { connectAgent } from '../agent.js';
22
+ import {
23
+ enboxHome,
24
+ listProfiles,
25
+ profileDataPath,
26
+ readConfig,
27
+ resolveProfile,
28
+ setGitConfigProfile,
29
+ upsertProfile,
30
+ writeConfig,
31
+ } from '../../profiles/config.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Sub-command dispatch
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /**
38
+ * The auth command can run without a pre-existing AgentContext (for `login`,
39
+ * `list`), but some sub-commands need one (for `export`). We accept
40
+ * ctx as optional and connect lazily when needed.
41
+ */
42
+ export async function authCommand(ctx: AgentContext | null, args: string[]): Promise<void> {
43
+ const sub = args[0];
44
+
45
+ switch (sub) {
46
+ case 'login': return authLogin();
47
+ case 'list': return authList();
48
+ case 'use': return authUse(args.slice(1));
49
+ case 'logout': return authLogout(args.slice(1));
50
+ default: return authInfo(ctx);
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // auth (no subcommand) — show current identity
56
+ // ---------------------------------------------------------------------------
57
+
58
+ async function authInfo(ctx: AgentContext | null): Promise<void> {
59
+ const config = readConfig();
60
+ const profileName = resolveProfile();
61
+
62
+ if (!profileName || !config.profiles[profileName]) {
63
+ p.log.warn('No identity configured. Run `gitd auth login` to get started.');
64
+ return;
65
+ }
66
+
67
+ const entry = config.profiles[profileName];
68
+
69
+ p.log.info(`Profile: ${profileName}${config.defaultProfile === profileName ? ' (default)' : ''}`);
70
+ p.log.info(`DID: ${entry.did}`);
71
+ p.log.info(`Created: ${entry.createdAt}`);
72
+ p.log.info(`Data: ${profileDataPath(profileName)}`);
73
+
74
+ if (ctx) {
75
+ p.log.info(`Connected: ${ctx.did}`);
76
+ }
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // auth login — create or import identity
81
+ // ---------------------------------------------------------------------------
82
+
83
+ async function authLogin(): Promise<void> {
84
+ p.intro('Identity setup');
85
+
86
+ const action = await p.select({
87
+ message : 'What would you like to do?',
88
+ options : [
89
+ { value: 'create', label: 'Create a new identity' },
90
+ { value: 'import', label: 'Import from recovery phrase' },
91
+ ],
92
+ });
93
+
94
+ if (p.isCancel(action)) {
95
+ p.cancel('Cancelled.');
96
+ return;
97
+ }
98
+
99
+ const name = await p.text({
100
+ message : 'Name this profile:',
101
+ placeholder : 'personal',
102
+ validate(val) {
103
+ if (!val?.trim()) { return 'Profile name is required.'; }
104
+ if (!/^[a-zA-Z0-9_-]+$/.test(val)) { return 'Only letters, numbers, hyphens, and underscores.'; }
105
+ const existing = listProfiles();
106
+ if (existing.includes(val)) { return `Profile "${val}" already exists.`; }
107
+ },
108
+ });
109
+
110
+ if (p.isCancel(name)) {
111
+ p.cancel('Cancelled.');
112
+ return;
113
+ }
114
+
115
+ const profileName = (name as string).trim();
116
+
117
+ const password = await p.password({
118
+ message: 'Choose a password for your vault:',
119
+ validate(val) {
120
+ if (!val || (val as string).length < 4) { return 'Password must be at least 4 characters.'; }
121
+ },
122
+ });
123
+
124
+ if (p.isCancel(password)) {
125
+ p.cancel('Cancelled.');
126
+ return;
127
+ }
128
+
129
+ let recoveryInput: string | undefined;
130
+
131
+ if (action === 'import') {
132
+ const phrase = await p.text({
133
+ message : 'Enter your 12-word recovery phrase:',
134
+ placeholder : 'abandon ability able about above absent ...',
135
+ validate(val) {
136
+ if (!val) { return 'Recovery phrase is required.'; }
137
+ const words = val.trim().split(/\s+/);
138
+ if (words.length !== 12) { return 'Recovery phrase must be exactly 12 words.'; }
139
+ },
140
+ });
141
+
142
+ if (p.isCancel(phrase)) {
143
+ p.cancel('Cancelled.');
144
+ return;
145
+ }
146
+
147
+ recoveryInput = (phrase as string).trim();
148
+ }
149
+
150
+ const spin = p.spinner();
151
+ spin.start('Creating identity...');
152
+
153
+ try {
154
+ const dataPath = profileDataPath(profileName);
155
+ const result = await connectAgent({
156
+ password : password as string,
157
+ dataPath,
158
+ recoveryPhrase : recoveryInput,
159
+ });
160
+
161
+ // Save profile metadata.
162
+ upsertProfile(profileName, {
163
+ name : profileName,
164
+ did : result.did,
165
+ createdAt : new Date().toISOString(),
166
+ });
167
+
168
+ spin.stop('Identity created!');
169
+
170
+ p.log.success(`DID: ${result.did}`);
171
+ p.log.success(`Profile: ${profileName} (${dataPath})`);
172
+
173
+ if (result.recoveryPhrase) {
174
+ p.log.warn('');
175
+ p.log.warn('Your recovery phrase (write this down!):');
176
+ p.log.warn(` ${result.recoveryPhrase}`);
177
+ p.log.warn('');
178
+ p.log.warn('This phrase can recover your identity if you lose your password.');
179
+ p.log.warn('Store it securely — it will NOT be shown again.');
180
+ }
181
+ } catch (err) {
182
+ spin.stop('Failed.');
183
+ p.log.error(`Failed to create identity: ${(err as Error).message}`);
184
+ process.exit(1);
185
+ }
186
+
187
+ p.outro('You\'re all set! Run `gitd whoami` to verify.');
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // auth list — list all profiles
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function authList(): void {
195
+ const config = readConfig();
196
+ const names = Object.keys(config.profiles);
197
+
198
+ if (names.length === 0) {
199
+ p.log.warn('No profiles found. Run `gitd auth login` to create one.');
200
+ return;
201
+ }
202
+
203
+ p.log.info(`Profiles (${enboxHome()}):\n`);
204
+
205
+ for (const name of names) {
206
+ const entry = config.profiles[name];
207
+ const isDefault = config.defaultProfile === name ? ' (default)' : '';
208
+ p.log.info(` ${name}${isDefault}`);
209
+ p.log.info(` DID: ${entry.did}`);
210
+ p.log.info(` Created: ${entry.createdAt}`);
211
+ }
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // auth use — set active profile
216
+ // ---------------------------------------------------------------------------
217
+
218
+ async function authUse(args: string[]): Promise<void> {
219
+ const name = args[0];
220
+ const isGlobal = args.includes('--global');
221
+
222
+ if (!name) {
223
+ p.log.error('Usage: gitd auth use <profile> [--global]');
224
+ process.exit(1);
225
+ }
226
+
227
+ const config = readConfig();
228
+ if (!config.profiles[name]) {
229
+ p.log.error(`Profile "${name}" not found. Run \`gitd auth list\` to see available profiles.`);
230
+ process.exit(1);
231
+ }
232
+
233
+ if (isGlobal) {
234
+ config.defaultProfile = name;
235
+ writeConfig(config);
236
+ p.log.success(`Default profile set to "${name}".`);
237
+ } else {
238
+ try {
239
+ setGitConfigProfile(name);
240
+ p.log.success(`Profile "${name}" set for this repository.`);
241
+ } catch {
242
+ p.log.error('Not in a git repository. Use --global to set the default profile.');
243
+ process.exit(1);
244
+ }
245
+ }
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // auth logout — remove a profile
250
+ // ---------------------------------------------------------------------------
251
+
252
+ async function authLogout(args: string[]): Promise<void> {
253
+ let name = args[0];
254
+
255
+ if (!name) {
256
+ name = resolveProfile() ?? '';
257
+ }
258
+
259
+ if (!name) {
260
+ p.log.error('No profile specified and no default profile found.');
261
+ process.exit(1);
262
+ }
263
+
264
+ const config = readConfig();
265
+ if (!config.profiles[name]) {
266
+ p.log.error(`Profile "${name}" not found.`);
267
+ process.exit(1);
268
+ }
269
+
270
+ const confirm = await p.confirm({
271
+ message: `Remove profile "${name}"? This will delete all local data for this identity.`,
272
+ });
273
+
274
+ if (p.isCancel(confirm) || !confirm) {
275
+ p.cancel('Cancelled.');
276
+ return;
277
+ }
278
+
279
+ // Remove from config (we don't delete the data directory — user can do that).
280
+ delete config.profiles[name];
281
+ if (config.defaultProfile === name) {
282
+ const remaining = Object.keys(config.profiles);
283
+ config.defaultProfile = remaining[0] ?? '';
284
+ }
285
+ writeConfig(config);
286
+
287
+ p.log.success(`Profile "${name}" removed.`);
288
+ p.log.info(`Data directory preserved at: ${profileDataPath(name)}`);
289
+ p.log.info('Delete it manually if you want to free disk space.');
290
+ }
package/src/cli/main.ts CHANGED
@@ -66,11 +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';
72
73
  import { createRequire } from 'node:module';
73
74
  import { daemonCommand } from './commands/daemon.js';
75
+ import { flagValue } from './flags.js';
74
76
  import { githubApiCommand } from './commands/github-api.js';
75
77
  import { indexerCommand } from '../indexer/main.js';
76
78
  import { initCommand } from './commands/init.js';
@@ -89,6 +91,7 @@ import { shimCommand } from './commands/shim.js';
89
91
  import { socialCommand } from './commands/social.js';
90
92
  import { webCommand } from './commands/web.js';
91
93
  import { wikiCommand } from './commands/wiki.js';
94
+ import { profileDataPath, resolveProfile } from '../profiles/config.js';
92
95
 
93
96
  // ---------------------------------------------------------------------------
94
97
  // Arg parsing
@@ -105,6 +108,11 @@ const rest = args.slice(1);
105
108
  function printUsage(): void {
106
109
  console.log('gitd — decentralized forge powered by DWN protocols\n');
107
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('');
108
116
  console.log(' setup Configure git for DID-based remotes');
109
117
  console.log(' clone <did>/<repo> Clone a repository via DID');
110
118
  console.log(' init <name> Create a repo record + bare git repo');
@@ -302,11 +310,19 @@ async function main(): Promise<void> {
302
310
  case 'clone':
303
311
  await cloneCommand(rest);
304
312
  return;
313
+
314
+ case 'auth':
315
+ // Auth can run without a pre-existing profile (for `login`).
316
+ await authCommand(null, rest);
317
+ return;
305
318
  }
306
319
 
307
320
  // Commands that require the Web5 agent.
308
321
  const password = await getPassword();
309
- 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 });
310
326
 
311
327
  switch (command) {
312
328
  case 'init':
@@ -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
+ }