@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.
@@ -16,7 +16,8 @@
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
  */
@@ -1 +1 @@
1
- {"version":3,"file":"credential-main.d.ts","sourceRoot":"","sources":["../../../src/git-remote/credential-main.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;GAoBG"}
1
+ {"version":3,"file":"credential-main.d.ts","sourceRoot":"","sources":["../../../src/git-remote/credential-main.ts"],"names":[],"mappings":";AACA;;;;;;;;;;;;;;;;;;;;;GAqBG"}
@@ -0,0 +1,69 @@
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
+ /** Base directory for all enbox data. Override with `ENBOX_HOME`. */
17
+ export declare function enboxHome(): string;
18
+ /** Path to the global config file. */
19
+ export declare function configPath(): string;
20
+ /** Base directory for all profile agent data. */
21
+ export declare function profilesDir(): string;
22
+ /** Path to a specific profile's agent data directory. */
23
+ export declare function profileDataPath(name: string): string;
24
+ /** Metadata about a single profile stored in config.json. */
25
+ export type ProfileEntry = {
26
+ /** Display name for the profile. */
27
+ name: string;
28
+ /** The DID URI associated with this profile. */
29
+ did: string;
30
+ /** ISO 8601 timestamp when the profile was created. */
31
+ createdAt: string;
32
+ };
33
+ /** Top-level config.json shape. */
34
+ export type EnboxConfig = {
35
+ /** Schema version for forward-compatibility. */
36
+ version: number;
37
+ /** Name of the default profile. */
38
+ defaultProfile: string;
39
+ /** Map of profile name → metadata. */
40
+ profiles: Record<string, ProfileEntry>;
41
+ };
42
+ /** Read the global config. Returns a default if the file doesn't exist. */
43
+ export declare function readConfig(): EnboxConfig;
44
+ /** Write the global config atomically. */
45
+ export declare function writeConfig(config: EnboxConfig): void;
46
+ /** Add or update a profile entry in the global config. */
47
+ export declare function upsertProfile(name: string, entry: ProfileEntry): void;
48
+ /** Remove a profile entry from the global config. */
49
+ export declare function removeProfile(name: string): void;
50
+ /** List all profile names. */
51
+ export declare function listProfiles(): string[];
52
+ /**
53
+ * Resolve which profile to use.
54
+ *
55
+ * Precedence (highest to lowest):
56
+ * 1. `--profile <name>` flag (passed as `flagProfile`)
57
+ * 2. `ENBOX_PROFILE` environment variable
58
+ * 3. `.git/config` → `[enbox] profile = <name>`
59
+ * 4. `~/.enbox/config.json` → `defaultProfile`
60
+ * 5. First (and only) profile, if exactly one exists
61
+ *
62
+ * Returns `null` when no profile can be resolved (i.e. none exist).
63
+ */
64
+ export declare function resolveProfile(flagProfile?: string): string | null;
65
+ /**
66
+ * Write the `[enbox] profile` setting to the current repo's `.git/config`.
67
+ */
68
+ export declare function setGitConfigProfile(name: string): void;
69
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../../src/profiles/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAWH,sEAAsE;AACtE,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,sCAAsC;AACtC,wBAAgB,UAAU,IAAI,MAAM,CAEnC;AAED,iDAAiD;AACjD,wBAAgB,WAAW,IAAI,MAAM,CAEpC;AAED,yDAAyD;AACzD,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEpD;AAMD,6DAA6D;AAC7D,MAAM,MAAM,YAAY,GAAG;IACzB,oCAAoC;IACpC,IAAI,EAAG,MAAM,CAAC;IACd,gDAAgD;IAChD,GAAG,EAAG,MAAM,CAAC;IACb,uDAAuD;IACvD,SAAS,EAAG,MAAM,CAAC;CACpB,CAAC;AAEF,mCAAmC;AACnC,MAAM,MAAM,WAAW,GAAG;IACxB,gDAAgD;IAChD,OAAO,EAAG,MAAM,CAAC;IACjB,mCAAmC;IACnC,cAAc,EAAG,MAAM,CAAC;IACxB,sCAAsC;IACtC,QAAQ,EAAG,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;CACzC,CAAC;AAMF,4EAA4E;AAC5E,wBAAgB,UAAU,IAAI,WAAW,CAOxC;AAED,0CAA0C;AAC1C,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CAIrD;AAED,0DAA0D;AAC1D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,IAAI,CAUrE;AAED,qDAAqD;AACrD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAWhD;AAED,8BAA8B;AAC9B,wBAAgB,YAAY,IAAI,MAAM,EAAE,CAEvC;AAMD;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAqBlE;AAoBD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAKtD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enbox/gitd",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Decentralized forge (GitHub alternative) built on DWN protocols",
5
5
  "type": "module",
6
6
  "main": "./dist/esm/index.js",
@@ -91,6 +91,7 @@
91
91
  "bun": ">=1.0.0"
92
92
  },
93
93
  "dependencies": {
94
+ "@clack/prompts": "^1.0.1",
94
95
  "@enbox/api": "0.3.0",
95
96
  "@enbox/crypto": "0.0.6",
96
97
  "@enbox/dids": "0.0.7",
package/src/cli/agent.ts CHANGED
@@ -6,12 +6,16 @@
6
6
  * the existing vault and return a ready-to-use `TypedWeb5` instance for
7
7
  * each protocol.
8
8
  *
9
+ * Agent data is stored under the resolved profile path:
10
+ * `~/.enbox/profiles/<profile>/DATA/AGENT/`
11
+ *
9
12
  * @module
10
13
  */
11
14
 
12
15
  import type { TypedWeb5 } from '@enbox/api';
13
16
 
14
17
  import { Web5 } from '@enbox/api';
18
+ import { Web5UserAgent } from '@enbox/agent';
15
19
 
16
20
  import type { ForgeCiSchemaMap } from '../ci.js';
17
21
  import type { ForgeIssuesSchemaMap } from '../issues.js';
@@ -58,6 +62,24 @@ export type AgentContext = {
58
62
  web5 : Web5;
59
63
  };
60
64
 
65
+ /** Options for connecting to the agent. */
66
+ export type ConnectOptions = {
67
+ /** Vault password. */
68
+ password : string;
69
+ /**
70
+ * Agent data path. When provided, the agent stores all data under
71
+ * this directory instead of the default `DATA/AGENT` relative to CWD.
72
+ *
73
+ * The profile system sets this to `~/.enbox/profiles/<name>/DATA/AGENT`.
74
+ */
75
+ dataPath? : string;
76
+ /**
77
+ * Optional recovery phrase (12-word BIP-39 mnemonic) for initializing
78
+ * a new vault. When omitted, a new phrase is generated automatically.
79
+ */
80
+ recoveryPhrase? : string;
81
+ };
82
+
61
83
  // ---------------------------------------------------------------------------
62
84
  // Agent bootstrap
63
85
  // ---------------------------------------------------------------------------
@@ -65,24 +87,85 @@ export type AgentContext = {
65
87
  /**
66
88
  * Connect to the local Web5 agent, initializing on first launch.
67
89
  *
68
- * The agent's persistent data lives under `dataPath` (default:
69
- * `~/.gitd/agent`). Sync is disabled the CLI operates against
70
- * the local DWN only.
90
+ * When `dataPath` is provided, the agent's persistent data lives there.
91
+ * Otherwise, it falls back to `DATA/AGENT` relative to CWD (legacy).
92
+ *
93
+ * Sync is disabled — the CLI operates against the local DWN only.
71
94
  */
72
- export async function connectAgent(password: string): Promise<AgentContext> {
73
- const { web5, did, recoveryPhrase } = await Web5.connect({
74
- password,
75
- sync: 'off',
76
- });
95
+ export async function connectAgent(options: ConnectOptions): Promise<AgentContext & { recoveryPhrase?: string }> {
96
+ const { password, dataPath, recoveryPhrase: inputPhrase } = options;
97
+
98
+ let agent: Web5UserAgent;
99
+ let recoveryPhrase: string | undefined;
100
+
101
+ if (dataPath) {
102
+ // Profile-based: create agent with explicit data path.
103
+ agent = await Web5UserAgent.create({ dataPath });
104
+
105
+ if (await agent.firstLaunch()) {
106
+ recoveryPhrase = await agent.initialize({
107
+ password,
108
+ recoveryPhrase : inputPhrase,
109
+ dwnEndpoints : ['https://enbox-dwn.fly.dev'],
110
+ });
111
+ }
112
+ await agent.start({ password });
113
+
114
+ // Ensure at least one identity exists.
115
+ const identities = await agent.identity.list();
116
+ let identity = identities[0];
117
+ if (!identity) {
118
+ identity = await agent.identity.create({
119
+ didMethod : 'dht',
120
+ metadata : { name: 'Default' },
121
+ didOptions : {
122
+ services: [{
123
+ id : 'dwn',
124
+ type : 'DecentralizedWebNode',
125
+ serviceEndpoint : ['https://enbox-dwn.fly.dev'],
126
+ enc : '#enc',
127
+ sig : '#sig',
128
+ }],
129
+ verificationMethods: [
130
+ { algorithm: 'Ed25519', id: 'sig', purposes: ['assertionMethod', 'authentication'] },
131
+ { algorithm: 'X25519', id: 'enc', purposes: ['keyAgreement'] },
132
+ ],
133
+ },
134
+ });
135
+ }
136
+
137
+ const result = await Web5.connect({
138
+ agent,
139
+ connectedDid : identity.did.uri,
140
+ sync : 'off',
141
+ });
77
142
 
78
- if (recoveryPhrase) {
143
+ return bindProtocols(result.web5, result.did, recoveryPhrase);
144
+ }
145
+
146
+ // Legacy: let Web5.connect() manage the agent (uses CWD-relative path).
147
+ const result = await Web5.connect({ password, sync: 'off' });
148
+
149
+ if (result.recoveryPhrase) {
79
150
  console.log('');
80
151
  console.log(' Recovery phrase (save this — it cannot be shown again):');
81
- console.log(` ${recoveryPhrase}`);
152
+ console.log(` ${result.recoveryPhrase}`);
82
153
  console.log('');
83
154
  }
84
155
 
85
- // Bind typed protocol handles.
156
+ return bindProtocols(result.web5, result.did, result.recoveryPhrase);
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Internal helpers
161
+ // ---------------------------------------------------------------------------
162
+
163
+ /** Bind typed protocol handles and configure all protocols. */
164
+ async function bindProtocols(
165
+ web5: Web5,
166
+ did: string,
167
+ recoveryPhrase?: string,
168
+ ): Promise<AgentContext & { recoveryPhrase?: string }> {
86
169
  const repo = web5.using(ForgeRepoProtocol);
87
170
  const refs = web5.using(ForgeRefsProtocol);
88
171
  const issues = web5.using(ForgeIssuesProtocol);
@@ -113,5 +196,6 @@ export async function connectAgent(password: string): Promise<AgentContext> {
113
196
  return {
114
197
  did, repo, refs, issues, patches, ci, releases,
115
198
  registry, social, notifications, wiki, org, web5,
199
+ recoveryPhrase,
116
200
  };
117
201
  }
@@ -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
+ }