nostr-git-client 0.0.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/README.md +167 -0
- package/package.json +38 -0
- package/src/NostrGitClient.js +352 -0
- package/src/SelfDeploy.js +226 -0
- package/src/StateManager.js +260 -0
- package/src/index.js +11 -0
- package/src/utils.js +90 -0
package/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# nostr-git-client
|
|
2
|
+
|
|
3
|
+
Browser-based git sync via Nostr with optional Bitcoin anchoring.
|
|
4
|
+
|
|
5
|
+
Subscribe to NIP-34 repo state events (kind 30618) and sync git repositories to browser IndexedDB using isomorphic-git.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install nostr-git-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Basic Git Sync
|
|
16
|
+
|
|
17
|
+
```javascript
|
|
18
|
+
import { NostrGitClient } from 'nostr-git-client';
|
|
19
|
+
|
|
20
|
+
const client = new NostrGitClient({
|
|
21
|
+
repo: 'https://solid.social/mel/public/myapp',
|
|
22
|
+
repoId: 'myapp',
|
|
23
|
+
branch: 'main',
|
|
24
|
+
relays: ['wss://relay.damus.io', 'wss://nos.lol'],
|
|
25
|
+
trusted: ['pubkey-hex...'] // Only sync from trusted publishers
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
client.on('sync', (event) => {
|
|
29
|
+
if (event.status === 'complete') {
|
|
30
|
+
console.log('Synced to:', event.commit);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
client.connect();
|
|
35
|
+
await client.sync();
|
|
36
|
+
|
|
37
|
+
// Read files from synced repo
|
|
38
|
+
const content = await client.readFile('index.html');
|
|
39
|
+
const files = await client.listFiles('src');
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### State Management with Bitcoin Anchoring
|
|
43
|
+
|
|
44
|
+
```javascript
|
|
45
|
+
import { NostrGitClient, StateManager } from 'nostr-git-client';
|
|
46
|
+
|
|
47
|
+
const client = new NostrGitClient({
|
|
48
|
+
repo: 'https://solid.social/mel/public/game',
|
|
49
|
+
trusted: ['pubkey...']
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const state = new StateManager({
|
|
53
|
+
sync: client,
|
|
54
|
+
file: 'state.json',
|
|
55
|
+
anchor: {
|
|
56
|
+
privkey: 'your-bitcoin-privkey-hex',
|
|
57
|
+
network: 'testnet4'
|
|
58
|
+
},
|
|
59
|
+
onChange: (newState) => {
|
|
60
|
+
console.log('State updated:', newState);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await state.init();
|
|
65
|
+
client.connect();
|
|
66
|
+
await client.sync();
|
|
67
|
+
|
|
68
|
+
// Anchor current state to Bitcoin
|
|
69
|
+
const anchor = await state.anchor();
|
|
70
|
+
console.log('Anchored:', anchor.txid);
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Self-Deploying Pages
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
import { SelfDeploy } from 'nostr-git-client';
|
|
77
|
+
|
|
78
|
+
// Auto-reload when repo is updated
|
|
79
|
+
SelfDeploy.init({
|
|
80
|
+
repo: 'https://solid.social/mel/public/myapp',
|
|
81
|
+
trusted: ['pubkey...'],
|
|
82
|
+
autoReload: true,
|
|
83
|
+
onUpdate: (event) => {
|
|
84
|
+
console.log('Update detected:', event.commit);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
### NostrGitClient
|
|
92
|
+
|
|
93
|
+
Main client for Nostr-based git sync.
|
|
94
|
+
|
|
95
|
+
**Constructor options:**
|
|
96
|
+
- `repo` - Git repository URL
|
|
97
|
+
- `repoId` - Repository identifier (default: derived from repo URL)
|
|
98
|
+
- `branch` - Branch to track (default: 'main')
|
|
99
|
+
- `relays` - Nostr relay URLs
|
|
100
|
+
- `trusted` - Trusted publisher pubkeys
|
|
101
|
+
- `corsProxy` - CORS proxy URL (optional)
|
|
102
|
+
|
|
103
|
+
**Methods:**
|
|
104
|
+
- `connect()` - Connect to relays and start listening
|
|
105
|
+
- `disconnect()` - Disconnect from relays
|
|
106
|
+
- `sync(commit?)` - Sync to specified commit or latest
|
|
107
|
+
- `readFile(path)` - Read file content as string
|
|
108
|
+
- `readFileBytes(path)` - Read file as Uint8Array
|
|
109
|
+
- `listFiles(path?)` - List directory contents
|
|
110
|
+
- `getAllFiles()` - Get all files recursively
|
|
111
|
+
- `getCommit()` - Get current commit info
|
|
112
|
+
- `clear()` - Delete local repository
|
|
113
|
+
|
|
114
|
+
**Events:**
|
|
115
|
+
- `sync` - Sync status changes
|
|
116
|
+
- `event` - Nostr events received
|
|
117
|
+
- `connect` - Connected to relay
|
|
118
|
+
- `disconnect` - Disconnected from relay
|
|
119
|
+
- `error` - Errors
|
|
120
|
+
|
|
121
|
+
### StateManager
|
|
122
|
+
|
|
123
|
+
Manages JSON state with optional Bitcoin anchoring.
|
|
124
|
+
|
|
125
|
+
**Constructor options:**
|
|
126
|
+
- `sync` - NostrGitClient instance
|
|
127
|
+
- `file` - Path to JSON file (default: 'state.json')
|
|
128
|
+
- `anchor` - Bitcoin config: `{ privkey, network }`
|
|
129
|
+
- `onChange` - Callback on state changes
|
|
130
|
+
|
|
131
|
+
**Methods:**
|
|
132
|
+
- `init()` - Initialize (loads blocktrails if anchoring)
|
|
133
|
+
- `loadState()` - Load state from file
|
|
134
|
+
- `get()` - Get current state
|
|
135
|
+
- `anchor()` - Anchor state to Bitcoin
|
|
136
|
+
- `getAnchors()` - Get anchor history
|
|
137
|
+
- `verifyChain()` - Verify anchor chain integrity
|
|
138
|
+
|
|
139
|
+
### SelfDeploy
|
|
140
|
+
|
|
141
|
+
Auto-updating pages.
|
|
142
|
+
|
|
143
|
+
**Static methods:**
|
|
144
|
+
- `init(options)` - Initialize self-deploy
|
|
145
|
+
- `loaderScript(options)` - Generate embed script
|
|
146
|
+
- `serviceWorkerCode()` - Generate SW code
|
|
147
|
+
|
|
148
|
+
**Options:**
|
|
149
|
+
- `repo` - Repository URL (required)
|
|
150
|
+
- `trusted` - Trusted pubkeys (required)
|
|
151
|
+
- `autoReload` - Auto reload on update (default: true)
|
|
152
|
+
- `reloadDelay` - Delay before reload in ms (default: 1000)
|
|
153
|
+
- `onUpdate` - Callback on update
|
|
154
|
+
- `onSync` - Callback after sync
|
|
155
|
+
|
|
156
|
+
## Dependencies
|
|
157
|
+
|
|
158
|
+
- [isomorphic-git](https://isomorphic-git.org/) - Git in JavaScript
|
|
159
|
+
- [@isomorphic-git/lightning-fs](https://github.com/isomorphic-git/lightning-fs) - IndexedDB filesystem
|
|
160
|
+
- [nostr-tools](https://github.com/nbd-wtf/nostr-tools) - Nostr protocol
|
|
161
|
+
|
|
162
|
+
**Optional:**
|
|
163
|
+
- [blocktrails](https://www.npmjs.com/package/blocktrails) - Bitcoin state anchoring
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nostr-git-client",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Browser-based git sync via Nostr with optional Bitcoin anchoring",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"browser": "src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "echo \"No tests yet\""
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@isomorphic-git/lightning-fs": "^4.6.0",
|
|
16
|
+
"isomorphic-git": "^1.27.1",
|
|
17
|
+
"nostr-tools": "^2.19.4"
|
|
18
|
+
},
|
|
19
|
+
"optionalDependencies": {
|
|
20
|
+
"blocktrails": "^0.0.11"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"nostr",
|
|
24
|
+
"git",
|
|
25
|
+
"isomorphic-git",
|
|
26
|
+
"browser",
|
|
27
|
+
"sync",
|
|
28
|
+
"nip-34",
|
|
29
|
+
"bitcoin",
|
|
30
|
+
"anchor",
|
|
31
|
+
"decentralized"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/JavaScriptSolidServer/nostr-git-client"
|
|
36
|
+
},
|
|
37
|
+
"license": "MIT"
|
|
38
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NostrGitClient - Browser-based git sync via Nostr
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to NIP-34 repo state events (kind 30618) and syncs
|
|
5
|
+
* git repositories to browser IndexedDB using isomorphic-git.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import git from 'isomorphic-git';
|
|
9
|
+
import { createFS, createHttpClient, parseRepoEvent, verifyEvent } from './utils.js';
|
|
10
|
+
|
|
11
|
+
export class NostrGitClient {
|
|
12
|
+
/**
|
|
13
|
+
* Create a new NostrGitClient
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {string} options.repo - Git repository URL
|
|
16
|
+
* @param {string} options.repoId - Repository identifier (for Nostr d tag)
|
|
17
|
+
* @param {string} options.branch - Branch to track (default: 'main')
|
|
18
|
+
* @param {string[]} options.relays - Nostr relay URLs
|
|
19
|
+
* @param {string[]} options.trusted - Trusted publisher pubkeys
|
|
20
|
+
* @param {string} options.dir - Local directory path (default: /<repoId>)
|
|
21
|
+
* @param {LightningFS} options.fs - Filesystem instance (optional)
|
|
22
|
+
* @param {Object} options.http - HTTP client (optional)
|
|
23
|
+
* @param {string} options.corsProxy - CORS proxy URL (optional)
|
|
24
|
+
*/
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.repo = options.repo;
|
|
27
|
+
this.repoId = options.repoId || options.repo.split('/').pop();
|
|
28
|
+
this.branch = options.branch || 'main';
|
|
29
|
+
this.relays = options.relays || ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
30
|
+
this.trusted = options.trusted || [];
|
|
31
|
+
this.dir = options.dir || `/${this.repoId}`;
|
|
32
|
+
|
|
33
|
+
this.fs = options.fs || createFS();
|
|
34
|
+
this.pfs = this.fs.promises;
|
|
35
|
+
this.http = options.http || createHttpClient({ corsProxy: options.corsProxy });
|
|
36
|
+
|
|
37
|
+
this.websockets = [];
|
|
38
|
+
this.listeners = new Map();
|
|
39
|
+
this.currentCommit = null;
|
|
40
|
+
this.synced = false;
|
|
41
|
+
this.syncing = false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Add event listener
|
|
46
|
+
* @param {string} event - Event name: 'sync', 'event', 'error', 'connect', 'disconnect'
|
|
47
|
+
* @param {Function} callback - Callback function
|
|
48
|
+
*/
|
|
49
|
+
on(event, callback) {
|
|
50
|
+
if (!this.listeners.has(event)) {
|
|
51
|
+
this.listeners.set(event, []);
|
|
52
|
+
}
|
|
53
|
+
this.listeners.get(event).push(callback);
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Remove event listener
|
|
59
|
+
*/
|
|
60
|
+
off(event, callback) {
|
|
61
|
+
const listeners = this.listeners.get(event);
|
|
62
|
+
if (listeners) {
|
|
63
|
+
const idx = listeners.indexOf(callback);
|
|
64
|
+
if (idx >= 0) listeners.splice(idx, 1);
|
|
65
|
+
}
|
|
66
|
+
return this;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Emit event to listeners
|
|
71
|
+
*/
|
|
72
|
+
emit(event, ...args) {
|
|
73
|
+
const listeners = this.listeners.get(event) || [];
|
|
74
|
+
listeners.forEach(cb => {
|
|
75
|
+
try { cb(...args); } catch (e) { console.error('Listener error:', e); }
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Connect to Nostr relays and start listening
|
|
81
|
+
*/
|
|
82
|
+
connect() {
|
|
83
|
+
this.disconnect(); // Clean up existing connections
|
|
84
|
+
|
|
85
|
+
this.relays.forEach((url, i) => {
|
|
86
|
+
const ws = new WebSocket(url);
|
|
87
|
+
|
|
88
|
+
ws.onopen = () => {
|
|
89
|
+
this.emit('connect', url);
|
|
90
|
+
|
|
91
|
+
// Subscribe to repo state events
|
|
92
|
+
ws.send(JSON.stringify([
|
|
93
|
+
'REQ',
|
|
94
|
+
`ngc-${i}`,
|
|
95
|
+
{
|
|
96
|
+
kinds: [30618],
|
|
97
|
+
'#d': [this.repoId],
|
|
98
|
+
limit: 1
|
|
99
|
+
}
|
|
100
|
+
]));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
ws.onmessage = (e) => {
|
|
104
|
+
try {
|
|
105
|
+
const msg = JSON.parse(e.data);
|
|
106
|
+
if (msg[0] === 'EVENT' && msg[2]) {
|
|
107
|
+
this.handleEvent(msg[2]);
|
|
108
|
+
}
|
|
109
|
+
} catch {}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
ws.onclose = () => {
|
|
113
|
+
this.emit('disconnect', url);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
ws.onerror = (err) => {
|
|
117
|
+
this.emit('error', new Error(`WebSocket error: ${url}`));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
this.websockets.push(ws);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Disconnect from all relays
|
|
128
|
+
*/
|
|
129
|
+
disconnect() {
|
|
130
|
+
this.websockets.forEach(ws => ws.close());
|
|
131
|
+
this.websockets = [];
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handle incoming Nostr event
|
|
137
|
+
*/
|
|
138
|
+
async handleEvent(event) {
|
|
139
|
+
// Verify signature
|
|
140
|
+
if (!await verifyEvent(event)) {
|
|
141
|
+
this.emit('error', new Error('Invalid event signature'));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Check if from trusted publisher
|
|
146
|
+
if (this.trusted.length > 0 && !this.trusted.includes(event.pubkey)) {
|
|
147
|
+
this.emit('event', { type: 'untrusted', event });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Parse event
|
|
152
|
+
const parsed = parseRepoEvent(event);
|
|
153
|
+
if (!parsed) return;
|
|
154
|
+
|
|
155
|
+
this.emit('event', { type: 'repo', ...parsed });
|
|
156
|
+
|
|
157
|
+
// Check if we need to sync
|
|
158
|
+
if (parsed.repoId === this.repoId && parsed.branch === this.branch) {
|
|
159
|
+
if (this.currentCommit !== parsed.commit) {
|
|
160
|
+
await this.sync(parsed.commit);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Sync repository to specified commit (or latest)
|
|
167
|
+
* @param {string} targetCommit - Commit hash to checkout (optional)
|
|
168
|
+
* @returns {Object} Sync result
|
|
169
|
+
*/
|
|
170
|
+
async sync(targetCommit = null) {
|
|
171
|
+
if (this.syncing) {
|
|
172
|
+
return { success: false, error: 'Already syncing' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.syncing = true;
|
|
176
|
+
this.emit('sync', { status: 'start', commit: targetCommit });
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
// Check if already cloned
|
|
180
|
+
let needsClone = true;
|
|
181
|
+
try {
|
|
182
|
+
await this.pfs.stat(`${this.dir}/.git`);
|
|
183
|
+
needsClone = false;
|
|
184
|
+
} catch {}
|
|
185
|
+
|
|
186
|
+
if (needsClone) {
|
|
187
|
+
// Ensure directory exists
|
|
188
|
+
try { await this.pfs.mkdir(this.dir); } catch {}
|
|
189
|
+
|
|
190
|
+
await git.clone({
|
|
191
|
+
fs: this.fs,
|
|
192
|
+
http: this.http,
|
|
193
|
+
dir: this.dir,
|
|
194
|
+
url: this.repo,
|
|
195
|
+
ref: this.branch,
|
|
196
|
+
singleBranch: true,
|
|
197
|
+
depth: 10
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
// Fetch updates
|
|
201
|
+
await git.fetch({
|
|
202
|
+
fs: this.fs,
|
|
203
|
+
http: this.http,
|
|
204
|
+
dir: this.dir,
|
|
205
|
+
url: this.repo,
|
|
206
|
+
ref: this.branch,
|
|
207
|
+
singleBranch: true
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Checkout to specific commit or branch head
|
|
212
|
+
const ref = targetCommit || this.branch;
|
|
213
|
+
await git.checkout({
|
|
214
|
+
fs: this.fs,
|
|
215
|
+
dir: this.dir,
|
|
216
|
+
ref,
|
|
217
|
+
force: true
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Get current commit info
|
|
221
|
+
const commits = await git.log({ fs: this.fs, dir: this.dir, depth: 1 });
|
|
222
|
+
const commit = commits[0];
|
|
223
|
+
this.currentCommit = commit?.oid;
|
|
224
|
+
this.synced = true;
|
|
225
|
+
this.syncing = false;
|
|
226
|
+
|
|
227
|
+
const result = {
|
|
228
|
+
success: true,
|
|
229
|
+
commit: commit?.oid,
|
|
230
|
+
message: commit?.commit.message,
|
|
231
|
+
author: commit?.commit.author.name,
|
|
232
|
+
date: new Date(commit?.commit.author.timestamp * 1000)
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
this.emit('sync', { status: 'complete', ...result });
|
|
236
|
+
return result;
|
|
237
|
+
|
|
238
|
+
} catch (err) {
|
|
239
|
+
this.syncing = false;
|
|
240
|
+
this.emit('sync', { status: 'error', error: err.message });
|
|
241
|
+
this.emit('error', err);
|
|
242
|
+
return { success: false, error: err.message };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Read a file from the synced repository
|
|
248
|
+
* @param {string} path - File path relative to repo root
|
|
249
|
+
* @returns {string} File content
|
|
250
|
+
*/
|
|
251
|
+
async readFile(path) {
|
|
252
|
+
return this.pfs.readFile(`${this.dir}/${path}`, 'utf8');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Read a file as bytes
|
|
257
|
+
* @param {string} path - File path
|
|
258
|
+
* @returns {Uint8Array} File content
|
|
259
|
+
*/
|
|
260
|
+
async readFileBytes(path) {
|
|
261
|
+
return this.pfs.readFile(`${this.dir}/${path}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* List files in directory
|
|
266
|
+
* @param {string} path - Directory path (default: repo root)
|
|
267
|
+
* @returns {Array} File entries
|
|
268
|
+
*/
|
|
269
|
+
async listFiles(path = '') {
|
|
270
|
+
const fullPath = `${this.dir}/${path}`.replace(/\/+$/, '');
|
|
271
|
+
const entries = await this.pfs.readdir(fullPath);
|
|
272
|
+
|
|
273
|
+
const result = [];
|
|
274
|
+
for (const entry of entries) {
|
|
275
|
+
if (entry === '.git') continue;
|
|
276
|
+
const stat = await this.pfs.stat(`${fullPath}/${entry}`);
|
|
277
|
+
result.push({
|
|
278
|
+
name: entry,
|
|
279
|
+
path: path ? `${path}/${entry}` : entry,
|
|
280
|
+
type: stat.isDirectory() ? 'dir' : 'file'
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return result.sort((a, b) => {
|
|
285
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
286
|
+
return a.name.localeCompare(b.name);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Get all files recursively
|
|
292
|
+
* @returns {Array} All file paths
|
|
293
|
+
*/
|
|
294
|
+
async getAllFiles() {
|
|
295
|
+
const files = [];
|
|
296
|
+
const walk = async (path) => {
|
|
297
|
+
const entries = await this.listFiles(path);
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
if (entry.type === 'dir') {
|
|
300
|
+
await walk(entry.path);
|
|
301
|
+
} else {
|
|
302
|
+
files.push(entry.path);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
await walk('');
|
|
307
|
+
return files;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Get current commit info
|
|
312
|
+
* @returns {Object} Commit info
|
|
313
|
+
*/
|
|
314
|
+
async getCommit() {
|
|
315
|
+
const commits = await git.log({ fs: this.fs, dir: this.dir, depth: 1 });
|
|
316
|
+
if (commits.length === 0) return null;
|
|
317
|
+
|
|
318
|
+
const c = commits[0];
|
|
319
|
+
return {
|
|
320
|
+
hash: c.oid,
|
|
321
|
+
message: c.commit.message,
|
|
322
|
+
author: c.commit.author.name,
|
|
323
|
+
email: c.commit.author.email,
|
|
324
|
+
date: new Date(c.commit.author.timestamp * 1000)
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Clear local repository from IndexedDB
|
|
330
|
+
*/
|
|
331
|
+
async clear() {
|
|
332
|
+
const deleteRecursive = async (path) => {
|
|
333
|
+
try {
|
|
334
|
+
const entries = await this.pfs.readdir(path);
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
const fullPath = `${path}/${entry}`;
|
|
337
|
+
const stat = await this.pfs.stat(fullPath);
|
|
338
|
+
if (stat.isDirectory()) {
|
|
339
|
+
await deleteRecursive(fullPath);
|
|
340
|
+
} else {
|
|
341
|
+
await this.pfs.unlink(fullPath);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
await this.pfs.rmdir(path);
|
|
345
|
+
} catch {}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
await deleteRecursive(this.dir);
|
|
349
|
+
this.currentCommit = null;
|
|
350
|
+
this.synced = false;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SelfDeploy - Auto-updating pages via Nostr git sync
|
|
3
|
+
*
|
|
4
|
+
* Enables pages to automatically reload when their source repo
|
|
5
|
+
* receives updates via Nostr announcements.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { NostrGitClient } from './NostrGitClient.js';
|
|
9
|
+
|
|
10
|
+
export class SelfDeploy {
|
|
11
|
+
/**
|
|
12
|
+
* Initialize self-deploying page
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {string} options.repo - Git repository URL
|
|
16
|
+
* @param {string} options.repoId - Repository identifier (optional, derived from repo)
|
|
17
|
+
* @param {string} options.branch - Branch to track (default: 'main')
|
|
18
|
+
* @param {string[]} options.relays - Nostr relay URLs
|
|
19
|
+
* @param {string[]} options.trusted - Trusted publisher pubkeys (required for auto-reload)
|
|
20
|
+
* @param {Function} options.onUpdate - Called when update detected (default: reload page)
|
|
21
|
+
* @param {Function} options.onSync - Called after sync completes
|
|
22
|
+
* @param {Function} options.onError - Called on errors
|
|
23
|
+
* @param {boolean} options.autoReload - Auto reload on update (default: true)
|
|
24
|
+
* @param {number} options.reloadDelay - Delay before reload in ms (default: 1000)
|
|
25
|
+
* @param {string} options.corsProxy - CORS proxy URL (optional)
|
|
26
|
+
* @returns {SelfDeploy} Instance
|
|
27
|
+
*/
|
|
28
|
+
static init(options) {
|
|
29
|
+
return new SelfDeploy(options);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
constructor(options) {
|
|
33
|
+
if (!options.repo) {
|
|
34
|
+
throw new Error('repo is required');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!options.trusted || options.trusted.length === 0) {
|
|
38
|
+
throw new Error('trusted pubkeys required for SelfDeploy');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
this.options = {
|
|
42
|
+
autoReload: true,
|
|
43
|
+
reloadDelay: 1000,
|
|
44
|
+
branch: 'main',
|
|
45
|
+
...options
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
this.client = new NostrGitClient({
|
|
49
|
+
repo: this.options.repo,
|
|
50
|
+
repoId: this.options.repoId,
|
|
51
|
+
branch: this.options.branch,
|
|
52
|
+
relays: this.options.relays,
|
|
53
|
+
trusted: this.options.trusted,
|
|
54
|
+
corsProxy: this.options.corsProxy
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
this.currentCommit = null;
|
|
58
|
+
this.setupListeners();
|
|
59
|
+
this.client.connect();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setupListeners() {
|
|
63
|
+
// Track sync events
|
|
64
|
+
this.client.on('sync', (event) => {
|
|
65
|
+
if (event.status === 'complete') {
|
|
66
|
+
const isUpdate = this.currentCommit && this.currentCommit !== event.commit;
|
|
67
|
+
this.currentCommit = event.commit;
|
|
68
|
+
|
|
69
|
+
if (this.options.onSync) {
|
|
70
|
+
this.options.onSync(event);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isUpdate) {
|
|
74
|
+
this.handleUpdate(event);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (event.status === 'error' && this.options.onError) {
|
|
79
|
+
this.options.onError(new Error(event.error));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Track repo events from trusted publishers
|
|
84
|
+
this.client.on('event', (event) => {
|
|
85
|
+
if (event.type === 'repo') {
|
|
86
|
+
// New commit announced - trigger sync
|
|
87
|
+
if (event.commit !== this.currentCommit) {
|
|
88
|
+
this.client.sync(event.commit);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Forward errors
|
|
94
|
+
this.client.on('error', (err) => {
|
|
95
|
+
if (this.options.onError) {
|
|
96
|
+
this.options.onError(err);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Log connections
|
|
101
|
+
this.client.on('connect', (url) => {
|
|
102
|
+
console.log(`[SelfDeploy] Connected to ${url}`);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
handleUpdate(event) {
|
|
107
|
+
console.log(`[SelfDeploy] Update detected: ${event.commit?.slice(0, 8)}`);
|
|
108
|
+
|
|
109
|
+
if (this.options.onUpdate) {
|
|
110
|
+
this.options.onUpdate(event);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (this.options.autoReload) {
|
|
114
|
+
console.log(`[SelfDeploy] Reloading in ${this.options.reloadDelay}ms...`);
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
location.reload();
|
|
117
|
+
}, this.options.reloadDelay);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Manually trigger sync
|
|
123
|
+
* @returns {Promise<Object>} Sync result
|
|
124
|
+
*/
|
|
125
|
+
sync() {
|
|
126
|
+
return this.client.sync();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get current commit info
|
|
131
|
+
* @returns {Promise<Object>} Commit info
|
|
132
|
+
*/
|
|
133
|
+
getCommit() {
|
|
134
|
+
return this.client.getCommit();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read a file from the synced repo
|
|
139
|
+
* @param {string} path - File path
|
|
140
|
+
* @returns {Promise<string>} File content
|
|
141
|
+
*/
|
|
142
|
+
readFile(path) {
|
|
143
|
+
return this.client.readFile(path);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* List files in directory
|
|
148
|
+
* @param {string} path - Directory path
|
|
149
|
+
* @returns {Promise<Array>} File entries
|
|
150
|
+
*/
|
|
151
|
+
listFiles(path) {
|
|
152
|
+
return this.client.listFiles(path);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check if synced
|
|
157
|
+
* @returns {boolean}
|
|
158
|
+
*/
|
|
159
|
+
get synced() {
|
|
160
|
+
return this.client.synced;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Disconnect from relays
|
|
165
|
+
*/
|
|
166
|
+
disconnect() {
|
|
167
|
+
this.client.disconnect();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Create a minimal loader script for embedding
|
|
172
|
+
* Returns HTML that can be injected to enable self-deploy
|
|
173
|
+
*
|
|
174
|
+
* @param {Object} options - Same as constructor options
|
|
175
|
+
* @returns {string} Script tag HTML
|
|
176
|
+
*/
|
|
177
|
+
static loaderScript(options) {
|
|
178
|
+
const config = JSON.stringify(options);
|
|
179
|
+
return `<script type="module">
|
|
180
|
+
import { SelfDeploy } from 'nostr-git-client';
|
|
181
|
+
SelfDeploy.init(${config});
|
|
182
|
+
</script>`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Create a service worker for offline-first self-deploy
|
|
187
|
+
* The service worker syncs in the background and notifies the page
|
|
188
|
+
*
|
|
189
|
+
* @returns {string} Service worker code
|
|
190
|
+
*/
|
|
191
|
+
static serviceWorkerCode() {
|
|
192
|
+
return `
|
|
193
|
+
// SelfDeploy Service Worker
|
|
194
|
+
// Syncs git repo in background and caches files
|
|
195
|
+
|
|
196
|
+
const CACHE_NAME = 'selfdeploy-v1';
|
|
197
|
+
|
|
198
|
+
self.addEventListener('install', (event) => {
|
|
199
|
+
self.skipWaiting();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
self.addEventListener('activate', (event) => {
|
|
203
|
+
event.waitUntil(clients.claim());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
self.addEventListener('message', async (event) => {
|
|
207
|
+
if (event.data.type === 'SYNC') {
|
|
208
|
+
// Notify all clients of update
|
|
209
|
+
const clients = await self.clients.matchAll();
|
|
210
|
+
clients.forEach(client => {
|
|
211
|
+
client.postMessage({ type: 'UPDATE', commit: event.data.commit });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
self.addEventListener('fetch', (event) => {
|
|
217
|
+
// Cache-first strategy for synced files
|
|
218
|
+
event.respondWith(
|
|
219
|
+
caches.match(event.request).then(cached => {
|
|
220
|
+
return cached || fetch(event.request);
|
|
221
|
+
})
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StateManager - JSON state management with optional Bitcoin anchoring
|
|
3
|
+
*
|
|
4
|
+
* Manages a JSON state file from a synced git repository.
|
|
5
|
+
* Optionally anchors state changes to Bitcoin via blocktrails.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class StateManager {
|
|
9
|
+
/**
|
|
10
|
+
* Create a StateManager
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {NostrGitClient} options.sync - NostrGitClient instance
|
|
13
|
+
* @param {string} options.file - Path to JSON state file (default: 'state.json')
|
|
14
|
+
* @param {Object} options.anchor - Bitcoin anchoring options (optional)
|
|
15
|
+
* @param {string} options.anchor.privkey - Private key hex for anchoring
|
|
16
|
+
* @param {string} options.anchor.network - 'testnet4' or 'mainnet' (default: 'testnet4')
|
|
17
|
+
* @param {Function} options.onChange - Callback when state changes
|
|
18
|
+
*/
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.sync = options.sync;
|
|
21
|
+
this.file = options.file || 'state.json';
|
|
22
|
+
this.anchorConfig = options.anchor || null;
|
|
23
|
+
this.onChange = options.onChange || null;
|
|
24
|
+
|
|
25
|
+
this.state = null;
|
|
26
|
+
this.stateHash = null;
|
|
27
|
+
this.anchors = [];
|
|
28
|
+
this.blocktrails = null;
|
|
29
|
+
|
|
30
|
+
// Listen for sync events
|
|
31
|
+
this.sync.on('sync', async (event) => {
|
|
32
|
+
if (event.status === 'complete') {
|
|
33
|
+
await this.loadState();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Initialize the state manager
|
|
40
|
+
* Loads blocktrails if anchoring is enabled
|
|
41
|
+
*/
|
|
42
|
+
async init() {
|
|
43
|
+
// Try to load blocktrails for anchoring
|
|
44
|
+
if (this.anchorConfig) {
|
|
45
|
+
try {
|
|
46
|
+
const bt = await import('blocktrails');
|
|
47
|
+
this.blocktrails = bt;
|
|
48
|
+
} catch {
|
|
49
|
+
console.warn('blocktrails not available - anchoring disabled');
|
|
50
|
+
this.anchorConfig = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Load initial state if synced
|
|
55
|
+
if (this.sync.synced) {
|
|
56
|
+
await this.loadState();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load state from the synced file
|
|
64
|
+
*/
|
|
65
|
+
async loadState() {
|
|
66
|
+
try {
|
|
67
|
+
const content = await this.sync.readFile(this.file);
|
|
68
|
+
const newState = JSON.parse(content);
|
|
69
|
+
const newHash = await this.hashState(newState);
|
|
70
|
+
|
|
71
|
+
if (newHash !== this.stateHash) {
|
|
72
|
+
this.state = newState;
|
|
73
|
+
this.stateHash = newHash;
|
|
74
|
+
|
|
75
|
+
if (this.onChange) {
|
|
76
|
+
this.onChange(this.state);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return this.state;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
// File might not exist yet
|
|
83
|
+
if (err.code === 'ENOENT') {
|
|
84
|
+
this.state = {};
|
|
85
|
+
this.stateHash = null;
|
|
86
|
+
return this.state;
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get current state
|
|
94
|
+
* @returns {Object} Current state
|
|
95
|
+
*/
|
|
96
|
+
get() {
|
|
97
|
+
return this.state;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Hash state for anchoring/comparison
|
|
102
|
+
* @param {Object} state - State to hash
|
|
103
|
+
* @returns {string} SHA-256 hash hex
|
|
104
|
+
*/
|
|
105
|
+
async hashState(state) {
|
|
106
|
+
const json = JSON.stringify(state, null, 2);
|
|
107
|
+
const bytes = new TextEncoder().encode(json);
|
|
108
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
|
109
|
+
return Array.from(new Uint8Array(hashBuffer))
|
|
110
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
111
|
+
.join('');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Anchor current state to Bitcoin
|
|
116
|
+
* Creates a P2TR output committing to the state hash
|
|
117
|
+
* @returns {Object} Anchor result with txid
|
|
118
|
+
*/
|
|
119
|
+
async anchor() {
|
|
120
|
+
if (!this.blocktrails || !this.anchorConfig) {
|
|
121
|
+
throw new Error('Anchoring not configured');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!this.state || !this.stateHash) {
|
|
125
|
+
throw new Error('No state to anchor');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const bt = this.blocktrails;
|
|
129
|
+
const privkey = this.anchorConfig.privkey;
|
|
130
|
+
const network = this.anchorConfig.network || 'testnet4';
|
|
131
|
+
|
|
132
|
+
// Get previous anchor states for chaining
|
|
133
|
+
const prevStates = this.anchors.map(a => a.hash);
|
|
134
|
+
const allStates = [...prevStates, this.stateHash];
|
|
135
|
+
|
|
136
|
+
// Derive pubkey from privkey
|
|
137
|
+
const pubkeyBase = bt.getPublicKey(privkey);
|
|
138
|
+
|
|
139
|
+
// For first anchor, derive simple tweaked address
|
|
140
|
+
// For subsequent anchors, use chained derivation
|
|
141
|
+
let fromPubkey, toPubkey;
|
|
142
|
+
|
|
143
|
+
if (prevStates.length === 0) {
|
|
144
|
+
// First anchor - spend from base key to tweaked key
|
|
145
|
+
fromPubkey = pubkeyBase;
|
|
146
|
+
toPubkey = bt.derivePubkey(pubkeyBase, this.stateHash);
|
|
147
|
+
} else {
|
|
148
|
+
// Chained anchor - spend from previous tweaked to new tweaked
|
|
149
|
+
fromPubkey = bt.deriveChainedPubkey(pubkeyBase, prevStates);
|
|
150
|
+
toPubkey = bt.deriveChainedPubkey(pubkeyBase, allStates);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Get UTXOs from previous address
|
|
154
|
+
const fromAddress = bt.pubkeyToAddress(fromPubkey, network);
|
|
155
|
+
const utxos = await bt.fetchUTXOs(fromAddress, network);
|
|
156
|
+
|
|
157
|
+
if (utxos.length === 0) {
|
|
158
|
+
throw new Error(`No funds at ${fromAddress}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Use largest UTXO
|
|
162
|
+
const utxo = utxos.reduce((a, b) => a.value > b.value ? a : b);
|
|
163
|
+
|
|
164
|
+
// Create and broadcast transaction
|
|
165
|
+
const toAddress = bt.pubkeyToAddress(toPubkey, network);
|
|
166
|
+
const fee = 300; // Minimal fee for P2TR
|
|
167
|
+
|
|
168
|
+
const tx = bt.createAnchorTx({
|
|
169
|
+
utxo,
|
|
170
|
+
privkey,
|
|
171
|
+
prevStates,
|
|
172
|
+
newState: this.stateHash,
|
|
173
|
+
toAddress,
|
|
174
|
+
fee,
|
|
175
|
+
network
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const txid = await bt.broadcastTx(tx, network);
|
|
179
|
+
|
|
180
|
+
// Record anchor
|
|
181
|
+
const anchor = {
|
|
182
|
+
txid,
|
|
183
|
+
hash: this.stateHash,
|
|
184
|
+
address: toAddress,
|
|
185
|
+
timestamp: Date.now(),
|
|
186
|
+
state: JSON.parse(JSON.stringify(this.state))
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
this.anchors.push(anchor);
|
|
190
|
+
|
|
191
|
+
return anchor;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get all anchors
|
|
196
|
+
* @returns {Array} List of anchors
|
|
197
|
+
*/
|
|
198
|
+
getAnchors() {
|
|
199
|
+
return this.anchors;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Verify anchor chain
|
|
204
|
+
* Checks that all anchors form a valid chain
|
|
205
|
+
* @returns {boolean} True if valid
|
|
206
|
+
*/
|
|
207
|
+
async verifyChain() {
|
|
208
|
+
if (!this.blocktrails || this.anchors.length === 0) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const bt = this.blocktrails;
|
|
213
|
+
const network = this.anchorConfig?.network || 'testnet4';
|
|
214
|
+
const pubkeyBase = bt.getPublicKey(this.anchorConfig.privkey);
|
|
215
|
+
|
|
216
|
+
// Verify each anchor has the expected address
|
|
217
|
+
const states = [];
|
|
218
|
+
for (const anchor of this.anchors) {
|
|
219
|
+
states.push(anchor.hash);
|
|
220
|
+
const expectedPubkey = bt.deriveChainedPubkey(pubkeyBase, states);
|
|
221
|
+
const expectedAddress = bt.pubkeyToAddress(expectedPubkey, network);
|
|
222
|
+
|
|
223
|
+
if (anchor.address !== expectedAddress) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get anchor chain storage key
|
|
233
|
+
*/
|
|
234
|
+
get storageKey() {
|
|
235
|
+
return `state-anchors-${this.sync.repoId}-${this.file}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Save anchors to localStorage
|
|
240
|
+
*/
|
|
241
|
+
saveAnchors() {
|
|
242
|
+
if (typeof localStorage !== 'undefined') {
|
|
243
|
+
localStorage.setItem(this.storageKey, JSON.stringify(this.anchors));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Load anchors from localStorage
|
|
249
|
+
*/
|
|
250
|
+
loadAnchors() {
|
|
251
|
+
if (typeof localStorage !== 'undefined') {
|
|
252
|
+
try {
|
|
253
|
+
const saved = localStorage.getItem(this.storageKey);
|
|
254
|
+
if (saved) {
|
|
255
|
+
this.anchors = JSON.parse(saved);
|
|
256
|
+
}
|
|
257
|
+
} catch {}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nostr-git-client - Browser-based git sync via Nostr
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { NostrGitClient, StateManager, SelfDeploy } from 'nostr-git-client';
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { NostrGitClient } from './NostrGitClient.js';
|
|
9
|
+
export { StateManager } from './StateManager.js';
|
|
10
|
+
export { SelfDeploy } from './SelfDeploy.js';
|
|
11
|
+
export { createFS, createHttpClient } from './utils.js';
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for nostr-git-client
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import LightningFS from '@isomorphic-git/lightning-fs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a LightningFS instance for IndexedDB storage
|
|
9
|
+
* @param {string} name - Database name
|
|
10
|
+
* @returns {LightningFS} Filesystem instance
|
|
11
|
+
*/
|
|
12
|
+
export function createFS(name = 'nostr-git-client') {
|
|
13
|
+
return new LightningFS(name);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create an HTTP client for isomorphic-git
|
|
18
|
+
* Works with JSS and other git HTTP servers
|
|
19
|
+
* @param {Object} options
|
|
20
|
+
* @param {string} options.corsProxy - CORS proxy URL (optional)
|
|
21
|
+
* @param {Object} options.headers - Additional headers
|
|
22
|
+
* @returns {Object} HTTP client for isomorphic-git
|
|
23
|
+
*/
|
|
24
|
+
export function createHttpClient(options = {}) {
|
|
25
|
+
const { corsProxy, headers = {} } = options;
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
async request({ url, method, headers: reqHeaders, body }) {
|
|
29
|
+
// Apply CORS proxy if specified
|
|
30
|
+
const finalUrl = corsProxy ? `${corsProxy}${encodeURIComponent(url)}` : url;
|
|
31
|
+
|
|
32
|
+
const res = await fetch(finalUrl, {
|
|
33
|
+
method,
|
|
34
|
+
headers: { ...reqHeaders, ...headers },
|
|
35
|
+
body
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
url: res.url,
|
|
40
|
+
method,
|
|
41
|
+
statusCode: res.status,
|
|
42
|
+
statusMessage: res.statusText,
|
|
43
|
+
headers: Object.fromEntries(res.headers.entries()),
|
|
44
|
+
body: [new Uint8Array(await res.arrayBuffer())]
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Verify Nostr event signature
|
|
52
|
+
* @param {Object} event - Nostr event
|
|
53
|
+
* @returns {boolean} True if valid
|
|
54
|
+
*/
|
|
55
|
+
export async function verifyEvent(event) {
|
|
56
|
+
try {
|
|
57
|
+
const { verifyEvent } = await import('nostr-tools/pure');
|
|
58
|
+
return verifyEvent(event);
|
|
59
|
+
} catch {
|
|
60
|
+
// Fallback: assume valid if nostr-tools not available
|
|
61
|
+
console.warn('nostr-tools not available for signature verification');
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a 30618 repo state event
|
|
68
|
+
* @param {Object} event - Nostr event
|
|
69
|
+
* @returns {Object|null} Parsed event data
|
|
70
|
+
*/
|
|
71
|
+
export function parseRepoEvent(event) {
|
|
72
|
+
if (event.kind !== 30618) return null;
|
|
73
|
+
|
|
74
|
+
const repoId = event.tags.find(t => t[0] === 'd')?.[1];
|
|
75
|
+
const refTag = event.tags.find(t => t[0].startsWith('refs/'));
|
|
76
|
+
const anchorTag = event.tags.find(t => t[0] === 'c');
|
|
77
|
+
|
|
78
|
+
if (!repoId || !refTag) return null;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
pubkey: event.pubkey,
|
|
82
|
+
repoId,
|
|
83
|
+
ref: refTag[0],
|
|
84
|
+
branch: refTag[0].replace('refs/heads/', ''),
|
|
85
|
+
commit: refTag[1],
|
|
86
|
+
anchor: anchorTag?.[1] || null,
|
|
87
|
+
createdAt: event.created_at,
|
|
88
|
+
raw: event
|
|
89
|
+
};
|
|
90
|
+
}
|